@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; legacy eval returns raw input.
1103
- const isAlreadyResolved =
1104
- !themeDef.typography && !themeDef.motion && !themeDef.radius;
1105
- if (isAlreadyResolved) {
1106
- resolvedTheme = themeDef;
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 = _defineTheme({
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(true);
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
- ['fonts', 'Base fonts included first, then child fonts appended.'],
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 be '.' or start with '..'.`,
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.b2057d1",
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.b2057d1",
88
- "@astryxdesign/core": "0.4.2-canary.b2057d1",
89
- "@astryxdesign/lab": "0.4.2-canary.b2057d1",
90
- "@astryxdesign/theme-neutral": "0.4.2-canary.b2057d1",
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.b2057d1",
109
- "@astryxdesign/core": "0.4.2-canary.b2057d1",
110
- "@astryxdesign/lab": "0.4.2-canary.b2057d1",
111
- "@astryxdesign/theme-neutral": "0.4.2-canary.b2057d1",
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": {