@astryxdesign/cli 0.4.7 → 0.5.0-canary.009bcb3

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 (105) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +53 -51
  3. package/api/docs/docs.doc.mjs +2 -2
  4. package/api/index.d.mts +1 -1
  5. package/api/index.mjs +1 -1
  6. package/api/search/search.mjs +50 -4
  7. package/api/search/search.test.mjs +71 -0
  8. package/api/template/data-token-fallbacks.test.mjs +85 -0
  9. package/api/template/template.doc.mjs +2 -2
  10. package/api/theme/build/build.mjs +43 -44
  11. package/api/theme/targets/targets.d.mts +18 -0
  12. package/api/theme/targets/targets.mjs +87 -0
  13. package/api/theme/targets/targets.test.mjs +66 -0
  14. package/api/theme/theme.d.mts +1 -0
  15. package/api/theme/theme.mjs +3 -1
  16. package/api/theme/theme.type.d.mts +23 -0
  17. package/api/theme/theme.type.mjs +21 -1
  18. package/api/theme/themeTargets.doc.d.mts +11 -0
  19. package/api/theme/themeTargets.doc.mjs +58 -0
  20. package/api/theme/themeTemplate.doc.mjs +3 -3
  21. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  22. package/assets/codemods/registry.mjs +1 -0
  23. package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
  24. package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
  25. package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
  26. package/assets/docs/README.md +50 -0
  27. package/assets/docs/cli-integrations.doc.mjs +4 -4
  28. package/assets/docs/theme.doc.dense.mjs +1 -1
  29. package/assets/docs/theme.doc.mjs +1 -1
  30. package/assets/docs/typography.doc.mjs +2 -2
  31. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
  32. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
  33. package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
  34. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
  35. package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
  36. package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
  37. package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
  38. package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
  39. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
  40. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
  41. package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
  42. package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
  43. package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
  44. package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
  45. package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
  46. package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
  47. package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
  48. package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
  49. package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
  50. package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
  51. package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
  52. package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
  53. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
  54. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
  55. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
  56. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
  57. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
  58. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
  59. package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
  60. package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
  61. package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
  62. package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
  63. package/assets/templates/pages/dashboard-data/page.tsx +1 -1
  64. package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
  65. package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
  66. package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
  67. package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
  68. package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
  69. package/assets/templates/pages/table-filter/page.tsx +4093 -0
  70. package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
  71. package/assets/templates/pages/table-grouped/page.tsx +151 -144
  72. package/assets/templates/pages/theme-showcase/page.tsx +36 -17
  73. package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
  74. package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
  75. package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
  76. package/assets/templates/themes/stone/stoneTheme.ts +3 -1
  77. package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
  78. package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
  79. package/clients/cli/commands/build-theme.mjs +85 -0
  80. package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
  81. package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
  82. package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
  83. package/clients/cli/commands/theme-targets.doc.mjs +38 -0
  84. package/clients/cli/commands/theme-template.doc.mjs +2 -2
  85. package/clients/cli/commands/theme.doc.mjs +4 -2
  86. package/clients/cli/index.mjs +1 -0
  87. package/clients/cli/lib/component-format.mjs +2 -2
  88. package/clients/cli/lib/manifest.mjs +2 -0
  89. package/foundation/discovery/component-discovery.mjs +10 -9
  90. package/foundation/discovery/hook-discovery.mjs +2 -1
  91. package/foundation/discovery/hook-discovery.test.mjs +156 -37
  92. package/foundation/discovery/theming-targets.d.mts +86 -0
  93. package/foundation/discovery/theming-targets.mjs +202 -0
  94. package/foundation/discovery/theming-targets.test.mjs +245 -0
  95. package/foundation/fs/paths.d.mts +16 -0
  96. package/foundation/fs/paths.mjs +36 -0
  97. package/foundation/fs/paths.test.mjs +29 -1
  98. package/foundation/response/response-types.doc.mjs +7 -2
  99. package/package.json +9 -9
  100. package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
  101. package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
  102. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
  103. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
  104. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
  105. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
