@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
@@ -5,16 +5,25 @@
5
5
  * @astryxdesign/core source tree plus throwaway fixtures. Pins the
6
6
  * category grouping, the missing-src guarding (which differs from
7
7
  * component-discovery), and the Levenshtein fuzzy-fallback in findHookDoc.
8
+ * Also gates the index against the hooks barrel: every hook the built barrel
9
+ * exports must be discoverable, so adding a hook without a doc fails here.
8
10
  */
9
11
 
10
12
  import {describe, it, expect, afterAll} from 'vitest';
11
13
  import * as fs from 'node:fs';
12
14
  import * as os from 'node:os';
13
15
  import * as path from 'node:path';
14
- import {fileURLToPath} from 'node:url';
15
- import {discoverHooks, findHookDoc, getAllHookNames} from './hook-discovery.mjs';
16
+ import {fileURLToPath, pathToFileURL} from 'node:url';
17
+ import {
18
+ discoverHooks,
19
+ findHookDoc,
20
+ getAllHookNames,
21
+ } from './hook-discovery.mjs';
16
22
 
17
- const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
23
+ const REPO = path.resolve(
24
+ path.dirname(fileURLToPath(import.meta.url)),
25
+ '../../../..',
26
+ );
18
27
  const CORE = path.join(REPO, 'packages', 'core');
19
28
  const SLOW = 30_000;
20
29
 
@@ -29,35 +38,53 @@ afterAll(() => {
29
38
  });
30
39
 
