@shoalrun/cli 0.1.0-alpha.12 → 0.1.0-alpha.13
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 +45 -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 +4 -1
package/dist/compiler.js
CHANGED
|
@@ -7,7 +7,7 @@ import { resolveTaxonomy } from './taxonomy.js';
|
|
|
7
7
|
import { resolveMcp } from './mcp-config.js';
|
|
8
8
|
import { fictionalDocument, resolveLlmsDiscovery } from './llms-discovery.js';
|
|
9
9
|
import { resolveAnalytics } from './analytics.js';
|
|
10
|
-
import { navigationCounts } from './compiler-config.js';
|
|
10
|
+
import { navigationCounts, navigationHeading } from './compiler-config.js';
|
|
11
11
|
import { validateAuthoringGuidance } from './authoring-guidance.js';
|
|
12
12
|
import { loadFreshness } from './freshness.js';
|
|
13
13
|
import { resolveAgentDiscovery } from './agent-discovery.js';
|
|
@@ -226,6 +226,7 @@ export async function compile(configFile, destination, provenance = {}, options
|
|
|
226
226
|
// `demo` ships it hidden until a page is opened with ?theme-switcher.
|
|
227
227
|
const themeSwitcherSetting = configField(htmlOptions, 'themeSwitcher') ?? false;
|
|
228
228
|
navigationCounts(config.outputs);
|
|
229
|
+
navigationHeading(config.outputs);
|
|
229
230
|
if (typeof themeSwitcherSetting !== 'boolean' && themeSwitcherSetting !== 'demo')
|
|
230
231
|
throw new Error('outputs.html.themeSwitcher must be true, false, or demo');
|
|
231
232
|
const themeSwitcher = themeSwitcherSetting !== false;
|
package/dist/component-names.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const builtinComponentNames = new Set('FrontmatterReference ForPublication ReaderSection ReaderChoice ReaderFilter Only Badge Kbd Tooltip InfoHint Progress Math ActionLink Button DefinitionList Term Definition Steps Timeline Milestone FileTree Tree Folder File Table CollapsibleGroup Collapsible Section Hero ThemePreview Diagram Image Tabs Walkthrough Step Tab Fields Field CookbookChooser DocCard DocCards DocCollection RelatedReading ApiExample ApiSchema Snippet Var DocLink Callout'.split(' '));
|
|
1
|
+
export const builtinComponentNames = new Set('Anchor FrontmatterReference ForPublication ReaderSection ReaderChoice ReaderFilter Only Badge Kbd Tooltip InfoHint Progress Math ActionLink Button DefinitionList Term Definition Steps Timeline Milestone FileTree Tree Folder File Table CollapsibleGroup Collapsible Section Hero ThemePreview Diagram Image Tabs Walkthrough Step Tab Fields Field CookbookChooser DocCard DocCards DocCollection RelatedReading ApiExample ApiSchema Snippet Var DocLink Callout'.split(' '));
|
|
@@ -15,6 +15,7 @@ import { validateReaderFilterControl, validateReaderSelection } from './reader-f
|
|
|
15
15
|
import { walkthroughComponent } from './walkthrough.js';
|
|
16
16
|
import { procedureComponent } from './procedure-components.js';
|
|
17
17
|
import { parsePlainMarkdown, paragraph, link, text } from './semantic-markdown.js';
|
|
18
|
+
import { lowerSectionMarkers, sectionIdentities } from './section-identity.js';
|
|
18
19
|
function isMdxRoot(tree) {
|
|
19
20
|
return !!tree && typeof tree === 'object'
|
|
20
21
|
&& tree.type === 'root'
|
|
@@ -58,6 +59,7 @@ function literalAttributes(attributes) {
|
|
|
58
59
|
export function transformMdx(tree, current, registry, snippets = new Map(), projectValues = {}, uses = [], calloutTypes = resolveCalloutTypes(undefined, current.file), projectRoot, readerConfig, frontmatterConfig = {}, publicationConfig, badgeVariants = resolveBadgeVariants(undefined, current.file), componentPlugins = new Map()) {
|
|
59
60
|
if (!isMdxRoot(tree))
|
|
60
61
|
throw new Error(`${current.file}: parsed content must be a root with children`);
|
|
62
|
+
tree = lowerSectionMarkers(tree, current.file);
|
|
61
63
|
let pluginDepth = 0;
|
|
62
64
|
let tabsIndex = 0;
|
|
63
65
|
let walkthroughIndex = 0;
|
|
@@ -98,6 +100,8 @@ export function transformMdx(tree, current, registry, snippets = new Map(), proj
|
|
|
98
100
|
if (node.type === 'heading' && stack.length)
|
|
99
101
|
throw new Error(`${sourceFile}: snippet headings need an anchor adapter; put headings in the page`);
|
|
100
102
|
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
103
|
+
if (node.name === 'Anchor')
|
|
104
|
+
throw new Error(`${sourceFile}: <Anchor> must be the single trailing marker of an authored root level-two heading`);
|
|
101
105
|
if (node.name && componentPlugins.has(node.name)) {
|
|
102
106
|
const start = node.position?.start;
|
|
103
107
|
const location = `${sourceFile}${start ? `:${start.line}:${start.column}` : ''}: <${node.name}>`;
|
|
@@ -693,5 +697,7 @@ export function transformMdx(tree, current, registry, snippets = new Map(), proj
|
|
|
693
697
|
}
|
|
694
698
|
return node;
|
|
695
699
|
}
|
|
696
|
-
|
|
700
|
+
const transformed = walk(tree, {}, [], current.file);
|
|
701
|
+
sectionIdentities(transformed, current.file);
|
|
702
|
+
return transformed;
|
|
697
703
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Browser-safe delivery contracts. These types do not import the compiler. */
|
|
2
|
+
export type ContentPublication = {
|
|
3
|
+
channel: string;
|
|
4
|
+
version: string;
|
|
5
|
+
language: string;
|
|
6
|
+
};
|
|
7
|
+
export type ContentFormat = 'html' | 'markdown';
|
|
8
|
+
export type ContentRef = {
|
|
9
|
+
kind: 'page';
|
|
10
|
+
documentId: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'section';
|
|
13
|
+
documentId: string;
|
|
14
|
+
sectionId: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'snippet';
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
export type ContentFile = {
|
|
20
|
+
path: string;
|
|
21
|
+
bytes: number;
|
|
22
|
+
digest: string;
|
|
23
|
+
};
|
|
24
|
+
export type ContentAsset = ContentFile & {
|
|
25
|
+
mediaType: string;
|
|
26
|
+
};
|
|
27
|
+
export type ContentAnchor = {
|
|
28
|
+
id: string;
|
|
29
|
+
exportedId: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
};
|
|
32
|
+
export type ContentLink = {
|
|
33
|
+
key: string;
|
|
34
|
+
kind: 'resource' | 'canonical' | 'external' | 'asset';
|
|
35
|
+
href: string;
|
|
36
|
+
ref?: ContentRef;
|
|
37
|
+
fragment?: string;
|
|
38
|
+
query?: string;
|
|
39
|
+
asset?: string;
|
|
40
|
+
};
|
|
41
|
+
export type ContentRepresentation = ContentAsset & {
|
|
42
|
+
anchors: ContentAnchor[];
|
|
43
|
+
links: ContentLink[];
|
|
44
|
+
};
|
|
45
|
+
export type ContentResource = {
|
|
46
|
+
ref: ContentRef;
|
|
47
|
+
publication: ContentPublication;
|
|
48
|
+
title: string;
|
|
49
|
+
summary: string;
|
|
50
|
+
type: string;
|
|
51
|
+
canonicalUrl?: string;
|
|
52
|
+
source?: {
|
|
53
|
+
documentId?: string;
|
|
54
|
+
snippetId?: string;
|
|
55
|
+
valuesDigest?: string;
|
|
56
|
+
};
|
|
57
|
+
representations: Partial<Record<ContentFormat, ContentRepresentation>>;
|
|
58
|
+
assets: string[];
|
|
59
|
+
externalDependencies: Array<{
|
|
60
|
+
url: string;
|
|
61
|
+
kind: 'image';
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
export type ContentManifest = {
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
kind: 'shoalrun-content-export';
|
|
67
|
+
digest: string;
|
|
68
|
+
publicBase: string;
|
|
69
|
+
publication: ContentPublication;
|
|
70
|
+
review: {
|
|
71
|
+
status: 'ok' | 'required';
|
|
72
|
+
apis: Array<{
|
|
73
|
+
apiId: string;
|
|
74
|
+
digest: string;
|
|
75
|
+
}>;
|
|
76
|
+
};
|
|
77
|
+
resources: ContentResource[];
|
|
78
|
+
assets: ContentAsset[];
|
|
79
|
+
files: ContentFile[];
|
|
80
|
+
diagnostics: Array<{
|
|
81
|
+
code: string;
|
|
82
|
+
documentId?: string;
|
|
83
|
+
message: string;
|
|
84
|
+
}>;
|
|
85
|
+
};
|
|
86
|
+
export type ContentResult = {
|
|
87
|
+
resource: Readonly<ContentResource>;
|
|
88
|
+
representation: Readonly<ContentRepresentation>;
|
|
89
|
+
format: ContentFormat;
|
|
90
|
+
content: string;
|
|
91
|
+
revision: string;
|
|
92
|
+
};
|
|
93
|
+
export interface ContentSnapshot {
|
|
94
|
+
readonly revision: string;
|
|
95
|
+
readonly publication: Readonly<ContentPublication>;
|
|
96
|
+
list(): readonly Readonly<ContentResource>[];
|
|
97
|
+
get(ref: ContentRef, options: {
|
|
98
|
+
format: ContentFormat;
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
}): Promise<ContentResult>;
|
|
101
|
+
dispose(): void;
|
|
102
|
+
}
|
|
103
|
+
export type ContentErrorCode = 'invalid_manifest' | 'not_found' | 'unsupported_format' | 'stale_revision' | 'network_error' | 'invalid_content' | 'disposed';
|
|
104
|
+
export declare class ContentDeliveryError extends Error {
|
|
105
|
+
readonly code: ContentErrorCode;
|
|
106
|
+
constructor(code: ContentErrorCode, message: string);
|
|
107
|
+
}
|
|
108
|
+
export declare function contentRefKey(ref: ContentRef): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class ContentDeliveryError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'ContentDeliveryError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function contentRefKey(ref) {
|
|
10
|
+
switch (ref.kind) {
|
|
11
|
+
case 'page': return JSON.stringify(['page', ref.documentId]);
|
|
12
|
+
case 'section': return JSON.stringify(['section', ref.documentId, ref.sectionId]);
|
|
13
|
+
case 'snippet': return JSON.stringify(['snippet', ref.id]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ContentDeliveryError } from './content-delivery-contract.js';
|
|
2
|
+
export function freeze(value) { if (value && typeof value === 'object') {
|
|
3
|
+
Object.freeze(value);
|
|
4
|
+
for (const child of Object.values(value))
|
|
5
|
+
freeze(child);
|
|
6
|
+
} return value; }
|
|
7
|
+
export async function sha256(bytes) { return [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))].map(byte => byte.toString(16).padStart(2, '0')).join(''); }
|
|
8
|
+
export async function readBounded(response, limit, signal) {
|
|
9
|
+
const declared = response.headers.get('content-length');
|
|
10
|
+
if (declared && (!/^\d+$/.test(declared) || Number(declared) > limit)) {
|
|
11
|
+
await response.body?.cancel().catch(() => { });
|
|
12
|
+
throw new ContentDeliveryError('invalid_content', 'Response exceeds the content size limit.');
|
|
13
|
+
}
|
|
14
|
+
const reader = response.body?.getReader();
|
|
15
|
+
if (!reader)
|
|
16
|
+
return new Uint8Array();
|
|
17
|
+
const abort = () => { void reader.cancel(signal.reason).catch(() => { }); };
|
|
18
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
try {
|
|
22
|
+
while (true) {
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
const next = await reader.read();
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
if (next.done)
|
|
27
|
+
break;
|
|
28
|
+
size += next.value.byteLength;
|
|
29
|
+
if (size > limit) {
|
|
30
|
+
await reader.cancel();
|
|
31
|
+
throw new ContentDeliveryError('invalid_content', 'Response exceeds the content size limit.');
|
|
32
|
+
}
|
|
33
|
+
chunks.push(next.value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
signal.removeEventListener('abort', abort);
|
|
38
|
+
reader.releaseLock();
|
|
39
|
+
}
|
|
40
|
+
const bytes = new Uint8Array(size);
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
bytes.set(chunk, offset);
|
|
44
|
+
offset += chunk.length;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ContentManifest, ContentRef } from './content-delivery-contract.js';
|
|
2
|
+
export declare const safeContentPath: (value: any) => value is string;
|
|
3
|
+
export declare const validContentPublication: (value: any) => boolean;
|
|
4
|
+
export declare function validContentRef(value: any): value is ContentRef;
|
|
5
|
+
export declare function validateContentManifest(value: any, maxResources?: number, maxResourceBytes?: number): asserts value is ContentManifest;
|
|
6
|
+
/** Shared metadata validation; transport supplies its file/asset inventory policy. */
|
|
7
|
+
export declare function validateContentResources(resources: any[], expectedPublication: unknown, assets: Set<string>, checkRepresentation: (value: any) => boolean, publicBase?: string): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ContentDeliveryError, contentRefKey } from './content-delivery-contract.js';
|
|
2
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
const keys = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key));
|
|
4
|
+
const nonempty = (value) => typeof value === 'string' && value.length > 0 && value.length <= 2048;
|
|
5
|
+
const digestPattern = /^[a-f0-9]{64}$/;
|
|
6
|
+
const pathPattern = /^(?:NOTICES\.txt|(?:content|assets)\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*)$/;
|
|
7
|
+
export const safeContentPath = (value) => typeof value === 'string' && pathPattern.test(value) && value.split('/').every(part => part !== '.' && part !== '..');
|
|
8
|
+
const invalid = (message) => { throw new ContentDeliveryError('invalid_manifest', message); };
|
|
9
|
+
export const validContentPublication = (value) => object(value) && Object.keys(value).length === 3 && ['channel', 'version', 'language'].every(key => nonempty(value[key]));
|
|
10
|
+
const samePublication = (left, right) => ['channel', 'version', 'language'].every(key => left[key] === right[key]);
|
|
11
|
+
export function validContentRef(value) {
|
|
12
|
+
if (!object(value))
|
|
13
|
+
return false;
|
|
14
|
+
if (value.kind === 'page')
|
|
15
|
+
return Object.keys(value).length === 2 && nonempty(value.documentId);
|
|
16
|
+
if (value.kind === 'section')
|
|
17
|
+
return Object.keys(value).length === 3 && nonempty(value.documentId) && typeof value.sectionId === 'string' && /^[a-z0-9][a-z0-9-]{0,511}$/.test(value.sectionId);
|
|
18
|
+
return value.kind === 'snippet' && Object.keys(value).length === 2 && nonempty(value.id);
|
|
19
|
+
}
|
|
20
|
+
function safeExternal(value, image = false) {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
return typeof value === 'string' && !url.username && !url.password && (image ? ['http:', 'https:'] : ['http:', 'https:', 'mailto:', 'tel:']).includes(url.protocol);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function validateContentManifest(value, maxResources = 10_000, maxResourceBytes = 8_000_000) {
|
|
30
|
+
if (!keys(value, ['schemaVersion', 'kind', 'digest', 'publicBase', 'publication', 'review', 'resources', 'assets', 'files', 'diagnostics']) || value.schemaVersion !== 1 || value.kind !== 'shoalrun-content-export' || !digestPattern.test(value.digest ?? ''))
|
|
31
|
+
invalid('Unsupported content manifest.');
|
|
32
|
+
if (typeof value.publicBase !== 'string' || !/^\/(?:[A-Za-z0-9_~.-]+\/)*$/.test(value.publicBase) || value.publicBase.split('/').some((part) => part === '.' || part === '..'))
|
|
33
|
+
invalid('Invalid content mount.');
|
|
34
|
+
if (!validContentPublication(value.publication))
|
|
35
|
+
invalid('Invalid publication.');
|
|
36
|
+
if (!keys(value.review, ['status', 'apis']) || value.review.status !== 'ok' || !Array.isArray(value.review.apis) || value.review.apis.some((api) => !keys(api, ['apiId', 'digest']) || !nonempty(api.apiId) || !digestPattern.test(api.digest ?? '')))
|
|
37
|
+
invalid('Content must have approved API review metadata.');
|
|
38
|
+
if (!Array.isArray(value.resources) || !value.resources.length || value.resources.length > maxResources || !Array.isArray(value.files) || !Array.isArray(value.assets) || !Array.isArray(value.diagnostics))
|
|
39
|
+
invalid('Invalid manifest inventory.');
|
|
40
|
+
const files = new Map();
|
|
41
|
+
const checkFile = (file) => object(file) && safeContentPath(file.path) && Number.isSafeInteger(file.bytes) && file.bytes >= 0 && file.bytes <= maxResourceBytes && digestPattern.test(file.digest ?? '');
|
|
42
|
+
for (const file of value.files) {
|
|
43
|
+
if (!keys(file, ['path', 'bytes', 'digest']) || !checkFile(file) || files.has(file.path))
|
|
44
|
+
invalid('Invalid or duplicate file.');
|
|
45
|
+
files.set(file.path, file);
|
|
46
|
+
}
|
|
47
|
+
const listed = (file) => checkFile(file) && files.has(file.path) && files.get(file.path).bytes === file.bytes && files.get(file.path).digest === file.digest;
|
|
48
|
+
const assets = new Set();
|
|
49
|
+
for (const asset of value.assets) {
|
|
50
|
+
if (!keys(asset, ['path', 'bytes', 'digest', 'mediaType']) || !listed(asset) || !asset.path.startsWith('assets/') || !nonempty(asset.mediaType) || assets.has(asset.path))
|
|
51
|
+
invalid('Invalid or duplicate asset.');
|
|
52
|
+
assets.add(asset.path);
|
|
53
|
+
}
|
|
54
|
+
validateContentResources(value.resources, value.publication, assets, listed, value.publicBase);
|
|
55
|
+
if (value.diagnostics.some((item) => !keys(item, ['code', 'documentId', 'message']) || typeof item.code !== 'string' || item.documentId !== undefined && typeof item.documentId !== 'string' || typeof item.message !== 'string'))
|
|
56
|
+
invalid('Invalid diagnostics.');
|
|
57
|
+
}
|
|
58
|
+
/** Shared metadata validation; transport supplies its file/asset inventory policy. */
|
|
59
|
+
export function validateContentResources(resources, expectedPublication, assets, checkRepresentation, publicBase = '/') {
|
|
60
|
+
const refs = new Set();
|
|
61
|
+
const links = [];
|
|
62
|
+
for (const resource of resources) {
|
|
63
|
+
if (!keys(resource, ['ref', 'publication', 'title', 'summary', 'type', 'canonicalUrl', 'source', 'representations', 'assets', 'externalDependencies']) || !validContentRef(resource.ref) || !validContentPublication(resource.publication) || !samePublication(resource.publication, expectedPublication) || typeof resource.title !== 'string' || typeof resource.summary !== 'string' || typeof resource.type !== 'string')
|
|
64
|
+
invalid('Invalid resource metadata.');
|
|
65
|
+
const key = contentRefKey(resource.ref);
|
|
66
|
+
if (refs.has(key))
|
|
67
|
+
invalid('Duplicate content reference.');
|
|
68
|
+
refs.add(key);
|
|
69
|
+
if (resource.canonicalUrl !== undefined && !safeExternal(resource.canonicalUrl, true))
|
|
70
|
+
invalid('Invalid canonical URL.');
|
|
71
|
+
if (resource.source !== undefined && (!object(resource.source) || !Object.keys(resource.source).length || Object.keys(resource.source).some(key => !['documentId', 'snippetId', 'valuesDigest'].includes(key)) || Object.values(resource.source).some(value => !nonempty(value))))
|
|
72
|
+
invalid('Invalid resource source.');
|
|
73
|
+
if (resource.source?.valuesDigest !== undefined && !digestPattern.test(resource.source.valuesDigest))
|
|
74
|
+
invalid('Invalid snippet values digest.');
|
|
75
|
+
if (!Array.isArray(resource.assets) || new Set(resource.assets).size !== resource.assets.length || resource.assets.some((asset) => !assets.has(asset)))
|
|
76
|
+
invalid('Unknown resource asset.');
|
|
77
|
+
if (!Array.isArray(resource.externalDependencies) || resource.externalDependencies.some((dependency) => !keys(dependency, ['url', 'kind']) || dependency.kind !== 'image' || !safeExternal(dependency.url, true)))
|
|
78
|
+
invalid('Invalid external dependency.');
|
|
79
|
+
if (!object(resource.representations) || !Object.keys(resource.representations).length || Object.keys(resource.representations).some(format => !['html', 'markdown'].includes(format)))
|
|
80
|
+
invalid('Invalid representations.');
|
|
81
|
+
for (const [format, rep] of Object.entries(resource.representations)) {
|
|
82
|
+
if (!keys(rep, ['path', 'bytes', 'digest', 'mediaType', 'anchors', 'links']) || !checkRepresentation(rep) || !rep.path.startsWith('content/') || rep.mediaType !== (format === 'html' ? 'text/html' : 'text/markdown') || !Array.isArray(rep.anchors) || !Array.isArray(rep.links))
|
|
83
|
+
invalid('Invalid representation file.');
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
const exportedIds = new Set();
|
|
86
|
+
const linkKeys = new Set();
|
|
87
|
+
for (const anchor of rep.anchors) {
|
|
88
|
+
if (!keys(anchor, ['id', 'exportedId', 'title']) || !nonempty(anchor.id) || !nonempty(anchor.exportedId) || ids.has(anchor.id) || exportedIds.has(anchor.exportedId) || anchor.title !== undefined && typeof anchor.title !== 'string')
|
|
89
|
+
invalid('Invalid representation anchor.');
|
|
90
|
+
ids.add(anchor.id);
|
|
91
|
+
exportedIds.add(anchor.exportedId);
|
|
92
|
+
}
|
|
93
|
+
for (const link of rep.links) {
|
|
94
|
+
if (!keys(link, ['key', 'kind', 'href', 'ref', 'fragment', 'query', 'asset']) || !nonempty(link.key) || linkKeys.has(link.key) || typeof link.href !== 'string' || !['resource', 'canonical', 'external', 'asset'].includes(link.kind))
|
|
95
|
+
invalid('Invalid representation link.');
|
|
96
|
+
linkKeys.add(link.key);
|
|
97
|
+
if (link.kind === 'resource' && !validContentRef(link.ref) || link.kind === 'asset' && !assets.has(link.asset) || ['canonical', 'external'].includes(link.kind) && !safeExternal(link.href))
|
|
98
|
+
invalid('Invalid link destination.');
|
|
99
|
+
if (link.fragment !== undefined && typeof link.fragment !== 'string' || link.query !== undefined && typeof link.query !== 'string')
|
|
100
|
+
invalid('Invalid link suffix.');
|
|
101
|
+
if (['resource', 'asset'].includes(link.kind)) {
|
|
102
|
+
const linkPath = link.href.split(/[?#]/, 1)[0];
|
|
103
|
+
if (!link.href.startsWith('#') && !link.href.startsWith(publicBase) || /\\|%2e|%2f|%5c/i.test(linkPath))
|
|
104
|
+
invalid('Link leaves content mount.');
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(link.href, 'https://content.invalid' + publicBase);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return invalid('Invalid internal link URL.');
|
|
111
|
+
}
|
|
112
|
+
if (url.origin !== 'https://content.invalid' || !url.pathname.startsWith(publicBase))
|
|
113
|
+
invalid('Link leaves content mount.');
|
|
114
|
+
}
|
|
115
|
+
links.push(link);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const link of links)
|
|
120
|
+
if (link.kind === 'resource' && !refs.has(contentRefKey(link.ref)))
|
|
121
|
+
invalid('Link references absent content.');
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ContentSnapshot } from './content-delivery-contract.js';
|
|
2
|
+
export * from './content-delivery-contract.js';
|
|
3
|
+
export * from './content-rest-contract.js';
|
|
4
|
+
export { validateContentManifest } from './content-delivery-validation.js';
|
|
5
|
+
export { mountContent } from './content-mount.js';
|
|
6
|
+
export type { ContentMount } from './content-mount.js';
|
|
7
|
+
export type ContentDeliveryLimits = {
|
|
8
|
+
manifestBytes?: number;
|
|
9
|
+
resourceBytes?: number;
|
|
10
|
+
resources?: number;
|
|
11
|
+
};
|
|
12
|
+
export type ManifestContentSourceOptions = {
|
|
13
|
+
manifestUrl: string | URL;
|
|
14
|
+
fetch?: typeof globalThis.fetch;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
limits?: ContentDeliveryLimits;
|
|
17
|
+
};
|
|
18
|
+
/** Opens one immutable manifest revision. The caller owns trust in the build/host. */
|
|
19
|
+
export declare function openManifestContentSource(options: ManifestContentSourceOptions): Promise<ContentSnapshot>;
|
|
20
|
+
export { openRestContentSource } from './content-rest-client.js';
|
|
21
|
+
export type { RestContentSourceOptions } from './content-rest-client.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ContentDeliveryError, contentRefKey } from './content-delivery-contract.js';
|
|
2
|
+
export * from './content-delivery-contract.js';
|
|
3
|
+
export * from './content-rest-contract.js';
|
|
4
|
+
import { validateContentManifest, validContentRef } from './content-delivery-validation.js';
|
|
5
|
+
export { validateContentManifest } from './content-delivery-validation.js';
|
|
6
|
+
import { freeze, sha256, readBounded } from './content-delivery-transport.js';
|
|
7
|
+
const invalid = (message) => { throw new ContentDeliveryError('invalid_manifest', message); };
|
|
8
|
+
export { mountContent } from './content-mount.js';
|
|
9
|
+
/** Opens one immutable manifest revision. The caller owns trust in the build/host. */
|
|
10
|
+
export async function openManifestContentSource(options) {
|
|
11
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
12
|
+
let manifestUrl;
|
|
13
|
+
try {
|
|
14
|
+
manifestUrl = new URL(options.manifestUrl, typeof location === 'undefined' ? undefined : location.href);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new ContentDeliveryError('invalid_manifest', 'Manifest URL must be a valid HTTP(S) URL.');
|
|
18
|
+
}
|
|
19
|
+
if (!['http:', 'https:'].includes(manifestUrl.protocol) || manifestUrl.username || manifestUrl.password || manifestUrl.hash || manifestUrl.search)
|
|
20
|
+
invalid('Manifest URL must be an HTTP(S) URL without credentials, query, or fragment.');
|
|
21
|
+
if (typeof location !== 'undefined' && manifestUrl.origin !== location.origin)
|
|
22
|
+
invalid('Browser content must be served from the application origin.');
|
|
23
|
+
const manifestBytes = options.limits?.manifestBytes ?? 5_000_000;
|
|
24
|
+
const resourceBytes = options.limits?.resourceBytes ?? 8_000_000;
|
|
25
|
+
const resources = options.limits?.resources ?? 10_000;
|
|
26
|
+
for (const limit of [manifestBytes, resourceBytes, resources])
|
|
27
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
28
|
+
invalid('Limits must be positive safe integers.');
|
|
29
|
+
const lifetime = new AbortController();
|
|
30
|
+
let disposed = false;
|
|
31
|
+
const openSignal = options.signal ? AbortSignal.any([options.signal, lifetime.signal]) : lifetime.signal;
|
|
32
|
+
async function request(url, mediaType, limit, signal, revision = false) {
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetcher(url.href, { signal, redirect: 'error', credentials: 'same-origin', cache: 'no-store', headers: { Accept: mediaType } });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
throw new ContentDeliveryError('network_error', 'Cannot fetch content.');
|
|
41
|
+
}
|
|
42
|
+
const rejectResponse = async (code, message) => { await response.body?.cancel().catch(() => { }); throw new ContentDeliveryError(code, message); };
|
|
43
|
+
if (response.status === 404)
|
|
44
|
+
return rejectResponse(revision ? 'stale_revision' : 'not_found', 'Content is unavailable.');
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
return rejectResponse('network_error', `Content request failed (${response.status}).`);
|
|
47
|
+
if (response.redirected || response.url && response.url !== url.href)
|
|
48
|
+
return rejectResponse('invalid_content', 'Redirected content is not accepted.');
|
|
49
|
+
if (response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() !== mediaType)
|
|
50
|
+
return rejectResponse('invalid_content', 'Unexpected content type.');
|
|
51
|
+
try {
|
|
52
|
+
return await readBounded(response, limit, signal);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
signal.throwIfAborted();
|
|
56
|
+
if (error instanceof ContentDeliveryError)
|
|
57
|
+
throw error;
|
|
58
|
+
throw new ContentDeliveryError('network_error', 'Content response was interrupted.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let manifest;
|
|
62
|
+
try {
|
|
63
|
+
manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await request(manifestUrl, 'application/json', manifestBytes, openSignal)));
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof ContentDeliveryError || openSignal.aborted)
|
|
67
|
+
throw error;
|
|
68
|
+
invalid('Manifest is not valid UTF-8 JSON.');
|
|
69
|
+
}
|
|
70
|
+
validateContentManifest(manifest, resources, resourceBytes);
|
|
71
|
+
const { digest, ...body } = manifest;
|
|
72
|
+
if (await sha256(new TextEncoder().encode(JSON.stringify(body))) !== digest)
|
|
73
|
+
invalid('Manifest digest does not match its contents.');
|
|
74
|
+
openSignal.throwIfAborted();
|
|
75
|
+
const mount = new URL(manifest.publicBase, manifestUrl.origin);
|
|
76
|
+
if (!manifestUrl.pathname.startsWith(mount.pathname))
|
|
77
|
+
invalid('Manifest is outside its declared content mount.');
|
|
78
|
+
freeze(manifest);
|
|
79
|
+
const byRef = new Map(manifest.resources.map(resource => [contentRefKey(resource.ref), resource]));
|
|
80
|
+
const active = () => { if (disposed)
|
|
81
|
+
throw new ContentDeliveryError('disposed', 'Content snapshot has been disposed.'); };
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
revision: manifest.digest, publication: manifest.publication,
|
|
84
|
+
list() { active(); return manifest.resources; },
|
|
85
|
+
async get(ref, getOptions) {
|
|
86
|
+
active();
|
|
87
|
+
if (!validContentRef(ref))
|
|
88
|
+
throw new ContentDeliveryError('not_found', 'Invalid content reference.');
|
|
89
|
+
const resource = byRef.get(contentRefKey(ref));
|
|
90
|
+
if (!resource)
|
|
91
|
+
throw new ContentDeliveryError('not_found', 'No matching content in this publication.');
|
|
92
|
+
const format = getOptions?.format;
|
|
93
|
+
if (!['html', 'markdown'].includes(format))
|
|
94
|
+
throw new ContentDeliveryError('unsupported_format', 'The requested representation is unavailable.');
|
|
95
|
+
const representation = resource.representations[format];
|
|
96
|
+
if (!representation)
|
|
97
|
+
throw new ContentDeliveryError('unsupported_format', 'The requested representation is unavailable.');
|
|
98
|
+
const signal = getOptions.signal ? AbortSignal.any([lifetime.signal, getOptions.signal]) : lifetime.signal;
|
|
99
|
+
const bytes = await request(new URL(representation.path, mount), representation.mediaType, resourceBytes, signal, true);
|
|
100
|
+
if (bytes.byteLength !== representation.bytes || await sha256(bytes) !== representation.digest)
|
|
101
|
+
throw new ContentDeliveryError('stale_revision', 'Representation differs from the opened revision.');
|
|
102
|
+
signal.throwIfAborted();
|
|
103
|
+
active();
|
|
104
|
+
let content;
|
|
105
|
+
try {
|
|
106
|
+
content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new ContentDeliveryError('invalid_content', 'Content is not valid UTF-8.');
|
|
110
|
+
}
|
|
111
|
+
return Object.freeze({ resource, representation, format, content, revision: manifest.digest });
|
|
112
|
+
},
|
|
113
|
+
dispose() { if (!disposed) {
|
|
114
|
+
disposed = true;
|
|
115
|
+
lifetime.abort(new ContentDeliveryError('disposed', 'Content snapshot has been disposed.'));
|
|
116
|
+
} },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export { openRestContentSource } from './content-rest-client.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Expanded snippets may share nodes; give every authored occurrence its own identity. */
|
|
2
|
+
export declare function isolateExportTree(value: any): any;
|
|
3
|
+
/** Stable internal provenance survives cloning, slicing, and portable lowering. */
|
|
4
|
+
export declare function markExportSource(tree: any): void;
|
|
5
|
+
/** Map semantic heading provenance to the actual IDs assigned by the renderer. */
|
|
6
|
+
export declare function exportHeadingInventory(tree: any, reservedIds?: Iterable<string>): {
|
|
7
|
+
headings: Map<string, string>;
|
|
8
|
+
ids: Set<string>;
|
|
9
|
+
};
|
|
10
|
+
/** HTML keeps source IDs; Markdown keeps clean headings and maps source to local IDs. */
|
|
11
|
+
export declare function preserveExportHeadingIds(tree: any, source: ReturnType<typeof exportHeadingInventory>, format: 'html' | 'markdown'): Map<string, string>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5';
|
|
2
|
+
import { renderHtmlWithHeadings } from './html-renderer.js';
|
|
3
|
+
/** Expanded snippets may share nodes; give every authored occurrence its own identity. */
|
|
4
|
+
export function isolateExportTree(value) {
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value.map(isolateExportTree);
|
|
7
|
+
if (value && typeof value === 'object')
|
|
8
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, isolateExportTree(item)]));
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
/** Stable internal provenance survives cloning, slicing, and portable lowering. */
|
|
12
|
+
export function markExportSource(tree) {
|
|
13
|
+
let index = 0;
|
|
14
|
+
const walk = (node) => {
|
|
15
|
+
if (!node || typeof node !== 'object')
|
|
16
|
+
return;
|
|
17
|
+
if (Array.isArray(node)) {
|
|
18
|
+
node.forEach(walk);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (typeof node.type === 'string')
|
|
22
|
+
node.data = { ...node.data, exportSourceKey: `source-${index++}` };
|
|
23
|
+
for (const [key, value] of Object.entries(node))
|
|
24
|
+
if (!['data', 'position'].includes(key))
|
|
25
|
+
walk(value);
|
|
26
|
+
};
|
|
27
|
+
walk(tree);
|
|
28
|
+
}
|
|
29
|
+
/** Map semantic heading provenance to the actual IDs assigned by the renderer. */
|
|
30
|
+
export function exportHeadingInventory(tree, reservedIds = []) {
|
|
31
|
+
const copy = structuredClone(tree);
|
|
32
|
+
const mark = (node) => {
|
|
33
|
+
if (node.data?.exportSourceKey)
|
|
34
|
+
node.data.hProperties = { ...node.data.hProperties, 'data-export-source-key': node.data.exportSourceKey };
|
|
35
|
+
for (const child of node.children ?? [])
|
|
36
|
+
mark(child);
|
|
37
|
+
};
|
|
38
|
+
mark(copy);
|
|
39
|
+
const parsed = parseFragment(renderHtmlWithHeadings(copy, undefined, reservedIds).html);
|
|
40
|
+
const headings = new Map();
|
|
41
|
+
const ids = new Set();
|
|
42
|
+
const visit = (node) => {
|
|
43
|
+
const id = node.attrs?.find((attr) => attr.name === 'id')?.value;
|
|
44
|
+
if (id) {
|
|
45
|
+
ids.add(id);
|
|
46
|
+
const key = node.attrs?.find((attr) => attr.name === 'data-export-source-key')?.value;
|
|
47
|
+
if (/^h[2-6]$/.test(node.tagName ?? '') && key)
|
|
48
|
+
headings.set(key, id);
|
|
49
|
+
}
|
|
50
|
+
for (const child of node.childNodes ?? [])
|
|
51
|
+
visit(child);
|
|
52
|
+
};
|
|
53
|
+
visit(parsed);
|
|
54
|
+
return { headings, ids };
|
|
55
|
+
}
|
|
56
|
+
/** HTML keeps source IDs; Markdown keeps clean headings and maps source to local IDs. */
|
|
57
|
+
export function preserveExportHeadingIds(tree, source, format) {
|
|
58
|
+
const local = exportHeadingInventory(tree);
|
|
59
|
+
const aliases = new Map();
|
|
60
|
+
const walk = (node) => {
|
|
61
|
+
const key = node.data?.exportSourceKey;
|
|
62
|
+
if (key && (node.type === 'heading' || /^h[2-6]$/.test(node.data?.hName ?? '')) && local.headings.has(key)) {
|
|
63
|
+
const original = source.headings.get(key);
|
|
64
|
+
if (!original)
|
|
65
|
+
throw new Error('Cannot determine full-source identity for an exported heading');
|
|
66
|
+
aliases.set(original, format === 'html' ? original : local.headings.get(key));
|
|
67
|
+
if (format === 'html')
|
|
68
|
+
node.data.hProperties = { ...node.data.hProperties, id: original };
|
|
69
|
+
}
|
|
70
|
+
for (const child of node.children ?? [])
|
|
71
|
+
walk(child);
|
|
72
|
+
};
|
|
73
|
+
walk(tree);
|
|
74
|
+
// Non-heading anchors (explicit aliases, footnotes, component IDs) retain IDs.
|
|
75
|
+
for (const id of local.ids)
|
|
76
|
+
if (!Array.from(local.headings.values()).includes(id) && source.ids.has(id))
|
|
77
|
+
aliases.set(id, id);
|
|
78
|
+
return aliases;
|
|
79
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
export declare const exportHelp = "Usage: shoalrun export [config] --out DIRECTORY [--target NAME]\n [--format markdown|html|both] [--document ID]...\n [--channel C --version V --language L] [--public-base /help/] [--review]\n\nExport
|
|
1
|
+
export declare const exportHelp = "Usage: shoalrun export [config] --out DIRECTORY [--target NAME]\n [--format markdown|html|both] [--document ID]...\n [--channel C --version V --language L] [--public-base /help/] [--review]\n [--previous-manifest FILE] [--fail-on-removed]\n\nExport selected pages, sections, and named snippets as Markdown and passive HTML.\nConfigure typed resources in an export target; --document selects whole pages.\nNavigation, facets, and a prior website build are not required.\n--review writes an API-review candidate; status needs-review exits 2 until pinned.\n--previous-manifest compares public references with an earlier reviewed export.\n--fail-on-removed requires that baseline and refuses to replace output if references disappear.\n";
|
|
2
2
|
export declare function runExportCommand(args: string[]): Promise<{
|
|
3
3
|
status: string;
|
|
4
4
|
output: string;
|
|
5
5
|
publication: import("./content-source.js").Variant;
|
|
6
6
|
formats: import("./content-export-config.js").ExportFormat[];
|
|
7
|
+
resources: number;
|
|
7
8
|
documents: number;
|
|
8
9
|
documentIds: string[];
|
|
10
|
+
comparison: import("./content-export-comparison.js").ContentExportComparison;
|
|
9
11
|
assets: number;
|
|
10
12
|
digest: string;
|
|
11
13
|
apiReview: {
|
|
@@ -14,7 +16,7 @@ export declare function runExportCommand(args: string[]): Promise<{
|
|
|
14
16
|
}[];
|
|
15
17
|
diagnostics: {
|
|
16
18
|
code: string;
|
|
17
|
-
documentId
|
|
19
|
+
documentId?: string;
|
|
18
20
|
message: string;
|
|
19
21
|
}[];
|
|
20
22
|
}>;
|