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.
- package/CHANGELOG.md +77 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/baseline.js +184 -27
- package/src/audit/codemod.js +39 -14
- package/src/audit/config.js +25 -31
- package/src/audit/contract.js +96 -10
- package/src/audit/render-github.js +4 -0
- package/src/audit/render-html.js +7 -0
- package/src/audit/render-markdown.js +27 -0
- package/src/audit/render-text.js +23 -1
- package/src/audit/report.js +60 -17
- package/src/audit/scan/files.js +154 -27
- package/src/audit/scan/stylesheets.js +87 -34
- package/src/audit/scan/templates.js +22 -7
- package/src/audit/token-chains.js +18 -7
- package/src/cli/audit.js +7 -3
- package/src/cli/doctor.js +48 -7
- package/src/cli/fix.js +56 -15
- package/src/cli/init.js +136 -23
- package/src/cli/quickstart.js +34 -8
- package/src/core/fs-cache.js +6 -0
- package/src/core/length.js +15 -0
- package/src/core/options.js +8 -1
- package/src/core/scale-inference.js +99 -68
- package/src/core/tailwind-class-analysis.js +26 -16
- package/src/core/tailwind-motion-analysis.js +12 -3
- package/src/core/token-index.js +76 -24
- package/src/core/token-map.js +23 -10
- package/src/core/token-source-entries.js +64 -0
- package/src/core/token-sources.js +361 -69
- package/src/core/value-nodes.js +280 -54
- package/src/eslint/literal-spans.js +319 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
- package/src/rules/no-offscale-transform/index.js +35 -14
- package/src/rules/prefer-token/index.js +72 -48
- package/src/rules/report.js +3 -1
- package/src/rules/use-motion-scale/index.js +68 -5
- package/src/rules/use-scale/index.js +54 -39
- package/types/__checks__/eslint-config.ts +37 -0
- package/types/audit.d.ts +10 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
441
|
+
const match = /^(--[\w-]+)\s*:/.exec(source.slice(index, index + 256));
|
|
332
442
|
if (match) {
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
796
|
-
if (
|
|
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
|
-
|
|
802
|
-
token,
|
|
803
|
-
|
|
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,
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|