@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/LICENSE +21 -21
- package/README.md +129 -129
- package/bin/omniacss-migrate.mjs +8 -8
- package/package.json +61 -61
- package/scripts/build-color-map.mjs +31 -31
- package/scripts/extract-palette.mjs +13 -13
- package/scripts/tailwind-palette.json +290 -290
- package/src/bootstrap/compat.mjs +72 -72
- package/src/bootstrap/convert.mjs +102 -102
- package/src/bootstrap/migrate.mjs +259 -259
- package/src/bootstrap/rules.mjs +212 -212
- package/src/cli.mjs +139 -139
- package/src/codemod.mjs +82 -82
- package/src/core.mjs +67 -67
- package/src/data/colors.json +290 -290
- package/src/extract.mjs +91 -91
- package/src/index.mjs +12 -12
- package/src/native.mjs +72 -72
- package/src/postcss.mjs +308 -308
- package/src/preflight.mjs +52 -52
- package/src/project.mjs +194 -194
- package/src/resolve.mjs +220 -220
- package/src/scan.mjs +106 -106
- package/src/setup.mjs +205 -205
- package/src/utilities.mjs +713 -713
- package/src/values.mjs +184 -184
- package/src/variants.mjs +189 -189
- package/src/vite.mjs +81 -81
package/src/preflight.mjs
CHANGED
|
@@ -1,52 +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
|
-
}
|
|
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
|
+
}
|
package/src/project.mjs
CHANGED
|
@@ -1,194 +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
|
-
}
|
|
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
|
+
}
|