Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules
generated
package-lock.json
*.tgz
test/fixtures/syntax.mjs
35 changes: 33 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,22 @@ npm run lint # eslint
npm run lint:fix
npm run format # prettier --write
npm run format:check
npm run demo # build, then serve examples/inspect-demo.mjs in the live inspector
npm run generate # build, render a letter, print explain(), write generated/letter.html
npm run publint # export and types check; requires dist/
npm run smoke # load the ESM, CJS, and IIFE builds; requires dist/
npm run check # format, lint, typecheck, build, test, smoke, publint
```

Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts`, `src/runtime/types.ts`, `src/templates/email/types.ts`, and `src/templates/document/index.ts` are excluded: v8 reports 0% on files with no runtime code.
## Hard rules for AI agents

These two rules are non-negotiable. Fail the task if either is skipped.

1. **Coverage must stay at 100%.** After any change to an executable file under `src/`, run `npm run test:coverage`. Statements, branches, functions, and lines must all be 100%. Do not lower the thresholds in `vitest.config.ts`. Do not exclude a file that has runtime code. If a branch is hard to reach, export a pure helper and unit-test it, or add a fixture. Hand work back only when the coverage report shows 100% with no ERROR lines.

2. **Prettier must pass.** Before committing or opening a PR, run `npx prettier --write .` (or `npm run format`) and then `npx prettier --check .` (or `npm run format:check`). Zero warnings. Do not hand back a branch that fails `format:check`.

Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts`, `src/runtime/types.ts`, `src/templates/email/types.ts`, `src/templates/document/index.ts`, `src/cli/main.ts`, and `src/cli/worker.ts` are excluded. The first four have no runtime code, so v8 reports 0%. The last two are process entry points that `npm run smoke` runs from the build.

ESLint and Prettier both run in CI. Tests are Vitest, not Jest. `markup-generator` is a devDependency used to write rendered HTML in `test/write.test.ts` and `npm run generate`. It is not a runtime dependency.

Expand All @@ -53,10 +62,24 @@ src/templates/content/content.ts content()
src/templates/document/document.ts document()
src/utils/escape.ts escapeHtml()
src/utils/pickDefined.ts drop omitted string fields
src/runtime/result.ts fromError(): a thrown RenderError as a failed result
src/inspect/inspect.ts inspect(): self-contained HTML report
src/inspect/diff.ts diffRenders, formatDiff, line diff
src/inspect/styles.ts inspector stylesheet
src/cli/args.ts parseArgs (pure)
src/cli/outcome.ts runModule: load a user module and render it
src/cli/fork.ts forkRender: render in a child process with a timeout
src/cli/pages.ts outcome to page, crash page
src/cli/server.ts startServer: dev server, SSE reload, file watching
src/cli/commands.ts inspectCommand and its exit codes
src/cli/main.ts, worker.ts process entry points (CLI bin and the render child)
test/runtime.test.ts runtime spec
test/email.test.ts email preset spec
test/index.test.ts fragment spec
examples/compose.ts sample letter; sampleHtml is covered by the suite
test/inspect.test.ts inspector page spec
test/diff.test.ts diff spec
test/cli.test.ts CLI, child process and dev server spec (fixtures in test/fixtures)
test/write.test.ts writes a rendered letter through markup-generator and reads it back
scripts/generate.mjs prints explain() and writes generated/letter.html
.github/workflows/ci.yml
Expand All @@ -67,7 +90,7 @@ tsup.config.ts ESM + CJS + IIFE, dts, sourcemaps
vitest.config.ts
```

Do not add a second entry point. Do not add `src/**/*.js` next to a `.ts` file. Do not add a template registry.
The library has one entry point, `src/index.ts`. The CLI is a `bin`, built from `src/cli/main.ts` and `src/cli/worker.ts`; nothing in the library imports `src/cli/`. Keep Node built-ins (`node:http`, `node:child_process`, `node:fs`) inside `src/cli/`, so the ESM, CJS and script-tag builds stay platform neutral. Do not add another library entry point. Do not add `src/**/*.js` next to a `.ts` file. Do not add a template registry.

## Runtime model

