@astryxdesign/cli 0.7.2-canary.c9f2659 → 0.7.2-canary.cbb7fa3

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/api/component/_adapter.d.mts +41 -1
  2. package/api/component/_adapter.mjs +93 -7
  3. package/api/component/component.mjs +22 -5
  4. package/api/component/component.test.mjs +420 -167
  5. package/api/component/integration-export-name.test.mjs +644 -0
  6. package/api/component/list/list.mjs +42 -4
  7. package/api/gap-report/gap-report.mjs +67 -4
  8. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  9. package/api/integration/add-contribution.mjs +7 -3
  10. package/api/integration/pack-check.mjs +61 -107
  11. package/api/integration/pack-check.test.mjs +359 -0
  12. package/api/search/search.d.mts +9 -0
  13. package/api/search/search.mjs +108 -16
  14. package/api/search/search.test.mjs +36 -0
  15. package/api/swizzle/copy/copy.mjs +28 -14
  16. package/api/template/template.test.mjs +120 -3
  17. package/api/theme/build/build.family.test.mjs +16 -0
  18. package/api/theme/build/build.mjs +77 -18
  19. package/api/theme/themeBuild.doc.mjs +3 -3
  20. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  21. package/assets/codemods/transforms/next/index.mjs +11 -1
  22. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  23. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  24. package/assets/docs/tree/checks.doc.mjs +1 -1
  25. package/assets/docs/tree/component-family.doc.mjs +5 -1
  26. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  27. package/assets/docs/tree/single-component.doc.mjs +1 -1
  28. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  29. package/assets/docs/tree/versioning.doc.mjs +1 -0
  30. package/assets/docs/working-with-ai.doc.mjs +43 -4
  31. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  32. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  33. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  34. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  35. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  36. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  37. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  38. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  39. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  40. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  41. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  42. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  43. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  44. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  45. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  46. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  47. package/assets/templates/pages/detail-page/page.tsx +3 -3
  48. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  49. package/authoring/doctypes/base/type.ts +6 -0
  50. package/authoring/doctypes/component/component.doc.mjs +4 -4
  51. package/authoring/doctypes/component/type.ts +6 -3
  52. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  53. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  54. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  55. package/clients/cli/lib/component-format.mjs +5 -1
  56. package/foundation/discovery/component-discovery.d.mts +172 -10
  57. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  58. package/foundation/discovery/component-discovery.mjs +863 -55
  59. package/foundation/discovery/component-discovery.test.mjs +96 -0
  60. package/foundation/discovery/component-replacement.mjs +18 -0
  61. package/foundation/discovery/component-replacement.test.mjs +40 -0
  62. package/foundation/discovery/module-exports.d.mts +27 -0
  63. package/foundation/discovery/module-exports.mjs +356 -31
  64. package/foundation/discovery/module-exports.test.mjs +172 -0
  65. package/foundation/discovery/template-adapter.mjs +74 -5
  66. package/foundation/doc-compiler/doc-loads.test.mjs +5 -1
  67. package/foundation/doc-compiler/inputs.mjs +23 -0
  68. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  69. package/foundation/integrations/contribution-inventory.mjs +6 -1
  70. package/foundation/xle/registry.mjs +4 -0
  71. package/foundation/xle/registry.test.mjs +28 -0
  72. package/package.json +10 -10