@@ -0,0 +1,144 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Asserts `astryx theme build` emits the `--color-data-*` defaults the
5
+ * way the `<Theme>` runtime does — once, at `:root`, in `@layer astryx-base`.
6
+ *
7
+ * Seeding them into the theme's own `@scope` block instead makes every nested
8
+ * `<Theme>` re-declare the default and shadow a parent theme's override, which
9
+ * no other token family does. Layer order is fixed by first declaration, so
10
+ * the base block also has to sit after `@layer reset` and before
11
+ * `@layer astryx-theme` for a consumer who imports this stylesheet alone.
12
+ *
13
+ * Building requires a compiled @astryxdesign/core, so this suite builds core
14
+ * once in beforeAll via the shared ensureCoreBuilt() helper.
15
+ *
16
+ * The build formats the defaults block itself, from the public
17
+ * `dataTokenDefaults` export; core formats it for the runtime. This suite is
18
+ * what keeps those two independent formattings byte-identical.
19
+ */
20
+
21
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import * as os from 'node:os';
25
+ // Core's own generator, imported from source rather than from the package: it
26
+ // is deliberately not part of `@astryxdesign/core/theme`'s public surface, and
27
+ // this suite is the guard that the build's independent formatting matches it.
28
+ import {generateDataTokenDefaultsCSS} from '../../../../core/src/theme/generateThemeRules';
29
+ import {dataTokenDefaults as sourceDataTokenDefaults} from '../../../../core/src/theme/domainTokens/dataTokens';
30
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
31
+ import {runCli} from '../../../test-utils/run-cli.mjs';
32
+
33
+ function writeTheme(dir, name, theme) {
34
+ fs.mkdirSync(dir, {recursive: true});
35
+ const file = path.join(dir, `${name}.mjs`);
36
+ fs.writeFileSync(
37
+ file,
38
+ `export default ${JSON.stringify({name, ...theme})};\n`,
39
+ );
40
+ return file;
41
+ }
42
+
43
+ async function buildTheme(tmpDir, name, theme) {
44
+ const project = path.join(tmpDir, 'project');
45
+ const themesDir = path.join(project, 'themes');
46
+ const themeFile = writeTheme(themesDir, name, theme);
47
+
48
+ const result = await runCli(
49
+ ['theme', 'build', path.relative(project, themeFile)],
50
+ project,
51
+ );
52
+ expect(result.code).toBe(0);
53
+
54
+ return fs.readFileSync(path.join(themesDir, `${name}.css`), 'utf-8');
55
+ }
56
+
57
+ beforeAll(() => {
58
+ ensureCoreBuilt();
59
+ }, 200_000);
60
+
61
+ let tmpDir;
62
+ beforeEach(() => {
63
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-data-'));
64
+ });
65
+ afterEach(() => {
66
+ fs.rmSync(tmpDir, {recursive: true, force: true});
67
+ });
68
+
69
+ describe('theme build data token output', () => {
70
+ it('emits the defaults once, at :root, in @layer astryx-base', async () => {
71
+ const css = await buildTheme(tmpDir, 'charts-untouched', {
72
+ tokens: {'--color-accent': '#0077B6'},
73
+ });
74
+
75
+ const baseStart = css.indexOf('@layer astryx-base');
76
+ expect(baseStart).toBeGreaterThanOrEqual(0);
77
+
78
+ const baseBlock = css.slice(baseStart, css.indexOf('@layer astryx-theme'));
79
+ expect(baseBlock).toContain(':root {');
80
+ expect(baseBlock).toContain('--color-data-categorical-blue:');
81
+ expect(baseBlock).toContain('--color-data-gray-1:');
82
+ expect(css.match(/--color-data-categorical-blue:/g)).toHaveLength(1);
83
+ expect(css.match(/@layer astryx-base/g)).toHaveLength(1);
84
+ });
85
+
86
+ it('declares astryx-base after reset and before astryx-theme', async () => {
87
+ const css = await buildTheme(tmpDir, 'charts-order', {
88
+ tokens: {'--color-accent': '#0077B6'},
89
+ });
90
+
91
+ const reset = css.indexOf('@layer reset');
92
+ const base = css.indexOf('@layer astryx-base');
93
+ const theme = css.indexOf('@layer astryx-theme');
94
+
95
+ expect(reset).toBeGreaterThanOrEqual(0);
96
+ expect(base).toBeGreaterThan(reset);
97
+ expect(theme).toBeGreaterThan(base);
98
+ });
99
+
100
+ it("puts only the theme's own data token in its scope block", async () => {
101
+ const css = await buildTheme(tmpDir, 'charts-override', {
102
+ tokens: {'--color-data-categorical-blue': '#00A3FF'},
103
+ });
104
+
105
+ const themeBlock = css.slice(css.indexOf('@layer astryx-theme'));
106
+ expect(themeBlock).toContain('--color-data-categorical-blue: #00A3FF;');
107
+ expect(themeBlock).not.toContain('--color-data-categorical-orange');
108
+ expect(themeBlock).not.toContain('--color-data-gray-1');
109
+ });
110
+
111
+ it('emits the bytes the runtime generator emits', async () => {
112
+ const css = await buildTheme(tmpDir, 'charts-parity', {
113
+ tokens: {'--color-accent': '#0077B6'},
114
+ });
115
+
116
+ // The expectation below comes from core's SOURCE; the stylesheet came from
117
+ // core's dist. ensureCoreBuilt() only checks that dist exists, never that
118
+ // it is current, so a dist older than the source would fail the byte
119
+ // comparison with a colour mismatch that reads as formatter drift. Name
120
+ // that case first.
121
+ const {dataTokenDefaults: builtDataTokenDefaults} = await import(
122
+ '@astryxdesign/core/theme'
123
+ );
124
+ expect(
125
+ builtDataTokenDefaults,
126
+ 'packages/core/dist is stale — run `pnpm -F @astryxdesign/core build`',
127
+ ).toEqual(sourceDataTokenDefaults);
128
+
129
+ // The build formats this block itself, from the public `dataTokenDefaults`
130
+ // export; the runtime formats it in core. Nothing else holds the two
131
+ // together, so compare the bytes — a changed indent or separator in either
132
+ // one fails here.
133
+ const runtimeBase = generateDataTokenDefaultsCSS();
134
+ const marker = '@layer astryx-base {\n';
135
+ const start = css.indexOf(marker);
136
+ expect(start).toBeGreaterThanOrEqual(0);
137
+
138
+ const bodyStart = start + marker.length;
139
+ expect(css.slice(bodyStart, bodyStart + runtimeBase.length)).toBe(
140
+ runtimeBase,
141
+ );
142
+ expect(css.slice(bodyStart + runtimeBase.length)).toMatch(/^\n\}/);
143
+ });
144
+ });
@@ -38,6 +38,7 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
38
38
  import {themeAdd} from '../../../api/theme/add/add.mjs';
