@shoalrun/cli 0.1.0-alpha.19 → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +15 -5
  3. package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +5 -3
  4. package/agent-docs/SNAPSHOT.json +5 -5
  5. package/agent-docs/about.md +3 -3
  6. package/agent-docs/author/agent-overview.md +2 -2
  7. package/agent-docs/author/components/code-blocks.md +46 -1
  8. package/agent-docs/author/frontmatter-reference.md +1 -0
  9. package/agent-docs/author/frontmatter-reference.sections/categorize-connect-and-present-the-page.md +1 -0
  10. package/agent-docs/author/workspace.md +2 -2
  11. package/agent-docs/author/writing-style.md +9 -3
  12. package/agent-docs/author/writing-style.sections/adapt-this-example.md +1 -1
  13. package/agent-docs/author/writing-style.sections/choose-components-by-reader-need.md +2 -2
  14. package/agent-docs/author/writing-style.sections/evidence-and-uncertainty.md +1 -1
  15. package/agent-docs/author/writing-style.sections/example-audience-and-scope.md +1 -1
  16. package/agent-docs/author/writing-style.sections/feature-availability.md +1 -1
  17. package/agent-docs/author/writing-style.sections/ingredients-recipes-and-cookbooks.md +1 -1
  18. package/agent-docs/author/writing-style.sections/page-purpose-and-structure.md +2 -2
  19. package/agent-docs/author/writing-style.sections/procedures-and-examples.md +1 -1
  20. package/agent-docs/author/writing-style.sections/related-reading.md +1 -1
  21. package/agent-docs/author/writing-style.sections/release-notes.md +30 -0
  22. package/agent-docs/author/writing-style.sections/review-a-draft.md +1 -1
  23. package/agent-docs/author/writing-style.sections/reviewed-writing-examples.md +1 -1
  24. package/agent-docs/author/writing-style.sections/voice-and-explanations.md +1 -1
  25. package/agent-docs/changelog/2026-10-16-navigation-layouts.md +1 -1
  26. package/agent-docs/changelog/pdf-custom-languages.md +48 -0
  27. package/agent-docs/changelog.md +9 -1
  28. package/agent-docs/config/examples.md +3 -3
  29. package/agent-docs/config/examples.sections/feature-showcase.md +3 -3
  30. package/agent-docs/config/examples.sections/minimal-site.md +2 -2
  31. package/agent-docs/config/examples.sections/multilingual-help-center.md +2 -2
  32. package/agent-docs/config/examples.sections/parcel-api-documentation.md +2 -2
  33. package/agent-docs/config/examples.sections/related-reading.md +2 -2
  34. package/agent-docs/config/examples.sections/shared-company-preset.md +2 -2
  35. package/agent-docs/config/outputs.md +33 -12
  36. package/agent-docs/config/outputs.sections/exports.md +3 -3
  37. package/agent-docs/config/outputs.sections/metadata.md +3 -3
  38. package/agent-docs/config/outputs.sections/pdf-output.md +51 -0
  39. package/agent-docs/config/outputs.sections/related-reading.md +3 -3
  40. package/agent-docs/configuration.md +2 -2
  41. package/agent-docs/configuration.sections/build-several-sites-from-shared-content.md +1 -1
  42. package/agent-docs/configuration.sections/change-inherited-lists-deliberately.md +1 -1
  43. package/agent-docs/configuration.sections/extract-a-section-with-include.md +1 -1
  44. package/agent-docs/configuration.sections/inspect-and-validate-changes.md +1 -1
  45. package/agent-docs/configuration.sections/related-reading.md +1 -1
  46. package/agent-docs/configuration.sections/separate-config-inheritance-from-page-metadata.md +1 -1
  47. package/agent-docs/configuration.sections/share-defaults-through-extends.md +1 -1
  48. package/agent-docs/configuration.sections/start-with-one-working-file.md +2 -2
  49. package/agent-docs/configuration.sections/understand-the-three-stages.md +1 -1
  50. package/agent-docs/configuration.sections/understand-where-paths-point.md +1 -1
  51. package/agent-docs/contribute/agent-skills.md +3 -1
  52. package/agent-docs/documents.json +510 -251
  53. package/agent-docs/features.md +4 -1
  54. package/agent-docs/index.md +2 -2
  55. package/agent-docs/provenance.json +6 -6
  56. package/agent-docs/publish/content-export.md +2 -0
  57. package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +2 -0
  58. package/agent-docs/publish/content-export.sections/check-the-result.md +2 -0
  59. package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +2 -0
  60. package/agent-docs/publish/content-export.sections/prerequisites.md +2 -0
  61. package/agent-docs/publish/content-export.sections/related-reading.md +2 -0
  62. package/agent-docs/publish/content-export.sections/review-exported-api-material.md +2 -0
  63. package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +2 -0
  64. package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +2 -0
  65. package/agent-docs/publish/pdf.md +184 -0
  66. package/agent-docs/publish/pdf.sections/apply-print-styling.md +65 -0
  67. package/agent-docs/publish/pdf.sections/check-the-result.md +33 -0
  68. package/agent-docs/publish/pdf.sections/export-a-page-or-publication.md +61 -0
  69. package/agent-docs/publish/pdf.sections/html-markdown-and-agents.md +31 -0
  70. package/agent-docs/publish/pdf.sections/offer-browser-page-printing.md +49 -0
  71. package/agent-docs/publish/pdf.sections/offer-direct-downloads-through-a-node-server.md +46 -0
  72. package/agent-docs/publish/pdf.sections/prerequisites.md +37 -0
  73. package/agent-docs/publish/pdf.sections/publish-an-optional-manual-download.md +39 -0
  74. package/agent-docs/publish.md +2 -0
  75. package/agent-docs/reference/agent-skills.md +7 -3
  76. package/agent-docs/search-index.json +1 -1
  77. package/agent-docs/start.md +4 -4
  78. package/agent-docs/start.sections/add-the-page-to-navigation.md +2 -2
  79. package/agent-docs/start.sections/build-and-check-both-outputs.md +2 -2
  80. package/agent-docs/start.sections/build-this-site.md +2 -2
  81. package/agent-docs/start.sections/check-writing-style.md +2 -2
  82. package/agent-docs/start.sections/create-a-page.md +2 -2
  83. package/agent-docs/start.sections/make-it-yours.md +2 -2
  84. package/agent-docs/start.sections/migrating-from-github-packages.md +2 -2
  85. package/agent-docs/start.sections/prerequisites.md +2 -2
  86. package/agent-docs/start.sections/related-reading.md +2 -2
  87. package/agent-docs/start.sections/start-your-own-project.md +4 -4
  88. package/agent-docs/status.md +7 -5
  89. package/agent-docs/what-is-shoalrun.md +2 -2
  90. package/assets/copy-markdown.js +1 -1
  91. package/assets/print-view.js +165 -0
  92. package/assets/print.js +63 -0
  93. package/dist/artifact-bundle.d.ts +1 -0
  94. package/dist/artifact-bundle.js +2 -0
  95. package/dist/authoring-snapshot.js +1 -1
  96. package/dist/browser-print-assets.d.ts +9 -0
  97. package/dist/browser-print-assets.js +45 -0
  98. package/dist/compiler.d.ts +4 -2
  99. package/dist/compiler.js +29 -4
  100. package/dist/content-export-command.d.ts +77 -1
  101. package/dist/content-export-command.js +5 -2
  102. package/dist/content-export-config.d.ts +4 -1
  103. package/dist/content-export-config.js +10 -5
  104. package/dist/content-export-render.d.ts +9 -3
  105. package/dist/content-export-render.js +11 -7
  106. package/dist/content-export.d.ts +126 -0
  107. package/dist/content-export.js +267 -124
  108. package/dist/content-source.d.ts +1 -0
  109. package/dist/frontmatter.js +5 -2
  110. package/dist/highlight-languages.d.ts +9 -0
  111. package/dist/highlight-languages.js +52 -0
  112. package/dist/highlight.d.ts +4 -1
  113. package/dist/highlight.js +20 -5
  114. package/dist/output-commit.d.ts +1 -1
  115. package/dist/output-commit.js +4 -2
  116. package/dist/page-renderer.js +6 -1
  117. package/dist/pdf-button.d.ts +22 -0
  118. package/dist/pdf-button.js +55 -0
  119. package/dist/pdf-config.d.ts +28 -0
  120. package/dist/pdf-config.js +43 -0
  121. package/dist/pdf-http.d.ts +36 -0
  122. package/dist/pdf-http.js +132 -0
  123. package/dist/pdf-project.d.ts +6 -0
  124. package/dist/pdf-project.js +31 -0
  125. package/dist/pdf-renderer.d.ts +45 -0
  126. package/dist/pdf-renderer.js +208 -0
  127. package/dist/pdf-site.d.ts +37 -0
  128. package/dist/pdf-site.js +65 -0
  129. package/dist/pdf-stylesheet.d.ts +1 -0
  130. package/dist/pdf-stylesheet.js +57 -0
  131. package/dist/representation-tree.js +1 -1
  132. package/dist/server.d.ts +2 -0
  133. package/dist/server.js +35 -8
  134. package/dist/vercel.js +6 -0
  135. package/examples/custom-language/README.md +32 -0
  136. package/examples/custom-language/content/start.mdx +28 -0
  137. package/examples/custom-language/docs.config.yaml +18 -0
  138. package/examples/custom-language/grammars/custompy.tmLanguage.json +7 -0
  139. package/examples/pdf/README.md +27 -0
  140. package/examples/pdf/content/operations.mdx +26 -0
  141. package/examples/pdf/content/start.mdx +28 -0
  142. package/examples/pdf/docs.config.yaml +29 -0
  143. package/examples/pdf/print.css +9 -0
  144. package/package.json +4 -2
  145. package/skills/shoalrun-author/SKILL.md +2 -0
  146. package/skills/shoalrun-author/references/pdf-export.md +15 -0
  147. package/skills/shoalrun-brand/SKILL.md +2 -0
  148. package/skills/shoalrun-pdf/SKILL.md +49 -0
  149. package/skills/shoalrun-pdf/assets/print.css +49 -0
  150. package/skills/shoalrun-pdf/references/print-design.md +75 -0
  151. package/styles/site.css +3 -1
