@astryxdesign/cli 0.5.0-canary.09d191c → 0.5.0-canary.1d94f85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/theme/build/build.public-component-vars.test.mjs +151 -0
- package/assets/docs/icons.doc.mjs +31 -1
- package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +1 -1
- package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
- package/assets/templates/pages/table-filter/page.tsx +4093 -0
- package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
- package/clients/cli/commands/theme-targets.doc.mjs +1 -1
- package/foundation/discovery/component-discovery.mjs +10 -9
- package/foundation/discovery/hook-discovery.mjs +2 -1
- package/foundation/discovery/hook-discovery.test.mjs +156 -37
- package/foundation/fs/paths.d.mts +16 -0
- package/foundation/fs/paths.mjs +36 -0
- package/foundation/fs/paths.test.mjs +29 -1
- package/package.json +9 -9
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A component's *documented* theming vars must survive `astryx theme build`.
|
|
5
|
+
*
|
|
6
|
+
* `validatePrivateVars` rejects a theme that sets a `--_*` var, on the rule
|
|
7
|
+
* that private vars are reached through the derived-var pipeline rather than
|
|
8
|
+
* written directly. That makes "which prefix a themeable var carries" a
|
|
9
|
+
* build-time contract rather than a naming preference — and nothing checked
|
|
10
|
+
* the two against each other, so a component could document a var, and ship a
|
|
11
|
+
* changeset telling theme authors to set it, that the build then complains
|
|
12
|
+
* about (#5214).
|
|
13
|
+
*
|
|
14
|
+
* The theme this builds is generated FROM each component's own
|
|
15
|
+
* `theming.vars[]`, not from a snippet copied into this file. A hand-copied
|
|
16
|
+
* snippet only ever proves the builder accepts the string it was handed; a
|
|
17
|
+
* doc-driven one fails the moment a component documents a var a theme author
|
|
18
|
+
* cannot actually set. It covers every component with public vars, so the
|
|
19
|
+
* next one is covered without touching this file.
|
|
20
|
+
*
|
|
21
|
+
* Asserted on the receipt's `warnings` rather than on a rejection: a private
|
|
22
|
+
* var is reported (logged `✗`, collected into the receipt) and the build then
|
|
23
|
+
* emits its CSS and resolves anyway. Asserting a throw would pass for the
|
|
24
|
+
* wrong reason — it never throws, which is why a throwaway build read as a
|
|
25
|
+
* pass on the first version of #5214.
|
|
26
|
+
*
|
|
27
|
+
* `themeBuild` compiles via @astryxdesign/core's generator, so it needs a built
|
|
28
|
+
* core — the `node` project's globalSetup builds it once before workers fork.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
describe,
|
|
33
|
+
it,
|
|
34
|
+
expect,
|
|
35
|
+
beforeAll,
|
|
36
|
+
beforeEach,
|
|
37
|
+
afterEach,
|
|
38
|
+
vi,
|
|
39
|
+
} from 'vitest';
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import * as os from 'node:os';
|
|
43
|
+
import {fileURLToPath} from 'node:url';
|
|
44
|
+
import {themeBuild} from './build.mjs';
|
|
45
|
+
import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';
|
|
46
|
+
|
|
47
|
+
vi.setConfig({testTimeout: 60000});
|
|
48
|
+
|
|
49
|
+
/** Every documented public var, as `{component, key, vars: [{name, value}]}`. */
|
|
50
|
+
const documented = [];
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
// Core and the CLI ship as siblings, the same resolution build.mjs uses.
|
|
54
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const coreSrc = path.resolve(here, '../../../../core/src');
|
|
56
|
+
const docs = [];
|
|
57
|
+
(function scan(dir) {
|
|
58
|
+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
59
|
+
const full = path.join(dir, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
if (entry.name !== 'node_modules' && entry.name !== '__tests__')
|
|
62
|
+
scan(full);
|
|
63
|
+
} else if (entry.name.endsWith('.doc.mjs')) {
|
|
64
|
+
docs.push(full);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})(coreSrc);
|
|
68
|
+
|
|
69
|
+
for (const docPath of docs) {
|
|
70
|
+
let doc;
|
|
71
|
+
try {
|
|
72
|
+
doc = await loadComponentDoc(docPath);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const theming = doc?.theming;
|
|
77
|
+
// The `defineTheme` key is the first target's class minus the namespace —
|
|
78
|
+
// the same derivation `theme targets` and the builder's own validation use.
|
|
79
|
+
const key = theming?.targets?.[0]?.className?.replace(/^astryx-/, '');
|
|
80
|
+
// Every var the docs PRESENT as settable — `private: true` is what hides
|
|
81
|
+
// one from `astryx component <Name>`, so anything without it is something
|
|
82
|
+
// a theme author is being told they may write. Deliberately not filtered
|
|
83
|
+
// by the `--_` prefix: a var carrying the private prefix while missing the
|
|
84
|
+
// private flag is advertised by the CLI and rejected by the builder, and
|
|
85
|
+
// that disagreement is the whole thing this test exists to catch.
|
|
86
|
+
const publicVars = (theming?.vars || []).filter(
|
|
87
|
+
v => typeof v?.name === 'string' && !v.private && !v.derived,
|
|
88
|
+
);
|
|
89
|
+
if (!key || publicVars.length === 0) continue;
|
|
90
|
+
documented.push({
|
|
91
|
+
component: path.basename(docPath, '.doc.mjs'),
|
|
92
|
+
key,
|
|
93
|
+
// A length or a color would each need a plausible value; `unset` is
|
|
94
|
+
// valid for any custom property and is not what is under test — that a
|
|
95
|
+
// theme may NAME the var at all is.
|
|
96
|
+
vars: publicVars.map(v => v.name),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let tmpDir;
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-public-vars-'));
|
|
104
|
+
});
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
async function buildTheme(name, components) {
|
|
110
|
+
const themeFile = path.join(tmpDir, `${name}.mjs`);
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
themeFile,
|
|
113
|
+
`export default ${JSON.stringify({name, tokens: {}, components}, null, 2)};\n`,
|
|
114
|
+
);
|
|
115
|
+
return themeBuild(`${name}.mjs`, {}, {cwd: tmpDir});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const privateVarWarnings = result =>
|
|
119
|
+
(result?.data.warnings ?? []).filter(w => /private var/i.test(w));
|
|
120
|
+
|
|
121
|
+
describe('documented component vars build cleanly', () => {
|
|
122
|
+
it('finds components with public theming vars to check', () => {
|
|
123
|
+
// A rename that broke the doc scan would otherwise silently empty this
|
|
124
|
+
// file out, the way a var-count bail once did in derivedVarRegistry.test.
|
|
125
|
+
expect(documented.length).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('accepts every var the component docs tell a theme author to set', async () => {
|
|
129
|
+
const components = Object.fromEntries(
|
|
130
|
+
documented.map(({key, vars}) => [
|
|
131
|
+
key,
|
|
132
|
+
{base: Object.fromEntries(vars.map(name => [name, 'unset']))},
|
|
133
|
+
]),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await buildTheme('documentedvars', components);
|
|
137
|
+
|
|
138
|
+
expect(result).not.toBeNull();
|
|
139
|
+
expect(privateVarWarnings(result)).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('still reports a private var, so the rule this relies on is real', async () => {
|
|
143
|
+
// The negative control: if the builder stopped reporting `--_*`, the test
|
|
144
|
+
// above would pass for the wrong reason.
|
|
145
|
+
const result = await buildTheme('privatevar', {
|
|
146
|
+
spinner: {'size:xl': {'--_spinner-diameter': '40px'}},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(privateVarWarnings(result)).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -102,13 +102,43 @@ export const brandTheme = defineTheme({
|
|
|
102
102
|
},
|
|
103
103
|
],
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
title: 'Component and Library Icons',
|
|
107
|
+
category: 'foundations',
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: 'prose',
|
|
111
|
+
text: 'A glyph that belongs to one component or library gets a namespaced key (`numberInput:stepperDown`, `richtext:bold`) instead of a new semantic name. It resolves through the same registry and a theme overrides it the same way, but the shared IconName list stays reserved for glyphs the whole system uses — so adding one does not make every downstream icon registry grow a key.',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
type: 'code',
|
|
115
|
+
lang: 'tsx',
|
|
116
|
+
label: 'Owning and theming a namespaced icon',
|
|
117
|
+
code: `// The component renders it like any other name, keeping size and color.
|
|
118
|
+
<Icon icon="numberInput:stepperDown" size="xsm" />
|
|
119
|
+
|
|
120
|
+
// A theme maps it independently of the shared chevron.
|
|
121
|
+
export const brandTheme = defineTheme({
|
|
122
|
+
name: 'brand',
|
|
123
|
+
icons: {
|
|
124
|
+
chevronDown: <ChevronDownIcon />,
|
|
125
|
+
'numberInput:stepperDown': <CaretDownFilledIcon />,
|
|
126
|
+
},
|
|
127
|
+
});`,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: 'prose',
|
|
131
|
+
text: 'Ship the fallback in `defaultIcons` under the same key so the glyph still renders with no theme, or pass one to `getExtendedIcon(key, fallback)` when the icon lives outside core.',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
105
135
|
{
|
|
106
136
|
title: 'Adding New Icons',
|
|
107
137
|
category: 'foundations',
|
|
108
138
|
content: [
|
|
109
139
|
{
|
|
110
140
|
type: 'prose',
|
|
111
|
-
text: 'To add a new semantic icon name to the design system:',
|
|
141
|
+
text: 'To add a new semantic icon name to the design system — only for a glyph the whole system shares; a component-owned one takes a namespaced key instead:',
|
|
112
142
|
},
|
|
113
143
|
{
|
|
114
144
|
type: 'list',
|
|
@@ -7,7 +7,7 @@ export const doc = {
|
|
|
7
7
|
name: 'Step — Indicator',
|
|
8
8
|
displayName: 'Step — Indicator',
|
|
9
9
|
description:
|
|
10
|
-
'Everything the indicator prop accepts: the auto default, an always-number badge, a custom ReactNode, and none
|
|
10
|
+
'Everything the indicator prop accepts: the auto default, an always-number badge, a custom ReactNode, and none, each on its own completed Step so the prop is the only difference between them. Every variant occupies the same 16px box, so a step swapping its number for a check as it completes never shifts the label beside it. The last cell shows that a custom node can be live rather than static: a Spinner on a step that is in progress, shaded `inherit` so it picks up the step\'s own tint like any other glyph.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
aspectRatio: 4 / 3,
|
|
13
13
|
componentsUsed: ['Stepper', 'Step', 'Icon', 'Spinner', 'Text'],
|
|
@@ -7,7 +7,7 @@ export const doc = {
|
|
|
7
7
|
name: 'Step',
|
|
8
8
|
displayName: 'Step',
|
|
9
9
|
description:
|
|
10
|
-
'A single Step, with every part it can render: the indicator, the label with its optional marker and trailing endContent, and the description beneath. A Step never sets its own completed/current state
|
|
10
|
+
'A single Step, with every part it can render: the indicator, the label with its optional marker and trailing endContent, and the description beneath. A Step never sets its own completed/current state. It declares its index and derives the rest from the parent Stepper, so one Step in one Stepper is a complete example.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
isShowcase: true,
|
|
13
13
|
aspectRatio: 16 / 9,
|
|
@@ -7,7 +7,7 @@ export const doc = {
|
|
|
7
7
|
name: 'Stepper — Checkout Progress',
|
|
8
8
|
displayName: 'Stepper — Checkout Progress',
|
|
9
9
|
description:
|
|
10
|
-
'The default stepper: a horizontal track where every step owns an equal segment of the progress bar above its label. The default auto indicator resolves itself per step
|
|
10
|
+
'The default stepper: a horizontal track where every step owns an equal segment of the progress bar above its label. The default auto indicator resolves itself per step: a check once the step is done, a ring on the current step, a number for the ones still ahead. Click any step to jump.',
|
|
11
11
|
isReady: true,
|
|
12
12
|
isShowcase: true,
|
|
13
13
|
aspectRatio: 16 / 9,
|