stylelint-plugin-rhythmguard 2.2.0 → 3.0.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 +28 -0
- package/CONTRIBUTING.md +66 -51
- package/README.md +6 -6
- package/SECURITY.md +2 -2
- package/package.json +21 -27
- package/scales/community/product-decimal-10.json +27 -4
- package/src/audit/contract.js +15 -0
- package/src/audit/render-markdown.js +2 -0
- package/src/audit/render-text.js +2 -0
- package/src/audit/report.js +24 -1
- package/src/audit/scan.js +67 -10
- package/src/cli/init.js +2 -3
- package/src/cli/quickstart.js +7 -3
- package/src/index.js +0 -4
- package/src/utils/scale-inference.js +108 -12
- package/src/utils/token-packages.json +48 -0
- package/src/utils/token-sources.js +282 -3
- package/types/__checks__/consumer.ts +0 -6
- package/types/audit.d.ts +6 -1
- package/types/index.d.ts +0 -4
- package/types/shared.d.ts +2 -0
- package/src/configs/expanded.js +0 -27
- package/src/configs/expanded.mjs +0 -4
- package/src/configs/logical.js +0 -16
- package/src/configs/logical.mjs +0 -4
- package/src/configs/migration.js +0 -31
- package/src/configs/migration.mjs +0 -4
- package/src/configs/react-tailwind.js +0 -25
- package/src/configs/react-tailwind.mjs +0 -4
package/src/cli/quickstart.js
CHANGED
|
@@ -14,6 +14,7 @@ const path = require('node:path');
|
|
|
14
14
|
|
|
15
15
|
const { createAuditReport } = require('../audit/report');
|
|
16
16
|
const { detect } = require('./init');
|
|
17
|
+
const { discoverTokenPackages } = require('../utils/scale-inference');
|
|
17
18
|
|
|
18
19
|
const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
|
|
19
20
|
const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
|
|
@@ -76,13 +77,14 @@ function topEntries(counts, limit = MAX_LISTED) {
|
|
|
76
77
|
.slice(0, limit);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
function suggestedStylelintConfig({ profile, tokenFiles }) {
|
|
80
|
+
function suggestedStylelintConfig({ nextjs = false, profile, tokenFiles }) {
|
|
80
81
|
const ruleOptions = { scale: 'auto' };
|
|
81
82
|
if (tokenFiles.length > 0) {
|
|
82
83
|
ruleOptions.scaleSources = tokenFiles.map(toPosix);
|
|
83
84
|
}
|
|
84
85
|
return {
|
|
85
86
|
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
87
|
+
...(nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
|
|
86
88
|
rules: {
|
|
87
89
|
'rhythmguard/use-scale': [true, ruleOptions],
|
|
88
90
|
},
|
|
@@ -127,6 +129,8 @@ async function run() {
|
|
|
127
129
|
out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
|
|
128
130
|
out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
|
|
129
131
|
out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
|
|
132
|
+
const tokenPackages = [...new Set(discoverTokenPackages(cwd).map((source) => source.package))];
|
|
133
|
+
out.push(` Token packages ${tokenPackages.length > 0 ? tokenPackages.join(', ') : 'none installed'}`);
|
|
130
134
|
out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
|
|
131
135
|
out.push('');
|
|
132
136
|
|
|
@@ -179,9 +183,9 @@ async function run() {
|
|
|
179
183
|
}
|
|
180
184
|
out.push('');
|
|
181
185
|
|
|
182
|
-
const profile = stack.tailwind ?
|
|
186
|
+
const profile = stack.tailwind ? 'tailwind' : 'recommended';
|
|
183
187
|
out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
|
|
184
|
-
out.push(JSON.stringify(suggestedStylelintConfig({ profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
|
|
188
|
+
out.push(JSON.stringify(suggestedStylelintConfig({ nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
|
|
185
189
|
out.push('');
|
|
186
190
|
|
|
187
191
|
if (stack.tailwind) {
|
package/src/index.js
CHANGED
|
@@ -19,11 +19,7 @@ module.exports.configs = {
|
|
|
19
19
|
recommended: require('./configs/recommended'),
|
|
20
20
|
strict: require('./configs/strict'),
|
|
21
21
|
tailwind: require('./configs/tailwind'),
|
|
22
|
-
expanded: require('./configs/expanded'),
|
|
23
|
-
logical: require('./configs/logical'),
|
|
24
|
-
migration: require('./configs/migration'),
|
|
25
22
|
motion: require('./configs/motion'),
|
|
26
|
-
'react-tailwind': require('./configs/react-tailwind'),
|
|
27
23
|
};
|
|
28
24
|
module.exports.eslint = require('./eslint');
|
|
29
25
|
module.exports.presets = require('./presets');
|
|
@@ -5,7 +5,7 @@ const path = require('node:path');
|
|
|
5
5
|
|
|
6
6
|
const { parseLengthToken, toPx } = require('./length');
|
|
7
7
|
const { buildEffectiveTokenMap } = require('./token-map');
|
|
8
|
-
const { parseTokenSources } = require('./token-sources');
|
|
8
|
+
const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require('./token-sources');
|
|
9
9
|
const { getScalePreset } = require('../presets/scales');
|
|
10
10
|
|
|
11
11
|
// Matches the audit default so lint and audit agree on what a spacing token is.
|
|
@@ -21,6 +21,80 @@ const MIN_INFERRED_SCALE_LENGTH = 4;
|
|
|
21
21
|
const RC_FILE = '.rhythmguardrc.json';
|
|
22
22
|
|
|
23
23
|
const sourceCache = new Map();
|
|
24
|
+
const TOKEN_PACKAGES = require('./token-packages.json').packages;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Installed design-token packages that ship a spacing scale (allowlist in
|
|
28
|
+
* token-packages.json). Resolved from the project, so only what the project
|
|
29
|
+
* actually depends on is read. Returns token-source entries.
|
|
30
|
+
*/
|
|
31
|
+
function readDirectDependencies(dir) {
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
34
|
+
return new Set([
|
|
35
|
+
...Object.keys(pkg.dependencies || {}),
|
|
36
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
37
|
+
...Object.keys(pkg.peerDependencies || {}),
|
|
38
|
+
]);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Project roots from cwd up to the repository boundary (first directory holding
|
|
46
|
+
* .git), each with the dependencies it declares directly. Hoisted monorepo
|
|
47
|
+
* installs are found by walking up; a stray global node_modules is never
|
|
48
|
+
* consulted because the walk stops at the repository.
|
|
49
|
+
*/
|
|
50
|
+
function projectRoots(cwd) {
|
|
51
|
+
const roots = [];
|
|
52
|
+
let current = path.resolve(cwd);
|
|
53
|
+
for (;;) {
|
|
54
|
+
const direct = readDirectDependencies(current);
|
|
55
|
+
if (direct) {
|
|
56
|
+
roots.push({ dir: current, direct });
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(current);
|
|
59
|
+
if (fs.existsSync(path.join(current, '.git')) || parent === current) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
return roots;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function discoverTokenPackages(cwd = process.cwd()) {
|
|
68
|
+
const sources = [];
|
|
69
|
+
const roots = projectRoots(cwd);
|
|
70
|
+
for (const entry of TOKEN_PACKAGES) {
|
|
71
|
+
// Only packages the project depends on directly count. A transitive
|
|
72
|
+
// tailwindcss (for example via stylelint-config-tailwindcss) must not hand
|
|
73
|
+
// a non-Tailwind project the Tailwind scale.
|
|
74
|
+
const owner = roots.find((root) => root.direct.has(entry.name));
|
|
75
|
+
if (!owner) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const root = roots
|
|
79
|
+
.map((candidate) => path.join(candidate.dir, 'node_modules', entry.name))
|
|
80
|
+
.find((dir) => fs.existsSync(path.join(dir, 'package.json')));
|
|
81
|
+
if (!root) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const file of entry.files) {
|
|
85
|
+
const resolved = path.join(root, file);
|
|
86
|
+
if (fs.existsSync(resolved)) {
|
|
87
|
+
sources.push({
|
|
88
|
+
format: 'auto',
|
|
89
|
+
package: entry.name,
|
|
90
|
+
path: resolved,
|
|
91
|
+
...(entry.tokenPattern ? { tokenPattern: entry.tokenPattern } : {}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return sources;
|
|
97
|
+
}
|
|
24
98
|
|
|
25
99
|
function pxValuesFromKeys(keys, baseFontSize) {
|
|
26
100
|
const values = new Set([0]);
|
|
@@ -54,6 +128,7 @@ function normalizeSource(source, baseDir) {
|
|
|
54
128
|
return {
|
|
55
129
|
format: typeof source.format === 'string' ? source.format : 'auto',
|
|
56
130
|
path: path.resolve(source.baseDir || baseDir, rawPath),
|
|
131
|
+
...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
|
|
57
132
|
};
|
|
58
133
|
}
|
|
59
134
|
|
|
@@ -69,7 +144,7 @@ function cacheKey(sources) {
|
|
|
69
144
|
} catch {
|
|
70
145
|
// missing file: key still changes when it appears
|
|
71
146
|
}
|
|
72
|
-
return `${source.path}|${source.format}|${mtime}`;
|
|
147
|
+
return `${source.path}|${source.format}|${source.tokenPattern || ''}|${mtime}`;
|
|
73
148
|
})
|
|
74
149
|
.join('\n');
|
|
75
150
|
}
|
|
@@ -86,13 +161,9 @@ function scaleFromSources(sources, baseFontSize) {
|
|
|
86
161
|
}
|
|
87
162
|
|
|
88
163
|
const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const scale = pxValuesFromKeys(keys, baseFontSize);
|
|
95
|
-
const outcome = scale.length >= MIN_INFERRED_SCALE_LENGTH
|
|
164
|
+
// scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
|
|
165
|
+
const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
|
|
166
|
+
const outcome = scale
|
|
96
167
|
? {
|
|
97
168
|
files: parsed.sources.map((source) => source.file),
|
|
98
169
|
scale,
|
|
@@ -157,8 +228,27 @@ function rcTokenSources(cwd) {
|
|
|
157
228
|
.filter(Boolean);
|
|
158
229
|
}
|
|
159
230
|
|
|
160
|
-
|
|
161
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Sass variables and maps declared in the linted stylesheet itself (postcss-scss
|
|
233
|
+
* exposes them as declarations whose prop starts with `$`). Evaluated with the
|
|
234
|
+
* same collector the audit uses; component variables such as $dropdown-spacer
|
|
235
|
+
* are excluded by the anchored name rule.
|
|
236
|
+
*/
|
|
237
|
+
function sassValuesFromRoot(root) {
|
|
238
|
+
const lines = [];
|
|
239
|
+
root.walkDecls((decl) => {
|
|
240
|
+
if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
|
|
241
|
+
lines.push(`${decl.prop}: ${decl.value};`);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
if (lines.length === 0) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing')).map((token) => token.value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
|
|
251
|
+
const keys = [...extraKeys];
|
|
162
252
|
const baseKeys = [];
|
|
163
253
|
for (const [key, reference] of Object.entries(map)) {
|
|
164
254
|
const name = String(reference).match(/^var\((--[\w-]+)\)$/);
|
|
@@ -206,7 +296,7 @@ function resolveAutoScale({
|
|
|
206
296
|
root,
|
|
207
297
|
tokenRegex,
|
|
208
298
|
});
|
|
209
|
-
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize);
|
|
299
|
+
const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
|
|
210
300
|
if (scale) {
|
|
211
301
|
return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
|
|
212
302
|
}
|
|
@@ -229,6 +319,11 @@ function resolveAutoScale({
|
|
|
229
319
|
}
|
|
230
320
|
}
|
|
231
321
|
|
|
322
|
+
const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize);
|
|
323
|
+
if (fromPackages) {
|
|
324
|
+
return { source: 'token-package', ...fromPackages };
|
|
325
|
+
}
|
|
326
|
+
|
|
232
327
|
return {
|
|
233
328
|
files: [],
|
|
234
329
|
preset: FALLBACK_PRESET,
|
|
@@ -250,6 +345,7 @@ function autoScaleFallbackNote(inference) {
|
|
|
250
345
|
module.exports = {
|
|
251
346
|
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
252
347
|
autoScaleFallbackNote,
|
|
348
|
+
discoverTokenPackages,
|
|
253
349
|
resolveAutoScale,
|
|
254
350
|
scaleFromDefinitions,
|
|
255
351
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Design-token packages whose installed files declare a spacing scale. Used by scale: \"auto\" after the project's own stylesheets are checked. Each entry lists the files to read (relative to the package root) and, when the package does not use space/spacing names, the token pattern to match. Additions welcome; keep them to files that ship in the published package.",
|
|
3
|
+
"packages": [
|
|
4
|
+
{
|
|
5
|
+
"name": "tailwindcss",
|
|
6
|
+
"files": [
|
|
7
|
+
"theme.css"
|
|
8
|
+
],
|
|
9
|
+
"note": "Tailwind v4 base multiplier --spacing"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "@radix-ui/themes",
|
|
13
|
+
"files": [
|
|
14
|
+
"tokens.css"
|
|
15
|
+
],
|
|
16
|
+
"note": "--space-1..9 as calc(<px> * var(--scaling))"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "@mantine/core",
|
|
20
|
+
"files": [
|
|
21
|
+
"styles.css"
|
|
22
|
+
],
|
|
23
|
+
"note": "--mantine-spacing-* as calc(<rem> * var(--mantine-scale))"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "@primer/primitives",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/css/base/size/size.css"
|
|
29
|
+
],
|
|
30
|
+
"tokenPattern": "^--base-size-\\d+$",
|
|
31
|
+
"note": "Primer names its spacing ladder size"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "@shopify/polaris-tokens",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/css/styles.css"
|
|
37
|
+
],
|
|
38
|
+
"note": "--p-space-* in rem"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "@spectrum-css/tokens",
|
|
42
|
+
"files": [
|
|
43
|
+
"dist/css/global-vars.css"
|
|
44
|
+
],
|
|
45
|
+
"note": "--spectrum-spacing-* in px"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -32,7 +32,13 @@ const TOKEN_KIND_PATTERNS = Object.freeze({
|
|
|
32
32
|
motion: /^--(?:motion|duration|delay|ease|easing)-/,
|
|
33
33
|
size: /^--(?:size|width|height|container)-/,
|
|
34
34
|
// Anchored or prefixed (--spacing-4, --lb-spacing-md, bare Tailwind v4 --spacing), never letter-/word-spacing.
|
|
35
|
-
|
|
35
|
+
// CSS custom properties (--spacing-4, --lb-spacing-md, bare --spacing) and Sass
|
|
36
|
+
// variables or map entries ($spacer, $spacers.3, $system-spacing.small.2).
|
|
37
|
+
// Custom properties may carry a namespace (--lb-spacing-md, --mantine-spacing-xs, bare
|
|
38
|
+
// --spacing) but never letter-/word-spacing. Sass names must start with the scale word
|
|
39
|
+
// (an optional `system-` prefix allowed): $spacer, $spacers.3, $spacing-01, $system-spacing.
|
|
40
|
+
// Component variables such as $dropdown-spacer or $card-spacer-y are not scale tokens.
|
|
41
|
+
spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing)(?:-|$))/,
|
|
36
42
|
typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
|
|
37
43
|
});
|
|
38
44
|
|
|
@@ -62,6 +68,15 @@ function normalizeTokenKind(kind) {
|
|
|
62
68
|
return normalized;
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
function createPatternMatcher(pattern, fallback) {
|
|
72
|
+
try {
|
|
73
|
+
const regex = new RegExp(pattern);
|
|
74
|
+
return (token) => regex.test(token);
|
|
75
|
+
} catch {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
function createTokenKindMatcher(kind) {
|
|
66
81
|
const normalizedKind = normalizeTokenKind(kind);
|
|
67
82
|
const pattern = TOKEN_KIND_PATTERNS[normalizedKind] || TOKEN_KIND_PATTERNS.spacing;
|
|
@@ -109,16 +124,19 @@ function parseTokenSources({
|
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
let tokens = [];
|
|
127
|
+
const matchesSource = normalizedSource.tokenPattern
|
|
128
|
+
? createPatternMatcher(normalizedSource.tokenPattern, matchesKind)
|
|
129
|
+
: matchesKind;
|
|
112
130
|
try {
|
|
113
131
|
if (normalizedSource.format === 'css') {
|
|
114
|
-
tokens = collectCssTokens(text,
|
|
132
|
+
tokens = collectCssTokens(text, matchesSource);
|
|
115
133
|
} else {
|
|
116
134
|
const parsed = JSON.parse(text);
|
|
117
135
|
const detectedFormat = normalizedSource.requestedFormat === 'auto'
|
|
118
136
|
? detectJsonTokenFormat(parsed)
|
|
119
137
|
: normalizedSource.format;
|
|
120
138
|
sourceReport.format = detectedFormat;
|
|
121
|
-
tokens = collectJsonTokens(parsed,
|
|
139
|
+
tokens = collectJsonTokens(parsed, matchesSource);
|
|
122
140
|
}
|
|
123
141
|
} catch (err) {
|
|
124
142
|
const warning = `Unable to parse token source ${normalizedSource.displayPath}: ${err.message}`;
|
|
@@ -168,6 +186,9 @@ function normalizeTokenSource(source) {
|
|
|
168
186
|
format: requestedFormat === 'auto' ? detectSourceFormat(resolvedPath) : requestedFormat,
|
|
169
187
|
requestedFormat,
|
|
170
188
|
resolvedPath,
|
|
189
|
+
// Optional per-source override for packages that name spacing tokens differently
|
|
190
|
+
// (Primer's --base-size-*). Falls back to the kind matcher when absent.
|
|
191
|
+
tokenPattern: typeof source.tokenPattern === 'string' && source.tokenPattern ? source.tokenPattern : null,
|
|
171
192
|
};
|
|
172
193
|
}
|
|
173
194
|
|
|
@@ -231,9 +252,266 @@ function collectCssTokens(source, matchesKind) {
|
|
|
231
252
|
});
|
|
232
253
|
}
|
|
233
254
|
|
|
255
|
+
tokens.push(...collectScssTokens(source, matchesKind));
|
|
234
256
|
return tokens;
|
|
235
257
|
}
|
|
236
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Sass variables and maps as token sources. Handles `$spacer: 1rem`, maps such
|
|
261
|
+
* as `$spacers: (1: $spacer * .25, ...)` including nested maps, variable
|
|
262
|
+
* references, `* / + -` arithmetic and `math.div()`. Anything it cannot
|
|
263
|
+
* evaluate (function calls, strings, keywords, interpolated keys, cycles) is
|
|
264
|
+
* skipped rather than guessed. Token names are `$name` or `$name.key.path`.
|
|
265
|
+
*/
|
|
266
|
+
function collectScssTokens(source, matchesKind) {
|
|
267
|
+
const declarations = parseScssDeclarations(source);
|
|
268
|
+
const cache = new Map();
|
|
269
|
+
|
|
270
|
+
const resolveVariable = (name, stack) => {
|
|
271
|
+
if (cache.has(name)) {
|
|
272
|
+
return cache.get(name);
|
|
273
|
+
}
|
|
274
|
+
if (stack.has(name) || !declarations.has(name)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
stack.add(name);
|
|
278
|
+
const raw = declarations.get(name);
|
|
279
|
+
const value = isScssMap(raw) ? null : evaluateScssExpression(raw, resolveVariable, stack);
|
|
280
|
+
stack.delete(name);
|
|
281
|
+
cache.set(name, value);
|
|
282
|
+
return value;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const tokens = [];
|
|
286
|
+
const push = (tokenName, value) => {
|
|
287
|
+
if (!value || !matchesKind(tokenName)) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
tokens.push({ token: tokenName, value: formatScssValue(value) });
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const [name, raw] of declarations) {
|
|
294
|
+
const tokenName = `$${name}`;
|
|
295
|
+
if (isScssMap(raw)) {
|
|
296
|
+
walkScssMap(raw, [tokenName], (pathName, expression) => {
|
|
297
|
+
push(pathName, evaluateScssExpression(expression, resolveVariable, new Set()));
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
push(tokenName, resolveVariable(name, new Set()));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return tokens;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function stripScssComments(source) {
|
|
308
|
+
return source
|
|
309
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
310
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseScssDeclarations(source) {
|
|
314
|
+
const text = stripScssComments(source);
|
|
315
|
+
const declarations = new Map();
|
|
316
|
+
const startPattern = /(?:^|\n)[ \t]*\$([\w-]+)[ \t]*:/g;
|
|
317
|
+
let match;
|
|
318
|
+
|
|
319
|
+
while ((match = startPattern.exec(text)) !== null) {
|
|
320
|
+
const name = match[1];
|
|
321
|
+
let index = match.index + match[0].length;
|
|
322
|
+
let depth = 0;
|
|
323
|
+
let quote = null;
|
|
324
|
+
let value = '';
|
|
325
|
+
|
|
326
|
+
for (; index < text.length; index += 1) {
|
|
327
|
+
const char = text[index];
|
|
328
|
+
if (quote) {
|
|
329
|
+
value += char;
|
|
330
|
+
if (char === quote) quote = null;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (char === '"' || char === "'") {
|
|
334
|
+
quote = char;
|
|
335
|
+
} else if (char === '(') {
|
|
336
|
+
depth += 1;
|
|
337
|
+
} else if (char === ')') {
|
|
338
|
+
depth -= 1;
|
|
339
|
+
} else if (char === ';' && depth === 0) {
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
value += char;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
startPattern.lastIndex = index;
|
|
346
|
+
const cleaned = value.replace(/!(default|global)\b/g, '').trim();
|
|
347
|
+
if (cleaned && !declarations.has(name)) {
|
|
348
|
+
declarations.set(name, cleaned);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return declarations;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isScssMap(raw) {
|
|
356
|
+
return raw.startsWith('(') && raw.endsWith(')') && /:/.test(raw);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function splitTopLevel(text, separator) {
|
|
360
|
+
const parts = [];
|
|
361
|
+
let depth = 0;
|
|
362
|
+
let quote = null;
|
|
363
|
+
let current = '';
|
|
364
|
+
for (const char of text) {
|
|
365
|
+
if (quote) {
|
|
366
|
+
current += char;
|
|
367
|
+
if (char === quote) quote = null;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (char === '"' || char === "'") quote = char;
|
|
371
|
+
else if (char === '(') depth += 1;
|
|
372
|
+
else if (char === ')') depth -= 1;
|
|
373
|
+
if (char === separator && depth === 0) {
|
|
374
|
+
parts.push(current);
|
|
375
|
+
current = '';
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
current += char;
|
|
379
|
+
}
|
|
380
|
+
if (current.trim()) parts.push(current);
|
|
381
|
+
return parts;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function walkScssMap(raw, pathSegments, visit) {
|
|
385
|
+
const inner = raw.slice(1, -1);
|
|
386
|
+
for (const entry of splitTopLevel(inner, ',')) {
|
|
387
|
+
const pair = splitTopLevel(entry, ':');
|
|
388
|
+
if (pair.length < 2) continue;
|
|
389
|
+
const key = pair[0].trim().replace(/^["']|["']$/g, '');
|
|
390
|
+
const expression = pair.slice(1).join(':').trim();
|
|
391
|
+
if (!key || key.includes('#{')) continue;
|
|
392
|
+
const nextPath = [...pathSegments, key];
|
|
393
|
+
if (isScssMap(expression)) {
|
|
394
|
+
walkScssMap(expression, nextPath, visit);
|
|
395
|
+
} else {
|
|
396
|
+
visit(nextPath.join('.'), expression);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const SCSS_TOKEN_PATTERN = /\s*(?:(-?(?:\d+\.?\d*|\.\d+)[a-zA-Z%]*)|(\$[\w-]+)|([a-zA-Z_][\w.-]*)\s*\(|([()*/+,-]))/y;
|
|
402
|
+
|
|
403
|
+
function tokenizeScssExpression(expression) {
|
|
404
|
+
const tokens = [];
|
|
405
|
+
let index = 0;
|
|
406
|
+
while (index < expression.length) {
|
|
407
|
+
SCSS_TOKEN_PATTERN.lastIndex = index;
|
|
408
|
+
const match = SCSS_TOKEN_PATTERN.exec(expression);
|
|
409
|
+
if (!match) {
|
|
410
|
+
if (/\s/.test(expression[index])) {
|
|
411
|
+
index += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
index = SCSS_TOKEN_PATTERN.lastIndex;
|
|
417
|
+
if (match[1] !== undefined) tokens.push({ type: 'number', raw: match[1] });
|
|
418
|
+
else if (match[2] !== undefined) tokens.push({ type: 'var', name: match[2].slice(1) });
|
|
419
|
+
else if (match[3] !== undefined) tokens.push({ type: 'call', name: match[3] });
|
|
420
|
+
else tokens.push({ type: 'op', value: match[4] });
|
|
421
|
+
}
|
|
422
|
+
return tokens;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function evaluateScssExpression(expression, resolveVariable, stack) {
|
|
426
|
+
const tokens = tokenizeScssExpression(expression.trim());
|
|
427
|
+
if (!tokens || tokens.length === 0) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
let position = 0;
|
|
431
|
+
const peek = () => tokens[position];
|
|
432
|
+
const next = () => tokens[position++];
|
|
433
|
+
|
|
434
|
+
const parseNumber = (raw) => {
|
|
435
|
+
const parsed = parseLengthToken(raw);
|
|
436
|
+
if (parsed) return { number: parsed.number, unit: parsed.unit };
|
|
437
|
+
const numeric = Number(raw);
|
|
438
|
+
return Number.isFinite(numeric) ? { number: numeric, unit: '' } : null;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const combine = (left, op, right) => {
|
|
442
|
+
if (!left || !right) return null;
|
|
443
|
+
if (op === '*') {
|
|
444
|
+
if (left.unit && right.unit) return null;
|
|
445
|
+
return { number: left.number * right.number, unit: left.unit || right.unit };
|
|
446
|
+
}
|
|
447
|
+
if (op === '/') {
|
|
448
|
+
if (right.number === 0) return null;
|
|
449
|
+
if (right.unit && right.unit !== left.unit) return null;
|
|
450
|
+
return { number: left.number / right.number, unit: right.unit ? '' : left.unit };
|
|
451
|
+
}
|
|
452
|
+
const compatible = left.unit === right.unit || left.number === 0 || right.number === 0;
|
|
453
|
+
if (!compatible) return null;
|
|
454
|
+
return { number: op === '+' ? left.number + right.number : left.number - right.number, unit: left.unit || right.unit };
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const parsePrimary = () => {
|
|
458
|
+
const token = next();
|
|
459
|
+
if (!token) return null;
|
|
460
|
+
if (token.type === 'number') return parseNumber(token.raw);
|
|
461
|
+
if (token.type === 'var') return resolveVariable(token.name, stack);
|
|
462
|
+
if (token.type === 'op' && token.value === '-') {
|
|
463
|
+
const value = parsePrimary();
|
|
464
|
+
return value ? { number: -value.number, unit: value.unit } : null;
|
|
465
|
+
}
|
|
466
|
+
if (token.type === 'op' && token.value === '(') {
|
|
467
|
+
const value = parseExpression();
|
|
468
|
+
const closing = next();
|
|
469
|
+
return closing && closing.type === 'op' && closing.value === ')' ? value : null;
|
|
470
|
+
}
|
|
471
|
+
if (token.type === 'call') {
|
|
472
|
+
const args = [];
|
|
473
|
+
let current = parseExpression();
|
|
474
|
+
args.push(current);
|
|
475
|
+
while (peek() && peek().type === 'op' && peek().value === ',') {
|
|
476
|
+
next();
|
|
477
|
+
args.push(parseExpression());
|
|
478
|
+
}
|
|
479
|
+
const closing = next();
|
|
480
|
+
if (!closing || closing.type !== 'op' || closing.value !== ')') return null;
|
|
481
|
+
if (token.name === 'math.div' && args.length === 2) return combine(args[0], '/', args[1]);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const parseTerm = () => {
|
|
488
|
+
let value = parsePrimary();
|
|
489
|
+
while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
|
|
490
|
+
const op = next().value;
|
|
491
|
+
value = combine(value, op, parsePrimary());
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const parseExpression = () => {
|
|
497
|
+
let value = parseTerm();
|
|
498
|
+
while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
|
|
499
|
+
const op = next().value;
|
|
500
|
+
value = combine(value, op, parseTerm());
|
|
501
|
+
}
|
|
502
|
+
return value;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const result = parseExpression();
|
|
506
|
+
return position === tokens.length ? result : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formatScssValue(value) {
|
|
510
|
+
const rounded = Math.round(value.number * 1000) / 1000;
|
|
511
|
+
if (rounded === 0) return '0';
|
|
512
|
+
return `${rounded}${value.unit}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
237
515
|
function collectJsonTokens(parsed, matchesKind) {
|
|
238
516
|
if (!isPlainObject(parsed)) {
|
|
239
517
|
return [];
|
|
@@ -441,6 +719,7 @@ module.exports = {
|
|
|
441
719
|
VALID_TOKEN_KINDS,
|
|
442
720
|
VALID_TOKEN_SOURCE_FORMATS,
|
|
443
721
|
addDefinition,
|
|
722
|
+
collectScssTokens,
|
|
444
723
|
createTokenKindMatcher,
|
|
445
724
|
getNormalizedValueKeys,
|
|
446
725
|
normalizeTokenKind,
|
|
@@ -15,23 +15,17 @@ import eslintPlugin from 'stylelint-plugin-rhythmguard/eslint';
|
|
|
15
15
|
import { getScalePreset, listScalePresetNames } from 'stylelint-plugin-rhythmguard/presets';
|
|
16
16
|
import recommended from 'stylelint-plugin-rhythmguard/configs/recommended';
|
|
17
17
|
import embed from 'stylelint-plugin-rhythmguard/configs/embed';
|
|
18
|
-
import reactTailwind from 'stylelint-plugin-rhythmguard/configs/react-tailwind';
|
|
19
18
|
import useScale, { ruleName as useScaleName } from 'stylelint-plugin-rhythmguard/rules/use-scale';
|
|
20
19
|
|
|
21
20
|
const pluginConfigs: readonly RhythmguardStylelintConfig[] = [
|
|
22
21
|
plugin.configs.recommended,
|
|
23
22
|
plugin.configs.strict,
|
|
24
23
|
plugin.configs.tailwind,
|
|
25
|
-
plugin.configs.expanded,
|
|
26
|
-
plugin.configs.logical,
|
|
27
|
-
plugin.configs.migration,
|
|
28
24
|
plugin.configs.motion,
|
|
29
|
-
plugin.configs['react-tailwind'],
|
|
30
25
|
configs.recommended,
|
|
31
26
|
plugin.configs.embed,
|
|
32
27
|
embed,
|
|
33
28
|
recommended,
|
|
34
|
-
reactTailwind,
|
|
35
29
|
];
|
|
36
30
|
|
|
37
31
|
const ruleOptions: RhythmguardRuleOptions = {
|
package/types/audit.d.ts
CHANGED
|
@@ -60,7 +60,12 @@ export interface AuditSummary {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export interface AuditScanned {
|
|
63
|
+
/** Authored stylesheets scanned: .css plus .scss when postcss-scss is available. */
|
|
63
64
|
cssFiles: number;
|
|
65
|
+
/** .scss files found. Audited through postcss-scss when it resolves. */
|
|
66
|
+
scssFiles?: number;
|
|
67
|
+
/** .scss files found but not audited because postcss-scss is not installed. */
|
|
68
|
+
scssSkipped?: number;
|
|
64
69
|
templateFiles: number;
|
|
65
70
|
totalFiles?: number;
|
|
66
71
|
[key: string]: unknown;
|
|
@@ -88,7 +93,7 @@ export interface AuditBaselineComparison {
|
|
|
88
93
|
[key: string]: unknown;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-sources";
|
|
96
|
+
export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
|
|
92
97
|
|
|
93
98
|
export interface AuditScale {
|
|
94
99
|
/** Files the scale was derived from (token sources or scanned stylesheets). Empty for explicit, default and fallback. */
|
package/types/index.d.ts
CHANGED
|
@@ -39,11 +39,7 @@ export interface RhythmguardPlugin extends Array<StylelintRuleModule> {
|
|
|
39
39
|
audit: typeof auditApi;
|
|
40
40
|
configs: {
|
|
41
41
|
embed: RhythmguardStylelintConfig;
|
|
42
|
-
expanded: RhythmguardStylelintConfig;
|
|
43
|
-
logical: RhythmguardStylelintConfig;
|
|
44
|
-
migration: RhythmguardStylelintConfig;
|
|
45
42
|
motion: RhythmguardStylelintConfig;
|
|
46
|
-
"react-tailwind": RhythmguardStylelintConfig;
|
|
47
43
|
recommended: RhythmguardStylelintConfig;
|
|
48
44
|
strict: RhythmguardStylelintConfig;
|
|
49
45
|
tailwind: RhythmguardStylelintConfig;
|
package/types/shared.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface ScaleSource {
|
|
|
10
10
|
/** `auto` (default), `css`, `flat-json`, `style-dictionary`, or `dtcg`. */
|
|
11
11
|
format?: string;
|
|
12
12
|
path?: string;
|
|
13
|
+
/** Regex for token names in this file, overriding the spacing kind matcher (for packages that name spacing differently). */
|
|
14
|
+
tokenPattern?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface RhythmguardRuleOptions {
|