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
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ These two rules are non-negotiable. Fail the task if either is skipped.

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.
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/quality/index.ts`, `src/cli/main.ts`, and `src/cli/worker.ts` are excluded. The first four and `src/quality/index.ts` are barrels or types with 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 Down Expand Up @@ -66,6 +66,11 @@ src/runtime/result.ts fromError(): a thrown RenderError as a failed
src/inspect/inspect.ts inspect(): self-contained HTML report
src/inspect/diff.ts diffRenders, formatDiff, line diff
src/inspect/styles.ts inspector stylesheet
src/inspect/scheme.ts withScheme: pin color-scheme on a previewed document
src/inspect/json.ts traceJson, traceJsonText
src/quality/size.ts byteLength, measureSize (Gmail clip budget)
src/quality/text.ts toText (plain-text version)
src/quality/index.ts barrel for the ./quality subpath
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
Expand All @@ -79,6 +84,8 @@ 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/quality.test.ts size and toText spec
test/polish.test.ts inspector sections, trace JSON, --json, /trace.json, withScheme
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
Expand Down Expand Up @@ -120,6 +127,14 @@ Traces and defined templates are frozen so a later template cannot change an ear

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.

## Quality checks

`measureSize`, `toText` and `traceJson` are pure, synchronous and dependency free, and they live in `src/quality/` and `src/inspect/`. They are also exported from `./quality`, so keep `src/quality/` free of imports from `src/cli/` and of Node built-ins. `byteLength` counts UTF-8 by code point so it needs neither `TextEncoder` nor `Buffer`.

`toText` reads the markup this package generates, not arbitrary HTML. Do not grow it into a general parser. `measureSize` finds clipped parts by locating each part's output in the final document; a `compose` that rewrites part output can hide a part from that list.

The Light / Dark preview pins `color-scheme` on the root of each previewed document with `withScheme`. A frame's own scheme follows the viewer's system setting, so relying on the iframe alone makes the toggle do nothing on a light system and makes "Light" turn dark on a dark one. Keep both frames, and keep the injected style after `<head>` or the doctype, never before it.

## Invariants

- Named exports only. No default export. Import the package root.
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,20 @@ npx template-runtime-display dev ./letter.mjs --port 4177
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.
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. Add `--json trace.json` to also write the trace as data, and `/trace.json` serves the same from `dev`.

### Size, plain text and dark mode

The report also checks the email against the size where Gmail clips it (about 102 KB, counted in UTF-8 bytes), shows the plain-text version, and previews the document in Light or Dark.

```ts
import { measureSize, toText } from '@llazyemail/template-runtime-display/quality';

measureSize(result).clipRisk; // 'ok' | 'near' | 'over'
toText(result.html); // the text/plain alternative
```

The full guide is [docs/inspector.md](./docs/inspector.md).

### From code

Expand Down Expand Up @@ -207,4 +220,4 @@ 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.
See [docs/email-quality-toolkit.md](./docs/email-quality-toolkit.md). The size check, plain-text version and Light / Dark preview have shipped. The client-compatibility linter, the dark-mode contrast rules and `result.quality` are next.
27 changes: 22 additions & 5 deletions docs/email-quality-toolkit.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
## Email quality toolkit: lint, plain text, dark mode and size checks on every render

### Status

Shipped (see [docs/inspector.md](./inspector.md)):

- [x] `toText(html)`: plain-text version
- [x] `measureSize(result)`: UTF-8 size, Gmail 102 KB clip risk, parts hidden by the clip, unsubscribe-after-clip warning
- [x] Inspector: size section, plain-text section, Light / Dark preview
- [x] `traceJson`, `inspect --json`, and `/trace.json`
- [x] Subpath export `@llazyemail/template-runtime-display/quality`

Still to do: `lintEmail` and its rules, the dark-mode contrast and image rules, `renderEmail(..., { check: true })` and `result.quality`, `inspect --fail-on`, and findings in the inspector.

Decisions made while building the first slice:

- The functions are pure and exported on their own, so they work on any `RenderResult` today. `result.quality` can wrap them later without changing them.
- The Light / Dark toggle pins `color-scheme` on the root of each previewed document instead of relying on the frame, because a frame follows the viewer's system setting. It does not run `prefers-color-scheme` rules. The dark-mode rules planned below must therefore analyse the CSS rather than rely on the preview.

### Why

The runtime answers "which part broke?" (a part threw). It does not answer the question that actually costs email teams money: **"which part will break in the inbox?"** An email can render with `ok: true` and still be clipped by Gmail, unreadable in Outlook, or blank in dark mode. Today every one of those is found by sending a test and squinting.
Expand Down Expand Up @@ -66,7 +83,7 @@ The shell already emits `color-scheme: light dark` meta. Nothing verifies the co

- Rule `dark-mode-contrast`: parse inline `color` / `background(-color)` pairs, flag combinations that fall below WCAG AA (4.5:1) in either scheme, and flag hard-coded `#fff` backgrounds with no `prefers-color-scheme` override.
- Rule `dark-mode-images`: images with transparent backgrounds and dark artwork get a warning prompt, since a PNG logo can vanish on a dark canvas.
- Inspector: add a **Light / Dark** toggle to the document preview (swap `color-scheme` on the sandboxed frame; the meta tag already makes compliant clients follow it). Pure CSS, no script, matching the current toggle.
- Inspector: a **Light / Dark** toggle on the document preview. Done: each choice shows its own frame with `color-scheme` pinned on the root (see Status).