39
39
  import {themeTemplate} from '../../../api/theme/template/template.mjs';
40
40
  import {themeList} from '../../../api/theme/list/list.mjs';
41
+ import {themeTargets} from '../../../api/theme/targets/targets.mjs';
41
42
  import {themeBuild, importSpecifier} from '../../../api/theme/build/build.mjs';
42
43
  import {defineCommand} from '../lib/define-command.mjs';
43
44
  import {doc as themeGroup} from './theme.doc.mjs';
@@ -45,10 +46,12 @@ import {doc as themeBuildCommand} from './theme-build.doc.mjs';
45
46
  import {doc as themeListCommand} from './theme-list.doc.mjs';
46
47
  import {doc as themeAddCommand} from './theme-add.doc.mjs';
47
48
  import {doc as themeTemplateCommand} from './theme-template.doc.mjs';
49
+ import {doc as themeTargetsCommand} from './theme-targets.doc.mjs';
48
50
  import {doc as themeBuildFn} from '../../../api/theme/themeBuild.doc.mjs';
49
51
  import {doc as themeListFn} from '../../../api/theme/themeList.doc.mjs';
50
52
  import {doc as themeAddFn} from '../../../api/theme/themeAdd.doc.mjs';
51
53
  import {doc as themeTemplateFn} from '../../../api/theme/themeTemplate.doc.mjs';
