@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.
- package/api/docs/_adapter.d.mts +30 -27
- package/api/docs/_adapter.mjs +154 -124
- package/api/docs/compiled-topics.test.mjs +78 -0
- package/api/docs/detail/detail.d.mts +0 -15
- package/api/docs/detail/detail.mjs +14 -78
- package/api/docs/detail/section/section.mjs +22 -18
- package/api/docs/detail/section/section.test.mjs +4 -3
- package/api/docs/index/index.mjs +6 -5
- package/api/doctor/doctor.mjs +3 -7
- package/api/search/search.mjs +5 -5
- package/assets/codemods/transforms/v0.3.0/__tests__/unwrap-authoring-factories.test.mjs +27 -5
- package/assets/codemods/transforms/v0.3.0/unwrap-authoring-factories.mjs +20 -5
- package/authoring/_shared/contract.ts +22 -0
- package/authoring/codemod/codemod.doc.mjs +6 -1
- package/authoring/codemod/parse.d.mts +8 -8
- package/authoring/codemod/parse.mjs +8 -6
- package/authoring/config/parse.d.mts +13 -13
- package/authoring/config/parse.mjs +8 -8
- package/authoring/config/type.ts +3 -3
- package/authoring/debug/parse.d.mts +4 -4
- package/authoring/debug/parse.mjs +3 -3
- package/authoring/doctypes/_schema.d.mts +121 -11
- package/authoring/doctypes/_schema.mjs +144 -13
- package/authoring/doctypes/base/graph-fields.doc.mjs +11 -4
- package/authoring/doctypes/base/type.ts +8 -4
- package/authoring/doctypes/command/command.doc.mjs +3 -2
- package/authoring/doctypes/command/parse.d.mts +2 -2
- package/authoring/doctypes/command/parse.mjs +1 -1
- package/authoring/doctypes/command/type.ts +2 -2
- package/authoring/doctypes/component/component.doc.mjs +5 -2
- package/authoring/doctypes/component/parse.d.mts +2 -2
- package/authoring/doctypes/component/parse.mjs +1 -1
- package/authoring/doctypes/component/type.ts +1 -1
- package/authoring/doctypes/enum/parse.d.mts +2 -2
- package/authoring/doctypes/enum/parse.mjs +1 -1
- package/authoring/doctypes/enum/type.ts +1 -1
- package/authoring/doctypes/function/function.doc.mjs +4 -0
- package/authoring/doctypes/function/parse.d.mts +2 -2
- package/authoring/doctypes/function/parse.mjs +1 -1
- package/authoring/doctypes/function/type.ts +1 -1
- package/authoring/doctypes/hook/hook.doc.mjs +4 -0
- package/authoring/doctypes/hook/parse.d.mts +2 -2
- package/authoring/doctypes/hook/parse.mjs +1 -1
- package/authoring/doctypes/hook/type.ts +1 -1
- package/authoring/doctypes/legacy.d.mts +6 -6
- package/authoring/doctypes/legacy.mjs +3 -3
- package/authoring/doctypes/load-contract.test.mjs +207 -0
- package/authoring/doctypes/namespace/namespace.doc.mjs +7 -3
- package/authoring/doctypes/namespace/parse.d.mts +2 -2
- package/authoring/doctypes/namespace/parse.mjs +1 -1
- package/authoring/doctypes/namespace/type.ts +2 -2
- package/authoring/doctypes/parse.d.mts +18 -18
- package/authoring/doctypes/parse.mjs +9 -9
- package/authoring/doctypes/reference/parse.d.mts +2 -2
- package/authoring/doctypes/reference/parse.mjs +1 -1
- package/authoring/doctypes/reference/reference.doc.mjs +6 -2
- package/authoring/doctypes/reference/type.ts +1 -1
- package/authoring/doctypes/schema/parse.d.mts +2 -2
- package/authoring/doctypes/schema/parse.mjs +1 -1
- package/authoring/doctypes/schema/type.ts +2 -2
- package/authoring/doctypes/template/parse.d.mts +92 -1
- package/authoring/doctypes/template/parse.mjs +33 -1
- package/authoring/doctypes/template/template.doc.mjs +4 -0
- package/authoring/doctypes/template/type.ts +4 -1
- package/authoring/doctypes/types.ts +10 -10
- package/authoring/gap-report/parse.d.mts +9 -9
- package/authoring/gap-report/parse.mjs +6 -6
- package/authoring/gap-report/type.ts +1 -1
- package/authoring/identity/identity.doc.mjs +3 -2
- package/authoring/identity/type.ts +10 -10
- package/authoring/index.d.ts +19 -19
- package/authoring/integration/parse.d.mts +2 -2
- package/authoring/integration/parse.mjs +1 -1
- package/authoring/integration/schema.d.mts +4 -4
- package/authoring/integration/schema.mjs +3 -3
- package/authoring/integration/type.ts +1 -1
- package/foundation/discovery/authoring-self-docs.test.mjs +71 -12
- package/foundation/discovery/docs-discovery.d.mts +4 -0
- package/foundation/discovery/docs-discovery.mjs +16 -2
- package/foundation/discovery/docs-discovery.test.mjs +47 -11
- package/foundation/doc-compiler/compile.d.mts +162 -0
- package/foundation/doc-compiler/compile.mjs +262 -0
- package/foundation/doc-compiler/doc-compiler.test.mjs +687 -0
- package/foundation/doc-compiler/ir.d.mts +9 -0
- package/foundation/doc-compiler/ir.mjs +287 -0
- package/foundation/doc-compiler/lenses.d.mts +33 -0
- package/foundation/doc-compiler/lenses.mjs +127 -0
- 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([
|
|
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
|
|
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 => ({
|
|
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(
|
|
516
|
-
[
|
|
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(
|
|
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 =>
|
|
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
|
-
{
|
|
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: [
|
|
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: [
|
|
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
|
+
}
|