tailwind-canonical 0.7.0 → 0.7.1

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.
@@ -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
  }
@@ -135,11 +135,61 @@ function getVariantOrder(cls) {
135
135
  return 2;
136
136
  return 3;
137
137
  }
138
- export function sortClasses(classStr, sortOrder) {
139
- const classes = classStr.split(/\s+/).filter(Boolean);
140
- if (classes.length <= 1)
141
- return classStr;
142
- const rank = buildRank(sortOrder?.length ? sortOrder : DEFAULT_SORT_ORDER);
138
+ const RE_ARBITRARY = /\[[^\]]*\]/g;
139
+ const RE_IMPLAUSIBLE_CLASS = /^:|:$|["'`{}?]|^[&|]+$/;
140
+ function isPlausibleClass(token) {
141
+ return !RE_IMPLAUSIBLE_CLASS.test(token.replace(RE_ARBITRARY, '[]'));
142
+ }
143
+ function interpolationEnd(str, from) {
144
+ let depth = 0;
145
+ let quote = null;
146
+ for (let j = from + 1; j < str.length; j++) {
147
+ const ch = str[j];
148
+ if (quote) {
149
+ if (ch === '\\')
150
+ j++;
151
+ else if (ch === quote)
152
+ quote = null;
153
+ }
154
+ else if (ch === '"' || ch === "'" || ch === '`')
155
+ quote = ch;
156
+ else if (ch === '{')
157
+ depth++;
158
+ else if (ch === '}' && --depth === 0)
159
+ return j + 1;
160
+ }
161
+ return str.length;
162
+ }
163
+ function splitAtoms(str) {
164
+ const atoms = [];
165
+ let i = 0;
166
+ while (i < str.length) {
167
+ if (/\s/.test(str[i])) {
168
+ i++;
169
+ continue;
170
+ }
171
+ let j = i;
172
+ let interpolated = false;
173
+ while (j < str.length && !/\s/.test(str[j])) {
174
+ if (str.startsWith('${', j)) {
175
+ interpolated = true;
176
+ j = interpolationEnd(str, j);
177
+ }
178
+ else
179
+ j++;
180
+ }
181
+ const text = str.slice(i, j);
182
+ atoms.push({
183
+ text,
184
+ start: i,
185
+ end: j,
186
+ fixed: interpolated || !isPlausibleClass(text),
187
+ });
188
+ i = j;
189
+ }
190
+ return atoms;
191
+ }
192
+ function sortSegment(classes, rank) {
143
193
  return classes
144
194
  .map((cls, i) => ({
145
195
  cls,
@@ -156,8 +206,31 @@ export function sortClasses(classStr, sortOrder) {
156
206
  return cd;
157
207
  return a.i - b.i;
158
208
  })
159
- .map((x) => x.cls)
160
- .join(' ');
209
+ .map((x) => x.cls);
210
+ }
211
+ export function sortClasses(classStr, sortOrder) {
212
+ const atoms = splitAtoms(classStr);
213
+ if (atoms.length <= 1)
214
+ return classStr;
215
+ const rank = buildRank(sortOrder?.length ? sortOrder : DEFAULT_SORT_ORDER);
216
+ if (!atoms.some((a) => a.fixed))
217
+ return sortSegment(atoms.map((a) => a.text), rank).join(' ');
218
+ const texts = atoms.map((a) => a.text);
219
+ let start = 0;
220
+ for (let k = 0; k <= atoms.length; k++) {
221
+ if (k < atoms.length && !atoms[k].fixed)
222
+ continue;
223
+ const sorted = sortSegment(texts.slice(start, k), rank);
224
+ texts.splice(start, sorted.length, ...sorted);
225
+ start = k + 1;
226
+ }
227
+ let out = '';
228
+ let cursor = 0;
229
+ atoms.forEach((atom, k) => {
230
+ out += classStr.slice(cursor, atom.start) + texts[k];
231
+ cursor = atom.end;
232
+ });
233
+ return out + classStr.slice(cursor);
161
234
  }
162
235
  export function sortContent(content, opts = {}, sortOrder) {
163
236
  return replaceClassStrings(content, (s) => sortClasses(s, sortOrder), {
@@ -1,7 +1,6 @@
1
1
  export declare function getSuppressedLines(content: string): Set<number>;
2
2
  export declare function makeLineSuppressor(content: string): (line: number) => boolean;
3
- export declare function indexToLineCol(content: string, index: number): {
3
+ export declare function createPositionLookup(content: string): (index: number) => {
4
4
  line: number;
5
5
  col: number;
6
6
  };
7
- export declare function lineAt(content: string, offset: number): number;
@@ -26,18 +26,22 @@ export function makeLineSuppressor(content) {
26
26
  const suppressed = getSuppressedLines(content);
27
27
  return (line) => suppressed.has(line);
28
28
  }
29
- export function indexToLineCol(content, index) {
30
- let line = 1;
31
- let lastNewline = -1;
32
- const end = Math.min(index, content.length);
33
- for (let i = 0; i < end; i++) {
34
- if (content[i] === '\n') {
35
- line++;
36
- lastNewline = i;
37
- }
29
+ export function createPositionLookup(content) {
30
+ const lineStarts = [0];
31
+ for (let i = 0; i < content.length; i++) {
32
+ if (content[i] === '\n')
33
+ lineStarts.push(i + 1);
38
34
  }
39
- return { line, col: index - lastNewline };
40
- }
41
- export function lineAt(content, offset) {
42
- return indexToLineCol(content, offset).line;
35
+ return (index) => {
36
+ let low = 0;
37
+ let high = lineStarts.length;
38
+ while (low + 1 < high) {
39
+ const mid = Math.floor((low + high) / 2);
40
+ if (lineStarts[mid] <= index)
41
+ low = mid;
42
+ else
43
+ high = mid;
44
+ }
45
+ return { line: low + 1, col: index - lineStarts[low] + 1 };
46
+ };
43
47
  }
@@ -1,6 +1,6 @@
1
1
  import { extractClassStrings, SINGLE_CLASS_REGEX, toClassStringOpts, } from './class-strings.js';
2
2
  import { parseColorClass, TAILWIND_COLORS } from './lexicon.js';
3
- import { getSuppressedLines, indexToLineCol } from './suppressions.js';
3
+ import { createPositionLookup, getSuppressedLines } from './suppressions.js';
4
4
  const MIN_COLOR_LEN = 3;
5
5
  function levenshtein(a, b) {
6
6
  const m = a.length;
@@ -45,6 +45,7 @@ export function detectTypo(cls, extraColors = new Set()) {
45
45
  export function analyzeTyposContent(filePath, content, config = {}) {
46
46
  const findings = [];
47
47
  const suppressed = getSuppressedLines(content);
48
+ const positionAt = createPositionLookup(content);
48
49
  const opts = toClassStringOpts(config);
49
50
  const extraColors = new Set(config.extraColors ?? []);
50
51
  for (const { value, start } of extractClassStrings(content, opts)) {
@@ -53,7 +54,7 @@ export function analyzeTyposContent(filePath, content, config = {}) {
53
54
  if (!typo)
54
55
  continue;
55
56
  const index = start + (clsMatch.index ?? 0);
56
- const { line, col } = indexToLineCol(content, index);
57
+ const { line, col } = positionAt(index);
57
58
  if (suppressed.has(line))
58
59
  continue;
59
60
  findings.push({
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from 'node:module';
2
- import { suggestCanonical } from '../core/rules.js';
2
+ import { suggestCanonical, } from '../core/rules.js';
3
3
  const _require = createRequire(import.meta.url);
4
4
  /**
5
5
  * Inner-text range of a `TemplateElement`. In espree, `quasi.range` spans the
@@ -24,14 +24,20 @@ function makeStringRuleVisitor(checkLiteral) {
24
24
  return {
25
25
  Literal: (node) => checkLiteral(node),
26
26
  TemplateLiteral(node) {
27
+ const isTagged = node.parent
28
+ ?.type === 'TaggedTemplateExpression';
27
29
  for (const quasi of node.quasis) {
30
+ const value = isTagged
31
+ ? quasi.value.raw
32
+ : (quasi.value.cooked ?? quasi.value.raw);
28
33
  checkLiteral({
29
34
  type: 'Literal',
30
- value: quasi.value.cooked ?? quasi.value.raw,
35
+ value,
31
36
  raw: quasi.value.raw,
32
37
  range: quasi.range,
33
38
  loc: quasi.loc,
34
39
  quasiRange: quasiTextRange(quasi),
40
+ valueIsRaw: isTagged || quasi.value.cooked === null,
35
41
  });
36
42
  }
37
43
  },
@@ -39,7 +45,40 @@ function makeStringRuleVisitor(checkLiteral) {
39
45
  }
40
46
  function quoteReplacement(node, value) {
41
47
  const quote = node.raw?.startsWith('"') ? '"' : "'";
42
- return `${quote}${value}${quote}`;
48
+ if (node.parent?.type === 'JSXAttribute') {
49
+ return `${quote}${escapeJsxAttribute(value, quote)}${quote}`;
50
+ }
51
+ return `${quote}${escapeString(value, quote)}${quote}`;
52
+ }
53
+ function escapeString(value, quote) {
54
+ const escaped = JSON.stringify(value).slice(1, -1);
55
+ return quote === '"'
56
+ ? escaped
57
+ : escaped.replace(/\\"/g, '"').replace(/'/g, "\\'");
58
+ }
59
+ function escapeJsxAttribute(value, quote) {
60
+ return value
61
+ .replace(/&/g, '&amp;')
62
+ .replace(quote === '"' ? /"/g : /'/g, quote === '"' ? '&quot;' : '&apos;');
63
+ }
64
+ function escapeTemplateText(value) {
65
+ return value
66
+ .replace(/\\/g, '\\\\')
67
+ .replace(/`/g, '\\`')
68
+ .replace(/\$\{/g, '\\${')
69
+ .replace(/\r/g, '\\r');
70
+ }
71
+ function replacementText(node, value) {
72
+ if (node.quasiRange) {
73
+ return node.valueIsRaw ? value : escapeTemplateText(value);
74
+ }
75
+ return quoteReplacement(node, value);
76
+ }
77
+ function replaceString(fixer, node, value) {
78
+ const text = replacementText(node, value);
79
+ return node.quasiRange
80
+ ? fixer.replaceTextRange(node.quasiRange, text)
81
+ : fixer.replaceText(node, text);
43
82
  }
44
83
  const noArbitraryCanonical = {
45
84
  meta: {
@@ -75,29 +114,36 @@ const noArbitraryCanonical = {
75
114
  function checkLiteral(node) {
76
115
  if (typeof node.value !== 'string')
77
116
  return;
78
- const value = node.value;
79
- const suggestions = [...value.matchAll(/\S+/g)]
80
- .map((match) => suggestCanonical(match[0], config))
81
- .filter((suggestion) => suggestion !== null);
117
+ const suggestions = [];
118
+ const corrected = node.value.replace(/\S+/g, (token) => {
119
+ const suggestion = suggestCanonical(token, config);
120
+ if (!suggestion)
121
+ return token;
122
+ suggestions.push(suggestion);
123
+ return suggestion.canonical;
124
+ });
82
125
  if (suggestions.length === 0)
83
126
  return;
84
- const corrected = value.replace(/\S+/g, (token) => suggestCanonical(token, config)?.canonical ?? token);
85
127
  const message = suggestions.length === 1
86
128
  ? `Use canonical class '${suggestions[0].canonical}' instead of '${suggestions[0].original}'`
87
129
  : `Use canonical classes '${suggestions.map((s) => s.canonical).join(' ')}' instead of '${suggestions.map((s) => s.original).join(' ')}'`;
88
130
  context.report({
89
131
  node,
90
132
  message,
91
- fix(fixer) {
92
- if (node.quasiRange)
93
- return fixer.replaceTextRange(node.quasiRange, corrected);
94
- return fixer.replaceText(node, quoteReplacement(node, corrected));
95
- },
133
+ fix: (fixer) => replaceString(fixer, node, corrected),
96
134
  });
97
135
  }
98
136
  return makeStringRuleVisitor(checkLiteral);
99
137
  },
100
138
  };
139
+ function loadTwMerge() {
140
+ try {
141
+ return _require('tailwind-merge').twMerge;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
101
147
  const noConflictingClasses = {
102
148
  meta: {
103
149
  type: 'suggestion',
@@ -108,17 +154,9 @@ const noConflictingClasses = {
108
154
  },
109
155
  },
110
156
  create(context) {
111
- let twMerge = null;
112
- let peerMissing = false;
113
- try {
114
- const mod = _require('tailwind-merge');
115
- twMerge = mod.twMerge;
116
- }
117
- catch {
118
- peerMissing = true;
119
- }
157
+ const twMerge = loadTwMerge();
120
158
  function checkLiteral(node) {
121
- if (peerMissing || !twMerge)
159
+ if (!twMerge)
122
160
  return;
123
161
  if (typeof node.value !== 'string')
124
162
  return;
@@ -128,11 +166,7 @@ const noConflictingClasses = {
128
166
  context.report({
129
167
  node,
130
168
  message: `Conflicting Tailwind classes detected. Use '${merged}' instead.`,
131
- fix(fixer) {
132
- if (node.quasiRange)
133
- return fixer.replaceTextRange(node.quasiRange, merged);
134
- return fixer.replaceText(node, quoteReplacement(node, merged));
135
- },
169
+ fix: (fixer) => replaceString(fixer, node, merged),
136
170
  });
137
171
  }
138
172
  return makeStringRuleVisitor(checkLiteral);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { Finding } from './core/analyzer.js';
2
2
  export { analyzeFile } from './core/analyzer.js';
3
3
  export { loadConfig } from './core/config.js';
4
- export type { ClassCombination, ColorVariant, ColorVariantGroup, ConsistencyOptions, ConsistencyReport, FileClasses, ScaleInconsistency, ScaleValue, } from './core/consistency.js';
4
+ export type { ClassCombination, ColorVariant, ColorVariantGroup, ConsistencyOptions, ConsistencyReport, FileClasses, RareScaleValue, ScaleInconsistency, ScaleValue, } from './core/consistency.js';
5
5
  export { analyzeConsistency } from './core/consistency.js';
6
6
  export { dedupeFile, deduplicateClasses } from './core/deduplicator.js';
7
7
  export { fixFile } from './core/fixer.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // This barrel is the package's stable surface. Internal plumbing
4
4
  // (class-string extraction, suppression predicates, config validation,
5
5
  // file-class collection, consistency file readers) is intentionally NOT
6
- // re-exported here — import it by direct module path if you need it.
6
+ // exposed through package exports.
7
7
  export { analyzeFile } from './core/analyzer.js';
8
8
  // Config loading
9
9
  export { loadConfig } from './core/config.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-canonical",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",
@@ -61,7 +61,7 @@
61
61
  "lefthook": "^2.1.12",
62
62
  "tailwind-merge": "^3.6.0",
63
63
  "tsx": "^4.23.13",
64
- "typescript": "^6.0.3"
64
+ "typescript": "^7.0.2"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "eslint": ">=8.0.0",