31
40
  describe('discoverHooks (real core)', () => {
32
- it('discovers real hooks grouped by category', () => {
33
- const hooks = discoverHooks(CORE);
34
- const cats = Object.keys(hooks);
35
- expect(cats.length).toBeGreaterThan(1);
36
- if (cats.includes('Other')) expect(cats[cats.length - 1]).toBe('Other');
37
- const nonOther = cats.filter(c => c !== 'Other');
38
- expect([...nonOther].sort((a, b) => a.localeCompare(b))).toEqual(nonOther);
39
- for (const members of Object.values(hooks)) {
40
- expect([...members].sort()).toEqual(members);
41
- }
42
- const all = getAllHookNames(CORE);
43
- expect(all).toContain('useMediaQuery');
44
- expect(all.length).toBeGreaterThan(10);
45
- }, SLOW);
41
+ it(
42
+ 'discovers real hooks grouped by category',
43
+ () => {
44
+ const hooks = discoverHooks(CORE);
45
+ const cats = Object.keys(hooks);
46
+ expect(cats.length).toBeGreaterThan(1);
47
+ if (cats.includes('Other')) expect(cats[cats.length - 1]).toBe('Other');
48
+ const nonOther = cats.filter(c => c !== 'Other');
49
+ expect([...nonOther].sort((a, b) => a.localeCompare(b))).toEqual(
50
+ nonOther,
51
+ );
52
+ for (const members of Object.values(hooks)) {
53
+ expect([...members].sort()).toEqual(members);
54
+ }
55
+ const all = getAllHookNames(CORE);
56
+ expect(all).toContain('useMediaQuery');
57
+ expect(all.length).toBeGreaterThan(10);
58
+ },
59
+ SLOW,
60
+ );
46
61
 
47
62
  it('capitalizes categories and buckets category-less docs into Other', () => {
48
63
  const core = mkTmp('as-hd-cat-');
49
64
  const hooksDir = path.join(core, 'src', 'hooks');
50
65
  fs.mkdirSync(hooksDir, {recursive: true});
51
- fs.writeFileSync(path.join(hooksDir, 'useThing.doc.mjs'), "export default {\n category: 'layout',\n};\n");
52
- fs.writeFileSync(path.join(hooksDir, 'useOrphan.doc.mjs'), 'export default {};\n');
53
- expect(discoverHooks(core)).toEqual({Layout: ['useThing'], Other: ['useOrphan']});
66
+ fs.writeFileSync(
67
+ path.join(hooksDir, 'useThing.doc.mjs'),
68
+ "export default {\n category: 'layout',\n};\n",
69
+ );
70
+ fs.writeFileSync(
71
+ path.join(hooksDir, 'useOrphan.doc.mjs'),
72
+ 'export default {};\n',
73
+ );
74
+ expect(discoverHooks(core)).toEqual({
75
+ Layout: ['useThing'],
76
+ Other: ['useOrphan'],
77
+ });
54
78
  });
55
79
 
56
80
  it('discovers use*.doc.mjs colocated in component directories', () => {
57
81
  const core = mkTmp('as-hd-colo-');
58
82
  const compDir = path.join(core, 'src', 'Resizable');
59
83
  fs.mkdirSync(compDir, {recursive: true});
60
- fs.writeFileSync(path.join(compDir, 'useResizable.doc.mjs'), "export default {\n category: 'interaction',\n};\n");
84
+ fs.writeFileSync(
85
+ path.join(compDir, 'useResizable.doc.mjs'),
86
+ "export default {\n category: 'interaction',\n};\n",
87
+ );
61
88
  expect(discoverHooks(core)).toEqual({Interaction: ['useResizable']});
62
89
  });
63
90
 
@@ -69,13 +96,25 @@ describe('discoverHooks (real core)', () => {
69
96
  });
70
97
 
71
98
  describe('findHookDoc (real core)', () => {
72
- it('resolves an exact hook name', () => {
73
- expect(findHookDoc(CORE, 'useMediaQuery')).toMatch(/useMediaQuery\.doc\.mjs$/);
74
- }, SLOW);
99
+ it(
100
+ 'resolves an exact hook name',
101
+ () => {
102
+ expect(findHookDoc(CORE, 'useMediaQuery')).toMatch(
103
+ /useMediaQuery\.doc\.mjs$/,
104
+ );
105
+ },
106
+ SLOW,
107
+ );
75
108
 
76
- it('resolves a bare (use-prefix-stripped, case-insensitive) name', () => {
77
- expect(findHookDoc(CORE, 'mediaquery')).toMatch(/useMediaQuery\.doc\.mjs$/);
78
- }, SLOW);
109
+ it(
110
+ 'resolves a bare (use-prefix-stripped, case-insensitive) name',
111
+ () => {
112
+ expect(findHookDoc(CORE, 'mediaquery')).toMatch(
113
+ /useMediaQuery\.doc\.mjs$/,
114
+ );
115
+ },
116
+ SLOW,
117
+ );
79
118
 
80
119
  it('returns null when src/ is missing (guarded)', () => {
81
120
  const core = mkTmp('as-fhd-nosrc-');
@@ -84,14 +123,94 @@ describe('findHookDoc (real core)', () => {
84
123
  });
85
124
 
86
125
  describe('findHookDoc fuzzy Levenshtein fallback (pinned current behavior)', () => {
87
- it('auto-resolves a typo within edit distance 3', () => {
88
- expect(getAllHookNames(CORE)).not.toContain('useLayers');
89
- expect(getAllHookNames(CORE)).toContain('useLayer');
90
- expect(findHookDoc(CORE, 'useLayers')).toMatch(/useLayer\.doc\.mjs$/);
91
- }, SLOW);
92
-
93
- it('returns null when the closest hook is farther than distance 3', () => {
94
- expect(findHookDoc(CORE, 'useZzzzzz')).toBeNull();
95
- expect(findHookDoc(CORE, 'zzzzzzzzzz')).toBeNull();
96
- }, SLOW);
126
+ it(
127
+ 'auto-resolves a typo within edit distance 3',
128
+ () => {
129
+ expect(getAllHookNames(CORE)).not.toContain('useLayers');
130
+ expect(getAllHookNames(CORE)).toContain('useLayer');
131
+ expect(findHookDoc(CORE, 'useLayers')).toMatch(/useLayer\.doc\.mjs$/);
132
+ },
133
+ SLOW,
134
+ );
135
+
136
+ it(
137
+ 'returns null when the closest hook is farther than distance 3',
138
+ () => {
139
+ expect(findHookDoc(CORE, 'useZzzzzz')).toBeNull();
140
+ expect(findHookDoc(CORE, 'zzzzzzzzzz')).toBeNull();
141
+ },
142
+ SLOW,
143
+ );
144
+ });
145
+
146
+ /**
147
+ * The set of hooks a barrel actually exports, read from the module itself
148
+ * rather than from its text. Importing is what makes this a derivation: it sees
149
+ * `export * from` re-exports, aliases and any other form the language allows,
150
+ * and type-only exports have already been erased. A regex over the source can
151
+ * only see the spellings someone thought to write a pattern for.
152
+ * @param {string} entry absolute path to a built ESM barrel
153
+ */
154
+ async function exportedHookNames(entry) {
155
+ const mod = await import(pathToFileURL(entry).href);
156
+ return new Set(Object.keys(mod).filter(name => /^use[A-Z]/.test(name)));
157
+ }
158
+
159
+ describe('core hooks barrel coverage (real core)', () => {
160
+ // Discovery is filesystem-driven: a hook is only reachable from `astryx hook`
161
+ // and `astryx search` if it ships a .doc.mjs. Nothing about adding a hook
162
+ // forces one, so the index silently goes stale; seven exported hooks,
163
+ // useAnnounce among them, were invisible until this check existed.
164
+ //
165
+ // Only the standalone hooks in src/hooks/ are in scope. Hooks that belong to
166
+ // a component's API (useDialog, useToast, ...) are documented in that
167
+ // component's directory and deliberately never enter this barrel, so their
168
+ // absence from it is not a defect.
169
+ it(
170
+ 'every hook exported from the barrel has a doc and is discoverable',
171
+ async () => {
172
+ // The built barrel, not src/hooks/index.ts: this suite runs in the `node`
173
+ // project, which has no StyleX babel transform, so importing core's TS
174
+ // source throws on the first stylex.defineVars it reaches. dist is present
175
+ // because the project's globalSetup builds core before any worker forks.
176
+ const exported = await exportedHookNames(
177
+ path.join(CORE, 'dist', 'hooks', 'index.js'),
178
+ );
179
+ expect(exported.size).toBeGreaterThan(10);
180
+
181
+ const discovered = new Set(getAllHookNames(CORE));
182
+ const missing = [...exported]
183
+ .filter(name => !discovered.has(name))
184
+ .sort();
185
+ if (missing.length) {
186
+ throw new Error(
187
+ `${missing.length} exported hook(s) are missing from the CLI hook index, so ` +
188
+ `\`astryx hook <name>\` and \`astryx search\` deny they exist. Add a ` +
189
+ `<hook>.doc.mjs next to each hook in packages/core/src/hooks/:\n ${missing.join('\n ')}`,
190
+ );
191
+ }
192
+ expect(missing).toEqual([]);
193
+ },
194
+ SLOW,
195
+ );
196
+
197
+ it('sees hooks re-exported with `export *`, which a regex over `export {…}` misses', async () => {
198
+ const dir = mkTmp('as-hd-star-');
199
+ fs.writeFileSync(
200
+ path.join(dir, 'useStarred.mjs'),
201
+ 'export function useStarred() {}\n',
202
+ );
203
+ fs.writeFileSync(
204
+ path.join(dir, 'useNamed.mjs'),
205
+ 'export function useNamed() {}\n',
206
+ );
207
+ fs.writeFileSync(
208
+ path.join(dir, 'index.mjs'),
209
+ "export * from './useStarred.mjs';\nexport {useNamed} from './useNamed.mjs';\n",
210
+ );
211
+
212
+ expect(await exportedHookNames(path.join(dir, 'index.mjs'))).toEqual(
213
+ new Set(['useStarred', 'useNamed']),
214
+ );
215
+ });
97
216
  });
@@ -0,0 +1,86 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * Every theming target declared under a core `src` directory, sorted by key
6
+ * then component. A key can appear more than once: a shared sub-element (the
7
+ * radio indicator, say) is documented by every component that renders it.
8
+ *
9
+ * Unreadable docs are skipped rather than fatal — a single malformed doc must
10
+ * not take out theme validation or the listing.
11
+ *
12
+ * @param {string} coreSrc - absolute path to `<core>/src`
13
+ * @returns {Promise<ThemingTarget[]>}
14
+ */
15
+ export function collectThemingTargets(coreSrc: string): Promise<ThemingTarget[]>;
16
+ /**
17
+ * One public custom property a theme may set on a component's target.
18
+ * @typedef {object} ThemingVar
19
+ * @property {string} name - the custom property, e.g. `--tree-list-indent`
20
+ * @property {string} component - the component whose doc declares it
21
+ * @property {string} dir - absolute path to the directory the doc lives in
22
+ * @property {string} default - the documented default value
23
+ */
24
+ /**
25
+ * Every PUBLIC theming var declared under a core `src` directory, sorted by
26
+ * name. Private `--_*` vars are a component's own plumbing, not a theme's to
27
+ * set, so they are not enumerated here.
28
+ *
29
+ * @param {string} coreSrc - absolute path to `<core>/src`
30
+ * @returns {Promise<ThemingVar[]>}
31
+ */
32
+ export function collectThemingVars(coreSrc: string): Promise<ThemingVar[]>;
33
+ /**
34
+ * Collapse the enumeration into the `{key: [props and states]}` map theme
35
+ * validation checks override keys against — both are legal override keys, so
36
+ * they share one list.
37
+ * @param {ThemingTarget[]} targets
38
+ * @returns {Record<string, string[]>}
39
+ */
40
+ export function targetsByKey(targets: ThemingTarget[]): Record<string, string[]>;
41
+ /**
42
+ * One theming target, as a theme author has to write it.
43
+ */
44
+ export type ThemingTarget = {
45
+ /**
46
+ * - the `defineTheme` `components` key (class minus the `astryx-` prefix)
47
+ */
48
+ key: string;
49
+ /**
50
+ * - the stable class the component renders
51
+ */
52
+ className: string;
53
+ /**
54
+ * - the component whose doc declares it
55
+ */
56
+ component: string;
57
+ /**
58
+ * - visual props the target reflects (`variant:value` keys)
59
+ */
60
+ props: string[];
61
+ /**
62
+ * - runtime states the target reflects (bare-name keys)
63
+ */
64
+ states: string[];
65
+ };
66
+ /**
67
+ * One public custom property a theme may set on a component's target.
68
+ */
69
+ export type ThemingVar = {
70
+ /**
71
+ * - the custom property, e.g. `--tree-list-indent`
72
+ */
73
+ name: string;
74
+ /**
75
+ * - the component whose doc declares it
76
+ */
77
+ component: string;
78
+ /**
79
+ * - absolute path to the directory the doc lives in
80
+ */
81
+ dir: string;
82
+ /**
83
+ * - the documented default value
84
+ */
85
+ default: string;
86
+ };
@@ -0,0 +1,202 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The one enumeration of component theming targets.
5
+ *
6
+ * @input a core `src` directory
7
+ * @output every `theming.targets` entry authored in a component `.doc.mjs`,
8
+ * flattened into the `defineTheme` component key a theme author writes
9
+ * @position packages/cli/foundation/discovery — shared by `theme targets` (the
10
+ * listing) and `theme build` (override validation). Both read the
11
+ * component docs, which are the source of truth `astryx component
12
+ * <Name>` prints; nothing here is a second registry, so the list a
13
+ * theme author can enumerate and the set the compiler accepts cannot
14
+ * drift from the components or from each other.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import {loadComponentDoc} from './component-loader.mjs';
20
+
21
+ const SKIP_DIRS = new Set(['node_modules', '__tests__']);
22
+
23
+ /**
24
+ * One theming target, as a theme author has to write it.
25
+ * @typedef {object} ThemingTarget
26
+ * @property {string} key - the `defineTheme` `components` key (class minus the `astryx-` prefix)
27
+ * @property {string} className - the stable class the component renders
28
+ * @property {string} component - the component whose doc declares it
29
+ * @property {string[]} props - visual props the target reflects (`variant:value` keys)
30
+ * @property {string[]} states - runtime states the target reflects (bare-name keys)
31
+ */
32
+
33
+ /**
34
+ * Strip the namespace prefix to get the `defineTheme` key for a class name.
35
+ *
36
+ * Keep the `astryx-` literal in sync with packages/core/src/naming.ts
37
+ * (NAMESPACE / classPrefix), the same way component-format.mjs does.
38
+ * <!-- SYNC: packages/core/src/naming.ts (namespace prefix source of truth) -->
39
+ * @param {string} className
40
+ * @returns {string}
41
+ */
42
+ function targetKey(className) {
43
+ return className.replace(/^astryx-/, '');
44
+ }
45
+
46
+ /**
47
+ * Every theming target declared under a core `src` directory, sorted by key
48
+ * then component. A key can appear more than once: a shared sub-element (the
49
+ * radio indicator, say) is documented by every component that renders it.
50
+ *
51
+ * Unreadable docs are skipped rather than fatal — a single malformed doc must
52
+ * not take out theme validation or the listing.
53
+ *
54
+ * @param {string} coreSrc - absolute path to `<core>/src`
55
+ * @returns {Promise<ThemingTarget[]>}
56
+ */
57
+ export async function collectThemingTargets(coreSrc) {
58
+ if (!coreSrc || !fs.existsSync(coreSrc)) return [];
59
+
60
+ /** @type {ThemingTarget[]} */
61
+ const targets = [];
62
+
63
+ /** @param {string} dir */
64
+ async function scan(dir) {
65
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
66
+ const full = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (SKIP_DIRS.has(entry.name)) continue;
69
+ await scan(full);
70
+ continue;
71
+ }
72
+ if (!entry.name.endsWith('.doc.mjs')) continue;
73
+
74
+ /** @type {any} */
75
+ let doc;
76
+ try {
77
+ doc = await loadComponentDoc(full);
78
+ } catch {
79
+ continue;
80
+ }
81
+
82
+ const component =
83
+ typeof doc?.name === 'string' && doc.name
84
+ ? doc.name
85
+ : path.basename(path.dirname(full));
86
+
87
+ for (const target of doc?.theming?.targets || []) {
88
+ const className = target?.className;
89
+ if (typeof className !== 'string') continue;
90
+ const key = targetKey(className);
91
+ if (!key) continue;
92
+ targets.push({
93
+ key,
94
+ className,
95
+ component,
96
+ props: stringList(target.visualProps),
97
+ states: stringList(target.states),
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ await scan(coreSrc);
104
+
105
+ targets.sort(
106
+ (a, b) => a.key.localeCompare(b.key) || a.component.localeCompare(b.component),
107
+ );
108
+ return targets;
109
+ }
110
+
111
+ /**
112
+ * One public custom property a theme may set on a component's target.
113
+ * @typedef {object} ThemingVar
114
+ * @property {string} name - the custom property, e.g. `--tree-list-indent`
115
+ * @property {string} component - the component whose doc declares it
116
+ * @property {string} dir - absolute path to the directory the doc lives in
117
+ * @property {string} default - the documented default value
118
+ */
119
+
120
+ /**
121
+ * Every PUBLIC theming var declared under a core `src` directory, sorted by
122
+ * name. Private `--_*` vars are a component's own plumbing, not a theme's to
123
+ * set, so they are not enumerated here.
124
+ *
125
+ * @param {string} coreSrc - absolute path to `<core>/src`
126
+ * @returns {Promise<ThemingVar[]>}
127
+ */
128
+ export async function collectThemingVars(coreSrc) {
129
+ if (!coreSrc || !fs.existsSync(coreSrc)) return [];
130
+
131
+ /** @type {Map<string, ThemingVar>} */
132
+ const vars = new Map();
133
+
134
+ /** @param {string} dir */
135
+ async function scan(dir) {
136
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
137
+ const full = path.join(dir, entry.name);
138
+ if (entry.isDirectory()) {
139
+ if (SKIP_DIRS.has(entry.name)) continue;
140
+ await scan(full);
141
+ continue;
142
+ }
143
+ if (!entry.name.endsWith('.doc.mjs')) continue;
144
+
145
+ /** @type {any} */
146
+ let doc;
147
+ try {
148
+ doc = await loadComponentDoc(full);
149
+ } catch {
150
+ continue;
151
+ }
152
+
153
+ const component =
154
+ typeof doc?.name === 'string' && doc.name
155
+ ? doc.name
156
+ : path.basename(path.dirname(full));
157
+
158
+ for (const entryVar of doc?.theming?.vars || []) {
159
+ const name = entryVar?.name;
160
+ if (typeof name !== 'string') continue;
161
+ if (entryVar.private === true || name.startsWith('--_')) continue;
162
+ if (vars.has(name)) continue;
163
+ vars.set(name, {
164
+ name,
165
+ component,
166
+ dir: path.dirname(full),
167
+ default: typeof entryVar.default === 'string' ? entryVar.default : '',
168
+ });
169
+ }
170
+ }
171
+ }
172
+
173
+ await scan(coreSrc);
174
+
175
+ return [...vars.values()].sort((a, b) => a.name.localeCompare(b.name));
176
+ }
177
+
178
+ /**
179
+ * Collapse the enumeration into the `{key: [props and states]}` map theme
180
+ * validation checks override keys against — both are legal override keys, so
181
+ * they share one list.
182
+ * @param {ThemingTarget[]} targets
183
+ * @returns {Record<string, string[]>}
184
+ */
185
+ export function targetsByKey(targets) {
186
+ /** @type {Record<string, string[]>} */
187
+ const byKey = {};
188
+ for (const t of targets) {
189
+ byKey[t.key] = [...new Set([...(byKey[t.key] || []), ...t.props, ...t.states])];
190
+ }
191
+ return byKey;
192
+ }
193
+
194
+ /**
195
+ * @param {unknown} value
196
+ * @returns {string[]}
197
+ */
198
+ function stringList(value) {
199
+ return Array.isArray(value)
200
+ ? value.filter((/** @type {unknown} */ v) => typeof v === 'string')
201
+ : [];
202
+ }