@astryxdesign/cli 0.4.3 → 0.4.4-canary.0f4d45f

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 (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +45 -43
  3. package/api/discover/_adapter.d.mts +5 -3
  4. package/api/discover/_adapter.mjs +6 -4
  5. package/api/template/cdn/cdn.d.mts +23 -0
  6. package/api/template/cdn/cdn.mjs +86 -0
  7. package/api/template/cdn/cdn.test.mjs +108 -0
  8. package/api/template/template.d.mts +2 -0
  9. package/api/template/template.doc.mjs +21 -3
  10. package/api/template/template.mjs +16 -2
  11. package/api/template/template.type.d.mts +19 -0
  12. package/api/template/template.type.mjs +12 -0
  13. package/api/theme/theme.type.d.mts +16 -0
  14. package/api/theme/theme.type.mjs +11 -0
  15. package/assets/cdn.template.html +124 -0
  16. package/assets/docs/theme.doc.dense.mjs +1 -1
  17. package/assets/docs/theme.doc.mjs +5 -4
  18. package/assets/docs/theme.doc.zh.mjs +1 -1
  19. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.doc.mjs +21 -0
  20. package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.tsx +46 -0
  21. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.doc.mjs +23 -0
  22. package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.tsx +100 -0
  23. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.doc.mjs +22 -0
  24. package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.tsx +47 -0
  25. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.doc.mjs +22 -0
  26. package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.tsx +51 -0
  27. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.doc.mjs +26 -0
  28. package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.tsx +221 -0
  29. package/assets/theme.template.ts +4 -3
  30. package/authoring/doctypes/base/type.ts +9 -0
  31. package/clients/cli/commands/build-theme.mjs +154 -65
  32. package/clients/cli/commands/build-theme.multi.test.mjs +148 -0
  33. package/clients/cli/commands/build-theme.watch.test.mjs +67 -0
  34. package/clients/cli/commands/discover.broken-integration.test.mjs +112 -0
  35. package/clients/cli/commands/discover.mjs +12 -0
  36. package/clients/cli/commands/search.mjs +9 -0
  37. package/clients/cli/commands/template-cdn.behavior.test.mjs +113 -0
  38. package/clients/cli/commands/template.doc.mjs +12 -2
  39. package/clients/cli/commands/template.mjs +24 -3
  40. package/clients/cli/commands/theme-build.doc.mjs +11 -5
  41. package/clients/cli/lib/json-shim.test.mjs +1 -1
  42. package/clients/cli/lib/manifest.mjs +3 -2
  43. package/clients/cli/lib/manifest.test.mjs +4 -2
  44. package/foundation/integrations/integration-warnings.test.mjs +17 -0
  45. package/foundation/integrations/validate-contributions.mjs +7 -0
  46. package/foundation/response/response-types.doc.mjs +11 -0
  47. package/foundation/text/copyright-header.mjs +11 -4
  48. package/package.json +12 -9
  49. package/assets/templates/pages/table-page-chart/page.tsx +0 -577
  50. package/assets/templates/pages/table-page-chart/template.doc.mjs +0 -13
  51. package/assets/templates/pages/table-page-heatmap-status/page.tsx +0 -467
  52. package/assets/templates/pages/table-page-heatmap-status/template.doc.mjs +0 -13
  53. package/assets/templates/pages/table-page-shoe-store-heatmap/page.tsx +0 -931
  54. package/assets/templates/pages/table-page-shoe-store-heatmap/template.doc.mjs +0 -13
@@ -0,0 +1,221 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {BottomSheet, BottomSheetSwitcher} from '@astryxdesign/core/BottomSheet';
7
+ import {Button} from '@astryxdesign/core/Button';
8
+ import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
9
+ import {Divider} from '@astryxdesign/core/Divider';
10
+ import {Heading} from '@astryxdesign/core/Heading';
11
+ import {Section} from '@astryxdesign/core/Section';
12
+ import {HStack, VStack} from '@astryxdesign/core/Stack';
13
+ import {Text} from '@astryxdesign/core/Text';
14
+ import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
15
+
16
+ type NotificationSheetHeight = 'hug' | 'capped';
17
+
18
+ interface NotificationOverviewSheetProps {
19
+ height: NotificationSheetHeight;
20
+ onCancel: () => void;
21
+ onContinue: () => void;
22
+ }
23
+
24
+ function NotificationOverviewSheet({
25
+ height,
26
+ onCancel,
27
+ onContinue,
28
+ }: NotificationOverviewSheetProps) {
29
+ return (
30
+ <BottomSheet
31
+ sheetId="overview"
32
+ label="Set up notifications"
33
+ height={height}>
34
+ <Section padding={4}>
35
+ <VStack gap={4}>
36
+ <VStack gap={1}>
37
+ <Heading level={3}>Set up notifications</Heading>
38
+ <Text type="supporting" color="secondary">
39
+ Step 1 of 3
40
+ </Text>
41
+ </VStack>
42
+ <Divider />
43
+ <Text type="supporting" color="secondary">
44
+ Stay informed about activity that matters without checking back
45
+ throughout the day.
46
+ </Text>
47
+ <VStack gap={3}>
48
+ <VStack gap={1}>
49
+ <Text type="label">Important activity</Text>
50
+ <Text type="supporting" color="secondary">
51
+ Know when someone mentions you or needs your attention.
52
+ </Text>
53
+ </VStack>
54
+ <VStack gap={1}>
55
+ <Text type="label">Timely reminders</Text>
56
+ <Text type="supporting" color="secondary">
57
+ Get a reminder before work reaches its due date.
58
+ </Text>
59
+ </VStack>
60
+ <VStack gap={1}>
61
+ <Text type="label">Useful summaries</Text>
62
+ <Text type="supporting" color="secondary">
63
+ Catch up on anything you may have missed.
64
+ </Text>
65
+ </VStack>
66
+ </VStack>
67
+ <HStack gap={2} hAlign="end">
68
+ <Button label="Cancel" variant="secondary" onClick={onCancel} />
69
+ <Button label="Continue" onClick={onContinue} />
70
+ </HStack>
71
+ </VStack>
72
+ </Section>
73
+ </BottomSheet>
74
+ );
75
+ }
76
+
77
+ interface NotificationFrequencySheetProps {
78
+ height: NotificationSheetHeight;
79
+ onBack: () => void;
80
+ onContinue: () => void;
81
+ }
82
+
83
+ function NotificationFrequencySheet({
84
+ height,
85
+ onBack,
86
+ onContinue,
87
+ }: NotificationFrequencySheetProps) {
88
+ const [frequency, setFrequency] = useState('daily');
89
+
90
+ return (
91
+ <BottomSheet
92
+ sheetId="frequency"
93
+ label="Notification frequency"
94
+ height={height}>
95
+ <Section padding={4}>
96
+ <VStack gap={4}>
97
+ <VStack gap={1}>
98
+ <Heading level={3}>How often?</Heading>
99
+ <Text type="supporting" color="secondary">
100
+ Step 2 of 3
101
+ </Text>
102
+ </VStack>
103
+ <Divider />
104
+ <RadioList
105
+ label="Notification frequency"
106
+ isLabelHidden
107
+ value={frequency}
108
+ onChange={setFrequency}>
109
+ <RadioListItem label="Immediately" value="immediately" />
110
+ <RadioListItem label="Daily" value="daily" />
111
+ <RadioListItem label="Weekly" value="weekly" />
112
+ </RadioList>
113
+ <HStack gap={2} hAlign="end">
114
+ <Button label="Back" variant="secondary" onClick={onBack} />
115
+ <Button label="Continue" onClick={onContinue} />
116
+ </HStack>
117
+ </VStack>
118
+ </Section>
119
+ </BottomSheet>
120
+ );
121
+ }
122
+
123
+ interface NotificationChannelsSheetProps {
124
+ height: NotificationSheetHeight;
125
+ onBack: () => void;
126
+ onFinish: () => void;
127
+ }
128
+
129
+ function NotificationChannelsSheet({
130
+ height,
131
+ onBack,
132
+ onFinish,
133
+ }: NotificationChannelsSheetProps) {
134
+ const [email, setEmail] = useState(true);
135
+ const [pushNotifications, setPushNotifications] = useState(true);
136
+ const [textMessages, setTextMessages] = useState(false);
137
+
138
+ return (
139
+ <BottomSheet
140
+ sheetId="channels"
141
+ label="Notification channels"
142
+ height={height}>
143
+ <Section padding={4}>
144
+ <VStack gap={4}>
145
+ <VStack gap={1}>
146
+ <Heading level={3}>Where should we notify you?</Heading>
147
+ <Text type="supporting" color="secondary">
148
+ Step 3 of 3
149
+ </Text>
150
+ </VStack>
151
+ <Divider />
152
+ <Text type="supporting" color="secondary">
153
+ Choose any combination. You can change these preferences later.
154
+ </Text>
155
+ <VStack gap={2}>
156
+ <CheckboxInput label="Email" value={email} onChange={setEmail} />
157
+ <CheckboxInput
158
+ label="Push notifications"
159
+ value={pushNotifications}
160
+ onChange={setPushNotifications}
161
+ />
162
+ <CheckboxInput
163
+ label="Text messages"
164
+ value={textMessages}
165
+ onChange={setTextMessages}
166
+ />
167
+ </VStack>
168
+ <HStack gap={2} hAlign="end">
169
+ <Button label="Back" variant="secondary" onClick={onBack} />
170
+ <Button label="Finish" onClick={onFinish} />
171
+ </HStack>
172
+ </VStack>
173
+ </Section>
174
+ </BottomSheet>
175
+ );
176
+ }
177
+
178
+ interface MultiStepSwitcherExampleProps {
179
+ height: NotificationSheetHeight;
180
+ hasScrim?: boolean;
181
+ }
182
+
183
+ function MultiStepSwitcherExample({
184
+ height,
185
+ hasScrim = true,
186
+ }: MultiStepSwitcherExampleProps) {
187
+ const [activeSheet, setActiveSheet] = useState<string | null>(null);
188
+
189
+ return (
190
+ <>
191
+ <Button
192
+ label="Set up notifications"
193
+ onClick={() => setActiveSheet('overview')}
194
+ />
195
+ <BottomSheetSwitcher
196
+ activeSheet={activeSheet}
197
+ onActiveSheetChange={setActiveSheet}
198
+ hasScrim={hasScrim}>
199
+ <NotificationOverviewSheet
200
+ height={height}
201
+ onCancel={() => setActiveSheet(null)}
202
+ onContinue={() => setActiveSheet('frequency')}
203
+ />
204
+ <NotificationFrequencySheet
205
+ height={height}
206
+ onBack={() => setActiveSheet('overview')}
207
+ onContinue={() => setActiveSheet('channels')}
208
+ />
209
+ <NotificationChannelsSheet
210
+ height={height}
211
+ onBack={() => setActiveSheet('frequency')}
212
+ onFinish={() => setActiveSheet(null)}
213
+ />
214
+ </BottomSheetSwitcher>
215
+ </>
216
+ );
217
+ }
218
+
219
+ export default function BottomSheetSwitcherShowcase() {
220
+ return <MultiStepSwitcherExample height="hug" />;
221
+ }
@@ -105,12 +105,13 @@ export const myTheme = defineTheme({
105
105
  // ───────────────────────────────────────────────────────────────────────
106
106
 
107
107
  /**
108
- * Generates the neutral ramp and the accent tokens from one seed colour
108
+ * Generates the neutral ramp and the accent tokens from a seed colour
109
109
  * using the HCT perceptual model: surfaces, text, icons, borders, muted
110
110
  * fills, hover and pressed overlays — light and dark both.
111
111
  *
112
- * accent seed hex; omit to keep the default accent and re-tone only
113
- * the neutrals
112
+ * accent seed hex, or a [light, dark] pair to seed each scheme's
113
+ * palette from its own colour; omit to keep the default
114
+ * accent and re-tone only the neutrals
114
115
  * neutralStyle 'warm' | 'cool' | 'neutral' — the temperature of the greys
115
116
  * contrast 'standard' | 'high' — 'high' widens the text/surface tone
116
117
  * gap, for dense data UI or bright and clinical screens
@@ -117,6 +117,15 @@ export interface ComponentPlaygroundConfig {
117
117
  * the component is visible on load and knobs stay usable, whereas a real
118
118
  * top-layer modal makes the rest of the page inert (#3657). */
119
119
  overlay?: boolean;
120
+ /** Override the controlled prop and open value used by an overlay preview.
121
+ * Use with `overlay: true` when the component does not use `isOpen: true`
122
+ * to represent its open state. */
123
+ overlayControl?: {
124
+ /** Controlled prop that determines whether the overlay is open. */
125
+ stateProp: string;
126
+ /** Value assigned to stateProp when the preview's open button is used. */
127
+ openValue: unknown;
128
+ };
120
129
  /** The component reads AppShell mobile context and renders nothing
121
130
  * without it (e.g. `MobileNavToggle` returns null unless the context
122
131
  * reports an enabled mobile viewport — the default value outside
@@ -17,6 +17,12 @@
17
17
  * Usage:
18
18
  * astryx theme build ./src/themes/ocean.ts
19
19
  * astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css
20
+ * astryx theme build ./src/themes/*.ts
21
+ *
22
+ * `build` takes one or more theme files. Each is compiled by the same
23
+ * single-file API call, in argument order, in one process — so the outputs are
24
+ * byte-identical to running the CLI once per theme, and the first failure stops
25
+ * the run exactly as a shell loop under `set -e` would.
20
26
  */
21
27
 
22
28
  import * as fs from 'node:fs';
@@ -80,66 +86,80 @@ function runThemeBuildOnceChild(file, options) {
80
86
  }
81
87
 
82
88
  /**
83
- * Watch a theme file and rebuild on change. Runs an initial build, then
84
- * rebuilds (debounced) whenever the file changes, until interrupted with
85
- * Ctrl-C. Each rebuild runs in a child process so a build error (which the
86
- * single-build path reports via a hard exit) is contained and the watcher
87
- * keeps running.
89
+ * Watch theme files and rebuild on change. Runs an initial build of each, then
90
+ * rebuilds (debounced) the file that changed, until interrupted with Ctrl-C.
91
+ * Rebuilds are serialized: one at a time, in the order the changes arrived, so
92
+ * the log stays readable. Each rebuild runs in a child process so a build error
93
+ * (which the single-build path reports via a hard exit) is contained and the
94
+ * watcher keeps running.
88
95
  *
89
- * @param {string} file - The theme file argument, as the user passed it.
90
- * @param {string} filePath - Absolute path to the theme file.
96
+ * @param {Array<{file: string, filePath: string}>} entries - The theme file
97
+ * arguments as the user passed them, with their resolved absolute paths.
91
98
  * @param {{out?: string, iconsSpecifier?: string}} options - Parsed command options.
92
99
  * @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
93
100
  */
94
- async function runThemeBuildWatch(file, filePath, options) {
95
- const rel = path.relative(process.cwd(), filePath);
101
+ async function runThemeBuildWatch(entries, options) {
102
+ const rel = (/** @type {string} */ filePath) =>
103
+ path.relative(process.cwd(), filePath);
104
+ const watchingLine = `\nWatching ${entries
105
+ .map(e => rel(e.filePath))
106
+ .join(', ')} for changes — press Ctrl-C to stop.`;
96
107
 
97
108
  // Initial build.
98
- await runThemeBuildOnceChild(file, options);
109
+ for (const entry of entries) {
110
+ await runThemeBuildOnceChild(entry.file, options);
111
+ }
99
112
 
100
113
  let building = false;
101
- let queued = false;
102
- /** @type {ReturnType<typeof setTimeout> | undefined} */
103
- let debounce;
114
+ /** @type {Set<{file: string, filePath: string}>} */
115
+ const queued = new Set();
116
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
117
+ const debounces = new Map();
104
118
 
105
- const rebuild = async () => {
119
+ /** @param {{file: string, filePath: string}} entry */
120
+ const rebuild = async entry => {
106
121
  if (building) {
107
122
  // Coalesce changes that land mid-build into a single follow-up run.
108
- queued = true;
123
+ queued.add(entry);
109
124
  return;
110
125
  }
111
126
  building = true;
112
- emit(text(`\nChange detected — rebuilding ${rel}...`));
113
- await runThemeBuildOnceChild(file, options);
127
+ emit(text(`\nChange detected — rebuilding ${rel(entry.filePath)}...`));
128
+ await runThemeBuildOnceChild(entry.file, options);
114
129
  building = false;
115
- emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`));
116
- if (queued) {
117
- queued = false;
118
- rebuild();
130
+ emit(text(watchingLine));
131
+ const next = queued.values().next();
132
+ if (!next.done) {
133
+ queued.delete(next.value);
134
+ rebuild(next.value);
119
135
  }
120
136
  };
121
137
 
122
138
  // Some editors replace the file (rename) rather than writing in place, which
123
139
  // can drop the watch. Watch the containing directory and filter to our file
124
140
  // so edits survive atomic-save/rename.
125
- const watchDir = path.dirname(filePath);
126
- const baseName = path.basename(filePath);
127
- const watcher = fs.watch(watchDir, (_eventType, changed) => {
128
- if (changed && changed !== baseName) return;
129
- clearTimeout(debounce);
130
- // Debounce: editors often emit several events per save.
131
- debounce = setTimeout(rebuild, 100);
141
+ const watchers = entries.map(entry => {
142
+ const baseName = path.basename(entry.filePath);
143
+ return fs.watch(path.dirname(entry.filePath), (_eventType, changed) => {
144
+ if (changed && changed !== baseName) return;
145
+ clearTimeout(debounces.get(entry.filePath));
146
+ // Debounce: editors often emit several events per save.
147
+ debounces.set(
148
+ entry.filePath,
149
+ setTimeout(() => rebuild(entry), 100),
150
+ );
151
+ });
132
152
  });
133
153
 
134
154
  // Announce readiness only AFTER fs.watch is armed — the log is the "safe to
135
155
  // edit" signal (tests and humans rely on it), so printing it before the watch
136
156
  // is registered would race: a change in that gap is silently missed.
137
- emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`));
157
+ emit(text(watchingLine));
138
158
 
139
159
  await new Promise((/** @type {(value?: void) => void} */ resolve) => {
140
160
  const stop = () => {
141
- clearTimeout(debounce);
142
- watcher.close();
161
+ for (const d of debounces.values()) clearTimeout(d);
162
+ for (const w of watchers) w.close();
143
163
  emit(text('\nStopped watching.'));
144
164
  resolve();
145
165
  };
@@ -208,15 +228,31 @@ export function registerTheme(program) {
208
228
  defineCommand(theme, themeBuildCommand, {
209
229
  fn: themeBuildFn,
210
230
  action: async (
211
- /** @type {string} */ file,
231
+ /** @type {string[]} */ files,
212
232
  /** @type {{out?: string, watch?: boolean, check?: boolean, iconsSpecifier?: string}} */ options,
213
233
  ) => {
214
- const filePath = path.resolve(process.cwd(), file);
215
234
  const json = program.opts().json || false;
235
+ const entries = files.map(file => ({
236
+ file,
237
+ filePath: path.resolve(process.cwd(), file),
238
+ }));
216
239
 
217
- if (!fs.existsSync(filePath)) {
218
- cliError(`File not found: ${filePath}`, {
240
+ for (const entry of entries) {
241
+ if (fs.existsSync(entry.filePath)) continue;
242
+ // A quoted glob reaches us unexpanded: say so rather than reporting a
243
+ // literal `themes/*.ts` as a missing file.
244
+ const looksGlobby = /[*?[\]{}]/.test(entry.file);
245
+ cliError(`File not found: ${entry.filePath}`, {
219
246
  code: ERROR_CODES.ERR_FILE_NOT_FOUND,
247
+ suggestions: looksGlobby
248
+ ? [
249
+ {
250
+ name: `astryx theme build ${entry.file.replace(/['"]/g, '')}`,
251
+ reason:
252
+ 'globs are expanded by your shell — pass the pattern unquoted, or list the files',
253
+ },
254
+ ]
255
+ : undefined,
220
256
  });
221
257
  return;
222
258
  }
@@ -230,6 +266,19 @@ export function registerTheme(program) {
230
266
  return;
231
267
  }
232
268
 
269
+ // --out names one output file, so it cannot describe N themes. Without
270
+ // it each theme writes `<theme name>.css` beside its source, which is
271
+ // what a multi-theme build wants anyway.
272
+ if (options.out && entries.length > 1) {
273
+ cliError(
274
+ `--out takes a single output path and ${entries.length} theme files were given. ` +
275
+ 'Build them without --out (each theme writes <name>.css next to its source), ' +
276
+ 'or run one invocation per theme.',
277
+ {code: ERROR_CODES.ERR_THEME_INVALID},
278
+ );
279
+ return;
280
+ }
281
+
233
282
  // Watch mode: run an initial build, then rebuild on every change to the
234
283
  // theme file. Watch is a human-interactive, long-running mode — it is not
235
284
  // supported in --json (machine) mode, which expects a single envelope.
@@ -240,41 +289,81 @@ export function registerTheme(program) {
240
289
  });
241
290
  return;
242
291
  }
243
- await runThemeBuildWatch(file, filePath, options);
292
+ await runThemeBuildWatch(entries, options);
244
293
  return;
245
294
  }
246
295
 
247
- // Non-watch: delegate to the API compiler. Enable human output unless in
248
- // --json mode (log → stdout via humanLog, warn/error → stderr). The
249
- // "Building theme from" line, the ✓/warning lines, and the install
250
- // instructions are all emitted from inside themeBuild via the shared logger.
296
+ // Non-watch: delegate to the API compiler, once per theme, in argument
297
+ // order. Enable human output unless in --json mode (log → stdout via
298
+ // humanLog, warn/error → stderr). The "Building theme from" line, the
299
+ // ✓/warning lines, and the install instructions are all emitted from
300
+ // inside themeBuild via the shared logger.
251
301
  logger.setSilent(json);
252
- try {
253
- const result = await themeBuild(
254
- file,
255
- {
256
- out: options.out,
257
- check: options.check,
258
- iconsSpecifier: options.iconsSpecifier,
259
- },
260
- {cwd: process.cwd()},
261
- );
262
- if (json && result) jsonOut(result);
263
- // In --check mode a stale/missing output is a failure: exit non-zero
264
- // (after emitting the receipt) so CI can gate on it. The receipt is
265
- // already printed above (shared logger or --json envelope).
266
- if (
267
- options.check &&
268
- result &&
269
- result.type === 'theme.build.check' &&
270
- !result.data.upToDate
271
- ) {
272
- process.exitCode = 1;
302
+ /** @type {Array<{file: string, receipt: import('../../../api/theme/theme.type.mjs').ThemeBuildResponse | import('../../../api/theme/theme.type.mjs').ThemeBuildCheckResponse | null}>} */
303
+ const results = [];
304
+ let stale = false;
305
+ for (const entry of entries) {
306
+ try {
307
+ const result = await themeBuild(
308
+ entry.file,
309
+ {
310
+ out: options.out,
311
+ check: options.check,
312
+ iconsSpecifier: options.iconsSpecifier,
313
+ },
314
+ {cwd: process.cwd()},
315
+ );
316
+ results.push({file: entry.file, receipt: result ?? null});
317
+ if (
318
+ options.check &&
319
+ result &&
320
+ result.type === 'theme.build.check' &&
321
+ !result.data.upToDate
322
+ ) {
323
+ stale = true;
324
+ }
325
+ } catch (e) {
326
+ const err =
327
+ /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
328
+ // Stop at the first failure, as a shell loop under `set -e` does.
329
+ // With several themes in flight the message alone rarely says which
330
+ // one broke, so name it.
331
+ cliError(
332
+ entries.length > 1 ? `${entry.file}: ${err.message}` : err.message,
333
+ {suggestions: err.suggestions, code: err.code},
334
+ );
335
+ return;
273
336
  }
274
- } catch (e) {
275
- const err =
276
- /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
277
- cliError(err.message, {suggestions: err.suggestions, code: err.code});
337
+ }
338
+
339
+ if (json) {
340
+ // One theme keeps the single-envelope contract it has always had; a
341
+ // batch gets its own discriminant rather than N envelopes on stdout.
342
+ if (entries.length === 1) {
343
+ if (results[0].receipt) jsonOut(results[0].receipt);
344
+ } else {
345
+ /** @type {import('../../../api/theme/theme.type.mjs').ThemeBuildBatchResponse} */
346
+ const batch = {
347
+ type: 'theme.build.batch',
348
+ data: {count: results.length, results},
349
+ };
350
+ jsonOut(batch);
351
+ }
352
+ } else if (entries.length > 1) {
353
+ emit(
354
+ text(
355
+ options.check
356
+ ? `\n${stale ? '✗' : '✓'} Checked ${entries.length} themes.`
357
+ : `\n✓ Built ${entries.length} themes.`,
358
+ ),
359
+ );
360
+ }
361
+
362
+ // In --check mode a stale/missing output is a failure: exit non-zero
363
+ // (after emitting the receipt) so CI can gate on it. The receipt is
364
+ // already printed above (shared logger or --json envelope).
365
+ if (options.check && stale) {
366
+ process.exitCode = 1;
278
367
  }
279
368
  },
280
369
  });