@birdycom/omniacss-migrate 0.12.0 → 0.12.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/README.md CHANGED
@@ -31,9 +31,10 @@ export default defineConfig({ plugins: [laravel({ input: ["resources/css/app.css
31
31
  ```
32
32
 
33
33
  ```css
34
- /* resources/css/app.css: unchanged. Both forms are understood: */
34
+ /* resources/css/app.css: the entry, under our own name (setup --write renames @import "tailwindcss" to it): */
35
+ @import "@birdycom/omniacss-migrate";
36
+ /* the Tailwind forms keep working too: */
35
37
  @import "tailwindcss";
36
- /* or */
37
38
  @tailwind base; @tailwind components; @tailwind utilities;
38
39
  ```
39
40
 
@@ -47,10 +48,10 @@ export default { plugins: [omniaMigrate({ content: ["resources/views/**/*.blade.
47
48
 
48
49
  ## What the plugin does
49
50
 
50
- - Replaces `@import "tailwindcss"` / `@tailwind base|components|utilities` with Omnia core (tokens + the Omnia classes you use), a small base reset and the compat CSS for the classes it finds in your content.
51
+ - Replaces `@import "@birdycom/omniacss-migrate"` / `@import "tailwindcss"` / `@tailwind base|components|utilities` with Omnia core (tokens + the Omnia classes you use), a small base reset and the compat CSS for the classes it finds in your content.
51
52
  - Expands `@apply` (with variants, `!important`, and your own classes), `theme(colors.red.500)`, `--spacing(4)`, `@theme`, `@custom-variant`, `@variant`, `@source`, `@source inline("…")`; ignores `@plugin` and `@config`.
52
53
  - Reads `tailwind.config.{js,cjs,mjs}` (`darkMode`, `theme.colors`/`extend.colors`, `fontFamily`, `borderRadius`, `spacing`, `boxShadow`, `screens`, `container`, `prefix`, `important`) and your `@theme` blocks.
53
- - Output is wrapped in cascade layers `omnia, theme, base, components, utilities`.
54
+ - Output is wrapped in cascade layers `omnia, theme, base, components, utilities`: Omnia's reset and tokens lowest; the Omnia classes (for example what the codemod wrote) and the Tailwind-style names together in `utilities`, sorted the Tailwind way (variants after the base class, shorthands before longhands, the class with more properties first). So an Omnia class beats every reset and your own `@layer components`, and wins or loses against a Tailwind-style class in the same `class` exactly as before the codemod.
54
55
 
55
56
  Class coverage: layout, flex/grid, spacing, sizing, typography, colors (text, bg, border, ring, outline, fill, stroke, divide, accent, caret, decoration, gradients `from-`/`via-`/`to-`), borders, radius, shadow, ring, opacity, filters and backdrop filters, transitions, animations, transforms, display, position, z-index, overflow, cursor, scroll snap, `tailwindcss-animate` classes, arbitrary values (`w-[37px]`, `bg-(--brand)`, `[mask-type:luminance]`), opacity modifiers (`bg-red-500/50`), negatives (`-mt-2`), `!important`, and variants `hover: focus: active: disabled: first: odd: before: after: placeholder: file: marker: selection:`, `sm: md: lg: xl: 2xl: max-md: min-[…]:`, `@md:` container queries, `dark:`, `group-*`, `peer-*`, `data-[…]:`, `aria-*:`, `has-[…]:`, `not-*`, `in-*`, `supports-[…]:`, `*:`, arbitrary variants `[&>svg]:`, `motion-safe:`, `print:`, `rtl:`.
56
57
 
@@ -81,7 +82,7 @@ omniacss-migrate css "md:hover:bg-red-500/50" print the CSS for one class
81
82
 
82
83
  ## Codemod (optional)
83
84
 
84
- `codemod` rewrites `class`/`className`/`cn()`/`cva()` strings to **native** OmniaCSS names when an Omnia class has exactly the same declarations (for example `items-center` becomes `align-items-center`, `p-4` becomes `p-1`). Everything else is left alone and listed. After `--write` the file `omniacss-migrate.manifest.json` tells the compat layer which names are now native, so `p-1` (Omnia, `1rem`) is never confused with the old `p-1` (`0.25rem`). Run it over the whole project, not a single folder.
85
+ `codemod` rewrites `class`/`className`/`cn()`/`cva()` strings to **native** OmniaCSS names when an Omnia class has exactly the same declarations (for example `items-center` becomes `align-items-center`, `p-4` becomes `p-1`). Everything else is left alone and listed. After `--write` the file `omniacss-migrate.manifest.json` tells the compat layer which names are now native, so `p-1` (Omnia, `1rem`) is never confused with the old `p-1` (`0.25rem`). Run it over the whole project, not a single folder. Tests and scripts (`tests/`, `scripts/`, `*.test.*`, `*.spec.*` …) are skipped and listed, because they often assert class names as data; add `--include-tests` to rewrite them too.
85
86
 
86
87
  ## From Bootstrap
87
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@birdycom/omniacss-migrate",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Switch a Laravel / Vue / React project from Tailwind CSS to OmniaCSS and keep your class names (PostCSS + Vite plugin, scan, setup, codemod), or convert a Bootstrap 3/4/5 site to OmniaCSS + OmniaUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,10 @@
32
32
  "omniacss-migrate": "./bin/omniacss-migrate.mjs"
33
33
  },
34
34
  "exports": {
35
- ".": "./src/index.mjs",
35
+ ".": {
36
+ "style": "./src/entry.css",
37
+ "default": "./src/index.mjs"
38
+ },
36
39
  "./postcss": "./src/postcss.mjs",
37
40
  "./vite": "./src/vite.mjs",
38
41
  "./cli": "./src/cli.mjs",
@@ -51,7 +54,7 @@
51
54
  "build:colors": "node scripts/build-color-map.mjs"
52
55
  },
53
56
  "dependencies": {
54
- "@birdycom/omniacss": "^0.12.0",
57
+ "@birdycom/omniacss": "^0.12.2",
55
58
  "postcss": "^8.5.3"
56
59
  },
57
60
  "publishConfig": {
package/src/cli.mjs CHANGED
@@ -26,6 +26,7 @@ Bootstrap 3/4/5 -> OmniaCSS + OmniaUI
26
26
 
27
27
  Options
28
28
  --write apply changes (setup, codemod)
29
+ --include-tests (codemod) also rewrite tests/, scripts/, *.test.* and *.spec.* files (skipped by default)
29
30
  --top <n> rows in the unsupported list (scan, codemod; default 40)
30
31
  --json machine readable output (scan)
31
32
  --breakpoints <omnia|tailwind> sm/md/lg values (default omnia)
@@ -44,7 +45,7 @@ function parse(args) {
44
45
  options: {
45
46
  write: { type: "boolean" }, top: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
46
47
  breakpoints: { type: "string" }, dark: { type: "string" }, tailwind: { type: "string" }, "keep-brand-colors": { type: "boolean" },
47
- content: { type: "string", short: "c", multiple: true }, output: { type: "string", short: "o" }, backup: { type: "boolean" },
48
+ content: { type: "string", short: "c", multiple: true }, output: { type: "string", short: "o" }, backup: { type: "boolean" }, "include-tests": { type: "boolean" },
48
49
  bs: { type: "string" }, compat: { type: "string" }, "omnia-href": { type: "string" }, "omniaui-href": { type: "string" }, "compat-href": { type: "string" },
49
50
  },
50
51
  });
@@ -102,7 +103,7 @@ export async function run(args = process.argv.slice(2)) {
102
103
 
103
104
  if (command === "codemod") {
104
105
  const { ctx } = await loadProject(dir, { ...ctxOptions(), breakpoints: values.breakpoints || "omnia" });
105
- const report = await runCodemod(dir, { ctx, write: !!values.write, cwd: dir });
106
+ const report = await runCodemod(dir, { ctx, write: !!values.write, cwd: dir, includeTests: !!values["include-tests"] });
106
107
  console.log(formatCodemod(report, { top }));
107
108
  return;
108
109
  }
package/src/codemod.mjs CHANGED
@@ -11,6 +11,11 @@ import { loadOmniaIndex, nativeFor } from "./native.mjs";
11
11
 
12
12
  export const MANIFEST = "omniacss-migrate.manifest.json";
13
13
 
14
+ // Tests and scripts often assert or build class names as data: rewriting them breaks the tests. Skipped unless asked.
15
+ const TEST_DIRS = /[\\/](tests?|__tests__|specs?|e2e|cypress|playwright|scripts)[\\/]/i;
16
+ const TEST_FILES = /\.(test|spec|cy)\.[cm]?[jt]sx?$|Test\.php$/;
17
+ const isTestOrScript = (file, dir) => TEST_DIRS.test(path.sep + path.relative(dir, file)) || TEST_FILES.test(file);
18
+
14
19
  /**
15
20
  * @param {string} dir
16
21
  * @param {{ ctx: object, write?: boolean, extensions?: string[], cwd?: string }} o
@@ -21,8 +26,10 @@ export async function runCodemod(dir, o) {
21
26
  const kind = makeKind(ctx);
22
27
  const memo = new Map();
23
28
  const map = (t) => { if (!memo.has(t)) memo.set(t, nativeFor(t, ctx, index)); return memo.get(t); };
24
- const files = await walk(dir, { extensions: o.extensions || DEFAULT_EXTENSIONS });
25
- const report = { dir, write, files: [], total: 0, rewritten: 0, unchanged: 0, leftovers: new Map(), collisions: new Map(), mapping: new Map() };
29
+ const all = await walk(dir, { extensions: o.extensions || DEFAULT_EXTENSIONS });
30
+ const skipped = o.includeTests ? [] : all.filter((f) => isTestOrScript(f, dir));
31
+ const files = o.includeTests ? all : all.filter((f) => !isTestOrScript(f, dir));
32
+ const report = { dir, write, files: [], skipped, total: 0, rewritten: 0, unchanged: 0, leftovers: new Map(), collisions: new Map(), mapping: new Map() };
26
33
  for (const file of files) {
27
34
  let text;
28
35
  try { text = await fs.readFile(file, "utf8"); } catch { continue; }
@@ -71,6 +78,11 @@ export function formatCodemod(r, { top = 25 } = {}) {
71
78
  lines.push(`${r.write ? "Rewrote" : "Dry run, would rewrite"} ${r.rewritten} of ${r.total} class occurrences (${pct}%) in ${r.files.length} files under ${r.dir}`);
72
79
  for (const f of r.files.slice(0, 15)) lines.push(` ${f.file} (${f.changes}) e.g. ${f.sample.slice(0, 3).join(", ")}`);
73
80
  if (r.files.length > 15) lines.push(` ... and ${r.files.length - 15} more files`);
81
+ if (r.skipped?.length) {
82
+ lines.push("", `Skipped ${r.skipped.length} test and script files (tests/, scripts/, *.test.*, *.spec.* …); add --include-tests to rewrite them too:`);
83
+ for (const f of r.skipped.slice(0, 8)) lines.push(` ${path.relative(r.dir, f)}`);
84
+ if (r.skipped.length > 8) lines.push(` ... and ${r.skipped.length - 8} more`);
85
+ }
74
86
  const left = [...r.leftovers.entries()].sort((a, b) => b[1] - a[1]);
75
87
  lines.push("", `Left as they are (no 1:1 native class): ${left.reduce((n, [, c]) => n + c, 0)} occurrences, ${left.length} unique`);
76
88
  for (const [t, n] of left.slice(0, top)) lines.push(` ${String(n).padStart(5)} ${t}${r.collisions.has(t) ? " (name exists in OmniaCSS with different meaning)" : ""}`);
package/src/core.mjs CHANGED
@@ -6,6 +6,7 @@ import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { createRequire } from "node:module";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import postcss from "postcss";
9
10
 
10
11
  const here = path.dirname(fileURLToPath(import.meta.url));
11
12
  let enginePromise;
@@ -42,9 +43,16 @@ export function omniaClassNames(full) {
42
43
  * Omnia core CSS for a project: tokens (every var(--color-*) the compat output uses) plus the Omnia-native
43
44
  * classes found in the content. Classes the compat layer resolves are excluded, so a Tailwind-style `p-4`
44
45
  * never collides with Omnia's own `p-4`.
45
- * @param {{ candidates: Set<string>, compatCss: string, exclude: Set<string>, cwd?: string, layer?: string|false, mode?: true|"tokens" }} o
46
+ *
47
+ * The result comes in two parts, so each can sit in its own cascade layer:
48
+ * - `base`: what styles elements, not classes (Omnia's reset, tokens, @keyframes, @property, @font-face);
49
+ * - `classes`: the rules of the Omnia classes. These are utilities: after the codemod they replace Tailwind-style
50
+ * classes, so they sort in with those (see nativeRules in postcss.mjs) and beat every reset, as those classes did.
51
+ * `used` is the set of Omnia class names that were kept.
52
+ * @param {{ candidates: Set<string>, compatCss: string, exclude: Set<string>, cwd?: string, mode?: true|"tokens" }} o
53
+ * @returns {Promise<{ base: string, classes: string, used: Set<string> }>}
46
54
  */
47
- export async function buildCoreCss({ candidates, compatCss, exclude, cwd, layer = "omnia", mode = true }) {
55
+ export async function buildCoreCss({ candidates, compatCss, exclude, cwd, mode = true }) {
48
56
  const engine = await loadEngine(cwd);
49
57
  const full = mode === true ? await engine.loadFullCss() : await fs.readFile(path.join(engine.DIST, "oc-colors.css"), "utf8");
50
58
  const set = new Set();
@@ -52,12 +60,41 @@ export async function buildCoreCss({ candidates, compatCss, exclude, cwd, layer
52
60
  const known = omniaClassNames(full);
53
61
  for (const c of candidates) if (known.has(c) && !exclude.has(c)) set.add(c);
54
62
  }
55
- const key = `${layer}|${mode}|${hash(compatCss)}|${[...set].sort().join(" ")}`;
63
+ const key = `${mode}|${hash(compatCss)}|${[...set].sort().join(" ")}`;
56
64
  if (cache.has(key)) return cache.get(key);
57
- const css = engine.generate(full, set, { layer, referencedBy: [compatCss] });
65
+ const parts = { ...splitByClass(engine.generate(full, set, { layer: false, referencedBy: [compatCss] })), used: set };
58
66
  cache.clear();
59
- cache.set(key, css);
60
- return css;
67
+ cache.set(key, parts);
68
+ return parts;
69
+ }
70
+
71
+ /** A selector that targets a class (`.p-1`, `a.link`, `:where(.dark) .x`); attribute values do not count. */
72
+ const HAS_CLASS = (selector) => /\.(?:[A-Za-z_-]|\\)/.test(selector.replace(/\[[^\]]*\]/g, "").replace(/"[^"]*"|'[^']*'/g, ""));
73
+
74
+ /** Splits generated CSS into the element/global part and the class part, keeping the at-rules around each rule. */
75
+ function splitByClass(css) {
76
+ const root = postcss.parse(css);
77
+ const base = root.clone();
78
+ const classes = root.clone();
79
+ base.walkRules((rule) => { if (!inKeyframes(rule) && HAS_CLASS(rule.selector)) rule.remove(); });
80
+ classes.walkRules((rule) => { if (inKeyframes(rule) || !HAS_CLASS(rule.selector)) rule.remove(); });
81
+ // Only element-free at-rules (@keyframes, @property, @font-face …) belong to the base part.
82
+ classes.walkAtRules((at) => { if (/^(keyframes|-\w+-keyframes|property|font-face|font-feature-values|counter-style|import|charset)$/.test(at.name)) at.remove(); });
83
+ for (const part of [base, classes]) pruneEmpty(part);
84
+ return { base: base.toString().trim(), classes: classes.toString().trim() };
85
+ }
86
+
87
+ const inKeyframes = (node) => { for (let p = node.parent; p; p = p.parent) if (p.type === "atrule" && /keyframes$/.test(p.name)) return true; return false; };
88
+
89
+ /** Removes at-rules left without rules (innermost first), keeping statement at-rules such as `@layer a, b;`. */
90
+ function pruneEmpty(root) {
91
+ let again = true;
92
+ while (again) {
93
+ again = false;
94
+ root.walkAtRules((at) => {
95
+ if (at.nodes && !at.nodes.some((n) => n.type !== "comment")) { at.remove(); again = true; }
96
+ });
97
+ }
61
98
  }
62
99
 
63
100
  function hash(s) {
package/src/entry.css ADDED
@@ -0,0 +1,8 @@
1
+ /* File: packages/migrate/src/entry.css
2
+ What `@import "@birdycom/omniacss-migrate";` resolves to when a bundler inlines the import itself (Vite with the
3
+ PostCSS plugin in postcss.config.js). The plugin replaces these directives with OmniaCSS core + compat CSS.
4
+ With the Vite plugin the import never gets this far: it is compiled before Vite looks at it.
5
+ Author: Serge Cools | Framework: OmniaCSS */
6
+ @tailwind base;
7
+ @tailwind components;
8
+ @tailwind utilities;
package/src/index.mjs CHANGED
@@ -8,5 +8,6 @@ export { runCodemod, formatCodemod } from "./codemod.mjs";
8
8
  export { planSetup, applySetup, formatPlan } from "./setup.mjs";
9
9
  export { loadProject, themeFromConfig } from "./project.mjs";
10
10
  export { COLOR_MAP } from "./values.mjs";
11
+ export { loadOmniaIndex, nativeFor } from "./native.mjs";
11
12
  export { default as postcss } from "./postcss.mjs";
12
13
  export { default as vite } from "./vite.mjs";
package/src/postcss.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // File: packages/migrate/src/postcss.mjs
2
2
  // PostCSS plugin "keep your classes": replaces the framework directives in your CSS
3
- // @import "tailwindcss"; / @tailwind base|components|utilities;
3
+ // @import "tailwindcss"; / @import "@birdycom/omniacss-migrate"; / @tailwind base|components|utilities;
4
4
  // with OmniaCSS core + compat CSS for the classes found in your content, and expands @apply, theme() and @theme.
5
5
  //
6
6
  // // postcss.config.js
@@ -12,16 +12,17 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import postcss from "postcss";
15
- import { createContext, resolveToken, serialize } from "./resolve.mjs";
15
+ import { createContext, resolveToken, serialize, propRank } from "./resolve.mjs";
16
16
  import { KEYFRAMES } from "./utilities.mjs";
17
17
  import { extractTokens } from "./extract.mjs";
18
18
  import { loadProject, parseThemeBlocks } from "./project.mjs";
19
19
  import { preflight } from "./preflight.mjs";
20
20
  import { buildCoreCss } from "./core.mjs";
21
21
  import { COLOR_MAP } from "./values.mjs";
22
- import { applyVariant } from "./variants.mjs";
22
+ import { applyVariant, splitTop } from "./variants.mjs";
23
23
 
24
- const TW_IMPORT = /^["']tailwindcss(?:\/(theme|preflight|utilities|base|components)(?:\.css)?)?["']/;
24
+ // `@import "tailwindcss"`, or the same entry under our own name, so a project can drop every Tailwind mention.
25
+ const TW_IMPORT = /^["'](?:tailwindcss|@birdycom\/omniacss-migrate)(?:\/(theme|preflight|utilities|base|components)(?:\.css)?)?["']/;
25
26
  const fileTokenCache = new Map();
26
27
 
27
28
  /** Default content globs when none are given. */
@@ -106,7 +107,8 @@ function withTheme(ctx, extra, patch = {}) {
106
107
  * @param {string} [options.cwd]
107
108
  * @param {true|"tokens"|false} [options.core=true] true: Omnia core on demand; "tokens": only color tokens + base; false: you load Omnia yourself
108
109
  * @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 {boolean} [options.layers=true] wrap output in cascade layers (omnia, theme, base, components, utilities):
111
+ * Omnia's reset and tokens lowest; the Omnia classes and the Tailwind-style names together in utilities, sorted the Tailwind way
110
112
  * @param {3|4} [options.tailwind] naming flavor (default: detected from package.json / CSS)
111
113
  * @param {"class"|"media"} [options.darkMode]
112
114
  * @param {"omnia"|"tailwind"} [options.breakpoints="omnia"]
@@ -156,7 +158,7 @@ export default function omniaMigrate(options = {}) {
156
158
  const ctx = withTheme(baseCtx, themeDecls.map(([k]) => k), { ...patch, customVariants, ...(options.important ? { important: true } : {}) });
157
159
 
158
160
  // --- content ---------------------------------------------------------------------------------------------
159
- let compatCss = "", exclude = new Set(), candidates = new Set(), files = [];
161
+ let compatCss = "", exclude = new Set(), candidates = new Set(), files = [], resolved = [];
160
162
  if (hasEntry) {
161
163
  const globs = [];
162
164
  for (const at of sources) {
@@ -175,7 +177,6 @@ export default function omniaMigrate(options = {}) {
175
177
  for (const t of collected.tokens) candidates.add(t);
176
178
  for (const t of options.safelist || []) candidates.add(t);
177
179
 
178
- const resolved = [];
179
180
  const manifest = options.manifest === false ? {} : JSON.parse(await fs.readFile(path.join(cwd, "omniacss-migrate.manifest.json"), "utf8").catch(() => "{}"));
180
181
  const native = new Set(manifest.native || []);
181
182
  for (const t of candidates) {
@@ -190,14 +191,19 @@ export default function omniaMigrate(options = {}) {
190
191
  const layers = options.layers !== false;
191
192
  const wrap = (name, css) => (layers && css.trim() ? `@layer ${name} {\n${css}\n}` : css);
192
193
  const pieces = {};
194
+ // omnia (Omnia's reset + tokens) < theme < base (compat preflight) < components < utilities. The Omnia classes
195
+ // (e.g. what the codemod wrote) share `utilities` with the Tailwind-style names, sorted together the Tailwind way,
196
+ // so an Omnia class beats every reset and wins or loses against a compat class exactly as before the codemod.
193
197
  pieces.order = layers ? "@layer omnia, theme, base, components, utilities;" : "";
194
198
  const themeBody = themeDecls.map(([k, v]) => ` ${k}: ${v};`).join("\n");
195
199
  pieces.theme = wrap("theme", (themeBody ? `:root, :host {\n${themeBody}\n}\n` : "") + keyframes.join("\n"));
196
200
  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);
201
+ const core = options.core === false ? { base: "", classes: "", used: new Set() } : await buildCoreCss({ candidates, compatCss, exclude, cwd, mode: options.core === "tokens" ? "tokens" : true });
202
+ pieces.core = wrap("omnia", core.base);
203
+ pieces.utilities = wrap("utilities", core.classes ? serialize([...resolved, ...nativeRules(core.classes, core.used, ctx)]) : compatCss);
199
204
  const all = [pieces.order, pieces.theme, pieces.preflight, pieces.core, pieces.utilities];
200
- const mk = (css) => postcss.parse(css, { from: undefined }).nodes;
205
+ // `from`: the stylesheet being built, so Vite resolves url() in the generated CSS against it (no "did not pass from" warning).
206
+ const mk = (css) => postcss.parse(css, { from: result.opts.from }).nodes;
201
207
  const place = (at, css) => { const nodes = css ? mk(css) : []; if (nodes.length) at.replaceWith(nodes); else at.remove(); };
202
208
 
203
209
  for (const at of twImports) {
@@ -244,11 +250,48 @@ export default function omniaMigrate(options = {}) {
244
250
  root.walkDecls((decl) => { const v = replaceThemeFunctions(decl.value, ctx); if (v !== decl.value) decl.value = v; });
245
251
 
246
252
  for (const file of files) result.messages.push({ type: "dependency", plugin: "omniacss-migrate", file: path.resolve(file), parent: result.opts.from });
253
+
254
+ // Anything still here was not understood: the page would silently miss its tokens and classes.
255
+ root.walkAtRules((at) => {
256
+ if ((at.name === "import" && TW_IMPORT.test(at.params.trim())) || at.name === "theme" || at.name === "tailwind") {
257
+ at.warn(result, `omniacss-migrate left "@${at.name} ${at.params}" unprocessed. If you just updated @birdycom/omniacss-migrate, restart the dev server.`);
258
+ }
259
+ });
247
260
  },
248
261
  };
249
262
  }
250
263
  omniaMigrate.postcss = true;
251
264
 
265
+ /**
266
+ * The Omnia class rules as resolved tokens, so serialize() sorts them in with the compat classes: the variant rank
267
+ * comes from the class's own prefixes (`hover:`, `md:` …), the property rank from its first declaration.
268
+ * @param {string} css the class part of the Omnia core output
269
+ * @param {Set<string>} used Omnia class names in it (to find a rule's own class among the ones in its selector)
270
+ */
271
+ function nativeRules(css, used, ctx) {
272
+ const out = [];
273
+ postcss.parse(css).walkRules((rule) => {
274
+ const decls = [];
275
+ rule.each((n) => { if (n.type === "decl") decls.push([n.prop, n.important ? `${n.value} !important` : n.value]); });
276
+ if (!decls.length) return;
277
+ const media = [];
278
+ for (let p = rule.parent; p && p.type === "atrule"; p = p.parent) media.unshift(`@${p.name} ${p.params}`.trim());
279
+ const classes = [...rule.selector.matchAll(/\.((?:\\.|[A-Za-z0-9_-])+)/g)].map((m) => m[1].replace(/\\(.)/g, "$1"));
280
+ const token = classes.find((c) => used.has(c)) || classes.at(-1) || rule.selector;
281
+ out.push({ token, rules: [{ selectors: rule.selectors, media, decls, rank: variantRank(token, ctx), urank: propRank(decls[0][0]) }], keyframes: [] });
282
+ });
283
+ return out;
284
+ }
285
+
286
+ /** Cascade rank of a class's variant prefixes, as the compat layer ranks them (unknown Omnia-only prefixes go late). */
287
+ function variantRank(token, ctx) {
288
+ const parts = splitTop(token, ":");
289
+ parts.pop();
290
+ const st = { sels: ["&"], media: [], prelude: [], rank: 0 };
291
+ for (let i = parts.length - 1; i >= 0; i--) if (!applyVariant(parts[i], st, ctx)) st.rank += 15000;
292
+ return st.rank;
293
+ }
294
+
252
295
  function expandUtility(at) {
253
296
  if (/--value|--modifier/.test(at.toString())) { at.remove(); return; }
254
297
  const rule = postcss.rule({ selector: `.${at.params.trim()}` });
@@ -293,7 +336,8 @@ function expandApply(at, ctx, classIndex, usedKeyframes, result) {
293
336
  else extras.push(rule);
294
337
  }
295
338
  }
296
- const nodes = inline.map(([prop, value, imp]) => postcss.decl({ prop, value, important: !!imp }));
339
+ // `source` of the @apply: Vite resolves url() in these declarations against the file they are written in.
340
+ const nodes = inline.map(([prop, value, imp]) => postcss.decl({ prop, value, important: !!imp, source: at.source }));
297
341
  if (nodes.length) at.replaceWith(nodes); else at.remove();
298
342
  if (!extras.length) return;
299
343
  const flat = parent.parent && parent.parent.type !== "rule";
@@ -302,7 +346,7 @@ function expandApply(at, ctx, classIndex, usedKeyframes, result) {
302
346
  const sels = flat ? x.selectors.flatMap((s) => parent.selectors.map((ps) => s.replace(/&/g, ps))) : x.selectors;
303
347
  let css = `${sels.join(",\n")} {\n${x.decls.map(([p, v]) => ` ${p}: ${v};`).join("\n")}\n}`;
304
348
  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;
349
+ const parsed = postcss.parse(css, { from: at.source?.input.file ?? result.opts.from }).nodes;
306
350
  if (flat) { for (const n of parsed) { anchor.after(n); anchor = n; } } else parent.append(parsed);
307
351
  }
308
352
  }
package/src/project.mjs CHANGED
@@ -146,7 +146,7 @@ export async function loadProject(dir, overrides = {}) {
146
146
  for (const f of info.cssFiles) {
147
147
  const css = await fs.readFile(f, "utf8").catch(() => "");
148
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;
149
+ if (/@import\s+["'](?:tailwindcss|@birdycom\/omniacss-migrate)/.test(css) || /@theme\b/.test(css)) info.tailwindVersion ||= 4;
150
150
  if (/@tailwind\s+(base|utilities|components)/.test(css)) info.tailwindVersion ||= 3;
151
151
  const cv = css.match(/@custom-variant\s+dark\s*\(([^;]*)\)\s*;/);
152
152
  if (cv) {
package/src/resolve.mjs CHANGED
@@ -123,7 +123,7 @@ function containerRule(ctx) {
123
123
 
124
124
  // property rank so shorthands sort before longhands (`p-4` before `px-2` before `pl-1`)
125
125
  const SHORTHANDS = new Set(["margin", "padding", "inset", "gap", "border-width", "border-color", "border-radius", "border", "overflow", "scroll-margin", "scroll-padding", "transition-property", "flex", "background", "font", "outline", "translate", "rotate", "scale", "size"]);
126
- function propRank(prop) {
126
+ export function propRank(prop) {
127
127
  if (SHORTHANDS.has(prop)) return 0;
128
128
  if (/-(top|right|bottom|left|start|end)\b|^(top|right|bottom|left)$|-(x|y)$/.test(prop)) return 2;
129
129
  return 1;
@@ -178,7 +178,9 @@ export function serialize(resolved) {
178
178
  r.keyframes.forEach((k) => frames.add(k));
179
179
  for (const rule of r.rules) rules.push({ ...rule, token: r.token });
180
180
  }
181
- rules.sort((a, b) => a.rank - b.rank || a.urank - b.urank || (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
181
+ // Like Tailwind: variants after the base class, shorthands before longhands, then the class that sets more
182
+ // properties first (so `leading-*` overrides the line-height of `text-sm`), then by name.
183
+ rules.sort((a, b) => a.rank - b.rank || a.urank - b.urank || propCount(b) - propCount(a) || (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
182
184
  const renderRule = (r, ind = "") => `${r.selectors.map((x) => ind + x).join(",\n")} {\n${fmtDecls(r.decls, ind + " ")}\n${ind}}`;
183
185
  const out = [];
184
186
  for (const k of frames) if (KEYFRAMES[k]) out.push(KEYFRAMES[k]);
@@ -203,6 +205,9 @@ export function serialize(resolved) {
203
205
  return out.join("\n");
204
206
  }
205
207
 
208
+ /** Number of real (not custom) properties a rule sets. */
209
+ const propCount = (rule) => new Set(rule.decls.map(([k]) => k).filter((k) => !k.startsWith("--"))).size;
210
+
206
211
  /** Resolve many tokens and report what could not be resolved. */
207
212
  export function compileTokens(tokens, ctx) {
208
213
  const resolved = [], unsupported = [];
package/src/setup.mjs CHANGED
@@ -5,12 +5,14 @@
5
5
 
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
+ import { createRequire } from "node:module";
8
9
  import { loadProject, loadTailwindConfig, themeFromConfig, findFiles } from "./project.mjs";
9
10
  import { createContext } from "./resolve.mjs";
10
11
 
11
12
  const TW_PKGS = ["tailwindcss", "@tailwindcss/vite", "@tailwindcss/postcss"];
12
13
  const TW_NAME = "(?:@tailwindcss\\/vite|@tailwindcss\\/postcss|tailwindcss)";
13
- const VERSION = "^0.4.0";
14
+ // The range written into the project: this package's own version, so it never goes stale.
15
+ const VERSION = `^${createRequire(import.meta.url)("../package.json").version}`;
14
16
 
15
17
  const read = (f) => fs.readFile(f, "utf8").catch(() => null);
16
18
 
@@ -147,10 +149,17 @@ export async function planSetup(dir, { keepBrandColors = false } = {}) {
147
149
  let entryDone = false;
148
150
  for (const f of cssFiles) {
149
151
  const before = await read(f);
150
- if (!/@import\s+["']tailwindcss|@tailwind\s+(base|utilities)/.test(before)) continue;
152
+ if (!/@import\s+["'](?:tailwindcss|@birdycom\/omniacss-migrate)|@tailwind\s+(base|utilities)/.test(before)) continue;
151
153
  detected.css.push(path.relative(dir, f));
152
- if (entryDone) continue;
154
+ // `@import "tailwindcss"` -> `@import "@birdycom/omniacss-migrate"` (same entry, no Tailwind name left).
155
+ // The v3 `@tailwind …` directives stay: they may sit mid-file, where an @import is not allowed.
156
+ const renamed = before.replace(/(@import\s+)(["'])tailwindcss((?:\/[\w.-]+)?)\2/g, "$1$2@birdycom/omniacss-migrate$3$2");
157
+ const isEntry = !entryDone;
153
158
  entryDone = true;
159
+ if (!isEntry) {
160
+ if (renamed !== before) changes.push({ file: f, before, after: renamed, note: 'rename @import "tailwindcss" to @import "@birdycom/omniacss-migrate"' });
161
+ continue;
162
+ }
154
163
  const add = [];
155
164
  if (loaded?.config) {
156
165
  const t = themeFromConfig(loaded.config, createContext({}), { keepColors: keepBrandColors });
@@ -165,15 +174,17 @@ export async function planSetup(dir, { keepBrandColors = false } = {}) {
165
174
  if ((dm === "class" || dm === "selector") && !/@custom-variant\s+dark/.test(before)) add.push("@custom-variant dark (&:where(.dark, .dark *));");
166
175
  }
167
176
  if (add.length) {
168
- const re = /^(@import\s+["']tailwindcss["'][^;\n]*;|@tailwind\s+utilities;)[ \t]*\r?\n/m;
169
- const m = before.match(re);
170
- const eol = before.includes("\r\n") ? "\r\n" : "\n";
171
- const after = m ? before.replace(re, `${m[0]}${eol}${add.join(eol)}${eol}`) : before + eol + add.join(eol) + eol;
172
- changes.push({ file: f, before, after, note: "carry tailwind.config theme into CSS (@theme) and dark mode variant" });
173
- } else notes.push(`${path.relative(dir, f)}: the directives (@import "tailwindcss" / @tailwind …) are handled by the plugin; no change needed.`);
177
+ const re = /^(@import\s+["']@birdycom\/omniacss-migrate["'][^;\n]*;|@tailwind\s+utilities;)[ \t]*\r?\n/m;
178
+ const m = renamed.match(re);
179
+ const eol = renamed.includes("\r\n") ? "\r\n" : "\n";
180
+ const after = m ? renamed.replace(re, `${m[0]}${eol}${add.join(eol)}${eol}`) : renamed + eol + add.join(eol) + eol;
181
+ changes.push({ file: f, before, after, note: `carry tailwind.config theme into CSS (@theme) and dark mode variant${renamed !== before ? '; rename @import "tailwindcss"' : ""}` });
182
+ } else if (renamed !== before) {
183
+ changes.push({ file: f, before, after: renamed, note: 'rename @import "tailwindcss" to @import "@birdycom/omniacss-migrate" (the plugin handles it)' });
184
+ } else notes.push(`${path.relative(dir, f)}: the directives (@import "@birdycom/omniacss-migrate" / @tailwind …) are handled by the plugin; no change needed.`);
174
185
  if (/@plugin\s+['"]@iconify/.test(before)) notes.push("`@plugin '@iconify/...'` is ignored by the plugin (icon-[...] classes are not generated).");
175
186
  }
176
- if (!detected.css.length) notes.push("No CSS file with Tailwind directives found. The plugin works on `@import \"tailwindcss\"` or `@tailwind base/components/utilities`.");
187
+ if (!detected.css.length) notes.push("No CSS file with Tailwind directives found. The plugin works on `@import \"@birdycom/omniacss-migrate\"`, `@import \"tailwindcss\"` or `@tailwind base/components/utilities`.");
177
188
  commands.push("npm uninstall " + (removed.join(" ") || "tailwindcss @tailwindcss/vite"));
178
189
  commands.push("npm install -D @birdycom/omniacss-migrate");
179
190
  return { changes, notes, commands, detected };
package/src/utilities.mjs CHANGED
@@ -262,6 +262,7 @@ const SIDE = { t: "top", r: "right", b: "bottom", l: "left", s: "inline-start",
262
262
  function boxHandler(prop) {
263
263
  // m, mx, my, mt … (margin / padding / scroll-margin / scroll-padding)
264
264
  return (u, root) => {
265
+ // The side after the root: "mt" -> "t", "scroll-mt" -> "t", "scroll-p" -> "".
265
266
  const key = root.slice(prefixLen(prop));
266
267
  const v = sp(u, u.value, { keywords: { auto: "auto" }, fractions: false });
267
268
  if (v == null) return null;
@@ -275,7 +276,7 @@ function boxHandler(prop) {
275
276
  return null;
276
277
  };
277
278
  }
278
- const prefixLen = (prop) => ({ margin: 1, padding: 1, "scroll-margin": 2, "scroll-padding": 2 })[prop];
279
+ const prefixLen = (prop) => ({ margin: 1, padding: 1, "scroll-margin": 8, "scroll-padding": 8 })[prop];
279
280
 
280
281
  function insetHandler(prop) {
281
282
  return (u) => {
package/src/vite.mjs CHANGED
@@ -49,9 +49,24 @@ async function loadExistingPlugins(root) {
49
49
  return out;
50
50
  }
51
51
 
52
+ // The version this process loaded. A running dev server keeps it after `npm install` brings a newer one.
53
+ const OWN_MANIFEST = new URL("../package.json", import.meta.url);
54
+ const LOADED_VERSION = JSON.parse(fs.readFileSync(OWN_MANIFEST, "utf8")).version;
55
+
56
+ /** The version now on disk when it differs from the loaded one (read again only when package.json changed). */
57
+ let seen = { mtime: 0, version: LOADED_VERSION };
58
+ function updatedVersion() {
59
+ try {
60
+ const mtime = fs.statSync(OWN_MANIFEST).mtimeMs;
61
+ if (mtime !== seen.mtime) seen = { mtime, version: JSON.parse(fs.readFileSync(OWN_MANIFEST, "utf8")).version };
62
+ } catch { return null; }
63
+ return seen.version !== LOADED_VERSION ? seen.version : null;
64
+ }
65
+
52
66
  /** @param {Parameters<typeof omniaMigratePostcss>[0]} [options] */
53
67
  export default function omniaMigrate(options = {}) {
54
68
  let root = process.cwd();
69
+ let warnedStale = false;
55
70
  return {
56
71
  name: "omniacss-migrate",
57
72
  enforce: "pre",
@@ -60,9 +75,16 @@ export default function omniaMigrate(options = {}) {
60
75
  async transform(code, id) {
61
76
  const file = id.split("?")[0];
62
77
  if (!/\.(css|pcss|postcss)$/.test(file)) return null;
63
- if (!/@import\s+["']tailwindcss|@tailwind\s+\w+|@apply\s|@theme\b/.test(code)) return null;
78
+ if (!/@import\s+["'](?:tailwindcss|@birdycom\/omniacss-migrate)|@tailwind\s+\w+|@apply\s|@theme\b/.test(code)) return null;
79
+ // Vite does not reload a plugin whose package changed underneath it; the old one may not understand the CSS.
80
+ const newer = !warnedStale && updatedVersion();
81
+ if (newer) {
82
+ warnedStale = true;
83
+ this.warn(`@birdycom/omniacss-migrate was updated from ${LOADED_VERSION} to ${newer} while this dev server runs. Restart the dev server: it still uses ${LOADED_VERSION}.`);
84
+ }
64
85
  const result = await postcss([omniaMigratePostcss({ cwd: root, ...options })]).process(code, { from: file, map: false });
65
86
  for (const m of result.messages) if (m.type === "dependency" && m.file) this.addWatchFile(m.file);
87
+ for (const w of result.warnings()) this.warn(w.text);
66
88
  return { code: result.css, map: null };
67
89
  },
68
90
  configResolved(config) {