From 35522322cd18098defc5dee8ee5bfb1fb618f47c Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Fri, 23 Oct 2026 20:53:43 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20email=20quality=20toolkit=20slice?= =?UTF-8?q?=201=20=E2=80=94=20size,=20plain=20text,=20JSON=20trace,=20insp?= =?UTF-8?q?ector=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the first slice of the quality toolkit from the uploaded patch: - measureSize: UTF-8 bytes, Gmail 102 KB clip risk, clipped parts, unsubscribe warning - toText: plain-text alternative from HTML (headings, lists, links, entities, wrap) - traceJson / traceJsonText and /trace.json + inspect --json - Inspector: size section, plain-text section, Light/Dark preview via withScheme, previous/next part navigation, select-all source - Subpath export @llazyemail/template-runtime-display/quality - CLI stability (no crash on closed stdout) Moves the inspector guide into docs/inspector.md, updates the quality toolkit status in docs/email-quality-toolkit.md, and removes the temporary source files. All tests pass with 100% coverage. Prettier clean. --- AGENTS.md | 17 +- README.md | 17 +- docs/email-quality-toolkit.md | 27 +- inspector-guide.md => docs/inspector.md | 0 llms-full.txt | 16 +- llms.txt | 13 + package.json | 10 + scripts/smoke.mjs | 27 +- src/cli/args.ts | 14 +- src/cli/commands.ts | 14 +- src/cli/main.ts | 19 +- src/cli/server.ts | 18 +- src/index.ts | 7 + src/inspect/inspect.ts | 74 +- src/inspect/json.ts | 83 + src/inspect/scheme.ts | 20 + src/inspect/styles.ts | 18 + src/quality/index.ts | 4 + src/quality/size.ts | 97 ++ src/quality/text.ts | 115 ++ test/polish.test.ts | 322 ++++ test/quality.test.ts | 288 ++++ toolkit-slice1-inspector-polish.patch | 1857 ----------------------- tsup.config.ts | 1 + vitest.config.ts | 1 + 25 files changed, 1197 insertions(+), 1882 deletions(-) rename inspector-guide.md => docs/inspector.md (100%) create mode 100644 src/inspect/json.ts create mode 100644 src/inspect/scheme.ts create mode 100644 src/quality/index.ts create mode 100644 src/quality/size.ts create mode 100644 src/quality/text.ts create mode 100644 test/polish.test.ts create mode 100644 test/quality.test.ts delete mode 100644 toolkit-slice1-inspector-polish.patch diff --git a/AGENTS.md b/AGENTS.md index 58a9758..7f98753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 @@ -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 @@ -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 `` or the doctype, never before it. + ## Invariants - Named exports only. No default export. Import the package root. diff --git a/README.md b/README.md index 040a9c9..1d95cfc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/email-quality-toolkit.md b/docs/email-quality-toolkit.md index 829a58d..0ea7849 100644 --- a/docs/email-quality-toolkit.md +++ b/docs/email-quality-toolkit.md @@ -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. @@ -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 @@ -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. diff --git a/inspector-guide.md b/docs/inspector.md similarity index 100% rename from inspector-guide.md rename to docs/inspector.md diff --git a/llms-full.txt b/llms-full.txt index 3c4e2f2..999367e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -195,12 +195,24 @@ Safety: no `

Shown

Hidden
Gone'; + expect(toText(html)).toBe('Shown\n'); + }); + + it('wraps long paragraphs at the width and leaves short lines alone', () => { + const text = toText('

one two three four five six

', { width: 12 }); + expect(text).toBe('one two\nthree four\nfive six\n'); + expect(toText('

short

', { width: 12 })).toBe('short\n'); + }); + + it('indents the continuation lines of a wrapped list item', () => { + expect(toText('', { width: 14 })).toBe( + '- one two\n three four\n five\n', + ); + }); + + it('does not break a word longer than the width', () => { + expect(toText('

https://example.com/a/very/long/path ok

', { width: 10 })).toBe( + 'https://example.com/a/very/long/path\nok\n', + ); + }); + + it('leaves lines unwrapped when the width is 0 and wraps at 78 by default', () => { + const long = Array.from({ length: 40 }, () => 'word').join(' '); + expect(toText(`

${long}

`, { width: 0 })).toBe(`${long}\n`); + const wrapped = toText(`

${long}

`); + expect(wrapped.split('\n').every((line) => line.length <= 78)).toBe(true); + expect(wrapped.split('\n').length).toBeGreaterThan(2); + }); + + it('collapses runs of blank lines and whitespace', () => { + expect(toText('

a \t b

\n\n\n\n

c

')).toBe('a b\n\nc\n'); + }); + + it('turns the email shell into readable text', () => { + const rendered = renderEmail( + { id: 'w', parts: [{ id: 'list', render: () => '' }] }, + { + title: 'T', + preview: 'Preview line', + heading: 'Weekly digest', + bodyText: 'Hello our site.', + ctaLabel: 'Read more', + ctaUrl: 'https://example.com/read', + companyName: 'LLazyEmail', + unsubscribeUrl: 'https://example.com/unsub', + }, + ); + expect(toText(rendered.html)).toBe( + [ + 'Preview line', + '', + 'WEEKLY DIGEST', + '', + 'Hello our site (https://example.com/x).', + '', + 'Read more (https://example.com/read)', + '', + '- Shipped', + '- Fixed', + '', + 'LLazyEmail', + '', + 'Unsubscribe (https://example.com/unsub)', + '', + ].join('\n'), + ); + }); +}); diff --git a/toolkit-slice1-inspector-polish.patch b/toolkit-slice1-inspector-polish.patch deleted file mode 100644 index d18df39..0000000 --- a/toolkit-slice1-inspector-polish.patch +++ /dev/null @@ -1,1857 +0,0 @@ -From 11157bafa1c431e73107d29fee679dcc10af75a0 Mon Sep 17 00:00:00 2001 -From: Claude -Date: Fri, 23 Oct 2026 23:44:31 +0300 -Subject: [PATCH] Add size budget, plain text, trace JSON and inspector polish - -- measureSize: UTF-8 size against Gmail's ~102 KB clip, parts hidden by - the clip, unsubscribe-after-clip warning -- toText: plain-text alternative from the generated HTML -- traceJson, inspect --json, /trace.json: the render as stable data -- inspector: size meter, plain-text section, Light/Dark preview with the - color scheme pinned per frame, select-all source, prev/next part links -- ./quality subpath export -- CLI no longer crashes on a closed stdout -- docs/inspector.md guide; toolkit doc, README, AGENTS.md, llms files updated - -Co-Authored-By: Claude Sonnet 5.5 -Claude-Session: https://claude.ai/code/session_01T1yS8KtBeTUZhn5Ls8JrM7 ---- - AGENTS.md | 17 +- - README.md | 17 +- - docs/email-quality-toolkit.md | 27 ++- - docs/inspector.md | 163 +++++++++++++++++ - llms-full.txt | 16 +- - llms.txt | 13 ++ - package.json | 10 ++ - scripts/smoke.mjs | 27 ++- - src/cli/args.ts | 14 +- - src/cli/commands.ts | 14 +- - src/cli/main.ts | 19 +- - src/cli/server.ts | 18 +- - src/index.ts | 7 + - src/inspect/inspect.ts | 74 +++++++- - src/inspect/json.ts | 83 +++++++++ - src/inspect/scheme.ts | 20 +++ - src/inspect/styles.ts | 18 ++ - src/quality/index.ts | 4 + - src/quality/size.ts | 97 ++++++++++ - src/quality/text.ts | 115 ++++++++++++ - test/polish.test.ts | 322 ++++++++++++++++++++++++++++++++++ - test/quality.test.ts | 288 ++++++++++++++++++++++++++++++ - tsup.config.ts | 1 + - vitest.config.ts | 1 + - 24 files changed, 1360 insertions(+), 25 deletions(-) - create mode 100644 docs/inspector.md - create mode 100644 src/inspect/json.ts - create mode 100644 src/inspect/scheme.ts - create mode 100644 src/quality/index.ts - create mode 100644 src/quality/size.ts - create mode 100644 src/quality/text.ts - create mode 100644 test/polish.test.ts - create mode 100644 test/quality.test.ts - -diff --git a/AGENTS.md b/AGENTS.md -index 58a9758..7f98753 100644 ---- a/AGENTS.md -+++ b/AGENTS.md -@@ -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. - -@@ -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 -@@ -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 -@@ -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 `` or the doctype, never before it. -+ - ## Invariants - - - Named exports only. No default export. Import the package root. -diff --git a/README.md b/README.md -index 040a9c9..1d95cfc 100644 ---- a/README.md -+++ b/README.md -@@ -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 - -@@ -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. -diff --git a/docs/email-quality-toolkit.md b/docs/email-quality-toolkit.md -index 829a58d..0ea7849 100644 ---- a/docs/email-quality-toolkit.md -+++ b/docs/email-quality-toolkit.md -@@ -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. -@@ -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 - -@@ -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. -diff --git a/docs/inspector.md b/docs/inspector.md -new file mode 100644 -index 0000000..f46ca5a ---- /dev/null -+++ b/docs/inspector.md -@@ -0,0 +1,163 @@ -+# Inspector guide -+ -+The inspector shows what a render did: which part ran, what each one produced, which one broke, and how the finished email will look and weigh. This page covers the report, the live dev server, the JSON trace, and the first two email quality checks (size and plain text). -+ -+- [The report](#the-report) -+- [Live dev server](#live-dev-server) -+- [One-shot report for CI](#one-shot-report-for-ci) -+- [JSON trace](#json-trace) -+- [Size check](#size-check) -+- [Plain-text version](#plain-text-version) -+- [Light and Dark preview](#light-and-dark-preview) -+- [From code](#from-code) -+- [What it does not do](#what-it-does-not-do) -+ -+## The report -+ -+`inspect(result)` returns one HTML page. It has no scripts and makes no network requests, so it is safe to attach to a CI run or send to a teammate. -+ -+| Section | What it shows | -+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -+| Header | Template id, `OK` or `FAILED`, part count, size, total time, slowest part | -+| Banner | Each broken part with its error, or the compose error, or the part that threw. Links to the part card. | -+| Output by part | A bar per part sized by its output, with status, characters and time | -+| Changes (with `compareTo`) | Which parts changed since an earlier render, and the changed lines | -+| Document | The finished email in a sandboxed frame at Full, Tablet 768 and Mobile 375, in Light or Dark | -+| Size | Bytes against the Gmail clip limit, a meter, the largest parts, and warnings | -+| Plain-text version | What `toText` makes of the document, ready to select and copy | -+| Parts | One card per part: status, a sandboxed preview, and the source. Broken parts open by default. Previous and next links move between cards. | -+ -+Safety: every id, message and title is escaped. Part and document HTML appear only inside ``, -+ '', -+ '', -+ `
`, -+ '

Light and Dark set the preview\'s color scheme, like a client that supports color-scheme. Your own prefers-color-scheme styles follow your system setting.

', - '', - '', - ].join('\n'); -@@ -146,15 +155,16 @@ function documentSection(result: RenderResult): string { - - function partsSection(parts: readonly PartTrace[]): string { - if (parts.length === 0) return ''; -- return `

