@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.
Files changed (193) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +45 -5
  3. package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +3 -1
  4. package/agent-docs/SNAPSHOT.json +5 -5
  5. package/agent-docs/api-docs/support.md +11 -10
  6. package/agent-docs/author/components.md +13 -1
  7. package/agent-docs/author/frontmatter-reference.md +28 -25
  8. package/agent-docs/author/frontmatter-reference.sections/choose-a-publication-and-visibility.md +3 -1
  9. package/agent-docs/author/frontmatter-reference.sections/control-output-details.md +3 -1
  10. package/agent-docs/author/frontmatter-reference.sections/global-defaults-and-enforced-values.md +3 -1
  11. package/agent-docs/author/frontmatter-reference.sections/html-markdown-and-agents.md +3 -1
  12. package/agent-docs/author/frontmatter-reference.sections/identify-and-route-a-page.md +3 -1
  13. package/agent-docs/author/frontmatter-reference.sections/point-agents-to-it.md +3 -1
  14. package/agent-docs/author/frontmatter-reference.sections/project-defined-fields.md +3 -1
  15. package/agent-docs/author/frontmatter-reference.sections/related-reading.md +3 -1
  16. package/agent-docs/author/frontmatter-reference.sections/use-the-local-files-while-authoring.md +3 -1
  17. package/agent-docs/author/frontmatter-reference.sections/when-the-files-update.md +3 -1
  18. package/agent-docs/author/frontmatter.md +5 -2
  19. package/agent-docs/author/frontmatter.sections/audience-badges.md +1 -1
  20. package/agent-docs/author/frontmatter.sections/give-deprecation-guidance.md +1 -1
  21. package/agent-docs/author/frontmatter.sections/global-defaults-and-enforced-values.md +1 -1
  22. package/agent-docs/author/frontmatter.sections/optional-native-fields.md +5 -2
  23. package/agent-docs/author/frontmatter.sections/publication-fields.md +1 -1
  24. package/agent-docs/author/frontmatter.sections/publishing-policy.md +1 -1
  25. package/agent-docs/author/frontmatter.sections/related-metadata-tasks.md +1 -1
  26. package/agent-docs/author/frontmatter.sections/related-reading.md +1 -1
  27. package/agent-docs/author/frontmatter.sections/required-after-inheritance.md +1 -1
  28. package/agent-docs/author/frontmatter.sections/seo-fields.md +1 -1
  29. package/agent-docs/author/frontmatter.sections/validation-rules.md +1 -1
  30. package/agent-docs/author/markdown-metadata.md +13 -5
  31. package/agent-docs/author/navigation.md +12 -1
  32. package/agent-docs/author/navigation.sections/arrange-authored-pages.md +1 -1
  33. package/agent-docs/author/navigation.sections/fill-an-api-tag-group.md +1 -1
  34. package/agent-docs/author/navigation.sections/hide-group-page-counts.md +12 -1
  35. package/agent-docs/author/navigation.sections/html-markdown-and-agents.md +1 -1
  36. package/agent-docs/author/navigation.sections/local-api-preview.md +1 -1
  37. package/agent-docs/author/navigation.sections/mix-guides-and-api-references.md +1 -1
  38. package/agent-docs/author/navigation.sections/optionally-split-a-large-menu-into-files.md +1 -1
  39. package/agent-docs/author/navigation.sections/reach-navigation-while-reading.md +1 -1
  40. package/agent-docs/author/navigation.sections/related-reading.md +1 -1
  41. package/agent-docs/author/navigation.sections/split-navigation-into-tabs.md +1 -1
  42. package/agent-docs/author/navigation.sections/subject-lifecycle-labels.md +1 -1
  43. package/agent-docs/author/templates.md +10 -2
  44. package/agent-docs/author/templates.sections/built-in-feature-templates.md +1 -1
  45. package/agent-docs/author/templates.sections/built-in-templates.md +1 -1
  46. package/agent-docs/author/templates.sections/choose-formatting-enforcement-for-your-project.md +1 -1
  47. package/agent-docs/author/templates.sections/create-or-edit-a-project-template.md +10 -2
  48. package/agent-docs/author/templates.sections/keep-the-four-shapes-distinct.md +1 -1
  49. package/agent-docs/author/templates.sections/project-added-templates.md +1 -1
  50. package/agent-docs/author/templates.sections/related-reading.md +1 -1
  51. package/agent-docs/author/templates.sections/scaffold-a-page.md +1 -1
  52. package/agent-docs/author/templates.sections/short-cli-and-derived-values.md +1 -1
  53. package/agent-docs/author/templates.sections/what-every-template-gives-you.md +1 -1
  54. package/agent-docs/author/workspace.md +7 -1
  55. package/agent-docs/author/writing-style.md +13 -1
  56. package/agent-docs/author/writing-style.sections/adapt-this-example.md +1 -1
  57. package/agent-docs/author/writing-style.sections/choose-components-by-reader-need.md +1 -1
  58. package/agent-docs/author/writing-style.sections/evidence-and-uncertainty.md +1 -1
  59. package/agent-docs/author/writing-style.sections/example-audience-and-scope.md +1 -1
  60. package/agent-docs/author/writing-style.sections/feature-availability.md +36 -0
  61. package/agent-docs/author/writing-style.sections/ingredients-recipes-and-cookbooks.md +1 -1
  62. package/agent-docs/author/writing-style.sections/page-purpose-and-structure.md +1 -1
  63. package/agent-docs/author/writing-style.sections/procedures-and-examples.md +1 -1
  64. package/agent-docs/author/writing-style.sections/related-reading.md +1 -1
  65. package/agent-docs/author/writing-style.sections/review-a-draft.md +1 -1
  66. package/agent-docs/author/writing-style.sections/reviewed-writing-examples.md +1 -1
  67. package/agent-docs/author/writing-style.sections/voice-and-explanations.md +1 -1
  68. package/agent-docs/changelog/2026-10-28.md +89 -0
  69. package/agent-docs/changelog.md +9 -1
  70. package/agent-docs/config/outputs.md +15 -11
  71. package/agent-docs/config/outputs.sections/exports.md +14 -11
  72. package/agent-docs/config/outputs.sections/metadata.md +1 -1
  73. package/agent-docs/config/outputs.sections/related-reading.md +1 -1
  74. package/agent-docs/content-delivery/agents.md +86 -0
  75. package/agent-docs/content-delivery/api.md +130 -0
  76. package/agent-docs/content-delivery/client.md +79 -0
  77. package/agent-docs/content-delivery/http-api/operations/get-content-representation-fa1d2e3f.md +394 -0
  78. package/agent-docs/content-delivery/http-api/operations/get-content-representation-head-4052375c.md +256 -0
  79. package/agent-docs/content-delivery/http-api/operations/get-content-resource-849f1c49.md +911 -0
  80. package/agent-docs/content-delivery/http-api/operations/get-content-resource-head-0a352acd.md +246 -0
  81. package/agent-docs/content-delivery/http-api/operations/list-content-resources-8e977ec7.md +913 -0
  82. package/agent-docs/content-delivery/http-api/operations/list-content-resources-head-b67cbad9.md +236 -0
  83. package/agent-docs/content-delivery/http-api.md +78 -0
  84. package/agent-docs/content-delivery/manifest.md +79 -0
  85. package/agent-docs/content-delivery/publish.md +72 -0
  86. package/agent-docs/content-delivery/render.md +73 -0
  87. package/agent-docs/content-delivery/rest.md +108 -0
  88. package/agent-docs/content-delivery/sections.md +110 -0
  89. package/agent-docs/content-delivery/select.md +100 -0
  90. package/agent-docs/content-delivery.md +79 -0
  91. package/agent-docs/documents.json +6748 -5073
  92. package/agent-docs/provenance.json +4 -4
  93. package/agent-docs/publish/content-export.md +32 -16
  94. package/agent-docs/publish/content-export.sections/assets-downloads-and-notices.md +3 -3
  95. package/agent-docs/publish/content-export.sections/check-the-result.md +2 -2
  96. package/agent-docs/publish/content-export.sections/export-selected-pages.md +12 -9
  97. package/agent-docs/publish/content-export.sections/inspect-the-manifest.md +5 -3
  98. package/agent-docs/publish/content-export.sections/prerequisites.md +3 -3
  99. package/agent-docs/publish/content-export.sections/related-reading.md +2 -2
  100. package/agent-docs/publish/content-export.sections/review-exported-api-material.md +3 -3
  101. package/agent-docs/publish/content-export.sections/try-the-help-drawer-example.md +3 -3
  102. package/agent-docs/publish/content-export.sections/upgrade-an-existing-integration.md +34 -0
  103. package/agent-docs/publish/content-export.sections/what-the-representations-retain.md +4 -4
  104. package/agent-docs/publish/mcp.md +3 -1
  105. package/agent-docs/publish/mcp.sections/check-budgets-and-errors.md +1 -1
  106. package/agent-docs/publish/mcp.sections/choose-the-documentation-source.md +1 -1
  107. package/agent-docs/publish/mcp.sections/configure-the-client.md +1 -1
  108. package/agent-docs/publish/mcp.sections/generate-a-vercel-endpoint.md +1 -1
  109. package/agent-docs/publish/mcp.sections/related-reading.md +3 -1
  110. package/agent-docs/publish/mcp.sections/search-then-read.md +1 -1
  111. package/agent-docs/publish/mcp.sections/serve-a-public-endpoint-on-node.md +1 -1
  112. package/agent-docs/publish/mcp.sections/verify-the-connection.md +1 -1
  113. package/agent-docs/publish/mcp.sections/why-offer-mcp-for-public-docs.md +1 -1
  114. package/agent-docs/publish/seo.md +16 -2
  115. package/agent-docs/publish/seo.sections/related-reading.md +28 -0
  116. package/agent-docs/publish/seo.sections/set-the-public-address.md +36 -0
  117. package/agent-docs/publish/seo.sections/what-agents-receive.md +28 -0
  118. package/agent-docs/publish/seo.sections/what-the-build-adds.md +42 -0
  119. package/agent-docs/publish/seo.sections/write-the-page-title-and-summary.md +43 -0
  120. package/agent-docs/search-index.json +1 -1
  121. package/agent-docs/styling/layouts.md +21 -3
  122. package/agent-docs/styling/layouts.sections/build-a-landing-page.md +6 -3
  123. package/agent-docs/styling/layouts.sections/build-a-splash-page.md +6 -3
  124. package/agent-docs/styling/layouts.sections/control-navigation-tabs.md +6 -3
  125. package/agent-docs/styling/layouts.sections/customize-the-hero.md +6 -3
  126. package/agent-docs/styling/layouts.sections/hero.md +6 -3
  127. package/agent-docs/styling/layouts.sections/hide-the-table-of-contents-or-give-content-more-room.md +54 -0
  128. package/agent-docs/styling/layouts.sections/html-markdown-and-agents.md +6 -3
  129. package/agent-docs/styling/layouts.sections/related-reading.md +6 -3
  130. package/dist/api.js +70 -3
  131. package/dist/authoring-hints.d.ts +3 -0
  132. package/dist/authoring-hints.js +35 -0
  133. package/dist/authoring-project.d.ts +1 -1
  134. package/dist/authoring-project.js +2 -0
  135. package/dist/authoring-validation.js +2 -0
  136. package/dist/code-samples.js +22 -11
  137. package/dist/compiler-config.d.ts +1 -0
  138. package/dist/compiler-config.js +13 -0
  139. package/dist/compiler.js +2 -1
  140. package/dist/component-names.js +1 -1
  141. package/dist/component-transform.js +7 -1
  142. package/dist/content-delivery-contract.d.ts +108 -0
  143. package/dist/content-delivery-contract.js +15 -0
  144. package/dist/content-delivery-transport.d.ts +3 -0
  145. package/dist/content-delivery-transport.js +47 -0
  146. package/dist/content-delivery-validation.d.ts +7 -0
  147. package/dist/content-delivery-validation.js +122 -0
  148. package/dist/content-delivery.d.ts +21 -0
  149. package/dist/content-delivery.js +119 -0
  150. package/dist/content-export-anchors.d.ts +11 -0
  151. package/dist/content-export-anchors.js +79 -0
  152. package/dist/content-export-command.d.ts +4 -2
  153. package/dist/content-export-command.js +15 -2
  154. package/dist/content-export-comparison.d.ts +16 -0
  155. package/dist/content-export-comparison.js +48 -0
  156. package/dist/content-export-config.d.ts +17 -0
  157. package/dist/content-export-config.js +47 -3
  158. package/dist/content-export-render.js +20 -1
  159. package/dist/content-export.d.ts +5 -1
  160. package/dist/content-export.js +193 -40
  161. package/dist/content-mount.d.ts +11 -0
  162. package/dist/content-mount.js +73 -0
  163. package/dist/content-rest-client.d.ts +12 -0
  164. package/dist/content-rest-client.js +162 -0
  165. package/dist/content-rest-contract.d.ts +27 -0
  166. package/dist/content-rest-contract.js +9 -0
  167. package/dist/content-rest-server.d.ts +7 -0
  168. package/dist/content-rest-server.js +99 -0
  169. package/dist/content-source.d.ts +3 -1
  170. package/dist/frontmatter.js +8 -7
  171. package/dist/html-renderer.js +25 -0
  172. package/dist/markdown-sections.d.ts +2 -1
  173. package/dist/markdown-sections.js +8 -18
  174. package/dist/page-renderer.js +5 -3
  175. package/dist/publication-plan.js +2 -1
  176. package/dist/scaffold-placeholders.d.ts +2 -0
  177. package/dist/scaffold-placeholders.js +19 -0
  178. package/dist/section-identity.d.ts +24 -0
  179. package/dist/section-identity.js +137 -0
  180. package/examples/content-delivery/README.md +87 -0
  181. package/examples/content-delivery/app.js +91 -0
  182. package/examples/content-delivery/content/billing.mdx +22 -0
  183. package/examples/content-delivery/docs.config.yaml +36 -0
  184. package/examples/content-delivery/index.html +25 -0
  185. package/examples/content-delivery/preview.mjs +86 -0
  186. package/examples/content-delivery/snippets/refund-window.mdx +3 -0
  187. package/examples/help-drawer/README.md +2 -2
  188. package/examples/help-drawer/app.js +3 -3
  189. package/package.json +6 -3
  190. package/schemas/content-export.schema.json +469 -33
  191. package/schemas/content-rest.openapi.json +1627 -0
  192. package/skills/shoalrun-author/SKILL.md +2 -0
  193. package/styles/site.css +9 -4
