@atelier-ui/create-workspace 0.2.41 → 0.2.43
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 +33 -4
- package/package.json +1 -1
- package/src/generators/preset/files/contracts/README.md +29 -0
- package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
- package/src/generators/preset/files/contracts/types.ts.template +55 -0
- package/src/generators/preset/files/figma/snapshot.json +164 -0
- package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
- package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
- package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
- package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
- package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
- package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
- package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
- package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
- package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
- package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
- package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
- package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
- package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
- package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
- package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
- package/src/generators/preset/files/styles/tokens.css +59 -43
- package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
- package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
- package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
- package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
- package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
- package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
- package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
- package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
- package/src/generators/preset/preset.d.ts +1 -0
- package/src/generators/preset/preset.js +955 -5
- package/src/generators/preset/preset.js.map +1 -1
- package/src/generators/preset/schema.d.ts +1 -0
- package/src/generators/preset/schema.json +5 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atelier/no-token-bypass
|
|
5
|
+
*
|
|
6
|
+
* Catches a literal whose value a token already holds — the one kind of
|
|
7
|
+
* unbound value that is objectively wrong. This rule deliberately does NOT
|
|
8
|
+
* demand that every value be a token: of the ~190 literals in the component
|
|
9
|
+
* stylesheets, most are component dimensions (an avatar size, a toggle
|
|
10
|
+
* track, a step circle) with exactly one user, and tokenising them would
|
|
11
|
+
* produce a hundred single-use tokens — the rule of three violated in token
|
|
12
|
+
* form. What IS wrong is a literal that duplicates a token in the FAMILY its
|
|
13
|
+
* own property should draw from — two real, invisible defects were found
|
|
14
|
+
* this way (AtlTab and AtlCodeBlock's header both hardcoding `2.5rem` where
|
|
15
|
+
* `--ui-control-height-md` already said 2.5rem, ADR-0047).
|
|
16
|
+
*
|
|
17
|
+
* Family per property (z-index → --ui-z-*, opacity → --ui-opacity-*,
|
|
18
|
+
* font-size/line-height/font-weight/letter-spacing → their own --ui-* family,
|
|
19
|
+
* border-radius → --ui-radius-*, box-shadow → --ui-shadow-*, transition/
|
|
20
|
+
* animation-duration → --ui-duration-*, padding/margin/gap → --ui-spacing-*,
|
|
21
|
+
* min-height/height/width/min-width → --ui-control-height-*), so the rule
|
|
22
|
+
* never suggests a spacing token for a width. Border widths get their own
|
|
23
|
+
* check: any literal length on a `border*` property is a bypass, because
|
|
24
|
+
* `--ui-border-width`/`--ui-border-width-thick` are the only two values that
|
|
25
|
+
* family holds and neither aliases a "normal" CSS default the way `0` does.
|
|
26
|
+
*
|
|
27
|
+
* `TOKEN_BYPASS_EXEMPT` (kept in tools/scripts/lib/allowlists.js — see
|
|
28
|
+
* no-primitive-token.js's header for why these exemption maps were not
|
|
29
|
+
* relocated out of that file) carries the same two kinds as every other
|
|
30
|
+
* allowlist there (ADR-0034): `kind: 'design'` — the shared value is a
|
|
31
|
+
* coincidence, binding it would be wrong — stays silent; `kind: 'gap'` — it
|
|
32
|
+
* should bind and hasn't yet — warns on every run via a per-report
|
|
33
|
+
* `severity: 'warning'` override.
|
|
34
|
+
*
|
|
35
|
+
* Staleness is checked the same way no-primitive-token.js checks it: once
|
|
36
|
+
* per `componentRoot` per process, against a direct filesystem scan of THAT
|
|
37
|
+
* root's own `**\/*.css` tree, not any other project's — see that file's
|
|
38
|
+
* header for the full reasoning and the one known gap (a root-asymmetric
|
|
39
|
+
* exemption would be falsely reported stale by the root(s) that don't
|
|
40
|
+
* reference it; none of today's four entries are asymmetric, verified
|
|
41
|
+
* 2026-09-12).
|
|
42
|
+
*
|
|
43
|
+
* One thing this port improves rather than copies: the retired script
|
|
44
|
+
* hand-rolled a `(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)` regex over
|
|
45
|
+
* comment-stripped CSS text to recover `prop: value` pairs. A stylelint rule
|
|
46
|
+
* gets that for free, correctly, from `root.walkDecls` — no hand-rolled
|
|
47
|
+
* parsing needed for the per-file violation check. The pre-scan used only
|
|
48
|
+
* for staleness still uses the same regex, because at that point there is no
|
|
49
|
+
* PostCSS AST — it exists purely to answer "is this literal referenced
|
|
50
|
+
* anywhere", not to attribute a violation to a location.
|
|
51
|
+
*
|
|
52
|
+
* Scope is deliberately component CSS only — every framework's own
|
|
53
|
+
* `src/lib` component stylesheets — the retired script never touched the
|
|
54
|
+
* docs app, and neither does this rule.
|
|
55
|
+
*
|
|
56
|
+
* THREE secondary options, all repo-relative POSIX paths, none guessed —
|
|
57
|
+
* the config declares topology the way `no-undeclared-token`'s `tokenFiles`
|
|
58
|
+
* already does:
|
|
59
|
+
* - `tokenFile` (required in practice): the single canonical token source
|
|
60
|
+
* to read DECLARED VALUES from (`tokenValue` below) — this repo passes
|
|
61
|
+
* the same `libs/create-workspace/.../tokens.css` `no-undeclared-token`
|
|
62
|
+
* uses. Omitted, `tokenValue` is `{}` and the family-match half of this
|
|
63
|
+
* rule silently finds nothing to match (the border-width half is
|
|
64
|
+
* unaffected — it never reads token values).
|
|
65
|
+
* - `componentRoot` (required for staleness): the one directory this
|
|
66
|
+
* invocation's `libs/<fw>/src/lib` (or a scaffold's own tree) lives at.
|
|
67
|
+
* Replaces a `frameworkOf()` regex that used to pattern-match the input
|
|
68
|
+
* file's path against `libs/(angular|react|vue)/src/lib/` to guess which
|
|
69
|
+
* of three hardcoded trees to scan — the config already knows which
|
|
70
|
+
* tree each Nx target lints (one target per project), so asking it to
|
|
71
|
+
* say so is not a bigger ask than `tokenFiles` already is. Omitted, the
|
|
72
|
+
* staleness scan (which needs a directory to walk) does not run; the
|
|
73
|
+
* per-declaration bypass/border check is unaffected — it never depended
|
|
74
|
+
* on knowing the framework, only on `dir` (a plain basename).
|
|
75
|
+
* - `allowlistsFile` (optional): repo-relative path to a CommonJS module
|
|
76
|
+
* exporting `TOKEN_BYPASS_EXEMPT`. This repo passes
|
|
77
|
+
* `tools/scripts/lib/allowlists.js`, unchanged. **Absent — the
|
|
78
|
+
* documented default for a scaffold, which starts with ZERO
|
|
79
|
+
* exemptions — `TOKEN_BYPASS_EXEMPT` is `{}`.** An empty exemption
|
|
80
|
+
* object also means the staleness scan is skipped outright, not just
|
|
81
|
+
* "runs and finds nothing": scanning a tree to police an empty map is
|
|
82
|
+
* work with no finding it could ever produce, so the scan (and the
|
|
83
|
+
* per-root cache it would populate) is short-circuited before it
|
|
84
|
+
* touches the filesystem. Loaded lazily, per file, from inside the rule
|
|
85
|
+
* closure (cached by resolved absolute path) rather than at module
|
|
86
|
+
* `require()` time — the old code's top-level `require('../scripts/lib/
|
|
87
|
+
* allowlists')` ran the instant `index.js` loaded this file into the
|
|
88
|
+
* `plugins` array, which happens whenever ANY rule in the plugin is
|
|
89
|
+
* used, whether or not `no-token-bypass` itself is turned on for that
|
|
90
|
+
* project. A scaffold has no such file at all, so that top-level
|
|
91
|
+
* `require()` would throw `MODULE_NOT_FOUND` merely from loading the
|
|
92
|
+
* plugin — before any config decision about whether to enable this rule
|
|
93
|
+
* even applies.
|
|
94
|
+
*
|
|
95
|
+
* **The empty default applies only when `allowlistsFile` is absent.**
|
|
96
|
+
* When it IS supplied, the loaded module's exports are validated:
|
|
97
|
+
* `TOKEN_BYPASS_EXEMPT` must be a plain object. A module that fails to
|
|
98
|
+
* load resolution still throws `MODULE_NOT_FOUND` as before (unchanged,
|
|
99
|
+
* already loud); a module that DOES load but doesn't actually export
|
|
100
|
+
* that name — a typo, a rename on one side only — used to be silently
|
|
101
|
+
* treated as the same "zero exemptions" default, which is exactly
|
|
102
|
+
* ADR-0124's failure class: the staleness scan skips outright (nothing
|
|
103
|
+
* to report), unconditionally, regardless of whether any exempted
|
|
104
|
+
* literal elsewhere in the tree also loses its exemption and starts
|
|
105
|
+
* reporting on its own — the rule never signals that its configured
|
|
106
|
+
* allowlist never actually loaded, only that SOME check silently didn't
|
|
107
|
+
* run (2026-09-12 stylelint review, claim 2, reproduced with a fixture
|
|
108
|
+
* that has no exempted literal in scope at all: a genuinely stale entry
|
|
109
|
+
* went from a blocking `[STALE-EXEMPT]` to total silence, exit 0, the
|
|
110
|
+
* moment the export name was misspelled). Now a mis-shaped supplied
|
|
111
|
+
* module reports `[INVALID-ALLOWLISTS]` at `error` severity on every
|
|
112
|
+
* file this override touches, via `stylelint.utils.report()` rather
|
|
113
|
+
* than a thrown exception — see `no-primitive-token.js`'s header for
|
|
114
|
+
* the full reasoning (verified there that a thrown error aborts the
|
|
115
|
+
* entire stylelint run rather than reporting per file); this rule makes
|
|
116
|
+
* the same call for the same reason.
|
|
117
|
+
*
|
|
118
|
+
* Formerly tools/scripts/check-token-bypass.js (check:token-bypass).
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
const fs = require('fs');
|
|
122
|
+
const path = require('path');
|
|
123
|
+
const stylelint = require('stylelint');
|
|
124
|
+
const {
|
|
125
|
+
REPO_ROOT,
|
|
126
|
+
toRepoRelative,
|
|
127
|
+
isNonEmptyString,
|
|
128
|
+
normalizeRepoRelative,
|
|
129
|
+
} = require('./utils');
|
|
130
|
+
|
|
131
|
+
const ruleName = 'atelier/no-token-bypass';
|
|
132
|
+
|
|
133
|
+
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
134
|
+
bypass: (prop, value, holders) =>
|
|
135
|
+
`[BYPASS] sets ${prop}: ${value}, which is exactly what ${holders
|
|
136
|
+
.map((h) => `var(${h})`)
|
|
137
|
+
.join(
|
|
138
|
+
' / ',
|
|
139
|
+
)} holds. Bind it, or exempt it in TOKEN_BYPASS_EXEMPT with a reason.`,
|
|
140
|
+
border: (prop, value) =>
|
|
141
|
+
`[BORDER] sets ${prop}: ${value} with a literal width. Use var(--ui-border-width) ` +
|
|
142
|
+
'or var(--ui-border-width-thick); if the value is a graphic device rather than a border ' +
|
|
143
|
+
'weight, exempt it in TOKEN_BYPASS_EXEMPT with a reason.',
|
|
144
|
+
stale: (key) =>
|
|
145
|
+
`[STALE-EXEMPT] TOKEN_BYPASS_EXEMPT lists '${key}', but no stylesheet under the configured componentRoot has that literal any more. Remove the entry.`,
|
|
146
|
+
invalidAllowlists: (allowlistsFile, reason) =>
|
|
147
|
+
`[INVALID-ALLOWLISTS] '${allowlistsFile}' ${reason}. A supplied allowlistsFile must actually export it — a missing or misspelled export is a broken configuration, not the documented empty default (which only applies when allowlistsFile is omitted entirely).`,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const meta = {
|
|
151
|
+
url: 'tools/stylelint-rules/no-token-bypass.js',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Which token family a property may draw from. Copied verbatim from the retired script. */
|
|
155
|
+
const FAMILY = {
|
|
156
|
+
'z-index': /^--ui-z-/,
|
|
157
|
+
opacity: /^--ui-opacity-/,
|
|
158
|
+
'letter-spacing': /^--ui-letter-spacing-/,
|
|
159
|
+
'font-weight': /^--ui-font-weight-/,
|
|
160
|
+
'font-size': /^--ui-font-size-/,
|
|
161
|
+
'line-height': /^--ui-line-height-/,
|
|
162
|
+
'border-radius': /^--ui-radius-/,
|
|
163
|
+
'box-shadow': /^--ui-shadow-/,
|
|
164
|
+
'transition-duration': /^--ui-duration-/,
|
|
165
|
+
'animation-duration': /^--ui-duration-/,
|
|
166
|
+
padding: /^--ui-spacing-/,
|
|
167
|
+
'padding-top': /^--ui-spacing-/,
|
|
168
|
+
'padding-right': /^--ui-spacing-/,
|
|
169
|
+
'padding-bottom': /^--ui-spacing-/,
|
|
170
|
+
'padding-left': /^--ui-spacing-/,
|
|
171
|
+
margin: /^--ui-spacing-/,
|
|
172
|
+
'margin-top': /^--ui-spacing-/,
|
|
173
|
+
'margin-right': /^--ui-spacing-/,
|
|
174
|
+
'margin-bottom': /^--ui-spacing-/,
|
|
175
|
+
'margin-left': /^--ui-spacing-/,
|
|
176
|
+
gap: /^--ui-spacing-/,
|
|
177
|
+
'row-gap': /^--ui-spacing-/,
|
|
178
|
+
'column-gap': /^--ui-spacing-/,
|
|
179
|
+
'min-height': /^--ui-control-height-/,
|
|
180
|
+
height: /^--ui-control-height-/,
|
|
181
|
+
width: /^--ui-control-height-/,
|
|
182
|
+
'min-width': /^--ui-control-height-/,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Border widths get their own rule: any literal length is a bypass. */
|
|
186
|
+
const BORDER_PROP = /^border(-top|-right|-bottom|-left)?(-width)?$/;
|
|
187
|
+
|
|
188
|
+
/** Values that mean "nothing", not "a measurement". */
|
|
189
|
+
const STRUCTURAL = new Set([
|
|
190
|
+
'0',
|
|
191
|
+
'none',
|
|
192
|
+
'auto',
|
|
193
|
+
'inherit',
|
|
194
|
+
'initial',
|
|
195
|
+
'unset',
|
|
196
|
+
'currentColor',
|
|
197
|
+
'transparent',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/** Token → value, read once per resolved `tokenFile` path from the light
|
|
201
|
+
* (`:root`) block of the canonical token source and cached by absolute
|
|
202
|
+
* path — the same single file for every framework, unlike
|
|
203
|
+
* no-undeclared-token's `tokenFiles`, because token-bypass never scopes to
|
|
204
|
+
* docs. Already a declared nx.json `stylelint` input since stage 1. */
|
|
205
|
+
function readTokenValues(absPath) {
|
|
206
|
+
const tokensCss = fs
|
|
207
|
+
.readFileSync(absPath, 'utf-8')
|
|
208
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
209
|
+
const darkAt = tokensCss.indexOf('@media (prefers-color-scheme: dark)');
|
|
210
|
+
const lightBlock = darkAt === -1 ? tokensCss : tokensCss.slice(0, darkAt);
|
|
211
|
+
const tokenValue = {};
|
|
212
|
+
for (const m of lightBlock.matchAll(/(--ui-[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
|
213
|
+
const value = m[2].trim();
|
|
214
|
+
if (value.startsWith('var(')) continue; // an alias, not a value of its own
|
|
215
|
+
tokenValue[m[1]] = value;
|
|
216
|
+
}
|
|
217
|
+
return tokenValue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const tokenValueCache = new Map(); // absolute tokenFile path -> tokenValue map
|
|
221
|
+
|
|
222
|
+
/** `{}` when `tokenFile` is absent — the family-match check then never
|
|
223
|
+
* matches anything (nothing to compare against); the border-width check is
|
|
224
|
+
* unaffected, since it never reads token values. */
|
|
225
|
+
function getTokenValues(tokenFile) {
|
|
226
|
+
if (!tokenFile) return {};
|
|
227
|
+
const absPath = path.resolve(REPO_ROOT, tokenFile);
|
|
228
|
+
if (!tokenValueCache.has(absPath)) {
|
|
229
|
+
tokenValueCache.set(absPath, readTokenValues(absPath));
|
|
230
|
+
}
|
|
231
|
+
return tokenValueCache.get(absPath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Human-readable description of what `require()` actually returned, for the
|
|
235
|
+
* `[INVALID-ALLOWLISTS]` message. */
|
|
236
|
+
function describeExport(value) {
|
|
237
|
+
if (value === undefined) return 'is undefined (no such export)';
|
|
238
|
+
if (Array.isArray(value)) return 'is an array';
|
|
239
|
+
if (value === null) return 'is null';
|
|
240
|
+
return `is a ${typeof value}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const allowlistsCache = new Map(); // absolute allowlistsFile path -> result below
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `{ module: null, invalidReason: null }` when `allowlistsFile` is absent —
|
|
247
|
+
* the documented default for a scaffold, which starts with ZERO exemptions
|
|
248
|
+
* (see header).
|
|
249
|
+
*
|
|
250
|
+
* When `allowlistsFile` IS supplied, the loaded module's shape is validated:
|
|
251
|
+
* `TOKEN_BYPASS_EXEMPT` must be a plain object (not an array, not `null`,
|
|
252
|
+
* not missing). Missing or wrongly-shaped yields `{ module: null,
|
|
253
|
+
* invalidReason: <string> }` instead of silently falling back to the same
|
|
254
|
+
* empty default the absent-option case uses — see header for why (claim 2).
|
|
255
|
+
* A module that itself fails to `require()` (a typo'd PATH, not a typo'd
|
|
256
|
+
* EXPORT) still throws `MODULE_NOT_FOUND` here, unchanged and already loud.
|
|
257
|
+
*/
|
|
258
|
+
function getAllowlists(allowlistsFile) {
|
|
259
|
+
if (!allowlistsFile) return { module: null, invalidReason: null };
|
|
260
|
+
const absPath = path.resolve(REPO_ROOT, allowlistsFile);
|
|
261
|
+
if (!allowlistsCache.has(absPath)) {
|
|
262
|
+
const required = require(absPath);
|
|
263
|
+
const value = required.TOKEN_BYPASS_EXEMPT;
|
|
264
|
+
const isValidShape =
|
|
265
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
266
|
+
allowlistsCache.set(
|
|
267
|
+
absPath,
|
|
268
|
+
isValidShape
|
|
269
|
+
? { module: required, invalidReason: null }
|
|
270
|
+
: {
|
|
271
|
+
module: null,
|
|
272
|
+
invalidReason: `'TOKEN_BYPASS_EXEMPT' ${describeExport(value)}, expected an object`,
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return allowlistsCache.get(absPath);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Which declared tokens in `family` hold exactly `value`. */
|
|
280
|
+
function tokensHolding(value, family, tokenValue) {
|
|
281
|
+
return Object.keys(tokenValue).filter(
|
|
282
|
+
(name) => family.test(name) && tokenValue[name] === value,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Every `TOKEN_BYPASS_EXEMPT` key (`<dir>:<prop>:<value>`) actually
|
|
288
|
+
* referenced anywhere under `componentRoot`'s own `**\/*.css` — a direct
|
|
289
|
+
* filesystem scan over raw text (no PostCSS AST available here), independent
|
|
290
|
+
* of which files stylelint hands this rule during this run.
|
|
291
|
+
*/
|
|
292
|
+
function scanComponentRootForSeenKeys(absBase, tokenValue) {
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
if (!fs.existsSync(absBase)) return seen;
|
|
295
|
+
for (const dir of fs.readdirSync(absBase)) {
|
|
296
|
+
const dirPath = path.join(absBase, dir);
|
|
297
|
+
if (!fs.statSync(dirPath).isDirectory()) continue;
|
|
298
|
+
for (const file of fs
|
|
299
|
+
.readdirSync(dirPath)
|
|
300
|
+
.filter((f) => f.endsWith('.css'))) {
|
|
301
|
+
const css = fs
|
|
302
|
+
.readFileSync(path.join(dirPath, file), 'utf-8')
|
|
303
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
304
|
+
for (const m of css.matchAll(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/g)) {
|
|
305
|
+
const prop = m[2];
|
|
306
|
+
const value = m[3].trim().replace(/\s+/g, ' ');
|
|
307
|
+
if (value.includes('var(--ui-')) continue;
|
|
308
|
+
if (STRUCTURAL.has(value)) continue;
|
|
309
|
+
|
|
310
|
+
if (BORDER_PROP.test(prop)) {
|
|
311
|
+
const width = value.match(/^([\d.]+)(px|rem|em)\b/);
|
|
312
|
+
if (width) seen.add(`${dir}:${prop}:${width[0]}`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const family = FAMILY[prop];
|
|
316
|
+
if (!family) continue;
|
|
317
|
+
if (tokensHolding(value, family, tokenValue).length > 0) {
|
|
318
|
+
seen.add(`${dir}:${prop}:${value}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return seen;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Keyed on (componentRoot, tokenFile, allowlistsFile), not on componentRoot
|
|
327
|
+
// alone — the scan result depends on `tokenValue` (which comes from
|
|
328
|
+
// `tokenFile`), and whether the once-per-root stale-report loop below even
|
|
329
|
+
// RUNS depends on `TOKEN_BYPASS_EXEMPT` (which comes from `allowlistsFile`).
|
|
330
|
+
// Two overrides sharing a `componentRoot` but differing in either used to
|
|
331
|
+
// serve the first one's scan (and its `staleReportedForRoot` flag) to the
|
|
332
|
+
// second, silently suppressing the second's own staleness check — see
|
|
333
|
+
// `no-primitive-token.js` for the full reasoning and its own reproduction
|
|
334
|
+
// of the same shape of bug (2026-09-12 stylelint review, claim 3).
|
|
335
|
+
function cacheKey(componentRoot, tokenFile, allowlistsFile) {
|
|
336
|
+
// JSON-encode the tuple rather than joining with a separator character:
|
|
337
|
+
// that's unambiguous no matter what any of the three strings contain,
|
|
338
|
+
// unlike a literal join (space, NUL, or any other single character) which
|
|
339
|
+
// two different (componentRoot, tokenFile, allowlistsFile) triples could
|
|
340
|
+
// in principle both produce.
|
|
341
|
+
return JSON.stringify([componentRoot, tokenFile || '', allowlistsFile || '']);
|
|
342
|
+
}
|
|
343
|
+
const seenByRoot = new Map();
|
|
344
|
+
const staleReportedForRoot = new Set();
|
|
345
|
+
|
|
346
|
+
/** @type {import('stylelint').Rule} */
|
|
347
|
+
const rule = (primary, secondaryOptions) => {
|
|
348
|
+
return (root, result) => {
|
|
349
|
+
const validOptions = stylelint.utils.validateOptions(
|
|
350
|
+
result,
|
|
351
|
+
ruleName,
|
|
352
|
+
{ actual: primary, possible: [true] },
|
|
353
|
+
{
|
|
354
|
+
actual: secondaryOptions,
|
|
355
|
+
possible: {
|
|
356
|
+
tokenFile: [isNonEmptyString],
|
|
357
|
+
componentRoot: [isNonEmptyString],
|
|
358
|
+
allowlistsFile: [isNonEmptyString],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
if (!validOptions) return;
|
|
363
|
+
|
|
364
|
+
// Normalized BEFORE the `inScope` compare below and before it's used in
|
|
365
|
+
// any cache key — see `no-primitive-token.js` / `normalizeRepoRelative`'s
|
|
366
|
+
// header for why a raw `componentRoot: './libs/react/src/lib'`, a
|
|
367
|
+
// trailing slash, or an absolute path would otherwise disagree with
|
|
368
|
+
// `toRepoRelative(inputFile)` and silently disable every staleness check
|
|
369
|
+
// for this override (claim 4).
|
|
370
|
+
const rawComponentRoot = secondaryOptions && secondaryOptions.componentRoot;
|
|
371
|
+
const componentRoot = rawComponentRoot
|
|
372
|
+
? normalizeRepoRelative(rawComponentRoot)
|
|
373
|
+
: undefined;
|
|
374
|
+
const tokenFile = secondaryOptions && secondaryOptions.tokenFile;
|
|
375
|
+
const tokenValue = getTokenValues(tokenFile);
|
|
376
|
+
const allowlistsFile = secondaryOptions && secondaryOptions.allowlistsFile;
|
|
377
|
+
const { module: allowlists, invalidReason } = getAllowlists(allowlistsFile);
|
|
378
|
+
if (invalidReason) {
|
|
379
|
+
stylelint.utils.report({
|
|
380
|
+
message: messages.invalidAllowlists(allowlistsFile, invalidReason),
|
|
381
|
+
node: root,
|
|
382
|
+
result,
|
|
383
|
+
ruleName,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const TOKEN_BYPASS_EXEMPT =
|
|
387
|
+
(allowlists && allowlists.TOKEN_BYPASS_EXEMPT) || {};
|
|
388
|
+
|
|
389
|
+
const inputFile = root.source && root.source.input.file;
|
|
390
|
+
const relFile = inputFile ? toRepoRelative(inputFile) : undefined;
|
|
391
|
+
const dir = relFile ? path.basename(path.dirname(relFile)) : undefined;
|
|
392
|
+
const inScope =
|
|
393
|
+
Boolean(componentRoot) &&
|
|
394
|
+
Boolean(relFile) &&
|
|
395
|
+
(relFile === componentRoot || relFile.startsWith(`${componentRoot}/`));
|
|
396
|
+
// Scanning a tree to police an empty map is work with no finding it
|
|
397
|
+
// could ever produce — skip the scan (and the staleness report loop)
|
|
398
|
+
// outright rather than run it and find nothing, every file, forever.
|
|
399
|
+
const hasExemptions = Object.keys(TOKEN_BYPASS_EXEMPT).length > 0;
|
|
400
|
+
const rootKey = cacheKey(componentRoot, tokenFile, allowlistsFile);
|
|
401
|
+
|
|
402
|
+
if (inScope && hasExemptions && !seenByRoot.has(rootKey)) {
|
|
403
|
+
seenByRoot.set(
|
|
404
|
+
rootKey,
|
|
405
|
+
scanComponentRootForSeenKeys(
|
|
406
|
+
path.resolve(REPO_ROOT, componentRoot),
|
|
407
|
+
tokenValue,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const seenKeys =
|
|
412
|
+
inScope && hasExemptions ? seenByRoot.get(rootKey) : new Set();
|
|
413
|
+
|
|
414
|
+
// Allowlist hygiene, reported once per (componentRoot, tokenFile,
|
|
415
|
+
// allowlistsFile) per run — see no-primitive-token.js for why this is
|
|
416
|
+
// the anchor and its limitation.
|
|
417
|
+
if (inScope && hasExemptions && !staleReportedForRoot.has(rootKey)) {
|
|
418
|
+
staleReportedForRoot.add(rootKey);
|
|
419
|
+
for (const key of Object.keys(TOKEN_BYPASS_EXEMPT)) {
|
|
420
|
+
if (!seenKeys.has(key)) {
|
|
421
|
+
stylelint.utils.report({
|
|
422
|
+
message: messages.stale(key),
|
|
423
|
+
node: root,
|
|
424
|
+
result,
|
|
425
|
+
ruleName,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
root.walkDecls((decl) => {
|
|
432
|
+
const prop = decl.prop.toLowerCase();
|
|
433
|
+
const value = decl.value.trim().replace(/\s+/g, ' ');
|
|
434
|
+
if (value.includes('var(--ui-')) return;
|
|
435
|
+
if (STRUCTURAL.has(value)) return;
|
|
436
|
+
|
|
437
|
+
if (BORDER_PROP.test(prop)) {
|
|
438
|
+
const width = value.match(/^([\d.]+)(px|rem|em)\b/);
|
|
439
|
+
if (!width) return;
|
|
440
|
+
|
|
441
|
+
const key = `${dir}:${prop}:${width[0]}`;
|
|
442
|
+
const exempt = TOKEN_BYPASS_EXEMPT[key];
|
|
443
|
+
if (exempt) {
|
|
444
|
+
if (exempt.kind === 'gap') {
|
|
445
|
+
stylelint.utils.report({
|
|
446
|
+
message: `[GAP] ${prop}: ${width[0]} still unbound. ${exempt.why}`,
|
|
447
|
+
node: decl,
|
|
448
|
+
result,
|
|
449
|
+
ruleName,
|
|
450
|
+
severity: 'warning',
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
stylelint.utils.report({
|
|
456
|
+
message: messages.border(prop, value),
|
|
457
|
+
node: decl,
|
|
458
|
+
result,
|
|
459
|
+
ruleName,
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const family = FAMILY[prop];
|
|
465
|
+
if (!family) return;
|
|
466
|
+
const holders = tokensHolding(value, family, tokenValue);
|
|
467
|
+
if (holders.length === 0) return;
|
|
468
|
+
|
|
469
|
+
const key = `${dir}:${prop}:${value}`;
|
|
470
|
+
const exempt = TOKEN_BYPASS_EXEMPT[key];
|
|
471
|
+
if (exempt) {
|
|
472
|
+
if (exempt.kind === 'gap') {
|
|
473
|
+
stylelint.utils.report({
|
|
474
|
+
message: `[GAP] ${prop}: ${value} should bind to var(${holders[0]}). ${exempt.why}`,
|
|
475
|
+
node: decl,
|
|
476
|
+
result,
|
|
477
|
+
ruleName,
|
|
478
|
+
severity: 'warning',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
stylelint.utils.report({
|
|
484
|
+
message: messages.bypass(prop, value, holders),
|
|
485
|
+
node: decl,
|
|
486
|
+
result,
|
|
487
|
+
ruleName,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
rule.ruleName = ruleName;
|
|
494
|
+
rule.messages = messages;
|
|
495
|
+
rule.meta = meta;
|
|
496
|
+
|
|
497
|
+
module.exports = stylelint.createPlugin(ruleName, rule);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atelier/no-undeclared-token
|
|
5
|
+
*
|
|
6
|
+
* Every `--ui-*` custom property a stylesheet READS via `var(--ui-x, ...)`
|
|
7
|
+
* must be declared somewhere in the token source(s) named by the `tokenFiles`
|
|
8
|
+
* secondary option (repo-root-relative paths, e.g.
|
|
9
|
+
* `['libs/create-workspace/src/generators/preset/files/styles/tokens.css']`).
|
|
10
|
+
* A `var()` fallback is what makes an undeclared reference silent: the
|
|
11
|
+
* component still renders, plausibly, at a value the design system does not
|
|
12
|
+
* control. Two real incidents motivated this (not hypothetical):
|
|
13
|
+
*
|
|
14
|
+
* - all three code-block stylesheets read `var(--ui-font-mono, …)` while
|
|
15
|
+
* nothing declared it, so every code block silently rendered in the
|
|
16
|
+
* Menlo fallback until ADR-0035 declared the token.
|
|
17
|
+
* - AtlTooltip read `var(--ui-z-tooltip, 200)`, which no token source had
|
|
18
|
+
* ever declared, so the tooltip's stacking level was the literal 200
|
|
19
|
+
* while every other floating layer used --ui-z-dropdown (ADR-0075).
|
|
20
|
+
*
|
|
21
|
+
* `tokenFiles` is a required secondary option, not a hardcoded path, because
|
|
22
|
+
* the declared-token SET differs by scope: component CSS is checked only
|
|
23
|
+
* against the shared `tokens.css` (one file, three frameworks' own copies
|
|
24
|
+
* kept byte-identical to it by `check:tokens`), while the docs site's CSS is
|
|
25
|
+
* checked against that file UNION `docs/src/styles/docs-theme.css` (which
|
|
26
|
+
* both declares docs-only tokens and re-declares/overrides some `--ui-*`
|
|
27
|
+
* ones). A stylelint config wires the two scopes to two different
|
|
28
|
+
* `tokenFiles` lists via per-glob overrides, rather than this rule guessing
|
|
29
|
+
* which file(s) apply to which CSS.
|
|
30
|
+
*
|
|
31
|
+
* `--ui-` itself (as opposed to `--docs-*` or any other custom-property
|
|
32
|
+
* prefix) is NOT an option: unlike `tokenFiles`, there is no known reason
|
|
33
|
+
* this repo would ever want to police a different prefix, so it stays a
|
|
34
|
+
* constant the way `atelier/host-attr-guard`'s `HOST_GUARD_KEY` does.
|
|
35
|
+
*
|
|
36
|
+
* Reports once per `var(--ui-x)` occurrence, attributed to the declaration
|
|
37
|
+
* that reads it — the formerly-`check-css-tokens.js` script instead
|
|
38
|
+
* aggregated one message per undeclared token name across the whole repo
|
|
39
|
+
* ("read by N component stylesheet(s)"); a linter reporting per file, at
|
|
40
|
+
* the exact line, is the more useful shape for the same fact and is what a
|
|
41
|
+
* `root.walkDecls` visitor naturally produces.
|
|
42
|
+
*
|
|
43
|
+
* Formerly Pass C of `tools/scripts/check-css-tokens.js`.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
const stylelint = require('stylelint');
|
|
49
|
+
const { REPO_ROOT, isNonEmptyString } = require('./utils');
|
|
50
|
+
|
|
51
|
+
const ruleName = 'atelier/no-undeclared-token';
|
|
52
|
+
|
|
53
|
+
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
54
|
+
rejected: (name) =>
|
|
55
|
+
`[UNDECLARED] '${name}' is read here via var() but is declared in no ` +
|
|
56
|
+
'configured token source. The component silently renders at its ' +
|
|
57
|
+
"var() fallback (if any), which the design system doesn't control. " +
|
|
58
|
+
'Declare the token, or reference one that exists.',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const meta = {
|
|
62
|
+
url: 'tools/stylelint-rules/no-undeclared-token.js',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Matches a `var(--ui-...)` READ inside a declaration's value.
|
|
66
|
+
const TOKEN_READ = /var\(\s*(--ui-[a-z0-9-]+)/g;
|
|
67
|
+
// Matches a `--ui-...` DECLARATION (`--ui-x: <value>;`) inside a token
|
|
68
|
+
// source file. A token may be re-declared across selectors (light / dark /
|
|
69
|
+
// [data-theme] blocks); only its existence matters here.
|
|
70
|
+
const TOKEN_DECLARATION = /(--ui-[a-zA-Z0-9-]+)\s*:/g;
|
|
71
|
+
|
|
72
|
+
/** The set of `--ui-*` names declared in `tokenFiles` (repo-root-relative paths). */
|
|
73
|
+
function readDeclaredTokens(tokenFiles) {
|
|
74
|
+
const declared = new Set();
|
|
75
|
+
for (const relFile of tokenFiles) {
|
|
76
|
+
const absFile = path.resolve(REPO_ROOT, relFile);
|
|
77
|
+
const src = fs.readFileSync(absFile, 'utf-8');
|
|
78
|
+
for (const match of src.matchAll(TOKEN_DECLARATION)) {
|
|
79
|
+
declared.add(match[1]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return declared;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @type {import('stylelint').Rule} */
|
|
86
|
+
const rule = (primary, secondaryOptions) => {
|
|
87
|
+
return (root, result) => {
|
|
88
|
+
const validOptions = stylelint.utils.validateOptions(
|
|
89
|
+
result,
|
|
90
|
+
ruleName,
|
|
91
|
+
{ actual: primary, possible: [true] },
|
|
92
|
+
{
|
|
93
|
+
actual: secondaryOptions,
|
|
94
|
+
possible: { tokenFiles: [isNonEmptyString] },
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
if (!validOptions) return;
|
|
98
|
+
|
|
99
|
+
const tokenFiles = secondaryOptions.tokenFiles;
|
|
100
|
+
const declaredTokens = readDeclaredTokens(tokenFiles);
|
|
101
|
+
|
|
102
|
+
root.walkDecls((decl) => {
|
|
103
|
+
for (const match of decl.value.matchAll(TOKEN_READ)) {
|
|
104
|
+
const name = match[1];
|
|
105
|
+
if (declaredTokens.has(name)) continue;
|
|
106
|
+
|
|
107
|
+
stylelint.utils.report({
|
|
108
|
+
message: messages.rejected(name),
|
|
109
|
+
node: decl,
|
|
110
|
+
result,
|
|
111
|
+
ruleName,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
rule.ruleName = ruleName;
|
|
119
|
+
rule.messages = messages;
|
|
120
|
+
rule.meta = meta;
|
|
121
|
+
|
|
122
|
+
module.exports = stylelint.createPlugin(ruleName, rule);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small, dependency-free helpers shared by the local rules in this
|
|
5
|
+
* directory. Deliberately does not import anything from `stylelint/lib/*`
|
|
6
|
+
* beyond the public API (`stylelint.utils.*`, `stylelint.createPlugin`) —
|
|
7
|
+
* those subpaths happen to be resolvable (the package's `exports` map
|
|
8
|
+
* allows `./lib/utils/*`), but they are stylelint's own internals, not part
|
|
9
|
+
* of its documented `PublicApi`, so a rule in this repo doesn't take a
|
|
10
|
+
* dependency on them staying stable across versions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Absolute path to the repository root, computed from this file's own
|
|
17
|
+
* location rather than from `process.cwd()` — `tools/stylelint-rules/` sits
|
|
18
|
+
* at the same depth as `tools/scripts/` (two levels under the root), so this
|
|
19
|
+
* resolves correctly regardless of which directory stylelint is invoked
|
|
20
|
+
* from (an Nx target may run with `cwd` set to a project root, not the
|
|
21
|
+
* workspace root).
|
|
22
|
+
*/
|
|
23
|
+
const REPO_ROOT = path.resolve(__dirname, '../..');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `absPath` expressed relative to `REPO_ROOT`, POSIX-separated so an option
|
|
27
|
+
* written as `'docs/src/styles/global.css'` matches on every platform.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} absPath
|
|
30
|
+
*/
|
|
31
|
+
function toRepoRelative(absPath) {
|
|
32
|
+
return path.relative(REPO_ROOT, absPath).split(path.sep).join('/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is `value` a non-empty string? Used to validate rule options without
|
|
36
|
+
* depending on stylelint's internal `validateTypes` module (see header). */
|
|
37
|
+
function isNonEmptyString(value) {
|
|
38
|
+
return typeof value === 'string' && value.length > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A `componentRoot`-shaped option, normalized exactly the way a linted
|
|
43
|
+
* file's own path already is by `toRepoRelative` above: resolved to an
|
|
44
|
+
* absolute path, then re-expressed relative to `REPO_ROOT` with POSIX
|
|
45
|
+
* separators. Without this, a rule comparing an option value directly
|
|
46
|
+
* against `toRepoRelative(inputFile)` (a plain `===`/`startsWith` string
|
|
47
|
+
* compare) silently disagrees on a leading `./`, a trailing slash, or an
|
|
48
|
+
* absolute path — all three name the same directory as the plain
|
|
49
|
+
* `libs/<fw>/src/lib` form every config in this repo happens to use today,
|
|
50
|
+
* but nothing enforced that shape until this normalization existed
|
|
51
|
+
* (2026-09-12 stylelint review, claim 4: reproduced — `componentRoot:
|
|
52
|
+
* 'scratch-claim4/'` (trailing slash), `'./scratch-claim4'` (leading dot),
|
|
53
|
+
* and the equivalent absolute path each turned a genuinely stale
|
|
54
|
+
* `PRIMITIVE_EXEMPTIONS` entry from a blocking `[STALE]` into total silence,
|
|
55
|
+
* exit 0 — the staleness scan never ran because `inScope` never matched).
|
|
56
|
+
* Both `no-primitive-token.js` and `no-token-bypass.js` normalize
|
|
57
|
+
* `componentRoot` through this before using it either in the `inScope`
|
|
58
|
+
* check or as (part of) a cache key.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} relOrAbsPath
|
|
61
|
+
*/
|
|
62
|
+
function normalizeRepoRelative(relOrAbsPath) {
|
|
63
|
+
return toRepoRelative(path.resolve(REPO_ROOT, relOrAbsPath));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
REPO_ROOT,
|
|
68
|
+
toRepoRelative,
|
|
69
|
+
isNonEmptyString,
|
|
70
|
+
normalizeRepoRelative,
|
|
71
|
+
};
|