@astryxdesign/cli 0.7.2-canary.db3a2e7 → 0.7.2-canary.ebcc404

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 (63) hide show
  1. package/api/component/_adapter.d.mts +27 -1
  2. package/api/component/_adapter.mjs +45 -7
  3. package/api/component/component.mjs +12 -3
  4. package/api/component/component.test.mjs +420 -167
  5. package/api/component/integration-export-name.test.mjs +419 -0
  6. package/api/component/list/list.mjs +42 -4
  7. package/api/gap-report/gap-report.mjs +66 -4
  8. package/api/integration/pack-check.mjs +13 -104
  9. package/api/integration/pack-check.test.mjs +112 -0
  10. package/api/search/search.d.mts +9 -0
  11. package/api/search/search.mjs +90 -10
  12. package/api/search/search.test.mjs +36 -0
  13. package/api/swizzle/copy/copy.mjs +28 -14
  14. package/api/template/template.test.mjs +120 -3
  15. package/api/theme/build/build.family.test.mjs +16 -0
  16. package/api/theme/build/build.mjs +77 -18
  17. package/api/theme/themeBuild.doc.mjs +3 -3
  18. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  19. package/assets/codemods/transforms/next/index.mjs +11 -1
  20. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  21. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  22. package/assets/docs/working-with-ai.doc.mjs +43 -4
  23. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  24. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  25. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  26. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  27. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  28. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  29. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  30. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  31. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  32. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  33. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  34. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  35. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  36. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  37. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  38. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  39. package/assets/templates/pages/detail-page/page.tsx +3 -3
  40. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  41. package/authoring/doctypes/base/type.ts +6 -0
  42. package/authoring/doctypes/component/component.doc.mjs +3 -3
  43. package/authoring/doctypes/component/type.ts +6 -3
  44. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  45. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  46. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  47. package/clients/cli/lib/component-format.mjs +5 -1
  48. package/foundation/discovery/component-discovery.d.mts +95 -6
  49. package/foundation/discovery/component-discovery.export-names.test.mjs +293 -0
  50. package/foundation/discovery/component-discovery.mjs +524 -49
  51. package/foundation/discovery/component-discovery.test.mjs +96 -0
  52. package/foundation/discovery/component-replacement.mjs +18 -0
  53. package/foundation/discovery/component-replacement.test.mjs +20 -0
  54. package/foundation/discovery/module-exports.d.mts +21 -0
  55. package/foundation/discovery/module-exports.mjs +343 -31
  56. package/foundation/discovery/module-exports.test.mjs +172 -0
  57. package/foundation/discovery/template-adapter.mjs +74 -5
  58. package/foundation/doc-compiler/doc-loads.test.mjs +5 -1
  59. package/foundation/doc-compiler/inputs.mjs +23 -0
  60. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  61. package/foundation/xle/registry.mjs +4 -0
  62. package/foundation/xle/registry.test.mjs +28 -0
  63. package/package.json +10 -10
