@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
@@ -43,6 +43,10 @@ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
43
43
  import {AstryxError} from '../../error.mjs';
44
44
  import {logger} from '../../logger.mjs';
45
45
  import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';
46
+ import {
47
+ collectThemingTargets,
48
+ targetsByKey,
49
+ } from '../../../foundation/discovery/theming-targets.mjs';
46
50
  import {
47
51
  collectUnloadedFonts,
48
52
  formatFontLoadingHelp,
@@ -58,12 +62,14 @@ import {
58
62
  /** @type {any} */ let _defineTheme = null;
59
63
  /** @type {any} */ let _generateThemeRulesSplit = null;
60
64
  /** @type {any} */ let _generateOnMediaCSS = null;
65
+ /** @type {any} */ let _dataTokenDefaults = null;
61
66
  /** @type {any} */ let _coreImportError = null;
62
67
  try {
63
68
  const coreTheme = await import('@astryxdesign/core/theme');
64
69
  _defineTheme = coreTheme.defineTheme;
65
70
  _generateThemeRulesSplit = coreTheme.generateThemeRulesSplit;
66
71
  _generateOnMediaCSS = coreTheme.generateOnMediaCSS;
72
+ _dataTokenDefaults = coreTheme.dataTokenDefaults;
67
73
  } catch (e) {
68
74
  // Capture the reason so the theme action can surface a precise, actionable
69
75
  // error. We don't throw here: this module is imported eagerly by the CLI
@@ -345,8 +351,8 @@ const _augmentationTargetCache = new Map();
345
351
  * Resolve a rendered theme class token (the key without `astryx-`) to candidate
346
352
  * public core subpaths and interface prefixes that may own its augmentable prop
347
353
  * maps. Some tokens are subtargets documented by a parent component
348
- * (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some stable
349
- * class tokens intentionally omit word separators (`progressbar`, `statusdot`)
354
+ * (`avatar-status-dot` augments `@astryxdesign/core/Avatar`), and some
355
+ * deprecated tokens still omit word separators (`progressbar`, `statusdot`)
350
356
  * while the public API keeps `ProgressBar`/`StatusDot` casing. Component docs
351
357
  * are the source of truth for the target token → owning component relationship.
352
358
  *
@@ -424,8 +430,8 @@ async function resolveAugmentationTargetCandidates(componentName) {
424
430
 
425
431
  // Try the exact rendered token first for documented subtargets such as
426
432
  // avatar-status-dot → AvatarStatusDotVariantMap, then the owning public
427
- // component name for unhyphenated public casings such as progressbar →
428
- // ProgressBarVariantMap/statusdot → StatusDotVariantMap.
433
+ // component name for the deprecated unhyphenated tokens such as
434
+ // progressbar → ProgressBarVariantMap/statusdot → StatusDotVariantMap.
429
435
  addCandidate(moduleName, toPascalCase(componentName));
430
436
  addCandidate(moduleName, moduleName);
431
437
  if (Array.isArray(doc?.components)) {
@@ -882,6 +888,9 @@ ${iconType}export declare const ${toIdentifier(themeDef.name)}Theme: DefinedThem
882
888
  * Returns null when docs are unavailable so validation can skip unknown-key
883
889
  * warnings rather than guessing from a second registry.
884
890
  *
891
+ * Shares its enumeration with `theme targets`, so what a theme author can list
892
+ * is exactly what this validator accepts.
893
+ *
885
894
  * @returns {Promise<Record<string, string[]> | null>}
886
895
  */
887
896
  async function loadKnownComponents() {
@@ -889,44 +898,7 @@ async function loadKnownComponents() {
889
898
  const coreSrc = coreRoot ? path.join(coreRoot, 'src') : null;
890
899
  if (!coreSrc || !fs.existsSync(coreSrc)) return null;
891
900
 
892
- /** @type {Record<string, string[]>} */
893
- const targets = {};
894
-
895
- /** @param {string} dir */
896
- async function scan(dir) {
897
- const entries = fs.readdirSync(dir, {withFileTypes: true});
898
- for (const entry of entries) {
899
- const full = path.join(dir, entry.name);
900
- if (entry.isDirectory()) {
901
- if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
902
- await scan(full);
903
- continue;
904
- }
905
- if (!entry.name.endsWith('.doc.mjs')) continue;
906
-
907
- /** @type {any} */
908
- let doc;
909
- try {
910
- doc = await loadComponentDoc(full);
911
- } catch {
912
- continue;
913
- }
914
-
915
- for (const target of doc?.theming?.targets || []) {
916
- const className = target?.className;
917
- if (typeof className !== 'string') continue;
918
- const key = className.replace(/^astryx-/, '');
919
- if (!key) continue;
920
- const props = [target.visualProps, target.states]
921
- .filter(list => Array.isArray(list))
922
- .flat()
923
- .filter((/** @type {unknown} */ p) => typeof p === 'string');
924
- targets[key] = [...new Set([...(targets[key] || []), ...props])];
925
- }
926
- }
927
- }
928
-
929
- await scan(coreSrc);
901
+ const targets = targetsByKey(await collectThemingTargets(coreSrc));
930
902
  return Object.keys(targets).length > 0 ? targets : null;
931
903
  }
932
904
 
@@ -1196,8 +1168,15 @@ export async function themeBuild(
1196
1168
  if (component.length > 0) {
1197
1169
  const componentInner = component.join('\n\n');
1198
1170
  const componentScope = `@scope (${scopeSelector}) to (${scopeTo}) {\n${componentInner}\n}`;
1199
- // #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme
1200
- const colorSchemeDecl = componentScope.includes('light-dark(')
1171
+ // #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme.
1172
+ // Decided from the theme's own values, not the generated CSS: that CSS
1173
+ // also carries the data-token defaults, which are light-dark() pairs, so
1174
+ // a substring check on it would fire for every theme.
1175
+ const themeOwnValues = JSON.stringify([
1176
+ resolvedTheme.tokens ?? {},
1177
+ resolvedTheme.components ?? {},
1178
+ ]);
1179
+ const colorSchemeDecl = themeOwnValues.includes('light-dark(')
1201
1180
  ? ' :root { color-scheme: light dark; }\n html[data-theme="light"] { color-scheme: light; }\n html[data-theme="dark"] { color-scheme: dark; }\n\n'
1202
1181
  : '';
1203
1182
  cssParts.push(
@@ -1215,6 +1194,26 @@ export async function themeBuild(
1215
1194
  logger.log('No overrides found — nothing to build.');
1216
1195
  return null;
1217
1196
  }
1197
+ // The data-token defaults are theme-independent and go in @layer
1198
+ // astryx-base, below the theme's own overrides. Formatted here from the
1199
+ // public `dataTokenDefaults` export, byte for byte as the `<Theme>`
1200
+ // runtime emits it — build-theme.data-tokens.test.mjs is the drift guard.
1201
+ // Placed after the reset block and before the theme block: a layer's order
1202
+ // is fixed by where it is first declared, so emitting it anywhere else in
1203
+ // the file would invert reset < astryx-base < astryx-theme for a consumer
1204
+ // who imports this stylesheet on its own.
1205
+ const baseCss = _dataTokenDefaults
1206
+ ? `:root {\n${Object.entries(_dataTokenDefaults)
1207
+ .map(([name, value]) => ` ${name}: ${value};`)
1208
+ .join('\n')}\n}`
1209
+ : '';
1210
+ if (baseCss) {
1211
+ cssParts.splice(
1212
+ prose.length > 0 ? 1 : 0,
1213
+ 0,
1214
+ `@layer astryx-base {\n${baseCss}\n}`,
1215
+ );
1216
+ }
1218
1217
  css = cssParts.join('\n\n') + '\n';
1219
1218
  }
1220
1219
 
@@ -0,0 +1,18 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * List every component theming target — the `defineTheme` `components` keys,
6
+ * with the props and states each one accepts.
7
+ *
8
+ * A filter naming a component exactly wins over a substring search, so
9
+ * `theme targets Button` is Button's own set (what `astryx component Button`
10
+ * prints) rather than every key that happens to contain "button".
11
+ *
12
+ * @param {string} [filter] - component name, or a substring of a target key
13
+ * @param {{cwd?: string}} [ctx]
14
+ * @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
15
+ */
16
+ export function themeTargets(filter?: string, { cwd }?: {
17
+ cwd?: string;
18
+ }): Promise<import("../theme.type.mjs").ThemeTargetsResponse>;
@@ -0,0 +1,87 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file `astryx theme targets` leaf — the whole themeable surface, enumerated.
5
+ *
6
+ * @input a cwd (to resolve the project's `@astryxdesign/core`) and an optional
7
+ * component/key filter
8
+ * @output the `theme.targets` envelope: one row per theming target
9
+ * @position api/theme/targets — projection over
10
+ * foundation/discovery/theming-targets.mjs, the same component docs
11
+ * `astryx component <Name>` prints its Theming table from.
12
+ */
13
+
14
+ import * as path from 'node:path';
15
+ import {findCoreDir} from '../../../foundation/fs/paths.mjs';
16
+ import {collectThemingTargets} from '../../../foundation/discovery/theming-targets.mjs';
17
+ import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
18
+ import {AstryxError} from '../../error.mjs';
19
+
20
+ /**
21
+ * Whether a target matches the caller's filter loosely: any target whose key,
22
+ * class, or component contains it — so `theme targets thumb` finds the switch
23
+ * thumb without knowing which component owns it.
24
+ * @param {import('../../../foundation/discovery/theming-targets.mjs').ThemingTarget} target
25
+ * @param {string} filter - already lowercased
26
+ * @returns {boolean}
27
+ */
28
+ function matchesLoosely(target, filter) {
29
+ return (
30
+ target.key.toLowerCase().includes(filter) ||
31
+ target.className.toLowerCase().includes(filter) ||
32
+ target.component.toLowerCase().includes(filter)
33
+ );
34
+ }
35
+
36
+ /**
37
+ * List every component theming target — the `defineTheme` `components` keys,
38
+ * with the props and states each one accepts.
39
+ *
40
+ * A filter naming a component exactly wins over a substring search, so
41
+ * `theme targets Button` is Button's own set (what `astryx component Button`
42
+ * prints) rather than every key that happens to contain "button".
43
+ *
44
+ * @param {string} [filter] - component name, or a substring of a target key
45
+ * @param {{cwd?: string}} [ctx]
46
+ * @returns {Promise<import('../theme.type.mjs').ThemeTargetsResponse>}
47
+ */
48
+ export async function themeTargets(filter, {cwd = process.cwd()} = {}) {
49
+ const coreDir = findCoreDir(cwd);
50
+ if (!coreDir) {
51
+ throw new AstryxError(
52
+ 'Could not find @astryxdesign/core package',
53
+ undefined,
54
+ ERROR_CODES.ERR_CORE_NOT_FOUND,
55
+ );
56
+ }
57
+
58
+ const all = await collectThemingTargets(path.join(coreDir, 'src'));
59
+ const needle = filter ? String(filter).toLowerCase() : null;
60
+ let targets = all;
61
+ if (needle) {
62
+ const named = all.filter(t => t.component.toLowerCase() === needle);
63
+ targets = named.length > 0 ? named : all.filter(t => matchesLoosely(t, needle));
64
+ }
65
+
66
+ if (needle && targets.length === 0) {
67
+ const near = [...new Set(all.map(t => t.component))]
68
+ .filter(name => name.toLowerCase().startsWith(needle.slice(0, 3)))
69
+ .sort()
70
+ .slice(0, 5)
71
+ .map(name => ({name, reason: 'has theming targets'}));
72
+ throw new AstryxError(
73
+ `No theming target matches "${filter}". Run \`theme targets\` with no filter for the whole list.`,
74
+ near.length > 0 ? near : undefined,
75
+ ERROR_CODES.ERR_UNKNOWN_COMPONENT,
76
+ );
77
+ }
78
+
79
+ return {
80
+ type: 'theme.targets',
81
+ data: {
82
+ filter: filter ?? null,
83
+ componentCount: new Set(targets.map(t => t.component)).size,
84
+ targets,
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,66 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Direct-API tests for the `theme targets` leaf. Runs against the real
5
+ * core docs, so it doubles as a guard that the themeable surface stays
6
+ * readable and shaped as `theme.targets` promises.
7
+ */
8
+
9
+ import {describe, it, expect} from 'vitest';
10
+ import {themeTargets} from './targets.mjs';
11
+
12
+ describe('themeTargets (api/theme/targets)', () => {
13
+ it('returns a theme.targets envelope covering the whole surface', async () => {
14
+ const result = await themeTargets();
15
+ expect(result.type).toBe('theme.targets');
16
+ expect(result.data.filter).toBeNull();
17
+ expect(result.data.targets.length).toBeGreaterThan(100);
18
+ expect(result.data.componentCount).toBeGreaterThan(50);
19
+ for (const t of result.data.targets) {
20
+ expect(Object.keys(t).sort()).toEqual([
21
+ 'className',
22
+ 'component',
23
+ 'key',
24
+ 'props',
25
+ 'states',
26
+ ]);
27
+ }
28
+ }, 60_000);
29
+
30
+ it('scopes to one component by name', async () => {
31
+ const {data} = await themeTargets('Switch');
32
+ expect(data.filter).toBe('Switch');
33
+ expect(data.componentCount).toBe(1);
34
+ expect(data.targets.map(t => t.key)).toEqual([
35
+ 'switch',
36
+ 'switch-field',
37
+ 'switch-label',
38
+ 'switch-thumb',
39
+ ]);
40
+ }, 60_000);
41
+
42
+ // Half the system's keys contain "button" (chat-send-button, toggle-button,
43
+ // …). A component name has to mean the component, or `theme targets Button`
44
+ // answers a different question than `component Button` and the two views
45
+ // look like they disagree.
46
+ it('prefers an exact component name over a substring match', async () => {
47
+ const {data} = await themeTargets('Button');
48
+ expect(data.targets.map(t => t.key)).toEqual(['button']);
49
+ }, 60_000);
50
+
51
+ // This command answers "which theme slot paints the switch thumb?" — a
52
+ // question you can only ask by the part, not the component, until you
53
+ // already know which component owns it.
54
+ it('searches keys by substring, across components', async () => {
55
+ const {data} = await themeTargets('thumb');
56
+ expect(data.componentCount).toBeGreaterThan(1);
57
+ expect(data.targets.map(t => t.key)).toContain('switch-thumb');
58
+ for (const t of data.targets) expect(t.key).toContain('thumb');
59
+ }, 60_000);
60
+
61
+ it('rejects a filter that matches nothing, with components to try', async () => {
62
+ await expect(themeTargets('nosuchthing')).rejects.toMatchObject({
63
+ code: 'ERR_UNKNOWN_COMPONENT',
64
+ });
65
+ }, 60_000);
66
+ });
@@ -3,6 +3,7 @@
3
3
 
4
4
  export { themeAdd } from "./add/add.mjs";
5
5
  export { themeTemplate } from "./template/template.mjs";
6
+ export { themeTargets } from "./targets/targets.mjs";
6
7
  export { themeList } from "./list/list.mjs";
7
8
  export { listThemes } from "./_adapter.mjs";
8
9
  export { themeBuild, importSpecifier } from "./build/build.mjs";
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  /**
4
- * @file `theme` command barrel — re-exports the build/add/list leaves so the CLI
4
+ * @file `theme` command barrel — re-exports the build/add/list/template/targets
5
+ * leaves so the CLI
5
6
  * (cli/commands/build-theme.mjs) and scripted callers import from one place.
6
7
  * Each leaf is also importable directly (e.g. api/theme/add/add.mjs). `theme`
7
8
  * has real subcommands, so there is no flag-dispatch here — the CLI calls the
@@ -11,5 +12,6 @@
11
12
  export {themeBuild, importSpecifier} from './build/build.mjs';
12
13
  export {themeAdd} from './add/add.mjs';
13
14
  export {themeTemplate} from './template/template.mjs';
15
+ export {themeTargets} from './targets/targets.mjs';
14
16
  export {themeList} from './list/list.mjs';
15
17
  export {listThemes} from './_adapter.mjs';
@@ -101,3 +101,26 @@ export type ThemeTemplateResponse = {
101
101
  reason: "exists" | null;
102
102
  };
103
103
  };
104
+ /**
105
+ * One themeable target: the `defineTheme` `components` key, the class it
106
+ * renders as, the component whose doc declares it, and the props and states
107
+ * that are legal override keys under it.
108
+ */
109
+ export type ThemeTargetEntry = {
110
+ key: string;
111
+ className: string;
112
+ component: string;
113
+ props: string[];
114
+ states: string[];
115
+ };
116
+ /**
117
+ * xds --json theme targets [filter]
118
+ */
119
+ export type ThemeTargetsResponse = {
120
+ type: "theme.targets";
121
+ data: {
122
+ filter: string | null;
123
+ componentCount: number;
124
+ targets: ThemeTargetEntry[];
125
+ };
126
+ };
@@ -14,9 +14,10 @@
14
14
  * xds --json theme list -> theme.list
15
15
  * xds --json theme add <slug> -> theme.add
16
16
  * xds --json theme template -> theme.template
17
+ * xds --json theme targets [filter] -> theme.targets
17
18
  * (file not found / parse error) -> CLIError
18
19
  *
19
- * @position api — colocated typedefs for api/theme/{theme,build,add,list,template,_adapter}
20
+ * @position api — colocated typedefs for api/theme/{theme,build,add,list,template,targets,_adapter}
20
21
  */
21
22
 
22
23
  /**
@@ -78,6 +79,25 @@
78
79
  * @property {{path: string, written: boolean, reason: 'exists' | null}} data
79
80
  */
80
81
 
82
+ /**
83
+ * One themeable target: the `defineTheme` `components` key, the class it
84
+ * renders as, the component whose doc declares it, and the props and states
85
+ * that are legal override keys under it.
86
+ * @typedef {object} ThemeTargetEntry
87
+ * @property {string} key
88
+ * @property {string} className
89
+ * @property {string} component
90
+ * @property {string[]} props
91
+ * @property {string[]} states
92
+ */
93
+
94
+ /**
95
+ * xds --json theme targets [filter]
96
+ * @typedef {object} ThemeTargetsResponse
97
+ * @property {'theme.targets'} type
98
+ * @property {{filter: string | null, componentCount: number, targets: ThemeTargetEntry[]}} data
99
+ */
100
+
81
101
  // Make this a module so the @typedefs above are importable as types via
82
102
  // `import('./theme.type.mjs').ThemeBuildResponse` (and re-exportable from a .d.ts).
83
103
  export {};
@@ -0,0 +1,11 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
6
+ * with the API function it documents; the response-shape source of truth stays
7
+ * in `theme.type.mjs`.
8
+ * @position packages/cli/api/theme — function documentation
9
+ */
10
+ /** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
11
+ export const doc: import("@astryxdesign/cli/authoring").FunctionDoc;
@@ -0,0 +1,58 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file FunctionDoc for `themeTargets()` / `astryx theme targets`. Colocated
5
+ * with the API function it documents; the response-shape source of truth stays
6
+ * in `theme.type.mjs`.
7
+ * @position packages/cli/api/theme — function documentation
8
+ */
9
+
10
+ /** @type {import('@astryxdesign/cli/authoring').FunctionDoc} */
11
+ export const doc = {
12
+ type: 'function',
13
+ kind: 'api',
14
+ name: 'themeTargets',
15
+ displayName: 'themeTargets()',
16
+ summary: 'List every component theming target a theme can override.',
17
+ description:
18
+ 'Enumerates the whole themeable surface: each `defineTheme` components key, the stable ' +
19
+ 'class it paints, the component that declares it, and the props and states that are legal ' +
20
+ 'override keys under it. Same source as the Theming table `astryx component <Name>` prints ' +
21
+ '(the component docs), so the list cannot drift from the components, and `theme build` ' +
22
+ 'validates overrides against this exact set. A filter naming a component gives that ' +
23
+ 'component\'s set; anything else is a substring search over the keys.',
24
+ importPath: '@astryxdesign/cli/api',
25
+ signature:
26
+ 'themeTargets(filter?: string, ctx?: {cwd?: string}): Promise<ThemeTargetsResponse>',
27
+ keywords: ['theme', 'targets', 'defineTheme', 'components', 'override', 'class', 'states', 'audit'],
28
+ params: [
29
+ {
30
+ name: 'filter',
31
+ type: 'string',
32
+ description:
33
+ 'A component name (exact, case-insensitive) or a substring of a target key. Omit for the whole surface.',
34
+ },
35
+ {
36
+ name: 'ctx.cwd',
37
+ type: 'string',
38
+ description: 'Directory the project\'s @astryxdesign/core is resolved from.',
39
+ },
40
+ ],
41
+ returns: [
42
+ {
43
+ type: 'theme.targets',
44
+ description:
45
+ 'The echoed filter, how many components are represented, and the targets: each {key, className, component, props, states}.',
46
+ },
47
+ ],
48
+ throws: [
49
+ {code: 'ERR_CORE_NOT_FOUND', when: '@astryxdesign/core cannot be resolved from cwd'},
50
+ {code: 'ERR_UNKNOWN_COMPONENT', when: 'a filter matches no target'},
51
+ ],
52
+ examples: [
53
+ {label: 'The whole themeable surface', code: 'await themeTargets();'},
54
+ {label: "One component's targets", code: "await themeTargets('Switch');"},
55
+ ],
56
+ command: 'theme targets',
57
+ related: ['themeBuild', 'themeTemplate', 'component'],
58
+ };
@@ -15,9 +15,9 @@ export const doc = {
15
15
  displayName: 'themeTemplate()',
16
16
  summary: 'Write the annotated theme template into a project.',
17
17
  description:
18
- 'Writes theme.template.ts: the annotated reference for the whole theme surface every ' +
18
+ 'Writes theme.template.ts: the annotated reference for the whole theme surface, covering every ' +
19
19
  'defineTheme field, the token families, the component override syntax, and how a theme is ' +
20
- 'consumed with the CLI command that prints the authoritative reference for each section. ' +
20
+ 'consumed, with the CLI command that prints the authoritative reference for each section. ' +
21
21
  'Read it, copy what you need into your own theme file, delete it. Where `theme add` starts ' +
22
22
  'you from a theme we ship, this starts you from a blank one. Refuses to overwrite without ' +
23
23
  '`overwrite`, so it is safe to re-run.',
@@ -48,7 +48,7 @@ export const doc = {
48
48
  {
49
49
  type: 'theme.template',
50
50
  description:
51
- 'Receipt: the path (relative to cwd), whether it was written, and the reason it was not `exists` when a file was already there, which is a success, not a failure.',
51
+ 'Receipt: the path (relative to cwd), whether it was written, and the reason it was not. `exists` when a file was already there, which is a success, not a failure.',
52
52
  },
53
53
  ],
54
54
  throws: [{code: 'ERR_PATH_TRAVERSAL', when: 'the target path escapes cwd'}],
@@ -26,6 +26,7 @@ describe('registry', () => {
26
26
  '0.2.1',
27
27
  '0.3.0',
28
28
  '0.4.0',
29
+ '0.5.0',
29
30
  ]);
30
31
  });
31
32
  });
@@ -27,6 +27,7 @@ const registry = new Map([
27
27
  ['0.2.1', () => import('./transforms/v0.2.1/index.mjs')],
28
28
  ['0.3.0', () => import('./transforms/v0.3.0/index.mjs')],
29
29
  ['0.4.0', () => import('./transforms/v0.4.0/index.mjs')],
30
+ ['0.5.0', () => import('./transforms/v0.5.0/index.mjs')],
30
31
  ]);
31
32
 
32
33
  // Re-export from the shared utility so registry callers and other consumers
@@ -0,0 +1,127 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Unit tests for the staged (next-release) codemods.
5
+ *
6
+ * Mirrors v0.4.0/__tests__/next-codemods.test.mjs, which covers the codemods
7
+ * after promotion. Keeping a copy here means a staged transform is tested from
8
+ * the day it is written rather than the day it is released.
9
+ */
10
+
11
+ import {describe, expect, it} from 'vitest';
12
+ import jscodeshift from 'jscodeshift';
13
+
14
+ const j = jscodeshift.withParser('tsx');
15
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
16
+
17
+ async function apply(name, source) {
18
+ const {default: transform} = await import(`../${name}.mjs`);
19
+ return transform({source, path: 'test.tsx'}, api) ?? source;
20
+ }
21
+
22
+ const TRANSFORM = 'banner-collapsible-content';
23
+
24
+ const IMPORT = "import {Banner} from '@astryxdesign/core/Banner';\n";
25
+
26
+ describe('banner-collapsible-content', () => {
27
+ it('rewrites a bare defaultIsExpanded to a starts-open config', async () => {
28
+ const output = await apply(
29
+ TRANSFORM,
30
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
31
+ );
32
+ expect(output).toContain('defaultIsOpen: true');
33
+ expect(output).not.toContain('defaultIsExpanded');
34
+ });
35
+
36
+ it('rewrites defaultIsExpanded={true} the same way', async () => {
37
+ const output = await apply(
38
+ TRANSFORM,
39
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={true}><p>d</p></Banner>;`,
40
+ );
41
+ expect(output).toContain('defaultIsOpen: true');
42
+ expect(output).not.toContain('defaultIsExpanded');
43
+ });
44
+
45
+ it('drops defaultIsExpanded={false}, which is the default', async () => {
46
+ const output = await apply(
47
+ TRANSFORM,
48
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={false}><p>d</p></Banner>;`,
49
+ );
50
+ expect(output).not.toContain('defaultIsExpanded');
51
+ // No config needed: starting collapsed is what a Banner does by default.
52
+ expect(output).not.toContain('collapsible');
53
+ // Untouched attributes keep their original text (recast only reprints
54
+ // what changed), so the element is exactly the base minus the prop.
55
+ expect(output).toContain('<Banner status="info" title="T">');
56
+ });
57
+
58
+ it('keeps a dynamic default dynamic', async () => {
59
+ const output = await apply(
60
+ TRANSFORM,
61
+ `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded={isOpen}><p>d</p></Banner>;`,
62
+ );
63
+ expect(output).toContain('defaultIsOpen: isOpen');
64
+ expect(output).not.toContain('defaultIsExpanded');
65
+ });
66
+
67
+ it('leaves a banner that never set the prop alone', async () => {
68
+ // The default is unchanged, so this banner still behaves as it did. The
69
+ // migration must not touch it — that is the whole point of the shape.
70
+ const source = `${IMPORT}const el = <Banner status="error" title="T"><ul><li>a</li></ul></Banner>;`;
71
+ const output = await apply(TRANSFORM, source);
72
+ expect(output).toBe(source);
73
+ });
74
+
75
+ it('leaves a childless banner alone', async () => {
76
+ const source = `${IMPORT}const el = <Banner status="info" title="T" />;`;
77
+ const output = await apply(TRANSFORM, source);
78
+ expect(output).toBe(source);
79
+ });
80
+
81
+ it('leaves a banner that already uses collapsible alone', async () => {
82
+ const source = `${IMPORT}const el = <Banner status="info" title="T" collapsible={false} defaultIsExpanded><p>d</p></Banner>;`;
83
+ const output = await apply(TRANSFORM, source);
84
+ expect(output).toBe(source);
85
+ });
86
+
87
+ it('does not guess around a spread', async () => {
88
+ const source = `${IMPORT}const el = <Banner status="info" title="T" defaultIsExpanded {...rest}><p>d</p></Banner>;`;
89
+ const output = await apply(TRANSFORM, source);
90
+ expect(output).toBe(source);
91
+ });
92
+
93
+ it("leaves another component's defaultIsExpanded alone", async () => {
94
+ // ChatToolCalls has a prop of the same name that this migration must not
95
+ // touch.
96
+ const source = `import {ChatToolCalls} from '@astryxdesign/core/Chat';
97
+ const el = <ChatToolCalls calls={calls} defaultIsExpanded />;`;
98
+ const output = await apply(TRANSFORM, source);
99
+ expect(output).toBe(source);
100
+ });
101
+
102
+ it('leaves a Banner that is not the core Banner alone', async () => {
103
+ const source = `import {Banner} from './ui/Banner';
104
+ const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`;
105
+ const output = await apply(TRANSFORM, source);
106
+ expect(output).toBe(source);
107
+ });
108
+
109
+ it('leaves the prop inside a props object alone', async () => {
110
+ // Out of scope by design: which component the object feeds is a guess,
111
+ // and the removed prop makes those sites a type error anyway.
112
+ const source = `${IMPORT}const args = {status: 'info', title: 'T', defaultIsExpanded: true};
113
+ const el = <Banner {...args} />;`;
114
+ const output = await apply(TRANSFORM, source);
115
+ expect(output).toBe(source);
116
+ });
117
+
118
+ it('migrates a Banner imported from the package root', async () => {
119
+ const output = await apply(
120
+ TRANSFORM,
121
+ `import {Banner, Button} from '@astryxdesign/core';
122
+ const el = <Banner status="info" title="T" defaultIsExpanded><p>d</p></Banner>;`,
123
+ );
124
+ expect(output).toContain('defaultIsOpen: true');
125
+ expect(output).not.toContain('defaultIsExpanded');
126
+ });
127
+ });