@astryxdesign/cli 0.3.0-canary.ee705d8 → 0.3.0-canary.f4607ea

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.
@@ -21,13 +21,18 @@ export function importSpecifier(relDir: string, base: string): string;
21
21
  * `logger` (silent by default).
22
22
  *
23
23
  * @param {string} file - Theme file path, resolved against `cwd`.
24
- * @param {{out?: string, check?: boolean}} [options] - `out` overrides the output CSS path; `check` compares against on-disk outputs instead of writing.
24
+ * @param {{out?: string, check?: boolean, iconsSpecifier?: string}} [options] -
25
+ * `out` overrides the output CSS path; `check` compares against on-disk outputs
26
+ * instead of writing. `iconsSpecifier` overrides the icon registry import
27
+ * specifier in the generated module (e.g. `./icons.mjs`); when omitted, the
28
+ * specifier scraped from the theme source is emitted unchanged.
25
29
  * @param {{cwd?: string}} [ctx]
26
30
  * @returns {Promise<import('../theme.type.mjs').ThemeBuildResponse | import('../theme.type.mjs').ThemeBuildCheckResponse | null>}
27
31
  */
28
32
  export function themeBuild(file: string, options?: {
29
33
  out?: string;
30
34
  check?: boolean;
35
+ iconsSpecifier?: string;
31
36
  }, { cwd }?: {
32
37
  cwd?: string;
33
38
  }): Promise<import("../theme.type.mjs").ThemeBuildResponse | import("../theme.type.mjs").ThemeBuildCheckResponse | null>;
