@astryxdesign/cli 0.7.1 → 0.7.2-canary.c68f5e8

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.
@@ -0,0 +1,45 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Regression coverage for the table-filter page's compact view-options tabs.
5
+ * @input The shipped table-filter page template source.
6
+ * @output Assertions that the shared tabpanel is labelled by its selected tab.
7
+ * @position Template contract test for the tabs relationship in the narrow-view sheet.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import {fileURLToPath} from 'node:url';
13
+ import {describe, expect, it} from 'vitest';
14
+
15
+ const sourcePath = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ '../../assets/templates/pages/table-filter/page.tsx',
18
+ );
19
+ const source = fs.readFileSync(sourcePath, 'utf8');
20
+ const sheet = source.match(
21
+ /const viewOptionsSheet = \(([\s\S]*?)\n {2}\);/,
22
+ )?.[1];
23
+ const tab = sheet?.match(/<Tab\b([\s\S]*?)\/>/)?.[1];
24
+ const panel = sheet?.match(/<VStack\b([^>]*?role="tabpanel"[^>]*?)>/)?.[1];
25
+
26
+ describe('table-filter view-options tabs', () => {
27
+ it('finds the compact sheet, its tab and its panel', () => {
28
+ expect(sheet).toBeDefined();
29
+ expect(tab).toBeDefined();
30
+ expect(panel).toBeDefined();
31
+ });
32
+
33
+ it('gives every tab an id derived from the panel id and its section', () => {
34
+ expect(tab).toContain('id={`${viewPanelId}-tab-${section.key}`}');
35
+ expect(tab).toContain('panelId={viewPanelId}');
36
+ });
37
+
38
+ it('labels the shared panel by the selected tab, not a fixed string', () => {
39
+ expect(panel).toContain('id={viewPanelId}');
40
+ expect(panel).toContain(
41
+ 'aria-labelledby={`${viewPanelId}-tab-${viewSection}`}',
42
+ );
43
+ expect(panel).not.toContain('aria-label=');
44
+ });
45
+ });
@@ -0,0 +1,113 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The theme build imports Core when a build runs, not when the CLI starts.
5
+ *
6
+ * The CLI loads the theme build module for every command, and Core's root
7
+ * entry pulls in all of Core and React. So a command that builds no theme must
8
+ * not ask for either, or `astryx --version` pays for them whenever a Core is
9
+ * installed. A resolve hook in a real subprocess records every request for
10
+ * Core or React and the module that made it, whether or not the request
11
+ * resolves.
12
+ */
13
+
14
+ import {afterAll, beforeAll, describe, expect, it} from 'vitest';
15
+ import {spawnSync} from 'node:child_process';
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+ import {fileURLToPath, pathToFileURL} from 'node:url';
20
+
21
+ const BIN = fileURLToPath(
22
+ new URL('../../../clients/cli/bin/astryx.mjs', import.meta.url),
23
+ );
24
+
25
+ /** @type {string} */
26
+ let dir;
27
+ /** @type {string} */
28
+ let register;
29
+ /** @type {string} */
30
+ let log;
31
+
32
+ beforeAll(() => {
33
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-core-on-demand-'));
34
+ log = path.join(dir, 'requests.jsonl');
35
+ const hooks = path.join(dir, 'hooks.mjs');
36
+ fs.writeFileSync(
37
+ hooks,
38
+ [
39
+ "import {appendFileSync} from 'node:fs';",
40
+ `const LOG = ${JSON.stringify(log)};`,
41
+ 'const WATCHED = /^(?:@astryxdesign\\/core|react|react-dom)(?:\\/|$)/;',
42
+ 'export async function resolve(specifier, context, nextResolve) {',
43
+ ' if (WATCHED.test(specifier)) {',
44
+ ' const parent = context.parentURL ?? null;',
45
+ " appendFileSync(LOG, JSON.stringify({specifier, parent}) + '\\n');",
46
+ ' }',
47
+ ' return nextResolve(specifier, context);',
48
+ '}',
49
+ '',
50
+ ].join('\n'),
51
+ );
52
+ register = path.join(dir, 'register.mjs');
53
+ fs.writeFileSync(
54
+ register,
55
+ `import {register} from 'node:module';\nregister(${JSON.stringify(pathToFileURL(hooks).href)});\n`,
56
+ );
57
+ // A plain theme object: the file itself never imports Core, so a request for
58
+ // Core can only come from the build.
59
+ fs.writeFileSync(
60
+ path.join(dir, 'ocean.mjs'),
61
+ "export default {name: 'ocean', tokens: {'--color-bg': '#0a0a0a'}};\n",
62
+ );
63
+ });
64
+
65
+ afterAll(() => {
66
+ fs.rmSync(dir, {recursive: true, force: true});
67
+ });
68
+
69
+ /**
70
+ * Run the CLI and return each Core or React specifier it asked for, mapped to
71
+ * the first module that asked.
72
+ * @param {string[]} args
73
+ * @returns {{status: number | null, requested: Record<string, string | null>}}
74
+ */
75
+ function run(args) {
76
+ fs.rmSync(log, {force: true});
77
+ const res = spawnSync(
78
+ process.execPath,
79
+ ['--import', pathToFileURL(register).href, BIN, ...args],
80
+ {cwd: dir, encoding: 'utf-8', timeout: 20_000},
81
+ );
82
+ /** @type {Record<string, string | null>} */
83
+ const requested = {};
84
+ const lines = fs.existsSync(log)
85
+ ? fs.readFileSync(log, 'utf-8').split('\n').filter(Boolean)
86
+ : [];
87
+ for (const line of lines) {
88
+ const {specifier, parent} = JSON.parse(line);
89
+ if (!(specifier in requested)) requested[specifier] = parent;
90
+ }
91
+ return {status: res.status, requested};
92
+ }
93
+
94
+ describe('theme build imports Core on demand', () => {
95
+ it('astryx --version asks for neither Core nor React', () => {
96
+ const {status, requested} = run(['--version']);
97
+ expect(status).toBe(0);
98
+ expect(requested).toEqual({});
99
+ });
100
+
101
+ it('theme build imports Core from the theme build module', () => {
102
+ const {requested} = run([
103
+ 'theme',
104
+ 'build',
105
+ 'ocean.mjs',
106
+ '--check',
107
+ '--json',
108
+ ]);
109
+ expect(requested['@astryxdesign/core/theme']).toMatch(
110
+ /\/api\/theme\/build\/build\.mjs$/,
111
+ );
112
+ });
113
+ });
@@ -163,6 +163,19 @@ export function themeBuildFamily(files: string[], options: {
163
163
  checked?: undefined;
164
164
  };
165
165
  }>;