@@ -0,0 +1,208 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import puppeteer from 'puppeteer-core';
5
+ import { parseFragment } from 'parse5';
6
+ import { validatePdfConfig } from './pdf-config.js';
7
+ import { validateExportSvg } from './content-export-assets.js';
8
+ import { validatePrintStylesheet } from './pdf-stylesheet.js';
9
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
10
+ const escape = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
11
+ const cssText = (value) => JSON.stringify(value).replace(/</g, '\\3c ');
12
+ const origin = 'https://pdf.shoalrun.invalid';
13
+ const media = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf' };
14
+ const baseCss = `:root{--pdf-accent:#225449;--pdf-text:#172722;--pdf-muted:#53665e;--pdf-font:Arial,sans-serif;--pdf-code-font:monospace;--code-foreground:#172722;--code-token-comment:#53665e;--code-token-punctuation:#172722;--code-token-keyword:#7144a0;--code-token-string:#225449;--code-token-string-expression:#225449;--code-token-constant:#855b12;--code-token-function:#145880;--code-token-parameter:#644479;--code-token-link:#225449}*{box-sizing:border-box}body{margin:0;color:var(--pdf-text);font:11pt/1.5 var(--pdf-font)}h1,h2,h3,h4,h5,h6{color:var(--pdf-accent);line-height:1.2;break-after:avoid}h1{font-size:28pt}h2{font-size:20pt}h3{font-size:15pt}a{color:var(--pdf-accent);text-decoration:underline;overflow-wrap:anywhere}p,li{orphans:3;widows:3}img,svg{max-width:100%;height:auto}figure{margin:1em 0}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9pt/1.5 var(--pdf-code-font);background:#f1f4f2;padding:12pt;border:1px solid #d5dfd9}code{font-family:var(--pdf-code-font)}table{border-collapse:collapse;width:100%;font-size:9pt;table-layout:fixed;overflow-wrap:anywhere}th,td{border:1px solid #c7d2cb;padding:6pt;text-align:left;vertical-align:top}thead{display:table-header-group}tr{break-inside:avoid}blockquote{border-left:3pt solid var(--pdf-accent);margin:1em 0;padding-left:12pt}summary{font-weight:bold}details{display:block}.pdf-cover{break-after:page;padding-top:24%;min-height:60vh}.pdf-cover h1{font-size:38pt}.pdf-logo{display:block;max-width:180pt;max-height:80pt;object-fit:contain;margin-bottom:28pt}.pdf-subtitle{font-size:16pt;color:var(--pdf-muted)}.pdf-publication{color:var(--pdf-muted)}.pdf-toc{break-after:page}.pdf-toc li{margin:5pt 0}.pdf-chapter+.pdf-chapter{break-before:page}hr{border:0;border-top:1px solid #c7d2cb;margin:18pt 0}@media print{*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}`;
15
+ async function readLocal(file, roots, max) {
16
+ const resolved = await realpath(file);
17
+ if (!roots.some(root => resolved === root || resolved.startsWith(root + path.sep)))
18
+ throw new Error(`PDF file escapes asset roots: ${file}`);
19
+ if (resolved.split(path.sep).some(part => /^\.env(?:\.|$)|^\.git$|^(?:PROJECT|DEPLOYMENT)\.local\.md$|^AGENTS\.override\.md$/i.test(part)))
20
+ throw new Error('Private local files cannot be PDF assets');
21
+ const info = await stat(resolved);
22
+ if (!info.isFile() || info.size > max)
23
+ throw new Error(`PDF file must be regular and at most ${max} bytes: ${file}`);
24
+ return readFile(resolved);
25
+ }
26
+ export async function preparePdfDocument(input) {
27
+ const config = validatePdfConfig(input.config);
28
+ if (!input.documents.length || input.documents.length > 10000)
29
+ throw new Error('PDF export needs 1–10000 documents');
30
+ const base = path.dirname(path.resolve(input.configFile));
31
+ const roots = await Promise.all([base, ...(input.assetRoots ?? []).map(root => path.resolve(base, root))].map(root => realpath(root)));
32
+ const assets = new Map();
33
+ // A page preparation can share the publication's asset registry. Carry only
34
+ // resources referenced by the selected print content into its private payload.
35
+ const referencedImages = new Set();
36
+ const collectImages = (node) => {
37
+ if (node.tagName === 'img') {
38
+ const source = node.attrs?.find((attr) => attr.name === 'src')?.value;
39
+ if (source) {
40
+ const url = new URL(source, origin);
41
+ if (url.origin === origin)
42
+ referencedImages.add(url.pathname);
43
+ }
44
+ }
45
+ for (const child of node.childNodes ?? [])
46
+ collectImages(child);
47
+ };
48
+ for (const doc of input.documents)
49
+ collectImages(parseFragment(doc.html));
50
+ for (const [file, content] of input.assets) {
51
+ const type = media[path.extname(file).toLowerCase()];
52
+ if (!type)
53
+ continue;
54
+ const url = new URL(`${input.publicBase}${file}`, origin);
55
+ if (url.origin !== origin)
56
+ throw new Error('PDF publicBase must be a local URL path');
57
+ if (!referencedImages.has(url.pathname))
58
+ continue;
59
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
60
+ if (bytes.length > 32 * 1024 * 1024)
61
+ throw new Error(`PDF asset too large: ${file}`);
62
+ if (type === 'image/svg+xml')
63
+ validateExportSvg(bytes.toString('utf8'));
64
+ assets.set(url.pathname, { bytes, mediaType: type });
65
+ }
66
+ let css = baseCss;
67
+ for (const font of config.fonts ?? []) {
68
+ const extension = path.extname(font.source).toLowerCase();
69
+ if (!['.woff', '.woff2', '.ttf', '.otf'].includes(extension))
70
+ throw new Error('PDF fonts must use woff, woff2, ttf, or otf files');
71
+ const bytes = await readLocal(path.resolve(base, font.source), roots, 16 * 1024 * 1024);
72
+ const url = `/__pdf_fonts/${digest(bytes)}${extension}`;
73
+ assets.set(url, { bytes, mediaType: media[extension] });
74
+ css += `\n@font-face{font-family:${cssText(font.family)};src:url("${url}");font-weight:${font.weight ?? 'normal'};font-style:${font.style ?? 'normal'};font-display:block}`;
75
+ }
76
+ if (config.stylesheet) {
77
+ const custom = (await readLocal(path.resolve(base, config.stylesheet), roots, 1024 * 1024)).toString('utf8');
78
+ // CSS is local styling, not a resource loader. Fonts have explicit registrations.
79
+ validatePrintStylesheet(custom);
80
+ css += '\n' + custom;
81
+ }
82
+ let logo = '';
83
+ if (config.logo) {
84
+ const extension = path.extname(config.logo).toLowerCase();
85
+ const type = media[extension];
86
+ if (!type?.startsWith('image/'))
87
+ throw new Error('PDF logo must be a local image');
88
+ const bytes = await readLocal(path.resolve(base, config.logo), roots, 32 * 1024 * 1024);
89
+ if (type === 'image/svg+xml')
90
+ validateExportSvg(bytes.toString('utf8'));
91
+ const url = `/__pdf_logo/${digest(bytes)}${extension}`;
92
+ assets.set(url, { bytes, mediaType: type });
93
+ logo = `<img class="pdf-logo" src="${url}" alt="">`;
94
+ }
95
+ const title = escape(input.title);
96
+ const publication = input.publication ? escape(Object.entries(input.publication).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}: ${String(value)}`).join(' · ')) : '';
97
+ const cover = config.cover ? `<section class="pdf-cover">${logo}<h1>${title}</h1>${config.subtitle ? `<p class="pdf-subtitle">${escape(config.subtitle)}</p>` : ''}${publication ? `<p class="pdf-publication">${publication}</p>` : ''}</section>` : '';
98
+ const toc = config.toc ? `<nav class="pdf-toc" aria-label="Contents"><h1>Contents</h1><ol>${input.documents.map((doc, index) => `<li><a href="#pdf-document-${index + 1}">${escape(doc.title)}</a></li>`).join('')}</ol></nav>` : '';
99
+ // Validate resource references before CSP can silently suppress a missing image.
100
+ for (const doc of input.documents) {
101
+ const check = (node) => {
102
+ if (['script', 'iframe', 'object', 'embed', 'link', 'base', 'form', 'style', 'meta', 'audio', 'video'].includes(node.tagName))
103
+ throw new Error('PDF input must be passive HTML');
104
+ for (const attr of node.attrs ?? []) {
105
+ if (/^on/i.test(attr.name))
106
+ throw new Error('PDF input must not contain event handlers');
107
+ if (attr.name === 'src' || attr.name === 'srcset') {
108
+ if (attr.name === 'srcset')
109
+ throw new Error('PDF images must use one explicit local src');
110
+ const url = new URL(attr.value, origin);
111
+ if (url.origin !== origin || !assets.has(url.pathname) || url.search || url.hash)
112
+ throw new Error(`PDF requires a registered offline asset: ${attr.value}`);
113
+ }
114
+ }
115
+ for (const child of node.childNodes ?? [])
116
+ check(child);
117
+ };
118
+ check(parseFragment(doc.html));
119
+ }
120
+ if ([...assets.values()].reduce((total, asset) => total + asset.bytes.length, 0) > 64 * 1024 * 1024)
121
+ throw new Error('PDF assets exceed 64 MiB');
122
+ const body = input.documents.map((doc, index) => `<article class="pdf-chapter" id="pdf-document-${index + 1}" data-document-id="${escape(doc.id)}">${doc.html}</article>`).join('\n');
123
+ const html = `<!doctype html><html lang="${escape(String(input.publication?.language ?? 'en'))}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'; font-src 'self'; style-src 'unsafe-inline'; script-src 'none'; base-uri 'none'"><link rel="icon" href="/favicon.ico"><title>${title}</title><style>${css}</style></head><body>${cover}${toc}${body}</body></html>`;
124
+ if (Buffer.byteLength(html) > 64 * 1024 * 1024)
125
+ throw new Error('PDF print document exceeds 64 MiB');
126
+ return { html, assets, styleDigest: digest(css) };
127
+ }
128
+ function template(value) {
129
+ if (!value)
130
+ return '<span></span>';
131
+ return `<div style="width:100%;font:9px Arial,sans-serif;color:#555;padding:0 36px;text-align:center">${escape(value).replace(/\{title\}/g, '<span class="title"></span>').replace(/\{page\}/g, '<span class="pageNumber"></span>').replace(/\{pages\}/g, '<span class="totalPages"></span>')}</div>`;
132
+ }
133
+ /** Resolve only synthetic print resources; never continue a browser request. */
134
+ export function pdfRequestResponse(prepared, address, type, navigation, method = 'GET') {
135
+ const url = new URL(address);
136
+ if (method !== 'GET' || url.origin !== origin || url.username || url.password || url.search || url.hash)
137
+ return undefined;
138
+ if (url.pathname === '/__print' && navigation && type === 'document')
139
+ return { status: 200, contentType: 'text/html', body: prepared.html };
140
+ // Chromium may request its favicon automatically after navigation. Fulfill only
141
+ // this exact synthetic route without granting access to any external resource.
142
+ if (url.pathname === '/favicon.ico' && !navigation && ['image', 'other'].includes(type))
143
+ return { status: 204, body: '' };
144
+ const resource = !navigation ? prepared.assets.get(url.pathname) : undefined;
145
+ if (resource && (type === 'image' && resource.mediaType.startsWith('image/') || type === 'font' && resource.mediaType.startsWith('font/')))
146
+ return { status: 200, contentType: resource.mediaType, body: resource.bytes };
147
+ return undefined;
148
+ }
149
+ export async function renderPreparedPdf(prepared, settings) {
150
+ const config = validatePdfConfig(settings);
151
+ const executablePath = config.executablePath ?? process.env.SHOALRUN_PDF_BROWSER ?? process.env.PUPPETEER_EXECUTABLE_PATH;
152
+ if (!executablePath)
153
+ throw new Error('PDF export needs Chrome/Chromium. Set SHOALRUN_PDF_BROWSER or pdf.executablePath to its executable; no browser is downloaded automatically.');
154
+ const timeout = config.timeout ?? 30000;
155
+ const browser = await puppeteer.launch({ executablePath, headless: true, timeout, protocolTimeout: timeout });
156
+ let timedOut = false;
157
+ const deadline = setTimeout(() => { timedOut = true; void browser.close().catch(() => { }); }, timeout);
158
+ deadline.unref();
159
+ try {
160
+ const page = await browser.newPage();
161
+ await page.setJavaScriptEnabled(false);
162
+ await page.setBypassServiceWorker(true);
163
+ await page.setRequestInterception(true);
164
+ const rejected = new Set();
165
+ page.on('request', request => {
166
+ const response = pdfRequestResponse(prepared, request.url(), request.resourceType(), request.isNavigationRequest(), request.method());
167
+ if (response)
168
+ void request.respond(response).catch(() => { });
169
+ else {
170
+ rejected.add(request.url());
171
+ void request.abort('blockedbyclient').catch(() => { });
172
+ }
173
+ });
174
+ await page.goto(`${origin}/__print`, { waitUntil: 'networkidle0', timeout });
175
+ if (rejected.size)
176
+ throw new Error(`PDF requires offline assets; blocked resource: ${[...rejected][0]}`);
177
+ const missingImages = await page.evaluate(() => [...document.images].some(image => !image.complete || !image.naturalWidth));
178
+ if (missingImages)
179
+ throw new Error('PDF image could not be decoded');
180
+ const fontsReady = await page.evaluate(async () => {
181
+ try {
182
+ const faces = [];
183
+ document.fonts.forEach(face => faces.push(face));
184
+ await Promise.all(faces.map(face => face.load()));
185
+ return faces.every(face => face.status === 'loaded');
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ });
191
+ if (!fontsReady)
192
+ throw new Error('PDF font could not be decoded or loaded');
193
+ const bytes = await page.pdf({ format: config.size ?? 'A4', margin: { top: '18mm', right: '18mm', bottom: '18mm', left: '18mm', ...config.margins }, printBackground: true, preferCSSPageSize: true, displayHeaderFooter: !!(config.header || config.footer), headerTemplate: template(config.header), footerTemplate: template(config.footer), outline: config.bookmarks ?? true, tagged: true, waitForFonts: true, timeout });
194
+ if (rejected.size)
195
+ throw new Error(`PDF requires offline assets; blocked resource: ${[...rejected][0]}`);
196
+ return { bytes: Buffer.from(bytes), metadata: { engine: 'chromium', browserVersion: await browser.version(), styleDigest: prepared.styleDigest, assets: [...prepared.assets].map(([file, asset]) => ({ path: file, digest: digest(asset.bytes) })) } };
197
+ }
198
+ catch (error) {
199
+ if (timedOut)
200
+ throw new Error(`PDF rendering exceeded ${timeout} milliseconds`);
201
+ throw error;
202
+ }
203
+ finally {
204
+ clearTimeout(deadline);
205
+ await browser.close();
206
+ }
207
+ }
208
+ export async function renderPdf(input) { return renderPreparedPdf(await preparePdfDocument(input), input.config); }
@@ -0,0 +1,37 @@
1
+ import type { Document, Variant } from './content-source.js';
2
+ import { type PreparedPdf } from './pdf-renderer.js';
3
+ import { type PdfConfig } from './pdf-config.js';
4
+ export type SerializedPreparedPdf = {
5
+ html: string;
6
+ assets: Array<{
7
+ path: string;
8
+ mediaType: string;
9
+ base64: string;
10
+ }>;
11
+ styleDigest: string;
12
+ };
13
+ export type PdfSiteRuntime = {
14
+ schemaVersion: 1;
15
+ endpoint: string;
16
+ config: PdfConfig;
17
+ pages: Record<string, {
18
+ documentId: string;
19
+ publication: Variant;
20
+ title: string;
21
+ file: string;
22
+ digest: string;
23
+ }>;
24
+ inventory: Record<string, string>;
25
+ };
26
+ export declare const serializePreparedPdf: (prepared: PreparedPdf) => SerializedPreparedPdf;
27
+ export declare function buildPdfSiteArtifacts(configFile: string, options: {
28
+ outputs: unknown;
29
+ exports?: unknown;
30
+ documents: Document[];
31
+ knownDocumentIds?: ReadonlySet<string>;
32
+ basePath: string;
33
+ profile?: string;
34
+ }): Promise<{
35
+ artifacts: Map<string, string | Buffer<ArrayBufferLike>>;
36
+ dependencies: Set<string>;
37
+ }>;
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { publicationKey } from './publication-identity.js';
3
+ import { preparePdfExport } from './content-export.js';
4
+ import { preparePdfDocument } from './pdf-renderer.js';
5
+ import { pdfButtonIncludes, pdfDataPath, pdfPageKey, pdfRuntimePath, resolvePdfButton, validatePdfButtonStyle } from './pdf-button.js';
6
+ import { sitePath } from './site-path.js';
7
+ import { browserPrintAssets } from './browser-print-assets.js';
8
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ export const serializePreparedPdf = (prepared) => ({ html: prepared.html, styleDigest: prepared.styleDigest, assets: [...prepared.assets].map(([path, asset]) => ({ path, mediaType: asset.mediaType, base64: asset.bytes.toString('base64') })) });
10
+ export async function buildPdfSiteArtifacts(configFile, options) {
11
+ const artifacts = new Map();
12
+ const dependencies = new Set();
13
+ const button = resolvePdfButton(options.outputs);
14
+ if (!button)
15
+ return { artifacts, dependencies };
16
+ validatePdfButtonStyle(button, options.exports);
17
+ const knownIds = options.knownDocumentIds ?? new Set(options.documents.map(doc => doc.frontmatter.id));
18
+ for (const id of [...button.include ?? [], ...button.exclude ?? []])
19
+ if (!knownIds.has(id))
20
+ throw new Error(`PDF button selects unknown document ${id}`);
21
+ if (button.mode === 'print') {
22
+ if (!options.documents.some(doc => pdfButtonIncludes(doc, button)))
23
+ return { artifacts, dependencies };
24
+ return browserPrintAssets(configFile, { basePath: options.basePath, profile: options.profile, style: button.style });
25
+ }
26
+ const selected = options.documents.filter(doc => pdfButtonIncludes(doc, button));
27
+ if (!selected.length)
28
+ return { artifacts, dependencies };
29
+ const groups = new Map();
30
+ for (const doc of selected) {
31
+ const key = publicationKey(doc.variant);
32
+ groups.set(key, [...groups.get(key) ?? [], doc]);
33
+ }
34
+ const runtime = { schemaVersion: 1, endpoint: sitePath(options.basePath, button.endpoint), config: {}, pages: {}, inventory: {} };
35
+ for (const docs of groups.values()) {
36
+ const exported = await preparePdfExport(configFile, { profile: options.profile, documents: docs.map(doc => doc.frontmatter.id), publication: docs[0].variant, pdfLinks: 'canonical', pdfStyleTarget: button.style });
37
+ for (const file of exported.inputs)
38
+ dependencies.add(file);
39
+ const { stylesheet, fonts, logo, ...renderConfig } = exported.input.config;
40
+ runtime.config = renderConfig;
41
+ for (const doc of docs) {
42
+ const document = exported.input.documents.find(item => item.id === doc.frontmatter.id);
43
+ if (!document)
44
+ throw new Error(`PDF preparation omitted selected document ${doc.frontmatter.id}`);
45
+ const input = { ...exported.input, documents: [document], title: doc.frontmatter.title, config: { ...exported.input.config, cover: false, toc: false } };
46
+ const prepared = await preparePdfDocument(input);
47
+ const key = pdfPageKey(doc.frontmatter.id, doc.variant);
48
+ const file = `${key}.json`;
49
+ const bytes = JSON.stringify(serializePreparedPdf(prepared));
50
+ if (Buffer.byteLength(bytes) > 64 * 1024 * 1024)
51
+ throw new Error(`Prepared page PDF exceeds 64 MiB: ${doc.frontmatter.id}`);
52
+ const hash = digest(bytes);
53
+ artifacts.set(`${pdfDataPath}/${file}`, bytes);
54
+ runtime.inventory[file] = hash;
55
+ runtime.pages[key] = { documentId: doc.frontmatter.id, publication: doc.variant, title: doc.frontmatter.title, file, digest: hash };
56
+ }
57
+ }
58
+ if (button.mode === 'endpoint') {
59
+ const metadata = JSON.stringify(runtime);
60
+ if (Buffer.byteLength(metadata) > 4 * 1024 * 1024)
61
+ throw new Error('PDF runtime metadata exceeds 4 MiB');
62
+ artifacts.set(pdfRuntimePath, metadata);
63
+ }
64
+ return { artifacts, dependencies };
65
+ }
@@ -0,0 +1 @@
1
+ export declare function validatePrintStylesheet(css: string): void;
@@ -0,0 +1,57 @@
1
+ // Print CSS may control layout, but resource loading uses explicit asset registrations.
2
+ // Allow known non-loading functions rather than trying to enumerate every CSS loader.
3
+ const functions = new Set(`
4
+ var env calc min max clamp round mod rem abs sign hypot sqrt pow exp log
5
+ sin cos tan asin acos atan atan2
6
+ rgb rgba hsl hsla hwb lab lch oklab oklch color color-mix light-dark contrast-color
7
+ linear-gradient repeating-linear-gradient radial-gradient repeating-radial-gradient
8
+ conic-gradient repeating-conic-gradient
9
+ counter counters attr symbols
10
+ translate translatex translatey translatez translate3d scale scalex scaley scalez scale3d
11
+ rotate rotatex rotatey rotatez rotate3d skew skewx skewy matrix matrix3d perspective
12
+ blur brightness contrast drop-shadow grayscale hue-rotate invert opacity saturate sepia
13
+ cubic-bezier steps linear
14
+ repeat minmax fit-content anchor anchor-size
15
+ circle ellipse inset polygon path rect xywh
16
+ is where not has nth-child nth-last-child nth-of-type nth-last-of-type lang dir
17
+ selector supports style scroll-state
18
+ `.trim().split(/\s+/));
19
+ export function validatePrintStylesheet(css) {
20
+ const fail = () => { throw new Error('PDF stylesheet must not import files, use CSS escapes, or load URLs; use supported layout/color functions and register local fonts with pdf.fonts'); };
21
+ if (/@import\b|<\/style|\\/i.test(css))
22
+ fail();
23
+ // Lex comments and quoted text separately so content strings aren't interpreted
24
+ // as functions. CSS escapes are already disallowed, including in identifiers.
25
+ for (let index = 0; index < css.length;) {
26
+ if (css.startsWith('/*', index)) {
27
+ const end = css.indexOf('*/', index + 2);
28
+ if (end === -1)
29
+ fail();
30
+ index = end + 2;
31
+ }
32
+ else if (css[index] === '"' || css[index] === "'") {
33
+ const quote = css[index++];
34
+ while (index < css.length && css[index] !== quote) {
35
+ if (/[\r\n\f]/.test(css[index]))
36
+ fail();
37
+ index++;
38
+ }
39
+ if (index === css.length)
40
+ fail();
41
+ index++;
42
+ }
43
+ else if (/[a-z_-]/i.test(css[index])) {
44
+ const start = index++;
45
+ while (index < css.length && /[a-z0-9_-]/i.test(css[index]))
46
+ index++;
47
+ const name = css.slice(start, index).toLowerCase();
48
+ let next = index;
49
+ while (next < css.length && /\s/.test(css[next]))
50
+ next++;
51
+ if (css[next] === '(' && !functions.has(name))
52
+ fail();
53
+ }
54
+ else
55
+ index++;
56
+ }
57
+ }
@@ -53,7 +53,7 @@ export function publicTree(tree, representation, documentRoutes, mode = 'light',
53
53
  if (node.type === 'docReaderSection') {
54
54
  const children = node.children.flatMap(walk);
55
55
  return representation === 'markdown' ? [paragraph({ type: 'strong', children: [text(`For ${node.description}.`)] }), ...children]
56
- : [element('section', { className: ['reader-section'], 'data-reader-filters': JSON.stringify(node.selection) }, children)];
56
+ : [element('section', { className: ['reader-section'], 'data-reader-filters': JSON.stringify(node.selection), 'data-reader-print-label': `For ${node.description}.` }, children)];
57
57
  }
58
58
  if (node.type === 'docReaderChoice') {
59
59
  if (representation === 'markdown') {
package/dist/server.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type Server, type ServerResponse } from 'node:http';
2
+ export declare function canonicalSitePath(url: string): string;
3
+ export declare function privateSitePath(pathname: string): boolean;
2
4
  export type PreviewStatus = {
3
5
  profile?: string;
4
6
  profileDigest?: string;
package/dist/server.js CHANGED
@@ -1,11 +1,22 @@
1
1
  import { loadMcpRuntime, createMcpHttpHandler } from './mcp-http.js';
2
2
  import { bridgeMcp } from './mcp-node.js';
3
+ import { loadPdfRuntime, createPdfHttpHandler } from './pdf-http.js';
3
4
  import { notFoundPage, addNotFoundTwin } from './not-found.js';
4
5
  import { createServer } from 'node:http';
5
6
  import { readFile, realpath, stat } from 'node:fs/promises';
6
7
  import path from 'node:path';
7
8
  import { homeUrl, negotiatedDocument } from './negotiation.js';
8
- const types = { '.html': 'text/html; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.json': 'application/json', '.yaml': 'application/yaml', '.yml': 'application/yaml', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf' };
9
+ export function canonicalSitePath(url) {
10
+ const decoded = decodeURIComponent(new URL(url, 'http://localhost').pathname).replace(/\\/g, '/');
11
+ if (decoded.includes('\0') || decoded.split('/').some(segment => segment === '..' || segment === '.'))
12
+ throw new Error('Invalid path');
13
+ return decoded.replace(/\/{2,}/g, '/');
14
+ }
15
+ export function privateSitePath(pathname) {
16
+ const internal = pathname.toLowerCase().replace(/\/+$/, '');
17
+ return ['/mcp-runtime.json', '/pdf-runtime.json', '/__shoalrun/mcp-data', '/__shoalrun/pdf-data'].some(privatePath => internal === privatePath || internal.startsWith(privatePath + '/'));
18
+ }
19
+ const types = { '.pdf': 'application/pdf', '.html': 'text/html; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.json': 'application/json', '.yaml': 'application/yaml', '.yml': 'application/yaml', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf' };
9
20
  const escapeHtml = (value) => value.replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[character]));
10
21
  const scriptJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
11
22
  export function previewMarkup(basePath, preview) {
@@ -58,6 +69,9 @@ export async function createSiteServer(directory, liveClients, extras = {}) {
58
69
  let mcp = await loadMcpRuntime(root);
59
70
  let mcpHandler = mcp ? createMcpHttpHandler(mcp) : undefined;
60
71
  let mcpBuild = mcp?.runtime.buildId;
72
+ let pdf = await loadPdfRuntime(root);
73
+ let pdfHandler = pdf ? createPdfHttpHandler(pdf) : undefined;
74
+ let pdfBuild = initialState.manifest.buildId;
61
75
  async function siteFile(route) {
62
76
  const file = path.resolve(root, `.${route}`);
63
77
  if (file !== root && !file.startsWith(`${root}${path.sep}`))
@@ -73,9 +87,7 @@ export async function createSiteServer(directory, liveClients, extras = {}) {
73
87
  let pathname = '';
74
88
  let recoveryState = initialState;
75
89
  try {
76
- pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
77
- if (pathname.includes('\0') || pathname.split('/').includes('..'))
78
- throw new Error('Invalid path');
90
+ pathname = canonicalSitePath(request.url ?? '/');
79
91
  const { headers, manifest, redirects, search, errorHtml, home, routes } = liveClients ? await loadState() : initialState;
80
92
  recoveryState = { headers, manifest, redirects, search, errorHtml, home, routes };
81
93
  const basePath = manifest.basePath ?? '';
@@ -85,12 +97,28 @@ export async function createSiteServer(directory, liveClients, extras = {}) {
85
97
  mcpHandler = next ? createMcpHttpHandler(next) : undefined;
86
98
  mcpBuild = manifest.buildId;
87
99
  }
88
- const internalPath = pathname.toLowerCase().replace(/\/+$/, '');
89
- if (internalPath === '/mcp-runtime.json' || internalPath === '/__shoalrun/mcp-data' || internalPath.startsWith('/__shoalrun/mcp-data/')) {
100
+ if (privateSitePath(pathname)) {
90
101
  response.writeHead(404);
91
102
  response.end();
92
103
  return;
93
104
  }
105
+ if (liveClients && manifest.buildId !== pdfBuild) {
106
+ pdf = await loadPdfRuntime(root);
107
+ pdfHandler = pdf ? createPdfHttpHandler(pdf) : undefined;
108
+ pdfBuild = manifest.buildId;
109
+ }
110
+ if (pdf && pdfHandler && pathname === pdf.runtime.endpoint) {
111
+ try {
112
+ await bridge(request, response, pdfHandler);
113
+ }
114
+ catch {
115
+ if (!response.headersSent) {
116
+ response.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
117
+ response.end('{"error":"PDF generation is unavailable."}');
118
+ }
119
+ }
120
+ return;
121
+ }
94
122
  if (mcp && mcpHandler && pathname === mcp.runtime.path) {
95
123
  await bridgeMcp(request, response, mcpHandler);
96
124
  return;
@@ -201,8 +229,7 @@ export async function createSiteServer(directory, liveClients, extras = {}) {
201
229
  mcpHandler = next ? createMcpHttpHandler(next) : undefined;
202
230
  mcpBuild = manifest.buildId;
203
231
  }
204
- const internalPath = pathname.toLowerCase().replace(/\/+$/, '');
205
- if (internalPath === '/mcp-runtime.json' || internalPath === '/__shoalrun/mcp-data' || internalPath.startsWith('/__shoalrun/mcp-data/')) {
232
+ if (privateSitePath(pathname)) {
206
233
  response.writeHead(404);
207
234
  response.end();
208
235
  return;
package/dist/vercel.js CHANGED
@@ -5,6 +5,9 @@ import path from 'node:path';
5
5
  import { compile } from './compiler.js';
6
6
  import { homeUrl } from './negotiation.js';
7
7
  import { addAskFunction } from './vercel-function.js';
8
+ import { loadConfig } from './config.js';
9
+ import { resolvedCompilerConfig } from './compiler-config.js';
10
+ import { resolvePdfButton } from './pdf-button.js';
8
11
  function exact(route) {
9
12
  return route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
13
  }
@@ -28,6 +31,9 @@ async function removeStaleFunctions(directory, keep = new Set()) {
28
31
  await visit(directory);
29
32
  }
30
33
  export async function buildVercel(configFile, outputDirectory, provenance = {}, options = {}) {
34
+ const settings = resolvedCompilerConfig((await loadConfig(configFile, { profile: options.profile })).value);
35
+ if (resolvePdfButton(settings.outputs)?.mode === 'endpoint')
36
+ throw new Error('On-demand PDF generation requires a Node server with an installed Chromium browser. Use outputs.html.pdf.mode: print for Vercel builds.');
31
37
  const output = path.resolve(outputDirectory);
32
38
  const staticDirectory = path.join(output, 'static');
33
39
  if (options.profile && options.chat)
@@ -0,0 +1,32 @@
1
+ # Custom language highlighting
2
+
3
+ Register a local TextMate JSON grammar under
4
+ `outputs.html.highlighting.languages`, then use its `id` or an `aliases` entry
5
+ as a fenced-code language. This example adds two illustrative keywords to
6
+ Python's existing syntax-color rules.
7
+
8
+ From this directory, with the CLI installed in your project:
9
+
10
+ ```sh
11
+ npx --no-install shoalrun build docs.config.yaml site-dist
12
+ npx --no-install shoalrun serve site-dist --port 8770
13
+ ```
14
+
15
+ From the framework repository root:
16
+
17
+ ```sh
18
+ node framework/dist/cli.js build framework/examples/custom-language/docs.config.yaml /tmp/shoalrun-custom-language
19
+ node framework/dist/cli.js serve /tmp/shoalrun-custom-language --port 8770
20
+ ```
21
+
22
+ Grammar paths resolve from the configuration file that declares them. The
23
+ compiler tracks those files as build dependencies; edit the grammar and rebuild,
24
+ or use `dev` for watched editing. Malformed or missing grammars fail the build
25
+ and preserve existing output. IDs and aliases can't replace bundled languages;
26
+ each grammar needs a distinct `scopeName`.
27
+
28
+ The resulting HTML contains syntax colors without loading the grammar in the
29
+ browser. Markdown keeps the original code and fence label. The grammar doesn't
30
+ validate programs or add autocomplete, filename-based language inference, or
31
+ editor integration. Replace this illustrative grammar with your language's
32
+ grammar and retain its applicable license notices.
@@ -0,0 +1,28 @@
1
+ ---
2
+ id: guide.custom-language
3
+ title: Highlight a custom language
4
+ summary: Register a local TextMate grammar and use its language ID or alias in fenced examples.
5
+ type: guide
6
+ slug: /
7
+ channel: production
8
+ version: current
9
+ language: en
10
+ access: { policy: public }
11
+ ---
12
+
13
+ This illustrative Python-like language adds `publish` and `unless`. The grammar
14
+ recognizes these keywords before falling back to the bundled Python grammar.
15
+
16
+ ```custompy title="Language ID"
17
+ def welcome(name):
18
+ unless name is None:
19
+ publish "Hello, " + name
20
+ ```
21
+
22
+ The alias selects the same grammar:
23
+
24
+ ```cpy title="Language alias"
25
+ publish "The same syntax colors, with a shorter fence label."
26
+ ```
27
+
28
+ This example only defines syntax colors. It doesn't implement an executable language.
@@ -0,0 +1,18 @@
1
+ schemaVersion: 1
2
+ site:
3
+ title: Custom language example
4
+ contentDir: ./content
5
+ publications:
6
+ defaults: { channel: production, version: current, language: en }
7
+ channels: { production: {} }
8
+ versions: { current: {} }
9
+ enabled: [{ channel: production, version: current, language: en }]
10
+ outputs:
11
+ html:
12
+ shareImages: false
13
+ highlighting:
14
+ provider: shiki
15
+ languages:
16
+ - id: custompy
17
+ grammar: ./grammars/custompy.tmLanguage.json
18
+ aliases: [cpy]
@@ -0,0 +1,7 @@
1
+ {
2
+ "scopeName": "source.custompy",
3
+ "patterns": [
4
+ { "name": "keyword.control.custompy", "match": "\\b(?:publish|unless)\\b" },
5
+ { "include": "source.python" }
6
+ ]
7
+ }
@@ -0,0 +1,27 @@
1
+ # Styled PDF export
2
+
3
+ This project demonstrates browser page printing and optional CLI PDF exports. All configuration values and thresholds are examples.
4
+
5
+ Build and preview the static site without a browser dependency:
6
+
7
+ ```sh
8
+ shoalrun build docs.config.yaml site-dist
9
+ shoalrun serve site-dist --port 8892
10
+ ```
11
+
12
+ Open `/start` and choose **Print PDF**. The browser fills a separate print view using the shared PDF theme and opens its print dialog. Site builds add shared scripts, styling, settings, and configured print assets; they generate no page PDFs or extra print documents. This works on static hosting, including Vercel. Allow the print view to open if the browser blocks it. Without JavaScript, use the browser's own Print command.
13
+
14
+ For a downloadable full manual, explicitly run the CLI export with an installed browser:
15
+
16
+ ```sh
17
+ export SHOALRUN_PDF_BROWSER="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
18
+ shoalrun export docs.config.yaml --format pdf --out pdf/manual
19
+ ```
20
+
21
+ Use the correct Chrome/Chromium executable path for your operating system. No browser is downloaded automatically. The command writes `pdf/manual/publication.pdf`. Publish that file and a deliberate download link only when wanted; it isn't added to the site automatically. Copy it into the static deployment after the site build, which replaces its output directory. Add `--document guide.start` to generate a single-page PDF explicitly.
22
+
23
+ Change `print.css` for manual and browser print-view colors, typography, and page breaks. Set `outputs.pdf.fonts` to local licensed font files and reference their registered families in CSS. Use `outputs.pdf.logo` for a local cover image. Print CSS can't import files or load URLs; fonts and logo are registered explicitly. Optional `outputs.html.pdf.style` selects a named export target's PDF styling without changing page selection. Headers and footers accept `{title}`, `{page}`, and `{pages}`; browser views use CSS page-margin support. Use `site.customCss` with `@media print` for the ordinary browser Print fallback, which doesn't open a branded view.
24
+
25
+ Inspect the actual PDF for spacing, math, long content, links, and bookmarks. The manual's table of contents is linked without printed page numbers. Chromium bookmark/tagging support is experimental; the output isn't certified PDF/UA. The `pdf` export manifest is separate from content REST representations.
26
+
27
+ Explicit `outputs.html.pdf.mode: endpoint` supports direct page PDF downloads through a Node host with Chromium. It isn't for static hosting. Keep `pdf-runtime.json` and `__shoalrun/pdf-data` behind that server; don't expose them through a generic static host.