@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/api/component/_adapter.d.mts +22 -5
  3. package/api/component/_adapter.mjs +51 -3
  4. package/api/docs/_adapter.d.mts +37 -8
  5. package/api/docs/_adapter.mjs +70 -41
  6. package/api/docs/detail/detail.d.mts +2 -0
  7. package/api/docs/detail/detail.mjs +12 -10
  8. package/api/docs/detail/section/section.d.mts +2 -0
  9. package/api/docs/detail/section/section.mjs +1 -0
  10. package/api/docs/docs.d.mts +2 -0
  11. package/api/docs/docs.doc.mjs +11 -2
  12. package/api/docs/docs.mjs +2 -1
  13. package/api/docs/docs.type.d.mts +15 -0
  14. package/api/docs/docs.type.mjs +6 -0
  15. package/api/docs/integrationDocs.test.mjs +208 -0
  16. package/api/docs/list/list.d.mts +5 -1
  17. package/api/docs/list/list.mjs +30 -14
  18. package/api/init/run/run.mjs +9 -4
  19. package/api/integration/validate-integration.mjs +2 -1
  20. package/api/integration/validate-integration.type.d.mts +1 -0
  21. package/api/integration/validate-integration.type.mjs +1 -0
  22. package/api/search/search.mjs +26 -15
  23. package/api/upgrade/_adapter.d.mts +2 -2
  24. package/api/upgrade/_adapter.mjs +7 -3
  25. package/api/upgrade/run/run.mjs +1 -1
  26. package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
  27. package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
  28. package/assets/docs/cli-integrations.doc.mjs +41 -3
  29. package/assets/docs/layout.doc.dense.mjs +275 -30
  30. package/assets/docs/layout.doc.mjs +412 -78
  31. package/assets/docs/theme.doc.mjs +3 -3
  32. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
  33. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
  34. package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
  35. package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
  36. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
  37. package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
  38. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
  39. package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
  40. package/assets/templates/pages/dashboard-data/page.tsx +894 -0
  41. package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
  42. package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
  43. package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
  44. package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
  45. package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
  46. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
  47. package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
  48. package/authoring/doctypes/_schema.d.mts +2 -0
  49. package/authoring/doctypes/_schema.mjs +5 -0
  50. package/authoring/doctypes/reference/reference.doc.mjs +14 -0
  51. package/authoring/doctypes/reference/type.ts +12 -0
  52. package/authoring/doctypes/template/type.ts +2 -0
  53. package/authoring/integration/integration.doc.mjs +10 -1
  54. package/authoring/integration/parse.d.mts +1 -0
  55. package/authoring/integration/parse.mjs +1 -0
  56. package/authoring/integration/parse.test.mjs +1 -0
  57. package/authoring/integration/type.ts +5 -0
  58. package/clients/cli/commands/component-ownership.test.mjs +63 -3
  59. package/clients/cli/commands/theme-build.doc.mjs +1 -1
  60. package/foundation/agent-docs/agent-docs.d.mts +19 -3
  61. package/foundation/agent-docs/agent-docs.mjs +30 -11
  62. package/foundation/agent-docs/agent-docs.test.mjs +38 -0
  63. package/foundation/config/project.d.mts +16 -0
  64. package/foundation/config/project.mjs +65 -4
  65. package/foundation/config/project.test.mjs +66 -0
  66. package/foundation/discovery/docs-discovery.d.mts +185 -0
  67. package/foundation/discovery/docs-discovery.mjs +544 -0
  68. package/foundation/discovery/docs-discovery.test.mjs +341 -0
  69. package/foundation/integrations/integrations.d.mts +8 -6
  70. package/foundation/integrations/integrations.mjs +6 -4
  71. package/foundation/integrations/validate-contributions.mjs +30 -2
  72. 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
+ }