166
+ /**
167
+ * The bindings one Core provides: the namespaces interception spreads, the
168
+ * functions the build calls, and why the import failed, if it did.
169
+ */
170
+ export type CoreBindings = {
171
+ themeModule: any;
172
+ rootModule: any;
173
+ importError: any;
174
+ defineTheme: any;
175
+ generateThemeRulesSplit: any;
176
+ generateOnMediaCSS: any;
177
+ generateAdaptationCSS: any;
178
+ };
166
179
  /**
167
180
  * How one `prop:value` pair on a component target is classified — the single
168
181
  * vocabulary both the root and adaptation paths reason in.
@@ -210,16 +223,3 @@ export type ThemeBuildTrailer = {
210
223
  */
211
224
  fontNotices: string[];
212
225
  };
213
- /**
214
- * The bindings one Core provides: the namespaces interception spreads, the
215
- * functions the build calls, and why the import failed, if it did.
216
- */
217
- export type CoreBindings = {
218
- themeModule: any;
219
- rootModule: any;
220
- importError: any;
221
- defineTheme: any;
222
- generateThemeRulesSplit: any;
223
- generateOnMediaCSS: any;
224
- generateAdaptationCSS: any;
225
- };
@@ -37,6 +37,10 @@
37
37
  * general compatibility scheme for every export an arbitrary older core might
38
38
  * lack.
39
39
  *
40
+ * Core is imported on first use, never when this module loads: the CLI loads
41
+ * this module for every command, and Core's root entry pulls in all of Core
42
+ * and React.
43
+ *
40
44
  * With `{check: true}`, it compiles the same outputs in memory but writes
41
45
  * nothing: it compares each generated file against what is on disk (ignoring
42
46
  * only the volatile `@generated` `Command:` line) and returns a
@@ -91,6 +95,8 @@ import {resolveIconImports} from './icon-imports.mjs';
91
95
  // action). A built, resolvable `@astryxdesign/core` is a hard requirement.
92
96
  // A built, resolvable `@astryxdesign/core` is a hard requirement. These are
93
97
  // populated from a dynamic import (a runtime boundary), so `any` is intentional.
98
+ // That import runs the first time a build needs Core (see `loadCliCore`), not
99
+ // when this module loads.
94
100
  //
95
101
  // The CLI and core are independently versioned optional peers, so this import
96
102
  // can succeed against a core OLDER than the CLI. Only the exports every theme
@@ -120,27 +126,6 @@ import {resolveIconImports} from './icon-imports.mjs';
120
126
  */
121
127
  /** @type {any} */ let _coreRootModule = null;
122
128
  /** @type {any} */ let _coreImportError = null;
