@shoalrun/cli 0.1.0-alpha.19 → 1.0.0

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 (219) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/CODE-REFERENCES.md +137 -0
  3. package/README.md +19 -6
  4. package/adapters/python/requirements.txt +1 -0
  5. package/adapters/python/shoalrun_reference.py +234 -0
  6. package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +7 -3
  7. package/agent-docs/SNAPSHOT.json +5 -5
  8. package/agent-docs/about.md +3 -3
  9. package/agent-docs/api-docs/code-reference-settings.md +160 -0
  10. package/agent-docs/api-docs/code-reference-settings.sections/catalog-settings.md +50 -0
  11. package/agent-docs/api-docs/code-reference-settings.sections/complete-reference-page.md +46 -0
  12. package/agent-docs/api-docs/code-reference-settings.sections/converter-snapshot.md +58 -0
  13. package/agent-docs/api-docs/code-reference-settings.sections/descriptions-type-links-and-source-links.md +40 -0
  14. package/agent-docs/api-docs/code-reference-settings.sections/html-markdown-and-agents.md +38 -0
  15. package/agent-docs/api-docs/code-reference-settings.sections/metadata-and-selection.md +38 -0
  16. package/agent-docs/api-docs/code-reference-settings.sections/publication-sources.md +54 -0
  17. package/agent-docs/api-docs/code-reference-settings.sections/python-adapter-limits.md +38 -0
  18. package/agent-docs/api-docs/code-reference-settings.sections/related-reading.md +32 -0
  19. package/agent-docs/api-docs/code-reference-settings.sections/symbol-fields.md +56 -0
  20. package/agent-docs/api-docs/code-references.md +143 -0
  21. package/agent-docs/author/agent-overview.md +2 -2
  22. package/agent-docs/author/components/code-blocks.md +46 -1
  23. package/agent-docs/author/frontmatter-reference.md +1 -0
  24. package/agent-docs/author/frontmatter-reference.sections/categorize-connect-and-present-the-page.md +1 -0
  25. package/agent-docs/author/workspace.md +2 -2
  26. package/agent-docs/author/writing-style.md +9 -3
  27. package/agent-docs/author/writing-style.sections/adapt-this-example.md +1 -1
  28. package/agent-docs/author/writing-style.sections/choose-components-by-reader-need.md +2 -2
  29. package/agent-docs/author/writing-style.sections/evidence-and-uncertainty.md +1 -1
  30. package/agent-docs/author/writing-style.sections/example-audience-and-scope.md +1 -1
  31. package/agent-docs/author/writing-style.sections/feature-availability.md +1 -1
  32. package/agent-docs/author/writing-style.sections/ingredients-recipes-and-cookbooks.md +1 -1
  33. package/agent-docs/author/writing-style.sections/page-purpose-and-structure.md +2 -2
  34. package/agent-docs/author/writing-style.sections/procedures-and-examples.md +1 -1
  35. package/agent-docs/author/writing-style.sections/related-reading.md +1 -1
  36. package/agent-docs/author/writing-style.sections/release-notes.md +30 -0
  37. package/agent-docs/author/writing-style.sections/review-a-draft.md +1 -1
  38. package/agent-docs/author/writing-style.sections/reviewed-writing-examples.md +1 -1
  39. package/agent-docs/author/writing-style.sections/voice-and-explanations.md +1 -1
  40. package/agent-docs/changelog/2026-10-16-navigation-layouts.md +1 -1
  41. package/agent-docs/changelog/code-api-references.md +44 -0
  42. package/agent-docs/changelog/pdf-custom-languages.md +48 -0
  43. package/agent-docs/changelog.md +17 -1
  44. package/agent-docs/cli.md +11 -1
  45. package/agent-docs/cli.sections/audit-a-project-and-api-sources.md +1 -1
  46. package/agent-docs/cli.sections/author-pages.md +1 -1
  47. package/agent-docs/cli.sections/build-and-serve.md +1 -1
  48. package/agent-docs/cli.sections/inspect-config-and-operations.md +1 -1
  49. package/agent-docs/cli.sections/npm-scripts-in-this-repository.md +1 -1
  50. package/agent-docs/cli.sections/related-reading.md +1 -1
  51. package/agent-docs/cli.sections/search-and-chat.md +1 -1
  52. package/agent-docs/cli.sections/start-a-project.md +1 -1
  53. package/agent-docs/cli.sections/validate-code-reference-snapshots.md +41 -0
  54. package/agent-docs/cli.sections/working-tree-authoring-tools.md +1 -1
  55. package/agent-docs/config/apis.md +5 -1
  56. package/agent-docs/config/examples.md +3 -3
  57. package/agent-docs/config/examples.sections/feature-showcase.md +3 -3
  58. package/agent-docs/config/examples.sections/minimal-site.md +2 -2
  59. package/agent-docs/config/examples.sections/multilingual-help-center.md +2 -2
  60. package/agent-docs/config/examples.sections/parcel-api-documentation.md +2 -2
  61. package/agent-docs/config/examples.sections/related-reading.md +2 -2
  62. package/agent-docs/config/examples.sections/shared-company-preset.md +2 -2
  63. package/agent-docs/config/outputs.md +33 -12
  64. package/agent-docs/config/outputs.sections/exports.md +3 -3
  65. package/agent-docs/config/outputs.sections/metadata.md +3 -3
  66. package/agent-docs/config/outputs.sections/pdf-output.md +51 -0
  67. package/agent-docs/config/outputs.sections/related-reading.md +3 -3
  68. package/agent-docs/config.md +2 -2
  69. package/agent-docs/configuration.md +2 -2
  70. package/agent-docs/configuration.sections/build-several-sites-from-shared-content.md +1 -1
  71. package/agent-docs/configuration.sections/change-inherited-lists-deliberately.md +1 -1
  72. package/agent-docs/configuration.sections/extract-a-section-with-include.md +1 -1
  73. package/agent-docs/configuration.sections/inspect-and-validate-changes.md +1 -1
  74. package/agent-docs/configuration.sections/related-reading.md +1 -1
  75. package/agent-docs/configuration.sections/separate-config-inheritance-from-page-metadata.md +1 -1
  76. package/agent-docs/configuration.sections/share-defaults-through-extends.md +1 -1
  77. package/agent-docs/configuration.sections/start-with-one-working-file.md +2 -2
  78. package/agent-docs/configuration.sections/understand-the-three-stages.md +1 -1
  79. package/agent-docs/configuration.sections/understand-where-paths-point.md +1 -1
  80. package/agent-docs/contribute/agent-skills.md +3 -1
  81. package/agent-docs/documents.json +1009 -356
  82. package/agent-docs/features.md +6 -2
  83. package/agent-docs/index.md +2 -2
  84. package/agent-docs/provenance.json +6 -6
  85. package/agent-docs/publish/api-references.md +3 -1
  86. package/agent-docs/publish/api-references.sections/author-the-reference-page.md +3 -1
  87. package/agent-docs/publish/api-references.sections/build-and-verify-the-output.md +3 -1
  88. package/agent-docs/publish/api-references.sections/inspect-the-machine-readable-contract.md +3 -1
  89. package/agent-docs/publish/api-references.sections/next-steps.md +3 -1
  90. package/agent-docs/publish/api-references.sections/preview-a-small-api-reference.md +3 -1
  91. package/agent-docs/publish/api-references.sections/put-the-spec-in-your-project.md +3 -1
  92. package/agent-docs/publish/api-references.sections/register-the-operation.md +3 -1
  93. package/agent-docs/publish/api-references.sections/related-reading.md +3 -1
  94. package/agent-docs/publish/api-references.sections/review-imported-content-before-publishing.md +3 -1
  95. package/agent-docs/publish/api-references.sections/what-readers-get.md +3 -1
  96. package/agent-docs/publish/content-export.md +2 -0
  97. package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +2 -0
  98. package/agent-docs/publish/content-export.sections/check-the-result.md +2 -0
  99. package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +2 -0
  100. package/agent-docs/publish/content-export.sections/prerequisites.md +2 -0
  101. package/agent-docs/publish/content-export.sections/related-reading.md +2 -0
  102. package/agent-docs/publish/content-export.sections/review-exported-api-material.md +2 -0
  103. package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +2 -0
  104. package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +2 -0
  105. package/agent-docs/publish/pdf.md +184 -0
  106. package/agent-docs/publish/pdf.sections/apply-print-styling.md +65 -0
  107. package/agent-docs/publish/pdf.sections/check-the-result.md +33 -0
  108. package/agent-docs/publish/pdf.sections/export-a-page-or-publication.md +61 -0
  109. package/agent-docs/publish/pdf.sections/html-markdown-and-agents.md +31 -0
  110. package/agent-docs/publish/pdf.sections/offer-browser-page-printing.md +49 -0
  111. package/agent-docs/publish/pdf.sections/offer-direct-downloads-through-a-node-server.md +46 -0
  112. package/agent-docs/publish/pdf.sections/prerequisites.md +37 -0
  113. package/agent-docs/publish/pdf.sections/publish-an-optional-manual-download.md +39 -0
  114. package/agent-docs/publish.md +2 -0
  115. package/agent-docs/reference/agent-skills.md +7 -3
  116. package/agent-docs/search-index.json +1 -1
  117. package/agent-docs/start.md +4 -4
  118. package/agent-docs/start.sections/add-the-page-to-navigation.md +2 -2
  119. package/agent-docs/start.sections/build-and-check-both-outputs.md +2 -2
  120. package/agent-docs/start.sections/build-this-site.md +2 -2
  121. package/agent-docs/start.sections/check-writing-style.md +2 -2
  122. package/agent-docs/start.sections/create-a-page.md +2 -2
  123. package/agent-docs/start.sections/make-it-yours.md +2 -2
  124. package/agent-docs/start.sections/migrating-from-github-packages.md +2 -2
  125. package/agent-docs/start.sections/prerequisites.md +2 -2
  126. package/agent-docs/start.sections/related-reading.md +2 -2
  127. package/agent-docs/start.sections/start-your-own-project.md +4 -4
  128. package/agent-docs/status.md +9 -5
  129. package/agent-docs/styling/themes.md +7 -3
  130. package/agent-docs/what-is-shoalrun.md +2 -2
  131. package/assets/copy-markdown.js +1 -1
  132. package/assets/print-view.js +165 -0
  133. package/assets/print.js +63 -0
  134. package/dist/artifact-bundle.d.ts +2 -0
  135. package/dist/artifact-bundle.js +13 -3
  136. package/dist/authoring-snapshot.js +1 -1
  137. package/dist/browser-print-assets.d.ts +9 -0
  138. package/dist/browser-print-assets.js +45 -0
  139. package/dist/cli-discovery.d.ts +1 -1
  140. package/dist/cli-discovery.js +1 -0
  141. package/dist/cli.js +7 -0
  142. package/dist/code-reference-model.d.ts +85 -0
  143. package/dist/code-reference-model.js +73 -0
  144. package/dist/code-references.d.ts +23 -0
  145. package/dist/code-references.js +371 -0
  146. package/dist/compiler-config.d.ts +1 -0
  147. package/dist/compiler.d.ts +4 -2
  148. package/dist/compiler.js +33 -7
  149. package/dist/config.js +1 -1
  150. package/dist/content-export-command.d.ts +77 -1
  151. package/dist/content-export-command.js +5 -2
  152. package/dist/content-export-config.d.ts +4 -1
  153. package/dist/content-export-config.js +10 -5
  154. package/dist/content-export-render.d.ts +9 -3
  155. package/dist/content-export-render.js +11 -7
  156. package/dist/content-export.d.ts +126 -0
  157. package/dist/content-export.js +267 -124
  158. package/dist/content-source.d.ts +2 -0
  159. package/dist/content-workspace.d.ts +1 -0
  160. package/dist/content-workspace.js +2 -0
  161. package/dist/fonts.d.ts +9 -0
  162. package/dist/fonts.js +58 -3
  163. package/dist/frontmatter.js +5 -2
  164. package/dist/highlight-languages.d.ts +9 -0
  165. package/dist/highlight-languages.js +52 -0
  166. package/dist/highlight.d.ts +4 -1
  167. package/dist/highlight.js +20 -5
  168. package/dist/output-commit.d.ts +1 -1
  169. package/dist/output-commit.js +4 -2
  170. package/dist/page-materialization.js +3 -2
  171. package/dist/page-renderer.d.ts +1 -0
  172. package/dist/page-renderer.js +8 -1
  173. package/dist/pdf-button.d.ts +22 -0
  174. package/dist/pdf-button.js +55 -0
  175. package/dist/pdf-config.d.ts +28 -0
  176. package/dist/pdf-config.js +43 -0
  177. package/dist/pdf-http.d.ts +36 -0
  178. package/dist/pdf-http.js +132 -0
  179. package/dist/pdf-project.d.ts +6 -0
  180. package/dist/pdf-project.js +31 -0
  181. package/dist/pdf-renderer.d.ts +45 -0
  182. package/dist/pdf-renderer.js +208 -0
  183. package/dist/pdf-site.d.ts +37 -0
  184. package/dist/pdf-site.js +65 -0
  185. package/dist/pdf-stylesheet.d.ts +1 -0
  186. package/dist/pdf-stylesheet.js +57 -0
  187. package/dist/publication-render.d.ts +2 -1
  188. package/dist/publication-render.js +2 -2
  189. package/dist/representation-tree.js +5 -1
  190. package/dist/server.d.ts +2 -0
  191. package/dist/server.js +35 -8
  192. package/dist/vercel.js +6 -0
  193. package/examples/code-references/content/start.md +53 -0
  194. package/examples/code-references/docs.config.yaml +262 -0
  195. package/examples/code-references/python/orchard/__init__.py +11 -0
  196. package/examples/code-references/python/orchard/client.py +212 -0
  197. package/examples/code-references/python/orchard/errors.py +31 -0
  198. package/examples/code-references/python/orchard/models.py +69 -0
  199. package/examples/code-references/python/orchard/pagination.py +39 -0
  200. package/examples/code-references/reference.json +1244 -0
  201. package/examples/custom-language/README.md +32 -0
  202. package/examples/custom-language/content/start.mdx +28 -0
  203. package/examples/custom-language/docs.config.yaml +18 -0
  204. package/examples/custom-language/grammars/custompy.tmLanguage.json +7 -0
  205. package/examples/pdf/README.md +27 -0
  206. package/examples/pdf/content/operations.mdx +26 -0
  207. package/examples/pdf/content/start.mdx +28 -0
  208. package/examples/pdf/docs.config.yaml +29 -0
  209. package/examples/pdf/print.css +9 -0
  210. package/package.json +11 -2
  211. package/schemas/code-reference.schema.json +306 -0
  212. package/skills/shoalrun-author/SKILL.md +6 -0
  213. package/skills/shoalrun-author/references/code-api-references.md +15 -0
  214. package/skills/shoalrun-author/references/pdf-export.md +15 -0
  215. package/skills/shoalrun-brand/SKILL.md +2 -0
  216. package/skills/shoalrun-pdf/SKILL.md +49 -0
  217. package/skills/shoalrun-pdf/assets/print.css +49 -0
  218. package/skills/shoalrun-pdf/references/print-design.md +75 -0
  219. package/styles/site.css +64 -3
