tailwind-canonical 0.7.1 → 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 +131 -11
- package/dist/cli/cli.js +47 -5
- package/dist/cli/index.js +1 -1
- package/dist/cli/watch.d.ts +12 -2
- package/dist/cli/watch.js +144 -24
- package/dist/core/class-strings.js +547 -49
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.js +4 -1
- package/dist/core/consistency.d.ts +5 -0
- package/dist/core/consistency.js +22 -15
- package/dist/core/deduplicator.js +4 -0
- package/dist/core/merger.d.ts +1 -0
- package/dist/core/merger.js +5 -2
- package/dist/core/rules.d.ts +7 -0
- package/dist/core/rules.js +444 -9
- package/dist/core/scanner.d.ts +0 -1
- package/dist/core/scanner.js +1 -50
- package/dist/eslint/plugin.d.ts +1 -1
- package/dist/eslint/plugin.js +105 -28
- package/dist/io/config.d.ts +4 -1
- package/dist/io/config.js +45 -4
- package/dist/io/consistency.js +4 -6
- package/dist/io/scanner.js +26 -10
- package/package.json +8 -6
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
|
-
|
|
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
|
|
@@ -352,10 +368,36 @@ left-2 right-2 → inset-x-2
|
|
|
352
368
|
| `text-[12px]` | `text-xs` | Built-in |
|
|
353
369
|
| `text-[0.75rem]` | `text-xs` | rem values |
|
|
354
370
|
| `h-[64px]` | `h-16` | Spacing scale ÷4 |
|
|
371
|
+
| `p-[1px]` / `gap-[1px]` | `p-px` / `gap-px` | 1px spacing step |
|
|
355
372
|
| `w-[50%]` | `w-1/2` | Percentage fractions |
|
|
356
|
-
| `
|
|
373
|
+
| `w-[100%]` / `basis-[100%]` | `w-full` / `basis-full` | Sizing, inset, translate, basis |
|
|
374
|
+
| `h-[100vh]` / `w-[100vw]` | `h-screen` / `w-screen` | Axis-checked; `min-w-screen` is v4-only |
|
|
375
|
+
| `h-[100dvh]` / `svh` / `lvh` | `h-dvh` / `h-svh` / `h-lvh` | v4 only |
|
|
376
|
+
| `w-[auto]` / `mx-[auto]` | `w-auto` / `mx-auto` | Never negated |
|
|
377
|
+
| `w-[fit-content]` / `min-content` / `max-content` | `w-fit` / `w-min` / `w-max` | |
|
|
378
|
+
| `p-[0]` / `top-[0]` | `p-0` / `top-0` | Unitless zero |
|
|
379
|
+
| `aspect-[16/9]` / `aspect-[1/1]` | `aspect-video` / `aspect-square` | Other ratios (`aspect-4/3`) v4 only |
|
|
380
|
+
| `rounded-[9999px]` | `rounded-full` | |
|
|
381
|
+
| `opacity-[0.5]` / `opacity-[.5]` | `opacity-50` | Opacity scale |
|
|
382
|
+
| `border-[1px]` / `border-t-[1px]` | `border` / `border-t` | Bare = 1px; `[0/2/4/8px]` → `border-0/2/4/8` |
|
|
383
|
+
| `divide-x-[1px]` | `divide-x` | Same scale as `border` |
|
|
384
|
+
| `ring-[1px]` / `outline-[2px]` | `ring-1` / `outline-2` | Never bare: `ring` is 3px on v3 |
|
|
385
|
+
| `duration-[200ms]` / `delay-[0.15s]` | `duration-200` / `delay-150` | ms or s |
|
|
386
|
+
| `scale-[1.05]` / `scale-x-[95%]` | `scale-105` / `scale-x-95` | Factor or percentage |
|
|
387
|
+
| `rotate-[45deg]` | `rotate-45` | Integer degrees |
|
|
388
|
+
| `blur-[4px]` | `blur-xs` | `blur-sm` with `tailwindVersion: 3` |
|
|
389
|
+
| `z-[10]` | `z-10` | Any integer on v4 |
|
|
390
|
+
| `font-[700]` | `font-bold` | Weights 100–900 |
|
|
391
|
+
| `leading-[24px]` / `leading-[1.5rem]` | `leading-6` | Spacing scale ÷4 |
|
|
392
|
+
| `leading-[1.5]` | `leading-normal` | `none`, `tight`, `snug`, `normal`, `relaxed`, `loose` |
|
|
393
|
+
| `order-[1]` / `order-[0]` | `order-1` / `order-none` | Any integer on v4 |
|
|
394
|
+
| `grow-[1]` / `shrink-[0]` | `grow` / `shrink-0` | |
|
|
395
|
+
| `line-clamp-[3]`, `columns-[3]` | `line-clamp-3`, `columns-3` | Any integer ≥ 1 on v4 |
|
|
357
396
|
| `text-[11px]` | `text-2xs` | Requires `customTextTokens` |
|
|
358
397
|
| `text-[8px]`–`text-[10px]` | `text-3xs` | Requires `customTextTokens` |
|
|
398
|
+
| `hover:p-[16px]` | `hover:p-4` | Variants are preserved |
|
|
399
|
+
| `!p-[16px]` / `p-[16px]!` | `!p-4` / `p-4!` | Important modifier kept in place |
|
|
400
|
+
| `-mt-[8px]` / `mt-[-8px]` | `-mt-2` | Negative margin / inset / translate / space / scale / rotate / z / order only |
|
|
359
401
|
|
|
360
402
|
`text-2xs` and `text-3xs` are not stock Tailwind tokens, so they are not in the built-in text size map — without config, `text-[11px]` and `text-[8px]`–`text-[10px]` are left untouched. Enable them with `customTextTokens`:
|
|
361
403
|
|
|
@@ -393,14 +435,36 @@ scale differs per utility, not just per family:
|
|
|
393
435
|
| `top-[20%]` | `top-1/5` | untouched — v3.3 stops at quarters here |
|
|
394
436
|
| `top-[25%]` | `top-1/4` | `top-1/4` |
|
|
395
437
|
| `w-[8.333333%]` | `w-1/12` | `w-1/12` — `w` carries the full set |
|
|
438
|
+
| `duration-[250ms]` | `duration-250` | untouched — not on the v3 scale |
|
|
439
|
+
| `scale-[1.15]` | `scale-115` | untouched — not on the v3 scale |
|
|
440
|
+
| `rotate-[30deg]` | `rotate-30` | untouched — not on the v3 scale |
|
|
441
|
+
| `blur-[4px]` | `blur-xs` | `blur-sm` — v3 names are one step lower |
|
|
442
|
+
| `blur-[8px]` | `blur-sm` | `blur` |
|
|
443
|
+
| `z-[15]` | `z-15` | untouched — v3 stops at `0`–`50` in tens |
|
|
444
|
+
| `leading-[44px]` | `leading-11` | untouched — v3 ships `leading-3`–`leading-10` |
|
|
445
|
+
| `order-[13]` | `order-13` | untouched — v3 ships `1`–`12` |
|
|
446
|
+
| `grow-[2]` | `grow-2` | untouched — v3 ships `grow` and `grow-0` |
|
|
447
|
+
| `line-clamp-[7]` | `line-clamp-7` | untouched — v3 ships `1`–`6` |
|
|
448
|
+
| `columns-[13]` | `columns-13` | untouched — v3 ships `1`–`12` |
|
|
396
449
|
|
|
397
450
|
Spacing steps: `size-*` has no numeric scale in v3.3, and `min-w-*`, `max-w-*`
|
|
398
451
|
and `min-h-*` only ship the zero step — they gained the scale afterwards.
|
|
399
452
|
Fractions: `w` carries all thirteen, `h` stops at sixths, `inset`/`top`/`left`/
|
|
400
453
|
`right`/`bottom`/`translate-x`/`translate-y` only have halves, thirds and
|
|
401
454
|
quarters, and `min-w`/`max-w`/`min-h`/`max-h` have none.
|
|
402
|
-
|
|
403
|
-
`
|
|
455
|
+
`duration-*`/`delay-*` stop at 0, 75, 100, 150, 200, 300, 500, 700 and 1000;
|
|
456
|
+
`scale-*` at 0, 50, 75, 90, 95, 100, 105, 110, 125 and 150; `rotate-*` at 0, 1,
|
|
457
|
+
2, 3, 6, 12, 45, 90 and 180. v4 accepts any integer for all four.
|
|
458
|
+
|
|
459
|
+
`*-px` follows the same per-utility gating: `size-px`, `min-w-px`, `max-w-px`
|
|
460
|
+
and `min-h-px` are left untouched on v3.
|
|
461
|
+
|
|
462
|
+
`rounded-*` follows each major's radius scale: v4 renamed the small end, so
|
|
463
|
+
`rounded-[2px]` → `rounded-xs` and `rounded-[4px]` → `rounded-sm` on v4, but
|
|
464
|
+
`rounded-sm` and bare `rounded` on v3; `rounded-[32px]` → `rounded-4xl` is
|
|
465
|
+
v4-only. `text-*`, `opacity-*`, `font-*` weights, the `leading-*`
|
|
466
|
+
keywords and the `border`/`divide`/`ring`/`outline`
|
|
467
|
+
widths are identical in both majors and are
|
|
404
468
|
never gated. `customSpacingTokens` stays your decision and is applied in both
|
|
405
469
|
modes. All of the above was measured against `tailwindcss@3.3.7`, the
|
|
406
470
|
advertised floor.
|
|
@@ -411,6 +475,25 @@ discrete — `p-13` does not exist there either. So v3 mode is the safe setting
|
|
|
411
475
|
and merely leaves a few v3.4-valid suggestions on the table; `tailwindVersion:
|
|
412
476
|
4` would emit steps v3.4 cannot generate.
|
|
413
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
|
+
|
|
414
497
|
## Config
|
|
415
498
|
|
|
416
499
|
Create `tailwind-canonical.config.ts` at the root:
|
|
@@ -534,15 +617,27 @@ ESLint without schema errors:
|
|
|
534
617
|
```
|
|
535
618
|
|
|
536
619
|
The rule **honors**: `customTextTokens`, `customSpacingTokens`, `ignorePatterns`
|
|
537
|
-
(all applied via `suggestCanonical`)
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
542
635
|
|
|
543
636
|
## Known limits
|
|
544
637
|
|
|
545
|
-
-
|
|
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.
|
|
546
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.
|
|
547
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`.
|
|
548
643
|
|
|
@@ -579,6 +674,26 @@ npx tailwind-canonical --fix --dedup --sort ./src ./app
|
|
|
579
674
|
}
|
|
580
675
|
```
|
|
581
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
|
+
|
|
582
697
|
## Dead code detection (knip)
|
|
583
698
|
|
|
584
699
|
```bash
|
|
@@ -589,6 +704,11 @@ Detects unused exports, files, and dependencies. Config in `knip.json`. Enforced
|
|
|
589
704
|
|
|
590
705
|
## Programmatic API
|
|
591
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
|
+
|
|
592
712
|
```ts
|
|
593
713
|
import {
|
|
594
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 (
|
|
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
|
-
|
|
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
package/dist/cli/watch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
if (
|
|
116
|
+
synchronize(next.roots);
|
|
117
|
+
if (closed)
|
|
15
118
|
return;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
128
|
+
processFile(file);
|
|
21
129
|
}
|
|
22
130
|
catch (error) {
|
|
23
|
-
|
|
131
|
+
fail(file, error);
|
|
24
132
|
}
|
|
25
|
-
}
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
process.
|
|
31
|
-
|
|
32
|
-
|
|
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
|
}
|