tailwind-canonical 0.7.1 → 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.
package/README.md CHANGED
@@ -352,10 +352,36 @@ left-2 right-2 → inset-x-2
352
352
  | `text-[12px]` | `text-xs` | Built-in |
353
353
  | `text-[0.75rem]` | `text-xs` | rem values |
354
354
  | `h-[64px]` | `h-16` | Spacing scale ÷4 |
355
+ | `p-[1px]` / `gap-[1px]` | `p-px` / `gap-px` | 1px spacing step |
355
356
  | `w-[50%]` | `w-1/2` | Percentage fractions |
356
- | `opacity-[0.5]` | `opacity-50` | Opacity scale |
357
+ | `w-[100%]` / `basis-[100%]` | `w-full` / `basis-full` | Sizing, inset, translate, basis |
358
+ | `h-[100vh]` / `w-[100vw]` | `h-screen` / `w-screen` | Axis-checked; `min-w-screen` is v4-only |
359
+ | `h-[100dvh]` / `svh` / `lvh` | `h-dvh` / `h-svh` / `h-lvh` | v4 only |
360
+ | `w-[auto]` / `mx-[auto]` | `w-auto` / `mx-auto` | Never negated |
361
+ | `w-[fit-content]` / `min-content` / `max-content` | `w-fit` / `w-min` / `w-max` | |
362
+ | `p-[0]` / `top-[0]` | `p-0` / `top-0` | Unitless zero |
363
+ | `aspect-[16/9]` / `aspect-[1/1]` | `aspect-video` / `aspect-square` | Other ratios (`aspect-4/3`) v4 only |
364
+ | `rounded-[9999px]` | `rounded-full` | |
365
+ | `opacity-[0.5]` / `opacity-[.5]` | `opacity-50` | Opacity scale |
366
+ | `border-[1px]` / `border-t-[1px]` | `border` / `border-t` | Bare = 1px; `[0/2/4/8px]` → `border-0/2/4/8` |
367
+ | `divide-x-[1px]` | `divide-x` | Same scale as `border` |
368
+ | `ring-[1px]` / `outline-[2px]` | `ring-1` / `outline-2` | Never bare: `ring` is 3px on v3 |
369
+ | `duration-[200ms]` / `delay-[0.15s]` | `duration-200` / `delay-150` | ms or s |
370
+ | `scale-[1.05]` / `scale-x-[95%]` | `scale-105` / `scale-x-95` | Factor or percentage |
371
+ | `rotate-[45deg]` | `rotate-45` | Integer degrees |
372
+ | `blur-[4px]` | `blur-xs` | `blur-sm` with `tailwindVersion: 3` |
373
+ | `z-[10]` | `z-10` | Any integer on v4 |
374
+ | `font-[700]` | `font-bold` | Weights 100–900 |
375
+ | `leading-[24px]` / `leading-[1.5rem]` | `leading-6` | Spacing scale ÷4 |
376
+ | `leading-[1.5]` | `leading-normal` | `none`, `tight`, `snug`, `normal`, `relaxed`, `loose` |
377
+ | `order-[1]` / `order-[0]` | `order-1` / `order-none` | Any integer on v4 |
378
+ | `grow-[1]` / `shrink-[0]` | `grow` / `shrink-0` | |
379
+ | `line-clamp-[3]`, `columns-[3]` | `line-clamp-3`, `columns-3` | Any integer ≥ 1 on v4 |
357
380
  | `text-[11px]` | `text-2xs` | Requires `customTextTokens` |
358
381
  | `text-[8px]`–`text-[10px]` | `text-3xs` | Requires `customTextTokens` |
382
+ | `hover:p-[16px]` | `hover:p-4` | Variants are preserved |
383
+ | `!p-[16px]` / `p-[16px]!` | `!p-4` / `p-4!` | Important modifier kept in place |
384
+ | `-mt-[8px]` / `mt-[-8px]` | `-mt-2` | Negative margin / inset / translate / space / scale / rotate / z / order only |
359
385
 
360
386
  `text-2xs` and `text-3xs` are not stock Tailwind tokens, so they are not in the built-in text size map — without config, `text-[11px]` and `text-[8px]`–`text-[10px]` are left untouched. Enable them with `customTextTokens`:
361
387
 
@@ -393,14 +419,36 @@ scale differs per utility, not just per family:
393
419
  | `top-[20%]` | `top-1/5` | untouched — v3.3 stops at quarters here |
394
420
  | `top-[25%]` | `top-1/4` | `top-1/4` |
395
421
  | `w-[8.333333%]` | `w-1/12` | `w-1/12` — `w` carries the full set |