@@ -0,0 +1,132 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, realpath, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { pdfDataPath, pdfRuntimePath } from './pdf-button.js';
5
+ import { renderPreparedPdf } from './pdf-renderer.js';
6
+ import { validatePdfConfig } from './pdf-config.js';
7
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
8
+ const hex = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
9
+ const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
10
+ const maxPreparedBytes = 64 * 1024 * 1024;
11
+ const safePath = (file) => !!file && !file.startsWith('/') && !/[\\\0:]/.test(file) && !file.split('/').some(part => !part || part === '.' || part === '..');
12
+ const within = (root, file) => file.startsWith(root + path.sep);
13
+ async function boundedRead(file, max) {
14
+ const info = await stat(file);
15
+ if (!info.isFile() || info.size > max)
16
+ throw new Error('Invalid or oversized PDF runtime resource');
17
+ const bytes = await readFile(file);
18
+ if (bytes.length > max)
19
+ throw new Error('Oversized PDF runtime resource');
20
+ return bytes;
21
+ }
22
+ export async function loadPdfRuntime(directory) {
23
+ const siteRoot = await realpath(directory);
24
+ const metadata = path.join(siteRoot, pdfRuntimePath.slice(1));
25
+ let file;
26
+ try {
27
+ file = await realpath(metadata);
28
+ }
29
+ catch (error) {
30
+ if (error.code === 'ENOENT')
31
+ return;
32
+ throw error;
33
+ }
34
+ if (!within(siteRoot, file))
35
+ throw new Error('PDF runtime escapes the built site');
36
+ const runtime = JSON.parse((await boundedRead(file, 4 * 1024 * 1024)).toString('utf8'));
37
+ if (!record(runtime) || runtime.schemaVersion !== 1 || typeof runtime.endpoint !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(runtime.endpoint) || !record(runtime.pages) || !record(runtime.inventory))
38
+ throw new Error('Invalid PDF runtime metadata; rebuild the site');
39
+ runtime.config = validatePdfConfig(runtime.config);
40
+ const root = await realpath(path.join(siteRoot, pdfDataPath.slice(1)));
41
+ if (!within(siteRoot, root))
42
+ throw new Error('PDF runtime data escapes the built site');
43
+ for (const [key, page] of Object.entries(runtime.pages)) {
44
+ if (!hex(key) || !record(page) || typeof page.documentId !== 'string' || !page.documentId || typeof page.title !== 'string' || typeof page.file !== 'string' || !safePath(page.file) || !hex(page.digest) || runtime.inventory[page.file] !== page.digest || !record(page.publication) || ['channel', 'version', 'language'].some(name => typeof page.publication[name] !== 'string') || page.publication.product !== undefined && typeof page.publication.product !== 'string')
45
+ throw new Error('Invalid PDF page inventory');
46
+ }
47
+ for (const [name, hash] of Object.entries(runtime.inventory)) {
48
+ if (!safePath(name) || !hex(hash))
49
+ throw new Error('Invalid PDF inventory path');
50
+ const resource = await realpath(path.join(root, name));
51
+ if (!within(root, resource) || digest(await boundedRead(resource, maxPreparedBytes)) !== hash)
52
+ throw new Error('PDF resource integrity mismatch');
53
+ }
54
+ return { runtime, directory: root };
55
+ }
56
+ const failure = (status, message) => new Response(JSON.stringify({ error: message }), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...(status === 429 ? { 'Retry-After': '60' } : {}) } });
57
+ /** Mount behind the same authentication as the built site. This handler does not grant access to other profiles. */
58
+ export function createPdfHttpHandler(input, options = {}) {
59
+ let active = false;
60
+ let windowStart = 0;
61
+ let attempts = 0;
62
+ const now = options.now ?? Date.now;
63
+ const render = options.render ?? renderPreparedPdf;
64
+ return async (request) => {
65
+ const url = new URL(request.url);
66
+ if (url.pathname !== input.runtime.endpoint)
67
+ return failure(404, 'Unknown PDF endpoint');
68
+ if (request.method !== 'GET' && request.method !== 'HEAD')
69
+ return new Response(null, { status: 405, headers: { Allow: 'GET, HEAD', 'Cache-Control': 'no-store' } });
70
+ if (request.headers.get('sec-fetch-site') === 'cross-site' || request.headers.has('origin') && request.headers.get('origin') !== url.origin)
71
+ return failure(403, 'PDF origin is not allowed');
72
+ try {
73
+ if (options.authorize && !await options.authorize(request))
74
+ return failure(403, 'PDF access is not allowed');
75
+ }
76
+ catch {
77
+ return failure(503, 'PDF authorization is unavailable');
78
+ }
79
+ const keys = [...url.searchParams.keys()];
80
+ const key = url.searchParams.get('key');
81
+ if (keys.length !== 1 || keys[0] !== 'key' || !hex(key) || !Object.hasOwn(input.runtime.pages, key))
82
+ return failure(404, 'Unknown PDF page');
83
+ const page = input.runtime.pages[key];
84
+ if (request.method === 'HEAD')
85
+ return new Response(null, { headers: { 'Content-Type': 'application/pdf', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
86
+ const time = now();
87
+ if (time - windowStart >= 60_000) {
88
+ windowStart = time;
89
+ attempts = 0;
90
+ }
91
+ if (active || attempts >= 10)
92
+ return failure(429, 'PDF generation is busy. Try again shortly.');
93
+ attempts++;
94
+ active = true;
95
+ try {
96
+ const file = await realpath(path.join(input.directory, page.file));
97
+ if (!within(input.directory, file))
98
+ throw new Error('Invalid PDF resource');
99
+ const bytes = await boundedRead(file, maxPreparedBytes);
100
+ if (digest(bytes) !== page.digest)
101
+ throw new Error('PDF resource integrity mismatch');
102
+ const prepared = hydratePreparedPdf(JSON.parse(bytes.toString('utf8')));
103
+ const result = await render(prepared, input.runtime.config);
104
+ const filename = page.documentId.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 100) || 'page';
105
+ return new Response(new Uint8Array(result.bytes), { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${filename}.pdf"`, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
106
+ }
107
+ catch {
108
+ return failure(503, 'PDF generation failed. Check the server browser setup and rebuild the PDF inputs if needed.');
109
+ }
110
+ finally {
111
+ active = false;
112
+ }
113
+ };
114
+ }
115
+ export function hydratePreparedPdf(value) {
116
+ if (!record(value) || typeof value.html !== 'string' || Buffer.byteLength(value.html) > maxPreparedBytes || !hex(value.styleDigest) || !Array.isArray(value.assets) || value.assets.length > 10000)
117
+ throw new Error('Invalid prepared PDF');
118
+ const assets = new Map();
119
+ let total = Buffer.byteLength(value.html);
120
+ for (const asset of value.assets) {
121
+ if (!record(asset) || typeof asset.path !== 'string' || !asset.path.startsWith('/') || !safePath(asset.path.slice(1)) || assets.has(asset.path) || !['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'font/woff', 'font/woff2', 'font/ttf', 'font/otf'].includes(asset.mediaType) || typeof asset.base64 !== 'string' || /[^A-Za-z0-9+/=]/.test(asset.base64))
122
+ throw new Error('Invalid prepared PDF asset');
123
+ const bytes = Buffer.from(asset.base64, 'base64');
124
+ if (bytes.toString('base64') !== asset.base64)
125
+ throw new Error('Invalid prepared PDF asset encoding');
126
+ total += bytes.length;
127
+ if (bytes.length > 32 * 1024 * 1024 || total > maxPreparedBytes)
128
+ throw new Error('Prepared PDF assets exceed 64 MiB');
129
+ assets.set(asset.path, { bytes, mediaType: asset.mediaType });
130
+ }
131
+ return { html: value.html, assets, styleDigest: value.styleDigest };
132
+ }
@@ -0,0 +1,6 @@
1
+ import { type loadConfig } from './config.js';
2
+ /** Resolve print assets from the configuration that declares each setting. */
3
+ export declare function projectPdfSettings(loaded: Awaited<ReturnType<typeof loadConfig>>, configFile: string, targetName?: string): {
4
+ config: import("./pdf-config.js").PdfConfig;
5
+ files: string[];
6
+ };
@@ -0,0 +1,31 @@
1
+ import path from 'node:path';
2
+ import { explainSegments } from './config.js';
3
+ import { configField, isConfigMapping, resolvedCompilerConfig } from './compiler-config.js';
4
+ import { mergePdfConfig } from './pdf-config.js';
5
+ /** Resolve print assets from the configuration that declares each setting. */
6
+ export function projectPdfSettings(loaded, configFile, targetName) {
7
+ const resolved = resolvedCompilerConfig(loaded.value);
8
+ const target = targetName && isConfigMapping(resolved.exports) ? resolved.exports[targetName] : undefined;
9
+ const override = isConfigMapping(target) ? target.pdf : undefined;
10
+ const config = mergePdfConfig(configField(resolved.outputs, 'pdf'), override);
11
+ const files = [];
12
+ const owner = (field, index) => explainSegments(loaded, [
13
+ ...(targetName && isConfigMapping(override) && Object.hasOwn(override, field) ? ['exports', targetName, 'pdf'] : ['outputs', 'pdf']),
14
+ field, ...(index === undefined ? [] : [String(index), 'source'])
15
+ ]).source.file;
16
+ for (const field of ['stylesheet', 'logo'])
17
+ if (config[field]) {
18
+ config[field] = path.resolve(path.dirname(owner(field)), config[field]);
19
+ files.push(config[field]);
20
+ }
21
+ if (config.fonts)
22
+ config.fonts = config.fonts.map((font, index) => {
23
+ const source = path.resolve(path.dirname(owner('fonts', index)), font.source);
24
+ files.push(source);
25
+ return { ...font, source };
26
+ });
27
+ // The browser executable is a host tool, never an asset to publish or capture.
28
+ if (config.executablePath)
29
+ config.executablePath = path.resolve(path.dirname(owner('executablePath')), config.executablePath);
30
+ return { config, files };
31
+ }
@@ -0,0 +1,45 @@
1
+ import { type PdfConfig } from './pdf-config.js';
2
+ export type PdfDocumentInput = {
3
+ documents: Array<{
4
+ id: string;
5
+ title: string;
6
+ html: string;
7
+ }>;
8
+ title: string;
9
+ publication?: Record<string, unknown>;
10
+ assets: Map<string, string | Buffer>;
11
+ publicBase: string;
12
+ config: PdfConfig;
13
+ configFile: string;
14
+ assetRoots?: string[];
15
+ };
16
+ export type PdfRenderInput = PdfDocumentInput;
17
+ export type PreparedPdf = {
18
+ html: string;
19
+ assets: Map<string, {
20
+ bytes: Buffer;
21
+ mediaType: string;
22
+ }>;
23
+ styleDigest: string;
24
+ };
25
+ export type PdfResult = {
26
+ bytes: Buffer;
27
+ metadata: {
28
+ engine: string;
29
+ browserVersion: string;
30
+ styleDigest: string;
31
+ assets: Array<{
32
+ path: string;
33
+ digest: string;
34
+ }>;
35
+ };
36
+ };
37
+ export declare function preparePdfDocument(input: PdfDocumentInput): Promise<PreparedPdf>;
38
+ /** Resolve only synthetic print resources; never continue a browser request. */
39
+ export declare function pdfRequestResponse(prepared: PreparedPdf, address: string, type: string, navigation: boolean, method?: string): {
40
+ status: number;
41
+ contentType?: string;
42
+ body?: string | Buffer;
43
+ } | undefined;
44
+ export declare function renderPreparedPdf(prepared: PreparedPdf, settings: PdfConfig): Promise<PdfResult>;
45
+ export declare function renderPdf(input: PdfDocumentInput): Promise<PdfResult>;
@@ -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
+ }
@@ -8,7 +8,7 @@ import type { ReaderFiltersConfig } from './reader-filters.js';
8
8
  import type { CodeHighlighter } from './highlight.js';
9
9
  import type { MaterializedPage } from './page-materialization.js';
10
10
  import type { ResolvedCompilerConfig, CompilerSiteConfig, CompilerPublicationsConfig } from './compiler-config.js';
11
- export declare function renderPublicationPages({ rendered, docs, relationships, knownOperations, cardUses, publishedRelationships, cookbooks, documentRoutes, basePath, siteOrigin, colorMode, config, highlighter, editionLabel, customStyleFile, themePreset, palette, themeStyleFile, generatedPaletteCss, generatedBadgeCss, themeSwitcherData, generatedFontsCss, fontsPreload, shareFor, faviconRoute, logoRoute, modeToggle, header, footer, search, readerFilters, variants, externalDocuments, externalSiteLinks, navigationSpec, navigationEntries, navigationTagOrder, freshness }: {
11
+ export declare function renderPublicationPages({ rendered, docs, relationships, knownOperations, cardUses, publishedRelationships, cookbooks, documentRoutes, basePath, siteOrigin, colorMode, config, highlighter, editionLabel, customStyleFile, themePreset, palette, themeStyleFile, generatedPaletteCss, generatedBadgeCss, themeSwitcherData, generatedFontsCss, fontsPreload, customFontPreloads, shareFor, faviconRoute, logoRoute, modeToggle, header, footer, search, readerFilters, variants, externalDocuments, externalSiteLinks, navigationSpec, navigationEntries, navigationTagOrder, freshness }: {
12
12
  freshness?: Freshness;
13
13
  rendered: MaterializedPage[];
14
14
  docs: Map<string, Document>;
@@ -41,6 +41,7 @@ export declare function renderPublicationPages({ rendered, docs, relationships,
41
41
  themeSwitcherData?: string;
42
42
  generatedFontsCss?: string;
43
43
  fontsPreload?: string;
44
+ customFontPreloads?: import('./fonts.js').FontPreload[];
44
45
  shareFor: (doc: Document) => {
45
46
  route: string;
46
47
  generated: boolean;
@@ -12,7 +12,7 @@ import { navigationPath, navigationTab, navigationTabRecord, resolveProjectNavig
12
12
  import { outputUrl, publicTree, resolveOutputLinks } from './representation-tree.js';
13
13
  import { presetCatalog } from './palettes.js';
14
14
  const keyFor = documentKey;
15
- export function renderPublicationPages({ rendered, docs, relationships, knownOperations, cardUses, publishedRelationships, cookbooks, documentRoutes, basePath, siteOrigin, colorMode, config, highlighter, editionLabel, customStyleFile, themePreset, palette, themeStyleFile, generatedPaletteCss, generatedBadgeCss, themeSwitcherData, generatedFontsCss, fontsPreload, shareFor, faviconRoute, logoRoute, modeToggle, header, footer, search, readerFilters, variants, externalDocuments, externalSiteLinks, navigationSpec, navigationEntries, navigationTagOrder, freshness }) {
15
+ export function renderPublicationPages({ rendered, docs, relationships, knownOperations, cardUses, publishedRelationships, cookbooks, documentRoutes, basePath, siteOrigin, colorMode, config, highlighter, editionLabel, customStyleFile, themePreset, palette, themeStyleFile, generatedPaletteCss, generatedBadgeCss, themeSwitcherData, generatedFontsCss, fontsPreload, customFontPreloads, shareFor, faviconRoute, logoRoute, modeToggle, header, footer, search, readerFilters, variants, externalDocuments, externalSiteLinks, navigationSpec, navigationEntries, navigationTagOrder, freshness }) {
16
16
  const resolvedNavigation = new Map();
17
17
  const selector = config.site.publicationSelector;
18
18
  if (selector !== undefined && (!isConfigMapping(selector) || Object.keys(selector).some(key => key !== 'showLanguage') || (selector.showLanguage !== undefined && typeof selector.showLanguage !== 'boolean')))
@@ -102,7 +102,7 @@ export function renderPublicationPages({ rendered, docs, relationships, knownOpe
102
102
  const group = variant.product === undefined ? config.site.title : config.publications.products?.[variant.product]?.label ?? variant.product;
103
103
  return [{ group, label: `${group} · ${variant.channel} · ${variant.version}${showLanguage ? ` · ${variant.language}` : ''}`, url: target.route, current: samePublication(page.doc.variant, variant) }];
104
104
  }) : [];
105
- page.html = buildHtmlPage(page.doc, page.htmlTree, page.doc.markdownRoute, config.site.title, navigation, page.pageExamples, page.pageShapes, highlighter, editionLabel(page.doc.variant), !!customStyleFile, { preset: themePreset, palette: palette.name, themeCss: !!themeStyleFile, paletteCss: !!generatedPaletteCss, badgesCss: !!generatedBadgeCss, themeSwitcher: themeSwitcherData, fontsCss: !!generatedFontsCss, preloadFont: fontsPreload, masthead: presetCatalog.find(preset => preset.name === themePreset)?.masthead, share: shareFor(page.doc), favicon: faviconRoute, logo: logoRoute, touchIcon: !faviconRoute.endsWith('.png'), origin: siteOrigin, indexing: config.site.indexing !== false, home: hasHome, mode: colorMode, toggle: modeToggle, header, footer, search: search.provider !== 'none', publicationChoices, publicationAlternates, breadcrumbs }, page.pageCurl, page.pageSamples, { ...config, readerFilters });
105
+ page.html = buildHtmlPage(page.doc, page.htmlTree, page.doc.markdownRoute, config.site.title, navigation, page.pageExamples, page.pageShapes, highlighter, editionLabel(page.doc.variant), !!customStyleFile, { preset: themePreset, palette: palette.name, themeCss: !!themeStyleFile, paletteCss: !!generatedPaletteCss, badgesCss: !!generatedBadgeCss, themeSwitcher: themeSwitcherData, fontsCss: !!generatedFontsCss, preloadFont: fontsPreload, preloadFonts: customFontPreloads, masthead: presetCatalog.find(preset => preset.name === themePreset)?.masthead, share: shareFor(page.doc), favicon: faviconRoute, logo: logoRoute, touchIcon: !faviconRoute.endsWith('.png'), origin: siteOrigin, indexing: config.site.indexing !== false, home: hasHome, mode: colorMode, toggle: modeToggle, header, footer, search: search.provider !== 'none', publicationChoices, publicationAlternates, breadcrumbs }, page.pageCurl, page.pageSamples, { ...config, readerFilters });
106
106
  }
107
107
  return resolvedNavigation;
108
108
  }