@astryxdesign/cli 0.4.2 → 0.4.3-canary.00bed29
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.
- package/CHANGELOG.md +23 -0
- package/README.md +45 -43
- package/api/discover/_adapter.d.mts +5 -3
- package/api/discover/_adapter.mjs +6 -4
- package/api/template/cdn/cdn.d.mts +23 -0
- package/api/template/cdn/cdn.mjs +86 -0
- package/api/template/cdn/cdn.test.mjs +108 -0
- package/api/template/template.d.mts +2 -0
- package/api/template/template.doc.mjs +21 -3
- package/api/template/template.mjs +16 -2
- package/api/template/template.type.d.mts +19 -0
- package/api/template/template.type.mjs +12 -0
- package/api/theme/build/build.font-warning.test.mjs +23 -14
- package/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +88 -16
- package/api/theme/build/build.test.mjs +185 -14
- package/api/theme/theme.type.d.mts +22 -0
- package/api/theme/theme.type.mjs +15 -1
- package/assets/cdn.template.html +124 -0
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +11 -5
- package/assets/docs/theme.doc.zh.mjs +1 -1
- package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.doc.mjs +21 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetHeights.tsx +46 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.doc.mjs +23 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetMobileKeyboard.tsx +100 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.doc.mjs +22 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetNoScrim.tsx +47 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.doc.mjs +22 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.tsx +51 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.doc.mjs +26 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.tsx +221 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
- package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
- package/assets/theme.template.ts +4 -3
- package/authoring/doctypes/base/type.ts +9 -0
- package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
- package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
- package/clients/cli/commands/build-theme.mjs +154 -65
- package/clients/cli/commands/build-theme.multi.test.mjs +148 -0
- package/clients/cli/commands/build-theme.watch.test.mjs +67 -0
- package/clients/cli/commands/discover.broken-integration.test.mjs +112 -0
- package/clients/cli/commands/discover.mjs +12 -0
- package/clients/cli/commands/search.mjs +9 -0
- package/clients/cli/commands/template-cdn.behavior.test.mjs +113 -0
- package/clients/cli/commands/template.doc.mjs +12 -2
- package/clients/cli/commands/template.mjs +24 -3
- package/clients/cli/commands/theme-build.doc.mjs +11 -5
- package/clients/cli/lib/json-shim.test.mjs +1 -1
- package/clients/cli/lib/manifest.mjs +3 -2
- package/clients/cli/lib/manifest.test.mjs +4 -2
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/foundation/integrations/integration-warnings.test.mjs +17 -0
- package/foundation/integrations/validate-contributions.mjs +7 -0
- package/foundation/response/response-types.doc.mjs +11 -0
- package/foundation/text/copyright-header.mjs +11 -4
- package/package.json +12 -9
- package/assets/templates/pages/table-page-chart/page.tsx +0 -577
- package/assets/templates/pages/table-page-chart/template.doc.mjs +0 -13
- package/assets/templates/pages/table-page-heatmap-status/page.tsx +0 -467
- package/assets/templates/pages/table-page-heatmap-status/template.doc.mjs +0 -13
- package/assets/templates/pages/table-page-shoe-store-heatmap/page.tsx +0 -931
- package/assets/templates/pages/table-page-shoe-store-heatmap/template.doc.mjs +0 -13
|
@@ -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
|
|
84
|
-
* rebuilds (debounced)
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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}
|
|
90
|
-
*
|
|
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(
|
|
95
|
-
const rel =
|
|
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
|
-
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
await runThemeBuildOnceChild(entry.file, options);
|
|
111
|
+
}
|
|
99
112
|
|
|
100
113
|
let building = false;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
157
|
+
emit(text(watchingLine));
|
|
138
158
|
|
|
139
159
|
await new Promise((/** @type {(value?: void) => void} */ resolve) => {
|
|
140
160
|
const stop = () => {
|
|
141
|
-
clearTimeout(
|
|
142
|
-
|
|
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} */
|
|
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
|
-
|
|
218
|
-
|
|
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(
|
|
292
|
+
await runThemeBuildWatch(entries, options);
|
|
244
293
|
return;
|
|
245
294
|
}
|
|
246
295
|
|
|
247
|
-
// Non-watch: delegate to the API compiler
|
|
248
|
-
// --json mode (log → stdout via
|
|
249
|
-
// "Building theme from" line, the
|
|
250
|
-
// instructions are all emitted from
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tests for `astryx theme build <a> <b> …` — several themes per
|
|
5
|
+
* invocation (kt-lc9s).
|
|
6
|
+
*
|
|
7
|
+
* The load-bearing guarantee is equivalence: one invocation over N theme files
|
|
8
|
+
* must write exactly the bytes N serial invocations write. Everything else here
|
|
9
|
+
* guards the edges that only exist once the argument is variadic — the JSON
|
|
10
|
+
* envelope, --check across a set, --out (which names one file), and fail-fast.
|
|
11
|
+
*
|
|
12
|
+
* `astryx theme build` needs a compiled @astryxdesign/core, so this suite
|
|
13
|
+
* builds core once via the shared ensureCoreBuilt() helper.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
21
|
+
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
22
|
+
|
|
23
|
+
const THEMES = {
|
|
24
|
+
'alpha.mjs': `export default { name: 'alpha', tokens: { '--color-bg': '#ffffff' } };\n`,
|
|
25
|
+
'beta.mjs': `export default { name: 'beta', tokens: { '--color-bg': '#010203' } };\n`,
|
|
26
|
+
'gamma.mjs': `export default { name: 'gamma', tokens: { '--color-bg': '#ff00ff' } };\n`,
|
|
27
|
+
};
|
|
28
|
+
const FILES = Object.keys(THEMES);
|
|
29
|
+
const OUTPUTS = ['alpha', 'beta', 'gamma'].flatMap(n => [
|
|
30
|
+
`${n}.css`,
|
|
31
|
+
`${n}.js`,
|
|
32
|
+
`${n}.d.ts`,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** A fresh temp dir holding the three theme sources. */
|
|
36
|
+
function themeDir() {
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-theme-multi-'));
|
|
38
|
+
for (const [name, source] of Object.entries(THEMES)) {
|
|
39
|
+
fs.writeFileSync(path.join(dir, name), source);
|
|
40
|
+
}
|
|
41
|
+
dirs.push(dir);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
let dirs;
|
|
47
|
+
|
|
48
|
+
beforeAll(() => {
|
|
49
|
+
ensureCoreBuilt();
|
|
50
|
+
}, 200_000);
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
dirs = [];
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
for (const dir of dirs) fs.rmSync(dir, {recursive: true, force: true});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('theme build with several files', () => {
|
|
60
|
+
it('writes byte-identical output to one invocation per theme', async () => {
|
|
61
|
+
const serial = themeDir();
|
|
62
|
+
for (const file of FILES) {
|
|
63
|
+
const r = await runCli(['theme', 'build', file], serial);
|
|
64
|
+
expect(r.status).toBe(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const batch = themeDir();
|
|
68
|
+
const r = await runCli(['theme', 'build', ...FILES], batch);
|
|
69
|
+
expect(r.status).toBe(0);
|
|
70
|
+
|
|
71
|
+
for (const output of OUTPUTS) {
|
|
72
|
+
const one = fs.readFileSync(path.join(serial, output), 'utf8');
|
|
73
|
+
const many = fs.readFileSync(path.join(batch, output), 'utf8');
|
|
74
|
+
expect({output, content: many}).toEqual({output, content: one});
|
|
75
|
+
}
|
|
76
|
+
}, 120_000);
|
|
77
|
+
|
|
78
|
+
it('reports every theme in one theme.build.batch envelope', async () => {
|
|
79
|
+
const dir = themeDir();
|
|
80
|
+
const r = await runCli(['--json', 'theme', 'build', ...FILES], dir);
|
|
81
|
+
expect(r.status).toBe(0);
|
|
82
|
+
|
|
83
|
+
const envelope = JSON.parse(r.stdout);
|
|
84
|
+
expect(envelope.type).toBe('theme.build.batch');
|
|
85
|
+
expect(envelope.data.count).toBe(3);
|
|
86
|
+
expect(envelope.data.results.map(x => x.file)).toEqual(FILES);
|
|
87
|
+
expect(envelope.data.results.map(x => x.receipt.data.name)).toEqual([
|
|
88
|
+
'alpha',
|
|
89
|
+
'beta',
|
|
90
|
+
'gamma',
|
|
91
|
+
]);
|
|
92
|
+
expect(envelope.data.results[0].receipt.type).toBe('theme.build');
|
|
93
|
+
}, 120_000);
|
|
94
|
+
|
|
95
|
+
it('keeps the bare theme.build envelope for a single file', async () => {
|
|
96
|
+
const dir = themeDir();
|
|
97
|
+
const r = await runCli(['--json', 'theme', 'build', 'alpha.mjs'], dir);
|
|
98
|
+
expect(r.status).toBe(0);
|
|
99
|
+
expect(JSON.parse(r.stdout).type).toBe('theme.build');
|
|
100
|
+
}, 120_000);
|
|
101
|
+
|
|
102
|
+
it('--check passes when every theme is current and fails when one drifts', async () => {
|
|
103
|
+
const dir = themeDir();
|
|
104
|
+
expect((await runCli(['theme', 'build', ...FILES], dir)).status).toBe(0);
|
|
105
|
+
|
|
106
|
+
const fresh = await runCli(['theme', 'build', ...FILES, '--check'], dir);
|
|
107
|
+
expect(fresh.status).toBe(0);
|
|
108
|
+
|
|
109
|
+
// Drift one committed output rather than its source: the harness runs the
|
|
110
|
+
// CLI in-process, where jiti would serve a re-read theme file from its
|
|
111
|
+
// module cache.
|
|
112
|
+
fs.writeFileSync(path.join(dir, 'beta.css'), '/* hand-edited */\n');
|
|
113
|
+
const drifted = await runCli(['theme', 'build', ...FILES, '--check'], dir);
|
|
114
|
+
expect(drifted.status).toBe(1);
|
|
115
|
+
expect(drifted.stdout + drifted.stderr).toMatch(/beta\.css/);
|
|
116
|
+
}, 120_000);
|
|
117
|
+
|
|
118
|
+
it('rejects --out with more than one theme', async () => {
|
|
119
|
+
const dir = themeDir();
|
|
120
|
+
const r = await runCli(
|
|
121
|
+
['theme', 'build', 'alpha.mjs', 'beta.mjs', '--out', 'one.css'],
|
|
122
|
+
dir,
|
|
123
|
+
);
|
|
124
|
+
expect(r.status).toBe(1);
|
|
125
|
+
expect(r.stderr).toMatch(/--out takes a single output path/);
|
|
126
|
+
expect(fs.existsSync(path.join(dir, 'one.css'))).toBe(false);
|
|
127
|
+
}, 120_000);
|
|
128
|
+
|
|
129
|
+
it('stops at the first failure and names the theme that failed', async () => {
|
|
130
|
+
const dir = themeDir();
|
|
131
|
+
fs.writeFileSync(
|
|
132
|
+
path.join(dir, 'beta.mjs'),
|
|
133
|
+
`export default { tokens: { '--color-bg': '#010203' } };\n`,
|
|
134
|
+
);
|
|
135
|
+
const r = await runCli(['theme', 'build', ...FILES], dir);
|
|
136
|
+
expect(r.status).toBe(1);
|
|
137
|
+
expect(r.stderr).toMatch(/beta\.mjs: Theme must have a name/);
|
|
138
|
+
expect(fs.existsSync(path.join(dir, 'alpha.css'))).toBe(true);
|
|
139
|
+
expect(fs.existsSync(path.join(dir, 'gamma.css'))).toBe(false);
|
|
140
|
+
}, 120_000);
|
|
141
|
+
|
|
142
|
+
it('tells the user a quoted glob was never expanded', async () => {
|
|
143
|
+
const dir = themeDir();
|
|
144
|
+
const r = await runCli(['theme', 'build', '*.mjs'], dir);
|
|
145
|
+
expect(r.status).toBe(1);
|
|
146
|
+
expect(r.stderr).toMatch(/expanded by your shell/);
|
|
147
|
+
}, 120_000);
|
|
148
|
+
});
|
|
@@ -156,4 +156,71 @@ describe('theme build --watch', () => {
|
|
|
156
156
|
expect(exited).toBe(true);
|
|
157
157
|
expect(stdout).toMatch(/Stopped watching/);
|
|
158
158
|
}, 30_000);
|
|
159
|
+
|
|
160
|
+
it('watches every file it was given and rebuilds only the one that changed', async () => {
|
|
161
|
+
const first = path.join(tmpDir, 'w1.mjs');
|
|
162
|
+
const second = path.join(tmpDir, 'w2.mjs');
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
first,
|
|
165
|
+
`export default { name: 'w1', tokens: { '--color-bg': '#ffffff' } };\n`,
|
|
166
|
+
);
|
|
167
|
+
fs.writeFileSync(
|
|
168
|
+
second,
|
|
169
|
+
`export default { name: 'w2', tokens: { '--color-bg': '#eeeeee' } };\n`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const child = spawn(
|
|
173
|
+
process.execPath,
|
|
174
|
+
[CLI_BIN, 'theme', 'build', 'w1.mjs', 'w2.mjs', '--watch'],
|
|
175
|
+
{cwd: tmpDir, env: {...process.env, FORCE_COLOR: '0'}},
|
|
176
|
+
);
|
|
177
|
+
let stdout = '';
|
|
178
|
+
child.stdout.on('data', d => (stdout += d.toString()));
|
|
179
|
+
child.stderr.on('data', d => (stdout += d.toString()));
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const built = await waitFor(
|
|
183
|
+
() =>
|
|
184
|
+
fs.existsSync(path.join(tmpDir, 'w1.css')) &&
|
|
185
|
+
fs.existsSync(path.join(tmpDir, 'w2.css')),
|
|
186
|
+
);
|
|
187
|
+
expect(built).toBe(true);
|
|
188
|
+
await waitFor(() => /Watching w1\.mjs, w2\.mjs/.test(stdout));
|
|
189
|
+
const firstCssBefore = fs.readFileSync(path.join(tmpDir, 'w1.css'), 'utf-8');
|
|
190
|
+
|
|
191
|
+
const changed = `export default { name: 'w2', tokens: { '--color-bg': '#010203' } };\n`;
|
|
192
|
+
fs.writeFileSync(second, changed);
|
|
193
|
+
|
|
194
|
+
const rebuilt = await waitFor(
|
|
195
|
+
() => {
|
|
196
|
+
try {
|
|
197
|
+
if (
|
|
198
|
+
fs
|
|
199
|
+
.readFileSync(path.join(tmpDir, 'w2.css'), 'utf-8')
|
|
200
|
+
.includes('#010203')
|
|
201
|
+
) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// CSS mid-write; fall through to re-touch.
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
fs.writeFileSync(second, changed);
|
|
209
|
+
} catch {
|
|
210
|
+
// Re-touch failed (e.g. dir mid-teardown); the next poll retries.
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
},
|
|
214
|
+
{timeout: 20000, interval: 200},
|
|
215
|
+
);
|
|
216
|
+
expect(rebuilt).toBe(true);
|
|
217
|
+
expect(stdout).toMatch(/rebuilding w2\.mjs/);
|
|
218
|
+
expect(stdout).not.toMatch(/rebuilding w1\.mjs/);
|
|
219
|
+
expect(fs.readFileSync(path.join(tmpDir, 'w1.css'), 'utf-8')).toBe(
|
|
220
|
+
firstCssBefore,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
child.kill('SIGINT');
|
|
224
|
+
}
|
|
225
|
+
}, 30_000);
|
|
159
226
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `astryx discover` and `astryx search` against an integration whose
|
|
5
|
+
* manifest fails to load.
|
|
6
|
+
*
|
|
7
|
+
* A manifest authored against a removed API throws on import, contributes
|
|
8
|
+
* nothing, and used to leave discover reporting "No integrations configured."
|
|
9
|
+
* — the package simply vanished. These drive the real CLI in-process against a
|
|
10
|
+
* hermetic project (astryx.config.mjs + a throwing astryx.integration.mjs under
|
|
11
|
+
* node_modules) and pin the loud behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import {fileURLToPath} from 'node:url';
|
|
19
|
+
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
20
|
+
|
|
21
|
+
const CORE_DIR = path.resolve(
|
|
22
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
'../../../../core',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
let tmpDir;
|
|
27
|
+
let project;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configure `@test/broken` with a manifest that throws at import. Calling an
|
|
31
|
+
* undeclared factory stands in for the real failure this came from: a 0.2.x
|
|
32
|
+
* manifest still calling `createIntegration`, which 0.3.0 removed.
|
|
33
|
+
*/
|
|
34
|
+
function buildBrokenIntegration() {
|
|
35
|
+
const intDir = path.join(project, 'node_modules', '@test', 'broken');
|
|
36
|
+
fs.mkdirSync(path.join(intDir, 'components'), {recursive: true});
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(intDir, 'package.json'),
|
|
39
|
+
JSON.stringify({name: '@test/broken', version: '1.2.3'}),
|
|
40
|
+
);
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
path.join(intDir, 'astryx.integration.mjs'),
|
|
43
|
+
`export default createIntegration({components: './components'});\n`,
|
|
44
|
+
);
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(project, 'astryx.config.mjs'),
|
|
47
|
+
`export default {integrations: ['@test/broken']};\n`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-discover-broken-'));
|
|
53
|
+
project = path.join(tmpDir, 'project');
|
|
54
|
+
fs.mkdirSync(project, {recursive: true});
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
path.join(project, 'package.json'),
|
|
57
|
+
JSON.stringify({name: 'proj', version: '1.0.0'}),
|
|
58
|
+
);
|
|
59
|
+
buildBrokenIntegration();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('astryx discover with a manifest that fails to load', () => {
|
|
67
|
+
it('warns on stderr and does not claim nothing is configured', async () => {
|
|
68
|
+
const {status, stdout, stderr} = await runCli(['discover'], {cwd: project});
|
|
69
|
+
|
|
70
|
+
expect(status).toBe(0);
|
|
71
|
+
expect(stderr).toContain(
|
|
72
|
+
'Warning: @test/broken has 1 integration issue(s). ' +
|
|
73
|
+
'Run: astryx validate-integration @test/broken',
|
|
74
|
+
);
|
|
75
|
+
expect(stdout).not.toContain('No integrations configured.');
|
|
76
|
+
expect(stdout).toContain('No external components found in configured integrations.');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reports meta.configured=true in --json, with the nudge suppressed', async () => {
|
|
80
|
+
const {status, stdout, stderr} = await runCli(['discover', '--json'], {
|
|
81
|
+
cwd: project,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(status).toBe(0);
|
|
85
|
+
expect(JSON.parse(stdout).meta).toEqual({configured: true});
|
|
86
|
+
expect(stderr).not.toContain('integration issue');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('astryx search with a manifest that fails to load', () => {
|
|
91
|
+
// search needs a resolvable @astryxdesign/core; without one it errors out
|
|
92
|
+
// before it can list anything, which is not the case under test.
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
const scope = path.join(project, 'node_modules', '@astryxdesign');
|
|
95
|
+
fs.mkdirSync(scope, {recursive: true});
|
|
96
|
+
fs.symlinkSync(CORE_DIR, path.join(scope, 'core'), 'dir');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('warns on stderr, and suppresses the nudge under --json', async () => {
|
|
100
|
+
const {status, stderr} = await runCli(['search', 'button'], {cwd: project});
|
|
101
|
+
|
|
102
|
+
expect(status).toBe(0);
|
|
103
|
+
expect(stderr).toContain(
|
|
104
|
+
'Warning: @test/broken has 1 integration issue(s). ' +
|
|
105
|
+
'Run: astryx validate-integration @test/broken',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const asJson = await runCli(['search', 'button', '--json'], {cwd: project});
|
|
109
|
+
expect(asJson.status).toBe(0);
|
|
110
|
+
expect(asJson.stderr).not.toContain('integration issue');
|
|
111
|
+
}, 30_000);
|
|
112
|
+
});
|
|
@@ -15,6 +15,8 @@ import {jsonOut} from '../../../foundation/response/json.mjs';
|
|
|
15
15
|
import {emit, section, text, record, records, list, code} from '../formatters/index.mjs';
|
|
16
16
|
import {cliError} from '../lib/cli-error.mjs';
|
|
17
17
|
import {discover as discoverApi} from '../../../api/discover/discover.mjs';
|
|
18
|
+
import {Project} from '../../../foundation/config/project.mjs';
|
|
19
|
+
import {warnOnIntegrationIssues} from '../../../foundation/integrations/integration-warnings.mjs';
|
|
18
20
|
import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
|
|
19
21
|
import {defineCommand} from '../lib/define-command.mjs';
|
|
20
22
|
import {doc as discoverCommand} from './discover.doc.mjs';
|
|
@@ -41,6 +43,16 @@ export function registerDiscover(program) {
|
|
|
41
43
|
const zh = program.opts().zh || false;
|
|
42
44
|
const run = getCliInvocation();
|
|
43
45
|
|
|
46
|
+
// Non-blocking nudge: if any configured integration has validation
|
|
47
|
+
// issues, print one compact line to stderr pointing at
|
|
48
|
+
// validate-integration. Best-effort; suppressed in --json mode.
|
|
49
|
+
try {
|
|
50
|
+
const project = await Project.load(process.cwd());
|
|
51
|
+
await warnOnIntegrationIssues(project.loadedIntegrations, {json});
|
|
52
|
+
} catch {
|
|
53
|
+
// Never let the nudge break the command.
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
let result;
|
|
45
57
|
try {
|
|
46
58
|
result = await discoverApi(query, {components: options.components, lang, zh});
|
|
@@ -26,6 +26,8 @@ import {emit, section, text, records} from '../formatters/index.mjs';
|
|
|
26
26
|
import {cliError} from '../lib/cli-error.mjs';
|
|
27
27
|
import {defineCommand} from '../lib/define-command.mjs';
|
|
28
28
|
import {search as searchApi} from '../../../api/search/search.mjs';
|
|
29
|
+
import {Project} from '../../../foundation/config/project.mjs';
|
|
30
|
+
import {warnOnIntegrationIssues} from '../../../foundation/integrations/integration-warnings.mjs';
|
|
29
31
|
import {doc as searchCommand} from './search.doc.mjs';
|
|
30
32
|
import {doc as searchFn} from '../../../api/search/search.doc.mjs';
|
|
31
33
|
|
|
@@ -41,6 +43,13 @@ export function registerSearch(program) {
|
|
|
41
43
|
) => {
|
|
42
44
|
const json = program.opts().json || false;
|
|
43
45
|
|
|
46
|
+
try {
|
|
47
|
+
const project = await Project.load(process.cwd());
|
|
48
|
+
await warnOnIntegrationIssues(project.loadedIntegrations, {json});
|
|
49
|
+
} catch {
|
|
50
|
+
// Never let the nudge break the command.
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
// Parse --limit to a number; the API validates it (positive integer) and
|
|
45
54
|
// throws ERR_INVALID_ARGUMENT, so we pass NaN through rather than
|
|
46
55
|
// pre-rejecting with a generic code here.
|