tailwind-canonical 0.9.0 → 0.10.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.
@@ -5,10 +5,11 @@ export function toClassStringOpts(config) {
5
5
  functionNames: config.functionNames,
6
6
  attributeNames: config.attributeNames,
7
7
  customTextTokens: config.customTextTokens,
8
+ tailwindVersion: config.tailwindVersion,
8
9
  };
9
10
  }
10
11
  const DEFAULT_ATTR_NAMES = ['className'];
11
- export const SINGLE_CLASS_REGEX = /[^\s"'`{}]+/g;
12
+ export const SINGLE_CLASS_REGEX = /\S+/g;
12
13
  function escapeRegex(s) {
13
14
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
15
  }
@@ -16,45 +17,43 @@ function buildAttrRegex(names) {
16
17
  const alts = names.map(escapeRegex).join('|');
17
18
  return new RegExp(`(^|[^\\w:$-])(${alts})\\s*=\\s*`, 'g');
18
19
  }
19
- function attributeStrings(content, names) {
20
+ function attributeStrings(content, names, functionNames = []) {
20
21
  const result = [];
21
22
  for (const match of content.matchAll(buildAttrRegex(names))) {
22
23
  const attributeStart = (match.index ?? 0) + match[1].length;
23
- let i = (match.index ?? 0) + match[0].length;
24
- const braced = content[i] === '{';
25
- if (braced) {
26
- i++;
27
- while (/\s/.test(content[i] ?? '') && i < content.length)
28
- i++;
24
+ const start = (match.index ?? 0) + match[0].length;
25
+ if (content[start] === '{') {
26
+ const expression = readCallTokens(content, start + 1, '}');
27
+ if (expression.complete) {
28
+ result.push(...classExpression(expression.tokens, functionNames).map((string) => ({ ...string, attributeStart })));
29
+ }
30
+ continue;
29
31
  }
30
- if (content[i] !== '"' && content[i] !== "'" && content[i] !== '`')
32
+ if (!['"', "'", '`'].includes(content[start]))
31
33
  continue;
32
- const quoted = readQuotedString(content, i);
34
+ const quoted = readQuotedString(content, start);
33
35
  if (!quoted)
34
36
  continue;
35
- if (braced) {
36
- i = quoted.next;
37
- while (/\s/.test(content[i] ?? '') && i < content.length)
38
- i++;
39
- // Only a single string expression is static. A concatenation, method
40
- // call or tagged template can change the meaning of its string pieces.
41
- if (content[i] !== '}')
37
+ if (match[2].startsWith(':') || match[2].startsWith('v-bind:')) {
38
+ // Vue bindings contain JavaScript, not a class list. Entities require an
39
+ // HTML decoder/source map, so leave those expressions intact for now.
40
+ if (/&(?:#\d+|#x[\da-f]+|[a-z]+);/i.test(quoted.value))
42
41
  continue;
42
+ const expression = readCallTokens(content, quoted.start, content[start]);
43
+ if (expression.complete && expression.next === quoted.next) {
44
+ result.push(...classExpression(expression.tokens, functionNames).map((string) => ({ ...string, attributeStart })));
45
+ }
43
46
  }
44
- result.push({
45
- value: quoted.value,
46
- start: quoted.start,
47
- end: quoted.end,
48
- attributeStart,
49
- });
47
+ else
48
+ result.push({ ...quoted, attributeStart, braceExpressions: true });
50
49
  }
51
50
  return result;
52
51
  }
53
52
  // A template expression is JavaScript, never a class list. Keep its adjacent
54
53
  // token fragments as well: `text-[12px]${suffix}` is not a static class.
55
- function interpolationEnd(content, start) {
54
+ function interpolationEnd(content, start, openingLength = 2) {
56
55
  let depth = 1;
57
- let i = start + 2;
56
+ let i = start + openingLength;
58
57
  while (i < content.length) {
59
58
  const ch = content[i];
60
59
  if (ch === '"' || ch === "'" || ch === '`') {
@@ -72,10 +71,7 @@ function interpolationEnd(content, start) {
72
71
  i = end < 0 ? content.length : end + 2;
73
72
  }
74
73
  else if (ch === '/') {
75
- const preceding = content
76
- .slice(start + 2, i)
77
- .trimEnd()
78
- .match(/(?:[\w$]+|=>|&&|\|\||\?\?|.)$/)?.[0];
74
+ const preceding = previousToken(content, i);
79
75
  if (regexMayStart(preceding)) {
80
76
  const end = readRegex(content, i);
81
77
  if (!end)
@@ -95,15 +91,27 @@ function interpolationEnd(content, start) {
95
91
  }
96
92
  return content.length;
97
93
  }
98
- function staticClassRanges(value) {
94
+ function staticClassRanges(value, braceExpressions = false) {
99
95
  const ranges = [];
100
96
  let start = 0;
101
97
  let i = 0;
98
+ let bracketDepth = 0;
102
99
  while (i < value.length) {
103
100
  let escapes = 0;
104
101
  for (let j = i - 1; j >= 0 && value[j] === '\\'; j--)
105
102
  escapes++;
106
- if (!value.startsWith('${', i) || escapes % 2 === 1) {
103
+ const openingLength = value.startsWith('${', i)
104
+ ? 2
105
+ : braceExpressions && bracketDepth === 0 && value[i] === '{'
106
+ ? 1
107
+ : 0;
108
+ if (!openingLength || escapes % 2 === 1) {
109
+ if (escapes % 2 === 0) {
110
+ if (value[i] === '[')
111
+ bracketDepth++;
112
+ else if (value[i] === ']')
113
+ bracketDepth = Math.max(0, bracketDepth - 1);
114
+ }
107
115
  i++;
108
116
  continue;
109
117
  }
@@ -112,7 +120,7 @@ function staticClassRanges(value) {
112
120
  before--;
113
121
  if (before > start)
114
122
  ranges.push({ value: value.slice(start, before), start, end: before });
115
- i = interpolationEnd(value, i);
123
+ i = interpolationEnd(value, i, openingLength);
116
124
  while (i < value.length && !/\s/.test(value[i]))
117
125
  i++;
118
126
  start = i;
@@ -121,7 +129,7 @@ function staticClassRanges(value) {
121
129
  ranges.push({ value: value.slice(start), start, end: value.length });
122
130
  return ranges;
123
131
  }
124
- function transformStaticClasses(value, transform, isSuppressedAt, offset = 0) {
132
+ function transformStaticClasses(value, transform, isSuppressedAt, offset = 0, braceExpressions = false) {
125
133
  const suppressed = [];
126
134
  if (isSuppressedAt) {
127
135
  let lineStart = 0;
@@ -133,7 +141,7 @@ function transformStaticClasses(value, transform, isSuppressedAt, offset = 0) {
133
141
  lineStart = end;
134
142
  }
135
143
  }
136
- const ranges = staticClassRanges(value).flatMap((range) => {
144
+ const ranges = staticClassRanges(value, braceExpressions).flatMap((range) => {
137
145
  const safe = [];
138
146
  let start = range.start;
139
147
  for (const line of suppressed) {
@@ -155,7 +163,9 @@ function transformStaticClasses(value, transform, isSuppressedAt, offset = 0) {
155
163
  });
156
164
  return safe;
157
165
  });
158
- const preserveEdges = value.includes('${') || suppressed.length > 0;
166
+ const preserveEdges = value.includes('${') ||
167
+ (braceExpressions && value.includes('{')) ||
168
+ suppressed.length > 0;
159
169
  let out = '';
160
170
  let pos = 0;
161
171
  for (const range of ranges) {
@@ -258,7 +268,32 @@ const REGEX_PREFIX_TOKENS = new Set([
258
268
  'await',
259
269
  'in',
260
270
  'instanceof',
271
+ 'else',
272
+ 'do',
273
+ 'new',
274
+ 'of',
275
+ // A slash after a statement block may open a regex. Treat a valid-looking
276
+ // literal conservatively even when an object/division expression is ambiguous.
277
+ '}',
261
278
  ]);
279
+ // Read only the token immediately before an offset. Searching an entire source
280
+ // prefix with an end-anchored regexp once per helper call is quadratic.
281
+ function previousToken(content, offset) {
282
+ let end = offset;
283
+ while (end > 0 && /\s/.test(content[end - 1]))
284
+ end--;
285
+ if (end === 0)
286
+ return undefined;
287
+ let start = end;
288
+ while (start > 0 && /[\w$]/.test(content[start - 1]))
289
+ start--;
290
+ if (start < end)
291
+ return content.slice(start, end);
292
+ const pair = content.slice(Math.max(0, end - 2), end);
293
+ if (['=>', '&&', '||', '??'].includes(pair))
294
+ return pair;
295
+ return content[end - 1];
296
+ }
262
297
  function regexMayStart(previous) {
263
298
  return previous === undefined || REGEX_PREFIX_TOKENS.has(previous);
264
299
  }
@@ -353,8 +388,14 @@ function classExpression(tokens, functionNames, objects = true) {
353
388
  return [token.string];
354
389
  if (token.text === '[' && token.children)
355
390
  return splitTokens(token.children, ',').flatMap((part) => classExpression(part, functionNames, objects));
356
- if (token.text === '{' && objects)
391
+ if (token.text === '{' && objects) {
392
+ // Rewriting keys of a multi-property object can create a duplicate key
393
+ // and discard a predicate. Unknown/spread properties can collide too.
394
+ const properties = splitTokens(token.children ?? [], ',').filter((property) => property.length > 0);
395
+ if (properties.length !== 1)
396
+ return [];
357
397
  return objectProperties(token).flatMap(({ key }) => key.string ? [key.string] : []);
398
+ }
358
399
  // A grouping pair does not establish class context, but preserves one.
359
400
  if (token.text === '(' && token.children)
360
401
  return classExpression(token.children, functionNames, objects);
@@ -429,7 +470,7 @@ function classCall(name, tokens, functionNames) {
429
470
  }
430
471
  function scanCallStrings(content, start, functionNames = []) {
431
472
  const scanned = readCallTokens(content, start);
432
- const name = content.slice(0, start - 1).match(/([\w$]+)\s*$/)?.[1] ?? '';
473
+ const name = previousToken(content, start - 1) ?? '';
433
474
  return {
434
475
  strings: scanned.complete
435
476
  ? classCall(name, scanned.tokens, functionNames)
@@ -438,26 +479,6 @@ function scanCallStrings(content, start, functionNames = []) {
438
479
  complete: scanned.complete,
439
480
  };
440
481
  }
441
- function transformCallContent(content, start, transform, isSuppressedAt, functionNames = []) {
442
- const scanned = scanCallStrings(content, start, functionNames);
443
- const original = content.slice(start, start + scanned.consumed);
444
- if (!scanned.complete) {
445
- return { processed: original, consumed: scanned.consumed, count: 0 };
446
- }
447
- const parts = [];
448
- let pos = start;
449
- let count = 0;
450
- for (const string of scanned.strings) {
451
- parts.push(content.slice(pos, string.start));
452
- const out = transformStaticClasses(string.value, transform, isSuppressedAt, string.start);
453
- if (out !== string.value)
454
- count++;
455
- parts.push(out);
456
- pos = string.end;
457
- }
458
- parts.push(content.slice(pos, start + scanned.consumed));
459
- return { processed: parts.join(''), consumed: scanned.consumed, count };
460
- }
461
482
  function offsetSuppressor(content, isSuppressed) {
462
483
  if (!isSuppressed)
463
484
  return undefined;
@@ -471,6 +492,8 @@ function protectedSourceOffsets(content) {
471
492
  const tags = [];
472
493
  const pendingTags = [];
473
494
  let expressionEnd = 0;
495
+ const controlParens = [];
496
+ let lastControlParenEnd = -1;
474
497
  let i = 0;
475
498
  while (i < content.length) {
476
499
  const start = i;
@@ -488,7 +511,7 @@ function protectedSourceOffsets(content) {
488
511
  if (!tag && ch === '<' && !content.startsWith('<!--', i)) {
489
512
  const match = content
490
513
  .slice(i)
491
- .match(/^<(\/?)([A-Za-z][\w:.-]*)(?=[\s/>])/);
514
+ .match(/^<(\/?)(?:([A-Za-z][\w:.-]*)(?=[\s/>])|(?=>))/);
492
515
  if (match) {
493
516
  let end = i + match[0].length;
494
517
  while (end < content.length && content[end] !== '>') {
@@ -505,8 +528,9 @@ function protectedSourceOffsets(content) {
505
528
  end++;
506
529
  }
507
530
  const closing = match[1] === '/';
508
- const selfClosing = content[end - 1] === '/';
509
- const name = match[2];
531
+ const selfClosing = content[end - 1] === '/' ||
532
+ /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/.test(match[2] ?? '');
533
+ const name = match[2] ?? '';
510
534
  if (end < content.length &&
511
535
  (tags.length > 0 ||
512
536
  selfClosing ||
@@ -514,7 +538,18 @@ function protectedSourceOffsets(content) {
514
538
  pendingTags.push({ end, name, closing, selfClosing });
515
539
  }
516
540
  }
517
- if (!tag && tags.length > 0 && i >= expressionEnd && ch !== '<') {
541
+ const rawTag = tags.at(-1)?.toLowerCase();
542
+ if (!tag && rawTag === 'style' && !content.startsWith('</style', i)) {
543
+ const end = content.indexOf('</style', i);
544
+ i = end < 0 ? content.length : end;
545
+ ranges.push({ start, end: i });
546
+ continue;
547
+ }
548
+ if (!tag &&
549
+ tags.length > 0 &&
550
+ rawTag !== 'script' &&
551
+ i >= expressionEnd &&
552
+ ch !== '<') {
518
553
  if (ch === '{')
519
554
  expressionEnd = readCallTokens(content, i + 1, '}').next;
520
555
  else {
@@ -538,11 +573,14 @@ function protectedSourceOffsets(content) {
538
573
  i = end < 0 ? content.length : end + ending.length;
539
574
  }
540
575
  else if (ch === '/' && content[i - 1] !== '<' && content[i + 1] !== '>') {
541
- const preceding = content
542
- .slice(0, i)
543
- .trimEnd()
544
- .match(/(?:[\w$]+|=>|&&|\|\||\?\?|.)$/)?.[0];
545
- const end = regexMayStart(preceding) ? readRegex(content, i) : null;
576
+ const preceding = previousToken(content, i);
577
+ let before = i - 1;
578
+ while (before >= 0 && /\s/.test(content[before]))
579
+ before--;
580
+ const afterControlCondition = preceding === ')' && before === lastControlParenEnd;
581
+ const end = regexMayStart(preceding) || afterControlCondition
582
+ ? readRegex(content, i)
583
+ : null;
546
584
  if (!end) {
547
585
  i++;
548
586
  continue;
@@ -550,6 +588,11 @@ function protectedSourceOffsets(content) {
550
588
  i = end;
551
589
  }
552
590
  else {
591
+ if (ch === '(') {
592
+ controlParens.push(['if', 'while', 'for', 'with', 'switch', 'catch'].includes(previousToken(content, i) ?? ''));
593
+ }
594
+ else if (ch === ')' && controlParens.pop())
595
+ lastControlParenEnd = i;
553
596
  i++;
554
597
  continue;
555
598
  }
@@ -568,82 +611,67 @@ function protectedSourceOffsets(content) {
568
611
  return low < ranges.length && ranges[low].start <= offset;
569
612
  };
570
613
  }
571
- export function replaceClassStrings(content, transform, opts = {}) {
614
+ /** Collect disjoint ranges against the original source before any rewrite.
615
+ * Source offsets and suppression line numbers must not depend on an earlier
616
+ * attribute transform changing the number of lines in the file.
617
+ */
618
+ function collectClassStrings(content, opts) {
572
619
  const attrNames = opts.attributeNames?.length
573
620
  ? opts.attributeNames
574
621
  : DEFAULT_ATTR_NAMES;
575
- let result = content;
576
- let count = 0;
577
- const isSuppressed = opts.isSuppressed;
578
622
  const isProtected = protectedSourceOffsets(content);
579
- const isAttributeSuppressed = offsetSuppressor(content, isSuppressed);
580
- const attributeParts = [];
581
- let attributeCursor = 0;
582
- for (const string of attributeStrings(content, attrNames)) {
583
- if (isProtected(string.attributeStart))
584
- continue;
585
- const out = transformStaticClasses(string.value, transform, isAttributeSuppressed, string.start);
623
+ const strings = attributeStrings(content, attrNames, opts.functionNames).filter((string) => !isProtected(string.attributeStart));
624
+ const funcNames = opts.functionNames;
625
+ if (funcNames?.length) {
626
+ const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
627
+ let consumedUntil = 0;
628
+ for (const match of content.matchAll(funcRe)) {
629
+ const offset = match.index ?? 0;
630
+ // A configured identifier must not select a suffix of a member call or
631
+ // a larger JavaScript identifier (where `$` is also an identifier char).
632
+ if (previousToken(content, offset) === '.' ||
633
+ /[\w$]/.test(content[offset - 1] ?? ''))
634
+ continue;
635
+ if (isProtected(offset) || offset < consumedUntil)
636
+ continue;
637
+ const start = (match.index ?? 0) + match[0].length;
638
+ const scanned = scanCallStrings(content, start, funcNames);
639
+ strings.push(...scanned.strings);
640
+ consumedUntil = start + scanned.consumed;
641
+ }
642
+ }
643
+ const seen = new Set();
644
+ return strings
645
+ .filter((string) => {
646
+ const key = `${string.start}:${string.end}`;
647
+ if (seen.has(key))
648
+ return false;
649
+ seen.add(key);
650
+ return true;
651
+ })
652
+ .sort((left, right) => left.start - right.start);
653
+ }
654
+ export function replaceClassStrings(content, transform, opts = {}) {
655
+ const isSuppressedAt = offsetSuppressor(content, opts.isSuppressed);
656
+ const parts = [];
657
+ let cursor = 0;
658
+ let count = 0;
659
+ for (const string of collectClassStrings(content, opts)) {
660
+ const out = transformStaticClasses(string.value, transform, isSuppressedAt, string.start, string.braceExpressions);
586
661
  if (out !== string.value)
587
662
  count++;
588
- attributeParts.push(content.slice(attributeCursor, string.start), out);
589
- attributeCursor = string.end;
663
+ parts.push(content.slice(cursor, string.start), out);
664
+ cursor = string.end;
590
665
  }
591
- attributeParts.push(content.slice(attributeCursor));
592
- result = attributeParts.join('');
593
- const funcNames = opts.functionNames;
594
- if (!funcNames?.length)
595
- return { result, count };
596
- const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
597
- const buf = [];
598
- let pos = 0;
599
- const isCallSuppressed = offsetSuppressor(result, isSuppressed);
600
- const isCallProtected = protectedSourceOffsets(result);
601
- for (const m of result.matchAll(funcRe)) {
602
- const callStart = (m.index ?? 0) + m[0].length;
603
- if (callStart < pos || isCallProtected(m.index ?? 0))
604
- continue;
605
- buf.push(result.slice(pos, callStart));
606
- const { processed, consumed, count: c, } = transformCallContent(result, callStart, transform, isCallSuppressed, funcNames);
607
- buf.push(processed);
608
- pos = callStart + consumed;
609
- count += c;
610
- funcRe.lastIndex = pos;
611
- }
612
- buf.push(result.slice(pos));
613
- return { result: buf.join(''), count };
666
+ parts.push(content.slice(cursor));
667
+ return { result: parts.join(''), count };
614
668
  }
615
669
  export function extractClassStrings(content, opts = {}) {
616
- const attrNames = opts.attributeNames?.length
617
- ? opts.attributeNames
618
- : DEFAULT_ATTR_NAMES;
619
- const results = [];
620
- const isProtected = protectedSourceOffsets(content);
621
- for (const string of attributeStrings(content, attrNames)) {
622
- if (!isProtected(string.attributeStart))
623
- results.push({
624
- value: string.value,
625
- start: string.start,
626
- end: string.end,
627
- });
628
- }
629
- const funcNames = opts.functionNames;
630
- if (!funcNames?.length)
631
- return staticRanges(results);
632
- const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
633
- let consumedUntil = 0;
634
- for (const m of content.matchAll(funcRe)) {
635
- if (isProtected(m.index ?? 0) || (m.index ?? 0) < consumedUntil)
636
- continue;
637
- const start = (m.index ?? 0) + m[0].length;
638
- const scanned = scanCallStrings(content, start, funcNames);
639
- results.push(...scanned.strings);
640
- consumedUntil = start + scanned.consumed;
641
- }
642
- return staticRanges(results);
670
+ return staticRanges(collectClassStrings(content, opts));
643
671
  }
644
672
  function staticRanges(strings) {
645
673
  const seen = new Set();
646
- return strings.flatMap((string) => staticClassRanges(string.value).flatMap((range) => {
674
+ return strings.flatMap((string) => staticClassRanges(string.value, string.braceExpressions).flatMap((range) => {
647
675
  const start = string.start + range.start;
648
676
  const end = string.start + range.end;
649
677
  const key = `${start}:${end}`;
@@ -8,6 +8,7 @@ export const CONFIG_FILENAMES = [
8
8
  ];
9
9
  const KNOWN_KEYS = [
10
10
  'useDefaultTheme',
11
+ 'allowTextSizeLineHeightChanges',
11
12
  'customTextTokens',
12
13
  'customSpacingTokens',
13
14
  'ignorePatterns',
@@ -54,7 +55,9 @@ function fail(filename, message) {
54
55
  throw new Error(`Invalid ${filename}: ${message}`);
55
56
  }
56
57
  function isPlainObject(value) {
57
- return typeof value === 'object' && value !== null && !Array.isArray(value);
58
+ return (typeof value === 'object' &&
59
+ value !== null &&
60
+ [Object.prototype, null].includes(Object.getPrototypeOf(value)));
58
61
  }
59
62
  function assertKnownKeys(cfg, known, label, filename) {
60
63
  for (const key of Object.keys(cfg)) {
@@ -72,6 +75,8 @@ function assertPxTokenMap(value, key, filename) {
72
75
  fail(filename, `${key} keys must be integers (got "${k}")`);
73
76
  if (typeof v !== 'string')
74
77
  fail(filename, `${key}[${k}] must be a string`);
78
+ if (v.length === 0 || /[\s"'`{}:\\]/.test(v))
79
+ fail(filename, `${key}[${k}] must be a nonempty token name without whitespace, quotes, backticks, braces, colons or backslashes`);
75
80
  }
76
81
  }
77
82
  function assertStringArray(value, key, filename) {
@@ -137,13 +142,16 @@ function assertSortOrder(value, filename) {
137
142
  export function validateConfig(input, filename = CONFIG_FILENAME) {
138
143
  if (input === undefined || input === null)
139
144
  return {};
140
- if (typeof input !== 'object' || Array.isArray(input)) {
141
- fail(filename, 'default export must be an object');
145
+ if (!isPlainObject(input)) {
146
+ fail(filename, 'default export must be an object (plain objects only)');
142
147
  }
143
148
  const cfg = input;
144
149
  assertKnownKeys(cfg, KNOWN_KEYS, 'unknown key', filename);
145
150
  if ('useDefaultTheme' in cfg && typeof cfg.useDefaultTheme !== 'boolean')
146
151
  fail(filename, 'useDefaultTheme must be a boolean');
152
+ if ('allowTextSizeLineHeightChanges' in cfg &&
153
+ typeof cfg.allowTextSizeLineHeightChanges !== 'boolean')
154
+ fail(filename, 'allowTextSizeLineHeightChanges must be a boolean');
147
155
  if ('customTextTokens' in cfg)
148
156
  assertPxTokenMap(cfg.customTextTokens, 'customTextTokens', filename);
149
157
  if ('customSpacingTokens' in cfg)
@@ -1,10 +1,10 @@
1
1
  import { extractClassStrings, SINGLE_CLASS_REGEX, } from './class-strings.js';
2
- import { COLOR_FAMILIES, parseColorClass, SCALE_PROPERTIES, stripVariants, TAILWIND_COLORS, } from './lexicon.js';
2
+ import { COLOR_FAMILIES, parseColorClass, SCALE_PROPERTIES, splitClassModifiers, TAILWIND_COLORS, } from './lexicon.js';
3
3
  function parseColor(cls, families, knownColors) {
4
4
  const parsed = parseColorClass(cls);
5
5
  if (!parsed)
6
6
  return null;
7
- const { property, color, shade } = parsed;
7
+ const { property, color, shade, opacity } = parsed;
8
8
  if (shade === '')
9
9
  return null;
10
10
  // A known color without an explicit family forms its own family, so colors
@@ -16,14 +16,14 @@ function parseColor(cls, families, knownColors) {
16
16
  : null;
17
17
  if (!family)
18
18
  return null;
19
- return { property, family, token: `${color}-${shade}` };
19
+ return { property, family, token: `${color}-${shade}${opacity}` };
20
20
  }
21
21
  // A scale value is a number, a fraction, or an arbitrary [..] value. Keyword
22
22
  // values (auto, px, full, screen, …) are not grid values, so they are excluded
23
23
  // to avoid false "inconsistency" reports like mt-4 vs mt-auto.
24
24
  const SCALE_VALUE_RE = /^(\d+(?:\.\d+)?|\d+\/\d+|\[.+\])$/;
25
25
  function parseScale(cls, scaleProperties) {
26
- const base = stripVariants(cls);
26
+ const { utility: base } = splitClassModifiers(cls);
27
27
  const negative = base.startsWith('-');
28
28
  const body = negative ? base.slice(1) : base;
29
29
  const bracket = body.indexOf('[');
@@ -1,5 +1,5 @@
1
1
  import { type ClassStringOpts } from './class-strings.js';
2
- export declare function deduplicateClasses(classStr: string): string;
2
+ export declare function deduplicateClasses(classStr: string, opts?: Pick<ClassStringOpts, 'tailwindVersion'>): string;
3
3
  export declare function dedupeContent(content: string, opts?: ClassStringOpts): {
4
4
  result: string;
5
5
  count: number;
@@ -128,6 +128,10 @@ for (const f of FAMILIES) {
128
128
  SIDE_MAP[prefix] ??= sides;
129
129
  }
130
130
  function parseBoxClass(cls) {
131
+ // Important utilities occupy a different cascade priority. Keep their
132
+ // declarations intact instead of mixing their suffix into a box value.
133
+ if (cls.endsWith('!'))
134
+ return null;
131
135
  for (const family of FAMILIES) {
132
136
  const m = cls.match(family.regex);
133
137
  if (!m)
@@ -135,7 +139,12 @@ function parseBoxClass(cls) {
135
139
  const sides = {};
136
140
  for (const side of SIDE_MAP[m[1]] ?? [])
137
141
  sides[side] = m[2];
138
- return { family, sides };
142
+ const kind = m[1] === family.full
143
+ ? 'full'
144
+ : m[1] === family.x || m[1] === family.y
145
+ ? 'axis'
146
+ : 'side';
147
+ return { family, sides, kind };
139
148
  }
140
149
  return null;
141
150
  }
@@ -186,7 +195,7 @@ const CORNER_MAP = {
186
195
  br: ['br'],
187
196
  };
188
197
  function parseRoundedCorner(cls) {
189
- if (/^rounded-(?:s|e|ss|se|es|ee)(?:-|$)/.test(cls))
198
+ if (cls.endsWith('!') || /^rounded-(?:s|e|ss|se|es|ee)(?:-|$)/.test(cls))
190
199
  return null;
191
200
  const m = cls.match(/^rounded-(tl|tr|bl|br|t|b|l|r)(?:-(.+))?$/) ??
192
201
  cls.match(/^rounded()(?:-(.+))?$/);
@@ -322,7 +331,7 @@ function keepLastOf(slots, indices) {
322
331
  for (const i of indices.slice(0, -1))
323
332
  slots[i] = [];
324
333
  }
325
- export function deduplicateClasses(classStr) {
334
+ export function deduplicateClasses(classStr, opts = {}) {
326
335
  if (classStr.includes('${'))
327
336
  return classStr;
328
337
  const original = classStr.split(/\s+/).filter(Boolean);
@@ -347,12 +356,20 @@ export function deduplicateClasses(classStr) {
347
356
  }
348
357
  const parsed = parseBoxClass(cls);
349
358
  if (parsed) {
350
- const { family, sides } = parsed;
359
+ const { family, sides, kind } = parsed;
351
360
  let group = boxGroups.get(family.kind);
352
361
  if (!group) {
353
- group = { family, box: { t: '', b: '', l: '', r: '' }, indices: [] };
362
+ group = {
363
+ family,
364
+ box: { t: '', b: '', l: '', r: '' },
365
+ indices: [],
366
+ hasAxis: false,
367
+ hasSide: false,
368
+ };
354
369
  boxGroups.set(family.kind, group);
355
370
  }
371
+ group.hasAxis ||= kind === 'axis';
372
+ group.hasSide ||= kind === 'side';
356
373
  group.indices.push(idx);
357
374
  Object.assign(group.box, sides);
358
375
  return;
@@ -370,8 +387,15 @@ export function deduplicateClasses(classStr) {
370
387
  });
371
388
  keepLastOf(slots, displayIndices);
372
389
  keepLastOf(slots, positionIndices);
373
- for (const { family, box, indices } of boxGroups.values()) {
374
- placeCollapsed(slots, classes, indices, collapseBox(box, family));
390
+ for (const { family, box, indices, hasAxis, hasSide } of boxGroups.values()) {
391
+ // v4's x/y utilities use inline/block properties, unlike the physical
392
+ // t/b/l/r utilities. They cannot be interchanged in vertical writing modes.
393
+ const allEqual = box.t && box.t === box.b && box.t === box.l && box.t === box.r;
394
+ const preservePhysical = opts.tailwindVersion !== 3 && hasSide && (hasAxis || !allEqual);
395
+ const collapsed = preservePhysical
396
+ ? indices.map((index) => classes[index]).join(' ')
397
+ : collapseBox(box, family);
398
+ placeCollapsed(slots, classes, indices, collapsed);
375
399
  }
376
400
  if (cornerIndices.length > 0) {
377
401
  placeCollapsed(slots, classes, cornerIndices, collapseCorners(corners));
@@ -382,7 +406,7 @@ export function deduplicateClasses(classStr) {
382
406
  return unchanged ? classStr : result.join(' ');
383
407
  }
384
408
  export function dedupeContent(content, opts = {}) {
385
- return replaceClassStrings(content, deduplicateClasses, {
409
+ return replaceClassStrings(content, (classes) => deduplicateClasses(classes, opts), {
386
410
  ...opts,
387
411
  isSuppressed: makeLineSuppressor(content),
388
412
  });
@@ -1,6 +1,11 @@
1
1
  export declare const TAILWIND_COLORS: Set<string>;
2
2
  export declare const COLOR_FAMILIES: Record<string, string>;
3
- export declare function stripVariants(cls: string): string;
3
+ export declare function splitClassModifiers(cls: string): {
4
+ prefix: string;
5
+ utility: string;
6
+ importantBefore: string;
7
+ importantAfter: string;
8
+ };
4
9
  /**
5
10
  * Parses a color utility class into its property, color name and (optional)
6
11
  * numeric shade. Strips leading variants (`hover:` …). Returns `null` when the
@@ -12,5 +17,6 @@ export declare function parseColorClass(cls: string): {
12
17
  property: string;
13
18
  color: string;
14
19
  shade: string;
20
+ opacity: string;
15
21
  } | null;
16
22
  export declare const SCALE_PROPERTIES: Set<string>;