422
+ | `duration-[250ms]` | `duration-250` | untouched — not on the v3 scale |
423
+ | `scale-[1.15]` | `scale-115` | untouched — not on the v3 scale |
424
+ | `rotate-[30deg]` | `rotate-30` | untouched — not on the v3 scale |
425
+ | `blur-[4px]` | `blur-xs` | `blur-sm` — v3 names are one step lower |
426
+ | `blur-[8px]` | `blur-sm` | `blur` |
427
+ | `z-[15]` | `z-15` | untouched — v3 stops at `0`–`50` in tens |
428
+ | `leading-[44px]` | `leading-11` | untouched — v3 ships `leading-3`–`leading-10` |
429
+ | `order-[13]` | `order-13` | untouched — v3 ships `1`–`12` |
430
+ | `grow-[2]` | `grow-2` | untouched — v3 ships `grow` and `grow-0` |
431
+ | `line-clamp-[7]` | `line-clamp-7` | untouched — v3 ships `1`–`6` |
432
+ | `columns-[13]` | `columns-13` | untouched — v3 ships `1`–`12` |
396
433
 
397
434
  Spacing steps: `size-*` has no numeric scale in v3.3, and `min-w-*`, `max-w-*`
398
435
  and `min-h-*` only ship the zero step — they gained the scale afterwards.
399
436
  Fractions: `w` carries all thirteen, `h` stops at sixths, `inset`/`top`/`left`/
400
437
  `right`/`bottom`/`translate-x`/`translate-y` only have halves, thirds and
401
438
  quarters, and `min-w`/`max-w`/`min-h`/`max-h` have none.
402
-
403
- `text-*`, `rounded-*` and `opacity-*` are identical in both majors and are
439
+ `duration-*`/`delay-*` stop at 0, 75, 100, 150, 200, 300, 500, 700 and 1000;
440
+ `scale-*` at 0, 50, 75, 90, 95, 100, 105, 110, 125 and 150; `rotate-*` at 0, 1,
441
+ 2, 3, 6, 12, 45, 90 and 180. v4 accepts any integer for all four.
442
+
443
+ `*-px` follows the same per-utility gating: `size-px`, `min-w-px`, `max-w-px`
444
+ and `min-h-px` are left untouched on v3.
445
+
446
+ `rounded-*` follows each major's radius scale: v4 renamed the small end, so
447
+ `rounded-[2px]` → `rounded-xs` and `rounded-[4px]` → `rounded-sm` on v4, but
448
+ `rounded-sm` and bare `rounded` on v3; `rounded-[32px]` → `rounded-4xl` is
449
+ v4-only. `text-*`, `opacity-*`, `font-*` weights, the `leading-*`
450
+ keywords and the `border`/`divide`/`ring`/`outline`
451
+ widths are identical in both majors and are
404
452
  never gated. `customSpacingTokens` stays your decision and is applied in both
405
453
  modes. All of the above was measured against `tailwindcss@3.3.7`, the
406
454
  advertised floor.
@@ -43,5 +43,10 @@ export type Config = {
43
43
  /** Target Tailwind major. v3 restricts suggestions to its discrete scale. */
44
44
  tailwindVersion?: 3 | 4;
45
45
  };
46
+ /**
47
+ * Suggests the canonical form of a class, preserving its variants
48
+ * (`hover:`, `md:`, `[&:hover]:`), important modifier (`!p-4` / `p-4!`)
49
+ * and sign (`-mt-[8px]` / `mt-[-8px]` → `-mt-2`).
50
+ */
46
51
  export declare function suggestCanonical(cls: string, config?: Config): Suggestion | null;
47
52
  export {};
@@ -14,14 +14,32 @@ const TEXT_SIZE_MAP = {
14
14
  128: '9xl',
15
15
  };
16
16
  const BUILT_IN_TEXT_PX = new Set(Object.keys(TEXT_SIZE_MAP).map(Number));
17
- const ROUNDED_MAP = {
18
- 2: 'sm',
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',
19
24
  4: 'sm',
20
25
  6: 'md',
21
26
  8: 'lg',
22
27
  12: 'xl',
23
28
  16: '2xl',
24
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',
25
43
  };
26
44
  const FRACTION_MAP = {
27
45
  '50': '1/2',
@@ -58,6 +76,7 @@ const FRACTION_PREFIXES = [
58
76
  'bottom',
59
77
  'translate-x',
60
78
  'translate-y',
79
+ 'basis',
61
80
  ];
62
81
  const SPACING_PREFIXES = [
63
82
  'h',
@@ -93,10 +112,49 @@ const SPACING_PREFIXES = [
93
112
  'translate-y',
94
113
  'space-x',
95
114
  'space-y',
115
+ 'leading',
96
116
  ];
97
117
  const SPACING_PX_RE = new RegExp(`^(${SPACING_PREFIXES.join('|')})-\\[(\\d+)px\\]$`);
98
118
  const SPACING_REM_RE = new RegExp(`^(${SPACING_PREFIXES.join('|')})-\\[(\\d+(?:\\.\\d+)?)rem\\]$`);
99
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]);
100
158
  const OPACITY_SCALE = new Set([
101
159
  0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100,
102
160
  ]);
