@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/package.json +1 -1
  3. package/src/generators/preset/files/contracts/README.md +29 -0
  4. package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
  5. package/src/generators/preset/files/contracts/types.ts.template +55 -0
  6. package/src/generators/preset/files/figma/snapshot.json +164 -0
  7. package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
  8. package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
  9. package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
  10. package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
  11. package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
  12. package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
  13. package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
  14. package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
  15. package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
  16. package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
  17. package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
  18. package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
  19. package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
  20. package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
  21. package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
  22. package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
  23. package/src/generators/preset/files/styles/tokens.css +59 -43
  24. package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
  25. package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
  26. package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
  27. package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
  28. package/src/generators/preset/files/tools/scripts/preflight.mjs +163 -31
  29. package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
  30. package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
  31. package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
  32. package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
  33. package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
  34. package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
  35. package/src/generators/preset/preset.d.ts +1 -0
  36. package/src/generators/preset/preset.js +955 -5
  37. package/src/generators/preset/preset.js.map +1 -1
  38. package/src/generators/preset/schema.d.ts +1 -0
  39. 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);