@@ -754,6 +754,285 @@ 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
+
789
+ it('checks each family member against the module its family doc imports', async () => {
790
+ fs.writeFileSync(
791
+ path.join(tmpDir, 'package.json'),
792
+ `${JSON.stringify({
793
+ name: '@acme/widgets',
794
+ version: '1.0.0',
795
+ files: ['astryx.integration.mjs', 'components'],
796
+ exports: {'./tabs': './components/tabs/index.ts'},
797
+ })}\n`,
798
+ );
799
+ fs.writeFileSync(
800
+ path.join(tmpDir, 'astryx.integration.mjs'),
801
+ "export default {components: './components'};\n",
802
+ );
803
+ const dir = path.join(tmpDir, 'components', 'tabs');
804
+ fs.mkdirSync(dir, {recursive: true});
805
+ fs.writeFileSync(
806
+ path.join(dir, 'AcmeTabs.tsx'),
807
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTabsLegacy() { return null; }\n',
808
+ );
809
+ fs.writeFileSync(
810
+ path.join(dir, 'tab.tsx'),
811
+ 'export function AcmeTab() { return null; }\n',
812
+ );
813
+ // The module apps import exports AcmeTabs and AcmeTab, not AcmeTabsLegacy.
814
+ fs.writeFileSync(
815
+ path.join(dir, 'index.ts'),
816
+ "export {AcmeTabs} from './AcmeTabs';\nexport {AcmeTab} from './tab';\n",
817
+ );
818
+ fs.writeFileSync(
819
+ path.join(dir, 'AcmeTabs.doc.mjs'),
820
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabsLegacy', description: 'x', props: []}]};\n",
821
+ );
822
+
823
+ const result = await integrationPackCheck({cwd: tmpDir});
824
+
825
+ expect(result.data.issues).toEqual([]);
826
+ expect(result.data.packable).toBe(true);
827
+ expect(result.data.contributions.packed.components).toEqual([
828
+ 'AcmeTab',
829
+ 'AcmeTabs',
830
+ ]);
831
+ });
832
+
833
+ it.each([
834
+ 'a clean checkout',
835
+ 'a stale build',
836
+ 'a stale build that exports more',
837
+ ])(
838
+ 'reads members a family imports from output its pack lifecycle builds, from %s',
839
+ async start => {
840
+ fs.writeFileSync(
841
+ path.join(tmpDir, 'package.json'),
842
+ `${JSON.stringify({
843
+ name: '@acme/widgets',
844
+ version: '1.0.0',
845
+ files: ['astryx.integration.mjs', 'components', 'dist'],
846
+ exports: {'./tabs': './dist/tabs/index.mjs'},
847
+ scripts: {prepack: 'node build.mjs'},
848
+ })}\n`,
849
+ );
850
+ fs.writeFileSync(
851
+ path.join(tmpDir, 'astryx.integration.mjs'),
852
+ "export default {components: './components'};\n",
853
+ );
854
+ // The build writes the module the family imports; a clean checkout has none.
855
+ fs.writeFileSync(
856
+ path.join(tmpDir, 'build.mjs'),
857
+ "import fs from 'node:fs';\nfs.mkdirSync('dist/tabs', {recursive: true});\nfs.writeFileSync('dist/tabs/index.mjs', \"export {AcmeTabs, AcmeTab} from '../../components/tabs/AcmeTabs.tsx';\\n\");\n",
858
+ );
859
+ const dir = path.join(tmpDir, 'components', 'tabs');
860
+ fs.mkdirSync(dir, {recursive: true});
861
+ fs.writeFileSync(
862
+ path.join(dir, 'AcmeTabs.tsx'),
863
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTab() { return null; }\nexport function AcmeTabLegacy() { return null; }\n',
864
+ );
865
+ fs.writeFileSync(
866
+ path.join(dir, 'AcmeTabs.doc.mjs'),
867
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabLegacy', description: 'x', props: []}]};\n",
868
+ );
869
+
870
+ if (start !== 'a clean checkout') {
871
+ // An earlier build: before AcmeTab was exported, or while AcmeTabLegacy
872
+ // still was.
873
+ const names =
874
+ start === 'a stale build'
875
+ ? 'AcmeTabs'
876
+ : 'AcmeTabs, AcmeTab, AcmeTabLegacy';
877
+ fs.mkdirSync(path.join(tmpDir, 'dist', 'tabs'), {recursive: true});
878
+ fs.writeFileSync(
879
+ path.join(tmpDir, 'dist', 'tabs', 'index.mjs'),
880
+ `export {${names}} from '../../components/tabs/AcmeTabs.tsx';\n`,
881
+ );
882
+ }
883
+
884
+ const result = await integrationPackCheck({cwd: tmpDir});
885
+
886
+ expect(result.data.issues).toEqual([]);
887
+ expect(result.data.packable).toBe(true);
888
+ expect(result.data.contributions.local.components).toEqual(
889
+ result.data.contributions.packed.components,
890
+ );
891
+ },
892
+ );
893
+
894
+ it.each(['a clean checkout', 'an incremental build'])(
895
+ 'reads members built output re-exports from compiled files, from %s',
896
+ async start => {
897
+ fs.writeFileSync(
898
+ path.join(tmpDir, 'package.json'),
899
+ `${JSON.stringify({
900
+ name: '@acme/widgets',
901
+ version: '1.0.0',
902
+ files: ['astryx.integration.mjs', 'components', 'dist'],
903
+ exports: {'./tabs': './dist/tabs/index.mjs'},
904
+ scripts: {prepack: 'node build.mjs'},
905
+ })}\n`,
906
+ );
907
+ fs.writeFileSync(
908
+ path.join(tmpDir, 'astryx.integration.mjs'),
909
+ "export default {components: './components'};\n",
910
+ );
911
+ // The build compiles each source to its own file and, like an
912
+ // incremental build, rewrites only the files whose output changed.
913
+ const output = {
914
+ 'index.mjs':
915
+ "export {AcmeTabs} from './tabs.mjs';\nexport * from './tab.mjs';\n",
916
+ 'tabs.mjs': 'export function AcmeTabs() { return null; }\n',
917
+ 'tab.mjs': 'export function AcmeTab() { return null; }\n',
918
+ };
919
+ fs.writeFileSync(
920
+ path.join(tmpDir, 'build.mjs'),
921
+ `import fs from 'node:fs';\nconst output = ${JSON.stringify(output)};\nfs.mkdirSync('dist/tabs', {recursive: true});\nfor (const [file, text] of Object.entries(output)) {\n const target = 'dist/tabs/' + file;\n if (!fs.existsSync(target) || fs.readFileSync(target, 'utf-8') !== text) fs.writeFileSync(target, text);\n}\n`,
922
+ );
923
+ const dir = path.join(tmpDir, 'components', 'tabs');
924
+ fs.mkdirSync(dir, {recursive: true});
925
+ fs.writeFileSync(
926
+ path.join(dir, 'AcmeTabs.tsx'),
927
+ 'export function AcmeTabs() { return null; }\n',
928
+ );
929
+ fs.writeFileSync(
930
+ path.join(dir, 'AcmeTab.tsx'),
931
+ 'export function AcmeTab() { return null; }\n',
932
+ );
933
+ fs.writeFileSync(
934
+ path.join(dir, 'AcmeTabs.doc.mjs'),
935
+ "export default {type: 'component', name: 'AcmeTabs', import: '@acme/widgets/tabs', description: 'x', components: [{name: 'AcmeTabs', description: 'x', props: []}, {name: 'AcmeTab', description: 'x', props: []}]};\n",
936
+ );
937
+ if (start === 'an incremental build') {
938
+ // An earlier build from before AcmeTab existed: the file the import
939
+ // names is already current, so the build leaves it untouched.
940
+ fs.mkdirSync(path.join(tmpDir, 'dist', 'tabs'), {recursive: true});
941
+ for (const [file, text] of Object.entries({...output, 'tab.mjs': ''})) {
942
+ fs.writeFileSync(path.join(tmpDir, 'dist', 'tabs', file), text);
943
+ }
944
+ }
945
+
946
+ const result = await integrationPackCheck({cwd: tmpDir});
947
+
948
+ expect(result.data.issues).toEqual([]);
949
+ expect(result.data.packable).toBe(true);
950
+ expect(result.data.contributions.packed.components).toEqual([
951
+ 'AcmeTab',
952
+ 'AcmeTabs',
953
+ ]);
954
+ expect(result.data.contributions.local.components).toEqual(
955
+ result.data.contributions.packed.components,
956
+ );
957
+ },
958
+ );
959
+
960
+ it('passes a member another folder declares, which has no source', async () => {
961
+ fs.writeFileSync(
962
+ path.join(tmpDir, 'package.json'),
963
+ `${JSON.stringify({
964
+ name: '@acme/widgets',
965
+ version: '1.0.0',
966
+ files: ['astryx.integration.mjs', 'components'],
967
+ exports: {'./menu': './components/menu/index.ts'},
968
+ })}\n`,
969
+ );
970
+ fs.writeFileSync(
971
+ path.join(tmpDir, 'astryx.integration.mjs'),
972
+ "export default {components: './components'};\n",
973
+ );
974
+ const menu = path.join(tmpDir, 'components', 'menu');
975
+ const shared = path.join(tmpDir, 'components', 'shared');
976
+ fs.mkdirSync(menu, {recursive: true});
977
+ fs.mkdirSync(shared, {recursive: true});
978
+ fs.writeFileSync(
979
+ path.join(menu, 'AcmeMenu.doc.mjs'),
980
+ "export default {type: 'component', name: 'AcmeMenu', import: '@acme/widgets/menu', description: 'x', components: [{name: 'AcmeMenuItem', description: 'x', props: []}]};\n",
981
+ );
982
+ fs.writeFileSync(
983
+ path.join(menu, 'AcmeMenu.tsx'),
984
+ 'export function AcmeMenu() { return null; }\n',
985
+ );
986
+ fs.writeFileSync(
987
+ path.join(menu, 'index.ts'),
988
+ "export {AcmeMenu} from './AcmeMenu';\nexport {AcmeMenuItem} from '../shared/AcmeMenuItem';\n",
989
+ );
990
+ fs.writeFileSync(
991
+ path.join(shared, 'AcmeMenuItem.tsx'),
992
+ 'export function AcmeMenuItem() { return null; }\n',
993
+ );
994
+
995
+ const result = await integrationPackCheck({cwd: tmpDir});
996
+
997
+ expect(result.data.issues).toEqual([]);
998
+ expect(result.data.packable).toBe(true);
999
+ expect(result.data.contributions.packed.components).toEqual([
1000
+ 'AcmeMenu',
1001
+ 'AcmeMenuItem',
1002
+ ]);
1003
+ });
1004
+
1005
+ it('reports a family doc the loader rejects once, not once per member', async () => {
1006
+ fs.writeFileSync(
1007
+ path.join(tmpDir, 'package.json'),
1008
+ `${JSON.stringify({
1009
+ name: '@acme/widgets',
1010
+ version: '1.0.0',
1011
+ files: ['astryx.integration.mjs', 'components'],
1012
+ })}\n`,
1013
+ );
1014
+ fs.writeFileSync(
1015
+ path.join(tmpDir, 'astryx.integration.mjs'),
1016
+ "export default {components: './components'};\n",
1017
+ );
1018
+ fs.mkdirSync(path.join(tmpDir, 'components'));
1019
+ fs.writeFileSync(
1020
+ path.join(tmpDir, 'components', 'AcmeTabs.tsx'),
1021
+ 'export function AcmeTabs() { return null; }\nexport function AcmeTab() { return null; }\nexport function AcmeTabPanel() { return null; }\n',
1022
+ );
1023
+ // A displayName that is not a string makes the loader reject the doc.
1024
+ fs.writeFileSync(
1025
+ path.join(tmpDir, 'components', 'AcmeTabs.doc.mjs'),
1026
+ "export default {type: 'component', name: 'AcmeTabs', displayName: 1, description: 'x', components: [{name: 'AcmeTab', description: 'x', props: []}, {name: 'AcmeTabPanel', description: 'x', props: []}]};\n",
1027
+ );
1028
+
1029
+ const result = await integrationPackCheck({cwd: tmpDir});
1030
+ const codes = result.data.issues.map(issue => issue.code);
1031
+
1032
+ expect(codes).toContain('component_doc_unloadable');
1033
+ expect(codes.length).toBe(new Set(codes).size);
1034
+ });
1035
+
757
1036
  it('passes a generated component through its exact packed export', async () => {
758
1037
  fs.writeFileSync(
759
1038
  path.join(tmpDir, 'package.json'),
@@ -811,6 +1090,86 @@ describe('integrationPackCheck', () => {
811
1090
  );
812
1091
  });
813
1092
 
1093
+ it('does not count a type-only re-export as the component', async () => {
1094
+ fs.writeFileSync(
1095
+ path.join(tmpDir, 'package.json'),
1096
+ `${JSON.stringify({
1097
+ name: '@acme/widgets',
1098
+ version: '1.0.0',
1099
+ files: ['astryx.integration.mjs', 'src'],
1100
+ exports: {'.': './src/index.ts'},
1101
+ })}\n`,
1102
+ );
1103
+ fs.writeFileSync(
1104
+ path.join(tmpDir, 'astryx.integration.mjs'),
1105
+ "export default {components: './src'};\n",
1106
+ );
1107
+ fs.mkdirSync(path.join(tmpDir, 'src'));
1108
+ fs.writeFileSync(
1109
+ path.join(tmpDir, 'src', 'index.ts'),
1110
+ "export type {AcmeWidget} from './AcmeWidget';\n",
1111
+ );
1112
+ fs.writeFileSync(
1113
+ path.join(tmpDir, 'src', 'AcmeWidget.tsx'),
1114
+ 'export function AcmeWidget() { return null; }\n',
1115
+ );
1116
+ fs.writeFileSync(
1117
+ path.join(tmpDir, 'src', 'AcmeWidget.doc.mjs'),
1118
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
1119
+ );
1120
+
1121
+ const result = await integrationPackCheck({cwd: tmpDir});
1122
+
1123
+ expect(result.data.issues).toContainEqual(
1124
+ expect.objectContaining({code: 'component_export_missing'}),
1125
+ );
1126
+ });
1127
+
1128
+ it('follows a re-export of a folder to its index file', async () => {
1129
+ // `./primitives` names a folder: TypeScript and bundlers load its index file.
1130
+ fs.writeFileSync(
1131
+ path.join(tmpDir, 'package.json'),
1132
+ `${JSON.stringify({
1133
+ name: '@acme/widgets',
1134
+ version: '1.0.0',
1135
+ files: ['astryx.integration.mjs', 'src'],
1136
+ exports: {'.': './src/index.ts'},
1137
+ })}\n`,
1138
+ );
1139
+ fs.writeFileSync(
1140
+ path.join(tmpDir, 'astryx.integration.mjs'),
1141
+ "export default {components: './src'};\n",
1142
+ );
1143
+ const primitives = path.join(tmpDir, 'src', 'primitives');
1144
+ fs.mkdirSync(primitives, {recursive: true});
1145
+ fs.writeFileSync(
1146
+ path.join(tmpDir, 'src', 'index.ts'),
1147
+ "export * from './primitives';\n",
1148
+ );
1149
+ fs.writeFileSync(
1150
+ path.join(primitives, 'index.ts'),
1151
+ "export * from './AcmeWidget';\n",
1152
+ );
1153
+ fs.writeFileSync(
1154
+ path.join(primitives, 'AcmeWidget.tsx'),
1155
+ 'export function AcmeWidget() { return null; }\n',
1156
+ );
1157
+ fs.writeFileSync(
1158
+ path.join(primitives, 'AcmeWidget.doc.mjs'),
1159
+ "export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
1160
+ );
1161
+
1162
+ const result = await integrationPackCheck({cwd: tmpDir});
1163
+
1164
+ expect(result.data.issues).not.toContainEqual(
1165
+ expect.objectContaining({code: 'component_export_missing'}),
1166
+ );
1167
+ expect(
1168
+ result.data.packable,
1169
+ JSON.stringify(result.data.issues, null, 2),
1170
+ ).toBe(true);
1171
+ });
1172
+
814
1173
  it('fails when packed template source is hidden by package exports', async () => {
815
1174
  writePackage({
816
1175
  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,20 @@
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
+ integrationMemberEntry,
96
+ integrationRecordHold,
97
+ isIntegrationMember,
89
98
  resolveImportPath,
90
99
  resolveIntegrationImportPath,
91
100
  CORE_PACKAGE,
@@ -97,6 +106,7 @@ import {
97
106
  import {
98
107
  loadComponentReplacements,
99
108
  loadIntegrationsSafely,
109
+ resolveLegacyExternalDoc,
100
110
  } from '../component/_adapter.mjs';
101
111
  import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
102
112
  import {discoverTemplates, extractComponents} from '../template/template.mjs';
@@ -149,6 +159,9 @@ import {setResultCoverage} from './coverage.mjs';
149
159
  * @property {'page'|'block'} [_kind]
150
160
  * @property {string} [_resultName]
151
161
  * @property {string} [_commandName]
162
+ * @property {'file'|'doc'} [_hold] - How an integration component holds its
163
+ * name, for the owner rule.
164
+ * @property {string} [_docPath] - An integration component's doc.
152
165
  */
153
166
 
154
167
  /**
@@ -1079,7 +1092,11 @@ async function gatherCoreComponents(coreDir) {
1079
1092
  /** @type {string[]} */
1080
1093
  let guidance = [];
1081
1094
  if (readme && readme.endsWith('.doc.mjs')) {
1082
- const doc = await loadModuleDoc(readme);
1095
+ const loaded = await loadModuleDoc(readme);
1096
+ // A member documented in its family's doc (VStack in Stack's doc) is
1097
+ // indexed from its own entry: the family's keywords and guidance
1098
+ // describe the family, not the member.
1099
+ const doc = familyMember(loaded, comp) ?? loaded;
1083
1100
  if (doc) {
1084
1101
  keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
1085
1102
  description = doc.usage?.description || doc.description || '';
@@ -1105,20 +1122,57 @@ async function gatherCoreComponents(coreDir) {
1105
1122
  * usage/description from each component's .doc.mjs, same as core. Without
1106
1123
  * this, an integration component is invisible to `search`/`build` even
1107
1124
  * though `component --list`/`component <Name>` already resolve it — the two
1108
- * discovery paths silently disagreed.
1125
+ * discovery paths silently disagreed. Also returns, per name, the docs that
1126
+ * hold it by their file name and the loadable docs that only record it.
1109
1127
  * @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
1110
- * @returns {Promise<Candidate[]>}
1128
+ * @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
1111
1129
  */
1112
1130
  async function gatherIntegrationComponents(loadedIntegrations) {
1113
1131
  /** @type {Candidate[]} */
1114
1132
  const candidates = [];
1133
+ // What the owner rule reads: the docs that hold a name by their file name,
1134
+ // loadable or not and hidden ones too, and the loadable docs that only
1135
+ // record it.
1136
+ /** @type {Map<string, Set<string>>} */
1137
+ const fileNamed = new Map();
1138
+ /** @type {Map<string, Set<string>>} */
1139
+ const documented = new Map();
1140
+ /** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
1141
+ const hold = (map, name, docPath) =>
1142
+ map.set(name, (map.get(name) ?? new Set()).add(docPath));
1115
1143
  for (const integration of loadedIntegrations) {
1116
- const {components} = await discoverValidIntegrationComponents(integration);
1144
+ const {components, discovered} =
1145
+ await discoverValidIntegrationComponents(integration);
1146
+ const renamed = new Set(
1147
+ discovered
1148
+ .filter(rec => integrationRecordHold(rec) === 'doc')
1149
+ .filter(rec => !isIntegrationMember(rec))
1150
+ .map(rec => rec.docPath),
1151
+ );
1152
+ forEachIntegrationDoc(integration.components, (docPath, stem) => {
1153
+ if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
1154
+ });
1117
1155
  for (const rec of components) {
1118
- const doc = await loadModuleDoc(rec.docPath);
1156
+ const loaded = await loadModuleDoc(rec.docPath);
1157
+ // A family member is indexed from its own entry: the family's keywords
1158
+ // and guidance describe the family.
1159
+ const member = isIntegrationMember(rec);
1160
+ const doc = member ? integrationMemberEntry(loaded, rec) : loaded;
1161
+ // A doc that names another export of its file still answers to the
1162
+ // file's name, as `component <file name>` does.
1163
+ const fileName = docStem(rec.docPath);
1164
+ if (integrationRecordHold(rec) === 'doc') {
1165
+ hold(documented, rec.name, rec.docPath);
1166
+ }
1167
+ if (renamed.has(rec.docPath) && !member) {
1168
+ hold(documented, fileName, rec.docPath);
1169
+ }
1119
1170
  candidates.push({
1120
1171
  domain: 'component',
1121
1172
  name: rec.name,
1173
+ aliases: !member && fileName !== rec.name ? [fileName] : [],
1174
+ _hold: integrationRecordHold(rec),
1175
+ _docPath: rec.docPath,
1122
1176
  keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
1123
1177
  description: doc ? doc.usage?.description || doc.description || '' : '',
1124
1178
  guidance: guidanceFrom(doc),
@@ -1138,12 +1192,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1138
1192
  packageName: rec.package,
1139
1193
  },
1140
1194
  rec.name,
1141
- doc?.import,
1195
+ loaded?.import,
1142
1196
  ),
1143
1197
  });
1144
1198
  }
1145
1199
  }
1146
- return candidates;
1200
+ return {candidates, fileNamed, documented};
1147
1201
  }
1148
1202
 
1149
1203
  /**
@@ -1155,11 +1209,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
1155
1209
  */
1156
1210
  async function gatherComponents(coreDir, cwd) {
1157
1211
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1158
- const [core, integrations, replacements] = await Promise.all([
1212
+ const [core, gathered, replacements] = await Promise.all([
1159
1213
  gatherCoreComponents(coreDir),
1160
1214
  gatherIntegrationComponents(loadedIntegrations),
1161
1215
  loadComponentReplacements(coreDir, loadedIntegrations),
1162
1216
  ]);
1217
+ const {candidates: integrations, fileNamed, documented} = gathered;
1163
1218
  // An active replacement answers to the Core name it replaces, so a search
1164
1219
  // for that name finds the replacement and not the Core original
1165
1220
  // (spec:AST-035 FR11).
@@ -1170,9 +1225,43 @@ async function gatherComponents(coreDir, cwd) {
1170
1225
  active.target,
1171
1226
  ]),
1172
1227
  );
1228
+ // The owner rule, as component detail applies it: a candidate `component
1229
+ // <name>` does not open names its package and follows the owner, and a
1230
+ // file-name alias that opens another owner is dropped.
1231
+ const coreNames = new Set(core.map(candidate => candidate.name));
1232
+ /** @param {string} name @param {Candidate} candidate */
1233
+ const opens = (name, candidate) =>
1234
+ !coreNames.has(name) &&
1235
+ findComponentReadme(coreDir, name) == null &&
1236
+ [...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
1237
+ [...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
1238
+ // A legacy docs package keeps an exact name over an export name a doc records.
1239
+ /** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
1240
+ let externals;
1241
+ /** @param {string} name */
1242
+ const legacyDocuments = name =>
1243
+ (externals ??= discoverExternalPackages(cwd)).some(
1244
+ ext => resolveLegacyExternalDoc(ext, name) != null,
1245
+ );
1246
+ const ruled = integrations.map(candidate => {
1247
+ const aliases = (candidate.aliases ?? []).filter(alias =>
1248
+ opens(alias, candidate),
1249
+ );
1250
+ return candidate._hold === 'doc' &&
1251
+ (!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
1252
+ ? {
1253
+ ...candidate,
1254
+ aliases,
1255
+ _command: `astryx component ${candidate.name} --package ${candidate._package}`,
1256
+ }
1257
+ : {...candidate, aliases};
1258
+ });
1173
1259
  return [
1174
1260
  ...core.filter(candidate => !replacements.forTarget(candidate.name)),
1175
- ...integrations.map(candidate => {
1261
+ ...[
1262
+ ...ruled.filter(candidate => !candidate._command),
1263
+ ...ruled.filter(candidate => candidate._command),
1264
+ ].map(candidate => {
1176
1265
  const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
1177
1266
  if (target != null) {
1178
1267
  return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
@@ -1225,11 +1314,11 @@ export async function componentKeywords(coreDir, cwd) {
1225
1314
  .flat()
1226
1315
  .map(async name => {
1227
1316
  const readme = findComponentReadme(coreDir, name);
1228
- const doc =
1317
+ const loaded =
1229
1318
  readme && readme.endsWith('.doc.mjs')
1230
1319
  ? await loadModuleDoc(readme)
1231
1320
  : null;
1232
- return {name, keywords: keywordsOf(doc)};
1321
+ return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
1233
1322
  });
1234
1323
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1235
1324
  const replacements = await loadComponentReplacements(
@@ -1239,10 +1328,13 @@ export async function componentKeywords(coreDir, cwd) {
1239
1328
  const integrations = loadedIntegrations.map(async integration => {
1240
1329
  const {components} = await discoverValidIntegrationComponents(integration);
1241
1330
  return Promise.all(
1242
- components.map(async rec => ({
1243
- name: rec.name,
1244
- keywords: keywordsOf(await loadModuleDoc(rec.docPath)),
1245
- })),
1331
+ components.map(async rec => {
1332
+ const loaded = await loadModuleDoc(rec.docPath);
1333
+ return {
1334
+ name: rec.name,
1335
+ keywords: keywordsOf(integrationMemberEntry(loaded, rec) ?? loaded),
1336
+ };
1337
+ }),
1246
1338
  );
1247
1339
  });
1248
1340
  return [
@@ -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`