@birdycom/omniacss-migrate 0.0.0-stage → 0.11.1
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 +21 -0
- package/README.md +129 -3
- package/bin/omniacss-migrate.mjs +9 -0
- package/package.json +61 -6
- package/scripts/build-color-map.mjs +31 -0
- package/scripts/extract-palette.mjs +13 -0
- package/scripts/tailwind-palette.json +291 -0
- package/src/bootstrap/compat.mjs +72 -0
- package/src/bootstrap/convert.mjs +102 -0
- package/src/bootstrap/migrate.mjs +259 -0
- package/src/bootstrap/rules.mjs +212 -0
- package/src/cli.mjs +139 -0
- package/src/codemod.mjs +82 -0
- package/src/core.mjs +67 -0
- package/src/data/colors.json +290 -0
- package/src/extract.mjs +91 -0
- package/src/index.mjs +12 -0
- package/src/native.mjs +72 -0
- package/src/postcss.mjs +308 -0
- package/src/preflight.mjs +52 -0
- package/src/project.mjs +194 -0
- package/src/resolve.mjs +220 -0
- package/src/scan.mjs +106 -0
- package/src/setup.mjs +205 -0
- package/src/utilities.mjs +713 -0
- package/src/values.mjs +184 -0
- package/src/variants.mjs +189 -0
- package/src/vite.mjs +81 -0
package/src/resolve.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// File: packages/migrate/src/resolve.mjs
|
|
2
|
+
// Resolver: a Tailwind-style class token (with variants, !important, negatives, modifiers) -> CSS rules
|
|
3
|
+
// that only use OmniaCSS tokens. Entry points: createContext(), resolveToken(), compileTokens().
|
|
4
|
+
// Author: Serge Cools | Framework: OmniaCSS
|
|
5
|
+
|
|
6
|
+
import { applyVariant, esc, splitTop } from "./variants.mjs";
|
|
7
|
+
import { HANDLERS, KEYFRAMES, MARKERS, STATIC } from "./utilities.mjs";
|
|
8
|
+
import { arb, normalizeArb } from "./values.mjs";
|
|
9
|
+
|
|
10
|
+
export const SCREENS = {
|
|
11
|
+
omnia: { sm: "36rem", md: "50rem", lg: "64rem", xl: "80rem", "2xl": "96rem" },
|
|
12
|
+
tailwind: { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem", "2xl": "96rem" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the resolver context.
|
|
17
|
+
* @param {object} [o]
|
|
18
|
+
* @param {boolean} [o.v4=true] Tailwind 4 naming (shadow-sm, rounded-sm, ring …)
|
|
19
|
+
* @param {"class"|"media"} [o.darkMode="class"]
|
|
20
|
+
* @param {string} [o.darkSelector=".dark"] selector for class based dark mode
|
|
21
|
+
* @param {"omnia"|"tailwind"} [o.breakpoints="omnia"]
|
|
22
|
+
* @param {Record<string,string>} [o.screens] extra/override screens
|
|
23
|
+
* @param {"nearest"|"keep"} [o.arbitraryColors="nearest"]
|
|
24
|
+
* @param {Iterable<string>} [o.themeVars] custom properties defined in the user's @theme (e.g. "--color-primary")
|
|
25
|
+
* @param {Record<string,string>} [o.customColors] extra color names -> css value (from tailwind.config)
|
|
26
|
+
* @param {string} [o.prefix] Tailwind `prefix` option
|
|
27
|
+
*/
|
|
28
|
+
export function createContext(o = {}) {
|
|
29
|
+
const screens = { ...SCREENS[o.breakpoints || "omnia"], ...(o.screens || {}) };
|
|
30
|
+
const order = Object.keys(screens).sort((a, b) => parseFloat(screens[a]) - parseFloat(screens[b]));
|
|
31
|
+
const themeVars = new Set(o.themeVars || []);
|
|
32
|
+
return {
|
|
33
|
+
v4: o.v4 ?? true,
|
|
34
|
+
darkMode: o.darkMode || "class",
|
|
35
|
+
darkSelector: o.darkSelector || ".dark",
|
|
36
|
+
screens,
|
|
37
|
+
screenRank: (n) => order.indexOf(n) * 10,
|
|
38
|
+
arbitraryColors: o.arbitraryColors || "nearest",
|
|
39
|
+
themeVars,
|
|
40
|
+
hasTheme: (n) => themeVars.has(n),
|
|
41
|
+
customColors: new Map(Object.entries(o.customColors || {})),
|
|
42
|
+
prefix: o.prefix || "",
|
|
43
|
+
container: o.container || {},
|
|
44
|
+
tokensFile: o.tokensFile,
|
|
45
|
+
important: !!o.important,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Roots that accept a leading `-` (negative values). */
|
|
50
|
+
const NEG_OK = /^(m[xytrblse]?|scroll-m[xytrblse]?|inset(-[xy])?|top|right|bottom|left|start|end|z|order|translate(-[xyz])?|rotate(-[xy])?|scale(-[xy])?|skew(-[xy])?|hue-rotate|backdrop-hue-rotate|tracking|indent|underline-offset|outline-offset|col(-start|-end)?|row(-start|-end)?|space-[xy]|spin-in|spin-out|bg)$/;
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------------------------- parsing
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a raw class token.
|
|
56
|
+
* @returns {{ raw: string, variants: string[], utility: string, important: boolean, neg: boolean, body: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function parseToken(raw, ctx) {
|
|
59
|
+
const parts = splitTop(raw, ":");
|
|
60
|
+
const variants = parts.slice(0, -1);
|
|
61
|
+
let utility = parts[parts.length - 1];
|
|
62
|
+
let important = false, neg = false;
|
|
63
|
+
if (utility.startsWith("!")) { important = true; utility = utility.slice(1); }
|
|
64
|
+
if (utility.endsWith("!")) { important = true; utility = utility.slice(0, -1); }
|
|
65
|
+
if (ctx?.prefix) {
|
|
66
|
+
const p = ctx.prefix;
|
|
67
|
+
if (utility.startsWith("-" + p)) utility = "-" + utility.slice(p.length + 1);
|
|
68
|
+
else if (utility.startsWith(p)) utility = utility.slice(p.length);
|
|
69
|
+
else if (utility.startsWith("[") === false && !MARKERS.test(utility)) return { raw, variants, utility, important, neg, body: utility, foreign: true };
|
|
70
|
+
}
|
|
71
|
+
if (utility.startsWith("-") && utility.length > 1) { neg = true; utility = utility.slice(1); }
|
|
72
|
+
return { raw, variants, utility, important, neg, body: utility };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Split `a-b-[c-d]` on dashes outside brackets. */
|
|
76
|
+
function splitDash(s) {
|
|
77
|
+
return splitTop(s, "-");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Split `bg-red-500/50` into [body, modifier]; ignores slashes inside brackets. */
|
|
81
|
+
function splitModifier(body) {
|
|
82
|
+
const parts = splitTop(body, "/");
|
|
83
|
+
if (parts.length < 2) return [body, undefined];
|
|
84
|
+
return [parts.slice(0, -1).join("/"), parts[parts.length - 1]];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const norm = (r) => (!r ? null : Array.isArray(r) ? { decls: r } : r);
|
|
88
|
+
|
|
89
|
+
/** Resolve the utility part (no variants). Returns { decls, tail?, keyframes?, extraRules?, marker? } or null. */
|
|
90
|
+
export function resolveUtility(p, ctx) {
|
|
91
|
+
const { body, neg } = p;
|
|
92
|
+
if (!neg) {
|
|
93
|
+
const m = body.match(/^\[(--[A-Za-z0-9_-]+|-?[a-z][a-z-]*):([^:\s].*)\]$/);
|
|
94
|
+
if (m && !/^[\s;{}]/.test(m[2])) return { decls: [[m[1], normalizeArb(m[2])]] };
|
|
95
|
+
if (MARKERS.test(body)) return { decls: [], marker: true };
|
|
96
|
+
if (Object.hasOwn(STATIC, body)) return { decls: STATIC[body], static: true, ...(body.startsWith("animate-in") || body.startsWith("animate-out") ? { keyframes: [body.slice(8)] } : {}) };
|
|
97
|
+
if (body === "container") return containerRule(ctx);
|
|
98
|
+
}
|
|
99
|
+
const [bare, mod] = splitModifier(body);
|
|
100
|
+
const words = splitDash(bare);
|
|
101
|
+
for (let i = words.length; i >= 1; i--) {
|
|
102
|
+
const root = words.slice(0, i).join("-");
|
|
103
|
+
const fn = Object.hasOwn(HANDLERS, root) ? HANDLERS[root] : null;
|
|
104
|
+
if (!fn) continue;
|
|
105
|
+
if (neg && !NEG_OK.test(root)) continue;
|
|
106
|
+
const value = words.slice(i).join("-");
|
|
107
|
+
if (!value && i === words.length && false) continue;
|
|
108
|
+
// `w-1/2`, `translate-x-1/2`: try the fraction first, then value + opacity/line-height modifier
|
|
109
|
+
let r = mod !== undefined ? norm(fn({ root, value: `${value}/${mod}`, mod: undefined, neg, ctx })) : null;
|
|
110
|
+
if (!r) r = norm(fn({ root, value, mod, neg, ctx }));
|
|
111
|
+
if (r) return r;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function containerRule(ctx) {
|
|
117
|
+
const decls = [["width", "100%"]];
|
|
118
|
+
if (ctx.container.center) decls.push(["margin-inline", "auto"]);
|
|
119
|
+
if (ctx.container.padding) decls.push(["padding-inline", ctx.container.padding]);
|
|
120
|
+
const extraRules = Object.entries(ctx.screens).map(([k, w]) => ({ media: [`@media (min-width: ${w})`], decls: [["max-width", w]], rank: ctx.screenRank(k) }));
|
|
121
|
+
return { decls, extraRules };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// property rank so shorthands sort before longhands (`p-4` before `px-2` before `pl-1`)
|
|
125
|
+
const SHORTHANDS = new Set(["margin", "padding", "inset", "gap", "border-width", "border-color", "border-radius", "border", "overflow", "scroll-margin", "scroll-padding", "transition-property", "flex", "background", "font", "outline", "translate", "rotate", "scale", "size"]);
|
|
126
|
+
function propRank(prop) {
|
|
127
|
+
if (SHORTHANDS.has(prop)) return 0;
|
|
128
|
+
if (/-(top|right|bottom|left|start|end)\b|^(top|right|bottom|left)$|-(x|y)$/.test(prop)) return 2;
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve one token.
|
|
134
|
+
* @param {string} raw class token, e.g. "md:hover:bg-red-500/50"
|
|
135
|
+
* @param {object} ctx from createContext()
|
|
136
|
+
* @param {{ selector?: string }} [opts] selector: use instead of `.escaped-token` (for @apply)
|
|
137
|
+
* @returns {null | { token: string, rules: Array<{selectors:string[], media:string[], decls:string[][], rank:number}>, keyframes: string[], marker?: boolean }}
|
|
138
|
+
*/
|
|
139
|
+
export function resolveToken(raw, ctx, opts = {}) {
|
|
140
|
+
const p = parseToken(raw, ctx);
|
|
141
|
+
if (p.foreign || !p.body) return null;
|
|
142
|
+
const util = resolveUtility(p, ctx);
|
|
143
|
+
if (!util) return null;
|
|
144
|
+
if (util.marker) return { token: raw, rules: [], keyframes: [], marker: true };
|
|
145
|
+
|
|
146
|
+
const st = { sels: [opts.selector || `.${esc(raw)}`], media: [], prelude: [], rank: 0 };
|
|
147
|
+
for (let i = p.variants.length - 1; i >= 0; i--) {
|
|
148
|
+
if (!applyVariant(p.variants[i], st, ctx)) return null;
|
|
149
|
+
}
|
|
150
|
+
// a pseudo-element can only end a selector: `[&_.x]:placeholder:italic` would give `::placeholder .x` (invalid, dropped)
|
|
151
|
+
if (st.sels.some((s) => /::[a-z-]+(?:\([^)]*\))?[\s>+~.[#]/.test(s))) return null;
|
|
152
|
+
const important = p.important || ctx.important;
|
|
153
|
+
const fix = (decls) => decls.map(([k, v]) => [k, important && !/ !important$/.test(v) ? `${v} !important` : v]);
|
|
154
|
+
const hasContent = util.decls.some(([k]) => k === "content");
|
|
155
|
+
const base = fix([...(hasContent ? [] : st.prelude), ...util.decls]);
|
|
156
|
+
const urank = propRank((util.decls[0] || [""])[0]);
|
|
157
|
+
const tail = util.tail || "";
|
|
158
|
+
const selectors = st.sels.map((s) => s + tail);
|
|
159
|
+
const rules = [];
|
|
160
|
+
if (base.length) rules.push({ selectors, media: st.media, decls: base, rank: st.rank, urank });
|
|
161
|
+
for (const x of util.extraRules || []) rules.push({ selectors, media: [...st.media, ...x.media], decls: fix(x.decls), rank: st.rank + 1 + (x.rank || 0), urank });
|
|
162
|
+
return { token: raw, rules, keyframes: util.keyframes || [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------------------------- compile
|
|
166
|
+
|
|
167
|
+
const fmtDecls = (decls, indent) => decls.map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compile a list of resolved tokens into one stylesheet (ordered like a utility framework: base < pseudo < dark < responsive).
|
|
171
|
+
* @param {Array<ReturnType<typeof resolveToken>>} resolved
|
|
172
|
+
*/
|
|
173
|
+
export function serialize(resolved) {
|
|
174
|
+
const rules = [];
|
|
175
|
+
const frames = new Set();
|
|
176
|
+
for (const r of resolved) {
|
|
177
|
+
if (!r) continue;
|
|
178
|
+
r.keyframes.forEach((k) => frames.add(k));
|
|
179
|
+
for (const rule of r.rules) rules.push({ ...rule, token: r.token });
|
|
180
|
+
}
|
|
181
|
+
rules.sort((a, b) => a.rank - b.rank || a.urank - b.urank || (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
|
|
182
|
+
const renderRule = (r, ind = "") => `${r.selectors.map((x) => ind + x).join(",\n")} {\n${fmtDecls(r.decls, ind + " ")}\n${ind}}`;
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const k of frames) if (KEYFRAMES[k]) out.push(KEYFRAMES[k]);
|
|
185
|
+
let group = null;
|
|
186
|
+
const flush = () => {
|
|
187
|
+
if (!group) return;
|
|
188
|
+
let inner = group.rules.map((r) => renderRule(r, " ".repeat(group.media.length))).join("\n");
|
|
189
|
+
for (let i = group.media.length - 1; i >= 0; i--) {
|
|
190
|
+
const ind = " ".repeat(i);
|
|
191
|
+
inner = `${ind}${group.media[i]} {\n${inner}\n${ind}}`;
|
|
192
|
+
}
|
|
193
|
+
out.push(inner);
|
|
194
|
+
group = null;
|
|
195
|
+
};
|
|
196
|
+
for (const rule of rules) {
|
|
197
|
+
if (!rule.media.length) { flush(); out.push(renderRule(rule)); continue; }
|
|
198
|
+
const key = rule.media.join("|");
|
|
199
|
+
if (!group || group.key !== key) { flush(); group = { key, media: rule.media, rules: [] }; }
|
|
200
|
+
group.rules.push(rule);
|
|
201
|
+
}
|
|
202
|
+
flush();
|
|
203
|
+
return out.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Resolve many tokens and report what could not be resolved. */
|
|
207
|
+
export function compileTokens(tokens, ctx) {
|
|
208
|
+
const resolved = [], unsupported = [];
|
|
209
|
+
for (const t of tokens) {
|
|
210
|
+
const r = resolveToken(t, ctx);
|
|
211
|
+
if (r) resolved.push(r); else unsupported.push(t);
|
|
212
|
+
}
|
|
213
|
+
return { css: serialize(resolved), resolved, unsupported };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Convenience: one token -> css string (or null). */
|
|
217
|
+
export function compileToken(raw, ctx = createContext()) {
|
|
218
|
+
const r = resolveToken(raw, ctx);
|
|
219
|
+
return r ? serialize([r]) : null;
|
|
220
|
+
}
|
package/src/scan.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|