@@ -140,6 +198,106 @@ const V3_FRACTIONS_BY_PREFIX = {
140
198
  bottom: new Set(V3_INSET_FRACTIONS),
141
199
  'translate-x': new Set(V3_INSET_FRACTIONS),
142
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',
143
301
  };
144
302
  function remToPx(rem) {
145
303
  const px = rem * 16;
@@ -153,8 +311,25 @@ function isIgnored(cls, patterns) {
153
311
  return re.test(cls);
154
312
  });
155
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
+ }
156
329
  /** True when the ÷4 result would be a class v3 never generates. */
157
330
  function isMissingInV3(prefix, step) {
331
+ if (prefix === 'leading')
332
+ return !V3_LEADING_STEPS.has(step);
158
333
  if (V3_UNSCALED_SPACING_PREFIXES.has(prefix))
159
334
  return true;
160
335
  if (V3_ZERO_ONLY_SPACING_PREFIXES.has(prefix))
@@ -179,6 +354,14 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
179
354
  isCustomToken: true,
180
355
  };
181
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
+ }
182
365
  if (px % 4 !== 0)
183
366
  return null;
184
367
  if (v3 && isMissingInV3(prefix, px / 4))
@@ -189,9 +372,100 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
189
372
  isCustomToken: false,
190
373
  };
191
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
+ */
192
445
  export function suggestCanonical(cls, config = {}) {
193
446
  if (isIgnored(cls, config.ignorePatterns))
194
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) {
195
469
  const textTokens = { ...TEXT_SIZE_MAP, ...config.customTextTokens };
196
470
  const spacingTokens = config.customSpacingTokens ?? {};
197
471
  const v3 = config.tailwindVersion === 3;
@@ -206,6 +480,39 @@ export function suggestCanonical(cls, config = {}) {
206
480
  const px = remToPx(parseFloat(textRemMatch[1]));
207
481
  return px === null ? null : textSuggestion(cls, px, textTokens);
208
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]))
505
+ return null;
506
+ const ratio = `${aspectMatch[1]}/${aspectMatch[2]}`;
507
+ const keyword = { '16/9': 'video', '1/1': 'square' }[ratio];
508
+ if (!keyword && v3)
509
+ return null;
510
+ return {
511
+ original: cls,
512
+ canonical: `aspect-${keyword ?? ratio}`,
513
+ isCustomToken: false,
514
+ };
515
+ }
209
516
  // spacing-[Npx]
210
517
  const spacingPxMatch = cls.match(SPACING_PX_RE);
211
518
  if (spacingPxMatch) {
@@ -236,8 +543,60 @@ export function suggestCanonical(cls, config = {}) {
236
543
  isCustomToken: false,
237
544
  };
238
545
  }
239
- // opacity-[N.N] or opacity-[0.N]
240
- 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+)\]$/);
241
600
  if (opacityMatch) {
242
601
  const raw = parseFloat(opacityMatch[1]);
243
602
  // CSS opacity is a 0..1 fraction; a bare value > 1 clamps to 1 and is not
@@ -256,19 +615,73 @@ export function suggestCanonical(cls, config = {}) {
256
615
  isCustomToken: false,
257
616
  };
258
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
+ }
259
642
  // rounded-[Npx]
260
643
  const roundedMatch = cls.match(/^(rounded|rounded-[a-z]+)-\[(\d+)px\]$/);
261
644
  if (roundedMatch) {
262
645
  const prefix = roundedMatch[1];
263
646
  const px = parseInt(roundedMatch[2], 10);
264
- const token = ROUNDED_MAP[px];
265
- if (!token)
647
+ const token = (v3 ? ROUNDED_MAP_V3 : ROUNDED_MAP_V4)[px];
648
+ if (token === undefined)
266
649
  return null;
267
650
  return {
268
651
  original: cls,
269
- canonical: `${prefix}-${token}`,
652
+ canonical: token ? `${prefix}-${token}` : prefix,
270
653
  isCustomToken: false,
271
654
  };
272
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
+ }
273
686
  return null;
274
687
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-canonical",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Lint and auto-fix Tailwind CSS arbitrary values that have canonical equivalents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",