@@ -0,0 +1,27 @@
1
+ import type { ContentPublication, ContentRef, ContentResource } from './content-delivery-contract.js';
2
+ /** Public identity only: independent of publication, revision, and export filenames. */
3
+ export declare function contentResourceKey(ref: ContentRef): string;
4
+ export type ContentRestEntry = {
5
+ key: string;
6
+ resource: ContentResource;
7
+ };
8
+ export type ContentRestList = {
9
+ schemaVersion: 1;
10
+ revision: string;
11
+ publication: ContentPublication;
12
+ resources: ContentRestEntry[];
13
+ };
14
+ export type ContentRestResource = {
15
+ schemaVersion: 1;
16
+ revision: string;
17
+ publication: ContentPublication;
18
+ key: string;
19
+ resource: ContentResource;
20
+ };
21
+ export type ContentRestErrorCode = 'invalid_request' | 'not_found' | 'unsupported_format' | 'stale_revision' | 'method_not_allowed' | 'internal_error';
22
+ export type ContentRestError = {
23
+ error: {
24
+ code: ContentRestErrorCode;
25
+ message: string;
26
+ };
27
+ };
@@ -0,0 +1,9 @@
1
+ import { contentRefKey } from './content-delivery-contract.js';
2
+ /** Public identity only: independent of publication, revision, and export filenames. */
3
+ export function contentResourceKey(ref) {
4
+ const bytes = new TextEncoder().encode(contentRefKey(ref));
5
+ let binary = '';
6
+ for (const byte of bytes)
7
+ binary += String.fromCharCode(byte);
8
+ return 'k_' + btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { ContentSnapshot } from './content-delivery-contract.js';
2
+ export type ContentRestHandlerOptions = {
3
+ sources: readonly ContentSnapshot[];
4
+ basePath?: string;
5
+ };
6
+ /** Serve already-approved, pinned snapshots. The caller retains source ownership. */
7
+ export declare function createContentRestHandler(options: ContentRestHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,99 @@
1
+ import { ContentDeliveryError, contentRefKey } from './content-delivery-contract.js';
2
+ import { contentResourceKey } from './content-rest-contract.js';
3
+ const publicationKey = (publication) => JSON.stringify([publication.channel, publication.version, publication.language]);
4
+ const digest = async (bytes) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join('');
5
+ const encoder = new TextEncoder();
6
+ const validPublication = (value) => value && Object.keys(value).sort().join(',') === 'channel,language,version' && Object.values(value).every(part => typeof part === 'string' && part.length > 0 && part.length <= 2048);
7
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
8
+ const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
9
+ /** Serve already-approved, pinned snapshots. The caller retains source ownership. */
10
+ export function createContentRestHandler(options) {
11
+ const basePath = options.basePath ?? '/content/v1';
12
+ if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath))
13
+ throw new Error('Content REST basePath must be an absolute path without a trailing slash.');
14
+ const publications = new Map();
15
+ for (const source of options.sources) {
16
+ if (!/^[a-f0-9]{64}$/.test(source.revision) || !validPublication(source.publication))
17
+ throw new Error('Content REST source requires a valid revision and publication.');
18
+ const publication = structuredClone(source.publication);
19
+ const key = publicationKey(publication);
20
+ if (publications.has(key))
21
+ throw new Error('Content REST sources must have distinct publications.');
22
+ const resources = source.list().map(resource => ({ key: contentResourceKey(resource.ref), resource: structuredClone(resource) }));
23
+ if (resources.some(item => !validPublication(item.resource.publication) || publicationKey(item.resource.publication) !== key) || new Set(resources.map(item => item.key)).size !== resources.length)
24
+ throw new Error('Content REST source contains inconsistent publication or duplicate references.');
25
+ resources.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
26
+ publications.set(key, { source, publication, revision: source.revision, resources });
27
+ }
28
+ return async (request) => {
29
+ request.signal.throwIfAborted();
30
+ const head = request.method === 'HEAD';
31
+ const error = (status, code, message, headers = {}) => new Response(head ? null : JSON.stringify({ error: { code, message } }), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers } });
32
+ if (!['GET', 'HEAD'].includes(request.method))
33
+ return error(405, 'method_not_allowed', 'Use GET or HEAD.', { Allow: 'GET, HEAD' });
34
+ const url = new URL(request.url);
35
+ const prefix = `${basePath}/resources`;
36
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`))
37
+ return error(404, 'not_found', 'Unknown content route.');
38
+ const parts = url.pathname.slice(prefix.length).split('/').slice(1);
39
+ if (parts.length !== 0 && parts.length !== 1 && !(parts.length === 3 && parts[1] === 'representations'))
40
+ return error(404, 'not_found', 'Unknown content route.');
41
+ const allowed = new Set(['channel', 'version', 'language', 'revision']);
42
+ if ([...url.searchParams.keys()].some(key => !allowed.has(key) || url.searchParams.getAll(key).length !== 1))
43
+ return error(400, 'invalid_request', 'Unknown or repeated query parameter.');
44
+ const publication = Object.fromEntries(['channel', 'version', 'language'].map(key => [key, url.searchParams.get(key)]));
45
+ if (Object.values(publication).some(value => !value || value.length > 2048))
46
+ return error(400, 'invalid_request', 'channel, version, and language are required.');
47
+ const revision = url.searchParams.get('revision');
48
+ if (revision !== null && !/^[a-f0-9]{64}$/.test(revision) || parts.length > 0 && revision === null)
49
+ return error(400, 'invalid_request', 'A valid revision is required for resource requests.');
50
+ const selected = publications.get(publicationKey(publication));
51
+ if (!selected)
52
+ return error(404, 'not_found', 'Publication not found.');
53
+ if (revision !== null && revision !== selected.revision)
54
+ return error(409, 'stale_revision', 'The requested revision is not available.');
55
+ const resource = parts.length ? selected.resources.find(item => item.key === parts[0]) : undefined;
56
+ if (parts.length && !resource)
57
+ return error(404, 'not_found', 'Resource not found.');
58
+ try {
59
+ let body;
60
+ let mediaType = 'application/json';
61
+ if (!parts.length)
62
+ body = JSON.stringify({ schemaVersion: 1, revision: selected.revision, publication: selected.publication, resources: selected.resources });
63
+ else if (parts.length === 1)
64
+ body = JSON.stringify({ schemaVersion: 1, revision: selected.revision, publication: selected.publication, ...resource });
65
+ else {
66
+ const format = parts[2];
67
+ if (!['html', 'markdown'].includes(format) || !resource.resource.representations[format])
68
+ return error(406, 'unsupported_format', 'Representation not available.');
69
+ const descriptor = resource.resource.representations[format];
70
+ const result = await selected.source.get(resource.resource.ref, { format, signal: request.signal });
71
+ request.signal.throwIfAborted();
72
+ if (result.revision !== selected.revision)
73
+ return error(409, 'stale_revision', 'The requested revision is no longer available.');
74
+ if (typeof result.content !== 'string' || contentRefKey(result.resource.ref) !== contentRefKey(resource.resource.ref) || result.format !== format || !same(result.resource, resource.resource) || !same(result.representation, descriptor))
75
+ return error(500, 'internal_error', 'Content source returned inconsistent metadata.');
76
+ const bytes = encoder.encode(result.content);
77
+ if (bytes.byteLength !== descriptor.bytes || await digest(bytes) !== descriptor.digest)
78
+ return error(409, 'stale_revision', 'The requested revision bytes are no longer available.');
79
+ body = result.content;
80
+ mediaType = descriptor.mediaType;
81
+ }
82
+ const bytes = encoder.encode(body);
83
+ const etag = `"sha256-${await digest(bytes)}"`;
84
+ request.signal.throwIfAborted();
85
+ const headers = { 'Content-Type': `${mediaType}; charset=utf-8`, 'Content-Length': String(bytes.byteLength), ETag: etag, 'Cache-Control': revision === null ? 'no-cache' : 'public, max-age=31536000, immutable' };
86
+ const condition = request.headers.get('if-none-match');
87
+ const matches = condition?.trim() === '*' || condition?.split(',').some(value => value.trim().replace(/^W\//, '') === etag);
88
+ if (matches)
89
+ return new Response(null, { status: 304, headers });
90
+ return new Response(head ? null : body, { status: 200, headers });
91
+ }
92
+ catch (cause) {
93
+ request.signal.throwIfAborted();
94
+ if (cause instanceof ContentDeliveryError && ['stale_revision', 'not_found'].includes(cause.code))
95
+ return error(409, 'stale_revision', 'The requested revision is no longer available.');
96
+ return error(500, 'internal_error', 'Content source could not serve the request.');
97
+ }
98
+ };
99
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AuthoredChange, ResolvedChange } from './authored-change.js';
2
2
  import type { SubjectDeprecation, ResolvedSubjectDeprecation } from './subject-deprecation.js';
3
3
  import type { ReaderSelection } from './reader-filters.js';
4
+ import type { StandaloneSectionDeclaration } from './section-identity.js';
4
5
  export type Variant = {
5
6
  channel: string;
6
7
  version: string;
@@ -32,7 +33,7 @@ export type Frontmatter = {
32
33
  product?: string;
33
34
  area?: string | string[];
34
35
  subarea?: string | string[];
35
- standaloneSections?: string[];
36
+ standaloneSections?: StandaloneSectionDeclaration[];
36
37
  access?: {
37
38
  policy: string;
38
39
  };
@@ -45,6 +46,7 @@ export type Frontmatter = {
45
46
  layout?: 'docs' | 'wide' | 'landing' | 'splash';
46
47
  image?: string;
47
48
  showNavigationTabs?: boolean;
49
+ showVerified?: boolean;
48
50
  [key: string]: unknown;
49
51
  };
50
52
  export type Document = {
@@ -2,8 +2,9 @@ import { validateAuthoredChange, changeKinds } from './authored-change.js';
2
2
  import { validateSubjectDeprecation } from './subject-deprecation.js';
3
3
  import { validateCardMetadata } from './cards.js';
4
4
  import { resolveTaxonomy, validateTaxonomy, taxonomySchema } from './taxonomy.js';
5
+ import { validateStandaloneSectionDeclarations } from './section-identity.js';
5
6
  export const nativePublicFields = new Set(['id', 'title', 'type', 'summary', 'description', 'language', 'locale', 'date', 'updated', 'verified', 'releaseVersion', 'product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'audiences', 'readerFilters', 'tags', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle']);
6
- export const protectedMetadataRoots = new Set(['standaloneSections', 'access', 'seo', 'markdownMetadata', 'variables', 'tocDepth', 'layout', 'showNavigationTabs', 'image', 'private', 'internal', 'card', 'displayText', 'publications']);
7
+ export const protectedMetadataRoots = new Set(['standaloneSections', 'access', 'seo', 'markdownMetadata', 'variables', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'image', 'private', 'internal', 'card', 'displayText', 'publications']);
7
8
  const fieldTypes = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array']);
8
9
  const safePath = (value) => /^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(value);
9
10
  const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
@@ -116,10 +117,7 @@ export function validateFrontmatter(frontmatter, config, file, source) {
116
117
  validateCardMetadata(frontmatter.card, file);
117
118
  validateSubjectDeprecation(frontmatter.subjectDeprecation, file);
118
119
  validateAuthoredChange(frontmatter, file);
119
- const standalone = frontmatter.standaloneSections;
120
- if (standalone !== undefined && (!Array.isArray(standalone) || standalone.some(title => typeof title !== 'string' || !title.trim() || title !== title.trim()) || new Set(standalone).size !== standalone.length)) {
121
- throw new Error(`${file}: standaloneSections must be a unique list of nonempty, trimmed heading titles`);
122
- }
120
+ validateStandaloneSectionDeclarations(frontmatter.standaloneSections, file);
123
121
  const mode = config?.changelogIdentifier ?? 'either';
124
122
  if (frontmatter.type === 'changelog-entry' &&
125
123
  (mode === 'date' && frontmatter.date === undefined || mode === 'version' && frontmatter.releaseVersion === undefined || mode === 'either' && frontmatter.date === undefined && frontmatter.releaseVersion === undefined))
@@ -180,6 +178,8 @@ export function validateFrontmatter(frontmatter, config, file, source) {
180
178
  throw new Error(`${file}: tocDepth must be 0 or an integer from 2 through 6`);
181
179
  if (frontmatter.layout !== undefined && !['docs', 'wide', 'landing', 'splash'].includes(frontmatter.layout))
182
180
  throw new Error(`${file}: layout must be docs, wide, landing, or splash`);
181
+ if (frontmatter.showVerified !== undefined && typeof frontmatter.showVerified !== 'boolean')
182
+ throw new Error(`${file}: showVerified must be true or false`);
183
183
  if (frontmatter.showNavigationTabs !== undefined && typeof frontmatter.showNavigationTabs !== 'boolean')
184
184
  throw new Error(`${file}: showNavigationTabs must be true or false`);
185
185
  if (frontmatter.image !== undefined && (typeof frontmatter.image !== 'string' || !/\.(?:png|jpe?g)$/i.test(frontmatter.image) || /^[a-z][a-z0-9+.-]*:/i.test(frontmatter.image)))
@@ -245,8 +245,9 @@ export function frontmatterSchema(config) {
245
245
  subjectDeprecation: { type: 'object', minProperties: 1, additionalProperties: false, description: 'Page-specific subject retirement dates and stable document IDs; requires deprecated subjectLifecycle, including OpenAPI inference. Dates do not change status automatically.', properties: { deprecatedOn: { type: 'string', format: 'date' }, sunsetOn: { type: 'string', format: 'date' }, replacement: { type: 'string', minLength: 1 }, migration: { type: 'string', minLength: 1 } } },
246
246
  subjectLifecycle: { type: 'string', enum: ['deprecated', 'in-development', 'beta', 'experimental'], description: 'Displays a content status badge in the page and navigation.' },
247
247
  lifecycle: { type: 'string', minLength: 1, description: 'Editorial lifecycle of the document; validated by an opted-in external vocabulary.' },
248
- standaloneSections: { type: 'array', items: { type: 'string' }, uniqueItems: true, description: 'Page-specific exact level-two heading titles reviewed by the author as self-contained with the page introduction. Allows eligible sections to be recommended alone; omitted means full-page reads.' },
248
+ standaloneSections: { type: 'array', items: { oneOf: [{ type: 'string', minLength: 1 }, { type: 'object', additionalProperties: false, required: ['id', 'context'], properties: { id: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,511}$' }, context: { const: 'section' } } }] }, uniqueItems: true, description: 'Page-specific standalone approval: a heading title approves contextual discovery with the page introduction; {id, context: section} approves the root level-two section slice for content export. IDs may be derived or explicitly anchored. Omitted means full-page reads.' },
249
249
  tocDepth: { type: 'integer', enum: [0, 2, 3, 4, 5, 6], description: 'Deepest heading shown in the page table of contents; 0 hides it. Defaults to 3.' },
250
+ showVerified: { type: 'boolean', description: 'Show an authored verified date in the HTML page header. Defaults to true. False hides it only in HTML; Markdown and manifest dates remain unchanged. Does not assign a verification date.' },
250
251
  showNavigationTabs: { type: 'boolean', description: 'Show the HTML navigation section bar when tabs are configured. Defaults to true for docs and wide layouts, false for landing and splash. Does not change agent discovery or section membership.' },
251
252
  image: { type: 'string', pattern: '\\.(?:png|jpe?g)$', description: 'Local .png or .jpg shown in link previews for this page, relative to the page file; replaces the generated share image. Best at 1200×630.' },
252
253
  layout: { type: 'string', enum: ['docs', 'wide', 'landing', 'splash'], description: 'HTML page layout: docs (default), wide (no page outline, full content width), landing (no sidebar or outline; a Hero replaces the page header), or splash (full-bleed marketing page built from Hero and Section blocks).' },
@@ -319,7 +320,7 @@ export function frontmatterReference(config, sectionsOnly = false) {
319
320
  'Language is the content language and selects a publication. Locale describes formatting conventions in the content and does not select a publication. `frontmatter.locales` can set a project locale for each language; pages may override it.', '',
320
321
  '## Categorize, connect, and present the page', '',
321
322
  'By default, `product`, `area`, and `subarea` are optional, nonempty string categories. They can come from project defaults and appear in the document manifest and search index. A matching configured reader facet makes each category a page filter; without that facet it remains taxonomy only. `operations` links a guide or reference to stable API operation IDs. On a guide or how-to page, `relatedApiReferences` names API reference page IDs to add cross links in both directions within the same publication. Tags help document collections find the page. Card settings change its preview when another page links to it; they do not change the destination. `subjectLifecycle` displays the status of the documented API or feature; `lifecycle` is available for the document\'s editorial state. `tocDepth` limits the HTML page table of contents; use 0 to hide it. `layout` picks the HTML page layout: `docs`, `wide`, `landing`, or `splash`. `showNavigationTabs` overrides the navigation tab bar for this page; landing and splash pages hide it by default. `image` replaces the generated link-preview image. `date` and `releaseVersion` can identify a changelog entry under the project\'s `frontmatter.changelogIdentifier` rule.', '',
322
- ...table, ...nativeRows(['product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'tags', 'date', 'releaseVersion', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle', 'card', 'standaloneSections', 'tocDepth', 'layout', 'showNavigationTabs', 'image']), '',
323
+ ...table, ...nativeRows(['product', 'area', 'subarea', 'operations', 'relatedApiReferences', 'tags', 'date', 'releaseVersion', 'subjectLifecycle', 'subjectDeprecation', 'change', 'lifecycle', 'card', 'standaloneSections', 'tocDepth', 'layout', 'showNavigationTabs', 'showVerified', 'image']), '',
323
324
  'Projects can opt into built-in category enforcement with `frontmatter.taxonomy`. It declares areas and their allowed subareas, optional required fields, and optional explicit-field requirements for authored pages. Its `multiple` list permits arrays for selected category fields; otherwise each field allows one value. Defaults and enforced values still apply; generated API references use resolved categories. Definitions appear in the document manifest and constrain the generated frontmatter schema. Omit taxonomy or set it to false to preserve optional category behavior.', '',
324
325
  '## Control output details', '',
325
326
  '`seo` changes the browser title or HTML meta description. `displayText` overrides the text chosen for one output surface on this page. `variables` supplies values to registered snippets before they expand into the page body. `markdownMetadata` selects or overrides the YAML preamble of the generated Markdown twin—the machine-readable copy of this page. That generated preamble is separate from the source frontmatter shown above.', '',
@@ -15,6 +15,7 @@ export function renderHtmlWithHeadings(tree, highlighter, reservedIds = []) {
15
15
  const hast = html.runSync(tree);
16
16
  const used = new Set(reservedIds);
17
17
  const headings = [];
18
+ const aliases = new Map();
18
19
  const textOf = (node) => node.type === 'text' ? node.value : (node.children ?? []).map(textOf).join('');
19
20
  const checkUrl = (value) => {
20
21
  if (typeof value !== 'string')
@@ -34,6 +35,11 @@ export function renderHtmlWithHeadings(tree, highlighter, reservedIds = []) {
34
35
  };
35
36
  collectIds(hast);
36
37
  const visit = (node) => {
38
+ const sectionId = node.properties?.['data-shoalrun-section-id'] ?? node.properties?.dataShoalrunSectionId;
39
+ if (node.properties) {
40
+ delete node.properties['data-shoalrun-section-id'];
41
+ delete node.properties.dataShoalrunSectionId;
42
+ }
37
43
  const match = /^h([2-6])$/.exec(node.tagName ?? '');
38
44
  if (match && !node.properties?.className?.some((name) => ['tab-fallback-heading', 'walkthrough-step-title'].includes(name))) {
39
45
  const title = textOf(node).trim();
@@ -50,11 +56,30 @@ export function renderHtmlWithHeadings(tree, highlighter, reservedIds = []) {
50
56
  }
51
57
  if (!node.properties?.className?.includes('doc-step-heading-toc-ignore'))
52
58
  headings.push({ depth, id, title });
59
+ if (typeof sectionId === 'string' && sectionId !== id)
60
+ aliases.set(node, sectionId);
53
61
  }
54
62
  }
55
63
  node.children?.forEach(visit);
56
64
  };
57
65
  visit(hast);
66
+ // Preserve existing heading-derived URLs. Explicit identities add aliases,
67
+ // rather than reserving names early and renumbering unrelated headings.
68
+ for (const id of aliases.values()) {
69
+ if (used.has(id))
70
+ throw new Error(`Explicit section ID ${id} conflicts with an existing HTML anchor`);
71
+ used.add(id);
72
+ }
73
+ const addAliases = (node) => {
74
+ if (!node.children)
75
+ return;
76
+ node.children = node.children.flatMap((child) => {
77
+ addAliases(child);
78
+ const id = aliases.get(child);
79
+ return id ? [{ type: 'element', tagName: 'span', properties: { id, ariaHidden: 'true' }, children: [] }, child] : [child];
80
+ });
81
+ };
82
+ addAliases(hast);
58
83
  let rendered = String(html.stringify(hast));
59
84
  for (const [marker, markup] of math)
60
85
  rendered = rendered.replaceAll(marker, markup);
@@ -1,3 +1,4 @@
1
+ import { type StandaloneSectionDeclaration } from './section-identity.js';
1
2
  export type MarkdownSection = {
2
3
  id: string;
3
4
  title: string;
@@ -12,4 +13,4 @@ export type MarkdownSection = {
12
13
  markdownTokenEncoding: string;
13
14
  content: string;
14
15
  };
15
- export declare function markdownSections(tree: any, markdownUrl: string, parent: string, prefix: string, render: (tree: any) => string, standaloneTitles?: string[]): MarkdownSection[];
16
+ export declare function markdownSections(tree: any, markdownUrl: string, parent: string, prefix: string, render: (tree: any) => string, declarations?: StandaloneSectionDeclaration[]): MarkdownSection[];
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { markdownSize } from './markdown-size.js';
3
3
  import { link, list, paragraph, renderMarkdown, root, text } from './semantic-markdown.js';
4
+ import { validateStandaloneSections } from './section-identity.js';
4
5
  const digest = (value) => createHash('sha256').update(value).digest('hex');
5
6
  const pathKey = (item) => `${item.type}:${item.id}`;
6
7
  // Only direct page children qualify: hoisting a view from a ReaderSection, tab,
@@ -27,34 +28,23 @@ function missingReadingPath(all, included) {
27
28
  const textOf = (node) => typeof node.value === 'string' ? node.value : (node.children ?? []).map(textOf).join('');
28
29
  // Split the semantic root, never the flattened Markdown: a reader section or tab
29
30
  // remains intact even when it contains headings. Nested headings are not resources.
30
- export function markdownSections(tree, markdownUrl, parent, prefix, render, standaloneTitles = []) {
31
+ export function markdownSections(tree, markdownUrl, parent, prefix, render, declarations = []) {
31
32
  const parentSize = markdownSize(parent);
32
33
  const children = tree.children ?? [];
33
- const starts = children.flatMap((node, index) => node.type === 'heading' && node.depth === 2 ? [index] : []);
34
+ const identities = validateStandaloneSections(tree, declarations, markdownUrl);
35
+ const starts = identities.map(section => section.start);
34
36
  // Check declarations even on short pages: a renamed or ambiguous heading must
35
37
  // never silently retain an author's approval. Titles refer to normalized text.
36
- for (const title of standaloneTitles) {
37
- if (starts.filter(start => textOf(children[start]).trim() === title).length !== 1) {
38
- throw new Error(`${markdownUrl}: standaloneSections title "${title}" must match exactly one top-level level-two heading`);
39
- }
40
- }
41
38
  if (parentSize.markdownTokens < 2000)
42
39
  return [];
43
40
  const preamble = children.slice(0, starts[0] ?? 0);
44
- const used = new Set();
45
- return starts.map(start => {
46
- const title = textOf(children[start]).trim();
47
- const stem = title.normalize('NFKD').toLowerCase().replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80).replace(/-$/, '') || 'section';
48
- let id = stem;
49
- for (let suffix = 2; used.has(id); suffix++)
50
- id = `${stem}-${suffix}`;
51
- used.add(id);
52
- const end = children.findIndex((node, position) => position > start && node.type === 'heading' && node.depth <= 2);
53
- const nodes = [...preamble, ...children.slice(start, end === -1 ? children.length : end)].filter(node => !['definition', 'footnoteDefinition'].includes(node.type));
41
+ return identities.map(({ start, end, id, title }) => {
42
+ const nodes = [...preamble, ...children.slice(start, end)].filter(node => !['definition', 'footnoteDefinition'].includes(node.type));
54
43
  const definitions = children.filter(node => ['definition', 'footnoteDefinition'].includes(node.type));
55
44
  const content = prefix + renderMarkdown(root([paragraph(text('Parent page: '), link(markdownUrl, 'Full Markdown page'))])) + '\n'
56
45
  + render(root([...missingReadingPath(children, nodes), ...nodes, ...definitions]));
57
- return { id, title, depth: 2, ...(standaloneTitles.includes(title) ? { standalone: true } : {}), markdownUrl: `${markdownUrl.slice(0, -3)}.sections/${id}.md`, parentMarkdownUrl: markdownUrl,
46
+ const standalone = declarations.some(item => typeof item === 'string' ? item === title : item.id === id);
47
+ return { id, title, depth: 2, ...(standalone ? { standalone: true } : {}), markdownUrl: `${markdownUrl.slice(0, -3)}.sections/${id}.md`, parentMarkdownUrl: markdownUrl,
58
48
  parentDigest: digest(parent), digest: digest(content), ...markdownSize(content), content };
59
49
  }).filter(section => section.markdownTokens <= parentSize.markdownTokens / 2 && section.markdownBytes <= parentSize.markdownBytes / 2);
60
50
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveAgentDiscovery } from './agent-discovery.js';
2
- import { navigationCounts } from './compiler-config.js';
2
+ import { navigationCounts, navigationHeading } from './compiler-config.js';
3
3
  import { navigationTab } from './project-navigation.js';
4
4
  import { highlightedExample } from './highlight.js';
5
5
  import { readerFilterAttribute } from './reader-filters.js';
@@ -81,8 +81,9 @@ export function buildHtmlPage(doc, tree, markdownUrl, siteTitle, navigation, exa
81
81
  const date = doc.frontmatter.date ? `<time class="page-date" datetime="${esc(doc.frontmatter.date)}">${esc(doc.frontmatter.date)}</time>` : '';
82
82
  const changelogEntry = doc.frontmatter.type === 'changelog-entry';
83
83
  const updated = doc.frontmatter.updated && !(changelogEntry && doc.frontmatter.date) ? `<span class="page-date">Updated <time datetime="${esc(doc.frontmatter.updated)}">${esc(doc.frontmatter.updated.slice(0, 10))}</time></span>` : '';
84
+ const verified = doc.frontmatter.verified && doc.frontmatter.showVerified !== false ? `<span class="page-date">Verified <time datetime="${esc(doc.frontmatter.verified)}">${esc(doc.frontmatter.verified)}</time></span>` : '';
84
85
  const releaseVersion = doc.frontmatter.releaseVersion ? `<span class="page-version">Version ${esc(doc.frontmatter.releaseVersion)}</span>` : '';
85
- const pageDates = changelogEntry ? `<div class="page-meta">${date}${updated}${releaseVersion}</div>` : `${date}${updated}${releaseVersion}`;
86
+ const pageDates = changelogEntry ? `<div class="page-meta">${date}${updated}${verified}${releaseVersion}</div>` : `${date}${updated}${verified}${releaseVersion}`;
86
87
  const containsCurrent = (item) => item.route === doc.route || !!item.items?.some(containsCurrent);
87
88
  const activeTab = navigationTab(navigation, doc.route);
88
89
  const analyticsBootstrap = config.analytics ? `<script type="application/json" id="analytics-config">${JSON.stringify({ pageId: doc.frontmatter.id, pageUrl: doc.route, documentType: doc.frontmatter.type, channel: doc.variant.channel, version: doc.variant.version, language: doc.variant.language, ...(activeTab ? { navigationSectionId: activeTab.id } : {}) }).replaceAll('<', '\\u003c')}</script><script type="module" src="/assets/analytics.js"></script>` : '';
@@ -107,7 +108,8 @@ export function buildHtmlPage(doc, tree, markdownUrl, siteTitle, navigation, exa
107
108
  return `<li class="nav-entry${deprecated ? ' nav-deprecated' : ''}" data-search="${esc(search)}"${config.readerFilters ? ` data-reader-filters="${esc(readerFilterAttribute(item.readerFilters ?? {}))}"` : ''}><a href="${esc(item.route)}"${item.route === doc.route ? ' aria-current="page"' : ''}>${method}${label}${status}</a></li>`;
108
109
  }).join('');
109
110
  const readerControls = readerConfig?.controls.sidebar ? readerFilterSlot({ display: 'checks', label: 'Reader view' }, esc) : '';
110
- const nav = `<details class="nav-disclosure" open><summary>Menu</summary><div class="nav-body"><a class="region-return" href="#main" data-region-return>Back to article</a><div class="nav-tools"><h2>Explore</h2>${readerControls}<div class="nav-search" hidden><label for="nav-filter">Filter navigation</label><input id="nav-filter" type="search" autocomplete="off"><p class="nav-filter-status" role="status" aria-live="polite"></p></div></div><ul class="nav-tree">${renderNav(sidebarNavigation)}</ul></div></details>`;
111
+ const heading = navigationHeading(config.outputs);
112
+ const nav = `<details class="nav-disclosure" open><summary>Menu</summary><div class="nav-body"><a class="region-return" href="#main" data-region-return>Back to article</a><div class="nav-tools">${heading === false ? '' : `<h2>${esc(heading)}</h2>`}${readerControls}<div class="nav-search" hidden><label for="nav-filter">Filter navigation</label><input id="nav-filter" type="search" autocomplete="off"><p class="nav-filter-status" role="status" aria-live="polite"></p></div></div><ul class="nav-tree">${renderNav(sidebarNavigation)}</ul></div></details>`;
111
113
  const readerBanner = readerConfig ? '<aside class="reader-outside-view" role="status" hidden></aside>' : '';
112
114
  const readerBootstrap = readerConfig ? `<script type="application/json" id="reader-filter-config">${JSON.stringify({ facets: readerConfig.facets, storageKey: `shoalrun-reader-view:${basePath || '/'}`, moduleUrl: readerConfig.module ? sitePath(basePath, '/assets/reader-view-custom.js') : null }).replaceAll('<', '\\u003c')}</script><script type="module" src="/assets/reader-filters.js"></script>` : '';
113
115
  const groups = new Map();
@@ -1,3 +1,4 @@
1
+ import { hasScaffoldPlaceholders } from './scaffold-placeholders.js';
1
2
  import { matchesContentFilter } from './content-filter.js';
2
3
  import { readdir } from 'node:fs/promises';
3
4
  import path from 'node:path';
@@ -60,7 +61,7 @@ export async function planAuthoredDocuments({ base, basePath, config, variants,
60
61
  const marks = pageReaderFilters(frontmatter, readerFilters, file);
61
62
  if (!matchesContentFilter(marks, contentFilter))
62
63
  continue;
63
- if (/\[\[TODO:[\s\S]*?\]\]/.test(body) || /\[\[TODO:[\s\S]*?\]\]/.test(JSON.stringify(frontmatter)) || frontmatter.summary?.startsWith('Draft guide for '))
64
+ if (hasScaffoldPlaceholders(body) || /\[\[TODO:[\s\S]*?\]\]/.test(JSON.stringify(frontmatter)) || frontmatter.summary?.startsWith('Draft guide for '))
64
65
  throw new Error(`${file}: unresolved scaffold placeholders cannot be published`);
65
66
  for (const variant of variants) {
66
67
  if (pagePublications ? !pagePublications.some(item => item.channel === variant.channel && item.version === variant.version) : (frontmatter.channel && frontmatter.channel !== variant.channel || frontmatter.version && frontmatter.version !== variant.version))
@@ -0,0 +1,2 @@
1
+ /** Fenced code are teaching examples, not scaffold prose. */
2
+ export declare function hasScaffoldPlaceholders(body: string): boolean;
@@ -0,0 +1,19 @@
1
+ import { parse } from './semantic-markdown.js';
2
+ /** Fenced code are teaching examples, not scaffold prose. */
3
+ export function hasScaffoldPlaceholders(body) {
4
+ const placeholder = /\[\[TODO:[\s\S]*?\]\]/;
5
+ if (!placeholder.test(body))
6
+ return false;
7
+ const ranges = [];
8
+ const visit = (node) => {
9
+ if (node.type === 'code' && node.position)
10
+ ranges.push([node.position.start.offset, node.position.end.offset]);
11
+ else
12
+ for (const child of node.children ?? [])
13
+ visit(child);
14
+ };
15
+ visit(parse.parse(body));
16
+ for (const [start, end] of ranges.sort((a, b) => b[0] - a[0]))
17
+ body = body.slice(0, start) + ' '.repeat(end - start) + body.slice(end);
18
+ return placeholder.test(body);
19
+ }
@@ -0,0 +1,24 @@
1
+ /** Section identities are independent of a representation's DOM anchors. */
2
+ export type StandaloneSectionDeclaration = string | {
3
+ id: string;
4
+ context: 'section';
5
+ };
6
+ export type SectionIdentity = {
7
+ id: string;
8
+ title: string;
9
+ depth: 2;
10
+ start: number;
11
+ end: number;
12
+ explicit: boolean;
13
+ };
14
+ export declare const validSectionId: (value: unknown) => value is string;
15
+ /** Consume only a direct trailing marker on an authored root H2. Code stays inert. */
16
+ export declare function lowerSectionMarkers(tree: any, label?: string): any;
17
+ export declare function sectionIdentities(tree: any, label?: string): SectionIdentity[];
18
+ export declare function validateStandaloneSectionDeclarations(value: unknown, label?: string): asserts value is StandaloneSectionDeclaration[] | undefined;
19
+ export declare function validateStandaloneSections(tree: any, declarations?: StandaloneSectionDeclaration[], label?: string): SectionIdentity[];
20
+ /** Slice-only delivery is a distinct author approval from contextual discovery. */
21
+ export declare function selectSection(tree: any, id: string, declarations?: StandaloneSectionDeclaration[], label?: string): {
22
+ section: SectionIdentity;
23
+ tree: any;
24
+ };
@@ -0,0 +1,137 @@
1
+ export const validSectionId = (value) => typeof value === 'string' && /^[a-z0-9][a-z0-9-]{0,511}$/.test(value);
2
+ const textOf = (node) => typeof node.value === 'string' ? node.value : (node.children ?? []).map(textOf).join('');
3
+ const marker = (node) => ['mdxJsxTextElement', 'mdxJsxFlowElement'].includes(node.type) && node.name === 'Anchor';
4
+ /** Consume only a direct trailing marker on an authored root H2. Code stays inert. */
5
+ export function lowerSectionMarkers(tree, label = 'document') {
6
+ const allowed = new Set();
7
+ const children = (tree.children ?? []).map((node) => {
8
+ if (node.type !== 'heading' || node.depth !== 2)
9
+ return node;
10
+ const parts = [...(node.children ?? [])];
11
+ while (parts.at(-1)?.type === 'text' && !parts.at(-1).value.trim())
12
+ parts.pop();
13
+ const last = parts.at(-1);
14
+ if (!last || !marker(last))
15
+ return node;
16
+ const attributes = last.attributes ?? [];
17
+ if (last.type !== 'mdxJsxTextElement' || last.children?.length || attributes.length !== 1 || attributes[0].type !== 'mdxJsxAttribute' || attributes[0].name !== 'id' || !validSectionId(attributes[0].value)) {
18
+ throw new Error(`${label}: <Anchor> needs only a literal id of 1–512 lowercase letters, numbers, or dashes and no children`);
19
+ }
20
+ allowed.add(last);
21
+ parts.pop();
22
+ if (parts.at(-1)?.type === 'text')
23
+ parts[parts.length - 1] = { ...parts.at(-1), value: parts.at(-1).value.trimEnd() };
24
+ if (!parts.map(textOf).join('').trim())
25
+ throw new Error(`${label}: an anchored heading needs a title`);
26
+ const id = attributes[0].value;
27
+ return { ...node, children: parts, data: { ...node.data, sectionId: id, hProperties: { ...node.data?.hProperties, 'data-shoalrun-section-id': id } } };
28
+ });
29
+ const check = (node) => {
30
+ if (marker(node) && !allowed.has(node))
31
+ throw new Error(`${label}: <Anchor> must be the single trailing marker of a root level-two heading`);
32
+ node.children?.forEach(check);
33
+ };
34
+ check(tree);
35
+ return { ...tree, children };
36
+ }
37
+ export function sectionIdentities(tree, label = 'document') {
38
+ const children = tree.children ?? [];
39
+ const starts = children.flatMap((node, index) => node.type === 'heading' && node.depth === 2 ? [index] : []);
40
+ const used = new Set();
41
+ const sections = starts.map(start => {
42
+ const node = children[start];
43
+ const title = textOf(node).trim();
44
+ const stem = title.normalize('NFKD').toLowerCase().replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80).replace(/-$/, '') || 'section';
45
+ let generated = stem;
46
+ for (let suffix = 2; used.has(generated); suffix++)
47
+ generated = `${stem}-${suffix}`;
48
+ used.add(generated);
49
+ const explicit = node.data?.sectionId !== undefined;
50
+ if (explicit && !validSectionId(node.data.sectionId))
51
+ throw new Error(`${label}: invalid explicit section ID`);
52
+ const end = children.findIndex((child, index) => index > start && child.type === 'heading' && child.depth <= 2);
53
+ return { id: explicit ? node.data.sectionId : generated, title, depth: 2, start, end: end < 0 ? children.length : end, explicit, generated };
54
+ });
55
+ const owners = new Map();
56
+ for (const item of sections) {
57
+ if (owners.has(item.id))
58
+ throw new Error(`${label}: duplicate section ID ${item.id}`);
59
+ owners.set(item.id, item.start);
60
+ if (item.explicit && sections.some(other => other.start !== item.start && other.generated === item.id))
61
+ throw new Error(`${label}: explicit section ID ${item.id} conflicts with another heading's derived ID`);
62
+ }
63
+ return sections.map(({ generated, ...section }) => section);
64
+ }
65
+ export function validateStandaloneSectionDeclarations(value, label = 'document') {
66
+ if (value === undefined)
67
+ return;
68
+ if (!Array.isArray(value))
69
+ throw new Error(`${label}: standaloneSections must be an array`);
70
+ const seen = new Set();
71
+ for (const item of value) {
72
+ const legacy = typeof item === 'string';
73
+ if (legacy ? !item.trim() || item !== item.trim() : !item || typeof item !== 'object' || Array.isArray(item) || Object.keys(item).sort().join(',') !== 'context,id' || item.context !== 'section' || !validSectionId(item.id)) {
74
+ throw new Error(`${label}: standaloneSections needs trimmed heading titles or { id, context: section } declarations`);
75
+ }
76
+ const key = legacy ? `title:${item}` : `id:${item.id}`;
77
+ if (seen.has(key))
78
+ throw new Error(`${label}: standaloneSections declarations must be unique`);
79
+ seen.add(key);
80
+ }
81
+ }
82
+ export function validateStandaloneSections(tree, declarations = [], label = 'document') {
83
+ validateStandaloneSectionDeclarations(declarations, label);
84
+ const sections = sectionIdentities(tree, label);
85
+ const seen = new Set();
86
+ for (const declaration of declarations) {
87
+ const matches = sections.filter(section => typeof declaration === 'string' ? section.title === declaration : section.id === declaration.id);
88
+ if (matches.length !== 1)
89
+ throw new Error(`${label}: standaloneSections ${typeof declaration === 'string' ? `title "${declaration}"` : `ID "${declaration.id}"`} must match exactly one top-level level-two heading`);
90
+ if (seen.has(matches[0].start))
91
+ throw new Error(`${label}: standaloneSections declares the same heading more than once`);
92
+ seen.add(matches[0].start);
93
+ }
94
+ return sections;
95
+ }
96
+ /** Slice-only delivery is a distinct author approval from contextual discovery. */
97
+ export function selectSection(tree, id, declarations = [], label = 'document') {
98
+ const section = validateStandaloneSections(tree, declarations, label).find(item => item.id === id);
99
+ if (!section)
100
+ throw new Error(`${label}: unknown section ${id}`);
101
+ if (!declarations.some(item => typeof item !== 'string' && item.id === id && item.context === 'section'))
102
+ throw new Error(`${label}: section ${id} needs standaloneSections: [{ id: ${id}, context: section }] approval`);
103
+ const definitions = new Map();
104
+ const key = (node) => `${node.type === 'footnoteDefinition' || node.type === 'footnoteReference' ? 'footnote' : 'link'}:${node.identifier}`;
105
+ const comparable = (value) => Array.isArray(value) ? value.map(comparable) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([name]) => name !== 'position' && name !== 'exportSourceKey').map(([name, item]) => [name, comparable(item)])) : value;
106
+ const collect = (node) => {
107
+ if (['definition', 'footnoteDefinition'].includes(node.type)) {
108
+ const previous = definitions.get(key(node));
109
+ if (previous && JSON.stringify(comparable(previous)) !== JSON.stringify(comparable(node)))
110
+ throw new Error(`${label}: ambiguous definition ${node.identifier} for section ${id}`);
111
+ if (!previous)
112
+ definitions.set(key(node), node);
113
+ }
114
+ node.children?.forEach(collect);
115
+ };
116
+ collect(tree);
117
+ const strip = (node) => ['definition', 'footnoteDefinition'].includes(node.type) ? [] : [{ ...node, ...(node.children ? { children: node.children.flatMap(strip) } : {}) }];
118
+ const nodes = tree.children.slice(section.start, section.end).flatMap(strip);
119
+ const included = new Set();
120
+ const closure = [];
121
+ const visit = (node) => {
122
+ if (['linkReference', 'imageReference', 'footnoteReference'].includes(node.type)) {
123
+ const identity = key(node);
124
+ if (!included.has(identity)) {
125
+ included.add(identity);
126
+ const definition = definitions.get(identity);
127
+ if (!definition)
128
+ throw new Error(`${label}: section ${id} has an unresolved reference ${node.identifier}`);
129
+ closure.push(definition);
130
+ visit(definition);
131
+ }
132
+ }
133
+ node.children?.forEach(visit);
134
+ };
135
+ nodes.forEach(visit);
136
+ return { section, tree: { type: 'root', children: [...nodes, ...closure] } };
137
+ }