@shoalrun/cli 0.1.0-alpha.18 → 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 (165) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +63 -28
  3. package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +7 -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/branding-security.md +66 -0
  27. package/agent-docs/changelog/pdf-custom-languages.md +48 -0
  28. package/agent-docs/changelog.md +17 -1
  29. package/agent-docs/chat/host.md +20 -2
  30. package/agent-docs/chat/settings.md +1 -1
  31. package/agent-docs/chat/settings.sections/configure-chat.md +1 -1
  32. package/agent-docs/config/examples.md +3 -3
  33. package/agent-docs/config/examples.sections/feature-showcase.md +3 -3
  34. package/agent-docs/config/examples.sections/minimal-site.md +2 -2
  35. package/agent-docs/config/examples.sections/multilingual-help-center.md +2 -2
  36. package/agent-docs/config/examples.sections/parcel-api-documentation.md +2 -2
  37. package/agent-docs/config/examples.sections/related-reading.md +2 -2
  38. package/agent-docs/config/examples.sections/shared-company-preset.md +2 -2
  39. package/agent-docs/config/outputs.md +33 -12
  40. package/agent-docs/config/outputs.sections/exports.md +3 -3
  41. package/agent-docs/config/outputs.sections/metadata.md +3 -3
  42. package/agent-docs/config/outputs.sections/pdf-output.md +51 -0
  43. package/agent-docs/config/outputs.sections/related-reading.md +3 -3
  44. package/agent-docs/configuration.md +2 -2
  45. package/agent-docs/configuration.sections/build-several-sites-from-shared-content.md +1 -1
  46. package/agent-docs/configuration.sections/change-inherited-lists-deliberately.md +1 -1
  47. package/agent-docs/configuration.sections/extract-a-section-with-include.md +1 -1
  48. package/agent-docs/configuration.sections/inspect-and-validate-changes.md +1 -1
  49. package/agent-docs/configuration.sections/related-reading.md +1 -1
  50. package/agent-docs/configuration.sections/separate-config-inheritance-from-page-metadata.md +1 -1
  51. package/agent-docs/configuration.sections/share-defaults-through-extends.md +1 -1
  52. package/agent-docs/configuration.sections/start-with-one-working-file.md +2 -2
  53. package/agent-docs/configuration.sections/understand-the-three-stages.md +1 -1
  54. package/agent-docs/configuration.sections/understand-where-paths-point.md +1 -1
  55. package/agent-docs/contribute/agent-skills.md +3 -1
  56. package/agent-docs/documents.json +623 -270
  57. package/agent-docs/features.md +4 -1
  58. package/agent-docs/index.md +2 -2
  59. package/agent-docs/provenance.json +6 -6
  60. package/agent-docs/publish/content-export.md +2 -0
  61. package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +2 -0
  62. package/agent-docs/publish/content-export.sections/check-the-result.md +2 -0
  63. package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +2 -0
  64. package/agent-docs/publish/content-export.sections/prerequisites.md +2 -0
  65. package/agent-docs/publish/content-export.sections/related-reading.md +2 -0
  66. package/agent-docs/publish/content-export.sections/review-exported-api-material.md +2 -0
  67. package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +2 -0
  68. package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +2 -0
  69. package/agent-docs/publish/pdf.md +184 -0
  70. package/agent-docs/publish/pdf.sections/apply-print-styling.md +65 -0
  71. package/agent-docs/publish/pdf.sections/check-the-result.md +33 -0
  72. package/agent-docs/publish/pdf.sections/export-a-page-or-publication.md +61 -0
  73. package/agent-docs/publish/pdf.sections/html-markdown-and-agents.md +31 -0
  74. package/agent-docs/publish/pdf.sections/offer-browser-page-printing.md +49 -0
  75. package/agent-docs/publish/pdf.sections/offer-direct-downloads-through-a-node-server.md +46 -0
  76. package/agent-docs/publish/pdf.sections/prerequisites.md +37 -0
  77. package/agent-docs/publish/pdf.sections/publish-an-optional-manual-download.md +39 -0
  78. package/agent-docs/publish.md +2 -0
  79. package/agent-docs/reference/agent-skills.md +7 -3
  80. package/agent-docs/search-index.json +1 -1
  81. package/agent-docs/start.md +4 -4
  82. package/agent-docs/start.sections/add-the-page-to-navigation.md +2 -2
  83. package/agent-docs/start.sections/build-and-check-both-outputs.md +2 -2
  84. package/agent-docs/start.sections/build-this-site.md +2 -2
  85. package/agent-docs/start.sections/check-writing-style.md +2 -2
  86. package/agent-docs/start.sections/create-a-page.md +2 -2
  87. package/agent-docs/start.sections/make-it-yours.md +2 -2
  88. package/agent-docs/start.sections/migrating-from-github-packages.md +2 -2
  89. package/agent-docs/start.sections/prerequisites.md +2 -2
  90. package/agent-docs/start.sections/related-reading.md +2 -2
  91. package/agent-docs/start.sections/start-your-own-project.md +4 -4
  92. package/agent-docs/status.md +7 -5
  93. package/agent-docs/what-is-shoalrun.md +2 -2
  94. package/assets/ask-widget.js +1 -1
  95. package/assets/copy-markdown.js +1 -1
  96. package/assets/print-view.js +165 -0
  97. package/assets/print.js +63 -0
  98. package/dist/artifact-bundle.d.ts +1 -0
  99. package/dist/artifact-bundle.js +2 -0
  100. package/dist/ask-function.js +5 -2
  101. package/dist/authoring-snapshot.js +1 -1
  102. package/dist/browser-print-assets.d.ts +9 -0
  103. package/dist/browser-print-assets.js +45 -0
  104. package/dist/chat-config.d.ts +2 -0
  105. package/dist/chat-config.js +10 -2
  106. package/dist/cli.js +2 -2
  107. package/dist/compiler.d.ts +4 -2
  108. package/dist/compiler.js +29 -4
  109. package/dist/content-export-command.d.ts +77 -1
  110. package/dist/content-export-command.js +5 -2
  111. package/dist/content-export-config.d.ts +4 -1
  112. package/dist/content-export-config.js +10 -5
  113. package/dist/content-export-render.d.ts +9 -3
  114. package/dist/content-export-render.js +11 -7
  115. package/dist/content-export.d.ts +126 -0
  116. package/dist/content-export.js +267 -124
  117. package/dist/content-source.d.ts +1 -0
  118. package/dist/frontmatter.js +5 -2
  119. package/dist/highlight-languages.d.ts +9 -0
  120. package/dist/highlight-languages.js +52 -0
  121. package/dist/highlight.d.ts +4 -1
  122. package/dist/highlight.js +20 -5
  123. package/dist/output-commit.d.ts +1 -1
  124. package/dist/output-commit.js +4 -2
  125. package/dist/page-renderer.js +6 -1
  126. package/dist/pdf-button.d.ts +22 -0
  127. package/dist/pdf-button.js +55 -0
  128. package/dist/pdf-config.d.ts +28 -0
  129. package/dist/pdf-config.js +43 -0
  130. package/dist/pdf-http.d.ts +36 -0
  131. package/dist/pdf-http.js +132 -0
  132. package/dist/pdf-project.d.ts +6 -0
  133. package/dist/pdf-project.js +31 -0
  134. package/dist/pdf-renderer.d.ts +45 -0
  135. package/dist/pdf-renderer.js +208 -0
  136. package/dist/pdf-site.d.ts +37 -0
  137. package/dist/pdf-site.js +65 -0
  138. package/dist/pdf-stylesheet.d.ts +1 -0
  139. package/dist/pdf-stylesheet.js +57 -0
  140. package/dist/representation-tree.js +1 -1
  141. package/dist/server.d.ts +2 -0
  142. package/dist/server.js +35 -8
  143. package/dist/turnstile-policy.d.ts +2 -0
  144. package/dist/turnstile-policy.js +23 -0
  145. package/dist/turnstile.d.ts +7 -0
  146. package/dist/turnstile.js +29 -2
  147. package/dist/vercel-function.js +1 -1
  148. package/dist/vercel.js +6 -0
  149. package/examples/custom-language/README.md +32 -0
  150. package/examples/custom-language/content/start.mdx +28 -0
  151. package/examples/custom-language/docs.config.yaml +18 -0
  152. package/examples/custom-language/grammars/custompy.tmLanguage.json +7 -0
  153. package/examples/pdf/README.md +27 -0
  154. package/examples/pdf/content/operations.mdx +26 -0
  155. package/examples/pdf/content/start.mdx +28 -0
  156. package/examples/pdf/docs.config.yaml +29 -0
  157. package/examples/pdf/print.css +9 -0
  158. package/package.json +4 -2
  159. package/skills/shoalrun-author/SKILL.md +2 -0
  160. package/skills/shoalrun-author/references/pdf-export.md +15 -0
  161. package/skills/shoalrun-brand/SKILL.md +54 -0
  162. package/skills/shoalrun-pdf/SKILL.md +49 -0
  163. package/skills/shoalrun-pdf/assets/print.css +49 -0
  164. package/skills/shoalrun-pdf/references/print-design.md +75 -0
  165. 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;
