@astryxdesign/cli 0.6.3-canary.98a2e4e → 0.6.3-canary.9e4c545

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 (88) hide show
  1. package/api/docs/_adapter.d.mts +30 -27
  2. package/api/docs/_adapter.mjs +154 -124
  3. package/api/docs/compiled-topics.test.mjs +78 -0
  4. package/api/docs/detail/detail.d.mts +0 -15
  5. package/api/docs/detail/detail.mjs +14 -78
  6. package/api/docs/detail/section/section.mjs +22 -18
  7. package/api/docs/detail/section/section.test.mjs +4 -3
  8. package/api/docs/index/index.mjs +6 -5
  9. package/api/doctor/doctor.mjs +3 -7
  10. package/api/search/search.mjs +5 -5
  11. package/assets/codemods/transforms/v0.3.0/__tests__/unwrap-authoring-factories.test.mjs +27 -5
  12. package/assets/codemods/transforms/v0.3.0/unwrap-authoring-factories.mjs +20 -5
  13. package/authoring/_shared/contract.ts +22 -0
  14. package/authoring/codemod/codemod.doc.mjs +6 -1
  15. package/authoring/codemod/parse.d.mts +8 -8
  16. package/authoring/codemod/parse.mjs +8 -6
  17. package/authoring/config/parse.d.mts +13 -13
  18. package/authoring/config/parse.mjs +8 -8
  19. package/authoring/config/type.ts +3 -3
  20. package/authoring/debug/parse.d.mts +4 -4
  21. package/authoring/debug/parse.mjs +3 -3
  22. package/authoring/doctypes/_schema.d.mts +121 -11
  23. package/authoring/doctypes/_schema.mjs +144 -13
  24. package/authoring/doctypes/base/graph-fields.doc.mjs +11 -4
  25. package/authoring/doctypes/base/type.ts +8 -4
  26. package/authoring/doctypes/command/command.doc.mjs +3 -2
  27. package/authoring/doctypes/command/parse.d.mts +2 -2
  28. package/authoring/doctypes/command/parse.mjs +1 -1
  29. package/authoring/doctypes/command/type.ts +2 -2
  30. package/authoring/doctypes/component/component.doc.mjs +5 -2
  31. package/authoring/doctypes/component/parse.d.mts +2 -2
  32. package/authoring/doctypes/component/parse.mjs +1 -1
  33. package/authoring/doctypes/component/type.ts +1 -1
  34. package/authoring/doctypes/enum/parse.d.mts +2 -2
  35. package/authoring/doctypes/enum/parse.mjs +1 -1
  36. package/authoring/doctypes/enum/type.ts +1 -1
  37. package/authoring/doctypes/function/function.doc.mjs +4 -0
  38. package/authoring/doctypes/function/parse.d.mts +2 -2
  39. package/authoring/doctypes/function/parse.mjs +1 -1
  40. package/authoring/doctypes/function/type.ts +1 -1
  41. package/authoring/doctypes/hook/hook.doc.mjs +4 -0
  42. package/authoring/doctypes/hook/parse.d.mts +2 -2
  43. package/authoring/doctypes/hook/parse.mjs +1 -1
  44. package/authoring/doctypes/hook/type.ts +1 -1
  45. package/authoring/doctypes/legacy.d.mts +6 -6
  46. package/authoring/doctypes/legacy.mjs +3 -3
  47. package/authoring/doctypes/load-contract.test.mjs +207 -0
  48. package/authoring/doctypes/namespace/namespace.doc.mjs +7 -3
  49. package/authoring/doctypes/namespace/parse.d.mts +2 -2
  50. package/authoring/doctypes/namespace/parse.mjs +1 -1
  51. package/authoring/doctypes/namespace/type.ts +2 -2
  52. package/authoring/doctypes/parse.d.mts +18 -18
  53. package/authoring/doctypes/parse.mjs +9 -9
  54. package/authoring/doctypes/reference/parse.d.mts +2 -2
  55. package/authoring/doctypes/reference/parse.mjs +1 -1
  56. package/authoring/doctypes/reference/reference.doc.mjs +6 -2
  57. package/authoring/doctypes/reference/type.ts +1 -1
  58. package/authoring/doctypes/schema/parse.d.mts +2 -2
  59. package/authoring/doctypes/schema/parse.mjs +1 -1
  60. package/authoring/doctypes/schema/type.ts +2 -2
  61. package/authoring/doctypes/template/parse.d.mts +92 -1
  62. package/authoring/doctypes/template/parse.mjs +33 -1
  63. package/authoring/doctypes/template/template.doc.mjs +4 -0
  64. package/authoring/doctypes/template/type.ts +4 -1
  65. package/authoring/doctypes/types.ts +10 -10
  66. package/authoring/gap-report/parse.d.mts +9 -9
  67. package/authoring/gap-report/parse.mjs +6 -6
  68. package/authoring/gap-report/type.ts +1 -1
  69. package/authoring/identity/identity.doc.mjs +3 -2
  70. package/authoring/identity/type.ts +10 -10
  71. package/authoring/index.d.ts +19 -19
  72. package/authoring/integration/parse.d.mts +2 -2
  73. package/authoring/integration/parse.mjs +1 -1
  74. package/authoring/integration/schema.d.mts +4 -4
  75. package/authoring/integration/schema.mjs +3 -3
  76. package/authoring/integration/type.ts +1 -1
  77. package/foundation/discovery/authoring-self-docs.test.mjs +71 -12
  78. package/foundation/discovery/docs-discovery.d.mts +4 -0
  79. package/foundation/discovery/docs-discovery.mjs +16 -2
  80. package/foundation/discovery/docs-discovery.test.mjs +47 -11
  81. package/foundation/doc-compiler/compile.d.mts +162 -0
  82. package/foundation/doc-compiler/compile.mjs +262 -0
  83. package/foundation/doc-compiler/doc-compiler.test.mjs +687 -0
  84. package/foundation/doc-compiler/ir.d.mts +9 -0
  85. package/foundation/doc-compiler/ir.mjs +287 -0
  86. package/foundation/doc-compiler/lenses.d.mts +33 -0
  87. package/foundation/doc-compiler/lenses.mjs +127 -0
  88. package/package.json +9 -9