123
- try {
124
- const coreTheme = await import('@astryxdesign/core/theme');
125
- _coreThemeModule = coreTheme;
126
- _defineTheme = coreTheme.defineTheme;
127
- _generateThemeRulesSplit = coreTheme.generateThemeRulesSplit;
128
- _generateOnMediaCSS = coreTheme.generateOnMediaCSS;
129
- _generateAdaptationCSS = coreTheme.generateAdaptationCSS;
130
- try {
131
- _coreRootModule = await import('@astryxdesign/core');
132
- } catch {
133
- // A core without a usable root entry still builds themes; interception
134
- // falls back to the theme namespace for the root specifier.
135
- }
136
- } catch (e) {
137
- // Capture the reason so the theme action can surface a precise, actionable
138
- // error. We don't throw here: this module is imported eagerly by the CLI
139
- // entrypoint for every command, and a throw at load time would break
140
- // unrelated commands (the entry wraps loads in try/catch and degrades the
141
- // command to a stub). The hard failure happens when `theme build` runs.
142
- _coreImportError = e;
143
- }
144
129
 
145
130
  /**
146
131
  * The bindings one Core provides: the namespaces interception spreads, the
@@ -157,19 +142,65 @@ try {
157
142
  */
158
143
 
159
144
  /**
160
- * The bindings exactly as the CLI's own import left them, kept so each build
161
- * can choose between them and the project's installed Core.
162
- * @type {CoreBindings}
145
+ * Import the CLI's own Core: the `@astryxdesign/core` this module resolves.
146
+ * Never throws. A failed import is kept as `importError`, so the build can
147
+ * surface a precise, actionable error when it runs.
148
+ * @returns {Promise<CoreBindings>}
163
149
  */
164
- const _cliCore = {
165
- themeModule: _coreThemeModule,
166
- rootModule: _coreRootModule,
167
- importError: _coreImportError,
168
- defineTheme: _defineTheme,
169
- generateThemeRulesSplit: _generateThemeRulesSplit,
170
- generateOnMediaCSS: _generateOnMediaCSS,
171
- generateAdaptationCSS: _generateAdaptationCSS,
172
- };
150
+ async function importCliCore() {
151
+ /** @type {CoreBindings} */
152
+ const core = {
153
+ themeModule: null,
154
+ rootModule: null,
155
+ importError: null,
156
+ defineTheme: null,
157
+ generateThemeRulesSplit: null,
158
+ generateOnMediaCSS: null,
159
+ generateAdaptationCSS: null,
160
+ };
161
+ try {
162
+ const coreTheme = await import('@astryxdesign/core/theme');
163
+ core.themeModule = coreTheme;
164
+ core.defineTheme = coreTheme.defineTheme;
165
+ core.generateThemeRulesSplit = coreTheme.generateThemeRulesSplit;
166
+ core.generateOnMediaCSS = coreTheme.generateOnMediaCSS;
167
+ core.generateAdaptationCSS = coreTheme.generateAdaptationCSS;
168
+ try {
169
+ core.rootModule = await import('@astryxdesign/core');
170
+ } catch {
171
+ // A core without a usable root entry still builds themes; interception
172
+ // falls back to the theme namespace for the root specifier.
173
+ }
174
+ } catch (e) {
175
+ // Capture the reason so the theme action can surface a precise, actionable
176
+ // error. The hard failure happens when `theme build` runs.
177
+ core.importError = e;
178
+ }
179
+ return core;
180
+ }
181
+
182
+ /**
183
+ * The CLI's own Core, imported once, the first time a build needs it. The CLI
184
+ * loads this module for every command, and Core's root entry loads all of Core
185
+ * and React, so an import at module load would make every command
186
+ * (`--version` included) pay for that whenever a Core resolves.
187
+ * @type {Promise<CoreBindings> | null}
188
+ */
189
+ let _cliCorePromise = null;
190
+
191
+ /**
192
+ * The CLI's own Core, kept so each build can choose between it and the
193
+ * project's installed Core. The first call also points the build at it, so a
194
+ * step that runs before any build has chosen a Core reads the CLI's own.
195
+ * @returns {Promise<CoreBindings>}
196
+ */
197
+ function loadCliCore() {
198
+ _cliCorePromise ??= importCliCore().then(core => {
199
+ setCore(core);
200
+ return core;
201
+ });
202
+ return _cliCorePromise;
203
+ }
173
204
 
174
205
  /**
175
206
  * Point the build at one Core: the module-level bindings every step reads.
@@ -248,13 +279,14 @@ function realOrSelf(file) {
248
279
  * repository, a bare directory), the same Core, or one that does not load.
249
280
  *
250
281
  * @param {string} cwd
282
+ * @param {CoreBindings} cliCore - The CLI's own Core, from {@link loadCliCore}.
251
283
  * @returns {Promise<CoreBindings | null>}
252
284
  */