@@ -0,0 +1,2 @@
1
+ export declare function turnstileHostname(value: unknown): string | undefined;
2
+ export declare function turnstileHostnames(value: unknown, setting: string): string[];
@@ -0,0 +1,23 @@
1
+ // Canonical, exact hostnames only: no wildcard, URL, credentials, or port.
2
+ export function turnstileHostname(value) {
3
+ if (typeof value !== 'string' || !value || value.length > 253 || /[\s*]/.test(value))
4
+ return;
5
+ const hostname = value.toLowerCase().replace(/\.$/, '');
6
+ try {
7
+ const url = new URL(`https://${hostname}`);
8
+ if (url.host !== hostname || url.pathname !== '/' || url.search || url.hash || url.username || url.password)
9
+ return;
10
+ return hostname;
11
+ }
12
+ catch {
13
+ return;
14
+ }
15
+ }
16
+ export function turnstileHostnames(value, setting) {
17
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100)
18
+ throw new Error(`${setting} must be a nonempty list of at most 100 exact hostnames`);
19
+ const names = value.map(turnstileHostname);
20
+ if (names.some(name => name === undefined))
21
+ throw new Error(`${setting} must contain exact hostnames without wildcards, URLs, or ports`);
22
+ return [...new Set(names)];
23
+ }
@@ -1,15 +1,22 @@
1
1
  export type TurnstileOptions = {
2
2
  secret: string;
3
+ allowedHostnames: readonly string[];
4
+ action?: string;
3
5
  timeoutMs?: number;
4
6
  fetch?: typeof fetch;
5
7
  };
