@astryxdesign/cli 0.4.7 → 0.5.0-canary.32a62fd

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 (85) 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/template.doc.mjs +2 -2
  9. package/api/theme/build/build.mjs +12 -42
  10. package/api/theme/targets/targets.d.mts +18 -0
  11. package/api/theme/targets/targets.mjs +87 -0
  12. package/api/theme/targets/targets.test.mjs +66 -0
  13. package/api/theme/theme.d.mts +1 -0
  14. package/api/theme/theme.mjs +3 -1
  15. package/api/theme/theme.type.d.mts +23 -0
  16. package/api/theme/theme.type.mjs +21 -1
  17. package/api/theme/themeTargets.doc.d.mts +11 -0
  18. package/api/theme/themeTargets.doc.mjs +58 -0
  19. package/api/theme/themeTemplate.doc.mjs +3 -3
  20. package/assets/codemods/__tests__/registry.test.mjs +1 -0
  21. package/assets/codemods/registry.mjs +1 -0
  22. package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
  23. package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
  24. package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
  25. package/assets/docs/README.md +50 -0
  26. package/assets/docs/cli-integrations.doc.mjs +4 -4
  27. package/assets/docs/theme.doc.dense.mjs +1 -1
  28. package/assets/docs/theme.doc.mjs +1 -1
  29. package/assets/docs/typography.doc.mjs +2 -2
  30. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
  31. package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
  32. package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
  33. package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
  34. package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
  35. package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
  36. package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
  37. package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
  38. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
  39. package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
  40. package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
  41. package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
  42. package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
  43. package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
  44. package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
  45. package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
  46. package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
  47. package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
  48. package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
  49. package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
  50. package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
  51. package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
  52. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
  53. package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
  54. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
  55. package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
  56. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
  57. package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
  58. package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
  59. package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
  60. package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
  61. package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
  62. package/assets/templates/pages/table-grouped/page.tsx +151 -144
  63. package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
  64. package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
  65. package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
  66. package/clients/cli/commands/build-theme.mjs +85 -0
  67. package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
  68. package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
  69. package/clients/cli/commands/theme-targets.doc.mjs +38 -0
  70. package/clients/cli/commands/theme-template.doc.mjs +2 -2
  71. package/clients/cli/commands/theme.doc.mjs +4 -2
  72. package/clients/cli/index.mjs +1 -0
  73. package/clients/cli/lib/component-format.mjs +2 -2
  74. package/clients/cli/lib/manifest.mjs +2 -0
  75. package/foundation/discovery/theming-targets.d.mts +86 -0
  76. package/foundation/discovery/theming-targets.mjs +202 -0
  77. package/foundation/discovery/theming-targets.test.mjs +245 -0
  78. package/foundation/response/response-types.doc.mjs +7 -2
  79. package/package.json +9 -9
  80. package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
  81. package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
  82. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
  83. package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
  84. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
  85. package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