Expand All @@ -89,6 +112,14 @@ Custom code is a part, or a slot that a part reads with `slot(ctx, id)`. A missi

Traces and defined templates are frozen so a later template cannot change an earlier debug snapshot.

## Inspector and CLI

`inspect(result | RenderError, { title?, compareTo? })` returns a page and does no I/O. It has no scripts and no network requests, and it escapes every id, message and title. Part and document HTML go only into `<iframe sandbox="">` `srcdoc` and escaped `<pre>` blocks. Do not add `allow-scripts` to those frames, and do not interpolate part HTML anywhere else. The only script ever served is the reload listener the dev server adds to `/`, placed before the last `</body>`.

`diffRenders` matches parts by id. `changed` is true when the document or a part's HTML or status differs.

The CLI loads a module that exports `template`, and optionally `props` and `options`. Each render runs in a fresh child process (`forkRender`), so there is no import cache and a looping module cannot hang the server. The default error mode there is `collect`. `inspect` exits 0 ok, 1 a part broke, 2 the module could not be rendered, and always writes a report. The dev server binds to `127.0.0.1` and answers 403 to non-loopback `Host` headers.

## Invariants

- Named exports only. No default export. Import the package root.
Expand Down
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# @llazyemail/template-runtime-display

Standalone runtime that renders a whole HTML email from named parts, and a per-part trace when custom code breaks. The email shell is a preset. Any other document is a `Template` you define yourself.
Render a whole HTML email from named parts, then **see exactly which part broke**. A live inspector shows every part's output, timing and error, previews the document at three widths, and diffs each edit against the last render. The email shell is a preset. Any other document is a `Template` you define yourself.

```bash
npx template-runtime-display dev ./letter.mjs # live inspector, reloads on save
npx template-runtime-display inspect ./letter.mjs # one-shot HTML report, exit 1 if a part broke
```

