@astryxdesign/cli 0.7.2-canary.d150f78 → 0.7.2-canary.d3ec0b2

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.
@@ -13,6 +13,7 @@ import {describe, it, expect} from 'vitest';
13
13
  import * as path from 'node:path';
14
14
  import {fileURLToPath} from 'node:url';
15
15
  import {component} from './component.mjs';
16
+ import {search} from '../search/search.mjs';
16
17
  import {AstryxError} from '../error.mjs';
17
18
  import {runCli} from '../../test-utils/run-cli.mjs';
18
19
 
@@ -92,6 +93,85 @@ describe('component dispatcher — detail levels on list', () => {
92
93
  );
93
94
  });
94
95
 
96
+ describe('component list — components whose docs live in a family folder', () => {
97
+ it('lists HStack, VStack and Heading at every detail level, each with its own import', async () => {
98
+ const {docs: stackDoc} = await import(
99
+ path.join(REPO, 'packages', 'core', 'src', 'Stack', 'Stack.doc.mjs')
100
+ );
101
+ const vstackMember = stackDoc.components.find(c => c.name === 'VStack');
102
+
103
+ const names = await component(undefined, {cwd, list: true});
104
+ const listed = Object.values(names.data.components).flat();
105
+ for (const name of ['HStack', 'VStack', 'Heading']) {
106
+ expect(listed).toContainEqual({name, package: '@astryxdesign/core'});
107
+ }
108
+
109
+ const compact = await component(undefined, {cwd, list: true, detail: 'compact'});
110
+ const brief = Object.values(compact.data.components).flat();
111
+ expect(brief.find(e => e.name === 'VStack')).toEqual({
112
+ name: 'VStack',
113
+ package: '@astryxdesign/core',
114
+ description: vstackMember.description,
115
+ import: '@astryxdesign/core/VStack',
116
+ });
117
+
118
+ const full = await component(undefined, {cwd, list: true, detail: 'full'});
119
+ const entry = Object.values(full.data.components)
120
+ .flat()
121
+ .find(e => e.name === 'VStack');
122
+ // Shaped like every other full entry (a component doc plus its package):
123
+ // the member's own name, display name and summary, and no scoping field
124
+ // the full-entry type does not describe.
125
+ expect(entry).toEqual({
126
+ name: 'VStack',
127
+ package: '@astryxdesign/core',
128
+ displayName: vstackMember.displayName,
129
+ group: stackDoc.group,
130
+ category: stackDoc.category,
131
+ usage: {description: vstackMember.description},
132
+ props: vstackMember.props,
133
+ theming: stackDoc.theming,
134
+ import: '@astryxdesign/core/VStack',
135
+ });
136
+ }, SLOW);
137
+
138
+ it('prints each one\'s own import path in the list, the detail, and search', async () => {
139
+ // The path `component <Name>` prints is the one docs and examples teach:
140
+ // the component's own entry point, never its family's (/Stack, /Text) or
141
+ // a re-export (/Layout).
142
+ const compact = Object.values(
143
+ (await component(undefined, {cwd, list: true, detail: 'compact'})).data.components,
144
+ ).flat();
145
+ const full = Object.values(
146
+ (await component(undefined, {cwd, list: true, detail: 'full'})).data.components,
147
+ ).flat();
148
+ const textList = (await runCli(['component', '--list'], cwd)).stdout;
149
+ const textCompact = (
150
+ await runCli(['component', '--list', '--detail', 'compact'], cwd)
151
+ ).stdout;
152
+ /** The `import:` line of a text record, found by its `name:` line. */
153
+ const textImport = (text, name) =>
154
+ new RegExp(`^name:\\s+${name}\\n(?:(?!name:).*\\n)*?import:\\s+(\\S+)`, 'm').exec(
155
+ text,
156
+ )?.[1];
157
+ for (const name of ['VStack', 'HStack', 'Heading']) {
158
+ const expected = `@astryxdesign/core/${name}`;
159
+ expect(compact.find(e => e.name === name)?.import).toBe(expected);
160
+ expect((await component(name, {cwd})).data.import).toBe(expected);
161
+ const hits = (await search(name, {cwd, type: 'component'})).data.results;
162
+ expect(hits.find(r => r.name === name)?.import).toBe(expected);
163
+ expect(textImport(textList, name)).toBe(expected);
164
+ expect(textImport(textCompact, name)).toBe(expected);
165
+ }
166
+ // The full level spreads a component's own doc, which states no import
167
+ // (Button's entry has none either); a member's entry is scoped to the
168
+ // member and states its own.
169
+ for (const name of ['VStack', 'HStack']) {
170
+ expect(full.find(e => e.name === name)?.import).toBe(`@astryxdesign/core/${name}`);
171
+ }
172
+ }, SLOW);
173
+ });
174
+
95
175
  describe('component dispatcher — projection precedence (locked)', () => {
96
176
  it(
97
177
  '--props routes to component.detail.props',
@@ -16,6 +16,7 @@ import {
16
16
  discoverComponents,
17
17
  discoverExternalComponentsGrouped,
18
18
  discoverValidIntegrationComponents,
19
+ familyMember,
19
20
  findComponentReadme,
20
21
  findExternalComponentDoc,
21
22
  resolveImportPath,
@@ -66,6 +67,33 @@ async function legacyImport(ext, name, coreDir, docOpts) {
66
67
  */
67
68
  const coreEntry = doc => ({name: doc.name, package: CORE_PACKAGE, ...doc});
68
69
 
70
+ /**
71
+ * The full-level entry for a component documented as a member of its family's
72
+ * doc (VStack in Stack's doc): a component doc built from the member's own
73
+ * entry, under its own name, display name and import, shaped like every other
74
+ * full entry. Null when the doc is the component's own.
75
+ * @param {any} docs
76
+ * @param {string} name
77
+ * @param {string} coreDir
78
+ * @returns {any}
79
+ */
80
+ function memberEntry(docs, name, coreDir) {
81
+ const member = familyMember(docs, name);
82
+ if (!member) return null;
83
+ return {
84
+ name,
85
+ package: CORE_PACKAGE,
86
+ displayName: member.displayName ?? name,
87
+ ...(docs.group ? {group: docs.group} : {}),
88
+ ...(docs.category ? {category: docs.category} : {}),
89
+ usage: member.usage ?? {description: member.description ?? ''},
90
+ props: member.props ?? [],
91
+ ...(member.examples ? {examples: member.examples} : {}),
92
+ ...(docs.theming ? {theming: docs.theming} : {}),
93
+ import: resolveImportPath(coreDir, name),
94
+ };
95
+ }
96
+
69
97
  /**
70
98
  * The entry for a Core slot an integration component replaces
71
99
  * (spec:AST-035 FR11), at the list's detail level: the replacement's own name,
@@ -177,7 +205,11 @@ export async function componentList(
177
205
  entries.push({
178
206
  name: comp,
179
207
  package: CORE_PACKAGE,
180
- description: docs.usage?.description || docs.description || '',
208
+ description:
209
+ familyMember(docs, comp)?.description ||
210
+ docs.usage?.description ||
211
+ docs.description ||
212
+ '',
181
213
  import: resolveImportPath(coreDir, comp),
182
214
  });
183
215
  } catch {
@@ -215,7 +247,8 @@ export async function componentList(
215
247
  const readme = findComponentReadme(coreDir, comp);
216
248
  if (readme && readme.endsWith('.doc.mjs')) {
217
249
  try {
218
- entries.push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
250
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
251
+ entries.push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
219
252
  } catch {
220
253
  entries.push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
221
254
  }
@@ -269,7 +302,11 @@ export async function componentList(
269
302
  result[cat].push({
270
303
  name: comp,
271
304
  package: CORE_PACKAGE,
272
- description: docs.usage?.description || docs.description || '',
305
+ description:
306
+ familyMember(docs, comp)?.description ||
307
+ docs.usage?.description ||
308
+ docs.description ||
309
+ '',
273
310
  import: resolveImportPath(coreDir, comp),
274
311
  });
275
312
  } catch {
@@ -310,7 +347,8 @@ export async function componentList(
310
347
  const readme = findComponentReadme(coreDir, comp);
311
348
  if (readme && readme.endsWith('.doc.mjs')) {
312
349
  try {
313
- result[cat].push(coreEntry(await loadComponentDoc(readme, {zh, lang, dense})));
350
+ const docs = await loadComponentDoc(readme, {zh, lang, dense});
351
+ result[cat].push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
314
352
  } catch {
315
353
  result[cat].push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
316
354
  }
@@ -660,11 +660,10 @@ async function validateIncompleteThemeExports(
660
660
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
661
661
  try {
662
662
  if (outputs.some(output => output.kind !== 'font stylesheet')) {
663
- const receipt = await themeBuild(
664
- source,
665
- {out: expectedCss},
666
- {cwd: packageDir},
667
- );
663
+ // The scratch directory is the write root; the package stays the cwd,
664
+ // so the build selects the package's Core and its paths as before.
665
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
666
+ const receipt = await themeBuild(source, scratchBuild, {cwd: packageDir});
668
667
  if (receipt == null || receipt.type !== 'theme.build') {
669
668
  throw new Error('the theme build produced no complete receipt');
670
669
  }
@@ -847,11 +846,12 @@ async function validatePackedThemeExports(
847
846
  );
848
847
  const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
849
848
  try {
850
- const receipt = await themeBuild(
851
- source,
852
- {out: expectedCss},
853
- {cwd: localIntegration.__packageDir},
854
- );
849
+ // The scratch directory is the write root; the package stays the cwd,
850
+ // so the build selects the package's Core and its paths as before.
851
+ const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
852
+ const receipt = await themeBuild(source, scratchBuild, {
853
+ cwd: localIntegration.__packageDir,
854
+ });
855
855
  if (receipt == null || receipt.type !== 'theme.build') {
856
856
  issues.push(
857
857
  error(
@@ -85,6 +85,7 @@ import {findCoreDir} from '../../foundation/fs/paths.mjs';
85
85
  import {
86
86
  discoverComponents,
87
87
  discoverValidIntegrationComponents,
88
+ familyMember,
88
89
  findComponentReadme,
89
90
  resolveImportPath,
90
91
  resolveIntegrationImportPath,
@@ -1079,7 +1080,11 @@ async function gatherCoreComponents(coreDir) {
1079
1080
  /** @type {string[]} */
1080
1081
  let guidance = [];
1081
1082
  if (readme && readme.endsWith('.doc.mjs')) {
1082
- const doc = await loadModuleDoc(readme);
1083
+ const loaded = await loadModuleDoc(readme);
1084
+ // A member documented in its family's doc (VStack in Stack's doc) is
1085
+ // indexed from its own entry: the family's keywords and guidance
1086
+ // describe the family, not the member.
1087
+ const doc = familyMember(loaded, comp) ?? loaded;
1083
1088
  if (doc) {
1084
1089
  keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
1085
1090
  description = doc.usage?.description || doc.description || '';
@@ -1225,11 +1230,11 @@ export async function componentKeywords(coreDir, cwd) {
1225
1230
  .flat()
1226
1231
  .map(async name => {
1227
1232
  const readme = findComponentReadme(coreDir, name);
1228
- const doc =
1233
+ const loaded =
1229
1234
  readme && readme.endsWith('.doc.mjs')
1230
1235
  ? await loadModuleDoc(readme)
1231
1236
  : null;
1232
- return {name, keywords: keywordsOf(doc)};
1237
+ return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
1233
1238
  });
1234
1239
  const loadedIntegrations = await loadIntegrationsSafely(cwd);
1235
1240
  const replacements = await loadComponentReplacements(
@@ -616,6 +616,42 @@ describe('search leaf — a theme is found by its names; its description is pros
616
616
  );
617
617
  });
618
618
 
619
+ describe('search leaf — components whose docs live in a family folder', () => {
620
+ it("indexes a family member from its own entry, not the family's keywords", async () => {
621
+ // HStack and VStack are documented in Stack's doc, whose keywords
622
+ // (horizontal, vertical, row, column, ...) describe Stack.
623
+ const reasons = async (/** @type {string} */ query) =>
624
+ Object.fromEntries(
625
+ (await search(query, {cwd, type: 'component'})).data.results.map(r => [
626
+ r.name,
627
+ r.reason,
628
+ ]),
629
+ );
630
+ const column = await reasons('column');
631
+ expect(column.HStack ?? '').not.toMatch(/keyword/);
632
+ expect(column.VStack ?? '').not.toMatch(/keyword/);
633
+ expect(column.Stack).toMatch(/keyword/);
634
+ const vertical = (await search('vertical', {cwd, type: 'component'})).data.results.map(
635
+ r => r.name,
636
+ );
637
+ expect(vertical.indexOf('VStack')).toBeGreaterThanOrEqual(0);
638
+ if (vertical.includes('HStack')) {
639
+ expect(vertical.indexOf('VStack')).toBeLessThan(vertical.indexOf('HStack'));
640
+ }
641
+ }, SLOW);
642
+
643
+ it('ranks VStack, HStack and Heading first for their own names, with their own import', async () => {
644
+ for (const name of ['VStack', 'HStack', 'Heading']) {
645
+ const r = await search(name.toLowerCase(), {cwd, type: 'component'});
646
+ expect(r.data.results[0]).toMatchObject({
647
+ domain: 'component',
648
+ name,
649
+ import: `@astryxdesign/core/${name}`,
650
+ });
651
+ }
652
+ }, SLOW);
653
+ });
654
+
619
655
  describe('search leaf — integration components', () => {
620
656
  /**
621
657
  * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
@@ -252,6 +252,22 @@ describe('themeBuildFamily()', () => {
252
252
  expect(fs.existsSync(dtsPath)).toBe(false);
253
253
  });
254
254
 
255
+ it('refuses family outputs outside cwd before checking or writing', async () => {
256
+ const dir = makeDir();
257
+ const files = writeFamily(dir).map(file => path.join(dir, file));
258
+ const app = path.join(dir, 'app');
259
+ fs.mkdirSync(app);
260
+
261
+ for (const check of [false, true]) {
262
+ await expect(
263
+ themeBuildFamily(files, {familyKey: FAMILY_KEY, check}, {cwd: app}),
264
+ ).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
265
+ }
266
+ for (const output of OUTPUTS) {
267
+ expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(false);
268
+ }
269
+ });
270
+
255
271
  it('fails without publishing a mixed trio when staging a write fails', async () => {
256
272
  const dir = makeDir();
257
273
  const files = writeFamily(dir);
@@ -27,6 +27,8 @@
27
27
  * files; an internal callback lets the CLI make only the process status fail.
28
28
  * Human progress is emitted through the shared `logger` (silent by default), so
29
29
  * the CLI keeps its exact output while a programmatic caller stays quiet.
30
+ * Every output, explicit or beside the theme file, stays inside `cwd`
31
+ * (`ERR_PATH_TRAVERSAL` otherwise), in check mode too.
30
32
  *
31
33
  * The installed `@astryxdesign/core` is an independently versioned optional
32
34
  * peer, so it can be older than the CLI. A theme that uses only baseline
@@ -270,6 +272,25 @@ function realOrSelf(file) {
270
272
  }
271
273
  }
272
274
 
275
+ /**
276
+ * `target` spelled through real directories: the deepest ancestor that exists
277
+ * is resolved through its symlinks, and the missing rest is appended as given.
278
+ * @param {string} target absolute path
279
+ * @returns {string}
280
+ */
281
+ function realLocation(target) {
282
+ /** @type {string[]} */
283
+ const missing = [];
284
+ let existing = target;
285
+ while (!fs.existsSync(existing)) {
286
+ const parent = path.dirname(existing);
287
+ if (parent === existing) break;
288
+ missing.unshift(path.basename(existing));
289
+ existing = parent;
290
+ }
291
+ return path.join(realOrSelf(existing), ...missing);
292
+ }
293
+
273
294
  /**
274
295
  * The Core the project at `cwd` installed, when it differs from the one the
275
296
  * CLI's own import found and loads. The app's `<Theme>` runs on the app's
@@ -506,6 +527,47 @@ function checkedBuildOutputs(writes, cwd) {
506
527
  .map(write => path.relative(cwd, write.dest));
507
528
  }
508
529
 
530
+ /** Remedies that end a confinement error, by where the output path came from. */
531
+ const OUT_REMEDY = 'Pass an --out path inside it.';
532
+ const SOURCE_REMEDY =
533
+ 'Without --out the outputs go beside the theme file, so run the build from a directory that contains the theme file, or pass an --out path inside the working directory.';
534
+ const FAMILY_REMEDY =
535
+ "Family outputs go beside the family's root theme file, so run the build from a directory that contains it.";
536
+
537
+ /**
538
+ * Refuse an output outside `root`: every write is confined, whether `out` names
539
+ * the path or it defaults to the theme file's directory (architecture:cli-surface
540
+ * INV10). Both sides are compared by real location, so an absolute path that
541
+ * reaches the working directory through a symlink is inside it, and a symlink
542
+ * inside it that leads elsewhere is not.
543
+ * @param {string} dest absolute output path
544
+ * @param {string} root the directory writes stay inside: the working directory
545
+ * @param {string} remedy what to do instead; ends the error message
546
+ * @param {string} [shown] the path as the caller wrote it, for the message
547
+ */
548
+ function confineOutput(dest, root, remedy, shown = dest) {
549
+ try {
550
+ assertWithin(realLocation(dest), realOrSelf(path.resolve(root)), {
551
+ allowAbsolute: true,
552
+ label: 'output path',
553
+ });
554
+ } catch (err) {
555
+ if (!(err instanceof PathSafetyError)) throw err;
556
+ throw new AstryxError(
557
+ err.code === ERROR_CODES.ERR_PATH_TRAVERSAL
558
+ ? `Output path "${shown}" is outside the working directory (${root}). ${remedy}`
559
+ : err.message,
560
+ undefined,
561
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
562
+ );
563
+ }
564
+ }
565
+
566
+ /** @param {Array<{dest: string}>} writes @param {string} root @param {string} remedy */
567
+ function confineOutputs(writes, root, remedy) {
568
+ for (const write of writes) confineOutput(write.dest, root, remedy);
569
+ }
570
+
509
571
  /** @param {Array<{dest: string, content: string}>} writes */
510
572
  function writeBuildOutputs(writes) {
511
573
  if (writes.length === 0) return;
@@ -2344,9 +2406,10 @@ function validateHeadingTypeAugmentationSupport(themeDef) {
2344
2406
  * `logger` (silent by default).
2345
2407
  *
2346
2408
  * @param {string} file - Theme file path, resolved against `cwd`.
2347
- * @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void}} [options] -
2409
+ * @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void, __writeRoot?: string}} [options] -
2348
2410
  * `out` overrides the output CSS path; `check` compares against on-disk outputs
2349
- * instead of writing. `iconsSpecifier` overrides the icon registry import
2411
+ * instead of writing. `__writeRoot` replaces `cwd` as the directory every
2412
+ * output must stay inside, for a CLI-internal scratch build. `iconsSpecifier` overrides the icon registry import
2350
2413
  * specifier in the generated module (e.g. `./icons.mjs`); when omitted, the
2351
2414
  * direct registry specifiers from the theme source are emitted unchanged.
2352
2415
  * @param {{cwd?: string}} [ctx]
@@ -2791,25 +2854,14 @@ async function themeBuildInternal(
2791
2854
  // Derive the default CSS name from the theme name so .css/.js/.d.ts
2792
2855
  // share one scheme; an explicit --out still wins.
2793
2856
  const baseName = themeDef.name;
2857
+ // Every write stays inside cwd. A CLI-internal caller that renders into a
2858
+ // scratch directory it creates and removes names that directory instead.
2859
+ const writeRoot = options.__writeRoot ?? cwd;
2794
2860
  let outPath;
2795
2861
  if (options.out) {
2862
+ // Relative or absolute, the explicit output stays inside the write root.
2796
2863
  outPath = path.resolve(cwd, options.out);
2797
- // Guard: relative paths must not escape cwd via `../`. Absolute paths are
2798
- // trusted (the user explicitly controls where output goes, like gcc -o).
2799
- if (!path.isAbsolute(options.out)) {
2800
- try {
2801
- assertWithin(options.out, cwd, {label: 'output path'});
2802
- } catch (err) {
2803
- if (err instanceof PathSafetyError) {
2804
- throw new AstryxError(
2805
- err.message,
2806
- undefined,
2807
- ERROR_CODES.ERR_PATH_TRAVERSAL,
2808
- );
2809
- }
2810
- throw err;
2811
- }
2812
- }
2864
+ confineOutput(outPath, writeRoot, OUT_REMEDY, options.out);
2813
2865
  } else {
2814
2866
  outPath = path.join(path.dirname(filePath), `${baseName}.css`);
2815
2867
  }
@@ -2905,6 +2957,11 @@ async function themeBuildInternal(
2905
2957
  writes.push({dest: variantDtsPath, content: variantContent});
2906
2958
  }
2907
2959
 
2960
+ // Every output stays inside cwd, including the defaults beside the theme
2961
+ // file. Check mode refuses the same paths, so it never passes outputs that a
2962
+ // build would refuse to write.
2963
+ confineOutputs(writes, writeRoot, options.out ? OUT_REMEDY : SOURCE_REMEDY);
2964
+
2908
2965
  // Check mode: compare generated content against what's on disk instead of
2909
2966
  // writing. A file is "stale" if it's missing or its content differs once the
2910
2967
  // volatile @generated `Command:` line is ignored. Returns a
@@ -3449,6 +3506,8 @@ export async function themeBuildFamily(
3449
3506
  js: path.relative(cwd, jsPath),
3450
3507
  dts: path.relative(cwd, dtsPath),
3451
3508
  };
3509
+ // The family's outputs stay inside cwd too, in check mode as well.
3510
+ confineOutputs(writes, cwd, FAMILY_REMEDY);
3452
3511
  if (options.check) {
3453
3512
  const stale = staleBuildOutputs(writes, cwd);
3454
3513
  const upToDate = stale.length === 0;
@@ -58,7 +58,7 @@ export const doc = {
58
58
  name: 'options.out',
59
59
  type: 'string',
60
60
  description:
61
- 'Override the output CSS path. The .js, .d.ts and any .variants.d.ts are written in the same directory, named after the theme (<name>.js), not after the CSS file. A relative path must stay within cwd.',
61
+ 'Override the output CSS path, relative or absolute; it must resolve inside cwd. The .js, .d.ts and any .variants.d.ts are written in the same directory, named after the theme (<name>.js), not after the CSS file.',
62
62
  },
63
63
  {
64
64
  name: 'options.check',
@@ -77,7 +77,7 @@ export const doc = {
77
77
  name: 'ctx.cwd',
78
78
  type: 'string',
79
79
  description:
80
- 'Directory the theme file, a relative out path, and the returned output paths resolve against.',
80
+ 'Directory the theme file, a relative out path, and the returned output paths resolve against. Every output is written inside it, including the default outputs beside the theme file.',
81
81
  default: 'process.cwd()',
82
82
  },
83
83
  ],
@@ -105,7 +105,7 @@ export const doc = {
105
105
  },
106
106
  {
107
107
  code: 'ERR_PATH_TRAVERSAL',
108
- when: 'the theme name contains a path separator or traversal marker, or a relative `out` path resolves outside cwd (including through a symlink)',
108
+ when: 'the theme name contains a path separator or traversal marker, or an output resolves outside cwd (including through a symlink): an `out` path, relative or absolute, or the default outputs beside a theme file outside cwd (also rejected in check mode)',
109
109
  },
110
110
  {
111
111
  code: 'ERR_CORE_NOT_FOUND',
@@ -9,6 +9,9 @@
9
9
  * - Multi-file write is atomic: if one file fails, none are left behind.
10
10
  * - An `--out` escape, or an output directory that cannot be created, fails
11
11
  * with a registered error code.
12
+ * - Every output stays inside the working directory: an absolute `--out`
13
+ * path or a theme file outside it fails before anything is written, while
14
+ * absolute paths inside it build, also through a symlinked spelling.
12
15
  *
13
16
  * Runs the CLI in-process (shared runCli harness) against a tiny synthetic theme file.
14
17
  */
@@ -129,6 +132,68 @@ describe('theme build output-path failures carry registered codes', () => {
129
132
  expect(fs.existsSync(path.join(tmpDir, 'escaped.css'))).toBe(false);
130
133
  });
131
134
 
135
+ it('reports an absolute --out path outside the project as ERR_PATH_TRAVERSAL, with --check too', async () => {
136
+ const project = path.join(tmpDir, 'project');
137
+ const outside = path.join(tmpDir, 'outside');
138
+ fs.mkdirSync(outside, {recursive: true});
139
+ const themeFile = writeTheme(path.join(project, 'themes'), 'ocean');
140
+ const out = path.join(outside, 'ocean.css');
141
+
142
+ for (const extra of [[], ['--check']]) {
143
+ const {result, envelope} = await buildJson(
144
+ [path.relative(project, themeFile), '--out', out, ...extra],
145
+ project,
146
+ );
147
+
148
+ expect(result.code).toBe(1);
149
+ expect(envelope.code).toBe(ERROR_CODES.ERR_PATH_TRAVERSAL);
150
+ }
151
+ expect(fs.readdirSync(outside)).toEqual([]);
152
+ });
153
+
154
+ it('reports a theme file outside the project as ERR_PATH_TRAVERSAL, because its outputs go beside it', async () => {
155
+ const project = path.join(tmpDir, 'project');
156
+ fs.mkdirSync(project, {recursive: true});
157
+ const shared = path.join(tmpDir, 'shared');
158
+ const themeFile = writeTheme(shared, 'ocean');
159
+
160
+ for (const file of [themeFile, path.relative(project, themeFile)]) {
161
+ const {result, envelope} = await buildJson([file], project);
162
+
163
+ expect(result.code).toBe(1);
164
+ expect(envelope.code).toBe(ERROR_CODES.ERR_PATH_TRAVERSAL);
165
+ }
166
+ expect(fs.readdirSync(shared)).toEqual(['theme.mjs']);
167
+ });
168
+
169
+ it('builds absolute paths inside the project, also when they reach it through a symlink', async () => {
170
+ const project = path.join(tmpDir, 'project');
171
+ const themeFile = writeTheme(path.join(project, 'themes'), 'ocean');
172
+ const alias = path.join(tmpDir, 'alias');
173
+ fs.symlinkSync(project, alias, 'dir');
174
+
175
+ for (const [args, written] of [
176
+ [
177
+ [themeFile, '--out', path.join(project, 'dist', 'direct.css')],
178
+ path.join(project, 'dist', 'direct.css'),
179
+ ],
180
+ [
181
+ [themeFile, '--out', path.join(alias, 'dist', 'aliased.css')],
182
+ path.join(project, 'dist', 'aliased.css'),
183
+ ],
184
+ [
185
+ [path.join(alias, 'themes', 'theme.mjs')],
186
+ path.join(project, 'themes', 'ocean.css'),
187
+ ],
188
+ ]) {
189
+ const {result, envelope} = await buildJson(args, project);
190
+
191
+ expect(result.code).toBe(0);
192
+ expect(envelope.type).toBe('theme.build');
193
+ expect(fs.existsSync(written)).toBe(true);
194
+ }
195
+ });
196
+
132
197
  it('reports an --out path that escapes through a symlink as ERR_PATH_TRAVERSAL', async () => {
133
198
  const project = path.join(tmpDir, 'project');
134
199
  const outside = path.join(tmpDir, 'outside');
@@ -177,4 +242,23 @@ describe('theme build output-path failures carry registered codes', () => {
177
242
  code: ERROR_CODES.ERR_PATH_TRAVERSAL,
178
243
  });
179
244
  });
245
+
246
+ it('themeBuild() rejects an output outside cwd: an absolute out path, or the default beside a theme file outside cwd', async () => {
247
+ const project = path.join(tmpDir, 'project');
248
+ const inside = writeTheme(path.join(project, 'themes'), 'ocean');
249
+ const shared = path.join(tmpDir, 'shared');
250
+ const outsideTheme = writeTheme(shared, 'ocean');
251
+ const outDir = path.join(tmpDir, 'out');
252
+
253
+ for (const [file, options] of [
254
+ [path.relative(project, inside), {out: path.join(outDir, 'ocean.css')}],
255
+ [outsideTheme, {}],
256
+ ]) {
257
+ await expect(
258
+ themeBuild(file, options, {cwd: project}),
259
+ ).rejects.toMatchObject({code: ERROR_CODES.ERR_PATH_TRAVERSAL});
260
+ }
261
+ expect(fs.existsSync(outDir)).toBe(false);
262
+ expect(fs.readdirSync(shared)).toEqual(['theme.mjs']);
263
+ });
180
264
  });
@@ -126,7 +126,10 @@ describe('theme build emits a live TextInput selector (#4109)', () => {
126
126
  `};\n`,
127
127
  );
128
128
 
129
- const result = await runCli(['theme', 'build', themeFile, '-o', outFile]);
129
+ const result = await runCli(
130
+ ['theme', 'build', themeFile, '-o', outFile],
131
+ tmpDir,
132
+ );
130
133
  const css = fs.readFileSync(outFile, 'utf8');
131
134
 
132
135
  expect(result.status).toBe(0);
@@ -148,7 +151,7 @@ describe('theme build emits a live TextInput selector (#4109)', () => {
148
151
  `};\n`,
149
152
  );
150
153
 
151
- await runCli(['theme', 'build', themeFile, '-o', outFile]);
154
+ await runCli(['theme', 'build', themeFile, '-o', outFile], tmpDir);
152
155
  const css = fs.readFileSync(outFile, 'utf8');
153
156
 
154
157
  expect(css).toContain('.astryx-text-input');
@@ -30,7 +30,7 @@ export const doc = {
30
30
  {
31
31
  flag: '--family',
32
32
  description:
33
- 'Build the selected extension family into one keyed CSS, JS, and declaration set. Requires --family-key; refused with --out or --watch (ERR_THEME_INVALID)',
33
+ 'Build the selected extension family into one keyed CSS, JS, and declaration set, written beside the family root theme file, which must be inside the working directory (ERR_PATH_TRAVERSAL). Requires --family-key; refused with --out or --watch (ERR_THEME_INVALID)',
34
34
  },
35
35
  {
36
36
  flag: '--family-key <key>',
@@ -41,7 +41,7 @@ export const doc = {
41
41
  flag: '-o, --out <path>',
42
42
  param: 'options.out',
43
43
  description:
44
- 'Output CSS file path; the .js and .d.ts are written beside it (default: <theme name>.css next to the source). A relative path must stay inside the working directory. Single theme only: refused with more than one file or with --family (ERR_THEME_INVALID)',
44
+ 'Output CSS file path, relative or absolute; the .js and .d.ts are written beside it (default: <theme name>.css next to the source). Every output must resolve inside the working directory, the defaults beside the source too (ERR_PATH_TRAVERSAL). Single theme only: refused with more than one file or with --family (ERR_THEME_INVALID)',
45
45
  },
46
46
  {
47
47
  flag: '--icons-specifier <specifier>',
@@ -1,8 +1,20 @@
1
1
  // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
2
  // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
3
 
4
+ /**
5
+ * The entry a family doc gives `name` in its `components`, when the doc is not
6
+ * the component's own (Stack's doc for VStack); null otherwise.
7
+ * @param {any} docs - a loaded component doc
8
+ * @param {string} name - bare component name
9
+ * @returns {any}
10
+ */
11
+ export function familyMember(docs: any, name: string): any;
4
12
  /**
5
13
  * Auto-discover components by scanning for Astryx*.tsx files in core/src/.
14
+ * A component is listed when a doc documents it: a doc in its folder, or, for a
15
+ * component folder with no doc of its own, the doc `findComponentReadme` finds
16
+ * elsewhere (Heading's doc in the Text folder; HStack and VStack as members of
17
+ * Stack's doc).
6
18
  *
7
19
  * Returns an ordered Record where:
8
20
  * - Grouped components use the group name as key: `'Buttons': ['Button', 'IconButton']`
@@ -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$/);
@@ -246,6 +246,10 @@ 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'],
@@ -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.d150f78",
3
+ "version": "0.7.2-canary.d3ec0b2",
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.d150f78",
105
- "@astryxdesign/core": "0.7.2-canary.d150f78",
106
- "@astryxdesign/lab": "0.7.2-canary.d150f78",
107
- "@astryxdesign/theme-neutral": "0.7.2-canary.d150f78"
104
+ "@astryxdesign/charts": "0.7.2-canary.d3ec0b2",
105
+ "@astryxdesign/core": "0.7.2-canary.d3ec0b2",
106
+ "@astryxdesign/lab": "0.7.2-canary.d3ec0b2",
107
+ "@astryxdesign/theme-neutral": "0.7.2-canary.d3ec0b2"
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.d150f78",
125
- "@astryxdesign/core": "0.7.2-canary.d150f78",
126
- "@astryxdesign/lab": "0.7.2-canary.d150f78",
127
- "@astryxdesign/theme-neutral": "0.7.2-canary.d150f78",
124
+ "@astryxdesign/charts": "0.7.2-canary.d3ec0b2",
125
+ "@astryxdesign/core": "0.7.2-canary.d3ec0b2",
126
+ "@astryxdesign/lab": "0.7.2-canary.d3ec0b2",
127
+ "@astryxdesign/theme-neutral": "0.7.2-canary.d3ec0b2",
128
128
  "@heroicons/react": "^2.2.0",
129
129
  "@stylexjs/stylex": "^0.19.0",
130
130
  "@types/babel__core": "^7.20.5",