#### 4. Size and clipping budget

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

- [ ] `lintEmail` with the first eight rules above, each with a passing and a failing fixture
- [ ] Every finding carries the `partId` it came from when the markup is in a part
- [ ] `toText` handles headings, lists, links, entities, hidden text, and wrapping, with tests
- [x] `toText` handles headings, lists, links, entities, hidden text, and wrapping, with tests
- [ ] `renderEmail(..., { check: true })` returns `quality`; default output is byte-identical to today
- [ ] Dark-mode contrast rule plus the Light/Dark preview toggle
- [ ] Size budget with UTF-8 bytes and footer-clipping warning
- [ ] Dark-mode contrast rule (the Light/Dark preview toggle is done)
- [x] Size budget with UTF-8 bytes and footer-clipping warning
- [ ] Inspector section for findings; CLI `--fail-on`
- [ ] README, AGENTS.md, llms.txt, llms-full.txt updated; `npm run check` green

### Suggested order

1. Size budget and `toText`: smallest, most certain value.
1. ~~Size budget and `toText`~~: shipped.
2. Linter core and the first rules, with inspector section and `--fail-on`.
3. Dark mode.
4. Remaining rules, driven by what real templates in the org trip over. Run the linter over the existing LLazyEmail templates first and keep only rules that find something real.
Expand Down
File renamed without changes.
16 changes: 14 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,24 @@ Safety: no `<script>`, no `<link>`, no network requests. Every id, message, titl

Compares two `RenderResult`s. Parts match by id, so reordering alone is not a change. `RenderDiff` is `{ templateBefore, templateAfter, changed, parts: PartDiff[], lines: DiffLine[] }`. `PartDiff` is `{ id, change: 'added' | 'removed' | 'changed' | 'unchanged', statusBefore?, statusAfter?, charsDelta, lines }`. `DiffLine` is `{ kind: 'same' | 'add' | 'del', text }`. `changed` is true when the document or any part (HTML or status) differs. Very large diffs fall back to replacing the changed block. `formatDiff` prints changed parts, then changed lines with two lines of context.

## measureSize(result, options?): SizeReport

From `@llazyemail/template-runtime-display` and `@llazyemail/template-runtime-display/quality`. `options`: `{ limit?: number; nearLimit?: number }`, default 102 KB (`CLIP_BYTES`) and 80 KB (`NEAR_BYTES`). `SizeReport` is `{ bytes, limit, nearLimit, clipRisk: 'ok' | 'near' | 'over', perPart: { id, bytes }[], clippedParts: string[], unsubscribeClipped: boolean, warnings: string[] }`. `bytes` is the UTF-8 length of `result.html`. `perPart` lists parts with output, largest first. `clipRisk` is `over` above `limit`, `near` above `nearLimit`. `clippedParts` is filled only when `over`, with the parts whose output starts after the clip point. `unsubscribeClipped` is true when the first `unsubscribe` in the document is after it. `byteLength(text)` is the UTF-8 length of a string.