54
+ import {doc as themeTargetsFn} from '../../../api/theme/themeTargets.doc.mjs';
52
55
 
53
56
  /**
54
57
  * Path to this CLI's real entry (clients/cli/bin/astryx.mjs), resolved from
@@ -194,6 +197,36 @@ function printThemeList(themes) {
194
197
  );
195
198
  }
196
199
 
200
+ /**
201
+ * Render the targets as one greppable line each, under an aligned header. A
202
+ * `records()` block would be five lines per target — over a thousand for the
203
+ * full surface, which is the view this command exists to make readable.
204
+ * @param {import('../../../api/theme/theme.type.mjs').ThemeTargetEntry[]} targets
205
+ * @returns {string}
206
+ */
207
+ function formatTargetsTable(targets) {
208
+ const rows = targets.map(t => ({
209
+ key: t.key,
210
+ component: t.component,
211
+ props: t.props.join(', ') || '-',
212
+ states: t.states.join(', ') || '-',
213
+ }));
214
+ const head = {key: 'key', component: 'component', props: 'props', states: 'states'};
215
+ const width = (/** @type {'key'|'component'|'props'} */ field) =>
216
+ [head, ...rows].reduce((max, r) => Math.max(max, r[field].length), 0);
217
+ const w = {key: width('key'), component: width('component'), props: width('props')};
218
+ const line = (/** @type {typeof head} */ r) =>
219
+ [
220
+ r.key.padEnd(w.key),
221
+ r.component.padEnd(w.component),
222
+ r.props.padEnd(w.props),
223
+ r.states,
224
+ ]
225
+ .join(' ')
226
+ .trimEnd();
227
+ return [line(head), ...rows.map(line)].join('\n');
228
+ }
229
+
197
230
  /**
198
231
  * @param {import('commander').Command} program
199
232
  */
@@ -225,6 +258,18 @@ export function registerTheme(program) {
225
258
  },
226
259
  });
227
260
 
261
+ // Theming questions are asked at `theme`, but per-component overrides live
262
+ // under `component`. Without this pointer the group reads as a build-tool
263
+ // menu, and the component targets are unreachable from the noun the user
264
+ // started at.
265
+ theme.addHelpText(
266
+ 'after',
267
+ `\nComponent style overrides:\n` +
268
+ ` ${getCliInvocation()} theme targets Every themeable class, with its props and states\n` +
269
+ ` ${getCliInvocation()} component <Name> One component's theming table\n` +
270
+ ` ${getCliInvocation()} docs theme How component overrides work\n`,
271
+ );
272
+
228
273
  defineCommand(theme, themeBuildCommand, {
229
274
  fn: themeBuildFn,
230
275
  action: async (
@@ -502,4 +547,44 @@ export function registerTheme(program) {
502
547
  );
503
548
  },
504
549
  });
550
+
551
+ defineCommand(theme, themeTargetsCommand, {
552
+ fn: themeTargetsFn,
553
+ action: async (/** @type {string | undefined} */ filter) => {
554
+ const json = program.opts().json || false;
555
+
556
+ /** @type {import('../../../api/theme/theme.type.mjs').ThemeTargetsResponse} */
557
+ let result;
558
+ try {
559
+ result = await themeTargets(filter, {cwd: process.cwd()});
560
+ } catch (e) {
561
+ const err =
562
+ /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
563
+ cliError(err.message, {
564
+ suggestions: err.suggestions || [],
565
+ code: err.code,
566
+ });
567
+ return;
568
+ }
569
+
570
+ if (json) return jsonOut(result);
571
+
572
+ const run = getCliInvocation();
573
+ const {targets, componentCount} = result.data;
574
+ emit(
575
+ section(
576
+ 'Theming targets',
577
+ `${targets.length} across ${componentCount} component${componentCount === 1 ? '' : 's'}`,
578
+ ),
579
+ text(formatTargetsTable(targets)),
580
+ text(
581
+ [
582
+ `Each key goes under \`components\` in defineTheme; it paints \`.astryx-<key>\`.`,
583
+ `Props take a value (\`variant:secondary\`); states are written bare (\`checked\`).`,
584
+ `One component in full: ${run} component <Name>`,
585
+ ].join('\n'),
586
+ ),
587
+ );
588
+ },
589
+ });
505
590
  }
