@astryxdesign/cli 0.4.2 → 0.4.3-canary.ac850d9
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 +44 -43
- 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 +6 -0
- package/api/theme/theme.type.mjs +4 -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/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/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/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/lib/manifest.mjs +2 -1
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/foundation/response/response-types.doc.mjs +6 -0
- package/foundation/text/copyright-header.mjs +11 -4
- package/package.json +9 -9
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useMemo, useState} from 'react';
|
|
6
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
7
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
8
|
+
import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList';
|
|
9
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
10
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
11
|
+
|
|
12
|
+
interface DestinationNode {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
path: string;
|
|
16
|
+
children?: DestinationNode[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Destination {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const destinationTree: DestinationNode[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'workspace',
|
|
28
|
+
label: 'Workspace',
|
|
29
|
+
path: '/Workspace',
|
|
30
|
+
children: [
|
|
31
|
+
{
|
|
32
|
+
id: 'research',
|
|
33
|
+
label: 'Research',
|
|
34
|
+
path: '/Workspace/Research',
|
|
35
|
+
children: [
|
|
36
|
+
{
|
|
37
|
+
id: 'field-notes',
|
|
38
|
+
label: 'Field notes',
|
|
39
|
+
path: '/Workspace/Research/Field notes',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'interviews',
|
|
43
|
+
label: 'Interviews',
|
|
44
|
+
path: '/Workspace/Research/Interviews',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'roadmap',
|
|
50
|
+
label: 'Roadmap',
|
|
51
|
+
path: '/Workspace/Roadmap',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'teams',
|
|
57
|
+
label: 'Teams',
|
|
58
|
+
path: '/Teams',
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
id: 'design-systems',
|
|
62
|
+
label: 'Design systems',
|
|
63
|
+
path: '/Teams/Design systems',
|
|
64
|
+
children: [
|
|
65
|
+
{
|
|
66
|
+
id: 'accessibility',
|
|
67
|
+
label: 'Accessibility',
|
|
68
|
+
path: '/Teams/Design systems/Accessibility',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function matches(node: DestinationNode, query: string): boolean {
|
|
77
|
+
if (node.label.toLowerCase().includes(query)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return (node.children ?? []).some(child => matches(child, query));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function filterTree(
|
|
84
|
+
nodes: DestinationNode[],
|
|
85
|
+
query: string,
|
|
86
|
+
): DestinationNode[] {
|
|
87
|
+
if (!query) {
|
|
88
|
+
return nodes;
|
|
89
|
+
}
|
|
90
|
+
return nodes
|
|
91
|
+
.filter(node => matches(node, query))
|
|
92
|
+
.map(node => ({
|
|
93
|
+
...node,
|
|
94
|
+
children: node.children ? filterTree(node.children, query) : undefined,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toItems(
|
|
99
|
+
nodes: DestinationNode[],
|
|
100
|
+
selectedId: string,
|
|
101
|
+
onSelect: (value: Destination) => void,
|
|
102
|
+
): TreeListItemData[] {
|
|
103
|
+
return nodes.map(node => {
|
|
104
|
+
const hasChildren = (node.children ?? []).length > 0;
|
|
105
|
+
return {
|
|
106
|
+
id: node.id,
|
|
107
|
+
label: node.label,
|
|
108
|
+
isSelected: node.id === selectedId,
|
|
109
|
+
isExpanded: true,
|
|
110
|
+
onClick: hasChildren
|
|
111
|
+
? undefined
|
|
112
|
+
: () => onSelect({id: node.id, label: node.label, path: node.path}),
|
|
113
|
+
children: hasChildren
|
|
114
|
+
? toItems(node.children ?? [], selectedId, onSelect)
|
|
115
|
+
: undefined,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function DestinationSearch({
|
|
121
|
+
value,
|
|
122
|
+
onChange,
|
|
123
|
+
close,
|
|
124
|
+
}: {
|
|
125
|
+
value: Destination;
|
|
126
|
+
onChange: (value: Destination) => void;
|
|
127
|
+
close: () => void;
|
|
128
|
+
}) {
|
|
129
|
+
const [query, setQuery] = useState('');
|
|
130
|
+
const items = useMemo(
|
|
131
|
+
() =>
|
|
132
|
+
toItems(
|
|
133
|
+
filterTree(destinationTree, query.toLowerCase()),
|
|
134
|
+
value.id,
|
|
135
|
+
next => {
|
|
136
|
+
onChange(next);
|
|
137
|
+
close();
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
[query, value.id, onChange, close],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<VStack gap={3} style={{width: 360}}>
|
|
145
|
+
<TextInput
|
|
146
|
+
label="Search destinations"
|
|
147
|
+
isLabelHidden
|
|
148
|
+
value={query}
|
|
149
|
+
onChange={setQuery}
|
|
150
|
+
hasClear
|
|
151
|
+
placeholder="Search folders or teams"
|
|
152
|
+
/>
|
|
153
|
+
{items.length > 0 ? (
|
|
154
|
+
<TreeList items={items} density="compact" />
|
|
155
|
+
) : (
|
|
156
|
+
<Text type="supporting" color="secondary">
|
|
157
|
+
No matching destinations.
|
|
158
|
+
</Text>
|
|
159
|
+
)}
|
|
160
|
+
</VStack>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default function ComplexSelectorTreeSearch() {
|
|
165
|
+
const [value, setValue] = useState<Destination>({
|
|
166
|
+
id: 'accessibility',
|
|
167
|
+
label: 'Accessibility',
|
|
168
|
+
path: '/Teams/Design systems/Accessibility',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ComplexSelector<Destination>
|
|
173
|
+
label="Destination"
|
|
174
|
+
description="Search and browse nested folders."
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={setValue}
|
|
177
|
+
triggerLabel={value.path}
|
|
178
|
+
style={{width: 360}}>
|
|
179
|
+
{(selectedValue, onChange, close) => (
|
|
180
|
+
<DestinationSearch
|
|
181
|
+
value={selectedValue}
|
|
182
|
+
onChange={onChange}
|
|
183
|
+
close={close}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</ComplexSelector>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -528,15 +528,17 @@ export const neutralTheme = defineTheme({
|
|
|
528
528
|
// to a deep tinted bg + light text rather than locking the
|
|
529
529
|
// light-mode pastel.
|
|
530
530
|
//
|
|
531
|
-
// The inner-header *-muted token
|
|
532
|
-
//
|
|
531
|
+
// The inner-header *-muted token carries the tinted background for every
|
|
532
|
+
// status, info included. A theme override that sets a plain CSS property
|
|
533
|
+
// instead lands in @layer astryx-theme, which StyleX's @layer priority4
|
|
534
|
+
// outranks, so `backgroundColor` here would silently do nothing and the
|
|
535
|
+
// info banner would paint no background at all.
|
|
533
536
|
//
|
|
534
537
|
// Status overrides reference --color-text-{hue} so text/icon colors
|
|
535
538
|
// stay in sync with the palette anchors automatically.
|
|
536
539
|
banner: {
|
|
537
540
|
'status:info': {
|
|
538
|
-
|
|
539
|
-
'--color-accent-muted': 'transparent',
|
|
541
|
+
'--color-accent-muted': 'var(--color-background-blue)',
|
|
540
542
|
'--color-text-primary': 'var(--color-text-blue)',
|
|
541
543
|
'--color-text-secondary': 'var(--color-text-blue)',
|
|
542
544
|
'--color-accent': 'var(--color-text-blue)',
|
package/assets/theme.template.ts
CHANGED
|
@@ -105,12 +105,13 @@ export const myTheme = defineTheme({
|
|
|
105
105
|
// ───────────────────────────────────────────────────────────────────────
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Generates the neutral ramp and the accent tokens from
|
|
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
|
|
113
|
-
* the
|
|
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
|
|
@@ -75,13 +75,16 @@ describe('theme build font-loading warning', () => {
|
|
|
75
75
|
expect(result.stdout).toContain('@font-face');
|
|
76
76
|
expect(result.stdout).toContain('font-display: swap');
|
|
77
77
|
expect(result.stdout).toContain('astryx docs typography');
|
|
78
|
-
// The one-line summaries follow the CLI's stream contract
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// The one-line summaries follow the CLI's stream contract. These are
|
|
79
|
+
// NOTICES about a correct theme, not warnings, so they go to stdout with
|
|
80
|
+
// the rest of the build's progress — stderr stays for the defects an
|
|
81
|
+
// author has to fix.
|
|
82
|
+
expect(result.stdout).toContain('note: Font "Space Grotesk"');
|
|
83
|
+
expect(result.stdout).toContain('note: Font "JetBrains Mono"');
|
|
84
|
+
expect(result.stderr).not.toContain('Font "');
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
it('keeps --json stdout one valid envelope:
|
|
87
|
+
it('keeps --json stdout one valid envelope: notices inside, snippet suppressed', async () => {
|
|
85
88
|
const project = path.join(tmpDir, 'project');
|
|
86
89
|
const themeFile = writeTheme(
|
|
87
90
|
project,
|
|
@@ -100,7 +103,7 @@ describe('theme build font-loading warning', () => {
|
|
|
100
103
|
// contract, not just substring presence.
|
|
101
104
|
const envelope = JSON.parse(result.stdout);
|
|
102
105
|
expect(envelope.type).toBe('theme.build');
|
|
103
|
-
expect(envelope.data.
|
|
106
|
+
expect(envelope.data.notices).toEqual(
|
|
104
107
|
expect.arrayContaining([expect.stringContaining('Font "Space Grotesk"')]),
|
|
105
108
|
);
|
|
106
109
|
expect(result.stdout).not.toContain('fonts.googleapis.com');
|
|
@@ -15,15 +15,34 @@
|
|
|
15
15
|
* declared rather than inferred. Absent the flag, output is byte-for-byte what
|
|
16
16
|
* it was before, which keeps the default no-`--out` flow — where the neighbour
|
|
17
17
|
* is an uncompiled `icons.tsx` that only a bundler can resolve — working.
|
|
18
|
+
*
|
|
19
|
+
* The spawned-process block at the bottom pins what only real processes can
|
|
20
|
+
* prove: the emitted module actually loads under Node ESM, and the watch
|
|
21
|
+
* loop's child re-invocations carry the flag to every rebuild.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
25
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
21
26
|
import * as fs from 'node:fs';
|
|
22
27
|
import * as path from 'node:path';
|
|
23
28
|
import * as os from 'node:os';
|
|
29
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
24
30
|
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
25
31
|
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
26
32
|
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const CLI_BIN = path.resolve(__dirname, '../bin/astryx.mjs');
|
|
35
|
+
|
|
36
|
+
/** Poll until `predicate()` is true or the timeout elapses. */
|
|
37
|
+
async function waitFor(predicate, {timeout = 20000, interval = 100} = {}) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (predicate()) return true;
|
|
41
|
+
if (Date.now() - start > timeout) return false;
|
|
42
|
+
await new Promise(r => setTimeout(r, interval));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
/**
|
|
28
47
|
* The emitted icon import, or null. Reads the statement rather than the whole
|
|
29
48
|
* file: the `@generated` header quotes the source filename and a usage example,
|
|
@@ -142,13 +161,7 @@ describe('theme build --icons-specifier', () => {
|
|
|
142
161
|
const relativeTheme = path.relative(project, themeFile);
|
|
143
162
|
|
|
144
163
|
const built = await runCli(
|
|
145
|
-
[
|
|
146
|
-
'theme',
|
|
147
|
-
'build',
|
|
148
|
-
relativeTheme,
|
|
149
|
-
'--icons-specifier',
|
|
150
|
-
'./icons.mjs',
|
|
151
|
-
],
|
|
164
|
+
['theme', 'build', relativeTheme, '--icons-specifier', './icons.mjs'],
|
|
152
165
|
project,
|
|
153
166
|
);
|
|
154
167
|
expect(built.code).toBe(0);
|
|
@@ -223,3 +236,115 @@ describe('theme build --icons-specifier', () => {
|
|
|
223
236
|
expect(generated).not.toContain('icons:');
|
|
224
237
|
});
|
|
225
238
|
});
|
|
239
|
+
|
|
240
|
+
describe('theme build --icons-specifier (spawned processes)', () => {
|
|
241
|
+
it('emits a module Node can actually load', async () => {
|
|
242
|
+
const project = path.join(tmpDir, 'project');
|
|
243
|
+
const themeFile = writeThemeWithIcons(project, 'loadable');
|
|
244
|
+
|
|
245
|
+
const result = await runCli(
|
|
246
|
+
[
|
|
247
|
+
'theme',
|
|
248
|
+
'build',
|
|
249
|
+
path.relative(project, themeFile),
|
|
250
|
+
'--icons-specifier',
|
|
251
|
+
'./icons.mjs',
|
|
252
|
+
],
|
|
253
|
+
project,
|
|
254
|
+
);
|
|
255
|
+
expect(result.code).toBe(0);
|
|
256
|
+
|
|
257
|
+
// The text assertions above prove the emitted line; only a real Node
|
|
258
|
+
// process proves the module resolves and evaluates. That distinction is
|
|
259
|
+
// the regression #4620 shipped: every byte existed, none of them loaded.
|
|
260
|
+
const builtUrl = pathToFileURL(path.join(project, 'loadable.js'));
|
|
261
|
+
const probe = spawnSync(
|
|
262
|
+
process.execPath,
|
|
263
|
+
[
|
|
264
|
+
'--input-type=module',
|
|
265
|
+
'-e',
|
|
266
|
+
`const m = await import(${JSON.stringify(builtUrl.href)});` +
|
|
267
|
+
`if (m.loadableTheme?.name !== 'loadable') throw new Error('bad theme export');` +
|
|
268
|
+
`if (typeof m.testIcons !== 'object') throw new Error('bad registry export');`,
|
|
269
|
+
],
|
|
270
|
+
{encoding: 'utf8'},
|
|
271
|
+
);
|
|
272
|
+
expect(probe.stderr).toBe('');
|
|
273
|
+
expect(probe.status).toBe(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('watch mode forwards the flag to every rebuild', async () => {
|
|
277
|
+
const project = path.join(tmpDir, 'project');
|
|
278
|
+
const themeFile = writeThemeWithIcons(project, 'watched');
|
|
279
|
+
const cssFile = path.join(project, 'out', 'theme.css');
|
|
280
|
+
const builtFile = path.join(project, 'out', 'watched.js');
|
|
281
|
+
const declaredImport = 'import { testIcons } from "./icons.mjs";';
|
|
282
|
+
|
|
283
|
+
const child = spawn(
|
|
284
|
+
process.execPath,
|
|
285
|
+
[
|
|
286
|
+
CLI_BIN,
|
|
287
|
+
'theme',
|
|
288
|
+
'build',
|
|
289
|
+
path.relative(project, themeFile),
|
|
290
|
+
'--out',
|
|
291
|
+
'out/theme.css',
|
|
292
|
+
'--icons-specifier',
|
|
293
|
+
'./icons.mjs',
|
|
294
|
+
'--watch',
|
|
295
|
+
],
|
|
296
|
+
{cwd: project, env: {...process.env, FORCE_COLOR: '0'}},
|
|
297
|
+
);
|
|
298
|
+
let output = '';
|
|
299
|
+
child.stdout.on('data', d => (output += d.toString()));
|
|
300
|
+
child.stderr.on('data', d => (output += d.toString()));
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Initial build: the declared specifier reaches the module.
|
|
304
|
+
expect(await waitFor(() => fs.existsSync(cssFile))).toBe(true);
|
|
305
|
+
expect(await waitFor(() => /Watching/i.test(output))).toBe(true);
|
|
306
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
307
|
+
declaredImport,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Rebuilds run through a child re-invocation of `theme build`, so the
|
|
311
|
+
// flag reaches them only if the watch loop forwards it. Change a token
|
|
312
|
+
// and wait for the rebuilt JavaScript module. fs.watch delivery is
|
|
313
|
+
// best-effort under load, so re-touch until the rebuild shows up
|
|
314
|
+
// (idempotent write).
|
|
315
|
+
const touched =
|
|
316
|
+
`import {testIcons} from './icons';\n` +
|
|
317
|
+
`export default {\n` +
|
|
318
|
+
` name: "watched",\n` +
|
|
319
|
+
` icons: testIcons,\n` +
|
|
320
|
+
` tokens: {'--color-bg': '#0a0b0c'},\n` +
|
|
321
|
+
`};\n`;
|
|
322
|
+
fs.writeFileSync(themeFile, touched);
|
|
323
|
+
const rebuilt = await waitFor(() => {
|
|
324
|
+
try {
|
|
325
|
+
if (fs.readFileSync(builtFile, 'utf-8').includes('#0a0b0c'))
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
// JavaScript module mid-write; fall through to re-touch.
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
fs.writeFileSync(themeFile, touched);
|
|
332
|
+
} catch {
|
|
333
|
+
// Retried on the next poll.
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
});
|
|
337
|
+
expect(rebuilt).toBe(true);
|
|
338
|
+
|
|
339
|
+
// The regenerated module still carries the declared specifier — the
|
|
340
|
+
// forwarding is what this test pins. A watch loop that dropped the flag
|
|
341
|
+
// would regenerate with the scraped './icons' here and ship the #4620
|
|
342
|
+
// bytes on every save.
|
|
343
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
344
|
+
declaredImport,
|
|
345
|
+
);
|
|
346
|
+
} finally {
|
|
347
|
+
child.kill('SIGINT');
|
|
348
|
+
}
|
|
349
|
+
}, 60_000);
|
|
350
|
+
});
|
|
@@ -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
|
});
|
|
@@ -70,6 +70,7 @@ 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
76
|
'theme build': ['theme.build', 'theme.build.check'],
|
|
@@ -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',
|
|
@@ -154,9 +154,9 @@ export function sanitizeName(name, options = {}) {
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
if (name === '.' || name === '..' || name.startsWith('
|
|
157
|
+
if (name === '.' || name === '..' || name.startsWith('.')) {
|
|
158
158
|
throw new PathSafetyError(
|
|
159
|
-
`Invalid ${label} "${name}": must not
|
|
159
|
+
`Invalid ${label} "${name}": must not start with '.'.`,
|
|
160
160
|
'NAME_TRAVERSAL',
|
|
161
161
|
);
|
|
162
162
|
}
|