@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/postcss.mjs CHANGED
@@ -1,308 +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
- }
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
+ }