@astryxdesign/cli 0.4.2-canary.791f395 → 0.4.2-canary.8b07cb9
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/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +76 -14
- package/api/theme/build/build.test.mjs +175 -1
- package/assets/docs/theme.doc.mjs +6 -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/clients/cli/commands/build-theme.icons-specifier.test.mjs +131 -7
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/package.json +9 -9
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Direct-API tests for `themeBuild`'s `iconsSpecifier` option (#4620).
|
|
5
|
+
*
|
|
6
|
+
* The CLI surface of `--icons-specifier` is pinned in
|
|
7
|
+
* clients/cli/commands/build-theme.icons-specifier.test.mjs; these tests pin
|
|
8
|
+
* the programmatic surface that watch mode, editor tooling, and build scripts
|
|
9
|
+
* call directly: the option reaches the emitted module, its absence leaves the
|
|
10
|
+
* scraped specifier byte-for-byte alone, and `check` mode compares the
|
|
11
|
+
* specifier-bearing text like any other generated byte — outputs built with a
|
|
12
|
+
* different specifier than the one being checked against are stale, not clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import {themeBuild} from './build.mjs';
|
|
20
|
+
|
|
21
|
+
let tmpDir;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = fs.mkdtempSync(
|
|
24
|
+
path.join(os.tmpdir(), 'astryx-api-icons-specifier-'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write a theme source (plus a loadable icons source beside it) under
|
|
33
|
+
* `<tmpDir>/src/`. The registry is a plain object — the emit path only
|
|
34
|
+
* re-exports it, so no React is needed.
|
|
35
|
+
*/
|
|
36
|
+
function writeIconTheme({withIcons = true, name = 'icotheme'} = {}) {
|
|
37
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
38
|
+
fs.mkdirSync(srcDir, {recursive: true});
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(srcDir, 'icons.ts'),
|
|
41
|
+
`export const myIcons = { close: 'x' };\n`,
|
|
42
|
+
);
|
|
43
|
+
const iconLines = withIcons ? [`import { myIcons } from './icons';`] : [];
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(srcDir, `${name}.ts`),
|
|
46
|
+
[
|
|
47
|
+
...iconLines,
|
|
48
|
+
`export default { name: '${name}', tokens: { '--color-bg': '#fff' }${
|
|
49
|
+
withIcons ? ', icons: myIcons' : ''
|
|
50
|
+
} };`,
|
|
51
|
+
'',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
);
|
|
54
|
+
return `src/${name}.ts`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function builtModule(name = 'icotheme') {
|
|
58
|
+
return fs.readFileSync(path.join(tmpDir, 'dist', `${name}.js`), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('themeBuild({iconsSpecifier}) — direct API', () => {
|
|
62
|
+
it('emits the declared specifier into the generated module', async () => {
|
|
63
|
+
const file = writeIconTheme();
|
|
64
|
+
|
|
65
|
+
const result = await themeBuild(
|
|
66
|
+
file,
|
|
67
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
68
|
+
{cwd: tmpDir},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result?.type).toBe('theme.build');
|
|
72
|
+
const js = builtModule();
|
|
73
|
+
expect(js).toContain(`import { myIcons } from "./icons.mjs";`);
|
|
74
|
+
// The registry re-export survives the override.
|
|
75
|
+
expect(js).toContain('export { myIcons }');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('emits the scraped specifier unchanged when the option is omitted', async () => {
|
|
79
|
+
const file = writeIconTheme();
|
|
80
|
+
|
|
81
|
+
const result = await themeBuild(
|
|
82
|
+
file,
|
|
83
|
+
{out: 'dist/theme.css'},
|
|
84
|
+
{cwd: tmpDir},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result?.type).toBe('theme.build');
|
|
88
|
+
expect(builtModule()).toContain(`import { myIcons } from './icons';`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is inert for a theme with no icons field', async () => {
|
|
92
|
+
const file = writeIconTheme({withIcons: false});
|
|
93
|
+
|
|
94
|
+
const result = await themeBuild(
|
|
95
|
+
file,
|
|
96
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
97
|
+
{cwd: tmpDir},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result?.type).toBe('theme.build');
|
|
101
|
+
// The header comment's usage example mentions imports; only a real
|
|
102
|
+
// statement (line-leading `import`) would be a leak.
|
|
103
|
+
const js = builtModule();
|
|
104
|
+
expect(js).not.toMatch(/^import /m);
|
|
105
|
+
expect(js).not.toContain('icons.mjs');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('check mode is clean against outputs built with the same specifier', async () => {
|
|
109
|
+
const file = writeIconTheme();
|
|
110
|
+
await themeBuild(
|
|
111
|
+
file,
|
|
112
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
113
|
+
{cwd: tmpDir},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await themeBuild(
|
|
117
|
+
file,
|
|
118
|
+
{out: 'dist/theme.css', check: true, iconsSpecifier: './icons.mjs'},
|
|
119
|
+
{cwd: tmpDir},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result?.type).toBe('theme.build.check');
|
|
123
|
+
expect(result?.data.upToDate).toBe(true);
|
|
124
|
+
expect(result?.data.stale).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('check mode reports outputs stale when the specifier differs', async () => {
|
|
128
|
+
const file = writeIconTheme();
|
|
129
|
+
await themeBuild(
|
|
130
|
+
file,
|
|
131
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
132
|
+
{cwd: tmpDir},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Checking without the option regenerates with the scraped './icons' —
|
|
136
|
+
// different bytes than the on-disk module, so the check must flag it.
|
|
137
|
+
const result = await themeBuild(
|
|
138
|
+
file,
|
|
139
|
+
{out: 'dist/theme.css', check: true},
|
|
140
|
+
{cwd: tmpDir},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result?.type).toBe('theme.build.check');
|
|
144
|
+
expect(result?.data.upToDate).toBe(false);
|
|
145
|
+
expect(result?.data.stale.some(entry => entry.reason === 'outdated')).toBe(
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -591,6 +591,30 @@ const themeScopeStart = (/** @type {string} */ name) =>
|
|
|
591
591
|
`[data-astryx-theme="${name}"]`;
|
|
592
592
|
const THEME_SCOPE_TO = `[data-astryx-theme]`;
|
|
593
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Module extensions the theme loader resolves, source before artifact.
|
|
596
|
+
*
|
|
597
|
+
* `theme build` writes `<name>.js` next to `<name>.ts`, and jiti's default
|
|
598
|
+
* order tries `.js` first — so once a base theme had been built, every sibling
|
|
599
|
+
* theme that `extends` it resolved to that generated artifact instead of the
|
|
600
|
+
* source. The artifact carries no `components` and exports a different name,
|
|
601
|
+
* so the inheritance silently evaporated. Resolving source first is also what
|
|
602
|
+
* the author's TypeScript sees, which is the point: the CSS the build emits
|
|
603
|
+
* matches the theme they type-checked.
|
|
604
|
+
*/
|
|
605
|
+
const THEME_MODULE_EXTENSIONS = [
|
|
606
|
+
'.ts',
|
|
607
|
+
'.tsx',
|
|
608
|
+
'.mts',
|
|
609
|
+
'.cts',
|
|
610
|
+
'.mtsx',
|
|
611
|
+
'.ctsx',
|
|
612
|
+
'.mjs',
|
|
613
|
+
'.cjs',
|
|
614
|
+
'.js',
|
|
615
|
+
'.json',
|
|
616
|
+
];
|
|
617
|
+
|
|
594
618
|
/**
|
|
595
619
|
* Import a theme module using jiti and find the defineTheme() result.
|
|
596
620
|
* Returns the resolved DefinedTheme object.
|
|
@@ -601,6 +625,7 @@ async function importThemeModule(filePath) {
|
|
|
601
625
|
const jiti = createJiti(import.meta.url, {
|
|
602
626
|
moduleCache: false,
|
|
603
627
|
jsx: true,
|
|
628
|
+
extensions: THEME_MODULE_EXTENSIONS,
|
|
604
629
|
});
|
|
605
630
|
|
|
606
631
|
const mod = await jiti.import(filePath, {default: true});
|
|
@@ -733,6 +758,13 @@ function extractIconInfo(filePath) {
|
|
|
733
758
|
* Includes the theme name, marker, and re-exports the icon registry.
|
|
734
759
|
* All styling is in the CSS file.
|
|
735
760
|
*
|
|
761
|
+
* The module carries the theme's resolved `components` and on-media surfaces
|
|
762
|
+
* alongside its tokens. They are not needed to apply the theme — the CSS holds
|
|
763
|
+
* all of that — but a built theme is a legitimate base for `extends` (the
|
|
764
|
+
* shipped themes expose one as their `./built` subpath), and a base that
|
|
765
|
+
* carries only tokens makes its children silently lose every component
|
|
766
|
+
* override it had.
|
|
767
|
+
*
|
|
736
768
|
* The icon registry is imported rather than inlined because it holds React
|
|
737
769
|
* elements, which cannot be serialized. `extractIconInfo` lifts the specifier
|
|
738
770
|
* out of the TypeScript source, where an extensionless `./icons` is resolved by
|
|
@@ -774,6 +806,27 @@ function generateBuiltModule(themeDef, iconInfo, iconsSpecifier) {
|
|
|
774
806
|
.map((line, i) => (i === 0 ? line : ' ' + line))
|
|
775
807
|
.join('\n');
|
|
776
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Serialize a resolved theme field as an indented object literal, or '' when
|
|
811
|
+
* there is nothing to emit.
|
|
812
|
+
* @param {string} field
|
|
813
|
+
* @param {unknown} value
|
|
814
|
+
* @returns {string}
|
|
815
|
+
*/
|
|
816
|
+
const serializeField = (field, value) => {
|
|
817
|
+
if (!value || Object.keys(value).length === 0) return '';
|
|
818
|
+
const body = JSON.stringify(value, null, 2)
|
|
819
|
+
.split('\n')
|
|
820
|
+
.map((line, i) => (i === 0 ? line : ' ' + line))
|
|
821
|
+
.join('\n');
|
|
822
|
+
return ` ${field}: ${body},\n`;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const inheritableFields =
|
|
826
|
+
serializeField('components', themeDef.components) +
|
|
827
|
+
serializeField('__onDark', themeDef.__onDark) +
|
|
828
|
+
serializeField('__onLight', themeDef.__onLight);
|
|
829
|
+
|
|
777
830
|
return `${iconImport}/**
|
|
778
831
|
* ${themeDef.name} theme — built by \`${getCliInvocation()} theme build\`
|
|
779
832
|
* Import the CSS file alongside this module:
|
|
@@ -785,7 +838,7 @@ export const ${toIdentifier(themeDef.name)}Theme = {
|
|
|
785
838
|
name: '${themeDef.name}',
|
|
786
839
|
__built: true,
|
|
787
840
|
tokens: ${tokensStr},
|
|
788
|
-
${iconsField}
|
|
841
|
+
${inheritableFields}${iconsField}
|
|
789
842
|
};
|
|
790
843
|
${iconReExport}`;
|
|
791
844
|
}
|
|
@@ -1099,20 +1152,29 @@ export async function themeBuild(
|
|
|
1099
1152
|
let css;
|
|
1100
1153
|
let resolvedTheme;
|
|
1101
1154
|
{
|
|
1102
|
-
// jiti returns an already-resolved theme;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1155
|
+
// jiti returns an already-resolved theme; a plain object literal (or the
|
|
1156
|
+
// legacy eval path) returns raw defineTheme input, which still has to go
|
|
1157
|
+
// through the resolver. Detect that by the input-only fields — a resolved
|
|
1158
|
+
// theme has none of them — and hand the WHOLE object over: picking fields
|
|
1159
|
+
// by name is how `extends` (and `color`, and `syntax`) used to be dropped
|
|
1160
|
+
// on the way in.
|
|
1161
|
+
const INPUT_ONLY_FIELDS = [
|
|
1162
|
+
'extends',
|
|
1163
|
+
'typography',
|
|
1164
|
+
'motion',
|
|
1165
|
+
'radius',
|
|
1166
|
+
'color',
|
|
1167
|
+
'syntax',
|
|
1168
|
+
'onDark',
|
|
1169
|
+
'onLight',
|
|
1170
|
+
];
|
|
1171
|
+
const needsResolution = INPUT_ONLY_FIELDS.some(
|
|
1172
|
+
field => themeDef[field] !== undefined,
|
|
1173
|
+
);
|
|
1174
|
+
if (needsResolution) {
|
|
1175
|
+
resolvedTheme = _defineTheme({...themeDef});
|
|
1107
1176
|
} else {
|
|
1108
|
-
resolvedTheme =
|
|
1109
|
-
name: themeDef.name,
|
|
1110
|
-
typography: themeDef.typography,
|
|
1111
|
-
motion: themeDef.motion,
|
|
1112
|
-
radius: themeDef.radius,
|
|
1113
|
-
tokens: themeDef.tokens,
|
|
1114
|
-
components: themeDef.components,
|
|
1115
|
-
});
|
|
1177
|
+
resolvedTheme = themeDef;
|
|
1116
1178
|
}
|
|
1117
1179
|
const scopeSelector = themeScopeStart(themeDef.name);
|
|
1118
1180
|
const scopeTo = THEME_SCOPE_TO;
|
|
@@ -323,6 +323,180 @@ describe('themeBuild() — the shipped theme template', () => {
|
|
|
323
323
|
expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
|
|
324
324
|
// The template teaches custom variants; the augmentation it promises the
|
|
325
325
|
// reader has to actually be generated.
|
|
326
|
-
expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
|
|
326
|
+
expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
|
|
327
|
+
true,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('themeBuild() — extends', () => {
|
|
333
|
+
// These fixtures `import {defineTheme} from '@astryxdesign/core/theme'` the
|
|
334
|
+
// way a real theme file does, so they have to sit somewhere that specifier
|
|
335
|
+
// resolves — an OS temp dir has no node_modules above it.
|
|
336
|
+
let extDir;
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
extDir = fs.mkdtempSync(
|
|
339
|
+
path.join(path.resolve(import.meta.dirname, '../../..'), '.tmp-extends-'),
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
afterEach(() => {
|
|
343
|
+
fs.rmSync(extDir, {recursive: true, force: true});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every `prop: value` a generated stylesheet actually applies. Header
|
|
348
|
+
* comments and scope wrappers are ignored — two themes never share those.
|
|
349
|
+
*/
|
|
350
|
+
function declarations(css) {
|
|
351
|
+
return new Set(
|
|
352
|
+
css
|
|
353
|
+
.split('\n')
|
|
354
|
+
.map(l => l.trim())
|
|
355
|
+
.filter(l => /^[-a-z][^{}]*:.+;$/.test(l)),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
/** Every component rule a stylesheet opens, e.g. `.astryx-switch {`. */
|
|
359
|
+
function selectors(css) {
|
|
360
|
+
return new Set(
|
|
361
|
+
css
|
|
362
|
+
.split('\n')
|
|
363
|
+
.map(l => l.trim())
|
|
364
|
+
.filter(l => l.endsWith('{') && l.startsWith('.')),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A base theme with geometry, elevation and a component override. */
|
|
369
|
+
const BASE_SOURCE = `export const brandTheme = {
|
|
370
|
+
name: 'ext-base',
|
|
371
|
+
tokens: {
|
|
372
|
+
'--radius-element': '6px',
|
|
373
|
+
'--shadow-low': '0 1px 3px rgb(0 0 0 / 0.1)',
|
|
374
|
+
'--color-border-emphasized': '#D4D4D4',
|
|
375
|
+
},
|
|
376
|
+
components: {
|
|
377
|
+
switch: {base: {backgroundColor: 'var(--color-border-emphasized)'}},
|
|
378
|
+
},
|
|
379
|
+
};\n`;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The child names its base with a plain relative specifier, exactly as a
|
|
383
|
+
* generated palette does. `theme build` writes `ext-base.js` next to
|
|
384
|
+
* `ext-base.mjs`, so `./ext-base` is ambiguous — and the artifact, which
|
|
385
|
+
* exports `extBaseTheme` rather than `brandTheme`, is the wrong answer.
|
|
386
|
+
*/
|
|
387
|
+
const CHILD_SOURCE = `import {defineTheme} from '@astryxdesign/core/theme';
|
|
388
|
+
import {brandTheme} from './ext-base';
|
|
389
|
+
export const paletteTheme = defineTheme({
|
|
390
|
+
name: 'ext-child',
|
|
391
|
+
extends: brandTheme,
|
|
392
|
+
tokens: {'--color-accent': 'hsl(220 88% 72%)'},
|
|
393
|
+
});\n`;
|
|
394
|
+
|
|
395
|
+
it('emits every declaration its base emits (the child stylesheet is self-contained)', async () => {
|
|
396
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
397
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
398
|
+
|
|
399
|
+
// Build the base FIRST, as any real project does — that write is what
|
|
400
|
+
// used to poison the child's build.
|
|
401
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
402
|
+
await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
403
|
+
|
|
404
|
+
const baseCss = fs.readFileSync(path.join(extDir, 'ext-base.css'), 'utf8');
|
|
405
|
+
const childCss = fs.readFileSync(
|
|
406
|
+
path.join(extDir, 'ext-child.css'),
|
|
407
|
+
'utf8',
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const childDecls = declarations(childCss);
|
|
411
|
+
expect([...declarations(baseCss)].filter(d => !childDecls.has(d))).toEqual(
|
|
412
|
+
[],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const childSelectors = selectors(childCss);
|
|
416
|
+
expect([...selectors(baseCss)].filter(s => !childSelectors.has(s))).toEqual(
|
|
417
|
+
[],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// …and the child's own override still wins.
|
|
421
|
+
expect(childCss).toContain('--color-accent: hsl(220 88% 72%);');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('resolves the base from its source, not from the generated sibling artifact', async () => {
|
|
425
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
426
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
427
|
+
|
|
428
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
429
|
+
const result = await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
430
|
+
|
|
431
|
+
expect(result?.data.componentCount).toBe(1);
|
|
432
|
+
expect(result?.data.tokenCount).toBe(4);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('inherits component overrides when the base IS a built theme module', async () => {
|
|
436
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
437
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
438
|
+
|
|
439
|
+
// Extending a package's pre-built theme module (e.g. the `./built`
|
|
440
|
+
// subpath the shipped themes expose) must not silently drop its
|
|
441
|
+
// component overrides.
|
|
442
|
+
fs.writeFileSync(
|
|
443
|
+
path.join(extDir, 'ext-built-child.mjs'),
|
|
444
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
445
|
+
import {extBaseTheme} from './ext-base.js';
|
|
446
|
+
export const builtChildTheme = defineTheme({
|
|
447
|
+
name: 'ext-built-child',
|
|
448
|
+
extends: extBaseTheme,
|
|
449
|
+
});\n`,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await themeBuild('ext-built-child.mjs', {}, {cwd: extDir});
|
|
453
|
+
const css = fs.readFileSync(
|
|
454
|
+
path.join(extDir, 'ext-built-child.css'),
|
|
455
|
+
'utf8',
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(css).toContain('.astryx-switch {');
|
|
459
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('resolves extends on a plain object theme file (no defineTheme call)', async () => {
|
|
463
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
464
|
+
fs.writeFileSync(
|
|
465
|
+
path.join(extDir, 'ext-plain.mjs'),
|
|
466
|
+
`import {brandTheme} from './ext-base.mjs';
|
|
467
|
+
export default {
|
|
468
|
+
name: 'ext-plain',
|
|
469
|
+
extends: brandTheme,
|
|
470
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
471
|
+
};\n`,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
475
|
+
await themeBuild('ext-plain.mjs', {}, {cwd: extDir});
|
|
476
|
+
|
|
477
|
+
const css = fs.readFileSync(path.join(extDir, 'ext-plain.css'), 'utf8');
|
|
478
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
479
|
+
expect(css).toContain('.astryx-switch {');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('fails loudly when the base import resolved to nothing', async () => {
|
|
483
|
+
fs.writeFileSync(
|
|
484
|
+
path.join(extDir, 'ext-broken.mjs'),
|
|
485
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
486
|
+
import {notAThing} from './ext-missing.mjs';
|
|
487
|
+
export const brokenTheme = defineTheme({
|
|
488
|
+
name: 'ext-broken',
|
|
489
|
+
extends: notAThing,
|
|
490
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
491
|
+
});\n`,
|
|
492
|
+
);
|
|
493
|
+
fs.writeFileSync(
|
|
494
|
+
path.join(extDir, 'ext-missing.mjs'),
|
|
495
|
+
`export const somethingElse = 1;\n`,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
await expect(
|
|
499
|
+
themeBuild('ext-broken.mjs', {}, {cwd: extDir}),
|
|
500
|
+
).rejects.toThrow(/extends/);
|
|
327
501
|
});
|
|
328
502
|
});
|
|
@@ -253,10 +253,15 @@ const brandTheme = defineTheme({
|
|
|
253
253
|
['tokens', 'Base tokens are copied first, then child tokens override on top.'],
|
|
254
254
|
['components', 'Deep-merged: child component rules override matching keys from the base.'],
|
|
255
255
|
['icons', 'Shallow-merged: child icons override matching names from the base.'],
|
|
256
|
-
['
|
|
256
|
+
['indicators', 'Shallow-merged: child indicators override matching names from the base.'],
|
|
257
|
+
['onDark, onLight', "Deep-merged per surface: the base's resolved surface first, then the child's overrides."],
|
|
257
258
|
['typography, motion, radius, color', 'Child config replaces base entirely (these are scale inputs, not additive).'],
|
|
258
259
|
],
|
|
259
260
|
},
|
|
261
|
+
{
|
|
262
|
+
type: 'prose',
|
|
263
|
+
text: 'Inheritance is resolved when the theme is defined, so an extended theme is flat: `astryx theme build` emits one self-contained stylesheet holding everything the child inherited, and the base theme\'s CSS does not need to be loaded next to it. A base that is not a theme — most often an import that missed — is a build error rather than a theme that silently inherits nothing.',
|
|
264
|
+
},
|
|
260
265
|
],
|
|
261
266
|
},
|
|
262
267
|
{
|
package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector — Deadline Picker',
|
|
8
|
+
displayName: 'Complex Selector — Deadline Picker',
|
|
9
|
+
description:
|
|
10
|
+
'A multi-step deadline field: pick a preset like Today or Next week, or switch to a custom date and time before applying. The popup stays open until the user commits, so the content owns the Apply action.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: [
|
|
14
|
+
'ComplexSelector',
|
|
15
|
+
'RadioList',
|
|
16
|
+
'DateInput',
|
|
17
|
+
'TimeInput',
|
|
18
|
+
'Button',
|
|
19
|
+
],
|
|
20
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useState} from 'react';
|
|
6
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
7
|
+
import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
|
|
8
|
+
import {DateInput} from '@astryxdesign/core/DateInput';
|
|
9
|
+
import {TimeInput} from '@astryxdesign/core/TimeInput';
|
|
10
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
11
|
+
import {VStack} from '@astryxdesign/core/Layout';
|
|
12
|
+
|
|
13
|
+
type ISODate =
|
|
14
|
+
`${number}${number}${number}${number}-${number}${number}-${number}${number}`;
|
|
15
|
+
type ISOTime = string & {readonly __brand: 'ISOTimeString'};
|
|
16
|
+
|
|
17
|
+
interface Deadline {
|
|
18
|
+
preset: 'today' | 'next-week' | 'custom';
|
|
19
|
+
date: ISODate;
|
|
20
|
+
time: ISOTime;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const presetLabels: Record<Deadline['preset'], string> = {
|
|
24
|
+
today: 'Today',
|
|
25
|
+
'next-week': 'Next week',
|
|
26
|
+
custom: 'Custom date',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function formatDeadline(value: Deadline) {
|
|
30
|
+
if (value.preset === 'custom') {
|
|
31
|
+
return `${value.date} at ${value.time}`;
|
|
32
|
+
}
|
|
33
|
+
return presetLabels[value.preset];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function ComplexSelectorDeadlinePicker() {
|
|
37
|
+
const [value, setValue] = useState<Deadline>({
|
|
38
|
+
preset: 'today',
|
|
39
|
+
date: '2026-04-06' as ISODate,
|
|
40
|
+
time: '17:00' as ISOTime,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ComplexSelector<Deadline>
|
|
45
|
+
label="Deadline"
|
|
46
|
+
description="Choose a preset or set a custom date and time."
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={setValue}
|
|
49
|
+
triggerLabel={formatDeadline(value)}
|
|
50
|
+
style={{width: 320}}>
|
|
51
|
+
{(selectedValue, onChange, close) => {
|
|
52
|
+
const set = (patch: Partial<Deadline>) =>
|
|
53
|
+
onChange({...selectedValue, ...patch});
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<VStack gap={4} style={{width: 320}}>
|
|
57
|
+
<RadioList
|
|
58
|
+
label="When is it due?"
|
|
59
|
+
value={selectedValue.preset}
|
|
60
|
+
onChange={preset => set({preset: preset as Deadline['preset']})}>
|
|
61
|
+
<RadioListItem label="Today" value="today" />
|
|
62
|
+
<RadioListItem label="Next week" value="next-week" />
|
|
63
|
+
<RadioListItem label="Custom date" value="custom" />
|
|
64
|
+
</RadioList>
|
|
65
|
+
|
|
66
|
+
{selectedValue.preset === 'custom' && (
|
|
67
|
+
<VStack gap={3}>
|
|
68
|
+
<DateInput
|
|
69
|
+
label="Date"
|
|
70
|
+
value={selectedValue.date}
|
|
71
|
+
onChange={date => date && set({date})}
|
|
72
|
+
/>
|
|
73
|
+
<TimeInput
|
|
74
|
+
label="Time"
|
|
75
|
+
value={selectedValue.time}
|
|
76
|
+
onChange={time => time && set({time})}
|
|
77
|
+
/>
|
|
78
|
+
</VStack>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<Button label="Apply" variant="primary" onClick={close} />
|
|
82
|
+
</VStack>
|
|
83
|
+
);
|
|
84
|
+
}}
|
|
85
|
+
</ComplexSelector>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector',
|
|
8
|
+
displayName: 'Complex Selector',
|
|
9
|
+
description:
|
|
10
|
+
'A two-axis picker: choose a fruit and a ripeness level from one control. ComplexSelector owns the trigger, popover, and focus restore while the custom grid owns its keyboard semantics.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 1,
|
|
13
|
+
isShowcase: true,
|
|
14
|
+
componentsUsed: ['ComplexSelector', 'Text'],
|
|
15
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import {useEffect, useState} from 'react';
|
|
6
|
+
import * as stylex from '@stylexjs/stylex';
|
|
7
|
+
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
|
|
8
|
+
import {Text} from '@astryxdesign/core/Text';
|
|
9
|
+
import {useGridFocus} from '@astryxdesign/core/hooks';
|
|
10
|
+
import {
|
|
11
|
+
borderVars,
|
|
12
|
+
colorVars,
|
|
13
|
+
radiusVars,
|
|
14
|
+
spacingVars,
|
|
15
|
+
} from '@astryxdesign/core/theme/tokens.stylex';
|
|
16
|
+
|
|
17
|
+
type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum';
|
|
18
|
+
type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak';
|
|
19
|
+
|
|
20
|
+
interface FruitValue {
|
|
21
|
+
fruit: Fruit;
|
|
22
|
+
ripeness: Ripeness;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fruits: Array<{id: Fruit; emoji: string; description: string}> = [
|
|
26
|
+
{id: 'Apple', emoji: '🍎', description: 'Bright and balanced'},
|
|
27
|
+
{id: 'Pear', emoji: '🍐', description: 'Soft floral sweetness'},
|
|
28
|
+
{id: 'Peach', emoji: '🍑', description: 'Round summer flavor'},
|
|
29
|
+
{id: 'Plum', emoji: '🟣', description: 'Jammy and tart'},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const ripenessLevels: Array<{
|
|
33
|
+
id: Ripeness;
|
|
34
|
+
shortLabel: string;
|
|
35
|
+
description: string;
|
|
36
|
+
}> = [
|
|
37
|
+
{id: 'Crisp', shortLabel: 'C', description: 'Snappy bite'},
|
|
38
|
+
{id: 'Tender', shortLabel: 'T', description: 'Easy bite'},
|
|
39
|
+
{id: 'Juicy', shortLabel: 'J', description: 'Full juice'},
|
|
40
|
+
{id: 'Peak', shortLabel: 'P', description: 'Most intense'},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const GRID_CELL_SELECTOR = '[role="gridcell"]';
|
|
44
|
+
|
|
45
|
+
const styles = stylex.create({
|
|
46
|
+
grid: {
|
|
47
|
+
display: 'flex',
|
|
48
|
+
flexDirection: 'column',
|
|
49
|
+
gap: spacingVars['--spacing-1'],
|
|
50
|
+
minWidth: 280,
|
|
51
|
+
},
|
|
52
|
+
row: {
|
|
53
|
+
display: 'grid',
|
|
54
|
+
gridTemplateColumns: 'minmax(150px, 1fr) repeat(4, 44px)',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
columnGap: spacingVars['--spacing-1'],
|
|
57
|
+
},
|
|
58
|
+
rowHeader: {
|
|
59
|
+
display: 'flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
gap: spacingVars['--spacing-2'],
|
|
62
|
+
textAlign: 'start',
|
|
63
|
+
minWidth: 0,
|
|
64
|
+
},
|
|
65
|
+
emoji: {
|
|
66
|
+
fontSize: 18,
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
},
|
|
69
|
+
fruitText: {
|
|
70
|
+
display: 'flex',
|
|
71
|
+
flexDirection: 'column',
|
|
72
|
+
minWidth: 0,
|
|
73
|
+
},
|
|
74
|
+
cell: {
|
|
75
|
+
display: 'flex',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
justifyContent: 'center',
|
|
78
|
+
height: 36,
|
|
79
|
+
borderWidth: borderVars['--border-width'],
|
|
80
|
+
borderStyle: 'solid',
|
|
81
|
+
borderColor: colorVars['--color-border'],
|
|
82
|
+
borderRadius: radiusVars['--radius-container'],
|
|
83
|
+
backgroundColor: colorVars['--color-background-card'],
|
|
84
|
+
color: colorVars['--color-text-secondary'],
|
|
85
|
+
fontFamily: 'inherit',
|
|
86
|
+
cursor: 'pointer',
|
|
87
|
+
':hover': {
|
|
88
|
+
'@media (hover: hover)': {
|
|
89
|
+
borderColor: colorVars['--color-border-emphasized'],
|
|
90
|
+
color: colorVars['--color-text-primary'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
cellSelected: {
|
|
95
|
+
borderColor: colorVars['--color-accent'],
|
|
96
|
+
backgroundColor: colorVars['--color-accent'],
|
|
97
|
+
color: colorVars['--color-on-accent'],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function FruitRipenessGrid({
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
}: {
|
|
105
|
+
value: FruitValue;
|
|
106
|
+
onChange: (value: FruitValue) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const {gridRef, handleKeyDown, handleFocus, focusCell} =
|
|
109
|
+
useGridFocus<HTMLDivElement>({
|
|
110
|
+
columns: ripenessLevels.length,
|
|
111
|
+
cellSelector: GRID_CELL_SELECTOR,
|
|
112
|
+
hasRovingTabIndex: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const rowIndex = fruits.findIndex(f => f.id === value.fruit);
|
|
117
|
+
const columnIndex = ripenessLevels.findIndex(l => l.id === value.ripeness);
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
focusCell(
|
|
120
|
+
rowIndex >= 0 && columnIndex >= 0
|
|
121
|
+
? rowIndex * ripenessLevels.length + columnIndex
|
|
122
|
+
: 0,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
}, [focusCell, value]);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
ref={gridRef}
|
|
130
|
+
role="grid"
|
|
131
|
+
aria-label="Fruit ripeness choices"
|
|
132
|
+
onKeyDown={handleKeyDown}
|
|
133
|
+
onFocus={handleFocus}
|
|
134
|
+
{...stylex.props(styles.grid)}>
|
|
135
|
+
{fruits.map(fruit => (
|
|
136
|
+
<div key={fruit.id} role="row" {...stylex.props(styles.row)}>
|
|
137
|
+
<div role="rowheader" {...stylex.props(styles.rowHeader)}>
|
|
138
|
+
<span aria-hidden="true" {...stylex.props(styles.emoji)}>
|
|
139
|
+
{fruit.emoji}
|
|
140
|
+
</span>
|
|
141
|
+
<span {...stylex.props(styles.fruitText)}>
|
|
142
|
+
<Text type="body">{fruit.id}</Text>
|
|
143
|
+
<Text type="supporting" color="secondary">
|
|
144
|
+
{fruit.description}
|
|
145
|
+
</Text>
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
{ripenessLevels.map(level => {
|
|
149
|
+
const isSelected =
|
|
150
|
+
value.fruit === fruit.id && value.ripeness === level.id;
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
key={`${fruit.id}-${level.id}`}
|
|
154
|
+
type="button"
|
|
155
|
+
role="gridcell"
|
|
156
|
+
aria-label={`${fruit.id}, ${level.id}: ${level.description}`}
|
|
157
|
+
aria-selected={isSelected || undefined}
|
|
158
|
+
tabIndex={isSelected ? 0 : -1}
|
|
159
|
+
onClick={() => onChange({fruit: fruit.id, ripeness: level.id})}
|
|
160
|
+
{...stylex.props(
|
|
161
|
+
styles.cell,
|
|
162
|
+
isSelected && styles.cellSelected,
|
|
163
|
+
)}>
|
|
164
|
+
{level.shortLabel}
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function ComplexSelectorShowcase() {
|
|
175
|
+
const [value, setValue] = useState<FruitValue>({
|
|
176
|
+
fruit: 'Apple',
|
|
177
|
+
ripeness: 'Juicy',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<ComplexSelector<FruitValue>
|
|
182
|
+
label="Fruit blend"
|
|
183
|
+
description="Choose a fruit and ripeness in one control. Arrow keys move across the grid."
|
|
184
|
+
value={value}
|
|
185
|
+
onChange={setValue}
|
|
186
|
+
triggerLabel={`${value.fruit} · ${value.ripeness}`}
|
|
187
|
+
style={{width: 280}}>
|
|
188
|
+
{(selectedValue, onChange, close) => (
|
|
189
|
+
<FruitRipenessGrid
|
|
190
|
+
value={selectedValue}
|
|
191
|
+
onChange={nextValue => {
|
|
192
|
+
onChange(nextValue);
|
|
193
|
+
close();
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
</ComplexSelector>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
|
|
4
|
+
export const doc = {
|
|
5
|
+
type: 'block',
|
|
6
|
+
exampleFor: 'ComplexSelector',
|
|
7
|
+
name: 'ComplexSelector — Tree Search',
|
|
8
|
+
displayName: 'Complex Selector — Tree Search',
|
|
9
|
+
description:
|
|
10
|
+
'A destination picker that combines a search field with a TreeList hierarchy. TreeList owns tree keyboard navigation; ComplexSelector owns the trigger, popover, and focus restore. Selecting a folder closes the popup.',
|
|
11
|
+
isReady: true,
|
|
12
|
+
aspectRatio: 4 / 3,
|
|
13
|
+
componentsUsed: ['ComplexSelector', 'TextInput', 'TreeList'],
|
|
14
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -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,114 @@ 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 CSS. fs.watch delivery is best-effort under
|
|
313
|
+
// load, so re-touch until the rebuild shows up (idempotent write).
|
|
314
|
+
const touched =
|
|
315
|
+
`import {testIcons} from './icons';\n` +
|
|
316
|
+
`export default {\n` +
|
|
317
|
+
` name: "watched",\n` +
|
|
318
|
+
` icons: testIcons,\n` +
|
|
319
|
+
` tokens: {'--color-bg': '#0a0b0c'},\n` +
|
|
320
|
+
`};\n`;
|
|
321
|
+
fs.writeFileSync(themeFile, touched);
|
|
322
|
+
const rebuilt = await waitFor(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (fs.readFileSync(cssFile, 'utf-8').includes('#0a0b0c'))
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
// CSS mid-write; fall through to re-touch.
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
fs.writeFileSync(themeFile, touched);
|
|
331
|
+
} catch {
|
|
332
|
+
// Retried on the next poll.
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
});
|
|
336
|
+
expect(rebuilt).toBe(true);
|
|
337
|
+
|
|
338
|
+
// The regenerated module still carries the declared specifier — the
|
|
339
|
+
// forwarding is what this test pins. A watch loop that dropped the flag
|
|
340
|
+
// would regenerate with the scraped './icons' here and ship the #4620
|
|
341
|
+
// bytes on every save.
|
|
342
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
343
|
+
declaredImport,
|
|
344
|
+
);
|
|
345
|
+
} finally {
|
|
346
|
+
child.kill('SIGINT');
|
|
347
|
+
}
|
|
348
|
+
}, 60_000);
|
|
349
|
+
});
|
|
@@ -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
|
}
|
|
@@ -91,6 +91,13 @@ describe('sanitizeName', () => {
|
|
|
91
91
|
expect(() => sanitizeName('.')).toThrow(PathSafetyError);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('rejects dotfile names (.env, .htaccess) that could create hidden files', () => {
|
|
95
|
+
expect(() => sanitizeName('.env')).toThrow(PathSafetyError);
|
|
96
|
+
expect(() => sanitizeName('.htaccess')).toThrow(PathSafetyError);
|
|
97
|
+
expect(() => sanitizeName('.bashrc')).toThrow(PathSafetyError);
|
|
98
|
+
expect(() => sanitizeName('.gitignore')).toThrow(PathSafetyError);
|
|
99
|
+
});
|
|
100
|
+
|
|
94
101
|
it('rejects NUL bytes', () => {
|
|
95
102
|
expect(() => sanitizeName('foo\0bar')).toThrow(PathSafetyError);
|
|
96
103
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.4.2-canary.
|
|
3
|
+
"version": "0.4.2-canary.8b07cb9",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -84,10 +84,10 @@
|
|
|
84
84
|
"zod": "^4.4.3"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
88
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
89
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
90
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
87
|
+
"@astryxdesign/charts": "0.4.2-canary.8b07cb9",
|
|
88
|
+
"@astryxdesign/core": "0.4.2-canary.8b07cb9",
|
|
89
|
+
"@astryxdesign/lab": "0.4.2-canary.8b07cb9",
|
|
90
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.8b07cb9",
|
|
91
91
|
"gpt-tokenizer": "^3.4.0"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
@@ -105,10 +105,10 @@
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
109
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
110
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
111
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
108
|
+
"@astryxdesign/charts": "0.4.2-canary.8b07cb9",
|
|
109
|
+
"@astryxdesign/core": "0.4.2-canary.8b07cb9",
|
|
110
|
+
"@astryxdesign/lab": "0.4.2-canary.8b07cb9",
|
|
111
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.8b07cb9",
|
|
112
112
|
"gpt-tokenizer": "^3.4.0"
|
|
113
113
|
},
|
|
114
114
|
"scripts": {
|