@astryxdesign/cli 0.4.2-canary.b2057d1 → 0.4.2-canary.bb07062
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.
|
@@ -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
|
{
|
|
@@ -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.bb07062",
|
|
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.bb07062",
|
|
88
|
+
"@astryxdesign/core": "0.4.2-canary.bb07062",
|
|
89
|
+
"@astryxdesign/lab": "0.4.2-canary.bb07062",
|
|
90
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.bb07062",
|
|
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.bb07062",
|
|
109
|
+
"@astryxdesign/core": "0.4.2-canary.bb07062",
|
|
110
|
+
"@astryxdesign/lab": "0.4.2-canary.bb07062",
|
|
111
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.bb07062",
|
|
112
112
|
"gpt-tokenizer": "^3.4.0"
|
|
113
113
|
},
|
|
114
114
|
"scripts": {
|