@astryxdesign/cli 0.4.2 → 0.4.3-canary.5939961
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/api/theme/build/build.font-warning.test.mjs +23 -14
- package/api/theme/build/build.icons-specifier.test.mjs +149 -0
- package/api/theme/build/build.mjs +88 -16
- package/api/theme/build/build.test.mjs +185 -14
- package/api/theme/theme.type.d.mts +6 -0
- package/api/theme/theme.type.mjs +4 -1
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +11 -5
- package/assets/docs/theme.doc.zh.mjs +1 -1
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.doc.mjs +20 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorDeadlinePicker.tsx +87 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorShowcase.tsx +199 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.doc.mjs +14 -0
- package/assets/templates/blocks/components/ComplexSelector/ComplexSelectorTreeSearch.tsx +188 -0
- package/assets/templates/themes/neutral/neutralTheme.ts +6 -4
- package/assets/theme.template.ts +4 -3
- package/clients/cli/commands/build-theme.font-warning.test.mjs +9 -6
- package/clients/cli/commands/build-theme.icons-specifier.test.mjs +132 -7
- package/foundation/fs/path-safety.mjs +2 -2
- package/foundation/fs/path-safety.test.mjs +7 -0
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @xds/cli
|
|
2
2
|
|
|
3
|
+
# 0.4.3
|
|
4
|
+
|
|
5
|
+
#### Fixes
|
|
6
|
+
|
|
7
|
+
- The unloaded-font advisory is a notice, not a warning. A theme file cannot load a font — Astryx sets `--font-family-*` and loading is the app's job — so #5045's advisory fires on any theme naming a webfont, including a perfectly correct one. As a warning that made a clean build read as defective, and it put the shipped template permanently in violation of its own "compiles with no warnings" guard (#5079 had to allowlist the template's two font names in that assertion).
|
|
8
|
+
The `theme.build` receipt now separates the two: `warnings` are defects the author should fix, `notices` are advisories about a correct theme. The font advisory moves to `notices` and to stdout with the rest of the build's progress; stderr stays for defects. The template guard is back to `warnings` being empty, and no longer needs to know which fonts the template names.
|
|
9
|
+
|
|
10
|
+
Programmatic callers reading `data.warnings` for font advisories should read `data.notices`; the message text is unchanged.
|
|
11
|
+
- `extends` now reaches the CSS. A theme that extended another built a stylesheet holding only the declarations it stated itself: the base's tokens, component overrides and surface rules were all absent, and because each theme is `@scope`d to its own `data-astryx-theme` value, loading the base's stylesheet alongside could not fill the gap either. Every consumer of an inheritance chain silently got stock geometry, elevation and type with a new palette painted over it (#5067). Nothing warned; the loss only showed up by diffing two generated stylesheets token by token.
|
|
12
|
+
The cause was `theme build` shadowing its own inputs. It writes `<name>.js` next to `<name>.ts`, and the loader resolved a plain `./<name>` specifier to that generated artifact before the source — so the second build of a family read the artifact, which carries no `components` and exports `<name>Theme` rather than whatever the source exports. A named import that missed became `extends: undefined`, and `defineTheme` treated an absent base as no base at all. The loader now resolves source extensions first, which is also the resolution the author's TypeScript sees, so the CSS a build emits matches the theme that type-checked.
|
|
13
|
+
|
|
14
|
+
Three things behind it are fixed too, so the failure cannot come back by another route. `defineTheme` **throws** when `extends` is present but is not a theme, naming the likely cause, instead of inheriting nothing — the one behavior change here, and it turns a silent stylesheet into a build error. A theme's `onDark`/`onLight` surfaces and its `__inputTokens` are now inherited like its tokens and components were, so a child no longer reverts its base's inverted-surface customizations to the defaults or loses its `[light, dark]` tuples. And a built theme module now carries the resolved `components` and surfaces alongside its tokens, so extending one — the `./built` subpath every shipped theme exposes — is no longer lossy. `theme build` also stopped hand-picking fields when it re-resolves a plain object theme file, which dropped `extends`, `color` and `syntax` on the way in.
|
|
15
|
+
|
|
16
|
+
An extended theme is flat: everything it inherits is resolved into its own output, and its stylesheet stands alone. Measured on a 14-theme family (one base, 13 palettes extending it): each palette went from 25 custom properties and no component rules to the base's full 175 and 70, with its own colours still winning.
|
|
17
|
+
|
|
18
|
+
#### Contributors
|
|
19
|
+
|
|
20
|
+
Thanks to everyone who contributed to this release:
|
|
21
|
+
|
|
22
|
+
- @cixzhang
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
3
26
|
# 0.4.2
|
|
4
27
|
|
|
5
28
|
#### New Features
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* API-contract tests for the font-loading
|
|
4
|
+
* API-contract tests for the font-loading advisory in `themeBuild()` (#5015):
|
|
5
5
|
* a theme that names font families it does not load gets one entry per family
|
|
6
|
-
* in the `theme.build` receipt's `
|
|
6
|
+
* in the `theme.build` receipt's `notices` array, on BOTH load paths — a raw
|
|
7
7
|
* typography config (resolved through core's defineTheme) and an
|
|
8
8
|
* already-resolved theme that sets `--font-family-*` tokens directly. Themes
|
|
9
|
-
* that only name generics or known system families
|
|
10
|
-
*
|
|
9
|
+
* that only name generics or known system families say nothing, and the
|
|
10
|
+
* advisory never breaks the API's silence contract (default noopLogger).
|
|
11
|
+
*
|
|
12
|
+
* `notices`, not `warnings`: a theme file cannot load a font — that is the
|
|
13
|
+
* app's job by design — so this fires on correct themes and is context, not a
|
|
14
|
+
* defect to fix. Every assertion here also pins it OUT of `warnings`, since
|
|
15
|
+
* the whole point is that a good theme builds warning-free.
|
|
11
16
|
*
|
|
12
17
|
* Needs a built core — the `node` project's globalSetup
|
|
13
18
|
* (vitest.global-setup.node.mjs) builds it once before workers fork.
|
|
@@ -45,15 +50,16 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
45
50
|
const result = await themeBuild('fonty.mjs', {}, {cwd: tmpDir});
|
|
46
51
|
|
|
47
52
|
expect(result?.type).toBe('theme.build');
|
|
48
|
-
const
|
|
49
|
-
expect(
|
|
53
|
+
const notices = result?.data.notices ?? [];
|
|
54
|
+
expect(notices).toEqual(
|
|
50
55
|
expect.arrayContaining([
|
|
51
56
|
expect.stringContaining('Font "Space Grotesk"'),
|
|
52
57
|
expect.stringContaining('Font "JetBrains Mono"'),
|
|
53
58
|
]),
|
|
54
59
|
);
|
|
55
|
-
// Heading inherits body's family; the shared family
|
|
56
|
-
expect(
|
|
60
|
+
// Heading inherits body's family; the shared family is named exactly once.
|
|
61
|
+
expect(notices.filter(w => w.includes('Space Grotesk'))).toHaveLength(1);
|
|
62
|
+
expect(result?.data.warnings).toEqual([]);
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
it('warns for an already-resolved theme: font-family tokens and component overrides, nothing else', async () => {
|
|
@@ -74,13 +80,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
74
80
|
// Exactly the two named families — a non-family --font-* token must not
|
|
75
81
|
// produce a bogus "Font \\"1rem\\"" entry, and the components half of the
|
|
76
82
|
// feature must survive the real themeBuild path, not just the unit helper.
|
|
77
|
-
const
|
|
83
|
+
const fontNotices = (result?.data.notices ?? []).filter(w =>
|
|
78
84
|
w.startsWith('Font "'),
|
|
79
85
|
);
|
|
80
|
-
expect(
|
|
86
|
+
expect(fontNotices).toEqual([
|
|
81
87
|
expect.stringContaining('Font "Bungee"'),
|
|
82
88
|
expect.stringContaining('Font "Orbitron"'),
|
|
83
89
|
]);
|
|
90
|
+
expect(result?.data.warnings).toEqual([]);
|
|
84
91
|
});
|
|
85
92
|
|
|
86
93
|
it('warns for a family named only inside a pseudo-class component override (defineTheme path)', async () => {
|
|
@@ -102,13 +109,14 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
102
109
|
|
|
103
110
|
const result = await themeBuild('pseudo.mjs', {}, {cwd: tmpDir});
|
|
104
111
|
|
|
105
|
-
const
|
|
112
|
+
const fontNotices = (result?.data.notices ?? []).filter(w =>
|
|
106
113
|
w.startsWith('Font "'),
|
|
107
114
|
);
|
|
108
115
|
// Exactly the hidden family — Helvetica/Arial are system fonts.
|
|
109
|
-
expect(
|
|
116
|
+
expect(fontNotices).toEqual([
|
|
110
117
|
expect.stringContaining('Font "Rubik Doodle"'),
|
|
111
118
|
]);
|
|
119
|
+
expect(result?.data.warnings).toEqual([]);
|
|
112
120
|
});
|
|
113
121
|
|
|
114
122
|
it('warns about nothing when every named family is a generic or known system font', async () => {
|
|
@@ -126,10 +134,11 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
126
134
|
const result = await themeBuild('sys.mjs', {}, {cwd: tmpDir});
|
|
127
135
|
|
|
128
136
|
expect(result?.type).toBe('theme.build');
|
|
137
|
+
expect(result?.data.notices).toEqual([]);
|
|
129
138
|
expect(result?.data.warnings).toEqual([]);
|
|
130
139
|
});
|
|
131
140
|
|
|
132
|
-
it('stays silent under the default noopLogger even when font
|
|
141
|
+
it('stays silent under the default noopLogger even when font notices fire', async () => {
|
|
133
142
|
fs.writeFileSync(
|
|
134
143
|
path.join(tmpDir, 'loud.mjs'),
|
|
135
144
|
`export default { name: 'loud', tokens: { '--font-family-body': '"Orbitron", sans-serif' } };\n`,
|
|
@@ -144,7 +153,7 @@ describe('themeBuild() — font-loading warnings in the receipt', () => {
|
|
|
144
153
|
|
|
145
154
|
try {
|
|
146
155
|
const result = await themeBuild('loud.mjs', {}, {cwd: tmpDir});
|
|
147
|
-
expect(result?.data.
|
|
156
|
+
expect(result?.data.notices).toEqual(
|
|
148
157
|
expect.arrayContaining([expect.stringContaining('Font "Orbitron"')]),
|
|
149
158
|
);
|
|
150
159
|
expect(logSpy).not.toHaveBeenCalled();
|
|
@@ -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
|
}
|
|
@@ -1061,6 +1114,8 @@ export async function themeBuild(
|
|
|
1061
1114
|
// Validate component overrides
|
|
1062
1115
|
const warnings = await validateComponentOverrides(themeDef);
|
|
1063
1116
|
const warningMessages = [];
|
|
1117
|
+
/** Advisories about a correct theme — see the `notices` note on the receipt. */
|
|
1118
|
+
const noticeMessages = [];
|
|
1064
1119
|
for (const w of warnings) {
|
|
1065
1120
|
warningMessages.push(w);
|
|
1066
1121
|
logger.warn(` ⚠ ${w}`);
|
|
@@ -1099,20 +1154,29 @@ export async function themeBuild(
|
|
|
1099
1154
|
let css;
|
|
1100
1155
|
let resolvedTheme;
|
|
1101
1156
|
{
|
|
1102
|
-
// jiti returns an already-resolved theme;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1157
|
+
// jiti returns an already-resolved theme; a plain object literal (or the
|
|
1158
|
+
// legacy eval path) returns raw defineTheme input, which still has to go
|
|
1159
|
+
// through the resolver. Detect that by the input-only fields — a resolved
|
|
1160
|
+
// theme has none of them — and hand the WHOLE object over: picking fields
|
|
1161
|
+
// by name is how `extends` (and `color`, and `syntax`) used to be dropped
|
|
1162
|
+
// on the way in.
|
|
1163
|
+
const INPUT_ONLY_FIELDS = [
|
|
1164
|
+
'extends',
|
|
1165
|
+
'typography',
|
|
1166
|
+
'motion',
|
|
1167
|
+
'radius',
|
|
1168
|
+
'color',
|
|
1169
|
+
'syntax',
|
|
1170
|
+
'onDark',
|
|
1171
|
+
'onLight',
|
|
1172
|
+
];
|
|
1173
|
+
const needsResolution = INPUT_ONLY_FIELDS.some(
|
|
1174
|
+
field => themeDef[field] !== undefined,
|
|
1175
|
+
);
|
|
1176
|
+
if (needsResolution) {
|
|
1177
|
+
resolvedTheme = _defineTheme({...themeDef});
|
|
1107
1178
|
} 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
|
-
});
|
|
1179
|
+
resolvedTheme = themeDef;
|
|
1116
1180
|
}
|
|
1117
1181
|
const scopeSelector = themeScopeStart(themeDef.name);
|
|
1118
1182
|
const scopeTo = THEME_SCOPE_TO;
|
|
@@ -1350,11 +1414,18 @@ Or with a <link> tag:
|
|
|
1350
1414
|
// Fonts the theme names but nothing loads (#5015). Resolved tokens and
|
|
1351
1415
|
// component overrides carry the final font-family values on both load
|
|
1352
1416
|
// paths, so this sees jiti-resolved and legacy themes alike.
|
|
1417
|
+
//
|
|
1418
|
+
// A NOTICE, not a warning: naming a font a theme file cannot load is how
|
|
1419
|
+
// the API is meant to be used — Astryx sets `--font-family-*` and loading
|
|
1420
|
+
// is the app's job, which no theme can do for it. So this fires on any
|
|
1421
|
+
// theme with a webfont, including a perfect one, and as a warning it made
|
|
1422
|
+
// every such build read as defective (it also put the shipped template
|
|
1423
|
+
// permanently in violation of its own "compiles with no warnings" guard).
|
|
1353
1424
|
const unloadedFonts = collectUnloadedFonts(resolvedTheme);
|
|
1354
1425
|
for (const family of unloadedFonts) {
|
|
1355
1426
|
const msg = `Font "${family}" is named by this theme but not loaded — add a <link> or @font-face in your app (recipe: astryx docs typography)`;
|
|
1356
|
-
|
|
1357
|
-
logger.
|
|
1427
|
+
noticeMessages.push(msg);
|
|
1428
|
+
logger.log(` note: ${msg}`);
|
|
1358
1429
|
}
|
|
1359
1430
|
if (unloadedFonts.length > 0) {
|
|
1360
1431
|
logger.log(formatFontLoadingHelp(themeDef.name, unloadedFonts));
|
|
@@ -1376,6 +1447,7 @@ Or with a <link> tag:
|
|
|
1376
1447
|
: {}),
|
|
1377
1448
|
},
|
|
1378
1449
|
warnings: warningMessages,
|
|
1450
|
+
notices: noticeMessages,
|
|
1379
1451
|
},
|
|
1380
1452
|
};
|
|
1381
1453
|
}
|
|
@@ -299,11 +299,10 @@ describe('themeBuild() — component override validation', () => {
|
|
|
299
299
|
describe('themeBuild() — the shipped theme template', () => {
|
|
300
300
|
// `assets/theme.template.ts` is what `astryx theme template` puts in a
|
|
301
301
|
// consumer's project. It is the one theme file we hand out, so it has to
|
|
302
|
-
// compile as shipped — and cleanly
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
it('compiles as shipped, warning only about the fonts it deliberately names', async () => {
|
|
302
|
+
// compile as shipped — and cleanly: a template that greets its first reader
|
|
303
|
+
// with warnings teaches them to ignore warnings. The claims its comments make
|
|
304
|
+
// are checked separately by scripts/check-theme-template.test.mjs.
|
|
305
|
+
it('compiles as shipped, with no warnings', async () => {
|
|
307
306
|
const src = path.resolve(
|
|
308
307
|
import.meta.dirname,
|
|
309
308
|
'../../../assets/theme.template.ts',
|
|
@@ -312,17 +311,189 @@ describe('themeBuild() — the shipped theme template', () => {
|
|
|
312
311
|
|
|
313
312
|
const result = await themeBuild('theme.template.ts', {}, {cwd: tmpDir});
|
|
314
313
|
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
);
|
|
321
|
-
expect(unexpected).toEqual([]);
|
|
322
|
-
expect(result?.data.warnings).toHaveLength(2);
|
|
314
|
+
expect(result?.data.warnings).toEqual([]);
|
|
315
|
+
// It DOES name Inter and Geist Mono without loading them, to teach "SHIP
|
|
316
|
+
// THE FONTS YOU NAME" — advisories about a correct file, which is why they
|
|
317
|
+
// are notices. Asserted here so moving them out of `warnings` cannot
|
|
318
|
+
// quietly become dropping them.
|
|
319
|
+
expect(result?.data.notices).toHaveLength(2);
|
|
323
320
|
expect(fs.existsSync(path.join(tmpDir, 'my-theme.css'))).toBe(true);
|
|
324
321
|
// The template teaches custom variants; the augmentation it promises the
|
|
325
322
|
// reader has to actually be generated.
|
|
326
|
-
expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
|
|
323
|
+
expect(fs.existsSync(path.join(tmpDir, 'my-theme.variants.d.ts'))).toBe(
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('themeBuild() — extends', () => {
|
|
330
|
+
// These fixtures `import {defineTheme} from '@astryxdesign/core/theme'` the
|
|
331
|
+
// way a real theme file does, so they have to sit somewhere that specifier
|
|
332
|
+
// resolves — an OS temp dir has no node_modules above it.
|
|
333
|
+
let extDir;
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
extDir = fs.mkdtempSync(
|
|
336
|
+
path.join(path.resolve(import.meta.dirname, '../../..'), '.tmp-extends-'),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
fs.rmSync(extDir, {recursive: true, force: true});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every `prop: value` a generated stylesheet actually applies. Header
|
|
345
|
+
* comments and scope wrappers are ignored — two themes never share those.
|
|
346
|
+
*/
|
|
347
|
+
function declarations(css) {
|
|
348
|
+
return new Set(
|
|
349
|
+
css
|
|
350
|
+
.split('\n')
|
|
351
|
+
.map(l => l.trim())
|
|
352
|
+
.filter(l => /^[-a-z][^{}]*:.+;$/.test(l)),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
/** Every component rule a stylesheet opens, e.g. `.astryx-switch {`. */
|
|
356
|
+
function selectors(css) {
|
|
357
|
+
return new Set(
|
|
358
|
+
css
|
|
359
|
+
.split('\n')
|
|
360
|
+
.map(l => l.trim())
|
|
361
|
+
.filter(l => l.endsWith('{') && l.startsWith('.')),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A base theme with geometry, elevation and a component override. */
|
|
366
|
+
const BASE_SOURCE = `export const brandTheme = {
|
|
367
|
+
name: 'ext-base',
|
|
368
|
+
tokens: {
|
|
369
|
+
'--radius-element': '6px',
|
|
370
|
+
'--shadow-low': '0 1px 3px rgb(0 0 0 / 0.1)',
|
|
371
|
+
'--color-border-emphasized': '#D4D4D4',
|
|
372
|
+
},
|
|
373
|
+
components: {
|
|
374
|
+
switch: {base: {backgroundColor: 'var(--color-border-emphasized)'}},
|
|
375
|
+
},
|
|
376
|
+
};\n`;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The child names its base with a plain relative specifier, exactly as a
|
|
380
|
+
* generated palette does. `theme build` writes `ext-base.js` next to
|
|
381
|
+
* `ext-base.mjs`, so `./ext-base` is ambiguous — and the artifact, which
|
|
382
|
+
* exports `extBaseTheme` rather than `brandTheme`, is the wrong answer.
|
|
383
|
+
*/
|
|
384
|
+
const CHILD_SOURCE = `import {defineTheme} from '@astryxdesign/core/theme';
|
|
385
|
+
import {brandTheme} from './ext-base';
|
|
386
|
+
export const paletteTheme = defineTheme({
|
|
387
|
+
name: 'ext-child',
|
|
388
|
+
extends: brandTheme,
|
|
389
|
+
tokens: {'--color-accent': 'hsl(220 88% 72%)'},
|
|
390
|
+
});\n`;
|
|
391
|
+
|
|
392
|
+
it('emits every declaration its base emits (the child stylesheet is self-contained)', async () => {
|
|
393
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
394
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
395
|
+
|
|
396
|
+
// Build the base FIRST, as any real project does — that write is what
|
|
397
|
+
// used to poison the child's build.
|
|
398
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
399
|
+
await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
400
|
+
|
|
401
|
+
const baseCss = fs.readFileSync(path.join(extDir, 'ext-base.css'), 'utf8');
|
|
402
|
+
const childCss = fs.readFileSync(
|
|
403
|
+
path.join(extDir, 'ext-child.css'),
|
|
404
|
+
'utf8',
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const childDecls = declarations(childCss);
|
|
408
|
+
expect([...declarations(baseCss)].filter(d => !childDecls.has(d))).toEqual(
|
|
409
|
+
[],
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const childSelectors = selectors(childCss);
|
|
413
|
+
expect([...selectors(baseCss)].filter(s => !childSelectors.has(s))).toEqual(
|
|
414
|
+
[],
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// …and the child's own override still wins.
|
|
418
|
+
expect(childCss).toContain('--color-accent: hsl(220 88% 72%);');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('resolves the base from its source, not from the generated sibling artifact', async () => {
|
|
422
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
423
|
+
fs.writeFileSync(path.join(extDir, 'ext-child.mjs'), CHILD_SOURCE);
|
|
424
|
+
|
|
425
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
426
|
+
const result = await themeBuild('ext-child.mjs', {}, {cwd: extDir});
|
|
427
|
+
|
|
428
|
+
expect(result?.data.componentCount).toBe(1);
|
|
429
|
+
expect(result?.data.tokenCount).toBe(4);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('inherits component overrides when the base IS a built theme module', async () => {
|
|
433
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
434
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
435
|
+
|
|
436
|
+
// Extending a package's pre-built theme module (e.g. the `./built`
|
|
437
|
+
// subpath the shipped themes expose) must not silently drop its
|
|
438
|
+
// component overrides.
|
|
439
|
+
fs.writeFileSync(
|
|
440
|
+
path.join(extDir, 'ext-built-child.mjs'),
|
|
441
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
442
|
+
import {extBaseTheme} from './ext-base.js';
|
|
443
|
+
export const builtChildTheme = defineTheme({
|
|
444
|
+
name: 'ext-built-child',
|
|
445
|
+
extends: extBaseTheme,
|
|
446
|
+
});\n`,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await themeBuild('ext-built-child.mjs', {}, {cwd: extDir});
|
|
450
|
+
const css = fs.readFileSync(
|
|
451
|
+
path.join(extDir, 'ext-built-child.css'),
|
|
452
|
+
'utf8',
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(css).toContain('.astryx-switch {');
|
|
456
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('resolves extends on a plain object theme file (no defineTheme call)', async () => {
|
|
460
|
+
fs.writeFileSync(path.join(extDir, 'ext-base.mjs'), BASE_SOURCE);
|
|
461
|
+
fs.writeFileSync(
|
|
462
|
+
path.join(extDir, 'ext-plain.mjs'),
|
|
463
|
+
`import {brandTheme} from './ext-base.mjs';
|
|
464
|
+
export default {
|
|
465
|
+
name: 'ext-plain',
|
|
466
|
+
extends: brandTheme,
|
|
467
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
468
|
+
};\n`,
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
await themeBuild('ext-base.mjs', {}, {cwd: extDir});
|
|
472
|
+
await themeBuild('ext-plain.mjs', {}, {cwd: extDir});
|
|
473
|
+
|
|
474
|
+
const css = fs.readFileSync(path.join(extDir, 'ext-plain.css'), 'utf8');
|
|
475
|
+
expect(css).toContain('--radius-element: 6px;');
|
|
476
|
+
expect(css).toContain('.astryx-switch {');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('fails loudly when the base import resolved to nothing', async () => {
|
|
480
|
+
fs.writeFileSync(
|
|
481
|
+
path.join(extDir, 'ext-broken.mjs'),
|
|
482
|
+
`import {defineTheme} from '@astryxdesign/core/theme';
|
|
483
|
+
import {notAThing} from './ext-missing.mjs';
|
|
484
|
+
export const brokenTheme = defineTheme({
|
|
485
|
+
name: 'ext-broken',
|
|
486
|
+
extends: notAThing,
|
|
487
|
+
tokens: {'--color-accent': '#ff0000'},
|
|
488
|
+
});\n`,
|
|
489
|
+
);
|
|
490
|
+
fs.writeFileSync(
|
|
491
|
+
path.join(extDir, 'ext-missing.mjs'),
|
|
492
|
+
`export const somethingElse = 1;\n`,
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
await expect(
|
|
496
|
+
themeBuild('ext-broken.mjs', {}, {cwd: extDir}),
|
|
497
|
+
).rejects.toThrow(/extends/);
|
|
327
498
|
});
|
|
328
499
|
});
|