tailwind-canonical 0.8.0 → 0.9.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.
@@ -7,6 +7,7 @@ export const CONFIG_FILENAMES = [
7
7
  'tailwind-canonical.config.mjs',
8
8
  ];
9
9
  const KNOWN_KEYS = [
10
+ 'useDefaultTheme',
10
11
  'customTextTokens',
11
12
  'customSpacingTokens',
12
13
  'ignorePatterns',
@@ -141,6 +142,8 @@ export function validateConfig(input, filename = CONFIG_FILENAME) {
141
142
  }
142
143
  const cfg = input;
143
144
  assertKnownKeys(cfg, KNOWN_KEYS, 'unknown key', filename);
145
+ if ('useDefaultTheme' in cfg && typeof cfg.useDefaultTheme !== 'boolean')
146
+ fail(filename, 'useDefaultTheme must be a boolean');
144
147
  if ('customTextTokens' in cfg)
145
148
  assertPxTokenMap(cfg.customTextTokens, 'customTextTokens', filename);
146
149
  if ('customSpacingTokens' in cfg)
@@ -176,4 +179,4 @@ export function validateConfig(input, filename = CONFIG_FILENAME) {
176
179
  }
177
180
  return cfg;
178
181
  }
179
- export { loadConfig, resolveInitFilename } from '../io/config.js';
182
+ export { findConfigPath, loadConfig, resolveInitFilename, } from '../io/config.js';
@@ -2,7 +2,12 @@ import { type ClassStringOpts } from './class-strings.js';
2
2
  import type { Config } from './rules.js';
3
3
  export type FileClasses = {
4
4
  file: string;
5
+ /** All class occurrences, used for color and scale statistics. */
5
6
  classes: string[];
7
+ /** Individual class strings, used for repeated-pattern detection.
8
+ * When omitted, `classes` is treated as one class string for compatibility.
9
+ */
10
+ classLists?: string[][];
6
11
  };
