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