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.
package/README.md CHANGED
@@ -95,6 +95,17 @@ npx tailwind-canonical --version
95
95
  ```
96
96
 
97
97
  An unmatched target exits `1` with `No files matched: <targets>`.
98
+ Directory and glob discovery skip symbolic links. An explicitly named file symlink remains supported and reads or changes its target.
99
+
100
+ ### Watch behavior
101
+
102
+ Watch mode discovers new and recreated files, including targets from initially
103
+ empty globs. It applies the same exclusions and symlink policy as single-pass
104
+ runs. Configuration entry-file changes reload mappings, defaults and targets;
105
+ invalid configuration pauses processing until it is corrected. Watch roots are
106
+ collapsed to avoid overlapping recursive watchers. SIGINT/SIGTERM close watchers
107
+ and pending timers; filesystem watcher failures are reported and stop the session
108
+ with a nonzero exit code. Watch output is text only.
98
109
 
99
110
  ## What each flag does
100
111
 
@@ -106,7 +117,7 @@ An unmatched target exits `1` with `No files matched: <targets>`.
106
117
  | `--merge` | tailwind-merge conflict resolution | `bg-red-500 bg-blue-500` → `bg-blue-500` |
107
118
  | `--check` | Dry-run: report what the combined transforms would change, write nothing (exit 1 on pending changes) | `would fix src/Button.tsx (1 replacement)` |
108
119
  | `--analyze` | Cross-file consistency (color variants, scale drift, repeated patterns) | `Warning: 3 red color variants used for text: text-red-500, text-rose-500, text-red-600` |
109
- | `--watch` | Re-run on every file save (debounced 50ms) | `[12:34:01] src/Button.tsx — 2 changes applied` |
120
+ | `--watch` | Re-run on every file save (debounced 50ms; text reporter only) | `[12:34:01] src/Button.tsx — 2 changes applied` |
110
121
  | `--typos` | Likely misspelled color names (read-only; chains after transforms) | `text-gry-500` → `text-gray-500` [typo] |
111
122
  | `--reporter json` | JSON output (check mode) or fix summary | machine-readable for CI pipelines |
112
123
  | `--reporter sarif` | SARIF 2.1.0 output (findings in check/typos modes; transform mode emits an empty document unless `--typos` is chained) | GitHub Code Scanning / VS Code |
@@ -234,7 +245,7 @@ npx tailwind-canonical --analyze ./src
234
245
  # - px-11: 3 uses in 1 file (608 px uses total); e.g. src/Dialog.tsx
235
246
  ```
236
247
 
237
- Three detectors:
248
+ Four detectors:
238
249
 
239
250
  | Detector | What it flags |
240
251
  |---|---|
@@ -243,6 +254,11 @@ Three detectors:
243
254
  | Rare scale values | Low-frequency scale values inside otherwise common properties (`ml-13`, `gap-24`, `px-11`, …) |
244
255
  | Repeated patterns | Identical class combinations recurring across 3+ files |
245
256
 
257
+ Repeated patterns compare complete class strings, ignoring class order; they do not
258
+ mine shared subsets inside larger strings. The programmatic `FileClasses` input
259
+ can provide `classLists` to preserve those string boundaries. If omitted,
260
+ `classes` is treated as one class string per file for backward compatibility.
261
+
246
262
  Pairs with `--reporter json` for machine-readable output. Exits `1` when any inconsistency is found.
247
263
 
248
264
  ```bash
@@ -459,6 +475,25 @@ discrete — `p-13` does not exist there either. So v3 mode is the safe setting
459
475
  and merely leaves a few v3.4-valid suggestions on the table; `tailwindVersion:
460
476
  4` would emit steps v3.4 cannot generate.
461
477
 
478
+ ### Custom themes (`useDefaultTheme`)
479
+
480
+ The default mappings assume Tailwind's stock theme and a 16px root font size for
481
+ px/rem equivalences. If your theme changes these values, disable inferred mappings:
482
+
483
+ ```ts
484
+ export default {
485
+ useDefaultTheme: false,
486
+ customTextTokens: { 12: 'tiny' },
487
+ customSpacingTokens: { 16: 'gutter' },
488
+ }
489
+ ```
490
+
491
+ Only explicitly configured pixel mappings are suggested in this mode:
492
+ `text-[12px]` becomes `text-tiny`, `p-[16px]` becomes `p-gutter`, and other
493
+ arbitrary values (including rem values) stay unchanged. The option is shared by
494
+ the CLI, library and ESLint canonicalization rule. It does not load your Tailwind
495
+ theme or customize the separate `tailwind-merge` conflict resolver.
496
+
462
497
  ## Config
463
498
 
464
499
  Create `tailwind-canonical.config.ts` at the root:
@@ -582,15 +617,27 @@ ESLint without schema errors:
582
617
  ```
583
618
 
584
619
  The rule **honors**: `customTextTokens`, `customSpacingTokens`, `ignorePatterns`
585
- (all applied via `suggestCanonical`).
586
-
587
- It **accepts but ignores**: `functionNames`, `attributeNames`, `sortOrder`.
588
- These are CLI-only options, allowed in the schema purely so a single shared
589
- config object can be passed without errors.
620
+ and `tailwindVersion` (all applied via `suggestCanonical`), plus
621
+ `useDefaultTheme` to disable inferred mappings. It also uses
622
+ `attributeNames` (default: `['className']`) and `functionNames` (default:
623
+ `['cn', 'clsx', 'cva']`) to identify class strings. Other `Config` fields are
624
+ accepted for shared configuration and have no effect on this rule.
625
+
626
+ Both rules inspect class attributes and recognized utility calls, including
627
+ arrays, object arguments and conditional branches. Unrelated strings and tagged
628
+ templates are ignored. In interpolated templates, only complete tokens separated
629
+ from an interpolation by whitespace are transformed; partial tokens such as
630
+ `text-[12px]${suffix}` are preserved. `no-conflicting-classes` accepts the same
631
+ shared configuration and uses its attribute/function names and custom text tokens. Enabling this rule without
632
+ `tailwind-merge` installed raises an installation error. In `cn`/`clsx` object arguments,
633
+ only keys contain classes; predicate values are preserved. For `cva`, base
634
+ classes, variant classes and compound variant `class`/`className` values are checked.
590
635
 
591
636
  ## Known limits
592
637
 
593
- - **`--watch` does not pick up files created after start** — the watcher initializes a file list once and monitors only existing files for changes. New files in the target directories are not detected until the watcher is restarted.
638
+ - **Source extraction is not a complete JavaScript/JSX parser** — ambiguous regex/division expressions and some nested templates are left intact. Quoted JSX prose can hide nested markup from CLI/API extraction; use the ESLint plugin when complete JSX syntax coverage is needed.
639
+ - **CLI and content APIs extract static class positions** — strings, arrays, conditional result branches, `cn`/`clsx` object keys, and `cva` variant classes are supported. Predicates, variant identifiers, `defaultVariants`, computed keys, spreads and nested calls to unconfigured functions are preserved. Dynamic values are not evaluated.
640
+ - **Watch reloads the configuration entry file** — imported helper modules keep Node's normal module cache. Restart the watcher after changing a helper imported by your config.
594
641
  - **`--typos` covers Tailwind color names only** — color-name detection uses Levenshtein distance 1 against the built-in Tailwind color palette. Custom colors from your config are supported via `extraColors` in the config, but typo detection does not extend to properties, function names, or arbitrary modifiers.
595
642
  - **`--merge` requires `tailwind-merge` as a peer dependency** — the plugin is optional, so the CLI will error if you use `--merge` without installing `tailwind-merge`. Install with `pnpm add -D tailwind-merge`.
596
643
 
@@ -627,6 +674,26 @@ npx tailwind-canonical --fix --dedup --sort ./src ./app
627
674
  }
