tw-canonicalize 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Helbich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # tw-canonicalize
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Rewrites Tailwind CSS **v4** class names in your templates to their canonical form, in bulk. These are the fixes the
4
+ Tailwind IntelliSense extension offers one by one as `suggestCanonicalClasses` (`min-h-[100px]` → `min-h-25`,
5
+ `gap-[10px]` → `gap-2.5`, `max-w-[600px]` → `max-w-150`, …). `npx @tailwindcss/upgrade` only covers a small part of them.
6
+
7
+ It uses the canonicalizer that ships with Tailwind itself (`canonicalizeCandidates` of the design system loaded via
8
+ `@tailwindcss/node`), with the Tailwind version and theme of the project you run it in.
9
+ Every rewrite is checked to generate the same CSS before it is applied. It does only this one job: no class sorting, deduplication or linting.
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # dry run in the current project (nothing is written)
15
+ npx tw-canonicalize
16
+
17
+ # write the changes
18
+ npx tw-canonicalize --apply
19
+
20
+ # CI: exit code 1 if anything is not canonical
21
+ npx tw-canonicalize --check
22
+ ```
23
+
24
+ Run it in a project that has Tailwind CSS v4 installed and a clean git tree, then review the diff.
25
+
26
+ | Option | Default | Description |
27
+ | -------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
28
+ | `--css <file>` | auto-detected | Entry stylesheet with `@import "tailwindcss"`. Required when several files match (or none). |
29
+ | `--root <dir>` | current directory | Project root. |
30
+ | `--ext <list>` | `html,vue,svelte,astro,jsx,tsx` | File extensions to process. Add e.g. `ts` (Angular inline templates), `mdx`, `php`, `erb`. |
31
+ | `--rem <px>` | `16` | Root font size for the `px` ↔ `rem` equivalence check. |
32
+ | `--apply` | off (dry run) | Write the changes. |
33
+ | `--check` | off | Exit with code 1 when changes are found. Cannot be combined with `--apply`. |
34
+ | `--no-verify` | verification on | Also apply rewrites whose generated CSS differs from the original. |
35
+
36
+ Exit codes: `0` ok, `1` changes found under `--check`, `2` error.
37
+
38
+ ## What it does (and does not do)
39
+
40
+ - Processes **static** `class="…"`, `class='…'` and `className="…"` attributes, in any file with a configured extension.
41
+ The match ignores context, so it also finds them inside JS strings and comments (this is what makes `--ext ts` work for
42
+ Angular inline templates). Files come from `git ls-files` (so `.gitignore` is respected), or a directory walk outside a git repository.
43
+ - Never touches dynamic bindings (`:class`, `v-bind:class`, `[ngClass]`, `[class]`, `className={…}`) or values with
44
+ interpolation (`{{ }}`, `${ }`, `{ }`). They are counted and reported as skipped.
45
+ - Does not look at class helper calls (`cn(…)`, `clsx(…)`, `cva(…)`, `twMerge(…)`). Their strings are neither rewritten nor counted.
46
+ - Does not touch `@apply` or `<style>` blocks.
47
+ - **Verifies every rewrite**: the generated CSS declarations of the old and the new class are compared (theme variables
48
+ resolved, `calc(<n> * <n>)` evaluated, `rem` converted to `px`). A rewrite that generates different CSS is reported and
49
+ left alone. Selectors are not compared, only declarations.
50
+ - Idempotent: a second run finds nothing.
51
+
52
+ ## Caveats
53
+
54
+ - It relies on `__unstable__loadDesignSystem` and `canonicalizeCandidates` from `@tailwindcss/node`, which are not a public API.
55
+ Needs **tailwindcss >= 4.1.18**; tested up to 4.3.3. Older versions either fail with a clear message (<= 4.1.14: no
56
+ `canonicalizeCandidates`) or rewrite only part of the classes (4.1.15 - 4.1.17: spacing utilities such as `gap-`/`px-`/`py-`,
57
+ but not e.g. `w-`, `min-h-`, `mt-`, `max-w-`).
58
+ - Pixel values become spacing-scale classes (`gap-[10px]` → `gap-2.5` = `0.625rem`). They are identical at the default 16px root
59
+ font size but scale with a changed browser font size, unlike the fixed pixel value.
60
+ - One stylesheet (one theme) is used for the whole run. In a monorepo with different themes, run it once per package:
61
+ `--root packages/a --css src/app.css`.
62
+ - Tailwind CSS v3 projects are not supported (migrate to v4 first).
63
+ - With strict `node_modules` layouts (pnpm) `@tailwindcss/node` is looked up through `@tailwindcss/vite`, `@tailwindcss/postcss`
64
+ and `@tailwindcss/cli`.
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ npm install
70
+ npm run typecheck && npm run lint && npm run format:check && npm test
71
+ npm run build
72
+ ```
73
+
74
+ Run a local checkout against a project:
75
+
76
+ ```bash
77
+ npx tsx src/cli.ts --root /path/to/project # from source
78
+ node dist/cli.js --root /path/to/project # after npm run build
79
+ ```
80
+
81
+ To try the packaged tarball: `npm pack`, then `npx --package=./tw-canonicalize-0.1.0.tgz tw-canonicalize` (plain `npx ./file.tgz` treats the path as a command).
82
+
83
+ Tests run the CLI against `test/fixtures/basic` (copied to the git-ignored `test/.tmp`, so Node resolves this repo's Tailwind).
84
+
85
+ ## Release
86
+
87
+ 1. `npm version patch|minor|major` (runs lint, typecheck and tests, bumps the version, creates the tag), then `git push --follow-tags`.
88
+ 2. The `Publish` workflow stages the version (`npm stage publish`, stage-only token in the `NPM_TOKEN` secret).
89
+ 3. Approve it with 2FA: `npm stage list`, then `npm stage approve <stage-id>` (or on npmjs.com).
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Canonicalizes every class in a `class="…"` value. Whitespace between the classes is preserved.
3
+ * Returns `null` if Tailwind throws, so the caller can skip the attribute.
4
+ */
5
+ export function canonicalizeValue(ds, value, rem, accept = () => true) {
6
+ const parts = value.split(/(\s+)/);
7
+ const tokens = parts.filter((part) => part.trim() !== "");
8
+ if (tokens.length === 0)
9
+ return { value, changes: [] };
10
+ const canonical = canonicalizeTokens(ds, tokens, rem);
11
+ if (canonical === null)
12
+ return null;
13
+ const changes = [];
14
+ let index = 0;
15
+ const rebuilt = parts.map((part) => {
16
+ if (part.trim() === "")
17
+ return part;
18
+ const to = canonical[index++] ?? part;
19
+ if (to === part)
20
+ return part;
21
+ const change = { from: part, to };
22
+ if (!accept(change))
23
+ return part;
24
+ changes.push(change);
25
+ return to;
26
+ });
27
+ return { value: rebuilt.join(""), changes };
28
+ }
29
+ /**
30
+ * Tailwind drops duplicates from its result (`["a", "a"]` gives `["a"]`), so the output of one batch call cannot be
31
+ * matched to the input by position. In that case fall back to one call per class, which always returns one class.
32
+ */
33
+ function canonicalizeTokens(ds, tokens, rem) {
34
+ try {
35
+ const batch = ds.canonicalizeCandidates(tokens, { rem });
36
+ if (batch.length === tokens.length)
37
+ return batch;
38
+ const single = [];
39
+ for (const token of tokens) {
40
+ const [canonical, ...rest] = ds.canonicalizeCandidates([token], { rem });
41
+ if (canonical === undefined || rest.length > 0)
42
+ return null;
43
+ single.push(canonical);
44
+ }
45
+ return single;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { formatReport } from "./report.js";
6
+ import { run } from "./run.js";
7
+ const DEFAULT_EXTENSIONS = ["html", "vue", "svelte", "astro", "jsx", "tsx"];
8
+ const HELP = `tw-canonicalize [options]
9
+
10
+ Rewrites Tailwind CSS v4 class names in templates to their canonical form (the "suggestCanonicalClasses"
11
+ fixes of the Tailwind IntelliSense extension, in bulk). Dry run by default.
12
+
13
+ Options:
14
+ --css <file> Entry stylesheet importing Tailwind (auto-detected when exactly one file has @import "tailwindcss")
15
+ --root <dir> Project root, default: current directory
16
+ --ext <list> Comma-separated file extensions, default: ${DEFAULT_EXTENSIONS.join(",")}
17
+ --rem <px> Root font size used for rem conversions, default: 16
18
+ --apply Write the changes to disk
19
+ --check Exit with code 1 when changes are found (for CI)
20
+ --no-verify Also apply rewrites whose generated CSS differs from the original
21
+ -h, --help Show this help
22
+ -v, --version Show the version
23
+
24
+ Only static class="…" / className="…" attributes are rewritten. Dynamic bindings are never touched.`;
25
+ async function main() {
26
+ const { values } = parseArgs({
27
+ options: {
28
+ css: { type: "string" },
29
+ root: { type: "string" },
30
+ ext: { type: "string" },
31
+ rem: { type: "string" },
32
+ apply: { type: "boolean", default: false },
33
+ check: { type: "boolean", default: false },
34
+ "no-verify": { type: "boolean", default: false },
35
+ help: { type: "boolean", short: "h", default: false },
36
+ version: { type: "boolean", short: "v", default: false },
37
+ },
38
+ allowPositionals: false,
39
+ });
40
+ if (values.help) {
41
+ console.log(HELP);
42
+ return 0;
43
+ }
44
+ if (values.version) {
45
+ console.log(await readVersion());
46
+ return 0;
47
+ }
48
+ const rem = values.rem === undefined ? 16 : Number(values.rem);
49
+ if (!Number.isFinite(rem) || rem <= 0)
50
+ throw new Error(`Invalid --rem value: ${values.rem}`);
51
+ if (values.apply && values.check)
52
+ throw new Error("--apply and --check cannot be combined.");
53
+ const root = path.resolve(values.root ?? process.cwd());
54
+ const extensions = (values.ext ?? DEFAULT_EXTENSIONS.join(","))
55
+ .split(",")
56
+ .map((extension) => extension.trim().replace(/^\./, "").toLowerCase())
57
+ .filter(Boolean);
58
+ const result = await run({
59
+ root,
60
+ css: values.css,
61
+ extensions,
62
+ rem,
63
+ apply: values.apply,
64
+ verify: !values["no-verify"],
65
+ });
66
+ console.log(formatReport(result, root));
67
+ return values.check && result.changes.length > 0 ? 1 : 0;
68
+ }
69
+ async function readVersion() {
70
+ const manifest = new URL("../package.json", import.meta.url);
71
+ return JSON.parse(await readFile(manifest, "utf8")).version;
72
+ }
73
+ main().then((code) => {
74
+ process.exitCode = code;
75
+ }, (error) => {
76
+ console.error(`tw-canonicalize: ${error instanceof Error ? error.message : String(error)}`);
77
+ process.exitCode = 2;
78
+ });
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ // Packages that depend on @tailwindcss/node. With strict layouts (pnpm) it is not resolvable from the project root directly.
5
+ const NODE_PROVIDERS = ["@tailwindcss/vite", "@tailwindcss/postcss", "@tailwindcss/cli"];
6
+ /** Resolves `@tailwindcss/node` from the target project, so each repo is processed with its own Tailwind version. */
7
+ export function resolveTailwindNode(root) {
8
+ const projectRequire = createRequire(path.join(root, "package.json"));
9
+ try {
10
+ return projectRequire.resolve("@tailwindcss/node");
11
+ }
12
+ catch {
13
+ // fall through to the packages that bring it in
14
+ }
15
+ for (const provider of NODE_PROVIDERS) {
16
+ try {
17
+ return createRequire(projectRequire.resolve(provider)).resolve("@tailwindcss/node");
18
+ }
19
+ catch {
20
+ // try the next one
21
+ }
22
+ }
23
+ throw new Error(`Could not resolve @tailwindcss/node from ${root}. Run the package manager install in the project first, and make sure it uses Tailwind CSS v4 (tailwindcss with @tailwindcss/vite, @tailwindcss/postcss or @tailwindcss/cli).`);
24
+ }
25
+ /** First Tailwind release whose canonicalizer covers the full set of utilities (4.1.15 - 4.1.17 only handle some). */
26
+ export const MIN_TAILWIND_VERSION = "4.1.18";
27
+ /** True when `version` is a plain `major.minor.patch` older than {@link MIN_TAILWIND_VERSION}. Unknown formats are not flagged. */
28
+ export function isBelowMinimumVersion(version) {
29
+ const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number);
30
+ const actual = version === undefined ? undefined : parse(version);
31
+ const minimum = parse(MIN_TAILWIND_VERSION);
32
+ if (!actual || !minimum)
33
+ return false;
34
+ for (let index = 0; index < 3; index++) {
35
+ const difference = (actual[index] ?? 0) - (minimum[index] ?? 0);
36
+ if (difference !== 0)
37
+ return difference < 0;
38
+ }
39
+ return false;
40
+ }
41
+ export function readTailwindVersion(root) {
42
+ try {
43
+ const manifest = createRequire(path.join(root, "package.json")).resolve("tailwindcss/package.json");
44
+ return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ export async function loadDesignSystem(root, entryCss) {
51
+ const tailwindVersion = readTailwindVersion(root);
52
+ const nodePath = resolveTailwindNode(root);
53
+ const tailwindNode = createRequire(nodePath)(nodePath);
54
+ const load = tailwindNode.__unstable__loadDesignSystem;
55
+ if (typeof load !== "function") {
56
+ throw new Error(`@tailwindcss/node (tailwindcss ${tailwindVersion ?? "unknown"}) has no __unstable__loadDesignSystem. This tool needs Tailwind CSS v4.`);
57
+ }
58
+ const ds = await load(fs.readFileSync(entryCss, "utf8"), { base: path.dirname(entryCss) });
59
+ if (typeof ds.canonicalizeCandidates !== "function") {
60
+ throw new Error(`tailwindcss ${tailwindVersion ?? "unknown"} has no canonicalizeCandidates. Upgrade to a newer Tailwind CSS 4.x release.`);
61
+ }
62
+ return { ds, tailwindVersion };
63
+ }
package/dist/entry.js ADDED
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ // Matches `@import "tailwindcss"`, `@import "tailwindcss/theme.css" layer(theme)` and `@import url("tailwindcss")`.
4
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']/;
5
+ /** CSS files that import Tailwind CSS v4 (`@import "tailwindcss"`), i.e. candidates for the entry stylesheet. */
6
+ export function findEntryCandidates(files) {
7
+ return files.filter((file) => file.endsWith(".css") && TAILWIND_IMPORT.test(fs.readFileSync(file, "utf8")));
8
+ }
9
+ /** Returns the absolute path of the entry stylesheet: `css` if given, else the only candidate among `files`. */
10
+ export function resolveEntryCss(root, css, files) {
11
+ if (css) {
12
+ const resolved = path.resolve(root, css);
13
+ if (!fs.existsSync(resolved))
14
+ throw new Error(`Entry stylesheet not found: ${resolved}`);
15
+ return resolved;
16
+ }
17
+ const candidates = findEntryCandidates(files);
18
+ if (candidates.length === 1 && candidates[0])
19
+ return candidates[0];
20
+ if (candidates.length === 0) {
21
+ throw new Error(`No stylesheet with @import "tailwindcss" found below ${root}. Pass it with --css <file>. (Tailwind CSS v3 projects are not supported.)`);
22
+ }
23
+ const list = candidates.map((candidate) => ` ${path.relative(root, candidate)}`).join("\n");
24
+ throw new Error(`Several stylesheets import Tailwind CSS, pass one with --css <file>:\n${list}`);
25
+ }
@@ -0,0 +1,33 @@
1
+ // Static class attributes only. The lookbehind rejects `:class`, `v-bind:class`, `[class]`, `data-class`, `.class` and identifiers ending in "class".
2
+ const STATIC_CLASS = /(?<![\w:.\-@#$[])(?:class|className)\s*=\s*(["'])([\s\S]*?)\1/g;
3
+ const DYNAMIC_CLASS = /(?:(?<![\w.\-@#$])(?::class|v-bind:class|\[ngClass\]|\[class(?:\.[\w.-]+)?\])\s*=|(?<![\w:.\-@#$[])className\s*=\s*\{)/g;
4
+ export function extractClassAttributes(source) {
5
+ const lineStarts = [0];
6
+ for (let index = source.indexOf("\n"); index !== -1; index = source.indexOf("\n", index + 1)) {
7
+ lineStarts.push(index + 1);
8
+ }
9
+ const attributes = [];
10
+ let interpolated = 0;
11
+ for (const match of source.matchAll(STATIC_CLASS)) {
12
+ const value = match[2] ?? "";
13
+ if (/[{}]/.test(value)) {
14
+ interpolated++;
15
+ continue;
16
+ }
17
+ const start = match.index + match[0].indexOf(value, match[0].indexOf("=") + 1);
18
+ attributes.push({ start, end: start + value.length, value, line: lineOf(lineStarts, start) });
19
+ }
20
+ return { attributes, interpolated, dynamic: [...source.matchAll(DYNAMIC_CLASS)].length };
21
+ }
22
+ function lineOf(lineStarts, offset) {
23
+ let low = 0;
24
+ let high = lineStarts.length - 1;
25
+ while (low < high) {
26
+ const middle = Math.ceil((low + high) / 2);
27
+ if ((lineStarts[middle] ?? 0) <= offset)
28
+ low = middle;
29
+ else
30
+ high = middle - 1;
31
+ }
32
+ return low + 1;
33
+ }
package/dist/files.js ADDED
@@ -0,0 +1,56 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const IGNORED_DIRS = new Set([
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ "coverage",
9
+ ".git",
10
+ ".nuxt",
11
+ ".output",
12
+ ".next",
13
+ ".angular",
14
+ ".svelte-kit",
15
+ ".turbo",
16
+ ".cache",
17
+ ]);
18
+ /** Lists the files below `root` as absolute paths. Prefers git (honours .gitignore), falls back to a directory walk. */
19
+ export function listFiles(root) {
20
+ const fromGit = listGitFiles(root);
21
+ return (fromGit.length > 0 ? fromGit : walk(root)).sort();
22
+ }
23
+ function listGitFiles(root) {
24
+ try {
25
+ const output = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
26
+ cwd: root,
27
+ encoding: "utf8",
28
+ maxBuffer: 256 * 1024 * 1024,
29
+ stdio: ["ignore", "pipe", "ignore"],
30
+ });
31
+ return output
32
+ .split("\0")
33
+ .filter(Boolean)
34
+ .map((relative) => path.join(root, relative))
35
+ .filter((absolute) => fs.existsSync(absolute));
36
+ }
37
+ catch {
38
+ // not a git repository, or git is unavailable
39
+ return [];
40
+ }
41
+ }
42
+ function walk(directory, found = []) {
43
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
44
+ if (entry.isDirectory()) {
45
+ if (!IGNORED_DIRS.has(entry.name))
46
+ walk(path.join(directory, entry.name), found);
47
+ }
48
+ else if (entry.isFile()) {
49
+ found.push(path.join(directory, entry.name));
50
+ }
51
+ }
52
+ return found;
53
+ }
54
+ export function hasExtension(file, extensions) {
55
+ return extensions.includes(path.extname(file).slice(1).toLowerCase());
56
+ }
package/dist/report.js ADDED
@@ -0,0 +1,56 @@
1
+ import path from "node:path";
2
+ import { isBelowMinimumVersion, MIN_TAILWIND_VERSION } from "./design-system.js";
3
+ /** Formats a run for the terminal: per-change locations, a summary of distinct rewrites and what was skipped. */
4
+ export function formatReport(result, root) {
5
+ const lines = [];
6
+ lines.push(`Entry stylesheet: ${path.relative(root, result.entryCss)} (tailwindcss ${result.tailwindVersion ?? "unknown"})`);
7
+ if (isBelowMinimumVersion(result.tailwindVersion)) {
8
+ lines.push(`Warning: tailwindcss < ${MIN_TAILWIND_VERSION} only canonicalizes part of the classes. Upgrade for complete results.`);
9
+ }
10
+ lines.push(`Scanned ${result.scannedFiles} files, ${result.attributes} static class attributes`);
11
+ if (result.changes.length > 0) {
12
+ lines.push("");
13
+ for (const change of result.changes) {
14
+ lines.push(`${change.file}:${change.line} ${change.from} -> ${change.to}`);
15
+ }
16
+ const counts = new Map();
17
+ for (const change of result.changes) {
18
+ const key = `${change.from} -> ${change.to}`;
19
+ counts.set(key, (counts.get(key) ?? 0) + 1);
20
+ }
21
+ lines.push("");
22
+ lines.push(`${result.changes.length} rewrites (${counts.size} distinct) in ${result.changedFiles.length} files:`);
23
+ for (const [key, count] of [...counts].sort(([a], [b]) => a.localeCompare(b))) {
24
+ lines.push(` ${count}x ${key}`);
25
+ }
26
+ }
27
+ else {
28
+ lines.push("");
29
+ lines.push("Nothing to change, all class names are already canonical.");
30
+ }
31
+ if (result.unverified.length > 0) {
32
+ lines.push("");
33
+ lines.push(`${result.unverified.length} suggested rewrites generate different CSS and were left alone (use --no-verify to force):`);
34
+ for (const change of result.unverified) {
35
+ lines.push(` ${change.file}:${change.line} ${change.from} -> ${change.to}`);
36
+ }
37
+ }
38
+ const skipped = [];
39
+ if (result.skippedDynamic > 0)
40
+ skipped.push(`${result.skippedDynamic} dynamic class bindings`);
41
+ if (result.skippedInterpolated > 0)
42
+ skipped.push(`${result.skippedInterpolated} class attributes with interpolation`);
43
+ if (result.skippedErrors > 0)
44
+ skipped.push(`${result.skippedErrors} class attributes Tailwind could not process`);
45
+ if (skipped.length > 0) {
46
+ lines.push("");
47
+ lines.push(`Skipped: ${skipped.join(", ")}`);
48
+ }
49
+ if (result.changes.length > 0) {
50
+ lines.push("");
51
+ lines.push(result.applied
52
+ ? `Applied to ${result.changedFiles.length} files.`
53
+ : "Dry run, nothing written. Re-run with --apply to write the changes.");
54
+ }
55
+ return lines.join("\n");
56
+ }
@@ -0,0 +1,42 @@
1
+ import { canonicalizeValue } from "./canonicalize.js";
2
+ import { extractClassAttributes } from "./extract.js";
3
+ import { isEquivalent } from "./verify.js";
4
+ /** Canonicalizes the static class attributes of one file's source. Pure: reads and writes nothing. */
5
+ export function rewriteSource(ds, source, options) {
6
+ const extraction = extractClassAttributes(source);
7
+ const result = {
8
+ source: null,
9
+ attributes: extraction.attributes.length,
10
+ changes: [],
11
+ unverified: [],
12
+ skippedInterpolated: extraction.interpolated,
13
+ skippedDynamic: extraction.dynamic,
14
+ skippedErrors: 0,
15
+ };
16
+ const replacements = [];
17
+ for (const attribute of extraction.attributes) {
18
+ const canonical = canonicalizeValue(ds, attribute.value, options.rem, (change) => {
19
+ if (!options.verify || isEquivalent(ds, change.from, change.to, options.rem))
20
+ return true;
21
+ result.unverified.push({ line: attribute.line, ...change });
22
+ return false;
23
+ });
24
+ if (canonical === null) {
25
+ result.skippedErrors++;
26
+ continue;
27
+ }
28
+ if (canonical.changes.length === 0)
29
+ continue;
30
+ result.changes.push(...canonical.changes.map((change) => ({ line: attribute.line, ...change })));
31
+ replacements.push({ start: attribute.start, end: attribute.end, value: canonical.value });
32
+ }
33
+ if (replacements.length > 0) {
34
+ // Apply from the end so earlier offsets stay valid.
35
+ let rewritten = source;
36
+ for (const { start, end, value } of replacements.reverse()) {
37
+ rewritten = rewritten.slice(0, start) + value + rewritten.slice(end);
38
+ }
39
+ result.source = rewritten;
40
+ }
41
+ return result;
42
+ }
package/dist/run.js ADDED
@@ -0,0 +1,44 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadDesignSystem } from "./design-system.js";
4
+ import { resolveEntryCss } from "./entry.js";
5
+ import { hasExtension, listFiles } from "./files.js";
6
+ import { rewriteSource } from "./rewrite.js";
7
+ export async function run(options) {
8
+ const root = path.resolve(options.root);
9
+ const files = listFiles(root);
10
+ const entryCss = resolveEntryCss(root, options.css, files);
11
+ const { ds, tailwindVersion } = await loadDesignSystem(root, entryCss);
12
+ const result = {
13
+ entryCss,
14
+ tailwindVersion,
15
+ scannedFiles: 0,
16
+ attributes: 0,
17
+ changes: [],
18
+ unverified: [],
19
+ skippedInterpolated: 0,
20
+ skippedDynamic: 0,
21
+ skippedErrors: 0,
22
+ changedFiles: [],
23
+ applied: options.apply,
24
+ };
25
+ for (const file of files) {
26
+ if (!hasExtension(file, options.extensions))
27
+ continue;
28
+ const relative = path.relative(root, file);
29
+ const rewrite = rewriteSource(ds, fs.readFileSync(file, "utf8"), options);
30
+ result.scannedFiles++;
31
+ result.attributes += rewrite.attributes;
32
+ result.skippedInterpolated += rewrite.skippedInterpolated;
33
+ result.skippedDynamic += rewrite.skippedDynamic;
34
+ result.skippedErrors += rewrite.skippedErrors;
35
+ result.changes.push(...rewrite.changes.map((change) => ({ file: relative, ...change })));
36
+ result.unverified.push(...rewrite.unverified.map((change) => ({ file: relative, ...change })));
37
+ if (rewrite.source !== null) {
38
+ result.changedFiles.push(relative);
39
+ if (options.apply)
40
+ fs.writeFileSync(file, rewrite.source);
41
+ }
42
+ }
43
+ return result;
44
+ }
package/dist/verify.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Checks that two class names generate the same CSS declarations.
3
+ *
4
+ * Selectors and wrapping at-rules are ignored. Theme variables are resolved, simple `calc(<n> * <n>)` expressions are
5
+ * evaluated and `rem` is converted to `px`, so `gap-[10px]` and `gap-2.5` compare equal at the given root font size.
6
+ */
7
+ export function isEquivalent(ds, from, to, rem) {
8
+ const [fromCss, toCss] = ds.candidatesToCss([from, to]);
9
+ if (fromCss == null || toCss == null)
10
+ return false;
11
+ const left = declarations(ds, fromCss, rem);
12
+ const right = declarations(ds, toCss, rem);
13
+ return (left.length > 0 && left.length === right.length && left.every((declaration, index) => declaration === right[index]));
14
+ }
15
+ function declarations(ds, css, rem) {
16
+ // Dropping everything in front of an opening brace removes selectors and at-rule preludes, leaving `{ prop: value; }` blocks.
17
+ const body = css.replace(/[^{};]*\{/g, "{");
18
+ const found = [];
19
+ for (const match of body.matchAll(/([\w-]+)\s*:\s*([^;{}]+)/g)) {
20
+ found.push(`${match[1] ?? ""}:${normalizeValue(ds, match[2] ?? "", rem)}`);
21
+ }
22
+ return found.sort();
23
+ }
24
+ function normalizeValue(ds, raw, rem) {
25
+ let value = raw.trim().toLowerCase();
26
+ // Resolve theme variables (`var(--spacing)`), a few passes for variables that reference other variables.
27
+ for (let pass = 0; pass < 5; pass++) {
28
+ const next = value.replace(/var\((--[\w-]+)\)/g, (whole, name) => ds.resolveThemeValue(name)?.toLowerCase() ?? whole);
29
+ if (next === value)
30
+ break;
31
+ value = next;
32
+ }
33
+ value = value.replace(/calc\(\s*(-?[\d.]+)(rem|px)?\s*\*\s*(-?[\d.]+)\s*\)/g, (_whole, a, unit, b) => {
34
+ return `${format(Number(a) * Number(b))}${unit ?? ""}`;
35
+ });
36
+ value = value.replace(/(-?[\d.]+)rem\b/g, (_whole, amount) => `${format(Number(amount) * rem)}px`);
37
+ return value.replace(/\s+/g, "");
38
+ }
39
+ function format(amount) {
40
+ return String(Number(amount.toFixed(4)));
41
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "tw-canonicalize",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Rewrite Tailwind CSS v4 class names in templates to their canonical form (the suggestCanonicalClasses fixes, in bulk)",
5
+ "homepage": "https://github.com/mhelbich/tw-canonicalize#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mhelbich/tw-canonicalize.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/mhelbich/tw-canonicalize/issues"
12
+ },
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "bin": {
18
+ "tw-canonicalize": "dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
25
+ "prepare": "npm run build",
26
+ "preversion": "npm run lint && npm run typecheck && npm test",
27
+ "typecheck": "tsc -p tsconfig.json --noEmit",
28
+ "test": "node --import tsx --test 'test/**/*.test.ts'",
29
+ "lint": "eslint .",
30
+ "lint:fix": "eslint . --fix",
31
+ "format": "prettier --write .",
32
+ "format:check": "prettier --check ."
33
+ },
34
+ "keywords": [
35
+ "tailwind",
36
+ "tailwindcss",
37
+ "canonical",
38
+ "codemod",
39
+ "cli"
40
+ ],
41
+ "author": {
42
+ "name": "mhelbich",
43
+ "email": "helbichmatthias@live.com",
44
+ "url": "https://mhelbich.de"
45
+ },
46
+ "license": "MIT",
47
+ "devDependencies": {
48
+ "@eslint/js": "^10.0.1",
49
+ "@tailwindcss/node": "^4.3.3",
50
+ "@types/node": "^22.0.0",
51
+ "eslint": "^10.8.1",
52
+ "eslint-config-prettier": "^10.1.8",
53
+ "globals": "^17.11.0",
54
+ "prettier": "^3.9.6",
55
+ "tailwindcss": "^4.3.3",
56
+ "tsx": "^4.19.2",
57
+ "typescript": "^6.0.3",
58
+ "typescript-eslint": "^8.67.0"
59
+ }
60
+ }