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'), + ); + }); + + it('keeps an unclosed tag as text instead of looping', () => { + expect(toText('before