@shoalrun/cli 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +55 -0
- package/README.md +45 -5
- package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +3 -1
- package/agent-docs/SNAPSHOT.json +5 -5
- package/agent-docs/api-docs/support.md +11 -10
- package/agent-docs/author/components.md +13 -1
- package/agent-docs/author/frontmatter-reference.md +28 -25
- package/agent-docs/author/frontmatter-reference.sections/choose-a-publication-and-visibility.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/control-output-details.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/global-defaults-and-enforced-values.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/html-markdown-and-agents.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/identify-and-route-a-page.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/point-agents-to-it.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/project-defined-fields.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/related-reading.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/use-the-local-files-while-authoring.md +3 -1
- package/agent-docs/author/frontmatter-reference.sections/when-the-files-update.md +3 -1
- package/agent-docs/author/frontmatter.md +5 -2
- package/agent-docs/author/frontmatter.sections/audience-badges.md +1 -1
- package/agent-docs/author/frontmatter.sections/give-deprecation-guidance.md +1 -1
- package/agent-docs/author/frontmatter.sections/global-defaults-and-enforced-values.md +1 -1
- package/agent-docs/author/frontmatter.sections/optional-native-fields.md +5 -2
- package/agent-docs/author/frontmatter.sections/publication-fields.md +1 -1
- package/agent-docs/author/frontmatter.sections/publishing-policy.md +1 -1
- package/agent-docs/author/frontmatter.sections/related-metadata-tasks.md +1 -1
- package/agent-docs/author/frontmatter.sections/related-reading.md +1 -1
- package/agent-docs/author/frontmatter.sections/required-after-inheritance.md +1 -1
- package/agent-docs/author/frontmatter.sections/seo-fields.md +1 -1
- package/agent-docs/author/frontmatter.sections/validation-rules.md +1 -1
- package/agent-docs/author/markdown-metadata.md +13 -5
- package/agent-docs/author/navigation.md +12 -1
- package/agent-docs/author/navigation.sections/arrange-authored-pages.md +1 -1
- package/agent-docs/author/navigation.sections/fill-an-api-tag-group.md +1 -1
- package/agent-docs/author/navigation.sections/hide-group-page-counts.md +12 -1
- package/agent-docs/author/navigation.sections/html-markdown-and-agents.md +1 -1
- package/agent-docs/author/navigation.sections/local-api-preview.md +1 -1
- package/agent-docs/author/navigation.sections/mix-guides-and-api-references.md +1 -1
- package/agent-docs/author/navigation.sections/optionally-split-a-large-menu-into-files.md +1 -1
- package/agent-docs/author/navigation.sections/reach-navigation-while-reading.md +1 -1
- package/agent-docs/author/navigation.sections/related-reading.md +1 -1
- package/agent-docs/author/navigation.sections/split-navigation-into-tabs.md +1 -1
- package/agent-docs/author/navigation.sections/subject-lifecycle-labels.md +1 -1
- package/agent-docs/author/templates.md +10 -2
- package/agent-docs/author/templates.sections/built-in-feature-templates.md +1 -1
- package/agent-docs/author/templates.sections/built-in-templates.md +1 -1
- package/agent-docs/author/templates.sections/choose-formatting-enforcement-for-your-project.md +1 -1
- package/agent-docs/author/templates.sections/create-or-edit-a-project-template.md +10 -2
- package/agent-docs/author/templates.sections/keep-the-four-shapes-distinct.md +1 -1
- package/agent-docs/author/templates.sections/project-added-templates.md +1 -1
- package/agent-docs/author/templates.sections/related-reading.md +1 -1
- package/agent-docs/author/templates.sections/scaffold-a-page.md +1 -1
- package/agent-docs/author/templates.sections/short-cli-and-derived-values.md +1 -1
- package/agent-docs/author/templates.sections/what-every-template-gives-you.md +1 -1
- package/agent-docs/author/workspace.md +7 -1
- package/agent-docs/author/writing-style.md +13 -1
- 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 +1 -1
- 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 +36 -0
- 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 +1 -1
- 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/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-28.md +89 -0
- package/agent-docs/changelog.md +9 -1
- package/agent-docs/config/outputs.md +15 -11
- package/agent-docs/config/outputs.sections/exports.md +14 -11
- package/agent-docs/config/outputs.sections/metadata.md +1 -1
- package/agent-docs/config/outputs.sections/related-reading.md +1 -1
- package/agent-docs/content-delivery/agents.md +86 -0
- package/agent-docs/content-delivery/api.md +130 -0
- package/agent-docs/content-delivery/client.md +79 -0
- package/agent-docs/content-delivery/http-api/operations/get-content-representation-fa1d2e3f.md +394 -0
- package/agent-docs/content-delivery/http-api/operations/get-content-representation-head-4052375c.md +256 -0
- package/agent-docs/content-delivery/http-api/operations/get-content-resource-849f1c49.md +911 -0
- package/agent-docs/content-delivery/http-api/operations/get-content-resource-head-0a352acd.md +246 -0
- package/agent-docs/content-delivery/http-api/operations/list-content-resources-8e977ec7.md +913 -0
- package/agent-docs/content-delivery/http-api/operations/list-content-resources-head-b67cbad9.md +236 -0
- package/agent-docs/content-delivery/http-api.md +78 -0
- package/agent-docs/content-delivery/manifest.md +79 -0
- package/agent-docs/content-delivery/publish.md +72 -0
- package/agent-docs/content-delivery/render.md +73 -0
- package/agent-docs/content-delivery/rest.md +108 -0
- package/agent-docs/content-delivery/sections.md +110 -0
- package/agent-docs/content-delivery/select.md +100 -0
- package/agent-docs/content-delivery.md +79 -0
- package/agent-docs/documents.json +6748 -5073
- package/agent-docs/provenance.json +4 -4
- package/agent-docs/publish/content-export.md +32 -16
- package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +3 -3
- package/agent-docs/publish/content-export.sections/check-the-result.md +2 -2
- package/agent-docs/publish/content-export.sections/export-selected-pages.md +12 -9
- package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +5 -3
- package/agent-docs/publish/content-export.sections/prerequisites.md +3 -3
- package/agent-docs/publish/content-export.sections/related-reading.md +2 -2
- package/agent-docs/publish/content-export.sections/review-exported-api-material.md +3 -3
- package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +3 -3
- package/agent-docs/publish/content-export.sections/upgrade-an-existing-integration.md +34 -0
- package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +4 -4
- package/agent-docs/publish/mcp.md +3 -1
- package/agent-docs/publish/mcp.sections/check-budgets-and-errors.md +1 -1
- package/agent-docs/publish/mcp.sections/choose-the-documentation-source.md +1 -1
- package/agent-docs/publish/mcp.sections/configure-the-client.md +1 -1
- package/agent-docs/publish/mcp.sections/generate-a-vercel-endpoint.md +1 -1
- package/agent-docs/publish/mcp.sections/related-reading.md +3 -1
- package/agent-docs/publish/mcp.sections/search-then-read.md +1 -1
- package/agent-docs/publish/mcp.sections/serve-a-public-endpoint-on-node.md +1 -1
- package/agent-docs/publish/mcp.sections/verify-the-connection.md +1 -1
- package/agent-docs/publish/mcp.sections/why-offer-mcp-for-public-docs.md +1 -1
- package/agent-docs/publish/seo.md +16 -2
- package/agent-docs/publish/seo.sections/related-reading.md +28 -0
- package/agent-docs/publish/seo.sections/set-the-public-address.md +36 -0
- package/agent-docs/publish/seo.sections/what-agents-receive.md +28 -0
- package/agent-docs/publish/seo.sections/what-the-build-adds.md +42 -0
- package/agent-docs/publish/seo.sections/write-the-page-title-and-summary.md +43 -0
- package/agent-docs/search-index.json +1 -1
- package/agent-docs/styling/layouts.md +21 -3
- package/agent-docs/styling/layouts.sections/build-a-landing-page.md +6 -3
- package/agent-docs/styling/layouts.sections/build-a-splash-page.md +6 -3
- package/agent-docs/styling/layouts.sections/control-navigation-tabs.md +6 -3
- package/agent-docs/styling/layouts.sections/customize-the-hero.md +6 -3
- package/agent-docs/styling/layouts.sections/hero.md +6 -3
- package/agent-docs/styling/layouts.sections/hide-the-table-of-contents-or-give-content-more-room.md +54 -0
- package/agent-docs/styling/layouts.sections/html-markdown-and-agents.md +6 -3
- package/agent-docs/styling/layouts.sections/related-reading.md +6 -3
- package/dist/api.js +70 -3
- package/dist/authoring-hints.d.ts +3 -0
- package/dist/authoring-hints.js +35 -0
- package/dist/authoring-project.d.ts +1 -1
- package/dist/authoring-project.js +2 -0
- package/dist/authoring-validation.js +2 -0
- package/dist/code-samples.js +22 -11
- package/dist/compiler-config.d.ts +1 -0
- package/dist/compiler-config.js +13 -0
- package/dist/compiler.js +2 -1
- package/dist/component-names.js +1 -1
- package/dist/component-transform.js +7 -1
- package/dist/content-delivery-contract.d.ts +108 -0
- package/dist/content-delivery-contract.js +15 -0
- package/dist/content-delivery-transport.d.ts +3 -0
- package/dist/content-delivery-transport.js +47 -0
- package/dist/content-delivery-validation.d.ts +7 -0
- package/dist/content-delivery-validation.js +122 -0
- package/dist/content-delivery.d.ts +21 -0
- package/dist/content-delivery.js +119 -0
- package/dist/content-export-anchors.d.ts +11 -0
- package/dist/content-export-anchors.js +79 -0
- package/dist/content-export-command.d.ts +4 -2
- package/dist/content-export-command.js +15 -2
- package/dist/content-export-comparison.d.ts +16 -0
- package/dist/content-export-comparison.js +48 -0
- package/dist/content-export-config.d.ts +17 -0
- package/dist/content-export-config.js +47 -3
- package/dist/content-export-render.js +20 -1
- package/dist/content-export.d.ts +5 -1
- package/dist/content-export.js +193 -40
- package/dist/content-mount.d.ts +11 -0
- package/dist/content-mount.js +73 -0
- package/dist/content-rest-client.d.ts +12 -0
- package/dist/content-rest-client.js +162 -0
- package/dist/content-rest-contract.d.ts +27 -0
- package/dist/content-rest-contract.js +9 -0
- package/dist/content-rest-server.d.ts +7 -0
- package/dist/content-rest-server.js +99 -0
- package/dist/content-source.d.ts +3 -1
- package/dist/frontmatter.js +8 -7
- package/dist/html-renderer.js +25 -0
- package/dist/markdown-sections.d.ts +2 -1
- package/dist/markdown-sections.js +8 -18
- package/dist/page-renderer.js +5 -3
- package/dist/publication-plan.js +2 -1
- package/dist/scaffold-placeholders.d.ts +2 -0
- package/dist/scaffold-placeholders.js +19 -0
- package/dist/section-identity.d.ts +24 -0
- package/dist/section-identity.js +137 -0
- package/examples/content-delivery/README.md +87 -0
- package/examples/content-delivery/app.js +91 -0
- package/examples/content-delivery/content/billing.mdx +22 -0
- package/examples/content-delivery/docs.config.yaml +36 -0
- package/examples/content-delivery/index.html +25 -0
- package/examples/content-delivery/preview.mjs +86 -0
- package/examples/content-delivery/snippets/refund-window.mdx +3 -0
- package/examples/help-drawer/README.md +2 -2
- package/examples/help-drawer/app.js +3 -3
- package/package.json +6 -3
- package/schemas/content-export.schema.json +469 -33
- package/schemas/content-rest.openapi.json +1627 -0
- package/skills/shoalrun-author/SKILL.md +2 -0
- package/styles/site.css +9 -4
package/dist/content-export.js
CHANGED
|
@@ -22,13 +22,21 @@ import { ExportAssets, exportDigest } from './content-export-assets.js';
|
|
|
22
22
|
import { portableTree, passiveHtml, portableMarkdown } from './content-export-render.js';
|
|
23
23
|
import { checkOutputDirectory } from './output-location.js';
|
|
24
24
|
import { commitArtifacts } from './output-commit.js';
|
|
25
|
-
import { heading, root } from './content.js';
|
|
26
|
-
import { outputUrl } from './representation-tree.js';
|
|
25
|
+
import { heading, root, parse, transformMdx } from './content.js';
|
|
26
|
+
import { outputUrl, publicTree } from './representation-tree.js';
|
|
27
27
|
import { resolveSubjectDeprecation, subjectDeprecationNotice } from './subject-deprecation.js';
|
|
28
28
|
import { resolveAuthoredChange, authoredChangeNotice } from './authored-change.js';
|
|
29
|
+
import { resolveSnippetValues } from './snippets.js';
|
|
30
|
+
import { isolateExportTree, markExportSource, exportHeadingInventory, preserveExportHeadingIds } from './content-export-anchors.js';
|
|
31
|
+
import { selectSection } from './section-identity.js';
|
|
32
|
+
import { compareContentManifests } from './content-export-comparison.js';
|
|
29
33
|
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
30
34
|
const keyFor = (doc) => `${doc.frontmatter.id}|${doc.variant.channel}|${doc.variant.version}|${doc.variant.language}`;
|
|
31
|
-
const
|
|
35
|
+
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]);
|
|
36
|
+
// Pages and their sections share a source; named snippet instances do not.
|
|
37
|
+
const sourceKeyFor = (doc, kind = 'document') => JSON.stringify([kind, doc.frontmatter.id, doc.variant.channel, doc.variant.version, doc.variant.language]);
|
|
38
|
+
const selectedSourceKey = (item) => sourceKeyFor(item.doc, item.ref.kind === 'snippet' ? 'snippet' : 'document');
|
|
39
|
+
const resourceKey = (item) => `resource-${exportDigest(resourceIdentity(item)).slice(0, 24)}`;
|
|
32
40
|
const sameVariant = (a, b) => ['channel', 'version', 'language'].every(key => a[key] === b[key]);
|
|
33
41
|
const visitTree = (node, visit) => {
|
|
34
42
|
if (!node || typeof node !== 'object')
|
|
@@ -85,7 +93,7 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
85
93
|
const metadataDependencies = new Set();
|
|
86
94
|
const workspace = await prepareContentWorkspace({ base, basePath, config, loaded, variants: [publication], language, readerFilters, metadataProvider, metadataDependencies, allowRouteCollisions: true });
|
|
87
95
|
const { docs: eligible, apiPlan, snippets, sourceForOperation } = workspace;
|
|
88
|
-
const ids = new Set(target.documents);
|
|
96
|
+
const ids = new Set(target.resources ? target.resources.filter(item => item.kind !== 'snippet').map(item => item.documentId) : target.documents);
|
|
89
97
|
for (const id of ids) {
|
|
90
98
|
const doc = [...eligible.values()].find(doc => doc.frontmatter.id === id);
|
|
91
99
|
if (!doc)
|
|
@@ -93,8 +101,8 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
93
101
|
if (!matchesContentFilter(doc.readerFilters, target.contentFilter))
|
|
94
102
|
throw new Error(`Requested export document ${id} is excluded by the export contentFilter`);
|
|
95
103
|
}
|
|
96
|
-
const docs = new Map([...eligible].filter(([, doc]) => (!target.documents || ids.has(doc.frontmatter.id)) && matchesContentFilter(doc.readerFilters, target.contentFilter)).sort(([, a], [, b]) => compare(keyFor(a), keyFor(b))));
|
|
97
|
-
if (!docs.size)
|
|
104
|
+
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))));
|
|
105
|
+
if (!docs.size && !target.resources?.some(item => item.kind === 'snippet'))
|
|
98
106
|
throw new Error('Export selection matched no eligible pages');
|
|
99
107
|
const plugins = await loadComponentPlugins(loaded, { browser: false });
|
|
100
108
|
const materialized = await materializePages({ variables: isConfigMapping(config.variables) ? config.variables : {}, frontmatterConfig: config.frontmatter, publications, docs, linkDocuments: eligible, apiPlan, sourceForOperation,
|
|
@@ -106,6 +114,7 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
106
114
|
const cookbooks = validateCookbooks(config.cookbooks, configFile);
|
|
107
115
|
const local = new Map([...eligible.values()].map(doc => [doc.frontmatter.id, doc]));
|
|
108
116
|
const diagnostics = [];
|
|
117
|
+
const requiredNotices = new Map();
|
|
109
118
|
for (const page of materialized.rendered) {
|
|
110
119
|
const use = { targets: [], queries: [] };
|
|
111
120
|
page.doc.tree = resolveCardTree(page.doc.tree, page.doc, eligible, publicationRelationships(edges, local), new Set(apiPlan.knownOperations.keys()), use, config);
|
|
@@ -114,18 +123,78 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
114
123
|
const change = resolveAuthoredChange(page.doc, eligible);
|
|
115
124
|
const existing = page.doc.tree;
|
|
116
125
|
// Preserve authored notices in both output representations.
|
|
117
|
-
|
|
126
|
+
const notices = [
|
|
118
127
|
{ type: 'docRepresentation', representation: 'html', children: [...subjectDeprecationNotice(deprecation, 'html').children, ...authoredChangeNotice(change, 'html').children] },
|
|
119
|
-
{ type: 'docRepresentation', representation: 'markdown', children: [...subjectDeprecationNotice(deprecation, 'markdown').children, ...authoredChangeNotice(change, 'markdown').children] }
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
{ type: 'docRepresentation', representation: 'markdown', children: [...subjectDeprecationNotice(deprecation, 'markdown').children, ...authoredChangeNotice(change, 'markdown').children] }
|
|
129
|
+
];
|
|
130
|
+
requiredNotices.set(keyFor(page.doc), notices);
|
|
131
|
+
page.doc.tree = root([...notices, ...existing.children]);
|
|
122
132
|
const found = new Set();
|
|
123
133
|
visitTree(page.doc.tree, node => { if (['docTabs', 'docWalkthrough', 'docReaderSection', 'docCollapsible', 'docTooltip', 'docInfoHint', 'docCookbookChooser'].includes(node.type))
|
|
124
134
|
found.add(node.type); });
|
|
125
135
|
if (found.size)
|
|
126
136
|
diagnostics.push({ code: 'static-content', documentId: page.doc.frontmatter.id, message: 'Interactions and reader sections are exported as complete labeled content.' });
|
|
127
137
|
}
|
|
128
|
-
|
|
138
|
+
// Materialized snippets can share nodes with their registry definition.
|
|
139
|
+
// Isolate each document before assigning source provenance.
|
|
140
|
+
for (const doc of docs.values()) {
|
|
141
|
+
doc.tree = isolateExportTree(doc.tree);
|
|
142
|
+
markExportSource(doc.tree);
|
|
143
|
+
}
|
|
144
|
+
const selected = [];
|
|
145
|
+
const selections = target.resources ?? [...docs.values()].map(doc => ({ kind: 'page', documentId: doc.frontmatter.id }));
|
|
146
|
+
for (const selection of selections) {
|
|
147
|
+
if (selection.kind === 'snippet') {
|
|
148
|
+
const definition = snippets.get(selection.snippetId);
|
|
149
|
+
if (!definition)
|
|
150
|
+
throw new Error(`Unknown export snippet ${selection.snippetId}`);
|
|
151
|
+
// This is an explicit isolated context. It has no containing page, operations,
|
|
152
|
+
// relationships, page variables, or inherited publication/access metadata.
|
|
153
|
+
const snippetDocument = { file: definition.file, body: '', tree: root([]), variant: publication,
|
|
154
|
+
route: `/__content_snippets/${encodeURIComponent(selection.id)}`, markdownRoute: '',
|
|
155
|
+
frontmatter: { id: selection.id, title: selection.title, summary: selection.summary, type: 'snippet', slug: '/', access: { policy: 'public' } } };
|
|
156
|
+
const checkSnippet = (id, seen = new Set()) => {
|
|
157
|
+
if (seen.has(id))
|
|
158
|
+
return;
|
|
159
|
+
seen.add(id);
|
|
160
|
+
const source = snippets.get(id);
|
|
161
|
+
if (!source)
|
|
162
|
+
throw new Error(`Unknown snippet ${id}`);
|
|
163
|
+
visitTree(source.tree, node => {
|
|
164
|
+
if (['RelatedReading', 'DocCard', 'DocCards', 'DocCollection', 'CookbookChooser', 'ApiExample', 'ApiSchema', 'FrontmatterReference', 'Anchor'].includes(node.name))
|
|
165
|
+
throw new Error(`Snippet instance ${selection.id}: <${node.name}> needs page context`);
|
|
166
|
+
if (node.name === 'Snippet') {
|
|
167
|
+
const child = node.attributes?.find((a) => a.name === 'id')?.value;
|
|
168
|
+
if (typeof child === 'string')
|
|
169
|
+
checkSnippet(child, seen);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
checkSnippet(selection.snippetId);
|
|
174
|
+
const invocation = parse.parse(`<Snippet id=${JSON.stringify(selection.snippetId)} values={${JSON.stringify(selection.values ?? {})}} />`);
|
|
175
|
+
snippetDocument.tree = transformMdx(invocation, snippetDocument, new Map([...eligible.values()].map(doc => [doc.frontmatter.id, doc])), snippets, isConfigMapping(config.variables) ? config.variables : {}, [], resolveCalloutTypes(config.callouts, configFile), base, readerFilters, {}, publications, resolveBadgeVariants(config.badges, configFile), plugins.registry);
|
|
176
|
+
visitTree(snippetDocument.tree, node => { if (/^doc(?:Card|Collection|Related|Cookbook|Api)/.test(node.type))
|
|
177
|
+
throw new Error(`Snippet instance ${selection.id} needs page context`); });
|
|
178
|
+
snippetDocument.tree = isolateExportTree(snippetDocument.tree);
|
|
179
|
+
markExportSource(snippetDocument.tree);
|
|
180
|
+
selected.push({ ref: { kind: 'snippet', id: selection.id }, doc: snippetDocument, tree: snippetDocument.tree,
|
|
181
|
+
title: selection.title, summary: selection.summary, type: 'snippet', source: { snippetId: selection.snippetId, valuesDigest: exportDigest(JSON.stringify(resolveSnippetValues(selection.snippetId, definition, config.variables, undefined, selection.values ?? {}))) } });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const doc = [...docs.values()].find(doc => doc.frontmatter.id === selection.documentId);
|
|
185
|
+
if (selection.kind === 'page')
|
|
186
|
+
selected.push({ ref: { kind: 'page', documentId: doc.frontmatter.id }, doc,
|
|
187
|
+
tree: root([heading(1, doc.frontmatter.title), ...doc.tree.children]), title: doc.frontmatter.title, summary: doc.frontmatter.summary, type: doc.frontmatter.type });
|
|
188
|
+
else {
|
|
189
|
+
const slice = selectSection(doc.tree, selection.sectionId, doc.frontmatter.standaloneSections ?? [], doc.frontmatter.id);
|
|
190
|
+
selected.push({ ref: { kind: 'section', documentId: doc.frontmatter.id, sectionId: selection.sectionId }, doc,
|
|
191
|
+
tree: root([...(requiredNotices.get(keyFor(doc)) ?? []), ...slice.tree.children]), meaningTree: slice.tree, title: slice.section.title,
|
|
192
|
+
summary: doc.frontmatter.summary, type: 'section', source: { documentId: doc.frontmatter.id } });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
selected.sort((a, b) => compare(resourceIdentity(a), resourceIdentity(b)));
|
|
197
|
+
const diagrams = await resolveDiagramTrees(selected.map(item => item.tree));
|
|
129
198
|
const assets = new ExportAssets(target.publicBase);
|
|
130
199
|
const assetRootsOrigin = options.target && target.assetRoots.length ? explainSegments(loaded, ['exports', options.target, 'assetRoots']).source.file : configFile;
|
|
131
200
|
const roots = [workspace.contentDir, ...target.assetRoots.map(root => path.resolve(path.dirname(assetRootsOrigin), root))];
|
|
@@ -134,13 +203,15 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
134
203
|
assets.register(asset.route, { content: asset.svg });
|
|
135
204
|
for (const [url, contract] of materialized.focusedContracts)
|
|
136
205
|
assets.register(url, { content: contract.content });
|
|
137
|
-
for (const
|
|
138
|
-
|
|
206
|
+
for (const item of selected) {
|
|
207
|
+
const doc = item.doc;
|
|
208
|
+
visitTree(item.tree, node => {
|
|
139
209
|
if (typeof node.file === 'string' && typeof node.url === 'string' && ['docImage', 'image', 'definition'].includes(node.type))
|
|
140
|
-
assets.register(node.url, { file: node.file },
|
|
210
|
+
assets.register(node.url, { file: node.file }, selectedSourceKey(item));
|
|
141
211
|
if (node.type === 'docHero' && node.image?.file)
|
|
142
|
-
assets.register(node.image.url, { file: node.image.file },
|
|
212
|
+
assets.register(node.image.url, { file: node.image.file }, selectedSourceKey(item));
|
|
143
213
|
});
|
|
214
|
+
}
|
|
144
215
|
const reviews = await reviewApiPublication(configFile, apiPlan, materialized.rendered, materialized.apiEmbedUses, materialized.focusedContracts, [...materialized.apiPublications.values()].map(source => ({ apiId: source.apiId, sourceUrl: source.sourceUrl })), sourceForOperation, 'report');
|
|
145
216
|
const unreviewed = reviews.filter(review => (target.apiReview[review.apiId] ?? configField(apiPlan.apiDefinitions[review.apiId].publicReview, 'digest')) !== review.digest);
|
|
146
217
|
if (unreviewed.length && !options.review)
|
|
@@ -158,24 +229,62 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
158
229
|
const trees = new Map();
|
|
159
230
|
const anchors = new Map();
|
|
160
231
|
const routes = new Set([...eligible.values()].map(doc => doc.route));
|
|
161
|
-
|
|
232
|
+
const sourceInventories = new Map();
|
|
233
|
+
const sourceAliases = new Map();
|
|
234
|
+
for (const item of selected)
|
|
162
235
|
for (const format of target.formats) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
236
|
+
const sourceKey = `${selectedSourceKey(item)}:${format}`;
|
|
237
|
+
if (!sourceInventories.has(sourceKey))
|
|
238
|
+
sourceInventories.set(sourceKey, exportHeadingInventory(portableTree(structuredClone(item.doc.tree), format, routes, basePath)));
|
|
239
|
+
const tree = portableTree(structuredClone(item.tree), format, routes, basePath);
|
|
240
|
+
if (item.ref.kind !== 'page') {
|
|
241
|
+
const meaning = item.meaningTree ? portableTree(structuredClone(item.meaningTree), format, routes, basePath) : tree;
|
|
242
|
+
const body = (meaning.children ?? []).filter((node) => node.type !== 'heading' && node.type !== 'definition' && node.type !== 'footnoteDefinition');
|
|
243
|
+
if (!portableMarkdown(root(body)).trim())
|
|
244
|
+
throw new Error(`Resource ${resourceIdentity(item)} has no content in ${format}`);
|
|
245
|
+
}
|
|
246
|
+
const aliases = preserveExportHeadingIds(tree, sourceInventories.get(sourceKey), format);
|
|
247
|
+
sourceAliases.set(`${resourceIdentity(item)}:${format}`, aliases);
|
|
248
|
+
trees.set(`${resourceIdentity(item)}:${format}`, tree);
|
|
249
|
+
// Resolve anchors independently in each representation, before URL rewriting.
|
|
250
|
+
anchors.set(`${resourceIdentity(item)}:${format}`, passiveHtml(tree, `sr-${resourceKey(item)}`, false).anchors);
|
|
168
251
|
}
|
|
252
|
+
const canonicalAnchors = new Map();
|
|
253
|
+
const verifiedCanonicalAnchors = (destination, format) => {
|
|
254
|
+
const key = `${sourceKeyFor(destination)}:${format}`;
|
|
255
|
+
if (canonicalAnchors.has(key))
|
|
256
|
+
return canonicalAnchors.get(key);
|
|
257
|
+
const source = sourceInventories.get(key);
|
|
258
|
+
if (!source || destination.endpoint)
|
|
259
|
+
throw new Error(`Cannot validate canonical fragments in ${destination.frontmatter.id}; select the target resource explicitly`);
|
|
260
|
+
const reserved = ['main', 'docs-navigation', 'docs-sections', 'documentation-menu', 'page-outline', 'nav-filter', 'examples-title', 'api-view-select'];
|
|
261
|
+
const explicit = new Set();
|
|
262
|
+
visitTree(destination.tree, node => {
|
|
263
|
+
if (node.type === 'docSection' && node.sectionId)
|
|
264
|
+
reserved.push(node.sectionId);
|
|
265
|
+
const alias = node.data?.hProperties?.['data-shoalrun-section-id'];
|
|
266
|
+
if (typeof alias === 'string')
|
|
267
|
+
explicit.add(alias);
|
|
268
|
+
});
|
|
269
|
+
const native = exportHeadingInventory(publicTree(structuredClone(destination.tree), 'html', routes, 'light', basePath), reserved);
|
|
270
|
+
const verified = new Set([...explicit].filter(id => native.ids.has(id)));
|
|
271
|
+
for (const [headingKey, id] of source.headings)
|
|
272
|
+
if (native.headings.get(headingKey) === id)
|
|
273
|
+
verified.add(id);
|
|
274
|
+
canonicalAnchors.set(key, verified);
|
|
275
|
+
return verified;
|
|
276
|
+
};
|
|
169
277
|
const records = [];
|
|
170
278
|
const artifacts = new Map();
|
|
171
|
-
for (const
|
|
279
|
+
for (const item of selected) {
|
|
280
|
+
const doc = item.doc;
|
|
172
281
|
const externalDependencies = new Map();
|
|
173
282
|
const usedAssets = new Set();
|
|
174
283
|
const representations = {};
|
|
175
|
-
const canonicalUrl = origin && canonicalDestination(doc) ? `${origin}${doc.route}` : undefined;
|
|
284
|
+
const canonicalUrl = item.ref.kind !== 'snippet' && origin && canonicalDestination(doc) ? `${origin}${doc.route}` : undefined;
|
|
176
285
|
for (const format of target.formats) {
|
|
177
286
|
const links = [];
|
|
178
|
-
const tree = trees.get(`${
|
|
287
|
+
const tree = trees.get(`${resourceIdentity(item)}:${format}`);
|
|
179
288
|
const resolve = async (node) => {
|
|
180
289
|
// Native adapters can express ordinary links/images as semantic elements.
|
|
181
290
|
const properties = node.data?.hProperties;
|
|
@@ -207,24 +316,56 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
207
316
|
if (destination) {
|
|
208
317
|
let href;
|
|
209
318
|
let kind;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
319
|
+
const fragment = decodeURIComponent(parsed.hash.slice(1));
|
|
320
|
+
const candidates = selected.filter(candidate => candidate.ref.kind !== 'snippet' && keyFor(candidate.doc) === keyFor(destination));
|
|
321
|
+
const hasAnchor = (candidate) => sourceAliases.get(`${resourceIdentity(candidate)}:${format}`)?.has(fragment);
|
|
322
|
+
let chosen = destination === doc && fragment && hasAnchor(item) ? item : undefined;
|
|
323
|
+
if (!chosen && fragment) {
|
|
324
|
+
const matching = candidates.filter(candidate => candidate.ref.kind === 'section' && hasAnchor(candidate));
|
|
325
|
+
if (matching.length > 1)
|
|
326
|
+
throw new Error(`Ambiguous fragment ${fragment} in ${destination.frontmatter.id}`);
|
|
327
|
+
chosen = matching[0];
|
|
328
|
+
}
|
|
329
|
+
chosen ??= candidates.find(candidate => candidate.ref.kind === 'page');
|
|
330
|
+
// Clean Markdown has no explicit alias anchors. Prefer an actual
|
|
331
|
+
// section resource for those references, otherwise the canonical HTML.
|
|
332
|
+
if (chosen && format === 'markdown' && fragment && chosen.ref.kind === 'page') {
|
|
333
|
+
const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
|
|
334
|
+
const target = anchors.get(`${resourceIdentity(chosen)}:${format}`)?.find(entry => entry.id === targetId);
|
|
335
|
+
if (target && !target.title)
|
|
336
|
+
chosen = candidates.find(candidate => candidate.ref.kind === 'section' && candidate.ref.sectionId === fragment);
|
|
337
|
+
}
|
|
338
|
+
if (chosen) {
|
|
339
|
+
const targetAnchors = anchors.get(`${resourceIdentity(chosen)}:${format}`);
|
|
340
|
+
const targetId = sourceAliases.get(`${resourceIdentity(chosen)}:${format}`)?.get(fragment);
|
|
341
|
+
const anchor = fragment ? targetAnchors.find(entry => entry.id === targetId) : undefined;
|
|
342
|
+
if (fragment && !anchor)
|
|
343
|
+
throw new Error(`Missing export anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
|
|
344
|
+
// A section-resource URL is sufficient when addressing its leading
|
|
345
|
+
// section; clean Markdown does not publish explicit HTML anchors.
|
|
346
|
+
const leadingSection = chosen.ref.kind === 'section' && chosen.ref.sectionId === fragment;
|
|
347
|
+
const markdownAnchor = anchor?.id ?? fragment;
|
|
348
|
+
const suffix = parsed.search + (fragment && !(format === 'markdown' && leadingSection) ? `#${format === 'html' ? anchor.exportedId : markdownAnchor}` : '');
|
|
349
|
+
href = chosen === item && !parsed.search && fragment && format === 'html' ? suffix : `${target.publicBase}content/${resourceKey(chosen)}.${format === 'html' ? 'html' : 'md'}${suffix}`;
|
|
350
|
+
kind = 'resource';
|
|
219
351
|
}
|
|
220
352
|
else {
|
|
221
353
|
if (!origin || !canonicalDestination(destination))
|
|
222
354
|
throw new Error(`Document ${destination.frontmatter.id} is outside the export and has no eligible canonical website fallback`);
|
|
355
|
+
if (fragment) {
|
|
356
|
+
const inventory = sourceInventories.get(`${sourceKeyFor(destination)}:${format}`);
|
|
357
|
+
if (!inventory)
|
|
358
|
+
throw new Error(`Cannot validate canonical anchor ${fragment} in unselected document ${destination.frontmatter.id}; select that page or section explicitly`);
|
|
359
|
+
if (!inventory.ids.has(fragment))
|
|
360
|
+
throw new Error(`Missing source anchor ${fragment} in ${destination.frontmatter.id} (${format})`);
|
|
361
|
+
if (!verifiedCanonicalAnchors(destination, format).has(fragment))
|
|
362
|
+
throw new Error(`Cannot verify canonical anchor ${fragment} in ${destination.frontmatter.id}; select its target resource explicitly`);
|
|
363
|
+
}
|
|
223
364
|
href = `${origin}${destination.route}${parsed.search}${parsed.hash}`;
|
|
224
365
|
kind = 'canonical';
|
|
225
366
|
}
|
|
226
367
|
const key = `link-${links.length + 1}`;
|
|
227
|
-
links.push({ key, kind, href,
|
|
368
|
+
links.push({ key, kind, href, ...(chosen ? { ref: chosen.ref } : {}), ...(fragment ? { fragment } : {}), ...(parsed.search ? { query: parsed.search.slice(1) } : {}) });
|
|
228
369
|
node.url = href;
|
|
229
370
|
if (format === 'html' && node.type === 'link')
|
|
230
371
|
node.data = { ...node.data, hProperties: { ...node.data?.hProperties, 'data-export-link': key } };
|
|
@@ -237,7 +378,7 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
237
378
|
links.push({ key: `link-${links.length + 1}`, kind: 'external', href: node.url });
|
|
238
379
|
}
|
|
239
380
|
else {
|
|
240
|
-
const asset = await assets.resolve(value, doc.file,
|
|
381
|
+
const asset = await assets.resolve(value, doc.file, selectedSourceKey(item));
|
|
241
382
|
if (!asset)
|
|
242
383
|
throw new Error(`Unresolved local export ${node.type === 'image' ? 'image' : 'link'} ${value} in ${doc.frontmatter.id}`);
|
|
243
384
|
usedAssets.add(asset.path);
|
|
@@ -250,15 +391,15 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
250
391
|
await resolve(child);
|
|
251
392
|
};
|
|
252
393
|
await resolve(tree);
|
|
253
|
-
const filename = `content/${resourceKey(
|
|
254
|
-
const rendered = format === 'html' ? passiveHtml(tree, `sr-${resourceKey(
|
|
394
|
+
const filename = `content/${resourceKey(item)}.${format === 'html' ? 'html' : 'md'}`;
|
|
395
|
+
const rendered = format === 'html' ? passiveHtml(tree, `sr-${resourceKey(item)}`) : undefined;
|
|
255
396
|
const content = rendered?.html ?? portableMarkdown(tree);
|
|
256
397
|
artifacts.set(filename, content);
|
|
257
398
|
representations[format] = { path: filename, mediaType: format === 'html' ? 'text/html' : 'text/markdown', bytes: Buffer.byteLength(content), digest: exportDigest(content), anchors: rendered?.anchors ?? [], links };
|
|
258
399
|
}
|
|
259
400
|
if (externalDependencies.size)
|
|
260
401
|
diagnostics.push({ code: 'external-render-dependencies', documentId: doc.frontmatter.id, message: `${externalDependencies.size} remote image(s) remain external; this document is not offline-complete.` });
|
|
261
|
-
records.push({
|
|
402
|
+
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)) });
|
|
262
403
|
}
|
|
263
404
|
for (const [filename, bytes] of assets.artifacts)
|
|
264
405
|
artifacts.set(filename, bytes);
|
|
@@ -291,8 +432,15 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
291
432
|
artifacts.set('NOTICES.txt', notices);
|
|
292
433
|
const manifest = { schemaVersion: 1, kind: 'shoalrun-content-export', publicBase: target.publicBase, publication,
|
|
293
434
|
review: { status: unreviewed.length ? 'required' : 'ok', apis: reviews.map(row => ({ apiId: row.apiId, digest: row.digest })) },
|
|
294
|
-
|
|
435
|
+
resources: records, assets: [...assets.records.values()].sort((a, b) => compare(a.path, b.path)),
|
|
295
436
|
files: [...artifacts].map(([filename, content]) => ({ path: filename, bytes: Buffer.byteLength(content), digest: exportDigest(content) })).sort((a, b) => compare(a.path, b.path)), diagnostics };
|
|
437
|
+
const previous = options.previousManifest ? JSON.parse(await readFile(options.previousManifest, 'utf8')) : undefined;
|
|
438
|
+
const comparison = compareContentManifests(previous, manifest);
|
|
439
|
+
if (options.failOnRemoved && !options.previousManifest)
|
|
440
|
+
throw new Error('failOnRemoved requires previousManifest');
|
|
441
|
+
if (options.failOnRemoved && comparison.removed.length)
|
|
442
|
+
throw new Error(`Export removes ${comparison.removed.length} previously exported resource(s)`);
|
|
443
|
+
diagnostics.push(...comparison.diagnostics);
|
|
296
444
|
const bundleDigest = exportDigest(JSON.stringify(manifest));
|
|
297
445
|
artifacts.set('manifest.json', JSON.stringify({ ...manifest, digest: bundleDigest }, null, 2) + '\n');
|
|
298
446
|
const inputs = [...noticeFiles, ...loaded.dependencies, ...metadataDependencies, ...plugins.dependencies, ...apiPlan.api.dependencies, ...[...eligible.values()].map(doc => doc.file), ...[...snippets.values()].map(snippet => snippet.file)];
|
|
@@ -300,8 +448,13 @@ export async function exportContent(configFile, destination, options = {}) {
|
|
|
300
448
|
// No export can replace a directory containing an allowed asset root.
|
|
301
449
|
for (const directory of roots)
|
|
302
450
|
checkOutputDirectory(destination, base, directory, inputs);
|
|
451
|
+
if (unreviewed.length) {
|
|
452
|
+
const prior = await readFile(path.join(destination, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => undefined);
|
|
453
|
+
if (prior?.review?.status === 'ok')
|
|
454
|
+
throw new Error('Review candidate cannot overwrite an approved export; choose a separate review destination');
|
|
455
|
+
}
|
|
303
456
|
await commitArtifacts(path.resolve(destination), artifacts, undefined, 'export');
|
|
304
457
|
return { status: unreviewed.length ? 'needs-review' : 'ok', output: path.resolve(destination), publication, formats: target.formats,
|
|
305
|
-
documents: records.length, documentIds: records.
|
|
458
|
+
resources: records.length, documents: records.filter(item => item.ref.kind === 'page').length, documentIds: records.flatMap(item => item.ref.kind === 'page' ? [item.ref.documentId] : []), comparison, assets: assets.records.size, digest: bundleDigest,
|
|
306
459
|
apiReview: reviews.map(row => ({ apiId: row.apiId, digest: row.digest })), diagnostics };
|
|
307
460
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ContentAnchor, ContentLink, ContentResult } from './content-delivery-contract.js';
|
|
2
|
+
export type ContentMount = {
|
|
3
|
+
anchors: readonly ContentAnchor[];
|
|
4
|
+
links: readonly ContentLink[];
|
|
5
|
+
unmount(): void;
|
|
6
|
+
};
|
|
7
|
+
/** Mount trusted compiler output, not arbitrary HTML. This is not a sanitizer. */
|
|
8
|
+
export declare function mountContent(container: Element, result: ContentResult, options?: {
|
|
9
|
+
instanceId?: string;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}): ContentMount;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ContentDeliveryError, contentRefKey } from './content-delivery-contract.js';
|
|
2
|
+
/** Mount trusted compiler output, not arbitrary HTML. This is not a sanitizer. */
|
|
3
|
+
export function mountContent(container, result, options = {}) {
|
|
4
|
+
options.signal?.throwIfAborted();
|
|
5
|
+
if (result.format !== 'html')
|
|
6
|
+
throw new ContentDeliveryError('unsupported_format', 'Mounting requires HTML content.');
|
|
7
|
+
const instance = options.instanceId ?? `content-${globalThis.crypto.randomUUID()}`;
|
|
8
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,127}$/.test(instance))
|
|
9
|
+
throw new ContentDeliveryError('invalid_content', 'Invalid mount instance ID.');
|
|
10
|
+
const document = container.ownerDocument;
|
|
11
|
+
const template = document.createElement('template');
|
|
12
|
+
template.innerHTML = result.content;
|
|
13
|
+
const ids = new Map();
|
|
14
|
+
for (const element of template.content.querySelectorAll('[id]')) {
|
|
15
|
+
const old = element.id;
|
|
16
|
+
const next = `${instance}-${old}`;
|
|
17
|
+
if (!old || ids.has(old))
|
|
18
|
+
throw new ContentDeliveryError('invalid_content', 'Duplicate or empty content ID.');
|
|
19
|
+
const existing = document.getElementById(next);
|
|
20
|
+
if (existing && (existing === container || !container.contains(existing)))
|
|
21
|
+
throw new ContentDeliveryError('invalid_content', 'Mount instance ID is already in use.');
|
|
22
|
+
ids.set(old, next);
|
|
23
|
+
element.id = next;
|
|
24
|
+
}
|
|
25
|
+
const tokenAttributes = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-flowto', 'headers'];
|
|
26
|
+
const singleAttributes = ['aria-activedescendant', 'aria-details', 'aria-errormessage', 'for', 'list', 'form'];
|
|
27
|
+
const rewrite = (id) => ids.get(id) ?? id;
|
|
28
|
+
const links = result.representation.links.map(link => ({ ...link }));
|
|
29
|
+
for (const element of template.content.querySelectorAll('*')) {
|
|
30
|
+
for (const attribute of tokenAttributes)
|
|
31
|
+
if (element.hasAttribute(attribute))
|
|
32
|
+
element.setAttribute(attribute, element.getAttribute(attribute).split(/\s+/).map(rewrite).join(' '));
|
|
33
|
+
for (const attribute of singleAttributes)
|
|
34
|
+
if (element.hasAttribute(attribute))
|
|
35
|
+
element.setAttribute(attribute, rewrite(element.getAttribute(attribute)));
|
|
36
|
+
const href = element.getAttribute('href');
|
|
37
|
+
if (href?.startsWith('#')) {
|
|
38
|
+
let id;
|
|
39
|
+
try {
|
|
40
|
+
id = decodeURIComponent(href.slice(1));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new ContentDeliveryError('invalid_content', 'Malformed content fragment.');
|
|
44
|
+
}
|
|
45
|
+
if (ids.has(id))
|
|
46
|
+
element.setAttribute('href', '#' + ids.get(id));
|
|
47
|
+
}
|
|
48
|
+
const key = element.getAttribute('data-export-link');
|
|
49
|
+
const link = key ? links.find(link => link.key === key) : undefined;
|
|
50
|
+
if (link?.kind === 'resource' && link.ref && contentRefKey(link.ref) === contentRefKey(result.resource.ref) && link.fragment && !link.query) {
|
|
51
|
+
const anchor = result.representation.anchors.find(anchor => anchor.id === link.fragment || anchor.exportedId === link.fragment);
|
|
52
|
+
const id = anchor && ids.get(anchor.exportedId);
|
|
53
|
+
if (id) {
|
|
54
|
+
element.setAttribute('href', '#' + id);
|
|
55
|
+
link.href = '#' + id;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const anchors = result.representation.anchors.map(anchor => {
|
|
60
|
+
const exportedId = ids.get(anchor.exportedId);
|
|
61
|
+
if (!exportedId)
|
|
62
|
+
throw new ContentDeliveryError('invalid_content', 'Declared anchor is absent from HTML.');
|
|
63
|
+
return Object.freeze({ ...anchor, exportedId });
|
|
64
|
+
});
|
|
65
|
+
options.signal?.throwIfAborted();
|
|
66
|
+
const mounted = [...template.content.childNodes];
|
|
67
|
+
container.replaceChildren(template.content);
|
|
68
|
+
return Object.freeze({ anchors: Object.freeze(anchors), links: Object.freeze(links.map(link => Object.freeze(link))),
|
|
69
|
+
unmount() { for (const node of mounted)
|
|
70
|
+
if (node.parentNode === container)
|
|
71
|
+
node.remove(); },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ContentSnapshot, ContentPublication } from './content-delivery-contract.js';
|
|
2
|
+
import type { ContentDeliveryLimits } from './content-delivery.js';
|
|
3
|
+
export type RestContentSourceOptions = {
|
|
4
|
+
baseUrl: string | URL;
|
|
5
|
+
publication: ContentPublication;
|
|
6
|
+
revision?: string;
|
|
7
|
+
fetch?: typeof globalThis.fetch;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
limits?: ContentDeliveryLimits;
|
|
10
|
+
};
|
|
11
|
+
/** Preloads a REST inventory and pins all subsequent reads to that revision. */
|
|
12
|
+
export declare function openRestContentSource(options: RestContentSourceOptions): Promise<ContentSnapshot>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { ContentDeliveryError, contentRefKey } from './content-delivery-contract.js';
|
|
2
|
+
import { contentResourceKey } from './content-rest-contract.js';
|
|
3
|
+
import { validContentPublication, validContentRef, validateContentResources, safeContentPath } from './content-delivery-validation.js';
|
|
4
|
+
import { freeze, sha256, readBounded } from './content-delivery-transport.js';
|
|
5
|
+
const digestPattern = /^[a-f0-9]{64}$/;
|
|
6
|
+
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const exactKeys = (value, keys) => record(value) && Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
|
|
8
|
+
const samePublication = (left, right) => left.channel === right.channel && left.version === right.version && left.language === right.language;
|
|
9
|
+
const invalid = (message) => { throw new ContentDeliveryError('invalid_manifest', message); };
|
|
10
|
+
/** Preloads a REST inventory and pins all subsequent reads to that revision. */
|
|
11
|
+
export async function openRestContentSource(options) {
|
|
12
|
+
let baseUrl;
|
|
13
|
+
try {
|
|
14
|
+
baseUrl = new URL(options.baseUrl, typeof location === 'undefined' ? undefined : location.href);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return invalid('REST base URL must be a valid HTTP(S) URL.');
|
|
18
|
+
}
|
|
19
|
+
if (!['http:', 'https:'].includes(baseUrl.protocol) || baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash || !/^\/(?:[A-Za-z0-9_-]+\/?)*$/.test(baseUrl.pathname))
|
|
20
|
+
invalid('Invalid REST base URL.');
|
|
21
|
+
if (typeof location !== 'undefined' && baseUrl.origin !== location.origin)
|
|
22
|
+
invalid('Browser content must be served from the application origin.');
|
|
23
|
+
if (!validContentPublication(options.publication) || options.revision !== undefined && !digestPattern.test(options.revision))
|
|
24
|
+
invalid('Invalid REST publication or revision.');
|
|
25
|
+
const publication = { ...options.publication };
|
|
26
|
+
const requestedRevision = options.revision;
|
|
27
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
28
|
+
const manifestBytes = options.limits?.manifestBytes ?? 5_000_000;
|
|
29
|
+
const resourceBytes = options.limits?.resourceBytes ?? 8_000_000;
|
|
30
|
+
const maxResources = options.limits?.resources ?? 10_000;
|
|
31
|
+
for (const limit of [manifestBytes, resourceBytes, maxResources])
|
|
32
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
33
|
+
invalid('Limits must be positive safe integers.');
|
|
34
|
+
const lifetime = new AbortController();
|
|
35
|
+
let disposed = false;
|
|
36
|
+
const openSignal = options.signal ? AbortSignal.any([options.signal, lifetime.signal]) : lifetime.signal;
|
|
37
|
+
const urlFor = (suffix, revision) => {
|
|
38
|
+
const url = new URL(baseUrl.href);
|
|
39
|
+
url.pathname = baseUrl.pathname.replace(/\/$/, '') + '/resources' + suffix;
|
|
40
|
+
for (const key of ['channel', 'version', 'language'])
|
|
41
|
+
url.searchParams.set(key, publication[key]);
|
|
42
|
+
if (revision !== undefined)
|
|
43
|
+
url.searchParams.set('revision', revision);
|
|
44
|
+
return url;
|
|
45
|
+
};
|
|
46
|
+
async function request(url, mediaType, limit, signal) {
|
|
47
|
+
signal.throwIfAborted();
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await fetcher(url.href, { signal, redirect: 'error', credentials: 'same-origin', cache: 'no-store', headers: { Accept: mediaType } });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
throw new ContentDeliveryError('network_error', 'Cannot fetch REST content.');
|
|
55
|
+
}
|
|
56
|
+
const reject = async (code, message) => {
|
|
57
|
+
// Cancelling releases the body without consuming an arbitrary host error.
|
|
58
|
+
// A custom stream's cancellation promise must not delay the failure.
|
|
59
|
+
void response.body?.cancel().catch(() => { });
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
throw new ContentDeliveryError(code, message);
|
|
62
|
+
};
|
|
63
|
+
if (response.redirected || response.url && response.url !== url.href)
|
|
64
|
+
return reject('invalid_content', 'Redirected content is not accepted.');
|
|
65
|
+
const actualType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
66
|
+
try {
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const codes = { 400: 'invalid_request', 404: 'not_found', 406: 'unsupported_format', 409: 'stale_revision', 405: 'method_not_allowed', 500: 'internal_error' };
|
|
69
|
+
if (!(response.status in codes))
|
|
70
|
+
return reject('network_error', `Content request failed (${response.status}).`);
|
|
71
|
+
if (actualType !== 'application/json')
|
|
72
|
+
return reject('invalid_content', 'REST error must be JSON.');
|
|
73
|
+
const bytes = await readBounded(response, Math.min(manifestBytes, 65_536), signal);
|
|
74
|
+
let envelope;
|
|
75
|
+
try {
|
|
76
|
+
envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new ContentDeliveryError('invalid_content', 'Malformed REST error.');
|
|
80
|
+
}
|
|
81
|
+
if (!exactKeys(envelope, ['error']) || !exactKeys(envelope.error, ['code', 'message']) || codes[response.status] !== envelope.error.code || typeof envelope.error.message !== 'string')
|
|
82
|
+
throw new ContentDeliveryError('invalid_content', 'Malformed REST error.');
|
|
83
|
+
const code = response.status === 404 ? 'not_found' : response.status === 406 ? 'unsupported_format' : response.status === 409 ? 'stale_revision' : 'network_error';
|
|
84
|
+
throw new ContentDeliveryError(code, envelope.error.message);
|
|
85
|
+
}
|
|
86
|
+
if (actualType !== mediaType)
|
|
87
|
+
return reject('invalid_content', 'Unexpected content type.');
|
|
88
|
+
return await readBounded(response, limit, signal);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
if (error instanceof ContentDeliveryError)
|
|
93
|
+
throw error;
|
|
94
|
+
throw new ContentDeliveryError('network_error', 'Content response was interrupted.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let envelope;
|
|
98
|
+
try {
|
|
99
|
+
envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await request(urlFor('', requestedRevision), 'application/json', manifestBytes, openSignal)));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof ContentDeliveryError || openSignal.aborted)
|
|
103
|
+
throw error;
|
|
104
|
+
return invalid('REST inventory is not valid UTF-8 JSON.');
|
|
105
|
+
}
|
|
106
|
+
if (!exactKeys(envelope, ['schemaVersion', 'revision', 'publication', 'resources']) || envelope.schemaVersion !== 1 || !digestPattern.test(envelope.revision ?? '') || !validContentPublication(envelope.publication) || !samePublication(envelope.publication, publication))
|
|
107
|
+
invalid('Invalid REST inventory identity.');
|
|
108
|
+
if (requestedRevision !== undefined && requestedRevision !== envelope.revision)
|
|
109
|
+
throw new ContentDeliveryError('stale_revision', 'REST inventory differs from the requested revision.');
|
|
110
|
+
if (!Array.isArray(envelope.resources) || envelope.resources.length > maxResources)
|
|
111
|
+
invalid('Invalid REST resource inventory.');
|
|
112
|
+
const resources = [];
|
|
113
|
+
const assets = new Set();
|
|
114
|
+
for (const item of envelope.resources) {
|
|
115
|
+
if (!exactKeys(item, ['key', 'resource']) || !record(item.resource) || !validContentRef(item.resource.ref) || item.key !== contentResourceKey(item.resource.ref))
|
|
116
|
+
invalid('Invalid REST resource key.');
|
|
117
|
+
if (!Array.isArray(item.resource.assets) || item.resource.assets.some((asset) => !safeContentPath(asset) || !asset.startsWith('assets/')))
|
|
118
|
+
invalid('Invalid REST resource assets.');
|
|
119
|
+
item.resource.assets.forEach((asset) => assets.add(asset));
|
|
120
|
+
resources.push(item.resource);
|
|
121
|
+
}
|
|
122
|
+
validateContentResources(resources, publication, assets, rep => record(rep) && safeContentPath(rep.path) && Number.isSafeInteger(rep.bytes) && rep.bytes >= 0 && rep.bytes <= resourceBytes && digestPattern.test(rep.digest ?? ''));
|
|
123
|
+
const revision = envelope.revision;
|
|
124
|
+
freeze(resources);
|
|
125
|
+
freeze(publication);
|
|
126
|
+
const byRef = new Map(resources.map(resource => [contentRefKey(resource.ref), resource]));
|
|
127
|
+
openSignal.throwIfAborted();
|
|
128
|
+
const active = () => { if (disposed)
|
|
129
|
+
throw new ContentDeliveryError('disposed', 'Content snapshot has been disposed.'); };
|
|
130
|
+
return Object.freeze({ revision, publication,
|
|
131
|
+
list() { active(); return resources; },
|
|
132
|
+
async get(ref, getOptions) {
|
|
133
|
+
active();
|
|
134
|
+
if (!validContentRef(ref))
|
|
135
|
+
throw new ContentDeliveryError('not_found', 'Invalid content reference.');
|
|
136
|
+
const resource = byRef.get(contentRefKey(ref));
|
|
137
|
+
if (!resource)
|
|
138
|
+
throw new ContentDeliveryError('not_found', 'No matching content in this publication.');
|
|
139
|
+
const format = getOptions?.format;
|
|
140
|
+
if (!['html', 'markdown'].includes(format) || !resource.representations[format])
|
|
141
|
+
throw new ContentDeliveryError('unsupported_format', 'The requested representation is unavailable.');
|
|
142
|
+
const representation = resource.representations[format];
|
|
143
|
+
const signal = getOptions.signal ? AbortSignal.any([lifetime.signal, getOptions.signal]) : lifetime.signal;
|
|
144
|
+
const bytes = await request(urlFor('/' + contentResourceKey(resource.ref) + '/representations/' + format, revision), representation.mediaType, resourceBytes, signal);
|
|
145
|
+
if (bytes.byteLength !== representation.bytes || await sha256(bytes) !== representation.digest)
|
|
146
|
+
throw new ContentDeliveryError('stale_revision', 'Representation differs from the opened revision.');
|
|
147
|
+
signal.throwIfAborted();
|
|
148
|
+
active();
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new ContentDeliveryError('invalid_content', 'Content is not valid UTF-8.');
|
|
155
|
+
}
|
|
156
|
+
return Object.freeze({ resource, representation, format, content, revision });
|
|
157
|
+
},
|
|
158
|
+
dispose() { if (!disposed) {
|
|
159
|
+
disposed = true;
|
|
160
|
+
lifetime.abort(new ContentDeliveryError('disposed', 'Content snapshot has been disposed.'));
|
|
161
|
+
} }, });
|
|
162
|
+
}
|