@astryxdesign/cli 0.1.4 → 0.1.5-canary.3f81427
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 +45 -1
- package/docs/getting-started.doc.mjs +4 -0
- package/docs/migration.doc.mjs +134 -0
- package/docs/styling.doc.mjs +53 -0
- package/docs/working-with-ai.doc.mjs +1 -1
- package/package.json +15 -10
- package/src/api/component.mjs +2 -1
- package/src/api/doctor.mjs +13 -2
- package/src/api/template.mjs +14 -3
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/__tests__/runner.test.mjs +38 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/runner.mjs +2 -0
- package/src/codemods/transforms/v0.1.5/__tests__/rename-switch-label-spacing-default-to-hug.test.mjs +93 -0
- package/src/codemods/transforms/v0.1.5/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.5/rename-switch-label-spacing-default-to-hug.mjs +140 -0
- package/src/commands/agent-docs.mjs +9 -6
- package/src/commands/agent-docs.test.mjs +33 -0
- package/src/commands/build-theme.color-scheme.test.mjs +153 -0
- package/src/commands/build-theme.mjs +112 -14
- package/src/commands/build-theme.variants.test.mjs +193 -0
- package/src/commands/component-resolution.test.mjs +4 -4
- package/src/commands/doctor.test.mjs +34 -0
- package/src/commands/init.mjs +1 -1
- package/src/commands/swizzle.mjs +34 -0
- package/src/commands/swizzle.routing.test.mjs +67 -0
- package/src/lib/component-discovery.mjs +8 -4
- package/templates/blocks/components/AspectRatio/AspectRatioCircleImage.tsx +1 -6
- package/templates/blocks/components/AspectRatio/AspectRatioImageGallery.tsx +2 -7
- package/templates/blocks/components/AspectRatio/AspectRatioShowcase.tsx +2 -5
- package/templates/blocks/components/AspectRatio/AspectRatioSquareImage.tsx +1 -6
- package/templates/blocks/components/AspectRatio/AspectRatioWidescreen.tsx +1 -6
- package/templates/blocks/components/BaseTypeahead/BaseTypeaheadCustomSearch.doc.mjs +14 -0
- package/templates/blocks/components/BaseTypeahead/BaseTypeaheadCustomSearch.tsx +58 -0
- package/templates/blocks/components/CodeBlock/CodeBlockTerminal.doc.mjs +14 -0
- package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +24 -0
- package/templates/blocks/components/Collapsible/CollapsibleDividedAccordion.doc.mjs +13 -0
- package/templates/blocks/components/Collapsible/CollapsibleDividedAccordion.tsx +33 -0
- package/templates/blocks/components/Collapsible/CollapsibleWithoutCard.tsx +25 -23
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupShowcase.tsx +27 -48
- package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputBasic.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputBasic.tsx +39 -0
- package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteInput/CommandPaletteInputShowcase.tsx +39 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemShowcase.tsx +31 -54
- package/templates/blocks/components/CommandPaletteList/CommandPaletteListBasic.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteList/CommandPaletteListBasic.tsx +27 -0
- package/templates/blocks/components/CommandPaletteList/CommandPaletteListShowcase.doc.mjs +15 -0
- package/templates/blocks/components/CommandPaletteList/CommandPaletteListShowcase.tsx +38 -0
- package/templates/blocks/components/ContextMenuItem/ContextMenuItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/ContextMenuItem/ContextMenuItemBasic.tsx +44 -0
- package/templates/blocks/components/ContextMenuItem/ContextMenuItemShowcase.doc.mjs +15 -0
- package/templates/blocks/components/ContextMenuItem/ContextMenuItemShowcase.tsx +107 -0
- package/templates/blocks/components/NavHeadingMenu/NavHeadingMenuShowcase.doc.mjs +15 -0
- package/templates/blocks/components/NavHeadingMenu/NavHeadingMenuShowcase.tsx +47 -0
- package/templates/blocks/components/Table/TableGroupedRowsTable.doc.mjs +14 -0
- package/templates/blocks/components/Table/TableGroupedRowsTable.tsx +66 -0
- package/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +14 -0
- package/templates/blocks/components/Table/TableRowIndexTable.tsx +47 -0
- package/templates/pages/classic-gallery/page.tsx +1 -1
- package/templates/pages/dashboard/page.tsx +1 -1
- package/templates/pages/documentation/page.tsx +1 -1
- package/templates/pages/incident-console/page.tsx +580 -0
- package/templates/pages/incident-console/template.doc.mjs +12 -0
- package/templates/pages/kanban-board/page.tsx +37 -13
- package/templates/pages/messaging-shell/page.tsx +676 -0
- package/templates/pages/messaging-shell/template.doc.mjs +12 -0
- package/templates/pages/mixed-gallery/page.tsx +1 -1
- package/templates/pages/payment-form/page.tsx +2 -6
- package/templates/pages/product-detail/page.tsx +3 -11
- package/templates/pages/product-gallery/page.tsx +1 -1
- package/templates/pages/settings/page.tsx +1 -1
- package/templates/pages/side-gallery/page.tsx +1 -1
- package/templates/pages/table-page/page.tsx +1 -1
- package/templates/pages/table-page-chart/page.tsx +4 -14
- package/templates/pages/table-page-heatmap-status/page.tsx +1 -1
- package/templates/pages/table-page-shoe-store-heatmap/page.tsx +5 -19
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Codemod: Rename Switch labelSpacing "default" to "hug"
|
|
5
|
+
* @see https://github.com/facebook/astryx/issues/2889
|
|
6
|
+
*
|
|
7
|
+
* The `labelSpacing="default"` value is renamed to `labelSpacing="hug"` so
|
|
8
|
+
* the name describes the behavior (label and switch hug each other), matching
|
|
9
|
+
* the behavioral register of the sibling `spread` value and the existing
|
|
10
|
+
* `hug`/`fill` layout vocabulary on TabList and SegmentedControl. The old
|
|
11
|
+
* `default` value keeps working as a deprecated alias, so this codemod is a
|
|
12
|
+
* cleanup, not a build fix.
|
|
13
|
+
*
|
|
14
|
+
* Transforms (scoped to Switch):
|
|
15
|
+
* - <Switch labelSpacing="default" /> → <Switch labelSpacing="hug" />
|
|
16
|
+
* - <Switch labelSpacing={'default'} /> → <Switch labelSpacing={'hug'} />
|
|
17
|
+
* - <Switch labelSpacing={cond ? 'default' : x}> → <Switch labelSpacing={cond ? 'hug' : x}>
|
|
18
|
+
* - Storybook argTypes: labelSpacing: { options: [..., 'default', ...] }
|
|
19
|
+
* → replaces 'default' with 'hug' (files importing Switch)
|
|
20
|
+
* - Object properties: { labelSpacing: 'default' } / { labelSpacing: 'default' as const }
|
|
21
|
+
* → { labelSpacing: 'hug' } (files importing Switch)
|
|
22
|
+
*
|
|
23
|
+
* The transform deliberately does NOT touch bare `'default'` strings that are
|
|
24
|
+
* not a `labelSpacing` value (an extremely common string otherwise), so it is
|
|
25
|
+
* safe to run across an entire codebase.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const meta = {
|
|
29
|
+
title: 'Rename Switch labelSpacing "default" to "hug"',
|
|
30
|
+
description:
|
|
31
|
+
'Renames the `labelSpacing="default"` value to `labelSpacing="hug"` on ' +
|
|
32
|
+
'Switch. The old value keeps working as a deprecated alias. Also updates ' +
|
|
33
|
+
'Storybook labelSpacing argTypes options and object-literal `labelSpacing` ' +
|
|
34
|
+
'props in files that import Switch.',
|
|
35
|
+
pr: '#2889',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const OLD_VALUE = 'default';
|
|
39
|
+
const NEW_VALUE = 'hug';
|
|
40
|
+
|
|
41
|
+
/** Prop whose value is being renamed. */
|
|
42
|
+
const TARGET_PROP = 'labelSpacing';
|
|
43
|
+
|
|
44
|
+
/** Components whose `labelSpacing` prop accepts a SwitchLabelSpacing value. */
|
|
45
|
+
const TARGET_COMPONENTS = new Set(['Switch']);
|
|
46
|
+
|
|
47
|
+
export default function transformer(file, api) {
|
|
48
|
+
const j = api.jscodeshift;
|
|
49
|
+
const root = j(file.source);
|
|
50
|
+
let hasChanges = false;
|
|
51
|
+
|
|
52
|
+
/** Rewrite a string-literal node when it equals the old value. */
|
|
53
|
+
function renameStringLiteral(node) {
|
|
54
|
+
if (!node) return false;
|
|
55
|
+
if (
|
|
56
|
+
(node.type === 'StringLiteral' || node.type === 'Literal') &&
|
|
57
|
+
node.value === OLD_VALUE
|
|
58
|
+
) {
|
|
59
|
+
node.value = NEW_VALUE;
|
|
60
|
+
if (node.raw) node.raw = `'${NEW_VALUE}'`;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Recursively rewrite the old value inside ternary/logical expressions. */
|
|
67
|
+
function renameInExpression(node) {
|
|
68
|
+
if (!node) return false;
|
|
69
|
+
let changed = false;
|
|
70
|
+
if (node.type === 'StringLiteral' || node.type === 'Literal') {
|
|
71
|
+
changed = renameStringLiteral(node) || changed;
|
|
72
|
+
} else if (node.type === 'ConditionalExpression') {
|
|
73
|
+
changed = renameInExpression(node.consequent) || changed;
|
|
74
|
+
changed = renameInExpression(node.alternate) || changed;
|
|
75
|
+
} else if (node.type === 'LogicalExpression') {
|
|
76
|
+
changed = renameInExpression(node.left) || changed;
|
|
77
|
+
changed = renameInExpression(node.right) || changed;
|
|
78
|
+
} else if (node.type === 'TSAsExpression') {
|
|
79
|
+
// `'default' as const` → rewrite the inner expression
|
|
80
|
+
changed = renameInExpression(node.expression) || changed;
|
|
81
|
+
}
|
|
82
|
+
return changed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- 1. JSX attribute: labelSpacing="default" / labelSpacing={'default'} on Switch ---
|
|
86
|
+
root.find(j.JSXOpeningElement).forEach(path => {
|
|
87
|
+
const name = path.node.name;
|
|
88
|
+
const componentName = name.type === 'JSXIdentifier' ? name.name : null;
|
|
89
|
+
if (!componentName || !TARGET_COMPONENTS.has(componentName)) return;
|
|
90
|
+
|
|
91
|
+
path.node.attributes.forEach(attr => {
|
|
92
|
+
if (attr.type !== 'JSXAttribute') return;
|
|
93
|
+
if (!attr.name || attr.name.name !== TARGET_PROP) return;
|
|
94
|
+
|
|
95
|
+
const value = attr.value;
|
|
96
|
+
if (!value) return;
|
|
97
|
+
|
|
98
|
+
if (value.type === 'StringLiteral' || value.type === 'Literal') {
|
|
99
|
+
if (renameStringLiteral(value)) hasChanges = true;
|
|
100
|
+
} else if (value.type === 'JSXExpressionContainer') {
|
|
101
|
+
if (renameInExpression(value.expression)) hasChanges = true;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Object-property / argTypes transforms only run in files that use a target
|
|
107
|
+
// component — keeps unrelated `{ labelSpacing: 'default' }` strings safe.
|
|
108
|
+
const importsTarget =
|
|
109
|
+
root
|
|
110
|
+
.find(j.ImportSpecifier)
|
|
111
|
+
.filter(p => TARGET_COMPONENTS.has(p.node.imported?.name))
|
|
112
|
+
.size() > 0;
|
|
113
|
+
|
|
114
|
+
if (importsTarget) {
|
|
115
|
+
const PropertyType = j.ObjectProperty ?? j.Property;
|
|
116
|
+
|
|
117
|
+
// --- 2. Object property: { labelSpacing: 'default' } / 'default' as const ---
|
|
118
|
+
root.find(PropertyType, {key: {name: TARGET_PROP}}).forEach(path => {
|
|
119
|
+
if (renameInExpression(path.node.value)) hasChanges = true;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- 3. Storybook argTypes: labelSpacing: { options: [..., 'default', ...] } ---
|
|
123
|
+
root.find(PropertyType, {key: {name: TARGET_PROP}}).forEach(path => {
|
|
124
|
+
const value = path.node.value;
|
|
125
|
+
if (!value || value.type !== 'ObjectExpression') return;
|
|
126
|
+
|
|
127
|
+
const optionsProp = value.properties.find(
|
|
128
|
+
p => p.key && (p.key.name === 'options' || p.key.value === 'options'),
|
|
129
|
+
);
|
|
130
|
+
if (optionsProp && optionsProp.value.type === 'ArrayExpression') {
|
|
131
|
+
optionsProp.value.elements.forEach(el => {
|
|
132
|
+
if (renameStringLiteral(el)) hasChanges = true;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!hasChanges) return undefined;
|
|
139
|
+
return root.toSource({quote: 'single'});
|
|
140
|
+
}
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* - Claude Code: CLAUDE.md (root) or .claude/CLAUDE.md
|
|
10
10
|
* - Cursor: .cursorrules
|
|
11
11
|
* - Codex/generic: AGENTS.md
|
|
12
|
+
* - Hermes Agent: .hermes.md or HERMES.md (existing), else AGENTS.md
|
|
12
13
|
*
|
|
13
14
|
* Auto-detect: discovers existing files and updates them in place.
|
|
14
15
|
* Default (no existing files): creates .claude/CLAUDE.md.
|
|
15
16
|
*
|
|
16
|
-
* --agent <tool>: target a specific tool preset (claude, cursor, codex, all)
|
|
17
|
+
* --agent <tool>: target a specific tool preset (claude, cursor, codex, hermes, all)
|
|
17
18
|
* --agent-docs-path <path>: explicit file path(s)
|
|
18
19
|
*/
|
|
19
20
|
|
|
@@ -29,7 +30,8 @@ import {ERROR_CODES} from '../lib/error-codes.mjs';
|
|
|
29
30
|
|
|
30
31
|
const AGENTS_MD = 'AGENTS.md';
|
|
31
32
|
const CLAUDE_MD = 'CLAUDE.md';
|
|
32
|
-
const CLAUDE_DIR_MD =
|
|
33
|
+
const CLAUDE_DIR_MD = '.claude/CLAUDE.md'; // cross-platform literal
|
|
34
|
+
|
|
33
35
|
|
|
34
36
|
const MARKER_START = '<!-- ASTRYX:START -->';
|
|
35
37
|
const MARKER_END = '<!-- ASTRYX:END -->';
|
|
@@ -45,6 +47,7 @@ const AGENT_PRESETS = {
|
|
|
45
47
|
claude: [CLAUDE_MD, CLAUDE_DIR_MD],
|
|
46
48
|
cursor: ['.cursorrules', AGENTS_MD],
|
|
47
49
|
codex: [AGENTS_MD],
|
|
50
|
+
hermes: ['.hermes.md', 'HERMES.md', AGENTS_MD],
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -63,7 +66,7 @@ export function discoverAgentDocs(targetDir) {
|
|
|
63
66
|
* Searches for existing files first, falls back to default creation path.
|
|
64
67
|
*
|
|
65
68
|
* @param {string} targetDir
|
|
66
|
-
* @param {string} agent - Preset name: 'claude', 'cursor', 'codex', 'all'
|
|
69
|
+
* @param {string} agent - Preset name: 'claude', 'cursor', 'codex', 'hermes', 'all'
|
|
67
70
|
* @returns {{inject: string[], create: string[]}} Files to inject into vs create fresh
|
|
68
71
|
*/
|
|
69
72
|
export function resolveAgentPaths(targetDir, agent) {
|
|
@@ -385,7 +388,7 @@ export function removeAgentDocs(targetDir) {
|
|
|
385
388
|
* @param {object} [options]
|
|
386
389
|
* @param {boolean} [options.zh]
|
|
387
390
|
* @param {string} [options.lang]
|
|
388
|
-
* @param {string} [options.agent] - Tool preset: 'claude', 'cursor', 'codex', 'all'
|
|
391
|
+
* @param {string} [options.agent] - Tool preset: 'claude', 'cursor', 'codex', 'hermes', 'all'
|
|
389
392
|
* @param {string[]} [options.paths] - Explicit paths (overrides agent/auto-detect)
|
|
390
393
|
* @param {boolean} [options.onlyReplace] - Only update files that already have Astryx markers (for upgrades)
|
|
391
394
|
* @returns {string[]} List of files written
|
|
@@ -467,14 +470,14 @@ export function installAgentDocs(targetDir, {zh = false, lang, agent, paths, onl
|
|
|
467
470
|
return written;
|
|
468
471
|
}
|
|
469
472
|
|
|
470
|
-
const VALID_AGENTS = ['claude', 'cursor', 'codex', 'all'];
|
|
473
|
+
const VALID_AGENTS = ['claude', 'cursor', 'codex', 'hermes', 'all'];
|
|
471
474
|
|
|
472
475
|
export function registerAgentDocs(program) {
|
|
473
476
|
program
|
|
474
477
|
.command('agent-docs')
|
|
475
478
|
.description('Install/update the component index for AI coding agents')
|
|
476
479
|
.option('--remove', 'Remove the design system section from all agent doc files')
|
|
477
|
-
.option('--agent <tool>', 'Target tool: claude, cursor, codex, all')
|
|
480
|
+
.option('--agent <tool>', 'Target tool: claude, cursor, codex, hermes, all')
|
|
478
481
|
.option('--agent-docs-path <path...>', 'Explicit file path(s) to write to')
|
|
479
482
|
.action(options => {
|
|
480
483
|
const targetDir = process.cwd();
|
|
@@ -449,6 +449,17 @@ describe('installAgentDocs', () => {
|
|
|
449
449
|
expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(true);
|
|
450
450
|
});
|
|
451
451
|
|
|
452
|
+
it('respects --agent hermes preset: creates AGENTS.md', () => {
|
|
453
|
+
setupCorePackage(tmpDir);
|
|
454
|
+
|
|
455
|
+
const written = installAgentDocs(tmpDir, {agent: 'hermes'});
|
|
456
|
+
|
|
457
|
+
expect(written).toEqual(['AGENTS.md']);
|
|
458
|
+
const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
|
|
459
|
+
expect(content).toContain('<!-- ASTRYX:START -->');
|
|
460
|
+
expect(fs.existsSync(path.join(tmpDir, '.claude'))).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
452
463
|
it('respects explicit --paths', () => {
|
|
453
464
|
setupCorePackage(tmpDir);
|
|
454
465
|
|
|
@@ -548,4 +559,26 @@ describe('resolveAgentPaths', () => {
|
|
|
548
559
|
expect(result.create).toContain('AGENTS.md');
|
|
549
560
|
expect(result.create).toContain('.claude/CLAUDE.md');
|
|
550
561
|
});
|
|
562
|
+
|
|
563
|
+
it('hermes preset creates AGENTS.md when nothing exists', () => {
|
|
564
|
+
const result = resolveAgentPaths(tmpDir, 'hermes');
|
|
565
|
+
expect(result).toEqual({inject: [], create: ['AGENTS.md']});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('hermes preset finds existing .hermes.md', () => {
|
|
569
|
+
fs.writeFileSync(path.join(tmpDir, '.hermes.md'), '');
|
|
570
|
+
const result = resolveAgentPaths(tmpDir, 'hermes');
|
|
571
|
+
expect(result).toEqual({inject: ['.hermes.md'], create: []});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('hermes preset finds existing HERMES.md when no .hermes.md', () => {
|
|
575
|
+
fs.writeFileSync(path.join(tmpDir, 'HERMES.md'), '');
|
|
576
|
+
const result = resolveAgentPaths(tmpDir, 'hermes');
|
|
577
|
+
expect(result).toEqual({inject: ['HERMES.md'], create: []});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('claude preset still creates .claude/CLAUDE.md when nothing exists (hermes is additive)', () => {
|
|
581
|
+
const result = resolveAgentPaths(tmpDir, 'claude');
|
|
582
|
+
expect(result).toEqual({inject: [], create: ['.claude/CLAUDE.md']});
|
|
583
|
+
});
|
|
551
584
|
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Regression test for the `color-scheme` declaration `astryx theme build`
|
|
5
|
+
* emits alongside `light-dark()` token output (see #3658).
|
|
6
|
+
*
|
|
7
|
+
* `astryx theme build` injects a `color-scheme` declaration into
|
|
8
|
+
* `@layer astryx-theme` whenever the generated CSS contains `light-dark()`,
|
|
9
|
+
* so LightningCSS/browsers can resolve it. That declaration must mirror
|
|
10
|
+
* reset.css's own `html[data-theme]` -> `color-scheme` mapping (a bare
|
|
11
|
+
* `:root { color-scheme: light dark; }` alone permanently pins the page to
|
|
12
|
+
* "light dark", defeating `<Theme mode="light|dark">` forcing regardless of
|
|
13
|
+
* `@layer astryx-theme` being declared after `@layer reset` in layer order):
|
|
14
|
+
*
|
|
15
|
+
* :root { color-scheme: light dark; }
|
|
16
|
+
* html[data-theme="light"] { color-scheme: light; }
|
|
17
|
+
* html[data-theme="dark"] { color-scheme: dark; }
|
|
18
|
+
*
|
|
19
|
+
* Themes that never use `light-dark()` have no `color-scheme` ambiguity to
|
|
20
|
+
* resolve, so none of the three rules should be emitted for them.
|
|
21
|
+
*
|
|
22
|
+
* Building `astryx theme build` requires a compiled @astryxdesign/core (there is no in-CLI
|
|
23
|
+
* fallback generator), so this suite builds core once in beforeAll via the
|
|
24
|
+
* shared ensureCoreBuilt() helper — which serializes concurrent Vitest workers
|
|
25
|
+
* behind a lock — to stay self-sufficient regardless of CI job ordering.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
29
|
+
import {execFileSync} from 'node:child_process';
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as os from 'node:os';
|
|
33
|
+
import {fileURLToPath} from 'node:url';
|
|
34
|
+
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
35
|
+
|
|
36
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
|
|
38
|
+
|
|
39
|
+
const COLOR_SCHEME_ROOT_DECL = ':root { color-scheme: light dark; }';
|
|
40
|
+
const COLOR_SCHEME_LIGHT_DECL = 'html[data-theme="light"] { color-scheme: light; }';
|
|
41
|
+
const COLOR_SCHEME_DARK_DECL = 'html[data-theme="dark"] { color-scheme: dark; }';
|
|
42
|
+
|
|
43
|
+
function runCli(args, cwd) {
|
|
44
|
+
try {
|
|
45
|
+
const out = execFileSync('node', [CLI_BIN, ...args], {
|
|
46
|
+
cwd,
|
|
47
|
+
encoding: 'utf-8',
|
|
48
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
49
|
+
env: {...process.env, FORCE_COLOR: '0'},
|
|
50
|
+
});
|
|
51
|
+
return {code: 0, stdout: out, stderr: ''};
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return {
|
|
54
|
+
code: e.status ?? 1,
|
|
55
|
+
stdout: e.stdout?.toString() ?? '',
|
|
56
|
+
stderr: e.stderr?.toString() ?? '',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeTheme(dir, name, tokens) {
|
|
62
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
63
|
+
// The CLI writes <basename>.css next to the source file, so use the
|
|
64
|
+
// theme name as the filename for unambiguous fixtures.
|
|
65
|
+
const file = path.join(dir, `${name}.mjs`);
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
file,
|
|
68
|
+
`export default { name: ${JSON.stringify(name)}, tokens: ${JSON.stringify(tokens)} };\n`,
|
|
69
|
+
);
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `astryx theme build` imports the compiled @astryxdesign/core/theme entry. Build core
|
|
74
|
+
// once if it isn't already present so the suite works in any CI job.
|
|
75
|
+
beforeAll(() => {
|
|
76
|
+
ensureCoreBuilt();
|
|
77
|
+
}, 200_000);
|
|
78
|
+
|
|
79
|
+
let tmpDir;
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-color-scheme-'));
|
|
82
|
+
});
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('theme build color-scheme output', () => {
|
|
88
|
+
it('emits the mode-aware color-scheme rules in @layer astryx-theme for a light-dark() theme', () => {
|
|
89
|
+
const project = path.join(tmpDir, 'project');
|
|
90
|
+
const themesDir = path.join(project, 'themes');
|
|
91
|
+
// A raw light-dark() token value is what triggers the color-scheme
|
|
92
|
+
// injection in build-theme.mjs (it checks the generated CSS for the
|
|
93
|
+
// literal substring). `defineTheme.test.ts` already covers the separate
|
|
94
|
+
// [light, dark] tuple -> light-dark() conversion; this fixture writes
|
|
95
|
+
// the string directly so this test stays focused on the CLI's own
|
|
96
|
+
// color-scheme decision, not on that conversion.
|
|
97
|
+
const themeFile = writeTheme(themesDir, 'with-light-dark', {
|
|
98
|
+
'--color-accent': 'light-dark(#0077B6, #48CAE4)',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = runCli(
|
|
102
|
+
['theme', 'build', path.relative(project, themeFile)],
|
|
103
|
+
project,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(result.code).toBe(0);
|
|
107
|
+
|
|
108
|
+
const cssPath = path.join(themesDir, 'with-light-dark.css');
|
|
109
|
+
expect(fs.existsSync(cssPath)).toBe(true);
|
|
110
|
+
const css = fs.readFileSync(cssPath, 'utf-8');
|
|
111
|
+
|
|
112
|
+
expect(css).toContain('light-dark(#0077B6, #48CAE4)');
|
|
113
|
+
|
|
114
|
+
// All 3 rules must be present...
|
|
115
|
+
expect(css).toContain(COLOR_SCHEME_ROOT_DECL);
|
|
116
|
+
expect(css).toContain(COLOR_SCHEME_LIGHT_DECL);
|
|
117
|
+
expect(css).toContain(COLOR_SCHEME_DARK_DECL);
|
|
118
|
+
|
|
119
|
+
// ...and specifically inside @layer astryx-theme, not @layer reset (the
|
|
120
|
+
// declaration must sit alongside the light-dark() token/component output
|
|
121
|
+
// it resolves, not the zero-specificity prose defaults).
|
|
122
|
+
const themeLayerStart = css.indexOf('@layer astryx-theme');
|
|
123
|
+
expect(themeLayerStart).toBeGreaterThanOrEqual(0);
|
|
124
|
+
const themeLayerBody = css.slice(themeLayerStart);
|
|
125
|
+
expect(themeLayerBody).toContain(COLOR_SCHEME_ROOT_DECL);
|
|
126
|
+
expect(themeLayerBody).toContain(COLOR_SCHEME_LIGHT_DECL);
|
|
127
|
+
expect(themeLayerBody).toContain(COLOR_SCHEME_DARK_DECL);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('omits all color-scheme rules for a theme with no light-dark() tokens', () => {
|
|
131
|
+
const project = path.join(tmpDir, 'project');
|
|
132
|
+
const themesDir = path.join(project, 'themes');
|
|
133
|
+
const themeFile = writeTheme(themesDir, 'no-light-dark', {
|
|
134
|
+
'--color-accent': '#0077B6',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = runCli(
|
|
138
|
+
['theme', 'build', path.relative(project, themeFile)],
|
|
139
|
+
project,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(result.code).toBe(0);
|
|
143
|
+
|
|
144
|
+
const cssPath = path.join(themesDir, 'no-light-dark.css');
|
|
145
|
+
expect(fs.existsSync(cssPath)).toBe(true);
|
|
146
|
+
const css = fs.readFileSync(cssPath, 'utf-8');
|
|
147
|
+
|
|
148
|
+
expect(css).not.toContain('light-dark(');
|
|
149
|
+
expect(css).not.toContain(COLOR_SCHEME_ROOT_DECL);
|
|
150
|
+
expect(css).not.toContain(COLOR_SCHEME_LIGHT_DECL);
|
|
151
|
+
expect(css).not.toContain(COLOR_SCHEME_DARK_DECL);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -175,14 +175,85 @@ async function getKnownValues(componentName) {
|
|
|
175
175
|
return _knownValuesCache.get(componentName);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Resolve `@astryxdesign/core`'s package root relative to the CLI package. Core
|
|
180
|
+
* and the CLI ship as siblings (`@astryxdesign/core`, `@astryxdesign/cli`), so
|
|
181
|
+
* `../../../core` from `src/commands/` reaches core whether installed from npm
|
|
182
|
+
* or run inside the monorepo. Returns null if it can't be found.
|
|
183
|
+
*/
|
|
184
|
+
function resolveCoreRoot() {
|
|
185
|
+
const cliDir = path.dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
const coreRoot = path.resolve(cliDir, '../../../core');
|
|
187
|
+
return fs.existsSync(coreRoot) ? coreRoot : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the type declarations `@astryxdesign/core/<Component>` exposes so we can
|
|
192
|
+
* check whether a given augmentation-target interface actually exists before
|
|
193
|
+
* generating a module augmentation against it.
|
|
194
|
+
*
|
|
195
|
+
* Reads the shipped `dist/<Component>/index.d.ts` (what a consumer's TypeScript
|
|
196
|
+
* actually sees), falling back to the `src/<Component>/index.ts` in the
|
|
197
|
+
* monorepo. Returns the file contents, or '' if nothing is found.
|
|
198
|
+
*/
|
|
199
|
+
const _componentDeclCache = new Map();
|
|
200
|
+
function readComponentDeclarations(pascalName) {
|
|
201
|
+
if (_componentDeclCache.has(pascalName)) {
|
|
202
|
+
return _componentDeclCache.get(pascalName);
|
|
203
|
+
}
|
|
204
|
+
let contents = '';
|
|
205
|
+
const coreRoot = resolveCoreRoot();
|
|
206
|
+
if (coreRoot) {
|
|
207
|
+
const candidates = [
|
|
208
|
+
path.join(coreRoot, 'dist', pascalName, 'index.d.ts'),
|
|
209
|
+
path.join(coreRoot, 'src', pascalName, 'index.ts'),
|
|
210
|
+
];
|
|
211
|
+
for (const file of candidates) {
|
|
212
|
+
try {
|
|
213
|
+
if (fs.existsSync(file)) {
|
|
214
|
+
contents = fs.readFileSync(file, 'utf-8');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// ignore and try the next candidate
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
_componentDeclCache.set(pascalName, contents);
|
|
223
|
+
return contents;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Determine whether `@astryxdesign/core/<Component>` exports an interface named
|
|
228
|
+
* `interfaceName` that can be augmented via module augmentation.
|
|
229
|
+
*
|
|
230
|
+
* Only interfaces are extension points — closed literal-union types (e.g.
|
|
231
|
+
* `HeadingType`, `ButtonSize`) are NOT augmentable, so a generated augmentation
|
|
232
|
+
* against them is dead code. We check that the name is exported (directly or
|
|
233
|
+
* re-exported) as a type/interface.
|
|
234
|
+
*/
|
|
235
|
+
function componentHasAugmentableInterface(pascalName, interfaceName) {
|
|
236
|
+
const decl = readComponentDeclarations(pascalName);
|
|
237
|
+
if (!decl) return false;
|
|
238
|
+
// Word-boundary match so `ButtonVariantMap` doesn't match `XButtonVariantMap`.
|
|
239
|
+
const re = new RegExp(`\\b${interfaceName}\\b`);
|
|
240
|
+
return re.test(decl);
|
|
241
|
+
}
|
|
242
|
+
|
|
178
243
|
/**
|
|
179
244
|
* Generate TypeScript declaration content with module augmentation for custom
|
|
180
245
|
* component prop values found in the theme's `components` keys. Reads known
|
|
181
246
|
* values from doc files to filter out base prop values.
|
|
182
247
|
*
|
|
183
|
-
* Interface naming convention:
|
|
184
|
-
* banner + status →
|
|
185
|
-
* button + variant →
|
|
248
|
+
* Interface naming convention: PascalCase(component) + PascalCase(prop) + Map
|
|
249
|
+
* banner + status → BannerStatusMap
|
|
250
|
+
* button + variant → ButtonVariantMap
|
|
251
|
+
*
|
|
252
|
+
* An augmentation is only emitted when `@astryxdesign/core/<Component>` actually
|
|
253
|
+
* exports a matching interface. Props backed by closed literal-union types
|
|
254
|
+
* (e.g. Button `size`, Heading `type`/`level`) have no augmentation point, so
|
|
255
|
+
* generating a `declare module` block for them would be dead code — those are
|
|
256
|
+
* skipped.
|
|
186
257
|
*
|
|
187
258
|
* @param {object} themeDef - Theme definition (resolved by defineTheme)
|
|
188
259
|
* @returns {Promise<string|null>} TypeScript declaration content, or null if no augmentations needed
|
|
@@ -234,7 +305,14 @@ async function generateVariantDeclarationsAsync(themeDef) {
|
|
|
234
305
|
const pascal = toPascalCase(component);
|
|
235
306
|
const propPascal = prop.charAt(0).toUpperCase() + prop.slice(1);
|
|
236
307
|
const modulePath = `@astryxdesign/core/${pascal}`;
|
|
237
|
-
const interfaceName =
|
|
308
|
+
const interfaceName = `${pascal}${propPascal}Map`;
|
|
309
|
+
|
|
310
|
+
// Only augment interfaces that actually exist as an extension point in
|
|
311
|
+
// core. Props backed by closed literal-union types (e.g. Button `size`,
|
|
312
|
+
// Heading `type`/`level`) have no `*Map` interface — a `declare module`
|
|
313
|
+
// block against a non-existent interface just creates a new, unused
|
|
314
|
+
// interface and never extends the component's prop union, so skip it.
|
|
315
|
+
if (!componentHasAugmentableInterface(pascal, interfaceName)) continue;
|
|
238
316
|
|
|
239
317
|
sections.push(`declare module '${modulePath}' {`);
|
|
240
318
|
sections.push(` interface ${interfaceName} {`);
|
|
@@ -247,6 +325,13 @@ async function generateVariantDeclarationsAsync(themeDef) {
|
|
|
247
325
|
}
|
|
248
326
|
}
|
|
249
327
|
|
|
328
|
+
// If every custom value targeted a non-augmentable prop, there's nothing to
|
|
329
|
+
// emit beyond the header — return null so no `.variants.d.ts` is written.
|
|
330
|
+
const hasEmittedAugmentation = sections.some(line =>
|
|
331
|
+
line.startsWith('declare module'),
|
|
332
|
+
);
|
|
333
|
+
if (!hasEmittedAugmentation) return null;
|
|
334
|
+
|
|
250
335
|
return sections.join('\n');
|
|
251
336
|
}
|
|
252
337
|
|
|
@@ -433,13 +518,21 @@ ${iconReExport}`;
|
|
|
433
518
|
/**
|
|
434
519
|
* Generate TypeScript declarations for a built theme module.
|
|
435
520
|
*/
|
|
436
|
-
function generateBuiltTypes(themeDef, iconInfo) {
|
|
521
|
+
function generateBuiltTypes(themeDef, iconInfo, variantsFileName) {
|
|
437
522
|
const iconType = iconInfo
|
|
438
523
|
? `import type { IconRegistry } from '@astryxdesign/core/Icon';
|
|
439
524
|
export declare const ${iconInfo.exportName}: IconRegistry;
|
|
440
525
|
`
|
|
441
526
|
: '';
|
|
442
|
-
|
|
527
|
+
// Pull in the generated custom-variant augmentations so that importing the
|
|
528
|
+
// theme's types also loads the module augmentations (otherwise the
|
|
529
|
+
// `.variants.d.ts` is emitted but never referenced, and the custom variants
|
|
530
|
+
// never widen the component prop unions for consumers).
|
|
531
|
+
const variantsRef = variantsFileName
|
|
532
|
+
? `/// <reference path="./${variantsFileName}" />
|
|
533
|
+
`
|
|
534
|
+
: '';
|
|
535
|
+
return `${variantsRef}import type { DefinedTheme } from '@astryxdesign/core/theme';
|
|
443
536
|
${iconType}export declare const ${toIdentifier(themeDef.name)}Theme: DefinedTheme;
|
|
444
537
|
`;
|
|
445
538
|
}
|
|
@@ -846,8 +939,9 @@ export function registerTheme(program) {
|
|
|
846
939
|
if (component.length > 0) {
|
|
847
940
|
const componentInner = component.join('\n\n');
|
|
848
941
|
const componentScope = `@scope (${scopeSelector}) to (${scopeTo}) {\n${componentInner}\n}`;
|
|
942
|
+
// #3658: also emit attribute-specific rules so <Theme mode> can override color-scheme
|
|
849
943
|
const colorSchemeDecl = componentScope.includes('light-dark(')
|
|
850
|
-
? ' :root { color-scheme: light dark; }\n\n'
|
|
944
|
+
? ' :root { color-scheme: light dark; }\n html[data-theme="light"] { color-scheme: light; }\n html[data-theme="dark"] { color-scheme: dark; }\n\n'
|
|
851
945
|
: '';
|
|
852
946
|
cssParts.push(
|
|
853
947
|
`@layer astryx-theme {\n${colorSchemeDecl}${componentScope}\n}`,
|
|
@@ -894,19 +988,23 @@ export function registerTheme(program) {
|
|
|
894
988
|
|
|
895
989
|
const iconInfo = extractIconInfo(filePath);
|
|
896
990
|
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
const jsContent = generatedHeader(sourceRelative, 'js', buildCommand) + generateBuiltModule(resolvedTheme || themeDef, iconInfo);
|
|
900
|
-
const dtsContent = generatedHeader(sourceRelative, 'ts', buildCommand) + generateBuiltTypes(themeDef, iconInfo);
|
|
901
|
-
|
|
902
|
-
// Type augmentation .d.ts if theme has custom prop values
|
|
991
|
+
// Type augmentation .d.ts if theme has custom prop values. Computed
|
|
992
|
+
// before the main .d.ts so the latter can reference it (see below).
|
|
903
993
|
const augmentationSource = resolvedTheme || themeDef;
|
|
904
994
|
const variantDecl = await generateVariantDeclarationsAsync(augmentationSource);
|
|
905
|
-
const
|
|
995
|
+
const variantsFileName = variantDecl ? `${baseName}.variants.d.ts` : null;
|
|
996
|
+
const variantDtsPath = variantDecl ? path.join(outDir, variantsFileName) : null;
|
|
906
997
|
const variantContent = variantDecl
|
|
907
998
|
? generatedHeader(sourceRelative, 'ts', buildCommand) + variantDecl
|
|
908
999
|
: null;
|
|
909
1000
|
|
|
1001
|
+
// Generate all file contents in memory first. The main .d.ts references
|
|
1002
|
+
// the variants file (when present) via a triple-slash directive so
|
|
1003
|
+
// importing the theme also loads the custom-variant augmentations.
|
|
1004
|
+
const cssContent = generatedHeader(sourceRelative, 'css', buildCommand) + css;
|
|
1005
|
+
const jsContent = generatedHeader(sourceRelative, 'js', buildCommand) + generateBuiltModule(resolvedTheme || themeDef, iconInfo);
|
|
1006
|
+
const dtsContent = generatedHeader(sourceRelative, 'ts', buildCommand) + generateBuiltTypes(themeDef, iconInfo, variantsFileName);
|
|
1007
|
+
|
|
910
1008
|
// Atomic-ish write: stage every file as `<dest>.tmp`, then rename
|
|
911
1009
|
// each into place. If any stage step fails we clean up partials and
|
|
912
1010
|
// exit; if a rename fails mid-way we still have the originals (or
|