@birdycom/omniacss-migrate 0.11.0 → 0.11.2

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/src/scan.mjs CHANGED
@@ -1,106 +1,106 @@
1
- // File: packages/migrate/src/scan.mjs
2
- // Coverage scan: how many of the Tailwind-style classes in a project can the compat layer resolve?
3
- // Author: Serge Cools | Framework: OmniaCSS
4
-
5
- import fs from "node:fs/promises";
6
- import { classStrings, walk, DEFAULT_EXTENSIONS } from "./extract.mjs";
7
- import { parseToken, resolveToken, createContext } from "./resolve.mjs";
8
- import { HANDLERS, STATIC } from "./utilities.mjs";
9
- import { applyVariant } from "./variants.mjs";
10
-
11
- const EXTRA_ROOTS = ["mask", "text-shadow", "bg-opacity", "text-opacity", "border-opacity", "ring-opacity", "divide-opacity", "placeholder-opacity", "font-stretch", "prose", "icon", "scrollbar",
12
- "bg-position", "bg-size", "border-spacing", "grid-cols", "bg-linear", "bg-radial", "bg-conic", "text-balance", "stroke", "fill", "ring", "size", "outline", "container", "mix", "bg-blend", "line-clamp",
13
- "scheme", "wrap", "perspective", "transform", "backface", "snap", "touch", "motion", "overflow", "sr", "not-sr", "decoration", "underline", "break", "hyphens", "font", "caption", "forced"];
14
- const ROOTS = new Set([...Object.keys(HANDLERS), ...Object.keys(STATIC).map((k) => k.split("-")[0]), ...Object.keys(STATIC), ...EXTRA_ROOTS]);
15
-
16
- const VARIANT_LIKE = /^(group|peer|data|aria|has|not|in|supports|min|max|nth|@|\[)/;
17
-
18
- /** Does an unresolved token look like a Tailwind class (so it counts as "unsupported" rather than "custom")? */
19
- export function looksLikeTailwind(token, ctx) {
20
- const p = parseToken(token, ctx);
21
- for (const v of p.variants) {
22
- const st = { sels: ["&"], media: [], prelude: [], rank: 0 };
23
- if (applyVariant(v, st, ctx) || VARIANT_LIKE.test(v)) return true;
24
- }
25
- const body = p.body.split("/")[0];
26
- if (/^\[/.test(body)) return /^\[[a-z-]+:/i.test(body);
27
- const words = body.split("-");
28
- if (words.length === 1 && !p.variants.length) return false;
29
- if (ROOTS.has(words[0])) return true;
30
- return false;
31
- }
32
-
33
- /** Heuristic: is a quoted string a list of class names? (>= 30% of its words resolve, or a class-ish attribute/call precedes it) */
34
- export function isClassString(kinds, hinted) {
35
- const hits = kinds.filter((k) => k === "supported").length;
36
- return hits >= 1 && (hits / kinds.length >= 0.3 || hinted) && (kinds.length >= 2 || hinted);
37
- }
38
-
39
- /**
40
- * Scan a directory.
41
- * @param {string} dir
42
- * @param {{ ctx?: object, extensions?: string[], onFile?: (f:string)=>void }} [o]
43
- */
44
- export function makeKind(ctx, cache = new Map()) {
45
- return (t) => {
46
- let k = cache.get(t);
47
- if (k) return k;
48
- k = resolveToken(t, ctx) ? "supported" : looksLikeTailwind(t, ctx) ? "unsupported" : "custom";
49
- cache.set(t, k);
50
- return k;
51
- };
52
- }
53
-
54
- export async function scanDir(dir, o = {}) {
55
- const ctx = o.ctx || createContext();
56
- const cache = new Map();
57
- const kind = (t) => {
58
- let k = cache.get(t);
59
- if (k) return k;
60
- const r = resolveToken(t, ctx);
61
- k = r ? (r.marker ? "supported" : "supported") : looksLikeTailwind(t, ctx) ? "unsupported" : "custom";
62
- cache.set(t, k);
63
- return k;
64
- };
65
- const files = await walk(dir, { extensions: o.extensions || DEFAULT_EXTENSIONS });
66
- const stats = { dir, files: files.length, occurrences: 0, supported: 0, unsupportedCount: 0, customCount: 0, unsupported: new Map(), custom: new Map(), uniqueSupported: new Set(), approximatedColors: 0 };
67
- for (const file of files) {
68
- let text;
69
- try { text = await fs.readFile(file, "utf8"); } catch { continue; }
70
- for (const s of classStrings(text)) {
71
- const words = s.words;
72
- if (!words.length) continue;
73
- const kinds = words.map(kind);
74
- const hits = kinds.filter((k) => k === "supported").length;
75
- if (!isClassString(kinds, s.hinted)) continue;
76
- words.forEach((w, i) => {
77
- if (kinds[i] === "supported") {
78
- stats.occurrences++; stats.supported++; stats.uniqueSupported.add(w);
79
- if (/^[^:]*:?[a-z-]+-\[#|\[#[0-9a-f]{3,8}\]/i.test(w)) stats.approximatedColors++;
80
- } else if (kinds[i] === "unsupported") {
81
- stats.occurrences++; stats.unsupportedCount++; stats.unsupported.set(w, (stats.unsupported.get(w) || 0) + 1);
82
- } else {
83
- stats.customCount++; stats.custom.set(w, (stats.custom.get(w) || 0) + 1);
84
- }
85
- });
86
- }
87
- }
88
- stats.coverage = stats.occurrences ? (stats.supported / stats.occurrences) * 100 : 100;
89
- return stats;
90
- }
91
-
92
- /** Plain-text report. */
93
- export function formatScan(stats, { top = 40 } = {}) {
94
- const sort = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]);
95
- const lines = [];
96
- lines.push(`Scanned ${stats.files} files in ${stats.dir}`);
97
- lines.push(`Tailwind-style class occurrences: ${stats.occurrences}`);
98
- lines.push(` supported: ${stats.supported} (${stats.coverage.toFixed(2)}%) [${stats.uniqueSupported.size} unique]`);
99
- lines.push(` unsupported: ${stats.unsupportedCount} (${stats.unsupported.size} unique)`);
100
- lines.push(` custom (not Tailwind, left alone): ${stats.customCount} occurrences, ${stats.custom.size} unique`);
101
- if (stats.unsupported.size) {
102
- lines.push("", `Top unsupported classes:`);
103
- for (const [t, n] of sort(stats.unsupported).slice(0, top)) lines.push(` ${String(n).padStart(5)} ${t}`);
104
- }
105
- return lines.join("\n");
106
- }
1
+ // File: packages/migrate/src/scan.mjs
2
+ // Coverage scan: how many of the Tailwind-style classes in a project can the compat layer resolve?
3
+ // Author: Serge Cools | Framework: OmniaCSS
4
+
5
+ import fs from "node:fs/promises";
6
+ import { classStrings, walk, DEFAULT_EXTENSIONS } from "./extract.mjs";
7
+ import { parseToken, resolveToken, createContext } from "./resolve.mjs";
8
+ import { HANDLERS, STATIC } from "./utilities.mjs";
9
+ import { applyVariant } from "./variants.mjs";
10
+
11
+ const EXTRA_ROOTS = ["mask", "text-shadow", "bg-opacity", "text-opacity", "border-opacity", "ring-opacity", "divide-opacity", "placeholder-opacity", "font-stretch", "prose", "icon", "scrollbar",
12
+ "bg-position", "bg-size", "border-spacing", "grid-cols", "bg-linear", "bg-radial", "bg-conic", "text-balance", "stroke", "fill", "ring", "size", "outline", "container", "mix", "bg-blend", "line-clamp",
13
+ "scheme", "wrap", "perspective", "transform", "backface", "snap", "touch", "motion", "overflow", "sr", "not-sr", "decoration", "underline", "break", "hyphens", "font", "caption", "forced"];
14
+ const ROOTS = new Set([...Object.keys(HANDLERS), ...Object.keys(STATIC).map((k) => k.split("-")[0]), ...Object.keys(STATIC), ...EXTRA_ROOTS]);
15
+
16
+ const VARIANT_LIKE = /^(group|peer|data|aria|has|not|in|supports|min|max|nth|@|\[)/;
17
+
18
+ /** Does an unresolved token look like a Tailwind class (so it counts as "unsupported" rather than "custom")? */
19
+ export function looksLikeTailwind(token, ctx) {
20
+ const p = parseToken(token, ctx);
21
+ for (const v of p.variants) {
22
+ const st = { sels: ["&"], media: [], prelude: [], rank: 0 };
23
+ if (applyVariant(v, st, ctx) || VARIANT_LIKE.test(v)) return true;
24
+ }
25
+ const body = p.body.split("/")[0];
26
+ if (/^\[/.test(body)) return /^\[[a-z-]+:/i.test(body);
27
+ const words = body.split("-");
28
+ if (words.length === 1 && !p.variants.length) return false;
29
+ if (ROOTS.has(words[0])) return true;
30
+ return false;
31
+ }
32
+
33
+ /** Heuristic: is a quoted string a list of class names? (>= 30% of its words resolve, or a class-ish attribute/call precedes it) */
34
+ export function isClassString(kinds, hinted) {
35
+ const hits = kinds.filter((k) => k === "supported").length;
36
+ return hits >= 1 && (hits / kinds.length >= 0.3 || hinted) && (kinds.length >= 2 || hinted);
37
+ }
38
+
39
+ /**
40
+ * Scan a directory.
41
+ * @param {string} dir
42
+ * @param {{ ctx?: object, extensions?: string[], onFile?: (f:string)=>void }} [o]
43
+ */
44
+ export function makeKind(ctx, cache = new Map()) {
45
+ return (t) => {
46
+ let k = cache.get(t);
47
+ if (k) return k;
48
+ k = resolveToken(t, ctx) ? "supported" : looksLikeTailwind(t, ctx) ? "unsupported" : "custom";
49
+ cache.set(t, k);
50
+ return k;
51
+ };
52
+ }
53
+
54
+ export async function scanDir(dir, o = {}) {
55
+ const ctx = o.ctx || createContext();
56
+ const cache = new Map();
57
+ const kind = (t) => {
58
+ let k = cache.get(t);
59
+ if (k) return k;
60
+ const r = resolveToken(t, ctx);
61
+ k = r ? (r.marker ? "supported" : "supported") : looksLikeTailwind(t, ctx) ? "unsupported" : "custom";
62
+ cache.set(t, k);
63
+ return k;
64
+ };
65
+ const files = await walk(dir, { extensions: o.extensions || DEFAULT_EXTENSIONS });
66
+ const stats = { dir, files: files.length, occurrences: 0, supported: 0, unsupportedCount: 0, customCount: 0, unsupported: new Map(), custom: new Map(), uniqueSupported: new Set(), approximatedColors: 0 };
67
+ for (const file of files) {
68
+ let text;
69
+ try { text = await fs.readFile(file, "utf8"); } catch { continue; }
70
+ for (const s of classStrings(text)) {
71
+ const words = s.words;
72
+ if (!words.length) continue;
73
+ const kinds = words.map(kind);
74
+ const hits = kinds.filter((k) => k === "supported").length;
75
+ if (!isClassString(kinds, s.hinted)) continue;
76
+ words.forEach((w, i) => {
77
+ if (kinds[i] === "supported") {
78
+ stats.occurrences++; stats.supported++; stats.uniqueSupported.add(w);
79
+ if (/^[^:]*:?[a-z-]+-\[#|\[#[0-9a-f]{3,8}\]/i.test(w)) stats.approximatedColors++;
80
+ } else if (kinds[i] === "unsupported") {
81
+ stats.occurrences++; stats.unsupportedCount++; stats.unsupported.set(w, (stats.unsupported.get(w) || 0) + 1);
82
+ } else {
83
+ stats.customCount++; stats.custom.set(w, (stats.custom.get(w) || 0) + 1);
84
+ }
85
+ });
86
+ }
87
+ }
88
+ stats.coverage = stats.occurrences ? (stats.supported / stats.occurrences) * 100 : 100;
89
+ return stats;
90
+ }
91
+
92
+ /** Plain-text report. */
93
+ export function formatScan(stats, { top = 40 } = {}) {
94
+ const sort = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]);
95
+ const lines = [];
96
+ lines.push(`Scanned ${stats.files} files in ${stats.dir}`);
97
+ lines.push(`Tailwind-style class occurrences: ${stats.occurrences}`);
98
+ lines.push(` supported: ${stats.supported} (${stats.coverage.toFixed(2)}%) [${stats.uniqueSupported.size} unique]`);
99
+ lines.push(` unsupported: ${stats.unsupportedCount} (${stats.unsupported.size} unique)`);
100
+ lines.push(` custom (not Tailwind, left alone): ${stats.customCount} occurrences, ${stats.custom.size} unique`);
101
+ if (stats.unsupported.size) {
102
+ lines.push("", `Top unsupported classes:`);
103
+ for (const [t, n] of sort(stats.unsupported).slice(0, top)) lines.push(` ${String(n).padStart(5)} ${t}`);
104
+ }
105
+ return lines.join("\n");
106
+ }
package/src/setup.mjs CHANGED
@@ -1,205 +1,205 @@
1
- // File: packages/migrate/src/setup.mjs
2
- // `omniacss-migrate setup <project>`: detect Tailwind in package.json / vite / postcss / CSS and plan (or apply)
3
- // the switch to OmniaCSS. Planning never touches the disk; `apply` writes only the planned files.
4
- // Author: Serge Cools | Framework: OmniaCSS
5
-
6
- import fs from "node:fs/promises";
7
- import path from "node:path";
8
- import { loadProject, loadTailwindConfig, themeFromConfig, findFiles } from "./project.mjs";
9
- import { createContext } from "./resolve.mjs";
10
-
11
- const TW_PKGS = ["tailwindcss", "@tailwindcss/vite", "@tailwindcss/postcss"];
12
- const TW_NAME = "(?:@tailwindcss\\/vite|@tailwindcss\\/postcss|tailwindcss)";
13
- const VERSION = "^0.4.0";
14
-
15
- const read = (f) => fs.readFile(f, "utf8").catch(() => null);
16
-
17
- function detectIndent(text) {
18
- const m = text.match(/^([ \t]+)"/m);
19
- return m ? m[1] : " ";
20
- }
21
-
22
- /** Tiny line diff (common prefix/suffix trimmed) for the dry-run output. */
23
- export function diffLines(before, after) {
24
- const a = before.split(/\r?\n/), b = after.split(/\r?\n/);
25
- let s = 0;
26
- while (s < a.length && s < b.length && a[s] === b[s]) s++;
27
- let ea = a.length - 1, eb = b.length - 1;
28
- while (ea >= s && eb >= s && a[ea] === b[eb]) { ea--; eb--; }
29
- const x = a.slice(s, ea + 1), y = b.slice(s, eb + 1);
30
- const out = [];
31
- if (x.length * y.length > 4_000_000) {
32
- x.forEach((l) => out.push(`- ${l}`)); y.forEach((l) => out.push(`+ ${l}`));
33
- return out;
34
- }
35
- // LCS table (lines are few: config files)
36
- const dp = Array.from({ length: x.length + 1 }, () => new Uint16Array(y.length + 1));
37
- for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) dp[i][j] = x[i] === y[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
38
- let i = 0, j = 0;
39
- while (i < x.length || j < y.length) {
40
- if (i < x.length && j < y.length && x[i] === y[j]) { i++; j++; }
41
- else if (j < y.length && (i === x.length || dp[i][j + 1] >= dp[i + 1][j])) out.push(`+ ${y[j++]}`);
42
- else out.push(`- ${x[i++]}`);
43
- }
44
- return out;
45
- }
46
-
47
- function transformVite(text) {
48
- let out = text;
49
- const notes = [];
50
- const imp = out.match(new RegExp(`^[ \\t]*import\\s+(\\w+)\\s+from\\s+['"]${TW_NAME}['"];?[ \\t]*$`, "m"));
51
- const req = out.match(new RegExp(`^[ \\t]*(?:const|let|var)\\s+(\\w+)\\s*=\\s*require\\(['"]${TW_NAME}['"]\\);?[ \\t]*$`, "m"));
52
- const hit = imp || req;
53
- const name = hit?.[1];
54
- if (hit) {
55
- const line = imp ? "import omniaMigrate from '@birdycom/omniacss-migrate/vite';" : "const omniaMigrate = require('@birdycom/omniacss-migrate/vite');";
56
- out = out.replace(hit[0], hit[0].replace(/\S.*$/, line));
57
- out = out.replace(new RegExp(`\\b${name}\\(`, "g"), "omniaMigrate(");
58
- // bare references inside the css.postcss.plugins array: the Vite plugin injects the PostCSS part itself
59
- out = out.replace(new RegExp(`(plugins:\\s*\\[)\\s*${name}\\s*,\\s*`, "g"), "$1");
60
- out = out.replace(new RegExp(`,\\s*${name}\\s*(?=[,\\]])`, "g"), "");
61
- out = out.replace(new RegExp(`(plugins:\\s*\\[)\\s*${name}\\s*\\]`, "g"), "$1]");
62
- } else if (!/@birdycom\/omniacss-migrate/.test(out)) {
63
- // no Tailwind import: add the plugin when there is a plugins array
64
- if (/plugins:\s*\[/.test(out) && /import\s/.test(out)) {
65
- const lines = out.split("\n");
66
- const lastImport = lines.map((l, i) => (/^\s*import\s/.test(l) ? i : -1)).filter((i) => i >= 0).pop();
67
- lines.splice(lastImport + 1, 0, "import omniaMigrate from '@birdycom/omniacss-migrate/vite';");
68
- out = lines.join("\n").replace(/(\n\s*)plugins:\s*\[/, "$1plugins: [$1 omniaMigrate(),");
69
- notes.push("added omniaMigrate() to plugins");
70
- }
71
- }
72
- return { out, notes };
73
- }
74
-
75
- function transformPostcss(text, hasVite) {
76
- let out = text;
77
- const entry = new RegExp(`^[ \\t]*["']?${TW_NAME}["']?[ \\t]*:[ \\t]*\\{[^}]*\\}[ \\t]*,?[ \\t]*\\r?\\n`, "gm");
78
- const arrayEntry = new RegExp(`^[ \\t]*(?:require\\(['"]${TW_NAME}['"]\\)|['"]${TW_NAME}['"]|tailwindcss(?:\\(\\))?)[ \\t]*,?[ \\t]*\\r?\\n`, "gm");
79
- const replacement = hasVite ? "" : null;
80
- if (replacement !== null) out = out.replace(entry, "").replace(arrayEntry, "");
81
- else {
82
- out = out.replace(entry, (m) => m.replace(/\S[\s\S]*?:\s*\{[^}]*\}/, '"@birdycom/omniacss-migrate/postcss": {}'));
83
- out = out.replace(arrayEntry, (m) => m.replace(/\S[^,\r\n]*/, "require('@birdycom/omniacss-migrate/postcss')()"));
84
- }
85
- return out;
86
- }
87
-
88
- /**
89
- * Plan the switch for a project.
90
- * @param {string} dir
91
- * @returns {Promise<{ changes: Array<{file:string, before:string, after:string, note:string}>, notes: string[], commands: string[], detected: object }>}
92
- */
93
- export async function planSetup(dir, { keepBrandColors = false } = {}) {
94
- const changes = [], notes = [], commands = [];
95
- const detected = { tailwind: false, packages: [], vite: [], postcss: [], css: [], config: null };
96
-
97
- // package.json
98
- const pkgFile = path.join(dir, "package.json");
99
- const pkgText = await read(pkgFile);
100
- if (pkgText == null) throw new Error(`No package.json in ${dir}`);
101
- const pkg = JSON.parse(pkgText);
102
- const removed = [];
103
- for (const section of ["dependencies", "devDependencies"]) {
104
- for (const name of TW_PKGS) if (pkg[section]?.[name]) { removed.push(name); detected.packages.push(`${name}@${pkg[section][name]}`); }
105
- }
106
- detected.tailwind = removed.length > 0;
107
- const afterPkg = structuredClone(pkg);
108
- for (const section of ["dependencies", "devDependencies"]) for (const name of TW_PKGS) if (afterPkg[section]) delete afterPkg[section][name];
109
- afterPkg.devDependencies ||= {};
110
- afterPkg.devDependencies["@birdycom/omniacss-migrate"] = VERSION;
111
- afterPkg.devDependencies = Object.fromEntries(Object.entries(afterPkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
112
- const nl = pkgText.includes("\r\n") ? "\r\n" : "\n";
113
- const pkgAfter = JSON.stringify(afterPkg, null, detectIndent(pkgText)).replace(/\n/g, nl) + (pkgText.endsWith("\n") ? nl : "");
114
- changes.push({ file: pkgFile, before: pkgText, after: pkgAfter, note: `remove ${removed.join(", ") || "(no tailwind packages)"}; add @birdycom/omniacss-migrate` });
115
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
116
- if (deps["@iconify/tailwind4"] || deps["@iconify/tailwind"]) notes.push("Icon classes from the Iconify Tailwind plugin (icon-[mdi--home]) are not generated: switch those to an icon component or the Iconify web component.");
117
- if (deps["@tailwindcss/typography"]) notes.push("@tailwindcss/typography (prose) is not emulated.");
118
- if (deps["@tailwindcss/forms"]) notes.push("@tailwindcss/forms is not emulated; the base reset covers most form basics.");
119
- if (deps["tailwindcss-animate"] || deps["tw-animate-css"]) notes.push("tailwindcss-animate classes (animate-in, fade-in-0, zoom-in-95, slide-in-from-*) are emulated.");
120
- if (deps["tailwind-merge"]) notes.push("tailwind-merge keeps working unchanged: your class names stay the same.");
121
- if (deps["prettier-plugin-tailwindcss"]) notes.push("prettier-plugin-tailwindcss only sorts class names; it can stay.");
122
-
123
- // vite + postcss configs
124
- const viteFiles = [];
125
- for (const n of ["vite.config.js", "vite.config.ts", "vite.config.mjs", "vite.config.mts"]) if (await read(path.join(dir, n)) != null) viteFiles.push(path.join(dir, n));
126
- for (const f of viteFiles) {
127
- const before = await read(f);
128
- const { out, notes: n } = transformVite(before);
129
- detected.vite.push(path.basename(f));
130
- if (out !== before) changes.push({ file: f, before, after: out, note: "use the OmniaCSS Vite plugin" + (n.length ? ` (${n.join(", ")})` : "") });
131
- else notes.push(`${path.basename(f)}: no Tailwind plugin found. Add \`import omniaMigrate from '@birdycom/omniacss-migrate/vite'\` and put omniaMigrate() in plugins.`);
132
- }
133
- for (const n of ["postcss.config.js", "postcss.config.cjs", "postcss.config.mjs"]) {
134
- const f = path.join(dir, n);
135
- const before = await read(f);
136
- if (before == null) continue;
137
- detected.postcss.push(n);
138
- const after = transformPostcss(before, viteFiles.length > 0);
139
- if (after !== before) changes.push({ file: f, before, after, note: viteFiles.length ? "remove the Tailwind PostCSS plugin (the Vite plugin injects the OmniaCSS one)" : "swap the Tailwind PostCSS plugin for @birdycom/omniacss-migrate/postcss" });
140
- }
141
-
142
- // tailwind.config + css
143
- const project = await loadProject(dir, { keepBrandColors });
144
- detected.config = project.info.configFile ? path.basename(project.info.configFile) : null;
145
- const loaded = await loadTailwindConfig(dir);
146
- const cssFiles = (await findFiles(dir, (n) => n.endsWith(".css"))).filter((f) => !/[\\/]public[\\/]/.test(f));
147
- let entryDone = false;
148
- for (const f of cssFiles) {
149
- const before = await read(f);
150
- if (!/@import\s+["']tailwindcss|@tailwind\s+(base|utilities)/.test(before)) continue;
151
- detected.css.push(path.relative(dir, f));
152
- if (entryDone) continue;
153
- entryDone = true;
154
- const add = [];
155
- if (loaded?.config) {
156
- const t = themeFromConfig(loaded.config, createContext({}), { keepColors: keepBrandColors });
157
- if (t.decls.length && !/omniacss-migrate: theme/.test(before)) {
158
- add.push("/* omniacss-migrate: theme from tailwind.config (literal colors mapped to the nearest Omnia palette token) */");
159
- add.push("@theme {");
160
- for (const [k, v] of t.decls) add.push(` ${k}: ${v};`);
161
- add.push("}");
162
- }
163
- notes.push(...t.notes.filter((n) => !n.startsWith("color ")));
164
- const dm = Array.isArray(loaded.config.darkMode) ? loaded.config.darkMode[0] : loaded.config.darkMode;
165
- if ((dm === "class" || dm === "selector") && !/@custom-variant\s+dark/.test(before)) add.push("@custom-variant dark (&:where(.dark, .dark *));");
166
- }
167
- if (add.length) {
168
- const re = /^(@import\s+["']tailwindcss["'][^;\n]*;|@tailwind\s+utilities;)[ \t]*\r?\n/m;
169
- const m = before.match(re);
170
- const eol = before.includes("\r\n") ? "\r\n" : "\n";
171
- const after = m ? before.replace(re, `${m[0]}${eol}${add.join(eol)}${eol}`) : before + eol + add.join(eol) + eol;
172
- changes.push({ file: f, before, after, note: "carry tailwind.config theme into CSS (@theme) and dark mode variant" });
173
- } else notes.push(`${path.relative(dir, f)}: the directives (@import "tailwindcss" / @tailwind …) are handled by the plugin; no change needed.`);
174
- if (/@plugin\s+['"]@iconify/.test(before)) notes.push("`@plugin '@iconify/...'` is ignored by the plugin (icon-[...] classes are not generated).");
175
- }
176
- if (!detected.css.length) notes.push("No CSS file with Tailwind directives found. The plugin works on `@import \"tailwindcss\"` or `@tailwind base/components/utilities`.");
177
- commands.push("npm uninstall " + (removed.join(" ") || "tailwindcss @tailwindcss/vite"));
178
- commands.push("npm install -D @birdycom/omniacss-migrate");
179
- return { changes, notes, commands, detected };
180
- }
181
-
182
- /** Write the planned changes to disk. */
183
- export async function applySetup(plan, { backup = false } = {}) {
184
- for (const c of plan.changes) {
185
- if (backup) await fs.copyFile(c.file, c.file + ".pre-omnia");
186
- await fs.writeFile(c.file, c.after);
187
- }
188
- }
189
-
190
- export function formatPlan(plan, dir, { write = false } = {}) {
191
- const lines = [];
192
- lines.push(`OmniaCSS setup for ${dir}`);
193
- lines.push(`Detected: ${plan.detected.tailwind ? "Tailwind " + plan.detected.packages.join(", ") : "no Tailwind package"}; vite: ${plan.detected.vite.join(", ") || "-"}; postcss: ${plan.detected.postcss.join(", ") || "-"}; css: ${plan.detected.css.join(", ") || "-"}; config: ${plan.detected.config || "-"}`);
194
- lines.push("");
195
- for (const c of plan.changes) {
196
- lines.push(`${write ? "Updated" : "Would update"} ${path.relative(dir, c.file) || c.file} (${c.note})`);
197
- const diff = diffLines(c.before, c.after);
198
- for (const l of diff.slice(0, 40)) lines.push(" " + l);
199
- if (diff.length > 40) lines.push(` ... ${diff.length - 40} more lines`);
200
- }
201
- if (plan.notes.length) { lines.push("", "Notes:"); for (const n of plan.notes) lines.push(" - " + n); }
202
- lines.push("", write ? "Next:" : "Dry run only. Re-run with --write to apply. Then:");
203
- for (const c of plan.commands) lines.push(" " + c);
204
- return lines.join("\n");
205
- }
1
+ // File: packages/migrate/src/setup.mjs
2
+ // `omniacss-migrate setup <project>`: detect Tailwind in package.json / vite / postcss / CSS and plan (or apply)
3
+ // the switch to OmniaCSS. Planning never touches the disk; `apply` writes only the planned files.
4
+ // Author: Serge Cools | Framework: OmniaCSS
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { loadProject, loadTailwindConfig, themeFromConfig, findFiles } from "./project.mjs";
9
+ import { createContext } from "./resolve.mjs";
10
+
11
+ const TW_PKGS = ["tailwindcss", "@tailwindcss/vite", "@tailwindcss/postcss"];
12
+ const TW_NAME = "(?:@tailwindcss\\/vite|@tailwindcss\\/postcss|tailwindcss)";
13
+ const VERSION = "^0.4.0";
14
+
15
+ const read = (f) => fs.readFile(f, "utf8").catch(() => null);
16
+
17
+ function detectIndent(text) {
18
+ const m = text.match(/^([ \t]+)"/m);
19
+ return m ? m[1] : " ";
20
+ }
21
+
22
+ /** Tiny line diff (common prefix/suffix trimmed) for the dry-run output. */
23
+ export function diffLines(before, after) {
24
+ const a = before.split(/\r?\n/), b = after.split(/\r?\n/);
25
+ let s = 0;
26
+ while (s < a.length && s < b.length && a[s] === b[s]) s++;
27
+ let ea = a.length - 1, eb = b.length - 1;
28
+ while (ea >= s && eb >= s && a[ea] === b[eb]) { ea--; eb--; }
29
+ const x = a.slice(s, ea + 1), y = b.slice(s, eb + 1);
30
+ const out = [];
31
+ if (x.length * y.length > 4_000_000) {
32
+ x.forEach((l) => out.push(`- ${l}`)); y.forEach((l) => out.push(`+ ${l}`));
33
+ return out;
34
+ }
35
+ // LCS table (lines are few: config files)
36
+ const dp = Array.from({ length: x.length + 1 }, () => new Uint16Array(y.length + 1));
37
+ for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) dp[i][j] = x[i] === y[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
38
+ let i = 0, j = 0;
39
+ while (i < x.length || j < y.length) {
40
+ if (i < x.length && j < y.length && x[i] === y[j]) { i++; j++; }
41
+ else if (j < y.length && (i === x.length || dp[i][j + 1] >= dp[i + 1][j])) out.push(`+ ${y[j++]}`);
42
+ else out.push(`- ${x[i++]}`);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function transformVite(text) {
48
+ let out = text;
49
+ const notes = [];
50
+ const imp = out.match(new RegExp(`^[ \\t]*import\\s+(\\w+)\\s+from\\s+['"]${TW_NAME}['"];?[ \\t]*$`, "m"));
51
+ const req = out.match(new RegExp(`^[ \\t]*(?:const|let|var)\\s+(\\w+)\\s*=\\s*require\\(['"]${TW_NAME}['"]\\);?[ \\t]*$`, "m"));
52
+ const hit = imp || req;
53
+ const name = hit?.[1];
54
+ if (hit) {
55
+ const line = imp ? "import omniaMigrate from '@birdycom/omniacss-migrate/vite';" : "const omniaMigrate = require('@birdycom/omniacss-migrate/vite');";
56
+ out = out.replace(hit[0], hit[0].replace(/\S.*$/, line));
57
+ out = out.replace(new RegExp(`\\b${name}\\(`, "g"), "omniaMigrate(");
58
+ // bare references inside the css.postcss.plugins array: the Vite plugin injects the PostCSS part itself
59
+ out = out.replace(new RegExp(`(plugins:\\s*\\[)\\s*${name}\\s*,\\s*`, "g"), "$1");
60
+ out = out.replace(new RegExp(`,\\s*${name}\\s*(?=[,\\]])`, "g"), "");
61
+ out = out.replace(new RegExp(`(plugins:\\s*\\[)\\s*${name}\\s*\\]`, "g"), "$1]");
62
+ } else if (!/@birdycom\/omniacss-migrate/.test(out)) {
63
+ // no Tailwind import: add the plugin when there is a plugins array
64
+ if (/plugins:\s*\[/.test(out) && /import\s/.test(out)) {
65
+ const lines = out.split("\n");
66
+ const lastImport = lines.map((l, i) => (/^\s*import\s/.test(l) ? i : -1)).filter((i) => i >= 0).pop();
67
+ lines.splice(lastImport + 1, 0, "import omniaMigrate from '@birdycom/omniacss-migrate/vite';");
68
+ out = lines.join("\n").replace(/(\n\s*)plugins:\s*\[/, "$1plugins: [$1 omniaMigrate(),");
69
+ notes.push("added omniaMigrate() to plugins");
70
+ }
71
+ }
72
+ return { out, notes };
73
+ }
74
+
75
+ function transformPostcss(text, hasVite) {
76
+ let out = text;
77
+ const entry = new RegExp(`^[ \\t]*["']?${TW_NAME}["']?[ \\t]*:[ \\t]*\\{[^}]*\\}[ \\t]*,?[ \\t]*\\r?\\n`, "gm");
78
+ const arrayEntry = new RegExp(`^[ \\t]*(?:require\\(['"]${TW_NAME}['"]\\)|['"]${TW_NAME}['"]|tailwindcss(?:\\(\\))?)[ \\t]*,?[ \\t]*\\r?\\n`, "gm");
79
+ const replacement = hasVite ? "" : null;
80
+ if (replacement !== null) out = out.replace(entry, "").replace(arrayEntry, "");
81
+ else {
82
+ out = out.replace(entry, (m) => m.replace(/\S[\s\S]*?:\s*\{[^}]*\}/, '"@birdycom/omniacss-migrate/postcss": {}'));
83
+ out = out.replace(arrayEntry, (m) => m.replace(/\S[^,\r\n]*/, "require('@birdycom/omniacss-migrate/postcss')()"));
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Plan the switch for a project.
90
+ * @param {string} dir
91
+ * @returns {Promise<{ changes: Array<{file:string, before:string, after:string, note:string}>, notes: string[], commands: string[], detected: object }>}
92
+ */
93
+ export async function planSetup(dir, { keepBrandColors = false } = {}) {
94
+ const changes = [], notes = [], commands = [];
95
+ const detected = { tailwind: false, packages: [], vite: [], postcss: [], css: [], config: null };
96
+
97
+ // package.json
98
+ const pkgFile = path.join(dir, "package.json");
99
+ const pkgText = await read(pkgFile);
100
+ if (pkgText == null) throw new Error(`No package.json in ${dir}`);
101
+ const pkg = JSON.parse(pkgText);
102
+ const removed = [];
103
+ for (const section of ["dependencies", "devDependencies"]) {
104
+ for (const name of TW_PKGS) if (pkg[section]?.[name]) { removed.push(name); detected.packages.push(`${name}@${pkg[section][name]}`); }
105
+ }
106
+ detected.tailwind = removed.length > 0;
107
+ const afterPkg = structuredClone(pkg);
108
+ for (const section of ["dependencies", "devDependencies"]) for (const name of TW_PKGS) if (afterPkg[section]) delete afterPkg[section][name];
109
+ afterPkg.devDependencies ||= {};
110
+ afterPkg.devDependencies["@birdycom/omniacss-migrate"] = VERSION;
111
+ afterPkg.devDependencies = Object.fromEntries(Object.entries(afterPkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
112
+ const nl = pkgText.includes("\r\n") ? "\r\n" : "\n";
113
+ const pkgAfter = JSON.stringify(afterPkg, null, detectIndent(pkgText)).replace(/\n/g, nl) + (pkgText.endsWith("\n") ? nl : "");
114
+ changes.push({ file: pkgFile, before: pkgText, after: pkgAfter, note: `remove ${removed.join(", ") || "(no tailwind packages)"}; add @birdycom/omniacss-migrate` });
115
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
116
+ if (deps["@iconify/tailwind4"] || deps["@iconify/tailwind"]) notes.push("Icon classes from the Iconify Tailwind plugin (icon-[mdi--home]) are not generated: switch those to an icon component or the Iconify web component.");
117
+ if (deps["@tailwindcss/typography"]) notes.push("@tailwindcss/typography (prose) is not emulated.");
118
+ if (deps["@tailwindcss/forms"]) notes.push("@tailwindcss/forms is not emulated; the base reset covers most form basics.");
119
+ if (deps["tailwindcss-animate"] || deps["tw-animate-css"]) notes.push("tailwindcss-animate classes (animate-in, fade-in-0, zoom-in-95, slide-in-from-*) are emulated.");
120
+ if (deps["tailwind-merge"]) notes.push("tailwind-merge keeps working unchanged: your class names stay the same.");
121
+ if (deps["prettier-plugin-tailwindcss"]) notes.push("prettier-plugin-tailwindcss only sorts class names; it can stay.");
122
+
123
+ // vite + postcss configs
124
+ const viteFiles = [];
125
+ for (const n of ["vite.config.js", "vite.config.ts", "vite.config.mjs", "vite.config.mts"]) if (await read(path.join(dir, n)) != null) viteFiles.push(path.join(dir, n));
126
+ for (const f of viteFiles) {
127
+ const before = await read(f);
128
+ const { out, notes: n } = transformVite(before);
129
+ detected.vite.push(path.basename(f));
130
+ if (out !== before) changes.push({ file: f, before, after: out, note: "use the OmniaCSS Vite plugin" + (n.length ? ` (${n.join(", ")})` : "") });
131
+ else notes.push(`${path.basename(f)}: no Tailwind plugin found. Add \`import omniaMigrate from '@birdycom/omniacss-migrate/vite'\` and put omniaMigrate() in plugins.`);
132
+ }
133
+ for (const n of ["postcss.config.js", "postcss.config.cjs", "postcss.config.mjs"]) {
134
+ const f = path.join(dir, n);
135
+ const before = await read(f);
136
+ if (before == null) continue;
137
+ detected.postcss.push(n);
138
+ const after = transformPostcss(before, viteFiles.length > 0);
139
+ if (after !== before) changes.push({ file: f, before, after, note: viteFiles.length ? "remove the Tailwind PostCSS plugin (the Vite plugin injects the OmniaCSS one)" : "swap the Tailwind PostCSS plugin for @birdycom/omniacss-migrate/postcss" });
140
+ }
141
+
142
+ // tailwind.config + css
143
+ const project = await loadProject(dir, { keepBrandColors });
144
+ detected.config = project.info.configFile ? path.basename(project.info.configFile) : null;
145
+ const loaded = await loadTailwindConfig(dir);
146
+ const cssFiles = (await findFiles(dir, (n) => n.endsWith(".css"))).filter((f) => !/[\\/]public[\\/]/.test(f));
147
+ let entryDone = false;
148
+ for (const f of cssFiles) {
149
+ const before = await read(f);
150
+ if (!/@import\s+["']tailwindcss|@tailwind\s+(base|utilities)/.test(before)) continue;
151
+ detected.css.push(path.relative(dir, f));
152
+ if (entryDone) continue;
153
+ entryDone = true;
154
+ const add = [];
155
+ if (loaded?.config) {
156
+ const t = themeFromConfig(loaded.config, createContext({}), { keepColors: keepBrandColors });
157
+ if (t.decls.length && !/omniacss-migrate: theme/.test(before)) {
158
+ add.push("/* omniacss-migrate: theme from tailwind.config (literal colors mapped to the nearest Omnia palette token) */");
159
+ add.push("@theme {");
160
+ for (const [k, v] of t.decls) add.push(` ${k}: ${v};`);
161
+ add.push("}");
162
+ }
163
+ notes.push(...t.notes.filter((n) => !n.startsWith("color ")));
164
+ const dm = Array.isArray(loaded.config.darkMode) ? loaded.config.darkMode[0] : loaded.config.darkMode;
165
+ if ((dm === "class" || dm === "selector") && !/@custom-variant\s+dark/.test(before)) add.push("@custom-variant dark (&:where(.dark, .dark *));");
166
+ }
167
+ if (add.length) {
168
+ const re = /^(@import\s+["']tailwindcss["'][^;\n]*;|@tailwind\s+utilities;)[ \t]*\r?\n/m;
169
+ const m = before.match(re);
170
+ const eol = before.includes("\r\n") ? "\r\n" : "\n";
171
+ const after = m ? before.replace(re, `${m[0]}${eol}${add.join(eol)}${eol}`) : before + eol + add.join(eol) + eol;
172
+ changes.push({ file: f, before, after, note: "carry tailwind.config theme into CSS (@theme) and dark mode variant" });
173
+ } else notes.push(`${path.relative(dir, f)}: the directives (@import "tailwindcss" / @tailwind …) are handled by the plugin; no change needed.`);
174
+ if (/@plugin\s+['"]@iconify/.test(before)) notes.push("`@plugin '@iconify/...'` is ignored by the plugin (icon-[...] classes are not generated).");
175
+ }
176
+ if (!detected.css.length) notes.push("No CSS file with Tailwind directives found. The plugin works on `@import \"tailwindcss\"` or `@tailwind base/components/utilities`.");
177
+ commands.push("npm uninstall " + (removed.join(" ") || "tailwindcss @tailwindcss/vite"));
178
+ commands.push("npm install -D @birdycom/omniacss-migrate");
179
+ return { changes, notes, commands, detected };
180
+ }
181
+
182
+ /** Write the planned changes to disk. */
183
+ export async function applySetup(plan, { backup = false } = {}) {
184
+ for (const c of plan.changes) {
185
+ if (backup) await fs.copyFile(c.file, c.file + ".pre-omnia");
186
+ await fs.writeFile(c.file, c.after);
187
+ }
188
+ }
189
+
190
+ export function formatPlan(plan, dir, { write = false } = {}) {
191
+ const lines = [];
192
+ lines.push(`OmniaCSS setup for ${dir}`);
193
+ lines.push(`Detected: ${plan.detected.tailwind ? "Tailwind " + plan.detected.packages.join(", ") : "no Tailwind package"}; vite: ${plan.detected.vite.join(", ") || "-"}; postcss: ${plan.detected.postcss.join(", ") || "-"}; css: ${plan.detected.css.join(", ") || "-"}; config: ${plan.detected.config || "-"}`);
194
+ lines.push("");
195
+ for (const c of plan.changes) {
196
+ lines.push(`${write ? "Updated" : "Would update"} ${path.relative(dir, c.file) || c.file} (${c.note})`);
197
+ const diff = diffLines(c.before, c.after);
198
+ for (const l of diff.slice(0, 40)) lines.push(" " + l);
199
+ if (diff.length > 40) lines.push(` ... ${diff.length - 40} more lines`);
200
+ }
201
+ if (plan.notes.length) { lines.push("", "Notes:"); for (const n of plan.notes) lines.push(" - " + n); }
202
+ lines.push("", write ? "Next:" : "Dry run only. Re-run with --write to apply. Then:");
203
+ for (const c of plan.commands) lines.push(" " + c);
204
+ return lines.join("\n");
205
+ }