@@ -164,6 +164,7 @@ describe('theme build custom-variant augmentations', () => {
164
164
  },
165
165
  breadcrumbs: { 'variant:customBreadcrumbs': { color: 'currentColor' } },
166
166
  button: { 'variant:customButton': { backgroundColor: 'transparent' } },
167
+ card: { 'variant:customCard': { backgroundColor: 'transparent' } },
167
168
  dialog: { 'variant:customDialog': { backgroundColor: 'transparent' } },
168
169
  divider: { 'variant:customDivider': { borderColor: 'currentColor' } },
169
170
  'field-status': { 'variant:customFieldStatus': { color: 'currentColor' } },
@@ -204,6 +205,7 @@ describe('theme build custom-variant augmentations', () => {
204
205
  `import {Banner} from '@astryxdesign/core/Banner';\n` +
205
206
  `import {Breadcrumbs} from '@astryxdesign/core/Breadcrumbs';\n` +
206
207
  `import {Button} from '@astryxdesign/core/Button';\n` +
208
+ `import {Card} from '@astryxdesign/core/Card';\n` +
207
209
  `import {Dialog} from '@astryxdesign/core/Dialog';\n` +
208
210
  `import {Divider} from '@astryxdesign/core/Divider';\n` +
209
211
  `import {FieldStatus} from '@astryxdesign/core/FieldStatus';\n` +
@@ -222,6 +224,7 @@ describe('theme build custom-variant augmentations', () => {
222
224
  ` <Banner title="Banner" status="customBannerStatus" container="customBannerContainer" />\n` +
223
225
  ` <Breadcrumbs variant="customBreadcrumbs">Crumbs</Breadcrumbs>\n` +
224
226
  ` <Button label="Button" variant="customButton" />\n` +
227
+ ` <Card variant="customCard">Card</Card>\n` +
225
228
  ` <Dialog isOpen onOpenChange={() => {}} variant="customDialog">Body</Dialog>\n` +
226
229
  ` <Divider variant="customDivider" />\n` +
227
230
  ` <FieldStatus type="success" message="Ok" variant="customFieldStatus" />\n` +
@@ -0,0 +1,24 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {component} from '../../../api/component/component.mjs';
5
+
6
+ const CWD = {cwd: '.'};
7
+
8
+ // Resolves built-in block templates by walking the source tree.
9
+ const SCAN_TIMEOUT = 30_000;
10
+
11
+ describe(
12
+ 'Dialog adaptive presentation template',
13
+ () => {
14
+ it('is listed as a Dialog example block', async () => {
15
+ const result = await component('Dialog', {
16
+ ...CWD,
17
+ blocks: true,
18
+ });
19
+ const exampleNames = result.data.examples.map(b => b.name);
20
+ expect(exampleNames).toContain('DialogAdaptivePresentation');
21
+ });
22
+ },
23
+ SCAN_TIMEOUT,
24
+ );
@@ -0,0 +1,64 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CLI behavior for `astryx theme targets`.
5
+ *
6
+ * The API leaf is covered by api/theme/targets/targets.test.mjs; what is only
7
+ * reachable here is the terminal binding — the table a human reads, the JSON
8
+ * envelope a lint script reads, and the route into component overrides from
9
+ * `theme --help`, which is where the question gets asked.
10
+ */
11
+
12
+ import {describe, it, expect} from 'vitest';
13
+ import {runCli} from '../../../test-utils/run-cli.mjs';
14
+
15
+ describe('astryx theme targets', () => {
16
+ it('prints one greppable line per target, with props and states', async () => {
17
+ const {status, stdout} = await runCli(['theme', 'targets', 'Switch']);
18
+
19
+ expect(status).toBe(0);
20
+ expect(stdout).toMatch(/^switch\s+Switch\s+size\s+checked, disabled$/m);
21
+ expect(stdout).toMatch(/^switch-thumb\s+Switch\s+size\s+checked$/m);
22
+ expect(stdout).toMatch(/4 across 1 component/);
23
+ });
24
+
25
+ it('lists the whole surface when unfiltered', async () => {
26
+ const {status, stdout} = await runCli(['theme', 'targets']);
27
+
28
+ expect(status).toBe(0);
29
+ const rows = stdout.split('\n').filter(l => /^[a-z][a-z0-9-]*\s{2,}/.test(l));
30
+ expect(rows.length).toBeGreaterThan(100);
31
+ expect(stdout).toMatch(/^button\s/m);
32
+ expect(stdout).toMatch(/^switch-thumb\s/m);
33
+ }, 30_000);
34
+
35
+ it('returns a theme.targets envelope under --json', async () => {
36
+ const {status, stdout} = await runCli(['--json', 'theme', 'targets', 'Switch']);
37
+
38
+ expect(status).toBe(0);
39
+ const payload = JSON.parse(stdout);
40
+ expect(payload.type).toBe('theme.targets');
41
+ expect(payload.data.targets).toContainEqual({
42
+ key: 'switch-thumb',
43
+ className: 'astryx-switch-thumb',
44
+ component: 'Switch',
45
+ props: ['size'],
46
+ states: ['checked'],
47
+ });
48
+ });
49
+
50
+ it('fails a filter that matches nothing', async () => {
51
+ const {status, stderr} = await runCli(['theme', 'targets', 'nosuchthing']);
52
+
53
+ expect(status).toBe(1);
54
+ expect(stderr).toMatch(/No theming target matches "nosuchthing"/);
55
+ });
56
+
57
+ it('routes a theming question from `theme --help` to component overrides', async () => {
58
+ const {stdout} = await runCli(['theme', '--help']);
59
+
60
+ expect(stdout).toMatch(/Component style overrides:/);
61
+ expect(stdout).toMatch(/theme targets/);
62
+ expect(stdout).toMatch(/component <Name>/);
63
+ });
64
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CommandDoc for `astryx theme targets`. The terminal binding of the
5
+ * `themeTargets()` function (referenced via `fn`); its args map to that
6
+ * function's params so a converter can build Commander config + --help from one
7
+ * source of truth.
8
+ * @position packages/cli/clients/cli/commands — command documentation
9
+ */
10
+
11
+ /** @type {import('@astryxdesign/cli/authoring').CommandDoc} */
12
+ export const doc = {
13
+ type: 'command',
14
+ name: 'theme targets',
15
+ displayName: 'astryx theme targets',
16
+ namespace: 'cli',
17
+ summary: 'List the component theming targets a theme can override',
18
+ description:
19
+ 'Prints every `defineTheme` components key across the system: the stable class it paints, ' +
20
+ 'the component that declares it, and the props and states that are legal override keys ' +
21
+ 'under it. This is the whole themeable surface in one command: what auditing a theme, or ' +
22
+ 'answering "which key paints this pixel?", used to need one `astryx component <Name>` per ' +
23
+ 'component to assemble. Pass a component name to scope it; pass any substring to search ' +
24
+ 'keys. `--json` for a list a repo can lint its own theme against.',
25
+ fn: 'themeTargets',
26
+ args: [{name: 'filter', param: 'filter', required: false}],
27
+ examples: [
28
+ {label: 'The whole themeable surface', cli: 'astryx theme targets'},
29
+ {label: "One component's targets", cli: 'astryx theme targets Switch'},
30
+ {label: 'Search keys', cli: 'astryx theme targets thumb'},
31
+ {label: 'For a lint or an audit script', cli: 'astryx --json theme targets'},
32
+ ],
33
+ exitCodes: [
34
+ {code: 0, when: 'success'},
35
+ {code: 1, when: 'a filter matches no target, or core cannot be resolved'},
36
+ ],
37
+ related: ['component', 'theme build', 'theme template'],
38
+ };
@@ -16,9 +16,9 @@ export const doc = {
16
16
  namespace: 'cli',
17
17
  summary: 'Write the annotated theme template into your project',
18
18
  description:
19
- 'Writes theme.template.ts: the annotated reference for the whole theme surface every ' +
19
+ 'Writes theme.template.ts: the annotated reference for the whole theme surface, covering every ' +
20
20
  'defineTheme field, the token families, the component override syntax, and how a theme is ' +
21
- 'consumed naming the CLI command that prints the authoritative reference for each. Read ' +
21
+ 'consumed, naming the CLI command that prints the authoritative reference for each. Read ' +
22
22
  'it, copy what you need into your own theme file, delete it. Use `theme add <slug>` instead ' +
23
23
  'to start from a theme we ship. Leaves an existing file untouched unless --overwrite.',
24
24
  fn: 'themeTemplate',
@@ -17,11 +17,13 @@ export const doc = {
17
17
  description:
18
18
  'The theme command group. Running astryx theme with no subcommand prints the ' +
19
19
  'subcommand list; the work happens in the subcommands: compile a theme (build), ' +
20
- 'scaffold one into your project (add), start a custom one from the annotated template (template), or list the bundled themes (list).',
21
- subcommands: ['build', 'add', 'list', 'template'],
20
+ 'scaffold one into your project (add), start a custom one from the annotated template (template), ' +
21
+ 'list the bundled themes (list), or list the component theming targets a theme can override (targets).',
22
+ subcommands: ['build', 'add', 'list', 'template', 'targets'],
22
23
  examples: [
23
24
  {label: 'List bundled themes', cli: 'astryx theme list'},
24
25
  {label: 'Scaffold a theme', cli: 'astryx theme add matcha'},
26
+ {label: 'See what a theme can override', cli: 'astryx theme targets'},
25
27
  ],
26
28
  exitCodes: [
27
29
  {code: 0, when: 'success (help shown, or a subcommand succeeded)'},
@@ -70,6 +70,7 @@ export const JSON_SUPPORTED = new Set([
70
70
  'theme list',
71
71
  'theme add',
72
72
  'theme template',
73
+ 'theme targets',
73
74
  'upgrade',
74
75
  'manifest',
75
76
  'doctor',
@@ -13,8 +13,8 @@ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
13
13
  *
14
14
  * Override keys are the component's stable class name with the `astryx-`
15
15
  * namespace prefix stripped — `generateThemeRules` re-adds the prefix when it
16
- * builds the `.astryx-*` selector. So `astryx-base-table` → key `base-table`
17
- * (→ `.astryx-base-table`), and `astryx-button` → key `button`.
16
+ * builds the `.astryx-*` selector. So `astryx-table-cell` → key `table-cell`
17
+ * (→ `.astryx-table-cell`), and `astryx-button` → key `button`.
18
18
  *
19
19
  * Keep the `astryx-` literal in sync with packages/core/src/naming.ts
20
20
  * (NAMESPACE / classPrefix), the same way build-theme.mjs mirrors it.
@@ -77,6 +77,7 @@ export const RESPONSE_TYPES = {
77
77
  'theme list': ['theme.list'],
78
78
  'theme add': ['theme.list', 'theme.add'],
79
79
  'theme template': ['theme.template'],
80
+ 'theme targets': ['theme.targets'],
80
81
  upgrade: ['upgrade.list', 'upgrade.status', 'upgrade.run'],
81
82
  manifest: ['manifest'],
82
83
  doctor: ['doctor'],
@@ -116,6 +117,7 @@ const EXAMPLES = {
116
117
  'astryx theme add matcha ./src/themes/matcha',
117
118
  ],
118
119
  'theme template': ['astryx theme template', 'astryx theme template --json'],
120
+ 'theme targets': ['astryx theme targets Switch', 'astryx --json theme targets'],
119
121
  upgrade: ['astryx upgrade --json'],
120
122
  manifest: ['astryx manifest --json', 'astryx --json'],
121
123
  doctor: ['astryx doctor', 'astryx doctor --json'],
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import {existsCaseExact} from '../fs/paths.mjs';
9
10
 
10
11
  const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
11
12
 
@@ -243,27 +244,27 @@ export function findComponentReadme(coreDir, name) {
243
244
 
244
245
  // Direct match: src/{name}/{Name}.doc.mjs or src/{name}/Astryx{Name}.doc.mjs
245
246
  const direct = path.join(srcDir, name, exactDoc);
246
- if (fs.existsSync(direct)) return direct;
247
+ if (existsCaseExact(direct, srcDir)) return direct;
247
248
  const directXds = path.join(srcDir, name, xdsDoc);
248
- if (fs.existsSync(directXds)) return directXds;
249
+ if (existsCaseExact(directXds, srcDir)) return directXds;
249
250
 
250
251
  // Nested match: src/*/{name}/{Name}.doc.mjs or src/*/{name}/Astryx{Name}.doc.mjs
251
252
  const entries = fs.readdirSync(srcDir, {withFileTypes: true});
252
253
  for (const entry of entries) {
253
254
  if (!entry.isDirectory()) continue;
254
255
  const nested = path.join(srcDir, entry.name, name, exactDoc);
255
- if (fs.existsSync(nested)) return nested;
256
+ if (existsCaseExact(nested, srcDir)) return nested;
256
257
  const nestedXds = path.join(srcDir, entry.name, name, xdsDoc);
257
- if (fs.existsSync(nestedXds)) return nestedXds;
258
+ if (existsCaseExact(nestedXds, srcDir)) return nestedXds;
258
259
  }
259
260
 
260
261
  // Per-component doc in a parent directory: src/*/{Name}.doc.mjs or src/*/Astryx{Name}.doc.mjs
261
262
  for (const entry of entries) {
262
263
  if (!entry.isDirectory()) continue;
263
264
  const perComp = path.join(srcDir, entry.name, exactDoc);
264
- if (fs.existsSync(perComp)) return perComp;
265
+ if (existsCaseExact(perComp, srcDir)) return perComp;
265
266
  const perCompXds = path.join(srcDir, entry.name, xdsDoc);
266
- if (fs.existsSync(perCompXds)) return perCompXds;
267
+ if (existsCaseExact(perCompXds, srcDir)) return perCompXds;
267
268
  }
268
269
 
269
270
  // Sub-component fallback: find the source file, then walk up
@@ -321,7 +322,7 @@ export function findComponentSource(coreDir, name) {
321
322
  // Check for an exact match (prefixed or bare) first
322
323
  for (const candidate of candidateFiles) {
323
324
  const exact = path.join(dirPath, candidate);
324
- if (fs.existsSync(exact)) return exact;
325
+ if (existsCaseExact(exact, dirPath)) return exact;
325
326
  }
326
327
 
327
328
  // Recurse into subdirectories
@@ -336,7 +337,7 @@ export function findComponentSource(coreDir, name) {
336
337
 
337
338
  // Search in the component's directory
338
339
  const directDir = path.join(srcDir, name);
339
- if (fs.existsSync(directDir)) {
340
+ if (existsCaseExact(directDir, srcDir)) {
340
341
  const found = searchDir(directDir);
341
342
  if (found) return found;
342
343
  }
@@ -346,7 +347,7 @@ export function findComponentSource(coreDir, name) {
346
347
  for (const entry of entries) {
347
348
  if (!entry.isDirectory()) continue;
348
349
  const nestedDir = path.join(srcDir, entry.name, name);
349
- if (fs.existsSync(nestedDir)) {
350
+ if (existsCaseExact(nestedDir, srcDir)) {
350
351
  const found = searchDir(nestedDir);
351
352
  if (found) return found;
352
353
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import {existsCaseExact} from '../fs/paths.mjs';
9
10
  import {levenshteinDistance} from '../text/string-utils.mjs';
10
11
 
11
12
  const SKIP_DIRS = new Set(['utils', '__tests__', 'node_modules']);
@@ -157,7 +158,7 @@ export function findHookDoc(coreDir, name) {
157
158
  // 1. Check src/hooks/{name}.doc.mjs first
158
159
  for (const candidate of uniqueCandidates) {
159
160
  const direct = path.join(hooksDir, `${candidate}.doc.mjs`);
160
- if (fs.existsSync(direct)) return direct;
161
+ if (existsCaseExact(direct, hooksDir)) return direct;
161
162
  }
162
163
 
163
164
  // 2. Case-insensitive search in hooks directory