@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3

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 (113) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -0,0 +1,182 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Programmatic API for `astryx theme add` — copies a bundled theme's
5
+ * source (`templates/themes/`, see scripts/generate-cli-themes.mjs) into the
6
+ * consumer's project so they own it, without needing the theme package.
7
+ */
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import {CLI_ROOT} from '../utils/paths.mjs';
12
+ import {assertWithin, PathSafetyError} from '../utils/path-safety.mjs';
13
+ import {AstryxError} from './error.mjs';
14
+ import {ERROR_CODES} from '../lib/error-codes.mjs';
15
+
16
+ const THEMES_DIR = path.join(CLI_ROOT, 'templates', 'themes');
17
+ const MANIFEST_PATH = path.join(THEMES_DIR, 'manifest.json');
18
+
19
+ // Stripped from scaffolded files so the consumer's copy doesn't carry our
20
+ // repo boilerplate (mirrors the docsite). Preserves a leading BOM/shebang.
21
+ const META_COPYRIGHT_HEADER_RE =
22
+ /^(\uFEFF?(?:#![^\r\n]*(?:\r?\n))?)\/\/ Copyright \(c\) Meta Platforms, Inc\. and affiliates\.\r?\n(?:\r?\n)*/;
23
+
24
+ function stripCopyrightHeader(source) {
25
+ return source.replace(META_COPYRIGHT_HEADER_RE, '$1');
26
+ }
27
+
28
+ /** Parsed `themes` array from the bundle manifest (empty if not generated). */
29
+ export function listThemes() {
30
+ if (!fs.existsSync(MANIFEST_PATH)) return [];
31
+ let manifest;
32
+ try {
33
+ manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
34
+ } catch (err) {
35
+ throw new AstryxError(
36
+ `Theme bundle manifest is unreadable (${MANIFEST_PATH}): ${err.message}`,
37
+ undefined,
38
+ ERROR_CODES.ERR_NO_SOURCE,
39
+ );
40
+ }
41
+ return Array.isArray(manifest.themes) ? manifest.themes : [];
42
+ }
43
+
44
+ function findTheme(slug) {
45
+ if (!slug) return undefined;
46
+ const lc = String(slug).toLowerCase();
47
+ return listThemes().find(t => t.slug.toLowerCase() === lc);
48
+ }
49
+
50
+ function defaultTargetDir(slug) {
51
+ return path.join('src', 'themes', slug);
52
+ }
53
+
54
+ /**
55
+ * Resolve the scaffold plan for `theme add`. Returns the theme list when no
56
+ * slug (or `list`); otherwise copies the theme's files into the destination
57
+ * (defaults to `src/themes/<slug>/`).
58
+ *
59
+ * @param {string} [slug]
60
+ * @param {{list?: boolean, targetPath?: string, overwrite?: boolean, cwd?: string}} [options]
61
+ * @returns {Promise<{type: string, data: unknown}>}
62
+ */
63
+ export async function themeAdd(slug, options = {}) {
64
+ const {list = false, targetPath, overwrite = false, cwd = process.cwd()} =
65
+ options;
66
+ const themes = listThemes();
67
+
68
+ if (list || !slug) {
69
+ return {
70
+ type: 'theme.list',
71
+ data: themes.map(t => ({
72
+ slug: t.slug,
73
+ displayName: t.displayName,
74
+ description: t.description,
75
+ maintained: t.maintained,
76
+ })),
77
+ };
78
+ }
79
+
80
+ const match = findTheme(slug);
81
+ if (!match) {
82
+ throw new AstryxError(
83
+ `Unknown theme "${slug}"`,
84
+ themes.map(t => ({
85
+ name: t.slug,
86
+ reason: t.maintained ? 'maintained theme' : 'example theme',
87
+ })),
88
+ ERROR_CODES.ERR_UNKNOWN_THEME,
89
+ );
90
+ }
91
+
92
+ const themeSrcDir = path.join(THEMES_DIR, match.slug);
93
+
94
+ // Path-safe destination; reject traversal outside cwd.
95
+ const rawTarget = targetPath || defaultTargetDir(match.slug);
96
+ let resolvedDir;
97
+ try {
98
+ resolvedDir = assertWithin(rawTarget, cwd, {label: 'theme target path'});
99
+ } catch (err) {
100
+ if (err instanceof PathSafetyError) {
101
+ throw new AstryxError(
102
+ err.message,
103
+ undefined,
104
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
105
+ );
106
+ }
107
+ throw err;
108
+ }
109
+
110
+ const writes = match.files.map(name => ({
111
+ name,
112
+ src: path.join(themeSrcDir, name),
113
+ dest: path.join(resolvedDir, name),
114
+ }));
115
+ for (const w of writes) {
116
+ if (!fs.existsSync(w.src)) {
117
+ throw new AstryxError(
118
+ `Theme "${match.slug}" is missing bundled file "${w.name}". ` +
119
+ `Re-run \`node scripts/generate-cli-themes.mjs\` to rebuild the bundle.`,
120
+ undefined,
121
+ ERROR_CODES.ERR_NO_SOURCE,
122
+ );
123
+ }
124
+ }
125
+
126
+ // Refuse to clobber unless --overwrite.
127
+ if (!overwrite) {
128
+ const existing = writes.find(w => fs.existsSync(w.dest));
129
+ if (existing) {
130
+ const rel = path.relative(cwd, existing.dest) || existing.dest;
131
+ throw new AstryxError(
132
+ `Refusing to overwrite existing file ${rel}. ` +
133
+ `Re-run with --overwrite (or -f) to replace it.`,
134
+ undefined,
135
+ ERROR_CODES.ERR_FILE_EXISTS,
136
+ );
137
+ }
138
+ }
139
+
140
+ // Stage to temp files then rename, rolling back partials on failure so a
141
+ // failed write never leaves a half-written theme.
142
+ fs.mkdirSync(resolvedDir, {recursive: true});
143
+ const staged = [];
144
+ try {
145
+ for (const w of writes) {
146
+ const tmp = `${w.dest}.${process.pid}.tmp`;
147
+ const contents = stripCopyrightHeader(fs.readFileSync(w.src, 'utf-8'));
148
+ fs.writeFileSync(tmp, contents);
149
+ staged.push({tmp, dest: w.dest});
150
+ }
151
+ for (const s of staged) {
152
+ fs.renameSync(s.tmp, s.dest);
153
+ }
154
+ } catch (err) {
155
+ for (const s of staged) {
156
+ try {
157
+ fs.rmSync(s.tmp, {force: true});
158
+ } catch {
159
+ /* best-effort */
160
+ }
161
+ }
162
+ throw new AstryxError(
163
+ `Failed to write theme files: ${err.message}`,
164
+ undefined,
165
+ ERROR_CODES.ERR_WRITE_FAILED,
166
+ );
167
+ }
168
+
169
+ const relDir = path.relative(cwd, resolvedDir) || '.';
170
+ return {
171
+ type: 'theme.add',
172
+ data: {
173
+ slug: match.slug,
174
+ displayName: match.displayName,
175
+ maintained: match.maintained,
176
+ outputDir: relDir,
177
+ entry: match.entry,
178
+ exportName: match.exportName,
179
+ files: match.files,
180
+ },
181
+ };
182
+ }
@@ -17,6 +17,7 @@ describe('registry', () => {
17
17
  '0.0.14',
18
18
  '0.0.15',
19
19
  '0.1.0',
20
+ '0.1.2',
20
21
  ]);
21
22
  });