@@ -0,0 +1,419 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Every surface knows an integration component by the name its doc
5
+ * documents. Fixtures live under a repo-local temp dir: Vite refuses to import
6
+ * a module from outside the project root.
7
+ */
8
+
9
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {component} from './component.mjs';
13
+ import {search} from '../search/search.mjs';
14
+ import {validateIntegration} from '../integration/validate-integration.mjs';
15
+ import {integrationComponentConflicts} from '../integration/authoring-checks.mjs';
16
+ import {swizzleCopy} from '../swizzle/copy/copy.mjs';
17
+ import {gapReport} from '../gap-report/gap-report.mjs';
18
+ import {COMPONENT_REPLACES_CLI} from '../../foundation/integrations/cli-requirement.mjs';
19
+
20
+ const SLOW = 30_000;
21
+
22
+ /** @type {string} */
23
+ let tmpDir;
24
+ /** @type {string} */
25
+ let pkgDir;
26
+
27
+ /**
28
+ * @param {string} file path under the package's src/, without extension
29
+ * @param {string} name
30
+ * @param {string} source
31
+ * @param {string} description
32
+ * @param {object} [extra] more doc fields
33
+ */
34
+ function writeComponent(file, name, source, description, extra = {}) {
35
+ const target = path.join(pkgDir, 'src', file);
36
+ fs.mkdirSync(path.dirname(target), {recursive: true});
37
+ fs.writeFileSync(`${target}.tsx`, source);
38
+ fs.writeFileSync(
39
+ `${target}.doc.mjs`,
40
+ `export default ${JSON.stringify(
41
+ {
42
+ type: 'component',
43
+ name,
44
+ displayName: name,
45
+ import: `@acme/widgets/${file}`,
46
+ usage: {description},
47
+ props: [],
48
+ ...extra,
49
+ },
50
+ null,
51
+ 2,
52
+ )};\n`,
53
+ );
54
+ }
55
+
56
+ beforeEach(() => {
57
+ tmpDir = fs.mkdtempSync(
58
+ path.join(process.cwd(), '.astryx-export-name-test-'),
59
+ );
60
+ fs.writeFileSync(
61
+ path.join(tmpDir, 'package.json'),
62
+ JSON.stringify({name: 'consumer', version: '1.0.0'}),
63
+ );
64
+ fs.writeFileSync(
65
+ path.join(tmpDir, 'astryx.config.mjs'),
66
+ "export default {integrations: ['@acme/widgets']};\n",
67
+ );
68
+ pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
69
+ fs.mkdirSync(pkgDir, {recursive: true});
70
+ fs.writeFileSync(
71
+ path.join(pkgDir, 'package.json'),
72
+ JSON.stringify({
73
+ name: '@acme/widgets',
74
+ version: '1.0.0',
75
+ exports: {
76
+ './gallery/Slides': './src/gallery/Slides.tsx',
77
+ './gallery/Overlays': './src/gallery/Overlays.tsx',
78
+ './AcmeBadge': './src/AcmeBadge.tsx',
79
+ },
80
+ }),
81
+ );
82
+ fs.writeFileSync(
83
+ path.join(pkgDir, 'astryx.integration.mjs'),
84
+ "export default {components: './src', issuesUrl: 'https://example.com/acme/issues'};\n",
85
+ );
86
+ writeComponent(
87
+ 'gallery/Slides',
88
+ 'AcmeCarousel',
89
+ 'export function AcmeCarousel() { return null; }\nexport function useSlides() { return []; }\n',
90
+ 'Cycles through a row of slides.',
91
+ );
92
+ writeComponent(
93
+ 'AcmeBadge',
94
+ 'AcmeBadge',
95
+ 'export function AcmeBadge() { return null; }\n',
96
+ 'A small status label.',
97
+ );
98
+ });
99
+
100
+ afterEach(() => {
101
+ fs.rmSync(tmpDir, {recursive: true, force: true});
102
+ });
103
+
104
+ /** @param {any} list @returns {string[]} the integration's listed names */
105
+ function listedNames(list) {
106
+ return Object.values(list.data.components)
107
+ .flat()
108
+ .filter(entry => entry.package === '@acme/widgets')
109
+ .map(entry => entry.name)
110
+ .sort();
111
+ }
112
+
113
+ describe('a component doc that names another export of its file', () => {
114
+ it(
115
+ "leaves a name a legacy docs package documents to that package's doc",
116
+ async () => {
117
+ const legacy = path.join(tmpDir, 'node_modules', '@legacy', 'docs');
118
+ fs.mkdirSync(path.join(legacy, 'docs'), {recursive: true});
119
+ fs.writeFileSync(
120
+ path.join(legacy, 'package.json'),
121
+ JSON.stringify({name: '@legacy/docs', astryx: {docs: './docs'}}),
122
+ );
123
+ for (const name of ['AcmeCarousel', 'Slides']) {
124
+ fs.writeFileSync(
125
+ path.join(legacy, 'docs', `${name}.doc.mjs`),
126
+ `export default {type: 'component', name: '${name}', usage: {description: 'Legacy.'}, props: []};\n`,
127
+ );
128
+ }
129
+ const cwd = tmpDir;
130
+ expect((await component('AcmeCarousel', {cwd})).data.package).toBe(
131
+ '@legacy/docs',
132
+ );
133
+ const scoped = {cwd, package: '@acme/widgets'};
134
+ expect((await component('AcmeCarousel', scoped)).data.package).toBe(
135
+ '@acme/widgets',
136
+ );
137
+ // Slides.doc.mjs's file name still answers first, as on main.
138
+ expect((await component('Slides', {cwd})).data.package).toBe(
139
+ '@acme/widgets',
140
+ );
141
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
142
+ for (const name of ['AcmeCarousel', 'acmecarousel']) {
143
+ expect((await gapReport(name, report)).data.package).toBe(
144
+ '@astryxdesign/core',
145
+ );
146
+ }
147
+ const hits = (await search('AcmeCarousel', {cwd, type: 'component'})).data
148
+ .results;
149
+ expect(hits.find(hit => hit.package === '@acme/widgets')?.command).toBe(
150
+ 'astryx component AcmeCarousel --package @acme/widgets',
151
+ );
152
+ },
153
+ SLOW,
154
+ );
155
+
156
+ it(
157
+ 'is listed, opened and found by the export name',
158
+ async () => {
159
+ expect(
160
+ listedNames(await component(undefined, {cwd: tmpDir, list: true})),
161
+ ).toEqual(['AcmeBadge', 'AcmeCarousel']);
162
+
163
+ const detail = await component('AcmeCarousel', {cwd: tmpDir});
164
+ expect(detail.type).toBe('component.detail');
165
+ expect(detail.data).toMatchObject({
166
+ name: 'AcmeCarousel',
167
+ package: '@acme/widgets',
168
+ import: '@acme/widgets/gallery/Slides',
169
+ sourceAvailable: true,
170
+ });
171
+
172
+ const hits = (
173
+ await search('AcmeCarousel', {cwd: tmpDir, type: 'component'})
174
+ ).data.results;
175
+ expect(hits[0]).toMatchObject({
176
+ name: 'AcmeCarousel',
177
+ package: '@acme/widgets',
178
+ import: '@acme/widgets/gallery/Slides',
179
+ });
180
+
181
+ // The file name opens the same doc, as it did before, routes a gap
182
+ // report to the package, and search finds the component by it, a word
183
+ // of it included.
184
+ expect((await component('Slides', {cwd: tmpDir})).data.name).toBe(
185
+ 'AcmeCarousel',
186
+ );
187
+ const report = {cwd: tmpDir, category: 'other', reason: 'Needs a size.'};
188
+ expect((await gapReport('Slides', report)).data.package).toBe(
189
+ '@acme/widgets',
190
+ );
191
+ for (const query of ['Slides', 'slide']) {
192
+ const byFileName = (
193
+ await search(query, {cwd: tmpDir, type: 'component'})
194
+ ).data.results;
195
+ expect(byFileName[0]?.name).toBe('AcmeCarousel');
196
+ }
197
+ },
198
+ SLOW,
199
+ );
200
+
201
+ it(
202
+ "leaves a Core name to Core unless --package names it, and passes Doctor's package checks",
203
+ async () => {
204
+ // Overlays.tsx exports Dialog, a Core name, and nothing named Overlays.
205
+ writeComponent(
206
+ 'gallery/Overlays',
207
+ 'Dialog',
208
+ 'export function AcmeOverlays() { return null; }\nexport function Dialog() { return null; }\n',
209
+ 'Overlays.',
210
+ );
211
+
212
+ const validation = await validateIntegration(undefined, {cwd: pkgDir});
213
+ expect(validation.data.issues).toEqual([]);
214
+
215
+ const conflicts = await integrationComponentConflicts(undefined, {
216
+ cwd: pkgDir,
217
+ });
218
+ expect(conflicts.data.conflicts.map(conflict => conflict.name)).toEqual([
219
+ 'Dialog',
220
+ ]);
221
+
222
+ // Lookups by the bare name still answer with Core's Dialog, as before.
223
+ const cwd = tmpDir;
224
+ const core = '@astryxdesign/core';
225
+ expect((await component('Dialog', {cwd})).data.package).toBe(core);
226
+ expect((await component('Dialog', {cwd, props: true})).package).toBe(
227
+ core,
228
+ );
229
+ expect((await swizzleCopy('Dialog', {cwd})).package).toBe(core);
230
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
231
+ expect((await gapReport('Dialog', report)).data.package).toBe(core);
232
+ // The list and --package reach the integration's Dialog.
233
+ const scoped = {cwd, package: '@acme/widgets'};
234
+ expect((await component('Dialog', scoped)).data.import).toBe(
235
+ '@acme/widgets/gallery/Overlays',
236
+ );
237
+ expect(
238
+ listedNames(await component(undefined, {cwd, list: true})),
239
+ ).toContain('Dialog');
240
+ // Search shows both; the package's hit names its package and follows
241
+ // Core's.
242
+ const hits = (await search('Dialog', {cwd, type: 'component'})).data
243
+ .results;
244
+ const ours = hits.findIndex(hit => hit.package === '@acme/widgets');
245
+ expect(hits[0]).toMatchObject({name: 'Dialog', package: core});
246
+ expect(hits[ours]).toMatchObject({
247
+ name: 'Dialog',
248
+ command: 'astryx component Dialog --package @acme/widgets',
249
+ });
250
+ },
251
+ SLOW,
252
+ );
253
+
254
+ it(
255
+ 'leaves a Core name with no folder of its own unknown to swizzle, as before',
256
+ async () => {
257
+ // Core documents CheckboxIndicator inside Indicator's doc, with no folder.
258
+ // BaseTable, named after its file, keeps routing reports to its package.
259
+ writeComponent(
260
+ 'BaseTable',
261
+ 'BaseTable',
262
+ 'export function BaseTable() { return null; }\n',
263
+ 'A file-named table.',
264
+ );
265
+ writeComponent(
266
+ 'gallery/Indicator',
267
+ 'CheckboxIndicator',
268
+ 'export function CheckboxIndicator() { return null; }\n',
269
+ 'A header.',
270
+ );
271
+ const cwd = tmpDir;
272
+ expect((await component('CheckboxIndicator', {cwd})).data.package).toBe(
273
+ '@astryxdesign/core',
274
+ );
275
+ await expect(
276
+ swizzleCopy('CheckboxIndicator', {cwd}),
277
+ ).rejects.toMatchObject({
278
+ code: 'ERR_UNKNOWN_COMPONENT',
279
+ });
280
+ const scoped = {cwd, package: '@acme/widgets'};
281
+ expect((await swizzleCopy('CheckboxIndicator', scoped)).package).toBe(
282
+ '@acme/widgets',
283
+ );
284
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
285
+ expect((await gapReport('CheckboxIndicator', report)).data.package).toBe(
286
+ '@astryxdesign/core',
287
+ );
288
+ const hit = (
289
+ await search('CheckboxIndicator', {cwd, type: 'component'})
290
+ ).data.results.find(result => result.package === '@acme/widgets');
291
+ expect(hit?.command).toBe(
292
+ 'astryx component CheckboxIndicator --package @acme/widgets',
293
+ );
294
+ expect((await gapReport('BaseTable', report)).data.package).toBe(
295
+ '@acme/widgets',
296
+ );
297
+ },
298
+ SLOW,
299
+ );
300
+
301
+ it.each([
302
+ // props must be an array: the loader rejects this doc.
303
+ [
304
+ 'invalid',
305
+ "export default {type: 'component', name: 'AcmeTab', props: 'x'};",
306
+ ],
307
+ [
308
+ 'hidden',
309
+ "export default {\n type: 'component',\n name: 'AcmeTab',\n hidden: true,\n usage: {description: 'x'},\n props: [],\n};",
310
+ ],
311
+ ])(
312
+ "leaves a name to another package's file-named doc even when that doc is %s",
313
+ async (kind, doc) => {
314
+ writeComponent(
315
+ 'gallery/Strip',
316
+ 'AcmeTab',
317
+ 'export function AcmeTab() { return null; }\n',
318
+ 'A strip of tabs.',
319
+ );
320
+ const beta = path.join(tmpDir, 'node_modules', '@beta', 'kit');
321
+ fs.mkdirSync(path.join(beta, 'src'), {recursive: true});
322
+ for (const [file, text] of Object.entries({
323
+ 'package.json': '{"name": "@beta/kit", "version": "1.0.0"}',
324
+ 'astryx.integration.mjs': "export default {components: './src'};",
325
+ 'src/AcmeTab.tsx': 'export function AcmeTab() { return null; }',
326
+ 'src/AcmeTab.doc.mjs': doc,
327
+ })) {
328
+ fs.writeFileSync(path.join(beta, file), `${text}\n`);
329
+ }
330
+ fs.writeFileSync(
331
+ path.join(tmpDir, 'astryx.config.mjs'),
332
+ "export default {integrations: ['@acme/widgets', '@beta/kit']};\n",
333
+ );
334
+
335
+ const cwd = tmpDir;
336
+ expect((await component('AcmeTab', {cwd, source: true})).package).toBe(
337
+ '@beta/kit',
338
+ );
339
+ if (kind === 'invalid') {
340
+ await expect(component('AcmeTab', {cwd})).rejects.toMatchObject({
341
+ code: 'ERR_INVALID_DOC',
342
+ });
343
+ }
344
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
345
+ for (const name of ['AcmeTab', 'acmetab']) {
346
+ expect((await gapReport(name, report)).data.package).toBe(
347
+ '@astryxdesign/core',
348
+ );
349
+ }
350
+ const hit = (
351
+ await search('AcmeTab', {cwd, type: 'component'})
352
+ ).data.results.find(result => result.package === '@acme/widgets');
353
+ expect(hit?.command).toBe(
354
+ 'astryx component AcmeTab --package @acme/widgets',
355
+ );
356
+ },
357
+ SLOW,
358
+ );
359
+
360
+ it(
361
+ 'leaves a file name Core also has to Core in search, as in detail',
362
+ async () => {
363
+ // Badge.doc.mjs documents AcmeStatusBadge; its file name is Core's.
364
+ writeComponent(
365
+ 'status/Badge',
366
+ 'AcmeStatusBadge',
367
+ 'export function AcmeStatusBadge() { return null; }\n',
368
+ 'Shows a status.',
369
+ );
370
+ const cwd = tmpDir;
371
+ const [first] = (await search('Badge', {cwd, type: 'component'})).data
372
+ .results;
373
+ expect(first).toMatchObject({
374
+ name: 'Badge',
375
+ package: '@astryxdesign/core',
376
+ });
377
+ expect((await component('Badge', {cwd})).data.package).toBe(
378
+ '@astryxdesign/core',
379
+ );
380
+ const [own] = (await search('AcmeStatusBadge', {cwd, type: 'component'}))
381
+ .data.results;
382
+ expect(own).toMatchObject({
383
+ name: 'AcmeStatusBadge',
384
+ command: 'astryx component AcmeStatusBadge',
385
+ });
386
+ },
387
+ SLOW,
388
+ );
389
+
390
+ it(
391
+ 'copies a replacement recorded under its export into its file name folder, as before',
392
+ async () => {
393
+ const manifest = path.join(pkgDir, 'package.json');
394
+ fs.writeFileSync(
395
+ manifest,
396
+ JSON.stringify({
397
+ ...JSON.parse(fs.readFileSync(manifest, 'utf-8')),
398
+ peerDependencies: {
399
+ '@astryxdesign/cli': `>=${COMPONENT_REPLACES_CLI}`,
400
+ },
401
+ }),
402
+ );
403
+ writeComponent(
404
+ 'gallery/Slides',
405
+ 'AcmeCarousel',
406
+ 'export function AcmeCarousel() { return null; }\n',
407
+ 'Cycles through a row of slides.',
408
+ {replaces: 'Carousel'},
409
+ );
410
+
411
+ const copied = await swizzleCopy('Carousel', {cwd: tmpDir});
412
+ expect(copied.data).toMatchObject({
413
+ package: '@acme/widgets',
414
+ outputDir: path.join('components', 'astryx', 'Slides'),
415
+ });
416
+ },
417
+ SLOW,
418
+ );
419
+ });
@@ -16,6 +16,7 @@ import {
16
16
  discoverComponents,
17
17
  discoverExternalComponentsGrouped,
18
18
  discoverValidIntegrationComponents,
19
+ familyMember,
19
20
  findComponentReadme,
20
21
  findExternalComponentDoc,
21
22
  resolveImportPath,
@@ -66,6 +67,33 @@ async function legacyImport(ext, name, coreDir, docOpts) {
66
67
  */
67
68
  const coreEntry = doc => ({name: doc.name, package: CORE_PACKAGE, ...doc});
68
69
 
70
+ /**
71
+ * The full-level entry for a component documented as a member of its family's
72
+ * doc (VStack in Stack's doc): a component doc built from the member's own
73
+ * entry, under its own name, display name and import, shaped like every other
74
+ * full entry. Null when the doc is the component's own.
75
+ * @param {any} docs
76
+ * @param {string} name
77
+ * @param {string} coreDir
78
+ * @returns {any}
79
+ */
80
+ function memberEntry(docs, name, coreDir) {
81
+ const member = familyMember(docs, name);
82
+ if (!member) return null;
83
+ return {
84
+ name,
85
+ package: CORE_PACKAGE,
86
+ displayName: member.displayName ?? name,
87
+ ...(docs.group ? {group: docs.group} : {}),
88
+ ...(docs.category ? {category: docs.category} : {}),
89
+ usage: member.usage ?? {description: member.description ?? ''},
90
+ props: member.props ?? [],
91
+ ...(member.examples ? {examples: member.examples} : {}),
92
+ ...(docs.theming ? {theming: docs.theming} : {}),
93
+ import: resolveImportPath(coreDir, name),
94
+ };
95
+ }
96
+
69
97
  /**
70
98
  * The entry for a Core slot an integration component replaces
71
99
  * (spec:AST-035 FR11), at the list's detail level: the replacement's own name,
@@ -177,7 +205,11 @@ export async function componentList(
177
205
  entries.push({
178
206
  name: comp,
179
207
  package: CORE_PACKAGE,
180
- description: docs.usage?.description || docs.description || '',
208
+ description:
209
+ familyMember(docs, comp)?.description ||
210
+ docs.usage?.description ||
211
+ docs.description ||
212
+ '',
181
213
  import: resolveImportPath(coreDir, comp),
182
214
  });
183
215
  } catch {
@@ -215,7 +247,8 @@ export async function componentList(
215
247
  const readme = findComponentReadme(coreDir, comp);
216
248
  if (readme && readme.endsWith('.doc.mjs')) {
217
249
  try {
218
- entries.push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
250
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
251
+ entries.push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
219
252
  } catch {
220
253
  entries.push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
221
254
  }
@@ -269,7 +302,11 @@ export async function componentList(
269
302
  result[cat].push({
270
303
  name: comp,
271
304
  package: CORE_PACKAGE,
272
- description: docs.usage?.description || docs.description || '',
305
+ description:
306
+ familyMember(docs, comp)?.description ||
307
+ docs.usage?.description ||
308
+ docs.description ||
309
+ '',
273
310
  import: resolveImportPath(coreDir, comp),
274
311
  });
275
312
  } catch {
@@ -310,7 +347,8 @@ export async function componentList(
310
347
  const readme = findComponentReadme(coreDir, comp);
311
348
  if (readme && readme.endsWith('.doc.mjs')) {
312
349
  try {
313
- result[cat].push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
350
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
351
+ result[cat].push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
314
352
  } catch {
315
353
  result[cat].push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
316
354
  }
@@ -17,7 +17,17 @@
17
17
  import {Worker} from 'node:worker_threads';
18
18
  import {AstryxError} from '../error.mjs';
19
19
  import {Project} from '../../foundation/config/project.mjs';
20
- import {CORE_PACKAGE} from '../../foundation/discovery/component-discovery.mjs';
20
+ import {
21
+ CORE_PACKAGE,
22
+ discoverIntegrationComponents,
23
+ docStem,
24
+ findComponentReadme,
25
+ findIntegrationComponent,
26
+ integrationRecordHold,
27
+ unscopedComponentOwners,
28
+ } from '../../foundation/discovery/component-discovery.mjs';
29
+ import {legacyExternalKeeps} from '../component/_adapter.mjs';
30
+ import {findCoreDir} from '../../foundation/fs/paths.mjs';
21
31
  import {captureEnv} from '../../foundation/debug/event.mjs';
22
32
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
23
33
  import {parseGapReportReceipt} from '../../authoring/gap-report/parse.mjs';
@@ -178,13 +188,65 @@ async function selectTarget(project, component, packageName) {
178
188
  coreName,
179
189
  );
180
190
  if (replacement) return targetForPackage(project, replacement.package);
191
+ // The owner rule runs over every owner discovery finds, loadable or not
192
+ // (a doc recorded under another export still answers to its file's name);
193
+ // then only owners whose docs load route a report.
194
+ const wanted = bareComponent.toLowerCase();
195
+ const loads = new Set(records.map(r => `${r.package}\0${r.docPath}`));
196
+ const recorded = project.loadedIntegrations.flatMap(integration =>
197
+ discoverIntegrationComponents(integration).flatMap(r =>
198
+ r.name.toLowerCase() === wanted
199
+ ? [{...r, hold: integrationRecordHold(r)}]
200
+ : docStem(r.docPath).toLowerCase() === wanted
201
+ ? [{...r, hold: 'doc'}]
202
+ : [],
203
+ ),
204
+ );
205
+ // The checks below find files by name, so they run on every spelling the
206
+ // case-blind match above found, as well as the one typed.
207
+ const spellings = new Set([
208
+ bareComponent,
209
+ ...recorded.flatMap(r =>
210
+ [r.name, docStem(r.docPath)].filter(n => n.toLowerCase() === wanted),
211
+ ),
212
+ ]);
213
+ // Core also holds a name only its doc lookup finds, as in detail: a name a
214
+ // doc records yields to it, though it routes no report itself.
215
+ const coreDir = findCoreDir(project.cwd);
216
+ const coreDocs =
217
+ coreDir != null &&
218
+ [...spellings].some(name => findComponentReadme(coreDir, name) != null);
219
+ const ruled = unscopedComponentOwners([
220
+ ...records
221
+ .filter(
222
+ r => r.package === CORE_PACKAGE && r.name.toLowerCase() === wanted,
223
+ )
224
+ .map(r => ({...r, hold: 'core'})),
225
+ ...recorded,
226
+ // A hidden doc named after its file still holds that name, as the
227
+ // component lookup finds it; it routes no report.
228
+ ...project.loadedIntegrations.flatMap(integration =>
229
+ [...spellings].flatMap(spelling => {
230
+ const found = findIntegrationComponent(integration, spelling);
231
+ return found?.hold === 'file'
232
+ ? [{...found, package: integration.name}]
233
+ : [];
234
+ }),
235
+ ),
236
+ ]).filter(r => !coreDocs || r.hold !== 'doc');
237
+ // A legacy docs package keeps an export name a doc records, as in detail.
181
238
  const owners = [
182
239
  ...new Set(
183
- records
240
+ ([...spellings].some(name =>
241
+ legacyExternalKeeps(project.cwd, name, ruled),
242
+ )
243
+ ? []
244
+ : ruled
245
+ )
184
246
  .filter(
185
- record => record.name.toLowerCase() === bareComponent.toLowerCase(),
247
+ r => r.hold === 'core' || loads.has(`${r.package}\0${r.docPath}`),
186
248
  )
187
- .map(record => record.package),
249
+ .map(r => r.package),
188
250
  ),
189
251
  ];
190
252