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.
- package/README.md +80 -33
- package/dist/cli/cli.js +33 -5
- package/dist/cli/watch.d.ts +4 -2
- package/dist/cli/watch.js +15 -6
- package/dist/core/class-strings.d.ts +2 -0
- package/dist/core/class-strings.js +158 -130
- package/dist/core/config.js +11 -3
- package/dist/core/consistency.js +4 -4
- package/dist/core/deduplicator.d.ts +1 -1
- package/dist/core/deduplicator.js +32 -8
- package/dist/core/lexicon.d.ts +7 -1
- package/dist/core/lexicon.js +43 -6
- package/dist/core/merger.js +7 -5
- package/dist/core/rules.d.ts +2 -0
- package/dist/core/rules.js +5 -1
- package/dist/core/scanner.d.ts +4 -0
- package/dist/core/typos.js +4 -5
- package/dist/eslint/plugin.js +9 -2
- package/dist/io/config.js +3 -0
- package/dist/io/scanner.js +48 -17
- package/package.json +4 -3
package/dist/core/lexicon.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
86
|
-
|
|
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',
|
package/dist/core/merger.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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));
|
package/dist/core/rules.d.ts
CHANGED
|
@@ -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[];
|
package/dist/core/rules.js
CHANGED
|
@@ -355,7 +355,8 @@ function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
|
|
|
355
355
|
}
|
|
356
356
|
if (px === 1) {
|
|
357
357
|
if (v3 &&
|
|
358
|
-
(
|
|
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) {
|
package/dist/core/scanner.d.ts
CHANGED
|
@@ -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';
|
package/dist/core/typos.js
CHANGED
|
@@ -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
|
|
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 = {}) {
|
package/dist/eslint/plugin.js
CHANGED
|
@@ -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
|
|
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,
|
|
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 });
|
package/dist/io/scanner.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
58
|
-
for await (const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.
|
|
62
|
-
|
|
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) =>
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
}
|