22
23
  });
@@ -18,6 +18,7 @@ const registry = new Map([
18
18
  ['0.0.14', () => import('./transforms/v0.0.14/index.mjs')],
19
19
  ['0.0.15', () => import('./transforms/v0.0.15/index.mjs')],
20
20
  ['0.1.0', () => import('./transforms/v0.1.0/index.mjs')],
21
+ ['0.1.2', () => import('./transforms/v0.1.2/index.mjs')],
21
22
  ]);
22
23
 
23
24
  // Re-export from the shared utility so registry callers and other consumers
@@ -0,0 +1,120 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, it, expect} from 'vitest';
4
+
5
+ async function applyTransform(source) {
6
+ const {default: transform} =
7
+ await import('../rename-text-color-active-to-accent.mjs');
8
+ const jscodeshift = (await import('jscodeshift')).default;
9
+ const j = jscodeshift.withParser('tsx');
10
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
11
+ const file = {source, path: 'test.tsx'};
12
+ const result = transform(file, api);
13
+ return result ?? source;
14
+ }
15
+
16
+ describe('rename-text-color-active-to-accent', () => {
17
+ it('renames Text color="active" to color="accent"', async () => {
18
+ const input = `<Text color="active">Hi</Text>`;
19
+ const output = await applyTransform(input);
20
+ expect(output).toContain("'accent'");
21
+ expect(output).not.toContain('active');
22
+ });
23
+
24
+ it('renames Heading color="active" to color="accent"', async () => {
25
+ const input = `<Heading level={2} color="active">Title</Heading>`;
26
+ const output = await applyTransform(input);
27
+ expect(output).toContain("'accent'");
28
+ expect(output).not.toContain('active');
29
+ });
30
+
31
+ it('renames Link color="active" to color="accent"', async () => {
32
+ const input = `<Link href="/x" color="active">Go</Link>`;
33
+ const output = await applyTransform(input);
34
+ expect(output).toContain("'accent'");
35
+ expect(output).not.toContain('active');
36
+ });
37
+
38
+ it('renames Timestamp color="active" to color="accent"', async () => {
39
+ const input = `<Timestamp value={d} color="active" />`;
40
+ const output = await applyTransform(input);
41
+ expect(output).toContain("'accent'");
42
+ expect(output).not.toContain('active');
43
+ });
44
+
45
+ it("renames expression-container color={'active'}", async () => {
46
+ const input = `<Text color={'active'}>Hi</Text>`;
47
+ const output = await applyTransform(input);
48
+ expect(output).toContain("'accent'");
49
+ expect(output).not.toContain("'active'");
50
+ });
51
+
52
+ it('renames active inside a ternary color expression', async () => {
53
+ const input = `<Text color={isOn ? 'active' : 'secondary'}>Hi</Text>`;
54
+ const output = await applyTransform(input);
55
+ expect(output).toContain("'accent'");
56
+ expect(output).toContain("'secondary'");
57
+ expect(output).not.toContain("'active'");
58
+ });
59
+
60
+ it('does not change other color values', async () => {
61
+ const input = `<Text color="secondary">Hi</Text>`;
62
+ const output = await applyTransform(input);
63
+ expect(output).toBe(input);
64
+ });
65
+
66
+ it('does not touch color="active" on non-target components', async () => {
67
+ const input = `<StatusDot color="active" />`;
68
+ const output = await applyTransform(input);
69
+ expect(output).toBe(input);
70
+ });
71
+
72
+ it('does not touch unrelated "active" string literals', async () => {
73
+ const input = `const node = {id: 'active', label: 'Active Users'};`;
74
+ const output = await applyTransform(input);
75
+ expect(output).toBe(input);
76
+ });
77
+
78
+ it('does not touch a status enum value named active', async () => {
79
+ const input = `const filters = {status: 'active'};`;
80
+ const output = await applyTransform(input);
81
+ expect(output).toBe(input);
82
+ });
83
+
84
+ it('renames object color prop in files importing a target component', async () => {
85
+ const input = `import {Text} from '@xds/core/Text';
86
+ const cfg = {color: 'active'};`;
87
+ const output = await applyTransform(input);
88
+ expect(output).toContain("color: 'accent'");
89
+ expect(output).not.toContain("'active'");
90
+ });
91
+
92
+ it("renames color: 'active' as const in files importing a target component", async () => {
93
+ const input = `import {Text} from '@xds/core/Text';
94
+ const COLORS = [{color: 'active' as const, description: 'Accent'}];`;
95
+ const output = await applyTransform(input);
96
+ expect(output).toContain("'accent' as const");
97
+ expect(output).not.toContain("'active'");
98
+ });
99
+
100
+ it('does not rename object color prop when no target component is imported', async () => {
101
+ const input = `const cfg = {color: 'active'};`;
102
+ const output = await applyTransform(input);
103
+ expect(output).toBe(input);
104
+ });
105
+
106
+ it('renames active in Storybook color argTypes options', async () => {
107
+ const input = `import {Text} from '@xds/core/Text';
108
+ const meta = {argTypes: {color: {control: 'select', options: ['primary', 'active', 'inherit']}}};`;
109
+ const output = await applyTransform(input);
110
+ expect(output).toContain("'accent'");
111
+ expect(output).not.toContain("'active'");
112
+ });
113
+
114
+ it('returns undefined (no change) when nothing matches', async () => {
115
+ const input = `<Text color="primary">Hi</Text>`;
116
+ const output = await applyTransform(input);
117
+ // applyTransform falls back to source when transform returns undefined
118
+ expect(output).toBe(input);
119
+ });
120
+ });
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file v0.1.2 transform manifest
5
+ *
6
+ * Lists all codemods for the v0.1.2 release in the order they should run.
7
+ */
8
+
9
+ import renameTextColorActiveToAccent, {
10
+ meta as renameTextColorActiveToAccentMeta,
11
+ } from './rename-text-color-active-to-accent.mjs';
12
+
13
+ export default [
14
+ {
15
+ name: 'rename-text-color-active-to-accent',
16
+ transform: renameTextColorActiveToAccent,
17
+ meta: renameTextColorActiveToAccentMeta,
18
+ },
19
+ ];
@@ -0,0 +1,136 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Codemod: Rename Text/Heading/Link/Timestamp color "active" to "accent"
5
+ * @see https://github.com/facebook/astryx/issues/2863
6
+ *
7
+ * The `active` text-color value is renamed to `accent` and now maps to the
8
+ * dedicated `--color-text-accent` token (legible accent ink) instead of
9
+ * `--color-accent` (the interactive/base accent surface). This gives a
10
+ * first-class, type-safe way to render accent-colored text and resolves the
11
+ * gap reported in #2863.
12
+ *
13
+ * Transforms (scoped to Text, Heading, Link, and Timestamp):
14
+ * - <Text color="active" /> → <Text color="accent" />
15
+ * - <Heading color={'active'} /> → <Heading color={'accent'} />
16
+ * - <Text color={cond ? 'active' : x}> → <Text color={cond ? 'accent' : x}>
17
+ * - Storybook argTypes: color: { options: [..., 'active', ...] }
18
+ * → replaces 'active' with 'accent' (files importing a target component)
19
+ * - Object properties: { color: 'active' } / { color: 'active' as const }
20
+ * → { color: 'accent' } (files importing a target component)
21
+ *
22
+ * The transform deliberately does NOT touch bare `'active'` strings that are
23
+ * not a `color` value (e.g. status enums, ids, filter values), so it is safe
24
+ * to run across an entire codebase.
25
+ */
26
+
27
+ export const meta = {
28
+ title: 'Rename Text/Heading/Link/Timestamp color "active" to "accent"',
29
+ description:
30
+ 'Renames the `color="active"` value to `color="accent"` on Text, Heading, ' +
31
+ 'Link, and Timestamp. The `accent` value maps to `--color-text-accent` ' +
32
+ '(the dedicated accent text ink). Also updates Storybook color argTypes ' +
33
+ 'options and object-literal `color` props in files that import a target component.',
34
+ pr: '#2863',
35
+ };
36
+
37
+ const OLD_VALUE = 'active';
38
+ const NEW_VALUE = 'accent';
39
+
40
+ /** Components whose `color` prop accepts a TextColor value. */
41
+ const TARGET_COMPONENTS = new Set(['Text', 'Heading', 'Link', 'Timestamp']);
42
+
43
+ export default function transformer(file, api) {
44
+ const j = api.jscodeshift;
45
+ const root = j(file.source);
46
+ let hasChanges = false;
47
+
48
+ /** Rewrite a string-literal node when it equals the old value. */
49
+ function renameStringLiteral(node) {
50
+ if (!node) return false;
51
+ if (
52
+ (node.type === 'StringLiteral' || node.type === 'Literal') &&
53
+ node.value === OLD_VALUE
54
+ ) {
55
+ node.value = NEW_VALUE;
56
+ if (node.raw) node.raw = `'${NEW_VALUE}'`;
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /** Recursively rewrite the old value inside ternary/logical expressions. */
63
+ function renameInExpression(node) {
64
+ if (!node) return false;
65
+ let changed = false;
66
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
67
+ changed = renameStringLiteral(node) || changed;
68
+ } else if (node.type === 'ConditionalExpression') {
69
+ changed = renameInExpression(node.consequent) || changed;
70
+ changed = renameInExpression(node.alternate) || changed;
71
+ } else if (node.type === 'LogicalExpression') {
72
+ changed = renameInExpression(node.left) || changed;
73
+ changed = renameInExpression(node.right) || changed;
74
+ } else if (node.type === 'TSAsExpression') {
75
+ // `'active' as const` → rewrite the inner expression
76
+ changed = renameInExpression(node.expression) || changed;
77
+ }
78
+ return changed;
79
+ }
80
+
81
+ // --- 1. JSX attribute: color="active" / color={'active'} on target components ---
82
+ root.find(j.JSXOpeningElement).forEach(path => {
83
+ const name = path.node.name;
84
+ const componentName = name.type === 'JSXIdentifier' ? name.name : null;
85
+ if (!componentName || !TARGET_COMPONENTS.has(componentName)) return;
86
+
87
+ path.node.attributes.forEach(attr => {
88
+ if (attr.type !== 'JSXAttribute') return;
89
+ if (!attr.name || attr.name.name !== 'color') return;
90
+
91
+ const value = attr.value;
92
+ if (!value) return;
93
+
94
+ if (value.type === 'StringLiteral' || value.type === 'Literal') {
95
+ if (renameStringLiteral(value)) hasChanges = true;
96
+ } else if (value.type === 'JSXExpressionContainer') {
97
+ if (renameInExpression(value.expression)) hasChanges = true;
98
+ }
99
+ });
100
+ });
101
+
102
+ // Object-property / argTypes transforms only run in files that use a target
103
+ // component — keeps unrelated `{ color: 'active' }` / `'active'` strings safe.
104
+ const importsTarget =
105
+ root
106
+ .find(j.ImportSpecifier)
107
+ .filter(p => TARGET_COMPONENTS.has(p.node.imported?.name))
108
+ .size() > 0;
109
+
110
+ if (importsTarget) {
111
+ const PropertyType = j.ObjectProperty ?? j.Property;
112
+
113
+ // --- 2. Object property: { color: 'active' } / { color: 'active' as const } ---
114
+ root.find(PropertyType, {key: {name: 'color'}}).forEach(path => {
115
+ if (renameInExpression(path.node.value)) hasChanges = true;
116
+ });
117
+
118
+ // --- 3. Storybook argTypes: color: { options: [..., 'active', ...] } ---
119
+ root.find(PropertyType, {key: {name: 'color'}}).forEach(path => {
120
+ const value = path.node.value;
121
+ if (!value || value.type !== 'ObjectExpression') return;
122
+
123
+ const optionsProp = value.properties.find(
124
+ p => p.key && (p.key.name === 'options' || p.key.value === 'options'),
125
+ );
126
+ if (optionsProp && optionsProp.value.type === 'ArrayExpression') {
127
+ optionsProp.value.elements.forEach(el => {
128
+ if (renameStringLiteral(el)) hasChanges = true;
129
+ });
130
+ }
131
+ });
132
+ }
133
+
134
+ if (!hasChanges) return undefined;
135
+ return root.toSource({quote: 'single'});
136
+ }
@@ -77,7 +77,7 @@ afterEach(() => {
77
77
  });
78
78
 
79
79
  describe('theme build install-instructions import path', () => {
80
- it('emits ./<name> (no double slash) when out dir equals cwd', () => {
80
+ it('emits bare ./<name> specifiers (no double slash) when out dir equals cwd', () => {
81
81
  // Source theme lives directly in the project root, so the output dir
82
82
  // is the cwd and the relative dir is an empty string.
83
83
  const project = path.join(tmpDir, 'project');
@@ -97,7 +97,7 @@ describe('theme build install-instructions import path', () => {
97
97
  expect(result.stdout).toContain('href="./cwd-theme.css"');
98
98
  });
99
99
 
100
- it('emits ./<sub>/<name> when out dir is a subdirectory of cwd', () => {
100
+ it('keeps the subdir in the import path for a non-src subdirectory build', () => {
101
101
  const project = path.join(tmpDir, 'project');
102
102
  const themeFile = writeTheme(path.join(project, 'themes'), 'sub-theme');
103
103
 
@@ -112,4 +112,24 @@ describe('theme build install-instructions import path', () => {
112
112
  expect(result.stdout).toContain("import './themes/sub-theme.css'");
113
113
  expect(result.stdout).toContain('href="./themes/sub-theme.css"');
114
114
  });
115
+
116
+ it('strips a leading src/ from the import path (relative to a file in src/)', () => {
117
+ const project = path.join(tmpDir, 'project');
118
+ const themeFile = writeTheme(
119
+ path.join(project, 'src', 'themes', 'ocean'),
120
+ 'ocean',
121
+ );
122
+
123
+ const result = runCli(
124
+ ['theme', 'build', path.relative(project, themeFile)],
125
+ project,
126
+ );
127
+
128
+ expect(result.code).toBe(0);
129
+ expect(result.stdout).not.toContain('.//');
130
+ // The leading src/ is dropped, but themes/ocean/ is kept (people need it).
131
+ expect(result.stdout).toContain("from './themes/ocean/ocean'");
132
+ expect(result.stdout).toContain("import './themes/ocean/ocean.css'");
133
+ expect(result.stdout).not.toContain('./src/');
134
+ });
115
135
  });