@birdycom/omniacss-migrate 0.12.1 → 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
@@ -51,7 +51,7 @@ export default { plugins: [omniaMigrate({ content: ["resources/views/**/*.blade.
51
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.
52
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`.
53
53
  - Reads `tailwind.config.{js,cjs,mjs}` (`darkMode`, `theme.colors`/`extend.colors`, `fontFamily`, `borderRadius`, `spacing`, `boxShadow`, `screens`, `container`, `prefix`, `important`) and your `@theme` blocks.
54
- - 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.
55
55
 
56
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:`.
57
57
 
@@ -82,7 +82,7 @@ omniacss-migrate css "md:hover:bg-red-500/50" print the CSS for one class
82
82
 
83
83
  ## Codemod (optional)
84
84
 
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.
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.
86
86
 
87
87
  ## From Bootstrap
88
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@birdycom/omniacss-migrate",
3
- "version": "0.12.1",
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",
@@ -54,7 +54,7 @@
54
54
  "build:colors": "node scripts/build-color-map.mjs"
55
55
  },
56
56
  "dependencies": {
57
- "@birdycom/omniacss": "^0.12.1",
57
+ "@birdycom/omniacss": "^0.12.2",
58
58
  "postcss": "^8.5.3"
59
59
  },
60
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/postcss.mjs CHANGED
@@ -12,14 +12,14 @@
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
24
  // `@import "tailwindcss"`, or the same entry under our own name, so a project can drop every Tailwind mention.
25
25
  const TW_IMPORT = /^["'](?:tailwindcss|@birdycom\/omniacss-migrate)(?:\/(theme|preflight|utilities|base|components)(?:\.css)?)?["']/;
@@ -107,7 +107,8 @@ function withTheme(ctx, extra, patch = {}) {
107
107
  * @param {string} [options.cwd]
108
108
  * @param {true|"tokens"|false} [options.core=true] true: Omnia core on demand; "tokens": only color tokens + base; false: you load Omnia yourself
109
109
  * @param {boolean} [options.preflight=true] include the base reset
110
- * @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
111
112
  * @param {3|4} [options.tailwind] naming flavor (default: detected from package.json / CSS)
112
113
  * @param {"class"|"media"} [options.darkMode]
113
114
  * @param {"omnia"|"tailwind"} [options.breakpoints="omnia"]
@@ -157,7 +158,7 @@ export default function omniaMigrate(options = {}) {
157
158
  const ctx = withTheme(baseCtx, themeDecls.map(([k]) => k), { ...patch, customVariants, ...(options.important ? { important: true } : {}) });
158
159
 
159
160
  // --- content ---------------------------------------------------------------------------------------------
160
- let compatCss = "", exclude = new Set(), candidates = new Set(), files = [];
161
+ let compatCss = "", exclude = new Set(), candidates = new Set(), files = [], resolved = [];
161
162
  if (hasEntry) {
162
163
  const globs = [];
163
164
  for (const at of sources) {
@@ -176,7 +177,6 @@ export default function omniaMigrate(options = {}) {
176
177
  for (const t of collected.tokens) candidates.add(t);
177
178
  for (const t of options.safelist || []) candidates.add(t);
178
179
 
179
- const resolved = [];
180
180
  const manifest = options.manifest === false ? {} : JSON.parse(await fs.readFile(path.join(cwd, "omniacss-migrate.manifest.json"), "utf8").catch(() => "{}"));
181
181
  const native = new Set(manifest.native || []);
182
182
  for (const t of candidates) {
@@ -191,12 +191,16 @@ export default function omniaMigrate(options = {}) {
191
191
  const layers = options.layers !== false;
192
192
  const wrap = (name, css) => (layers && css.trim() ? `@layer ${name} {\n${css}\n}` : css);
193
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.
194
197
  pieces.order = layers ? "@layer omnia, theme, base, components, utilities;" : "";
195
198
  const themeBody = themeDecls.map(([k, v]) => ` ${k}: ${v};`).join("\n");
196
199
  pieces.theme = wrap("theme", (themeBody ? `:root, :host {\n${themeBody}\n}\n` : "") + keyframes.join("\n"));
197
200
  pieces.preflight = options.preflight === false ? "" : wrap("base", preflight({ borderColor: ctx.v4 ? "currentColor" : `var(${COLOR_MAP["gray-200"]})` }));
198
- pieces.core = options.core === false ? "" : await buildCoreCss({ candidates, compatCss, exclude, cwd, layer: layers ? "omnia" : false, mode: options.core === "tokens" ? "tokens" : true });
199
- 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);
200
204
  const all = [pieces.order, pieces.theme, pieces.preflight, pieces.core, pieces.utilities];
201
205
  // `from`: the stylesheet being built, so Vite resolves url() in the generated CSS against it (no "did not pass from" warning).
202
206
  const mk = (css) => postcss.parse(css, { from: result.opts.from }).nodes;
@@ -246,11 +250,48 @@ export default function omniaMigrate(options = {}) {
246
250
  root.walkDecls((decl) => { const v = replaceThemeFunctions(decl.value, ctx); if (v !== decl.value) decl.value = v; });
247
251
 
248
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
+ });
249
260
  },
250
261
  };
251
262
  }
252
263
  omniaMigrate.postcss = true;
253
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
+
254
295
  function expandUtility(at) {
255
296
  if (/--value|--modifier/.test(at.toString())) { at.remove(); return; }
256
297
  const rule = postcss.rule({ selector: `.${at.params.trim()}` });
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/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",
@@ -61,8 +76,15 @@ export default function omniaMigrate(options = {}) {
61
76
  const file = id.split("?")[0];
62
77
  if (!/\.(css|pcss|postcss)$/.test(file)) return null;
63
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) {