@astryxdesign/cli 0.4.5 → 0.4.6-canary.cea9ecd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/api/component/_adapter.d.mts +22 -5
- package/api/component/_adapter.mjs +51 -3
- package/api/docs/_adapter.d.mts +37 -8
- package/api/docs/_adapter.mjs +70 -41
- package/api/docs/detail/detail.d.mts +2 -0
- package/api/docs/detail/detail.mjs +12 -10
- package/api/docs/detail/section/section.d.mts +2 -0
- package/api/docs/detail/section/section.mjs +1 -0
- package/api/docs/docs.d.mts +2 -0
- package/api/docs/docs.doc.mjs +11 -2
- package/api/docs/docs.mjs +2 -1
- package/api/docs/docs.type.d.mts +15 -0
- package/api/docs/docs.type.mjs +6 -0
- package/api/docs/integrationDocs.test.mjs +208 -0
- package/api/docs/list/list.d.mts +5 -1
- package/api/docs/list/list.mjs +30 -14
- package/api/init/run/run.mjs +9 -4
- package/api/integration/validate-integration.mjs +2 -1
- package/api/integration/validate-integration.type.d.mts +1 -0
- package/api/integration/validate-integration.type.mjs +1 -0
- package/api/search/search.mjs +26 -15
- package/api/upgrade/_adapter.d.mts +2 -2
- package/api/upgrade/_adapter.mjs +7 -3
- package/api/upgrade/run/run.mjs +1 -1
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
- package/assets/docs/cli-integrations.doc.mjs +41 -3
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/docs/theme.doc.mjs +3 -3
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
- package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
- package/assets/templates/pages/dashboard-data/page.tsx +894 -0
- package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
- package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
- package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
- package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
- package/authoring/doctypes/_schema.d.mts +2 -0
- package/authoring/doctypes/_schema.mjs +5 -0
- package/authoring/doctypes/reference/reference.doc.mjs +14 -0
- package/authoring/doctypes/reference/type.ts +12 -0
- package/authoring/doctypes/template/type.ts +2 -0
- package/authoring/integration/integration.doc.mjs +10 -1
- package/authoring/integration/parse.d.mts +1 -0
- package/authoring/integration/parse.mjs +1 -0
- package/authoring/integration/parse.test.mjs +1 -0
- package/authoring/integration/type.ts +5 -0
- package/clients/cli/commands/component-ownership.test.mjs +63 -3
- package/clients/cli/commands/theme-build.doc.mjs +1 -1
- package/foundation/agent-docs/agent-docs.d.mts +19 -3
- package/foundation/agent-docs/agent-docs.mjs +30 -11
- package/foundation/agent-docs/agent-docs.test.mjs +38 -0
- package/foundation/config/project.d.mts +16 -0
- package/foundation/config/project.mjs +65 -4
- package/foundation/config/project.test.mjs +66 -0
- package/foundation/discovery/docs-discovery.d.mts +185 -0
- package/foundation/discovery/docs-discovery.mjs +544 -0
- package/foundation/discovery/docs-discovery.test.mjs +341 -0
- package/foundation/integrations/integrations.d.mts +8 -6
- package/foundation/integrations/integrations.mjs +6 -4
- package/foundation/integrations/validate-contributions.mjs +30 -2
- package/package.json +9 -9
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Reference-doc (topic) discovery — the CLI's own topics plus the ones
|
|
5
|
+
* configured integrations contribute, resolved into one catalog.
|
|
6
|
+
*
|
|
7
|
+
* @input packages/cli/assets/docs/{topic}.doc.mjs (built in), and each loaded
|
|
8
|
+
* integration's resolved `docs` root ({topic}.doc.{ts,mjs,js}).
|
|
9
|
+
* @output A {@link DocsCatalog}: every topic the project can read, keyed by
|
|
10
|
+
* name, carrying its owner package, its file, and any extension overlays —
|
|
11
|
+
* plus the alias a renamed replacement leaves behind.
|
|
12
|
+
* @position foundation/discovery — the single seam every docs surface reads
|
|
13
|
+
* (api/docs, api/search, and the agent-docs block), so a topic contributed
|
|
14
|
+
* once shows up in all of them.
|
|
15
|
+
*
|
|
16
|
+
* An integration contributes a topic the way it contributes a component: a
|
|
17
|
+
* root in its manifest, a file per artifact. What a doc says about its
|
|
18
|
+
* relationship to an existing topic is authored on the doc itself, not in a
|
|
19
|
+
* second registry that has to be kept in step with it:
|
|
20
|
+
*
|
|
21
|
+
* (neither) add a topic under its own name
|
|
22
|
+
* replaces: 'x' take over topic x — core's, or another integration's
|
|
23
|
+
* extends: 'x' merge onto topic x, section by section
|
|
24
|
+
*
|
|
25
|
+
* A topic whose name collides with an existing one and declares neither is an
|
|
26
|
+
* `invalid_doc` issue rather than silent shadowing. Shadowing by name would
|
|
27
|
+
* make a core rename swallow an integration's guide (or the reverse) with no
|
|
28
|
+
* diagnostic anywhere, which is the failure mode integration discovery already
|
|
29
|
+
* refuses for components provided by two packages.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as fs from 'node:fs';
|
|
33
|
+
import * as path from 'node:path';
|
|
34
|
+
import {CLI_ROOT} from '../fs/paths.mjs';
|
|
35
|
+
import {importUserModule} from '../fs/module-loader.mjs';
|
|
36
|
+
import {parseDoc} from '../../authoring/doctypes/parse.mjs';
|
|
37
|
+
|
|
38
|
+
/** Where the CLI's own topics live. */
|
|
39
|
+
const BUILTIN_DOCS_DIR = path.join(CLI_ROOT, 'assets', 'docs');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Owner package recorded for the built-in topics. They ship inside the CLI
|
|
43
|
+
* (assets/docs), not in @astryxdesign/core, so this is the CLI's own name —
|
|
44
|
+
* unlike component discovery, whose built-ins belong to core.
|
|
45
|
+
*/
|
|
46
|
+
export const BUILTIN_DOCS_PACKAGE = '@astryxdesign/cli';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A built-in topic file: `{topic}.doc.mjs`. Anchored at both ends so a
|
|
50
|
+
* localization overlay (`{topic}.doc.zh.mjs`) is not read as a topic of its
|
|
51
|
+
* own — it is loaded by the topic it overlays.
|
|
52
|
+
*/
|
|
53
|
+
const BUILTIN_TOPIC_FILE_RE = /^([\w-]+)\.doc\.mjs$/;
|
|
54
|
+
|
|
55
|
+
/** Conventional doc-file suffixes for an integration's topics. */
|
|
56
|
+
const INTEGRATION_DOC_SUFFIXES = ['.doc.ts', '.doc.mjs', '.doc.js'];
|
|
57
|
+
|
|
58
|
+
/** A topic name is a CLI argument and a URL segment; keep it to both. */
|
|
59
|
+
const TOPIC_NAME_RE = /^[\w-]+$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} DocsTopicRecord A doc file discovered under a docs root.
|
|
63
|
+
* @property {string} name
|
|
64
|
+
* @property {string} package owner package
|
|
65
|
+
* @property {string} path absolute path to the doc file
|
|
66
|
+
* @property {string} [title]
|
|
67
|
+
* @property {string} [description]
|
|
68
|
+
* @property {string|null} [category]
|
|
69
|
+
* @property {string} [replaces] topic this doc takes the place of
|
|
70
|
+
* @property {string} [extendsTopic] topic this doc merges onto (`extends`)
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {object} DocsTopicEntry A resolved topic in the catalog.
|
|
75
|
+
* @property {string} name
|
|
76
|
+
* @property {string} package owner package
|
|
77
|
+
* @property {string} path absolute path to the doc file
|
|
78
|
+
* @property {string} [title]
|
|
79
|
+
* @property {string} [description]
|
|
80
|
+
* @property {string|null} [category]
|
|
81
|
+
* @property {string} [replaces] the topic this one took the place of
|
|
82
|
+
* @property {Array<{package: string, path: string}>} extensions overlays to
|
|
83
|
+
* merge onto the base doc, in the order their integrations were configured
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Discover the CLI's own topics.
|
|
88
|
+
* @returns {Record<string, string>} topic name → absolute doc path
|
|
89
|
+
*/
|
|
90
|
+
export function discoverBuiltinTopics() {
|
|
91
|
+
/** @type {Record<string, string>} */
|
|
92
|
+
const topics = Object.create(null);
|
|
93
|
+
if (!fs.existsSync(BUILTIN_DOCS_DIR)) return topics;
|
|
94
|
+
for (const file of fs.readdirSync(BUILTIN_DOCS_DIR)) {
|
|
95
|
+
const match = file.match(BUILTIN_TOPIC_FILE_RE);
|
|
96
|
+
if (match) topics[match[1]] = path.join(BUILTIN_DOCS_DIR, file);
|
|
97
|
+
}
|
|
98
|
+
return topics;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a topic doc from disk. A `.ts` doc is loaded through jiti, the rest
|
|
103
|
+
* natively; both the historical `export const docs` and the stamped
|
|
104
|
+
* `export default` forms are accepted, because core authors the first and the
|
|
105
|
+
* integration guide documents the second.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} file absolute path to a doc file
|
|
108
|
+
* @returns {Promise<unknown>} the authored doc value
|
|
109
|
+
*/
|
|
110
|
+
export async function loadTopicModule(file) {
|
|
111
|
+
const mod = await importUserModule(file);
|
|
112
|
+
const doc = mod?.docs ?? mod?.default;
|
|
113
|
+
if (doc == null) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`${path.basename(file)} exports no doc. A topic exports \`docs\` (or a default export).`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return doc;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every block kind a section may hold, with the fields each one requires. */
|
|
122
|
+
const BLOCK_FIELDS = {
|
|
123
|
+
prose: ['text'],
|
|
124
|
+
heading: ['level', 'text'],
|
|
125
|
+
code: ['lang', 'code'],
|
|
126
|
+
table: ['headers', 'rows'],
|
|
127
|
+
list: ['style', 'items'],
|
|
128
|
+
'token-ref': ['topic', 'section'],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fields a block kind may carry but does not need. Kept per kind rather than
|
|
133
|
+
* globally: only a code block renders a `label`, so allowing it everywhere
|
|
134
|
+
* would wave through the misspellings this check exists to catch.
|
|
135
|
+
*/
|
|
136
|
+
const OPTIONAL_BLOCK_FIELDS = {code: ['label']};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fields whose value has to be one of a set, because the renderer indexes on
|
|
140
|
+
* it. An unlisted heading level renders at the wrong depth and an unlisted
|
|
141
|
+
* list style resolves to undefined, so the value is checked, not just its
|
|
142
|
+
* presence.
|
|
143
|
+
*/
|
|
144
|
+
const BLOCK_FIELD_VALUES = {
|
|
145
|
+
heading: {level: [3, 4, 5, 6]},
|
|
146
|
+
list: {style: ['ordered', 'unordered', 'do', 'dont']},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Keys a section may carry. */
|
|
150
|
+
const SECTION_FIELDS = ['title', 'category', 'content', 'previewType'];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check the fields the docs surfaces actually read. `parseDoc` is the outer
|
|
154
|
+
* gate, but the reference-doc schema is a passthrough over `{name, type}` —
|
|
155
|
+
* a doc with no `sections`, or a prose block whose `text` is misspelled,
|
|
156
|
+
* passes it and reaches a reader as a missing section or a blank gap. Those
|
|
157
|
+
* are hard to trace back from the rendered output, so they are caught here,
|
|
158
|
+
* where the file that needs fixing can be named.
|
|
159
|
+
*
|
|
160
|
+
* @param {any} doc a parsed doc
|
|
161
|
+
* @returns {string[]} problems, each already pointed at a place in the doc
|
|
162
|
+
*/
|
|
163
|
+
export function problemsInTopic(doc) {
|
|
164
|
+
/** @type {string[]} */
|
|
165
|
+
const problems = [];
|
|
166
|
+
for (const field of ['name', 'title', 'description']) {
|
|
167
|
+
if (typeof doc?.[field] !== 'string' || doc[field] === '') {
|
|
168
|
+
problems.push(`${field}: expected a non-empty string`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (typeof doc?.name === 'string' && !TOPIC_NAME_RE.test(doc.name)) {
|
|
172
|
+
problems.push(
|
|
173
|
+
`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 "-".`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(doc?.sections) || doc.sections.length === 0) {
|
|
177
|
+
problems.push('sections: expected at least one section');
|
|
178
|
+
return problems;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
doc.sections.forEach((/** @type {any} */ section, /** @type {number} */ s) => {
|
|
182
|
+
const at = `sections[${s}]`;
|
|
183
|
+
if (typeof section?.title !== 'string' || section.title === '') {
|
|
184
|
+
problems.push(`${at}.title: expected a non-empty string`);
|
|
185
|
+
}
|
|
186
|
+
for (const key of Object.keys(section ?? {})) {
|
|
187
|
+
if (!SECTION_FIELDS.includes(key)) {
|
|
188
|
+
problems.push(`${at}.${key}: not a field of a section`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(section?.content)) {
|
|
192
|
+
problems.push(`${at}.content: expected an array of blocks`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
section.content.forEach((/** @type {any} */ block, /** @type {number} */ b) => {
|
|
196
|
+
const blockAt = `${at}.content[${b}]`;
|
|
197
|
+
const fields = /** @type {Record<string, string[]>} */ (BLOCK_FIELDS)[block?.type];
|
|
198
|
+
if (fields == null) {
|
|
199
|
+
problems.push(
|
|
200
|
+
`${blockAt}.type: ${JSON.stringify(block?.type)} is not one of ${Object.keys(BLOCK_FIELDS).join(', ')}`,
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const field of fields) {
|
|
205
|
+
const value = block[field];
|
|
206
|
+
// Empty counts as missing, the way it does for the doc's own title: a
|
|
207
|
+
// block whose text is '' passes every other check and renders as a gap.
|
|
208
|
+
if (value == null) {
|
|
209
|
+
problems.push(`${blockAt}.${field}: required for a ${block.type} block`);
|
|
210
|
+
} else if (typeof value === 'string' && value.trim() === '') {
|
|
211
|
+
problems.push(`${blockAt}.${field}: expected a non-empty string`);
|
|
212
|
+
} else if (Array.isArray(value) && value.length === 0) {
|
|
213
|
+
problems.push(`${blockAt}.${field}: expected a non-empty array`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const allowedValues =
|
|
217
|
+
/** @type {Record<string, Record<string, unknown[]>>} */ (BLOCK_FIELD_VALUES)[block.type] ?? {};
|
|
218
|
+
for (const [field, values] of Object.entries(allowedValues)) {
|
|
219
|
+
const value = block[field];
|
|
220
|
+
if (value != null && !values.includes(value)) {
|
|
221
|
+
problems.push(
|
|
222
|
+
`${blockAt}.${field}: ${JSON.stringify(value)} is not one of ${values.join(', ')}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// A table's cells are read by column index, so a short row renders blank
|
|
227
|
+
// cells and a long one drops its tail — both silently.
|
|
228
|
+
if (block.type === 'table' && Array.isArray(block.headers) && Array.isArray(block.rows)) {
|
|
229
|
+
block.rows.forEach((/** @type {any} */ row, /** @type {number} */ r) => {
|
|
230
|
+
if (!Array.isArray(row)) {
|
|
231
|
+
problems.push(`${blockAt}.rows[${r}]: expected an array of cells`);
|
|
232
|
+
} else if (row.length !== block.headers.length) {
|
|
233
|
+
problems.push(
|
|
234
|
+
`${blockAt}.rows[${r}]: has ${row.length} cells but the table has ${block.headers.length} headers`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// An unknown key is almost always a misspelled required one, and it
|
|
240
|
+
// would otherwise reach a reader as a block that renders nothing.
|
|
241
|
+
const allowed = [
|
|
242
|
+
'type',
|
|
243
|
+
...fields,
|
|
244
|
+
...(/** @type {Record<string, string[]>} */ (OPTIONAL_BLOCK_FIELDS)[block.type] ?? []),
|
|
245
|
+
];
|
|
246
|
+
for (const key of Object.keys(block)) {
|
|
247
|
+
if (!allowed.includes(key)) {
|
|
248
|
+
problems.push(`${blockAt}.${key}: not a field of a ${block.type} block`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
return problems;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Discover the topics contributed by a single loaded integration. Mirrors
|
|
258
|
+
* `discoverIntegrationComponents`: walk the resolved root, take every
|
|
259
|
+
* conventional doc file, and record what it declares. Unlike component
|
|
260
|
+
* discovery this loads each doc, because a topic's name and its relationship
|
|
261
|
+
* to an existing topic are fields inside the file.
|
|
262
|
+
*
|
|
263
|
+
* Errors are returned, not thrown: one unusable doc is reported as an issue
|
|
264
|
+
* against its package while the rest of the CLI keeps working.
|
|
265
|
+
*
|
|
266
|
+
* @param {{name: string, docs?: string}} integration a loaded integration
|
|
267
|
+
* @returns {Promise<{records: DocsTopicRecord[], errors: Error[]}>}
|
|
268
|
+
*/
|
|
269
|
+
export async function discoverIntegrationDocs(integration) {
|
|
270
|
+
const docsDir = integration?.docs;
|
|
271
|
+
/** @type {DocsTopicRecord[]} */
|
|
272
|
+
const records = [];
|
|
273
|
+
/** @type {Error[]} */
|
|
274
|
+
const errors = [];
|
|
275
|
+
if (!docsDir || !fs.existsSync(docsDir)) return {records, errors};
|
|
276
|
+
|
|
277
|
+
/** @type {string[]} */
|
|
278
|
+
const files = [];
|
|
279
|
+
/** @param {string} dirPath */
|
|
280
|
+
function scanDir(dirPath) {
|
|
281
|
+
for (const entry of fs.readdirSync(dirPath, {withFileTypes: true})) {
|
|
282
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
283
|
+
const full = path.join(dirPath, entry.name);
|
|
284
|
+
if (entry.isDirectory()) {
|
|
285
|
+
scanDir(full);
|
|
286
|
+
} else if (INTEGRATION_DOC_SUFFIXES.some(suffix => entry.name.endsWith(suffix))) {
|
|
287
|
+
files.push(full);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
scanDir(docsDir);
|
|
292
|
+
files.sort();
|
|
293
|
+
|
|
294
|
+
/** @type {Map<string, string>} */
|
|
295
|
+
const seen = new Map();
|
|
296
|
+
for (const file of files) {
|
|
297
|
+
let doc;
|
|
298
|
+
try {
|
|
299
|
+
doc = parseDoc(await loadTopicModule(file), path.basename(file));
|
|
300
|
+
} catch (err) {
|
|
301
|
+
errors.push(new Error(`${path.relative(docsDir, file)}: ${/** @type {any} */ (err).message}`));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const problems = problemsInTopic(doc);
|
|
305
|
+
if (problems.length > 0) {
|
|
306
|
+
errors.push(
|
|
307
|
+
new Error(
|
|
308
|
+
`${path.relative(docsDir, file)} is not a usable topic:\n${problems
|
|
309
|
+
.map(problem => ` ${problem}`)
|
|
310
|
+
.join('\n')}`,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const parsed = /** @type {any} */ (doc);
|
|
316
|
+
// Two files claiming one name would collapse into a single entry, and the
|
|
317
|
+
// one that lost would never be reachable. Named here, where both files are.
|
|
318
|
+
const previous = seen.get(parsed.name);
|
|
319
|
+
if (previous) {
|
|
320
|
+
errors.push(
|
|
321
|
+
new Error(
|
|
322
|
+
`${path.relative(docsDir, file)} and ${previous} both define the topic "${parsed.name}". Each topic name is a URL and a CLI argument, so they have to be unique.`,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
seen.set(parsed.name, path.relative(docsDir, file));
|
|
328
|
+
if (parsed.replaces != null && parsed.extends != null) {
|
|
329
|
+
errors.push(
|
|
330
|
+
new Error(
|
|
331
|
+
`${path.relative(docsDir, file)} declares both \`replaces\` and \`extends\`. A topic either takes another's place or merges onto it.`,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
records.push({
|
|
337
|
+
name: parsed.name,
|
|
338
|
+
package: integration.name,
|
|
339
|
+
path: file,
|
|
340
|
+
title: parsed.title,
|
|
341
|
+
description: parsed.description,
|
|
342
|
+
category: parsed.category ?? null,
|
|
343
|
+
replaces: parsed.replaces,
|
|
344
|
+
extendsTopic: parsed.extends,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {records, errors};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Merge an extension onto a base topic: a section whose title matches one in
|
|
353
|
+
* the base replaces it, a section the base does not have is appended, and the
|
|
354
|
+
* title/description are taken from the extension when it states them.
|
|
355
|
+
*
|
|
356
|
+
* Keyed by section TITLE rather than by position, the way the localization
|
|
357
|
+
* overlays are — position keying grafts an overlay onto whichever section
|
|
358
|
+
* happens to share its index, so a partial or reordered overlay corrupts
|
|
359
|
+
* everything after it (#2182).
|
|
360
|
+
*
|
|
361
|
+
* @param {any} base
|
|
362
|
+
* @param {any} overlay
|
|
363
|
+
* @returns {any} a new doc; neither input is mutated
|
|
364
|
+
*/
|
|
365
|
+
export function mergeTopic(base, overlay) {
|
|
366
|
+
const sections = [...(base.sections ?? [])];
|
|
367
|
+
for (const section of overlay.sections ?? []) {
|
|
368
|
+
const at = sections.findIndex((/** @type {any} */ s) => s.title === section.title);
|
|
369
|
+
if (at === -1) sections.push(section);
|
|
370
|
+
else sections[at] = section;
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
...base,
|
|
374
|
+
title: overlay.title || base.title,
|
|
375
|
+
description: overlay.description || base.description,
|
|
376
|
+
sections,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Every topic a project can read, and the relationships between them.
|
|
382
|
+
*
|
|
383
|
+
* Insertion order is the read order: the built-in topics in discovery order,
|
|
384
|
+
* then whatever the configured integrations add, in the order they are
|
|
385
|
+
* configured. A replacement keeps the position of the topic it replaced, so
|
|
386
|
+
* "the first topic" stays stable for a reader that opens it by default.
|
|
387
|
+
*/
|
|
388
|
+
export class DocsCatalog {
|
|
389
|
+
/** @type {Map<string, DocsTopicEntry>} */
|
|
390
|
+
#topics = new Map();
|
|
391
|
+
/** @type {Map<string, string>} old topic name → the name that replaced it */
|
|
392
|
+
#aliases = new Map();
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Seed a catalog with the CLI's own topics.
|
|
396
|
+
* @param {Record<string, string>} [builtins] topic name → absolute path
|
|
397
|
+
* @returns {DocsCatalog}
|
|
398
|
+
*/
|
|
399
|
+
static fromBuiltins(builtins = discoverBuiltinTopics()) {
|
|
400
|
+
const catalog = new DocsCatalog();
|
|
401
|
+
for (const [name, file] of Object.entries(builtins)) {
|
|
402
|
+
catalog.#topics.set(name, {
|
|
403
|
+
name,
|
|
404
|
+
package: BUILTIN_DOCS_PACKAGE,
|
|
405
|
+
path: file,
|
|
406
|
+
extensions: [],
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return catalog;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Add one integration-contributed doc, honoring what it declares. Returns
|
|
414
|
+
* the issue it caused, or null when it applied cleanly — the caller owns
|
|
415
|
+
* routing (an `error` skips the contribution, a `warning` keeps it).
|
|
416
|
+
*
|
|
417
|
+
* @param {DocsTopicRecord} record
|
|
418
|
+
* @returns {import('../integrations/issue').AstryxIntegrationIssue | null}
|
|
419
|
+
*/
|
|
420
|
+
add(record) {
|
|
421
|
+
if (record.extendsTopic != null) {
|
|
422
|
+
const target = this.resolve(record.extendsTopic);
|
|
423
|
+
if (!target) {
|
|
424
|
+
return {
|
|
425
|
+
code: 'invalid_doc',
|
|
426
|
+
severity: 'error',
|
|
427
|
+
message: `"${record.name}" extends "${record.extendsTopic}", which is not a topic in this project.`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
target.extensions.push({package: record.package, path: record.path});
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (record.replaces != null) {
|
|
435
|
+
const target = this.resolve(record.replaces);
|
|
436
|
+
if (!target) {
|
|
437
|
+
return {
|
|
438
|
+
code: 'invalid_doc',
|
|
439
|
+
severity: 'error',
|
|
440
|
+
message: `"${record.name}" replaces "${record.replaces}", which is not a topic in this project.`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/** @type {import('../integrations/issue').AstryxIntegrationIssue | null} */
|
|
444
|
+
let warning = null;
|
|
445
|
+
if (target.package !== BUILTIN_DOCS_PACKAGE) {
|
|
446
|
+
// Two integrations replacing one topic is a real configuration, not a
|
|
447
|
+
// broken one: the later-configured package wins, the way the last
|
|
448
|
+
// writer does everywhere else. Both are named so the loser is visible.
|
|
449
|
+
warning = {
|
|
450
|
+
code: 'duplicate_doc',
|
|
451
|
+
severity: 'warning',
|
|
452
|
+
message: `Topic "${record.replaces}" is replaced by both ${target.package} and ${record.package}. ${record.package} is configured later, so it wins.`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// The replacement takes the base topic's slot, so a reader that opens
|
|
456
|
+
// the first topic (or the nth) sees the same one it did before.
|
|
457
|
+
const replaced = target.name;
|
|
458
|
+
this.#replaceAt(replaced, {
|
|
459
|
+
name: record.name,
|
|
460
|
+
package: record.package,
|
|
461
|
+
path: record.path,
|
|
462
|
+
title: record.title,
|
|
463
|
+
description: record.description,
|
|
464
|
+
category: record.category,
|
|
465
|
+
replaces: replaced,
|
|
466
|
+
// Extensions were authored against the content that just went away.
|
|
467
|
+
extensions: [],
|
|
468
|
+
});
|
|
469
|
+
if (record.name !== replaced) {
|
|
470
|
+
this.#aliases.set(replaced, record.name);
|
|
471
|
+
// A topic renamed twice keeps every name it has ever answered to.
|
|
472
|
+
for (const [from, to] of this.#aliases) {
|
|
473
|
+
if (to === replaced) this.#aliases.set(from, record.name);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return warning;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const existing = this.#topics.get(record.name);
|
|
480
|
+
if (existing) {
|
|
481
|
+
return {
|
|
482
|
+
code: 'invalid_doc',
|
|
483
|
+
severity: 'error',
|
|
484
|
+
message: `Topic "${record.name}" is already provided by ${existing.package}. Give it another name, or declare \`replaces: '${record.name}'\` to take its place.`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
this.#topics.set(record.name, {
|
|
488
|
+
name: record.name,
|
|
489
|
+
package: record.package,
|
|
490
|
+
path: record.path,
|
|
491
|
+
title: record.title,
|
|
492
|
+
description: record.description,
|
|
493
|
+
category: record.category,
|
|
494
|
+
extensions: [],
|
|
495
|
+
});
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Look a topic up by name, case-insensitively, following the alias a renamed
|
|
501
|
+
* replacement left behind.
|
|
502
|
+
* @param {unknown} name
|
|
503
|
+
* @returns {DocsTopicEntry | undefined}
|
|
504
|
+
*/
|
|
505
|
+
resolve(name) {
|
|
506
|
+
if (typeof name !== 'string') return undefined;
|
|
507
|
+
let key = name.toLowerCase();
|
|
508
|
+
// An alias chain is at most as long as the number of replacements, and a
|
|
509
|
+
// cycle can only come from a bug here; bound the walk either way.
|
|
510
|
+
for (let hops = 0; hops <= this.#aliases.size; hops++) {
|
|
511
|
+
const entry = this.#topics.get(key);
|
|
512
|
+
if (entry) return entry;
|
|
513
|
+
const next = this.#aliases.get(key);
|
|
514
|
+
if (next == null) return undefined;
|
|
515
|
+
key = next;
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** @returns {string[]} every topic name, in read order */
|
|
521
|
+
names() {
|
|
522
|
+
return [...this.#topics.keys()];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @returns {DocsTopicEntry[]} every topic, in read order */
|
|
526
|
+
entries() {
|
|
527
|
+
return [...this.#topics.values()];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Swap an entry in place, preserving its position in the read order.
|
|
532
|
+
* @param {string} name
|
|
533
|
+
* @param {DocsTopicEntry} entry
|
|
534
|
+
*/
|
|
535
|
+
#replaceAt(name, entry) {
|
|
536
|
+
/** @type {Map<string, DocsTopicEntry>} */
|
|
537
|
+
const next = new Map();
|
|
538
|
+
for (const [key, value] of this.#topics) {
|
|
539
|
+
if (key === name) next.set(entry.name, entry);
|
|
540
|
+
else next.set(key, value);
|
|
541
|
+
}
|
|
542
|
+
this.#topics = next;
|
|
543
|
+
}
|
|
544
|
+
}
|