tailwind-canonical 0.7.1 → 0.9.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.
@@ -13,15 +13,32 @@ const TEXT_SIZE_MAP = {
13
13
  96: '8xl',
14
14
  128: '9xl',
15
15
  };
16
- const BUILT_IN_TEXT_PX = new Set(Object.keys(TEXT_SIZE_MAP).map(Number));
17
- const ROUNDED_MAP = {
18
- 2: 'sm',
16
+ /**
17
+ * Radius px → token suffix. v4 renamed the small end (`rounded-sm` → `-xs`,
18
+ * bare `rounded` → `-sm`) and added `4xl`; `''` is the bare v3 `rounded`.
19
+ */
20
+ const ROUNDED_MAP_V4 = {
21
+ 0: 'none',
22
+ 2: 'xs',
19
23
  4: 'sm',
20
24
  6: 'md',
21
25
  8: 'lg',
22
26
  12: 'xl',
23
27
  16: '2xl',
24
28
  24: '3xl',
29
+ 32: '4xl',
30
+ 9999: 'full',
31
+ };
32
+ const ROUNDED_MAP_V3 = {
33
+ 0: 'none',
34
+ 2: 'sm',
35
+ 4: '',
36
+ 6: 'md',
37
+ 8: 'lg',
38
+ 12: 'xl',
39
+ 16: '2xl',
40
+ 24: '3xl',
41
+ 9999: 'full',
25
42
  };
26
43
  const FRACTION_MAP = {
27
44
  '50': '1/2',
@@ -58,6 +75,7 @@ const FRACTION_PREFIXES = [
58
75
  'bottom',
59
76
  'translate-x',
60
77
  'translate-y',
78
+ 'basis',
61
79
  ];
62
80
  const SPACING_PREFIXES = [
63
81
  'h',
@@ -93,10 +111,49 @@ const SPACING_PREFIXES = [
93
111
  'translate-y',
94
112
  'space-x',
95
113
  'space-y',
114
+ 'leading',
96
115
  ];
97
116
  const SPACING_PX_RE = new RegExp(`^(${SPACING_PREFIXES.join('|')})-\\[(\\d+)px\\]$`);
98
117
  const SPACING_REM_RE = new RegExp(`^(${SPACING_PREFIXES.join('|')})-\\[(\\d+(?:\\.\\d+)?)rem\\]$`);
99
118
  const FRACTION_RE = new RegExp(`^(${FRACTION_PREFIXES.join('|')})-\\[(\\d+(?:\\.\\d+)?)%\\]$`);
119
+ /** Pixel widths shipped by `border`, `divide`, `ring` and `outline` in both majors. */
120
+ const WIDTH_SCALE = new Set([0, 1, 2, 4, 8]);
121
+ /** Bare form is 1px in both majors. */
122
+ const BARE_WIDTH_RE = /^(border(?:-[xytrblse])?|divide-[xy])-\[(\d+)px\]$/;
123
+ /** No bare form: `ring` is 3px in v3, `outline` is a style in v3. */
124
+ const NUMBERED_WIDTH_RE = /^(ring|outline)-\[(\d+)px\]$/;
125
+ const LEADING_KEYWORDS = {
126
+ '1': 'none',
127
+ '1.25': 'tight',
128
+ '1.375': 'snug',
129
+ '1.5': 'normal',
130
+ '1.625': 'relaxed',
131
+ '2': 'loose',
132
+ };
133
+ const FONT_WEIGHT_MAP = {
134
+ 100: 'thin',
135
+ 200: 'extralight',
136
+ 300: 'light',
137
+ 400: 'normal',
138
+ 500: 'medium',
139
+ 600: 'semibold',
140
+ 700: 'bold',
141
+ 800: 'extrabold',
142
+ 900: 'black',
143
+ };
144
+ /**
145
+ * Unitless integer utilities. v4 derives each from the bare number, so any
146
+ * integer at or above `min` works; v3 only ships the listed steps.
147
+ */
148
+ const INTEGER_SCALES = {
149
+ z: { min: 0, v3: new Set([0, 10, 20, 30, 40, 50]) },
150
+ order: { min: 1, v3: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) },
151
+ 'line-clamp': { min: 1, v3: new Set([1, 2, 3, 4, 5, 6]) },
152
+ columns: { min: 1, v3: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) },
153
+ };
154
+ const INTEGER_SCALE_RE = new RegExp(`^(${Object.keys(INTEGER_SCALES).join('|')})-\\[(\\d+)\\]$`);
155
+ /** Leading steps v3 ships; v4 derives any integer from the spacing scale. */
156
+ const V3_LEADING_STEPS = new Set([3, 4, 5, 6, 7, 8, 9, 10]);
100
157
  const OPACITY_SCALE = new Set([
101
158
  0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100,
102
159
  ]);
@@ -140,6 +197,106 @@ const V3_FRACTIONS_BY_PREFIX = {
140
197
  bottom: new Set(V3_INSET_FRACTIONS),
141
198
  'translate-x': new Set(V3_INSET_FRACTIONS),
142
199
  'translate-y': new Set(V3_INSET_FRACTIONS),
200
+ basis: new Set([...V3_HEIGHT_FRACTIONS, '1/12', '11/12']),
201
+ };
202
+ const SIZING_PREFIXES = ['w', 'h', 'min-w', 'max-w', 'min-h', 'max-h'];
203
+ const INSET_PREFIXES = ['inset', 'top', 'left', 'right', 'bottom'];
204
+ const MARGIN_PREFIXES = ['m', 'mx', 'my', 'mt', 'mb', 'ml', 'mr'];
205
+ const fullViewport = (keyword, prefixes) => ({
206
+ keyword,
207
+ v4: prefixes,
208
+ v3: [],
209
+ });
210
+ /**
211
+ * Arbitrary value → named keyword, with the prefixes shipping it per major.
212
+ * v3 lists what tailwindcss@3.3.7 generates: no `size-*`, no dynamic
213
+ * viewport units, and `screen` only on the axis it measures.
214
+ */
215
+ const KEYWORD_VALUES = {
216
+ '100%': {
217
+ keyword: 'full',
218
+ v4: [
219
+ ...SIZING_PREFIXES,
220
+ 'size',
221
+ ...INSET_PREFIXES,
222
+ 'translate-x',
223
+ 'translate-y',
224
+ 'basis',
225
+ ],
226
+ v3: [
227
+ ...SIZING_PREFIXES,
228
+ ...INSET_PREFIXES,
229
+ 'translate-x',
230
+ 'translate-y',
231
+ 'basis',
232
+ ],
233
+ },
234
+ '100vw': { keyword: 'screen', v4: ['w', 'min-w', 'max-w'], v3: ['w'] },
235
+ '100vh': {
236
+ keyword: 'screen',
237
+ v4: ['h', 'min-h', 'max-h'],
238
+ v3: ['h', 'min-h', 'max-h'],
239
+ },
240
+ '100dvh': fullViewport('dvh', ['h', 'min-h', 'max-h']),
241
+ '100svh': fullViewport('svh', ['h', 'min-h', 'max-h']),
242
+ '100lvh': fullViewport('lvh', ['h', 'min-h', 'max-h']),
243
+ '100dvw': fullViewport('dvw', ['w']),
244
+ '100svw': fullViewport('svw', ['w']),
245
+ '100lvw': fullViewport('lvw', ['w']),
246
+ auto: {
247
+ keyword: 'auto',
248
+ v4: [
249
+ 'w',
250
+ 'h',
251
+ 'size',
252
+ ...MARGIN_PREFIXES,
253
+ ...INSET_PREFIXES,
254
+ 'basis',
255
+ 'aspect',
256
+ ],
257
+ v3: ['w', 'h', ...MARGIN_PREFIXES, ...INSET_PREFIXES, 'basis', 'aspect'],
258
+ },
259
+ 'fit-content': {
260
+ keyword: 'fit',
261
+ v4: [...SIZING_PREFIXES, 'size'],
262
+ v3: SIZING_PREFIXES,
263
+ },
264
+ 'min-content': {
265
+ keyword: 'min',
266
+ v4: [...SIZING_PREFIXES, 'size'],
267
+ v3: SIZING_PREFIXES,
268
+ },
269
+ 'max-content': {
270
+ keyword: 'max',
271
+ v4: [...SIZING_PREFIXES, 'size'],
272
+ v3: SIZING_PREFIXES,
273
+ },
274
+ };
275
+ const KEYWORD_RE = /^([a-z]+(?:-[a-z]+)*)-\[([a-z0-9%-]+)\]$/;
276
+ const SPACING_ZERO_RE = new RegExp(`^(${SPACING_PREFIXES.join('|')})-\\[0\\]$`);
277
+ const ASPECT_RATIO_RE = /^aspect-\[(\d+)_?\/_?(\d+)\]$/;
278
+ /** Discrete v3 scales; v4 accepts any integer for these utilities. */
279
+ const V3_TIME_STEPS = new Set([0, 75, 100, 150, 200, 300, 500, 700, 1000]);
280
+ const V3_SCALE_STEPS = new Set([0, 50, 75, 90, 95, 100, 105, 110, 125, 150]);
281
+ const V3_ROTATE_STEPS = new Set([0, 1, 2, 3, 6, 12, 45, 90, 180]);
282
+ /** px → blur suffix; `''` is the bare `blur` class. */
283
+ const BLUR_MAP_V4 = {
284
+ 4: '-xs',
285
+ 8: '-sm',
286
+ 12: '-md',
287
+ 16: '-lg',
288
+ 24: '-xl',
289
+ 40: '-2xl',
290
+ 64: '-3xl',
291
+ };
292
+ const BLUR_MAP_V3 = {
293
+ 4: '-sm',
294
+ 8: '',
295
+ 12: '-md',
296
+ 16: '-lg',
297
+ 24: '-xl',
298
+ 40: '-2xl',
299
+ 64: '-3xl',
143
300
  };
144
301
  function remToPx(rem) {
145
302
  const px = rem * 16;
@@ -153,8 +310,25 @@ function isIgnored(cls, patterns) {
153
310
  return re.test(cls);
154
311
  });
155
312
  }