@@ -138,7 +138,14 @@ const BLOCK_FIELDS = {
138
138
  };
139
139
 
140
140
  /** Blocks that are valid authoring but require the compiled graph renderer. */
141
- const GRAPH_BLOCK_TYPES = new Set(['workflow', 'collection', 'reference']);
141
+ export const GRAPH_BLOCK_TYPES = new Set([
142
+ 'workflow',
143
+ 'collection',
144
+ 'reference',
145
+ ]);
146
+
147
+ /** Doc fields only the docs graph reads; a topic that sets one fails to load. */
148
+ export const GRAPH_ONLY_FIELDS = ['placement', 'aliases', 'audience'];
142
149
 
143
150
  /**
144
151
  * Fields a block kind may carry but does not need. Kept per kind rather than
@@ -176,6 +183,13 @@ const SECTION_FIELDS = ['id', 'title', 'category', 'content', 'previewType'];
176
183
  * @returns {string[]} problems, each already pointed at a place in the doc
177
184
  */
178
185
  export function problemsInTopic(doc) {
186
+ // A namespace doc is valid authoring that only the docs graph reads. Said
187
+ // plainly, instead of as the topic fields it does not have.
188
+ if (doc?.type === 'namespace') {
189
+ return [
190
+ `"${doc.name}" is a namespace doc. Only the docs graph reads namespace docs, and it is not built yet; remove this file from the docs directory.`,
191
+ ];
192
+ }
179
193
  /** @type {string[]} */
180
194
  const problems = [];
181
195
  for (const field of ['name', 'title', 'description']) {
@@ -188,7 +202,7 @@ export function problemsInTopic(doc) {
188
202
  `name: "${doc.name}" is not URL-safe. A topic name is its CLI argument and its docsite path, so it may hold only letters, digits, "_" and "-".`,
189
203
  );
190
204
  }
191
- for (const field of ['placement', 'aliases', 'audience']) {
205
+ for (const field of GRAPH_ONLY_FIELDS) {
192
206
  if (doc?.[field] != null) {
193
207
  problems.push(
194
208
  `${field}: requires the compiled graph reader and is not supported by legacy topic readers`,
@@ -182,6 +182,26 @@ describe('discoverIntegrationDocs', () => {
182
182
  expect(records).toEqual([]);
183
183
  expect(errors[0].message).toContain('requires the compiled graph renderer');
184
184
  });
185
+
186
+ it('names a namespace doc instead of listing topic fields it lacks', async () => {
187
+ const {records, errors} = await discoverIntegrationDocs(
188
+ integration('@acme/namespace', {
189
+ 'guides.doc.mjs': {
190
+ type: 'namespace',
191
+ name: 'guides',
192
+ title: 'Guides',
193
+ summary: 'Every guide.',
194
+ slots: {guides: {title: 'Guides', accepts: {kinds: ['generic']}}},
195
+ },
196
+ }),
197
+ );
198
+ expect(records).toEqual([]);
199
+ expect(errors).toHaveLength(1);
200
+ expect(errors[0].message).toContain(
201
+ '"guides" is a namespace doc. Only the docs graph reads namespace docs',
202
+ );
203
+ expect(errors[0].message).not.toContain('sections:');
204
+ });
185
205
  });
186
206
 
187
207
  describe('problemsInTopic', () => {
@@ -500,7 +520,10 @@ describe('problemsInTopic section keys', () => {
500
520
  name: 'keys',
501
521
  title: 'Keys',
502
522
  description: 'Section keys.',
503
- sections: sections.map(s => ({content: [{type: 'prose', text: 'x'}], ...s})),
523
+ sections: sections.map(s => ({
524
+ content: [{type: 'prose', text: 'x'}],
525
+ ...s,
526
+ })),
504
527
  });
505
528
 
506
529
  it('rejects two sections that derive the same key', () => {
@@ -512,18 +535,18 @@ describe('problemsInTopic section keys', () => {
512
535
  });
513
536
 
514
537
  it('rejects an unsafe id', () => {
515
- expect(problemsInTopic(doc([{id: 'Quick Start', title: 'Quick Start'}]))).toEqual(
516
- [expect.stringContaining('is not a stable key')],
517
- );
538
+ expect(
539
+ problemsInTopic(doc([{id: 'Quick Start', title: 'Quick Start'}])),
540
+ ).toEqual([expect.stringContaining('is not a stable key')]);
518
541
  });
519
542
 
520
543
  it('rejects a title no key derives from, unless it has an id', () => {
521
544
  expect(problemsInTopic(doc([{title: '亮/暗模式'}]))).toEqual([
522
545
  expect.stringContaining('Give the section an id'),
523
546
  ]);
524
- expect(problemsInTopic(doc([{id: 'light-dark', title: '亮/暗模式'}]))).toEqual(
525
- [],
526
- );
547
+ expect(
548
+ problemsInTopic(doc([{id: 'light-dark', title: '亮/暗模式'}])),
549
+ ).toEqual([]);
527
550
  });
528
551
  });
529
552
 
@@ -535,12 +558,17 @@ describe('mergeTopic by section key', () => {
535
558
  {title: 'Light/Dark Mode', content: [{type: 'prose', text: 'base'}]},
536
559
  ],
537
560
  };
538
- const titles = doc => doc.sections.map(s => [s.id ?? null, s.title, s.content[0].text]);
561
+ const titles = doc =>
562
+ doc.sections.map(s => [s.id ?? null, s.title, s.content[0].text]);
539
563
 
540
564
  it('replaces the section whose derived key an extension id names', () => {
541
565
  const merged = mergeTopic(base, {
542
566
  sections: [
543
- {id: 'quick-start', title: 'Quick Start with Acme', content: [{type: 'prose', text: 'acme'}]},
567
+ {
568
+ id: 'quick-start',
569
+ title: 'Quick Start with Acme',
570
+ content: [{type: 'prose', text: 'acme'}],
571
+ },
544
572
  ],
545
573
  });
546
574
  expect(titles(merged)).toEqual([
@@ -551,7 +579,9 @@ describe('mergeTopic by section key', () => {
551
579
 
552
580
  it('replaces the section a title variant derives the same key as', () => {
553
581
  const merged = mergeTopic(base, {
554
- sections: [{title: 'Light-Dark Mode', content: [{type: 'prose', text: 'acme'}]}],
582
+ sections: [
583
+ {title: 'Light-Dark Mode', content: [{type: 'prose', text: 'acme'}]},
584
+ ],
555
585
  });
556
586
  expect(titles(merged)).toEqual([
557
587
  [null, 'Quick Start', 'base'],
@@ -561,7 +591,13 @@ describe('mergeTopic by section key', () => {
561
591
 
562
592
  it('prefers the key over a legacy title for an extension with an id', () => {
563
593
  const merged = mergeTopic(base, {
564
- sections: [{id: 'light-dark-mode', title: 'Quick Start', content: [{type: 'prose', text: 'acme'}]}],
594
+ sections: [
595
+ {
596
+ id: 'light-dark-mode',
597
+ title: 'Quick Start',
598
+ content: [{type: 'prose', text: 'acme'}],
599
+ },
600
+ ],
565
601
  });
566
602
  expect(titles(merged)).toEqual([
567
603
  [null, 'Quick Start', 'base'],
@@ -0,0 +1,162 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * One authored file, as discovery loaded it.
6
+ * @typedef {object} AuthoredFile
7
+ * @property {string} file the file's name, for messages only
8
+ * @property {any} [doc] the parsed doc, when it loaded
9
+ * @property {unknown} [error] why it did not load or parse
10
+ * @property {any} [overlay] the language overlay's export, when one applies
11
+ * @property {unknown} [overlayError] why the overlay did not load
12
+ */
13
+ /**
14
+ * @typedef {object} ReferenceTopicInput
15
+ * @property {string} id the topic's name in the catalog
16
+ * @property {string} provider the package that owns the topic
17
+ * @property {string | null} replaces the topic it took the place of
18
+ * @property {string | null} lang the overlay language, or null for authored text
19
+ * @property {AuthoredFile} base
20
+ * @property {Array<AuthoredFile & {provider: string}>} extensions in merge order
21
+ */
22
+ /**
23
+ * A token reference after linking: the target section's content, or why it
24
+ * has none.
25
+ * @typedef {{status: 'resolved', topic: string, section: string, previewType?: string, content: any[]}
26
+ * | {status: 'unknown-topic'}
27
+ * | {status: 'unknown-section'}} TokenRefResolution
28
+ */
29
+ /**
30
+ * @typedef {object} CompiledReferenceNode
31
+ * @property {number} schemaVersion
32
+ * @property {'reference'} kind
33
+ * @property {'lowered' | 'linked'} stage `linked` once every token reference
34
+ * carries its resolution; a lowered node carries none
35
+ * @property {string} id the topic's name in the catalog
36
+ * @property {string | null} lang
37
+ * @property {{provider: string, replaces: string | null, extensions: string[]}} provenance
38
+ * @property {Record<string, string>} sourceTitles section key -> authored title
39
+ * @property {any} doc the topic: authored fields in authored order, every
40
+ * section keyed; a linked node's token references carry `resolved`
41
+ */
42
+ /**
43
+ * Lower one topic: overlay each file, merge the extensions in order, and stamp
44
+ * every section with its key.
45
+ * @param {ReferenceTopicInput} input
46
+ * @returns {CompiledReferenceNode}
47
+ */
48
+ export function lowerReferenceTopic(input: ReferenceTopicInput): CompiledReferenceNode;
49
+ /**
50
+ * Link every section of a lowered node.
51
+ * @param {CompiledReferenceNode} node
52
+ * @param {(topic: string) => Promise<CompiledReferenceNode | null>} lowerTarget
53
+ * the lowered node a reference names, or null when no topic has that name
54
+ * @returns {Promise<CompiledReferenceNode>}
55
+ */
56
+ export function linkReferenceTopic(node: CompiledReferenceNode, lowerTarget: (topic: string) => Promise<CompiledReferenceNode | null>): Promise<CompiledReferenceNode>;
57
+ /**
58
+ * Resolve the token references in one section. A section with none comes back
59
+ * as it went in.
60
+ * @template {{content: any[]}} S
61
+ * @param {S} section
62
+ * @param {(topic: string) => Promise<CompiledReferenceNode | null>} lowerTarget
63
+ * @returns {Promise<S>}
64
+ */
65
+ export function linkReferenceSection<S extends {
66
+ content: any[];
67
+ }>(section: S, lowerTarget: (topic: string) => Promise<CompiledReferenceNode | null>): Promise<S>;
68
+ /** Bumped whenever the shape of a compiled node changes. */
69
+ export const COMPILED_DOC_SCHEMA_VERSION: 1;
70
+ /**
71
+ * One authored file, as discovery loaded it.
72
+ */
73
+ export type AuthoredFile = {
74
+ /**
75
+ * the file's name, for messages only
76
+ */
77
+ file: string;
78
+ /**
79
+ * the parsed doc, when it loaded
80
+ */
81
+ doc?: any;
82
+ /**
83
+ * why it did not load or parse
84
+ */
85
+ error?: unknown;
86
+ /**
87
+ * the language overlay's export, when one applies
88
+ */
89
+ overlay?: any;
90
+ /**
91
+ * why the overlay did not load
92
+ */
93
+ overlayError?: unknown;
94
+ };
95
+ export type ReferenceTopicInput = {
96
+ /**
97
+ * the topic's name in the catalog
98
+ */
99
+ id: string;
100
+ /**
101
+ * the package that owns the topic
102
+ */
103
+ provider: string;
104
+ /**
105
+ * the topic it took the place of
106
+ */
107
+ replaces: string | null;
108
+ /**
109
+ * the overlay language, or null for authored text
110
+ */
111
+ lang: string | null;
112
+ base: AuthoredFile;
113
+ /**
114
+ * in merge order
115
+ */
116
+ extensions: Array<AuthoredFile & {
117
+ provider: string;
118
+ }>;
119
+ };
120
+ /**
121
+ * A token reference after linking: the target section's content, or why it
122
+ * has none.
123
+ */
124
+ export type TokenRefResolution = {
125
+ status: "resolved";
126
+ topic: string;
127
+ section: string;
128
+ previewType?: string;
129
+ content: any[];
130
+ } | {
131
+ status: "unknown-topic";
132
+ } | {
133
+ status: "unknown-section";
134
+ };
135
+ export type CompiledReferenceNode = {
136
+ schemaVersion: number;
137
+ kind: "reference";
138
+ /**
139
+ * `linked` once every token reference
140
+ * carries its resolution; a lowered node carries none
141
+ */
142
+ stage: "lowered" | "linked";
143
+ /**
144
+ * the topic's name in the catalog
145
+ */
146
+ id: string;
147
+ lang: string | null;
148
+ provenance: {
149
+ provider: string;
150
+ replaces: string | null;
151
+ extensions: string[];
152
+ };
153
+ /**
154
+ * section key -> authored title
155
+ */
156
+ sourceTitles: Record<string, string>;
157
+ /**
158
+ * the topic: authored fields in authored order, every
159
+ * section keyed; a linked node's token references carry `resolved`
160
+ */
161
+ doc: any;
162
+ };
@@ -0,0 +1,262 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Doc compiler — authored reference topics in, compiled nodes out.
5
+ *
6
+ * @input A {@link ReferenceTopicInput}: one topic's own file and the files of
7
+ * the extensions merged onto it, each already through the authored-doc parser
8
+ * and each carrying the overlay for the language being read. Discovery
9
+ * assembles it, and records a file that failed to load instead of throwing,
10
+ * so problems surface here in the order a reader meets them.
11
+ * @output A compiled reference node: plain JSON carrying a schema version, the
12
+ * topic after overlay, extension merge and key stamping, and the authored
13
+ * title of every section. Linking then resolves each token reference against
14
+ * its target. Nothing in a node is a function, a symbol, or a file path: an
15
+ * authored value JSON cannot hold (a function, a Date, `undefined`) takes its
16
+ * JSON form, the one `--json` output has always shown.
17
+ * @position The one step between authored docs and every docs reader. The docs
18
+ * API, doctor and search read compiled nodes, and ./lenses.mjs turns them into
19
+ * response shapes. Internal to the CLI: the public way in is `docs()`.
20
+ */
21
+
22
+ import {mergeTopic, problemsInTopic} from '../discovery/docs-discovery.mjs';
23
+ import {
24
+ sectionKey,
25
+ sectionKeyProblems,
26
+ sourceTitle,
27
+ withSectionKeys,
28
+ withSourceTitle,
29
+ } from '../discovery/docs-section-key.mjs';
30
+
31
+ /** Bumped whenever the shape of a compiled node changes. */
32
+ export const COMPILED_DOC_SCHEMA_VERSION = 1;
33
+
34
+ /**
35
+ * One authored file, as discovery loaded it.
36
+ * @typedef {object} AuthoredFile
37
+ * @property {string} file the file's name, for messages only
38
+ * @property {any} [doc] the parsed doc, when it loaded
39
+ * @property {unknown} [error] why it did not load or parse
40
+ * @property {any} [overlay] the language overlay's export, when one applies
41
+ * @property {unknown} [overlayError] why the overlay did not load
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} ReferenceTopicInput
46
+ * @property {string} id the topic's name in the catalog
47
+ * @property {string} provider the package that owns the topic
48
+ * @property {string | null} replaces the topic it took the place of
49
+ * @property {string | null} lang the overlay language, or null for authored text
50
+ * @property {AuthoredFile} base
51
+ * @property {Array<AuthoredFile & {provider: string}>} extensions in merge order
52
+ */
53
+
54
+ /**
55
+ * A token reference after linking: the target section's content, or why it
56
+ * has none.
57
+ * @typedef {{status: 'resolved', topic: string, section: string, previewType?: string, content: any[]}
58
+ * | {status: 'unknown-topic'}
59
+ * | {status: 'unknown-section'}} TokenRefResolution
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} CompiledReferenceNode
64
+ * @property {number} schemaVersion
65
+ * @property {'reference'} kind
66
+ * @property {'lowered' | 'linked'} stage `linked` once every token reference
67
+ * carries its resolution; a lowered node carries none
68
+ * @property {string} id the topic's name in the catalog
69
+ * @property {string | null} lang
70
+ * @property {{provider: string, replaces: string | null, extensions: string[]}} provenance
71
+ * @property {Record<string, string>} sourceTitles section key -> authored title
72
+ * @property {any} doc the topic: authored fields in authored order, every
73
+ * section keyed; a linked node's token references carry `resolved`
74
+ */
75
+
76
+ /**
77
+ * Lower one topic: overlay each file, merge the extensions in order, and stamp
78
+ * every section with its key.
79
+ * @param {ReferenceTopicInput} input
80
+ * @returns {CompiledReferenceNode}
81
+ */
82
+ export function lowerReferenceTopic(input) {
83
+ let doc = readAuthoredFile(input.base);
84
+ for (const extension of input.extensions) {
85
+ doc = mergeTopic(doc, readAuthoredFile(extension));
86
+ // Merging matches on keys, so this holds unless merge itself regresses.
87
+ const problems = sectionKeyProblems(doc.sections);
88
+ if (problems.length > 0) {
89
+ throw new Error(
90
+ `${extension.file}, extending ${input.id}, leaves two sections with one key: ${problems.join('; ')}`,
91
+ );
92
+ }
93
+ }
94
+ // Derived keys are stamped only now, so they never take part in merging.
95
+ const keyed = withSectionKeys(doc);
96
+ /** @type {Record<string, string>} */
97
+ const sourceTitles = {};
98
+ for (const section of keyed.sections) {
99
+ sourceTitles[section.id] = sourceTitle(section);
100
+ }
101
+ return {
102
+ schemaVersion: COMPILED_DOC_SCHEMA_VERSION,
103
+ kind: 'reference',
104
+ stage: 'lowered',
105
+ id: input.id,
106
+ lang: input.lang,
107
+ provenance: {
108
+ provider: input.provider,
109
+ replaces: input.replaces,
110
+ extensions: input.extensions.map(extension => extension.provider),
111
+ },
112
+ sourceTitles,
113
+ // The authored title travels in sourceTitles; JSON drops the symbol.
114
+ doc: asJson(keyed, input.id),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Link every section of a lowered node.
120
+ * @param {CompiledReferenceNode} node
121
+ * @param {(topic: string) => Promise<CompiledReferenceNode | null>} lowerTarget
122
+ * the lowered node a reference names, or null when no topic has that name
123
+ * @returns {Promise<CompiledReferenceNode>}
124
+ */
125
+ export async function linkReferenceTopic(node, lowerTarget) {
126
+ const sections = [];
127
+ for (const section of node.doc.sections) {
128
+ sections.push(await linkReferenceSection(section, lowerTarget));
129
+ }
130
+ return {...node, stage: 'linked', doc: {...node.doc, sections}};
131
+ }
132
+
133
+ /**
134
+ * Resolve the token references in one section. A section with none comes back
135
+ * as it went in.
136
+ * @template {{content: any[]}} S
137
+ * @param {S} section
138
+ * @param {(topic: string) => Promise<CompiledReferenceNode | null>} lowerTarget
139
+ * @returns {Promise<S>}
140
+ */
141
+ export async function linkReferenceSection(section, lowerTarget) {
142
+ if (!section.content.some(block => block?.type === 'token-ref')) {
143
+ return section;
144
+ }
145
+ const content = [];
146
+ for (const block of section.content) {
147
+ content.push(
148
+ block?.type === 'token-ref'
149
+ ? {...block, resolved: await resolveTokenRef(block, lowerTarget)}
150
+ : block,
151
+ );
152
+ }
153
+ return {...section, content};
154
+ }
155
+
156
+ /**
157
+ * The section a token reference names — by key, or by authored title in any
158
+ * letter case — in the target topic lowered for the same language.
159
+ * @param {any} block
160
+ * @param {(topic: string) => Promise<CompiledReferenceNode | null>} lowerTarget
161
+ * @returns {Promise<TokenRefResolution>}
162
+ */
163
+ async function resolveTokenRef(block, lowerTarget) {
164
+ const target = await lowerTarget(block.topic);
165
+ if (!target) return {status: 'unknown-topic'};
166
+ const wanted = block.section.toLowerCase();
167
+ const found = target.doc.sections.find(
168
+ (/** @type {any} */ section) =>
169
+ sectionKey(section) === block.section ||
170
+ (target.sourceTitles[section.id] ?? section.title).toLowerCase() ===
171
+ wanted,
172
+ );
173
+ if (!found) return {status: 'unknown-section'};
174
+ return {
175
+ status: 'resolved',
176
+ topic: target.id,
177
+ section: found.id,
178
+ ...(found.previewType ? {previewType: found.previewType} : {}),
179
+ content: found.content,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * A value as JSON carries it: functions, symbols and `undefined` dropped,
185
+ * Dates as ISO strings.
186
+ * @param {any} value
187
+ * @param {string} topic for the message when the value cannot be serialized
188
+ * @returns {any}
189
+ */
190
+ function asJson(value, topic) {
191
+ try {
192
+ return JSON.parse(JSON.stringify(value));
193
+ } catch (err) {
194
+ throw new Error(
195
+ `${topic} cannot be compiled: ${err instanceof Error ? err.message : String(err)}`,
196
+ {cause: err},
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * One file's doc, checked and in the reading language. Problems throw in the
203
+ * order a reader meets them: the file itself, then its overlay.
204
+ * @param {AuthoredFile} file
205
+ * @returns {any}
206
+ */
207
+ function readAuthoredFile(file) {
208
+ if ('error' in file) throw file.error;
209
+ const parsed = file.doc;
210
+ if (!('sections' in parsed)) {
211
+ throw new Error(`${file.file} is not a reference document.`);
212
+ }
213
+ const problems = problemsInTopic(parsed);
214
+ if (problems.length > 0) {
215
+ throw new Error(`${file.file} is invalid: ${problems.join('; ')}`);
216
+ }
217
+ if ('overlayError' in file) throw file.overlayError;
218
+ return file.overlay ? applyOverlay(parsed, file.overlay) : parsed;
219
+ }
220
+
221
+ /**
222
+ * Lay a translation over a doc. Overlays are keyed to a base section by title
223
+ * (`section`), not by array position: position keying grafted each overlay
224
+ * title onto whatever base section shared its index, so an overlay that omitted
225
+ * or reordered a section corrupted every section after it (#2182). An overlay
226
+ * may cover any subset of sections, in any order; the rest keep their base
227
+ * content.
228
+ * @param {any} docs
229
+ * @param {any} translation
230
+ * @returns {any}
231
+ */
232
+ function applyOverlay(docs, translation) {
233
+ /** @type {Map<string, any>} */
234
+ const bySection = new Map();
235
+ for (const ts of translation.sections ?? []) {
236
+ if (ts?.section != null) bySection.set(ts.section, ts);
237
+ }
238
+ return {
239
+ ...docs,
240
+ description: translation.description || docs.description,
241
+ sections: docs.sections.map((/** @type {any} */ section) => {
242
+ const ts = bySection.get(section.title);
243
+ if (!ts) return section;
244
+ const localized = {
245
+ ...section,
246
+ title: ts.title || section.title,
247
+ content: section.content.map(
248
+ (/** @type {any} */ block, /** @type {number} */ bi) => {
249
+ const tb = ts.content?.[bi];
250
+ if (!tb) return block;
251
+ if (tb.type === 'prose' && block.type === 'prose')
252
+ return {...block, text: tb.text};
253
+ if (tb.type === 'list' && block.type === 'list')
254
+ return {...block, items: tb.items};
255
+ return block;
256
+ },
257
+ ),
258
+ };
259
+ return withSourceTitle(localized, section.title);
260
+ }),
261
+ };
262
+ }