6
8
  export declare const turnstileVerifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
9
+ export declare function turnstilePreviewVerifier(options?: Pick<TurnstileOptions, 'fetch' | 'timeoutMs'>): (input: {
10
+ token?: string;
11
+ ip?: string;
12
+ }) => Promise<boolean>;
7
13
  export declare function turnstileVerifier(options: TurnstileOptions): (input: {
8
14
  token?: string;
9
15
  ip?: string;
10
16
  }) => Promise<boolean>;
11
17
  export type SessionOptions = {
12
18
  secret: string;
19
+ context?: string;
13
20
  maxAgeSeconds?: number;
14
21
  now?: () => number;
15
22
  };
package/dist/turnstile.js CHANGED
@@ -1,8 +1,31 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { turnstileHostname, turnstileHostnames } from './turnstile-policy.js';
2
3
  export const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
4
+ const previewSecret = '1x0000000000000000000000000000000AA';
5
+ const previewToken = 'XXXX.DUMMY.TOKEN.XXXX';
6
+ // Cloudflare's published test pair returns synthetic hostname metadata rather
7
+ // than a real browser hostname. This helper is used only by loopback preview.
8
+ export function turnstilePreviewVerifier(options = {}) {
9
+ const send = options.fetch ?? fetch;
10
+ const verify = turnstileVerifier({ ...options, secret: previewSecret, allowedHostnames: ['example.com'], fetch: async (url, init) => {
11
+ const response = await send(url, init);
12
+ if (!response.ok)
13
+ return response;
14
+ const result = await response.json();
15
+ return Response.json({ success: result.success === true && result.metadata?.result_with_testing_key === true, hostname: result.hostname });
16
+ } });
17
+ return async (input) => {
18
+ if (input.token !== previewToken)
19
+ return false;
20
+ return verify(input);
21
+ };
22
+ }
3
23
  export function turnstileVerifier(options) {
4
24
  if (!options.secret)
5
25
  throw new Error('Turnstile needs a secret key');
26
+ const allowed = new Set(turnstileHostnames(options.allowedHostnames, 'Turnstile allowedHostnames'));
27
+ if (options.action !== undefined && !/^[A-Za-z0-9_-]{1,32}$/.test(options.action))
28
+ throw new Error('Turnstile action must contain 1 to 32 letters, numbers, underscores, or hyphens');
6
29
  const send = options.fetch ?? fetch;
7
30
  return async ({ token, ip }) => {
8
31
  // Tokens are short. A missing or oversized one is refused without asking Cloudflare.
@@ -16,7 +39,8 @@ export function turnstileVerifier(options) {
16
39
  if (!response.ok)
17
40
  return false;
18
41
  const body = await response.json();
19
- return body?.success === true;
42
+ const hostname = turnstileHostname(body?.hostname);
43
+ return body?.success === true && hostname !== undefined && allowed.has(hostname) && (options.action === undefined || body.action === options.action);
20
44
  }
21
45
  catch {
22
46
  return false;
@@ -30,7 +54,10 @@ export function createSession(options) {
30
54
  const maxAge = options.maxAgeSeconds ?? 900;
31
55
  const name = 'sr_ask';
32
56
  // A separate key, so the cookie signature is never the Turnstile secret itself.
33
- const key = createHmac('sha256', options.secret).update('shoalrun-ask-session').digest();
57
+ const derivation = createHmac('sha256', options.secret).update('shoalrun-ask-session');
58
+ if (options.context !== undefined)
59
+ derivation.update('\0').update(options.context);
60
+ const key = derivation.digest();
34
61
  const sign = (expires, caller) => createHmac('sha256', key).update(`${expires}.${caller}`).digest('base64url');
35
62
  return {
36
63
  name,
@@ -83,7 +83,7 @@ export async function addAskFunction(input) {
83
83
  await mkdir(shoalrun, { recursive: true });
84
84
  const assets = path.join(input.distDirectory ?? path.dirname(fileURLToPath(import.meta.url)), '..', 'assets');
85
85
  await cp(path.join(assets, 'ask-widget.js'), path.join(shoalrun, 'ask.js'));
86
- await writeFile(path.join(shoalrun, 'chat.json'), JSON.stringify({ ...(chat.challenge ? { siteKey: chat.challenge.siteKey, ...(chat.challenge.when === 'always' ? { prewarm: true } : {}) } : {}), ...(chat.log ? { notice: chat.log.notice } : {}), ...(chat.panel ? { panel: chat.panel } : {}) }, null, 2) + '\n');
86
+ await writeFile(path.join(shoalrun, 'chat.json'), JSON.stringify({ ...(chat.challenge ? { siteKey: chat.challenge.siteKey, ...(chat.challenge.action ? { action: chat.challenge.action } : {}), ...(chat.challenge.when === 'always' ? { prewarm: true } : {}) } : {}), ...(chat.log ? { notice: chat.log.notice } : {}), ...(chat.panel ? { panel: chat.panel } : {}) }, null, 2) + '\n');
87
87
  await addPanelToPages(input.staticDirectory, basePath);
88
88
  const publications = settings.publications?.defaults;
89
89
  const origin = settings.site?.origin;
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)