@birdycom/omniacss-migrate 0.0.0-stage → 0.11.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/src/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ // File: packages/migrate/src/index.mjs
2
+ // Public API of @birdycom/omniacss-migrate.
3
+ // Author: Serge Cools | Framework: OmniaCSS
4
+ export { createContext, resolveToken, resolveUtility, parseToken, compileToken, compileTokens, serialize, SCREENS } from "./resolve.mjs";
5
+ export { extractTokens, classStrings } from "./extract.mjs";
6
+ export { scanDir, formatScan } from "./scan.mjs";
7
+ export { runCodemod, formatCodemod } from "./codemod.mjs";
8
+ export { planSetup, applySetup, formatPlan } from "./setup.mjs";
9
+ export { loadProject, themeFromConfig } from "./project.mjs";
10
+ export { COLOR_MAP } from "./values.mjs";
11
+ export { default as postcss } from "./postcss.mjs";
12
+ export { default as vite } from "./vite.mjs";
package/src/native.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // File: packages/migrate/src/native.mjs
2
+ // Codemod brain: find the native OmniaCSS class that has exactly the same declarations as a Tailwind-style class.
3
+ // The index is read from the shipped @birdycom/omniacss CSS, so it always matches the installed version.
4
+ // Author: Serge Cools | Framework: OmniaCSS
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { createRequire } from "node:module";
9
+ import { fileURLToPath } from "node:url";
10
+ import { parseToken, resolveToken } from "./resolve.mjs";
11
+ import { esc } from "./variants.mjs";
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ function findOmniaCss(cwd) {
16
+ const tries = [
17
+ () => createRequire(path.join(cwd, "package.json")).resolve("@birdycom/omniacss/dist/omnia.css"),
18
+ () => createRequire(import.meta.url).resolve("@birdycom/omniacss/dist/omnia.css"),
19
+ () => path.resolve(here, "../../core/dist/omnia.css"),
20
+ ];
21
+ for (const t of tries) { try { const p = t(); if (fs.existsSync(p)) return p; } catch { /* next */ } }
22
+ throw new Error("@birdycom/omniacss-migrate: cannot find @birdycom/omniacss/dist/omnia.css (npm install @birdycom/omniacss)");
23
+ }
24
+
25
+ const normDecls = (body) =>
26
+ body.replace(/\/\*[\s\S]*?\*\//g, "").split(";").map((s) => s.trim().replace(/\s+/g, " ")).filter(Boolean).map((s) => s.replace(/\s*:\s*/, ":")).join(";");
27
+
28
+ let cached;
29
+
30
+ /** Index of Omnia's own classes: declarations -> class names, plus the set of every class name. */
31
+ export function loadOmniaIndex(cwd = process.cwd()) {
32
+ const file = findOmniaCss(cwd);
33
+ if (cached?.file === file) return cached;
34
+ const css = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
35
+ const names = new Set();
36
+ for (const m of css.matchAll(/\.((?:\\.|[A-Za-z0-9_-])+)/g)) names.add(m[1].replace(/\\(.)/g, "$1"));
37
+ const byDecls = new Map();
38
+ for (const m of css.matchAll(/^\.((?:\\.|[A-Za-z0-9_-])+) \{\n([^}]*)\}/gm)) {
39
+ const name = m[1].replace(/\\(.)/g, "$1");
40
+ const key = normDecls(m[2]);
41
+ if (!key) continue;
42
+ const list = byDecls.get(key) || [];
43
+ if (!list.includes(name)) list.push(name);
44
+ byDecls.set(key, list);
45
+ }
46
+ cached = { file, names, byDecls };
47
+ return cached;
48
+ }
49
+
50
+ const SAFE_VARIANTS = new Set(["sm", "md", "lg", "dark", "hover", "focus", "active", "focus-visible", "focus-within", "disabled", "group-hover", "first", "last", "odd", "even"]);
51
+
52
+ /**
53
+ * Native Omnia class for a Tailwind-style token, or null.
54
+ * @returns {{ native: string } | { collision: true } | null}
55
+ */
56
+ export function nativeFor(token, ctx, index) {
57
+ const p = parseToken(token, ctx);
58
+ if (p.foreign || p.important || !p.body) return null;
59
+ if (!p.variants.every((v) => SAFE_VARIANTS.has(v))) return null;
60
+ const r = resolveToken(p.neg ? "-" + p.utility : p.utility, ctx);
61
+ if (!r || r.marker) return r?.marker ? { native: token } : null;
62
+ if (r.rules.length !== 1 || r.rules[0].media.length || r.rules[0].selectors.length !== 1) return null;
63
+ const rule = r.rules[0];
64
+ if (rule.selectors[0] !== `.${esc(p.neg ? "-" + p.utility : p.utility)}`) return null;
65
+ const key = rule.decls.map(([k, v]) => `${k}:${v.replace(/\s+/g, " ")}`).join(";");
66
+ const list = index.byDecls.get(normDecls(key));
67
+ if (!list) return index.names.has(p.utility) ? { collision: true } : null;
68
+ const base = list.includes(p.utility) ? p.utility : [...list].sort((a, b) => a.length - b.length)[0];
69
+ const native = p.variants.length ? `${p.variants.join(":")}:${base}` : base;
70
+ if (p.variants.length && !index.names.has(native)) return null;
71
+ return { native };
72
+ }
@@ -0,0 +1,308 @@
1
+ // File: packages/migrate/src/postcss.mjs
2
+ // PostCSS plugin "keep your classes": replaces the framework directives in your CSS
3
+ // @import "tailwindcss"; / @tailwind base|components|utilities;
4
+ // with OmniaCSS core + compat CSS for the classes found in your content, and expands @apply, theme() and @theme.
5
+ //
6
+ // // postcss.config.js
7
+ // import omniaMigrate from "@birdycom/omniacss-migrate/postcss";
8
+ // export default { plugins: [omniaMigrate({ content: ["resources/views/**/*.blade.php", "resources/js/**/*.{vue,tsx}"] })] };
9
+ //
10
+ // Author: Serge Cools | Framework: OmniaCSS
11
+
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import postcss from "postcss";
15
+ import { createContext, resolveToken, serialize } from "./resolve.mjs";
16
+ import { KEYFRAMES } from "./utilities.mjs";
17
+ import { extractTokens } from "./extract.mjs";
18
+ import { loadProject, parseThemeBlocks } from "./project.mjs";
19
+ import { preflight } from "./preflight.mjs";
20
+ import { buildCoreCss } from "./core.mjs";
21
+ import { COLOR_MAP } from "./values.mjs";
22
+ import { applyVariant } from "./variants.mjs";
23
+
24
+ const TW_IMPORT = /^["']tailwindcss(?:\/(theme|preflight|utilities|base|components)(?:\.css)?)?["']/;
25
+ const fileTokenCache = new Map();
26
+
27
+ /** Default content globs when none are given. */
28
+ export async function defaultContent(cwd) {
29
+ const has = (p) => fs.stat(path.join(cwd, p)).then(() => true, () => false);
30
+ if (await has("artisan")) return ["resources/views/**/*.blade.php", "resources/js/**/*.{js,ts,jsx,tsx,vue}", "app/**/*.php"];
31
+ if (await has("astro.config.mjs")) return ["src/**/*.{astro,html,md,mdx,jsx,tsx,vue,svelte}"];
32
+ if (await has("nuxt.config.ts")) return ["{app,components,pages,layouts}/**/*.vue"];
33
+ return ["index.html", "src/**/*.{html,js,ts,jsx,tsx,vue,svelte}"];
34
+ }
35
+
36
+ /** Collect candidate class tokens from content globs. Returns { tokens, files }. */
37
+ export async function collectTokens(patterns, cwd) {
38
+ const tokens = new Set(), files = [];
39
+ for (const pattern of [].concat(patterns)) {
40
+ for await (const rel of fs.glob(pattern, { cwd, exclude: (p) => /node_modules|[\\/]\.git[\\/]|[\\/]vendor[\\/]|[\\/]storage[\\/]/.test(p) })) {
41
+ const file = path.resolve(cwd, rel);
42
+ const st = await fs.stat(file).catch(() => null);
43
+ if (!st || !st.isFile()) continue;
44
+ files.push(file);
45
+ let entry = fileTokenCache.get(file);
46
+ if (!entry || entry.mtime !== st.mtimeMs) {
47
+ entry = { mtime: st.mtimeMs, tokens: new Set(extractTokens(await fs.readFile(file, "utf8"))) };
48
+ fileTokenCache.set(file, entry);
49
+ }
50
+ for (const t of entry.tokens) tokens.add(t);
51
+ }
52
+ }
53
+ return { tokens, files };
54
+ }
55
+
56
+ /** `underline {hover:,focus:}bg-red-500` style brace expansion used by @source inline(). */
57
+ function expandBraces(s) {
58
+ const m = s.match(/\{([^{}]*)\}/);
59
+ if (!m) return [s];
60
+ return m[1].split(",").flatMap((alt) => expandBraces(s.slice(0, m.index) + alt + s.slice(m.index + m[0].length)));
61
+ }
62
+
63
+ const projectCache = new Map();
64
+ async function getProject(cwd, options) {
65
+ const key = cwd + JSON.stringify([options.tailwind, options.darkMode, options.breakpoints, options.arbitraryColors, options.keepBrandColors]);
66
+ if (!projectCache.has(key)) {
67
+ projectCache.set(key, loadProject(cwd, {
68
+ v4: options.tailwind ? options.tailwind >= 4 : undefined,
69
+ darkMode: options.darkMode, breakpoints: options.breakpoints, arbitraryColors: options.arbitraryColors,
70
+ keepBrandColors: options.keepBrandColors, screens: options.screens, tokensFile: options.tokensFile,
71
+ }));
72
+ }
73
+ return projectCache.get(key);
74
+ }
75
+
76
+ /** theme(colors.red.500) / theme(spacing.4) / --spacing(4) inside declaration values. */
77
+ function replaceThemeFunctions(value, ctx) {
78
+ if (!/theme\(|--spacing\(|--alpha\(/.test(value)) return value;
79
+ return value
80
+ .replace(/--spacing\(\s*([\d.]+)\s*\)/g, (_, n) => `${+n * 0.25}rem`)
81
+ .replace(/--alpha\(\s*(.+?)\s*\/\s*([\d.]+)%\s*\)/g, (_, c, p) => `color-mix(in oklab, ${c} ${p}%, transparent)`)
82
+ .replace(/theme\(\s*['"]?([^'")]+?)['"]?\s*(?:,\s*[^)]+)?\)/g, (all, key) => {
83
+ let m = key.match(/^colors?[.\-/]([\w-]+?)(?:[.\-/](\d+))?(?:\/(\d+))?$/);
84
+ if (m) {
85
+ const name = m[2] ? `${m[1]}-${m[2]}` : m[1];
86
+ const token = COLOR_MAP[name];
87
+ if (token) return `var(${token})`;
88
+ if (ctx.hasTheme(`--color-${name}`)) return `var(--color-${name})`;
89
+ }
90
+ m = key.match(/^spacing[.\-/](.+)$/);
91
+ if (m && /^[\d.]+$/.test(m[1])) return `${+m[1] * 0.25}rem`;
92
+ m = key.match(/^--[\w-]+$/);
93
+ if (m) return `var(${key})`;
94
+ return all;
95
+ });
96
+ }
97
+
98
+ function withTheme(ctx, extra, patch = {}) {
99
+ const themeVars = new Set([...ctx.themeVars, ...extra]);
100
+ return { ...ctx, ...patch, themeVars, hasTheme: (n) => themeVars.has(n) };
101
+ }
102
+
103
+ /**
104
+ * @param {object} [options]
105
+ * @param {string|string[]} [options.content] globs of files that contain class names (default: detected per framework)
106
+ * @param {string} [options.cwd]
107
+ * @param {true|"tokens"|false} [options.core=true] true: Omnia core on demand; "tokens": only color tokens + base; false: you load Omnia yourself
108
+ * @param {boolean} [options.preflight=true] include the base reset
109
+ * @param {boolean} [options.layers=true] wrap output in cascade layers (omnia, theme, base, components, utilities)
110
+ * @param {3|4} [options.tailwind] naming flavor (default: detected from package.json / CSS)
111
+ * @param {"class"|"media"} [options.darkMode]
112
+ * @param {"omnia"|"tailwind"} [options.breakpoints="omnia"]
113
+ * @param {"nearest"|"keep"} [options.arbitraryColors="nearest"]
114
+ * @param {string[]} [options.safelist] extra class names to always emit
115
+ */
116
+ export default function omniaMigrate(options = {}) {
117
+ return {
118
+ postcssPlugin: "omniacss-migrate",
119
+ async Once(root, { result }) {
120
+ const twImports = [], twDirectives = [], applies = [], themes = [], sources = [], misc = [];
121
+ root.walkAtRules((at) => {
122
+ const n = at.name;
123
+ if (n === "import" && TW_IMPORT.test(at.params.trim())) twImports.push(at);
124
+ else if (n === "tailwind") twDirectives.push(at);
125
+ else if (n === "apply") applies.push(at);
126
+ else if (n === "theme") themes.push(at);
127
+ else if (n === "source") sources.push(at);
128
+ else if (["plugin", "config", "reference", "custom-variant", "utility", "variant"].includes(n)) misc.push(at);
129
+ });
130
+ const hasEntry = twImports.length || twDirectives.length;
131
+ if (!hasEntry && !applies.length && !themes.length && !misc.length) return;
132
+
133
+ const cwd = options.cwd || process.cwd();
134
+ const { ctx: baseCtx, info, themeDecls: configTheme } = await getProject(cwd, options);
135
+ const cssDir = result.opts.from ? path.dirname(result.opts.from) : cwd;
136
+
137
+ // --- per-file theme / variants -------------------------------------------------------------------------
138
+ const themeDecls = [...configTheme];
139
+ const keyframes = [];
140
+ for (const at of themes) {
141
+ at.walkDecls((decl) => { if (decl.prop.startsWith("--") && !/^initial$/.test(decl.value)) themeDecls.push([decl.prop, decl.value]); });
142
+ at.walkAtRules("keyframes", (k) => keyframes.push(k.toString()));
143
+ }
144
+ let patch = {};
145
+ const customVariants = {};
146
+ for (const at of misc.filter((a) => a.name === "custom-variant")) {
147
+ const m = at.params.match(/^([\w-]+)\s*\(([\s\S]*)\)$/);
148
+ if (!m) continue;
149
+ if (m[1] === "dark") {
150
+ const attr = m[2].match(/\[data-[\w-]+=["']?[\w-]+["']?\]/);
151
+ const cls = m[2].match(/\.([\w-]+)/);
152
+ if (m[2].includes("prefers-color-scheme")) patch.darkMode = "media";
153
+ else { patch.darkMode = "class"; patch.darkSelector = attr ? attr[0] : cls ? `.${cls[1]}` : ".dark"; }
154
+ } else customVariants[m[1]] = m[2];
155
+ }
156
+ const ctx = withTheme(baseCtx, themeDecls.map(([k]) => k), { ...patch, customVariants, ...(options.important ? { important: true } : {}) });
157
+
158
+ // --- content ---------------------------------------------------------------------------------------------
159
+ let compatCss = "", exclude = new Set(), candidates = new Set(), files = [];
160
+ if (hasEntry) {
161
+ const globs = [];
162
+ for (const at of sources) {
163
+ const p = at.params.trim();
164
+ const inline = p.match(/^inline\(\s*["'](.+)["']\s*\)$/);
165
+ if (inline) { expandBraces(inline[1]).flatMap((s) => s.split(/\s+/)).forEach((t) => t && candidates.add(t)); continue; }
166
+ if (/^not\b/.test(p)) continue;
167
+ const g = p.replace(/^["']|["']$/g, "");
168
+ globs.push(path.relative(cwd, path.resolve(cssDir, g)).replace(/\\/g, "/"));
169
+ }
170
+ let content = options.content ?? null;
171
+ if (!content) content = [...await defaultContent(cwd), ...globs];
172
+ else content = [].concat(content).concat(globs);
173
+ const collected = await collectTokens(content, cwd);
174
+ files = collected.files;
175
+ for (const t of collected.tokens) candidates.add(t);
176
+ for (const t of options.safelist || []) candidates.add(t);
177
+
178
+ const resolved = [];
179
+ const manifest = options.manifest === false ? {} : JSON.parse(await fs.readFile(path.join(cwd, "omniacss-migrate.manifest.json"), "utf8").catch(() => "{}"));
180
+ const native = new Set(manifest.native || []);
181
+ for (const t of candidates) {
182
+ if (native.has(t)) continue; // already rewritten to a native OmniaCSS class by the codemod
183
+ const r = resolveToken(t, ctx);
184
+ if (r) { resolved.push(r); exclude.add(t); }
185
+ }
186
+ compatCss = serialize(resolved);
187
+ }
188
+
189
+ // --- build replacement nodes -------------------------------------------------------------------------------
190
+ const layers = options.layers !== false;
191
+ const wrap = (name, css) => (layers && css.trim() ? `@layer ${name} {\n${css}\n}` : css);
192
+ const pieces = {};
193
+ pieces.order = layers ? "@layer omnia, theme, base, components, utilities;" : "";
194
+ const themeBody = themeDecls.map(([k, v]) => ` ${k}: ${v};`).join("\n");
195
+ pieces.theme = wrap("theme", (themeBody ? `:root, :host {\n${themeBody}\n}\n` : "") + keyframes.join("\n"));
196
+ pieces.preflight = options.preflight === false ? "" : wrap("base", preflight({ borderColor: ctx.v4 ? "currentColor" : `var(${COLOR_MAP["gray-200"]})` }));
197
+ pieces.core = options.core === false ? "" : await buildCoreCss({ candidates, compatCss, exclude, cwd, layer: layers ? "omnia" : false, mode: options.core === "tokens" ? "tokens" : true });
198
+ pieces.utilities = wrap("utilities", compatCss);
199
+ const all = [pieces.order, pieces.theme, pieces.preflight, pieces.core, pieces.utilities];
200
+ const mk = (css) => postcss.parse(css, { from: undefined }).nodes;
201
+ const place = (at, css) => { const nodes = css ? mk(css) : []; if (nodes.length) at.replaceWith(nodes); else at.remove(); };
202
+
203
+ for (const at of twImports) {
204
+ const sub = at.params.trim().match(TW_IMPORT)?.[1];
205
+ if (!sub) place(at, all.filter(Boolean).join("\n"));
206
+ else if (sub === "theme") place(at, [pieces.order, pieces.theme].filter(Boolean).join("\n"));
207
+ else if (sub === "preflight" || sub === "base") place(at, [pieces.preflight, pieces.core].filter(Boolean).join("\n"));
208
+ else if (sub === "utilities") place(at, pieces.utilities);
209
+ else at.remove();
210
+ }
211
+ for (const at of twDirectives) {
212
+ const what = at.params.trim();
213
+ if (what === "base") place(at, [pieces.order, pieces.theme, pieces.preflight, pieces.core].filter(Boolean).join("\n"));
214
+ else if (what === "utilities") place(at, pieces.utilities);
215
+ else at.remove();
216
+ }
217
+ // `@import` must stay in front of all rules (your own imports used to follow the framework import)
218
+ if (hasEntry) {
219
+ const imports = [];
220
+ root.each((n) => { if (n.type === "atrule" && n.name === "import") imports.push(n); });
221
+ // the layer order statement goes first of all, so layers used inside imported files keep the intended order
222
+ const order = [];
223
+ root.each((n) => { if (n.type === "atrule" && n.name === "layer" && !n.nodes && /^omnia,\s*theme,/.test(n.params)) order.push(n); });
224
+ const top = [...order, ...imports];
225
+ if (top.length) {
226
+ top.forEach((n) => n.remove());
227
+ if (root.first?.type === "atrule" && root.first.name === "charset") root.first.after(top); else root.prepend(top);
228
+ }
229
+ }
230
+ for (const at of themes) at.remove();
231
+ for (const at of sources) at.remove();
232
+ for (const at of misc) {
233
+ if (at.name === "variant") expandVariant(at, ctx);
234
+ else if (at.name === "utility") expandUtility(at);
235
+ else at.remove();
236
+ }
237
+
238
+ // --- @apply and theme() ---------------------------------------------------------------------------------------------------
239
+ const classIndex = new Map();
240
+ root.walkRules((r) => { if (/^\.[\w-]+$/.test(r.selector)) classIndex.set(r.selector.slice(1), r); });
241
+ const usedKeyframes = new Set();
242
+ for (const at of applies) expandApply(at, ctx, classIndex, usedKeyframes, result);
243
+ if (usedKeyframes.size) root.append(mk([...usedKeyframes].map((k) => KEYFRAMES[k]).filter(Boolean).join("\n")));
244
+ root.walkDecls((decl) => { const v = replaceThemeFunctions(decl.value, ctx); if (v !== decl.value) decl.value = v; });
245
+
246
+ for (const file of files) result.messages.push({ type: "dependency", plugin: "omniacss-migrate", file: path.resolve(file), parent: result.opts.from });
247
+ },
248
+ };
249
+ }
250
+ omniaMigrate.postcss = true;
251
+
252
+ function expandUtility(at) {
253
+ if (/--value|--modifier/.test(at.toString())) { at.remove(); return; }
254
+ const rule = postcss.rule({ selector: `.${at.params.trim()}` });
255
+ at.each((n) => rule.append(n.clone()));
256
+ at.replaceWith(rule);
257
+ }
258
+
259
+ /** `.card { @variant dark { … } }` -> nested rule / at-rule */
260
+ function expandVariant(at, ctx) {
261
+ const st = { sels: ["&"], media: [], prelude: [], rank: 0 };
262
+ if (!applyVariant(at.params.trim(), st, ctx)) { at.remove(); return; }
263
+ let content = (at.nodes || []).map((n) => n.clone());
264
+ if (st.sels[0] !== "&") { const r = postcss.rule({ selector: st.sels.join(", ") }); r.append(content); content = [r]; }
265
+ for (let i = st.media.length - 1; i >= 0; i--) {
266
+ const m = st.media[i].match(/^@(\S+)\s*(.*)$/);
267
+ const a = postcss.atRule({ name: m[1], params: m[2] }); a.append(content); content = [a];
268
+ }
269
+ if (content.length) at.replaceWith(content); else at.remove();
270
+ }
271
+
272
+ function expandApply(at, ctx, classIndex, usedKeyframes, result) {
273
+ const parent = at.parent;
274
+ if (!parent || parent.type !== "rule") { at.warn(result, "@apply outside a rule is not supported"); at.remove(); return; }
275
+ let params = at.params;
276
+ let important = false;
277
+ if (/\s*!important\s*$/.test(params)) { important = true; params = params.replace(/\s*!important\s*$/, ""); }
278
+ const actx = important ? { ...ctx, important: true } : ctx;
279
+ const tokens = params.split(/\s+/).filter(Boolean);
280
+ const inline = [];
281
+ const extras = [];
282
+ for (const token of tokens) {
283
+ const r = resolveToken(token, actx, { selector: "&" });
284
+ if (!r) {
285
+ const src = classIndex.get(token);
286
+ if (src && src !== parent) { src.each((n) => { if (n.type === "decl") inline.push([n.prop, n.value, n.important]); }); continue; }
287
+ at.warn(result, `@apply: cannot resolve "${token}"`);
288
+ continue;
289
+ }
290
+ r.keyframes.forEach((k) => usedKeyframes.add(k));
291
+ for (const rule of r.rules) {
292
+ if (rule.selectors.length === 1 && rule.selectors[0] === "&" && !rule.media.length) inline.push(...rule.decls.map(([p, v]) => [p, v.replace(/ !important$/, ""), / !important$/.test(v)]));
293
+ else extras.push(rule);
294
+ }
295
+ }
296
+ const nodes = inline.map(([prop, value, imp]) => postcss.decl({ prop, value, important: !!imp }));
297
+ if (nodes.length) at.replaceWith(nodes); else at.remove();
298
+ if (!extras.length) return;
299
+ const flat = parent.parent && parent.parent.type !== "rule";
300
+ let anchor = parent;
301
+ for (const x of extras) {
302
+ const sels = flat ? x.selectors.flatMap((s) => parent.selectors.map((ps) => s.replace(/&/g, ps))) : x.selectors;
303
+ let css = `${sels.join(",\n")} {\n${x.decls.map(([p, v]) => ` ${p}: ${v};`).join("\n")}\n}`;
304
+ for (let i = x.media.length - 1; i >= 0; i--) css = `${x.media[i]} {\n${css}\n}`;
305
+ const parsed = postcss.parse(css, { from: undefined }).nodes;
306
+ if (flat) { for (const n of parsed) { anchor.after(n); anchor = n; } } else parent.append(parsed);
307
+ }
308
+ }
@@ -0,0 +1,52 @@
1
+ // File: packages/migrate/src/preflight.mjs
2
+ // Base reset that replaces the CSS reset of a utility framework, so migrated markup renders the same.
3
+ // Uses Omnia palette tokens only. Emitted inside `@layer base`.
4
+ // Author: Serge Cools | Framework: OmniaCSS
5
+
6
+ /** @param {{ borderColor?: string }} [o] */
7
+ export function preflight(o = {}) {
8
+ const border = o.borderColor || "currentColor";
9
+ return `*, ::before, ::after, ::backdrop, ::file-selector-button {
10
+ box-sizing: border-box;
11
+ margin: 0;
12
+ padding: 0;
13
+ border: 0 solid;
14
+ }
15
+ html, :host {
16
+ line-height: 1.5;
17
+ -webkit-text-size-adjust: 100%;
18
+ tab-size: 4;
19
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
20
+ -webkit-tap-highlight-color: transparent;
21
+ }
22
+ *, ::before, ::after, ::backdrop, ::file-selector-button { border-color: ${border}; }
23
+ hr { height: 0; color: inherit; border-top-width: 1px; }
24
+ abbr:where([title]) { text-decoration: underline dotted; }
25
+ h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; }
26
+ a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; }
27
+ b, strong { font-weight: bolder; }
28
+ code, kbd, samp, pre { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-size: 1em; }
29
+ small { font-size: 80%; }
30
+ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
31
+ sub { bottom: -0.25em; }
32
+ sup { top: -0.5em; }
33
+ table { text-indent: 0; border-color: inherit; border-collapse: collapse; }
34
+ :-moz-focusring { outline: auto; }
35
+ progress { vertical-align: baseline; }
36
+ summary { display: list-item; }
37
+ ol, ul, menu { list-style: none; }
38
+ img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; }
39
+ img, video { max-width: 100%; height: auto; }
40
+ button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; }
41
+ :where(select:is([multiple], [size])) optgroup { font-weight: bolder; }
42
+ ::file-selector-button { margin-inline-end: 4px; }
43
+ ::placeholder { opacity: 1; color: color-mix(in oklab, currentColor 50%, transparent); }
44
+ textarea { resize: vertical; }
45
+ ::-webkit-search-decoration { -webkit-appearance: none; }
46
+ ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; }
47
+ :-moz-ui-invalid { box-shadow: none; }
48
+ button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { appearance: button; }
49
+ [hidden]:where(:not([hidden="until-found"])) { display: none !important; }
50
+ button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; }
51
+ `;
52
+ }
@@ -0,0 +1,194 @@
1
+ // File: packages/migrate/src/project.mjs
2
+ // Read a project's own Tailwind setup (CSS @theme blocks, tailwind.config.*, package.json) into a resolver context.
3
+ // Author: Serge Cools | Framework: OmniaCSS
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import vm from "node:vm";
9
+ import { createContext } from "./resolve.mjs";
10
+ import { nearestToken, parseColor } from "./values.mjs";
11
+
12
+ const IGNORE = new Set(["node_modules", "vendor", ".git", "storage", "dist", "build", "public", "bootstrap", ".next", ".nuxt", ".output"]);
13
+
14
+ export async function findFiles(dir, test, max = 400) {
15
+ const out = [];
16
+ async function rec(d, depth) {
17
+ if (depth > 8 || out.length >= max) return;
18
+ let entries;
19
+ try { entries = await fs.readdir(d, { withFileTypes: true }); } catch { return; }
20
+ for (const e of entries) {
21
+ if (e.isDirectory()) { if (!IGNORE.has(e.name)) await rec(path.join(d, e.name), depth + 1); } else if (test(e.name)) out.push(path.join(d, e.name));
22
+ }
23
+ }
24
+ await rec(dir, 0);
25
+ return out;
26
+ }
27
+
28
+ /** Extract `--x: value` pairs from every `@theme { … }` block of a CSS string. */
29
+ export function parseThemeBlocks(css) {
30
+ const vars = new Map();
31
+ const re = /@theme\b[^{]*\{/g;
32
+ let m;
33
+ while ((m = re.exec(css))) {
34
+ let depth = 1, i = re.lastIndex;
35
+ const start = i;
36
+ while (i < css.length && depth) { if (css[i] === "{") depth++; else if (css[i] === "}") depth--; i++; }
37
+ const body = css.slice(start, i - 1).replace(/\/\*[\s\S]*?\*\//g, "");
38
+ // only top-level declarations (skip nested @keyframes blocks)
39
+ let flat = "", d = 0;
40
+ for (const ch of body) { if (ch === "{") d++; else if (ch === "}") d--; else if (d === 0) flat += ch; }
41
+ for (const decl of flat.split(";")) {
42
+ const k = decl.indexOf(":");
43
+ if (k < 0) continue;
44
+ const name = decl.slice(0, k).trim();
45
+ if (name.startsWith("--")) vars.set(name, decl.slice(k + 1).trim());
46
+ }
47
+ re.lastIndex = i;
48
+ }
49
+ return vars;
50
+ }
51
+
52
+ /** Load tailwind.config.{js,cjs,mjs,ts}; returns { config, file } or null. */
53
+ export async function loadTailwindConfig(dir) {
54
+ for (const name of ["tailwind.config.js", "tailwind.config.mjs", "tailwind.config.cjs", "tailwind.config.ts"]) {
55
+ const file = path.join(dir, name);
56
+ const text = await fs.readFile(file, "utf8").catch(() => null);
57
+ if (text == null) continue;
58
+ if (!name.endsWith(".ts")) {
59
+ try {
60
+ const mod = await import(pathToFileURL(file).href + "?t=" + Date.now());
61
+ return { config: mod.default ?? mod, file };
62
+ } catch { /* fall back to a sandboxed evaluation below */ }
63
+ }
64
+ try {
65
+ const body = text.replace(/^\s*import\s+[^;]*;?\s*$/gm, "").replace(/export\s+default\s+/, "module.exports = ").replace(/\/\*\*[\s\S]*?\*\//g, "");
66
+ const mod = { exports: {} };
67
+ const stub = new Proxy(function () { return stub; }, { get: (_, k) => (k === "default" ? stub : k === Symbol.toPrimitive ? () => "" : stub), apply: () => stub });
68
+ vm.runInNewContext(body, { module: mod, exports: mod.exports, require: () => stub, process, console }, { timeout: 2000 });
69
+ return { config: mod.exports.default ?? mod.exports, file };
70
+ } catch { return { config: null, file, error: true }; }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const flatColors = (obj, prefix = "") => {
76
+ const out = {};
77
+ for (const [k, v] of Object.entries(obj || {})) {
78
+ const name = k === "DEFAULT" ? prefix : prefix ? `${prefix}-${k}` : k;
79
+ if (typeof v === "string") out[name] = v;
80
+ else if (v && typeof v === "object") Object.assign(out, flatColors(v, name));
81
+ }
82
+ return out;
83
+ };
84
+
85
+ /**
86
+ * Turn a tailwind.config theme into @theme-style declarations.
87
+ * Colors given as literals are mapped to the nearest Omnia palette token; var()/hsl(var()) stay as written.
88
+ * @returns {{ decls: Array<[string,string]>, screens: Record<string,string>, notes: string[] }}
89
+ */
90
+ export function themeFromConfig(config, ctx, { keepColors = false } = {}) {
91
+ const decls = [], notes = [], screens = {};
92
+ const theme = config?.theme || {};
93
+ const ext = theme.extend || {};
94
+ const colorSets = [theme.colors, ext.colors];
95
+ const stdKeys = new Set(["inherit", "current", "transparent", "black", "white"]);
96
+ for (const set of colorSets) {
97
+ if (!set || typeof set !== "object") continue;
98
+ for (const [name, raw] of Object.entries(flatColors(set))) {
99
+ if (typeof raw !== "string") continue;
100
+ let val = raw.replace(/<alpha-value>/g, "1");
101
+ if (stdKeys.has(name) && set === theme.colors) continue;
102
+ if (/^(var\(|hsl\(var|rgb\(var|hsla\(var)/i.test(val) || /var\(/.test(val)) { decls.push([`--color-${name}`, val]); continue; }
103
+ const parsed = parseColor(val);
104
+ if (parsed && !keepColors) {
105
+ const n = nearestToken(val, ctx);
106
+ if (n) { decls.push([`--color-${name}`, `var(${n.token})`]); notes.push(`color ${name}: ${raw} -> ${n.token}`); continue; }
107
+ }
108
+ decls.push([`--color-${name}`, val]);
109
+ }
110
+ }
111
+ const scalar = (obj, prefix, map = (v) => v) => {
112
+ for (const [k, v] of Object.entries(obj || {})) {
113
+ if (typeof v === "string" || typeof v === "number") decls.push([`--${prefix}-${k === "DEFAULT" ? "DEFAULT" : k}`, String(map(v))]);
114
+ }
115
+ };
116
+ scalar(ext.borderRadius || theme.borderRadius, "radius");
117
+ scalar(ext.spacing, "spacing");
118
+ scalar(ext.boxShadow, "shadow");
119
+ for (const [k, v] of Object.entries({ ...(theme.fontFamily || {}), ...(ext.fontFamily || {}) })) {
120
+ if (["sans", "serif", "mono"].includes(k) && !ext.fontFamily?.[k]) continue;
121
+ decls.push([`--font-${k}`, Array.isArray(v) ? v.map((f) => (/[\s\d]/.test(f) && !/^["']/.test(f) ? `"${f}"` : f)).join(", ") : String(v)]);
122
+ }
123
+ for (const [k, v] of Object.entries({ ...(theme.screens || {}), ...(ext.screens || {}) })) {
124
+ if (typeof v === "string") screens[k] = v.endsWith("px") ? `${+v.slice(0, -2) / 16}rem` : v;
125
+ }
126
+ if (ext.keyframes || ext.animation) notes.push("custom keyframes/animation in tailwind.config are not migrated (add them to your CSS)");
127
+ if (config?.plugins?.length) notes.push(`${config.plugins.length} tailwind plugin(s) in config: only tailwindcss-animate style classes are emulated`);
128
+ return { decls, screens, notes };
129
+ }
130
+
131
+ /**
132
+ * Build resolver options for a project directory.
133
+ * @param {string} dir project root
134
+ * @param {object} [overrides] options for createContext()
135
+ */
136
+ export async function loadProject(dir, overrides = {}) {
137
+ const info = { dir, tailwindVersion: null, themeVars: new Map(), cssFiles: [], configFile: null, notes: [] };
138
+ let pkg = {};
139
+ try { pkg = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")); } catch { /* no package.json */ }
140
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
141
+ if (deps.tailwindcss) info.tailwindVersion = parseInt(String(deps.tailwindcss).replace(/[^\d.]/g, ""), 10) || null;
142
+ if (!info.tailwindVersion && (deps["@tailwindcss/vite"] || deps["@tailwindcss/postcss"])) info.tailwindVersion = 4;
143
+
144
+ info.cssFiles = await findFiles(dir, (n) => n.endsWith(".css") || n.endsWith(".scss"));
145
+ let darkSelector = null, darkMode = null;
146
+ for (const f of info.cssFiles) {
147
+ const css = await fs.readFile(f, "utf8").catch(() => "");
148
+ for (const [k, v] of parseThemeBlocks(css)) info.themeVars.set(k, v);
149
+ if (/@import\s+["']tailwindcss/.test(css) || /@theme\b/.test(css)) info.tailwindVersion ||= 4;
150
+ if (/@tailwind\s+(base|utilities|components)/.test(css)) info.tailwindVersion ||= 3;
151
+ const cv = css.match(/@custom-variant\s+dark\s*\(([^;]*)\)\s*;/);
152
+ if (cv) {
153
+ darkMode = "class";
154
+ const sel = cv[1].match(/\.([\w-]+)/);
155
+ const attr = cv[1].match(/\[data-[\w-]+=["']?[\w-]+["']?\]/);
156
+ darkSelector = attr ? attr[0] : sel ? `.${sel[1]}` : ".dark";
157
+ }
158
+ }
159
+
160
+ const loaded = await loadTailwindConfig(dir);
161
+ let cfg = null;
162
+ if (loaded) {
163
+ info.configFile = loaded.file;
164
+ cfg = loaded.config;
165
+ info.tailwindVersion ||= 3;
166
+ if (loaded.error) info.notes.push("tailwind.config could not be evaluated; theme extensions were not read");
167
+ }
168
+ if (cfg) {
169
+ const dm = Array.isArray(cfg.darkMode) ? cfg.darkMode[0] : cfg.darkMode;
170
+ if (dm === "class" || dm === "selector") { darkMode ||= "class"; if (Array.isArray(cfg.darkMode) && cfg.darkMode[1]) darkSelector ||= cfg.darkMode[1]; }
171
+ else if (dm === "media") darkMode ||= "media";
172
+ }
173
+
174
+ const probe = createContext({ ...overrides });
175
+ const fromConfig = cfg ? themeFromConfig(cfg, probe, { keepColors: overrides.keepBrandColors }) : { decls: [], screens: {}, notes: [] };
176
+ info.notes.push(...fromConfig.notes);
177
+ const themeDecls = new Map(fromConfig.decls);
178
+ const themeVars = new Set([...info.themeVars.keys(), ...themeDecls.keys()]);
179
+ const v4 = overrides.v4 ?? (info.tailwindVersion ? info.tailwindVersion >= 4 : true);
180
+ const ctx = createContext({
181
+ v4,
182
+ darkMode: overrides.darkMode || darkMode || (v4 ? "media" : "media"),
183
+ darkSelector: overrides.darkSelector || darkSelector || ".dark",
184
+ themeVars,
185
+ screens: { ...fromConfig.screens, ...(overrides.screens || {}) },
186
+ container: cfg?.theme?.container ? { center: !!cfg.theme.container.center, padding: typeof cfg.theme.container.padding === "string" ? cfg.theme.container.padding : undefined } : {},
187
+ prefix: cfg?.prefix || overrides.prefix,
188
+ important: cfg?.important === true,
189
+ breakpoints: overrides.breakpoints,
190
+ arbitraryColors: overrides.arbitraryColors,
191
+ tokensFile: overrides.tokensFile,
192
+ });
193
+ return { ctx, info, themeDecls, themeCss: info.themeVars };
194
+ }