stylelint-plugin-rhythmguard 3.10.2 → 3.12.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +5 -1
  3. package/package.json +1 -1
  4. package/src/audit/args.js +22 -2
  5. package/src/audit/baseline.js +184 -27
  6. package/src/audit/codemod.js +39 -14
  7. package/src/audit/config.js +25 -31
  8. package/src/audit/contract.js +96 -10
  9. package/src/audit/render-github.js +4 -0
  10. package/src/audit/render-html.js +7 -0
  11. package/src/audit/render-markdown.js +27 -0
  12. package/src/audit/render-text.js +23 -1
  13. package/src/audit/report.js +60 -17
  14. package/src/audit/scan/files.js +154 -27
  15. package/src/audit/scan/stylesheets.js +87 -34
  16. package/src/audit/scan/templates.js +22 -7
  17. package/src/audit/token-chains.js +18 -7
  18. package/src/cli/audit.js +7 -3
  19. package/src/cli/doctor.js +48 -7
  20. package/src/cli/fix.js +56 -15
  21. package/src/cli/init.js +136 -23
  22. package/src/cli/quickstart.js +34 -8
  23. package/src/core/fs-cache.js +6 -0
  24. package/src/core/length.js +15 -0
  25. package/src/core/options.js +8 -1
  26. package/src/core/scale-inference.js +99 -68
  27. package/src/core/tailwind-class-analysis.js +26 -16
  28. package/src/core/tailwind-motion-analysis.js +12 -3
  29. package/src/core/token-index.js +76 -24
  30. package/src/core/token-map.js +23 -10
  31. package/src/core/token-source-entries.js +64 -0
  32. package/src/core/token-sources.js +361 -69
  33. package/src/core/value-nodes.js +280 -54
  34. package/src/eslint/literal-spans.js +319 -0
  35. package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
  36. package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
  37. package/src/rules/no-offscale-transform/index.js +35 -14
  38. package/src/rules/prefer-token/index.js +72 -48
  39. package/src/rules/report.js +3 -1
  40. package/src/rules/use-motion-scale/index.js +68 -5
  41. package/src/rules/use-scale/index.js +54 -39
  42. package/types/__checks__/eslint-config.ts +37 -0
  43. package/types/audit.d.ts +10 -2
  44. package/types/shared.d.ts +21 -3
@@ -39,7 +39,9 @@ const TOKEN_KIND_PATTERNS = Object.freeze({
39
39
  // (an optional `system-` prefix allowed): $spacer, $spacers.3, $spacing-01, $system-spacing.
40
40
  // Component variables such as $dropdown-spacer or $card-spacer-y are not scale tokens.
41
41
  spacing: /^(?:\$(?:system-)?(?:space|spacing|spacer)s?(?:-|$|\.)|--(?:[\w-]*-)?(?<!letter-)(?<!word-)(?:space|spacing|spacer)(?:-|$))/,
42
- typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
42
+ // Tailwind's --text-* and the --fs-* shorthand are font sizes, and tracking
43
+ // tokens (--letter-spacing-*, --tw-word-spacing) are type, never spacing (issue #239).
44
+ typography: /^--(?:(?:font|font-size|line-height|leading|tracking|typography|text|fs)-|(?:[\w-]*-)?(?:letter|word)-spacing(?:-|$))/,
43
45
  });
44
46
 
