@astryxdesign/cli 0.7.2-canary.fc12a18 → 0.7.2-canary.fdd384f

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 (174) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/component.test.mjs +420 -167
  7. package/api/component/integration-export-name.test.mjs +644 -0
  8. package/api/component/list/list.mjs +42 -4
  9. package/api/docs/_adapter.d.mts +11 -0
  10. package/api/docs/_adapter.mjs +244 -1
  11. package/api/docs/integrationDocs.test.mjs +124 -0
  12. package/api/doctor/theme-checks.mjs +1 -1
  13. package/api/gap-report/gap-report.mjs +67 -4
  14. package/api/index.d.mts +2 -0
  15. package/api/index.mjs +2 -0
  16. package/api/init/run/run.mjs +2 -2
  17. package/api/integration/_adapter.d.mts +12 -0
  18. package/api/integration/_adapter.mjs +54 -0
  19. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  20. package/api/integration/add-contribution.mjs +8 -4
  21. package/api/integration/add-contribution.test.mjs +4 -2
  22. package/api/integration/authoring-checks.mjs +11 -2
  23. package/api/integration/authoring-checks.test.mjs +39 -0
  24. package/api/integration/authoring-checks.type.d.mts +9 -0
  25. package/api/integration/authoring-checks.type.mjs +14 -1
  26. package/api/integration/check/check.d.mts +96 -0
  27. package/api/integration/check/check.mjs +357 -0
  28. package/api/integration/check/check.test.mjs +489 -0
  29. package/api/integration/integration-check.type.d.mts +118 -0
  30. package/api/integration/integration-check.type.mjs +84 -0
  31. package/api/integration/integrationCheck.doc.d.mts +10 -0
  32. package/api/integration/integrationCheck.doc.mjs +73 -0
  33. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  34. package/api/integration/pack-check.mjs +61 -107
  35. package/api/integration/pack-check.test.mjs +359 -0
  36. package/api/integration/validate-integration.mjs +8 -0
  37. package/api/integration/validate-integration.test.mjs +11 -0
  38. package/api/integration/validate-integration.type.d.mts +8 -0
  39. package/api/integration/validate-integration.type.mjs +12 -1
  40. package/api/integration/validateIntegration.doc.mjs +17 -3
  41. package/api/search/search.d.mts +9 -0
  42. package/api/search/search.integration-check.test.mjs +24 -0
  43. package/api/search/search.mjs +108 -16
  44. package/api/search/search.test.mjs +36 -0
  45. package/api/swizzle/copy/copy.mjs +28 -14
  46. package/api/template/template.test.mjs +120 -3
  47. package/api/theme/build/build.family.test.mjs +64 -0
  48. package/api/theme/build/build.mjs +180 -24
  49. package/api/theme/themeBuild.doc.mjs +4 -4
  50. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  51. package/assets/codemods/transforms/next/index.mjs +11 -1
  52. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  53. package/assets/docs/README.md +10 -0
  54. package/assets/docs/author-a-theme.doc.mjs +1 -1
  55. package/assets/docs/getting-started.doc.mjs +3 -3
  56. package/assets/docs/theme.doc.dense.mjs +1 -1
  57. package/assets/docs/theme.doc.mjs +1 -1
  58. package/assets/docs/theme.doc.zh.mjs +1 -1
  59. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  60. package/assets/docs/tree/block-template.doc.mjs +2 -2
  61. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  62. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  63. package/assets/docs/tree/checks.doc.mjs +21 -13
  64. package/assets/docs/tree/component-family.doc.mjs +5 -1
  65. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  66. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  67. package/assets/docs/tree/links.doc.mjs +2 -2
  68. package/assets/docs/tree/page-template.doc.mjs +1 -1
  69. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  70. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  71. package/assets/docs/tree/single-component.doc.mjs +3 -2
  72. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  73. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  74. package/assets/docs/tree/versioning.doc.mjs +1 -0
  75. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  76. package/assets/docs/use-a-theme.doc.mjs +2 -2
  77. package/assets/docs/working-with-ai.doc.mjs +43 -4
  78. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  79. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  80. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  81. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  82. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  83. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  84. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  85. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  86. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  87. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  88. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  89. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  90. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  91. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  92. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  93. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  94. package/assets/templates/pages/detail-page/page.tsx +3 -3
  95. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  96. package/assets/theme.template.ts +1 -1
  97. package/authoring/doctypes/base/type.ts +6 -0
  98. package/authoring/doctypes/component/component.doc.mjs +4 -4
  99. package/authoring/doctypes/component/type.ts +6 -3
  100. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  101. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  102. package/authoring/doctypes/template/parse.d.mts +6 -2
  103. package/authoring/doctypes/template/parse.mjs +3 -1
  104. package/authoring/doctypes/template/template.doc.mjs +1 -1
  105. package/authoring/doctypes/template/type.ts +22 -0
  106. package/authoring/index.d.ts +1 -0
  107. package/authoring/integration/integration.doc.mjs +8 -1
  108. package/authoring/integration/parse.test.mjs +10 -0
  109. package/authoring/integration/schema.d.mts +2 -0
  110. package/authoring/integration/schema.mjs +1 -0
  111. package/authoring/integration/type.ts +4 -0
  112. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  113. package/clients/cli/commands/build-theme.mjs +1 -1
  114. package/clients/cli/commands/build-theme.path-safety.test.mjs +224 -6
  115. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  116. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  117. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  118. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  119. package/clients/cli/commands/doctor.mjs +190 -0
  120. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  121. package/clients/cli/commands/integration-check.test.mjs +267 -0
  122. package/clients/cli/commands/integration.doc.mjs +6 -2
  123. package/clients/cli/commands/integration.mjs +107 -1
  124. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  125. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  126. package/clients/cli/index.mjs +1 -0
  127. package/clients/cli/lib/component-format.mjs +5 -1
  128. package/foundation/config/project.mjs +82 -0
  129. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  130. package/foundation/discovery/component-discovery.d.mts +190 -10
  131. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  132. package/foundation/discovery/component-discovery.mjs +864 -56
  133. package/foundation/discovery/component-discovery.test.mjs +96 -0
  134. package/foundation/discovery/component-replacement.mjs +18 -0
  135. package/foundation/discovery/component-replacement.test.mjs +40 -0
  136. package/foundation/discovery/doc-types.d.mts +157 -0
  137. package/foundation/discovery/doc-types.mjs +1224 -0
  138. package/foundation/discovery/doc-types.test.mjs +652 -0
  139. package/foundation/discovery/docs-discovery.d.mts +81 -0
  140. package/foundation/discovery/docs-discovery.mjs +57 -0
  141. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  142. package/foundation/discovery/integration-doc-files.mjs +231 -0
  143. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  144. package/foundation/discovery/module-exports.d.mts +27 -0
  145. package/foundation/discovery/module-exports.mjs +356 -31
  146. package/foundation/discovery/module-exports.test.mjs +172 -0
  147. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  148. package/foundation/discovery/package-tsconfig.mjs +228 -0
  149. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  150. package/foundation/discovery/project-typescript.d.mts +23 -0
  151. package/foundation/discovery/project-typescript.mjs +61 -0
  152. package/foundation/discovery/prop-drift.d.mts +278 -0
  153. package/foundation/discovery/prop-drift.mjs +2263 -0
  154. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  155. package/foundation/discovery/template-adapter.mjs +74 -5
  156. package/foundation/discovery/type-match.d.mts +53 -0
  157. package/foundation/discovery/type-match.mjs +660 -0
  158. package/foundation/discovery/type-match.test.mjs +169 -0
  159. package/foundation/discovery/type-text.d.mts +68 -0
  160. package/foundation/discovery/type-text.mjs +658 -0
  161. package/foundation/discovery/type-text.test.mjs +196 -0
  162. package/foundation/discovery/unresolved-references.d.mts +62 -0
  163. package/foundation/discovery/unresolved-references.mjs +790 -0
  164. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  165. package/foundation/doc-compiler/doc-loads.test.mjs +11 -1
  166. package/foundation/doc-compiler/inputs.mjs +23 -0
  167. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  168. package/foundation/doc-compiler/links.d.mts +9 -5
  169. package/foundation/doc-compiler/links.mjs +4 -2
  170. package/foundation/integrations/contribution-inventory.mjs +6 -1
  171. package/foundation/response/response-types.doc.mjs +7 -2
  172. package/foundation/xle/registry.mjs +4 -0
  173. package/foundation/xle/registry.test.mjs +28 -0
  174. package/package.json +10 -10