Parts

${parts.map(partCard).join('\n')}
`; -+ return `

Parts

${parts.map((part, index) => partCard(part, index, parts.length)).join('\n')}
`; - } - --function partCard(part: PartTrace, index: number): string { -+function partCard(part: PartTrace, index: number, count: number): string { - const open = part.status === 'error' ? ' open' : ''; - return [ - `
`, - `${escapeHtml(part.id)} ${chip(part.status)} ${fmtChars(part.chars)} · ${fmtMs(part.ms)}`, - partBody(part), -+ partNav(index, count), - '
', - ].join('\n'); - } -@@ -194,3 +204,61 @@ function diffLine(line: DiffLine): string { - const sign = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '; - return `${sign} ${escapeHtml(line.text)}`; - } -+ -+function partNav(index: number, count: number): string { -+ const prev = index > 0 ? `↑ previous` : ''; -+ const next = index < count - 1 ? `next ↓` : ''; -+ return prev === '' && next === '' ? '' : ``; -+} -+ -+function fmtBytes(bytes: number): string { -+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; -+} -+ -+const RISK_CHIP: Record = { -+ ok: 's-ok', -+ near: 's-empty', -+ over: 's-error', -+}; -+ -+function sizeSection(result: RenderResult): string { -+ if (result.html === '') return ''; -+ const size = measureSize(result); -+ const fill = Math.min(100, (size.bytes / size.limit) * 100).toFixed(1); -+ const mark = ((size.nearLimit / size.limit) * 100).toFixed(1); -+ const largest = size.perPart -+ .slice(0, 5) -+ .map( -+ (part) => -+ `
  • ${escapeHtml(part.id)} ${fmtBytes(part.bytes)}
  • `, -+ ); -+ const warnings = size.warnings.map((warning) => `
  • ${escapeHtml(warning)}
  • `); -+ -+ return [ -+ '
    ', -+ '

    Size

    ', -+ `

    ${fmtBytes(size.bytes)} of ${fmtBytes(size.limit)} ${size.clipRisk}

    `, -+ ``, -+ '

    Gmail clips a message over this size and hides the rest, including the unsubscribe link. The line marks where the warning starts.

    ', -+ warnings.length > 0 ? `
      ${warnings.join('')}
    ` : '', -+ `
      ${largest.join('')}
    `, -+ '
    ', -+ ] -+ .filter((chunk) => chunk !== '') -+ .join('\n'); -+} -+ -+function textSection(result: RenderResult): string { -+ if (result.html === '') return ''; -+ const text = toText(result.html); -+ const lines = text === '' ? 0 : text.trimEnd().split('\n').length; -+ return [ -+ '
    ', -+ `
    Plain-text version ${lines} ${lines === 1 ? 'line' : 'lines'} · click the text to select it all`, -+ text === '' -+ ? '

    The document has no text.

    ' -+ : `
    ${escapeHtml(text)}
    `, -+ '
    ', -+ '
    ', -+ ].join('\n'); -+} -diff --git a/src/inspect/json.ts b/src/inspect/json.ts -new file mode 100644 -index 0000000..8a5ed34 ---- /dev/null -+++ b/src/inspect/json.ts -@@ -0,0 +1,83 @@ -+import { RenderError } from '@llazyemail/render-template'; -+import { byteLength, measureSize } from '../quality/size'; -+import { fromError } from '../runtime/result'; -+import type { PartFailure, PartStatus, RenderResult } from '../runtime/types'; -+ -+export interface TraceJsonOptions { -+ /** Include each part's HTML and the final document. Default false, so the file stays small. */ -+ html?: boolean; -+} -+ -+/** One part in the JSON trace. */ -+export interface TraceJsonPart { -+ id: string; -+ status: PartStatus; -+ chars: number; -+ bytes: number; -+ ms: number; -+ error?: PartFailure; -+ html?: string; -+} -+ -+/** The shape `traceJson` returns. Stable, so scripts and CI can depend on it. */ -+export interface TraceJson { -+ templateId: string; -+ ok: boolean; -+ /** Set when the render threw, and names the part that threw. */ -+ threw?: { partId: string; message: string }; -+ composeError?: PartFailure; -+ size: { -+ bytes: number; -+ limit: number; -+ clipRisk: 'ok' | 'near' | 'over'; -+ warnings: readonly string[]; -+ }; -+ parts: readonly TraceJsonPart[]; -+ html?: string; -+} -+ -+/** -+ * A render as plain data: status, size and timing per part, errors, and the -+ * Gmail size check. Accepts a result or a thrown `RenderError`. Pass -+ * `{ html: true }` to include the markup. -+ */ -+export function traceJson( -+ input: RenderResult | RenderError, -+ options: TraceJsonOptions = {}, -+): TraceJson { -+ const result = input instanceof RenderError ? fromError(input) : input; -+ const size = measureSize(result); -+ -+ return { -+ templateId: result.templateId, -+ ok: result.ok, -+ ...(input instanceof RenderError -+ ? { threw: { partId: input.partId, message: input.message } } -+ : {}), -+ ...(result.composeError ? { composeError: result.composeError } : {}), -+ size: { -+ bytes: size.bytes, -+ limit: size.limit, -+ clipRisk: size.clipRisk, -+ warnings: size.warnings, -+ }, -+ parts: result.parts.map((part) => ({ -+ id: part.id, -+ status: part.status, -+ chars: part.chars, -+ bytes: byteLength(part.html), -+ ms: part.ms, -+ ...(part.error ? { error: part.error } : {}), -+ ...(options.html ? { html: part.html } : {}), -+ })), -+ ...(options.html ? { html: result.html } : {}), -+ }; -+} -+ -+/** `traceJson` as pretty-printed JSON text with a trailing newline. */ -+export function traceJsonText( -+ input: RenderResult | RenderError, -+ options: TraceJsonOptions = {}, -+): string { -+ return `${JSON.stringify(traceJson(input, options), null, 2)}\n`; -+} -diff --git a/src/inspect/scheme.ts b/src/inspect/scheme.ts -new file mode 100644 -index 0000000..0f34d01 ---- /dev/null -+++ b/src/inspect/scheme.ts -@@ -0,0 +1,20 @@ -+export type Scheme = 'light' | 'dark'; -+ -+/** -+ * Pin the color scheme of a document for the preview. A frame's own scheme -+ * follows the viewer's system setting, so without this the "Light" preview -+ * turns dark on a dark system. The style goes right after ``, or after -+ * the doctype when there is no head (a doctype must stay first, or the page -+ * drops into quirks mode), or at the start of a fragment. -+ * -+ * This sets the default canvas and text colors, like a client that supports -+ * `color-scheme`. It does not run the document's own `prefers-color-scheme` -+ * rules, which follow the system setting. -+ */ -+export function withScheme(html: string, scheme: Scheme): string { -+ const style = ``; -+ const after = /]*>/i.exec(html) ?? /^\s*]*>/i.exec(html); -+ if (!after) return `${style}${html}`; -+ const at = after.index + after[0].length; -+ return `${html.slice(0, at)}${style}${html.slice(at)}`; -+} -diff --git a/src/inspect/styles.ts b/src/inspect/styles.ts -index 323d6c8..91a51fb 100644 ---- a/src/inspect/styles.ts -+++ b/src/inspect/styles.ts -@@ -50,20 +50,38 @@ padding:4px 8px;border-radius:999px;margin-right:4px} - font-size:13px;cursor:pointer;color:var(--muted)} - .viewport input:checked+label{color:var(--accent);border-color:var(--accent);font-weight:600} - .viewport input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px} -+.viewport label.first{margin-left:14px} - .stage{background:var(--bg);border-radius:8px;padding:12px;overflow:auto} - .stage iframe{display:block;margin:0 auto;width:100%;height:560px;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light} -+.stage .doc-dark{display:none} -+#sc-dark:checked~.stage .doc-light{display:none} -+#sc-dark:checked~.stage .doc-dark{display:block} - #vp-tablet:checked~.stage iframe{width:768px;max-width:100%} - #vp-mobile:checked~.stage iframe{width:375px;max-width:100%} - .part{border-top:1px solid var(--line);padding:2px 0} - .part:first-of-type{border-top:0} - .part summary{cursor:pointer;padding:10px 0;display:flex;gap:10px;align-items:center;flex-wrap:wrap} - .frag{display:block;width:100%;height:220px;resize:vertical;border:1px solid var(--line);border-radius:6px;background:#fff;color-scheme:light;margin:8px 0} -+.src{user-select:all;cursor:copy} - .src,.diff{margin:8px 0 12px;padding:12px;background:var(--bg);border-radius:8px;overflow:auto;max-height:360px;font:12.5px/1.5 var(--mono)} - .src code,.diff code{font:inherit} - .diff span{display:block;white-space:pre} - .d-add{background:var(--add)} - .d-del{background:var(--del)} - .changes{list-style:none;margin:0 0 4px;padding:0;display:grid;gap:6px} -+.pn{display:flex;justify-content:space-between;gap:12px;padding:4px 0 10px;font-size:13px} -+.pn a{color:var(--accent);text-decoration:none} -+.pn a:first-child:last-child{margin-left:auto} -+.part:target{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px} -+.plain summary{cursor:pointer;display:flex;gap:10px;align-items:center;flex-wrap:wrap} -+.sizeline{margin:0 0 10px} -+.meter{position:relative;height:14px;background:var(--bg);border-radius:999px;overflow:hidden} -+.fill{display:block;height:100%;width:var(--w,0%);border-radius:999px;background:var(--ok)} -+.fill.r-near{background:var(--warn)} -+.fill.r-over{background:var(--bad)} -+.mark{position:absolute;top:0;bottom:0;left:var(--at,80%);width:2px;background:var(--ink);opacity:.45} -+.warns{margin:8px 0;padding:10px 14px 10px 30px;background:var(--warnbg);color:var(--warn);border-radius:8px} -+.sizes{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:4px} - .note{margin:8px 0 12px;color:var(--muted)} - .foot{margin:8px 0 0;text-align:center;color:var(--muted);font-size:12px} - @media (max-width:620px){.row{grid-template-columns:1fr;gap:4px}.stat{text-align:left}} -diff --git a/src/quality/index.ts b/src/quality/index.ts -new file mode 100644 -index 0000000..aff6791 ---- /dev/null -+++ b/src/quality/index.ts -@@ -0,0 +1,4 @@ -+export { byteLength, measureSize, CLIP_BYTES, NEAR_BYTES } from './size'; -+export type { ClipRisk, PartSize, SizeOptions, SizeReport } from './size'; -+export { toText } from './text'; -+export type { TextOptions } from './text'; -diff --git a/src/quality/size.ts b/src/quality/size.ts -new file mode 100644 -index 0000000..b2c8d38 ---- /dev/null -+++ b/src/quality/size.ts -@@ -0,0 +1,97 @@ -+import type { RenderResult } from '../runtime/types'; -+ -+/** Gmail clips a message whose HTML is larger than this and hides the rest behind "View entire message". */ -+export const CLIP_BYTES = 102 * 1024; -+/** Where the report starts to warn, so there is room to grow before the clip. */ -+export const NEAR_BYTES = 80 * 1024; -+ -+export type ClipRisk = 'ok' | 'near' | 'over'; -+ -+export interface PartSize { -+ id: string; -+ bytes: number; -+} -+ -+export interface SizeOptions { -+ /** Bytes at which a client clips. Default `CLIP_BYTES`. */ -+ limit?: number; -+ /** Bytes at which the report warns. Default `NEAR_BYTES`, never above `limit`. */ -+ nearLimit?: number; -+} -+ -+export interface SizeReport { -+ /** UTF-8 size of the final document. */ -+ bytes: number; -+ limit: number; -+ nearLimit: number; -+ clipRisk: ClipRisk; -+ /** Parts that produced output, largest first. */ -+ perPart: readonly PartSize[]; -+ /** Parts that start after the clip point, so a client hides them. */ -+ clippedParts: readonly string[]; -+ /** True when the unsubscribe link sits after the clip point. */ -+ unsubscribeClipped: boolean; -+ warnings: readonly string[]; -+} -+ -+/** UTF-8 length of a string. Counts code points, so no `TextEncoder` or `Buffer` is needed. */ -+export function byteLength(text: string): number { -+ let bytes = 0; -+ for (const char of text) { -+ const code = char.codePointAt(0) as number; -+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; -+ } -+ return bytes; -+} -+ -+function kb(bytes: number): string { -+ return `${(bytes / 1024).toFixed(1)} KB`; -+} -+ -+/** -+ * Measure a render against the size at which Gmail clips. Size is the UTF-8 -+ * byte length, not `.length`, because that is what the client counts. When the -+ * document is over the limit, the report also names the parts that start after -+ * the clip point, since those are the ones readers never see. -+ */ -+export function measureSize(result: RenderResult, options: SizeOptions = {}): SizeReport { -+ const limit = options.limit ?? CLIP_BYTES; -+ const nearLimit = Math.min(options.nearLimit ?? NEAR_BYTES, limit); -+ const bytes = byteLength(result.html); -+ const clipRisk: ClipRisk = bytes > limit ? 'over' : bytes > nearLimit ? 'near' : 'ok'; -+ -+ const perPart = result.parts -+ .filter((part) => part.html !== '') -+ .map((part) => ({ id: part.id, bytes: byteLength(part.html) })) -+ .sort((a, b) => b.bytes - a.bytes); -+ -+ const clippedParts: string[] = []; -+ const warnings: string[] = []; -+ -+ if (clipRisk === 'over') { -+ for (const part of result.parts) { -+ const at = part.html === '' ? -1 : result.html.indexOf(part.html); -+ if (at !== -1 && byteLength(result.html.slice(0, at)) >= limit) clippedParts.push(part.id); -+ } -+ warnings.push( -+ `The document is ${kb(bytes)}, over the ${kb(limit)} where Gmail clips it. Readers see "View entire message" instead of the end.`, -+ ); -+ if (clippedParts.length > 0) { -+ warnings.push(`Hidden by the clip: ${clippedParts.join(', ')}.`); -+ } -+ } else if (clipRisk === 'near') { -+ warnings.push( -+ `The document is ${kb(bytes)}, close to the ${kb(limit)} where Gmail clips it. ${kb(limit - bytes)} left.`, -+ ); -+ } -+ -+ const link = result.html.search(/unsubscribe/i); -+ const unsubscribeClipped = link !== -1 && byteLength(result.html.slice(0, link)) >= limit; -+ if (unsubscribeClipped) { -+ warnings.push( -+ 'The unsubscribe link is after the clip point. Gmail hides it, which hurts deliverability and breaks bulk-sender rules.', -+ ); -+ } -+ -+ return { bytes, limit, nearLimit, clipRisk, perPart, clippedParts, unsubscribeClipped, warnings }; -+} -diff --git a/src/quality/text.ts b/src/quality/text.ts -new file mode 100644 -index 0000000..efa62c3 ---- /dev/null -+++ b/src/quality/text.ts -@@ -0,0 +1,115 @@ -+export interface TextOptions { -+ /** Wrap lines at this many columns. Default 78. Use 0 to leave lines unwrapped. */ -+ width?: number; -+} -+ -+const ENTITIES: Readonly> = { -+ amp: '&', -+ lt: '<', -+ gt: '>', -+ quot: '"', -+ apos: "'", -+ nbsp: ' ', -+ copy: '©', -+ reg: '®', -+ hellip: '…', -+ mdash: '—', -+ ndash: '–', -+ bull: '•', -+ lsquo: '‘', -+ rsquo: '’', -+ ldquo: '“', -+ rdquo: '”', -+}; -+ -+function decodeEntities(text: string): string { -+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => { -+ if (body.startsWith('#')) { -+ const code = /^#x/i.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); -+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; -+ } -+ return ENTITIES[body.toLowerCase()] ?? match; -+ }); -+} -+ -+function stripTags(html: string): string { -+ return html.replace(/<[^>]*>/g, ''); -+} -+ -+function attribute(tag: string, name: string): string | undefined { -+ const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag); -+ return found ? (found[1] ?? found[2] ?? found[3]) : undefined; -+} -+ -+function wrapLine(line: string, width: number): string[] { -+ if (width <= 0 || line.length <= width) return [line]; -+ const indent = line.startsWith('- ') ? ' ' : ''; -+ const lines: string[] = []; -+ let current = ''; -+ for (const word of line.split(' ')) { -+ if (current !== '' && current.length + 1 + word.length > width) { -+ lines.push(current); -+ current = indent + word; -+ } else { -+ current = current === '' ? word : `${current} ${word}`; -+ } -+ } -+ lines.push(current); -+ return lines; -+} -+ -+/** -+ * Turn an email's HTML into the plain-text alternative that goes in the -+ * `text/plain` part. No DOM and no dependency: it reads the markup this -+ * package generates (headings, paragraphs, lists, links, tables), not -+ * arbitrary HTML. -+ * -+ * Headings become uppercase lines. Paragraphs are separated by a blank line. -+ * Lists become `- item`. A link becomes `label (url)`, or just the URL when the -+ * label is the URL. Images become `[alt]`. The hidden preheader is kept as the -+ * first line. ``, styles, scripts, comments and other `display:none` -+ * blocks are dropped. -+ */ -+export function toText(html: string, options: TextOptions = {}): string { -+ const width = options.width ?? 78; -+ -+ const preheader = -+ /]*class\s*=\s*["'][^"']*\bpreheader\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i.exec(html); -+ const first = preheader?.[1] === undefined ? '' : decodeEntities(stripTags(preheader[1])); -+ -+ let body = html -+ .replace(//g, '') -+ .replace(/<(head|style|script)\b[\s\S]*?<\/\1>/gi, '') -+ .replace(/<(div|span|p|td|table|tr)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi, ''); -+ -+ body = body -+ .replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attrs: string, inner: string) => { -+ const label = decodeEntities(stripTags(inner)).trim(); -+ const href = (attribute(` ${attrs}`, 'href') ?? '').trim(); -+ if (href === '' || href.startsWith('#') || /^javascript:/i.test(href)) return label; -+ return label === '' || label === href ? href : `${label} (${href})`; -+ }) -+ .replace(/]*>/gi, (tag) => { -+ const alt = attribute(tag, 'alt')?.trim(); -+ return alt ? `[${alt}]` : ''; -+ }) -+ .replace(/]*>([\s\S]*?)<\/h[1-6]>/gi, (_match, inner: string) => { -+ return `\n\n${stripTags(inner).replace(/\s+/g, ' ').trim().toUpperCase()}\n\n`; -+ }) -+ .replace(/]*>/gi, '\n- ') -+ .replace(//gi, '\n') -+ .replace(/<\/(p|div|tr|table|main|footer|header|section|article|ul|ol|blockquote)>/gi, '\n\n'); -+ -+ const lines = decodeEntities(stripTags(body)) -+ .split('\n') -+ .map((line) => line.replace(/[ \t ]+/g, ' ').trim()); -+ -+ const lead = first.replace(/\s+/g, ' ').trim(); -+ const text = [...(lead === '' ? [] : [lead, '']), ...lines] -+ .flatMap((line) => wrapLine(line, width)) -+ .join('\n') -+ .replace(/\n{3,}/g, '\n\n') -+ .trim(); -+ -+ return text === '' ? '' : `${text}\n`; -+} -diff --git a/test/polish.test.ts b/test/polish.test.ts -new file mode 100644 -index 0000000..15c7c71 ---- /dev/null -+++ b/test/polish.test.ts -@@ -0,0 +1,322 @@ -+import { mkdtemp } from 'node:fs/promises'; -+import { tmpdir } from 'node:os'; -+import { join } from 'node:path'; -+import { afterEach, describe, expect, it } from 'vitest'; -+import { RenderError, inspect, traceJson, traceJsonText } from '../src/index'; -+import type { PartStatus, PartTrace, RenderResult } from '../src/index'; -+import { parseArgs } from '../src/cli/args'; -+import { crashJsonText, inspectCommand } from '../src/cli/commands'; -+import { crash } from '../src/cli/outcome'; -+import type { Outcome } from '../src/cli/outcome'; -+import { startServer } from '../src/cli/server'; -+import { withScheme } from '../src/inspect/scheme'; -+import type { DevServer } from '../src/cli/server'; -+ -+function part(id: string, html: string, status: PartStatus = 'ok', extra: Partial = {}) { -+ return { id, status, html, chars: html.length, ms: 1, ...extra } satisfies PartTrace; -+} -+ -+function result( -+ parts: PartTrace[], -+ html?: string, -+ extra: Partial = {}, -+): RenderResult { -+ return { -+ templateId: 'demo', -+ ok: parts.every((p) => p.status !== 'error'), -+ html: html ?? parts.map((p) => p.html).join('\n'), -+ parts, -+ ...extra, -+ }; -+} -+ -+describe('inspect: size section', () => { -+ it('shows bytes, the limit, a meter, the largest parts and no warning for a small document', () => { -+ const html = inspect(result([part('small', 'x'.repeat(10)), part('big', 'x'.repeat(2048))])); -+ expect(html).toContain('

    Size

    '); -+ expect(html).toContain('2.0 KB of 102.0 KB'); -+ expect(html).toContain('class="chip s-ok">ok'); -+ expect(html).toContain('class="fill r-ok" style="--w:2.0%"'); -+ const half = inspect(result([part('p', 'x'.repeat(51_200))])); -+ expect(half).toContain('class="fill r-ok" style="--w:49.0%"'); -+ expect(html).toContain('class="mark" style="--at:78.4%"'); -+ expect(html).toContain('big 2.0 KB'); -+ expect(html).toContain('small 10 B'); -+ expect(html).not.toContain('class="warns"'); -+ }); -+ -+ it('warns near the limit and when over, and fills the meter', () => { -+ const near = inspect(result([part('p', 'x'.repeat(90_000))])); -+ expect(near).toContain('class="chip s-empty">near'); -+ expect(near).toContain('class="fill r-near"'); -+ expect(near).toContain('class="warns"'); -+ expect(near).toContain('close to the'); -+ -+ const over = inspect(result([part('top', 'x'.repeat(110_000)), part('footer', 'Unsubscribe')])); -+ expect(over).toContain('class="chip s-error">over'); -+ expect(over).toContain('class="fill r-over" style="--w:100.0%"'); -+ expect(over).toContain('Hidden by the clip: footer.'); -+ expect(over).toContain('unsubscribe link is after the clip point'); -+ }); -+ -+ it('lists at most five parts', () => { -+ const parts = Array.from({ length: 7 }, (_, i) => part(`p${i}`, 'x'.repeat(i + 1))); -+ const html = inspect(result(parts)); -+ const sizes = html.match(/
      [\s\S]*?<\/ul>/)?.[0] ?? ''; -+ expect(sizes.match(/
    • /g)).toHaveLength(5); -+ }); -+ -+ it('is left out when there is no document', () => { -+ const html = inspect(result([], '')); -+ expect(html).not.toContain('

      Size

      '); -+ expect(html).not.toContain('Plain-text version'); -+ }); -+}); -+ -+describe('inspect: plain text section', () => { -+ it('shows the plain-text version, escaped, with a line count', () => { -+ const html = inspect(result([part('a', '

      Hi

      1 < 2

      ')])); -+ expect(html).toContain('Plain-text version'); -+ expect(html).toContain('3 lines · click the text to select it all'); -+ expect(html).toContain('
      HI\n\n1 < 2\n
      '); -+ }); -+ -+ it('uses the singular for one line', () => { -+ expect(inspect(result([part('a', '

      Only

      ')]))).toContain('1 line · click'); -+ }); -+ -+ it('says so when the document has no text', () => { -+ const html = inspect(result([part('a', '
      ')])); -+ expect(html).toContain('0 lines · click'); -+ expect(html).toContain('The document has no text.'); -+ }); -+}); -+ -+describe('inspect: preview and navigation', () => { -+ it('offers Light and Dark next to the width choices, each with its own pinned frame', () => { -+ const html = inspect(result([part('a', '

      A

      ')])); -+ expect(html).toContain('id="sc-light" checked'); -+ expect(html).toContain('for="sc-dark">Dark'); -+ expect(html).toContain('name="sc"'); -+ expect(html).toContain('title="Rendered document" class="doc-light"'); -+ expect(html).toContain('title="Rendered document, dark" class="doc-dark"'); -+ expect(html).toContain('<head><style>:root{color-scheme:light}</style>'); -+ expect(html).toContain('<head><style>:root{color-scheme:dark}</style>'); -+ expect(html).toContain('prefers-color-scheme styles follow your system setting'); -+ }); -+ -+ it('links each part card to its neighbours, and omits links that go nowhere', () => { -+ const three = inspect(result([part('a', 'A'), part('b', 'B'), part('c', 'C')])); -+ const cards = three.split('
      next'); -+ expect(cards[0]).not.toContain('previous'); -+ expect(cards[1]).toContain('↑ previousnext'); -+ expect(cards[2]).toContain('↑ previous'); -+ expect(cards[2]).not.toContain('next ↓'); -+ expect(inspect(result([part('only', 'x')]))).not.toContain('class="pn"'); -+ }); -+}); -+ -+describe('traceJson', () => { -+ it('describes parts by status, size, timing and error, without markup by default', () => { -+ const json = traceJson( -+ result([ -+ part('ok', '

      €

      '), -+ part('bad', '', 'error', { error: { name: 'TypeError', message: 'nope' } }), -+ ]), -+ ); -+ expect(json).toMatchObject({ -+ templateId: 'demo', -+ ok: false, -+ size: { clipRisk: 'ok', warnings: [] }, -+ }); -+ expect(json.parts[0]).toEqual({ id: 'ok', status: 'ok', chars: 8, bytes: 10, ms: 1 }); -+ expect(json.parts[1]).toMatchObject({ id: 'bad', status: 'error', error: { message: 'nope' } }); -+ expect(json.html).toBeUndefined(); -+ expect(json.threw).toBeUndefined(); -+ expect(json.composeError).toBeUndefined(); -+ expect('html' in (json.parts[0] ?? {})).toBe(false); -+ }); -+ -+ it('includes the markup on request', () => { -+ const json = traceJson(result([part('a', '

      A

      ')], '

      A

      '), { -+ html: true, -+ }); -+ expect(json.html).toBe('

      A

      '); -+ expect(json.parts[0]?.html).toBe('

      A

      '); -+ }); -+ -+ it('records a compose error', () => { -+ const json = traceJson( -+ result([part('a', 'A')], '', { ok: false, composeError: { name: 'E', message: 'join' } }), -+ ); -+ expect(json.composeError).toEqual({ name: 'E', message: 'join' }); -+ }); -+ -+ it('names the part that threw', () => { -+ const error = new RenderError('part "b" failed', { -+ templateId: 'demo', -+ partId: 'b', -+ trace: [part('a', 'A'), part('b', '', 'error', { error: { name: 'E', message: 'x' } })], -+ }); -+ const json = traceJson(error); -+ expect(json.threw).toEqual({ partId: 'b', message: 'part "b" failed' }); -+ expect(json.ok).toBe(false); -+ }); -+ -+ it('reports the Gmail size check', () => { -+ const json = traceJson(result([part('a', 'x'.repeat(110_000))])); -+ expect(json.size.clipRisk).toBe('over'); -+ expect(json.size.warnings.length).toBeGreaterThan(0); -+ }); -+ -+ it('prints pretty JSON with a trailing newline that parses back', () => { -+ const text = traceJsonText(result([part('a', 'A')])); -+ expect(text.endsWith('}\n')).toBe(true); -+ expect(JSON.parse(text).templateId).toBe('demo'); -+ expect(JSON.parse(traceJsonText(result([part('a', 'A')]), { html: true })).html).toBe('A'); -+ }); -+}); -+ -+describe('inspect --json', () => { -+ it('parses --json in both flag styles and leaves it out otherwise', () => { -+ expect(parseArgs(['inspect', 'a.mjs', '--json', 't.json'])).toEqual({ -+ kind: 'inspect', -+ file: 'a.mjs', -+ out: 'inspect.html', -+ json: 't.json', -+ }); -+ expect(parseArgs(['inspect', 'a.mjs', '--json=t.json'])).toMatchObject({ json: 't.json' }); -+ expect('json' in parseArgs(['inspect', 'a.mjs'])).toBe(false); -+ expect(parseArgs(['inspect', 'a.mjs', '--json'])).toMatchObject({ kind: 'error' }); -+ expect(parseArgs(['dev', 'a.mjs', '--json', 'x'])).toMatchObject({ kind: 'error' }); -+ }); -+ -+ function run(outcome: Outcome, jsonOut?: string) { -+ const written: Record = {}; -+ const code = inspectCommand( -+ 'mod.mjs', -+ 'out.html', -+ { -+ render: async () => outcome, -+ write: async (file, contents) => void (written[file] = contents), -+ log: () => undefined, -+ }, -+ jsonOut, -+ ); -+ return { code, written }; -+ } -+ -+ it('writes the trace next to the report for a result and for a throw', async () => { -+ const ok = run({ kind: 'result', result: result([part('a', 'A')]) }, 't.json'); -+ expect(await ok.code).toBe(0); -+ expect(JSON.parse(ok.written['t.json'] ?? '').ok).toBe(true); -+ -+ const thrown = run( -+ { -+ kind: 'thrown', -+ message: 'failed', -+ templateId: 'demo', -+ partId: 'a', -+ trace: [part('a', '')], -+ }, -+ 't.json', -+ ); -+ expect(await thrown.code).toBe(1); -+ expect(JSON.parse(thrown.written['t.json'] ?? '').threw.partId).toBe('a'); -+ }); -+ -+ it('writes a crash description when there was nothing to render', async () => { -+ const run1 = run(crash('nope', 'stack'), 't.json'); -+ expect(await run1.code).toBe(2); -+ expect(JSON.parse(run1.written['t.json'] ?? '')).toEqual({ -+ ok: false, -+ crash: { message: 'nope', detail: 'stack' }, -+ }); -+ expect(JSON.parse(crashJsonText({ kind: 'crash', message: 'm' }))).toEqual({ -+ ok: false, -+ crash: { message: 'm' }, -+ }); -+ }); -+ -+ it('writes no JSON unless asked', async () => { -+ const plain = run({ kind: 'result', result: result([part('a', 'A')]) }); -+ await plain.code; -+ expect(Object.keys(plain.written)).toEqual(['out.html']); -+ const crashed = run(crash('x')); -+ await crashed.code; -+ expect(Object.keys(crashed.written)).toEqual(['out.html']); -+ }); -+}); -+ -+describe('dev server /trace.json', () => { -+ let server: DevServer | undefined; -+ afterEach(async () => { -+ await server?.close(); -+ server = undefined; -+ }); -+ -+ async function start(outcome: Outcome) { -+ const dir = await mkdtemp(join(tmpdir(), 'trd-trace-')); -+ server = await startServer({ file: join(dir, 'm.mjs'), port: 0, render: async () => outcome }); -+ return server.url; -+ } -+ -+ it('serves the render as JSON', async () => { -+ const url = await start({ kind: 'result', result: result([part('a', 'A')]) }); -+ const res = await fetch(`${url}trace.json`); -+ expect(res.status).toBe(200); -+ expect(res.headers.get('content-type')).toContain('application/json'); -+ expect(((await res.json()) as { templateId: string }).templateId).toBe('demo'); -+ }); -+ -+ it('serves a thrown render too', async () => { -+ const url = await start({ -+ kind: 'thrown', -+ message: 'failed', -+ templateId: 'demo', -+ partId: 'a', -+ trace: [part('a', '')], -+ }); -+ const body = (await (await fetch(`${url}trace.json`)).json()) as { threw: { partId: string } }; -+ expect(body.threw.partId).toBe('a'); -+ }); -+ -+ it('answers 404 when the module could not be rendered', async () => { -+ const url = await start(crash('nope')); -+ const res = await fetch(`${url}trace.json`); -+ expect(res.status).toBe(404); -+ expect(await res.text()).toContain('No trace'); -+ }); -+}); -+ -+describe('withScheme', () => { -+ it('inserts the style right after , keeping attributes on the tag', () => { -+ expect(withScheme('T', 'dark')).toBe( -+ 'T', -+ ); -+ expect(withScheme('', 'light')).toBe( -+ '', -+ ); -+ }); -+ -+ it('keeps the doctype first when there is no head', () => { -+ expect(withScheme('

      x

      ', 'dark')).toBe( -+ '

      x

      ', -+ ); -+ expect(withScheme(' \n

      x

      ', 'light')).toBe( -+ ' \n

      x

      ', -+ ); -+ }); -+ -+ it('starts a fragment with the style', () => { -+ expect(withScheme('

      x

      ', 'dark')).toBe('

      x

      '); -+ expect(withScheme('', 'light')).toBe(''); -+ }); -+ -+ it('does not mistake
      for ', () => { -+ expect(withScheme('
      h
      ', 'dark')).toBe( -+ '
      h
      ', -+ ); -+ }); -+}); -diff --git a/test/quality.test.ts b/test/quality.test.ts -new file mode 100644 -index 0000000..91dfe41 ---- /dev/null -+++ b/test/quality.test.ts -@@ -0,0 +1,288 @@ -+import { describe, expect, it } from 'vitest'; -+import { defineEmailTemplate, renderEmail } from '../src/index'; -+import type { PartTrace, RenderResult } from '../src/index'; -+import { CLIP_BYTES, NEAR_BYTES, byteLength, measureSize } from '../src/quality/size'; -+import { toText } from '../src/quality/text'; -+ -+function part(id: string, html: string): PartTrace { -+ return { id, status: 'ok', html, chars: html.length, ms: 0 }; -+} -+ -+function result(parts: PartTrace[], html?: string): RenderResult { -+ return { templateId: 'demo', ok: true, html: html ?? parts.map((p) => p.html).join(''), parts }; -+} -+ -+describe('byteLength', () => { -+ it('counts UTF-8 bytes for one, two, three and four byte characters', () => { -+ expect(byteLength('')).toBe(0); -+ expect(byteLength('a')).toBe(1); -+ expect(byteLength('é')).toBe(2); -+ expect(byteLength('€')).toBe(3); -+ expect(byteLength('😀')).toBe(4); -+ expect(byteLength('a€😀')).toBe(8); -+ }); -+ -+ it('puts every encoding boundary on the right side', () => { -+ expect(byteLength('\u007f')).toBe(1); -+ expect(byteLength('\u0080')).toBe(2); -+ expect(byteLength('Ж')).toBe(2); -+ expect(byteLength('\u07ff')).toBe(2); -+ expect(byteLength('\u0800')).toBe(3); -+ expect(byteLength('\uffff')).toBe(3); -+ expect(byteLength('\u{10000}')).toBe(4); -+ expect(byteLength('\u{10ffff}')).toBe(4); -+ }); -+ -+ it('is larger than .length for non-ASCII text', () => { -+ expect('€€€'.length).toBe(3); -+ expect(byteLength('€€€')).toBe(9); -+ }); -+}); -+ -+describe('measureSize', () => { -+ it('exposes the Gmail limits', () => { -+ expect(CLIP_BYTES).toBe(104_448); -+ expect(NEAR_BYTES).toBe(81_920); -+ }); -+ -+ it('reports a small document as ok with parts largest first and no warnings', () => { -+ const report = measureSize(result([part('a', 'xx'), part('b', 'xxxxx'), part('c', '')])); -+ expect(report).toMatchObject({ -+ bytes: 7, -+ limit: CLIP_BYTES, -+ nearLimit: NEAR_BYTES, -+ clipRisk: 'ok', -+ clippedParts: [], -+ unsubscribeClipped: false, -+ warnings: [], -+ }); -+ expect(report.perPart).toEqual([ -+ { id: 'b', bytes: 5 }, -+ { id: 'a', bytes: 2 }, -+ ]); -+ }); -+ -+ it('measures bytes, not characters', () => { -+ expect(measureSize(result([part('a', '€€€')])).bytes).toBe(9); -+ }); -+ -+ it('warns when near the limit and says how much room is left', () => { -+ const report = measureSize(result([part('a', 'x'.repeat(95))]), { limit: 100, nearLimit: 90 }); -+ expect(report.clipRisk).toBe('near'); -+ expect(report.warnings).toHaveLength(1); -+ expect(report.warnings[0]).toMatch(/close to the .* where Gmail clips it/); -+ }); -+ -+ it('is ok at exactly the near limit and near at exactly the clip limit', () => { -+ expect( -+ measureSize(result([part('a', 'x'.repeat(90))]), { limit: 100, nearLimit: 90 }).clipRisk, -+ ).toBe('ok'); -+ expect( -+ measureSize(result([part('a', 'x'.repeat(100))]), { limit: 100, nearLimit: 90 }).clipRisk, -+ ).toBe('near'); -+ expect( -+ measureSize(result([part('a', 'x'.repeat(101))]), { limit: 100, nearLimit: 90 }).clipRisk, -+ ).toBe('over'); -+ }); -+ -+ it('never lets the near limit sit above the limit', () => { -+ const report = measureSize(result([part('a', 'x')]), { limit: 10, nearLimit: 50 }); -+ expect(report.nearLimit).toBe(10); -+ }); -+ -+ it('names the parts that start after the clip point when over', () => { -+ const report = measureSize( -+ result([part('top', 'x'.repeat(100)), part('tail', 'tail!'), part('empty', '')]), -+ { limit: 100 }, -+ ); -+ expect(report.clipRisk).toBe('over'); -+ expect(report.clippedParts).toEqual(['tail']); -+ expect(report.warnings[0]).toMatch(/over the .* where Gmail clips it/); -+ expect(report.warnings[1]).toBe('Hidden by the clip: tail.'); -+ }); -+ -+ it('says nothing is hidden when every part starts before the clip point', () => { -+ const report = measureSize(result([part('only', 'x'.repeat(150))]), { limit: 100 }); -+ expect(report.clipRisk).toBe('over'); -+ expect(report.clippedParts).toEqual([]); -+ expect(report.warnings).toHaveLength(1); -+ }); -+ -+ it('ignores a part whose output is not in the final document', () => { -+ const report = measureSize( -+ result([part('ghost', 'zzz'), part('real', 'x'.repeat(150))], 'x'.repeat(150)), -+ { -+ limit: 100, -+ }, -+ ); -+ expect(report.clippedParts).toEqual([]); -+ }); -+ -+ it('warns when the unsubscribe link is after the clip point', () => { -+ const html = `${'x'.repeat(100)}Unsubscribe`; -+ const report = measureSize(result([part('footer', html)]), { limit: 100 }); -+ expect(report.unsubscribeClipped).toBe(true); -+ expect(report.warnings.at(-1)).toMatch(/unsubscribe link is after the clip point/); -+ }); -+ -+ it('does not warn when the unsubscribe link is before the clip point or missing', () => { -+ const early = measureSize(result([part('f', `unsubscribe${'x'.repeat(200)}`)]), { -+ limit: 100, -+ }); -+ expect(early.unsubscribeClipped).toBe(false); -+ expect(measureSize(result([part('f', 'no link here')])).unsubscribeClipped).toBe(false); -+ }); -+ -+ it('measures a real render', () => { -+ const letter = defineEmailTemplate({ -+ id: 'w', -+ parts: [{ id: 'big', render: () => '

      '.padEnd(120_000, 'x') }], -+ }); -+ const report = measureSize(renderEmail(letter, { unsubscribeUrl: 'https://example.com/u' })); -+ expect(report.clipRisk).toBe('over'); -+ expect(report.perPart[0]?.id).toBe('big'); -+ expect(report.clippedParts).toEqual(['footer']); -+ expect(report.unsubscribeClipped).toBe(true); -+ }); -+}); -+ -+describe('toText', () => { -+ it('returns an empty string for empty or markup-only input', () => { -+ expect(toText('')).toBe(''); -+ expect(toText('

      ')).toBe(''); -+ }); -+ -+ it('puts headings in uppercase and separates paragraphs with a blank line', () => { -+ expect(toText('

      Weekly digest

      One

      Two

      ')).toBe( -+ 'WEEKLY DIGEST\n\nOne\n\nTwo\n', -+ ); -+ }); -+ -+ it('turns lists into dashes and keeps items on consecutive lines', () => { -+ expect(toText('
      • One
      • Two bold

      After

      ')).toBe( -+ '- One\n- Two bold\n\nAfter\n', -+ ); -+ }); -+ -+ it('turns
      into a line break', () => { -+ expect(toText('

      a
      b
      c

      ')).toBe('a\nb\nc\n'); -+ }); -+ -+ it('prints links as label (url), or once when the label is the url', () => { -+ expect(toText('Site')).toBe('Site (https://a.com/x)\n'); -+ expect(toText('https://a.com')).toBe('https://a.com\n'); -+ expect(toText("Single")).toBe( -+ 'Single (https://a.com/s)\n', -+ ); -+ expect(toText('Bare')).toBe('Bare (https://a.com/u)\n'); -+ }); -+ -+ it('prints just the url for an empty label and just the label for a useless href', () => { -+ expect(toText('')).toBe('https://a.com/e\n'); -+ expect(toText('Empty')).toBe('Empty\n'); -+ expect(toText('No href')).toBe('No href\n'); -+ expect(toText('Top')).toBe('Top\n'); -+ expect(toText('Js')).toBe('Js\n'); -+ }); -+ -+ it('decodes entities in link labels', () => { -+ expect(toText('Fish & chips')).toBe( -+ 'Fish & chips (https://a.com)\n', -+ ); -+ }); -+ -+ it('prints image alt text and drops images without it', () => { -+ expect(toText('

       Logo

      ')).toBe('[Logo]\n'); -+ expect(toText('

      abc

      ')).toBe('abc\n'); -+ }); -+ -+ it('decodes named, decimal and hex entities and leaves unknown or invalid ones alone', () => { -+ expect( -+ toText('

      & < > " ' © &MDASH; € € €

      '), -+ ).toBe('& < > " \' © — € € €\n'); -+ expect(toText('

       x 

      ')).toBe('x\n'); -+ expect(toText('

      &unknown; � �

      ')).toBe('&unknown; � �\n'); -+ }); -+ -+ it('keeps the preheader as the first line, decoded', () => { -+ const html = -+ 'Three — things

      Body

      '; -+ expect(toText(html)).toBe('Three — things\n\nBody\n'); -+ }); -+ -+ it('works when the preheader is empty or absent', () => { -+ expect(toText('

      Body

      ')).toBe('Body\n'); -+ expect(toText('T

      Body

      ')).toBe('Body\n'); -+ }); -+ -+ it('drops head, style, script, comments and display:none blocks', () => { -+ const html = -+ 'T

      Shown

      Hidden
      Gone'; -+ expect(toText(html)).toBe('Shown\n'); -+ }); -+ -+ it('wraps long paragraphs at the width and leaves short lines alone', () => { -+ const text = toText('

      one two three four five six

      ', { width: 12 }); -+ expect(text).toBe('one two\nthree four\nfive six\n'); -+ expect(toText('

      short

      ', { width: 12 })).toBe('short\n'); -+ }); -+ -+ it('indents the continuation lines of a wrapped list item', () => { -+ expect(toText('
      • one two three four five
      ', { width: 14 })).toBe( -+ '- one two\n three four\n five\n', -+ ); -+ }); -+ -+ it('does not break a word longer than the width', () => { -+ expect(toText('

      https://example.com/a/very/long/path ok

      ', { width: 10 })).toBe( -+ 'https://example.com/a/very/long/path\nok\n', -+ ); -+ }); -+ -+ it('leaves lines unwrapped when the width is 0 and wraps at 78 by default', () => { -+ const long = Array.from({ length: 40 }, () => 'word').join(' '); -+ expect(toText(`

      ${long}

      `, { width: 0 })).toBe(`${long}\n`); -+ const wrapped = toText(`

      ${long}

      `); -+ expect(wrapped.split('\n').every((line) => line.length <= 78)).toBe(true); -+ expect(wrapped.split('\n').length).toBeGreaterThan(2); -+ }); -+ -+ it('collapses runs of blank lines and whitespace', () => { -+ expect(toText('

      a \t b

      \n\n\n\n

      c

      ')).toBe('a b\n\nc\n'); -+ }); -+ -+ it('turns the email shell into readable text', () => { -+ const rendered = renderEmail( -+ { id: 'w', parts: [{ id: 'list', render: () => '
      • Shipped
      • Fixed
      ' }] }, -+ { -+ title: 'T', -+ preview: 'Preview line', -+ heading: 'Weekly digest', -+ bodyText: 'Hello our site.', -+ ctaLabel: 'Read more', -+ ctaUrl: 'https://example.com/read', -+ companyName: 'LLazyEmail', -+ unsubscribeUrl: 'https://example.com/unsub', -+ }, -+ ); -+ expect(toText(rendered.html)).toBe( -+ [ -+ 'Preview line', -+ '', -+ 'WEEKLY DIGEST', -+ '', -+ 'Hello our site (https://example.com/x).', -+ '', -+ 'Read more (https://example.com/read)', -+ '', -+ '- Shipped', -+ '- Fixed', -+ '', -+ 'LLazyEmail', -+ '', -+ 'Unsubscribe (https://example.com/unsub)', -+ '', -+ ].join('\n'), -+ ); -+ }); -+}); -diff --git a/tsup.config.ts b/tsup.config.ts -index 46405ea..1ba7f46 100644 ---- a/tsup.config.ts -+++ b/tsup.config.ts -@@ -33,6 +33,7 @@ export default defineConfig([ - body: 'src/templates/body/body.ts', - main: 'src/templates/main/main.ts', - document: 'src/templates/document/index.ts', -+ quality: 'src/quality/index.ts', - }, - format: ['esm', 'cjs'], - clean: false, -diff --git a/vitest.config.ts b/vitest.config.ts -index a2d470e..957d637 100644 ---- a/vitest.config.ts -+++ b/vitest.config.ts -@@ -14,6 +14,7 @@ export default defineConfig({ - 'src/runtime/types.ts', - 'src/templates/email/types.ts', - 'src/templates/document/index.ts', -+ 'src/quality/index.ts', - // Process entry points. scripts/smoke.mjs runs them from the build. - 'src/cli/main.ts', - 'src/cli/worker.ts', --- -2.43.0 - diff --git a/tsup.config.ts b/tsup.config.ts index 46405ea..1ba7f46 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -33,6 +33,7 @@ export default defineConfig([ body: 'src/templates/body/body.ts', main: 'src/templates/main/main.ts', document: 'src/templates/document/index.ts', + quality: 'src/quality/index.ts', }, format: ['esm', 'cjs'], clean: false, diff --git a/vitest.config.ts b/vitest.config.ts index a2d470e..957d637 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'src/runtime/types.ts', 'src/templates/email/types.ts', 'src/templates/document/index.ts', + 'src/quality/index.ts', // Process entry points. scripts/smoke.mjs runs them from the build. 'src/cli/main.ts', 'src/cli/worker.ts', From 0bbf17a084a312b38198b09b9ff00c0847ad80ab Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Fri, 23 Oct 2026 21:00:31 +0000 Subject: [PATCH 2/5] fix: replace ReDoS-prone stripTags regex with a linear scan The previous /<[^>]*>/g could backtrack on inputs that start with '<' and contain many '<' without a matching '>'. The new implementation walks the string once with indexOf and keeps any unclosed tag as text, so it is linear and cannot loop. --- src/quality/text.ts | 21 ++++++++++++++++++++- test/quality.test.ts | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/quality/text.ts b/src/quality/text.ts index efa62c3..7f0ab19 100644 --- a/src/quality/text.ts +++ b/src/quality/text.ts @@ -32,8 +32,27 @@ function decodeEntities(text: string): string { }); } +/** Remove tags with a single linear scan. The regex form can backtrack on + * inputs that start with `<` and contain many `<` with no matching `>`. */ function stripTags(html: string): string { - return html.replace(/<[^>]*>/g, ''); + let out = ''; + let i = 0; + while (i < html.length) { + const open = html.indexOf('<', i); + if (open === -1) { + out += html.slice(i); + break; + } + out += html.slice(i, open); + const close = html.indexOf('>', open + 1); + if (close === -1) { + // Unclosed tag: keep the rest as text so we never loop. + out += html.slice(open); + break; + } + i = close + 1; + } + return out; } function attribute(tag: string, name: string): string | undefined { diff --git a/test/quality.test.ts b/test/quality.test.ts index 91dfe41..bef75e2 100644 --- a/test/quality.test.ts +++ b/test/quality.test.ts @@ -285,4 +285,9 @@ describe('toText', () => { ].join('\n'), ); }); + + it('keeps an unclosed tag as text instead of looping', () => { + expect(toText('before Date: Sat, 24 Oct 2026 06:18:27 +0300 Subject: [PATCH 3/5] Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/quality/text.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/quality/text.ts b/src/quality/text.ts index 7f0ab19..cfd5fa4 100644 --- a/src/quality/text.ts +++ b/src/quality/text.ts @@ -96,10 +96,15 @@ export function toText(html: string, options: TextOptions = {}): string { /]*class\s*=\s*["'][^"']*\bpreheader\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i.exec(html); const first = preheader?.[1] === undefined ? '' : decodeEntities(stripTags(preheader[1])); - let body = html - .replace(//g, '') - .replace(/<(head|style|script)\b[\s\S]*?<\/\1>/gi, '') - .replace(/<(div|span|p|td|table|tr)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi, ''); + let body = html; + let previous: string; + do { + previous = body; + body = body + .replace(//g, '') + .replace(/<(head|style|script)\b[\s\S]*?<\/\1>/gi, '') + .replace(/<(div|span|p|td|table|tr)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi, ''); + } while (body !== previous); body = body .replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attrs: string, inner: string) => { From 3e99ca8e6c00202598b6de4bf9754d83f4142e2d Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Sat, 24 Oct 2026 03:20:57 +0000 Subject: [PATCH 4/5] fix: replace irregular whitespace in toText line collapse ESLint no-irregular-whitespace flagged the raw U+00A0 inside the character class on the line that collapses runs of spaces. Use the explicit \\u00A0 escape so the source stays ASCII-clean and lint passes. --- src/quality/text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quality/text.ts b/src/quality/text.ts index cfd5fa4..96cc0b8 100644 --- a/src/quality/text.ts +++ b/src/quality/text.ts @@ -126,7 +126,7 @@ export function toText(html: string, options: TextOptions = {}): string { const lines = decodeEntities(stripTags(body)) .split('\n') - .map((line) => line.replace(/[ \t ]+/g, ' ').trim()); + .map((line) => line.replace(/[ \t\u00A0]+/g, ' ').trim()); const lead = first.replace(/\s+/g, ' ').trim(); const text = [...(lead === '' ? [] : [lead, '']), ...lines] From 389c947367644124972a0a48ccc3a9b2a623d37a Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Sat, 24 Oct 2026 03:24:59 +0000 Subject: [PATCH 5/5] fix: clear CodeQL alerts in toText by replacing polynomial regexes CodeQL flagged several polynomial regular expressions on library input and incomplete multi-character sanitization in src/quality/text.ts. - Comments, head/style/script pairs, and display:none blocks are now removed with linear indexOf scans. - Preheader extraction walks spans with indexOf instead of a backtracking regex. - Anchors and headings are rewritten with the same linear style. The previous do-while replace loop is gone. Behavior for well-formed email HTML is unchanged, and unclosed constructs no longer risk quadratic backtracking. Coverage remains 100%. --- src/quality/text.ts | 179 ++++++++++++++++++++++++++++++++++++------- test/quality.test.ts | 22 ++++++ 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/src/quality/text.ts b/src/quality/text.ts index 96cc0b8..0526ead 100644 --- a/src/quality/text.ts +++ b/src/quality/text.ts @@ -32,8 +32,7 @@ function decodeEntities(text: string): string { }); } -/** Remove tags with a single linear scan. The regex form can backtrack on - * inputs that start with `<` and contain many `<` with no matching `>`. */ +/** Remove tags with a single linear scan. */ function stripTags(html: string): string { let out = ''; let i = 0; @@ -46,7 +45,6 @@ function stripTags(html: string): string { out += html.slice(i, open); const close = html.indexOf('>', open + 1); if (close === -1) { - // Unclosed tag: keep the rest as text so we never loop. out += html.slice(open); break; } @@ -55,6 +53,72 @@ function stripTags(html: string): string { return out; } +/** Drop HTML comments with a linear scan. */ +function stripComments(html: string): string { + let out = ''; + let i = 0; + while (i < html.length) { + const open = html.indexOf('', open + 4); + i = close === -1 ? html.length : close + 3; + } + return out; +} + +/** Drop the contents of matching open/close tag pairs (head, style, script, …). */ +function stripPaired(html: string, tag: string): string { + const openRe = new RegExp(`<${tag}\\b`, 'gi'); + const close = ``; + let out = ''; + let i = 0; + let match: RegExpExecArray | null; + while ((match = openRe.exec(html)) !== null) { + out += html.slice(i, match.index); + const closeAt = html.toLowerCase().indexOf(close, match.index + match[0].length); + if (closeAt === -1) { + i = match.index; + break; + } + i = closeAt + close.length; + openRe.lastIndex = i; + } + out += html.slice(i); + return out; +} + +/** Drop elements whose opening tag contains `display:none`. */ +function stripHidden(html: string): string { + const tags = ['div', 'span', 'p', 'td', 'table', 'tr']; + let out = html; + for (const tag of tags) { + const openRe = new RegExp(`<${tag}\\b[^>]*>`, 'gi'); + let result = ''; + let i = 0; + let match: RegExpExecArray | null; + while ((match = openRe.exec(out)) !== null) { + const open = match[0]; + if (!/display\s*:\s*none/i.test(open)) continue; + result += out.slice(i, match.index); + const close = ``; + const closeAt = out.toLowerCase().indexOf(close, match.index + open.length); + if (closeAt === -1) { + i = match.index; + break; + } + i = closeAt + close.length; + openRe.lastIndex = i; + } + result += out.slice(i); + out = result; + } + return out; +} + function attribute(tag: string, name: string): string | undefined { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag); return found ? (found[1] ?? found[2] ?? found[3]) : undefined; @@ -77,6 +141,25 @@ function wrapLine(line: string, width: number): string[] { return lines; } +function extractPreheader(html: string): string { + const lower = html.toLowerCase(); + let search = 0; + while (search < html.length) { + const open = lower.indexOf('', open); + if (close === -1) break; + const tag = html.slice(open, close + 1); + if (/class\s*=\s*["'][^"']*\bpreheader\b/i.test(tag)) { + const end = lower.indexOf('', close); + if (end === -1) return ''; + return decodeEntities(stripTags(html.slice(close + 1, end))); + } + search = close + 1; + } + return ''; +} + /** * Turn an email's HTML into the plain-text alternative that goes in the * `text/plain` part. No DOM and no dependency: it reads the markup this @@ -89,37 +172,77 @@ function wrapLine(line: string, width: number): string[] { * first line. ``, styles, scripts, comments and other `display:none` * blocks are dropped. */ + +function rewriteAnchors(html: string): string { + const re = /]*)>/gi; + let out = ''; + let i = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + out += html.slice(i, match.index); + const close = html.toLowerCase().indexOf('', match.index + match[0].length); + if (close === -1) { + out += html.slice(match.index); + break; + } + const attrs = match[1]; + const inner = html.slice(match.index + match[0].length, close); + const label = decodeEntities(stripTags(inner)).trim(); + const href = (attribute(` ${attrs}`, 'href') ?? '').trim(); + let replacement: string; + if (href === '' || href.startsWith('#') || /^javascript:/i.test(href)) replacement = label; + else replacement = label === '' || label === href ? href : `${label} (${href})`; + out += replacement; + i = close + 4; + re.lastIndex = i; + } + out += html.slice(i); + return out; +} + +function rewriteImages(html: string): string { + return html.replace(/]*>/gi, (tag) => { + const alt = attribute(tag, 'alt')?.trim(); + return alt ? `[${alt}]` : ''; + }); +} + +function rewriteHeadings(html: string): string { + const re = /]*>/gi; + let out = ''; + let i = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + out += html.slice(i, match.index); + const close = html.toLowerCase().indexOf(``, match.index + match[0].length); + if (close === -1) { + out += html.slice(match.index); + break; + } + const inner = html.slice(match.index + match[0].length, close); + out += `\n\n${stripTags(inner).replace(/\s+/g, ' ').trim().toUpperCase()}\n\n`; + i = close + ``.length; + re.lastIndex = i; + } + out += html.slice(i); + return out; +} + export function toText(html: string, options: TextOptions = {}): string { const width = options.width ?? 78; - const preheader = - /]*class\s*=\s*["'][^"']*\bpreheader\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i.exec(html); - const first = preheader?.[1] === undefined ? '' : decodeEntities(stripTags(preheader[1])); + const first = extractPreheader(html); - let body = html; - let previous: string; - do { - previous = body; - body = body - .replace(//g, '') - .replace(/<(head|style|script)\b[\s\S]*?<\/\1>/gi, '') - .replace(/<(div|span|p|td|table|tr)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi, ''); - } while (body !== previous); + let body = stripComments(html); + for (const tag of ['head', 'style', 'script']) { + body = stripPaired(body, tag); + } + body = stripHidden(body); + body = rewriteAnchors(body); + body = rewriteImages(body); + body = rewriteHeadings(body); body = body - .replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attrs: string, inner: string) => { - const label = decodeEntities(stripTags(inner)).trim(); - const href = (attribute(` ${attrs}`, 'href') ?? '').trim(); - if (href === '' || href.startsWith('#') || /^javascript:/i.test(href)) return label; - return label === '' || label === href ? href : `${label} (${href})`; - }) - .replace(/]*>/gi, (tag) => { - const alt = attribute(tag, 'alt')?.trim(); - return alt ? `[${alt}]` : ''; - }) - .replace(/]*>([\s\S]*?)<\/h[1-6]>/gi, (_match, inner: string) => { - return `\n\n${stripTags(inner).replace(/\s+/g, ' ').trim().toUpperCase()}\n\n`; - }) .replace(/]*>/gi, '\n- ') .replace(//gi, '\n') .replace(/<\/(p|div|tr|table|main|footer|header|section|article|ul|ol|blockquote)>/gi, '\n\n'); diff --git a/test/quality.test.ts b/test/quality.test.ts index bef75e2..aab95d5 100644 --- a/test/quality.test.ts +++ b/test/quality.test.ts @@ -290,4 +290,26 @@ describe('toText', () => { expect(toText('before