render.ts
636 lines · make-pdf/src/render.ts at 3f79d81
1/**
2 * Markdown → HTML renderer. Pure function, no I/O, no browser.
3 *
4 * Pipeline:
5 * 1. marked parses markdown → HTML
6 * 2. Sanitize: strip <script>, <iframe>, <object>, <embed>, <link>,
7 * <meta>, <base>, <form>, and all on* event handlers + javascript:
8 * URLs. (Codex round 2 #9: untrusted markdown can embed raw HTML.)
9 * 3. Smartypants transform (code/URL-safe).
10 * 4. Assemble full HTML document with print CSS inlined and
11 * semantic structure (cover, TOC placeholder, body).
12 */
14import { marked } from "marked";
15import { smartypants } from "./smartypants";
16import { printCss, type PrintCssOptions } from "./print-css";
17import { applyImageDirectives } from "./image-policy";
19export interface RenderOptions {
20 markdown: string;
22 // Document-level metadata (used for cover, PDF metadata, running header).
23 title?: string;
24 author?: string;
25 date?: string; // ISO or human string
26 subtitle?: string;
28 // Features
29 cover?: boolean;
30 toc?: boolean;
31 watermark?: string;
32 noChapterBreaks?: boolean;
33 confidential?: boolean; // default: true
35 // Page layout
36 pageSize?: "letter" | "a4" | "legal" | "tabloid";
37 margins?: string;
38 // Per-side margins (override `margins`). Must reach the CSS @page rule:
39 // when a landscape promotion flips preferCSSPageSize on, the CSS margins
40 // are the ones Chromium honors — dropping per-side flags there would
41 // silently change the whole document's layout (Codex P2).
42 marginTop?: string;
43 marginRight?: string;
44 marginBottom?: string;
45 marginLeft?: string;
47 // Footer behavior. pageNumbers defaults to true. When footerTemplate is set,
48 // CSS page numbers are suppressed so the custom Chromium footer wins cleanly.
49 pageNumbers?: boolean;
50 footerTemplate?: string;
51}
53export interface RenderResult {
54 html: string; // full HTML document, staged and printed through the browser
55 printCss: string; // for debugging / preview
56 bodyHtml: string; // just the rendered body (tests, snapshots)
57 meta: {
58 title: string;
59 author: string;
60 date: string;
61 wordCount: number;
62 };
63}
65/**
66 * Pure renderer. No side effects.
67 */
68export function render(opts: RenderOptions): RenderResult {
69 // 1. Markdown → HTML (strip a leading YAML frontmatter block first; marked
70 // has no frontmatter awareness and would otherwise render it as a literal
71 // paragraph of body text on its own first page).
72 const rawHtml = marked.parse(stripFrontmatter(opts.markdown), { async: false }) as string;
74 // 1.5. Image directive suffixes: `{width=50%}` → data-gstack-*
75 // attributes. Before the sanitizer (which keeps data- attrs) so the brace
76 // text never reaches smartypants or the final page.
77 const directedHtml = applyImageDirectives(rawHtml);
79 // 2. Sanitize
80 const cleanHtml = sanitizeUntrustedHtml(directedHtml);
82 // 3. Decode common entities so smartypants can match raw " and '.
83 // marked HTML-encodes quotes in text ("hello" → "hello");
84 // without decoding, smartypants' regex never fires. These get re-encoded
85 // implicitly by the browser's HTML parser downstream, and for the ones
86 // that should stay as curly-quote Unicode, that IS the final form.
87 const decoded = decodeTypographicEntities(cleanHtml);
89 // 4. Smartypants (code-safe)
90 const typographicHtml = smartypants(decoded);
92 // 4. Derive metadata (title from first H1 if not provided)
93 const derivedTitle = opts.title ?? extractFirstHeading(typographicHtml) ?? "Document";
94 const derivedAuthor = opts.author ?? "";
95 const derivedDate = opts.date ?? formatToday();
97 // 5. Build CSS
98 // CSS is the single source of truth for page numbers (Chromium native
99 // numbering is always off in orchestrator). If the caller supplied a custom
100 // footerTemplate, suppress CSS page numbers too so their footer wins.
101 const showPageNumbers = opts.pageNumbers !== false && !opts.footerTemplate;
102 const cssOptions: PrintCssOptions = {
103 cover: opts.cover,
104 toc: opts.toc,
105 noChapterBreaks: opts.noChapterBreaks,
106 watermark: opts.watermark,
107 confidential: opts.confidential !== false,
108 runningHeader: derivedTitle,
109 pageSize: opts.pageSize,
110 // Compose per-side margins into the CSS shorthand so @page stays the
111 // single source of truth even under preferCSSPageSize.
112 margins: composeMargins(opts),
113 pageNumbers: showPageNumbers,
114 };
115 const css = printCss(cssOptions);
117 // 6. Assemble document
118 const coverBlock = opts.cover
119 ? buildCoverBlock({
120 title: derivedTitle,
121 subtitle: opts.subtitle,
122 author: derivedAuthor,
123 date: derivedDate,
124 })
125 : "";
127 // TOC anchors must resolve: one heading inventory supplies both the TOC
128 // entry labels and their link targets, so an entry can never point at a
129 // different heading (an empty heading or a reused id used to shift them).
130 const anchored = opts.toc ? anchorHeadings(typographicHtml) : { html: typographicHtml, entries: [] };
131 const anchoredHtml = anchored.html;
133 const tocBlock = opts.toc ? buildTocBlock(anchored.entries) : "";
135 // Wrap body in .chapter sections at H1 boundaries if chapter breaks are on.
136 const chapterHtml = opts.noChapterBreaks
137 ? `<section class="chapter">${anchoredHtml}</section>`
138 : wrapChaptersByH1(anchoredHtml);
140 const watermarkBlock = opts.watermark
141 ? `<div class="watermark">${escapeHtml(opts.watermark)}</div>`
142 : "";
144 const fullHtml = [
145 `<!doctype html>`,
146 `<html lang="en">`,
147 `<head>`,
148 `<meta charset="utf-8">`,
149 `<title>${escapeHtml(derivedTitle)}</title>`,
150 derivedAuthor ? `<meta name="author" content="${escapeHtml(derivedAuthor)}">` : ``,
151 `<style>`,
152 css,
153 `</style>`,
154 `</head>`,
155 `<body>`,
156 watermarkBlock,
157 coverBlock,
158 tocBlock,
159 chapterHtml,
160 `</body>`,
161 `</html>`,
162 ].filter(Boolean).join("\n");
164 return {
165 html: fullHtml,
166 printCss: css,
167 bodyHtml: typographicHtml,
168 meta: {
169 title: derivedTitle,
170 author: derivedAuthor,
171 date: derivedDate,
172 wordCount: countWords(stripTags(typographicHtml)),
173 },
174 };
175}
177/**
178 * Decode the HTML entities that marked emits for text-node quotes/apostrophes.
179 * Only the four that matter for smartypants — leaves & alone because it
180 * can be legitimately doubled (&amp;) and we don't want to double-decode.
181 */
182function decodeTypographicEntities(html: string): string {
183 return html
184 .replace(/"/g, "\"")
185 .replace(/'/g, "'")
186 .replace(/'/g, "'")
187 .replace(/'/g, "'");
188}
190// ─── Sanitizer ────────────────────────────────────────────────────────
192/**
193 * Strip dangerous HTML from markdown-produced output.
194 *
195 * We can't use DOMPurify (server-side; adds a jsdom dep). A conservative
196 * regex sanitizer is fine for this use case because:
197 * 1. marked produces structured HTML (never malformed)
198 * 2. we only need to strip a fixed blacklist of elements + attrs
199 * 3. the output goes through Chromium's parser again, which normalizes
200 *
201 * What's stripped:
202 * - <script>, <iframe>, <object>, <embed>, <link>, <meta>, <base>, <form>
203 * (and their content).
204 * - on* event handler attributes (onclick, ONCLICK, etc.).
205 * - href/src with javascript: scheme.
206 * - <svg> tags with <script> inside them.
207 * - remote href/xlink:href inside <svg> (and on svg-only elements anywhere)
208 * → "#" (offline posture: no fetch at print time).
209 * - remote CSS fetch vectors in <style> blocks and style attributes
210 * (@import, url(), image-set() with remote string candidates).
211 */
212export function sanitizeUntrustedHtml(html: string): string {
213 let s = html;
215 // Elements to remove entirely (including content).
216 const DANGER_TAGS = [
217 "script", "iframe", "object", "embed", "link", "meta", "base", "form",
218 "applet", "frame", "frameset",
219 ];
220 for (const tag of DANGER_TAGS) {
221 const re = new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}>`, "gi");
222 s = s.replace(re, "");
223 // Self-closing / unclosed variants
224 const selfRe = new RegExp(`<${tag}\\b[^>]*/?>`, "gi");
225 s = s.replace(selfRe, "");
226 }
228 // SVG <script>
229 s = s.replace(/<svg([^>]*)>([\s\S]*?)<\/svg>/gi, (_, attrs, body) => {
230 return `<svg${attrs}>${body.replace(/<script\b[\s\S]*?<\/script>/gi, "")}</svg>`;
231 });
233 // Event handler attributes (on* in any case).
234 s = s.replace(/\s+on[a-zA-Z]+\s*=\s*"[^"]*"/gi, "");
235 s = s.replace(/\s+on[a-zA-Z]+\s*=\s*'[^']*'/gi, "");
236 s = s.replace(/\s+on[a-zA-Z]+\s*=\s*[^\s>]+/gi, "");
238 // javascript: URLs in href/src/action/formaction
239 s = s.replace(
240 /(\s(?:href|src|action|formaction|xlink:href)\s*=\s*)(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
241 '$1"#"',
242 );
244 // srcdoc attribute (iframe escape hatch — already stripped via iframe above,
245 // but defense-in-depth).
246 s = s.replace(/\s+srcdoc\s*=\s*"[^"]*"/gi, "");
247 s = s.replace(/\s+srcdoc\s*=\s*'[^']*'/gi, "");
249 // style="url(javascript:..)" — strip javascript: inside style attrs.
250 s = s.replace(/url\(\s*javascript:[^)]*\)/gi, "url(#)");
252 // ── Offline-posture fetch vectors (no --allow-network must mean no network
253 // at print time; the image inliner covers <img src> only, and must keep
254 // seeing remote <img src> so its blocked-remote placeholder still fires) ──
256 // Untrusted CSS neutralization. Scoped to <style> blocks and style
257 // attributes below so prose/code samples that mention URLs stay untouched.
258 //
259 // Chromium decodes CSS ident/string escapes (\69 → i, \68 → h) before
260 // fetching, so literal patterns alone are bypassable: @\69mport dodges
261 // /@import\b/, url("\68ttps://…") dodges the https?://-shaped remote-url
262 // pattern, and u\72l(…) dodges the url( prefix itself. Untrusted styling
263 // has no legitimate need for escaped url schemes or at-rule names, so any
264 // construct carrying a backslash escape is dropped/defanged (fail closed).
265 // In style ATTRIBUTES the HTML parser also entity-decodes before the CSS
266 // parser runs, so \ / \ / \ spellings of the backslash count
267 // as escapes too. (<style> content is raw text — no entity layer there.)
268 const CSS_ESCAPE_MARKER = /\\|�*92(?![0-9])|�*5c(?![0-9a-f])|\/i;
269 const neutralizeUntrustedCss = (css: string): string => {
270 // (a) At-rules whose keyword carries a backslash escape (@\69mport …):
271 // drop the whole statement through `;`, `{`, or end-of-value.
272 let out = css.replace(
273 /@[-\w\\&#;]*?(?:\\|�*92(?![0-9]);?|�*5c(?![0-9a-f]);?|\)[-\w\\&#;]*[^;{}]*(?:;|\{|$)/gi,
274 "");
275 // (b) Literal @import is always a fetch (relative ones can't resolve
276 // under load-html either) — drop outright.
277 out = out.replace(/@import\b[^;]*(;|$)/gi, "");
278 // (c) Any function-like token whose name or arguments carry a backslash
279 // escape → url(#). Covers escaped schemes (url("\68ttps://…")) and
280 // escaped function names (u\72l(…)) in one fail-closed pass. The
281 // end-of-value alternative closes the unterminated-url() dodge:
282 // Chromium's CSS parser closes an open function token at EOF.
283 out = out.replace(/[-\w\\&#;][-\w \t\\&#;]*\(\s*[^)]*(?:\)|$)/g, (m) =>
284 CSS_ESCAPE_MARKER.test(m) ? "url(#)" : m);
285 // (d) Remote url(...) → url(#).
286 out = out.replace(
287 /url\(\s*(?:"|�?39;|'|["'])?\s*(?:https?:)?\/\/[^)]*(?:\)|$)/gi,
288 "url(#)");
289 // (e) image-set() / -webkit-image-set() accept BARE quoted URL strings —
290 // no url() token, no backslash — so passes (c)/(d) never fire on
291 // `image-set("https://…" 1x)`. Any image-set whose arguments carry a
292 // remote-scheme quoted string → url(#) (fail closed). Local string
293 // candidates stay; url()-form arguments are already covered by (c)/(d).
294 out = out.replace(/(?:-webkit-)?image-set\(\s*[^)]*(?:\)|$)/gi, (m) =>
295 /(?:"|�?39;|'|["'])\s*(?:https?:)?\/\//i.test(m) ? "url(#)" : m);
296 return out;
297 };
299 // Raw-HTML <style> blocks. Element content is RAW TEXT — the HTML parser
300 // never entity-decodes it — so unlike style attributes below, no entity
301 // decode step is needed (or correct) here.
302 s = s.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, css, close) =>
303 open + neutralizeUntrustedCss(css) + close);
305 // Style ATTRIBUTE values are entity-decoded by the HTML parser before the
306 // CSS parser ever runs, so https://… reaches Chromium as https://… and
307 // // as // — dodging every literal pattern above. Decode the value
308 // the way the parser will (numeric dec/hex refs with the spec's optional
309 // semicolon; the syntax-significant named refs; the legacy semicolonless
310 // four), in ONE left-to-right pass so the sanitizer performs exactly the
311 // browser's single decode round — decoding recursively would turn a
312 // double-encoded &#104; into a live scheme the browser never sees.
313 const NAMED_REFS: Record<string, string> = {
314 amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
315 sol: "/", bsol: "\\", colon: ":", semi: ";", num: "#",
316 lpar: "(", rpar: ")", commat: "@", grave: "`",
317 Tab: "\t", NewLine: "\n",
318 };
319 const refCodePoint = (n: number): string =>
320 (!Number.isFinite(n) || n <= 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff))
321 ? "�" : String.fromCodePoint(n);
322 const decodeStyleAttrEntities = (v: string): string => v.replace(
323 /&(?:#[xX]([0-9a-fA-F]+);?|#(\d+);?|([a-zA-Z]+);|(amp|lt|gt|quot)(?![a-zA-Z0-9=;]))/g,
324 (m, hex, dec, named, legacy) => {
325 if (hex !== undefined) return refCodePoint(parseInt(hex, 16));
326 if (dec !== undefined) return refCodePoint(parseInt(dec, 10));
327 if (named !== undefined) return NAMED_REFS[named] ?? m;
328 return NAMED_REFS[legacy];
329 });
331 // Inline style attributes — quoted AND unquoted. HTML spec: an unquoted
332 // attribute value runs until whitespace or `>`, so
333 // <div style=background:url(https://…)> is live markup Chromium honors;
334 // a quoted-only pattern misses it. The value is unquoted, entity-decoded
335 // (see above), neutralized in decoded form, then RE-ENCODED and emitted
336 // double-quoted — never emit decoded text raw (a decoded `"` would break
337 // out of the attribute) and the re-encode also keeps once-decoded text like
338 // h inert instead of granting it a second decode round.
339 s = s.replace(/(\s+style\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>][^\s>]*))/gi,
340 (_m, pre, dq, sq, uq) => {
341 const raw = dq ?? sq ?? uq;
342 const cleaned = neutralizeUntrustedCss(decodeStyleAttrEntities(raw));
343 return `${pre}"${escapeHtml(cleaned)}"`;
344 });
346 // SVG remote-fetch vectors: <image href>, <use href>, <feImage href> (and
347 // their xlink:href spellings) fetch at print time — the svg handling above
348 // only strips <script>, and the javascript:-scheme rewrite doesn't touch a
349 // plain https:// href. Fail closed: inside an <svg> block, ANY href /
350 // xlink:href whose entity-decoded value is remote (https?:// or //) is
351 // rewritten to "#"; local fragment refs (href="#id") and local files stay
352 // intact. The tag-scoped second pass catches svg-only elements smuggled
353 // through an UNCLOSED <svg> (Chromium auto-closes at EOF and still
354 // fetches); outside foreign content those tags are inert or parser-mapped
355 // to <img> (href ignored), so the extra pass can't break plain HTML —
356 // regular <a href> hyperlinks are untouched (links don't fetch at print).
357 const neutralizeRemoteSvgHref = (fragment: string): string =>
358 fragment.replace(
359 /(\s(?:xlink:)?href\s*=\s*)("([^"]*)"|'([^']*)'|[^\s>]+)/gi,
360 (m, pre, val, dq, sq) => {
361 // Decode the value the way the HTML parser will (same single-round
362 // decode as style attributes above), then drop the tab/newline/CR
363 // characters URL parsing ignores, so https and h\nttps count.
364 const raw = dq ?? sq ?? String(val);
365 const decoded = decodeStyleAttrEntities(raw).replace(/[\t\n\r]/g, "");
366 return /^\s*(?:https?:)?\/\//i.test(decoded) ? `${pre}"#"` : m;
367 });
368 s = s.replace(/<svg\b[\s\S]*?<\/svg>/gi, neutralizeRemoteSvgHref);
369 s = s.replace(/<(?:image|use|feimage)\b[^>]*>/gi, neutralizeRemoteSvgHref);
371 // srcset with a remote candidate: Chromium prefers srcset over the inlined
372 // src, so a remote candidate fetches at print time. Strip the attribute;
373 // local/data: srcset values are left alone.
374 const remoteSrcsetCandidate = /(?:^|[,\s])\s*(?:https?:)?\/\//i;
375 s = s.replace(/\s+srcset\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m, val) =>
376 remoteSrcsetCandidate.test(String(val).replace(/^["']|["']$/g, "")) ? "" : m);
378 // Remote src/poster on media elements (<video poster>, <source src>, …).
379 s = s.replace(/<(?:video|audio|source|track)\b[^>]*>/gi, (tag) =>
380 tag.replace(
381 /(\s(?:src|poster)\s*=\s*)(?:"(?:https?:)?\/\/[^"]*"|'(?:https?:)?\/\/[^']*'|(?:https?:)?\/\/[^\s>]+)/gi,
382 '$1"#"',
383 ));
385 return s;
386}
388// ─── Cover / TOC / Chapter helpers ────────────────────────────────────
390function buildCoverBlock(opts: {
391 title: string;
392 subtitle?: string;
393 author?: string;
394 date: string;
395}): string {
396 const title = escapeHtml(opts.title);
397 const subtitle = opts.subtitle ? escapeHtml(opts.subtitle) : "";
398 const author = opts.author ? escapeHtml(opts.author) : "";
399 const date = escapeHtml(opts.date);
400 return [
401 `<section class="cover">`,
402 ` <h1 class="cover-title">${title}</h1>`,
403 subtitle ? ` <p class="cover-subtitle">${subtitle}</p>` : ``,
404 ` <hr class="rule">`,
405 ` <div class="cover-meta">`,
406 author ? ` <div><strong>${author}</strong></div>` : ``,
407 ` <div>${date}</div>`,
408 ` </div>`,
409 `</section>`,
410 ].filter(Boolean).join("\n");
411}
413export interface TocEntry {
414 level: number;
415 text: string;
416 /** The id the heading carries in the output (decoded text, not attribute-escaped). */
417 id: string;
418}
420/**
421 * Emit the TOC. Each entry links to its heading's id; the empty
422 * `.toc-page` span is filled with the printed page number by
423 * toc-pages.ts after the PDF has been laid out (PDF output only).
424 */
425function buildTocBlock(entries: TocEntry[]): string {
426 if (entries.length === 0) return "";
428 const items = entries.map((h) => {
429 const level = h.level >= 2 ? "level-2" : "level-1";
430 const id = escapeHtml(h.id);
431 return [
432 ` <li class="${level}">`,
433 ` <span class="toc-title"><a href="#${id}">${escapeHtml(h.text)}</a></span>`,
434 ` <span class="toc-dots"></span>`,
435 ` <span class="toc-page" data-toc-target="${id}"></span>`,
436 ` </li>`,
437 ].join("\n");
438 }).join("\n");
440 return [
441 `<section class="toc">`,
442 ` <h2>Contents</h2>`,
443 ` <ol>`,
444 items,
445 ` </ol>`,
446 `</section>`,
447 ].join("\n");
448}
450const ID_ATTR = /(\sid\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
452/**
453 * The single heading inventory behind the TOC. Every H1-H3 with visible text
454 * becomes one entry, in document order, and the entry's id is the id that
455 * heading owns in the output:
456 * - a heading whose own id is the FIRST element with that id keeps it;
457 * - a heading with no id, or with an id an earlier element already owns
458 * (fragment links resolve to the first owner), gets a fresh `toc-N` that
459 * no element in the document uses.
460 * Headings without text get no entry and no id, so they cannot shift the
461 * entries after them.
462 */
463export function anchorHeadings(html: string): { html: string; entries: TocEntry[] } {
464 const firstOwner = new Map<string, number>();
465 for (const m of html.matchAll(/<[a-zA-Z][^>]*>/g)) {
466 const id = attrId(m[0]);
467 if (id !== null && !firstOwner.has(id)) firstOwner.set(id, m.index!);
468 }
469 let next = 0;
470 const freshId = (): string => {
471 while (firstOwner.has(`toc-${next}`)) next++;
472 const id = `toc-${next++}`;
473 firstOwner.set(id, -1);
474 return id;
475 };
477 const entries: TocEntry[] = [];
478 const out = html.replace(
479 /<(h[1-3])\b([^>]*)>([\s\S]*?)<\/\1>/gi,
480 (full, tag: string, attrs: string, inner: string, offset: number) => {
481 const text = decodeTextEntities(stripTags(inner).trim());
482 if (!text) return full;
483 const level = parseInt(tag.slice(1), 10);
484 const existing = attrId(`<${tag}${attrs}>`);
485 if (existing !== null && firstOwner.get(existing) === offset) {
486 entries.push({ level, text, id: existing });
487 return full;
488 }
489 const id = freshId();
490 entries.push({ level, text, id });
491 const newAttrs = ID_ATTR.test(attrs)
492 ? attrs.replace(ID_ATTR, (_m, pre: string) => `${pre}"${id}"`)
493 : `${attrs} id="${id}"`;
494 return `<${tag}${newAttrs}>${inner}</${tag}>`;
495 },
496 );
497 return { html: out, entries };
498}
500/** The decoded value of a tag's `id` attribute (not `data-id`), or null when absent or empty. */
501function attrId(tag: string): string | null {
502 const m = tag.match(ID_ATTR);
503 const id = m ? decodeTextEntities(m[2] ?? m[3] ?? m[4] ?? "") : "";
504 return id || null;
505}
507/**
508 * Wrap H1-rooted sections in <section class="chapter">. When chapter breaks
509 * are on (default), CSS `.chapter { break-before: page }` fires between them.
510 */
511function wrapChaptersByH1(html: string): string {
512 // Split on H1 openings. Everything before the first H1 is a preamble.
513 const h1Re = /<h1\b[^>]*>/gi;
514 const matches: number[] = [];
515 let m;
516 while ((m = h1Re.exec(html)) !== null) {
517 matches.push(m.index);
518 }
519 if (matches.length === 0) {
520 return `<section class="chapter">${html}</section>`;
521 }
522 const chunks: string[] = [];
523 const preamble = html.slice(0, matches[0]);
524 // A preamble that renders nothing visible (a leading <style> block, an HTML
525 // comment) must NOT become its own .chapter. That section would take the
526 // `.chapter:first-of-type { break-before: auto }` exception, so the first
527 // *real* chapter inherits `break-before: page` and starts on page 2 — leaving
528 // a blank page 1. Keep the non-rendering markup (so its styling still applies)
529 // but fold it into the first real chapter instead of giving it a page break.
530 let carriedPreamble = "";
531 if (preamble.trim().length > 0) {
532 if (stripNonRendering(preamble).trim().length > 0) {
533 chunks.push(`<section class="chapter">${preamble}</section>`);
534 } else {
535 carriedPreamble = preamble;
536 }
537 }
538 for (let i = 0; i < matches.length; i++) {
539 const start = matches[i];
540 const end = i + 1 < matches.length ? matches[i + 1] : html.length;
541 const body = i === 0 ? carriedPreamble + html.slice(start, end) : html.slice(start, end);
542 chunks.push(`<section class="chapter">${body}</section>`);
543 }
544 return chunks.join("\n");
545}
547/**
548 * Strip leading YAML frontmatter (`---\n...\n---`). Only a block at the very
549 * start of the document is removed, so a `---` thematic break elsewhere is
550 * untouched.
551 */
552function stripFrontmatter(md: string): string {
553 return md.replace(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, "");
554}
556/**
557 * Remove non-rendering markup (style/script blocks, HTML comments) so an
558 * otherwise-empty preamble is recognized as visually empty. Used only to decide
559 * whether a preamble deserves its own page — the original markup is preserved in
560 * the output.
561 */
562function stripNonRendering(html: string): string {
563 return html
564 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
565 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
566 .replace(/<!--[\s\S]*?-->/g, "");
567}
569function extractFirstHeading(html: string): string | null {
570 const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
571 return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
572}
574/**
575 * Decode HTML entities in plain text extracted from rendered HTML. Distinct
576 * from decodeTypographicEntities (which runs on in-pipeline HTML and preserves
577 * & because &amp; can be legitimate there). This runs on text destined
578 * for <title>, cover, and TOC entries where & MUST become & or escapeHtml
579 * produces &amp;.
580 *
581 * Amp-last ordering: input "&#169;" decodes to "©" in the named pass,
582 * then the numeric pass decodes "©" to "©". Decoding & first would
583 * produce "©" and the numeric pass would consume it — different end state
584 * but risks double-decode on inputs like "&lt;".
585 */
586function decodeTextEntities(s: string): string {
587 return s
588 .replace(/</g, "<")
589 .replace(/>/g, ">")
590 .replace(/"/g, '"')
591 .replace(/'/g, "'")
592 .replace(/'/g, "'")
593 .replace(/'/g, "'")
594 .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
595 .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
596 .replace(/&/g, "&");
597}
599/** Compose `margin: top right bottom left` from per-side overrides + base. */
600function composeMargins(opts: {
601 margins?: string; marginTop?: string; marginRight?: string;
602 marginBottom?: string; marginLeft?: string;
603}): string | undefined {
604 const base = opts.margins ?? "1in";
605 if (!opts.marginTop && !opts.marginRight && !opts.marginBottom && !opts.marginLeft) {
606 return opts.margins;
607 }
608 return [
609 opts.marginTop ?? base,
610 opts.marginRight ?? base,
611 opts.marginBottom ?? base,
612 opts.marginLeft ?? base,
613 ].join(" ");
614}
616function stripTags(html: string): string {
617 return html.replace(/<[^>]+>/g, "");
618}
620export function escapeHtml(s: string): string {
621 return s
622 .replace(/&/g, "&")
623 .replace(/</g, "<")
624 .replace(/>/g, ">")
625 .replace(/"/g, """)
626 .replace(/'/g, "'");
627}
629function countWords(text: string): number {
630 return text.split(/\s+/).filter(w => w.length > 0).length;
631}
633function formatToday(): string {
634 const now = new Date();
635 return now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
636}