45
47
  function isPlainObject(value) {
@@ -127,16 +129,17 @@ function parseTokenSources({
127
129
  const matchesSource = normalizedSource.tokenPattern
128
130
  ? createPatternMatcher(normalizedSource.tokenPattern, matchesKind)
129
131
  : matchesKind;
132
+ const skipped = [];
130
133
  try {
131
134
  if (normalizedSource.format === 'css') {
132
- tokens = collectCssTokens(text, matchesSource);
135
+ tokens = collectCssTokens(text, matchesSource, { lineComments: hasLineComments(normalizedSource.resolvedPath) });
133
136
  } else {
134
137
  const parsed = JSON.parse(text);
135
138
  const detectedFormat = normalizedSource.requestedFormat === 'auto'
136
139
  ? detectJsonTokenFormat(parsed)
137
140
  : normalizedSource.format;
138
141
  sourceReport.format = detectedFormat;
139
- tokens = collectJsonTokens(parsed, matchesSource);
142
+ tokens = collectJsonTokens(parsed, matchesSource, (token, reason) => skipped.push(`${token} (${reason})`));
140
143
  }
141
144
  } catch (err) {
142
145
  const warning = `Unable to parse token source ${normalizedSource.displayPath}: ${err.message}`;
@@ -146,10 +149,19 @@ function parseTokenSources({
146
149
  continue;
147
150
  }
148
151
 
152
+ if (skipped.length > 0) {
153
+ const warning = `Token source ${normalizedSource.displayPath}: skipped ${skipped.join(', ')}.`;
154
+ sourceReport.warnings.push(warning);
155
+ warnings.push(warning);
156
+ }
157
+
149
158
  for (const token of tokens) {
150
159
  addDefinition(definitions, {
151
160
  baseFontSize,
152
161
  file: sourceReport.file,
162
+ // `:root` and `@theme` declarations are the scale; a `.chip` variable is a
163
+ // component parameter and stays out of it, as in the linted stylesheet (issue #258).
164
+ scope: token.scope || 'root',
153
165
  source: sourceReport.file,
154
166
  token: token.token,
155
167
  value: token.value,
@@ -236,25 +248,125 @@ function detectJsonTokenFormat(parsed) {
236
248
  return 'flat-json';
237
249
  }
238
250
 
239
- function collectCssTokens(source, matchesKind) {
251
+ /**
252
+ * Custom properties and Sass tokens in a stylesheet. `lineComments` says
253
+ * whether `//` starts a comment: true for SCSS and Less, false for a plain
254
+ * `.css` file (see hasLineComments).
255
+ */
256
+ function collectCssTokens(source, matchesKind, { lineComments = true } = {}) {
240
257
  const tokens = [];
241
- for (const declaration of customPropertyDeclarations(source)) {
258
+ for (const declaration of customPropertyDeclarations(source, { lineComments })) {
242
259
  if (matchesKind(declaration.token)) {
243
260
  tokens.push(declaration);
244
261
  }
245
262
  }
246
- for (const sassToken of collectScssTokens(source, matchesKind)) {
263
+ for (const sassToken of collectScssTokens(source, matchesKind, { lineComments })) {
247
264
  tokens.push({ ...sassToken, scope: 'root' });
248
265
  }
249
266
  return tokens;
250
267
  }
251
268
 
269
+ /** Plain CSS has no `//` comments; SCSS, Less and the other preprocessor syntaxes do. */
270
+ function hasLineComments(filePath) {
271
+ return path.extname(String(filePath || '')).toLowerCase() !== '.css';
272
+ }
273
+
252
274
  const ROOT_SELECTOR = /^(?::root|html|:host|:(?:where|is)\(\s*(?::root|html)\s*\))$/i;
253
275
  // At-rules that only condition their contents; the enclosing selector still decides the scope.
254
276
  const TRANSPARENT_AT_RULES = new Set(['media', 'supports', 'layer', 'container', 'scope', 'document']);
255
277
 
256
- function stripComments(text) {
257
- return text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
278
+ /**
279
+ * Whether `//` at `index` starts a line comment. Never in plain CSS, and never
280
+ * right after `:` or `(`, so `https://` and an unquoted `url(//cdn...)` stay
281
+ * values (issue #263).
282
+ */
283
+ function startsLineComment(source, index, lineComments) {
284
+ return lineComments
285
+ && source[index] === '/'
286
+ && source[index + 1] === '/'
287
+ && !/[:(]/.test(source[index - 1] || '');
288
+ }
289
+
290
+ /** The index just past the string that opens at `index`, honouring backslash escapes. */
291
+ function skipString(source, index) {
292
+ const quote = source[index];
293
+ let end = index + 1;
294
+ while (end < source.length && source[end] !== quote) end += source[end] === '\\' ? 2 : 1;
295
+ return Math.min(end + 1, source.length);
296
+ }
297
+
298
+ /**
299
+ * The text with comments removed and strings kept as written. A block comment
300
+ * becomes a space so the tokens on either side stay apart; a line comment is
301
+ * dropped up to its newline.
302
+ */
303
+ function stripComments(text, { lineComments = true } = {}) {
304
+ let output = '';
305
+ let index = 0;
306
+ while (index < text.length) {
307
+ const char = text[index];
308
+ if (char === '"' || char === "'") {
309
+ const end = skipString(text, index);
310
+ output += text.slice(index, end);
311
+ index = end;
312
+ continue;
313
+ }
314
+ if (char === '/' && text[index + 1] === '*') {
315
+ const end = text.indexOf('*/', index + 2);
316
+ output += ' ';
317
+ index = end === -1 ? text.length : end + 2;
318
+ continue;
319
+ }
320
+ if (startsLineComment(text, index, lineComments)) {
321
+ const end = text.indexOf('\n', index);
322
+ index = end === -1 ? text.length : end;
323
+ continue;
324
+ }
325
+ output += char;
326
+ index += 1;
327
+ }
328
+ return output;
329
+ }
330
+
331
+ /**
332
+ * The value of a custom property declaration whose value starts at `start`:
333
+ * the text up to the `;` that ends it (outside strings and parentheses) or up
334
+ * to the brace that closes its block. Comments are dropped and a trailing
335
+ * `!important` is removed, so `4px !important` and `4px` followed by a
336
+ * comment are both `4px` (issue #269). Returns the value and where it ends.
337
+ */
338
+ function readCustomPropertyValue(source, start, lineComments) {
339
+ let value = '';
340
+ let depth = 0;
341
+ let index = start;
342
+ while (index < source.length) {
343
+ const char = source[index];
344
+ if (char === '"' || char === "'") {
345
+ const end = skipString(source, index);
346
+ value += source.slice(index, end);
347
+ index = end;
348
+ continue;
349
+ }
350
+ if (char === '/' && source[index + 1] === '*') {
351
+ const end = source.indexOf('*/', index + 2);
352
+ value += ' ';
353
+ index = end === -1 ? source.length : end + 2;
354
+ continue;
355
+ }
356
+ if (startsLineComment(source, index, lineComments)) {
357
+ const end = source.indexOf('\n', index);
358
+ index = end === -1 ? source.length : end;
359
+ continue;
360
+ }
361
+ if (char === '{' || char === '}' || (char === ';' && depth === 0)) {
362
+ break;
363
+ }
364
+ if (char === '(') depth += 1;
365
+ else if (char === ')') depth = Math.max(0, depth - 1);
366
+ value += char;
367
+ index += 1;
368
+ }
369
+ return { end: index, value: value.replace(/\s*!\s*important\s*$/i, '').trim() };
258
370
  }
259
371
 
260
372
  function scopeOfBlockHeaders(headers) {
@@ -278,7 +390,7 @@ function scopeOfBlockHeaders(headers) {
278
390
  * skips comments and strings, so a brace in a comment cannot shift the
279
391
  * scope; this is the text-side twin of scopeOfNode in scale-inference.js.
280
392
  */
281
- function customPropertyDeclarations(source) {
393
+ function customPropertyDeclarations(source, { lineComments = true } = {}) {
282
394
  const declarations = [];
283
395
  const headers = [];
284
396
  let headerStart = 0;
@@ -294,15 +406,13 @@ function customPropertyDeclarations(source) {
294
406
  index = end === -1 ? length : end + 2;
295
407
  continue;
296
408
  }
297
- if (char === '/' && next === '/') {
409
+ if (startsLineComment(source, index, lineComments)) {
298
410
  const end = source.indexOf('\n', index);
299
411
  index = end === -1 ? length : end;
300
412
  continue;
301
413
  }
302
414
  if (char === '"' || char === "'") {
303
- let end = index + 1;
304
- while (end < length && source[end] !== char) end += source[end] === '\\' ? 2 : 1;
305
- index = end + 1;
415
+ index = skipString(source, index);
306
416
  continue;
307
417
  }
308
418
  if (char === '{') {
@@ -311,7 +421,7 @@ function customPropertyDeclarations(source) {
311
421
  index = end === -1 ? length : end + 1;
312
422
  continue;
313
423
  }
314
- headers.push(stripComments(source.slice(headerStart, index)).trim().replace(/\s+/g, ' '));
424
+ headers.push(stripComments(source.slice(headerStart, index), { lineComments }).trim().replace(/\s+/g, ' '));
315
425
  headerStart = index + 1;
316
426
  index += 1;
317
427
  continue;
@@ -328,10 +438,13 @@ function customPropertyDeclarations(source) {
328
438
  continue;
329
439
  }
330
440
  if (char === '-' && next === '-' && !/[\w-]/.test(source[index - 1] || '')) {
331
- const match = /^(--[\w-]+)\s*:\s*([^;{}]+)/.exec(source.slice(index));
441
+ const match = /^(--[\w-]+)\s*:/.exec(source.slice(index, index + 256));
332
442
  if (match) {
333
- declarations.push({ scope: scopeOfBlockHeaders(headers), token: match[1], value: match[2].trim() });
334
- index += match[0].length;
443
+ const { end, value } = readCustomPropertyValue(source, index + match[0].length, lineComments);
444
+ if (value) {
445
+ declarations.push({ scope: scopeOfBlockHeaders(headers), token: match[1], value });
446
+ }
447
+ index = end;
335
448
  continue;
336
449
  }
337
450
  }
@@ -348,8 +461,10 @@ function customPropertyDeclarations(source) {
348
461
  * evaluate (function calls, strings, keywords, interpolated keys, cycles) is
349
462
  * skipped rather than guessed. Token names are `$name` or `$name.key.path`.
350
463
  */
351
- function collectScssTokens(source, matchesKind) {
352
- const declarations = parseScssDeclarations(source);
464
+ function collectScssTokens(source, matchesKind, { lineComments = true } = {}) {
465
+ // Only top-level and `!global` declarations define the project's variables;
466
+ // `$spacer: 2px` inside `.compact` is local to that block (issue #266).
467
+ const declarations = parseScssDeclarations(source, { lineComments, topLevelOnly: true });
353
468
  const cache = new Map();
354
469
 
355
470
  const resolveVariable = (name, stack) => {
@@ -447,45 +562,119 @@ function createScssVariableResolver(source, fallback = () => null) {
447
562
  const NAMESPACED_SPACING_MAP = /^\$[a-z0-9]+-(?:space|spacing|spacer)s?(?:-|$)/i;
448
563
  const MIN_NAMESPACED_MAP_LENGTHS = 4;
449
564
 
450
- function stripScssComments(source) {
451
- return source
452
- .replace(/\/\*[\s\S]*?\*\//g, '')
453
- .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
565
+ /**
566
+ * The value of a Sass variable declaration starting at `start`: the text up to
567
+ * the `;` outside parentheses and strings. With `stopAtBrace`, a `}` outside
568
+ * parentheses also ends it (a last declaration without `;` in a block), and an
569
+ * interpolation `#{...}` is kept whole.
570
+ */
571
+ function readScssValue(text, start, stopAtBrace) {
572
+ let depth = 0;
573
+ let index = start;
574
+ while (index < text.length) {
575
+ const char = text[index];
576
+ if (char === '"' || char === "'") {
577
+ index = skipString(text, index);
578
+ continue;
579
+ }
580
+ if (stopAtBrace && char === '#' && text[index + 1] === '{') {
581
+ const end = text.indexOf('}', index + 2);
582
+ index = end === -1 ? text.length : end + 1;
583
+ continue;
584
+ }
585
+ if (char === '(') depth += 1;
586
+ else if (char === ')') depth -= 1;
587
+ else if (char === ';' && depth <= 0) break;
588
+ else if (stopAtBrace && depth <= 0 && (char === '}' || char === '{')) break;
589
+ index += 1;
590
+ }
591
+ return { end: index, value: text.slice(start, index) };
454
592
  }
455
593
 
456
- function parseScssDeclarations(source) {
457
- const text = stripScssComments(source);
458
- const declarations = new Map();
459
- const startPattern = /(?:^|\n)[ \t]*\$([\w-]+)[ \t]*:/g;
460
- let match;
461
-
462
- while ((match = startPattern.exec(text)) !== null) {
463
- const name = match[1];
464
- let index = match.index + match[0].length;
465
- let depth = 0;
466
- let quote = null;
467
- let value = '';
468
-
469
- for (; index < text.length; index += 1) {
470
- const char = text[index];
471
- if (quote) {
472
- value += char;
473
- if (char === quote) quote = null;
594
+ /**
595
+ * The start of every top-level Sass variable declaration, plus those inside a
596
+ * block that carry `!global`. Declarations in style rules, mixins, functions,
597
+ * `@media` and control-flow blocks are local or conditional, and `$name:`
598
+ * inside parentheses is a parameter or a `@use ... with (...)` setting, so
599
+ * none of those count. Several declarations may share a line.
600
+ */
601
+ function topLevelScssDeclarations(text) {
602
+ const found = [];
603
+ let braceDepth = 0;
604
+ let parenDepth = 0;
605
+ let statementStart = true;
606
+ let index = 0;
607
+ while (index < text.length) {
608
+ const char = text[index];
609
+ if (char === '"' || char === "'") {
610
+ index = skipString(text, index);
611
+ statementStart = false;
612
+ continue;
613
+ }
614
+ if (char === '#' && text[index + 1] === '{') {
615
+ const end = text.indexOf('}', index + 2);
616
+ index = end === -1 ? text.length : end + 1;
617
+ statementStart = false;
618
+ continue;
619
+ }
620
+ if (char === '{' || char === '}') {
621
+ braceDepth = Math.max(0, braceDepth + (char === '{' ? 1 : -1));
622
+ parenDepth = 0;
623
+ statementStart = true;
624
+ index += 1;
625
+ continue;
626
+ }
627
+ if (char === ';') {
628
+ statementStart = parenDepth === 0;
629
+ index += 1;
630
+ continue;
631
+ }
632
+ if (/\s/.test(char)) {
633
+ index += 1;
634
+ continue;
635
+ }
636
+ if (char === '$' && statementStart && parenDepth === 0) {
637
+ const match = /^\$([\w-]+)\s*:/.exec(text.slice(index, index + 256));
638
+ if (match) {
639
+ const { end, value } = readScssValue(text, index + match[0].length, true);
640
+ if (braceDepth === 0 || /!global\b/.test(value)) {
641
+ found.push({ name: match[1], value });
642
+ }
643
+ index = end;
644
+ statementStart = false;
474
645
  continue;
475
646
  }
476
- if (char === '"' || char === "'") {
477
- quote = char;
478
- } else if (char === '(') {
479
- depth += 1;
480
- } else if (char === ')') {
481
- depth -= 1;
482
- } else if (char === ';' && depth === 0) {
483
- break;
484
- }
485
- value += char;
486
647
  }
648
+ if (char === '(') parenDepth += 1;
649
+ else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
650
+ statementStart = false;
651
+ index += 1;
652
+ }
653
+ return found;
654
+ }
655
+
656
+ /**
657
+ * Every Sass variable declaration that starts a line, wherever it is. The
658
+ * resolver for a linted stylesheet's own values reads these; token collection
659
+ * reads topLevelScssDeclarations instead.
660
+ */
661
+ function lineStartScssDeclarations(text) {
662
+ const found = [];
663
+ const startPattern = /(?:^|\n)[ \t]*\$([\w-]+)[ \t]*:/g;
664
+ let match;
665
+ while ((match = startPattern.exec(text)) !== null) {
666
+ const { end, value } = readScssValue(text, match.index + match[0].length, false);
667
+ found.push({ name: match[1], value });
668
+ startPattern.lastIndex = end;
669
+ }
670
+ return found;
671
+ }
672
+
673
+ function parseScssDeclarations(source, { lineComments = true, topLevelOnly = false } = {}) {
674
+ const text = stripComments(source, { lineComments });
675
+ const declarations = new Map();
487
676
 
488
- startPattern.lastIndex = index;
677
+ for (const { name, value } of topLevelOnly ? topLevelScssDeclarations(text) : lineStartScssDeclarations(text)) {
489
678
  const isDefault = /!default\b/.test(value);
490
679
  const cleaned = value.replace(/!(default|global)\b/g, '').trim();
491
680
  // Sass semantics at the top level: `!default` assigns only when the variable is
@@ -764,17 +953,26 @@ function formatScssValue(value) {
764
953
  return `${rounded}${value.unit}`;
765
954
  }
766
955
 
767
- function collectJsonTokens(parsed, matchesKind) {
956
+ /**
957
+ * Tokens in a DTCG, Style Dictionary or flat JSON file. References
958
+ * (`{dimension.100}`, `{size.base.value}`) are resolved against the whole file
959
+ * first, so a semantic alias over a primitive of another kind still counts
960
+ * (issue #267). `onSkip(token, reason)` hears about tokens of the requested
961
+ * kind that were left out, such as a dimension in an unsupported unit.
962
+ */
963
+ function collectJsonTokens(parsed, matchesKind, onSkip = () => {}) {
768
964
  if (!isPlainObject(parsed)) {
769
965
  return [];
770
966
  }
771
967
 
772
968
  const tokens = [];
773
- walkJsonTokenGroup(parsed, [], tokens, matchesKind);
969
+ const context = { matchesKind, onSkip, root: parsed };
970
+ walkJsonTokenGroup(parsed, [], tokens, context);
774
971
  return tokens;
775
972
  }
776
973
 
777
- function walkJsonTokenGroup(group, segments, tokens, matchesKind) {
974
+ function walkJsonTokenGroup(group, segments, tokens, context) {
975
+ const { matchesKind, onSkip } = context;
778
976
  for (const [key, value] of Object.entries(group)) {
779
977
  if (isMetadataKey(key)) {
780
978
  continue;
@@ -792,21 +990,22 @@ function walkJsonTokenGroup(group, segments, tokens, matchesKind) {
792
990
  continue;
793
991
  }
794
992
 
795
- const leafValue = leafTokenValue(value);
796
- if (leafValue !== null) {
993
+ const leaf = leafTokenValue(value, context.root);
994
+ if (leaf !== null) {
797
995
  const token = key.startsWith('--')
798
996
  ? key
799
997
  : toCustomProperty([...segments, key]);
800
998
  if (matchesKind(token)) {
801
- tokens.push({
802
- token,
803
- value: leafValue,
804
- });
999
+ if (leaf.value !== null) {
1000
+ tokens.push({ token, value: leaf.value });
1001
+ } else if (leaf.skipped) {
1002
+ onSkip(token, leaf.skipped);
1003
+ }
805
1004
  }
806
1005
  continue;
807
1006
  }
808
1007
 
809
- walkJsonTokenGroup(value, [...segments, key], tokens, matchesKind);
1008
+ walkJsonTokenGroup(value, [...segments, key], tokens, context);
810
1009
  }
811
1010
  }
812
1011
 
@@ -842,16 +1041,105 @@ function tokenFromPrimitive(key, value, segments) {
842
1041
  return null;
843
1042
  }
844
1043
 
845
- function leafTokenValue(value) {
846
- if (typeof value.$value === 'string' || typeof value.$value === 'number') {
847
- return String(value.$value);
1044
+ // DTCG 2025.10 dimensions are `{ "value": 4, "unit": "px" }`; the format allows px and rem.
1045
+ const DTCG_DIMENSION_UNITS = new Set(['px', 'rem']);
1046
+ const JSON_REFERENCE = /^\{([^{}]+)\}$/;
1047
+ const MAX_JSON_REFERENCE_DEPTH = 32;
1048
+
1049
+ function isDimensionObject(value) {
1050
+ return isPlainObject(value)
1051
+ && typeof value.value === 'number'
1052
+ && typeof value.unit === 'string';
1053
+ }
1054
+
1055
+ /**
1056
+ * The raw value a token node holds: `$value` (DTCG, any shape) or a string or
1057
+ * number `value` (Style Dictionary). Undefined when the node is a group.
1058
+ */
1059
+ function rawTokenValue(node) {
1060
+ if (!isPlainObject(node)) {
1061
+ return undefined;
1062
+ }
1063
+ if (Object.prototype.hasOwnProperty.call(node, '$value')) {
1064
+ const raw = node.$value;
1065
+ if (typeof raw === 'string' || typeof raw === 'number' || isDimensionObject(raw)) {
1066
+ return raw;
1067
+ }
1068
+ return undefined;
1069
+ }
1070
+ if (isDimensionObject(node)) {
1071
+ // `{ "value": 4, "unit": "rem" }` is one dimension, not a number with metadata.
1072
+ return node;
1073
+ }
1074
+ if (typeof node.value === 'string' || typeof node.value === 'number') {
1075
+ return node.value;
1076
+ }
1077
+ return undefined;
1078
+ }
1079
+
1080
+ /**
1081
+ * The node a reference path names: `dimension.100` is a token, and in Style
1082
+ * Dictionary's `size.base.value` the last segment is the value itself.
1083
+ */
1084
+ function lookupJsonReference(root, reference) {
1085
+ let node = root;
1086
+ for (const segment of reference.split('.').map((part) => part.trim())) {
1087
+ if (!isPlainObject(node) || !Object.prototype.hasOwnProperty.call(node, segment)) {
1088
+ return undefined;
1089
+ }
1090
+ node = node[segment];
848
1091
  }
1092
+ return isPlainObject(node) ? rawTokenValue(node) : node;
1093
+ }
849
1094
 
850
- if (typeof value.value === 'string' || typeof value.value === 'number') {
851
- return String(value.value);
1095
+ /**
1096
+ * A raw token value as a string: a number as written, a dimension object as
1097
+ * `4px` or `0.75rem`, a whole-value reference followed through the file. A
1098
+ * reference that cannot be followed (a missing target, a cycle) keeps its text,
1099
+ * as before, and reads as a non-length; a dimension in another unit is skipped.
1100
+ */
1101
+ function resolveJsonValue(raw, root, stack = new Set()) {
1102
+ if (typeof raw === 'number') {
1103
+ return { value: String(raw) };
1104
+ }
1105
+ if (isDimensionObject(raw)) {
1106
+ const unit = raw.unit.trim().toLowerCase();
1107
+ if (!Number.isFinite(raw.value) || !DTCG_DIMENSION_UNITS.has(unit)) {
1108
+ return { skipped: `dimension unit "${raw.unit}" is not px or rem`, value: null };
1109
+ }
1110
+ return { value: `${raw.value}${unit}` };
1111
+ }
1112
+ if (typeof raw !== 'string') {
1113
+ return { value: null };
852
1114
  }
1115
+ const reference = raw.trim().match(JSON_REFERENCE);
1116
+ if (!reference) {
1117
+ return { value: raw };
1118
+ }
1119
+ const target = reference[1].trim();
1120
+ if (stack.has(target) || stack.size >= MAX_JSON_REFERENCE_DEPTH) {
1121
+ return { value: raw };
1122
+ }
1123
+ const next = lookupJsonReference(root, target);
1124
+ if (next === undefined || next === null) {
1125
+ return { value: raw };
1126
+ }
1127
+ stack.add(target);
1128
+ const resolved = resolveJsonValue(next, root, stack);
1129
+ stack.delete(target);
1130
+ if (resolved.value === null && !resolved.skipped) {
1131
+ return { value: raw };
1132
+ }
1133
+ return resolved;
1134
+ }
853
1135
 
854
- return null;
1136
+ /** The value of a token node, resolved; null when the node is a group, not a token. */
1137
+ function leafTokenValue(node, root = node) {
1138
+ const raw = rawTokenValue(node);
1139
+ if (raw === undefined) {
1140
+ return null;
1141
+ }
1142
+ return resolveJsonValue(raw, root);
855
1143
  }
856
1144
 
857
1145
  function isMetadataKey(key) {
@@ -952,7 +1240,10 @@ function getNormalizedValueKeys(value, baseFontSize = 16) {
952
1240
 
953
1241
  const keys = new Set([raw]);
954
1242
  const parsed = parseTokenValueLength(raw);
955
- if (!parsed) {
1243
+ // A non-zero number without a unit (`--space-ratio: 1.5`) is a ratio or a
1244
+ // multiplier, not a px length (issue #268). Flat-JSON numbers get their px
1245
+ // unit explicitly in tokenFromPrimitive before they reach this point.
1246
+ if (!parsed || (parsed.unit === '' && parsed.number !== 0)) {
956
1247
  return Array.from(keys);
957
1248
  }
958
1249
 
@@ -982,6 +1273,7 @@ module.exports = {
982
1273
  customPropertyDeclarations,
983
1274
  createTokenKindMatcher,
984
1275
  getNormalizedValueKeys,
1276
+ hasLineComments,
985
1277
  normalizeTokenKind,
986
1278
  normalizeTokenSourceFormat,
987
1279
  parseTokenSources,