313
+ /** `value × factor` when it lands on an integer, else null (float-safe). */
314
+ function toInteger(value, factor) {
315
+ const scaled = value * factor;
316
+ const rounded = Math.round(scaled);
317
+ return Math.abs(scaled - rounded) <= 1e-9 ? rounded : null;
318
+ }
319
+ function scaleStepSuggestion(cls, prefix, step, v3Steps) {
320
+ if (step === null || (v3Steps && !v3Steps.has(step)))
321
+ return null;
322
+ return {
323
+ original: cls,
324
+ canonical: `${prefix}-${step}`,
325
+ isCustomToken: false,
326
+ };
327
+ }
156
328
  /** True when the ÷4 result would be a class v3 never generates. */
157
329
  function isMissingInV3(prefix, step) {
330
+ if (prefix === 'leading')
331
+ return !V3_LEADING_STEPS.has(step);
158
332
  if (V3_UNSCALED_SPACING_PREFIXES.has(prefix))
159
333
  return true;
160
334
  if (V3_ZERO_ONLY_SPACING_PREFIXES.has(prefix))
@@ -168,7 +342,7 @@ function textSuggestion(cls, px, textTokens) {
168
342
  return {
169
343
  original: cls,
170
344
  canonical: `text-${token}`,
171
- isCustomToken: !BUILT_IN_TEXT_PX.has(px),
345
+ isCustomToken: textTokens[px] !== TEXT_SIZE_MAP[px],
172
346
  };
173
347
  }
174
348
  function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
@@ -179,6 +353,14 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
179
353
  isCustomToken: true,
180
354
  };
