stylelint-plugin-rhythmguard 1.4.2 → 1.5.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/README.md +118 -25
- package/package.json +9 -2
- package/src/cli/audit.js +161 -0
- package/src/cli/doctor.js +231 -0
- package/src/cli/index.js +38 -0
- package/src/cli/init.js +128 -0
- package/src/configs/react-tailwind.js +25 -0
- package/src/configs/react-tailwind.mjs +4 -0
- package/src/configs/tailwind.js +9 -0
- package/src/utils/token-map.js +44 -3
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg" width="100%" alt="Rhythmguard banner
|
|
2
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=3" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Token governance for CSS and Tailwind. Enforce spacing scales, require design tokens, and catch arbitrary values before they ship.
|
|
8
8
|
|
|
9
9
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
10
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
@@ -12,25 +12,48 @@ High-precision spacing governance for CSS and design systems.
|
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
[](https://nodejs.org/)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Rhythmguard enforces scale and token discipline across spacing, radius, typography, size, and motion offsets — in CSS declarations and Tailwind class strings.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Built for teams that want:
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</p>
|
|
19
|
+
- zero random spacing values in production
|
|
20
|
+
- token-first workflows with autofix migration
|
|
21
|
+
- Tailwind arbitrary value governance (`p-[13px]` → `p-[12px]`)
|
|
22
|
+
- consistent layout rhythm across components and pages
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
## Quick Start: Next.js + Tailwind
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --save-dev stylelint stylelint-plugin-rhythmguard
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**.stylelintrc.json:**
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**eslint.config.js** (for Tailwind class-string governance):
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';
|
|
26
42
|
|
|
27
|
-
|
|
43
|
+
export default [
|
|
44
|
+
{
|
|
45
|
+
plugins: { 'rhythmguard-tailwind': rhythmguard },
|
|
46
|
+
rules: {
|
|
47
|
+
'rhythmguard-tailwind/tailwind-class-use-scale': [
|
|
48
|
+
'error',
|
|
49
|
+
{ scale: [0, 4, 8, 12, 16, 24, 32] }
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
```
|
|
28
55
|
|
|
29
|
-
|
|
30
|
-
- consistent numeric scales for radius, typography, and sizing primitives
|
|
31
|
-
- token-first spacing workflows
|
|
32
|
-
- predictable autofix behavior for large migrations
|
|
33
|
-
- consistent layout rhythm across web surfaces
|
|
56
|
+
This gives you spacing governance in both CSS files and JSX/TSX templates.
|
|
34
57
|
|
|
35
58
|
## Rule Matrix
|
|
36
59
|
|
|
@@ -44,6 +67,16 @@ It is built for teams that want:
|
|
|
44
67
|
| `rhythmguard/prefer-token` | Enforces token usage over raw spacing literals | Yes, with `tokenMap` |
|
|
45
68
|
| `rhythmguard/no-offscale-transform` | Enforces scale-aligned `translate*` motion offsets | Yes, nearest safe value |
|
|
46
69
|
|
|
70
|
+
## Demo
|
|
71
|
+
|
|
72
|
+
<p align="center">
|
|
73
|
+
<a href="https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/assets/rhythmguard-campaign-60s.webm">
|
|
74
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-campaign-60s.gif" width="100%" alt="Rhythmguard 60-second demo" />
|
|
75
|
+
</a>
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
I built Rhythmguard after 20 years of watching teams ignore spacing scales and ship arbitrary pixel values everywhere.
|
|
79
|
+
|
|
47
80
|
## Installation
|
|
48
81
|
|
|
49
82
|
```bash
|
|
@@ -66,32 +99,32 @@ npm install --save-dev stylelint-plugin-rhythmguard
|
|
|
66
99
|
|
|
67
100
|
## Quick Start
|
|
68
101
|
|
|
69
|
-
###
|
|
102
|
+
### Tailwind config
|
|
70
103
|
|
|
71
104
|
```json
|
|
72
105
|
{
|
|
73
|
-
"extends": ["stylelint-plugin-rhythmguard/configs/
|
|
106
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
|
|
74
107
|
}
|
|
75
108
|
```
|
|
76
109
|
|
|
77
|
-
###
|
|
110
|
+
### Recommended config
|
|
78
111
|
|
|
79
112
|
```json
|
|
80
113
|
{
|
|
81
|
-
"extends": ["stylelint-plugin-rhythmguard/configs/
|
|
114
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/recommended"]
|
|
82
115
|
}
|
|
83
116
|
```
|
|
84
117
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
### Tailwind config
|
|
118
|
+
### Strict config
|
|
88
119
|
|
|
89
120
|
```json
|
|
90
121
|
{
|
|
91
|
-
"extends": ["stylelint-plugin-rhythmguard/configs/
|
|
122
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/strict"]
|
|
92
123
|
}
|
|
93
124
|
```
|
|
94
125
|
|
|
126
|
+
`strict` intentionally delegates transform translation enforcement to `rhythmguard/no-offscale-transform` to reduce overlapping warnings from `use-scale`.
|
|
127
|
+
|
|
95
128
|
### Expanded config
|
|
96
129
|
|
|
97
130
|
```json
|
|
@@ -122,15 +155,28 @@ npm install --save-dev stylelint-plugin-rhythmguard
|
|
|
122
155
|
|
|
123
156
|
`migration` keeps on-scale numeric values temporarily while auto-building token mappings from CSS custom properties and optional Tailwind spacing config.
|
|
124
157
|
|
|
158
|
+
### React / Next.js + Tailwind config
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/react-tailwind"]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`react-tailwind` extends the tailwind config with CSS Modules overrides (spacing + radius enforcement) and ignores Next.js build directories.
|
|
167
|
+
|
|
125
168
|
Stable shared config entry points:
|
|
126
169
|
|
|
127
170
|
- `stylelint-plugin-rhythmguard/configs/recommended`
|
|
128
171
|
- `stylelint-plugin-rhythmguard/configs/strict`
|
|
129
172
|
- `stylelint-plugin-rhythmguard/configs/tailwind`
|
|
173
|
+
- `stylelint-plugin-rhythmguard/configs/react-tailwind`
|
|
130
174
|
- `stylelint-plugin-rhythmguard/configs/expanded`
|
|
131
175
|
- `stylelint-plugin-rhythmguard/configs/logical`
|
|
132
176
|
- `stylelint-plugin-rhythmguard/configs/migration`
|
|
133
177
|
|
|
178
|
+
Framework-specific setup for Vue, Lit, Astro, and SvelteKit: [`docs/FRAMEWORKS.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FRAMEWORKS.md)
|
|
179
|
+
|
|
134
180
|
## Comparison and Migration Recipes
|
|
135
181
|
|
|
136
182
|
- Side-by-side tool fit guide with migration snippets: [`docs/COMPARISON.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/COMPARISON.md)
|
|
@@ -409,7 +455,7 @@ Options:
|
|
|
409
455
|
| `mathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Restricts linting to specific 1-based argument indexes per math function |
|
|
410
456
|
| `ignoreMathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Excludes specific 1-based argument indexes per math function |
|
|
411
457
|
| `tokenMap` | `Record<string,string>` | `{}` | Enables autofix from raw value to token |
|
|
412
|
-
| `tokenMapFile` | `string` | `null` | JSON file path to merge additional token mappings |
|
|
458
|
+
| `tokenMapFile` | `string` | `null` | JSON file path to merge additional token mappings (supports flat, Style Dictionary, and W3C DTCG formats) |
|
|
413
459
|
| `tokenMapFromCssCustomProperties` | `boolean` | `false` | Auto-builds mappings from matching custom property declarations in the same stylesheet |
|
|
414
460
|
| `tokenMapFromTailwindSpacing` | `boolean` | `false` | Auto-builds mappings from `theme.spacing` and `theme.extend.spacing` in Tailwind config |
|
|
415
461
|
| `tailwindConfigPath` | `string` | `null` | Path to Tailwind config used by `tokenMapFromTailwindSpacing` (`.js`, `.cjs`, `.mjs`) |
|
|
@@ -450,6 +496,12 @@ Rhythmguard works well in Tailwind projects, but it enforces what Stylelint can
|
|
|
450
496
|
- CSS Modules (for example `*.module.css`)
|
|
451
497
|
- declarations inside `@layer` blocks
|
|
452
498
|
|
|
499
|
+
### Tailwind v4 @theme tokens
|
|
500
|
+
|
|
501
|
+
The `tailwind` config preset automatically extracts spacing tokens from Tailwind v4 `@theme` blocks and uses them for `prefer-token` enforcement. Raw values like `padding: 16px` are autofixed to `padding: var(--spacing-4)`.
|
|
502
|
+
|
|
503
|
+
See [`docs/TAILWIND.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/TAILWIND.md) for full setup.
|
|
504
|
+
|
|
453
505
|
### What Rhythmguard does not cover
|
|
454
506
|
|
|
455
507
|
- Tailwind class strings in templates/JSX/TSX, for example:
|
|
@@ -480,6 +532,18 @@ export default [
|
|
|
480
532
|
|
|
481
533
|
This rule targets arbitrary spacing utilities such as `p-[13px]`, `gap-[18px]`, `translate-x-[10px]`, and autofixes to the nearest configured scale value.
|
|
482
534
|
|
|
535
|
+
#### Supported patterns
|
|
536
|
+
|
|
537
|
+
The rule checks every string literal in your code, so it works automatically with common utility functions:
|
|
538
|
+
|
|
539
|
+
- `cn("p-[13px]")` / `cn("p-[13px]", condition && "m-[7px]")`
|
|
540
|
+
- `clsx("p-[13px]", "gap-[18px]")`
|
|
541
|
+
- `twMerge("p-[13px]", otherClasses)`
|
|
542
|
+
- `cva("base", { variants: { size: { sm: "p-[5px]" } } })`
|
|
543
|
+
- `<div className={cn("p-[13px]")} />`
|
|
544
|
+
|
|
545
|
+
No extra config needed — if the string contains an arbitrary spacing value, it gets caught and autofixed.
|
|
546
|
+
|
|
483
547
|
### Recommended stack for full Tailwind enforcement
|
|
484
548
|
|
|
485
549
|
Use both layers:
|
|
@@ -521,6 +585,35 @@ console.log(rhythmguard.presets.scales['rhythmic-4']);
|
|
|
521
585
|
console.log(Object.keys(rhythmguard.eslint.rules));
|
|
522
586
|
```
|
|
523
587
|
|
|
588
|
+
## Token File Formats
|
|
589
|
+
|
|
590
|
+
The `tokenMapFile` option supports multiple JSON formats:
|
|
591
|
+
|
|
592
|
+
**Flat token-to-value:**
|
|
593
|
+
|
|
594
|
+
```json
|
|
595
|
+
{ "--spacing-4": "16px", "--spacing-3": "12px" }
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Style Dictionary:**
|
|
599
|
+
|
|
600
|
+
```json
|
|
601
|
+
{ "--spacing-4": { "value": "16px" } }
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**W3C DTCG (Design Token Community Group):**
|
|
605
|
+
|
|
606
|
+
```json
|
|
607
|
+
{
|
|
608
|
+
"spacing": {
|
|
609
|
+
"4": { "$value": "16px", "$type": "dimension" },
|
|
610
|
+
"2": { "$value": "8px", "$type": "dimension" }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Nested DTCG groups are walked recursively. The key path becomes the CSS variable name: `spacing.4` → `var(--spacing-4)`. Non-length values (colors, fonts) are ignored automatically.
|
|
616
|
+
|
|
524
617
|
## Autofix Philosophy
|
|
525
618
|
|
|
526
619
|
Rhythmguard only applies deterministic fixes:
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
|
|
5
|
+
"bin": {
|
|
6
|
+
"rhythmguard": "./src/cli/index.js"
|
|
7
|
+
},
|
|
5
8
|
"keywords": [
|
|
6
9
|
"stylelint",
|
|
7
10
|
"stylelint-plugin",
|
|
@@ -46,6 +49,10 @@
|
|
|
46
49
|
"require": "./src/configs/migration.js",
|
|
47
50
|
"import": "./src/configs/migration.mjs"
|
|
48
51
|
},
|
|
52
|
+
"./configs/react-tailwind": {
|
|
53
|
+
"require": "./src/configs/react-tailwind.js",
|
|
54
|
+
"import": "./src/configs/react-tailwind.mjs"
|
|
55
|
+
},
|
|
49
56
|
"./presets": {
|
|
50
57
|
"require": "./src/presets/index.js",
|
|
51
58
|
"import": "./src/presets/index.mjs"
|
package/src/cli/audit.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(3);
|
|
7
|
+
const jsonMode = args.includes('--json');
|
|
8
|
+
const dir = args.find((a) => !a.startsWith('-'));
|
|
9
|
+
|
|
10
|
+
if (!dir) {
|
|
11
|
+
process.stderr.write('Usage: rhythmguard audit <dir> [--json]\n');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const resolvedDir = path.resolve(dir);
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
18
|
+
process.stderr.write(`Directory not found: ${dir}\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const GLOB_PATTERN = `${resolvedDir}/**/*.{css,module.css}`;
|
|
23
|
+
|
|
24
|
+
const pluginPath = path.resolve(__dirname, '..', 'index.js');
|
|
25
|
+
|
|
26
|
+
async function run() {
|
|
27
|
+
const { default: stylelint } = await import('stylelint');
|
|
28
|
+
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = await stylelint.lint({
|
|
32
|
+
files: GLOB_PATTERN,
|
|
33
|
+
config: {
|
|
34
|
+
plugins: [pluginPath],
|
|
35
|
+
rules: {
|
|
36
|
+
'rhythmguard/use-scale': [true, { severity: 'warning' }],
|
|
37
|
+
'rhythmguard/prefer-token': [
|
|
38
|
+
true,
|
|
39
|
+
{
|
|
40
|
+
tokenMapFromCssCustomProperties: true,
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
process.stderr.write(`Lint error: ${err.message}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fileResults = result.results || [];
|
|
53
|
+
const totalFiles = fileResults.length;
|
|
54
|
+
|
|
55
|
+
const offScaleValues = {};
|
|
56
|
+
const tokenOpportunities = {};
|
|
57
|
+
let filesWithIssues = 0;
|
|
58
|
+
let totalWarnings = 0;
|
|
59
|
+
|
|
60
|
+
for (const fileResult of fileResults) {
|
|
61
|
+
const warnings = fileResult.warnings || [];
|
|
62
|
+
if (warnings.length > 0) {
|
|
63
|
+
filesWithIssues++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const warning of warnings) {
|
|
67
|
+
totalWarnings++;
|
|
68
|
+
const text = warning.text || '';
|
|
69
|
+
|
|
70
|
+
// Extract off-scale values from use-scale messages
|
|
71
|
+
// Format: Unexpected off-scale value "13px". Use scale values (nearest: 12px or 16px).
|
|
72
|
+
const offScaleMatch = text.match(
|
|
73
|
+
/Unexpected off-scale value "([^"]+)"/,
|
|
74
|
+
);
|
|
75
|
+
if (offScaleMatch) {
|
|
76
|
+
const value = offScaleMatch[1];
|
|
77
|
+
offScaleValues[value] = (offScaleValues[value] || 0) + 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Extract token opportunities from prefer-token messages
|
|
81
|
+
// Format: Unexpected raw scale value "16px". Use design tokens for scale decisions.
|
|
82
|
+
const tokenMatch = text.match(
|
|
83
|
+
/Unexpected raw scale value "([^"]+)"/,
|
|
84
|
+
);
|
|
85
|
+
if (tokenMatch) {
|
|
86
|
+
const value = tokenMatch[1];
|
|
87
|
+
tokenOpportunities[value] = (tokenOpportunities[value] || 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sortedOffScale = Object.entries(offScaleValues)
|
|
93
|
+
.sort((a, b) => b[1] - a[1])
|
|
94
|
+
.slice(0, 10);
|
|
95
|
+
|
|
96
|
+
const sortedTokenOps = Object.entries(tokenOpportunities)
|
|
97
|
+
.sort((a, b) => b[1] - a[1])
|
|
98
|
+
.slice(0, 10);
|
|
99
|
+
|
|
100
|
+
if (jsonMode) {
|
|
101
|
+
const output = {
|
|
102
|
+
directory: dir,
|
|
103
|
+
totalFiles,
|
|
104
|
+
filesWithIssues,
|
|
105
|
+
totalWarnings,
|
|
106
|
+
offScaleValues: Object.fromEntries(sortedOffScale),
|
|
107
|
+
tokenOpportunities: Object.fromEntries(sortedTokenOps),
|
|
108
|
+
};
|
|
109
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Human-readable output
|
|
114
|
+
const pct = totalFiles > 0
|
|
115
|
+
? Math.round((filesWithIssues / totalFiles) * 100)
|
|
116
|
+
: 0;
|
|
117
|
+
|
|
118
|
+
const lines = [
|
|
119
|
+
'',
|
|
120
|
+
`Rhythmguard Audit: ${dir}`,
|
|
121
|
+
'',
|
|
122
|
+
`Files scanned: ${totalFiles}`,
|
|
123
|
+
`Files with issues: ${filesWithIssues} (${pct}%)`,
|
|
124
|
+
'',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
if (sortedOffScale.length > 0) {
|
|
128
|
+
const offScaleTotal = Object.values(offScaleValues).reduce(
|
|
129
|
+
(a, b) => a + b,
|
|
130
|
+
0,
|
|
131
|
+
);
|
|
132
|
+
lines.push(`Off-scale values: ${offScaleTotal}`);
|
|
133
|
+
for (const [value, count] of sortedOffScale) {
|
|
134
|
+
lines.push(` ${value.padEnd(10)} ×${count}`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (sortedTokenOps.length > 0) {
|
|
140
|
+
const tokenTotal = Object.values(tokenOpportunities).reduce(
|
|
141
|
+
(a, b) => a + b,
|
|
142
|
+
0,
|
|
143
|
+
);
|
|
144
|
+
lines.push(`Token opportunities: ${tokenTotal}`);
|
|
145
|
+
for (const [value, count] of sortedTokenOps) {
|
|
146
|
+
lines.push(` ${value.padEnd(10)} ×${count}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (totalWarnings === 0) {
|
|
152
|
+
lines.push('No issues found. Your spacing is on scale.');
|
|
153
|
+
} else {
|
|
154
|
+
lines.push('Run "npx stylelint --fix" to auto-correct.');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lines.push('');
|
|
158
|
+
process.stdout.write(lines.join('\n'));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
run();
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
let issues = 0;
|
|
8
|
+
|
|
9
|
+
function pass(msg) {
|
|
10
|
+
process.stdout.write(`✓ ${msg}\n`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(msg, fix) {
|
|
14
|
+
issues++;
|
|
15
|
+
process.stdout.write(`✗ ${msg}\n`);
|
|
16
|
+
if (fix) {
|
|
17
|
+
process.stdout.write(` → ${fix}\n`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function skip(msg) {
|
|
22
|
+
process.stdout.write(`- ${msg}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check 1: Stylelint installed
|
|
26
|
+
function checkStylelint() {
|
|
27
|
+
try {
|
|
28
|
+
const stylelintPkg = require.resolve('stylelint/package.json', {
|
|
29
|
+
paths: [cwd],
|
|
30
|
+
});
|
|
31
|
+
const version = require(stylelintPkg).version;
|
|
32
|
+
pass(`stylelint installed (v${version})`);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
fail(
|
|
36
|
+
'stylelint not installed',
|
|
37
|
+
'Run: npm install --save-dev stylelint',
|
|
38
|
+
);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check 2: Config found
|
|
44
|
+
function findConfig() {
|
|
45
|
+
const configFiles = [
|
|
46
|
+
'.stylelintrc',
|
|
47
|
+
'.stylelintrc.json',
|
|
48
|
+
'.stylelintrc.js',
|
|
49
|
+
'.stylelintrc.cjs',
|
|
50
|
+
'.stylelintrc.mjs',
|
|
51
|
+
'.stylelintrc.yml',
|
|
52
|
+
'.stylelintrc.yaml',
|
|
53
|
+
'stylelint.config.js',
|
|
54
|
+
'stylelint.config.cjs',
|
|
55
|
+
'stylelint.config.mjs',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
for (const file of configFiles) {
|
|
59
|
+
const fullPath = path.join(cwd, file);
|
|
60
|
+
if (fs.existsSync(fullPath)) {
|
|
61
|
+
pass(`config found (${file})`);
|
|
62
|
+
return fullPath;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check package.json
|
|
67
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
68
|
+
if (fs.existsSync(pkgPath)) {
|
|
69
|
+
try {
|
|
70
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
71
|
+
if (pkg.stylelint) {
|
|
72
|
+
pass('config found (package.json stylelint key)');
|
|
73
|
+
return pkgPath;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fail(
|
|
81
|
+
'no Stylelint config found',
|
|
82
|
+
'Run: npx rhythmguard init',
|
|
83
|
+
);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check 3: Config references Rhythmguard
|
|
88
|
+
function checkConfigReferences(configPath) {
|
|
89
|
+
if (!configPath) {
|
|
90
|
+
skip('config reference check skipped (no config)');
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
96
|
+
|
|
97
|
+
if (content.includes('rhythmguard')) {
|
|
98
|
+
pass('config references rhythmguard');
|
|
99
|
+
return content;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check package.json stylelint key
|
|
103
|
+
if (configPath.endsWith('package.json')) {
|
|
104
|
+
const pkg = JSON.parse(content);
|
|
105
|
+
const stylelintConfig = JSON.stringify(pkg.stylelint || {});
|
|
106
|
+
if (stylelintConfig.includes('rhythmguard')) {
|
|
107
|
+
pass('config references rhythmguard');
|
|
108
|
+
return stylelintConfig;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fail(
|
|
113
|
+
'config does not reference rhythmguard',
|
|
114
|
+
'Add: "extends": ["stylelint-plugin-rhythmguard/configs/recommended"]',
|
|
115
|
+
);
|
|
116
|
+
return content;
|
|
117
|
+
} catch {
|
|
118
|
+
fail(
|
|
119
|
+
'could not read config file',
|
|
120
|
+
`Check permissions on ${configPath}`,
|
|
121
|
+
);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check 4: Token pattern valid
|
|
127
|
+
function checkTokenPattern(configContent) {
|
|
128
|
+
if (!configContent) {
|
|
129
|
+
skip('token pattern check skipped (no config content)');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const patternMatch = configContent.match(
|
|
134
|
+
/tokenPattern['":\s]+['"]([^'"]+)['"]/,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!patternMatch) {
|
|
138
|
+
skip('token pattern check skipped (not configured)');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
new RegExp(patternMatch[1]);
|
|
144
|
+
pass(`token pattern valid (${patternMatch[1]})`);
|
|
145
|
+
} catch {
|
|
146
|
+
fail(
|
|
147
|
+
`token pattern invalid: ${patternMatch[1]}`,
|
|
148
|
+
'Fix the regex in tokenPattern option',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check 5: Tailwind config exists
|
|
154
|
+
function checkTailwindConfig(configContent) {
|
|
155
|
+
if (!configContent) {
|
|
156
|
+
skip('tailwind config check skipped (no config content)');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tailwindPathMatch = configContent.match(
|
|
161
|
+
/tailwindConfigPath['":\s]+['"]([^'"]+)['"]/,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (!tailwindPathMatch) {
|
|
165
|
+
skip('tailwind config check skipped (not configured)');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const tailwindPath = path.resolve(cwd, tailwindPathMatch[1]);
|
|
170
|
+
if (fs.existsSync(tailwindPath)) {
|
|
171
|
+
pass(`tailwind config found (${tailwindPathMatch[1]})`);
|
|
172
|
+
} else {
|
|
173
|
+
fail(
|
|
174
|
+
`tailwind config not found at ${tailwindPathMatch[1]}`,
|
|
175
|
+
'Update tailwindConfigPath or remove tokenMapFromTailwindSpacing',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check 6: Custom syntax installed
|
|
181
|
+
function checkCustomSyntax(configContent) {
|
|
182
|
+
if (!configContent) {
|
|
183
|
+
skip('custom syntax check skipped (no config content)');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const syntaxMatch = configContent.match(
|
|
188
|
+
/customSyntax['":\s]+['"]([^'"]+)['"]/,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!syntaxMatch) {
|
|
192
|
+
skip('custom syntax check skipped (not configured)');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pkg = syntaxMatch[1];
|
|
197
|
+
try {
|
|
198
|
+
require.resolve(pkg, { paths: [cwd] });
|
|
199
|
+
pass(`custom syntax installed (${pkg})`);
|
|
200
|
+
} catch {
|
|
201
|
+
fail(
|
|
202
|
+
`custom syntax package not installed: ${pkg}`,
|
|
203
|
+
`Run: npm install --save-dev ${pkg}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function run() {
|
|
209
|
+
process.stdout.write('\nRhythmguard Doctor\n\n');
|
|
210
|
+
|
|
211
|
+
const stylelintOk = checkStylelint();
|
|
212
|
+
const configPath = findConfig();
|
|
213
|
+
const configContent = checkConfigReferences(configPath);
|
|
214
|
+
checkTokenPattern(configContent);
|
|
215
|
+
checkTailwindConfig(configContent);
|
|
216
|
+
checkCustomSyntax(configContent);
|
|
217
|
+
|
|
218
|
+
process.stdout.write('\n');
|
|
219
|
+
|
|
220
|
+
if (issues === 0) {
|
|
221
|
+
process.stdout.write('All checks passed.\n\n');
|
|
222
|
+
} else {
|
|
223
|
+
process.stdout.write(
|
|
224
|
+
`${issues} issue${issues === 1 ? '' : 's'} found.\n\n`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
process.exit(stylelintOk && issues === 0 ? 0 : 1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
run();
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const command = process.argv[2];
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: rhythmguard <command>
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
audit <dir> Report scale drift and token opportunities
|
|
10
|
+
init Scaffold a Rhythmguard config for your project
|
|
11
|
+
doctor Validate your Rhythmguard setup
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--help Show this help message
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
npx rhythmguard audit ./src
|
|
18
|
+
npx rhythmguard audit ./src --json
|
|
19
|
+
npx rhythmguard init
|
|
20
|
+
npx rhythmguard doctor
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
if (!command || command === '--help' || command === '-h') {
|
|
24
|
+
process.stdout.write(HELP);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command === 'audit') {
|
|
29
|
+
require('./audit');
|
|
30
|
+
} else if (command === 'init') {
|
|
31
|
+
require('./init');
|
|
32
|
+
} else if (command === 'doctor') {
|
|
33
|
+
require('./doctor');
|
|
34
|
+
} else {
|
|
35
|
+
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
36
|
+
process.stdout.write(HELP);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
package/src/cli/init.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const readline = require('node:readline');
|
|
6
|
+
|
|
7
|
+
function ask(question) {
|
|
8
|
+
const rl = readline.createInterface({
|
|
9
|
+
input: process.stdin,
|
|
10
|
+
output: process.stdout,
|
|
11
|
+
});
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
rl.question(question, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
resolve(answer.trim().toLowerCase());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function detect() {
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
23
|
+
|
|
24
|
+
let pkg = {};
|
|
25
|
+
if (fs.existsSync(pkgPath)) {
|
|
26
|
+
try {
|
|
27
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const allDeps = {
|
|
34
|
+
...pkg.dependencies,
|
|
35
|
+
...pkg.devDependencies,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const hasTailwindConfig =
|
|
39
|
+
fs.existsSync(path.join(cwd, 'tailwind.config.js')) ||
|
|
40
|
+
fs.existsSync(path.join(cwd, 'tailwind.config.cjs')) ||
|
|
41
|
+
fs.existsSync(path.join(cwd, 'tailwind.config.mjs')) ||
|
|
42
|
+
fs.existsSync(path.join(cwd, 'tailwind.config.ts'));
|
|
43
|
+
|
|
44
|
+
const hasTailwindDep = Boolean(
|
|
45
|
+
allDeps.tailwindcss || allDeps['@tailwindcss/postcss'],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const hasNextConfig =
|
|
49
|
+
fs.existsSync(path.join(cwd, 'next.config.js')) ||
|
|
50
|
+
fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
|
|
51
|
+
fs.existsSync(path.join(cwd, 'next.config.ts'));
|
|
52
|
+
|
|
53
|
+
const hasExistingConfig =
|
|
54
|
+
fs.existsSync(path.join(cwd, '.stylelintrc')) ||
|
|
55
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.json')) ||
|
|
56
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.js')) ||
|
|
57
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.cjs')) ||
|
|
58
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.mjs')) ||
|
|
59
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.yml')) ||
|
|
60
|
+
fs.existsSync(path.join(cwd, '.stylelintrc.yaml')) ||
|
|
61
|
+
fs.existsSync(path.join(cwd, 'stylelint.config.js')) ||
|
|
62
|
+
fs.existsSync(path.join(cwd, 'stylelint.config.cjs')) ||
|
|
63
|
+
fs.existsSync(path.join(cwd, 'stylelint.config.mjs')) ||
|
|
64
|
+
Boolean(pkg.stylelint);
|
|
65
|
+
|
|
66
|
+
const tailwind = hasTailwindConfig || hasTailwindDep;
|
|
67
|
+
const nextjs = hasNextConfig;
|
|
68
|
+
|
|
69
|
+
return { tailwind, nextjs, hasExistingConfig };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function selectProfile(stack) {
|
|
73
|
+
if (stack.nextjs && stack.tailwind) {
|
|
74
|
+
return 'react-tailwind';
|
|
75
|
+
}
|
|
76
|
+
if (stack.tailwind) {
|
|
77
|
+
return 'tailwind';
|
|
78
|
+
}
|
|
79
|
+
return 'recommended';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function run() {
|
|
83
|
+
process.stdout.write('\nRhythmguard Init\n\n');
|
|
84
|
+
|
|
85
|
+
const stack = detect();
|
|
86
|
+
|
|
87
|
+
// Report detection
|
|
88
|
+
const detected = [];
|
|
89
|
+
if (stack.tailwind) detected.push('Tailwind CSS');
|
|
90
|
+
if (stack.nextjs) detected.push('Next.js');
|
|
91
|
+
if (detected.length > 0) {
|
|
92
|
+
process.stdout.write(`Detected: ${detected.join(', ')}\n`);
|
|
93
|
+
} else {
|
|
94
|
+
process.stdout.write('Detected: plain CSS project\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Warn about existing config
|
|
98
|
+
if (stack.hasExistingConfig) {
|
|
99
|
+
process.stdout.write('\n⚠ Existing Stylelint config found.\n');
|
|
100
|
+
const answer = await ask('Overwrite? (y/n) ');
|
|
101
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
102
|
+
process.stdout.write('Aborted.\n');
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const profile = selectProfile(stack);
|
|
108
|
+
process.stdout.write(`\nProfile: ${profile}\n`);
|
|
109
|
+
|
|
110
|
+
const answer = await ask('Write .stylelintrc.json? (y/n) ');
|
|
111
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
112
|
+
process.stdout.write('Aborted.\n');
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const config = {
|
|
117
|
+
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const configPath = path.join(process.cwd(), '.stylelintrc.json');
|
|
121
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
122
|
+
|
|
123
|
+
process.stdout.write(`\n✓ Wrote ${configPath}\n`);
|
|
124
|
+
process.stdout.write(`\nNext steps:\n`);
|
|
125
|
+
process.stdout.write(` npx stylelint "src/**/*.css"\n\n`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
run();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
extends: [
|
|
5
|
+
'stylelint-plugin-rhythmguard/configs/tailwind',
|
|
6
|
+
],
|
|
7
|
+
ignoreFiles: [
|
|
8
|
+
'.next/**',
|
|
9
|
+
'out/**',
|
|
10
|
+
'node_modules/**',
|
|
11
|
+
],
|
|
12
|
+
overrides: [
|
|
13
|
+
{
|
|
14
|
+
files: ['**/*.module.css'],
|
|
15
|
+
rules: {
|
|
16
|
+
'rhythmguard/use-scale': [
|
|
17
|
+
true,
|
|
18
|
+
{
|
|
19
|
+
propertyGroups: ['spacing', 'radius'],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
package/src/configs/tailwind.js
CHANGED
|
@@ -5,4 +5,13 @@ module.exports = {
|
|
|
5
5
|
'stylelint-config-tailwindcss',
|
|
6
6
|
'stylelint-plugin-rhythmguard/configs/strict',
|
|
7
7
|
],
|
|
8
|
+
rules: {
|
|
9
|
+
'rhythmguard/prefer-token': [
|
|
10
|
+
true,
|
|
11
|
+
{
|
|
12
|
+
tokenPattern: '^--spacing-',
|
|
13
|
+
tokenMapFromCssCustomProperties: true,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
8
17
|
};
|
package/src/utils/token-map.js
CHANGED
|
@@ -79,6 +79,31 @@ function mergeExplicitTokenMap(target, source) {
|
|
|
79
79
|
return target;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function walkTokenGroup(map, group, prefix, baseFontSize) {
|
|
83
|
+
for (const [key, value] of Object.entries(group)) {
|
|
84
|
+
const tokenName = `${prefix}-${key}`;
|
|
85
|
+
|
|
86
|
+
if (!isPlainObject(value)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Leaf node with $value (DTCG)
|
|
91
|
+
if (typeof value.$value === 'string') {
|
|
92
|
+
addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Leaf node with value (Style Dictionary)
|
|
97
|
+
if (typeof value.value === 'string') {
|
|
98
|
+
addLengthValueMapping(map, value.value, tokenName, baseFontSize);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Nested group — recurse deeper
|
|
103
|
+
walkTokenGroup(map, value, tokenName, baseFontSize);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
function mergeTokenMapFromFile({
|
|
83
108
|
baseFontSize,
|
|
84
109
|
currentMap,
|
|
@@ -130,8 +155,21 @@ function mergeTokenMapFromFile({
|
|
|
130
155
|
continue;
|
|
131
156
|
}
|
|
132
157
|
|
|
133
|
-
if (isPlainObject(entryValue)
|
|
134
|
-
|
|
158
|
+
if (isPlainObject(entryValue)) {
|
|
159
|
+
// Style Dictionary format: { value: "16px" }
|
|
160
|
+
if (typeof entryValue.value === 'string') {
|
|
161
|
+
addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// W3C DTCG format: { $value: "16px", $type: "dimension" }
|
|
166
|
+
if (typeof entryValue.$value === 'string') {
|
|
167
|
+
addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Nested group — recurse (e.g. { spacing: { 4: { $value: "16px" } } })
|
|
172
|
+
walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
|
|
135
173
|
}
|
|
136
174
|
}
|
|
137
175
|
|
|
@@ -324,7 +362,7 @@ function buildEffectiveTokenMap({
|
|
|
324
362
|
root,
|
|
325
363
|
tokenRegex,
|
|
326
364
|
}) {
|
|
327
|
-
let tokenMap =
|
|
365
|
+
let tokenMap = {};
|
|
328
366
|
|
|
329
367
|
if (options.tokenMapFile) {
|
|
330
368
|
tokenMap = mergeTokenMapFromFile({
|
|
@@ -350,6 +388,9 @@ function buildEffectiveTokenMap({
|
|
|
350
388
|
});
|
|
351
389
|
}
|
|
352
390
|
|
|
391
|
+
// Explicit tokenMap applied last so it takes precedence over auto-derived tokens
|
|
392
|
+
tokenMap = mergeExplicitTokenMap(tokenMap, options.tokenMap);
|
|
393
|
+
|
|
353
394
|
return tokenMap;
|
|
354
395
|
}
|
|
355
396
|
|