@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
@@ -24,7 +24,6 @@ import * as path from 'node:path';
24
24
  import * as os from 'node:os';
25
25
  import {fileURLToPath} from 'node:url';
26
26
  import {spawnSync} from 'node:child_process';
27
- import jscodeshift from 'jscodeshift';
28
27
  import {assertWithin} from '../../foundation/fs/path-safety.mjs';
29
28
  import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs';
30
29
  import {
@@ -41,6 +40,7 @@ import {
41
40
  } from '../../foundation/discovery/docs-discovery.mjs';
42
41
  import {discoverIntegrationThemes} from '../../foundation/discovery/theme-discovery.mjs';
43
42
  import {inspectPackageExport} from '../../foundation/discovery/package-exports.mjs';
43
+ import {moduleExportsName} from '../../foundation/discovery/module-exports.mjs';
44
44
  import {
45
45
  ThemeImportError,
46
46
  resolveThemeImports,
@@ -51,6 +51,7 @@ import {
51
51
  } from '../theme/build/build.mjs';
52
52
  import {
53
53
  discoverIntegrationComponents,
54
+ docStem,
54
55
  resolveIntegrationImportPath,
55
56
  } from '../../foundation/discovery/component-discovery.mjs';
56
57
  import {loadComponentReplacements} from '../component/_adapter.mjs';
@@ -307,98 +308,6 @@ function extractTarball(tgzPath, extractDir) {
307
308
  }
308
309
  }
309
310
 
310
- const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.tsx', '.jsx'];
311
- const moduleParser = jscodeshift.withParser('tsx');
312
-
313
- /** @param {unknown} specifier @param {string} fromFile */
314
- function resolveLocalModule(specifier, fromFile) {
315
- if (typeof specifier !== 'string' || !specifier.startsWith('.')) return null;
316
- const base = path.resolve(path.dirname(fromFile), specifier);
317
- const candidates = [
318
- base,
319
- ...MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
320
- ...MODULE_EXTENSIONS.map(extension => path.join(base, `index${extension}`)),
321
- ];
322
- return candidates.find(candidate => fs.existsSync(candidate)) ?? null;
323
- }
324
-
325
- /** @param {any} declaration @param {string} exportName */
326
- function declarationExports(declaration, exportName) {
327
- if (!declaration) return false;
328
- if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
329
- return declaration.id?.name === exportName;
330
- }
331
- if (declaration.type !== 'VariableDeclaration') return false;
332
- return declaration.declarations.some(
333
- (/** @type {any} */ item) =>
334
- item.id?.type === 'Identifier' && item.id.name === exportName,
335
- );
336
- }
337
-
338
- /**
339
- * Check one resolved module's static export surface without executing package
340
- * code. Local ESM re-exports are followed recursively.
341
- *
342
- * @param {string} file
343
- * @param {string} exportName
344
- * @param {Set<string>} [seen]
345
- */
346
- function moduleExportsName(file, exportName, seen = new Set()) {
347
- const identity = `${file}\0${exportName}`;
348
- if (seen.has(identity) || !fs.existsSync(file)) return false;
349
- seen.add(identity);
350
-
351
- let root;
352
- try {
353
- root = moduleParser(fs.readFileSync(file, 'utf-8'));
354
- } catch {
355
- return false;
356
- }
357
-
358
- let found = false;
359
- root
360
- .find(moduleParser.ExportNamedDeclaration)
361
- .forEach((/** @type {any} */ exportPath) => {
362
- if (found) return;
363
- const node = exportPath.node;
364
- if (declarationExports(node.declaration, exportName)) {
365
- found = true;
366
- return;
367
- }
368
- for (const specifier of node.specifiers ?? []) {
369
- const exported = specifier.exported?.name ?? specifier.exported?.value;
370
- if (exported !== exportName) continue;
371
- if (!node.source) {
372
- found = true;
373
- return;
374
- }
375
- const target = resolveLocalModule(node.source.value, file);
376
- const imported =
377
- specifier.local?.name ?? specifier.local?.value ?? exportName;
378
- if (target && moduleExportsName(target, imported, seen)) {
379
- found = true;
380
- return;
381
- }
382
- }
383
- });
384
- if (found) return true;
385
- if (
386
- exportName === 'default' &&
387
- root.find(moduleParser.ExportDefaultDeclaration).size() > 0
388
- ) {
389
- return true;
390
- }
391
-
392
- root
393
- .find(moduleParser.ExportAllDeclaration)
394
- .forEach((/** @type {any} */ exportPath) => {
395
- if (found) return;
396
- const target = resolveLocalModule(exportPath.node.source?.value, file);
397
- if (target && moduleExportsName(target, exportName, seen)) found = true;
398
- });
399
- return found;
400
- }
401
-
402
311
  /**
403
312
  * Whether any of these doc files has a section that sets `id`.
404
313
  * @param {string[]} files
@@ -480,7 +389,7 @@ async function validatePackedComponentExports(integration, scratchBase) {
480
389
  issues.push(
481
390
  error(
482
391
  'component_doc_unloadable',
483
- `Component "${record.name}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
392
+ `Component "${docStem(record.docPath)}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
484
393
  ),
485
394
  );
486
395
  continue;
@@ -660,11 +569,10 @@ async function validateIncompleteThemeExports(
660
569
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
661
570
  try {
662
571
  if (outputs.some(output => output.kind !== 'font stylesheet')) {
663
- const receipt = await themeBuild(
664
- source,
665
- {out: expectedCss},
666
- {cwd: packageDir},
667
- );
572
+ // The scratch directory is the write root; the package stays the cwd,
573
+ // so the build selects the package's Core and its paths as before.
574
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
575
+ const receipt = await themeBuild(source, scratchBuild, {cwd: packageDir});
668
576
  if (receipt == null || receipt.type !== 'theme.build') {
669
577
  throw new Error('the theme build produced no complete receipt');
670
578
  }
@@ -847,11 +755,12 @@ async function validatePackedThemeExports(
847
755
  );
848
756
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
849
757
  try {
850
- const receipt = await themeBuild(
851
- source,
852
- {out: expectedCss},
853
- {cwd: localIntegration.__packageDir},
854
- );
758
+ // The scratch directory is the write root; the package stays the cwd,
759
+ // so the build selects the package's Core and its paths as before.
760
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
761
+ const receipt = await themeBuild(source, scratchBuild, {
762
+ cwd: localIntegration.__packageDir,
763
+ });
855
764
  if (receipt == null || receipt.type !== 'theme.build') {
856
765
  issues.push(
857
766
  error(
@@ -754,6 +754,38 @@ describe('integrationPackCheck', () => {
754
754
  );
755
755
  });
756
756
 
757
+ it('checks the export a component doc names when its file is named otherwise', async () => {
758
+ fs.writeFileSync(
759
+ path.join(tmpDir, 'package.json'),
760
+ `${JSON.stringify({
761
+ name: '@acme/widgets',
762
+ version: '1.0.0',
763
+ files: ['astryx.integration.mjs', 'components'],
764
+ exports: {'./components/Slides': './components/Slides.tsx'},
765
+ })}\n`,
766
+ );
767
+ fs.writeFileSync(
768
+ path.join(tmpDir, 'astryx.integration.mjs'),
769
+ "export default {components: './components'};\n",
770
+ );
771
+ fs.mkdirSync(path.join(tmpDir, 'components'));
772
+ // Slides.tsx exports AcmeCarousel and nothing named Slides.
773
+ fs.writeFileSync(
774
+ path.join(tmpDir, 'components', 'Slides.tsx'),
775
+ 'export function AcmeCarousel() { return null; }\n',
776
+ );
777
+ fs.writeFileSync(
778
+ path.join(tmpDir, 'components', 'Slides.doc.mjs'),
779
+ "export default {type: 'component', name: 'AcmeCarousel', import: '@acme/widgets/components/Slides', description: 'x', props: []};\n",
780
+ );
781
+
782
+ const result = await integrationPackCheck({cwd: tmpDir});
783
+ expect(result.data.packable).toBe(true);
784
+ expect(result.data.contributions.packed.components).toEqual([
785
+ 'AcmeCarousel',
786
+ ]);
787
+ });
788
+
757
789
  it('passes a generated component through its exact packed export', async () => {
758
790
  fs.writeFileSync(
759
791
  path.join(tmpDir, 'package.json'),
@@ -811,6 +843,86 @@ describe('integrationPackCheck', () => {
811
843
  );
812
844
  });
813
845
 
846
+ it('does not count a type-only re-export as the component', async () => {
847
+ fs.writeFileSync(
848
+ path.join(tmpDir, 'package.json'),
849
+ `${JSON.stringify({
850
+ name: '@acme/widgets',
851
+ version: '1.0.0',
852
+ files: ['astryx.integration.mjs', 'src'],
853
+ exports: {'.': './src/index.ts'},
854
+ })}\n`,
855
+ );
856
+ fs.writeFileSync(
857
+ path.join(tmpDir, 'astryx.integration.mjs'),
858
+ "export default {components: './src'};\n",
859
+ );
860
+ fs.mkdirSync(path.join(tmpDir, 'src'));
861
+ fs.writeFileSync(
862
+ path.join(tmpDir, 'src', 'index.ts'),
863
+ "export type {AcmeWidget} from './AcmeWidget';\n",
864
+ );
865
+ fs.writeFileSync(
866
+ path.join(tmpDir, 'src', 'AcmeWidget.tsx'),
867
+ 'export function AcmeWidget() { return null; }\n',
868
+ );
869
+ fs.writeFileSync(
870
+ path.join(tmpDir, 'src', 'AcmeWidget.doc.mjs'),
871
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
872
+ );
873
+
874
+ const result = await integrationPackCheck({cwd: tmpDir});
875
+
876
+ expect(result.data.issues).toContainEqual(
877
+ expect.objectContaining({code: 'component_export_missing'}),
878
+ );
879
+ });
880
+
881
+ it('follows a re-export of a folder to its index file', async () => {
882
+ // `./primitives` names a folder: TypeScript and bundlers load its index file.
883
+ fs.writeFileSync(
884
+ path.join(tmpDir, 'package.json'),
885
+ `${JSON.stringify({
886
+ name: '@acme/widgets',
887
+ version: '1.0.0',
888
+ files: ['astryx.integration.mjs', 'src'],
889
+ exports: {'.': './src/index.ts'},
890
+ })}\n`,
891
+ );
892
+ fs.writeFileSync(
893
+ path.join(tmpDir, 'astryx.integration.mjs'),
894
+ "export default {components: './src'};\n",
895
+ );
896
+ const primitives = path.join(tmpDir, 'src', 'primitives');
897
+ fs.mkdirSync(primitives, {recursive: true});
898
+ fs.writeFileSync(
899
+ path.join(tmpDir, 'src', 'index.ts'),
900
+ "export * from './primitives';\n",
901
+ );
902
+ fs.writeFileSync(
903
+ path.join(primitives, 'index.ts'),
904
+ "export * from './AcmeWidget';\n",
905
+ );
906
+ fs.writeFileSync(
907
+ path.join(primitives, 'AcmeWidget.tsx'),
908
+ 'export function AcmeWidget() { return null; }\n',
909
+ );
910
+ fs.writeFileSync(
911
+ path.join(primitives, 'AcmeWidget.doc.mjs'),
912
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
913
+ );
914
+
915
+ const result = await integrationPackCheck({cwd: tmpDir});
916
+
917
+ expect(result.data.issues).not.toContainEqual(
918
+ expect.objectContaining({code: 'component_export_missing'}),
919
+ );
920
+ expect(
921
+ result.data.packable,
922
+ JSON.stringify(result.data.issues, null, 2),
923
+ ).toBe(true);
924
+ });
925
+
814
926
  it('fails when packed template source is hidden by package exports', async () => {
815
927
  writePackage({
816
928
  manifest: "export default {templates: './templates'};\n",
@@ -252,4 +252,13 @@ export type Candidate = {
252
252
  _kind?: "page" | "block" | undefined;
253
253
  _resultName?: string | undefined;
254
254
  _commandName?: string | undefined;
255
+ /**
256
+ * - How an integration component holds its
257
+ * name, for the owner rule.
258
+ */
259
+ _hold?: "file" | "doc" | undefined;
260
+ /**
261
+ * - An integration component's doc.
262
+ */
263
+ _docPath?: string | undefined;
255
264
  };
