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.
@@ -32,8 +32,7 @@ const ANALYZE_KEYS = [
32
32
  'maxRareValues',
33
33
  'maxPatterns',
34
34
  ];
35
- const ANALYZE_KEY_SET = new Set(ANALYZE_KEYS);
36
- const DEFAULT_COMMAND_KEYS = [
35
+ const DEFAULT_COMMAND_BOOLEAN_KEYS = [
37
36
  'fix',
38
37
  'merge',
39
38
  'dedup',
@@ -42,21 +41,29 @@ const DEFAULT_COMMAND_KEYS = [
42
41
  'analyze',
43
42
  'typos',
44
43
  'watch',
44
+ ];
45
+ const DEFAULT_COMMAND_KEYS = [
46
+ ...DEFAULT_COMMAND_BOOLEAN_KEYS,
45
47
  'reporter',
46
48
  'targets',
47
49
  ];
48
- const DEFAULT_COMMAND_KEY_SET = new Set(DEFAULT_COMMAND_KEYS);
49
50
  const REPORTERS = new Set(['text', 'json', 'sarif']);
50
- const KNOWN_KEY_SET = new Set(KNOWN_KEYS);
51
51
  const SORT_CATEGORIES = new Set(DEFAULT_SORT_ORDER);
52
- function invalidConfig(filename, message) {
53
- return new Error(`Invalid ${filename}: ${message}`);
54
- }
55
52
  function fail(filename, message) {
56
- throw invalidConfig(filename, message);
53
+ throw new Error(`Invalid ${filename}: ${message}`);
54
+ }
55
+ function isPlainObject(value) {
56
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
57
+ }
58
+ function assertKnownKeys(cfg, known, label, filename) {
59
+ for (const key of Object.keys(cfg)) {
60
+ if (!known.includes(key)) {
61
+ fail(filename, `${label} "${key}" (expected one of: ${known.join(', ')})`);
62
+ }
63
+ }
57
64
  }
58
65
  function assertPxTokenMap(value, key, filename) {
59
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
66
+ if (!isPlainObject(value)) {
60
67
  fail(filename, `${key} must be an object mapping px numbers to token names`);
61
68
  }
62
69
  for (const [k, v] of Object.entries(value)) {
@@ -77,7 +84,7 @@ function assertRegExpArray(value, key, filename) {
77
84
  }
78
85
  }
79
86
  function assertStringRecord(value, key, filename) {
80
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
87
+ if (!isPlainObject(value)) {
81
88
  fail(filename, `${key} must be an object mapping strings to strings`);
82
89
  }
83
90
  for (const [k, v] of Object.entries(value)) {
@@ -91,40 +98,20 @@ function assertPositiveInteger(value, key, filename) {
91
98
  }
92
99
  }
93
100
  function assertAnalyzeConfig(value, filename) {
94
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
101
+ if (!isPlainObject(value))
95
102
  fail(filename, 'analyze must be an object');
96
- }
97
- const cfg = value;
98
- for (const key of Object.keys(cfg)) {
99
- if (!ANALYZE_KEY_SET.has(key)) {
100
- fail(filename, `analyze contains unknown key "${key}" (expected one of: ${ANALYZE_KEYS.join(', ')})`);
101
- }
102
- }
103
+ assertKnownKeys(value, ANALYZE_KEYS, 'analyze contains unknown key', filename);
103
104
  for (const key of ANALYZE_KEYS) {
104
- if (key in cfg)
105
- assertPositiveInteger(cfg[key], `analyze.${key}`, filename);
105
+ if (key in value)
106
+ assertPositiveInteger(value[key], `analyze.${key}`, filename);
106
107
  }
107
108
  }
108
109
  function assertDefaultCommandConfig(value, filename) {
109
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110
+ if (!isPlainObject(value))
110
111
  fail(filename, 'defaultCommand must be an object');
111
- }
112
112
  const cfg = value;
113
- for (const key of Object.keys(cfg)) {
114
- if (!DEFAULT_COMMAND_KEY_SET.has(key)) {
115
- fail(filename, `defaultCommand contains unknown key "${key}" (expected one of: ${DEFAULT_COMMAND_KEYS.join(', ')})`);
116
- }
117
- }
118
- for (const key of [
119
- 'fix',
120
- 'merge',
121
- 'dedup',
122
- 'sort',
123
- 'check',
124
- 'analyze',
125
- 'typos',
126
- 'watch',
127
- ]) {
113
+ assertKnownKeys(cfg, DEFAULT_COMMAND_KEYS, 'defaultCommand contains unknown key', filename);
114
+ for (const key of DEFAULT_COMMAND_BOOLEAN_KEYS) {
128
115
  if (key in cfg && typeof cfg[key] !== 'boolean') {
129
116
  fail(filename, `defaultCommand.${key} must be a boolean`);
130
117
  }
@@ -153,11 +140,7 @@ export function validateConfig(input, filename = CONFIG_FILENAME) {
153
140
  fail(filename, 'default export must be an object');
154
141
  }
155
142
  const cfg = input;
156
- for (const key of Object.keys(cfg)) {
157
- if (!KNOWN_KEY_SET.has(key)) {
158
- fail(filename, `unknown key "${key}" (expected one of: ${KNOWN_KEYS.join(', ')})`);
159
- }
160
- }
143
+ assertKnownKeys(cfg, KNOWN_KEYS, 'unknown key', filename);
161
144
  if ('customTextTokens' in cfg)
162
145
  assertPxTokenMap(cfg.customTextTokens, 'customTextTokens', filename);
163
146
  if ('customSpacingTokens' in cfg)
@@ -23,7 +23,7 @@ export type ScaleInconsistency = {
23
23
  property: string;
24
24
  values: ScaleValue[];
25
25
  };
26
- type RareScaleValue = {
26
+ export type RareScaleValue = {
27
27
  property: string;
28
28
  value: string;
29
29
  className: string;
@@ -30,53 +30,6 @@ const POSITION_GROUP = new Set([
30
30
  'relative',
31
31
  'sticky',
32
32
  ]);
33
- const SIDE_MAP = {
34
- p: ['t', 'b', 'l', 'r'],
35
- m: ['t', 'b', 'l', 'r'],
36
- border: ['t', 'b', 'l', 'r'],
37
- inset: ['t', 'b', 'l', 'r'],
38
- gap: ['t', 'b', 'l', 'r'],
39
- 'scroll-p': ['t', 'b', 'l', 'r'],
40
- 'scroll-m': ['t', 'b', 'l', 'r'],
41
- px: ['l', 'r'],
42
- mx: ['l', 'r'],
43
- 'border-x': ['l', 'r'],
44
- 'inset-x': ['l', 'r'],
45
- 'gap-x': ['l', 'r'],
46
- 'scroll-px': ['l', 'r'],
47
- 'scroll-mx': ['l', 'r'],
48
- py: ['t', 'b'],
49
- my: ['t', 'b'],
50
- 'border-y': ['t', 'b'],
51
- 'inset-y': ['t', 'b'],
52
- 'gap-y': ['t', 'b'],
53
- 'scroll-py': ['t', 'b'],
54
- 'scroll-my': ['t', 'b'],
55
- pt: ['t'],
56
- mt: ['t'],
57
- 'border-t': ['t'],
58
- top: ['t'],
59
- 'scroll-pt': ['t'],
60
- 'scroll-mt': ['t'],
61
- pb: ['b'],
62
- mb: ['b'],
63
- 'border-b': ['b'],
64
- bottom: ['b'],
65
- 'scroll-pb': ['b'],
66
- 'scroll-mb': ['b'],
67
- pl: ['l'],
68
- ml: ['l'],
69
- 'border-l': ['l'],
70
- left: ['l'],
71
- 'scroll-pl': ['l'],
72
- 'scroll-ml': ['l'],
73
- pr: ['r'],
74
- mr: ['r'],
75
- 'border-r': ['r'],
76
- right: ['r'],
77
- 'scroll-pr': ['r'],
78
- 'scroll-mr': ['r'],
79
- };
80
33
  const FAMILIES = [
81
34
  {
82
35
  kind: 'p',
@@ -160,6 +113,20 @@ const FAMILIES = [
160
113
  regex: /^(scroll-m|scroll-mx|scroll-my|scroll-mt|scroll-mb|scroll-ml|scroll-mr)-(.+)$/,
161
114
  },
162
115
  ];
116
+ const SIDE_MAP = {};
117
+ for (const f of FAMILIES) {
118
+ const entries = [
119
+ [f.full, ['t', 'b', 'l', 'r']],
120
+ [f.x, ['l', 'r']],
121
+ [f.y, ['t', 'b']],
122
+ [f.t, ['t']],
123
+ [f.b, ['b']],
124
+ [f.l, ['l']],
125
+ [f.r, ['r']],
126
+ ];
127
+ for (const [prefix, sides] of entries)
128
+ SIDE_MAP[prefix] ??= sides;
129
+ }
163
130
  function parseBoxClass(cls) {
164
131
  for (const family of FAMILIES) {
165
132
  const m = cls.match(family.regex);
@@ -207,39 +174,27 @@ function collapseBox(box, f) {
207
174
  }
208
175
  return parts.join(' ');
209
176
  }
177
+ const CORNER_MAP = {
178
+ '': ['tl', 'tr', 'bl', 'br'],
179
+ t: ['tl', 'tr'],
180
+ b: ['bl', 'br'],
181
+ l: ['tl', 'bl'],
182
+ r: ['tr', 'br'],
183
+ tl: ['tl'],
184
+ tr: ['tr'],
185
+ bl: ['bl'],
186
+ br: ['br'],
187
+ };
210
188
  function parseRoundedCorner(cls) {
211
- const sideM = cls.match(/^rounded-(tl|tr|bl|br|t|b|l|r)(?:-(.+))?$/);
212
- if (sideM) {
213
- const side = sideM[1];
214
- const val = sideM[2] ?? '';
215
- const c = {};
216
- if (side === 't') {
217
- c.tl = val;
218
- c.tr = val;
219
- }
220
- else if (side === 'b') {
221
- c.bl = val;
222
- c.br = val;
223
- }
224
- else if (side === 'l') {
225
- c.tl = val;
226
- c.bl = val;
227
- }
228
- else if (side === 'r') {
229
- c.tr = val;
230
- c.br = val;
231
- }
232
- else {
233
- c[side] = val;
234
- }
235
- return { corners: c };
236
- }
237
- const allM = cls.match(/^rounded(?:-(.+))?$/);
238
- if (allM) {
239
- const val = allM[1] ?? '';
240
- return { corners: { tl: val, tr: val, bl: val, br: val } };
241
- }
242
- return null;
189
+ const m = cls.match(/^rounded-(tl|tr|bl|br|t|b|l|r)(?:-(.+))?$/) ??
190
+ cls.match(/^rounded()(?:-(.+))?$/);
191
+ if (!m)
192
+ return null;
193
+ const value = m[2] ?? '';
194
+ const corners = {};
195
+ for (const corner of CORNER_MAP[m[1]])
196
+ corners[corner] = value;
197
+ return corners;
243
198
  }
244
199
  function collapseCorners(c) {
245
200
  const { tl, tr, bl, br } = c;
@@ -310,132 +265,117 @@ const BP_RANK = {
310
265
  '2xl': 5,
311
266
  };
312
267
  function collapseResponsiveCascade(classes) {
313
- // Last-wins keyword utilities (display, position) share a single CSS property
314
- // even though their class names differ, so they must group by property — not
315
- // by class name — or an intermediate override (e.g. md:block between flex and
316
- // lg:flex) is missed and the restoring class is wrongly dropped.
317
- const propertyKey = (base) => {
318
- if (DISPLAY_GROUP.has(base))
319
- return 'display';
320
- if (POSITION_GROUP.has(base))
321
- return 'position';
322
- const dash = base.lastIndexOf('-');
323
- return dash === -1 ? base : base.slice(0, dash);
324
- };
325
268
  const entries = classes.map((cls, idx) => {
326
269
  for (const bp of RESPONSIVE_BREAKPOINTS) {
327
270
  const prefix = `${bp}:`;
328
271
  if (cls.startsWith(prefix) && !cls.slice(prefix.length).includes(':')) {
329
272
  const base = cls.slice(prefix.length);
330
- return { bp, base, key: propertyKey(base), idx };
273
+ return { bp, base, idx };
331
274
  }
332
275
  }
333
- return { bp: '', base: cls, key: propertyKey(cls), idx };
276
+ return { bp: '', base: cls, idx };
334
277
  });
335
- const groups = new Map();
336
- for (const e of entries) {
337
- const arr = groups.get(e.key);
338
- if (arr)
339
- arr.push(e);
340
- else
341
- groups.set(e.key, [e]);
342
- }
278
+ // Unknown variants cannot be ordered against the standard breakpoints safely.
279
+ if (entries.some((entry) => entry.base.includes(':')))
280
+ return classes;
281
+ if (!entries.some((entry) => entry.bp !== ''))
282
+ return classes;
283
+ const sorted = [...entries].sort((a, b) => BP_RANK[a.bp] - BP_RANK[b.bp] || a.idx - b.idx);
343
284
  const toRemove = new Set();
344
- for (const group of groups.values()) {
345
- if (group.length < 2 || !group.some((e) => e.bp !== ''))
346
- continue;
347
- const sorted = [...group].sort((a, b) => BP_RANK[a.bp] - BP_RANK[b.bp]);
348
- let prev = null;
349
- for (const entry of sorted) {
350
- if (entry.base === prev) {
351
- toRemove.add(entry.idx);
352
- }
353
- else {
354
- prev = entry.base;
355
- }
356
- }
285
+ let prev = null;
286
+ for (const entry of sorted) {
287
+ if (entry.base === prev)
288
+ toRemove.add(entry.idx);
289
+ else
290
+ prev = entry.base;
357
291
  }
358
292
  return classes.filter((_, i) => !toRemove.has(i));
359
293
  }
360
294
  // ─── Public API ───────────────────────────────────────────────────────────────
361
- function pushCollapsed(result, collapsed, classes) {
362
- const original = [...new Set(classes)].join(' ');
363
- result.push(...(collapsed === original ? original.split(' ') : collapsed.split(' ')));
295
+ // Survivors keep their slot; new shorthands take the slot of the first token
296
+ // they replace, so an unchanged group leaves the string byte-for-byte intact.
297
+ function placeCollapsed(slots, classes, indices, collapsed) {
298
+ const firstIndex = new Map();
299
+ for (const i of indices) {
300
+ if (!firstIndex.has(classes[i]))
301
+ firstIndex.set(classes[i], i);
302
+ }
303
+ const kept = new Set();
304
+ const fresh = [];
305
+ for (const token of collapsed.split(' ')) {
306
+ const i = firstIndex.get(token);
307
+ if (i === undefined)
308
+ fresh.push(token);
309
+ else
310
+ kept.add(i);
311
+ }
312
+ for (const i of indices)
313
+ slots[i] = kept.has(i) ? [classes[i]] : [];
314
+ if (fresh.length === 0)
315
+ return;
316
+ const anchor = indices.find((i) => !kept.has(i)) ?? indices[0];
317
+ slots[anchor] = [...slots[anchor], ...fresh];
318
+ }
319
+ function keepLastOf(slots, indices) {
320
+ for (const i of indices.slice(0, -1))
321
+ slots[i] = [];
364
322
  }
365
323
  export function deduplicateClasses(classStr) {
366
- let classes = classStr.split(/\s+/).filter(Boolean);
367
- if (classes.length <= 1)
324
+ const original = classStr.split(/\s+/).filter(Boolean);
325
+ if (original.length <= 1)
368
326
  return classStr;
369
- classes = collapseResponsiveCascade(classes);
370
- let displayWinner = null;
371
- let positionWinner = null;
327
+ const classes = collapseResponsiveCascade(original);
328
+ const slots = classes.map((cls) => [cls]);
329
+ const displayIndices = [];
330
+ const positionIndices = [];
372
331
  const seen = new Set();
373
- const others = [];
374
332
  const boxGroups = new Map();
375
333
  const corners = { tl: null, tr: null, bl: null, br: null };
376
- const cornersClasses = [];
377
- for (const cls of classes) {
334
+ const cornerIndices = [];
335
+ classes.forEach((cls, idx) => {
378
336
  if (DISPLAY_GROUP.has(cls)) {
379
- displayWinner = cls;
380
- continue;
337
+ displayIndices.push(idx);
338
+ return;
381
339
  }
382
340
  if (POSITION_GROUP.has(cls)) {
383
- positionWinner = cls;
384
- continue;
341
+ positionIndices.push(idx);
342
+ return;
385
343
  }
386
344
  const parsed = parseBoxClass(cls);
387
345
  if (parsed) {
388
346
  const { family, sides } = parsed;
389
347
  let group = boxGroups.get(family.kind);
390
348
  if (!group) {
391
- group = { family, box: { t: '', b: '', l: '', r: '' }, classes: [] };
349
+ group = { family, box: { t: '', b: '', l: '', r: '' }, indices: [] };
392
350
  boxGroups.set(family.kind, group);
393
351
  }
394
- group.classes.push(cls);
395
- if (sides.t !== undefined)
396
- group.box.t = sides.t;
397
- if (sides.b !== undefined)
398
- group.box.b = sides.b;
399
- if (sides.l !== undefined)
400
- group.box.l = sides.l;
401
- if (sides.r !== undefined)
402
- group.box.r = sides.r;
403
- continue;
352
+ group.indices.push(idx);
353
+ Object.assign(group.box, sides);
354
+ return;
404
355
  }
405
- const roundedParsed = parseRoundedCorner(cls);
406
- if (roundedParsed) {
407
- cornersClasses.push(cls);
408
- const { corners: c } = roundedParsed;
409
- if (c.tl !== undefined)
410
- corners.tl = c.tl;
411
- if (c.tr !== undefined)
412
- corners.tr = c.tr;
413
- if (c.bl !== undefined)
414
- corners.bl = c.bl;
415
- if (c.br !== undefined)
416
- corners.br = c.br;
417
- continue;
356
+ const roundedCorners = parseRoundedCorner(cls);
357
+ if (roundedCorners) {
358
+ cornerIndices.push(idx);
359
+ Object.assign(corners, roundedCorners);
360
+ return;
418
361
  }
419
- if (!seen.has(cls)) {
362
+ if (seen.has(cls))
363
+ slots[idx] = [];
364
+ else
420
365
  seen.add(cls);
421
- others.push(cls);
422
- }
423
- }
424
- const result = [];
425
- if (displayWinner)
426
- result.push(displayWinner);
427
- if (positionWinner)
428
- result.push(positionWinner);
429
- for (const { family, box, classes: groupClasses } of boxGroups.values()) {
430
- if (groupClasses.length === 0)
431
- continue;
432
- pushCollapsed(result, collapseBox(box, family), groupClasses);
366
+ });
367
+ keepLastOf(slots, displayIndices);
368
+ keepLastOf(slots, positionIndices);
369
+ for (const { family, box, indices } of boxGroups.values()) {
370
+ placeCollapsed(slots, classes, indices, collapseBox(box, family));
433
371
  }
434
- if (cornersClasses.length > 0) {
435
- pushCollapsed(result, collapseCorners(corners), cornersClasses);
372
+ if (cornerIndices.length > 0) {
373
+ placeCollapsed(slots, classes, cornerIndices, collapseCorners(corners));
436
374
  }
437
- result.push(...others);
438
- return result.join(' ');
375
+ const result = slots.flat();
376
+ const unchanged = result.length === original.length &&
377
+ result.every((cls, i) => cls === original[i]);
378
+ return unchanged ? classStr : result.join(' ');
439
379
  }
440
380
  export function dedupeContent(content, opts = {}) {
441
381
  return replaceClassStrings(content, deduplicateClasses, {
@@ -38,9 +38,15 @@ function isUnsafeTextSize(base, customTextClasses) {
38
38
  * `cls`'s own position — a leading declared after it never applied to it.
39
39
  */
40
40
  function lastLeadingBefore(originalClasses, cls, mergedTokens) {
41
- // twMerge only filters tokens, never synthesises one, so `cls` is always
42
- // present in the original — `findIndex` cannot return -1 here.
43
- const at = originalClasses.findIndex(({ token }) => token === cls.token);
41
+ // twMerge keeps the last occurrence of repeated class tokens. Use that
42
+ // occurrence when restoring its preceding leading utility.
43
+ let at = -1;
44
+ for (let i = originalClasses.length - 1; i >= 0; i -= 1) {
45
+ if (originalClasses[i].token === cls.token) {
46
+ at = i;
47
+ break;
48
+ }
49
+ }
44
50
  for (let i = at - 1; i >= 0; i -= 1) {
45
51
  const candidate = originalClasses[i];
46
52
  if (RE_LEADING.test(candidate.base) &&
@@ -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 {};