@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
@@ -1,4 +1,4 @@
1
- import { documentKey, samePublication } from './publication-identity.js';
1
+ import { documentKey, publicationKey, samePublication } from './publication-identity.js';
2
2
  import { pruneFeatureTree, profileSelection } from './deployment-profile.js';
3
3
  import { readFile, realpath } from 'node:fs/promises';
4
4
  import path from 'node:path';
@@ -31,7 +31,18 @@ import { resolveAuthoredChange, authoredChangeNotice } from './authored-change.j
31
31
  import { resolveSnippetValues } from './snippets.js';
32
32
  import { isolateExportTree, markExportSource, exportHeadingInventory, preserveExportHeadingIds } from './content-export-anchors.js';
33
33
  import { selectSection } from './section-identity.js';
34
+ import { profileNavigation, resolveProjectNavigation } from './project-navigation.js';
35
+ import { createCodeHighlighter } from './highlight.js';
36
+ import { projectPdfSettings } from './pdf-project.js';
37
+ import { renderPdf } from './pdf-renderer.js';
34
38
  import { compareContentManifests } from './content-export-comparison.js';
39
+ /** Prepare passive, scoped print inputs without launching a browser or writing output. */
40
+ export async function preparePdfExport(configFile, options = {}) {
41
+ const result = await exportContent(configFile, '', { ...options, formats: ['pdf'], preparePdfOnly: true });
42
+ if (!('preparedPdf' in result) || !result.preparedPdf)
43
+ throw new Error('PDF preparation failed');
44
+ return result.preparedPdf;
45
+ }
35
46
  const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
36
47
  const keyFor = (doc) => documentKey(doc.frontmatter.id, doc.variant);
37
48
  const resourceIdentity = (item) => JSON.stringify([item.ref.kind, item.ref.kind === 'snippet' ? item.ref.id : item.ref.documentId, item.ref.kind === 'section' ? item.ref.sectionId : null, item.doc.variant.channel, item.doc.variant.version, item.doc.variant.language, ...(item.doc.variant.product === undefined ? [] : [item.doc.variant.product])]);
@@ -75,7 +86,22 @@ export async function exportContent(configFile, destination, options = {}) {
75
86
  throw new Error(`Unknown export target ${options.target}`);
76
87
  raw = config.exports[options.target];
77
88
  }
89
+ // Website buttons can reuse a target's print styling without inheriting its
90
+ // content selection, publication, asset roots, or API review approvals.
91
+ let styleTarget = raw;
92
+ const styleTargetName = options.pdfStyleTarget ?? options.target;
93
+ if (options.pdfStyleTarget) {
94
+ if (!isConfigMapping(config.exports) || !Object.hasOwn(config.exports, options.pdfStyleTarget))
95
+ throw new Error(`Unknown PDF style target ${options.pdfStyleTarget}`);
96
+ styleTarget = config.exports[options.pdfStyleTarget];
97
+ if (!isConfigMapping(styleTarget.pdf))
98
+ throw new Error(`PDF style target ${options.pdfStyleTarget} needs a pdf mapping`);
99
+ }
78
100
  const target = validateExportTarget({ ...raw, ...(options.formats ? { formats: options.formats } : {}), ...(options.documents ? { documents: options.documents } : {}), ...(options.publication ? { publication: options.publication } : {}), ...(options.publicBase ? { publicBase: options.publicBase } : {}) }, config.readerFilters);
101
+ const pdf = target.formats.includes('pdf');
102
+ if (pdf && (options.previousManifest || options.failOnRemoved))
103
+ throw new Error('PDF exports do not support content-reference manifest comparison; compare their PDF manifests separately');
104
+ const formats = pdf ? ['html'] : target.formats;
79
105
  const language = defaultLanguage(config);
80
106
  const enabled = config.publications.enabled;
81
107
  if (!Array.isArray(enabled) || !enabled.length || enabled.some(value => !isConfigMapping(value)))
@@ -88,6 +114,8 @@ export async function exportContent(configFile, destination, options = {}) {
88
114
  if (!unique.some(variant => sameVariant(variant, publication)))
89
115
  throw new Error('The selected export publication is not enabled');
90
116
  const readerFilters = validateReaderFilters(config.readerFilters);
117
+ const siteSelection = validateContentFilter(site.contentFilter, readerFilters);
118
+ const selection = target.contentFilter ?? (pdf ? siteSelection : undefined);
91
119
  if (config.metadata !== undefined && (!isConfigMapping(config.metadata) || Object.keys(config.metadata).some(key => key !== 'provider')))
92
120
  throw new Error('metadata accepts only provider');
93
121
  const providerSetting = configField(config.metadata, 'provider');
@@ -100,10 +128,10 @@ export async function exportContent(configFile, destination, options = {}) {
100
128
  const doc = [...eligible.values()].find(doc => doc.frontmatter.id === id);
101
129
  if (!doc)
102
130
  throw new Error(`Requested export document ${id} is missing, non-public, or unavailable in the selected publication`);
103
- if (!matchesContentFilter(doc.readerFilters, target.contentFilter))
131
+ if (!matchesContentFilter(doc.readerFilters, selection))
104
132
  throw new Error(`Requested export document ${id} is excluded by the export contentFilter`);
105
133
  }
106
- const docs = new Map([...eligible].filter(([, doc]) => (!(target.documents || target.resources) || ids.has(doc.frontmatter.id)) && matchesContentFilter(doc.readerFilters, target.contentFilter)).sort(([, a], [, b]) => compare(keyFor(a), keyFor(b))));
134
+ const docs = new Map([...eligible].filter(([, doc]) => (!(target.documents || target.resources) || ids.has(doc.frontmatter.id)) && matchesContentFilter(doc.readerFilters, selection)).sort(([, a], [, b]) => compare(keyFor(a), keyFor(b))));
107
135
  if (!docs.size && !target.resources?.some(item => item.kind === 'snippet'))
108
136
  throw new Error('Export selection matched no eligible pages');
109
137
  const plugins = await loadComponentPlugins(loaded, { browser: false });
@@ -196,6 +224,34 @@ export async function exportContent(configFile, destination, options = {}) {
196
224
  }
197
225
  }