## toText(html, options?): string

`options`: `{ width?: number }`, default 78, `0` for no wrapping. Headings become uppercase, paragraphs are separated by a blank line, `<li>` becomes `- item`, `<a href="u">label</a>` becomes `label (u)` (or `u` when they match, or `label` when the href is empty, `#` or `javascript:`), `<img alt>` becomes `[alt]`. The `preheader` span is the first line. `<head>`, `<style>`, `<script>`, comments and `display:none` blocks are dropped. Entities are decoded. It reads the markup this package generates, not arbitrary HTML. Empty input gives `''`; otherwise the text ends with one newline.

## traceJson(input, options?): TraceJson and traceJsonText(input, options?): string

`input` is a `RenderResult` or a thrown `RenderError`. `options`: `{ html?: boolean }`. `TraceJson` is `{ templateId, ok, threw?: { partId, message }, composeError?, size: { bytes, limit, clipRisk, warnings }, parts: { id, status, chars, bytes, ms, error?, html? }[], html? }`. `traceJsonText` is the same as pretty JSON with a trailing newline. The shape is stable.

## CLI: template-runtime-display

`bin` in `package.json`. Node-only, not part of the library entry point, and the library keeps no dependency on it.

- `dev <module> [--port 4177] [--host 127.0.0.1] [--watch <dir>]...` serves the inspector at `/`, the bare document at `/email`, and reloads open pages when a file under the module's directory (or a `--watch` directory) changes. Each render runs in a fresh child process with a 10 second limit. Non-loopback `Host` headers get 403 when bound to loopback.
- `inspect <module> [--out inspect.html]` renders once, prints `explain`, writes the report. Exit `0` ok, `1` a part broke or a throw, `2` the module could not be rendered.
- `dev <module> [--port 4177] [--host 127.0.0.1] [--watch <dir>]...` serves the inspector at `/`, the bare document at `/email`, the trace at `/trace.json` (404 when the module could not be rendered), and reloads open pages when a file under the module's directory (or a `--watch` directory) changes. Each render runs in a fresh child process with a 10 second limit. Non-loopback `Host` headers get 403 when bound to loopback.
- `inspect <module> [--out inspect.html] [--json trace.json]` renders once, prints `explain`, writes the report, and with `--json` the trace. Exit `0` ok, `1` a part broke or a throw, `2` the module could not be rendered. A crash writes `{ ok: false, crash: { message, detail? } }` to the JSON file. A closed stdout does not crash it.

The module exports `template` (`EmailTemplate` input or `Template<EmailProps>`), and optionally `props` and `options` (`RenderOptions`). A default export with those keys also works. The CLI uses `onError: 'collect'` unless `options.onError` says otherwise.

Expand Down
13 changes: 13 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ template-runtime-display inspect ./letter.mjs [--out inspect.html] # exit 0 ok
```

The module exports `template`, and optionally `props` and `options`. The CLI defaults `onError` to `collect`.

## Quality checks

```ts
import { measureSize, toText } from '@llazyemail/template-runtime-display/quality';
import { traceJson } from '@llazyemail/template-runtime-display';