181
355
  }
356
+ if (px === 1) {
357
+ if (v3 &&
358
+ (V3_UNSCALED_SPACING_PREFIXES.has(prefix) ||
359
+ V3_ZERO_ONLY_SPACING_PREFIXES.has(prefix))) {
360
+ return null;
361
+ }
362
+ return { original: cls, canonical: `${prefix}-px`, isCustomToken: false };
363
+ }
182
364
  if (px % 4 !== 0)
183
365
  return null;
184
366
  if (v3 && isMissingInV3(prefix, px / 4))
@@ -189,9 +371,123 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
189
371
  isCustomToken: false,
190
372
  };
191
373
  }
374
+ /** Utilities whose arbitrary value may be negated (`-mt-2`, `-translate-x-1/2`). */
375
+ const NEGATABLE_PREFIXES = [
376
+ 'm',
377
+ 'mx',
378
+ 'my',
379
+ 'mt',
380
+ 'mb',
381
+ 'ml',
382
+ 'mr',
383
+ 'inset',
384
+ 'top',
385
+ 'left',
386
+ 'right',
387
+ 'bottom',
388
+ 'translate-x',
389
+ 'translate-y',
390
+ 'space-x',
391
+ 'space-y',
392
+ 'scale',
393
+ 'scale-x',
394
+ 'scale-y',
395
+ 'rotate',
396
+ 'z',
397
+ 'order',
398
+ ];
399
+ /** Index just past the last top-level `:` — brackets/parens may hold colons. */
400
+ function variantsEnd(cls) {
401
+ let depth = 0;
402
+ let end = 0;
403
+ for (let i = 0; i < cls.length; i++) {
404
+ const ch = cls[i];
405
+ if (ch === '[' || ch === '(')
406
+ depth++;
407
+ else if ((ch === ']' || ch === ')') && depth > 0)
408
+ depth--;
409
+ else if (ch === ':' && depth === 0)
410
+ end = i + 1;
411
+ }
412
+ return end;
413
+ }
414
+ function parseClass(cls) {
415
+ const end = variantsEnd(cls);
416
+ let utility = cls.slice(end);
417
+ const leadingImportant = utility.startsWith('!');
418
+ if (leadingImportant)
419
+ utility = utility.slice(1);
420
+ const trailingImportant = !leadingImportant && utility.endsWith('!');
421
+ if (trailingImportant)
422
+ utility = utility.slice(0, -1);
423
+ let negative = utility.startsWith('-');
424
+ if (negative)
425
+ utility = utility.slice(1);
426
+ const innerNegative = utility.match(/^([a-z-]+)-\[-([^\]]+)\]$/);
427
+ if (innerNegative) {
428
+ negative = !negative;
429
+ utility = `${innerNegative[1]}-[${innerNegative[2]}]`;
430
+ }
431
+ return {
432
+ variants: cls.slice(0, end),
433
+ leadingImportant,
434
+ trailingImportant,
435
+ negative,
436
+ utility,
437
+ };
438
+ }
439
+ /**
440
+ * Suggests the canonical form of a class, preserving its variants
441
+ * (`hover:`, `md:`, `[&:hover]:`), important modifier (`!p-4` / `p-4!`)
442
+ * and sign (`-mt-[8px]` / `mt-[-8px]` → `-mt-2`).
443
+ */
192
444
  export function suggestCanonical(cls, config = {}) {
193
445
  if (isIgnored(cls, config.ignorePatterns))
194
446
  return null;
447
+ const parsed = parseClass(cls);
448
+ if (parsed.utility !== cls &&
449
+ isIgnored(parsed.utility, config.ignorePatterns))
450
+ return null;
451
+ const base = suggestUtility(parsed.utility, config);
452
+ if (!base)
453
+ return null;
454
+ if (parsed.negative &&
455
+ !NEGATABLE_PREFIXES.some((p) => parsed.utility.startsWith(`${p}-[`))) {
456
+ return null;
457
+ }
458
+ if (parsed.negative && base.canonical.endsWith('-auto'))
459
+ return null;
460
+ const sign = parsed.negative && !/-(0|none)$/.test(base.canonical) ? '-' : '';
461
+ return {
462
+ original: cls,
463
+ canonical: `${parsed.variants}${parsed.leadingImportant ? '!' : ''}${sign}${base.canonical}${parsed.trailingImportant ? '!' : ''}`,
464
+ isCustomToken: base.isCustomToken,
465
+ };
466
+ }
467
+ function suggestUtility(cls, config) {
468
+ if (config.useDefaultTheme === false) {
469
+ const text = cls.match(/^text-\[(\d+)px\]$/);
470
+ if (text) {
471
+ const token = config.customTextTokens?.[Number(text[1])];
472
+ return token
473
+ ? { original: cls, canonical: `text-${token}`, isCustomToken: true }
474
+ : null;
475
+ }
476
+ const spacing = cls.match(SPACING_PX_RE);
477
+ if (spacing) {
478
+ const token = config.customSpacingTokens?.[Number(spacing[2])];
479
+ return token
480
+ ? {
481
+ original: cls,
482
+ canonical: `${spacing[1]}-${token}`,
483
+ isCustomToken: true,
484
+ }
485
+ : null;
486
+ }
487
+ // Even rem-to-px equivalences depend on the consumer's root font size.
488
+ // A custom theme must opt in through explicit mappings, never inference.
489
+ return null;
490
+ }
195
491
  const textTokens = { ...TEXT_SIZE_MAP, ...config.customTextTokens };
196
492
  const spacingTokens = config.customSpacingTokens ?? {};
197
493
  const v3 = config.tailwindVersion === 3;
@@ -206,6 +502,39 @@ export function suggestCanonical(cls, config = {}) {
206
502
  const px = remToPx(parseFloat(textRemMatch[1]));
207
503
  return px === null ? null : textSuggestion(cls, px, textTokens);
208
504
  }
505
+ // w-[100%] → w-full, h-[100vh] → h-screen, mx-[auto] → mx-auto
506
+ const keywordMatch = cls.match(KEYWORD_RE);
507
+ const keywordRule = keywordMatch && KEYWORD_VALUES[keywordMatch[2]];
508
+ if (keywordMatch && keywordRule) {
509
+ const prefix = keywordMatch[1];
510
+ if (!(v3 ? keywordRule.v3 : keywordRule.v4).includes(prefix))
511
+ return null;
512
+ return {
513
+ original: cls,
514
+ canonical: `${prefix}-${keywordRule.keyword}`,
515
+ isCustomToken: false,
516
+ };
517
+ }
518
+ // p-[0] → p-0 (unitless zero)
519
+ const spacingZeroMatch = cls.match(SPACING_ZERO_RE);
520
+ if (spacingZeroMatch) {
521
+ return spacingSuggestion(cls, spacingZeroMatch[1], 0, spacingTokens, v3);
522
+ }
523
+ // aspect-[16/9] → aspect-video; other ratios are v4-only
524
+ const aspectMatch = cls.match(ASPECT_RATIO_RE);
525
+ if (aspectMatch) {
526
+ if (/^0+$/.test(aspectMatch[1]) || /^0+$/.test(aspectMatch[2]))
527
+ return null;
528
+ const ratio = `${aspectMatch[1]}/${aspectMatch[2]}`;
529
+ const keyword = { '16/9': 'video', '1/1': 'square' }[ratio];
530
+ if (!keyword && v3)
531
+ return null;
532
+ return {
533
+ original: cls,
534
+ canonical: `aspect-${keyword ?? ratio}`,
535
+ isCustomToken: false,
536
+ };
537
+ }
209
538
  // spacing-[Npx]
210
539
  const spacingPxMatch = cls.match(SPACING_PX_RE);
211
540
  if (spacingPxMatch) {
@@ -236,8 +565,60 @@ export function suggestCanonical(cls, config = {}) {
236
565
  isCustomToken: false,
237
566
  };
238
567
  }
239
- // opacity-[N.N] or opacity-[0.N]
240
- const opacityMatch = cls.match(/^opacity-\[(\d+(?:\.\d+)?)\]$/);
568
+ // leading-[1.5] → leading-normal
569
+ const leadingUnitlessMatch = cls.match(/^leading-\[(\d+(?:\.\d+)?|\.\d+)\]$/);
570
+ if (leadingUnitlessMatch) {
571
+ const keyword = LEADING_KEYWORDS[String(parseFloat(leadingUnitlessMatch[1]))];
572
+ if (!keyword)
573
+ return null;
574
+ return {
575
+ original: cls,
576
+ canonical: `leading-${keyword}`,
577
+ isCustomToken: false,
578
+ };
579
+ }
580
+ // font-[700] → font-bold
581
+ const fontWeightMatch = cls.match(/^font-\[(\d+)\]$/);
582
+ if (fontWeightMatch) {
583
+ const weight = FONT_WEIGHT_MAP[parseInt(fontWeightMatch[1], 10)];
584
+ if (!weight)
585
+ return null;
586
+ return { original: cls, canonical: `font-${weight}`, isCustomToken: false };
587
+ }
588
+ // z/order/line-clamp/columns-[N]
589
+ const integerMatch = cls.match(INTEGER_SCALE_RE);
590
+ if (integerMatch) {
591
+ const prefix = integerMatch[1];
592
+ const value = parseInt(integerMatch[2], 10);
593
+ if (prefix === 'order' && value === 0) {
594
+ return { original: cls, canonical: 'order-none', isCustomToken: false };
595
+ }
596
+ const scale = INTEGER_SCALES[prefix];
597
+ if (value < scale.min)
598
+ return null;
599
+ if (v3 && !scale.v3.has(value))
600
+ return null;
601
+ return {
602
+ original: cls,
603
+ canonical: `${prefix}-${value}`,
604
+ isCustomToken: false,
605
+ };
606
+ }
607
+ // grow-[N] / shrink-[N]
608
+ const flexFactorMatch = cls.match(/^(grow|shrink)-\[(\d+)\]$/);
609
+ if (flexFactorMatch) {
610
+ const prefix = flexFactorMatch[1];
611
+ const value = parseInt(flexFactorMatch[2], 10);
612
+ if (value > 1 && v3)
613
+ return null;
614
+ return {
615
+ original: cls,
616
+ canonical: value === 1 ? prefix : `${prefix}-${value}`,
617
+ isCustomToken: false,
618
+ };
619
+ }
620
+ // opacity-[N.N], opacity-[0.N] or opacity-[.N]
621
+ const opacityMatch = cls.match(/^opacity-\[(\d+(?:\.\d+)?|\.\d+)\]$/);
241
622
  if (opacityMatch) {
242
623
  const raw = parseFloat(opacityMatch[1]);
243
624
  // CSS opacity is a 0..1 fraction; a bare value > 1 clamps to 1 and is not
@@ -256,19 +637,73 @@ export function suggestCanonical(cls, config = {}) {
256
637
  isCustomToken: false,
257
638
  };
258
639
  }
640
+ // border/divide-[Npx]
641
+ const bareWidthMatch = cls.match(BARE_WIDTH_RE);
642
+ if (bareWidthMatch) {
643
+ const px = parseInt(bareWidthMatch[2], 10);
644
+ if (!WIDTH_SCALE.has(px))
645
+ return null;
646
+ return {
647
+ original: cls,
648
+ canonical: px === 1 ? bareWidthMatch[1] : `${bareWidthMatch[1]}-${px}`,
649
+ isCustomToken: false,
650
+ };
651
+ }
652
+ // ring/outline-[Npx]
653
+ const numberedWidthMatch = cls.match(NUMBERED_WIDTH_RE);
654
+ if (numberedWidthMatch) {
655
+ const px = parseInt(numberedWidthMatch[2], 10);
656
+ if (!WIDTH_SCALE.has(px))
657
+ return null;
658
+ return {
659
+ original: cls,
660
+ canonical: `${numberedWidthMatch[1]}-${px}`,
661
+ isCustomToken: false,
662
+ };
663
+ }
259
664
  // rounded-[Npx]
260
665
  const roundedMatch = cls.match(/^(rounded|rounded-[a-z]+)-\[(\d+)px\]$/);
261
666
  if (roundedMatch) {
262
667
  const prefix = roundedMatch[1];
263
668
  const px = parseInt(roundedMatch[2], 10);
264
- const token = ROUNDED_MAP[px];
265
- if (!token)
669
+ const token = (v3 ? ROUNDED_MAP_V3 : ROUNDED_MAP_V4)[px];
670
+ if (token === undefined)
266
671
  return null;
267
672
  return {
268
673
  original: cls,
269
- canonical: `${prefix}-${token}`,
674
+ canonical: token ? `${prefix}-${token}` : prefix,
270
675
  isCustomToken: false,
271
676
  };
272
677
  }
678
+ // duration-[200ms] / delay-[0.2s]
679
+ const timeMatch = cls.match(/^(duration|delay)-\[(\d+(?:\.\d+)?)(ms|s)\]$/);
680
+ if (timeMatch) {
681
+ const ms = toInteger(parseFloat(timeMatch[2]), timeMatch[3] === 's' ? 1000 : 1);
682
+ return scaleStepSuggestion(cls, timeMatch[1], ms, v3 ? V3_TIME_STEPS : null);
683
+ }
684
+ // scale-[1.05] / scale-x-[105%]
685
+ const scaleMatch = cls.match(/^(scale(?:-[xy])?)-\[(\d+(?:\.\d+)?)(%?)\]$/);
686
+ if (scaleMatch) {
687
+ const pct = toInteger(parseFloat(scaleMatch[2]), scaleMatch[3] ? 1 : 100);
688
+ return scaleStepSuggestion(cls, scaleMatch[1], pct, v3 ? V3_SCALE_STEPS : null);
689
+ }
690
+ // rotate-[45deg]
691
+ const rotateMatch = cls.match(/^rotate-\[(\d+(?:\.\d+)?)deg\]$/);
692
+ if (rotateMatch) {
693
+ const deg = toInteger(parseFloat(rotateMatch[1]), 1);
694
+ return scaleStepSuggestion(cls, 'rotate', deg, v3 ? V3_ROTATE_STEPS : null);
695
+ }
696
+ // blur-[8px] / blur-[0.5rem]
697
+ const blurMatch = cls.match(/^blur-\[(\d+(?:\.\d+)?)(px|rem)\]$/);
698
+ if (blurMatch) {
699
+ const raw = parseFloat(blurMatch[1]);
700
+ const px = blurMatch[2] === 'rem' ? remToPx(raw) : toInteger(raw, 1);
701
+ if (px === null)
702
+ return null;
703
+ const suffix = (v3 ? BLUR_MAP_V3 : BLUR_MAP_V4)[px];
704
+ if (suffix === undefined)
705
+ return null;
706
+ return { original: cls, canonical: `blur${suffix}`, isCustomToken: false };
707
+ }
273
708
  return null;
274
709
  }