@@ -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`
@@ -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(
@@ -1,7 +1,14 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
3
6
  import {describe, it, expect} from 'vitest';
4
- import {stripTemplateAssetRefs, template} from './template.mjs';
7
+ import {
8
+ stripTemplateAssetRefs,
9
+ template,
10
+ extractComponents,
11
+ } from './template.mjs';
5
12
 
6
13
  describe('stripTemplateAssetRefs', () => {
7
14
  it('replaces a /template-assets image path with an inline data URI', () => {
@@ -31,8 +38,7 @@ describe('stripTemplateAssetRefs', () => {
31
38
  });
32
39
 
33
40
  it('preserves surrounding source structure', () => {
34
- const src =
35
- "const data = [{src: '/template-assets/x.png', alt: 'X'}];";
41
+ const src = "const data = [{src: '/template-assets/x.png', alt: 'X'}];";
36
42
  const out = stripTemplateAssetRefs(src);
37
43
  expect(out).toContain("alt: 'X'");
38
44
  expect(out).toContain('const data = [{src:');
@@ -102,4 +108,115 @@ describe('template --skeleton component extraction (prefix-agnostic)', () => {
102
108
 
103
109
  expect(result.data.skeleton).toContain('columns={{minWidth: 200}}');
104
110
  });
111
+
112
+ it('filters out local helper components declared in the same file', () => {
113
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
114
+ const pagePath = path.join(tmpDir, 'page.tsx');
115
+ fs.writeFileSync(
116
+ pagePath,
117
+ `
118
+ import {Card} from '@astryxdesign/core/Layout';
119
+
120
+ function TimelineSection() {
121
+ return <div />;
122
+ }
123
+
124
+ export default function Page() {
125
+ return (
126
+ <Card>
127
+ <TimelineSection />
128
+ </Card>
129
+ );
130
+ }
131
+ `,
132
+ );
133
+
134
+ try {
135
+ const components = extractComponents(pagePath);
136
+ expect(components).toContain('Card');
137
+ expect(components).not.toContain('Timeline');
138
+ } finally {
139
+ fs.rmSync(tmpDir, {recursive: true, force: true});
140
+ }
141
+ });
142
+
143
+ it('filters out a type-annotated local helper', () => {
144
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
145
+ const pagePath = path.join(tmpDir, 'page.tsx');
146
+ fs.writeFileSync(
147
+ pagePath,
148
+ `
149
+ import type {FC} from 'react';
150
+ import {Card} from '@astryxdesign/core/Layout';
151
+
152
+ const TimelineSection: FC = () => <div />;
153
+
154
+ export default function Page() {
155
+ return (
156
+ <Card>
157
+ <TimelineSection />
158
+ </Card>
159
+ );
160
+ }
161
+ `,
162
+ );
163
+ try {
164
+ const components = extractComponents(pagePath);
165
+ expect(components).toContain('Card');
166
+ expect(components).not.toContain('Timeline');
167
+ } finally {
168
+ fs.rmSync(tmpDir, {recursive: true, force: true});
169
+ }
170
+ });
171
+
172
+ it('keeps an imported component when declaration-like text appears in a comment or string', () => {
173
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
174
+ const pagePath = path.join(tmpDir, 'page.tsx');
175
+ fs.writeFileSync(
176
+ pagePath,
177
+ `
178
+ import {Card} from '@astryxdesign/core/Layout';
179
+
180
+ // const Card = () => null;
181
+ const note = 'function Card() {}';
182
+
183
+ export default function Page() {
184
+ return <Card>{note}</Card>;
185
+ }
186
+ `,
187
+ );
188
+ try {
189
+ expect(extractComponents(pagePath)).toContain('Card');
190
+ } finally {
191
+ fs.rmSync(tmpDir, {recursive: true, force: true});
192
+ }
193
+ });
194
+
195
+ it('keeps a bare imported component whose name is itself a stripped suffix', () => {
196
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
197
+ const pagePath = path.join(tmpDir, 'page.tsx');
198
+ fs.writeFileSync(
199
+ pagePath,
200
+ `
201
+ import {Section} from '@astryxdesign/core/Section';
202
+
203
+ function SectionHeading() {
204
+ return <div />;
205
+ }
206
+
207
+ export default function Page() {
208
+ return (
209
+ <Section>
210
+ <SectionHeading />
211
+ </Section>
212
+ );
213
+ }
214
+ `,
215
+ );
216
+ try {
217
+ expect(extractComponents(pagePath)).toContain('Section');
218
+ } finally {
219
+ fs.rmSync(tmpDir, {recursive: true, force: true});
220
+ }
221
+ });
105
222
  });
@@ -13,6 +13,7 @@ import {themeBuild, themeBuildFamily} from './build.mjs';
13
13
  import {interceptCore} from './core-interception.mjs';
14
14
  import {ensureCoreBuilt} from '../../../clients/cli/commands/ensure-core-built.mjs';
15
15
  import {runCli} from '../../../test-utils/run-cli.mjs';
16
+ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
16
17
  vi.setConfig({testTimeout: 120_000});
17
18
  const CLI_ROOT = path.resolve(import.meta.dirname, '../../..');
18
19
  const FAMILY_KEY = 'ocean-family';
@@ -252,6 +253,69 @@ describe('themeBuildFamily()', () => {
252
253
  expect(fs.existsSync(dtsPath)).toBe(false);
253
254
  });
254
255
 
256
+ it('refuses to write family outputs outside cwd, and checks them read-only', async () => {
257
+ const dir = makeDir();
258
+ const files = writeFamily(dir).map(file => path.join(dir, file));
259
+ const app = path.join(dir, 'app');
260
+ fs.mkdirSync(app);
261
+
262
+ await expect(
263
+ themeBuildFamily(files, {familyKey: FAMILY_KEY}, {cwd: app}),
264
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
265
+ const checked = await themeBuildFamily(
266
+ files,
267
+ {familyKey: FAMILY_KEY, check: true},
268
+ {cwd: app},
269
+ );
270
+ expect(checked?.type).toBe('theme.build.check');
271
+ expect(checked?.data.upToDate).toBe(false);
272
+ // The `.css.d.ts` import stub may be absent in check mode.
273
+ expect(checked?.data.stale).toEqual(
274
+ OUTPUTS.filter(file => !file.endsWith('.css.d.ts')).map(file => ({
275
+ path: `../themes/${file}`,
276
+ reason: 'missing',
277
+ })),
278
+ );
279
+ for (const output of OUTPUTS) {
280
+ expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(false);
281
+ }
282
+ });
283
+
284
+ it('ends a stale family report for outputs outside cwd with a remedy that works', async () => {
285
+ const dir = makeDir();
286
+ const files = writeFamily(dir).map(file => path.join(dir, file));
287
+ const app = path.join(dir, 'app');
288
+ fs.mkdirSync(app);
289
+ const family = ['--family', ...files, '--family-key', FAMILY_KEY];
290
+
291
+ const stale = await runCli(['theme', 'build', ...family, '--check'], app);
292
+ expect(stale.code).toBe(1);
293
+ // The old remedy ran the build from cwd, which refuses these outputs.
294
+ expect(stale.stderr).not.toContain('Rebuild with:');
295
+
296
+ const lines = stale.stderr.split('\n').map(line => line.trim());
297
+ const runDir = lines.find(line => line.startsWith('cd '))?.slice(3);
298
+ expect(runDir).toBe(fs.realpathSync(path.join(dir, 'themes')));
299
+ const stem = `${getCliInvocation(runDir)} theme build `;
300
+ const [buildArgs, checkArgs] = lines
301
+ .filter(line => line.startsWith(stem))
302
+ .map(line => line.slice(stem.length).split(' '));
303
+ expect(checkArgs).toEqual([...buildArgs, '--check']);
304
+
305
+ // Following the remedy builds, and the check from there passes.
306
+ const built = await runCli(['theme', 'build', ...buildArgs], runDir);
307
+ expect(built.code).toBe(0);
308
+ for (const output of OUTPUTS) {
309
+ expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(true);
310
+ }
311
+ const checked = await runCli(
312
+ ['--json', 'theme', 'build', ...checkArgs],
313
+ runDir,
314
+ );
315
+ expect(checked.code).toBe(0);
316
+ expect(JSON.parse(checked.stdout).data.upToDate).toBe(true);
317
+ });
318
+
255
319
  it('fails without publishing a mixed trio when staging a write fails', async () => {
256
320
  const dir = makeDir();
257
321
  const files = writeFamily(dir);