measureSize(result); // { bytes, clipRisk: 'ok' | 'near' | 'over', perPart, clippedParts, unsubscribeClipped, warnings }
toText(result.html); // plain-text alternative, wrapped at 78 columns
traceJson(result); // status, bytes and ms per part, errors, size check
```

`bytes` is UTF-8, which is what Gmail counts against its ~102 KB clip. `inspect --json trace.json` writes the trace, and the dev server serves it at `/trace.json`. The report shows size, the plain-text version, and a Light / Dark preview. Guide: docs/inspector.md.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@
"types": "./dist/document.d.cts",
"default": "./dist/document.cjs"
}
},
"./quality": {
"import": {
"types": "./dist/quality.d.ts",
"default": "./dist/quality.js"
},
"require": {
"types": "./dist/quality.d.cts",
"default": "./dist/quality.cjs"
}
}
},
"files": [
Expand Down
27 changes: 26 additions & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ try {
writeFileSync(bad, letter(`() => { throw new Error('boom'); }`));

const runs = [
['clean render exits 0', ['inspect', good, '--out', join(work, 'good.html')], 0, /smoke: ok/],
[
'clean render exits 0',
['inspect', good, '--out', join(work, 'good.html'), '--json', join(work, 'good.json')],
0,
/smoke: ok/,
],
[
'broken part exits 1',
['inspect', bad, '--out', join(work, 'bad.html')],
Expand All @@ -102,10 +107,30 @@ try {
process.exit(1);
}
}
const trace = JSON.parse(read(join(work, 'good.json'), 'utf8'));
if (trace.templateId !== 'smoke' || trace.ok !== true || trace.size?.clipRisk !== 'ok') {
console.error('cli: --json did not write the trace');
process.exit(1);
}
if (!read(join(work, 'bad.html'), 'utf8').includes('1 part broke')) {
console.error('cli: the report did not name the broken part');
process.exit(1);
}
} finally {
rmSync(work, { recursive: true, force: true });
}

// The quality subpath loads from both builds and does its job.
const esmQuality = await import('../dist/quality.js');
const cjsQuality = require('../dist/quality.cjs');
for (const [name, quality] of [
['esm', esmQuality],
['cjs', cjsQuality],
]) {
const text = quality.toText('<h1>Hi</h1><p>One <a href="https://example.com">link</a></p>');
const size = quality.measureSize({ templateId: 't', ok: true, html: '€', parts: [] });
if (text !== 'HI\n\nOne link (https://example.com)\n' || size.bytes !== 3) {
console.error(`${name} quality build returned an unexpected result`);
process.exit(1);
}
}
14 changes: 10 additions & 4 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ export type Command =
| { kind: 'help' }
| { kind: 'version' }
| { kind: 'dev'; file: string; port: number; host: string; watch: string[] }
| { kind: 'inspect'; file: string; out: string }
| { kind: 'inspect'; file: string; out: string; json?: string }
| { kind: 'error'; message: string };

export const USAGE = `template-runtime-display: see which part of a template broke.

Usage
template-runtime-display dev <module> [--port 4177] [--host 127.0.0.1] [--watch <dir>]...
template-runtime-display inspect <module> [--out inspect.html]
template-runtime-display inspect <module> [--out inspect.html] [--json trace.json]

dev Serve a live inspector. Saving the module, or anything it imports from
a watched directory, reloads the page.
Expand All @@ -24,6 +24,7 @@ Options
--host <h> Interface for dev. Default 127.0.0.1 (this machine only).
--watch <dir> Also watch this directory. Repeatable.
--out <file> Where inspect writes the report. Default inspect.html.
--json <file> Also write the trace as JSON: status, size and timing per part.
-h, --help Show this help.
-v, --version Show the version.
`;
Expand All @@ -47,6 +48,7 @@ export function parseArgs(argv: readonly string[]): Command {
let port = 4177;
let host = '127.0.0.1';
let out = 'inspect.html';
let json: string | undefined;
const watch: string[] = [];

for (let i = 0; i < rest.length; i += 1) {
Expand All @@ -61,7 +63,7 @@ export function parseArgs(argv: readonly string[]): Command {

const eq = arg.indexOf('=');
const flag = eq === -1 ? arg : arg.slice(0, eq);
const allowed = name === 'dev' ? ['--port', '--host', '--watch'] : ['--out'];
const allowed = name === 'dev' ? ['--port', '--host', '--watch'] : ['--out', '--json'];
if (!allowed.includes(flag)) return fail(`Unknown option "${flag}" for ${name}.`);

const value = eq === -1 ? rest[(i += 1)] : arg.slice(eq + 1);
Expand All @@ -77,11 +79,15 @@ export function parseArgs(argv: readonly string[]): Command {
host = value;
} else if (flag === '--watch') {
watch.push(value);
} else if (flag === '--json') {
json = value;
} else {
out = value;
}
}

if (file === undefined) return fail(`${name} needs a module path. Example: ${name} ./letter.mjs`);
return name === 'dev' ? { kind: 'dev', file, port, host, watch } : { kind: 'inspect', file, out };
return name === 'dev'
? { kind: 'dev', file, port, host, watch }
: { kind: 'inspect', file, out, ...(json === undefined ? {} : { json }) };
}
Loading
Loading