@@ -5,5 +5,4 @@ export type ScanOptions = {
5
5
  ignore?: string[];
6
6
  };
7
7
  export declare function isGlob(target: string): boolean;
8
- export declare function globToRegex(pattern: string): RegExp;
9
8
  export { resolveTargets, scanFiles } from '../io/scanner.js';
@@ -15,55 +15,6 @@ export const DEFAULT_IGNORE = [
15
15
  'coverage',
16
16
  ];
17
17
  export function isGlob(target) {
18
- return target.includes('*') || target.includes('?') || target.includes('{');
19
- }
20
- function escapeRegex(text) {
21
- return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
22
- }
23
- function wildcardToRegex(text) {
24
- return text
25
- .split('*')
26
- .map((part) => part.split('?').map(escapeRegex).join('[^/]'))
27
- .join('[^/]*');
28
- }
29
- export function globToRegex(pattern) {
30
- let i = 0;
31
- const anchored = pattern.startsWith('/') || /^[A-Za-z]:/.test(pattern);
32
- let re = anchored ? '^' : '(?:^|/)';
33
- while (i < pattern.length) {
34
- const c = pattern[i];
35
- if (c === '*' && pattern[i + 1] === '*') {
36
- i += 2;
37
- if (pattern[i] === '/') {
38
- re += '(?:[^/]+/)*';
39
- i++;
40
- }
41
- else {
42
- re += '.*';
43
- }
44
- }
45
- else if (c === '*') {
46
- re += '[^/]*';
47
- i++;
48
- }
49
- else if (c === '?') {
50
- re += '[^/]';
51
- i++;
52
- }
53
- else if (c === '{' && pattern.indexOf('}', i) !== -1) {
54
- const close = pattern.indexOf('}', i);
55
- const alts = pattern
56
- .slice(i + 1, close)
57
- .split(',')
58
- .map(wildcardToRegex);
59
- re += `(?:${alts.join('|')})`;
60
- i = close + 1;
61
- }
62
- else {
63
- re += escapeRegex(c);
64
- i++;
65
- }
66
- }
67
- return new RegExp(`${re}$`);
18
+ return /[*?{[(]/.test(target);
68
19
  }
69
20
  export { resolveTargets, scanFiles } from '../io/scanner.js';
@@ -9,7 +9,7 @@ interface TailwindCanonicalPlugin {
9
9
  plugins: {
10
10
  'tailwind-canonical': TailwindCanonicalPlugin;
11
11
  };
12
- rules: Record<string, string>;
12
+ rules: Record<string, 'warn'>;
13
13
  };
14
14
  };
15
15
  }