198
226
  selected.sort((a, b) => compare(resourceIdentity(a), resourceIdentity(b)));
227
+ if (pdf) {
228
+ const order = [];
229
+ if (target.documents)
230
+ order.push(...target.documents);
231
+ else if (config.navigation !== undefined) {
232
+ // Validate against known identities before pruning this publication/edition.
233
+ const layout = profileNavigation(config.navigation, docs, workspace.knownDocumentIds, apiPlan.knownOperations, new Set(Object.keys(apiPlan.apiDefinitions)));
234
+ const navigation = resolveProjectNavigation(layout, publication, docs, materialized.navigationEntries.get(publicationKey(publication)) ?? [], materialized.navigationTagOrder.get(publicationKey(publication)) ?? new Map());
235
+ const byRoute = new Map([...docs.values()].map(doc => [doc.route, doc.frontmatter.id]));
236
+ const walk = (nodes) => { for (const node of nodes) {
237
+ if (node.route) {
238
+ const id = byRoute.get(node.route);
239
+ if (id && !order.includes(id))
240
+ order.push(id);
241
+ }
242
+ if (node.items)
243
+ walk(node.items);
244
+ } };
245
+ walk(navigation);
246
+ const unlisted = [...docs.values()].map(doc => doc.frontmatter.id).filter(id => !order.includes(id)).sort(compare);
247
+ if (unlisted.length)
248
+ diagnostics.push({ code: 'pdf-unlisted-pages', message: `${unlisted.length} eligible page(s) outside navigation were appended in stable document-ID order.` });
249
+ order.push(...unlisted);
250
+ }
251
+ else
252
+ order.push(...[...docs.values()].map(doc => doc.frontmatter.id).sort(compare));
253
+ selected.sort((a, b) => order.indexOf(a.doc.frontmatter.id) - order.indexOf(b.doc.frontmatter.id));
254
+ }
199
255
  const diagrams = await resolveDiagramTrees(selected.map(item => item.tree));
200
256
  const assets = new ExportAssets(target.publicBase);
201
257
  const assetRootsOrigin = options.target && target.assetRoots.length ? explainSegments(loaded, ['exports', options.target, 'assetRoots']).source.file : configFile;
