@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.
@@ -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 — 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\u2019s own tint like any other glyph.',
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 — it declares its index and derives the rest from the parent Stepper, so one Step in one Stepper is a complete example.',
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 — 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.',
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,