@@ -726,13 +726,30 @@ function extractIconInfo(filePath) {
726
726
  * Generate a minimal JS module for a built theme.
727
727
  * Includes the theme name, marker, and re-exports the icon registry.
728
728
  * All styling is in the CSS file.
729
+ *
730
+ * The icon registry is imported rather than inlined because it holds React
731
+ * elements, which cannot be serialized. `extractIconInfo` lifts the specifier
732
+ * out of the TypeScript source, where an extensionless `./icons` is resolved by
733
+ * the TypeScript resolver — but the artifact here is ESM JavaScript, which
734
+ * requires a fully specified path. Only the caller knows what its own build
735
+ * will emit and under what name, so `iconsSpecifier` lets it say. When it is
736
+ * not given, the scraped specifier is emitted unchanged.
737
+ *
729
738
  * @param {any} themeDef
730
739
  * @param {{exportName: string, importPath: string} | null} iconInfo
740
+ * @param {string} [iconsSpecifier] - Overrides the scraped icon import specifier.
731
741
  * @returns {string}
732
742
  */
733
- function generateBuiltModule(themeDef, iconInfo) {
743
+ function generateBuiltModule(themeDef, iconInfo, iconsSpecifier) {
744
+ // Preserve the historical generated bytes when no override is supplied.
745
+ // User-provided specifiers need string-literal encoding so quotes and
746
+ // backslashes cannot produce invalid JavaScript.
747
+ const renderedSpecifier =
748
+ iconsSpecifier === undefined
749
+ ? `'${iconInfo?.importPath}'`
750
+ : JSON.stringify(iconsSpecifier);
734
751
  const iconImport = iconInfo
735
- ? `import { ${iconInfo.exportName} } from '${iconInfo.importPath}';\n`
752
+ ? `import { ${iconInfo.exportName} } from ${renderedSpecifier};\n`
736
753
  : '';
737
754
  const iconsField = iconInfo ? ` icons: ${iconInfo.exportName},` : '';
738
755
  const iconReExport = iconInfo ? `\nexport { ${iconInfo.exportName} };\n` : '';
@@ -970,7 +987,11 @@ function validatePrivateVars(themeDef) {
970
987
  * `logger` (silent by default).
971
988
  *
972
989
  * @param {string} file - Theme file path, resolved against `cwd`.
973
- * @param {{out?: string, check?: boolean}} [options] - `out` overrides the output CSS path; `check` compares against on-disk outputs instead of writing.
990
+ * @param {{out?: string, check?: boolean, iconsSpecifier?: string}} [options] -
991
+ * `out` overrides the output CSS path; `check` compares against on-disk outputs
992
+ * instead of writing. `iconsSpecifier` overrides the icon registry import
993
+ * specifier in the generated module (e.g. `./icons.mjs`); when omitted, the
994
+ * specifier scraped from the theme source is emitted unchanged.
974
995
  * @param {{cwd?: string}} [ctx]
975
996
  * @returns {Promise<import('../theme.type.mjs').ThemeBuildResponse | import('../theme.type.mjs').ThemeBuildCheckResponse | null>}
976
997
  */
@@ -1185,7 +1206,11 @@ export async function themeBuild(
1185
1206
  generatedHeader(sourceRelative, 'css', buildCommand, versions) + css;
1186
1207
  const jsContent =
1187
1208
  generatedHeader(sourceRelative, 'js', buildCommand, versions) +
1188
- generateBuiltModule(resolvedTheme || themeDef, iconInfo);
1209
+ generateBuiltModule(
1210
+ resolvedTheme || themeDef,
1211
+ iconInfo,
1212
+ options.iconsSpecifier,
1213
+ );
1189
1214
  const dtsContent =
1190
1215
  generatedHeader(sourceRelative, 'ts', buildCommand, versions) +
1191
1216
  generateBuiltTypes(themeDef, iconInfo, variantsFileName);
@@ -19,11 +19,13 @@ export const doc = {
19
19
  "via @astryxdesign/core's shared generator (the single source of truth, so the build " +
20
20
  'emits the exact CSS the <Theme> runtime does), writes a scoped CSS file, a JS module ' +
21
21
  'that re-exports the built theme, and a .d.ts (plus an optional .variants.d.ts when the ' +
22
- 'theme adds custom prop values). With {check: true} it writes nothing and instead compares ' +
22
+ 'theme adds custom prop values). When another build step emits the icon registry, ' +
23
+ '{iconsSpecifier} declares the fully specified module path for the generated JS import. ' +
24
+ 'With {check: true} it writes nothing and instead compares ' +
23
25
  'each output against disk, returning the drift: the CI guard for committed, generated theme CSS.',
24
26
  importPath: '@astryxdesign/cli/api',
25
27
  signature:
26
- 'themeBuild(file: string, options?: {out?: string, check?: boolean}, ctx?: {cwd?: string}): Promise<ThemeBuildResponse | ThemeBuildCheckResponse | null>',
28
+ 'themeBuild(file: string, options?: {out?: string, check?: boolean, iconsSpecifier?: string}, ctx?: {cwd?: string}): Promise<ThemeBuildResponse | ThemeBuildCheckResponse | null>',
27
29
  keywords: [
28
30
  'theme',
29
31
  'build',
@@ -54,6 +56,12 @@ export const doc = {
54
56
  'Compile in memory and compare each output against what is on disk instead of writing: the CI drift guard.',
55
57
  default: 'false',
56
58
  },
59
+ {
60
+ name: 'options.iconsSpecifier',
61
+ type: 'string',
62
+ description:
63
+ 'Override the icon-registry import specifier in the generated JS module, for example ./icons.mjs. When omitted, the source specifier is preserved.',
64
+ },
57
65
  {
58
66
  name: 'ctx.cwd',
59
67
  type: 'string',
@@ -102,6 +110,10 @@ export const doc = {
102
110
  label: 'Check for drift (CI)',
103
111
  code: "const r = await themeBuild('src/themes/ocean.ts', {check: true});",
104
112
  },
113
+ {
114
+ label: 'Use a separately compiled icon registry',
115
+ code: "const r = await themeBuild('src/themes/ocean.ts', {iconsSpecifier: './icons.mjs'});",
116
+ },
105
117
  ],
106
118
  command: 'theme build',
107
119
  related: ['themeAdd', 'themeList', 'listThemes'],
@@ -7,7 +7,7 @@ export const doc = {
7
7
  name: 'Stepper — Validation Status',
8
8
  displayName: 'Stepper — Validation Status',
9
9
  description:
10
- 'Semantic status per step in a verification flow: success shows a green check, error a red glyph, accent the in-progress step. Status sets the indicator color and glyph only never the connector and is announced to assistive tech as text.',
10
+ 'Semantic status per step in a verification flow: success shows a green check, error a red glyph, accent the in-progress step. Status sets the indicator color and glyph only, never the connector, and is announced to assistive tech as text.',
11
11
  isReady: true,
12
12
  aspectRatio: 4 / 3,
13
13
  componentsUsed: ['Stepper'],
@@ -0,0 +1,225 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Regression tests for `astryx theme build --icons-specifier`.
5
+ *
6
+ * The generated module imports the icon registry rather than inlining it,
7
+ * because the registry holds React elements. `extractIconInfo` lifts that
8
+ * specifier out of the TypeScript source, where an extensionless `./icons` is
9
+ * resolved by the TypeScript resolver — but the artifact is ESM JavaScript,
10
+ * which requires a fully specified path, so Node cannot load it. See #4620.
11
+ *
12
+ * Only the caller knows what its own build emits and under what name (the same
13
+ * source compiled by tsup lands at `icons.mjs` in a package with no `"type"`
14
+ * field and at `icons.js` in one with `"type": "module"`), so the specifier is
15
+ * declared rather than inferred. Absent the flag, output is byte-for-byte what
16
+ * it was before, which keeps the default no-`--out` flow — where the neighbour
17
+ * is an uncompiled `icons.tsx` that only a bundler can resolve — working.
18
+ */
19
+
20
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import * as os from 'node:os';
24
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
25
+ import {runCli} from '../../../test-utils/run-cli.mjs';
26
+
27
+ /**
28
+ * The emitted icon import, or null. Reads the statement rather than the whole
29
+ * file: the `@generated` header quotes the source filename and a usage example,
30
+ * so a substring search over the file matches comment text too.
31
+ */
32
+ function iconImportLine(generated) {
33
+ const match = generated.match(/^import .*$/m);
34
+ return match ? match[0] : null;
35
+ }
36
+
37
+ /** A theme whose registry arrives via a relative import, plus that module. */
38
+ function writeThemeWithIcons(dir, name) {
39
+ fs.mkdirSync(dir, {recursive: true});
40
+ fs.writeFileSync(
41
+ path.join(dir, 'icons.mjs'),
42
+ 'export const testIcons = {};\n',
43
+ );
44
+ const file = path.join(dir, `${name}.mjs`);
45
+ fs.writeFileSync(
46
+ file,
47
+ `import {testIcons} from './icons';\n` +
48
+ `export default {\n` +
49
+ ` name: ${JSON.stringify(name)},\n` +
50
+ ` icons: testIcons,\n` +
51
+ ` tokens: {'--color-bg': '#fff'},\n` +
52
+ `};\n`,
53
+ );
54
+ return file;
55
+ }
56
+
57
+ beforeAll(() => {
58
+ ensureCoreBuilt();
59
+ }, 200_000);
60
+
61
+ let tmpDir;
62
+ beforeEach(() => {
63
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-icons-specifier-'));
64
+ });
65
+ afterEach(() => {
66
+ fs.rmSync(tmpDir, {recursive: true, force: true});
67
+ });
68
+
69
+ describe('theme build --icons-specifier', () => {
70
+ it('emits the declared specifier', async () => {
71
+ const project = path.join(tmpDir, 'project');
72
+ const themeFile = writeThemeWithIcons(project, 'declared');
73
+
74
+ const result = await runCli(
75
+ [
76
+ 'theme',
77
+ 'build',
78
+ path.relative(project, themeFile),
79
+ '--icons-specifier',
80
+ './icons.mjs',
81
+ ],
82
+ project,
83
+ );
84
+ expect(result.code).toBe(0);
85
+
86
+ const generated = fs.readFileSync(
87
+ path.join(project, 'declared.js'),
88
+ 'utf8',
89
+ );
90
+ expect(iconImportLine(generated)).toBe(
91
+ 'import { testIcons } from "./icons.mjs";',
92
+ );
93
+ });
94
+
95
+ it('accepts a specifier the generator could never have inferred', async () => {
96
+ const project = path.join(tmpDir, 'project');
97
+ const themeFile = writeThemeWithIcons(project, 'custom');
98
+
99
+ const result = await runCli(
100
+ [
101
+ 'theme',
102
+ 'build',
103
+ path.relative(project, themeFile),
104
+ '--icons-specifier',
105
+ '../build/registry/icons.js',
106
+ ],
107
+ project,
108
+ );
109
+ expect(result.code).toBe(0);
110
+
111
+ const generated = fs.readFileSync(path.join(project, 'custom.js'), 'utf8');
112
+ expect(iconImportLine(generated)).toBe(
113
+ 'import { testIcons } from "../build/registry/icons.js";',
114
+ );
115
+ });
116
+
117
+ it('encodes the declared specifier as a valid JavaScript string', async () => {
118
+ const project = path.join(tmpDir, 'project');
119
+ const themeFile = writeThemeWithIcons(project, 'encoded');
120
+
121
+ const result = await runCli(
122
+ [
123
+ 'theme',
124
+ 'build',
125
+ path.relative(project, themeFile),
126
+ '--icons-specifier',
127
+ "./icon's.mjs",
128
+ ],
129
+ project,
130
+ );
131
+ expect(result.code).toBe(0);
132
+
133
+ const generated = fs.readFileSync(path.join(project, 'encoded.js'), 'utf8');
134
+ expect(iconImportLine(generated)).toBe(
135
+ 'import { testIcons } from "./icon\'s.mjs";',
136
+ );
137
+ });
138
+
139
+ it('combines with the current --check flow', async () => {
140
+ const project = path.join(tmpDir, 'project');
141
+ const themeFile = writeThemeWithIcons(project, 'checked');
142
+ const relativeTheme = path.relative(project, themeFile);
143
+
144
+ const built = await runCli(
145
+ [
146
+ 'theme',
147
+ 'build',
148
+ relativeTheme,
149
+ '--icons-specifier',
150
+ './icons.mjs',
151
+ ],
152
+ project,
153
+ );
154
+ expect(built.code).toBe(0);
155
+
156
+ const withoutSpecifier = await runCli(
157
+ ['theme', 'build', relativeTheme, '--check'],
158
+ project,
159
+ );
160
+ expect(withoutSpecifier.code).toBe(1);
161
+
162
+ const withSpecifier = await runCli(
163
+ [
164
+ 'theme',
165
+ 'build',
166
+ relativeTheme,
167
+ '--check',
168
+ '--icons-specifier',
169
+ './icons.mjs',
170
+ ],
171
+ project,
172
+ );
173
+ expect(withSpecifier.code).toBe(0);
174
+ });
175
+
176
+ it('leaves the scraped specifier untouched when the flag is absent', async () => {
177
+ const project = path.join(tmpDir, 'project');
178
+ const themeFile = writeThemeWithIcons(project, 'untouched');
179
+
180
+ const result = await runCli(
181
+ ['theme', 'build', path.relative(project, themeFile)],
182
+ project,
183
+ );
184
+ expect(result.code).toBe(0);
185
+
186
+ // The pre-flag behaviour, preserved: no extension is invented. In this
187
+ // layout the neighbour is a source file only a bundler can resolve, so
188
+ // adding one would break a build that works today.
189
+ const generated = fs.readFileSync(
190
+ path.join(project, 'untouched.js'),
191
+ 'utf8',
192
+ );
193
+ expect(iconImportLine(generated)).toBe(
194
+ "import { testIcons } from './icons';",
195
+ );
196
+ });
197
+
198
+ it('is inert for a theme with no icons field', async () => {
199
+ const project = path.join(tmpDir, 'project');
200
+ fs.mkdirSync(project, {recursive: true});
201
+ const themeFile = path.join(project, 'plain.mjs');
202
+ fs.writeFileSync(
203
+ themeFile,
204
+ `export default {name: 'plain', tokens: {'--color-bg': '#fff'}};\n`,
205
+ );
206
+
207
+ const result = await runCli(
208
+ [
209
+ 'theme',
210
+ 'build',
211
+ path.relative(project, themeFile),
212
+ '--icons-specifier',
213
+ './icons.mjs',
214
+ ],
215
+ project,
216
+ );
217
+ expect(result.code).toBe(0);
218
+
219
+ // No icons field means no import to rewrite, so the flag has nothing to act
220
+ // on and must not introduce one.
221
+ const generated = fs.readFileSync(path.join(project, 'plain.js'), 'utf8');
222
+ expect(iconImportLine(generated)).toBeNull();
223
+ expect(generated).not.toContain('icons:');
224
+ });
225
+ });
@@ -57,13 +57,15 @@ function resolveCliBin() {
57
57
  * Resolves with the child's exit code; never rejects.
58
58
  *
59
59
  * @param {string} file - The theme file argument, as the user passed it.
60
- * @param {{out?: string}} options - Parsed command options (only `out` is forwarded).
60
+ * @param {{out?: string, iconsSpecifier?: string}} options - Parsed command
61
+ * options that affect generated output.
61
62
  * @returns {Promise<number>}
62
63
  */
63
64
  function runThemeBuildOnceChild(file, options) {
64
65
  const cliBin = resolveCliBin();
65
66
  const args = [cliBin, 'theme', 'build', file];
66
67
  if (options.out) args.push('--out', options.out);
68
+ if (options.iconsSpecifier) args.push('--icons-specifier', options.iconsSpecifier);
67
69
  return new Promise((/** @type {(code: number) => void} */ resolve) => {
68
70
  const child = spawn(process.execPath, args, {
69
71
  stdio: 'inherit',
@@ -83,7 +85,7 @@ function runThemeBuildOnceChild(file, options) {
83
85
  *
84
86
  * @param {string} file - The theme file argument, as the user passed it.
85
87
  * @param {string} filePath - Absolute path to the theme file.
86
- * @param {{out?: string}} options - Parsed command options.
88
+ * @param {{out?: string, iconsSpecifier?: string}} options - Parsed command options.
87
89
  * @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
88
90
  */
89
91
  async function runThemeBuildWatch(file, filePath, options) {
@@ -204,7 +206,7 @@ export function registerTheme(program) {
204
206
  fn: themeBuildFn,
205
207
  action: async (
206
208
  /** @type {string} */ file,
207
- /** @type {{out?: string, watch?: boolean, check?: boolean}} */ options,
209
+ /** @type {{out?: string, watch?: boolean, check?: boolean, iconsSpecifier?: string}} */ options,
208
210
  ) => {
209
211
  const filePath = path.resolve(process.cwd(), file);
210
212
  const json = program.opts().json || false;
@@ -247,7 +249,11 @@ export function registerTheme(program) {
247
249
  try {
248
250
  const result = await themeBuild(
249
251
  file,
250
- {out: options.out, check: options.check},
252
+ {
253
+ out: options.out,
254
+ check: options.check,
255
+ iconsSpecifier: options.iconsSpecifier,
256
+ },
251
257
  {cwd: process.cwd()},
252
258
  );
253
259
  if (json && result) jsonOut(result);
@@ -18,11 +18,19 @@ export const doc = {
18
18
  description:
19
19
  'Compiles a file that calls defineTheme() into a scoped CSS file, a JS module, and ' +
20
20
  'type declarations: the exact CSS the <Theme> runtime emits. With --check it writes ' +
21
- 'nothing and instead reports whether the committed outputs have drifted from source.',
21
+ 'nothing and instead reports whether the committed outputs have drifted from source. ' +
22
+ 'When a separate build step emits the icon registry, --icons-specifier declares the ' +
23
+ 'fully specified module path that the generated JS should import.',
22
24
  fn: 'themeBuild',
23
25
  args: [{name: 'file', param: 'file', required: true}],
24
26
  options: [
25
27
  {flag: '-o, --out <path>', param: 'options.out', description: 'Output CSS file path'},
28
+ {
29
+ flag: '--icons-specifier <specifier>',
30
+ param: 'options.iconsSpecifier',
31
+ description:
32
+ 'Override the icon-registry import in the generated JS module (for example, ./icons.mjs)',
33
+ },
26
34
  {
27
35
  flag: '-w, --watch',
28
36
  description: 'Rebuild automatically when the theme file changes (Ctrl-C to stop)',
@@ -43,6 +51,10 @@ export const doc = {
43
51
  label: 'Check for drift (CI)',
44
52
  cli: 'astryx theme build ./src/themes/ocean.ts --check',
45
53
  },
54
+ {
55
+ label: 'Build against a separately compiled icon registry',
56
+ cli: 'astryx theme build ./src/themes/ocean.ts --icons-specifier ./icons.mjs',
57
+ },
46
58
  ],
47
59
  exitCodes: [
48
60
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.3.0-canary.ee705d8",
3
+ "version": "0.3.0-canary.f4607ea",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -84,10 +84,10 @@
84
84
  "zod": "^4.4.3"
85
85
  },
86
86
  "peerDependencies": {
87
- "@astryxdesign/charts": "0.3.0-canary.ee705d8",
88
- "@astryxdesign/core": "0.3.0-canary.ee705d8",
89
- "@astryxdesign/lab": "0.3.0-canary.ee705d8",
90
- "@astryxdesign/theme-neutral": "0.3.0-canary.ee705d8",
87
+ "@astryxdesign/charts": "0.3.0-canary.f4607ea",
88
+ "@astryxdesign/core": "0.3.0-canary.f4607ea",
89
+ "@astryxdesign/lab": "0.3.0-canary.f4607ea",
90
+ "@astryxdesign/theme-neutral": "0.3.0-canary.f4607ea",
91
91
  "gpt-tokenizer": "^3.4.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
@@ -105,10 +105,10 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "@astryxdesign/charts": "0.3.0-canary.ee705d8",
109
- "@astryxdesign/core": "0.3.0-canary.ee705d8",
110
- "@astryxdesign/lab": "0.3.0-canary.ee705d8",
111
- "@astryxdesign/theme-neutral": "0.3.0-canary.ee705d8",
108
+ "@astryxdesign/charts": "0.3.0-canary.f4607ea",
109
+ "@astryxdesign/core": "0.3.0-canary.f4607ea",
110
+ "@astryxdesign/lab": "0.3.0-canary.f4607ea",
111
+ "@astryxdesign/theme-neutral": "0.3.0-canary.f4607ea",
112
112
  "gpt-tokenizer": "^3.4.0"
113
113
  },
114
114
  "scripts": {