A
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
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('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\nc
')).toBe('a b\n\nc\n'); + }); + + it('turns the email shell into readable text', () => { + const rendered = renderEmail( + { id: 'w', parts: [{ id: 'list', render: () => 'ok
')).toContain('ok'); + expect(toText('ok
')).toContain('ok'); + expect(toText('ok
')).toContain('ok'); + expect(toText('ok
')).toContain('ok'); + expect(toText('ok
')).not.toContain('secret'); + // unclosed forms should not hang + expect(toText('`; -+ 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: Readonly1 < 2
')])); -+ expect(html).toContain('Plain-text version'); -+ expect(html).toContain(''); -+ 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('€
'), -+ 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
A
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: Recordx
', 'dark')).toBe( -+ 'x
', -+ ); -+ expect(withScheme(' \nx
', 'light')).toBe( -+ ' \nx
', -+ ); -+ }); -+ -+ it('starts a fragment with the style', () => { -+ expect(withScheme('x
', 'dark')).toBe('x
'); -+ expect(withScheme('', 'light')).toBe(''); -+ }); -+ -+ it('does not mistake'.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('One
Two
')).toBe( -+ 'WEEKLY DIGEST\n\nOne\n\nTwo\n', -+ ); -+ }); -+ -+ it('turns lists into dashes and keeps items on consecutive lines', () => { -+ expect(toText('After
')).toBe( -+ '- One\n- Two bold\n\nAfter\n', -+ ); -+ }); -+ -+ it('turnsa
b
c
ab
c
& < > " ' © &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 — thingsBody
'; -+ 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('Body
')).toBe('Body\n'); -+ }); -+ -+ it('drops head, style, script, comments and display:none blocks', () => { -+ const html = -+ 'Shown
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('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\nc
')).toBe('a b\n\nc\n'); -+ }); -+ -+ it('turns the email shell into readable text', () => { -+ const rendered = renderEmail( -+ { id: 'w', parts: [{ id: 'list', render: () => '