@shoalrun/cli 0.1.0-alpha.19 → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +15 -5
- package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +5 -3
- package/agent-docs/SNAPSHOT.json +5 -5
- package/agent-docs/about.md +3 -3
- package/agent-docs/author/agent-overview.md +2 -2
- package/agent-docs/author/components/code-blocks.md +46 -1
- package/agent-docs/author/frontmatter-reference.md +1 -0
- package/agent-docs/author/frontmatter-reference.sections/categorize-connect-and-present-the-page.md +1 -0
- package/agent-docs/author/workspace.md +2 -2
- package/agent-docs/author/writing-style.md +9 -3
- package/agent-docs/author/writing-style.sections/adapt-this-example.md +1 -1
- package/agent-docs/author/writing-style.sections/choose-components-by-reader-need.md +2 -2
- package/agent-docs/author/writing-style.sections/evidence-and-uncertainty.md +1 -1
- package/agent-docs/author/writing-style.sections/example-audience-and-scope.md +1 -1
- package/agent-docs/author/writing-style.sections/feature-availability.md +1 -1
- package/agent-docs/author/writing-style.sections/ingredients-recipes-and-cookbooks.md +1 -1
- package/agent-docs/author/writing-style.sections/page-purpose-and-structure.md +2 -2
- package/agent-docs/author/writing-style.sections/procedures-and-examples.md +1 -1
- package/agent-docs/author/writing-style.sections/related-reading.md +1 -1
- package/agent-docs/author/writing-style.sections/release-notes.md +30 -0
- package/agent-docs/author/writing-style.sections/review-a-draft.md +1 -1
- package/agent-docs/author/writing-style.sections/reviewed-writing-examples.md +1 -1
- package/agent-docs/author/writing-style.sections/voice-and-explanations.md +1 -1
- package/agent-docs/changelog/2026-10-16-navigation-layouts.md +1 -1
- package/agent-docs/changelog/pdf-custom-languages.md +48 -0
- package/agent-docs/changelog.md +9 -1
- package/agent-docs/config/examples.md +3 -3
- package/agent-docs/config/examples.sections/feature-showcase.md +3 -3
- package/agent-docs/config/examples.sections/minimal-site.md +2 -2
- package/agent-docs/config/examples.sections/multilingual-help-center.md +2 -2
- package/agent-docs/config/examples.sections/parcel-api-documentation.md +2 -2
- package/agent-docs/config/examples.sections/related-reading.md +2 -2
- package/agent-docs/config/examples.sections/shared-company-preset.md +2 -2
- package/agent-docs/config/outputs.md +33 -12
- package/agent-docs/config/outputs.sections/exports.md +3 -3
- package/agent-docs/config/outputs.sections/metadata.md +3 -3
- package/agent-docs/config/outputs.sections/pdf-output.md +51 -0
- package/agent-docs/config/outputs.sections/related-reading.md +3 -3
- package/agent-docs/configuration.md +2 -2
- package/agent-docs/configuration.sections/build-several-sites-from-shared-content.md +1 -1
- package/agent-docs/configuration.sections/change-inherited-lists-deliberately.md +1 -1
- package/agent-docs/configuration.sections/extract-a-section-with-include.md +1 -1
- package/agent-docs/configuration.sections/inspect-and-validate-changes.md +1 -1
- package/agent-docs/configuration.sections/related-reading.md +1 -1
- package/agent-docs/configuration.sections/separate-config-inheritance-from-page-metadata.md +1 -1
- package/agent-docs/configuration.sections/share-defaults-through-extends.md +1 -1
- package/agent-docs/configuration.sections/start-with-one-working-file.md +2 -2
- package/agent-docs/configuration.sections/understand-the-three-stages.md +1 -1
- package/agent-docs/configuration.sections/understand-where-paths-point.md +1 -1
- package/agent-docs/contribute/agent-skills.md +3 -1
- package/agent-docs/documents.json +510 -251
- package/agent-docs/features.md +4 -1
- package/agent-docs/index.md +2 -2
- package/agent-docs/provenance.json +6 -6
- package/agent-docs/publish/content-export.md +2 -0
- package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +2 -0
- package/agent-docs/publish/content-export.sections/check-the-result.md +2 -0
- package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +2 -0
- package/agent-docs/publish/content-export.sections/prerequisites.md +2 -0
- package/agent-docs/publish/content-export.sections/related-reading.md +2 -0
- package/agent-docs/publish/content-export.sections/review-exported-api-material.md +2 -0
- package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +2 -0
- package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +2 -0
- package/agent-docs/publish/pdf.md +184 -0
- package/agent-docs/publish/pdf.sections/apply-print-styling.md +65 -0
- package/agent-docs/publish/pdf.sections/check-the-result.md +33 -0
- package/agent-docs/publish/pdf.sections/export-a-page-or-publication.md +61 -0
- package/agent-docs/publish/pdf.sections/html-markdown-and-agents.md +31 -0
- package/agent-docs/publish/pdf.sections/offer-browser-page-printing.md +49 -0
- package/agent-docs/publish/pdf.sections/offer-direct-downloads-through-a-node-server.md +46 -0
- package/agent-docs/publish/pdf.sections/prerequisites.md +37 -0
- package/agent-docs/publish/pdf.sections/publish-an-optional-manual-download.md +39 -0
- package/agent-docs/publish.md +2 -0
- package/agent-docs/reference/agent-skills.md +7 -3
- package/agent-docs/search-index.json +1 -1
- package/agent-docs/start.md +4 -4
- package/agent-docs/start.sections/add-the-page-to-navigation.md +2 -2
- package/agent-docs/start.sections/build-and-check-both-outputs.md +2 -2
- package/agent-docs/start.sections/build-this-site.md +2 -2
- package/agent-docs/start.sections/check-writing-style.md +2 -2
- package/agent-docs/start.sections/create-a-page.md +2 -2
- package/agent-docs/start.sections/make-it-yours.md +2 -2
- package/agent-docs/start.sections/migrating-from-github-packages.md +2 -2
- package/agent-docs/start.sections/prerequisites.md +2 -2
- package/agent-docs/start.sections/related-reading.md +2 -2
- package/agent-docs/start.sections/start-your-own-project.md +4 -4
- package/agent-docs/status.md +7 -5
- package/agent-docs/what-is-shoalrun.md +2 -2
- package/assets/copy-markdown.js +1 -1
- package/assets/print-view.js +165 -0
- package/assets/print.js +63 -0
- package/dist/artifact-bundle.d.ts +1 -0
- package/dist/artifact-bundle.js +2 -0
- package/dist/authoring-snapshot.js +1 -1
- package/dist/browser-print-assets.d.ts +9 -0
- package/dist/browser-print-assets.js +45 -0
- package/dist/compiler.d.ts +4 -2
- package/dist/compiler.js +29 -4
- package/dist/content-export-command.d.ts +77 -1
- package/dist/content-export-command.js +5 -2
- package/dist/content-export-config.d.ts +4 -1
- package/dist/content-export-config.js +10 -5
- package/dist/content-export-render.d.ts +9 -3
- package/dist/content-export-render.js +11 -7
- package/dist/content-export.d.ts +126 -0
- package/dist/content-export.js +267 -124
- package/dist/content-source.d.ts +1 -0
- package/dist/frontmatter.js +5 -2
- package/dist/highlight-languages.d.ts +9 -0
- package/dist/highlight-languages.js +52 -0
- package/dist/highlight.d.ts +4 -1
- package/dist/highlight.js +20 -5
- package/dist/output-commit.d.ts +1 -1
- package/dist/output-commit.js +4 -2
- package/dist/page-renderer.js +6 -1
- package/dist/pdf-button.d.ts +22 -0
- package/dist/pdf-button.js +55 -0
- package/dist/pdf-config.d.ts +28 -0
- package/dist/pdf-config.js +43 -0
- package/dist/pdf-http.d.ts +36 -0
- package/dist/pdf-http.js +132 -0
- package/dist/pdf-project.d.ts +6 -0
- package/dist/pdf-project.js +31 -0
- package/dist/pdf-renderer.d.ts +45 -0
- package/dist/pdf-renderer.js +208 -0
- package/dist/pdf-site.d.ts +37 -0
- package/dist/pdf-site.js +65 -0
- package/dist/pdf-stylesheet.d.ts +1 -0
- package/dist/pdf-stylesheet.js +57 -0
- package/dist/representation-tree.js +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +35 -8
- package/dist/vercel.js +6 -0
- package/examples/custom-language/README.md +32 -0
- package/examples/custom-language/content/start.mdx +28 -0
- package/examples/custom-language/docs.config.yaml +18 -0
- package/examples/custom-language/grammars/custompy.tmLanguage.json +7 -0
- package/examples/pdf/README.md +27 -0
- package/examples/pdf/content/operations.mdx +26 -0
- package/examples/pdf/content/start.mdx +28 -0
- package/examples/pdf/docs.config.yaml +29 -0
- package/examples/pdf/print.css +9 -0
- package/package.json +4 -2
- package/skills/shoalrun-author/SKILL.md +2 -0
- package/skills/shoalrun-author/references/pdf-export.md +15 -0
- package/skills/shoalrun-brand/SKILL.md +2 -0
- package/skills/shoalrun-pdf/SKILL.md +49 -0
- package/skills/shoalrun-pdf/assets/print.css +49 -0
- package/skills/shoalrun-pdf/references/print-design.md +75 -0
- package/styles/site.css +3 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const identifier = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
4
|
+
const mapping = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
export async function customHighlightLanguages(value, declaringFile, reserved, options, reservedScopes = new Set()) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return [];
|
|
8
|
+
if (!Array.isArray(value))
|
|
9
|
+
throw new Error('outputs.html.highlighting.languages must be a list');
|
|
10
|
+
const names = new Set(reserved);
|
|
11
|
+
const scopes = new Set(reservedScopes);
|
|
12
|
+
const result = [];
|
|
13
|
+
for (const [index, entry] of value.entries()) {
|
|
14
|
+
const setting = `outputs.html.highlighting.languages[${index}]`;
|
|
15
|
+
if (!mapping(entry) || Object.keys(entry).some(key => !['id', 'grammar', 'aliases'].includes(key)))
|
|
16
|
+
throw new Error(`${setting} accepts id, grammar, and aliases`);
|
|
17
|
+
if (typeof entry.id !== 'string' || !identifier.test(entry.id))
|
|
18
|
+
throw new Error(`${setting}.id must be a lowercase language ID, using letters, digits, underscores, or hyphens`);
|
|
19
|
+
const aliases = entry.aliases ?? [];
|
|
20
|
+
if (!Array.isArray(aliases) || aliases.some(alias => typeof alias !== 'string' || !identifier.test(alias)))
|
|
21
|
+
throw new Error(`${setting}.aliases must be a list of lowercase language IDs`);
|
|
22
|
+
for (const name of [entry.id, ...aliases]) {
|
|
23
|
+
if (names.has(name))
|
|
24
|
+
throw new Error(`${setting}: language ID or alias ${name} is already registered`);
|
|
25
|
+
names.add(name);
|
|
26
|
+
}
|
|
27
|
+
if (typeof entry.grammar !== 'string' || !entry.grammar || path.isAbsolute(entry.grammar) || /^[a-z][a-z0-9+.-]*:/i.test(entry.grammar) || path.extname(entry.grammar).toLowerCase() !== '.json')
|
|
28
|
+
throw new Error(`${setting}.grammar must be a relative path to a local TextMate JSON grammar`);
|
|
29
|
+
const origin = options.grammarOrigin?.(index) ?? declaringFile;
|
|
30
|
+
if (!origin)
|
|
31
|
+
throw new Error(`${setting}.grammar needs the declaring configuration file`);
|
|
32
|
+
const file = path.resolve(path.dirname(origin), entry.grammar);
|
|
33
|
+
options.onDependency?.(file);
|
|
34
|
+
const info = await stat(file).catch(() => { throw new Error(`${setting}: cannot read grammar ${file}`); });
|
|
35
|
+
if (!info.isFile() || info.size > 4 * 1024 * 1024)
|
|
36
|
+
throw new Error(`${setting}: grammar must be a regular JSON file no larger than 4 MiB: ${file}`);
|
|
37
|
+
let grammar;
|
|
38
|
+
try {
|
|
39
|
+
grammar = JSON.parse(await readFile(file, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`${setting}: invalid TextMate JSON grammar ${file}: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (!mapping(grammar) || typeof grammar.scopeName !== 'string' || !/^\S+$/.test(grammar.scopeName) || !Array.isArray(grammar.patterns))
|
|
45
|
+
throw new Error(`${setting}: grammar needs a scopeName and a patterns array: ${file}`);
|
|
46
|
+
if (scopes.has(grammar.scopeName))
|
|
47
|
+
throw new Error(`${setting}: grammar scopeName ${grammar.scopeName} is already registered`);
|
|
48
|
+
scopes.add(grammar.scopeName);
|
|
49
|
+
result.push({ file, grammar: { ...grammar, name: entry.id, aliases } });
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
package/dist/highlight.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LanguageFiles } from './highlight-languages.js';
|
|
1
2
|
export type HighlightToken = {
|
|
2
3
|
text: string;
|
|
3
4
|
color?: string;
|
|
@@ -8,7 +9,9 @@ export type CodeHighlighter = {
|
|
|
8
9
|
foreground?: string;
|
|
9
10
|
dark?: boolean;
|
|
10
11
|
dependency?: string;
|
|
12
|
+
dependencies?: string[];
|
|
13
|
+
dispose?: () => void;
|
|
11
14
|
};
|
|
12
|
-
export declare function createCodeHighlighter(config?: any, declaringFile?: string): Promise<CodeHighlighter>;
|
|
15
|
+
export declare function createCodeHighlighter(config?: any, declaringFile?: string, options?: LanguageFiles): Promise<CodeHighlighter>;
|
|
13
16
|
export declare function highlightCode(highlighter?: CodeHighlighter): (tree: any) => void;
|
|
14
17
|
export declare function highlightedExample(value: string, language: string, highlighter?: CodeHighlighter): string;
|
package/dist/highlight.js
CHANGED
|
@@ -2,16 +2,18 @@ import path from 'node:path';
|
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { unified } from 'unified';
|
|
4
4
|
import rehypeStringify from 'rehype-stringify';
|
|
5
|
+
import { customHighlightLanguages } from './highlight-languages.js';
|
|
5
6
|
const plain = { highlight: text => [{ text }] };
|
|
6
7
|
const stringify = unified().use(rehypeStringify);
|
|
7
8
|
const hex = /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i;
|
|
8
9
|
// The site theme colors tokens with CSS variables that each palette sets.
|
|
9
10
|
const siteColor = /^var\(--code-(?:foreground|token-[a-z-]+)\)$/;
|
|
10
|
-
|
|
11
|
+
const defaultLanguages = ['json', 'yaml', 'bash', 'javascript', 'typescript', 'python', 'html', 'css', 'mdx', 'markdown', 'http', 'xml', 'sql', 'go', 'java', 'ruby', 'csharp', 'php', 'rust'];
|
|
12
|
+
export async function createCodeHighlighter(config = {}, declaringFile, options = {}) {
|
|
11
13
|
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
12
14
|
throw new Error('outputs.html.highlighting must be a mapping');
|
|
13
15
|
const provider = config.provider ?? 'shiki';
|
|
14
|
-
const allowed = provider === 'shiki' ? ['provider', 'theme'] : provider === 'custom' ? ['provider', 'module'] : ['provider'];
|
|
16
|
+
const allowed = provider === 'shiki' ? ['provider', 'theme', 'languages'] : provider === 'custom' ? ['provider', 'module'] : ['provider'];
|
|
15
17
|
for (const key of Object.keys(config))
|
|
16
18
|
if (!allowed.includes(key))
|
|
17
19
|
throw new Error(`Unsupported highlighting setting ${key} for ${provider}`);
|
|
@@ -29,17 +31,30 @@ export async function createCodeHighlighter(config = {}, declaringFile) {
|
|
|
29
31
|
if (provider !== 'shiki')
|
|
30
32
|
throw new Error(`Unknown highlighting provider ${provider}`);
|
|
31
33
|
// Load only when selected. Shiki caches its build-time instance and grammars.
|
|
32
|
-
const { getSingletonHighlighter, bundledThemes, createCssVariablesTheme } = await import('shiki');
|
|
34
|
+
const { getSingletonHighlighter, createHighlighter, bundledLanguages, bundledThemes, createCssVariablesTheme } = await import('shiki');
|
|
33
35
|
const theme = config.theme ?? 'site';
|
|
34
36
|
if (typeof theme !== 'string' || (theme !== 'site' && !Object.hasOwn(bundledThemes, theme)))
|
|
35
37
|
throw new Error(`Unknown Shiki theme ${theme}; use site or a bundled Shiki theme name`);
|
|
36
|
-
const
|
|
38
|
+
const builtinGrammars = Array.isArray(config.languages) && config.languages.length ? (await Promise.all(defaultLanguages.map(async (language) => (await bundledLanguages[language]()).default))).flat() : [];
|
|
39
|
+
const custom = await customHighlightLanguages(config.languages, declaringFile, new Set([...Object.keys(bundledLanguages), 'text', 'txt', 'plain', 'plaintext', 'ansi']), options, new Set(builtinGrammars.map(grammar => grammar.scopeName)));
|
|
40
|
+
// Custom grammars belong to this compilation, never Shiki's process-wide registry.
|
|
41
|
+
const create = custom.length ? createHighlighter : getSingletonHighlighter;
|
|
42
|
+
const highlighter = await create({
|
|
37
43
|
themes: [theme === 'site' ? createCssVariablesTheme({ name: 'site', variablePrefix: '--code-' }) : theme],
|
|
38
|
-
langs: [
|
|
44
|
+
langs: [...defaultLanguages]
|
|
39
45
|
});
|
|
46
|
+
try {
|
|
47
|
+
if (custom.length)
|
|
48
|
+
await highlighter.loadLanguage(...custom.map(item => item.grammar));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
highlighter.dispose();
|
|
52
|
+
throw new Error(`Cannot load TextMate grammars ${custom.map(item => item.file).join(', ')}: ${error.message}`);
|
|
53
|
+
}
|
|
40
54
|
const languages = new Set(highlighter.getLoadedLanguages());
|
|
41
55
|
const colors = highlighter.getTheme(theme);
|
|
42
56
|
return {
|
|
57
|
+
...(custom.length ? { dependencies: custom.map(item => item.file), dispose: () => highlighter.dispose() } : {}),
|
|
43
58
|
...(theme === 'site' ? {} : { background: colors.bg, foreground: colors.fg, dark: colors.type === 'dark' }),
|
|
44
59
|
highlight(value, language) {
|
|
45
60
|
const lang = language?.toLowerCase();
|
package/dist/output-commit.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function commitArtifacts(target: string, artifacts: Map<string, string | Buffer>, prepare?: () => Promise<void>, kind?: 'site' | 'export'): Promise<void>;
|
|
1
|
+
export declare function commitArtifacts(target: string, artifacts: Map<string, string | Buffer>, prepare?: () => Promise<void>, kind?: 'site' | 'export' | 'pdf'): Promise<void>;
|
package/dist/output-commit.js
CHANGED
|
@@ -65,9 +65,11 @@ async function checkReplaceable(target, kind) {
|
|
|
65
65
|
return;
|
|
66
66
|
if (kind === 'site' && entries.includes('documents.json') && entries.includes('resource-headers.json'))
|
|
67
67
|
return;
|
|
68
|
-
if (kind === 'export' && entries.includes('manifest.json')) {
|
|
68
|
+
if ((kind === 'export' || kind === 'pdf') && entries.includes('manifest.json')) {
|
|
69
69
|
const manifest = JSON.parse(await readFile(path.join(target, 'manifest.json'), 'utf8'));
|
|
70
|
-
if (manifest.kind === 'shoalrun-content-export' && manifest.schemaVersion
|
|
70
|
+
if (kind === 'export' && manifest.kind === 'shoalrun-content-export' && [1, 2].includes(manifest.schemaVersion))
|
|
71
|
+
return;
|
|
72
|
+
if (kind === 'pdf' && manifest.kind === 'shoalrun-pdf-export' && manifest.schemaVersion === 1)
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
75
|
throw new Error(`Output directory ${target} already exists and is not an earlier Shoalrun ${kind === 'site' ? 'build (it has no documents.json and resource-headers.json)' : 'content export'}. The build replaces its output directory, so it stopped instead of deleting it. Choose a new or empty directory`);
|
package/dist/page-renderer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pdfPageAction } from './pdf-button.js';
|
|
1
2
|
import { publicationKey } from './publication-identity.js';
|
|
2
3
|
import { resolveAgentDiscovery } from './agent-discovery.js';
|
|
3
4
|
import { navigationCounts, navigationHeading } from './compiler-config.js';
|
|
@@ -60,6 +61,10 @@ export function buildHtmlPage(doc, tree, markdownUrl, siteTitle, navigation, exa
|
|
|
60
61
|
const sampleBlock = (list, fallbackCurl, idHint) => list?.length ? codeSamplesBlock(list, highlighter, esc, idHint) : fallbackCurl ? highlightedExample(fallbackCurl, 'bash', highlighter) : '';
|
|
61
62
|
const subjectLifecycleLabel = (subjectLifecycle) => ({ deprecated: 'Deprecated', 'in-development': 'In development', beta: 'Beta', experimental: 'Experimental' })[subjectLifecycle];
|
|
62
63
|
const badge = (subjectLifecycle) => subjectLifecycle ? `<span class="subject-lifecycle-badge subject-lifecycle-${subjectLifecycle}">${subjectLifecycleLabel(subjectLifecycle)}</span>` : '';
|
|
64
|
+
const pdfAction = pdfPageAction(doc, config.outputs, normalizeBasePath(config.site?.basePath));
|
|
65
|
+
const pdfLink = pdfAction ? pdfAction.mode === 'print'
|
|
66
|
+
? `<button type="button" class="formats pdf-print" data-print-page data-print-settings="${esc(sitePath(normalizeBasePath(config.site?.basePath), '/assets/print/settings.json'))}" hidden>${esc(pdfAction.label)}</button><span role="status" data-print-status></span><script type="application/json" data-print-page-meta>${JSON.stringify({ title: doc.frontmatter.title, summary: doc.frontmatter.summary, publication: doc.variant }).replaceAll('<', '\\u003c')}</script>`
|
|
67
|
+
: `<a class="formats pdf-download" href="${esc(pdfAction.href)}">${esc(pdfAction.label)}</a>` : '';
|
|
63
68
|
const readerConfig = config.readerFilters;
|
|
64
69
|
const basePath = normalizeBasePath(config.site?.basePath);
|
|
65
70
|
const agent = resolveAgentDiscovery(config.site?.agentDiscovery);
|
|
@@ -187,6 +192,6 @@ export function buildHtmlPage(doc, tree, markdownUrl, siteTitle, navigation, exa
|
|
|
187
192
|
const menu = side || sectionNavigation ? `<a class="mobile-menu" href="#${side ? 'docs-navigation' : 'docs-sections'}">Menu</a>` : '';
|
|
188
193
|
const rail = toc || panel ? `<aside class="page-rail">${doc.endpoint ? `${panel}${toc}` : `${toc}${panel}`}</aside>` : '';
|
|
189
194
|
const breadcrumbs = (shell.breadcrumbs?.length ?? 0) > 1 && !hero ? `<nav class="doc-breadcrumbs" aria-label="Breadcrumb"><ol>${shell.breadcrumbs.map((item, index) => `<li>${index === shell.breadcrumbs.length - 1 ? `<span aria-current="page">${esc(item.label)}</span>` : item.url ? `<a href="${esc(item.url)}">${esc(item.label)}</a>` : `<span>${esc(item.label)}</span>`}</li>`).join('')}</ol></nav>` : '';
|
|
190
|
-
const markup = `<!doctype html><html lang="${esc(doc.variant.language)}"${shell.themeCss || shell.paletteCss ? ` data-theme="${esc(shell.preset ?? 'shoal')}" data-palette="${esc(shell.palette ?? '')}"` : ''}${shell.masthead ? ` data-masthead="${esc(shell.masthead)}"` : ''}${layout === 'docs' ? '' : ` data-layout="${layout}"`}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="${shell.mode === 'system' ? 'light dark' : shell.mode ?? 'light'}">${shell.indexing === false || doc.frontmatter.seo?.noindex === true ? '<meta name="robots" content="noindex">' : ''}${shell.origin ? `<link rel="canonical" href="${esc(absolute(doc.route))}">` : ''}${shell.toggle ? '<script src="/assets/mode.js"></script>' : ''}<title>${esc(String(title))}</title>${description ? `<meta name="description" content="${esc(String(description))}">` : ''}${socialDescription ? `<meta property="og:description" content="${esc(socialDescription)}">` : ''}${social}<link rel="alternate" type="text/markdown" href="${esc(markdownUrl)}"><link rel="describedby" href="/llms.min.txt" title="Documentation index">${icons}${shell.preloadFont ? `<link rel="preload" href="/assets/fonts/${esc(shell.preloadFont)}" as="font" type="font/woff2" crossorigin>` : ''}<link rel="stylesheet" href="/assets/site.css">${article.includes('data-theme-preview') ? '<link rel="stylesheet" href="/assets/theme-preview.css">' : ''}${shell.themeCss ? '<link rel="stylesheet" href="/assets/theme.css" data-theme-link="theme">' : ''}${shell.paletteCss ? '<link rel="stylesheet" href="/assets/palette.css" data-theme-link="palette">' : ''}${shell.badgesCss ? '<link rel="stylesheet" href="/assets/badges.css">' : ''}${shell.themeSwitcher ? `<script src="/assets/theme-switcher.js" data-themes="${esc(shell.themeSwitcher)}"></script>` : ''}${shell.fontsCss ? '<link rel="stylesheet" href="/assets/fonts.css">' : ''}${customCss ? '<link rel="stylesheet" href="/assets/project.css">' : ''}</head><body><a class="skip" href="#main">Skip to content</a>${agent.mode === 'disclosure' ? '' : agentBlock}<header class="mast"><div class="mast-inner">${shell.home ? `<a class="${brandClass}" href="/">${brand}</a>` : `<span class="${brandClass}">${brand}</span>`}${publicationPicker(shell, esc)}${editionLabel && !shell.publicationChoices?.length && (shell.publicationAlternates?.length ?? 0) < 2 ? `<span class="edition">${esc(editionLabel)}</span>` : ''}${menu}${headerTools(shell, doc.variant, esc, readerConfig)}${agent.mode === 'disclosure' ? agentBlock : ''}</div></header>${sectionNavigation}${shortcuts}<div class="layout${layout === 'docs' ? '' : ` layout-${layout}`}${rail ? ' has-rail' : ''}${panel ? ' has-examples' : ''}${doc.endpoint ? ' api-reference' : ''}"><main id="main" class="content" tabindex="-1" data-document-type="${esc(doc.frontmatter.type)}"${readerConfig ? ` data-reader-page="${esc(readerFilterAttribute(doc.readerFilters ?? {}))}"` : ''}>${breadcrumbs}${hero ? '' : `<header class="page-head"><span class="eyebrow">${esc(typeLabel(doc.frontmatter.type))}</span>${doc.endpoint ? '' : badge(doc.frontmatter.subjectLifecycle)}<h1>${esc(doc.frontmatter.title)}${doc.endpoint ? badge(doc.frontmatter.subjectLifecycle) : ''}</h1>${pageHeader && !routeSummary ? `<p class="summary">${esc(pageHeader)}</p>` : ''}${pageDates}${endpoint}${audienceBadges}</header>`}${hero ? '' : `<div class="page-formats"
|
|
195
|
+
const markup = `<!doctype html><html lang="${esc(doc.variant.language)}"${shell.themeCss || shell.paletteCss ? ` data-theme="${esc(shell.preset ?? 'shoal')}" data-palette="${esc(shell.palette ?? '')}"` : ''}${shell.masthead ? ` data-masthead="${esc(shell.masthead)}"` : ''}${layout === 'docs' ? '' : ` data-layout="${layout}"`}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="${shell.mode === 'system' ? 'light dark' : shell.mode ?? 'light'}">${shell.indexing === false || doc.frontmatter.seo?.noindex === true ? '<meta name="robots" content="noindex">' : ''}${shell.origin ? `<link rel="canonical" href="${esc(absolute(doc.route))}">` : ''}${shell.toggle ? '<script src="/assets/mode.js"></script>' : ''}<title>${esc(String(title))}</title>${description ? `<meta name="description" content="${esc(String(description))}">` : ''}${socialDescription ? `<meta property="og:description" content="${esc(socialDescription)}">` : ''}${social}<link rel="alternate" type="text/markdown" href="${esc(markdownUrl)}"><link rel="describedby" href="/llms.min.txt" title="Documentation index">${icons}${shell.preloadFont ? `<link rel="preload" href="/assets/fonts/${esc(shell.preloadFont)}" as="font" type="font/woff2" crossorigin>` : ''}<link rel="stylesheet" href="/assets/site.css">${article.includes('data-theme-preview') ? '<link rel="stylesheet" href="/assets/theme-preview.css">' : ''}${shell.themeCss ? '<link rel="stylesheet" href="/assets/theme.css" data-theme-link="theme">' : ''}${shell.paletteCss ? '<link rel="stylesheet" href="/assets/palette.css" data-theme-link="palette">' : ''}${shell.badgesCss ? '<link rel="stylesheet" href="/assets/badges.css">' : ''}${shell.themeSwitcher ? `<script src="/assets/theme-switcher.js" data-themes="${esc(shell.themeSwitcher)}"></script>` : ''}${shell.fontsCss ? '<link rel="stylesheet" href="/assets/fonts.css">' : ''}${customCss ? '<link rel="stylesheet" href="/assets/project.css">' : ''}</head><body><a class="skip" href="#main">Skip to content</a>${agent.mode === 'disclosure' ? '' : agentBlock}<header class="mast"><div class="mast-inner">${shell.home ? `<a class="${brandClass}" href="/">${brand}</a>` : `<span class="${brandClass}">${brand}</span>`}${publicationPicker(shell, esc)}${editionLabel && !shell.publicationChoices?.length && (shell.publicationAlternates?.length ?? 0) < 2 ? `<span class="edition">${esc(editionLabel)}</span>` : ''}${menu}${headerTools(shell, doc.variant, esc, readerConfig)}${agent.mode === 'disclosure' ? agentBlock : ''}</div></header>${sectionNavigation}${shortcuts}<div class="layout${layout === 'docs' ? '' : ` layout-${layout}`}${rail ? ' has-rail' : ''}${panel ? ' has-examples' : ''}${doc.endpoint ? ' api-reference' : ''}"><main id="main" class="content" tabindex="-1" data-document-type="${esc(doc.frontmatter.type)}"${readerConfig ? ` data-reader-page="${esc(readerFilterAttribute(doc.readerFilters ?? {}))}"` : ''}>${breadcrumbs}${hero ? '' : `<header class="page-head"><span class="eyebrow">${esc(typeLabel(doc.frontmatter.type))}</span>${doc.endpoint ? '' : badge(doc.frontmatter.subjectLifecycle)}<h1>${esc(doc.frontmatter.title)}${doc.endpoint ? badge(doc.frontmatter.subjectLifecycle) : ''}</h1>${pageHeader && !routeSummary ? `<p class="summary">${esc(pageHeader)}</p>` : ''}${pageDates}${endpoint}${audienceBadges}</header>`}${hero ? (pdfLink ? `<div class="page-formats">${pdfLink}</div>` : '') : `<div class="page-formats">${pdfAction?.placement === 'before' ? pdfLink : ''}<a class="formats" data-markdown-link href="${esc(markdownUrl)}">Read as Markdown</a><button type="button" class="formats" data-copy-markdown hidden>Copy as Markdown</button><span role="status" data-copy-markdown-status></span>${pdfAction?.placement === 'after' ? pdfLink : ''}</div>`}${toc ? '<div class="mobile-outline-slot"></div>' : ''}${jumps}${readerBanner}<article>${article}</article></main>${rail}${side}</div>${footer}${analyticsBootstrap}${readerBootstrap}${pdfAction?.mode === 'print' ? '<script type="module" src="/assets/print.js"></script>' : ''}<script type="module" src="/assets/copy-markdown.js"></script><script type="module" src="/assets/navigation.js"></script><script type="module" src="/assets/reader.js"></script><script type="module" src="/assets/tabs.js"></script>${shell.search ? '<script type="module" src="/assets/search.js"></script>' : ''}${article.includes('data-tooltip') ? '<script type="module" src="/assets/tooltips.js"></script>' : ''}${article.includes('data-component-module') ? '<script type="module" src="/assets/components.js"></script>' : ''}${article.includes('data-file-tree') ? '<script type="module" src="/assets/file-tree.js"></script>' : ''}${article.includes('data-image-expand') ? '<script type="module" src="/assets/image-viewer.js"></script>' : ''}${article.includes('data-table-filter') || article.includes('data-definition-filter') ? '<script type="module" src="/assets/table-filter.js"></script>' : ''}${article.includes('data-walkthrough') ? '<script type="module" src="/assets/walkthrough.js"></script>' : ''}${article.includes('data-cookbook-chooser') ? '<script type="module" src="/assets/cookbook-chooser.js"></script>' : ''}${article.includes('data-theme-preview') ? '<script type="module" src="/assets/theme-preview.js"></script>' : ''}${panel ? '<script type="module" src="/assets/example-panel.js"></script>' : ''}${panel.includes('data-code-samples') ? '<script type="module" src="/assets/code-samples.js"></script>' : ''}</body></html>`;
|
|
191
196
|
return basePath ? markup.replace(/((?:href|src)=")\/(assets\/|llms(?:\.min)?\.txt|apple-touch-icon\.png)/g, `$1${basePath}/$2`).replace('href="/"', `href="${basePath}/"`) : markup;
|
|
192
197
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Document, Variant } from './content-source.js';
|
|
2
|
+
export type PdfButtonConfig = {
|
|
3
|
+
mode: 'print' | 'endpoint';
|
|
4
|
+
label: string;
|
|
5
|
+
endpoint: string;
|
|
6
|
+
placement: 'before' | 'after';
|
|
7
|
+
style?: string;
|
|
8
|
+
include?: string[];
|
|
9
|
+
exclude?: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare const pdfRuntimePath = "/pdf-runtime.json";
|
|
12
|
+
export declare const pdfDataPath = "/__shoalrun/pdf-data";
|
|
13
|
+
export declare function resolvePdfButton(outputs: unknown): PdfButtonConfig | undefined;
|
|
14
|
+
export declare function validatePdfButtonStyle(button: PdfButtonConfig | undefined, exports: unknown): void;
|
|
15
|
+
export declare function pdfPageKey(id: string, publication: Variant): string;
|
|
16
|
+
export declare function pdfButtonIncludes(doc: Document, button: PdfButtonConfig): boolean;
|
|
17
|
+
export declare function pdfPageAction(doc: Document, outputs: unknown, basePath?: string): {
|
|
18
|
+
mode: 'print' | 'endpoint';
|
|
19
|
+
href?: string;
|
|
20
|
+
label: string;
|
|
21
|
+
placement: 'before' | 'after';
|
|
22
|
+
} | undefined;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { configField, isConfigMapping } from './compiler-config.js';
|
|
3
|
+
import { documentKey } from './publication-identity.js';
|
|
4
|
+
import { normalizeBasePath, sitePath } from './site-path.js';
|
|
5
|
+
export const pdfRuntimePath = '/pdf-runtime.json';
|
|
6
|
+
export const pdfDataPath = '/__shoalrun/pdf-data';
|
|
7
|
+
export function resolvePdfButton(outputs) {
|
|
8
|
+
const value = configField(configField(outputs, 'html'), 'pdf');
|
|
9
|
+
if (value === undefined || value === false)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!isConfigMapping(value) || Object.keys(value).some(key => !['mode', 'label', 'endpoint', 'placement', 'style', 'include', 'exclude'].includes(key)))
|
|
12
|
+
throw new Error('outputs.html.pdf must be false or a mapping containing mode, label, endpoint, placement, style, include, and exclude');
|
|
13
|
+
const mode = value.mode ?? 'print';
|
|
14
|
+
if (mode !== 'print' && mode !== 'endpoint')
|
|
15
|
+
throw new Error('outputs.html.pdf.mode must be print or endpoint; site builds do not generate per-page PDFs');
|
|
16
|
+
const label = value.label ?? (mode === 'print' ? 'Print PDF' : 'Download PDF');
|
|
17
|
+
if (typeof label !== 'string' || !label.trim() || label.length > 128)
|
|
18
|
+
throw new Error('outputs.html.pdf.label must be 1–128 characters');
|
|
19
|
+
const endpoint = value.endpoint ?? '/__shoalrun/pdf';
|
|
20
|
+
if (typeof endpoint !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(endpoint))
|
|
21
|
+
throw new Error('outputs.html.pdf.endpoint must be a same-origin absolute path without query, fragment, or encoded separators');
|
|
22
|
+
if (mode === 'print' && value.endpoint !== undefined)
|
|
23
|
+
throw new Error('outputs.html.pdf.endpoint requires mode: endpoint');
|
|
24
|
+
const normalizedEndpoint = endpoint.toLowerCase();
|
|
25
|
+
if (['/mcp-runtime.json', '/__shoalrun/mcp-data', pdfRuntimePath, pdfDataPath, '/__shoalrun/ask', '/__shoalrun/search', '/__shoalrun/reload'].some(reserved => normalizedEndpoint === reserved || normalizedEndpoint.startsWith(`${reserved}/`) || reserved.startsWith(`${normalizedEndpoint}/`)))
|
|
26
|
+
throw new Error('outputs.html.pdf.endpoint overlaps a reserved runtime path');
|
|
27
|
+
if (value.style !== undefined && (typeof value.style !== 'string' || !/^[a-z][a-z0-9-]*$/.test(value.style)))
|
|
28
|
+
throw new Error('outputs.html.pdf.style must name an export target containing PDF styling');
|
|
29
|
+
const placement = value.placement ?? 'after';
|
|
30
|
+
if (placement !== 'before' && placement !== 'after')
|
|
31
|
+
throw new Error('outputs.html.pdf.placement must be before or after');
|
|
32
|
+
for (const key of ['include', 'exclude'])
|
|
33
|
+
if (value[key] !== undefined && (!Array.isArray(value[key]) || value[key].some(id => typeof id !== 'string' || !id.trim()) || new Set(value[key]).size !== value[key].length))
|
|
34
|
+
throw new Error(`outputs.html.pdf.${key} must contain unique document IDs`);
|
|
35
|
+
return { mode, label: label.trim(), endpoint, placement, ...(value.style === undefined ? {} : { style: value.style }), ...(value.include === undefined ? {} : { include: value.include }), ...(value.exclude === undefined ? {} : { exclude: value.exclude }) };
|
|
36
|
+
}
|
|
37
|
+
export function validatePdfButtonStyle(button, exports) {
|
|
38
|
+
if (!button?.style)
|
|
39
|
+
return;
|
|
40
|
+
const target = isConfigMapping(exports) && Object.hasOwn(exports, button.style) ? exports[button.style] : undefined;
|
|
41
|
+
if (!isConfigMapping(target) || !isConfigMapping(target.pdf))
|
|
42
|
+
throw new Error(`Unknown PDF button style ${button.style}; define exports.${button.style}.pdf`);
|
|
43
|
+
}
|
|
44
|
+
export function pdfPageKey(id, publication) {
|
|
45
|
+
return createHash('sha256').update(documentKey(id, publication)).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
export function pdfButtonIncludes(doc, button) {
|
|
48
|
+
return doc.frontmatter.pdf !== false && (!button.include || button.include.includes(doc.frontmatter.id)) && !button.exclude?.includes(doc.frontmatter.id);
|
|
49
|
+
}
|
|
50
|
+
export function pdfPageAction(doc, outputs, basePath = '') {
|
|
51
|
+
const button = resolvePdfButton(outputs);
|
|
52
|
+
if (!button || !pdfButtonIncludes(doc, button))
|
|
53
|
+
return undefined;
|
|
54
|
+
return { mode: button.mode, label: button.label, placement: button.placement, ...(button.mode === 'endpoint' ? { href: `${sitePath(normalizeBasePath(basePath), button.endpoint)}?key=${pdfPageKey(doc.frontmatter.id, doc.variant)}` } : {}) };
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PdfFont = {
|
|
2
|
+
family: string;
|
|
3
|
+
source: string;
|
|
4
|
+
weight?: string;
|
|
5
|
+
style?: 'normal' | 'italic';
|
|
6
|
+
};
|
|
7
|
+
export type PdfConfig = {
|
|
8
|
+
stylesheet?: string;
|
|
9
|
+
logo?: string;
|
|
10
|
+
subtitle?: string;
|
|
11
|
+
fonts?: PdfFont[];
|
|
12
|
+
size?: 'A4' | 'Letter' | 'Legal' | 'A3' | 'A5';
|
|
13
|
+
margins?: {
|
|
14
|
+
top?: string;
|
|
15
|
+
right?: string;
|
|
16
|
+
bottom?: string;
|
|
17
|
+
left?: string;
|
|
18
|
+
};
|
|
19
|
+
header?: string;
|
|
20
|
+
footer?: string;
|
|
21
|
+
cover?: boolean;
|
|
22
|
+
toc?: boolean;
|
|
23
|
+
bookmarks?: boolean;
|
|
24
|
+
executablePath?: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
};
|
|
27
|
+
export declare function validatePdfConfig(value: unknown): PdfConfig;
|
|
28
|
+
export declare function mergePdfConfig(base: unknown, override: unknown): PdfConfig;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isConfigMapping } from './compiler-config.js';
|
|
2
|
+
const localFile = (value) => typeof value === 'string' && value.trim() && !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(value) && !value.includes('\0');
|
|
3
|
+
export function validatePdfConfig(value) {
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return {};
|
|
6
|
+
const keys = ['stylesheet', 'logo', 'subtitle', 'fonts', 'size', 'margins', 'header', 'footer', 'cover', 'toc', 'bookmarks', 'executablePath', 'timeout'];
|
|
7
|
+
if (!isConfigMapping(value) || Object.keys(value).some(key => !keys.includes(key)))
|
|
8
|
+
throw new Error(`PDF settings accept ${keys.join(', ')}`);
|
|
9
|
+
for (const key of ['stylesheet', 'logo', 'executablePath'])
|
|
10
|
+
if (value[key] !== undefined && !localFile(value[key]))
|
|
11
|
+
throw new Error(`PDF ${key} must be a local file path`);
|
|
12
|
+
if (value.subtitle !== undefined && (typeof value.subtitle !== 'string' || value.subtitle.length > 1000))
|
|
13
|
+
throw new Error('PDF subtitle must be text of at most 1000 characters');
|
|
14
|
+
if (value.size !== undefined && !['A4', 'Letter', 'Legal', 'A3', 'A5'].includes(value.size))
|
|
15
|
+
throw new Error('PDF size must be A4, Letter, Legal, A3, or A5');
|
|
16
|
+
if (value.margins !== undefined) {
|
|
17
|
+
if (!isConfigMapping(value.margins) || Object.keys(value.margins).some(key => !['top', 'right', 'bottom', 'left'].includes(key)))
|
|
18
|
+
throw new Error('PDF margins accept top, right, bottom, left');
|
|
19
|
+
for (const margin of Object.values(value.margins))
|
|
20
|
+
if (typeof margin !== 'string' || !/^(?:\d+(?:\.\d+)?|\.\d+)(?:mm|cm|in|px)$/.test(margin) || parseFloat(margin) > 1000)
|
|
21
|
+
throw new Error('PDF margins need nonnegative dimensions with mm, cm, in, or px units');
|
|
22
|
+
}
|
|
23
|
+
for (const key of ['cover', 'toc', 'bookmarks'])
|
|
24
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean')
|
|
25
|
+
throw new Error(`PDF ${key} must be boolean`);
|
|
26
|
+
for (const key of ['header', 'footer'])
|
|
27
|
+
if (value[key] !== undefined && (typeof value[key] !== 'string' || value[key].length > 2000 || /\{(?!title\}|page\}|pages\})[^}]*\}/.test(value[key])))
|
|
28
|
+
throw new Error(`PDF ${key} accepts text and {title}, {page}, {pages} placeholders`);
|
|
29
|
+
if (value.timeout !== undefined && (!Number.isInteger(value.timeout) || Number(value.timeout) < 1000 || Number(value.timeout) > 120000))
|
|
30
|
+
throw new Error('PDF timeout must be 1000–120000 milliseconds');
|
|
31
|
+
if (value.fonts !== undefined) {
|
|
32
|
+
if (!Array.isArray(value.fonts) || value.fonts.length > 32)
|
|
33
|
+
throw new Error('PDF fonts must be an array of at most 32 local fonts');
|
|
34
|
+
for (const font of value.fonts)
|
|
35
|
+
if (!isConfigMapping(font) || Object.keys(font).some(key => !['family', 'source', 'weight', 'style'].includes(key)) || typeof font.family !== 'string' || !font.family.trim() || font.family.length > 100 || !localFile(font.source) || (font.weight !== undefined && (typeof font.weight !== 'string' || !/^(?:normal|bold|[1-9]00)$/.test(font.weight))) || (font.style !== undefined && !['normal', 'italic'].includes(font.style)))
|
|
36
|
+
throw new Error('Each PDF font needs family and local source, with optional weight and normal/italic style');
|
|
37
|
+
}
|
|
38
|
+
return structuredClone(value);
|
|
39
|
+
}
|
|
40
|
+
export function mergePdfConfig(base, override) {
|
|
41
|
+
const first = validatePdfConfig(base), second = validatePdfConfig(override);
|
|
42
|
+
return { ...first, ...second, ...(first.margins || second.margins ? { margins: { ...first.margins, ...second.margins } } : {}) };
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PreparedPdf } from './pdf-renderer.js';
|
|
2
|
+
import { type PdfConfig } from './pdf-config.js';
|
|
3
|
+
type Page = {
|
|
4
|
+
documentId: string;
|
|
5
|
+
publication: {
|
|
6
|
+
product?: string;
|
|
7
|
+
channel: string;
|
|
8
|
+
version: string;
|
|
9
|
+
language: string;
|
|
10
|
+
};
|
|
11
|
+
title: string;
|
|
12
|
+
file: string;
|
|
13
|
+
digest: string;
|
|
14
|
+
};
|
|
15
|
+
export type PdfRuntime = {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
endpoint: string;
|
|
18
|
+
config: PdfConfig;
|
|
19
|
+
pages: Record<string, Page>;
|
|
20
|
+
inventory: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
export type LoadedPdfRuntime = {
|
|
23
|
+
runtime: PdfRuntime;
|
|
24
|
+
directory: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function loadPdfRuntime(directory: string): Promise<LoadedPdfRuntime | undefined>;
|
|
27
|
+
/** Mount behind the same authentication as the built site. This handler does not grant access to other profiles. */
|
|
28
|
+
export declare function createPdfHttpHandler(input: LoadedPdfRuntime, options?: {
|
|
29
|
+
authorize?: (request: Request) => boolean | Promise<boolean>;
|
|
30
|
+
render?: (prepared: PreparedPdf, config: PdfConfig) => Promise<{
|
|
31
|
+
bytes: Buffer | Uint8Array;
|
|
32
|
+
}>;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}): (request: Request) => Promise<Response>;
|
|
35
|
+
export declare function hydratePreparedPdf(value: unknown): PreparedPdf;
|
|
36
|
+
export {};
|
package/dist/pdf-http.js
ADDED
|
@@ -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>;
|