7
12
  export type ColorVariant = {
8
13
  token: string;
@@ -9,7 +9,11 @@ function parseColor(cls, families, knownColors) {
9
9
  return null;
10
10
  // A known color without an explicit family forms its own family, so colors
11
11
  // newly added to the palette are grouped rather than silently dropped.
12
- const family = families[color] ?? (knownColors.has(color) ? color : null);
12
+ const family = Object.hasOwn(families, color)
13
+ ? families[color]
14
+ : knownColors.has(color)
15
+ ? color
16
+ : null;
13
17
  if (!family)
14
18
  return null;
15
19
  return { property, family, token: `${color}-${shade}` };
@@ -22,7 +26,8 @@ function parseScale(cls, scaleProperties) {
22
26
  const base = stripVariants(cls);
23
27
  const negative = base.startsWith('-');
24
28
  const body = negative ? base.slice(1) : base;
25
- const dash = body.lastIndexOf('-');
29
+ const bracket = body.indexOf('[');
30
+ const dash = body.lastIndexOf('-', bracket === -1 ? body.length : bracket);
26
31
  if (dash === -1)
27
32
  return null;
28
33
  const property = body.slice(0, dash);
@@ -149,20 +154,22 @@ function detectRareScaleValues(inconsistencies, minPropertyOccurrences, maxFiles
149
154
  // frequent sub-patterns (no k-subset itemset mining).
150
155
  function detectCombinations(input, minSize, minFiles, max) {
151
156
  const combos = new Map();
152
- for (const { file, classes } of input) {
153
- if (classes.length < minSize)
154
- continue;
155
- const unique = [...new Set(classes)].sort();
156
- if (unique.length < minSize)
157
- continue;
158
- const key = unique.join(' ');
159
- let entry = combos.get(key);
160
- if (!entry) {
161
- entry = { classes: unique, count: 0, files: new Set() };
162
- combos.set(key, entry);
157
+ for (const { file, classes: allClasses, classLists } of input) {
158
+ for (const classes of classLists ?? [allClasses]) {
159
+ if (classes.length < minSize)
160
+ continue;
161
+ const unique = [...new Set(classes)].sort();
162
+ if (unique.length < minSize)
163
+ continue;
164
+ const key = unique.join(' ');
165
+ let entry = combos.get(key);
166
+ if (!entry) {
167
+ entry = { classes: unique, count: 0, files: new Set() };
168
+ combos.set(key, entry);
169
+ }
170
+ entry.count++;
171
+ entry.files.add(file);
163
172
  }
164
- entry.count++;
165
- entry.files.add(file);
166
173
  }
167
174
  return [...combos.values()]
168
175
  .filter((c) => c.files.size >= minFiles)
@@ -186,6 +186,8 @@ const CORNER_MAP = {
186
186
  br: ['br'],
187
187
  };
188
188
  function parseRoundedCorner(cls) {
189
+ if (/^rounded-(?:s|e|ss|se|es|ee)(?:-|$)/.test(cls))
190
+ return null;
189
191
  const m = cls.match(/^rounded-(tl|tr|bl|br|t|b|l|r)(?:-(.+))?$/) ??
190
192
  cls.match(/^rounded()(?:-(.+))?$/);
191
193
  if (!m)
@@ -321,6 +323,8 @@ function keepLastOf(slots, indices) {
321
323
  slots[i] = [];
322
324
  }
323
325
  export function deduplicateClasses(classStr) {
326
+ if (classStr.includes('${'))
327
+ return classStr;
324
328
  const original = classStr.split(/\s+/).filter(Boolean);
325
329
  if (original.length <= 1)
326
330
  return classStr;
@@ -1,4 +1,5 @@
1
1
  import { type ClassStringOpts } from './class-strings.js';
2
+ export declare function mergeClasses(classes: string, twMerge: (classes: string) => string, opts?: Pick<ClassStringOpts, 'customTextTokens'>): string;
2
3
  export declare function mergeContent(content: string, twMerge: (classes: string) => string, opts?: ClassStringOpts): {
3
4
  result: string;
4
5
  count: number;
@@ -84,9 +84,12 @@ function restoreLeadingForUnsafeTextSizes(original, merged, customTextClasses) {
84
84
  }
85
85
  return restored.join(' ');
86
86
  }
87
- export function mergeContent(content, twMerge, opts = {}) {
87
+ export function mergeClasses(classes, twMerge, opts = {}) {
88
88
  const customTextClasses = new Set(Object.values(opts.customTextTokens ?? {}).map((token) => `text-${token}`));
89
- return replaceClassStrings(content, (classes) => restoreLeadingForUnsafeTextSizes(classes, twMerge(classes), customTextClasses), {
89
+ return restoreLeadingForUnsafeTextSizes(classes, twMerge(classes), customTextClasses);
90
+ }
91
+ export function mergeContent(content, twMerge, opts = {}) {
92
+ return replaceClassStrings(content, (classes) => mergeClasses(classes, twMerge, opts), {
90
93
  ...opts,
91
94
  isSuppressed: makeLineSuppressor(content),
92
95
  });
@@ -26,6 +26,8 @@ type DefaultCommandConfig = {
26
26
  targets?: string[];
27
27
  };
28
28
  export type Config = {
29
+ /** Set false for custom themes: only explicitly configured px mappings apply. */
30
+ useDefaultTheme?: boolean;
29
31
  customTextTokens?: Record<number, string>;
30
32
  customSpacingTokens?: Record<number, string>;
31
33
  ignorePatterns?: RegExp[];
@@ -13,7 +13,6 @@ const TEXT_SIZE_MAP = {
13
13
  96: '8xl',
14
14
  128: '9xl',
15
15
  };
16
- const BUILT_IN_TEXT_PX = new Set(Object.keys(TEXT_SIZE_MAP).map(Number));
17
16
  /**
18
17
  * Radius px → token suffix. v4 renamed the small end (`rounded-sm` → `-xs`,
19
18
  * bare `rounded` → `-sm`) and added `4xl`; `''` is the bare v3 `rounded`.
@@ -343,7 +342,7 @@ function textSuggestion(cls, px, textTokens) {
343
342
  return {
344
343
  original: cls,
345
344
  canonical: `text-${token}`,
346
- isCustomToken: !BUILT_IN_TEXT_PX.has(px),
345
+ isCustomToken: textTokens[px] !== TEXT_SIZE_MAP[px],
347
346
  };
348
347
  }
349
348
  function spacingSuggestion(cls, prefix, px, spacingTokens, v3) {
@@ -466,6 +465,29 @@ export function suggestCanonical(cls, config = {}) {
466
465
  };
467
466
  }
468
467
  function suggestUtility(cls, config) {
468
+ if (config.useDefaultTheme === false) {
469
+ const text = cls.match(/^text-\[(\d+)px\]$/);
470
+ if (text) {
471
+ const token = config.customTextTokens?.[Number(text[1])];
472
+ return token
473
+ ? { original: cls, canonical: `text-${token}`, isCustomToken: true }
474
+ : null;
475
+ }
476
+ const spacing = cls.match(SPACING_PX_RE);
477
+ if (spacing) {
478
+ const token = config.customSpacingTokens?.[Number(spacing[2])];
479
+ return token
480
+ ? {
481
+ original: cls,
482
+ canonical: `${spacing[1]}-${token}`,
483
+ isCustomToken: true,
484
+ }
485
+ : null;
486
+ }
487
+ // Even rem-to-px equivalences depend on the consumer's root font size.
488
+ // A custom theme must opt in through explicit mappings, never inference.
489
+ return null;
490
+ }
469
491
  const textTokens = { ...TEXT_SIZE_MAP, ...config.customTextTokens };
470
492
  const spacingTokens = config.customSpacingTokens ?? {};
471
493
  const v3 = config.tailwindVersion === 3;
@@ -5,5 +5,4 @@ export type ScanOptions = {
5
5
  ignore?: string[];
6
6
  };
7
7
  export declare function isGlob(target: string): boolean;
8
- export declare function globToRegex(pattern: string): RegExp;
9
8
  export { resolveTargets, scanFiles } from '../io/scanner.js';
@@ -15,55 +15,6 @@ export const DEFAULT_IGNORE = [
15
15
  'coverage',
16
16
  ];
17
17
  export function isGlob(target) {
18
- return target.includes('*') || target.includes('?') || target.includes('{');
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
- }
29
- export function globToRegex(pattern) {
30
- let i = 0;
31
- const anchored = pattern.startsWith('/') || /^[A-Za-z]:/.test(pattern);
32
- let re = anchored ? '^' : '(?:^|/)';
33
- while (i < pattern.length) {
34
- const c = pattern[i];
35
- if (c === '*' && pattern[i + 1] === '*') {
36
- i += 2;
37
- if (pattern[i] === '/') {
38
- re += '(?:[^/]+/)*';
39
- i++;
40
- }
41
- else {
42
- re += '.*';
43
- }
44
- }
45
- else if (c === '*') {
46
- re += '[^/]*';
47
- i++;
48
- }
49
- else if (c === '?') {
50
- re += '[^/]';
51
- i++;
52
- }
53
- else if (c === '{' && pattern.indexOf('}', i) !== -1) {
54
- const close = pattern.indexOf('}', i);
55
- const alts = pattern
56
- .slice(i + 1, close)
57
- .split(',')
58
- .map(wildcardToRegex);
59
- re += `(?:${alts.join('|')})`;
60
- i = close + 1;
61
- }
62
- else {
63
- re += escapeRegex(c);
64
- i++;
65
- }
66
- }
67
- return new RegExp(`${re}$`);
18
+ return /[*?{[(]/.test(target);
68
19
  }
69
20
  export { resolveTargets, scanFiles } from '../io/scanner.js';
@@ -9,7 +9,7 @@ interface TailwindCanonicalPlugin {
9
9
  plugins: {
10
10
  'tailwind-canonical': TailwindCanonicalPlugin;
11
11
  };
12
- rules: Record<string, string>;
12
+ rules: Record<string, 'warn'>;
13
13
  };
14
14
  };
15
15
  }
@@ -1,4 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
+ import { validateConfig } from '../core/config.js';
3
+ import { mergeClasses } from '../core/merger.js';
2
4
  import { suggestCanonical, } from '../core/rules.js';
3
5
  const _require = createRequire(import.meta.url);
4
6
  /**
@@ -13,31 +15,97 @@ function quasiTextRange(quasi) {
13
15
  const start = quasi.range[0] + 1;
14
16
  return [start, start + quasi.value.raw.length];
15
17
  }
16
- /**
17
- * Builds the shared ESLint visitor that runs `checkLiteral` on every string
18
- * `Literal` and on each quasi of a `TemplateLiteral`. Both rules walk class
19
- * strings identically, so the traversal lives here once. Quasi nodes carry the
20
- * real `range`/`loc` so `context.report` and the fixer operate on actual source
21
- * positions instead of a detached synthetic node.
22
- */
23
- function makeStringRuleVisitor(checkLiteral) {
18
+ /** Only inspect strings whose syntax establishes that they contain classes. */
19
+ function isClassContext(node, config) {
20
+ const attributes = config.attributeNames?.length
21
+ ? config.attributeNames
22
+ : ['className'];
23
+ const functions = config.functionNames ?? ['cn', 'clsx', 'cva'];
24
+ const properties = [];
25
+ let current = node;
26
+ while (current.parent) {
27
+ const parent = current.parent;
28
+ if (parent.type === 'JSXAttribute') {
29
+ const name = parent.name;
30
+ return (properties.length === 0 &&
31
+ typeof name === 'object' &&
32
+ attributes.includes(name.name ?? ''));
33
+ }
34
+ if (parent.type === 'CallExpression') {
35
+ if (parent.callee?.type !== 'Identifier')
36
+ return false;
37
+ const name = String(parent.callee.name);
38
+ if (!functions.includes(name))
39
+ return false;
40
+ if (properties.length === 0)
41
+ return true;
42
+ if (name === 'cva') {
43
+ if (properties.some((property) => property.isKey))
44
+ return false;
45
+ return ((properties.length === 3 && properties[2].key === 'variants') ||
46
+ (properties.length === 2 &&
47
+ properties[1].key === 'compoundVariants' &&
48
+ ['class', 'className'].includes(properties[0].key)));
49
+ }
50
+ // clsx-style object arguments emit their keys; values are predicates.
51
+ return properties.every((property) => property.isKey);
52
+ }
53
+ if (parent.type === 'Property') {
54
+ properties.push({
55
+ key: String(parent.key?.name ?? parent.key?.value ?? ''),
56
+ isKey: current === parent.key,
57
+ });
58
+ }
59
+ if (parent.type === 'LogicalExpression' &&
60
+ parent.operator === '&&' &&
61
+ current === parent.left)
62
+ return false;
63
+ if (parent.type === 'ConditionalExpression' &&
64
+ current !== parent.consequent &&
65
+ current !== parent.alternate)
66
+ return false;
67
+ if (![
68
+ 'JSXExpressionContainer',
69
+ 'ArrayExpression',
70
+ 'ObjectExpression',
71
+ 'Property',
72
+ 'ConditionalExpression',
73
+ 'LogicalExpression',
74
+ ].includes(parent.type))
75
+ return false;
76
+ current = parent;
77
+ }
78
+ return false;
79
+ }
80
+ function makeStringRuleVisitor(checkLiteral, config = {}) {
24
81
  return {
25
- Literal: (node) => checkLiteral(node),
82
+ Literal: (node) => {
83
+ if (isClassContext(node, config)) {
84
+ checkLiteral(node);
85
+ }
86
+ },
26
87
  TemplateLiteral(node) {
27
- const isTagged = node.parent
28
- ?.type === 'TaggedTemplateExpression';
29
- for (const quasi of node.quasis) {
30
- const value = isTagged
31
- ? quasi.value.raw
32
- : (quasi.value.cooked ?? quasi.value.raw);
88
+ if (!isClassContext(node, config))
89
+ return;
90
+ for (const [index, quasi] of node.quasis.entries()) {
91
+ const value = quasi.value.cooked ?? quasi.value.raw;
92
+ // A token touching an interpolation is incomplete: e.g.
93
+ // `text-[12px]${suffix}` must not become `text-xs${suffix}`.
94
+ const start = value.match(index > 0 ? /^\S*\s*/ : /^\s*/)?.[0].length ?? 0;
95
+ const trailing = value.match(index < node.quasis.length - 1 ? /\s*\S*$/ : /\s*$/)?.[0]
96
+ .length ?? 0;
97
+ const end = Math.max(start, value.length - trailing);
98
+ const inner = value.slice(start, end);
33
99
  checkLiteral({
34
100
  type: 'Literal',
35
- value,
101
+ value: inner,
36
102
  raw: quasi.value.raw,
37
103
  range: quasi.range,
38
104
  loc: quasi.loc,
39
105
  quasiRange: quasiTextRange(quasi),
40
- valueIsRaw: isTagged || quasi.value.cooked === null,
106
+ valueIsRaw: quasi.value.cooked === null,
107
+ templatePrefix: value.slice(0, start),
108
+ templateSuffix: value.slice(end),
41
109
  });
42
110
  }
43
111
  },
@@ -70,7 +138,8 @@ function escapeTemplateText(value) {
70
138
  }
71
139
  function replacementText(node, value) {
72
140
  if (node.quasiRange) {
73
- return node.valueIsRaw ? value : escapeTemplateText(value);
141
+ const full = (node.templatePrefix ?? '') + value + (node.templateSuffix ?? '');
142
+ return node.valueIsRaw ? full : escapeTemplateText(full);
74
143
  }
75
144
  return quoteReplacement(node, value);
76
145
  }
@@ -88,8 +157,8 @@ const noArbitraryCanonical = {
88
157
  // config object can be passed as rule options without schema errors (#42).
89
158
  // Honored by this rule (via suggestCanonical): customTextTokens,
90
159
  // customSpacingTokens, ignorePatterns, tailwindVersion.
91
- // Accepted but ignored here (CLI-only): functionNames, attributeNames,
92
- // sortOrder.
160
+ // functionNames and attributeNames restrict class string detection.
161
+ // Other CLI-only keys are accepted for shared config objects.
93
162
  schema: [
94
163
  {
95
164
  type: 'object',
@@ -98,9 +167,18 @@ const noArbitraryCanonical = {
98
167
  customSpacingTokens: { type: 'object' },
99
168
  ignorePatterns: { type: 'array' },
100
169
  tailwindVersion: { enum: [3, 4] },
170
+ useDefaultTheme: { type: 'boolean' },
101
171
  functionNames: { type: 'array', items: { type: 'string' } },
102
172
  attributeNames: { type: 'array', items: { type: 'string' } },
103
173
  sortOrder: { type: 'array', items: { type: 'string' } },
174
+ extraColorFamilies: { type: 'object' },
175
+ extraScaleProperties: { type: 'array', items: { type: 'string' } },
176
+ extraColors: { type: 'array', items: { type: 'string' } },
177
+ analyze: { type: 'object' },
178
+ minRareScalePropertyOccurrences: { type: 'number' },
179
+ rareScaleMaxFiles: { type: 'number' },
180
+ rareScaleMaxCount: { type: 'number' },
181
+ defaultCommand: { type: 'object' },
104
182
  },
105
183
  additionalProperties: false,
106
184
  },
@@ -110,7 +188,7 @@ const noArbitraryCanonical = {
110
188
  },
111
189
  },
112
190
  create(context) {
113
- const config = context.options[0] ?? {};
191
+ const config = validateConfig(context.options[0], 'ESLint rule options');
114
192
  function checkLiteral(node) {
115
193
  if (typeof node.value !== 'string')
116
194
  return;
@@ -133,7 +211,7 @@ const noArbitraryCanonical = {
133
211
  fix: (fixer) => replaceString(fixer, node, corrected),
134
212
  });
135
213
  }
136
- return makeStringRuleVisitor(checkLiteral);
214
+ return makeStringRuleVisitor(checkLiteral, config);
137
215
  },
138
216
  };
139
217
  function loadTwMerge() {
@@ -141,26 +219,25 @@ function loadTwMerge() {
141
219
  return _require('tailwind-merge').twMerge;
142
220
  }
143
221
  catch {
144
- return null;
222
+ throw new Error('The no-conflicting-classes rule requires the optional peer dependency "tailwind-merge". Install it with: pnpm add -D tailwind-merge');
145
223
  }
146
224
  }
147
225
  const noConflictingClasses = {
148
226
  meta: {
149
227
  type: 'suggestion',
150
228
  fixable: 'code',
151
- schema: [],
229
+ schema: noArbitraryCanonical.meta?.schema ?? [],
152
230
  messages: {
153
231
  conflicting: "Conflicting Tailwind classes detected. Use '{{merged}}' instead.",
154
232
  },
155
233
  },
156
234
  create(context) {
157
235
  const twMerge = loadTwMerge();
236
+ const config = validateConfig(context.options[0], 'ESLint rule options');
158
237
  function checkLiteral(node) {
159
- if (!twMerge)
160
- return;
161
238
  if (typeof node.value !== 'string')
162
239
  return;
163
- const merged = twMerge(node.value);
240
+ const merged = mergeClasses(node.value, twMerge, config);
164
241
  if (merged === node.value)
165
242
  return;
166
243
  context.report({
@@ -169,7 +246,7 @@ const noConflictingClasses = {
169
246
  fix: (fixer) => replaceString(fixer, node, merged),
170
247
  });
171
248
  }
172
- return makeStringRuleVisitor(checkLiteral);
249
+ return makeStringRuleVisitor(checkLiteral, config);
173
250
  },
174
251
  };
175
252
  const plugin = {
@@ -8,4 +8,7 @@ import type { Config } from '../core/rules.js';
8
8
  * nothing.
9
9
  */
10
10
  export declare function resolveInitFilename(cwd: string): string;
11
- export declare function loadConfig(cwd: string): Promise<Config>;
11
+ export declare function findConfigPath(cwd: string): string | undefined;
12
+ export declare function loadConfig(cwd: string, options?: {
13
+ reload?: boolean;
14
+ }): Promise<Config>;
package/dist/io/config.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync, readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
2
4
  import { basename, dirname, join } from 'node:path';
3
5
  import { pathToFileURL } from 'node:url';
4
6
  import { CONFIG_FILENAMES, validateConfig } from '../core/config.js';
@@ -51,7 +53,7 @@ export function resolveInitFilename(cwd) {
51
53
  ? 'tailwind-canonical.config.js'
52
54
  : 'tailwind-canonical.config.mjs';
53
55
  }
54
- function findConfigPath(cwd) {
56
+ export function findConfigPath(cwd) {
55
57
  let dir = cwd;
56
58
  while (true) {
57
59
  const filename = CONFIG_FILENAMES.find((name) => existsSync(join(dir, name)));
@@ -65,14 +67,50 @@ function findConfigPath(cwd) {
65
67
  dir = parent;
66
68
  }
67
69
  }
68
- export async function loadConfig(cwd) {
70
+ const reloadCache = new Map();
71
+ export async function loadConfig(cwd, options = {}) {
69
72
  const path = findConfigPath(cwd);
70
73
  if (!path)
71
74
  return {};
72
75
  const filename = basename(path);
73
76
  let mod;
77
+ let hash;
74
78
  try {
75
- mod = await import(pathToFileURL(path).href);
79
+ const url = pathToFileURL(path);
80
+ if (options.reload) {
81
+ hash = createHash('sha256').update(readFileSync(path)).digest('hex');
82
+ const cached = reloadCache.get(path);
83
+ if (cached?.hash === hash)
84
+ return cached.config;
85
+ // Content-based URLs avoid another ESM instance on unchanged source saves.
86
+ url.searchParams.set('version', hash);
87
+ }
88
+ if (options.reload &&
89
+ /\.(?:js|ts)$/.test(filename) &&
90
+ nearestPackageType(dirname(path)) === 'commonjs') {
91
+ // CJS has an additional require cache: query parameters only invalidate
92
+ // its ESM namespace wrapper. Read it via require after clearing that cache.
93
+ const require = createRequire(import.meta.url);
94
+ delete require.cache[require.resolve(path)];
95
+ try {
96
+ const loaded = require(path);
97
+ mod =
98
+ typeof loaded === 'object' &&
99
+ loaded !== null &&
100
+ '__esModule' in loaded &&
101
+ 'default' in loaded
102
+ ? loaded
103
+ : { default: loaded };
104
+ }
105
+ catch (error) {
106
+ if (!['ERR_REQUIRE_ESM', 'ERR_REQUIRE_ASYNC_MODULE'].includes(error.code ?? ''))
107
+ throw error;
108
+ mod = await import(url.href);
109
+ }
110
+ }
111
+ else {
112
+ mod = await import(url.href);
113
+ }
76
114
  }
77
115
  catch (err) {
78
116
  const msg = err instanceof Error ? err.message : String(err);
@@ -95,5 +133,8 @@ export async function loadConfig(cwd) {
95
133
  }
96
134
  throw err;
97
135
  }
98
- return validateConfig(mod.default, filename);
136
+ const config = validateConfig(mod.default, filename);
137
+ if (hash)
138
+ reloadCache.set(path, { hash, config });
139
+ return config;
99
140
  }
@@ -1,15 +1,13 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { toClassStringOpts } from '../core/class-strings.js';
3
- import { analyzeConsistency, collectClasses, } from '../core/consistency.js';
2
+ import { extractClassStrings, SINGLE_CLASS_REGEX, toClassStringOpts, } from '../core/class-strings.js';
3
+ import { analyzeConsistency, } from '../core/consistency.js';
4
4
  export function analyzeConsistencyFiles(filePaths, config = {}, options = {}, onError) {
5
5
  const opts = toClassStringOpts(config);
6
6
  const input = [];
7
7
  for (const file of filePaths) {
8
8
  try {
9
- input.push({
10
- file,
11
- classes: collectClasses(readFileSync(file, 'utf8'), opts),
12
- });
9
+ const classLists = extractClassStrings(readFileSync(file, 'utf8'), opts).map(({ value }) => [...value.matchAll(SINGLE_CLASS_REGEX)].map((match) => match[0]));
10
+ input.push({ file, classes: classLists.flat(), classLists });
13
11
  }
14
12
  catch (err) {
15
13
  if (onError)