628
675
  ```
629
676
 
677
+ ## Compatibility and performance checks
678
+
679
+ The supported ESLint range is `>=8.57.1 <11` (flat configuration), and the optional
680
+ `tailwind-merge` range is `>=2.0.0 <4`. CI tests the built tarball in isolated
681
+ consumer projects across ESLint 8/9/10 and tailwind-merge 2/3, including minimum
682
+ versions. Imports, the installed binary, ESLint fixes and TypeScript consumers
683
+ are exercised rather than relying only on source tests.
684
+
685
+ ```bash
686
+ pnpm test:compat 8.57.1 2.0.0
687
+ pnpm benchmark
688
+ # Tune the fixed JSX/Vue/Svelte corpus and emit machine-readable measurements:
689
+ pnpm benchmark --files 300 --lines 40 --runs 7 --json
690
+ ```
691
+
692
+ The benchmark checks expected findings and transform idempotence, then measures
693
+ file discovery, file analysis, fix/dedup/sort and cross-file consistency. Results
694
+ include Node/CPU/platform, corpus size, median and p95 after warmup. Compare on the
695
+ same machine; this synthetic corpus is not a promise about every project.
696
+
630
697
  ## Dead code detection (knip)
631
698
 
632
699
  ```bash
@@ -637,6 +704,11 @@ Detects unused exports, files, and dependencies. Config in `knip.json`. Enforced
637
704
 
638
705
  ## Programmatic API
639
706
 