@@ -81,11 +81,18 @@
81
81
 
82
82
  import {withListingCache} from '../../foundation/fs/listing-cache.mjs';
83
83
  import {readDocView} from '../../foundation/doc-compiler/read.mjs';
84
- import {findCoreDir} from '../../foundation/fs/paths.mjs';
84
+ import {
85
+ discoverExternalPackages,
86
+ findCoreDir,
87
+ } from '../../foundation/fs/paths.mjs';
85
88
  import {
86
89
  discoverComponents,
87
90
  discoverValidIntegrationComponents,
91
+ docStem,
92
+ familyMember,
88
93
  findComponentReadme,
94
+ forEachIntegrationDoc,
95
+ integrationRecordHold,
89
96
  resolveImportPath,
90
97
  resolveIntegrationImportPath,
91
98
  CORE_PACKAGE,
@@ -97,6 +104,7 @@ import {
97
104
  import {
98
105
  loadComponentReplacements,
99
106
  loadIntegrationsSafely,
107
+ resolveLegacyExternalDoc,
100
108
  } from '../component/_adapter.mjs';
101
109
  import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
102
110
  import {discoverTemplates, extractComponents} from '../template/template.mjs';
@@ -149,6 +157,9 @@ import {setResultCoverage} from './coverage.mjs';
149
157
  * @property {'page'|'block'} [_kind]
150
158
  * @property {string} [_resultName]
151
159
  * @property {string} [_commandName]
160
+ * @property {'file'|'doc'} [_hold] - How an integration component holds its
161
+ * name, for the owner rule.
162
+ * @property {string} [_docPath] - An integration component's doc.
152
163
  */
153
164
 
154
165
  /**
@@ -1079,7 +1090,11 @@ async function gatherCoreComponents(coreDir) {
1079
1090
  /** @type {string[]} */
1080
1091
  let guidance = [];
1081
1092
  if (readme && readme.endsWith('.doc.mjs')) {
1082
- const doc = await loadModuleDoc(readme);
1093
+ const loaded = await loadModuleDoc(readme);
1094
+ // A member documented in its family's doc (VStack in Stack's doc) is
1095
+ // indexed from its own entry: the family's keywords and guidance
1096
+ // describe the family, not the member.
1097
+ const doc = familyMember(loaded, comp) ?? loaded;
1083
1098
  if (doc) {
1084
1099
  keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
1085
1100
  description = doc.usage?.description || doc.description || '';
@@ -1105,20 +1120,50 @@ async function gatherCoreComponents(coreDir) {
1105
1120
  * usage/description from each component's .doc.mjs, same as core. Without
1106
1121
  * this, an integration component is invisible to `search`/`build` even
1107
1122
  * though `component --list`/`component <Name>` already resolve it — the two
1108
- * discovery paths silently disagreed.
1123
+ * discovery paths silently disagreed. Also returns, per name, the docs that
1124
+ * hold it by their file name and the loadable docs that only record it.
1109
1125
  * @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
1110
- * @returns {Promise<Candidate[]>}
1126
+ * @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
1111
1127
  */
1112
1128
  async function gatherIntegrationComponents(loadedIntegrations) {
1113
1129
  /** @type {Candidate[]} */
1114
1130
  const candidates = [];
1131
+ // What the owner rule reads: the docs that hold a name by their file name,
1132
+ // loadable or not and hidden ones too, and the loadable docs that only
1133
+ // record it.
1134
+ /** @type {Map<string, Set<string>>} */
1135
+ const fileNamed = new Map();
1136
+ /** @type {Map<string, Set<string>>} */
1137
+ const documented = new Map();
1138
+ /** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
1139
+ const hold = (map, name, docPath) =>
1140
+ map.set(name, (map.get(name) ?? new Set()).add(docPath));
1115
1141
  for (const integration of loadedIntegrations) {
1116
- const {components} = await discoverValidIntegrationComponents(integration);
1142
+ const {components, discovered} =
1143
+ await discoverValidIntegrationComponents(integration);
1144
+ const renamed = new Set(
1145
+ discovered
1146
+ .filter(rec => integrationRecordHold(rec) === 'doc')
1147
+ .map(rec => rec.docPath),
1148
+ );
1149
+ forEachIntegrationDoc(integration.components, (docPath, stem) => {
1150
+ if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
1151
+ });
1117
1152
  for (const rec of components) {
1118
1153
  const doc = await loadModuleDoc(rec.docPath);
1154
+ // A doc that names another export of its file still answers to the
1155
+ // file's name, as `component <file name>` does.
1156
+ const fileName = docStem(rec.docPath);
1157
+ if (renamed.has(rec.docPath)) {
1158
+ hold(documented, rec.name, rec.docPath);
1159
+ hold(documented, fileName, rec.docPath);
1160
+ }
1119
1161
  candidates.push({
1120
1162
  domain: 'component',
1121
1163
  name: rec.name,
1164
+ aliases: fileName !== rec.name ? [fileName] : [],
1165
+ _hold: integrationRecordHold(rec),
1166
+ _docPath: rec.docPath,
1122
1167
  keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
1123
1168
  description: doc ? doc.usage?.description || doc.description || '' : '',
1124
1169
  guidance: guidanceFrom(doc),
@@ -1143,7 +1188,7 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1143
1188
  });
1144
1189
  }
1145
1190
  }
1146
- return candidates;
1191
+ return {candidates, fileNamed, documented};
1147
1192
  }
1148
1193
 
1149
1194
  /**
@@ -1155,11 +1200,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1155
1200
  */
1156
1201
  async function gatherComponents(coreDir, cwd) {
1157
1202
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1158
- const [core, integrations, replacements] = await Promise.all([
1203
+ const [core, gathered, replacements] = await Promise.all([
1159
1204
  gatherCoreComponents(coreDir),
1160
1205
  gatherIntegrationComponents(loadedIntegrations),
1161
1206
  loadComponentReplacements(coreDir, loadedIntegrations),
1162
1207
  ]);
1208
+ const {candidates: integrations, fileNamed, documented} = gathered;
1163
1209
  // An active replacement answers to the Core name it replaces, so a search
1164
1210
  // for that name finds the replacement and not the Core original
1165
1211
  // (spec:AST-035 FR11).
@@ -1170,9 +1216,43 @@ async function gatherComponents(coreDir, cwd) {
1170
1216
  active.target,
1171
1217
  ]),
1172
1218
  );
1219
+ // The owner rule, as component detail applies it: a candidate `component
1220
+ // <name>` does not open names its package and follows the owner, and a
1221
+ // file-name alias that opens another owner is dropped.
1222
+ const coreNames = new Set(core.map(candidate => candidate.name));
1223
+ /** @param {string} name @param {Candidate} candidate */
1224
+ const opens = (name, candidate) =>
1225
+ !coreNames.has(name) &&
1226
+ findComponentReadme(coreDir, name) == null &&
1227
+ [...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
1228
+ [...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
1229
+ // A legacy docs package keeps an exact name over an export name a doc records.
1230
+ /** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
1231
+ let externals;
1232
+ /** @param {string} name */
1233
+ const legacyDocuments = name =>
1234
+ (externals ??= discoverExternalPackages(cwd)).some(
1235
+ ext => resolveLegacyExternalDoc(ext, name) != null,
1236
+ );
1237
+ const ruled = integrations.map(candidate => {
1238
+ const aliases = (candidate.aliases ?? []).filter(alias =>
1239
+ opens(alias, candidate),
1240
+ );
1241
+ return candidate._hold === 'doc' &&
1242
+ (!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
1243
+ ? {
1244
+ ...candidate,
1245
+ aliases,
1246
+ _command: `astryx component ${candidate.name} --package ${candidate._package}`,
1247
+ }
1248
+ : {...candidate, aliases};
1249
+ });
1173
1250
  return [
1174
1251
  ...core.filter(candidate => !replacements.forTarget(candidate.name)),
1175
- ...integrations.map(candidate => {
1252
+ ...[
1253
+ ...ruled.filter(candidate => !candidate._command),
1254
+ ...ruled.filter(candidate => candidate._command),
1255
+ ].map(candidate => {
1176
1256
  const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
1177
1257
  if (target != null) {
1178
1258
  return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
@@ -1225,11 +1305,11 @@ export async function componentKeywords(coreDir, cwd) {
1225
1305
  .flat()
1226
1306
  .map(async name => {
1227
1307
  const readme = findComponentReadme(coreDir, name);
1228
- const doc =
1308
+ const loaded =
1229
1309
  readme && readme.endsWith('.doc.mjs')
1230
1310
  ? await loadModuleDoc(readme)
1231
1311
  : null;
1232
- return {name, keywords: keywordsOf(doc)};
1312
+ return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
1233
1313
  });
1234
1314
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1235
1315
  const replacements = await loadComponentReplacements(
@@ -616,6 +616,42 @@ describe('search leaf — a theme is found by its names; its description is pros
616
616
  );
617
617
  });
618
618
 
619
+ describe('search leaf — components whose docs live in a family folder', () => {
620
+ it("indexes a family member from its own entry, not the family's keywords", async () => {
621
+ // HStack and VStack are documented in Stack's doc, whose keywords
622
+ // (horizontal, vertical, row, column, ...) describe Stack.
623
+ const reasons = async (/** @type {string} */ query) =>
624
+ Object.fromEntries(
625
+ (await search(query, {cwd, type: 'component'})).data.results.map(r => [
626
+ r.name,
627
+ r.reason,
628
+ ]),
629
+ );
630
+ const column = await reasons('column');
631
+ expect(column.HStack ?? '').not.toMatch(/keyword/);
632
+ expect(column.VStack ?? '').not.toMatch(/keyword/);
633
+ expect(column.Stack).toMatch(/keyword/);
634
+ const vertical = (await search('vertical', {cwd, type: 'component'})).data.results.map(
635
+ r => r.name,
636
+ );
637
+ expect(vertical.indexOf('VStack')).toBeGreaterThanOrEqual(0);
638
+ if (vertical.includes('HStack')) {
639
+ expect(vertical.indexOf('VStack')).toBeLessThan(vertical.indexOf('HStack'));
640
+ }
641
+ }, SLOW);
642
+
643
+ it('ranks VStack, HStack and Heading first for their own names, with their own import', async () => {
644
+ for (const name of ['VStack', 'HStack', 'Heading']) {
645
+ const r = await search(name.toLowerCase(), {cwd, type: 'component'});
646
+ expect(r.data.results[0]).toMatchObject({
647
+ domain: 'component',
648
+ name,
649
+ import: `@astryxdesign/core/${name}`,
650
+ });
651
+ }
652
+ }, SLOW);
653
+ });
654
+
619
655
  describe('search leaf — integration components', () => {
620
656
  /**
621
657
  * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
@@ -21,8 +21,10 @@ import {checkGhCli} from '../_github.mjs';
21
21
  import {Project} from '../../../foundation/config/project.mjs';
22
22
  import {
23
23
  CORE_PACKAGE,
24
- findIntegrationComponentDoc,
25
- findIntegrationComponentSource,
24
+ docStem,
25
+ findComponentReadme,
26
+ findIntegrationComponent,
27
+ unscopedComponentOwners,
26
28
  } from '../../../foundation/discovery/component-discovery.mjs';
27
29
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
28
30
  import {AstryxError, writeFailed} from '../../error.mjs';
@@ -126,33 +128,38 @@ async function loadConfigSafely(cwd) {
126
128
 
127
129
  /**
128
130
  * Build the set of OWNER packages that provide a component named `name` across
129
- * core + every loaded integration.
131
+ * core + every loaded integration. Core holds every name it documents, but has
132
+ * source to copy (`sourceDir`) only for a name with its own folder.
130
133
  * @param {string} coreDir
131
134
  * @param {Array<{name: string, components?: string, issuesUrl?: string}>} loadedIntegrations
132
135
  * @param {string} name
133
136
  * @param {string|undefined} coreIssuesUrl
134
- * @returns {Array<{package: string, sourceDir: string|null, ownerPackage: string, issuesUrl: string|undefined}>}
137
+ * @returns {Array<{package: string, sourceDir: string|null, docPath: string|null, ownerPackage: string, issuesUrl: string|undefined, hold: string}>}
135
138
  */
136
139
  function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
137
140
  const owners = [];
138
141
  const coreComponentDir = path.join(coreDir, 'src', name);
139
- if (fs.existsSync(coreComponentDir)) {
142
+ const coreFolder = fs.existsSync(coreComponentDir);
143
+ if (coreFolder || findComponentReadme(coreDir, name)) {
140
144
  owners.push({
141
145
  package: CORE_PACKAGE,
142
- sourceDir: coreComponentDir,
146
+ sourceDir: coreFolder ? coreComponentDir : null,
147
+ docPath: null,
143
148
  ownerPackage: CORE_PACKAGE,
144
149
  issuesUrl: coreIssuesUrl || DEFAULT_ISSUES_URL,
150
+ hold: 'core',
145
151
  });
146
152
  }
147
153
  for (const integration of loadedIntegrations) {
148
- const docPath = findIntegrationComponentDoc(integration, name);
149
- if (!docPath) continue;
150
- const sourcePath = findIntegrationComponentSource(integration, name);
154
+ const found = findIntegrationComponent(integration, name);
155
+ if (!found) continue;
151
156
  owners.push({
152
157
  package: integration.name,
153
- sourceDir: sourcePath ? path.dirname(sourcePath) : null,
158
+ sourceDir: found.sourcePath ? path.dirname(found.sourcePath) : null,
159
+ docPath: found.docPath,
154
160
  ownerPackage: integration.name,
155
161
  issuesUrl: integration.issuesUrl,
162
+ hold: found.hold,
156
163
  });
157
164
  }
158
165
  return owners;
@@ -222,25 +229,32 @@ export async function swizzleCopy(component, options = {}) {
222
229
  : undefined;
223
230
  // An active integration replacement answers to the Core name it replaces,
224
231
  // bare or qualified by its own package (spec:AST-035 FR11): copy it under its
225
- // own name. `--package @astryxdesign/core` still copies the Core original.
232
+ // doc's file name, the folder it has always gone to. `--package
233
+ // @astryxdesign/core` still copies the Core original.
226
234
  if (pkg !== CORE_PACKAGE) {
227
235
  const replacement = await resolveComponentReplacement(
228
236
  coreDir,
229
237
  loadedIntegrations,
230
238
  dirName,
231
239
  );
240
+ const copyName = replacement ? docStem(replacement.docPath) : null;
232
241
  if (
233
242
  replacement &&
234
- (!pkg || (pkg === replacement.package && replacement.name !== dirName))
243
+ copyName &&
244
+ (!pkg || (pkg === replacement.package && copyName !== dirName))
235
245
  ) {
236
- return swizzleCopy(replacement.name, {
246
+ return swizzleCopy(copyName, {
237
247
  ...options,
238
248
  package: replacement.package,
239
249
  });
240
250
  }
241
251
  }
242
252
 
243
- const allOwners = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
253
+ const found = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
254
+ // The owner rule, then a Core name without its own folder drops out.
255
+ const allOwners = (pkg ? found : unscopedComponentOwners(found)).filter(
256
+ owner => owner.package !== CORE_PACKAGE || owner.sourceDir != null,
257
+ );
244
258
 
245
259
  if (allOwners.length === 0) {
246
260
  throw new AstryxError(