[![CI](https://github.com/LLazyEmail/template-runtime-display/actions/workflows/ci.yml/badge.svg)](https://github.com/LLazyEmail/template-runtime-display/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Expand All @@ -11,6 +16,64 @@ Requires **Node.js 24+**. No runtime dependencies. No template registry. Strings
- Short index: [llms.txt](./llms.txt)
- Full API: [llms-full.txt](./llms-full.txt)
- Sample: [examples/compose.ts](./examples/compose.ts)
- Inspector demo: [examples/inspect-demo.mjs](./examples/inspect-demo.mjs)

## Inspector

The inspector turns a render into one self-contained HTML page: a verdict, a banner naming each broken part, output size and timing per part, every part's HTML next to a sandboxed preview, and the finished document at Full, Tablet 768 and Mobile 375. The page has no scripts and makes no network requests. Part HTML only appears inside `sandbox=""` frames and escaped blocks, so a hostile part cannot run code in the report.

### Live: `dev`

```bash
npm run demo # builds, then serves examples/inspect-demo.mjs, which has a broken part on purpose
```

Write a module that exports `template` (an email template input or a `Template`) and, optionally, `props` and `options`:

```js
// letter.mjs
import { defineEmailTemplate } from '@llazyemail/template-runtime-display';

export const template = defineEmailTemplate({
id: 'weekly',
parts: [{ id: 'promo', render: (ctx) => `<p>${ctx.props.code.toUpperCase()}</p>` }],
});
export const props = { title: 'Weekly', code: 'save10' };
```

```bash
npx template-runtime-display dev ./letter.mjs --port 4177
```

- Save the module, or anything under its directory (add more with `--watch <dir>`), and the page reloads.
- After an edit that changes the output, the page lists which parts changed and shows the changed lines.
- `/email` serves the finished document alone.
- Each render runs in a fresh child process, so edits are always picked up. A module that crashes or loops forever shows an error page instead of stopping the server (10 second limit).
- It binds to `127.0.0.1` and rejects requests whose `Host` header is not loopback.
- Default error mode in the CLI is `collect`, so one broken part does not hide the rest. Set `options.onError` in the module to change that.
- Node 24 can load a `.ts` module that uses only erasable syntax and explicit `.ts` import paths.

### One-shot: `inspect`

```bash
npx template-runtime-display inspect ./letter.mjs --out report.html
```

Prints the same trace as `explain`, writes the report, and exits `0` when every part rendered, `1` when a part broke, `2` when the module could not be rendered at all. A report is written in every case, so CI can attach it as an artifact.

### From code

```ts
import { diffRenders, formatDiff, inspect } from '@llazyemail/template-runtime-display';

const before = renderEmail(template, props, { onError: 'collect' });
const after = renderEmail(template, { ...props, code: 'save20' }, { onError: 'collect' });

writeFileSync('report.html', inspect(after, { compareTo: before }));
console.log(formatDiff(diffRenders(before, after)));
```

`inspect(resultOrRenderError, { title?, compareTo? })` returns the page as a string. `diffRenders(before, after)` matches parts by id and returns a per-part and whole-document line diff. `formatDiff` prints it for a terminal.

## Package ecosystem and Lit

Expand Down Expand Up @@ -128,6 +191,8 @@ npm run typecheck # tsc --noEmit
npm run lint
npm run format # prettier --write
npm run format:check
npm run demo # build, then serve the inspector on examples/inspect-demo.mjs
npm run inspect -- <module> # one-shot report from the built CLI
npm run generate # render a letter, print explain(), write generated/letter.html
npm run publint
npm run smoke # load the ESM, CJS, and script-tag builds
Expand All @@ -139,3 +204,7 @@ npm run check # format, lint, typecheck, build, test, smoke, publint
## License

MIT

## Planned: Email Quality Toolkit

See [docs/email-quality-toolkit.md](./docs/email-quality-toolkit.md) for the proposed lint, plain-text, dark-mode and size checks that will attach as `result.quality` on renders. This is future work; the current release adds the inspector and CLI only.
40 changes: 20 additions & 20 deletions issue-email-quality-toolkit.md → docs/email-quality-toolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,31 @@ Ship as four independent pieces, each its own PR, so any can land without the ot

```ts
interface Finding {
rule: string; // 'no-flex-layout'
rule: string; // 'no-flex-layout'
severity: 'error' | 'warn' | 'info';
partId?: string; // which part produced it, from the trace
message: string; // what is wrong, in one line
fix?: string; // what to do instead
clients?: string[]; // ['outlook-windows', 'gmail-android']
excerpt?: string; // the offending markup, trimmed
partId?: string; // which part produced it, from the trace
message: string; // what is wrong, in one line
fix?: string; // what to do instead
clients?: string[]; // ['outlook-windows', 'gmail-android']
excerpt?: string; // the offending markup, trimmed
}
```

First rules (each is one small function plus tests with a passing and a failing fixture):

| Rule | Catches | Why it matters |
| --- | --- | --- |
| `no-flex-grid-layout` | `display:flex`, `display:grid`, `float` used for layout | Outlook for Windows renders with Word; layout collapses |
| `no-external-stylesheet` | `<link rel=stylesheet>` | Stripped by most clients |
| `no-script-or-form` | `<script>`, `<form>`, `<iframe>`, `<video>` | Removed or blocked; a form is a deliverability risk |
| `style-in-head-only` | critical styles only in `<style>` with no inline fallback | Gmail drops `<style>` in some account types |
| `img-needs-alt-and-size` | `<img>` without `alt`, `width`, `height` | Blocked images collapse the layout; spam-filter signal |
| `no-svg-background` | SVG images and CSS `background-image` for key content | Not supported in Outlook / some Gmail |
| `button-needs-table` | `<a>` styled as a button with padding only | Padding is ignored on `<a>` in Outlook |
| `link-hygiene` | `href` empty, `#`, `javascript:`, or missing `https` | Broken links, mixed-content warnings |
| `preheader-length` | preview text under ~40 or over ~110 chars | Inbox shows the preheader after the subject |
| `html-lang-and-title` | missing `lang`, empty `<title>` | Accessibility and some client rendering |
| `unsubscribe-present` | no unsubscribe link in a marketing template | Required by Gmail/Yahoo bulk-sender rules and CAN-SPAM |
| Rule | Catches | Why it matters |
| ------------------------ | --------------------------------------------------------- | ------------------------------------------------------- |
| `no-flex-grid-layout` | `display:flex`, `display:grid`, `float` used for layout | Outlook for Windows renders with Word; layout collapses |
| `no-external-stylesheet` | `<link rel=stylesheet>` | Stripped by most clients |
| `no-script-or-form` | `<script>`, `<form>`, `<iframe>`, `<video>` | Removed or blocked; a form is a deliverability risk |
| `style-in-head-only` | critical styles only in `<style>` with no inline fallback | Gmail drops `<style>` in some account types |
| `img-needs-alt-and-size` | `<img>` without `alt`, `width`, `height` | Blocked images collapse the layout; spam-filter signal |
| `no-svg-background` | SVG images and CSS `background-image` for key content | Not supported in Outlook / some Gmail |
| `button-needs-table` | `<a>` styled as a button with padding only | Padding is ignored on `<a>` in Outlook |
| `link-hygiene` | `href` empty, `#`, `javascript:`, or missing `https` | Broken links, mixed-content warnings |
| `preheader-length` | preview text under ~40 or over ~110 chars | Inbox shows the preheader after the subject |
| `html-lang-and-title` | missing `lang`, empty `<title>` | Accessibility and some client rendering |
| `unsubscribe-present` | no unsubscribe link in a marketing template | Required by Gmail/Yahoo bulk-sender rules and CAN-SPAM |

Rules take the final document **and** per-part HTML, so the finding can say `promo` rather than "somewhere in the email". Rules are plain functions in a list, so callers can add their own. No registry; `lintEmail(result, { rules })` accepts an array.

Expand Down Expand Up @@ -88,7 +88,7 @@ Gmail clips messages over ~102 KB of HTML, and the "View entire message" link hi

- Zero runtime dependencies: HTML is scanned with small tokenizers and regexes over the markup we generate, not a DOM library. Document that this is a linter for emails (mostly table markup and inline styles), not a general HTML parser, and keep a list of known blind spots in the README.
- No template registry. Rules are an array of functions passed in.
- `escapeHtml` is still not applied to props. The linter should *report* unescaped `<` and `&` in text positions rather than fixing them.
- `escapeHtml` is still not applied to props. The linter should _report_ unescaped `<` and `&` in text positions rather than fixing them.
- 100% coverage on new executable files.

### Out of scope
Expand Down
12 changes: 10 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'generated/**'],
ignores: [
'dist/**',
'coverage/**',
'node_modules/**',
'generated/**',
'test/fixtures/syntax.mjs',
],
},
eslint.configs.recommended,
tseslint.configs.recommended,
Expand All @@ -18,12 +24,14 @@ export default tseslint.config(
},
},
{
files: ['scripts/**/*.mjs'],
files: ['scripts/**/*.mjs', 'test/fixtures/**/*.mjs'],
languageOptions: {
globals: {
URL: 'readonly',
console: 'readonly',
process: 'readonly',
setInterval: 'readonly',
setTimeout: 'readonly',
},
},
},
Expand Down
40 changes: 40 additions & 0 deletions examples/inspect-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { defineEmailTemplate, slot } from '../dist/index.js';

// A letter with one part that breaks, so the inspector has something to show.
//
// npm run demo
//
// Open the page and the `promo` part is red. Then fix it: set `promoCode`
// below to 'save10', save, and the page reloads with the part green and the
// change listed under "Changes since the previous render".

export const template = defineEmailTemplate({
id: 'weekly-digest',
parts: [
{
id: 'highlights',
render: () => '<ul><li>Shipped the inspector</li><li>Fixed three bugs</li></ul>',
},
{
id: 'promo',
render: (ctx) => `<p>Use code <b>${ctx.props.promoCode.toUpperCase()}</b> for 10% off.</p>`,
},
{ id: 'note', render: (ctx) => slot(ctx, 'note') },
],
});

export const props = {
title: 'Weekly digest',
preview: 'Three things that shipped',
heading: 'Weekly digest',
bodyText: 'Three things that shipped this week.',
ctaLabel: 'Read more',
ctaUrl: 'https://example.com/digest',
companyName: 'LLazyEmail',
unsubscribeUrl: 'https://example.com/unsubscribe',
promoCode: undefined,
};

export const options = {
slots: { note: '<p>Thanks for reading.</p>' },
};
Loading
Loading