@atelier-ui/create-workspace 0.2.42 → 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 +29 -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 +163 -31
- 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,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atelier/no-primitive-token
|
|
5
|
+
*
|
|
6
|
+
* ADR-0018 tiers tokens primitive → semantic → component. Component CSS is
|
|
7
|
+
* meant to read the semantic tier — `--ui-color-primary`, not the ramp step
|
|
8
|
+
* behind it; `--ui-type-code`, not the font stack it names. A component that
|
|
9
|
+
* reaches past the semantic tier into a primitive re-decides, in one
|
|
10
|
+
* stylesheet, something the token layer already decided for everyone, and it
|
|
11
|
+
* does so invisibly (the result looks right in that one component). Two ADRs
|
|
12
|
+
* left exactly this hole open: ADR-0036 (a component could name
|
|
13
|
+
* `--ui-font-display` directly and pair it with a weight the face doesn't
|
|
14
|
+
* have) and ADR-0038 (a component could name a `--ui-color-teal-*` ramp step
|
|
15
|
+
* directly, pinning it to one theme's shade of the brand colour).
|
|
16
|
+
*
|
|
17
|
+
* `PRIMITIVE_TOKENS` (the forbidden patterns, with what to use instead and
|
|
18
|
+
* why) and `PRIMITIVE_EXEMPTIONS` (the `<component-dir>:<token>` pairs that
|
|
19
|
+
* may reference one anyway) both stay in `tools/scripts/lib/allowlists.js` —
|
|
20
|
+
* this rule reaches that file through the `allowlistsFile` secondary option
|
|
21
|
+
* (see below) rather than duplicating or relocating it. That file's own
|
|
22
|
+
* header calls it "the single source of truth for the gates' hand-maintained
|
|
23
|
+
* EXCEPTIONS"; a stylelint rule enforcing a CSS-discipline invariant is the
|
|
24
|
+
* same kind of consumer a script gate was, so splitting these two maps out
|
|
25
|
+
* to live beside the rule instead would cost an auditor a second file to
|
|
26
|
+
* check for no structural gain. Same two kinds as every other allowlist
|
|
27
|
+
* there (ADR-0034): `kind: 'design'` is a closed question and stays silent;
|
|
28
|
+
* `kind: 'gap'` should bind and hasn't yet, and warns on every run — via a
|
|
29
|
+
* per-message `severity: 'warning'` override, since stylelint applies
|
|
30
|
+
* severity per report(), not just per rule.
|
|
31
|
+
*
|
|
32
|
+
* Staleness (ADR-0034: an allowlist entry naming something that no longer
|
|
33
|
+
* exists, or no longer violates, is itself a blocker) is checked once per
|
|
34
|
+
* `componentRoot` per process against a direct filesystem scan of THAT
|
|
35
|
+
* root's own `**\/*.css` — not any other project's, because Nx wires one
|
|
36
|
+
* `stylelint` target per project, and reading a SIBLING project's files from
|
|
37
|
+
* inside this one is the undeclared cross-project input trap ADR-0126's
|
|
38
|
+
* Consequences names (nx.json declares this rule's own inputs, not another
|
|
39
|
+
* project's tree). Every entry in `PRIMITIVE_EXEMPTIONS` today is referenced
|
|
40
|
+
* identically in all three frameworks' mirrored CSS (verified 2026-09-12),
|
|
41
|
+
* so a per-`componentRoot` scan agrees with the retired script's whole-repo
|
|
42
|
+
* one for everything that exists now. The gap this leaves: an exemption that
|
|
43
|
+
* is legitimately framework-asymmetric (bound in only one or two of the
|
|
44
|
+
* three) would be reported stale by the root(s) that don't reference it — a
|
|
45
|
+
* real narrowing from the retired script's single-process, any-of-three
|
|
46
|
+
* view. No entry today is asymmetric; if one becomes so, widen the scan
|
|
47
|
+
* here.
|
|
48
|
+
*
|
|
49
|
+
* Scope is deliberately component CSS only — every framework's own
|
|
50
|
+
* `src/lib` component stylesheets — because the token source declares
|
|
51
|
+
* primitives (that's its job) and the docs app is a consumer like any other
|
|
52
|
+
* product surface. Wired only on that override block in
|
|
53
|
+
* stylelint.config.mjs, never on docs'.
|
|
54
|
+
*
|
|
55
|
+
* TWO secondary options, both repo-relative POSIX paths, neither guessed —
|
|
56
|
+
* the config declares topology the way `no-undeclared-token`'s `tokenFiles`
|
|
57
|
+
* already does:
|
|
58
|
+
* - `componentRoot` (required for staleness): the one directory this
|
|
59
|
+
* invocation's `libs/<fw>/src/lib` (or a scaffold's own tree) lives at.
|
|
60
|
+
* Replaces a `frameworkOf()` regex that used to pattern-match the input
|
|
61
|
+
* file's path against `libs/(angular|react|vue)/src/lib/` to guess which
|
|
62
|
+
* of three hardcoded trees to scan. Omitted, the staleness scan does not
|
|
63
|
+
* run; the per-declaration primitive check is unaffected — it never
|
|
64
|
+
* depended on knowing the framework, only on `dir` (a plain basename).
|
|
65
|
+
* - `allowlistsFile` (optional): repo-relative path to a CommonJS module
|
|
66
|
+
* exporting `PRIMITIVE_TOKENS` and `PRIMITIVE_EXEMPTIONS`. This repo
|
|
67
|
+
* passes `tools/scripts/lib/allowlists.js`, unchanged. **Absent — the
|
|
68
|
+
* documented default for a scaffold, which starts with ZERO
|
|
69
|
+
* exemptions — `PRIMITIVE_TOKENS` is `[]` and `PRIMITIVE_EXEMPTIONS` is
|
|
70
|
+
* an empty `Map`.** An empty `PRIMITIVE_TOKENS` makes the whole rule a
|
|
71
|
+
* no-op (nothing left to forbid), which is exactly the shape a rule
|
|
72
|
+
* nobody configured should have. An empty `PRIMITIVE_EXEMPTIONS` also
|
|
73
|
+
* means the staleness scan is skipped outright, not just "runs and finds
|
|
74
|
+
* nothing": scanning a tree to police an empty map is work with no
|
|
75
|
+
* finding it could ever produce, so the scan (and the per-root cache it
|
|
76
|
+
* would populate) is short-circuited before it touches the filesystem.
|
|
77
|
+
* Loaded lazily, per file, from inside the rule closure (cached by
|
|
78
|
+
* resolved absolute path) rather than at module `require()` time — the
|
|
79
|
+
* old code's top-level `require('../scripts/lib/allowlists')` ran the
|
|
80
|
+
* instant `index.js` loaded this file into the `plugins` array, which
|
|
81
|
+
* happens whenever ANY rule in the plugin is used, whether or not
|
|
82
|
+
* `no-primitive-token` itself is turned on for that project. A scaffold
|
|
83
|
+
* has no such file at all, so that top-level `require()` would throw
|
|
84
|
+
* `MODULE_NOT_FOUND` merely from loading the plugin — before any config
|
|
85
|
+
* decision about whether to enable this rule even applies.
|
|
86
|
+
*
|
|
87
|
+
* **The empty default applies only when `allowlistsFile` is absent.**
|
|
88
|
+
* When it IS supplied, the loaded module's exports are validated:
|
|
89
|
+
* `PRIMITIVE_TOKENS` must be an array and `PRIMITIVE_EXEMPTIONS` must be
|
|
90
|
+
* a `Map`. A module that fails to load resolution still throws
|
|
91
|
+
* `MODULE_NOT_FOUND` as before (unchanged, already loud); a module that
|
|
92
|
+
* DOES load but doesn't actually export those two names — a typo, a
|
|
93
|
+
* rename on one side only — used to be silently treated as the same
|
|
94
|
+
* "zero exemptions" default, which is exactly ADR-0124's failure class:
|
|
95
|
+
* the staleness scan skips (nothing to report) AND every real
|
|
96
|
+
* `PRIMITIVE_TOKENS` match goes undetected (nothing to forbid), and the
|
|
97
|
+
* rule reports a clean pass over a config that never actually loaded
|
|
98
|
+
* (2026-09-12 stylelint review, claim 2). Now a mis-shaped supplied
|
|
99
|
+
* module reports `[INVALID-ALLOWLISTS]` at `error` severity on every
|
|
100
|
+
* file this override touches, via `stylelint.utils.report()` rather than
|
|
101
|
+
* a thrown exception — a thrown error aborts the ENTIRE stylelint run
|
|
102
|
+
* (verified: one throwing rule turns the whole CLI invocation into a
|
|
103
|
+
* bare Node stack trace, exit 1, with no per-file breakdown and no
|
|
104
|
+
* structured formatter output for any other file), where `report()`
|
|
105
|
+
* keeps every other file's real findings intact, keeps `--formatter
|
|
106
|
+
* json` usable, and reuses the exact channel `[STALE]`/`[GAP]`/etc.
|
|
107
|
+
* already report through — the same tradeoff `validateOptions` above
|
|
108
|
+
* already makes for a malformed *option*; this is the same call for a
|
|
109
|
+
* malformed *file the option points at*.
|
|
110
|
+
*
|
|
111
|
+
* Formerly tools/scripts/check-primitives.js (check:token-tiers).
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
const fs = require('fs');
|
|
115
|
+
const path = require('path');
|
|
116
|
+
const stylelint = require('stylelint');
|
|
117
|
+
const {
|
|
118
|
+
REPO_ROOT,
|
|
119
|
+
toRepoRelative,
|
|
120
|
+
isNonEmptyString,
|
|
121
|
+
normalizeRepoRelative,
|
|
122
|
+
} = require('./utils');
|
|
123
|
+
|
|
124
|
+
const ruleName = 'atelier/no-primitive-token';
|
|
125
|
+
|
|
126
|
+
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
127
|
+
rejected: (token, primitive) =>
|
|
128
|
+
`[PRIMITIVE] references ${token} (${primitive.label}). Use ${primitive.useInstead}. ${primitive.why}`,
|
|
129
|
+
stale: (key, kind) =>
|
|
130
|
+
`[STALE] PRIMITIVE_EXEMPTIONS carries '${key}' (${kind}) but no component CSS under the configured componentRoot references it any more. Remove the entry.`,
|
|
131
|
+
invalidAllowlists: (allowlistsFile, reason) =>
|
|
132
|
+
`[INVALID-ALLOWLISTS] '${allowlistsFile}' ${reason}. A supplied allowlistsFile must actually export both — a missing or misspelled export is a broken configuration, not the documented empty default (which only applies when allowlistsFile is omitted entirely).`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const meta = {
|
|
136
|
+
url: 'tools/stylelint-rules/no-primitive-token.js',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Matches a `var(--ui-…)` READ inside a declaration's value.
|
|
140
|
+
const TOKEN_READ = /var\(\s*(--ui-[a-z0-9-]+)/g;
|
|
141
|
+
|
|
142
|
+
/** Human-readable description of what `require()` actually returned, for the
|
|
143
|
+
* `[INVALID-ALLOWLISTS]` message. */
|
|
144
|
+
function describeExport(value) {
|
|
145
|
+
if (value === undefined) return 'is undefined (no such export)';
|
|
146
|
+
if (Array.isArray(value)) return 'is an array';
|
|
147
|
+
if (value instanceof Map) return 'is a Map';
|
|
148
|
+
return `is a ${typeof value}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const allowlistsCache = new Map(); // absolute allowlistsFile path -> result below
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `{ module: null, invalidReason: null }` when `allowlistsFile` is absent —
|
|
155
|
+
* the documented default for a scaffold, which starts with ZERO exemptions
|
|
156
|
+
* (see header).
|
|
157
|
+
*
|
|
158
|
+
* When `allowlistsFile` IS supplied, the loaded module's shape is validated:
|
|
159
|
+
* `PRIMITIVE_TOKENS` must be an array, `PRIMITIVE_EXEMPTIONS` must be a
|
|
160
|
+
* `Map`. Either missing or wrongly-shaped yields `{ module: null,
|
|
161
|
+
* invalidReason: <string> }` instead of silently falling back to the same
|
|
162
|
+
* empty defaults the absent-option case uses — see header for why (claim 2).
|
|
163
|
+
* A module that itself fails to `require()` (a typo'd PATH, not a typo'd
|
|
164
|
+
* EXPORT) still throws `MODULE_NOT_FOUND` here, unchanged and already loud.
|
|
165
|
+
*/
|
|
166
|
+
function getAllowlists(allowlistsFile) {
|
|
167
|
+
if (!allowlistsFile) return { module: null, invalidReason: null };
|
|
168
|
+
const absPath = path.resolve(REPO_ROOT, allowlistsFile);
|
|
169
|
+
if (!allowlistsCache.has(absPath)) {
|
|
170
|
+
const required = require(absPath);
|
|
171
|
+
const problems = [];
|
|
172
|
+
if (!Array.isArray(required.PRIMITIVE_TOKENS)) {
|
|
173
|
+
problems.push(
|
|
174
|
+
`'PRIMITIVE_TOKENS' ${describeExport(required.PRIMITIVE_TOKENS)}, expected an array`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!(required.PRIMITIVE_EXEMPTIONS instanceof Map)) {
|
|
178
|
+
problems.push(
|
|
179
|
+
`'PRIMITIVE_EXEMPTIONS' ${describeExport(required.PRIMITIVE_EXEMPTIONS)}, expected a Map`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
allowlistsCache.set(
|
|
183
|
+
absPath,
|
|
184
|
+
problems.length === 0
|
|
185
|
+
? { module: required, invalidReason: null }
|
|
186
|
+
: { module: null, invalidReason: problems.join('; ') },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return allowlistsCache.get(absPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every `PRIMITIVE_EXEMPTIONS` key (`<dir>:<token>`) actually referenced
|
|
194
|
+
* anywhere under `componentRoot`'s own `**\/*.css` — a direct filesystem
|
|
195
|
+
* scan, independent of which files stylelint itself hands this rule during
|
|
196
|
+
* this run, so staleness is computed correctly even for a file this
|
|
197
|
+
* particular invocation never visits.
|
|
198
|
+
*/
|
|
199
|
+
function scanComponentRootForSeenKeys(absBase, primitiveTokens) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
if (!fs.existsSync(absBase)) return seen;
|
|
202
|
+
for (const dir of fs.readdirSync(absBase)) {
|
|
203
|
+
const dirPath = path.join(absBase, dir);
|
|
204
|
+
if (!fs.statSync(dirPath).isDirectory()) continue;
|
|
205
|
+
for (const entry of fs
|
|
206
|
+
.readdirSync(dirPath)
|
|
207
|
+
.filter((f) => f.endsWith('.css'))) {
|
|
208
|
+
const css = fs.readFileSync(path.join(dirPath, entry), 'utf-8');
|
|
209
|
+
for (const m of css.matchAll(TOKEN_READ)) {
|
|
210
|
+
const token = m[1];
|
|
211
|
+
if (primitiveTokens.some((p) => p.match.test(token))) {
|
|
212
|
+
seen.add(`${dir}:${token}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return seen;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Computed at most once per (componentRoot, allowlistsFile) pair per process
|
|
221
|
+
// — one project's stylelint run only ever lints one project's files, but the
|
|
222
|
+
// module stays loaded (and its module-scope state with it) for every file in
|
|
223
|
+
// that run. Keyed on the PAIR, not on `componentRoot` alone: the scan result
|
|
224
|
+
// depends on which `PRIMITIVE_TOKENS` patterns it matched against, and that
|
|
225
|
+
// comes from `allowlistsFile` — two overrides sharing a `componentRoot` but
|
|
226
|
+
// pointing at different allowlists modules used to serve the first one's
|
|
227
|
+
// scan (and its `staleReportedForRoot` flag) to the second, silently
|
|
228
|
+
// suppressing the second's own staleness check (2026-09-12 stylelint review,
|
|
229
|
+
// claim 3, reproduced: a componentRoot processed second had its own
|
|
230
|
+
// genuinely-stale exemption go unreported once a differently-configured
|
|
231
|
+
// override sharing that root ran first in the same process).
|
|
232
|
+
function cacheKey(componentRoot, allowlistsFile) {
|
|
233
|
+
// JSON-encode the tuple rather than joining with a separator character:
|
|
234
|
+
// that's unambiguous no matter what either string contains, unlike a
|
|
235
|
+
// literal join (space, NUL, or any other single character) which two
|
|
236
|
+
// different (componentRoot, allowlistsFile) pairs could in principle
|
|
237
|
+
// both produce.
|
|
238
|
+
return JSON.stringify([componentRoot, allowlistsFile || '']);
|
|
239
|
+
}
|
|
240
|
+
const seenByRoot = new Map();
|
|
241
|
+
const staleReportedForRoot = new Set();
|
|
242
|
+
|
|
243
|
+
/** @type {import('stylelint').Rule} */
|
|
244
|
+
const rule = (primary, secondaryOptions) => {
|
|
245
|
+
return (root, result) => {
|
|
246
|
+
const validOptions = stylelint.utils.validateOptions(
|
|
247
|
+
result,
|
|
248
|
+
ruleName,
|
|
249
|
+
{ actual: primary, possible: [true] },
|
|
250
|
+
{
|
|
251
|
+
actual: secondaryOptions,
|
|
252
|
+
possible: {
|
|
253
|
+
componentRoot: [isNonEmptyString],
|
|
254
|
+
allowlistsFile: [isNonEmptyString],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
if (!validOptions) return;
|
|
259
|
+
|
|
260
|
+
// Normalized BEFORE the `inScope` compare below and before it's used in
|
|
261
|
+
// any cache key — see `normalizeRepoRelative`'s header for why a raw
|
|
262
|
+
// `componentRoot: './libs/react/src/lib'`, a trailing slash, or an
|
|
263
|
+
// absolute path would otherwise disagree with `toRepoRelative(inputFile)`
|
|
264
|
+
// and silently disable every staleness check for this override (claim 4).
|
|
265
|
+
const rawComponentRoot = secondaryOptions && secondaryOptions.componentRoot;
|
|
266
|
+
const componentRoot = rawComponentRoot
|
|
267
|
+
? normalizeRepoRelative(rawComponentRoot)
|
|
268
|
+
: undefined;
|
|
269
|
+
const allowlistsFile = secondaryOptions && secondaryOptions.allowlistsFile;
|
|
270
|
+
const { module: allowlists, invalidReason } = getAllowlists(allowlistsFile);
|
|
271
|
+
if (invalidReason) {
|
|
272
|
+
stylelint.utils.report({
|
|
273
|
+
message: messages.invalidAllowlists(allowlistsFile, invalidReason),
|
|
274
|
+
node: root,
|
|
275
|
+
result,
|
|
276
|
+
ruleName,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const PRIMITIVE_TOKENS = (allowlists && allowlists.PRIMITIVE_TOKENS) || [];
|
|
280
|
+
const PRIMITIVE_EXEMPTIONS =
|
|
281
|
+
(allowlists && allowlists.PRIMITIVE_EXEMPTIONS) || new Map();
|
|
282
|
+
|
|
283
|
+
const inputFile = root.source && root.source.input.file;
|
|
284
|
+
const relFile = inputFile ? toRepoRelative(inputFile) : undefined;
|
|
285
|
+
const dir = relFile ? path.basename(path.dirname(relFile)) : undefined;
|
|
286
|
+
const inScope =
|
|
287
|
+
Boolean(componentRoot) &&
|
|
288
|
+
Boolean(relFile) &&
|
|
289
|
+
(relFile === componentRoot || relFile.startsWith(`${componentRoot}/`));
|
|
290
|
+
// Scanning a tree to police an empty map is work with no finding it
|
|
291
|
+
// could ever produce — skip the scan (and the staleness report loop)
|
|
292
|
+
// outright rather than run it and find nothing, every file, forever.
|
|
293
|
+
const hasExemptions = PRIMITIVE_EXEMPTIONS.size > 0;
|
|
294
|
+
const rootKey = cacheKey(componentRoot, allowlistsFile);
|
|
295
|
+
|
|
296
|
+
if (inScope && hasExemptions && !seenByRoot.has(rootKey)) {
|
|
297
|
+
seenByRoot.set(
|
|
298
|
+
rootKey,
|
|
299
|
+
scanComponentRootForSeenKeys(
|
|
300
|
+
path.resolve(REPO_ROOT, componentRoot),
|
|
301
|
+
PRIMITIVE_TOKENS,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const seenKeys =
|
|
306
|
+
inScope && hasExemptions ? seenByRoot.get(rootKey) : new Set();
|
|
307
|
+
|
|
308
|
+
// Allowlist hygiene, reported once per (componentRoot, allowlistsFile)
|
|
309
|
+
// per run (anchored to whichever file happens to be first) — ADR-0034
|
|
310
|
+
// requires this check to actually run, not just the per-occurrence
|
|
311
|
+
// [GAP] warning below.
|
|
312
|
+
if (inScope && hasExemptions && !staleReportedForRoot.has(rootKey)) {
|
|
313
|
+
staleReportedForRoot.add(rootKey);
|
|
314
|
+
for (const [exemptKey, entry] of PRIMITIVE_EXEMPTIONS) {
|
|
315
|
+
if (!seenKeys.has(exemptKey)) {
|
|
316
|
+
stylelint.utils.report({
|
|
317
|
+
message: messages.stale(exemptKey, entry.kind),
|
|
318
|
+
node: root,
|
|
319
|
+
result,
|
|
320
|
+
ruleName,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
root.walkDecls((decl) => {
|
|
327
|
+
for (const match of decl.value.matchAll(TOKEN_READ)) {
|
|
328
|
+
const token = match[1];
|
|
329
|
+
const primitive = PRIMITIVE_TOKENS.find((p) => p.match.test(token));
|
|
330
|
+
if (!primitive) continue;
|
|
331
|
+
|
|
332
|
+
const key = `${dir}:${token}`;
|
|
333
|
+
const exemption = PRIMITIVE_EXEMPTIONS.get(key);
|
|
334
|
+
if (exemption) {
|
|
335
|
+
if (exemption.kind === 'gap') {
|
|
336
|
+
stylelint.utils.report({
|
|
337
|
+
message: `[GAP] ${key} — ${exemption.reason}`,
|
|
338
|
+
node: decl,
|
|
339
|
+
result,
|
|
340
|
+
ruleName,
|
|
341
|
+
severity: 'warning',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
stylelint.utils.report({
|
|
348
|
+
message: messages.rejected(token, primitive),
|
|
349
|
+
node: decl,
|
|
350
|
+
result,
|
|
351
|
+
ruleName,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
rule.ruleName = ruleName;
|
|
359
|
+
rule.messages = messages;
|
|
360
|
+
rule.meta = meta;
|
|
361
|
+
|
|
362
|
+
module.exports = stylelint.createPlugin(ruleName, rule);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atelier/no-raw-color-literal
|
|
5
|
+
*
|
|
6
|
+
* A component (or docs-site) stylesheet must not spell a colour as a raw
|
|
7
|
+
* literal — a hex triplet/quad (`#fff`, `#00647055`), or an `rgb()`/`rgba()`/
|
|
8
|
+
* `hsl()`/`hsla()` function call — anywhere a declaration's value is not
|
|
9
|
+
* itself the argument list of a `var(...)` call. Colours come from `--ui-*`
|
|
10
|
+
* custom properties; a raw literal is how the design system's single source
|
|
11
|
+
* of colour drifts (a one-off `#006470` that does not track the token it
|
|
12
|
+
* happens to equal today).
|
|
13
|
+
*
|
|
14
|
+
* Two allowances, both structural (not configurable — see below):
|
|
15
|
+
* - a literal fallback inside `var(--token, <fallback>)` is good defensive
|
|
16
|
+
* practice; the token still drives the value when it resolves, so the
|
|
17
|
+
* fallback text is stripped before this rule ever looks at the value.
|
|
18
|
+
* - `box-shadow`/`text-shadow` (and their `-webkit-` forms) legitimately
|
|
19
|
+
* carry rgba alpha, and `mask-image`/`-webkit-mask-image` gradient stops
|
|
20
|
+
* set the mask's alpha (opaque vs. transparent), not a rendered colour —
|
|
21
|
+
* both are exempt on every property that matches, not on a per-file
|
|
22
|
+
* allowlist, because the exemption is about what the CSS *means*, not
|
|
23
|
+
* about which file it lives in.
|
|
24
|
+
*
|
|
25
|
+
* A file/literal pair that is deliberate for some OTHER reason (rare — the
|
|
26
|
+
* inherited script's own docs-only allowlist has stood empty since it was
|
|
27
|
+
* introduced, ADR-0089 §2) is the `exempt` secondary option: an array of
|
|
28
|
+
* `{ file, literal, reason }`, `file` repo-root-relative POSIX
|
|
29
|
+
* (`docs/src/styles/global.css`), `literal` the exact matched text (e.g.
|
|
30
|
+
* `'#000'`, `'rgba('`). A reason is required — this is a rule option with a
|
|
31
|
+
* reason recorded next to it, not an inline `stylelint-disable` comment,
|
|
32
|
+
* so it stays visible to whoever next audits exemptions rather than living
|
|
33
|
+
* unaudited next to the declaration it excuses.
|
|
34
|
+
*
|
|
35
|
+
* Formerly Pass A of `tools/scripts/check-css-tokens.js`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const stylelint = require('stylelint');
|
|
39
|
+
const { toRepoRelative, isNonEmptyString } = require('./utils');
|
|
40
|
+
|
|
41
|
+
const ruleName = 'atelier/no-raw-color-literal';
|
|
42
|
+
|
|
43
|
+
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
44
|
+
rejected: (prop, literal) =>
|
|
45
|
+
`[RAW-COLOR] '${prop}' uses literal '${literal}' — use a --ui-* token ` +
|
|
46
|
+
'(or var(--token, fallback)) instead of a raw color literal.',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const meta = {
|
|
50
|
+
url: 'tools/stylelint-rules/no-raw-color-literal.js',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/;
|
|
54
|
+
|
|
55
|
+
// Both patterns tolerate an optional leading `-` and an optional `webkit-`
|
|
56
|
+
// segment independently (so `box-shadow`, `-webkit-box-shadow`, and the
|
|
57
|
+
// unprefixed-but-still-matched `webkit-box-shadow` all match) — copied
|
|
58
|
+
// verbatim from the script this replaces, which is the actual property-name
|
|
59
|
+
// shape this repo's CSS uses.
|
|
60
|
+
const SHADOW_PROP = /^-?(webkit-)?(box|text)-shadow$/;
|
|
61
|
+
const MASK_PROP = /^-?(webkit-)?mask-image$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove every `var(...)` call (balanced parens) from `value` so a literal
|
|
65
|
+
* fallback inside one doesn't count as a raw literal in the declaration's
|
|
66
|
+
* own value.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} value
|
|
69
|
+
*/
|
|
70
|
+
function stripVarCalls(value) {
|
|
71
|
+
let result = '';
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < value.length) {
|
|
74
|
+
if (value.startsWith('var(', i)) {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let j = i + 3; // at the '('
|
|
77
|
+
for (; j < value.length; j++) {
|
|
78
|
+
if (value[j] === '(') depth++;
|
|
79
|
+
else if (value[j] === ')') {
|
|
80
|
+
depth--;
|
|
81
|
+
if (depth === 0) {
|
|
82
|
+
j++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
i = j;
|
|
88
|
+
} else {
|
|
89
|
+
result += value[i];
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Is `entry` a well-formed `{ file, literal, reason }` exemption? */
|
|
97
|
+
function isExemptionEntry(entry) {
|
|
98
|
+
return (
|
|
99
|
+
entry !== null &&
|
|
100
|
+
typeof entry === 'object' &&
|
|
101
|
+
isNonEmptyString(entry.file) &&
|
|
102
|
+
isNonEmptyString(entry.literal) &&
|
|
103
|
+
isNonEmptyString(entry.reason)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @type {import('stylelint').Rule} */
|
|
108
|
+
const rule = (primary, secondaryOptions) => {
|
|
109
|
+
return (root, result) => {
|
|
110
|
+
const validOptions = stylelint.utils.validateOptions(
|
|
111
|
+
result,
|
|
112
|
+
ruleName,
|
|
113
|
+
{ actual: primary, possible: [true] },
|
|
114
|
+
{
|
|
115
|
+
actual: secondaryOptions,
|
|
116
|
+
possible: { exempt: [isExemptionEntry] },
|
|
117
|
+
optional: true,
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
if (!validOptions) return;
|
|
121
|
+
|
|
122
|
+
const exempt = (secondaryOptions && secondaryOptions.exempt) || [];
|
|
123
|
+
const inputFile = root.source && root.source.input.file;
|
|
124
|
+
const relFile = inputFile ? toRepoRelative(inputFile) : undefined;
|
|
125
|
+
|
|
126
|
+
root.walkDecls((decl) => {
|
|
127
|
+
const prop = decl.prop.toLowerCase();
|
|
128
|
+
if (SHADOW_PROP.test(prop) || MASK_PROP.test(prop)) return;
|
|
129
|
+
|
|
130
|
+
const stripped = stripVarCalls(decl.value);
|
|
131
|
+
const match = stripped.match(COLOR_LITERAL);
|
|
132
|
+
if (!match) return;
|
|
133
|
+
|
|
134
|
+
const literal = match[0];
|
|
135
|
+
const isExempt = exempt.some(
|
|
136
|
+
(entry) => entry.file === relFile && entry.literal === literal,
|
|
137
|
+
);
|
|
138
|
+
if (isExempt) return;
|
|
139
|
+
|
|
140
|
+
stylelint.utils.report({
|
|
141
|
+
message: messages.rejected(decl.prop, literal),
|
|
142
|
+
node: decl,
|
|
143
|
+
result,
|
|
144
|
+
ruleName,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
rule.ruleName = ruleName;
|
|
151
|
+
rule.messages = messages;
|
|
152
|
+
rule.meta = meta;
|
|
153
|
+
|
|
154
|
+
module.exports = stylelint.createPlugin(ruleName, rule);
|