253
- async function loadProjectCore(cwd) {
285
+ async function loadProjectCore(cwd, cliCore) {
254
286
  const dir = findInstalledPackage(cwd, '@astryxdesign/core');
255
287
  const themeFile = dir ? importTarget(dir, './theme') : null;
256
288
  if (!dir || !themeFile) return null;
257
- if (_cliCore.themeModule) {
289
+ if (cliCore.themeModule) {
258
290
  try {
259
291
  const own = fileURLToPath(
260
292
  import.meta.resolve('@astryxdesign/core/theme'),
@@ -301,9 +333,10 @@ const _projectCores = new Map();
301
333
  * @param {string} cwd
302
334
  */
303
335
  async function selectCore(cwd) {
336
+ const cliCore = await loadCliCore();
304
337
  if (!_projectCores.has(cwd))
305
- _projectCores.set(cwd, await loadProjectCore(cwd));
306
- setCore(_projectCores.get(cwd) ?? _cliCore);
338
+ _projectCores.set(cwd, await loadProjectCore(cwd, cliCore));
339
+ setCore(_projectCores.get(cwd) ?? cliCore);
307
340
  }
308
341
 
309
342
  /**
@@ -1694,6 +1727,8 @@ export async function validateThemePrivateInputs(
1694
1727
  );
1695
1728
  }
1696
1729
 
1730
+ // The CLI's own Core, unless a build in this process already chose one.
1731
+ await loadCliCore();
1697
1732
  const interception = interceptCore(_coreThemeModule, _coreRootModule);
1698
1733
  let theme;
1699
1734
  try {
@@ -4099,7 +4099,7 @@ export default function TableFilterTemplate() {
4099
4099
  restates the selected tab word for word, and the strip already
4100
4100
  marks which one that is. On a phone that is a line of vertical
4101
4101
  space spent saying it twice. The panel keeps the name for anyone
4102
- not seeing the strip, as its accessible label. */}
4102
+ not seeing the strip: its selected tab labels it. */}
4103
4103
  <TabList
4104
4104
  value={viewSection}
4105
4105
  onChange={value => setViewSection(value as ViewSection)}
@@ -4109,6 +4109,7 @@ export default function TableFilterTemplate() {
4109
4109
  {VIEW_SECTIONS.map(section => (
4110
4110
  <Tab
4111
4111
  key={section.key}
4112
+ id={`${viewPanelId}-tab-${section.key}`}
4112
4113
  value={section.key}
4113
4114
  label={section.label}
4114
4115
  icon={section.icon}
@@ -4126,7 +4127,7 @@ export default function TableFilterTemplate() {
4126
4127
  gap={0}
4127
4128
  id={viewPanelId}
4128
4129
  role="tabpanel"
4129
- aria-label={activeSection?.title}
4130
+ aria-labelledby={`${viewPanelId}-tab-${viewSection}`}
4130
4131
  paddingBlockStart={3}>
4131
4132
  {viewPanelBody()}
4132
4133
  </VStack>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2-canary.c68f5e8",
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",
@@ -101,10 +101,10 @@
101
101
  "zod": "^4.4.3"
102
102
  },
103
103
  "peerDependencies": {
104
- "@astryxdesign/charts": "*",
105
- "@astryxdesign/core": "*",
106
- "@astryxdesign/lab": "*",
107
- "@astryxdesign/theme-neutral": "*"
104
+ "@astryxdesign/charts": "0.7.2-canary.c68f5e8",
105
+ "@astryxdesign/core": "0.7.2-canary.c68f5e8",
106
+ "@astryxdesign/lab": "0.7.2-canary.c68f5e8",
107
+ "@astryxdesign/theme-neutral": "0.7.2-canary.c68f5e8"
108
108
  },
109
109
  "peerDependenciesMeta": {
110
110
  "@astryxdesign/charts": {
@@ -121,10 +121,10 @@
121
121
  }
122
122
  },
123
123
  "devDependencies": {
124
- "@astryxdesign/charts": "*",
125
- "@astryxdesign/core": "*",
126
- "@astryxdesign/lab": "*",
127
- "@astryxdesign/theme-neutral": "*",
124
+ "@astryxdesign/charts": "0.7.2-canary.c68f5e8",
125
+ "@astryxdesign/core": "0.7.2-canary.c68f5e8",
126
+ "@astryxdesign/lab": "0.7.2-canary.c68f5e8",
127
+ "@astryxdesign/theme-neutral": "0.7.2-canary.c68f5e8",
128
128
  "@heroicons/react": "^2.2.0",
129
129
  "@stylexjs/stylex": "^0.19.0",
130
130
  "@types/babel__core": "^7.20.5",