stylelint-plugin-rhythmguard 2.2.0 → 3.1.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.
@@ -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
- spacing: /(?:^--|-)(?<!letter-)(?<!word-)(?:space|spacing)(?:-|$)/,
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, matchesKind);
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, matchesKind);
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;
@@ -72,7 +77,8 @@ export interface AuditFinding {
72
77
  key?: string;
73
78
  line?: number;
74
79
  message: string;
75
- property?: string;
80
+ /** CSS property of the declaration, recovered from the source. Null when the position is not inside a declaration. CSS findings only. */
81
+ property?: string | null;
76
82
  rule?: string;
77
83
  type: string;
78
84
  value?: string;
@@ -88,7 +94,7 @@ export interface AuditBaselineComparison {
88
94
  [key: string]: unknown;
89
95
  }
90
96
 
91
- export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-sources";
97
+ export type AuditScaleSource = "default" | "explicit" | "fallback" | "scanned-css" | "token-package" | "token-sources";
92
98
 
93
99
  export interface AuditScale {
94
100
  /** Files the scale was derived from (token sources or scanned stylesheets). Empty for explicit, default and fallback. */
@@ -108,6 +114,10 @@ export interface AuditReport {
108
114
  motion: AuditFinding[];
109
115
  tailwind: AuditFinding[];
110
116
  };
117
+ /** Off-scale CSS findings counted by property, top ten. */
118
+ offScaleProperties?: Record<string, number>;
119
+ /** Off-scale CSS findings counted by value, top ten. */
120
+ offScaleValues?: Record<string, number>;
111
121
  scanned: AuditScanned;
112
122
  summary: AuditSummary;
113
123
  [key: string]: unknown;
@@ -125,6 +135,7 @@ export interface AuditContractReport {
125
135
  scale: {
126
136
  cleanliness?: unknown;
127
137
  files: string[];
138
+ offScaleProperties?: Record<string, number>;
128
139
  offScaleValues?: unknown;
129
140
  source: AuditScaleSource;
130
141
  tokenOpportunities?: unknown;
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 {
@@ -1,27 +0,0 @@
1
- 'use strict';
2
-
3
- module.exports = {
4
- plugins: ['stylelint-plugin-rhythmguard'],
5
- rules: {
6
- 'rhythmguard/use-scale': [
7
- true,
8
- {
9
- propertyGroups: ['spacing', 'radius', 'typography', 'size'],
10
- scale: [0, 2, 4, 8, 12, 16, 24, 32, 40, 48, 64],
11
- },
12
- ],
13
- 'rhythmguard/no-offscale-transform': [
14
- true,
15
- {
16
- scale: [0, 4, 8, 12, 16, 24, 32],
17
- },
18
- ],
19
- 'rhythmguard/prefer-token': [
20
- true,
21
- {
22
- propertyGroups: ['spacing', 'radius'],
23
- tokenPattern: '^--space-|^--radius-',
24
- },
25
- ],
26
- },
27
- };
@@ -1,4 +0,0 @@
1
- import { createRequire } from 'node:module';
2
- const require = createRequire(import.meta.url);
3
- const config = require('./expanded.js');
4
- export default config;
@@ -1,16 +0,0 @@
1
- 'use strict';
2
-
3
- module.exports = {
4
- extends: [
5
- 'stylelint-plugin-logical-css/configs/recommended',
6
- 'stylelint-plugin-rhythmguard/configs/strict',
7
- ],
8
- rules: {
9
- 'rhythmguard/use-scale': [
10
- true,
11
- {
12
- propertyGroups: ['spacing', 'size'],
13
- },
14
- ],
15
- },
16
- };
@@ -1,4 +0,0 @@
1
- import { createRequire } from 'node:module';
2
- const require = createRequire(import.meta.url);
3
- const config = require('./logical.js');
4
- export default config;
@@ -1,31 +0,0 @@
1
- 'use strict';
2
-
3
- module.exports = {
4
- plugins: ['stylelint-plugin-rhythmguard'],
5
- rules: {
6
- 'rhythmguard/use-scale': [
7
- true,
8
- {
9
- propertyGroups: ['spacing', 'radius'],
10
- scale: [0, 2, 4, 8, 12, 16, 24, 32, 40, 48, 64],
11
- },
12
- ],
13
- 'rhythmguard/prefer-token': [
14
- true,
15
- {
16
- allowNumericScale: true,
17
- propertyGroups: ['spacing', 'radius'],
18
- tokenMapFromCssCustomProperties: true,
19
- tokenMapFromTailwindSpacing: true,
20
- tailwindConfigPath: './tailwind.config.js',
21
- tokenPattern: '^--space-|^--radius-',
22
- },
23
- ],
24
- 'rhythmguard/no-offscale-transform': [
25
- true,
26
- {
27
- scale: [0, 4, 8, 12, 16, 24, 32],
28
- },
29
- ],
30
- },
31
- };
@@ -1,4 +0,0 @@
1
- import { createRequire } from 'node:module';
2
- const require = createRequire(import.meta.url);
3
- const config = require('./migration.js');
4
- export default config;
@@ -1,25 +0,0 @@
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
- };
@@ -1,4 +0,0 @@
1
- import { createRequire } from 'node:module';
2
- const require = createRequire(import.meta.url);
3
- const config = require('./react-tailwind.js');
4
- export default config;