stylelint-plugin-rhythmguard 3.7.1 → 3.9.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 +45 -0
- package/CONTRIBUTING.md +19 -6
- package/README.md +24 -10
- package/package.json +15 -6
- package/src/audit/config.js +1 -1
- package/src/audit/contract.js +3 -1
- package/src/audit/render-markdown.js +1 -0
- package/src/audit/render-text.js +1 -0
- package/src/audit/report.js +1 -0
- package/src/audit/scan/files.js +2 -2
- package/src/audit/scan/stylesheets.js +8 -0
- package/src/audit/scan/templates.js +45 -11
- package/src/cli/index.js +28 -19
- package/src/cli/index.mjs +8 -0
- package/src/configs/embed.js +2 -2
- package/src/core/scale-inference.js +8 -4
- package/src/core/token-index.js +66 -12
- package/src/core/token-packages.json +7 -0
- package/src/core/token-sources.js +81 -12
- package/src/rules/no-offscale-transform/index.js +9 -3
- package/src/rules/prefer-token/index.js +77 -8
- package/src/rules/use-scale/index.js +89 -11
- package/types/audit.d.ts +2 -0
- package/types/cli.d.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,51 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.9.0] - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `scale: "auto"` reads `@mozilla-protocol/tokens` when it is installed: `--spacing-xs` to `--spacing-2xl` (4 to 48px) from `dist/units/units.custom-properties.css`. Mozilla Protocol's benchmark row is measured against that package at 7.0.0, as a Protocol contributor pointed out in mozilla/protocol#1132: 19 off-scale values, against 18 on the `rhythmic-4` fallback, because 12px is not a Protocol step and 48px is.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `prefer-token` reads the token sources `use-scale` reads for `fixWith: "token"`: the linted stylesheet, `scaleSources`, the `audit.tokenSources` files in `.rhythmguardrc.json` and installed token packages ([#202](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/202)). Before, a token declared in a config token source was invisible to `prefer-token`, which then said no token held a value that one did; on one consumer, 478 of 611 baselined warnings were values an existing token holds. Both rules now resolve tokens through one function, so a token is known to both or to neither, and a contract test holds them to it. The fix writes such a token only when exactly one holds the value in the literal's unit, the same rule `fixWith: "token"` follows.
|
|
18
|
+
- Tokens are matched to the property's kind. A `tokenPattern` such as `^--space-|^--radius-` no longer lets `padding: 1rem` be pointed at, or fixed to, `--radius-xl` because it also holds 16px. Radius, typography and motion tokens serve their own properties; spacing and size tokens serve each other's. Applies to `prefer-token` and to `fixWith: "token"` in `use-scale` and `no-offscale-transform`.
|
|
19
|
+
- A rule's own `tokenPattern` applies to the token source files it reads as well as to the stylesheet, so `--radius-*` tokens in a config token source are found when the pattern asks for them.
|
|
20
|
+
- The audit no longer hangs on template files that contain an unclosed quote followed by escape-dense source. The Tailwind class-string scanner found string literals with a regex whose alternation was ambiguous on every backslash, so from an apostrophe in a comment or a quote inside a regex literal it backtracked exponentially over the `\{ \[ \(` of the next regex literal; on the Ace editor's `mode-alda.js` (10 KB, vendored by a MediaWiki skin) one `rhythmguard audit` ran for eleven hours before it was killed, and Bootswatch's audit took longer than three minutes. The finder is now a single linear pass: an escape never closes a literal, a `'` or `"` literal that reaches a line break is not a literal, a backtick literal may span lines. Both repositories audit in seconds. The benchmark gained five findings that an unclosed quote had been hiding (four in Ghost's `input-group.tsx`, one in Cal.com's `Button.tsx`).
|
|
21
|
+
- The audit no longer throws `All input files were ignored because of the ignore pattern` when a project's own `.stylelintignore` excludes every stylesheet the walk found. The files are counted as `scanned.stylelintIgnored` in the JSON contract and shown in text and Markdown output, so a clean report cannot hide that nothing was audited.
|
|
22
|
+
|
|
23
|
+
### Messages
|
|
24
|
+
|
|
25
|
+
Message text changes are listed here, so teams that key a warning baseline on message text know which warnings will reappear as resolved-plus-new pairs (see [#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)).
|
|
26
|
+
|
|
27
|
+
- `prefer-token`, when a token in a config token source, `scaleSources` or an installed token package holds the value: was `No known token holds this value; use the nearest token or add one.`, now `Use var(--token) (path/to/tokens.css).` When a token in another unit holds it, the message names that token and the fix leaves the literal.
|
|
28
|
+
- `prefer-token`, when several tokens hold the value: was `No known token holds this value; ...`, now `Use one of the tokens that hold it: var(--a) or var(--b).`
|
|
29
|
+
- Unchanged: the message for a value no token holds, and every `use-scale` message.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- The post-publish smoke waits for the version through `npm view --prefer-online`, the client that will install it, instead of the raw packument; for 3.8.0 the packument listed the version a minute before `npm install` could resolve it and the smoke failed.
|
|
34
|
+
- The quiet benchmark installs the token packages maintainers named as the home of their scale. A manifest row lists them under `tokenPackages` with a pinned version; `npm run bench:quiet` installs them once under `benchmarks/quiet/packages/` with install scripts disabled and passes the allowlisted token files to the audit as explicit sources, so the row is measured against the ladder the maintainers pointed at instead of the `rhythmic-4` fallback or a set of consumer tokens. Carbon (`@carbon/layout`), Primer CSS and Primer React (`@primer/primitives`), mittwald Flow (`@mittwald/flow-design-tokens`) and AdminLTE (`bootstrap`) are measured this way; the SAP rows stay unmeasured because their theming package carries no ladder. The State of Spacing edition marks these rows `installed` and counts them.
|
|
35
|
+
- PatternFly's fourteen benchmark findings are labelled by review: four in a vendored Font Awesome partial and ten in documentation example CSS, none in component SCSS, as the reply in patternfly/patternfly#8579 said. Per-repo labels live in `benchmarks/quiet/labels/`.
|
|
36
|
+
- New README banner for 3.8.0. GitHub shows the transparent SVG; npmjs.com, which renders the README on a white page, gets the non-transparent PNG (`assets/rhythmguard-banner.png`): the release job swaps the banner line with `scripts/ci/npm-readme.mjs` just before `npm publish`, and a contract test fails if the banner line is not found exactly once. The npm page changes with the next published version.
|
|
37
|
+
- `rhythmguard` and `eslint-plugin-rhythmguard` are on npm (3.8.0, first published by hand on 2026-09-27). `npx rhythmguard` now works in a project that has installed nothing, and the README's first command and ESLint setup use the two packages. Later releases publish them from `release.yml` once each has a trusted publisher and `PUBLISH_COMPANIONS` is set.
|
|
38
|
+
|
|
39
|
+
## [3.8.0] - 2026-09-18
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `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))
|
|
44
|
+
- 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))
|
|
45
|
+
- `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.
|
|
46
|
+
- [`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.
|
|
47
|
+
- `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()`.
|
|
48
|
+
- `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.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- 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.
|
|
53
|
+
|
|
9
54
|
## [3.7.1] - 2026-09-17
|
|
10
55
|
|
|
11
56
|
### Fixed
|
package/CONTRIBUTING.md
CHANGED
|
@@ -6,7 +6,7 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
|
|
|
6
6
|
|
|
7
7
|
**Report a false positive.** A finding your team considers wrong is the most valuable report we get. It is how percentages and hairlines stopped being findings. Open a [false-positive report](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/new?template=false-positive.yml) with the CSS or class string and your config. No fix required.
|
|
8
8
|
|
|
9
|
-
**Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions. The same results feed the dated [State of Spacing](./docs/STATE_OF_SPACING.md) editions, so every repository you add widens that report.
|
|
9
|
+
**Add a repository to the quiet benchmark.** The benchmark audits public design systems and fails CI when the findings change. Adding a repo is one JSON entry in `benchmarks/quiet/repos.json` plus `npm run bench:quiet -- --only <name>` to create its snapshot. Systems with tokens in unusual places are the most useful additions. When the scale ships in an npm package rather than in the repository, list it under `tokenPackages` with a pinned version (the package must be in `src/core/token-packages.json`); the bench installs it and measures the row against that ladder. The same results feed the dated [State of Spacing](./docs/STATE_OF_SPACING.md) editions, so every repository you add widens that report.
|
|
10
10
|
|
|
11
11
|
**Review a drift list.** Each benchmark repo has a list of findings that heuristics call real drift. Labelling them as real or as an allowance in `benchmarks/quiet/labels/<repo>.json` with a one-line reason turns a heuristic number into a reviewed one, and any systematic pattern you find becomes a candidate default.
|
|
12
12
|
|
|
@@ -16,6 +16,8 @@ Thank you for being here. Rhythmguard is a small, opinionated tool with one job:
|
|
|
16
16
|
|
|
17
17
|
**Contribute a community scale.** JSON files in `scales/community/`, scaffolded with `npm run scales:add -- --name my-team-scale --base 8 --steps 0,4,8,12,16,24,32` and checked with `npm run scales:validate`. Spec and policy in [`docs/COMMUNITY_SCALES.md`](./docs/COMMUNITY_SCALES.md).
|
|
18
18
|
|
|
19
|
+
The plan for the next nine months is in [`ROADMAP.md`](./ROADMAP.md), and every item there is an issue labelled [`roadmap`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/roadmap) with a milestone. Items labelled `decision` are open questions with the evidence that will settle them; a comment with evidence is a contribution.
|
|
20
|
+
|
|
19
21
|
Issues labelled [`good first issue`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/good%20first%20issue) are scoped for a first PR. [`help wanted`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/labels/help%20wanted) marks work the maintainer would like a second pair of hands on. If you want to work on something larger, open an issue first so we can agree the shape before you write code.
|
|
20
22
|
|
|
21
23
|
## Getting set up
|
|
@@ -27,7 +29,7 @@ npm ci
|
|
|
27
29
|
npm test
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` through `scripts/test.mjs` and take about
|
|
32
|
+
Node 20.19 or newer. No build step; the source runs as is. Tests use `node --test` through `scripts/test.mjs` and take about fifteen seconds. They are grouped by what they prove:
|
|
31
33
|
|
|
32
34
|
| Folder | Proves | Run one |
|
|
33
35
|
| --- | --- | --- |
|
|
@@ -48,7 +50,7 @@ Run these before you push. They are what CI runs, so a green local run means a g
|
|
|
48
50
|
```bash
|
|
49
51
|
npm run lint # eslint
|
|
50
52
|
npm run typecheck # the published TypeScript declarations against a consumer file and the examples
|
|
51
|
-
npm test #
|
|
53
|
+
npm test # 300+ tests, on the Stylelint in the lockfile (17)
|
|
52
54
|
```
|
|
53
55
|
|
|
54
56
|
Optional, depending on what you touched:
|
|
@@ -59,6 +61,8 @@ npm run test:pack-smoke # pack the tarball and install it in a temp proje
|
|
|
59
61
|
npm run scales:validate # community scale JSON
|
|
60
62
|
npm run bench:quiet -- --check # findings on the benchmark repos must match snapshots
|
|
61
63
|
npm run build:agents # after editing the block in docs/FOR_AGENTS.md
|
|
64
|
+
npm run build:playground # after changing anything under src/ the rules use
|
|
65
|
+
npm run bench:agents -- --dry-run --limit 4 # the agent eval pipeline, no API key needed
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
The local gate runs on whatever Node you have installed; CI runs Node 20 and 22. Node 20.19 is the floor, so avoid features that arrived in Node 21 or later. Glob patterns for `node --test` were one such feature, and they took a release run down.
|
|
@@ -67,7 +71,7 @@ If your shell's npm registry is overridden by a corporate `.npmrc`, add `--regis
|
|
|
67
71
|
|
|
68
72
|
### Pull requests from forks
|
|
69
73
|
|
|
70
|
-
CI for this repository runs on self-hosted runners for pushes and same-repo branches. For pull requests from forks a separate job runs on GitHub-hosted runners with Node 22 and
|
|
74
|
+
CI for this repository runs on self-hosted runners for pushes and same-repo branches. For pull requests from forks a separate job runs on GitHub-hosted runners with Node 22 and the Stylelint in the lockfile (17), covering lint, typecheck and the test suite. You will see its result on your PR. The full matrix and the benchmark run after merge.
|
|
71
75
|
|
|
72
76
|
## Where things live
|
|
73
77
|
|
|
@@ -96,7 +100,8 @@ CI for this repository runs on self-hosted runners for pushes and same-repo bran
|
|
|
96
100
|
## Compatibility
|
|
97
101
|
|
|
98
102
|
- Stylelint `^16.0.0 || ^17.0.0`. The 16.0.0 floor has known autofix differences; CI runs the floor suite against it.
|
|
99
|
-
- Node `>=20.19.0
|
|
103
|
+
- Node `>=20.19.0` until 4.0, which raises it to 22.22 ([#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)).
|
|
104
|
+
- ESLint `>=8.0.0` for the companion rules; tested on 9.
|
|
100
105
|
- CommonJS and ESM entry points; every export has a declaration under `types/`.
|
|
101
106
|
- Three runtime dependencies: `known-css-properties`, `postcss` and `postcss-value-parser`. `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers and dev dependencies here. `test/contracts/architecture.test.js` fails if a runtime import is not declared.
|
|
102
107
|
|
|
@@ -106,7 +111,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
|
|
|
106
111
|
|
|
107
112
|
## Releases
|
|
108
113
|
|
|
109
|
-
Maintainer-run. Bump `package.json` and `CHANGELOG.md
|
|
114
|
+
Maintainer-run. Bump `package.json`, the two companion packages under `packages/` and `CHANGELOG.md` to the same version, 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
115
|
|
|
111
116
|
## Benchmarking performance
|
|
112
117
|
|
|
@@ -116,3 +121,11 @@ npm run bench:perf:fix
|
|
|
116
121
|
```
|
|
117
122
|
|
|
118
123
|
Compares runtime against `stylelint-scales` on a deterministic corpus. Method in [`docs/BENCHMARKING.md`](./docs/BENCHMARKING.md).
|
|
124
|
+
|
|
125
|
+
## The README recording
|
|
126
|
+
|
|
127
|
+
`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`.
|
|
128
|
+
|
|
129
|
+
## The browser playground
|
|
130
|
+
|
|
131
|
+
`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.
|
|
2
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.png?v=13" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
|
@@ -7,13 +7,13 @@
|
|
|
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
|
|
|
14
14
|
Rhythmguard is scale-aware rather than a blanket ban: values on your scale pass, values off it are reported with the two nearest steps, and tokens are only ever suggested from a map you control. It works on CSS and SCSS declarations through Stylelint and on Tailwind class strings through an ESLint companion, and it ships an audit CLI so you can measure drift and ratchet it down before enforcing anything.
|
|
15
15
|
|
|
16
|
-
What it is not: it does not check colors or hex values, and the Stylelint rules do not see Tailwind class strings (that is the separate ESLint companion below). Pair it with a color linter if you need one; do not expect one tool to do both.
|
|
16
|
+
What it is not: it does not check colors or hex values, and the Stylelint rules do not see Tailwind class strings (that is the separate ESLint companion below). It reads CSS and SCSS; Less and CSS-in-JS are not read. Pair it with a color linter if you need one; do not expect one tool to do both.
|
|
17
17
|
|
|
18
18
|
## Start here
|
|
19
19
|
|
|
@@ -21,7 +21,13 @@ What it is not: it does not check colors or hex values, and the Stylelint rules
|
|
|
21
21
|
npx 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,11 +47,15 @@ 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, [`eslint-plugin-rhythmguard`](packages/eslint-plugin-rhythmguard#readme). It is the same rules as `stylelint-plugin-rhythmguard/eslint`, so either import works. The rules also run under Oxlint's JS plugins.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install --save-dev eslint eslint-plugin-rhythmguard
|
|
54
|
+
```
|
|
45
55
|
|
|
46
56
|
```js
|
|
47
57
|
// eslint.config.js
|
|
48
|
-
import rhythmguard from '
|
|
58
|
+
import rhythmguard from 'eslint-plugin-rhythmguard';
|
|
49
59
|
|
|
50
60
|
export default [
|
|
51
61
|
{
|
|
@@ -74,6 +84,8 @@ Every rule validates its options up front. Unknown option names and wrong shapes
|
|
|
74
84
|
|
|
75
85
|
## Audit before you enforce
|
|
76
86
|
|
|
87
|
+
With the package installed, the `rhythmguard` command is available to `npx`:
|
|
88
|
+
|
|
77
89
|
```bash
|
|
78
90
|
npx rhythmguard audit ./src --format markdown
|
|
79
91
|
npx rhythmguard audit ./src --write-baseline
|
|
@@ -81,7 +93,7 @@ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
|
|
|
81
93
|
npx rhythmguard audit ./src --format github
|
|
82
94
|
```
|
|
83
95
|
|
|
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).
|
|
96
|
+
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
97
|
|
|
86
98
|
`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
99
|
|
|
@@ -95,13 +107,15 @@ The audit scans CSS declarations, Tailwind class strings and your token contract
|
|
|
95
107
|
- [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
108
|
- [Quiet benchmark](docs/QUIET_BENCHMARK.md): findings on public design systems, checked on every change
|
|
97
109
|
- [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
|
|
110
|
+
- [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. First edition (2026-10-10): every model fixed every finding in one round with or without the findings; fixing from findings cost Sonnet 5 less than half as much
|
|
98
111
|
- [Architecture](docs/ARCHITECTURE.md): the layers, the rule kit, the invariants and where each is enforced
|
|
99
|
-
- [
|
|
100
|
-
-
|
|
112
|
+
- [Roadmap](ROADMAP.md): what is planned from October 2026 to June 2027, and the checkpoint that decides the pace
|
|
113
|
+
- [Product analysis, September 2026](docs/STRATEGY_2026-09.md): the market data behind the roadmap
|
|
114
|
+
- 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
115
|
|
|
102
116
|
## Compatibility
|
|
103
117
|
|
|
104
|
-
Stylelint 16 and 17. Node 20.19 or newer. Three runtime dependencies (`known-css-properties`, `postcss`, `postcss-value-parser`); `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers. CommonJS and ESM entry points, TypeScript declarations for every export. The CI matrix runs Node 20 and 22 against Stylelint 16.0.0, 16.x and 17.x. Upgrading from 2.x: [docs/MIGRATING_TO_3.md](docs/MIGRATING_TO_3.md).
|
|
118
|
+
Stylelint 16 and 17. Node 20.19 or newer; Node 20 itself is end of life, and 4.0 raises the floor to 22.22 ([#172](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/172)). The ESLint companion needs ESLint 8 or newer, is tested on ESLint 9, and has been checked by hand on ESLint 10 and Oxlint 1.85. Three runtime dependencies (`known-css-properties`, `postcss`, `postcss-value-parser`); `postcss-scss`, `stylelint-config-tailwindcss` and `stylelint-plugin-logical-css` are optional peers. CommonJS and ESM entry points, TypeScript declarations for every export. The CI matrix runs Node 20 and 22 against Stylelint 16.0.0, 16.x and 17.x. Upgrading from 2.x: [docs/MIGRATING_TO_3.md](docs/MIGRATING_TO_3.md).
|
|
105
119
|
|
|
106
120
|
## Contributing and support
|
|
107
121
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.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,16 +158,18 @@
|
|
|
151
158
|
"postcss-value-parser": "^4.2.0"
|
|
152
159
|
},
|
|
153
160
|
"devDependencies": {
|
|
154
|
-
"@
|
|
161
|
+
"@anthropic-ai/sdk": "^0.133.0",
|
|
162
|
+
"@eslint/js": "^10.0.0",
|
|
155
163
|
"@types/node": "^22.20.1",
|
|
156
164
|
"c8": "^12.0.0",
|
|
157
|
-
"
|
|
165
|
+
"esbuild": "^0.28.0",
|
|
166
|
+
"eslint": "^10.0.0",
|
|
158
167
|
"postcss-scss": "^4.0.9",
|
|
159
|
-
"stylelint": "^
|
|
168
|
+
"stylelint": "^17.0.0",
|
|
160
169
|
"stylelint-config-tailwindcss": "^1.0.1",
|
|
161
170
|
"stylelint-plugin-logical-css": "^2.0.2",
|
|
162
171
|
"stylelint-scales": "^5.0.0",
|
|
163
|
-
"typescript": "^
|
|
172
|
+
"typescript": "^7.0.0"
|
|
164
173
|
},
|
|
165
174
|
"engines": {
|
|
166
175
|
"node": ">=20.19.0"
|
package/src/audit/config.js
CHANGED
|
@@ -45,7 +45,7 @@ function loadAuditConfig(parsed) {
|
|
|
45
45
|
try {
|
|
46
46
|
config = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
|
47
47
|
} catch (err) {
|
|
48
|
-
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}
|
|
48
|
+
throw new Error(`Invalid Rhythmguard config ${parsed.configPath}: ${err.message}`, { cause: err });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
package/src/audit/contract.js
CHANGED
|
@@ -26,7 +26,7 @@ function collectTokenContract({
|
|
|
26
26
|
const matchesKind = createTokenKindMatcher(tokenKind);
|
|
27
27
|
|
|
28
28
|
for (const filePath of cssFiles) {
|
|
29
|
-
let source
|
|
29
|
+
let source;
|
|
30
30
|
try {
|
|
31
31
|
source = fs.readFileSync(filePath, 'utf8');
|
|
32
32
|
} catch {
|
|
@@ -235,6 +235,7 @@ function buildReport({
|
|
|
235
235
|
scanScope,
|
|
236
236
|
scssFiles = 0,
|
|
237
237
|
scssSkipped = 0,
|
|
238
|
+
stylelintIgnored = 0,
|
|
238
239
|
templateFiles,
|
|
239
240
|
tailwindFindings,
|
|
240
241
|
tokenCandidateMinCount,
|
|
@@ -308,6 +309,7 @@ function buildReport({
|
|
|
308
309
|
cssFiles: cssFiles.length,
|
|
309
310
|
scssFiles,
|
|
310
311
|
scssSkipped,
|
|
312
|
+
stylelintIgnored,
|
|
311
313
|
templateFiles: templateFiles.length,
|
|
312
314
|
totalFiles,
|
|
313
315
|
},
|
|
@@ -27,6 +27,7 @@ function renderMarkdown(report) {
|
|
|
27
27
|
`| Template files scanned | ${report.templateFilesScanned} |`,
|
|
28
28
|
...(report.scanned && report.scanned.scssFiles > 0 ? [`| SCSS files scanned | ${report.scanned.scssFiles - report.scanned.scssSkipped} |`] : []),
|
|
29
29
|
...(report.scanned && report.scanned.scssSkipped > 0 ? [`| SCSS files skipped (install postcss-scss) | ${report.scanned.scssSkipped} |`] : []),
|
|
30
|
+
...(report.scanned && report.scanned.stylelintIgnored > 0 ? [`| Stylesheets ignored by .stylelintignore (not audited) | ${report.scanned.stylelintIgnored} |`] : []),
|
|
30
31
|
`| Files with issues | ${report.filesWithIssues} |`,
|
|
31
32
|
`| Total findings | ${report.totalWarnings} |`,
|
|
32
33
|
`| Scale cleanliness | ${report.scaleCleanliness}% |`,
|
package/src/audit/render-text.js
CHANGED
|
@@ -21,6 +21,7 @@ function renderText(report) {
|
|
|
21
21
|
` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
|
|
22
22
|
...(report.scanned && report.scanned.scssFiles > 0 ? [` SCSS files scanned ${String(report.scanned.scssFiles - report.scanned.scssSkipped).padStart(4)}`] : []),
|
|
23
23
|
...(report.scanned && report.scanned.scssSkipped > 0 ? [` SCSS files skipped ${String(report.scanned.scssSkipped).padStart(4)} (install postcss-scss to audit them)`] : []),
|
|
24
|
+
...(report.scanned && report.scanned.stylelintIgnored > 0 ? [` Ignored by .stylelintignore ${String(report.scanned.stylelintIgnored).padStart(4)} (not audited)`] : []),
|
|
24
25
|
` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
|
|
25
26
|
` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
|
|
26
27
|
];
|
package/src/audit/report.js
CHANGED
|
@@ -89,6 +89,7 @@ async function createAuditReport(options) {
|
|
|
89
89
|
scanScope,
|
|
90
90
|
scssFiles: cssResults.scssFiles || 0,
|
|
91
91
|
scssSkipped: cssResults.scssSkipped || 0,
|
|
92
|
+
stylelintIgnored: cssResults.stylelintIgnored || 0,
|
|
92
93
|
tailwindFindings: decided.tailwindFindings,
|
|
93
94
|
templateFiles,
|
|
94
95
|
tokenCandidateMinCount: parsed.tokenCandidateMinCount,
|
package/src/audit/scan/files.js
CHANGED
|
@@ -80,7 +80,7 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
80
80
|
const args = parsed.staged
|
|
81
81
|
? ['diff', '--name-only', '--cached', '--diff-filter=ACMR', '--']
|
|
82
82
|
: ['diff', '--name-only', '--diff-filter=ACMR', parsed.since, '--'];
|
|
83
|
-
let output
|
|
83
|
+
let output;
|
|
84
84
|
|
|
85
85
|
try {
|
|
86
86
|
output = execFileSync('git', args, {
|
|
@@ -90,7 +90,7 @@ function getGitChangedScanFiles(rootDir, ignorePatterns, parsed) {
|
|
|
90
90
|
});
|
|
91
91
|
} catch (err) {
|
|
92
92
|
const stderr = err.stderr ? String(err.stderr).trim() : err.message;
|
|
93
|
-
throw new Error(`Unable to read changed files from git: ${stderr}
|
|
93
|
+
throw new Error(`Unable to read changed files from git: ${stderr}`, { cause: err });
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const cssFiles = [];
|
|
@@ -77,7 +77,10 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
77
77
|
const results = [];
|
|
78
78
|
|
|
79
79
|
if (plainFiles.length > 0) {
|
|
80
|
+
// A project's own .stylelintignore may exclude every file we pass; that is
|
|
81
|
+
// a count to report, not a reason for the audit to throw.
|
|
80
82
|
const result = await stylelint.lint({
|
|
83
|
+
allowEmptyInput: true,
|
|
81
84
|
files: plainFiles,
|
|
82
85
|
config: {
|
|
83
86
|
plugins: [pluginPath],
|
|
@@ -92,6 +95,7 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
92
95
|
const scssSyntax = resolveScssSyntax();
|
|
93
96
|
if (scssSyntax) {
|
|
94
97
|
const result = await stylelint.lint({
|
|
98
|
+
allowEmptyInput: true,
|
|
95
99
|
files: scssFiles,
|
|
96
100
|
config: {
|
|
97
101
|
customSyntax: scssSyntax,
|
|
@@ -107,6 +111,10 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
107
111
|
|
|
108
112
|
results.scssFiles = scssFiles.length;
|
|
109
113
|
results.scssSkipped = scssSkipped;
|
|
114
|
+
// Stylelint returns no result at all for a file its ignore file excludes, so
|
|
115
|
+
// the difference between what was passed and what came back is that count.
|
|
116
|
+
const linted = plainFiles.length + scssFiles.length - scssSkipped;
|
|
117
|
+
results.stylelintIgnored = Math.max(0, linted - results.length);
|
|
110
118
|
return results;
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -18,7 +18,7 @@ function collectTailwindFindings(templateFiles, options) {
|
|
|
18
18
|
const findings = [];
|
|
19
19
|
|
|
20
20
|
for (const filePath of templateFiles) {
|
|
21
|
-
let source
|
|
21
|
+
let source;
|
|
22
22
|
try {
|
|
23
23
|
source = fs.readFileSync(filePath, 'utf8');
|
|
24
24
|
} catch {
|
|
@@ -64,7 +64,7 @@ function collectTailwindMotionFindings(templateFiles, options) {
|
|
|
64
64
|
const findings = [];
|
|
65
65
|
|
|
66
66
|
for (const filePath of templateFiles) {
|
|
67
|
-
let source
|
|
67
|
+
let source;
|
|
68
68
|
try {
|
|
69
69
|
source = fs.readFileSync(filePath, 'utf8');
|
|
70
70
|
} catch {
|
|
@@ -115,17 +115,51 @@ function buildTailwindMotionFindingText(token, analysis) {
|
|
|
115
115
|
return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Linear scan for string literals. A regex did this before, and from every
|
|
120
|
+
* quote that never closes (an apostrophe in a comment, a quote inside a regex
|
|
121
|
+
* literal) it backtracked to the end of the file; on a 3.5 MB minified editor
|
|
122
|
+
* bundle that took hours. This walks the source once: an escape never closes a
|
|
123
|
+
* literal, a ' or " literal that reaches a line break is not a literal (the
|
|
124
|
+
* language agrees), and a backtick literal may span lines.
|
|
125
|
+
*/
|
|
118
126
|
function findStringLiterals(source) {
|
|
119
127
|
const literals = [];
|
|
120
|
-
const
|
|
121
|
-
let
|
|
122
|
-
|
|
123
|
-
while (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
128
|
+
const { length } = source;
|
|
129
|
+
let index = 0;
|
|
130
|
+
|
|
131
|
+
while (index < length) {
|
|
132
|
+
const quote = source[index];
|
|
133
|
+
if (quote !== '"' && quote !== "'" && quote !== '`') {
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const valueStart = index + 1;
|
|
139
|
+
let cursor = valueStart;
|
|
140
|
+
let closed = false;
|
|
141
|
+
while (cursor < length) {
|
|
142
|
+
const char = source[cursor];
|
|
143
|
+
if (char === '\\') {
|
|
144
|
+
cursor += 2;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (char === quote) {
|
|
148
|
+
closed = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (quote !== '`' && char === '\n') {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
cursor += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (closed) {
|
|
158
|
+
literals.push({ quote, value: source.slice(valueStart, cursor), valueStart });
|
|
159
|
+
index = cursor + 1;
|
|
160
|
+
} else {
|
|
161
|
+
index = valueStart;
|
|
162
|
+
}
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
return literals;
|
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
|
}
|
package/src/configs/embed.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* spacing tokens (scaleSources, .rhythmguardrc.json token sources, the
|
|
8
8
|
* stylesheet's custom properties, a Tailwind config, then rhythmic-4).
|
|
9
9
|
* No `extends`, no dependency beyond this plugin, and the shape is frozen for
|
|
10
|
-
* the life of
|
|
11
|
-
* on a minor bump.
|
|
10
|
+
* the life of a major version so a config that embeds it never changes
|
|
11
|
+
* behaviour on a minor bump.
|
|
12
12
|
*/
|
|
13
13
|
module.exports = {
|
|
14
14
|
plugins: ['stylelint-plugin-rhythmguard'],
|
|
@@ -201,12 +201,16 @@ function scaleFromSources(sources, baseFontSize) {
|
|
|
201
201
|
* `.rhythmguardrc.json` token sources and installed token packages. The same
|
|
202
202
|
* places scale inference reads, in the same order.
|
|
203
203
|
*/
|
|
204
|
-
function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenRegex }) {
|
|
204
|
+
function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenPattern = null, tokenRegex }) {
|
|
205
205
|
const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
|
|
206
206
|
const sources = [...scaleSources, ...rcTokenSources(process.cwd()), ...discoverTokenPackages(process.cwd())];
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
// A rule's own tokenPattern (`^--space-|^--radius-`) also applies to the
|
|
208
|
+
// files it reads, on top of the spacing names, unless a source sets its own.
|
|
209
|
+
const withPattern = tokenPattern
|
|
210
|
+
? sources.map((source) => (typeof source === 'string' ? { path: source, tokenPattern } : { tokenPattern, ...source }))
|
|
211
|
+
: [];
|
|
212
|
+
for (const parsed of [parseSourcesCached(sources, baseFontSize), parseSourcesCached(withPattern, baseFontSize)]) {
|
|
213
|
+
for (const [token, definition] of parsed ? parsed.definitions : []) {
|
|
210
214
|
if (!definitions.has(token)) {
|
|
211
215
|
definitions.set(token, definition);
|
|
212
216
|
}
|
package/src/core/token-index.js
CHANGED
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { PROPERTY_GROUP_PATTERNS } = require('./css-vocabulary');
|
|
3
4
|
const { fixedLengthValue, numbersEqual, toPx } = require('./length');
|
|
4
|
-
const { parseTokenValueLength } = require('./token-sources');
|
|
5
|
+
const { createTokenKindMatcher, parseTokenValueLength } = require('./token-sources');
|
|
6
|
+
|
|
7
|
+
// Dedicated kinds first: a name like --radius-space-1 is a radius token.
|
|
8
|
+
const TOKEN_KINDS = ['radius', 'typography', 'motion', 'size', 'spacing'].map((kind) => [kind, createTokenKindMatcher(kind)]);
|
|
9
|
+
|
|
10
|
+
// Which token kinds may stand in for a property of each group. Spacing and size
|
|
11
|
+
// share tokens in many systems (Open Props --size-*, mittwald --size-px--*);
|
|
12
|
+
// radius, type and motion tokens serve only their own properties.
|
|
13
|
+
const ACCEPTED_KINDS = Object.freeze({
|
|
14
|
+
motion: ['motion'],
|
|
15
|
+
radius: ['radius'],
|
|
16
|
+
size: ['size', 'spacing'],
|
|
17
|
+
spacing: ['spacing', 'size'],
|
|
18
|
+
typography: ['typography'],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The kind a token's name declares, or null when the name says nothing. */
|
|
22
|
+
function tokenKindOf(token) {
|
|
23
|
+
const found = TOKEN_KINDS.find(([, matches]) => matches(token));
|
|
24
|
+
return found ? found[0] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function propertyGroupOf(prop) {
|
|
28
|
+
const lower = String(prop || '').toLowerCase();
|
|
29
|
+
const found = Object.entries(PROPERTY_GROUP_PATTERNS).find(([, patterns]) => patterns.some((pattern) => pattern.test(lower)));
|
|
30
|
+
return found ? found[0] : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether a token may be offered for a property: a padding never gets a radius
|
|
35
|
+
* token, even when both hold 16px and the token pattern admits both. A token
|
|
36
|
+
* whose name declares no kind, or a property outside every group, is allowed.
|
|
37
|
+
*/
|
|
38
|
+
function tokenFitsProperty(token, prop) {
|
|
39
|
+
const group = prop ? propertyGroupOf(prop) : null;
|
|
40
|
+
const kind = group ? tokenKindOf(String(token).replace(/^var\(\s*|\s*\)$/g, '')) : null;
|
|
41
|
+
return !group || !kind || ACCEPTED_KINDS[group].includes(kind);
|
|
42
|
+
}
|
|
5
43
|
|
|
6
44
|
/**
|
|
7
45
|
* Which token a fix may write for a length. Strict on purpose: the token must
|
|
@@ -19,24 +57,37 @@ function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
|
|
|
19
57
|
const unit = parsed.unit || 'px';
|
|
20
58
|
const px = toPx(Math.abs(parsed.number), unit, baseFontSize);
|
|
21
59
|
if (px === null || !Number.isFinite(px) || px === 0) continue;
|
|
22
|
-
|
|
60
|
+
const file = definition.files ? [...definition.files][0] : null;
|
|
61
|
+
index.push({ file: file && file !== 'stylesheet' ? file : null, px, raw: String(raw).trim(), token: definition.token, unit });
|
|
23
62
|
}
|
|
24
63
|
}
|
|
25
64
|
return index;
|
|
26
65
|
}
|
|
27
66
|
|
|
28
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Every custom property that holds a length, for one property. The shared
|
|
69
|
+
* resolver behind use-scale's `fixWith: "token"` and prefer-token, so the two
|
|
70
|
+
* rules always know the same tokens. Same unit first; with `anyUnit`, a token
|
|
71
|
+
* in another unit that holds the same px value counts when none matches the
|
|
72
|
+
* unit. Sorted, so messages are stable.
|
|
73
|
+
*/
|
|
74
|
+
function tokensForLength(index, px, unit, { anyUnit = false, prop = null } = {}) {
|
|
29
75
|
const wanted = unit || 'px';
|
|
30
|
-
const
|
|
76
|
+
const sameUnit = new Set();
|
|
77
|
+
const otherUnit = new Set();
|
|
31
78
|
for (const entry of index) {
|
|
32
|
-
if (entry.token.startsWith('--')
|
|
33
|
-
|
|
79
|
+
if (!entry.token.startsWith('--') || !numbersEqual(entry.px, Math.abs(px)) || !tokenFitsProperty(entry.token, prop)) {
|
|
80
|
+
continue;
|
|
34
81
|
}
|
|
82
|
+
(entry.unit === wanted ? sameUnit : otherUnit).add(entry.token);
|
|
35
83
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
84
|
+
const found = sameUnit.size > 0 || !anyUnit ? sameUnit : otherUnit;
|
|
85
|
+
return [...found].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tokenForLength(index, px, unit, prop = null) {
|
|
89
|
+
const candidates = tokensForLength(index, px, unit, { prop });
|
|
90
|
+
return candidates.length === 1 ? `var(${candidates[0]})` : null;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
93
|
/** A negative token reference that is still valid where it is written. */
|
|
@@ -54,9 +105,9 @@ function negateReplacement(replacement) {
|
|
|
54
105
|
* The replacement text a length rule writes: the matching token when the
|
|
55
106
|
* options ask for one and exactly one qualifies, otherwise the literal.
|
|
56
107
|
*/
|
|
57
|
-
function replacementFor(parsedLength, nearestPx, options) {
|
|
108
|
+
function replacementFor(parsedLength, nearestPx, options, prop = null) {
|
|
58
109
|
if (options.fixWith === 'token' && options.tokenIndex) {
|
|
59
|
-
const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px');
|
|
110
|
+
const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px', prop);
|
|
60
111
|
if (token) {
|
|
61
112
|
return parsedLength.number < 0 ? negateReplacement(token) : token;
|
|
62
113
|
}
|
|
@@ -76,7 +127,10 @@ function tokenHoldsNote(replacement, formattedNearest) {
|
|
|
76
127
|
module.exports = {
|
|
77
128
|
negateReplacement,
|
|
78
129
|
replacementFor,
|
|
130
|
+
tokenFitsProperty,
|
|
79
131
|
tokenHoldsNote,
|
|
80
132
|
tokenForLength,
|
|
81
133
|
tokenIndexFromDefinitions,
|
|
134
|
+
tokenKindOf,
|
|
135
|
+
tokensForLength,
|
|
82
136
|
};
|
|
@@ -86,6 +86,13 @@
|
|
|
86
86
|
"dist/nhsuk/core/settings/_spacing.scss"
|
|
87
87
|
],
|
|
88
88
|
"note": "$nhsuk-spacing-points map"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "@mozilla-protocol/tokens",
|
|
92
|
+
"files": [
|
|
93
|
+
"dist/units/units.custom-properties.css"
|
|
94
|
+
],
|
|
95
|
+
"note": "--spacing-xs..2xl in px; Protocol's Sass $spacing-* come from the same package"
|
|
89
96
|
}
|
|
90
97
|
]
|
|
91
98
|
}
|
|
@@ -123,7 +123,7 @@ function parseTokenSources({
|
|
|
123
123
|
continue;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
let tokens
|
|
126
|
+
let tokens;
|
|
127
127
|
const matchesSource = normalizedSource.tokenPattern
|
|
128
128
|
? createPatternMatcher(normalizedSource.tokenPattern, matchesKind)
|
|
129
129
|
: matchesKind;
|
|
@@ -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;
|
|
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;
|
|
@@ -587,20 +617,19 @@ function tokenizeScssExpression(expression) {
|
|
|
587
617
|
}
|
|
588
618
|
return null;
|
|
589
619
|
}
|
|
620
|
+
const start = index + (match[0].length - match[0].trimStart().length);
|
|
590
621
|
index = SCSS_TOKEN_PATTERN.lastIndex;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
else if (match[
|
|
594
|
-
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 });
|
|
595
627
|
}
|
|
596
628
|
return tokens;
|
|
597
629
|
}
|
|
598
630
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (!tokens || tokens.length === 0) {
|
|
602
|
-
return null;
|
|
603
|
-
}
|
|
631
|
+
/** A recursive-descent parser over Sass expression tokens; `parseExpression` consumes one arithmetic expression. */
|
|
632
|
+
function createScssParser(tokens, resolveVariable, stack) {
|
|
604
633
|
let position = 0;
|
|
605
634
|
const peek = () => tokens[position];
|
|
606
635
|
const next = () => tokens[position++];
|
|
@@ -687,8 +716,46 @@ function evaluateScssExpression(expression, resolveVariable, stack) {
|
|
|
687
716
|
return value;
|
|
688
717
|
};
|
|
689
718
|
|
|
690
|
-
|
|
691
|
-
|
|
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;
|
|
692
759
|
}
|
|
693
760
|
|
|
694
761
|
function formatScssValue(value) {
|
|
@@ -905,6 +972,8 @@ function formatPath(filePath) {
|
|
|
905
972
|
}
|
|
906
973
|
|
|
907
974
|
module.exports = {
|
|
975
|
+
createScssVariableResolver,
|
|
976
|
+
evaluateScssValueList,
|
|
908
977
|
VALID_TOKEN_KINDS,
|
|
909
978
|
VALID_TOKEN_SOURCE_FORMATS,
|
|
910
979
|
addDefinition,
|
|
@@ -69,7 +69,13 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
69
69
|
withResolvedScale(options, root);
|
|
70
70
|
if (options.fixWith === 'token') {
|
|
71
71
|
options.tokenIndex = tokenIndexFromDefinitions(
|
|
72
|
-
collectTokenDefinitions({
|
|
72
|
+
collectTokenDefinitions({
|
|
73
|
+
baseFontSize: options.baseFontSize,
|
|
74
|
+
root,
|
|
75
|
+
scaleSources: options.scaleSources,
|
|
76
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
|
|
77
|
+
tokenRegex: new RegExp(options.tokenPattern),
|
|
78
|
+
}),
|
|
73
79
|
options.baseFontSize,
|
|
74
80
|
);
|
|
75
81
|
}
|
|
@@ -157,7 +163,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
const fixedValue = options.fixToScale
|
|
160
|
-
? replacementFor(parsedLength, nearest.nearest, options)
|
|
166
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
161
167
|
: null;
|
|
162
168
|
|
|
163
169
|
report(node, nearest, unit, fixedValue);
|
|
@@ -181,7 +187,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
const fixedValue = options.fixToScale
|
|
184
|
-
? replacementFor(parsedLength, nearest.nearest, options)
|
|
190
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
185
191
|
: null;
|
|
186
192
|
|
|
187
193
|
report(node, nearest, 'px', fixedValue);
|
|
@@ -25,25 +25,40 @@ const {
|
|
|
25
25
|
const { buildEffectiveTokenMap } = require('../../core/token-map');
|
|
26
26
|
|
|
27
27
|
const {
|
|
28
|
+
collectTokenDefinitions,
|
|
28
29
|
withResolvedScale,
|
|
29
30
|
} = require('../../core/scale-inference');
|
|
30
31
|
|
|
31
32
|
const { createTokenRegex, reportInvalidPreset, reportValueNode } = require('../report');
|
|
32
33
|
const { validatePrimary, validatePreferTokenSecondaryOptions } = require('../validate');
|
|
33
34
|
|
|
34
|
-
const {
|
|
35
|
+
const {
|
|
36
|
+
negateReplacement,
|
|
37
|
+
tokenFitsProperty,
|
|
38
|
+
tokenIndexFromDefinitions,
|
|
39
|
+
tokensForLength,
|
|
40
|
+
} = require('../../core/token-index');
|
|
35
41
|
|
|
36
42
|
const ruleName = 'rhythmguard/prefer-token';
|
|
37
43
|
|
|
38
44
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
39
45
|
invalidPreset: (presetName, presetNames) =>
|
|
40
46
|
`Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
|
|
41
|
-
rejected: (value, replacement = null, origin = null, note = '') =>
|
|
47
|
+
rejected: (value, replacement = null, origin = null, note = '', candidates = []) =>
|
|
42
48
|
`Unexpected raw scale value "${value}". ${replacement
|
|
43
49
|
? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
|
|
44
|
-
:
|
|
50
|
+
: candidates.length > 1
|
|
51
|
+
? `Use one of the tokens that hold it: ${listTokens(candidates)}.`
|
|
52
|
+
: 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
|
|
45
53
|
});
|
|
46
54
|
|
|
55
|
+
function listTokens(tokens) {
|
|
56
|
+
const references = tokens.map((token) => `var(${token})`);
|
|
57
|
+
return references.length > 1
|
|
58
|
+
? `${references.slice(0, -1).join(', ')} or ${references[references.length - 1]}`
|
|
59
|
+
: references.join('');
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
function applyNegativeToken(replacement, parsedLength) {
|
|
48
63
|
return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
|
|
49
64
|
}
|
|
@@ -74,6 +89,36 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
|
|
|
74
89
|
return null;
|
|
75
90
|
}
|
|
76
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The tokens use-scale's `fixWith: "token"` would see: the stylesheet,
|
|
94
|
+
* `scaleSources`, `.rhythmguardrc.json` token sources and installed token
|
|
95
|
+
* packages, filtered to the property's kind. One same-unit token is a fix; a
|
|
96
|
+
* token in another unit, or several tokens, are named but never written.
|
|
97
|
+
*/
|
|
98
|
+
function resolveFromIndex(tokenIndex, parsedLength, prop, options) {
|
|
99
|
+
const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', options.baseFontSize);
|
|
100
|
+
if (px === null || tokenIndex.length === 0) {
|
|
101
|
+
return { candidates: [] };
|
|
102
|
+
}
|
|
103
|
+
const unit = parsedLength.unit || 'px';
|
|
104
|
+
const sameUnit = tokensForLength(tokenIndex, px, unit, { prop });
|
|
105
|
+
const candidates = sameUnit.length > 0 || options.unitStrategy !== 'convert'
|
|
106
|
+
? sameUnit
|
|
107
|
+
: tokensForLength(tokenIndex, px, unit, { anyUnit: true, prop });
|
|
108
|
+
if (candidates.length !== 1) {
|
|
109
|
+
return { candidates };
|
|
110
|
+
}
|
|
111
|
+
const token = `var(${candidates[0]})`;
|
|
112
|
+
const entry = tokenIndex.find((item) => item.token === candidates[0]);
|
|
113
|
+
return {
|
|
114
|
+
candidates,
|
|
115
|
+
fixable: sameUnit.length === 1,
|
|
116
|
+
origin: entry ? entry.file : null,
|
|
117
|
+
replacement: applyNegativeToken(token, parsedLength),
|
|
118
|
+
token,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
const ruleFunction = (primary, secondaryOptions) => {
|
|
78
123
|
return (root, result) => {
|
|
79
124
|
const valid = validatePrimary(result, ruleName, primary);
|
|
@@ -105,6 +150,17 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
105
150
|
tokenRegex,
|
|
106
151
|
});
|
|
107
152
|
|
|
153
|
+
const tokenIndex = tokenIndexFromDefinitions(
|
|
154
|
+
collectTokenDefinitions({
|
|
155
|
+
baseFontSize: options.baseFontSize,
|
|
156
|
+
root,
|
|
157
|
+
scaleSources: options.scaleSources,
|
|
158
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
|
|
159
|
+
tokenRegex,
|
|
160
|
+
}),
|
|
161
|
+
options.baseFontSize,
|
|
162
|
+
);
|
|
163
|
+
|
|
108
164
|
const getScaleStateForProperty = createPropertyScaleResolver(options);
|
|
109
165
|
|
|
110
166
|
root.walkDecls((decl) => {
|
|
@@ -121,10 +177,23 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
121
177
|
const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
|
|
122
178
|
let changed = false;
|
|
123
179
|
|
|
124
|
-
const reportNode = (node,
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
180
|
+
const reportNode = (node, parsedLength) => {
|
|
181
|
+
const mapped = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
|
|
182
|
+
if (mapped && tokenFitsProperty(mapped.token, prop)) {
|
|
183
|
+
const origin = origins[mapped.token] || null;
|
|
184
|
+
reportValueNode({ decl, message: messages.rejected(node.value, mapped.replacement, origin, options.note), node, replacement: mapped.replacement, result, ruleName });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const indexed = resolveFromIndex(tokenIndex, parsedLength, prop, options);
|
|
188
|
+
const named = indexed.replacement || null;
|
|
189
|
+
reportValueNode({
|
|
190
|
+
decl,
|
|
191
|
+
message: messages.rejected(node.value, named, indexed.origin || null, options.note, indexed.candidates),
|
|
192
|
+
node,
|
|
193
|
+
replacement: indexed.fixable ? named : null,
|
|
194
|
+
result,
|
|
195
|
+
ruleName,
|
|
196
|
+
});
|
|
128
197
|
};
|
|
129
198
|
|
|
130
199
|
const checkWordNode = (node, context) => {
|
|
@@ -190,7 +259,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
190
259
|
}
|
|
191
260
|
}
|
|
192
261
|
|
|
193
|
-
reportNode(node,
|
|
262
|
+
reportNode(node, parsedLength);
|
|
194
263
|
return true;
|
|
195
264
|
};
|
|
196
265
|
|
|
@@ -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
|
|
134
|
-
? replacementFor(parsedLength, nearest.nearest, options)
|
|
138
|
+
const fixedValue = options.fixToScale && fixable
|
|
139
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
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
|
|
158
|
-
? replacementFor(parsedLength, nearest.nearest, options)
|
|
162
|
+
const fixedValue = options.fixToScale && fixable
|
|
163
|
+
? replacementFor(parsedLength, nearest.nearest, options, decl.prop)
|
|
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
|
|
|
@@ -187,7 +239,13 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
187
239
|
const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
|
|
188
240
|
if (options.fixWith === 'token') {
|
|
189
241
|
options.tokenIndex = tokenIndexFromDefinitions(
|
|
190
|
-
collectTokenDefinitions({
|
|
242
|
+
collectTokenDefinitions({
|
|
243
|
+
baseFontSize: options.baseFontSize,
|
|
244
|
+
root,
|
|
245
|
+
scaleSources: options.scaleSources,
|
|
246
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
|
|
247
|
+
tokenRegex,
|
|
248
|
+
}),
|
|
191
249
|
options.baseFontSize,
|
|
192
250
|
);
|
|
193
251
|
}
|
|
@@ -195,13 +253,13 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
195
253
|
let fallbackNote = autoScaleFallbackNote(options.scaleInference);
|
|
196
254
|
const getScaleStateForProperty = createPropertyScaleResolver(options);
|
|
197
255
|
|
|
198
|
-
const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px') => {
|
|
256
|
+
const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '') => {
|
|
199
257
|
const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
|
|
200
258
|
const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
|
|
201
259
|
const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
|
|
202
260
|
reportValueNode({
|
|
203
261
|
decl,
|
|
204
|
-
message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, options.note].filter(Boolean).join(' ')),
|
|
262
|
+
message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' ')),
|
|
205
263
|
node,
|
|
206
264
|
replacement: fixedValue,
|
|
207
265
|
result,
|
|
@@ -210,9 +268,24 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
210
268
|
fallbackNote = '';
|
|
211
269
|
};
|
|
212
270
|
|
|
271
|
+
// Built on the first Sass value seen, so plain CSS pays nothing.
|
|
272
|
+
let resolveSassVariable = null;
|
|
273
|
+
const sassResolver = () => {
|
|
274
|
+
if (!resolveSassVariable) {
|
|
275
|
+
const definitions = collectTokenDefinitions({ baseFontSize: options.baseFontSize, root, scaleSources: options.scaleSources, tokenRegex });
|
|
276
|
+
resolveSassVariable = createScssVariableResolver(root.source && root.source.input ? root.source.input.css : '', (name) => {
|
|
277
|
+
const definition = definitions.get(`$${name}`);
|
|
278
|
+
const first = definition ? [...definition.values][0] : null;
|
|
279
|
+
const parsed = first ? parseLengthToken(String(first).trim()) : null;
|
|
280
|
+
return parsed && parsed.unit ? { number: parsed.number, unit: parsed.unit } : null;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return resolveSassVariable;
|
|
284
|
+
};
|
|
285
|
+
|
|
213
286
|
root.walkDecls((decl) => {
|
|
214
287
|
const prop = decl.prop.toLowerCase();
|
|
215
|
-
if (prop.startsWith('--')) {
|
|
288
|
+
if (prop.startsWith('--') || prop.startsWith('$')) {
|
|
216
289
|
return;
|
|
217
290
|
}
|
|
218
291
|
|
|
@@ -221,6 +294,11 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
221
294
|
}
|
|
222
295
|
|
|
223
296
|
const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
|
|
297
|
+
|
|
298
|
+
if (decl.value.includes('$') && checkSassValue({ decl, options, report, resolveVariable: sassResolver(), scaleByUnit, scalePx })) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
224
302
|
const parsed = valueParser(decl.value);
|
|
225
303
|
let changed = false;
|
|
226
304
|
|
package/types/audit.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface AuditScanned {
|
|
|
68
68
|
scssFiles?: number;
|
|
69
69
|
/** .scss files found but not audited because postcss-scss is not installed. */
|
|
70
70
|
scssSkipped?: number;
|
|
71
|
+
/** Stylesheets Stylelint skipped because the project's own .stylelintignore excludes them. */
|
|
72
|
+
stylelintIgnored?: number;
|
|
71
73
|
templateFiles: number;
|
|
72
74
|
totalFiles?: number;
|
|
73
75
|
[key: string]: unknown;
|
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;
|