@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,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.
@@ -0,0 +1,113 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CLI behavior for `astryx template --cdn`.
5
+ *
6
+ * The API leaf is covered by api/template/cdn/cdn.test.mjs; what is only
7
+ * reachable here is the terminal binding — that the flag short-circuits the
8
+ * family's name resolution, which message the user sees, and the JSON envelope.
9
+ * A receipt read at the wrong depth (`result.written` instead of
10
+ * `result.data.written`) writes the file and then reports a skip, and no unit
11
+ * test of the leaf can see it.
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 {runCli} from '../../../test-utils/run-cli.mjs';
19
+
20
+ let tmpDir;
21
+ beforeEach(() => {
22
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-cli-template-cdn-'));
23
+ fs.writeFileSync(
24
+ path.join(tmpDir, 'package.json'),
25
+ JSON.stringify({name: 'tmp', private: true}),
26
+ );
27
+ });
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, {recursive: true, force: true});
30
+ });
31
+
32
+ const read = f => fs.readFileSync(path.join(tmpDir, f), 'utf-8');
33
+
34
+ describe('astryx template --cdn', () => {
35
+ it('writes the page and says it wrote it', async () => {
36
+ const {status, stdout} = await runCli(['template', '--cdn'], {cwd: tmpDir});
37
+
38
+ expect(status).toBe(0);
39
+ expect(stdout).toMatch(/Wrote cdn\.template\.html/);
40
+ expect(stdout).not.toMatch(/already exists/);
41
+ expect(read('cdn.template.html')).toMatch(/<script type="importmap">/);
42
+ });
43
+
44
+ it('leaves an existing file alone, and says that instead', async () => {
45
+ fs.writeFileSync(path.join(tmpDir, 'cdn.template.html'), '<!-- mine -->\n');
46
+
47
+ const {status, stdout} = await runCli(['template', '--cdn'], {cwd: tmpDir});
48
+
49
+ expect(status).toBe(0);
50
+ expect(stdout).toMatch(/already exists/);
51
+ expect(read('cdn.template.html')).toBe('<!-- mine -->\n');
52
+ });
53
+
54
+ it('replaces it with --overwrite', async () => {
55
+ fs.writeFileSync(path.join(tmpDir, 'cdn.template.html'), '<!-- mine -->\n');
56
+
57
+ const {status} = await runCli(['template', '--cdn', '--overwrite'], {
58
+ cwd: tmpDir,
59
+ });
60
+
61
+ expect(status).toBe(0);
62
+ expect(read('cdn.template.html')).toMatch(/importmap/);
63
+ });
64
+
65
+ it('writes to the path given to the flag', async () => {
66
+ const {status} = await runCli(['template', '--cdn', 'public/demo.html'], {
67
+ cwd: tmpDir,
68
+ });
69
+
70
+ expect(status).toBe(0);
71
+ expect(read(path.join('public', 'demo.html'))).toMatch(/importmap/);
72
+ });
73
+
74
+ it('returns a template.cdn envelope under --json', async () => {
75
+ const {status, stdout} = await runCli(['--json', 'template', '--cdn'], {
76
+ cwd: tmpDir,
77
+ });
78
+
79
+ expect(status).toBe(0);
80
+ const payload = JSON.parse(stdout);
81
+ expect(payload.type).toBe('template.cdn');
82
+ expect(payload.data).toEqual({
83
+ path: 'cdn.template.html',
84
+ version: expect.stringMatching(/^\d+\.\d+\.\d+/),
85
+ written: true,
86
+ reason: null,
87
+ });
88
+ });
89
+
90
+ it('refuses a path that escapes the project', async () => {
91
+ const {status, stderr} = await runCli(
92
+ ['template', '--cdn', '../escaped.html'],
93
+ {cwd: tmpDir},
94
+ );
95
+
96
+ expect(status).toBe(1);
97
+ expect(stderr).toMatch(/outside the project root/);
98
+ expect(fs.existsSync(path.join(path.dirname(tmpDir), 'escaped.html'))).toBe(
99
+ false,
100
+ );
101
+ });
102
+
103
+ // The flag exists because the positional cannot: `template cdn` would resolve
104
+ // `cdn` against everything discoverAll() finds, so a template with that id
105
+ // would shadow the starter page. The flag answers before discovery runs.
106
+ it('does not shadow, or get shadowed by, a discovered template id', async () => {
107
+ const {status, stderr} = await runCli(['template', 'cdn'], {cwd: tmpDir});
108
+
109
+ expect(status).toBe(1);
110
+ expect(stderr).toMatch(/Unknown template "cdn"/);
111
+ expect(fs.existsSync(path.join(tmpDir, 'cdn.template.html'))).toBe(false);
112
+ });
113
+ });
@@ -18,7 +18,9 @@ export const doc = {
18
18
  description:
19
19
  'One entry point for the template family: with no name it lists the discovered ' +
20
20
  'templates; with a name it shows the source or a layout skeleton, or scaffolds it ' +
21
- 'into the project at a target path. Narrow an ambiguous name with --type and/or --package.',
21
+ 'into the project at a target path. Narrow an ambiguous name with --type and/or --package. ' +
22
+ '--cdn writes the no-build-step CDN starter page, which ships as an asset rather than as ' +
23
+ 'a discovered template.',
22
24
  fn: 'template',
23
25
  args: [
24
26
  {name: 'name', param: 'name', required: false},
@@ -43,6 +45,12 @@ export const doc = {
43
45
  description:
44
46
  'Show layout skeleton with spatial annotations (padding, gap, nesting)',
45
47
  },
48
+ {
49
+ flag: '--cdn [path]',
50
+ param: 'options.cdn',
51
+ description:
52
+ 'Write the no-build-step CDN starter page (default: cdn.template.html)',
53
+ },
46
54
  {
47
55
  flag: '-f, --overwrite',
48
56
  param: 'options.overwrite',
@@ -55,9 +63,11 @@ export const doc = {
55
63
  label: 'Scaffold into the app',
56
64
  cli: 'astryx template dashboard ./src/app',
57
65
  },
66
+ {label: 'CDN starter page', cli: 'astryx template --cdn'},
67
+ {label: 'CDN starter page, elsewhere', cli: 'astryx template --cdn public/demo.html'},
58
68
  ],
59
69
  exitCodes: [
60
- {code: 0, when: 'success'},
70
+ {code: 0, when: 'success, including a CDN page left untouched because it already exists'},
61
71
  {
62
72
  code: 1,
63
73
  when: 'unknown or ambiguous template, no source, a path escape, or an existing target without --overwrite',
@@ -41,7 +41,8 @@ export {discoverTemplates, listTemplates} from '../../../api/template/template.m
41
41
  * import('../../../api/template/template.type.mjs').TemplateListResponse |
42
42
  * import('../../../api/template/template.type.mjs').TemplateShowResponse |
43
43
  * import('../../../api/template/template.type.mjs').TemplateSkeletonResponse |
44
- * import('../../../api/template/template.type.mjs').TemplateCopyResponse
44
+ * import('../../../api/template/template.type.mjs').TemplateCopyResponse |
45
+ * import('../../../api/template/template.type.mjs').TemplateCdnResponse
45
46
  * )} TemplateResponse
46
47
  */
47
48
 
@@ -55,7 +56,7 @@ export function registerTemplate(program) {
55
56
  /**
56
57
  * @param {string | undefined} name
57
58
  * @param {string | undefined} targetPath
58
- * @param {{list?: boolean, type?: string, package?: string, skeleton?: boolean, overwrite?: boolean}} options
59
+ * @param {{list?: boolean, type?: string, package?: string, skeleton?: boolean, cdn?: boolean | string, overwrite?: boolean}} options
59
60
  */
60
61
  async (name, targetPath, options) => {
61
62
  const json = program.opts().json || false;
@@ -83,7 +84,8 @@ export function registerTemplate(program) {
83
84
  name &&
84
85
  targetPath &&
85
86
  !options.list &&
86
- !options.skeleton
87
+ !options.skeleton &&
88
+ !options.cdn
87
89
  ) {
88
90
  const collision = await detectTemplateCollision(name, targetPath);
89
91
  if (collision && !options.overwrite) {
@@ -103,6 +105,7 @@ export function registerTemplate(program) {
103
105
  await templateApi(name, {
104
106
  list: options.list,
105
107
  skeleton: options.skeleton,
108
+ cdn: options.cdn,
106
109
  type: /** @type {'page' | 'block' | undefined} */ (options.type),
107
110
  package: options.package,
108
111
  targetPath,
@@ -147,6 +150,7 @@ export function registerTemplate(program) {
147
150
  `${run} template <id> --skeleton Layout reference`,
148
151
  `${run} template --list --type block List only blocks`,
149
152
  `${run} template --list --package <pkg> List from one package`,
153
+ `${run} template --cdn CDN starter page, no build step`,
150
154
  ].join('\n'),
151
155
  ),
152
156
  );
@@ -179,6 +183,23 @@ export function registerTemplate(program) {
179
183
  );
180
184
  break;
181
185
  }
186
+
187
+ case 'template.cdn': {
188
+ if (!result.data.written) {
189
+ emit(
190
+ text(`[skip] ${result.data.path} already exists — left as is.`),
191
+ text('Pass --overwrite to replace it with a fresh copy.'),
192
+ );
193
+ break;
194
+ }
195
+ emit(
196
+ text(`[ok] Wrote ${result.data.path}`),
197
+ text(
198
+ `Open it in a browser — no bundler, no install, no build step. Every CDN URL is pinned to ${result.data.version}, and the annotations mark the parts that are load-bearing.`,
199
+ ),
200
+ );
201
+ break;
202
+ }
182
203
  }
183
204
  },
184
205
  });
@@ -14,17 +14,19 @@ export const doc = {
14
14
  name: 'theme build',
15
15
  displayName: 'astryx theme build',
16
16
  namespace: 'cli',
17
- summary: 'Compile a defineTheme file to CSS + JS',
17
+ summary: 'Compile one or more defineTheme files to CSS + JS',
18
18
  description:
19
19
  'Compiles a file that calls defineTheme() into a scoped CSS file, a JS module, and ' +
20
- 'type declarations: the exact CSS the <Theme> runtime emits. With --check it writes ' +
20
+ 'type declarations: the exact CSS the <Theme> runtime emits. Takes any number of theme ' +
21
+ 'files and compiles them in one process, in argument order, stopping at the first ' +
22
+ 'failure — an app with several themes does not need a shell loop. With --check it writes ' +
21
23
  'nothing and instead reports whether the committed outputs have drifted from source. ' +
22
24
  'When a separate build step emits the icon registry, --icons-specifier declares the ' +
23
25
  'fully specified module path that the generated JS should import.',
24
26
  fn: 'themeBuild',
25
- args: [{name: 'file', param: 'file', required: true}],
27
+ args: [{name: 'files', param: 'file', required: true, variadic: true}],
26
28
  options: [
27
- {flag: '-o, --out <path>', param: 'options.out', description: 'Output CSS file path'},
29
+ {flag: '-o, --out <path>', param: 'options.out', description: 'Output CSS file path (single theme only)'},
28
30
  {
29
31
  flag: '--icons-specifier <specifier>',
30
32
  param: 'options.iconsSpecifier',
@@ -33,7 +35,7 @@ export const doc = {
33
35
  },
34
36
  {
35
37
  flag: '-w, --watch',
36
- description: 'Rebuild automatically when the theme file changes (Ctrl-C to stop)',
38
+ description: 'Rebuild automatically when a theme file changes (Ctrl-C to stop)',
37
39
  },
38
40
  {
39
41
  flag: '-c, --check',
@@ -47,6 +49,10 @@ export const doc = {
47
49
  label: 'Build to a CSS file',
48
50
  cli: 'astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css',
49
51
  },
52
+ {
53
+ label: 'Build every theme in a directory',
54
+ cli: 'astryx theme build ./src/themes/*.ts',
55
+ },
50
56
  {
51
57
  label: 'Check for drift (CI)',
52
58
  cli: 'astryx theme build ./src/themes/ocean.ts --check',
@@ -61,7 +61,7 @@ describe('--json shim: --help renders JSON envelope', () => {
61
61
  expect(parsed.apiVersion).toBe(1);
62
62
  expect(parsed.type).toBe('help');
63
63
  expect(parsed.data.command).toBe('astryx theme build');
64
- expect(parsed.data.usage).toMatch(/<file>/);
64
+ expect(parsed.data.usage).toMatch(/<files\.\.\.>/);
65
65
  });
66
66
  });
67
67
 
@@ -70,9 +70,10 @@ export const RESPONSE_TYPES = {
70
70
  'template.show',
71
71
  'template.skeleton',
72
72
  'template.copy',
73
+ 'template.cdn',
73
74
  ],
74
75
  hook: ['hook.list', 'hook.detail', 'hook.detail.params'],
75
- 'theme build': ['theme.build', 'theme.build.check'],
76
+ 'theme build': ['theme.build', 'theme.build.check', 'theme.build.batch'],
76
77
  'theme list': ['theme.list'],
77
78
  'theme add': ['theme.list', 'theme.add'],
78
79
  'theme template': ['theme.template'],
@@ -103,7 +104,7 @@ const EXAMPLES = {
103
104
  ],
104
105
  build: ['astryx build', 'astryx build "analytics dashboard" --json'],
105
106
  swizzle: ['astryx swizzle XDSButton'],
106
- template: ['astryx template --json', 'astryx template dashboard ./src/app'],
107
+ template: ['astryx template --json', 'astryx template dashboard ./src/app', 'astryx template --cdn'],
107
108
  hook: ['astryx hook', 'astryx hook useFocusTrap --json'],
108
109
  'theme build': [
109
110
  'astryx theme build ./src/themes/ocean.ts --out ./dist/ocean.css',