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.
@@ -31,6 +31,14 @@ const COLOR_PROPERTIES = new Set([
31
31
  'text',
32
32
  'bg',
33
33
  'border',
34
+ 'border-t',
35
+ 'border-b',
36
+ 'border-l',
37
+ 'border-r',
38
+ 'border-x',
39
+ 'border-y',
40
+ 'border-s',
41
+ 'border-e',
34
42
  'ring',
35
43
  'ring-offset',
36
44
  'divide',
@@ -44,6 +52,8 @@ const COLOR_PROPERTIES = new Set([
44
52
  'accent',
45
53
  'caret',
46
54
  'shadow',
55
+ 'text-shadow',
56
+ 'inset-shadow',
47
57
  'placeholder',
48
58
  ]);
49
59
  export const COLOR_FAMILIES = {
@@ -70,9 +80,32 @@ export const COLOR_FAMILIES = {
70
80
  neutral: 'gray',
71
81
  stone: 'gray',
72
82
  };
73
- export function stripVariants(cls) {
74
- const idx = cls.lastIndexOf(':');
75
- return idx === -1 ? cls : cls.slice(idx + 1);
83
+ function stripVariants(cls) {
84
+ let depth = 0;
85
+ let colon = -1;
86
+ for (let i = 0; i < cls.length; i++) {
87
+ const ch = cls[i];
88
+ if (ch === '\\')
89
+ i++;
90
+ else if (ch === '[' || ch === '(')
91
+ depth++;
92
+ else if (ch === ']' || ch === ')')
93
+ depth--;
94
+ else if (ch === ':' && depth === 0)
95
+ colon = i;
96
+ }
97
+ return cls.slice(colon + 1);
98
+ }
99
+ export function splitClassModifiers(cls) {
100
+ const base = stripVariants(cls);
101
+ const importantBefore = base.startsWith('!') ? '!' : '';
102
+ const importantAfter = base.endsWith('!') ? '!' : '';
103
+ return {
104
+ prefix: cls.slice(0, cls.length - base.length),
105
+ utility: base.slice(importantBefore.length, base.length - importantAfter.length),
106
+ importantBefore,
107
+ importantAfter,
108
+ };
76
109
  }
77
110
  /**
78
111
  * Parses a color utility class into its property, color name and (optional)
@@ -82,8 +115,11 @@ export function stripVariants(cls) {
82
115
  * otherwise `shade` is `''`. Shared by `typos.ts` and `consistency.ts`.
83
116
  */
84
117
  export function parseColorClass(cls) {
85
- const base = stripVariants(cls);
86
- if (base.includes('['))
118
+ const { utility } = splitClassModifiers(cls);
119
+ const slash = utility.indexOf('/');
120
+ const base = slash === -1 ? utility : utility.slice(0, slash);
121
+ const opacity = slash === -1 ? '' : utility.slice(slash);
122
+ if (base.includes('[') || base.includes('('))
87
123
  return null;
88
124
  const firstDash = base.indexOf('-');
89
125
  if (firstDash === -1)
@@ -106,9 +142,10 @@ export function parseColorClass(cls) {
106
142
  property,
107
143
  color: rest.slice(0, lastDash),
108
144
  shade: rest.slice(lastDash + 1),
145
+ opacity,
109
146
  };
110
147
  }
111
- return { property, color: rest, shade: '' };
148
+ return { property, color: rest, shade: '', opacity };
112
149
  }
113
150
  export const SCALE_PROPERTIES = new Set([
114
151
  'p',
@@ -6,9 +6,9 @@ function splitOutsideBrackets(value, separator) {
6
6
  let start = 0;
7
7
  for (let i = 0; i < value.length; i += 1) {
8
8
  const ch = value[i];
9
- if (ch === '[')
9
+ if (ch === '[' || ch === '(')
10
10
  depth += 1;
11
- else if (ch === ']')
11
+ else if (ch === ']' || ch === ')')
12
12
  depth = Math.max(0, depth - 1);
13
13
  else if (ch === separator && depth === 0) {
14
14
  parts.push(value.slice(start, i));
@@ -20,14 +20,16 @@ function splitOutsideBrackets(value, separator) {
20
20
  }
21
21
  function parseClassToken(token) {
22
22
  const parts = splitOutsideBrackets(token, ':');
23
+ const rawBase = parts.at(-1) ?? token;
24
+ const important = rawBase.startsWith('!') || rawBase.endsWith('!');
23
25
  return {
24
26
  token,
25
- modifiers: parts.slice(0, -1).join(':'),
26
- base: parts.at(-1) ?? token,
27
+ modifiers: `${parts.slice(0, -1).join(':')}${important ? '!' : ''}`,
28
+ base: rawBase.replace(/^!|!$/g, ''),
27
29
  };
28
30
  }
29
31
  const RE_LEADING = /^!?leading-/;
30
- const RE_ARBITRARY_TEXT_SIZE = /^!?text-\[-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|ex|lh|rlh|vw|vh|vmin|vmax|vi|vb|svw|lvw|dvw|svh|lvh|dvh)\]$/;
32
+ const RE_ARBITRARY_TEXT_SIZE = /^text-(?:\[(?:length:.+|-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|ex|lh|rlh|vw|vh|vmin|vmax|vi|vb|svw|lvw|dvw|svh|lvh|dvh|%)|(?:calc|min|max|clamp)\(.+\))\]|\(length:--[^)]+\))$/;
31
33
  function isUnsafeTextSize(base, customTextClasses) {
32
34
  const normalized = base.startsWith('!') ? base.slice(1) : base;
33
35
  return (customTextClasses.has(normalized) || RE_ARBITRARY_TEXT_SIZE.test(normalized));
@@ -28,6 +28,8 @@ type DefaultCommandConfig = {
28
28
  export type Config = {
29
29
  /** Set false for custom themes: only explicitly configured px mappings apply. */
30
30
  useDefaultTheme?: boolean;
31
+ /** Opt in to named text sizes, whose bundled line-height can change rendering. */
32
+ allowTextSizeLineHeightChanges?: boolean;
31
33
  customTextTokens?: Record<number, string>;
32
34
  customSpacingTokens?: Record<number, string>;
33
35
  ignorePatterns?: RegExp[];
@@ -355,7 +355,8 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
355
355
  }
356
356
  if (px === 1) {
357
357
  if (v3 &&
358
- (V3_UNSCALED_SPACING_PREFIXES.has(prefix) ||
358
+ (prefix === 'leading' ||
359
+ V3_UNSCALED_SPACING_PREFIXES.has(prefix) ||
359
360
  V3_ZERO_ONLY_SPACING_PREFIXES.has(prefix))) {
360
361
  return null;
361
362
  }
@@ -465,6 +466,9 @@ export function suggestCanonical(cls, config = {}) {
465
466
  };
466
467
  }
467
468
  function suggestUtility(cls, config) {
469
+ if (config.allowTextSizeLineHeightChanges !== true &&
470
+ /^text-\[\d+(?:\.\d+)?(?:px|rem)\]$/.test(cls))
471
+ return null;
468
472
  if (config.useDefaultTheme === false) {
469
473
  const text = cls.match(/^text-\[(\d+)px\]$/);
470
474
  if (text) {
@@ -3,6 +3,10 @@ export declare const DEFAULT_IGNORE: string[];
3
3
  export type ScanOptions = {
4
4
  extensions?: string[];
5
5
  ignore?: string[];
6
+ /** Preserve target interpretations across repeated scans (e.g. watch mode).
7
+ * Keys use forward slashes and omit the leading exclusion marker.
8
+ */
9
+ targetKinds?: ReadonlyMap<string, 'literal' | 'glob'>;
6
10
  };
7
11
  export declare function isGlob(target: string): boolean;
8
12
  export { resolveTargets, scanFiles } from '../io/scanner.js';
@@ -1,5 +1,5 @@
1
1
  import { extractClassStrings, SINGLE_CLASS_REGEX, toClassStringOpts, } from './class-strings.js';
2
- import { parseColorClass, TAILWIND_COLORS } from './lexicon.js';
2
+ import { parseColorClass, splitClassModifiers, TAILWIND_COLORS, } from './lexicon.js';
3
3
  import { createPositionLookup, getSuppressedLines } from './suppressions.js';
4
4
  const MIN_COLOR_LEN = 3;
5
5
  function levenshtein(a, b) {
@@ -28,18 +28,17 @@ function nearestColor(name) {
28
28
  return null;
29
29
  }
30
30
  export function detectTypo(cls, extraColors = new Set()) {
31
- const colon = cls.lastIndexOf(':');
32
- const prefix = colon === -1 ? '' : cls.slice(0, colon + 1);
31
+ const { prefix, importantBefore, importantAfter } = splitClassModifiers(cls);
33
32
  const parsed = parseColorClass(cls);
34
33
  if (!parsed)
35
34
  return null;
36
- const { property, color, shade } = parsed;
35
+ const { property, color, shade, opacity } = parsed;
37
36
  if (TAILWIND_COLORS.has(color) || extraColors.has(color))
38
37
  return null;
39
38
  const near = nearestColor(color);
40
39
  if (!near)
41
40
  return null;
42
- const suggestion = `${prefix}${property}-${near}${shade ? `-${shade}` : ''}`;
41
+ const suggestion = `${prefix}${importantBefore}${property}-${near}${shade ? `-${shade}` : ''}${opacity}${importantAfter}`;
43
42
  return { suggestion };
44
43
  }
45
44
  export function analyzeTyposContent(filePath, content, config = {}) {
@@ -7,13 +7,14 @@ const _require = createRequire(import.meta.url);
7
7
  * Inner-text range of a `TemplateElement`. In espree, `quasi.range` spans the
8
8
  * delimiters too (the opening backtick or `}` plus the closing backtick or
9
9
  * `${`). The leading delimiter is always one character, so the raw text starts
10
- * at `range[0] + 1` and runs for `raw.length` characters.
10
+ * at `range[0] + 1`. Use source range endpoints for its end because espree
11
+ * normalizes CRLF in `value.raw`, whose length can differ from the source.
11
12
  */
12
13
  function quasiTextRange(quasi) {
13
14
  if (!quasi.range)
14
15
  return undefined;
15
16
  const start = quasi.range[0] + 1;
16
- return [start, start + quasi.value.raw.length];
17
+ return [start, quasi.range[1] - (quasi.tail === false ? 2 : 1)];
17
18
  }
18
19
  /** Only inspect strings whose syntax establishes that they contain classes. */
19
20
  function isClassContext(node, config) {
@@ -51,6 +52,11 @@ function isClassContext(node, config) {
51
52
  return properties.every((property) => property.isKey);
52
53
  }
53
54
  if (parent.type === 'Property') {
55
+ // Computed CVA option names need evaluation to establish their meaning.
56
+ if (parent.computed)
57
+ return false;
58
+ if (current === parent.key && parent.parent?.properties?.length !== 1)
59
+ return false;
54
60
  properties.push({
55
61
  key: String(parent.key?.name ?? parent.key?.value ?? ''),
56
62
  isKey: current === parent.key,
@@ -168,6 +174,7 @@ const noArbitraryCanonical = {
168
174
  ignorePatterns: { type: 'array' },
169
175
  tailwindVersion: { enum: [3, 4] },
170
176
  useDefaultTheme: { type: 'boolean' },
177
+ allowTextSizeLineHeightChanges: { type: 'boolean' },
171
178
  functionNames: { type: 'array', items: { type: 'string' } },
172
179
  attributeNames: { type: 'array', items: { type: 'string' } },
173
180
  sortOrder: { type: 'array', items: { type: 'string' } },
package/dist/io/config.js CHANGED
@@ -133,6 +133,9 @@ export async function loadConfig(cwd, options = {}) {
133
133
  }
134
134
  throw err;
135
135
  }
136
+ if (mod.default === undefined || mod.default === null) {
137
+ throw new Error(`Invalid ${filename}: default export must be a configuration object`);
138
+ }
136
139
  const config = validateConfig(mod.default, filename);
137
140
  if (hash)
138
141
  reloadCache.set(path, { hash, config });
@@ -1,7 +1,10 @@
1
- import { existsSync, lstatSync, readdirSync, statSync, } from 'node:fs';
1
+ import { accessSync, constants, existsSync, readdirSync, statSync, } from 'node:fs';
2
2
  import { glob } from 'node:fs/promises';
3
- import { isAbsolute, join, matchesGlob, resolve } from 'node:path';
3
+ import { isAbsolute, join, matchesGlob, relative, resolve } from 'node:path';
4
4
  import { DEFAULT_EXTENSIONS, DEFAULT_IGNORE, isGlob, } from '../core/scanner.js';
5
+ function missing(error) {
6
+ return ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
7
+ }
5
8
  export function scanFiles(target, options = {}) {
6
9
  const ignore = options.ignore ?? DEFAULT_IGNORE;
7
10
  const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
@@ -11,8 +14,10 @@ export function scanFiles(target, options = {}) {
11
14
  try {
12
15
  entries = readdirSync(current, { withFileTypes: true });
13
16
  }
14
- catch {
15
- return;
17
+ catch (error) {
18
+ if (missing(error))
19
+ return;
20
+ throw error;
16
21
  }
17
22
  for (const entry of entries) {
18
23
  if (ignore.includes(entry.name))
@@ -31,8 +36,10 @@ export function scanFiles(target, options = {}) {
31
36
  try {
32
37
  stat = statSync(target);
33
38
  }
34
- catch {
35
- return [];
39
+ catch (error) {
40
+ if (missing(error))
41
+ return [];
42
+ throw error;
36
43
  }
37
44
  if (stat.isFile())
38
45
  return extensions.some((ext) => target.endsWith(ext)) ? [target] : [];
@@ -53,18 +60,42 @@ export async function resolveTargets(patterns, options = {}) {
53
60
  }
54
61
  }
55
62
  const files = new Set();
63
+ const isLiteral = (pattern, allowMissingLiteral = false) => {
64
+ const kind = options.targetKinds?.get(pattern.replace(/\\/g, '/'));
65
+ return kind
66
+ ? kind === 'literal'
67
+ : existsSync(pattern) || (allowMissingLiteral && !isGlob(pattern));
68
+ };
56
69
  for (const pattern of positive) {
57
- if (isGlob(pattern) && !existsSync(pattern)) {
58
- for await (const f of glob(pattern, {
59
- exclude: (entry) => entry
60
- .replace(/\\/g, '/')
61
- .split('/')
62
- .some((s) => ignore.includes(s)),
70
+ if (!isLiteral(pattern, true)) {
71
+ for await (const entry of glob(pattern, {
72
+ withFileTypes: true,
73
+ exclude: (entry) => {
74
+ const path = join(entry.parentPath, entry.name);
75
+ if (path
76
+ .replace(/\\/g, '/')
77
+ .split('/')
78
+ .some((part) => ignore.includes(part)))
79
+ return true;
80
+ if (entry.isDirectory()) {
81
+ try {
82
+ // fs.glob otherwise silently swallows unreadable directories,
83
+ // producing a falsely clean, partial scan.
84
+ accessSync(path, constants.R_OK | constants.X_OK);
85
+ }
86
+ catch (error) {
87
+ if (missing(error))
88
+ return true;
89
+ throw error;
90
+ }
91
+ }
92
+ return false;
93
+ },
63
94
  })) {
64
- if (extensions.some((ext) => f.endsWith(ext)) &&
65
- lstatSync(f).isFile()) {
66
- files.add(f);
67
- }
95
+ if (!(entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))))
96
+ continue;
97
+ const path = join(entry.parentPath, entry.name);
98
+ files.add(isAbsolute(pattern) ? path : relative(process.cwd(), path));
68
99
  }
69
100
  }
70
101
  else {
@@ -76,7 +107,7 @@ export async function resolveTargets(patterns, options = {}) {
76
107
  if (negativePatterns.length > 0) {
77
108
  const exclusions = negativePatterns.map((pattern) => ({
78
109
  pattern,
79
- literal: existsSync(pattern)
110
+ literal: isLiteral(pattern)
80
111
  ? resolve(pattern).replace(/\\/g, '/')
81
112
  : undefined,
82
113
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-canonical",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -54,7 +54,7 @@
54
54
  ],
55
55
  "license": "MIT",
56
56
  "devDependencies": {
57
- "@biomejs/biome": "^2.5.11",
57
+ "@biomejs/biome": "^2.6.3",
58
58
  "@types/estree": "^1.0.9",
59
59
  "@types/node": "^22.20.1",
60
60
  "knip": "^6.34.0",
@@ -84,6 +84,7 @@
84
84
  "lint:fix": "biome check --write src/ scripts/",
85
85
  "knip": "knip",
86
86
  "benchmark": "pnpm build && node scripts/benchmark.mjs",
87
- "test:compat": "node scripts/compatibility.mjs"
87
+ "test:compat": "node scripts/compatibility.mjs",
88
+ "test:css": "node scripts/css-semantics.mjs"
88
89
  }
89
90
  }