@@ -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\u2019s 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
+ });
@@ -0,0 +1,171 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: migrate Banner's `defaultIsExpanded` onto `collapsible`
5
+ *
6
+ * Banner's collapse axis used to be a single knob, `defaultIsExpanded`, with
7
+ * the disclosure itself inferred from the presence of `children`. There was no
8
+ * way to have content without a toggle, and no controlled mode. The axis now
9
+ * lives on one `boolean | CollapsibleConfig` prop, per the boolean-or-config
10
+ * convention:
11
+ *
12
+ * <Banner>{children}</Banner> → collapsible, starts closed
13
+ * <Banner collapsible={{defaultIsOpen: true}}> → collapsible, starts open
14
+ * <Banner collapsible={{isOpen, onOpenChange}}> → controlled
15
+ * <Banner collapsible={false}> → always visible, no toggle
16
+ *
17
+ * The default is unchanged, so a Banner that never mentioned the old prop needs
18
+ * no rewrite at all — this transform is a prop rename and nothing more:
19
+ *
20
+ * defaultIsExpanded → collapsible={{defaultIsOpen: true}}
21
+ * defaultIsExpanded={true} → collapsible={{defaultIsOpen: true}}
22
+ * defaultIsExpanded={false} → (removed — it is the default)
23
+ * defaultIsExpanded={expr} → collapsible={{defaultIsOpen: expr}}
24
+ *
25
+ * Only elements named `Banner` are touched, and only when the file imports that
26
+ * name from `@astryxdesign/core`: `defaultIsExpanded` is also a ChatToolCalls
27
+ * prop, which this migration must leave alone.
28
+ *
29
+ * Scope: JSX attributes only. `defaultIsExpanded` inside a props object (a
30
+ * Storybook `args`, a spread built up in a variable) is left alone rather than
31
+ * rewritten on a guess about which component the object is for — removing the
32
+ * prop from the type makes those sites a type error, which is loud enough to
33
+ * find them.
34
+ */
35
+
36
+ export const meta = {
37
+ title: "Rename Banner's `defaultIsExpanded` to the `collapsible` config",
38
+ description:
39
+ "Banner's collapse axis is now a single `collapsible?: boolean | " +
40
+ 'CollapsibleConfig` prop. Rewrites `defaultIsExpanded` to the equivalent ' +
41
+ 'config (`{defaultIsOpen: true}`), and drops ' +
42
+ '`defaultIsExpanded={false}`, which is the default. Banners that never ' +
43
+ 'set the prop are untouched — the default is unchanged. Pass ' +
44
+ '`collapsible={false}` for content that is always visible.',
45
+ pr: '#5255',
46
+ };
47
+
48
+ const OLD_PROP = 'defaultIsExpanded';
49
+ const NEW_PROP = 'collapsible';
50
+ const COMPONENT = 'Banner';
51
+
52
+ /**
53
+ * Does this file use the core `Banner`?
54
+ *
55
+ * `defaultIsExpanded` is a ChatToolCalls prop too, and a local component may
56
+ * well be called Banner, so an unqualified element-name match is not enough.
57
+ *
58
+ * @param {any} j
59
+ * @param {any} root
60
+ * @returns {boolean}
61
+ */
62
+ function importsCoreBanner(j, root) {
63
+ let found = false;
64
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
65
+ const source = path.node.source?.value;
66
+ if (typeof source !== 'string' || !source.startsWith('@astryxdesign/core')) {
67
+ return;
68
+ }
69
+ for (const spec of path.node.specifiers ?? []) {
70
+ if (
71
+ (spec.type === 'ImportSpecifier' && spec.imported?.name === COMPONENT) ||
72
+ (spec.local?.name === COMPONENT &&
73
+ (spec.type === 'ImportDefaultSpecifier' ||
74
+ spec.type === 'ImportSpecifier'))
75
+ ) {
76
+ found = true;
77
+ }
78
+ }
79
+ });
80
+ return found;
81
+ }
82
+
83
+ /**
84
+ * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file
85
+ * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api
86
+ * @returns {string | null | undefined}
87
+ */
88
+ export default function transformer(file, api) {
89
+ // Cheap bail-out: nothing to rename without the old prop.
90
+ if (!file.source.includes(OLD_PROP)) {
91
+ return undefined;
92
+ }
93
+
94
+ const j = api.jscodeshift;
95
+ const root = j(file.source);
96
+
97
+ if (!importsCoreBanner(j, root)) {
98
+ return undefined;
99
+ }
100
+
101
+ let hasChanges = false;
102
+
103
+ /** `collapsible={{defaultIsOpen: <expr>}}` */
104
+ const collapsibleWithDefault = (/** @type {any} */ expression) =>
105
+ j.jsxAttribute(
106
+ j.jsxIdentifier(NEW_PROP),
107
+ j.jsxExpressionContainer(
108
+ j.objectExpression([
109
+ j.objectProperty(j.identifier('defaultIsOpen'), expression),
110
+ ]),
111
+ ),
112
+ );
113
+
114
+ root.find(j.JSXOpeningElement).forEach((/** @type {any} */ path) => {
115
+ const name = path.node.name;
116
+ if (name?.type !== 'JSXIdentifier' || name.name !== COMPONENT) {
117
+ return;
118
+ }
119
+
120
+ const attrs = path.node.attributes ?? [];
121
+ const oldIndex = attrs.findIndex(
122
+ (/** @type {any} */ a) =>
123
+ a.type === 'JSXAttribute' && a.name?.name === OLD_PROP,
124
+ );
125
+ if (oldIndex === -1) {
126
+ return;
127
+ }
128
+
129
+ // Already migrated by hand, or a spread that may carry either prop:
130
+ // in both cases a rewrite would be guesswork.
131
+ const hasNewProp = attrs.some(
132
+ (/** @type {any} */ a) =>
133
+ a.type === 'JSXAttribute' && a.name?.name === NEW_PROP,
134
+ );
135
+ const hasSpread = attrs.some(
136
+ (/** @type {any} */ a) => a.type === 'JSXSpreadAttribute',
137
+ );
138
+ if (hasNewProp || hasSpread) {
139
+ return;
140
+ }
141
+
142
+ const value = attrs[oldIndex].value;
143
+
144
+ if (value == null) {
145
+ // Bare `defaultIsExpanded` — starts open.
146
+ attrs[oldIndex] = collapsibleWithDefault(j.booleanLiteral(true));
147
+ } else if (value.type === 'JSXExpressionContainer') {
148
+ const expression = value.expression;
149
+ const isBooleanLiteral =
150
+ expression.type === 'BooleanLiteral' ||
151
+ (expression.type === 'Literal' && typeof expression.value === 'boolean');
152
+ if (isBooleanLiteral && expression.value === false) {
153
+ // Starting closed is the default now, so the prop simply goes.
154
+ attrs.splice(oldIndex, 1);
155
+ } else if (isBooleanLiteral) {
156
+ attrs[oldIndex] = collapsibleWithDefault(j.booleanLiteral(true));
157
+ } else {
158
+ // A dynamic default stays dynamic.
159
+ attrs[oldIndex] = collapsibleWithDefault(expression);
160
+ }
161
+ } else {
162
+ // `defaultIsExpanded="something"` is not valid for a boolean prop;
163
+ // leave it for a human rather than inventing a meaning.
164
+ return;
165
+ }
166
+
167
+ hasChanges = true;
168
+ });
169
+
170
+ return hasChanges ? root.toSource({quote: 'single'}) : undefined;
171
+ }
@@ -0,0 +1,20 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file next transform manifest
5
+ *
6
+ * Staged codemods for the next release. The Version Packages PR promotes
7
+ * this file into the resolved version folder.
8
+ */
9
+
10
+ import bannerCollapsibleContent, {
11
+ meta as bannerCollapsibleContentMeta,
12
+ } from './banner-collapsible-content.mjs';
13
+
14
+ export default [
15
+ {
16
+ name: 'banner-collapsible-content',
17
+ transform: bannerCollapsibleContent,
18
+ meta: bannerCollapsibleContentMeta,
19
+ },
20
+ ];