@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
@@ -5,11 +5,22 @@
5
5
  */
6
6
 
7
7
  import * as fs from 'node:fs';
8
+ import {createRequire} from 'node:module';
8
9
  import * as path from 'node:path';
10
+ import {DOC_EXPORTS} from '../doc-compiler/read.mjs';
9
11
  import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
10
12
  import {existsCaseExact} from '../fs/paths.mjs';
11
13
  import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
12
14
  import {loadComponentDoc} from './component-loader.mjs';
15
+ import {
16
+ HAS_COMPUTED,
17
+ moduleExportSource,
18
+ moduleExportsName,
19
+ staticExportObject,
20
+ } from './module-exports.mjs';
21
+ import {inspectPackageExport} from './package-exports.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
13
24
 
14
25
  const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
15
26
 
@@ -79,12 +90,40 @@ function componentNameFromFile(fileName) {
79
90
  const GROUP_RE = /(?:^|\n) {0,2}group:\s*['"]([^'"]+)['"]/;
80
91
  const HIDDEN_COMPONENTS_RE = /(?:^|\n) {0,2}hiddenComponents:\s*\[([^\]]*)\]/;
81
92
  const HIDDEN_RE = /(?:^|\n) {0,2}hidden:\s*true/;
93
+ // Every top-level `name: '...'` (or `"name": "..."`) in the file: a quick check
94
+ // that a doc is named after its file before anything parses it.
95
+ const NAME_FIELD_RE = /(?:^|\n) {0,2}['"]?name['"]?\s*:\s*['"]([^'"\n]+)['"]/g;
96
+ // A `components` field anywhere in the file, written out or as a shorthand: the
97
+ // quick check for a family doc, before anything parses it. A false match only
98
+ // costs the parse.
99
+ const MEMBERS_FIELD_RE = /\bcomponents\b/;
100
+ // A top-level `replaces: '...'`: the Core name a doc claims for its package.
101
+ const REPLACES_FIELD_RE =
102
+ /(?:^|\n) {0,2}['"]?replaces['"]?\s*:\s*['"]([^'"\n]+)['"]/;
103
+
104
+ /**
105
+ * What a quick scan of a doc's text finds before anything parses it: the
106
+ * top-level `name` values it writes (none when a spread could set the name),
107
+ * whether it may list family members, and the Core name it replaces.
108
+ * @param {string} text
109
+ * @returns {{names: string[], hasMembers: boolean, replaces: string | null}}
110
+ */
111
+ function scanDocText(text) {
112
+ return {
113
+ names: text.includes('...')
114
+ ? []
115
+ : [...text.matchAll(NAME_FIELD_RE)].map(match => match[1]),
116
+ hasMembers: MEMBERS_FIELD_RE.test(text),
117
+ replaces: REPLACES_FIELD_RE.exec(text)?.[1] ?? null,
118
+ };
119
+ }
82
120
 
83
121
  /**
84
122
  * Read the `group`, `hiddenComponents`, and `hidden` fields from a
85
- * component's .doc.mjs file (synchronous).
123
+ * component's .doc.mjs file (synchronous), with what {@link scanDocText}
124
+ * finds in it.
86
125
  * @param {string} docPath
87
- * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean}}
126
+ * @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean, names: string[], hasMembers: boolean, replaces: string | null}}
88
127
  */
89
128
  function readDocMeta(docPath) {
90
129
  try {
@@ -103,14 +142,214 @@ function readDocMeta(docPath) {
103
142
  group: groupMatch ? groupMatch[1] : null,
104
143
  hiddenComponents: hiddenSet,
105
144
  hidden,
145
+ ...scanDocText(content),
106
146
  };
107
147
  } catch {
108
- return {group: null, hiddenComponents: new Set(), hidden: false};
148
+ return {
149
+ group: null,
150
+ hiddenComponents: new Set(),
151
+ hidden: false,
152
+ names: [],
153
+ hasMembers: false,
154
+ replaces: null,
155
+ };
156
+ }
157
+ }
158
+
159
+ /** @type {typeof import('@babel/parser') | undefined} */
160
+ let babelParser;
161
+
162
+ /**
163
+ * A property of an object literal, by its plain key.
164
+ * @param {any} object - an ObjectExpression node
165
+ * @param {string} key
166
+ * @returns {any}
167
+ */
168
+ function propertyValue(object, key) {
169
+ for (const property of object.properties) {
170
+ if (property.type !== 'ObjectProperty' || property.computed) continue;
171
+ const name =
172
+ property.key.type === 'Identifier'
173
+ ? property.key.name
174
+ : property.key.type === 'StringLiteral'
175
+ ? property.key.value
176
+ : null;
177
+ if (name === key) return property.value;
109
178
  }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * The names a component doc lists as `components` members, read from its
184
+ * source without running it. Only a static `{name: '<Name>'}` entry in the doc
185
+ * object the loader reads (the `default` export, else `docs`) counts: a name in
186
+ * prose, an example, or `relatedComponents` is not a member.
187
+ * @param {string} source - the doc file's text
188
+ * @returns {string[]}
189
+ */
190
+ function docMemberNames(source) {
191
+ /** @type {any} */
192
+ let ast;
193
+ try {
194
+ babelParser ??= require('@babel/parser');
195
+ ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(
196
+ source,
197
+ {
198
+ sourceType: 'module',
199
+ },
200
+ );
201
+ } catch {
202
+ return [];
203
+ }
204
+ /** @type {Map<string, any>} */
205
+ const constants = new Map();
206
+ let defaultExport = null;
207
+ let docsExport = null;
208
+ for (const node of ast.program.body) {
209
+ const declaration =
210
+ node.type === 'ExportNamedDeclaration' ? node.declaration : node;
211
+ if (declaration?.type === 'VariableDeclaration') {
212
+ for (const declarator of declaration.declarations) {
213
+ if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
214
+ constants.set(declarator.id.name, declarator.init);
215
+ if (
216
+ node.type === 'ExportNamedDeclaration' &&
217
+ declarator.id.name === 'docs'
218
+ ) {
219
+ docsExport = declarator.init;
220
+ }
221
+ }
222
+ }
223
+ if (node.type === 'ExportDefaultDeclaration')
224
+ defaultExport = node.declaration;
225
+ }
226
+ let doc = defaultExport ?? docsExport;
227
+ if (doc?.type === 'Identifier') doc = constants.get(doc.name);
228
+ if (doc?.type !== 'ObjectExpression') return [];
229
+ const members = propertyValue(doc, 'components');
230
+ if (members?.type !== 'ArrayExpression') return [];
231
+ return members.elements.flatMap((/** @type {any} */ element) => {
232
+ if (element?.type !== 'ObjectExpression') return [];
233
+ const name = propertyValue(element, 'name');
234
+ return name?.type === 'StringLiteral' ? [name.value] : [];
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Whether `name` has its own component folder in Core: `src/<Name>/<Name>.tsx`
240
+ * (or the `XDS`-prefixed form).
241
+ * @param {string} srcDir
242
+ * @param {string} name
243
+ * @returns {boolean}
244
+ */
245
+ function hasOwnFolder(srcDir, name) {
246
+ return [`${name}.tsx`, `XDS${name}.tsx`].some(file =>
247
+ existsCaseExact(path.join(srcDir, name, file), srcDir),
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Member names read in this process, by doc path, reused while the file keeps
253
+ * its size and mtime.
254
+ * @type {Map<string, {size: number, mtimeMs: number, members: string[]}>}
255
+ */
256
+ const memberReads = new Map();
257
+
258
+ /**
259
+ * The names a doc lists as `components` members (see docMemberNames).
260
+ * @param {string} docPath
261
+ * @returns {string[]}
262
+ */
263
+ function readDocMembers(docPath) {
264
+ let stat;
265
+ try {
266
+ stat = fs.statSync(docPath);
267
+ } catch {
268
+ return [];
269
+ }
270
+ const cached = memberReads.get(docPath);
271
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
272
+ return cached.members;
273
+ }
274
+ /** @type {string[]} */
275
+ let members;
276
+ try {
277
+ members = docMemberNames(fs.readFileSync(docPath, 'utf-8'));
278
+ } catch {
279
+ members = [];
280
+ }
281
+ memberReads.set(docPath, {size: stat.size, mtimeMs: stat.mtimeMs, members});
282
+ return members;
283
+ }
284
+
285
+ // A sibling component folder named by a relative import or re-export.
286
+ const SIBLING_IMPORT_RE =
287
+ /(?:\bfrom|\bimport\s*\()\s*['"]\.\.\/([A-Za-z0-9_-]+)(?:\/[^'"]*)?['"]/g;
288
+
289
+ /**
290
+ * The Core doc that documents `name` as one of its `components` members: the
291
+ * doc of a component it builds on. HStack and VStack import Stack, and Stack's
292
+ * doc lists them, so each has its own folder and import path but no doc of its
293
+ * own. Only the folders the component's own source imports from are read.
294
+ * @param {string} srcDir
295
+ * @param {string} name
296
+ * @returns {string | null}
297
+ */
298
+ function findFamilyDoc(srcDir, name) {
299
+ const sourcePath = [`${name}.tsx`, `XDS${name}.tsx`]
300
+ .map(file => path.join(srcDir, name, file))
301
+ .find(file => existsCaseExact(file, srcDir));
302
+ if (!sourcePath) return null;
303
+ let source;
304
+ try {
305
+ source = fs.readFileSync(sourcePath, 'utf-8');
306
+ } catch {
307
+ return null;
308
+ }
309
+ const families = [
310
+ ...new Set([...source.matchAll(SIBLING_IMPORT_RE)].map(match => match[1])),
311
+ ]
312
+ .filter(dir => dir !== name && !SKIP_DIRS.has(dir))
313
+ .sort();
314
+ for (const dir of families) {
315
+ const dirPath = path.join(srcDir, dir);
316
+ if (!existsCaseExact(dirPath, srcDir)) continue;
317
+ for (const file of [...listDir(dirPath)].sort()) {
318
+ if (!file.endsWith('.doc.mjs')) continue;
319
+ const docPath = path.join(dirPath, file);
320
+ const members = readDocMembers(docPath);
321
+ if (members.includes(name) || members.includes(`XDS${name}`)) {
322
+ return docPath;
323
+ }
324
+ }
325
+ }
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * The entry a family doc gives `name` in its `components`, when the doc is not
331
+ * the component's own (Stack's doc for VStack); null otherwise.
332
+ * @param {any} docs - a loaded component doc
333
+ * @param {string} name - bare component name
334
+ * @returns {any}
335
+ */
336
+ export function familyMember(docs, name) {
337
+ if (!docs || docs.name === name || docs.name === `XDS${name}`) return null;
338
+ const members = Array.isArray(docs.components) ? docs.components : [];
339
+ return (
340
+ members.find(
341
+ (/** @type {any} */ member) =>
342
+ member?.name === name || member?.name === `XDS${name}`,
343
+ ) ?? null
344
+ );
110
345
  }
111
346
 
112
347
  /**
113
348
  * Auto-discover components by scanning for Astryx*.tsx files in core/src/.
349
+ * A component is listed when a doc documents it: a doc in its folder, or, for a
350
+ * component folder with no doc of its own, the doc `findComponentReadme` finds
351
+ * elsewhere (Heading's doc in the Text folder; HStack and VStack as members of
352
+ * Stack's doc).
114
353
  *
115
354
  * Returns an ordered Record where:
116
355
  * - Grouped components use the group name as key: `'Buttons': ['Button', 'IconButton']`
@@ -192,6 +431,27 @@ export function discoverComponents(coreDir) {
192
431
  }
193
432
  }
194
433
 
434
+ // A component folder with no doc in it is still listed when a doc elsewhere
435
+ // documents it, the doc `component <Name>` opens: its own same-stem doc in a
436
+ // family folder (Text/Heading.doc.mjs), or a member entry in the family's doc
437
+ // (HStack and VStack in Stack's doc).
438
+ for (const entry of topEntries) {
439
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
440
+ const name = entry.name;
441
+ if (componentGroups.has(name) || name.endsWith('Context')) continue;
442
+ if (
443
+ listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))
444
+ ) {
445
+ continue;
446
+ }
447
+ if (!hasOwnFolder(srcDir, name)) continue;
448
+ const docPath = findComponentReadme(coreDir, name);
449
+ if (!docPath?.endsWith('.doc.mjs')) continue;
450
+ const meta = readDocMeta(docPath);
451
+ if (meta.hidden || meta.hiddenComponents.has(name)) continue;
452
+ componentGroups.set(name, meta.group);
453
+ }
454
+
195
455
  // Build the result: group name → sorted members, or component name → [self]
196
456
  /** @type {Map<string, string[]>} */
197
457
  const groups = new Map();
@@ -284,6 +544,10 @@ export function findComponentReadme(coreDir, name) {
284
544
  }
285
545
  }
286
546
 
547
+ // A component with its own folder, documented as a member of its family's
548
+ // doc (HStack and VStack in Stack's doc).
549
+ if (hasOwnFolder(srcDir, name)) return findFamilyDoc(srcDir, name);
550
+
287
551
  return null;
288
552
  }
289
553
 
@@ -632,6 +896,345 @@ export function findExternalComponentDoc(docsDir, name) {
632
896
  // commands — and the future integration-component swizzle — can disambiguate
633
897
  // by package and route source/issues correctly.
634
898
 
899
+ // A doc's `name` can name another export of its file, and a family doc's full
900
+ // entries are components under their own names; every other doc reads as before.
901
+ const COMPONENT_NAME_RE = /^[A-Z][\w$]*$/;
902
+
903
+ /** @type {Map<string, {stamp: string, value: Record<string, any> | null}>} doc reads, reused while the doc file is unchanged */
904
+ const docReads = new Map();
905
+
906
+ /**
907
+ * A file's identity for a cache: ctime and inode change on a rewrite even when
908
+ * npm stamps the same size and mtime.
909
+ * @param {string} file
910
+ */
911
+ function fileStamp(file) {
912
+ try {
913
+ const {size, mtimeMs, ctimeMs, ino} = fs.statSync(file);
914
+ return `${size}:${mtimeMs}:${ctimeMs}:${ino}`;
915
+ } catch {
916
+ return 'missing';
917
+ }
918
+ }
919
+
920
+ /** UTF-16 code-unit order, the same in every locale. @param {string} a @param {string} b */
921
+ function byCodeUnit(a, b) {
922
+ return a < b ? -1 : a > b ? 1 : 0;
923
+ }
924
+
925
+ /**
926
+ * A component doc's exported object as plain data, read without running it.
927
+ * @param {string} docPath
928
+ * @returns {Record<string, any> | null}
929
+ */
930
+ function docObject(docPath) {
931
+ const stamp = fileStamp(docPath);
932
+ const cached = docReads.get(docPath);
933
+ if (cached?.stamp === stamp) return cached.value;
934
+ const value = staticExportObject(docPath, DOC_EXPORTS.components);
935
+ docReads.set(docPath, {stamp, value});
936
+ return value;
937
+ }
938
+
939
+ /** @param {string} docPath */
940
+ export function docStem(docPath) {
941
+ return path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
942
+ }
943
+
944
+ /**
945
+ * The export a component doc names when its same-stem source is not named
946
+ * after an export, or null to keep the stem.
947
+ * @param {{docPath: string, sourcePath: string|null, stem: string, names: string[]}} doc
948
+ * `names`: top-level names a quick scan found (none: the doc is read)
949
+ * @returns {string | null}
950
+ */
951
+ function otherExportName({docPath, sourcePath, stem, names}) {
952
+ if (sourcePath == null) return null;
953
+ if (names.length > 0 && names.every(name => name === stem)) return null;
954
+ const name = docObject(docPath)?.name;
955
+ return typeof name === 'string' &&
956
+ name !== stem &&
957
+ COMPONENT_NAME_RE.test(name) &&
958
+ moduleExportsName(sourcePath, name) &&
959
+ !moduleExportsName(sourcePath, stem)
960
+ ? name
961
+ : null;
962
+ }
963
+
964
+ /**
965
+ * The file Node loads for a package without `exports`: its `main`, else its
966
+ * `index` file.
967
+ * @param {string} packageDir
968
+ * @returns {string | null}
969
+ */
970
+ function packageMain(packageDir) {
971
+ let main;
972
+ try {
973
+ main = JSON.parse(
974
+ fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'),
975
+ ).main;
976
+ } catch {
977
+ return null;
978
+ }
979
+ const entry =
980
+ typeof main === 'string' && main !== ''
981
+ ? `./${path.posix.normalize(main).replace(/^\.\//, '')}`
982
+ : './index';
983
+ return inspectPackageExport({}, packageDir, entry).target;
984
+ }
985
+
986
+ /**
987
+ * The local module a doc's import names: the doc's own `import`, else the
988
+ * subpath its directory publishes, read through the package's `exports` (its
989
+ * `main` when it has none). Undefined when the package directory is unknown;
990
+ * null when the import names another package or resolves to no file.
991
+ *
992
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
993
+ * @param {string} docPath
994
+ * @param {Record<string, any> | null} doc
995
+ * @returns {string | null | undefined}
996
+ */
997
+ function importedModule(integration, docPath, doc) {
998
+ const packageName = integration?.name;
999
+ const packageDir = integration?.__packageDir;
1000
+ if (!packageName || !packageDir) return undefined;
1001
+ const exportsMap =
1002
+ integration.__packageExports === undefined
1003
+ ? readPackageExports(packageDir)
1004
+ : integration.__packageExports;
1005
+ const specifier = resolveIntegrationImportPath(
1006
+ {exportsMap, packageDir, docPath, packageName},
1007
+ docStem(docPath),
1008
+ typeof doc?.import === 'string' ? doc.import : null,
1009
+ );
1010
+ if (specifier !== packageName && !specifier.startsWith(`${packageName}/`)) {
1011
+ return null;
1012
+ }
1013
+ if (specifier === packageName && exportsMap == null) {
1014
+ return packageMain(packageDir);
1015
+ }
1016
+ const subpath =
1017
+ specifier === packageName
1018
+ ? '.'
1019
+ : `./${specifier.slice(packageName.length + 1)}`;
1020
+ return inspectPackageExport(
1021
+ {name: packageName, exports: exportsMap ?? undefined},
1022
+ packageDir,
1023
+ subpath,
1024
+ ).target;
1025
+ }
1026
+
1027
+ /**
1028
+ * Whether `file` sits in `folder` or below it.
1029
+ * @param {string} folder
1030
+ * @param {string} file
1031
+ */
1032
+ function isWithin(folder, file) {
1033
+ const relative = path.relative(folder, file);
1034
+ return (
1035
+ relative !== '' &&
1036
+ !path.isAbsolute(relative) &&
1037
+ relative.split(path.sep)[0] !== '..'
1038
+ );
1039
+ }
1040
+
1041
+ /**
1042
+ * The `.tsx` file in `folder`, or below it, that declares `name`, taking files
1043
+ * in path order: the source of a member whose import never reaches a `.tsx`
1044
+ * file, as when the import names built output.
1045
+ *
1046
+ * @param {string} folder
1047
+ * @param {string} name
1048
+ * @returns {string | null}
1049
+ */
1050
+ export function folderSourceFor(folder, name) {
1051
+ /** @type {string[]} */
1052
+ const files = [];
1053
+ /** @param {string} dirPath */
1054
+ const scan = dirPath => {
1055
+ for (const entry of listDirEntries(dirPath)) {
1056
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
1057
+ if (entry.name.startsWith('.')) continue;
1058
+ const fullPath = path.join(dirPath, entry.name);
1059
+ if (entry.isDirectory()) scan(fullPath);
1060
+ else if (
1061
+ entry.name.endsWith('.tsx') &&
1062
+ !/\.(?:test|spec|stories)\.tsx$/.test(entry.name)
1063
+ ) {
1064
+ files.push(fullPath);
1065
+ }
1066
+ }
1067
+ };
1068
+ scan(folder);
1069
+ for (const file of files.sort(byCodeUnit)) {
1070
+ const declared = moduleExportSource(file, name);
1071
+ if (
1072
+ declared != null &&
1073
+ declared.endsWith('.tsx') &&
1074
+ isWithin(folder, declared)
1075
+ ) {
1076
+ return declared;
1077
+ }
1078
+ }
1079
+ return null;
1080
+ }
1081
+
1082
+ /**
1083
+ * The full entries a family doc lists in `components` that the module its
1084
+ * import names exports, each with its source. An entry is full when it says
1085
+ * more than its `name`; the name must be static PascalCase, not hidden, and
1086
+ * neither the doc's stem nor the export the doc is recorded under. A member's
1087
+ * source is the `.tsx` file in the family doc's folder, or below it, that
1088
+ * declares it: the one the family's own source or the imported module reaches,
1089
+ * else the one in that folder that declares the name, as when the import names
1090
+ * built output. A member that only a bundle or a file outside the folder
1091
+ * declares has no source.
1092
+ *
1093
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1094
+ * @param {{docPath: string, sourcePath: string|null}} record the doc's own
1095
+ * @param {string | null | undefined} ownName the export the doc is recorded
1096
+ * under, when it names one
1097
+ * @param {string} [only] look for this member alone
1098
+ * @returns {Array<{name: string, sourcePath: string | null}>}
1099
+ */
1100
+ function familyMembers(integration, record, ownName, only) {
1101
+ const familySource = record.sourcePath;
1102
+ const doc = docObject(record.docPath);
1103
+ const entries = Array.isArray(doc?.components) ? doc.components : [];
1104
+ const hidden = new Set(
1105
+ Array.isArray(doc?.hiddenComponents) ? doc.hiddenComponents : [],
1106
+ );
1107
+ const stem = docStem(record.docPath);
1108
+ /** @type {string[]} */
1109
+ const names = [];
1110
+ for (const entry of entries) {
1111
+ if (entry == null || typeof entry !== 'object') continue;
1112
+ const name = entry.name;
1113
+ const full = Object.keys(entry).length > 1 || entry[HAS_COMPUTED] === true;
1114
+ if (typeof name !== 'string' || !full) continue;
1115
+ if (!COMPONENT_NAME_RE.test(name) || hidden.has(name)) continue;
1116
+ if (name === stem || name === ownName) continue;
1117
+ if (only != null && name !== only) continue;
1118
+ names.push(name);
1119
+ }
1120
+ if (names.length === 0) return [];
1121
+ const imported = importedModule(integration, record.docPath, doc);
1122
+ const module = imported === undefined ? familySource : imported;
1123
+ if (module == null) return [];
1124
+ const folder = path.dirname(record.docPath);
1125
+ /** @param {string | null} file */
1126
+ const inFolder = file =>
1127
+ file != null && file.endsWith('.tsx') && isWithin(folder, file);
1128
+ return names.flatMap(name => {
1129
+ const exported = moduleExportSource(module, name);
1130
+ if (exported == null) return [];
1131
+ // A member's source is a `.tsx` file in the family's folder, as a doc's own
1132
+ // is: never a bundle, even one under a family at the package root.
1133
+ const reached = [
1134
+ familySource == null ? null : moduleExportSource(familySource, name),
1135
+ exported,
1136
+ ].find(inFolder);
1137
+ return [{name, sourcePath: reached ?? folderSourceFor(folder, name)}];
1138
+ });
1139
+ }
1140
+
1141
+ /**
1142
+ * Each family doc under an integration's components root that names an
1143
+ * import, with the file the import resolves to: null when it resolves to none,
1144
+ * such as build output that does not exist yet.
1145
+ *
1146
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1147
+ * @returns {Array<{docPath: string, module: string | null}>}
1148
+ */
1149
+ export function familyImports(integration) {
1150
+ return discoverIntegrationComponents(integration).flatMap(record => {
1151
+ if (record.docPath == null || !isOwnRecord(record)) return [];
1152
+ const doc = docObject(record.docPath);
1153
+ if (!Array.isArray(doc?.components) || doc.components.length === 0) {
1154
+ return [];
1155
+ }
1156
+ const module = importedModule(integration, record.docPath, doc);
1157
+ return module === undefined ? [] : [{docPath: record.docPath, module}];
1158
+ });
1159
+ }
1160
+
1161
+ /**
1162
+ * Whether `record` is its doc's own component (under the doc's stem or the
1163
+ * export its `name` documents) rather than a member the doc lists.
1164
+ *
1165
+ * @param {{name: string, docPath: string|null}} record
1166
+ * @returns {boolean}
1167
+ */
1168
+ function isOwnRecord(record) {
1169
+ if (record.docPath == null) return true;
1170
+ const stem = docStem(record.docPath);
1171
+ if (stem === record.name) return true;
1172
+ return (
1173
+ otherExportName({
1174
+ docPath: record.docPath,
1175
+ sourcePath: integrationSourceForDoc(record.docPath),
1176
+ stem,
1177
+ names: [],
1178
+ }) === record.name
1179
+ );
1180
+ }
1181
+
1182
+ /**
1183
+ * Whether a discovered integration record is a member its family doc lists,
1184
+ * decided from the files, not from whether the doc loads.
1185
+ *
1186
+ * @param {{name: string, docPath: string|null}} record
1187
+ * @returns {boolean}
1188
+ */
1189
+ export function isIntegrationMember(record) {
1190
+ return !isOwnRecord(record);
1191
+ }
1192
+
1193
+ /**
1194
+ * The entry a family doc gives a member that discovery lists under its own
1195
+ * name, or null when the record is the doc's own component or the loaded doc
1196
+ * has no entry for it.
1197
+ *
1198
+ * @param {any} doc the doc loaded from `record.docPath`
1199
+ * @param {{name: string, docPath: string|null}} record
1200
+ * @returns {any}
1201
+ */
1202
+ export function integrationMemberEntry(doc, record) {
1203
+ if (doc == null || typeof doc !== 'object' || isOwnRecord(record)) {
1204
+ return null;
1205
+ }
1206
+ if (!Array.isArray(doc.components)) return null;
1207
+ return (
1208
+ doc.components.find(
1209
+ (/** @type {any} */ entry) => entry?.name === record.name,
1210
+ ) ?? null
1211
+ );
1212
+ }
1213
+
1214
+ /**
1215
+ * Visit every component doc under an integration's components root, hidden
1216
+ * ones included, in discovery order, with its stem.
1217
+ * @param {string | undefined} componentsDir
1218
+ * @param {(docPath: string, stem: string) => void} visit
1219
+ */
1220
+ export function forEachIntegrationDoc(componentsDir, visit) {
1221
+ if (!componentsDir || !pathExists(componentsDir)) return;
1222
+ /** @param {string} dirPath */
1223
+ function scanDir(dirPath) {
1224
+ for (const entry of listDirEntries(dirPath)) {
1225
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
1226
+ const fullPath = path.join(dirPath, entry.name);
1227
+ if (entry.isDirectory()) {
1228
+ scanDir(fullPath);
1229
+ continue;
1230
+ }
1231
+ const suffix = INTEGRATION_DOC_SUFFIXES.find(s => entry.name.endsWith(s));
1232
+ if (suffix) visit(fullPath, entry.name.slice(0, -suffix.length));
1233
+ }
1234
+ }
1235
+ scanDir(componentsDir);
1236
+ }
1237
+
635
1238
  /**
636
1239
  * Given an integration component doc path, return the sibling component source
637
1240
  * (`{Name}.tsx`) if one exists, else null.
@@ -639,7 +1242,7 @@ export function findExternalComponentDoc(docsDir, name) {
639
1242
  * @param {string} docPath absolute path to a `{Name}.doc.{ts,mjs,js}` file
640
1243
  * @returns {string|null}
641
1244
  */
642
- function integrationSourceForDoc(docPath) {
1245
+ export function integrationSourceForDoc(docPath) {
643
1246
  const dir = path.dirname(docPath);
644
1247
  const base = path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
645
1248
  const candidate = path.join(dir, `${base}.tsx`);
@@ -649,49 +1252,85 @@ function integrationSourceForDoc(docPath) {
649
1252
  /**
650
1253
  * Discover ownership records for the components contributed by a single loaded
651
1254
  * integration. Scans the integration's resolved `components` dir for same-stem
652
- * doc files and records each with owner package + issuesUrl + sourcePath.
1255
+ * doc files and records each with owner package + issuesUrl + sourcePath. A doc
1256
+ * that names a differently named export of its file is recorded under that
1257
+ * export, and each family member it lists, under the member's name, unless the
1258
+ * name is taken: every doc's file name (a hidden doc's too), a Core name a doc
1259
+ * in the package replaces, and a name an earlier doc records.
653
1260
  *
654
- * @param {{name: string, components?: string, issuesUrl?: string}} integration
1261
+ * @param {{name: string, components?: string, issuesUrl?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
655
1262
  * a single entry from `loadedIntegrations` (lib/integrations.mjs)
1263
+ * @param {{documentedNames?: boolean}} [options] `documentedNames: false`
1264
+ * records each doc under its file name alone, without reading the names
1265
+ * docs document: for a caller that needs only what each doc says
656
1266
  * @returns {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>}
657
1267
  */
658
- export function discoverIntegrationComponents(integration) {
1268
+ export function discoverIntegrationComponents(
1269
+ integration,
1270
+ {documentedNames = true} = {},
1271
+ ) {
659
1272
  const componentsDir = integration?.components;
660
1273
  if (!componentsDir || !pathExists(componentsDir)) return [];
661
1274
 
662
1275
  /** @type {Map<string, {name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>} */
663
1276
  const byName = new Map();
664
-
665
- /** @param {string} dirPath */
666
- function scanDir(dirPath) {
667
- const entries = listDirEntries(dirPath);
668
- for (const entry of entries) {
669
- if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
670
- const fullPath = path.join(dirPath, entry.name);
671
- if (entry.isDirectory()) {
672
- scanDir(fullPath);
673
- continue;
674
- }
675
- const suffix = INTEGRATION_DOC_SUFFIXES.find(s => entry.name.endsWith(s));
676
- if (!suffix) continue;
677
- const name = entry.name.slice(0, -suffix.length);
678
- const {group, hidden} = readDocMeta(fullPath);
679
- if (hidden) continue;
680
- // First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
681
- if (byName.has(name)) continue;
682
- byName.set(name, {
683
- name,
684
- package: integration.name,
685
- docPath: fullPath,
686
- sourcePath: integrationSourceForDoc(fullPath),
687
- issuesUrl: integration.issuesUrl,
688
- group: group ?? null,
689
- });
1277
+ /** @type {Map<string, {names: string[], hasMembers: boolean, replaces: string | null}>} stem → quick scan */
1278
+ const scans = new Map();
1279
+ /** @type {Set<string>} names no doc can record under another name */
1280
+ const taken = new Set();
1281
+
1282
+ forEachIntegrationDoc(componentsDir, (fullPath, name) => {
1283
+ taken.add(name);
1284
+ const {group, hidden, names, hasMembers, replaces} = readDocMeta(fullPath);
1285
+ if (hidden) return;
1286
+ // First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
1287
+ if (byName.has(name)) return;
1288
+ byName.set(name, {
1289
+ name,
1290
+ package: integration.name,
1291
+ docPath: fullPath,
1292
+ sourcePath: integrationSourceForDoc(fullPath),
1293
+ issuesUrl: integration.issuesUrl,
1294
+ group: group ?? null,
1295
+ });
1296
+ scans.set(name, {names, hasMembers, replaces});
1297
+ });
1298
+ if (!documentedNames) return [...byName.values()];
1299
+
1300
+ // A doc that names a differently named export of its file is listed under
1301
+ // that export, and each member a family doc lists under the member's name,
1302
+ // unless the name is taken.
1303
+ for (const {replaces} of scans.values()) if (replaces) taken.add(replaces);
1304
+ const records = [...byName.values()];
1305
+ const byPath = [...records].sort((a, b) => byCodeUnit(a.docPath, b.docPath));
1306
+ /** @type {Map<string, string>} stem → export name */
1307
+ const exportNames = new Map();
1308
+ for (const record of byPath) {
1309
+ const exportName = otherExportName({
1310
+ ...record,
1311
+ stem: record.name,
1312
+ names: scans.get(record.name)?.names ?? [],
1313
+ });
1314
+ if (exportName == null || taken.has(exportName)) continue;
1315
+ taken.add(exportName);
1316
+ exportNames.set(record.name, exportName);
1317
+ }
1318
+ /** @type {typeof records} */
1319
+ const members = [];
1320
+ for (const record of byPath) {
1321
+ if (!scans.get(record.name)?.hasMembers) continue;
1322
+ const ownName = exportNames.get(record.name);
1323
+ for (const member of familyMembers(integration, record, ownName)) {
1324
+ if (taken.has(member.name)) continue;
1325
+ taken.add(member.name);
1326
+ members.push({...record, ...member});
690
1327
  }
691
1328
  }
692
-
693
- scanDir(componentsDir);
694
- return [...byName.values()];
1329
+ const named = records.map(record => {
1330
+ const name = exportNames.get(record.name);
1331
+ return name ? {...record, name} : record;
1332
+ });
1333
+ return [...named, ...members];
695
1334
  }
696
1335
 
697
1336
  /**
@@ -699,24 +1338,33 @@ export function discoverIntegrationComponents(integration) {
699
1338
  * Invalid metadata or a missing same-stem source removes only that component;
700
1339
  * callers can report the returned errors while retaining valid siblings. A
701
1340
  * component whose doc sets `replaces` carries the value as written; the
702
- * component replacement resolver decides what it means.
1341
+ * component replacement resolver decides what it means. A family member is
1342
+ * read through its family's doc: it never carries the family's `replaces`, and
1343
+ * a family doc that fails to load is reported once. A doc that fails to load
1344
+ * is reported under its file name: what it says cannot be trusted.
703
1345
  *
704
1346
  * @param {{name: string, components?: string, issuesUrl?: string}} integration
1347
+ * @param {{documentedNames?: boolean}} [options] as for
1348
+ * {@link discoverIntegrationComponents}
705
1349
  * @returns {Promise<{
706
- * components: Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
1350
+ * components: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
707
1351
  * discovered: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>,
708
1352
  * errors: Array<{name: string, message: string}>,
709
1353
  * }>}
710
1354
  */
711
- export async function discoverValidIntegrationComponents(integration) {
712
- const discovered = discoverIntegrationComponents(integration);
713
- /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
1355
+ export async function discoverValidIntegrationComponents(integration, options) {
1356
+ const discovered = discoverIntegrationComponents(integration, options);
1357
+ /** @type {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
714
1358
  const components = [];
715
1359
  /** @type {Array<{name: string, message: string}>} */
716
1360
  const errors = [];
1361
+ /** @type {Set<string>} docs that failed to load */
1362
+ const failedDocs = new Set();
717
1363
 
718
1364
  for (const record of discovered) {
719
- if (record.sourcePath == null) {
1365
+ // A doc's own component needs its same-stem source. A member has none when
1366
+ // only a bundle or a file outside its family's folder declares it.
1367
+ if (record.sourcePath == null && isOwnRecord(record)) {
720
1368
  errors.push({
721
1369
  name: record.name,
722
1370
  message: `Component "${record.name}" is missing its same-stem source file ${record.name}.tsx.`,
@@ -726,18 +1374,24 @@ export async function discoverValidIntegrationComponents(integration) {
726
1374
  try {
727
1375
  const doc = await loadComponentDoc(record.docPath);
728
1376
  const valid =
729
- /** @type {{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null}} */ (
1377
+ /** @type {{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}} */ (
730
1378
  record
731
1379
  );
732
1380
  components.push(
733
- doc != null && typeof doc === 'object' && doc.replaces !== undefined
1381
+ isOwnRecord(record) &&
1382
+ doc != null &&
1383
+ typeof doc === 'object' &&
1384
+ doc.replaces !== undefined
734
1385
  ? {...valid, replaces: doc.replaces}
735
1386
  : valid,
736
1387
  );
737
1388
  } catch (err) {
1389
+ if (failedDocs.has(record.docPath)) continue;
1390
+ failedDocs.add(record.docPath);
1391
+ const name = docStem(record.docPath);
738
1392
  errors.push({
739
- name: record.name,
740
- message: `Component "${record.name}" has invalid metadata: ${
1393
+ name,
1394
+ message: `Component "${name}" has invalid metadata: ${
741
1395
  err instanceof Error ? err.message : String(err)
742
1396
  }`,
743
1397
  });
@@ -747,19 +1401,158 @@ export async function discoverValidIntegrationComponents(integration) {
747
1401
  return {components, discovered, errors};
748
1402
  }
749
1403
 
1404
+ /**
1405
+ * How an owner holds a component name, which the owner rule
1406
+ * ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
1407
+ * `file` when an integration doc named after its file is recorded under it;
1408
+ * `doc` when only a doc records it: an export name, a member, or the file name
1409
+ * of a doc recorded under another export.
1410
+ * @typedef {'core' | 'file' | 'doc'} NameHold
1411
+ */
1412
+
1413
+ /**
1414
+ * How a discovered integration record holds its name.
1415
+ * @param {{name: string, docPath: string|null}} record
1416
+ * @returns {'file' | 'doc'}
1417
+ */
1418
+ export function integrationRecordHold(record) {
1419
+ return record.docPath == null || docStem(record.docPath) === record.name
1420
+ ? 'file'
1421
+ : 'doc';
1422
+ }
1423
+
1424
+ /**
1425
+ * The doc that records `name` as its export or a member though its file is
1426
+ * named otherwise, with the source, reading only docs that write the name.
1427
+ *
1428
+ * @param {{name?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1429
+ * @param {string} componentsDir
1430
+ * @param {string} name
1431
+ * @returns {{docPath: string, sourcePath: string|null} | null}
1432
+ */
1433
+ function findDocumentedComponent(integration, componentsDir, name) {
1434
+ if (!COMPONENT_NAME_RE.test(name)) return null;
1435
+ const literals = [`'${name}'`, `"${name}"`, `\`${name}\``];
1436
+ /** @type {Set<string>} */
1437
+ const stems = new Set();
1438
+ /** @type {Array<{docPath: string, stem: string, names: string[], hasMembers: boolean, replaces: string | null, sourcePath: string|null}>} */
1439
+ const candidates = [];
1440
+ forEachIntegrationDoc(componentsDir, (docPath, stem) => {
1441
+ let text;
1442
+ try {
1443
+ text = fs.readFileSync(docPath, 'utf-8');
1444
+ } catch {
1445
+ return;
1446
+ }
1447
+ // The docs discovery records: the first visible doc for each stem.
1448
+ if (HIDDEN_RE.test(text) || stems.has(stem)) return;
1449
+ stems.add(stem);
1450
+ if (!literals.some(literal => text.includes(literal))) return;
1451
+ candidates.push({
1452
+ docPath,
1453
+ stem,
1454
+ ...scanDocText(text),
1455
+ sourcePath: integrationSourceForDoc(docPath),
1456
+ });
1457
+ });
1458
+ // A Core name a doc in the package replaces is the replacement's to answer.
1459
+ if (candidates.some(candidate => candidate.replaces === name)) return null;
1460
+ candidates.sort((a, b) => byCodeUnit(a.docPath, b.docPath));
1461
+ for (const candidate of candidates) {
1462
+ if (otherExportName(candidate) === name) {
1463
+ return {docPath: candidate.docPath, sourcePath: candidate.sourcePath};
1464
+ }
1465
+ }
1466
+ for (const candidate of candidates) {
1467
+ if (!candidate.hasMembers) continue;
1468
+ const ownName = otherExportName(candidate);
1469
+ const [member] = familyMembers(integration, candidate, ownName, name);
1470
+ if (member) {
1471
+ return {docPath: candidate.docPath, sourcePath: member.sourcePath};
1472
+ }
1473
+ }
1474
+ return null;
1475
+ }
1476
+
1477
+ /**
1478
+ * The integration component a name finds, as discovery records it, and its
1479
+ * hold: the same-stem doc first, then a doc that records the name as an export
1480
+ * or a member.
1481
+ *
1482
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1483
+ * @param {string} name
1484
+ * @returns {{docPath: string, sourcePath: string|null, hold: 'file' | 'doc'} | null}
1485
+ */
1486
+ export function findIntegrationComponent(integration, name) {
1487
+ const componentsDir = integration?.components;
1488
+ if (!componentsDir || !pathExists(componentsDir)) return null;
1489
+ const sameStem = findSameStemDoc(componentsDir, name);
1490
+ if (sameStem) {
1491
+ const exportName = otherExportName({
1492
+ docPath: sameStem,
1493
+ sourcePath: integrationSourceForDoc(sameStem),
1494
+ stem: name,
1495
+ names: readDocMeta(sameStem).names,
1496
+ });
1497
+ const renamed =
1498
+ exportName != null &&
1499
+ findSameStemDoc(componentsDir, exportName) == null &&
1500
+ findDocumentedComponent(integration, componentsDir, exportName)
1501
+ ?.docPath === sameStem;
1502
+ return {
1503
+ docPath: sameStem,
1504
+ sourcePath: integrationSourceForDoc(sameStem),
1505
+ hold: renamed ? 'doc' : 'file',
1506
+ };
1507
+ }
1508
+ const found = findDocumentedComponent(integration, componentsDir, name);
1509
+ return found && {...found, hold: 'doc'};
1510
+ }
1511
+
1512
+ /**
1513
+ * The name discovery records a component doc under: the export its `name`
1514
+ * documents when the doc is recorded under it, else the doc's file name.
1515
+ *
1516
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
1517
+ * @param {string} docPath
1518
+ * @returns {string}
1519
+ */
1520
+ export function recordedDocName(integration, docPath) {
1521
+ const stem = docStem(docPath);
1522
+ const found = findIntegrationComponent(integration, stem);
1523
+ if (found?.docPath !== docPath || found.hold !== 'doc') return stem;
1524
+ return (
1525
+ otherExportName({
1526
+ docPath,
1527
+ sourcePath: integrationSourceForDoc(docPath),
1528
+ stem,
1529
+ names: readDocMeta(docPath).names,
1530
+ }) ?? stem
1531
+ );
1532
+ }
1533
+
750
1534
  /**
751
1535
  * Find an integration component's doc file by name within a loaded
752
1536
  * integration's resolved `components` dir. Honors the same-stem convention
753
- * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`.
1537
+ * (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`, and finds a
1538
+ * doc that records the component under another name: a differently named
1539
+ * export of its file, or a member its family doc lists.
754
1540
  *
755
- * @param {{components?: string}} integration
1541
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
756
1542
  * @param {string} name bare component name (no `XDS`/`Astryx` prefix)
757
1543
  * @returns {string|null}
758
1544
  */
759
1545
  export function findIntegrationComponentDoc(integration, name) {
760
- const componentsDir = integration?.components;
761
- if (!componentsDir || !pathExists(componentsDir)) return null;
1546
+ return findIntegrationComponent(integration, name)?.docPath ?? null;
1547
+ }
762
1548
 
1549
+ /**
1550
+ * The first `{Name}.doc.{ts,mjs,js}` under `componentsDir`, shallowest first.
1551
+ * @param {string} componentsDir
1552
+ * @param {string} name
1553
+ * @returns {string|null}
1554
+ */
1555
+ function findSameStemDoc(componentsDir, name) {
763
1556
  /**
764
1557
  * @param {string} dirPath
765
1558
  * @returns {string | null}
@@ -785,18 +1578,33 @@ export function findIntegrationComponentDoc(integration, name) {
785
1578
  }
786
1579
 
787
1580
  /**
788
- * Find an integration component's swizzleable source file (`{Name}.tsx`) by
789
- * name within a loaded integration's resolved `components` dir. Returns null
1581
+ * Find an integration component's swizzleable source file by name within a
1582
+ * loaded integration's resolved `components` dir: the same-stem `{Name}.tsx`
1583
+ * of its doc, or, for a family member, the file that declares it. Returns null
790
1584
  * when the integration ships docs without source.
791
1585
  *
792
- * @param {{components?: string}} integration
1586
+ * @param {{name?: string, components?: string, __packageDir?: string, __packageExports?: Record<string, unknown>|null}} integration
793
1587
  * @param {string} name bare component name
794
1588
  * @returns {string|null}
795
1589
  */
796
1590
  export function findIntegrationComponentSource(integration, name) {
797
- const docPath = findIntegrationComponentDoc(integration, name);
798
- if (!docPath) return null;
799
- return integrationSourceForDoc(docPath);
1591
+ return findIntegrationComponent(integration, name)?.sourcePath ?? null;
1592
+ }
1593
+
1594
+ /**
1595
+ * The owner rule: an owner that holds a name only because a doc records it
1596
+ * (`hold: 'doc'`) yields to Core and to every file-named owner; `--package`
1597
+ * still reaches it. Surfaces resolve a replacement first, then this rule, then
1598
+ * which docs load.
1599
+ *
1600
+ * @template {{hold?: string}} T
1601
+ * @param {T[]} owners each with its {@link NameHold}; an owner without one
1602
+ * counts as Core's or file-named
1603
+ * @returns {T[]}
1604
+ */
1605
+ export function unscopedComponentOwners(owners) {
1606
+ const primary = owners.filter(owner => owner.hold !== 'doc');
1607
+ return primary.length > 0 ? primary : owners;
800
1608
  }
801
1609
 
802
1610
  /**