@astryxdesign/cli 0.7.2-canary.d65096c → 0.7.2-canary.e2909cf

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.
@@ -5,12 +5,15 @@
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';
9
10
  import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
10
11
  import {existsCaseExact} from '../fs/paths.mjs';
11
12
  import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
12
13
  import {loadComponentDoc} from './component-loader.mjs';
13
14
 
15
+ const require = createRequire(import.meta.url);
16
+
14
17
  const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
15
18
 
16
19
  /** The owner package name for built-in (core) components. */
@@ -109,8 +112,193 @@ function readDocMeta(docPath) {
109
112
  }
110
113
  }
111
114
 
115
+ /** @type {typeof import('@babel/parser') | undefined} */
116
+ let babelParser;
117
+
118
+ /**
119
+ * A property of an object literal, by its plain key.
120
+ * @param {any} object - an ObjectExpression node
121
+ * @param {string} key
122
+ * @returns {any}
123
+ */
124
+ function propertyValue(object, key) {
125
+ for (const property of object.properties) {
126
+ if (property.type !== 'ObjectProperty' || property.computed) continue;
127
+ const name =
128
+ property.key.type === 'Identifier'
129
+ ? property.key.name
130
+ : property.key.type === 'StringLiteral'
131
+ ? property.key.value
132
+ : null;
133
+ if (name === key) return property.value;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * The names a component doc lists as `components` members, read from its
140
+ * source without running it. Only a static `{name: '<Name>'}` entry in the doc
141
+ * object the loader reads (the `default` export, else `docs`) counts: a name in
142
+ * prose, an example, or `relatedComponents` is not a member.
143
+ * @param {string} source - the doc file's text
144
+ * @returns {string[]}
145
+ */
146
+ function docMemberNames(source) {
147
+ /** @type {any} */
148
+ let ast;
149
+ try {
150
+ babelParser ??= require('@babel/parser');
151
+ ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(source, {
152
+ sourceType: 'module',
153
+ });
154
+ } catch {
155
+ return [];
156
+ }
157
+ /** @type {Map<string, any>} */
158
+ const constants = new Map();
159
+ let defaultExport = null;
160
+ let docsExport = null;
161
+ for (const node of ast.program.body) {
162
+ const declaration =
163
+ node.type === 'ExportNamedDeclaration' ? node.declaration : node;
164
+ if (declaration?.type === 'VariableDeclaration') {
165
+ for (const declarator of declaration.declarations) {
166
+ if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
167
+ constants.set(declarator.id.name, declarator.init);
168
+ if (node.type === 'ExportNamedDeclaration' && declarator.id.name === 'docs') {
169
+ docsExport = declarator.init;
170
+ }
171
+ }
172
+ }
173
+ if (node.type === 'ExportDefaultDeclaration') defaultExport = node.declaration;
174
+ }
175
+ let doc = defaultExport ?? docsExport;
176
+ if (doc?.type === 'Identifier') doc = constants.get(doc.name);
177
+ if (doc?.type !== 'ObjectExpression') return [];
178
+ const members = propertyValue(doc, 'components');
179
+ if (members?.type !== 'ArrayExpression') return [];
180
+ return members.elements.flatMap((/** @type {any} */ element) => {
181
+ if (element?.type !== 'ObjectExpression') return [];
182
+ const name = propertyValue(element, 'name');
183
+ return name?.type === 'StringLiteral' ? [name.value] : [];
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Whether `name` has its own component folder in Core: `src/<Name>/<Name>.tsx`
189
+ * (or the `XDS`-prefixed form).
190
+ * @param {string} srcDir
191
+ * @param {string} name
192
+ * @returns {boolean}
193
+ */
194
+ function hasOwnFolder(srcDir, name) {
195
+ return [`${name}.tsx`, `XDS${name}.tsx`].some(file =>
196
+ existsCaseExact(path.join(srcDir, name, file), srcDir),
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Member names read in this process, by doc path, reused while the file keeps
202
+ * its size and mtime.
203
+ * @type {Map<string, {size: number, mtimeMs: number, members: string[]}>}
204
+ */
205
+ const memberReads = new Map();
206
+
207
+ /**
208
+ * The names a doc lists as `components` members (see docMemberNames).
209
+ * @param {string} docPath
210
+ * @returns {string[]}
211
+ */
212
+ function readDocMembers(docPath) {
213
+ let stat;
214
+ try {
215
+ stat = fs.statSync(docPath);
216
+ } catch {
217
+ return [];
218
+ }
219
+ const cached = memberReads.get(docPath);
220
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
221
+ return cached.members;
222
+ }
223
+ /** @type {string[]} */
224
+ let members;
225
+ try {
226
+ members = docMemberNames(fs.readFileSync(docPath, 'utf-8'));
227
+ } catch {
228
+ members = [];
229
+ }
230
+ memberReads.set(docPath, {size: stat.size, mtimeMs: stat.mtimeMs, members});
231
+ return members;
232
+ }
233
+
234
+ // A sibling component folder named by a relative import or re-export.
235
+ const SIBLING_IMPORT_RE =
236
+ /(?:\bfrom|\bimport\s*\()\s*['"]\.\.\/([A-Za-z0-9_-]+)(?:\/[^'"]*)?['"]/g;
237
+
238
+ /**
239
+ * The Core doc that documents `name` as one of its `components` members: the
240
+ * doc of a component it builds on. HStack and VStack import Stack, and Stack's
241
+ * doc lists them, so each has its own folder and import path but no doc of its
242
+ * own. Only the folders the component's own source imports from are read.
243
+ * @param {string} srcDir
244
+ * @param {string} name
245
+ * @returns {string | null}
246
+ */
247
+ function findFamilyDoc(srcDir, name) {
248
+ const sourcePath = [`${name}.tsx`, `XDS${name}.tsx`]
249
+ .map(file => path.join(srcDir, name, file))
250
+ .find(file => existsCaseExact(file, srcDir));
251
+ if (!sourcePath) return null;
252
+ let source;
253
+ try {
254
+ source = fs.readFileSync(sourcePath, 'utf-8');
255
+ } catch {
256
+ return null;
257
+ }
258
+ const families = [
259
+ ...new Set([...source.matchAll(SIBLING_IMPORT_RE)].map(match => match[1])),
260
+ ]
261
+ .filter(dir => dir !== name && !SKIP_DIRS.has(dir))
262
+ .sort();
263
+ for (const dir of families) {
264
+ const dirPath = path.join(srcDir, dir);
265
+ if (!existsCaseExact(dirPath, srcDir)) continue;
266
+ for (const file of [...listDir(dirPath)].sort()) {
267
+ if (!file.endsWith('.doc.mjs')) continue;
268
+ const docPath = path.join(dirPath, file);
269
+ const members = readDocMembers(docPath);
270
+ if (members.includes(name) || members.includes(`XDS${name}`)) {
271
+ return docPath;
272
+ }
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+
278
+ /**
279
+ * The entry a family doc gives `name` in its `components`, when the doc is not
280
+ * the component's own (Stack's doc for VStack); null otherwise.
281
+ * @param {any} docs - a loaded component doc
282
+ * @param {string} name - bare component name
283
+ * @returns {any}
284
+ */
285
+ export function familyMember(docs, name) {
286
+ if (!docs || docs.name === name || docs.name === `XDS${name}`) return null;
287
+ const members = Array.isArray(docs.components) ? docs.components : [];
288
+ return (
289
+ members.find(
290
+ (/** @type {any} */ member) =>
291
+ member?.name === name || member?.name === `XDS${name}`,
292
+ ) ?? null
293
+ );
294
+ }
295
+
112
296
  /**
113
297
  * Auto-discover components by scanning for Astryx*.tsx files in core/src/.
298
+ * A component is listed when a doc documents it: a doc in its folder, or, for a
299
+ * component folder with no doc of its own, the doc `findComponentReadme` finds
300
+ * elsewhere (Heading's doc in the Text folder; HStack and VStack as members of
301
+ * Stack's doc).
114
302
  *
115
303
  * Returns an ordered Record where:
116
304
  * - Grouped components use the group name as key: `'Buttons': ['Button', 'IconButton']`
@@ -192,6 +380,25 @@ export function discoverComponents(coreDir) {
192
380
  }
193
381
  }
194
382
 
383
+ // A component folder with no doc in it is still listed when a doc elsewhere
384
+ // documents it, the doc `component <Name>` opens: its own same-stem doc in a
385
+ // family folder (Text/Heading.doc.mjs), or a member entry in the family's doc
386
+ // (HStack and VStack in Stack's doc).
387
+ for (const entry of topEntries) {
388
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
389
+ const name = entry.name;
390
+ if (componentGroups.has(name) || name.endsWith('Context')) continue;
391
+ if (listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))) {
392
+ continue;
393
+ }
394
+ if (!hasOwnFolder(srcDir, name)) continue;
395
+ const docPath = findComponentReadme(coreDir, name);
396
+ if (!docPath?.endsWith('.doc.mjs')) continue;
397
+ const meta = readDocMeta(docPath);
398
+ if (meta.hidden || meta.hiddenComponents.has(name)) continue;
399
+ componentGroups.set(name, meta.group);
400
+ }
401
+
195
402
  // Build the result: group name → sorted members, or component name → [self]
196
403
  /** @type {Map<string, string[]>} */
197
404
  const groups = new Map();
@@ -284,6 +491,10 @@ export function findComponentReadme(coreDir, name) {
284
491
  }
285
492
  }
286
493
 
494
+ // A component with its own folder, documented as a member of its family's
495
+ // doc (HStack and VStack in Stack's doc).
496
+ if (hasOwnFolder(srcDir, name)) return findFamilyDoc(srcDir, name);
497
+
287
498
  return null;
288
499
  }
289
500
 
@@ -16,6 +16,7 @@ import {fileURLToPath} from 'node:url';
16
16
  import {
17
17
  CORE_PACKAGE,
18
18
  discoverComponents,
19
+ familyMember,
19
20
  findComponentReadme,
20
21
  findComponentSource,
21
22
  resolveImportPath,
@@ -121,6 +122,91 @@ describe('discoverComponents (real core)', () => {
121
122
  expect(discoverComponents(core)).toEqual({});
122
123
  });
123
124
 
125
+ it('lists HStack, VStack and Heading, whose docs live in their family folders', () => {
126
+ // Each has its own folder and import path; Stack's doc documents HStack and
127
+ // VStack as members, and Heading's own doc sits in the Text folder.
128
+ const comps = discoverComponents(CORE);
129
+ expect(comps.Layout).toEqual(expect.arrayContaining(['HStack', 'Stack', 'VStack']));
130
+ expect(comps.Heading).toEqual(['Heading']);
131
+ expect(findComponentReadme(CORE, 'VStack')).toMatch(/[\\/]Stack[\\/]Stack\.doc\.mjs$/);
132
+ expect(findComponentReadme(CORE, 'HStack')).toMatch(/[\\/]Stack[\\/]Stack\.doc\.mjs$/);
133
+ expect(findComponentReadme(CORE, 'Heading')).toMatch(/[\\/]Text[\\/]Heading\.doc\.mjs$/);
134
+ expect(resolveImportPath(CORE, 'VStack')).toBe('@astryxdesign/core/VStack');
135
+ expect(resolveImportPath(CORE, 'Heading')).toBe('@astryxdesign/core/Heading');
136
+ }, SLOW);
137
+
138
+ it('lists a component folder with no doc when its family doc lists it as a member', () => {
139
+ const core = mkTmp('as-cd-family-');
140
+ const write = (rel, text) => {
141
+ fs.mkdirSync(path.dirname(path.join(core, 'src', rel)), {recursive: true});
142
+ fs.writeFileSync(path.join(core, 'src', rel), text);
143
+ };
144
+ write('Stack/Stack.tsx', 'x');
145
+ write('VStack/VStack.tsx', "import {Stack} from '../Stack/Stack';\n");
146
+ write('Prose/Prose.tsx', "import {Stack} from '../Stack/Stack';\n");
147
+ // Listed by Stack's doc, but builds on nothing in Stack's folder.
148
+ write('Orphan/Orphan.tsx', "import {x} from '../utils';\n");
149
+ write(
150
+ 'Stack/Stack.doc.mjs',
151
+ 'export const docs = {\n' +
152
+ " name: 'Stack',\n" +
153
+ " group: 'Layout',\n" +
154
+ " relatedComponents: ['Prose'],\n" +
155
+ " usage: {description: 'Pair with Prose.', examples: [{code: \"<Prose label='Prose' />\"}]},\n" +
156
+ " components: [{name: 'Stack'}, {name: 'VStack', description: 'Vertical.'}, {name: 'Orphan'}],\n" +
157
+ '};\n',
158
+ );
159
+ expect(discoverComponents(core)).toEqual({Layout: ['Stack', 'VStack']});
160
+ expect(findComponentReadme(core, 'VStack')).toBe(
161
+ path.join(core, 'src', 'Stack', 'Stack.doc.mjs'),
162
+ );
163
+ // Named only in relatedComponents, prose, and an example: not documented.
164
+ expect(findComponentReadme(core, 'Prose')).toBeNull();
165
+ // A family doc is read only from the folders the component imports.
166
+ expect(findComponentReadme(core, 'Orphan')).toBeNull();
167
+ });
168
+
169
+ it('lists a component folder with no doc when its own doc sits in a family folder', () => {
170
+ const core = mkTmp('as-cd-sibling-');
171
+ for (const dir of ['Text', 'Heading']) {
172
+ fs.mkdirSync(path.join(core, 'src', dir), {recursive: true});
173
+ }
174
+ fs.writeFileSync(path.join(core, 'src', 'Text', 'Text.tsx'), 'x');
175
+ fs.writeFileSync(path.join(core, 'src', 'Heading', 'Heading.tsx'), 'x');
176
+ fs.writeFileSync(
177
+ path.join(core, 'src', 'Text', 'Text.doc.mjs'),
178
+ "export const docs = {name: 'Text', components: [{name: 'Heading'}]};\n",
179
+ );
180
+ fs.writeFileSync(
181
+ path.join(core, 'src', 'Text', 'Heading.doc.mjs'),
182
+ "export const docs = {name: 'Heading'};\n",
183
+ );
184
+ expect(discoverComponents(core)).toEqual({Heading: ['Heading'], Text: ['Text']});
185
+ expect(findComponentReadme(core, 'Heading')).toBe(
186
+ path.join(core, 'src', 'Text', 'Heading.doc.mjs'),
187
+ );
188
+ });
189
+
190
+ it('keeps a member its family hides, and a context folder, unlisted', () => {
191
+ const core = mkTmp('as-cd-family-hidden-');
192
+ for (const dir of ['Stack', 'VStack', 'SizeContext']) {
193
+ fs.mkdirSync(path.join(core, 'src', dir), {recursive: true});
194
+ fs.writeFileSync(
195
+ path.join(core, 'src', dir, `${dir}.tsx`),
196
+ "import {Stack} from '../Stack/Stack';\n",
197
+ );
198
+ }
199
+ fs.writeFileSync(
200
+ path.join(core, 'src', 'Stack', 'Stack.doc.mjs'),
201
+ 'export const docs = {\n' +
202
+ " name: 'Stack',\n" +
203
+ " hiddenComponents: ['VStack'],\n" +
204
+ " components: [{name: 'Stack'}, {name: 'VStack'}, {name: 'SizeContext'}],\n" +
205
+ '};\n',
206
+ );
207
+ expect(discoverComponents(core)).toEqual({Stack: ['Stack']});
208
+ });
209
+
124
210
  it('returns {} for an empty src/ dir', () => {
125
211
  const core = mkTmp('as-cd-emptysrc-');
126
212
  fs.mkdirSync(path.join(core, 'src'));
@@ -133,6 +219,16 @@ describe('discoverComponents (real core)', () => {
133
219
  });
134
220
  });
135
221
 
222
+ describe('familyMember', () => {
223
+ it("returns a family doc's entry for a member, and null for a component's own doc", () => {
224
+ const stack = {name: 'Stack', components: [{name: 'Stack'}, {name: 'VStack', description: 'v'}]};
225
+ expect(familyMember(stack, 'VStack')).toEqual({name: 'VStack', description: 'v'});
226
+ expect(familyMember(stack, 'Stack')).toBeNull();
227
+ expect(familyMember({name: 'Button'}, 'Button')).toBeNull();
228
+ expect(familyMember(stack, 'HStack')).toBeNull();
229
+ });
230
+ });
231
+
136
232
  describe('findComponentReadme / findComponentSource / resolveImportPath', () => {
137
233
  it('finds the doc + source for a top-level component', () => {
138
234
  expect(findComponentReadme(CORE, 'Button')).toMatch(/Button\.doc\.mjs$/);
@@ -15,7 +15,8 @@
15
15
  * needs it: `Project` assembles templates from it. While this sat in api/, that
16
16
  * made a cycle across the layer boundary — Project reached up for discovery and
17
17
  * this module imported Project back. Nothing here depends on api/; every import
18
- * is node:*, jiti, foundation/*, or authoring/*.
18
+ * is node:*, jiti, foundation/*, or authoring/*, plus a lazily required
19
+ * @babel/parser for extractComponents' local-declaration scan.
19
20
  *
20
21
  * @position packages/cli/foundation/discovery — shared template discovery/IO;
21
22
  * consumed by Project and by the api/template leaves.
@@ -512,6 +513,19 @@ function expressionUse(source, open, close) {
512
513
  return null;
513
514
  }
514
515
 
516
+ /** @type {typeof import('@babel/parser') | undefined} */
517
+ let babelParser;
518
+
519
+ /**
520
+ * The template source parser, loaded on first use so listing templates
521
+ * never pays for it.
522
+ * @returns {typeof import('@babel/parser')}
523
+ */
524
+ function templateSourceParser() {
525
+ babelParser ??= require('@babel/parser');
526
+ return /** @type {typeof import('@babel/parser')} */ (babelParser);
527
+ }
528
+
515
529
  /**
516
530
  * The [start, end) range of every comment in `source`, or none when it does
517
531
  * not parse as TSX.
@@ -520,8 +534,7 @@ function expressionUse(source, open, close) {
520
534
  */
521
535
  function commentRanges(source) {
522
536
  try {
523
- const {parse} = require('@babel/parser');
524
- const {comments} = parse(source, {
537
+ const {comments} = templateSourceParser().parse(source, {
525
538
  sourceType: 'module',
526
539
  plugins: ['jsx', 'typescript'],
527
540
  });
@@ -1267,7 +1280,8 @@ export async function discoverIntegrationTemplatesForOne(integration) {
1267
1280
  template: id,
1268
1281
  message: `Template "${id}" is missing a "type" of "page" or "block". Stamp the default export with type: 'page' or type: 'block'.`,
1269
1282
  });
1270
- const declared = doc?.replaces ?? (await declaredReplacement(docPath, id));
1283
+ const declared =
1284
+ doc?.replaces ?? (await declaredReplacement(docPath, id));
1271
1285
  if (declared != null) {
1272
1286
  errors.push({
1273
1287
  code: 'invalid_template_replacement',
@@ -1423,6 +1437,51 @@ const UBIQUITOUS = new Set([
1423
1437
  'Icon',
1424
1438
  ]);
1425
1439
 
1440
+ /**
1441
+ * Names bound by top-level `function`, `class`, `const`, `let` or `var`
1442
+ * declarations (exported or not). Returns an empty set when the source does
1443
+ * not parse, so a parse failure never drops a real component.
1444
+ *
1445
+ * @param {string} src
1446
+ * @returns {Set<string>}
1447
+ */
1448
+ function topLevelBindingNames(src) {
1449
+ /** @type {Set<string>} */
1450
+ const names = new Set();
1451
+ let program;
1452
+ try {
1453
+ program = templateSourceParser().parse(src, {
1454
+ sourceType: 'module',
1455
+ plugins: ['jsx', 'typescript'],
1456
+ errorRecovery: true,
1457
+ }).program;
1458
+ } catch {
1459
+ return names;
1460
+ }
1461
+ for (const statement of program.body) {
1462
+ const node =
1463
+ statement.type === 'ExportNamedDeclaration' ||
1464
+ statement.type === 'ExportDefaultDeclaration'
1465
+ ? statement.declaration
1466
+ : statement;
1467
+ if (node == null) continue;
1468
+ if (
1469
+ (node.type === 'FunctionDeclaration' ||
1470
+ node.type === 'ClassDeclaration') &&
1471
+ node.id
1472
+ ) {
1473
+ names.add(node.id.name);
1474
+ } else if (node.type === 'VariableDeclaration') {
1475
+ for (const declarator of node.declarations) {
1476
+ if (declarator.id.type === 'Identifier') {
1477
+ names.add(declarator.id.name);
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+ return names;
1483
+ }
1484
+
1426
1485
  /**
1427
1486
  * @param {string} pagePath
1428
1487
  * @returns {string[]}
@@ -1449,14 +1508,24 @@ export function extractComponents(pagePath) {
1449
1508
  while ((m = tagRegex.exec(src)) !== null) {
1450
1509
  matches.push(m[2]);
1451
1510
  }
1511
+
1512
+ // Exclude names bound by a top-level declaration in the same file: those are
1513
+ // page-local helpers (e.g. `function TimelineSection()`), not components a
1514
+ // builder can import. Read real bindings from the syntax tree so comments,
1515
+ // strings and type annotations cannot add or hide a name.
1516
+ const localDeclNames = topLevelBindingNames(src);
1517
+
1452
1518
  return [
1453
1519
  ...new Set(
1454
1520
  matches
1521
+ .filter(n => !localDeclNames.has(n))
1455
1522
  .filter(n => !['Theme', 'ThemeProvider'].includes(n))
1456
1523
  .filter(n => !UBIQUITOUS.has(n))
1457
1524
  .map(n =>
1458
1525
  n.replace(
1459
- /(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/,
1526
+ // Strip only when a stem remains, so a bare `<Section>` keeps its
1527
+ // own name instead of emptying out.
1528
+ /(?<=.)(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/,
1460
1529
  '',
1461
1530
  ),
1462
1531
  )
@@ -246,12 +246,16 @@ const RUNNERS = {
246
246
  runs: 'astryx.config, through the config parser',
247
247
  sites: ['loadModuleWithParser(..., parseConfig)'],
248
248
  },
249
+ 'foundation/discovery/component-discovery.mjs': {
250
+ runs: '@babel/parser, on first use, to read the members a family doc lists in its components without running the doc',
251
+ sites: ['require(@babel/parser)'],
252
+ },
249
253
  'foundation/discovery/docs-discovery.mjs': {
250
254
  runs: 'a contributed topic, for its catalog fields: name, title, description, category, and relationships',
251
255
  sites: ['importDocModule'],
252
256
  },
253
257
  'foundation/discovery/template-adapter.mjs': {
254
- runs: '@babel/parser, to find the fixture references in template source; and a template doc that does not validate, to read the replacement it declares',
258
+ runs: '@babel/parser, to find the fixture references and page-local bindings in template source; and a template doc that does not validate, to read the replacement it declares',
255
259
  sites: ['importDocModule', 'require(@babel/parser)'],
256
260
  },
257
261
  'foundation/discovery/theme-discovery.mjs': {
@@ -81,8 +81,31 @@ const NON_DESCRIPTOR_DIRS = new Set([
81
81
  export async function collectDocInputs(project) {
82
82
  const list = new InputList();
83
83
 
84
+ // A component documented as a member of its family's doc (HStack and VStack
85
+ // in Stack's doc) is read through the family's own entry: one descriptor,
86
+ // one reader. Own docs go first so the family keeps its name.
87
+ /** @type {Array<{name: string, package: string, docPath: string}>} */
88
+ const ownDocs = [];
89
+ /** @type {Array<{name: string, package: string, docPath: string}>} */
90
+ const familyDocs = [];
84
91
  for (const record of await project.components()) {
85
92
  if (record.docPath == null) continue;
93
+ const stem = path
94
+ .basename(record.docPath)
95
+ .replace(/\.doc\.[cm]?[jt]s$/u, '')
96
+ .replace(/^XDS/u, '');
97
+ const entry = {
98
+ name: record.name,
99
+ package: record.package,
100
+ docPath: record.docPath,
101
+ };
102
+ (stem === record.name ? ownDocs : familyDocs).push(entry);
103
+ }
104
+ for (const record of ownDocs) {
105
+ list.add('components', record.name, record.package, record.docPath);
106
+ }
107
+ for (const record of familyDocs) {
108
+ if (list.claims(record.docPath)) continue;
86
109
  list.add('components', record.name, record.package, record.docPath);
87
110
  }
88
111
 
@@ -91,7 +91,6 @@ describe('collectDocInputs over what this repo ships', () => {
91
91
  'Chat',
92
92
  'ChatComposerTokenElement',
93
93
  'ContextMenuItem',
94
- 'Heading',
95
94
  'Indicator',
96
95
  'Layer',
97
96
  'NavMenu',
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import {findCoreDir} from '../fs/paths.mjs';
21
21
  import {
22
22
  discoverComponents,
23
+ familyMember,
23
24
  findComponentReadme,
24
25
  resolveImportPath,
25
26
  } from '../discovery/component-discovery.mjs';
@@ -126,6 +127,9 @@ export async function buildRegistry({cwd = process.cwd()} = {}) {
126
127
  } catch {
127
128
  continue; // a malformed doc must not take down the whole language
128
129
  }
130
+ // A member documented in its family's doc (VStack in Stack's doc) is
131
+ // registered by the family's own folder, with every other member.
132
+ if (familyMember(docs, dirName)) continue;
129
133
  const importPath = resolveImportPath(coreDir, dirName);
130
134
 
131
135
  const register = (/** @type {string} */ rawName, /** @type {import('./xle-ast').DocProp[]} */ props) => {
@@ -0,0 +1,28 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The layout-language registry over the real @astryxdesign/core tree:
5
+ * the import path it emits for each component.
6
+ */
7
+
8
+ import {describe, it, expect} from 'vitest';
9
+ import * as path from 'node:path';
10
+ import {fileURLToPath} from 'node:url';
11
+ import {buildRegistry} from './registry.mjs';
12
+
13
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
14
+
15
+ describe('buildRegistry import paths', () => {
16
+ it('keeps the Stack family on the paths it registers from Stack\'s folder', async () => {
17
+ // HStack and VStack are listed components whose docs live in Stack's doc.
18
+ // Listing them must not let their folders re-register the family under
19
+ // their own paths: a member's folder registers nothing, Stack's does.
20
+ const {components} = await buildRegistry({cwd: REPO});
21
+ const importOf = (/** @type {string} */ name) => components.get(name)?.importPath;
22
+ for (const name of ['Stack', 'StackItem', 'HStack', 'VStack']) {
23
+ expect(importOf(name), name).toBe('@astryxdesign/core/Stack');
24
+ }
25
+ expect(importOf('Heading')).toBe('@astryxdesign/core/Heading');
26
+ expect(importOf('Text')).toBe('@astryxdesign/core/Text');
27
+ }, 120_000);
28
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.2-canary.d65096c",
3
+ "version": "0.7.2-canary.e2909cf",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -101,10 +101,10 @@
101
101
  "zod": "^4.4.3"
102
102
  },
103
103
  "peerDependencies": {
104
- "@astryxdesign/charts": "0.7.2-canary.d65096c",
105
- "@astryxdesign/core": "0.7.2-canary.d65096c",
106
- "@astryxdesign/lab": "0.7.2-canary.d65096c",
107
- "@astryxdesign/theme-neutral": "0.7.2-canary.d65096c"
104
+ "@astryxdesign/charts": "0.7.2-canary.e2909cf",
105
+ "@astryxdesign/core": "0.7.2-canary.e2909cf",
106
+ "@astryxdesign/lab": "0.7.2-canary.e2909cf",
107
+ "@astryxdesign/theme-neutral": "0.7.2-canary.e2909cf"
108
108
  },
109
109
  "peerDependenciesMeta": {
110
110
  "@astryxdesign/charts": {
@@ -121,10 +121,10 @@
121
121
  }
122
122
  },
123
123
  "devDependencies": {
124
- "@astryxdesign/charts": "0.7.2-canary.d65096c",
125
- "@astryxdesign/core": "0.7.2-canary.d65096c",
126
- "@astryxdesign/lab": "0.7.2-canary.d65096c",
127
- "@astryxdesign/theme-neutral": "0.7.2-canary.d65096c",
124
+ "@astryxdesign/charts": "0.7.2-canary.e2909cf",
125
+ "@astryxdesign/core": "0.7.2-canary.e2909cf",
126
+ "@astryxdesign/lab": "0.7.2-canary.e2909cf",
127
+ "@astryxdesign/theme-neutral": "0.7.2-canary.e2909cf",
128
128
  "@heroicons/react": "^2.2.0",
129
129
  "@stylexjs/stylex": "^0.19.0",
130
130
  "@types/babel__core": "^7.20.5",