@@ -218,9 +274,13 @@ export async function exportContent(configFile, destination, options = {}) {
218
274
  const unreviewed = reviews.filter(review => (target.apiReview[review.apiId] ?? configField(apiPlan.apiDefinitions[review.apiId].publicReview, 'digest')) !== review.digest);
219
275
  if (unreviewed.length && !options.review)
220
276
  throw new Error(`Export API review required: ${unreviewed.map(row => `${row.apiId}=${row.digest}`).join(', ')}. Run export with --review, inspect the candidate, and pin these digests in exports.<target>.apiReview`);
221
- const siteSelection = validateContentFilter(site.contentFilter, readerFilters);
222
277
  const website = [...eligible.values()].filter(doc => matchesContentFilter(doc.readerFilters, siteSelection));
223
278
  const canonicalDestination = (doc) => website.includes(doc) && website.filter(other => other.route.toLowerCase() === doc.route.toLowerCase()).length === 1;
279
+ const publishedAssetRoutes = new Map();
280
+ for (const route of materialized.focusedContracts.keys())
281
+ publishedAssetRoutes.set(route, route);
282
+ for (const asset of diagrams)
283
+ publishedAssetRoutes.set(asset.route, publicPath(asset.route));
224
284
  const byId = new Map([...eligible.values()].map(doc => [doc.frontmatter.id, doc]));
225
285
  const byRoute = (pathname) => {
226
286
  const matches = [...eligible.values()].filter(doc => doc.route === pathname.replace(/\/$/, '') || doc.route === pathname || doc.markdownRoute === pathname);
@@ -234,13 +294,13 @@ export async function exportContent(configFile, destination, options = {}) {
234
294
  const sourceInventories = new Map();
235
295
  const sourceAliases = new Map();
236
296
  for (const item of selected)
237
- for (const format of target.formats) {
297
+ for (const format of formats) {
238
298
  const sourceKey = `${selectedSourceKey(item)}:${format}`;
239
299
  if (!sourceInventories.has(sourceKey))
240
- sourceInventories.set(sourceKey, exportHeadingInventory(portableTree(structuredClone(item.doc.tree), format, routes, basePath)));
241
- const tree = portableTree(structuredClone(item.tree), format, routes, basePath);
300
+ sourceInventories.set(sourceKey, exportHeadingInventory(portableTree(structuredClone(item.doc.tree), format, routes, basePath, { print: pdf })));
301
+ const tree = portableTree(structuredClone(item.tree), format, routes, basePath, { print: pdf });
242
302
  if (item.ref.kind !== 'page') {
243
- const meaning = item.meaningTree ? portableTree(structuredClone(item.meaningTree), format, routes, basePath) : tree;
303
+ const meaning = item.meaningTree ? portableTree(structuredClone(item.meaningTree), format, routes, basePath, { print: pdf }) : tree;
244
304
  const body = (meaning.children ?? []).filter((node) => node.type !== 'heading' && node.type !== 'definition' && node.type !== 'footnoteDefinition');
245
305
  if (!portableMarkdown(root(body)).trim())
246
306
  throw new Error(`Resource ${resourceIdentity(item)} has no content in ${format}`);
@@ -249,7 +309,7 @@ export async function exportContent(configFile, destination, options = {}) {
249
309
  sourceAliases.set(`${resourceIdentity(item)}:${format}`, aliases);
250
310
  trees.set(`${resourceIdentity(item)}:${format}`, tree);
251
311
  // Resolve anchors independently in each representation, before URL rewriting.
252
- anchors.set(`${resourceIdentity(item)}:${format}`, passiveHtml(tree, `sr-${resourceKey(item)}`, false).anchors);
312
+ anchors.set(`${resourceIdentity(item)}:${format}`, passiveHtml(tree, `sr-${resourceKey(item)}`, false, { print: pdf }).anchors);
253
313
  }
254
314
  const canonicalAnchors = new Map();
255
315
  const verifiedCanonicalAnchors = (destination, format) => {
@@ -278,130 +338,177 @@ export async function exportContent(configFile, destination, options = {}) {
278
338
  };
279
339
  const records = [];
280
340
  const artifacts = new Map();
281
- for (const item of selected) {
282
- const doc = item.doc;
283
- const externalDependencies = new Map();
284
- const usedAssets = new Set();
285
- const representations = {};
286
- const canonicalUrl = item.ref.kind !== 'snippet' && origin && canonicalDestination(doc) ? `${origin}${doc.route}` : undefined;
287
- for (const format of target.formats) {
288
- const links = [];
289
- const tree = trees.get(`${resourceIdentity(item)}:${format}`);
290
- const resolve = async (node) => {
291
- // Native adapters can express ordinary links/images as semantic elements.
292
- const properties = node.data?.hProperties;
293
- const attr = node.data?.hName === 'a' ? 'href' : node.data?.hName === 'img' ? 'src' : undefined;
294
- if (attr && typeof properties?.[attr] === 'string') {
295
- const virtual = { type: attr === 'src' ? 'image' : 'link', url: properties[attr], children: [] };
296
- await resolve(virtual);
297
- properties[attr] = virtual.url;
298
- if (virtual.data?.hProperties?.['data-export-link'])
299
- properties['data-export-link'] = virtual.data.hProperties['data-export-link'];
300
- }
301
- if (typeof node.url === 'string' && ['link', 'image', 'definition'].includes(node.type)) {
302
- const value = node.url;
303
- const parsed = new URL(value, `https://export.invalid${doc.route === basePath ? `${doc.route}/` : doc.route}`);
304
- if (!['http:', 'https:', ...(node.type !== 'image' ? ['mailto:', 'tel:'] : [])].includes(parsed.protocol) || parsed.username || parsed.password)
305
- throw new Error(`Unsafe export URL ${value}`);
306
- let destination;
307
- if (node.type !== 'image') {
308
- destination = node.exportDocumentId ? byId.get(node.exportDocumentId) : value.startsWith('#') || value.startsWith('?') ? doc : undefined;
309
- if (!destination && parsed.origin === 'https://export.invalid') {
310
- const pathUrl = outputUrl(value, doc.route, undefined, 'html', routes, basePath);
311
- destination = byRoute(new URL(pathUrl, 'https://export.invalid').pathname);
312
- if (!destination && /\.mdx?$/.test(parsed.pathname) && !value.startsWith('/')) {
313
- const file = path.resolve(path.dirname(doc.file), decodeURIComponent(value.split(/[?#]/)[0]));
314
- destination = [...eligible.values()].find(other => path.resolve(other.file) === file);
315
- }
316
- }
341
+ const printDocuments = [];
342
+ const highlightDependencies = new Set();
343
+ const highlighting = configField(configField(config.outputs, 'html'), 'highlighting');
344
+ // PDF has a light paper background, independent of the site's screen mode.
345
+ const printHighlighting = isConfigMapping(highlighting) && highlighting.provider !== 'custom' && highlighting.provider !== 'none' ? { ...highlighting, theme: 'github-light' } : highlighting ?? { theme: 'github-light' };
346
+ const highlighter = pdf ? await createCodeHighlighter(printHighlighting, highlighting ? explainSegments(loaded, ['outputs', 'html', 'highlighting']).source.file : configFile, {
347
+ grammarOrigin: index => explainSegments(loaded, ['outputs', 'html', 'highlighting', 'languages', String(index), 'grammar']).source.file,
348
+ onDependency: file => highlightDependencies.add(file)
349
+ }) : undefined;
350
+ if (highlighter?.dependency)
351
+ highlightDependencies.add(highlighter.dependency);
352
+ for (const file of highlighter?.dependencies ?? [])
353
+ highlightDependencies.add(file);
354
+ try {
355
+ for (const item of selected) {
356
+ const doc = item.doc;
357
+ const externalDependencies = new Map();
358
+ const usedAssets = new Set();
359
+ const representations = {};
360
+ const canonicalUrl = item.ref.kind !== 'snippet' && origin && canonicalDestination(doc) ? `${origin}${doc.route}` : undefined;
361
+ for (const format of formats) {
362
+ const links = [];
363
+ const tree = trees.get(`${resourceIdentity(item)}:${format}`);
364
+ const resolve = async (node) => {
365
+ // Native adapters can express ordinary links/images as semantic elements.
366
+ const properties = node.data?.hProperties;
367
+ const attr = node.data?.hName === 'a' ? 'href' : node.data?.hName === 'img' ? 'src' : undefined;
368
+ if (attr && typeof properties?.[attr] === 'string') {
369
+ const virtual = { type: attr === 'src' ? 'image' : 'link', url: properties[attr], children: [] };
370
+ await resolve(virtual);
371
+ properties[attr] = virtual.url;
372
+ if (virtual.data?.hProperties?.['data-export-link'])
373
+ properties['data-export-link'] = virtual.data.hProperties['data-export-link'];
317
374
  }
318
- if (destination) {
319
- let href;
320
- let kind;
321
- const fragment = decodeURIComponent(parsed.hash.slice(1));
322
- const candidates = selected.filter(candidate => candidate.ref.kind !== 'snippet' && keyFor(candidate.doc) === keyFor(destination));
323
- const hasAnchor = (candidate) => sourceAliases.get(`${resourceIdentity(candidate)}:${format}`)?.has(fragment);
324
- let chosen = destination === doc && fragment && hasAnchor(item) ? item : undefined;
325
- if (!chosen && fragment) {
326
- const matching = candidates.filter(candidate => candidate.ref.kind === 'section' && hasAnchor(candidate));
327
- if (matching.length > 1)
328
- throw new Error(`Ambiguous fragment ${fragment} in ${destination.frontmatter.id}`);
329
- chosen = matching[0];
375
+ if (typeof node.url === 'string' && ['link', 'image', 'definition'].includes(node.type)) {
376
+ const value = node.url;
377
+ const parsed = new URL(value, `https://export.invalid${doc.route === basePath ? `${doc.route}/` : doc.route}`);
378
+ if (!['http:', 'https:', ...(node.type !== 'image' ? ['mailto:', 'tel:'] : [])].includes(parsed.protocol) || parsed.username || parsed.password)
379
+ throw new Error(`Unsafe export URL ${value}`);
380
+ let destination;
381
+ if (node.type !== 'image') {
382
+ destination = node.exportDocumentId ? byId.get(node.exportDocumentId) : value.startsWith('#') || value.startsWith('?') ? doc : undefined;
383
+ if (!destination && parsed.origin === 'https://export.invalid') {
384
+ const pathUrl = outputUrl(value, doc.route, undefined, 'html', routes, basePath);
385
+ destination = byRoute(new URL(pathUrl, 'https://export.invalid').pathname);
386
+ if (!destination && /\.mdx?$/.test(parsed.pathname) && !value.startsWith('/')) {
387
+ const file = path.resolve(path.dirname(doc.file), decodeURIComponent(value.split(/[?#]/)[0]));
388
+ destination = [...eligible.values()].find(other => path.resolve(other.file) === file);
389
+ }
390
+ }
330
391
  }
331
- chosen ??= candidates.find(candidate => candidate.ref.kind === 'page');
332
- // Clean Markdown has no explicit alias anchors. Prefer an actual
333
- // section resource for those references, otherwise the canonical HTML.
334
- if (chosen && format === 'markdown' && fragment && chosen.ref.kind === 'page') {
335
- const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
336
- const target = anchors.get(`${resourceIdentity(chosen)}:${format}`)?.find(entry => entry.id === targetId);
337
- if (target && !target.title)
338
- chosen = candidates.find(candidate => candidate.ref.kind === 'section' && candidate.ref.sectionId === fragment);
392
+ if (destination) {
393
+ let href;
394
+ let kind;
395
+ const fragment = decodeURIComponent(parsed.hash.slice(1));
396
+ const candidates = selected.filter(candidate => candidate.ref.kind !== 'snippet' && keyFor(candidate.doc) === keyFor(destination));
397
+ const hasAnchor = (candidate) => sourceAliases.get(`${resourceIdentity(candidate)}:${format}`)?.has(fragment);
398
+ let chosen = destination === doc && fragment && hasAnchor(item) ? item : undefined;
399
+ if (!chosen && fragment) {
400
+ const matching = candidates.filter(candidate => candidate.ref.kind === 'section' && hasAnchor(candidate));
401
+ if (matching.length > 1)
402
+ throw new Error(`Ambiguous fragment ${fragment} in ${destination.frontmatter.id}`);
403
+ chosen = matching[0];
404
+ }
405
+ chosen ??= candidates.find(candidate => candidate.ref.kind === 'page');
406
+ // Clean Markdown has no explicit alias anchors. Prefer an actual
407
+ // section resource for those references, otherwise the canonical HTML.
408
+ if (chosen && format === 'markdown' && fragment && chosen.ref.kind === 'page') {
409
+ const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
410
+ const target = anchors.get(`${resourceIdentity(chosen)}:${format}`)?.find(entry => entry.id === targetId);
411
+ if (target && !target.title)
412
+ chosen = candidates.find(candidate => candidate.ref.kind === 'section' && candidate.ref.sectionId === fragment);
413
+ }
414
+ if (chosen) {
415
+ const targetAnchors = anchors.get(`${resourceIdentity(chosen)}:${format}`);
416
+ const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
417
+ const anchor = fragment ? targetAnchors.find(entry => entry.id === targetId) : undefined;
418
+ if (fragment && !anchor)
419
+ throw new Error(`Missing export anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
420
+ // A section-resource URL is sufficient when addressing its leading
421
+ // section; clean Markdown does not publish explicit HTML anchors.
422
+ const leadingSection = chosen.ref.kind === 'section' && chosen.ref.sectionId === fragment;
423
+ const markdownAnchor = anchor?.id ?? fragment;
424
+ const suffix = parsed.search + (fragment && !(format === 'markdown' && leadingSection) ? `#${format === 'html' ? anchor.exportedId : markdownAnchor}` : '');
425
+ href = chosen === item && !parsed.search && fragment && format === 'html' ? suffix : `${target.publicBase}content/${resourceKey(chosen)}.${format === 'html' ? 'html' : 'md'}${suffix}`;
426
+ if (pdf) {
427
+ if (options.pdfLinks === 'canonical' && chosen !== item) {
428
+ if (!origin || !canonicalDestination(destination))
429
+ throw new Error(`PDF page link to ${destination.frontmatter.id} requires site.origin and an eligible canonical website route`);
430
+ if (fragment && !verifiedCanonicalAnchors(destination, format).has(fragment))
431
+ throw new Error(`Cannot verify canonical anchor ${fragment} in ${destination.frontmatter.id}; use its page URL without a fragment`);
432
+ href = `${origin}${destination.route}${parsed.search}${parsed.hash}`;
433
+ kind = 'canonical';
434
+ }
435
+ else {
436
+ if (parsed.search)
437
+ throw new Error(`PDF internal links cannot retain query parameters: ${value}. Use an explicit canonical HTTPS link instead`);
438
+ href = `#${fragment ? anchor.exportedId : `pdf-document-${options.pdfLinks === 'canonical' ? 1 : selected.indexOf(chosen) + 1}`}`;
439
+ kind = 'resource';
440
+ }
441
+ }
442
+ else
443
+ kind = 'resource';
444
+ }
445
+ else {
446
+ if (!origin || !canonicalDestination(destination))
447
+ throw new Error(`Document ${destination.frontmatter.id} is outside the export and has no eligible canonical website fallback`);
448
+ if (fragment) {
449
+ const inventory = sourceInventories.get(`${sourceKeyFor(destination)}:${format}`);
450
+ if (!inventory)
451
+ throw new Error(`Cannot validate canonical anchor ${fragment} in unselected document ${destination.frontmatter.id}; select that page or section explicitly`);
452
+ if (!inventory.ids.has(fragment))
453
+ throw new Error(`Missing source anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
454
+ if (!verifiedCanonicalAnchors(destination, format).has(fragment))
455
+ throw new Error(`Cannot verify canonical anchor ${fragment} in ${destination.frontmatter.id}; select its target resource explicitly`);
456
+ }
457
+ href = `${origin}${destination.route}${parsed.search}${parsed.hash}`;
458
+ kind = 'canonical';
459
+ }
460
+ const key = `link-${links.length + 1}`;
461
+ links.push({ key, kind, href, ...(chosen ? { ref: chosen.ref } : {}), ...(fragment ? { fragment } : {}), ...(parsed.search ? { query: parsed.search.slice(1) } : {}) });
462
+ node.url = href;
463
+ if (format === 'html' && node.type === 'link')
464
+ node.data = { ...node.data, hProperties: { ...node.data?.hProperties, 'data-export-link': key } };
339
465
  }
340
- if (chosen) {
341
- const targetAnchors = anchors.get(`${resourceIdentity(chosen)}:${format}`);
342
- const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
343
- const anchor = fragment ? targetAnchors.find(entry => entry.id === targetId) : undefined;
344
- if (fragment && !anchor)
345
- throw new Error(`Missing export anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
346
- // A section-resource URL is sufficient when addressing its leading
347
- // section; clean Markdown does not publish explicit HTML anchors.
348
- const leadingSection = chosen.ref.kind === 'section' && chosen.ref.sectionId === fragment;
349
- const markdownAnchor = anchor?.id ?? fragment;
350
- const suffix = parsed.search + (fragment && !(format === 'markdown' && leadingSection) ? `#${format === 'html' ? anchor.exportedId : markdownAnchor}` : '');
351
- href = chosen === item && !parsed.search && fragment && format === 'html' ? suffix : `${target.publicBase}content/${resourceKey(chosen)}.${format === 'html' ? 'html' : 'md'}${suffix}`;
352
- kind = 'resource';
466
+ else if (parsed.origin !== 'https://export.invalid') {
467
+ node.url = value.startsWith('//') ? `https:${value}` : value;
468
+ if (node.type === 'image') {
469
+ if (pdf)
470
+ throw new Error(`PDF images must be local staged assets: ${node.url}`);
471
+ externalDependencies.set(node.url, { url: node.url, kind: 'image' });
472
+ }
473
+ else
474
+ links.push({ key: `link-${links.length + 1}`, kind: 'external', href: node.url });
353
475
  }
354
476
  else {
355
- if (!origin || !canonicalDestination(destination))
356
- throw new Error(`Document ${destination.frontmatter.id} is outside the export and has no eligible canonical website fallback`);
357
- if (fragment) {
358
- const inventory = sourceInventories.get(`${sourceKeyFor(destination)}:${format}`);
359
- if (!inventory)
360
- throw new Error(`Cannot validate canonical anchor ${fragment} in unselected document ${destination.frontmatter.id}; select that page or section explicitly`);
361
- if (!inventory.ids.has(fragment))
362
- throw new Error(`Missing source anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
363
- if (!verifiedCanonicalAnchors(destination, format).has(fragment))
364
- throw new Error(`Cannot verify canonical anchor ${fragment} in ${destination.frontmatter.id}; select its target resource explicitly`);
477
+ const asset = await assets.resolve(value, doc.file, selectedSourceKey(item));
478
+ if (!asset)
479
+ throw new Error(`Unresolved local export ${node.type === 'image' ? 'image' : 'link'} ${value} in ${doc.frontmatter.id}`);
480
+ usedAssets.add(asset.path);
481
+ node.url = `${target.publicBase}${asset.path}${parsed.search}${parsed.hash}`;
482
+ if (node.type !== 'image') {
483
+ if (pdf) {
484
+ if (!origin || !canonicalDestination(doc) || !publishedAssetRoutes.has(parsed.pathname))
485
+ throw new Error(`PDF cannot embed local download link ${value}; use an explicit canonical HTTPS download URL instead`);
486
+ node.url = `${origin}${publishedAssetRoutes.get(parsed.pathname)}${parsed.search}${parsed.hash}`;
487
+ }
488
+ links.push({ key: `link-${links.length + 1}`, kind: 'asset', href: node.url, asset: asset.path });
365
489
  }
366
- href = `${origin}${destination.route}${parsed.search}${parsed.hash}`;
367
- kind = 'canonical';
368
490
  }
369
- const key = `link-${links.length + 1}`;
370
- links.push({ key, kind, href, ...(chosen ? { ref: chosen.ref } : {}), ...(fragment ? { fragment } : {}), ...(parsed.search ? { query: parsed.search.slice(1) } : {}) });
371
- node.url = href;
372
- if (format === 'html' && node.type === 'link')
373
- node.data = { ...node.data, hProperties: { ...node.data?.hProperties, 'data-export-link': key } };
374
- }
375
- else if (parsed.origin !== 'https://export.invalid') {
376
- node.url = value.startsWith('//') ? `https:${value}` : value;
377
- if (node.type === 'image')
378
- externalDependencies.set(node.url, { url: node.url, kind: 'image' });
379
- else
380
- links.push({ key: `link-${links.length + 1}`, kind: 'external', href: node.url });
381
- }
382
- else {
383
- const asset = await assets.resolve(value, doc.file, selectedSourceKey(item));
384
- if (!asset)
385
- throw new Error(`Unresolved local export ${node.type === 'image' ? 'image' : 'link'} ${value} in ${doc.frontmatter.id}`);
386
- usedAssets.add(asset.path);
387
- node.url = `${target.publicBase}${asset.path}${parsed.search}${parsed.hash}`;
388
- if (node.type !== 'image')
389
- links.push({ key: `link-${links.length + 1}`, kind: 'asset', href: node.url, asset: asset.path });
390
491
  }
391
- }
392
- for (const child of node.children ?? [])
393
- await resolve(child);
394
- };
395
- await resolve(tree);
396
- const filename = `content/${resourceKey(item)}.${format === 'html' ? 'html' : 'md'}`;
397
- const rendered = format === 'html' ? passiveHtml(tree, `sr-${resourceKey(item)}`) : undefined;
398
- const content = rendered?.html ?? portableMarkdown(tree);
399
- artifacts.set(filename, content);
400
- representations[format] = { path: filename, mediaType: format === 'html' ? 'text/html' : 'text/markdown', bytes: Buffer.byteLength(content), digest: exportDigest(content), anchors: rendered?.anchors ?? [], links };
492
+ for (const child of node.children ?? [])
493
+ await resolve(child);
494
+ };
495
+ await resolve(tree);
496
+ const filename = `content/${resourceKey(item)}.${format === 'html' ? 'html' : 'md'}`;
497
+ const rendered = format === 'html' ? passiveHtml(tree, `sr-${resourceKey(item)}`, !pdf, { print: pdf, highlighter }) : undefined;
498
+ const content = rendered?.html ?? portableMarkdown(tree);
499
+ if (pdf)
500
+ printDocuments.push({ id: doc.frontmatter.id, title: item.title, html: content });
501
+ else
502
+ artifacts.set(filename, content);
503
+ representations[format] = { path: filename, mediaType: format === 'html' ? 'text/html' : 'text/markdown', bytes: Buffer.byteLength(content), digest: exportDigest(content), anchors: rendered?.anchors ?? [], links };
504
+ }
505
+ if (externalDependencies.size)
506
+ diagnostics.push({ code: 'external-render-dependencies', documentId: doc.frontmatter.id, message: `${externalDependencies.size} remote image(s) remain external; this document is not offline-complete.` });
507
+ records.push({ ref: item.ref, publication, title: item.title, summary: item.summary, type: item.type, ...(item.source ? { source: item.source } : {}), ...(canonicalUrl ? { canonicalUrl } : {}), representations, assets: [...usedAssets].sort(), externalDependencies: [...externalDependencies.values()].sort((a, b) => compare(a.url, b.url)) });
401
508
  }
402
- if (externalDependencies.size)
403
- diagnostics.push({ code: 'external-render-dependencies', documentId: doc.frontmatter.id, message: `${externalDependencies.size} remote image(s) remain external; this document is not offline-complete.` });
404
- records.push({ ref: item.ref, publication, title: item.title, summary: item.summary, type: item.type, ...(item.source ? { source: item.source } : {}), ...(canonicalUrl ? { canonicalUrl } : {}), representations, assets: [...usedAssets].sort(), externalDependencies: [...externalDependencies.values()].sort((a, b) => compare(a.url, b.url)) });
509
+ }
510
+ finally {
511
+ highlighter?.dispose?.();
405
512
  }
406
513
  for (const [filename, bytes] of assets.artifacts)
407
514
  artifacts.set(filename, bytes);
@@ -432,6 +539,42 @@ export async function exportContent(configFile, destination, options = {}) {
432
539
  notices += `\n${path.basename(file)}:\n${await readFile(resolvedNotice, 'utf8')}\n`;
433
540
  }
434
541
  artifacts.set('NOTICES.txt', notices);
542
+ if (pdf) {
543
+ const singlePage = target.documents?.length === 1;
544
+ const standalone = singlePage || options.pdfLinks === 'canonical';
545
+ const projectPdf = projectPdfSettings(loaded, configFile, styleTargetName);
546
+ const pdfConfig = { cover: !standalone, toc: !standalone, ...projectPdf.config };
547
+ const pdfInputs = projectPdf.files;
548
+ const inputs = [...noticeFiles, ...loaded.dependencies, ...metadataDependencies, ...plugins.dependencies, ...apiPlan.api.dependencies, ...[...eligible.values()].map(doc => doc.file), ...[...snippets.values()].map(snippet => snippet.file), ...highlightDependencies, ...pdfInputs];
549
+ const manifest = {
550
+ schemaVersion: 1, kind: 'shoalrun-pdf-export', publication, title: site.title,
551
+ review: { status: unreviewed.length ? 'required' : 'ok', apis: reviews.map(row => ({ apiId: row.apiId, digest: row.digest })) },
552
+ documents: records.map((record, index) => ({ id: selected[index].doc.frontmatter.id, title: record.title, contentDigest: exportDigest(printDocuments[index].html), anchors: record.representations.html.anchors, links: record.representations.html.links })), diagnostics
553
+ };
554
+ const input = { documents: printDocuments, title: selected.length === 1 ? selected[0].title : site.title, publication, assets: assets.artifacts, publicBase: target.publicBase, config: pdfConfig, configFile, assetRoots: [...roots, ...pdfInputs.map(file => path.dirname(file))] };
555
+ const preparedPdf = { input, manifest, inputs };
556
+ const common = { status: unreviewed.length ? 'needs-review' : 'ok', output: path.resolve(destination || base), publication, formats: target.formats, profileSelection: profileSelection(loaded.deployment), resources: records.length, documents: records.length, documentIds: selected.map(item => item.doc.frontmatter.id), comparison: { status: 'no-baseline', added: [], removed: [], diagnostics: [] }, assets: assets.records.size, apiReview: manifest.review.apis, diagnostics };
557
+ if (options.preparePdfOnly)
558
+ return { ...common, digest: exportDigest(JSON.stringify(manifest)), preparedPdf };
559
+ checkOutputDirectory(destination, base, workspace.contentDir, inputs);
560
+ // Style/font/logo files are protected by the explicit input list. Their
561
+ // parent directory can be the project root and isn't a content directory.
562
+ for (const directory of roots)
563
+ checkOutputDirectory(destination, base, directory, inputs);
564
+ if (unreviewed.length) {
565
+ const prior = await readFile(path.join(destination, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => undefined);
566
+ if (prior?.review?.status === 'ok')
567
+ throw new Error('Review candidate cannot overwrite an approved export; choose a separate review destination');
568
+ }
569
+ const rendered = await renderPdf(input);
570
+ const filename = singlePage ? 'page.pdf' : 'publication.pdf';
571
+ const pdfArtifacts = new Map([[filename, rendered.bytes], ['NOTICES.txt', notices]]);
572
+ const payload = { ...manifest, renderer: rendered.metadata, files: [...pdfArtifacts].map(([file, bytes]) => ({ path: file, bytes: Buffer.byteLength(bytes), digest: exportDigest(bytes) })) };
573
+ const digest = exportDigest(JSON.stringify(payload));
574
+ pdfArtifacts.set('manifest.json', JSON.stringify({ ...payload, digest }, null, 2) + '\n');
575
+ await commitArtifacts(path.resolve(destination), pdfArtifacts, undefined, 'pdf');
576
+ return { ...common, digest, pdf: filename };
577
+ }
435
578
  const manifest = { schemaVersion: publication.product === undefined ? 1 : 2, kind: 'shoalrun-content-export', publicBase: target.publicBase, publication,
436
579
  review: { status: unreviewed.length ? 'required' : 'ok', apis: reviews.map(row => ({ apiId: row.apiId, digest: row.digest })) },
437
580
  resources: records, assets: [...assets.records.values()].sort((a, b) => compare(a.path, b.path)),
@@ -53,6 +53,7 @@ export type Frontmatter = {
53
53
  image?: string;
54
54
  showNavigationTabs?: boolean;
55
55
  showVerified?: boolean;
56
+ pdf?: boolean;
56
57
  [key: string]: unknown;
57
58
  };
58
59
  export type Document = {
@@ -4,7 +4,7 @@ import { validateCardMetadata } from './cards.js';
4
4
  import { resolveTaxonomy, validateTaxonomy, taxonomySchema } from './taxonomy.js';
5
5
  import { validateStandaloneSectionDeclarations } from './section-identity.js';
6
6
  export const nativePublicFields = new Set(['id', 'title', 'type', 'summary', 'description', 'language', 'locale', 'date', 'updated', 'verified', 'releaseVersion', 'availability', 'product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'audiences', 'readerFilters', 'tags', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle']);
7
- export const protectedMetadataRoots = new Set(['shared', 'requires', 'standaloneSections', 'access', 'seo', 'markdownMetadata', 'variables', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'image', 'private', 'internal', 'card', 'displayText', 'publications']);
7
+ export const protectedMetadataRoots = new Set(['shared', 'requires', 'standaloneSections', 'access', 'seo', 'markdownMetadata', 'variables', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'pdf', 'image', 'private', 'internal', 'card', 'displayText', 'publications']);
8
8
  const fieldTypes = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array']);
9
9
  const safePath = (value) => /^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(value);
10
10
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
@@ -118,6 +118,8 @@ export function validateFrontmatter(frontmatter, config, file, source) {
118
118
  throw new Error(`${file}: shared must be true; use requires for restricted content`);
119
119
  if (frontmatter.shared === true && frontmatter.requires !== undefined)
120
120
  throw new Error(`${file}: declare shared: true or requires, not both`);
121
+ if (frontmatter.pdf !== undefined && typeof frontmatter.pdf !== 'boolean')
122
+ throw new Error(`${file}: pdf must be true or false`);
121
123
  validateCardMetadata(frontmatter.card, file);
122
124
  validateSubjectDeprecation(frontmatter.subjectDeprecation, file);
123
125
  validateAuthoredChange(frontmatter, file);
@@ -259,6 +261,7 @@ export function frontmatterSchema(config) {
259
261
  lifecycle: { type: 'string', minLength: 1, description: 'Editorial lifecycle of the document; validated by an opted-in external vocabulary.' },
260
262
  standaloneSections: { type: 'array', items: { oneOf: [{ type: 'string', minLength: 1 }, { type: 'object', additionalProperties: false, required: ['id', 'context'], properties: { id: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,511}$' }, context: { const: 'section' } } }] }, uniqueItems: true, description: 'Page-specific standalone approval: a heading title approves contextual discovery with the page introduction; {id, context: section} approves the root level-two section slice for content export. IDs may be derived or explicitly anchored. Omitted means full-page reads.' },
261
263
  tocDepth: { type: 'integer', enum: [0, 2, 3, 4, 5, 6], description: 'Deepest heading shown in the page table of contents; 0 hides it. Defaults to 3.' },
264
+ pdf: { type: 'boolean', description: 'Controls the optional page print/PDF action. False suppresses its control and endpoint inputs; true follows the site setting. Site builds do not generate per-page PDFs.' },
262
265
  showVerified: { type: 'boolean', description: 'Show an authored verified date in the HTML page header. Defaults to true. False hides it only in HTML; Markdown and manifest dates remain unchanged. Does not assign a verification date.' },
263
266
  showNavigationTabs: { type: 'boolean', description: 'Show the HTML navigation section bar when tabs are configured. Defaults to true for docs and wide layouts, false for landing and splash. Does not change agent discovery or section membership.' },
264
267
  image: { type: 'string', pattern: '\\.(?:png|jpe?g)$', description: 'Local .png or .jpg shown in link previews for this page, relative to the page file; replaces the generated share image. Best at 1200×630.' },
@@ -332,7 +335,7 @@ export function frontmatterReference(config, sectionsOnly = false) {
332
335
  'Language is the content language and selects a publication. Locale describes formatting conventions in the content and does not select a publication. `frontmatter.locales` can set a project locale for each language; pages may override it.', '',
333
336
  '## Categorize, connect, and present the page', '',
334
337
  'By default, `product`, `area`, and `subarea` are optional, nonempty string categories. They can come from project defaults and appear in the document manifest and search index. A matching configured reader facet makes each category a page filter; without that facet it remains taxonomy only. `operations` links a guide or reference to stable API operation IDs. On a guide or how-to page, `relatedApiReferences` names API reference page IDs to add cross links in both directions within the same publication. Tags help document collections find the page. Card settings change its preview when another page links to it; they do not change the destination. `subjectLifecycle` displays the status of the documented API or feature; `lifecycle` is available for the document\'s editorial state. `tocDepth` limits the HTML page table of contents; use 0 to hide it. `layout` picks the HTML page layout: `docs`, `wide`, `landing`, or `splash`. `showNavigationTabs` overrides the navigation tab bar for this page; landing and splash pages hide it by default. `image` replaces the generated link-preview image. `date` and `releaseVersion` can identify a changelog entry under the project\'s `frontmatter.changelogIdentifier` rule.', '',
335
- ...table, ...nativeRows(['availability', 'product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'tags', 'date', 'releaseVersion', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle', 'card', 'standaloneSections', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'image']), '',
338
+ ...table, ...nativeRows(['availability', 'product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'tags', 'date', 'releaseVersion', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle', 'card', 'standaloneSections', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'pdf', 'image']), '',
336
339
  'Projects can opt into built-in category enforcement with `frontmatter.taxonomy`. It declares areas and their allowed subareas, optional required fields, and optional explicit-field requirements for authored pages. Its `multiple` list permits arrays for selected category fields; otherwise each field allows one value. Defaults and enforced values still apply; generated API references use resolved categories. Definitions appear in the document manifest and constrain the generated frontmatter schema. Omit taxonomy or set it to false to preserve optional category behavior.', '',
337
340
  'With `profiles.requireClassification: true`, every public source page in the shared enabled publications needs an explicit `shared: true` or `requires` declaration before customer selection. Defaults do not count. Shared pages may contain feature-specific sections wrapped in `<ForFeature>`; unwrapped text remains shared.', '',
338
341
  '## Control output details', '',
@@ -0,0 +1,9 @@
1
+ import type { LanguageRegistration } from 'shiki';
2
+ export type LanguageFiles = {
3
+ grammarOrigin?: (index: number) => string;
4
+ onDependency?: (file: string) => void;
5
+ };
6
+ export declare function customHighlightLanguages(value: unknown, declaringFile: string | undefined, reserved: ReadonlySet<string>, options: LanguageFiles, reservedScopes?: ReadonlySet<string>): Promise<{
7
+ file: string;
8
+ grammar: LanguageRegistration;
9
+ }[]>;