707
+ This package targets Node.js. Some exported functions are pure transformations,
708
+ but the package entry and `core` re-exports also load Node I/O modules. They are
709
+ not browser entry points. “Pure” describes the function's behavior, not the
710
+ entire import graph.
711
+
640
712
  ```ts
641
713
  import {
642
714
  suggestCanonical, // pure: class string → suggestion or null
package/dist/cli/cli.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { analyzeFile } from '../core/analyzer.js';
4
4
  import { toClassStringOpts } from '../core/class-strings.js';
5
- import { CONFIG_FILENAMES, loadConfig, resolveInitFilename, } from '../core/config.js';
5
+ import { CONFIG_FILENAMES, findConfigPath, loadConfig, resolveInitFilename, } from '../core/config.js';
6
6
  import { analyzeConsistencyFiles, toConsistencyOptions, } from '../core/consistency.js';
7
7
  import { dedupeContent } from '../core/deduplicator.js';
8
8
  import { fixContent } from '../core/fixer.js';
@@ -12,7 +12,7 @@ import { sortContent } from '../core/sorter.js';
12
12
  import { analyzeTyposFile } from '../core/typos.js';
13
13
  import { errMsg, pluralize, timestamp } from './format.js';
14
14
  import { analyzeSarifDocument, findingsSarifDocument, logAnalyzeText, logFindings, logTransformCounts, logTyposText, logWatchFindings, sarifDocument, typoSarifDocument, writeJson, } from './reporters.js';
15
- import { startWatch } from './watch.js';
15
+ import { startWatch, watchRoots } from './watch.js';
16
16
  const USAGE = 'Usage: tailwind-canonical [--fix] [--merge] [--dedup] [--sort] [--check] [--analyze] [--typos] [--watch] [--reporter json|sarif] <dir|file> [dir|file...]\n tailwind-canonical init';
17
17
  const INIT_CONFIG_BODY = [
18
18
  ' // Run `tailwind-canonical` with no flags using these defaults:',
@@ -49,7 +49,7 @@ const HELP_TEXT = [
49
49
  ' --check Dry-run: report what transforms would change, write nothing (exit 1 on pending changes)',
50
50
  ' --analyze Cross-file consistency analysis (read-only)',
51
51
  ' --typos Flag likely misspelled color names (read-only)',
52
- ' --watch Re-run on every file save (transform/check mode; ignored with --typos)',
52
+ ' --watch Re-run on every file save (text reporter only; ignored with --typos/--check)',
53
53
  ' --reporter <type> Output format: text|json|sarif',
54
54
  ' --help, -h Show this help message',
55
55
  ' --version, -V Show the installed version',
@@ -439,6 +439,7 @@ export async function run(argv, cwd, sink = defaultSink) {
439
439
  sink.error(`tailwind-canonical: ${errMsg(err)}`);
440
440
  return { exitCode: 1 };
441
441
  }
442
+ const cliFlags = flags;
442
443
  flags = applyDefaultCommand(flags, config);
443
444
  if (flags.targets.length === 0) {
444
445
  sink.error(USAGE);
@@ -448,6 +449,14 @@ export async function run(argv, cwd, sink = defaultSink) {
448
449
  sink.error('--check requires at least one of --fix, --dedup, --merge, --sort');
449
450
  return { exitCode: 1 };
450
451
  }
452
+ if (flags.watch &&
453
+ !flags.analyze &&
454
+ !flags.typos &&
455
+ !flags.check &&
456
+ flags.reporter !== 'text') {
457
+ sink.error('--watch requires --reporter text; JSON and SARIF support single-pass runs only');
458
+ return { exitCode: 1 };
459
+ }
451
460
  let twMerge;
452
461
  if (flags.merge && !flags.analyze) {
453
462
  try {
@@ -463,7 +472,8 @@ export async function run(argv, cwd, sink = defaultSink) {
463
472
  }
464
473
  const targetPaths = flags.targets.map((target) => resolveTarget(cwd, target));
465
474
  const files = await resolveTargets(targetPaths);
466
- if (files.length === 0) {
475
+ if (files.length === 0 &&
476
+ !(flags.watch && !flags.analyze && !flags.typos && !flags.check)) {
467
477
  sink.error(`No files matched: ${flags.targets.join(' ')}`);
468
478
  return { exitCode: 1 };
469
479
  }
@@ -476,10 +486,42 @@ export async function run(argv, cwd, sink = defaultSink) {
476
486
  return runTypos(files, config, flags.reporter, sink, sarif);
477
487
  }
478
488
  if (flags.watch && !flags.typos && !flags.check) {
489
+ const configDirectory = () => {
490
+ const path = findConfigPath(cwd);
491
+ return path ? dirname(path) : cwd;
492
+ };
479
493
  return startWatch({
480
494
  files,
495
+ roots: watchRoots(targetPaths, [cwd, configDirectory()]),
481
496
  sink,
482
- processFile: (file) => transforming
497
+ refresh: async (changed) => {
498
+ const nextConfig = await loadConfig(cwd, { reload: true });
499
+ const nextFlags = applyDefaultCommand(cliFlags, nextConfig);
500
+ if (nextFlags.analyze ||
501
+ nextFlags.typos ||
502
+ nextFlags.check ||
503
+ nextFlags.reporter !== 'text') {
504
+ throw new Error('Watch configuration must use text output without --analyze, --typos or --check');
505
+ }
506
+ let nextMerge = twMerge;
507
+ if (nextFlags.merge && !nextMerge)
508
+ ({ twMerge: nextMerge } = await import('tailwind-merge'));
509
+ const paths = nextFlags.targets.map((target) => resolveTarget(cwd, target));
510
+ const nextFiles = await resolveTargets(paths);
511
+ const nextRoots = watchRoots(paths, [cwd, configDirectory()]);
512
+ const configChanged = JSON.stringify(config, (_key, value) => value instanceof RegExp ? value.toString() : value) !==
513
+ JSON.stringify(nextConfig, (_key, value) => value instanceof RegExp ? value.toString() : value);
514
+ config = nextConfig;
515
+ flags = nextFlags;
516
+ twMerge = nextMerge;
517
+ return {
518
+ files: nextFiles,
519
+ roots: nextRoots,
520
+ reprocessAll: configChanged ||
521
+ [...changed].some((file) => CONFIG_FILENAMES.some((name) => file.endsWith(`/${name}`) || file.endsWith(`\\${name}`))),
522
+ };
523
+ },
524
+ processFile: (file) => isTransforming(flags)
483
525
  ? watchTransform(file, flags, config, sink, twMerge)
484
526
  : logWatchFindings(file, analyzeFile(file, config), timestamp(), sink),
485
527
  });
package/dist/cli/index.js CHANGED
@@ -2,4 +2,4 @@
2
2
  import { run } from './cli.js';
3
3
  const { exitCode, watching } = await run(process.argv.slice(2), process.cwd());
4
4
  if (!watching)
5
- process.exit(exitCode);
5
+ process.exitCode = exitCode;
@@ -1,11 +1,21 @@
1
1
  import type { Sink } from './types.js';
2
+ type WatchRefresh = {
3
+ files: string[];
4
+ roots: string[];
5
+ reprocessAll?: boolean;
6
+ };
2
7
  type WatchOptions = {
3
8
  files: string[];
9
+ roots: string[];
4
10
  sink: Sink;
11
+ refresh: (changed: ReadonlySet<string>) => Promise<WatchRefresh>;
5
12
  processFile: (file: string) => void;
6
13
  };
7
- export declare function startWatch({ files, sink, processFile }: WatchOptions): {
14
+ /** Watch the existing ancestor of each target, including currently empty globs. */
15
+ export declare function watchRoots(targets: string[], configDirs: string[]): string[];
16
+ export declare function startWatch({ files, roots, sink, refresh, processFile, }: WatchOptions): {
8
17
  exitCode: number;
9
- watching: true;
18
+ watching: boolean;
19
+ close: () => void;
10
20
  };
11
21
  export {};
package/dist/cli/watch.js CHANGED
@@ -1,33 +1,153 @@
1
- import { watch as fsWatch } from 'node:fs';
2
- import { dirname, resolve } from 'node:path';
1
+ import { existsSync, watch as fsWatch, statSync, } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { DEFAULT_IGNORE } from '../core/scanner.js';
3
4
  import { errMsg, pluralize, timestamp } from './format.js';
4
- export function startWatch({ files, sink, processFile }) {
5
- const fileSet = new Set(files);
6
- const timers = new Map();
7
- const dirs = new Set(files.map((file) => dirname(file)));
8
- sink.log(`Watching ${pluralize(files.length, 'file')}... (Ctrl+C to stop)`);
9
- for (const dir of dirs) {
10
- fsWatch(dir, { recursive: true }, (_, filename) => {
11
- if (!filename)
5
+ function contains(root, path) {
6
+ const rel = relative(root, path);
7
+ return (rel === '' ||
8
+ (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)));
9
+ }
10
+ /** Watch the existing ancestor of each target, including currently empty globs. */
11
+ export function watchRoots(targets, configDirs) {
12
+ const dirs = [...configDirs];
13
+ for (const target of targets) {
14
+ if (target.startsWith('!'))
15
+ continue;
16
+ const magic = existsSync(target) ? -1 : target.search(/[*?{[(]/);
17
+ const prefix = target.slice(0, magic);
18
+ let dir = magic < 0 ? target : prefix.endsWith(sep) ? prefix : dirname(prefix);
19
+ if (existsSync(dir) && !statSync(dir).isDirectory())
20
+ dir = dirname(dir);
21
+ while (!existsSync(dir)) {
22
+ const parent = dirname(dir);
23
+ if (parent === dir)
24
+ break;
25
+ dir = parent;
26
+ }
27
+ dirs.push(dir);
28
+ }
29
+ const roots = [...new Set(dirs.map((dir) => resolve(dir)))];
30
+ return roots.filter((dir) => !roots.some((other) => other !== dir && contains(other, dir)));
31
+ }
32
+ export function startWatch({ files, roots, sink, refresh, processFile, }) {
33
+ let fileSet = new Set(files);
34
+ const watchers = new Map();
35
+ const changed = new Set();
36
+ let timer;
37
+ let running = false;
38
+ let closed = false;
39
+ function close() {
40
+ if (closed)
41
+ return;
42
+ closed = true;
43
+ clearTimeout(timer);
44
+ changed.clear();
45
+ for (const watcher of watchers.values())
46
+ watcher.close();
47
+ watchers.clear();
48
+ process.removeListener('SIGINT', stop);
49
+ process.removeListener('SIGTERM', stop);
50
+ }
51
+ function stop() {
52
+ close();
53
+ sink.log('\nWatcher stopped.');
54
+ }
55
+ function fail(path, error) {
56
+ sink.error(`${timestamp()} ${path} — error: ${errMsg(error)}`);
57
+ }
58
+ function schedule() {
59
+ if (closed || running)
60
+ return;
61
+ clearTimeout(timer);
62
+ timer = setTimeout(() => {
63
+ void flush();
64
+ }, 50);
65
+ }
66
+ function synchronize(nextRoots) {
67
+ // Install replacements before closing the old set, so no event gap is added.
68
+ for (const root of nextRoots) {
69
+ if (watchers.has(root))
70
+ continue;
71
+ try {
72
+ const watcher = fsWatch(root, { recursive: true }, (_, filename) => {
73
+ if (closed)
74
+ return;
75
+ if (filename &&
76
+ String(filename)
77
+ .split(/[\\/]/)
78
+ .some((part) => DEFAULT_IGNORE.includes(part)))
79
+ return;
80
+ changed.add(filename ? resolve(root, String(filename)) : root);
81
+ schedule();
82
+ });
83
+ watchers.set(root, watcher);
84
+ watcher.on('error', (error) => {
85
+ if (closed)
86
+ return;
87
+ fail(root, error);
88
+ process.exitCode = 1;
89
+ close();
90
+ });
91
+ }
92
+ catch (error) {
93
+ fail(root, error);
94
+ process.exitCode = 1;
95
+ close();
96
+ return;
97
+ }
98
+ }
99
+ for (const [root, watcher] of watchers) {
100
+ if (!nextRoots.includes(root)) {
101
+ watcher.close();
102
+ watchers.delete(root);
103
+ }
104
+ }
105
+ }
106
+ async function flush() {
107
+ if (closed || running)
108
+ return;
109
+ running = true;
110
+ const batch = new Set(changed);
111
+ changed.clear();
112
+ try {
113
+ const next = await refresh(batch);
114
+ if (closed)
12
115
  return;
13
- const full = resolve(dir, filename);
14
- if (!fileSet.has(full))
116
+ synchronize(next.roots);
117
+ if (closed)
15
118
  return;
16
- clearTimeout(timers.get(full));
17
- timers.set(full, setTimeout(() => {
18
- timers.delete(full);
119
+ const nextFiles = new Set(next.files);
120
+ for (const file of nextFiles) {
121
+ if (closed)
122
+ break;
123
+ if (!next.reprocessAll &&
124
+ fileSet.has(file) &&
125
+ ![...batch].some((path) => contains(path, file)))
126
+ continue;
19
127
  try {
20
- processFile(full);
128
+ processFile(file);
21
129
  }
22
130
  catch (error) {
23
- sink.error(`${timestamp()} ${full} — error: ${errMsg(error)}`);
131
+ fail(file, error);
24
132
  }
25
- }, 50));
26
- });
133
+ }
134
+ fileSet = nextFiles;
135
+ }
136
+ catch (error) {
137
+ if (!closed)
138
+ fail('watch refresh', error);
139
+ }
140
+ finally {
141
+ running = false;
142
+ if (changed.size > 0)
143
+ schedule();
144
+ }
27
145
  }
28
- process.once('SIGINT', () => {
29
- sink.log('\nWatcher stopped.');
30
- process.exit(0);
31
- });
32
- return { exitCode: 0, watching: true };
146
+ synchronize(roots);
147
+ if (!closed) {
148
+ process.once('SIGINT', stop);
149
+ process.once('SIGTERM', stop);
150
+ sink.log(`Watching ${pluralize(files.length, 'file')}... (Ctrl+C to stop)`);
151
+ }
152
+ return { exitCode: closed ? 1 : 0, watching: !closed, close };
33
153
  }