stylelint-plugin-rhythmguard 3.7.0 → 3.8.0
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 +26 -0
- package/CONTRIBUTING.md +9 -1
- package/README.md +14 -7
- package/package.json +12 -3
- package/src/cli/index.js +28 -19
- package/src/cli/index.mjs +8 -0
- package/src/core/token-sources.js +133 -12
- package/src/rules/use-scale/index.js +80 -8
- package/types/cli.d.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.8.0] - 2026-09-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `npm run bench:agents`, the agent eval harness: sixteen tasks over three fixture projects, each run as before (no linter), with findings (up to three correction rounds) and rules only (the control), through Sonnet 5, Haiku 4.5 and Opus 5; outcomes, rounds to zero and cost per condition in a dated edition under `docs/agent-evals/`. `--dry-run` exercises the whole pipeline against a scripted model. Method in `docs/AGENT_EVALS.md`. ([#135](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/135))
|
|
14
|
+
- The browser playground runs the real rules. `docs/playground/rhythmguard.js` is an esbuild bundle of the shipped `use-scale`, `prefer-token`, `no-offscale-transform` and `use-motion-scale` with a small Stylelint shim; the page offers `scale: "auto"`, every preset, SCSS, and a fixed-CSS view. A contract test rebuilds the bundle and compares the page's sample against Stylelint, so the playground cannot drift from the plugin again. ([#57](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/57))
|
|
15
|
+
- `use-scale` evaluates Sass expressions in `.scss` declarations: `padding: $spacer * .3` is reported as off scale with `Evaluates to 4.8px.` when `$spacer` resolves from the linted file, `scaleSources`, the config's token sources or an installed package; each term of `padding: $y $x` is checked on its own; `* / + -`, negation, parentheses and `math.div()` are understood. Unresolvable terms are left alone as before, and an expression is never rewritten by `--fix`. The audit inherits it, so SCSS design systems now see drift that hid behind a variable.
|
|
16
|
+
- [`PetriLahdelma/rhythmguard-action`](https://github.com/PetriLahdelma/rhythmguard-action), a GitHub Action that runs the audit, annotates the diff, posts the report as one pull-request comment updated in place, and fails on new drift against a committed baseline. Linked from the README and the CI adoption recipe.
|
|
17
|
+
- `rhythmguard`, the command as its own package: `npx rhythmguard` resolves a package of that name, and none existed, so the first command in the README failed in a project that had installed nothing (`npx stylelint-plugin-rhythmguard` worked, and the README says that until the package is published). `packages/rhythmguard` is one dependency and one line that runs the CLI, now exported as `stylelint-plugin-rhythmguard/cli` with a `main()`.
|
|
18
|
+
- `eslint-plugin-rhythmguard`, the ESLint companion under its own package name, from `packages/eslint-plugin-rhythmguard`. It re-exports `stylelint-plugin-rhythmguard/eslint`, so the rules, options and messages are one implementation; it exists so a search for an ESLint Tailwind plugin finds it. Published by the release run once the package has a trusted publisher.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The post-publish smoke waits up to ten minutes for the registry to list the new version before deciding it is unpublished. It checked once, and the registry took three minutes to list 3.7.1, so the smoke skipped itself and had to be dispatched by hand.
|
|
23
|
+
|
|
24
|
+
## [3.7.1] - 2026-09-17
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Sass maps declared through a merge helper are read: `$spacers: defaults((...), $spacers)` (Bootstrap v6), `map.merge($base, (...))` and `map-merge((...), (...))` contribute the merged map, later keys winning. The reader also follows Sass assignment rules now: a later plain assignment replaces an earlier `!default`, a later `!default` does not. Before, the first assignment always won, so Bootstrap v6's `$spacers: () !default;` hid the map that followed it and `scale: "auto"` fell back. ([#143](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/143))
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- The quiet benchmark has a `bootstrap-v6` row, the branch its maintainers asked to see (twbs/bootstrap#42907); the manifest accepts a `ref` so a row can pin a branch other than the default.
|
|
33
|
+
- The release verify matrix and the post-publish smoke are back on the self-hosted farm. The repository variable `RELEASE_ON_HOSTED=true` moves a release onto GitHub-hosted runners while the farm is down, without a workflow edit or a re-cut; the release checklist explains when to use it.
|
|
34
|
+
|
|
9
35
|
## [3.7.0] - 2026-09-17
|
|
10
36
|
|
|
11
37
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -106,7 +106,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
|
|
|
106
106
|
|
|
107
107
|
## Releases
|
|
108
108
|
|
|
109
|
-
Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies the matrix and publishes through npm trusted publishing (OIDC)
|
|
109
|
+
Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. The checklist covers cutting a release while the farm is down. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
|
|
110
110
|
|
|
111
111
|
## Benchmarking performance
|
|
112
112
|
|
|
@@ -116,3 +116,11 @@ npm run bench:perf:fix
|
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
Compares runtime against `stylelint-scales` on a deterministic corpus. Method in [`docs/BENCHMARKING.md`](./docs/BENCHMARKING.md).
|
|
119
|
+
|
|
120
|
+
## The README recording
|
|
121
|
+
|
|
122
|
+
`assets/quickstart.gif` is recorded with [vhs](https://github.com/charmbracelet/vhs) from `assets/quickstart.tape` on a shallow clone of Bootstrap `v6-dev` at `/tmp/bootstrap`. Re-record when the quickstart output changes: `git clone --depth 1 --branch v6-dev https://github.com/twbs/bootstrap.git /tmp/bootstrap && vhs assets/quickstart.tape`, then bump the `?v=` cache key on the image in `README.md`.
|
|
123
|
+
|
|
124
|
+
## The browser playground
|
|
125
|
+
|
|
126
|
+
`docs/index.html` runs the real rules through `docs/playground/rhythmguard.js`, an esbuild bundle of `src/` with a Stylelint shim and no-op Node shims (`scripts/playground/`). Rebuild it after any change under `src/` that the rules use: `npm run build:playground`, and commit the file. `test/contracts/playground.test.js` rebuilds the bundle and fails when the committed one is stale, and checks that the page's sample gets the same warnings from the bundle and from Stylelint.
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=
|
|
2
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=12" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
|
|
8
8
|
|
|
9
9
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
|
@@ -18,10 +18,16 @@ What it is not: it does not check colors or hex values, and the Stylelint rules
|
|
|
18
18
|
## Start here
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
npx rhythmguard
|
|
21
|
+
npx stylelint-plugin-rhythmguard
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
No install, no config. It detects your stack and token files, infers your spacing scale from your own tokens, audits the current directory, and prints the exact `.stylelintrc.json` (and ESLint snippet for Tailwind) to paste.
|
|
24
|
+
No install, no config. It detects your stack and token files, infers your spacing scale from your own tokens, audits the current directory, and prints the exact `.stylelintrc.json` (and ESLint snippet for Tailwind) to paste. This is the whole run on Bootstrap's `v6-dev` branch, unedited:
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/quickstart.gif?v=1" width="100%" alt="Terminal recording: npx stylelint-plugin-rhythmguard on Bootstrap v6-dev detects the stack, infers the 13-step spacing scale from scss/_config.scss, reports 20 off-scale values in CSS and prints a .stylelintrc.json to paste" />
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
Then:
|
|
25
31
|
|
|
26
32
|
```bash
|
|
27
33
|
npm install --save-dev stylelint stylelint-plugin-rhythmguard
|
|
@@ -41,7 +47,7 @@ That enables `rhythmguard/use-scale` on spacing properties with the default 4px
|
|
|
41
47
|
}
|
|
42
48
|
```
|
|
43
49
|
|
|
44
|
-
For class strings in JSX, TSX, Vue, Svelte or Astro, add the ESLint companion:
|
|
50
|
+
For class strings in JSX, TSX, Vue, Svelte or Astro, add the ESLint companion. It is the same rules under two names: `stylelint-plugin-rhythmguard/eslint` if you already have this package, or [`eslint-plugin-rhythmguard`](packages/eslint-plugin-rhythmguard#readme) on its own.
|
|
45
51
|
|
|
46
52
|
```js
|
|
47
53
|
// eslint.config.js
|
|
@@ -81,7 +87,7 @@ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
|
|
|
81
87
|
npx rhythmguard audit ./src --format github
|
|
82
88
|
```
|
|
83
89
|
|
|
84
|
-
The audit scans CSS declarations, Tailwind class strings and your token contract, prints a scale-cleanliness score, and supports baselines so legacy codebases can gate only new drift. Formats: text, Markdown, JSON 2.0, HTML, GitHub Actions annotations, and a shields.io badge document for your README. Full reference in [docs/AUDIT.md](docs/AUDIT.md), rollout recipe and the badge workflow in [docs/CI_ADOPTION.md](docs/CI_ADOPTION.md).
|
|
90
|
+
The audit scans CSS declarations, Tailwind class strings and your token contract, prints a scale-cleanliness score, and supports baselines so legacy codebases can gate only new drift. Formats: text, Markdown, JSON 2.0, HTML, GitHub Actions annotations, and a shields.io badge document for your README. Full reference in [docs/AUDIT.md](docs/AUDIT.md), rollout recipe and the badge workflow in [docs/CI_ADOPTION.md](docs/CI_ADOPTION.md). In GitHub Actions, [`PetriLahdelma/rhythmguard-action@v1`](https://github.com/PetriLahdelma/rhythmguard-action) runs the audit, annotates the diff, comments on the pull request and fails on new drift in one step.
|
|
85
91
|
|
|
86
92
|
`npx rhythmguard audit ./src --plan` turns the report into a proposed `decisions` section (adopt a value, allow it, or snap it), and `npx rhythmguard fix ./src --value 10px --to "var(--space-sm)" --write` executes one decision as one reviewable change; see [decisions](docs/AUDIT.md#decisions). `npx rhythmguard init` writes a starter config for your stack, and `init --agents all` installs the coding-agent instructions. `npx rhythmguard doctor` checks the setup.
|
|
87
93
|
|
|
@@ -95,9 +101,10 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
|
|
|
95
101
|
- [For coding agents](docs/FOR_AGENTS.md): a paste-ready `AGENTS.md` block, installable with `npx rhythmguard init --agents all` for Claude Code, Cursor and Copilot
|
|
96
102
|
- [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
|
|
97
103
|
- [State of Spacing](docs/STATE_OF_SPACING.md): dated editions of the same data, ranked by drift density, with the values and properties that drifted
|
|
104
|
+
- [Agent evals](docs/AGENT_EVALS.md): does a finding get a coding agent to zero drift, in how many rounds, at what cost, against a rules-only control
|
|
98
105
|
- [Architecture](docs/ARCHITECTURE.md): the layers, the rule kit, the invariants and where each is enforced
|
|
99
106
|
- [Product direction](docs/STRATEGY_2026-09.md)
|
|
100
|
-
- Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)
|
|
107
|
+
- Browser playground: [petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/) runs the real rules, bundled for the browser, on CSS or SCSS you paste; `scale: "auto"`, tokens and fixes included, and a test proves it reports what Stylelint reports
|
|
101
108
|
|
|
102
109
|
## Compatibility
|
|
103
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"rhythmguard": "src/cli/index.js"
|
|
@@ -62,6 +62,11 @@
|
|
|
62
62
|
"require": "./src/audit/index.js",
|
|
63
63
|
"import": "./src/audit/index.mjs"
|
|
64
64
|
},
|
|
65
|
+
"./cli": {
|
|
66
|
+
"types": "./types/cli.d.ts",
|
|
67
|
+
"require": "./src/cli/index.js",
|
|
68
|
+
"import": "./src/cli/index.mjs"
|
|
69
|
+
},
|
|
65
70
|
"./rules/use-scale": {
|
|
66
71
|
"types": "./types/rule.d.ts",
|
|
67
72
|
"require": "./src/rules/use-scale/index.js",
|
|
@@ -117,7 +122,9 @@
|
|
|
117
122
|
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
118
123
|
"build:agents": "node scripts/build-agents.mjs",
|
|
119
124
|
"bench:state-of-spacing": "node scripts/bench/state-of-spacing.mjs",
|
|
120
|
-
"bench:outreach": "node scripts/bench/outreach.mjs"
|
|
125
|
+
"bench:outreach": "node scripts/bench/outreach.mjs",
|
|
126
|
+
"build:playground": "node scripts/build-playground.mjs",
|
|
127
|
+
"bench:agents": "node scripts/bench/agents/run.mjs"
|
|
121
128
|
},
|
|
122
129
|
"repository": {
|
|
123
130
|
"type": "git",
|
|
@@ -151,9 +158,11 @@
|
|
|
151
158
|
"postcss-value-parser": "^4.2.0"
|
|
152
159
|
},
|
|
153
160
|
"devDependencies": {
|
|
161
|
+
"@anthropic-ai/sdk": "^0.126.0",
|
|
154
162
|
"@eslint/js": "^9.39.5",
|
|
155
163
|
"@types/node": "^22.20.1",
|
|
156
|
-
"c8": "^
|
|
164
|
+
"c8": "^12.0.0",
|
|
165
|
+
"esbuild": "^0.25.10",
|
|
157
166
|
"eslint": "^9.39.5",
|
|
158
167
|
"postcss-scss": "^4.0.9",
|
|
159
168
|
"stylelint": "^16.15.0",
|
package/src/cli/index.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const command = process.argv[2];
|
|
5
|
-
|
|
6
4
|
const HELP = `Usage: rhythmguard [command]
|
|
7
5
|
|
|
8
6
|
With no command: zero-config quickstart. Detects your stack and tokens, infers
|
|
@@ -29,23 +27,34 @@ Examples:
|
|
|
29
27
|
npx rhythmguard doctor
|
|
30
28
|
`;
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
process.
|
|
30
|
+
/** Dispatch on process.argv. The `rhythmguard` bin and the `rhythmguard` alias package both call this. */
|
|
31
|
+
function main() {
|
|
32
|
+
const command = process.argv[2];
|
|
33
|
+
|
|
34
|
+
if (command === '--help' || command === '-h') {
|
|
35
|
+
process.stdout.write(HELP);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!command || command === 'quickstart') {
|
|
40
|
+
require('./quickstart').run();
|
|
41
|
+
} else if (command === 'audit') {
|
|
42
|
+
require('./audit').run();
|
|
43
|
+
} else if (command === 'fix') {
|
|
44
|
+
require('./fix').run();
|
|
45
|
+
} else if (command === 'init') {
|
|
46
|
+
require('./init').run();
|
|
47
|
+
} else if (command === 'doctor') {
|
|
48
|
+
require('./doctor');
|
|
49
|
+
} else {
|
|
50
|
+
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
51
|
+
process.stdout.write(HELP);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
35
54
|
}
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} else if (command === 'fix') {
|
|
42
|
-
require('./fix').run();
|
|
43
|
-
} else if (command === 'init') {
|
|
44
|
-
require('./init').run();
|
|
45
|
-
} else if (command === 'doctor') {
|
|
46
|
-
require('./doctor');
|
|
47
|
-
} else {
|
|
48
|
-
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
49
|
-
process.stdout.write(HELP);
|
|
50
|
-
process.exit(1);
|
|
56
|
+
module.exports = { HELP, main };
|
|
57
|
+
|
|
58
|
+
if (require.main === module) {
|
|
59
|
+
main();
|
|
51
60
|
}
|
|
@@ -413,6 +413,36 @@ function collectScssTokens(source, matchesKind) {
|
|
|
413
413
|
return tokens;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* A variable resolver over a Sass source text, for evaluating declaration
|
|
418
|
+
* values: same-file declarations first, then `fallback(name)` for a length
|
|
419
|
+
* declared elsewhere (a scaleSources file, an installed package).
|
|
420
|
+
*/
|
|
421
|
+
function createScssVariableResolver(source, fallback = () => null) {
|
|
422
|
+
const declarations = parseScssDeclarations(source || '');
|
|
423
|
+
const cache = new Map();
|
|
424
|
+
const resolveVariable = (name, stack) => {
|
|
425
|
+
if (cache.has(name)) {
|
|
426
|
+
return cache.get(name);
|
|
427
|
+
}
|
|
428
|
+
if (stack.has(name)) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
let value = null;
|
|
432
|
+
if (declarations.has(name)) {
|
|
433
|
+
stack.add(name);
|
|
434
|
+
const raw = declarations.get(name);
|
|
435
|
+
value = isScssMap(raw) ? null : evaluateScssExpression(raw, resolveVariable, stack);
|
|
436
|
+
stack.delete(name);
|
|
437
|
+
} else {
|
|
438
|
+
value = fallback(name);
|
|
439
|
+
}
|
|
440
|
+
cache.set(name, value);
|
|
441
|
+
return value;
|
|
442
|
+
};
|
|
443
|
+
return resolveVariable;
|
|
444
|
+
}
|
|
445
|
+
|
|
416
446
|
/** `$<namespace>-spacing-points`, `$<ns>-space-scale`: one namespace segment before the anchor. */
|
|
417
447
|
const NAMESPACED_SPACING_MAP = /^\$[a-z0-9]+-(?:space|spacing|spacer)s?(?:-|$)/i;
|
|
418
448
|
const MIN_NAMESPACED_MAP_LENGTHS = 4;
|
|
@@ -456,15 +486,67 @@ function parseScssDeclarations(source) {
|
|
|
456
486
|
}
|
|
457
487
|
|
|
458
488
|
startPattern.lastIndex = index;
|
|
489
|
+
const isDefault = /!default\b/.test(value);
|
|
459
490
|
const cleaned = value.replace(/!(default|global)\b/g, '').trim();
|
|
460
|
-
|
|
491
|
+
// Sass semantics at the top level: `!default` assigns only when the variable is
|
|
492
|
+
// still unset, a plain assignment always wins. Bootstrap v6 declares
|
|
493
|
+
// `$spacers: () !default;` and then `$spacers: defaults((...), $spacers);`.
|
|
494
|
+
if (cleaned && (!declarations.has(name) || !isDefault)) {
|
|
461
495
|
declarations.set(name, cleaned);
|
|
462
496
|
}
|
|
463
497
|
}
|
|
464
498
|
|
|
499
|
+
for (const [name, raw] of declarations) {
|
|
500
|
+
const unwrapped = unwrapScssMapCall(raw, declarations);
|
|
501
|
+
if (unwrapped) {
|
|
502
|
+
declarations.set(name, unwrapped);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
465
506
|
return declarations;
|
|
466
507
|
}
|
|
467
508
|
|
|
509
|
+
/**
|
|
510
|
+
* A map declared through a merge helper, read as the map it produces:
|
|
511
|
+
* `defaults((...), $spacers)` (Bootstrap v6), `map.merge($a, (...))`,
|
|
512
|
+
* `map-merge((...), (...))`. Map-literal arguments and variables that hold a
|
|
513
|
+
* map are merged left to right with later keys winning, as Sass does; anything
|
|
514
|
+
* else (an unset `!default` placeholder, an expression) contributes nothing.
|
|
515
|
+
*/
|
|
516
|
+
const SCSS_MAP_MERGE_CALL = /^(?:defaults|map\.merge|map-merge)\s*\(([\s\S]*)\)$/;
|
|
517
|
+
|
|
518
|
+
function unwrapScssMapCall(raw, declarations) {
|
|
519
|
+
const match = raw.match(SCSS_MAP_MERGE_CALL);
|
|
520
|
+
if (!match) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
const entries = new Map();
|
|
524
|
+
for (const argument of splitTopLevel(match[1], ',')) {
|
|
525
|
+
const text = argument.trim();
|
|
526
|
+
let mapLiteral = null;
|
|
527
|
+
if (isScssMap(text)) {
|
|
528
|
+
mapLiteral = text;
|
|
529
|
+
} else if (/^\$[\w-]+$/.test(text)) {
|
|
530
|
+
const referenced = declarations.get(text.slice(1));
|
|
531
|
+
if (referenced && isScssMap(referenced)) {
|
|
532
|
+
mapLiteral = referenced;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!mapLiteral) {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
for (const entry of splitTopLevel(mapLiteral.slice(1, -1), ',')) {
|
|
539
|
+
const pair = splitTopLevel(entry, ':');
|
|
540
|
+
if (pair.length < 2) continue;
|
|
541
|
+
entries.set(pair[0].trim(), pair.slice(1).join(':').trim());
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (entries.size === 0) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
return `(${[...entries].map(([key, expression]) => `${key}: ${expression}`).join(', ')})`;
|
|
548
|
+
}
|
|
549
|
+
|
|
468
550
|
function isScssMap(raw) {
|
|
469
551
|
return raw.startsWith('(') && raw.endsWith(')') && /:/.test(raw);
|
|
470
552
|
}
|
|
@@ -535,20 +617,19 @@ function tokenizeScssExpression(expression) {
|
|
|
535
617
|
}
|
|
536
618
|
return null;
|
|
537
619
|
}
|
|
620
|
+
const start = index + (match[0].length - match[0].trimStart().length);
|
|
538
621
|
index = SCSS_TOKEN_PATTERN.lastIndex;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
else if (match[
|
|
542
|
-
else tokens.push({ type: '
|
|
622
|
+
const span = { end: index, start };
|
|
623
|
+
if (match[1] !== undefined) tokens.push({ type: 'number', raw: match[1], ...span });
|
|
624
|
+
else if (match[2] !== undefined) tokens.push({ type: 'var', name: match[2].slice(1), ...span });
|
|
625
|
+
else if (match[3] !== undefined) tokens.push({ type: 'call', name: match[3], ...span });
|
|
626
|
+
else tokens.push({ type: 'op', value: match[4], ...span });
|
|
543
627
|
}
|
|
544
628
|
return tokens;
|
|
545
629
|
}
|
|
546
630
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (!tokens || tokens.length === 0) {
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
631
|
+
/** A recursive-descent parser over Sass expression tokens; `parseExpression` consumes one arithmetic expression. */
|
|
632
|
+
function createScssParser(tokens, resolveVariable, stack) {
|
|
552
633
|
let position = 0;
|
|
553
634
|
const peek = () => tokens[position];
|
|
554
635
|
const next = () => tokens[position++];
|
|
@@ -635,8 +716,46 @@ function evaluateScssExpression(expression, resolveVariable, stack) {
|
|
|
635
716
|
return value;
|
|
636
717
|
};
|
|
637
718
|
|
|
638
|
-
|
|
639
|
-
|
|
719
|
+
return {
|
|
720
|
+
get position() { return position; },
|
|
721
|
+
parseExpression,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function evaluateScssExpression(expression, resolveVariable, stack) {
|
|
726
|
+
const tokens = tokenizeScssExpression(expression.trim());
|
|
727
|
+
if (!tokens || tokens.length === 0) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
const parser = createScssParser(tokens, resolveVariable, stack);
|
|
731
|
+
const result = parser.parseExpression();
|
|
732
|
+
return parser.position === tokens.length ? result : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* A space-separated Sass value (`$spacer * .3 $spacer`) as its evaluated terms,
|
|
737
|
+
* each with the source text it came from, or null when any term cannot be
|
|
738
|
+
* evaluated (an unknown variable, a keyword, a function the evaluator does not
|
|
739
|
+
* know). A `-` between terms is read as subtraction, as Sass does.
|
|
740
|
+
*/
|
|
741
|
+
function evaluateScssValueList(value, resolveVariable, stack) {
|
|
742
|
+
const tokens = tokenizeScssExpression(value);
|
|
743
|
+
if (!tokens || tokens.length === 0) {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
const parser = createScssParser(tokens, resolveVariable, stack);
|
|
747
|
+
const terms = [];
|
|
748
|
+
while (parser.position < tokens.length) {
|
|
749
|
+
const startToken = tokens[parser.position];
|
|
750
|
+
const before = parser.position;
|
|
751
|
+
const result = parser.parseExpression();
|
|
752
|
+
if (!result || parser.position === before) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const endToken = tokens[parser.position - 1];
|
|
756
|
+
terms.push({ ...result, end: endToken.end, start: startToken.start, text: value.slice(startToken.start, endToken.end) });
|
|
757
|
+
}
|
|
758
|
+
return terms;
|
|
640
759
|
}
|
|
641
760
|
|
|
642
761
|
function formatScssValue(value) {
|
|
@@ -853,6 +972,8 @@ function formatPath(filePath) {
|
|
|
853
972
|
}
|
|
854
973
|
|
|
855
974
|
module.exports = {
|
|
975
|
+
createScssVariableResolver,
|
|
976
|
+
evaluateScssValueList,
|
|
856
977
|
VALID_TOKEN_KINDS,
|
|
857
978
|
VALID_TOKEN_SOURCE_FORMATS,
|
|
858
979
|
addDefinition,
|
|
@@ -29,6 +29,7 @@ const {
|
|
|
29
29
|
collectTokenDefinitions,
|
|
30
30
|
withResolvedScale,
|
|
31
31
|
} = require('../../core/scale-inference');
|
|
32
|
+
const { createScssVariableResolver, evaluateScssValueList } = require('../../core/token-sources');
|
|
32
33
|
|
|
33
34
|
const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
|
|
34
35
|
const { decisionFor, loadRcDecisions } = require('../../core/decisions');
|
|
@@ -59,13 +60,17 @@ function readDecisions(options, { result, root }) {
|
|
|
59
60
|
|
|
60
61
|
function checkLengthValue({
|
|
61
62
|
decl,
|
|
63
|
+
evaluated = null,
|
|
64
|
+
fixable = true,
|
|
62
65
|
node,
|
|
63
66
|
options,
|
|
64
67
|
report,
|
|
65
68
|
scaleByUnit,
|
|
66
69
|
scalePx,
|
|
67
70
|
}) {
|
|
68
|
-
|
|
71
|
+
// `evaluated` is a length that came from a Sass expression; the node's text is
|
|
72
|
+
// the expression, which is reported but never rewritten.
|
|
73
|
+
const parsedLength = evaluated || parseLengthToken(node.value);
|
|
69
74
|
|
|
70
75
|
if (!parsedLength) {
|
|
71
76
|
return false;
|
|
@@ -130,11 +135,11 @@ function checkLengthValue({
|
|
|
130
135
|
return false;
|
|
131
136
|
}
|
|
132
137
|
|
|
133
|
-
const fixedValue = options.fixToScale
|
|
138
|
+
const fixedValue = options.fixToScale && fixable
|
|
134
139
|
? replacementFor(parsedLength, nearest.nearest, options)
|
|
135
140
|
: null;
|
|
136
141
|
|
|
137
|
-
report(node.value, decl, node, nearest, fixedValue, unit);
|
|
142
|
+
report(node.value, decl, node, nearest, fixedValue, unit, evaluated ? evaluatedNote(evaluated, options) : '');
|
|
138
143
|
return true;
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -154,11 +159,58 @@ function checkLengthValue({
|
|
|
154
159
|
return false;
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
const fixedValue = options.fixToScale
|
|
162
|
+
const fixedValue = options.fixToScale && fixable
|
|
158
163
|
? replacementFor(parsedLength, nearest.nearest, options)
|
|
159
164
|
: null;
|
|
160
165
|
|
|
161
|
-
report(node.value, decl, node, nearest, fixedValue, 'px');
|
|
166
|
+
report(node.value, decl, node, nearest, fixedValue, 'px', evaluated ? evaluatedNote(evaluated, options) : '');
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What a Sass expression came to, so the reader sees the number the scale was checked against. */
|
|
171
|
+
function evaluatedNote(evaluated, options) {
|
|
172
|
+
const px = toPx(evaluated.number, evaluated.unit, options.baseFontSize);
|
|
173
|
+
return px === null ? '' : `Evaluates to ${formatLength(px, 'px')}.`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Sass values (`$spacer * .3`, `math.div($spacer, 2)`, `$y $x`) are evaluated
|
|
178
|
+
* with the file's own variables first and the project's spacing tokens second,
|
|
179
|
+
* and each term is checked like a literal. Terms that do not resolve are left
|
|
180
|
+
* alone, as they always were.
|
|
181
|
+
*/
|
|
182
|
+
function checkSassValue({ decl, options, report, resolveVariable, scaleByUnit, scalePx }) {
|
|
183
|
+
const terms = evaluateScssValueList(decl.value, resolveVariable, new Set());
|
|
184
|
+
if (!terms) {
|
|
185
|
+
// Not fully evaluable (an unknown variable, interpolation, a keyword): the
|
|
186
|
+
// literal walk below still checks the plain lengths in the value.
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// Right to left, so a fix spliced into an earlier term never shifts the spans
|
|
190
|
+
// of the terms still to be checked. Stylelint orders the warnings by position.
|
|
191
|
+
for (const term of [...terms].reverse()) {
|
|
192
|
+
if (!term.unit || term.number === 0) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const node = {
|
|
196
|
+
sourceIndex: term.start,
|
|
197
|
+
type: 'word',
|
|
198
|
+
get value() { return term.text; },
|
|
199
|
+
// The fix writes into the declaration at the term's span.
|
|
200
|
+
set value(replacement) { decl.value = `${decl.value.slice(0, term.start)}${replacement}${decl.value.slice(term.end)}`; },
|
|
201
|
+
};
|
|
202
|
+
checkLengthValue({
|
|
203
|
+
decl,
|
|
204
|
+
evaluated: { number: term.number, unit: term.unit },
|
|
205
|
+
// A term that is a plain literal keeps its autofix; an expression is only reported.
|
|
206
|
+
fixable: parseLengthToken(term.text) !== null,
|
|
207
|
+
node,
|
|
208
|
+
options,
|
|
209
|
+
report,
|
|
210
|
+
scaleByUnit,
|
|
211
|
+
scalePx,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
162
214
|
return true;
|
|
163
215
|
}
|
|
164
216
|
|
|
@@ -195,13 +247,13 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
195
247
|
let fallbackNote = autoScaleFallbackNote(options.scaleInference);
|
|
196
248
|
const getScaleStateForProperty = createPropertyScaleResolver(options);
|
|
197
249
|
|
|
198
|
-
const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px') => {
|
|
250
|
+
const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '') => {
|
|
199
251
|
const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
|
|
200
252
|
const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
|
|
201
253
|
const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
|
|
202
254
|
reportValueNode({
|
|
203
255
|
decl,
|
|
204
|
-
message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, options.note].filter(Boolean).join(' ')),
|
|
256
|
+
message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' ')),
|
|
205
257
|
node,
|
|
206
258
|
replacement: fixedValue,
|
|
207
259
|
result,
|
|
@@ -210,9 +262,24 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
210
262
|
fallbackNote = '';
|
|
211
263
|
};
|
|
212
264
|
|
|
265
|
+
// Built on the first Sass value seen, so plain CSS pays nothing.
|
|
266
|
+
let resolveSassVariable = null;
|
|
267
|
+
const sassResolver = () => {
|
|
268
|
+
if (!resolveSassVariable) {
|
|
269
|
+
const definitions = collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex });
|
|
270
|
+
resolveSassVariable = createScssVariableResolver(root.source && root.source.input ? root.source.input.css : '', (name) => {
|
|
271
|
+
const definition = definitions.get(`$${name}`);
|
|
272
|
+
const first = definition ? [...definition.values][0] : null;
|
|
273
|
+
const parsed = first ? parseLengthToken(String(first).trim()) : null;
|
|
274
|
+
return parsed && parsed.unit ? { number: parsed.number, unit: parsed.unit } : null;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return resolveSassVariable;
|
|
278
|
+
};
|
|
279
|
+
|
|
213
280
|
root.walkDecls((decl) => {
|
|
214
281
|
const prop = decl.prop.toLowerCase();
|
|
215
|
-
if (prop.startsWith('--')) {
|
|
282
|
+
if (prop.startsWith('--') || prop.startsWith('$')) {
|
|
216
283
|
return;
|
|
217
284
|
}
|
|
218
285
|
|
|
@@ -221,6 +288,11 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
221
288
|
}
|
|
222
289
|
|
|
223
290
|
const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
|
|
291
|
+
|
|
292
|
+
if (decl.value.includes('$') && checkSassValue({ decl, options, report, resolveVariable: sassResolver(), scaleByUnit, scalePx })) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
224
296
|
const parsed = valueParser(decl.value);
|
|
225
297
|
let changed = false;
|
|
226
298
|
|
package/types/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The usage text printed by `rhythmguard --help`. */
|
|
2
|
+
export const HELP: string;
|
|
3
|
+
|
|
4
|
+
/** Run the rhythmguard CLI against `process.argv`. Exits the process on completion or error. */
|
|
5
|
+
export function main(): void;
|
|
6
|
+
|
|
7
|
+
declare const cli: { HELP: string; main: typeof main };
|
|
8
|
+
|
|
9
|
+
export default cli;
|