@decocms/blocks-cli 7.21.0 → 7.21.1

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.
@@ -1,7 +1,26 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { MigrationContext } from "../types";
4
+ import { log } from "../types";
4
5
  import type { ExtractedTheme } from "../analyzers/theme-extractor";
6
+ import { fixOklchHexMismatches, isOklchCoordinates } from "../transforms/color-oklch";
7
+ import { transformCss } from "../transforms/css";
8
+ import { renameToken } from "../transforms/tailwind-renames";
9
+
10
+ const GRAY_SHADES = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"];
11
+ const GRAY_SCALE_DEFAULTS: Record<string, string> = {
12
+ "50": "#f9fafb",
13
+ "100": "#f3f4f6",
14
+ "200": "#e5e7eb",
15
+ "300": "#d1d5db",
16
+ "400": "#9ca3af",
17
+ "500": "#6b7280",
18
+ "600": "#4b5563",
19
+ "700": "#374151",
20
+ "800": "#1f2937",
21
+ "900": "#111827",
22
+ "950": "#030712",
23
+ };
5
24
 
6
25
  /**
7
26
  * Find the original site's custom CSS file.
@@ -48,41 +67,20 @@ function extractCustomCss(rawCss: string): string {
48
67
  /**
49
68
  * Transform @apply directives for TW3→TW4 compatibility.
50
69
  * The tailwind.ts transform handles className= attributes in JSX,
51
- * but @apply inside CSS also needs class renames.
52
- *
53
- * Also converts @apply with custom brand/theme colors to native CSS
54
- * properties when the utility class might not be registered in TW4.
70
+ * but @apply inside CSS also needs the same class renames — reuses the
71
+ * single shared table (transforms/tailwind-renames.ts) so this never
72
+ * drifts from the className= rewriter again.
55
73
  */
56
74
  function transformApplyDirectives(css: string): string {
57
- return css
58
- .replace(/@apply\s+([^;]+);/g, (_match, classes: string) => {
59
- let fixed = classes;
60
- // flex-grow-0 → grow-0
61
- fixed = fixed.replace(/\bflex-grow-0\b/g, "grow-0");
62
- fixed = fixed.replace(/\bflex-grow\b/g, "grow");
63
- fixed = fixed.replace(/\bflex-shrink-0\b/g, "shrink-0");
64
- fixed = fixed.replace(/\bflex-shrink\b/g, "shrink");
65
- // transform → removed (auto in v4)
66
- fixed = fixed.replace(/\btransform\b(?!-none)/g, "");
67
- fixed = fixed.replace(/\bfilter\b/g, "");
68
- // ring → ring-3
69
- fixed = fixed.replace(/\bring\b(?!-)/g, "ring-3");
70
- // Clean up multiple spaces
71
- fixed = fixed.replace(/\s{2,}/g, " ").trim();
72
- return `@apply ${fixed};`;
73
- });
74
- }
75
-
76
- /**
77
- * Test if a value looks like oklch coordinates (space-separated numbers,
78
- * possibly with `/` for alpha). Examples: "0.5 0.2 30", "0.8 0.15 120 / 0.5"
79
- * This distinguishes oklch coordinate values from hex colors.
80
- */
81
- function isOklchCoordinates(val: string): boolean {
82
- const trimmed = val.trim();
83
- // oklch coordinates: 2-3 space-separated numbers, possibly with / alpha
84
- // e.g. "0.5 0.2 30" or "0.85 0.15 120 / 0.5"
85
- return /^[\d.]+\s+[\d.]+\s+[\d.]+(\s*\/\s*[\d.]+)?$/.test(trimmed);
75
+ return css.replace(/@apply\s+([^;]+);/g, (_match, classes: string) => {
76
+ const fixed = classes
77
+ .split(/\s+/)
78
+ .filter(Boolean)
79
+ .map(renameToken)
80
+ .filter(Boolean)
81
+ .join(" ");
82
+ return `@apply ${fixed};`;
83
+ });
86
84
  }
87
85
 
88
86
  /**
@@ -203,22 +201,57 @@ ${colorLines}
203
201
  }
204
202
  }
205
203
 
206
- // Gray scale compat (Tailwind v3 had gray-50..gray-950 by default)
204
+ // Port theme.extend.colors / theme.extend.fontFamily / theme.extend.screens
205
+ // from the source site's tailwind.config.ts (extracted in phase-analyze
206
+ // before that file is deleted — see analyzers/tailwind-config.ts). Without
207
+ // this, custom brand palettes and named font stacks are silently dropped
208
+ // and any class referencing them (`bg-perola`, `font-bebas-neue`) makes
209
+ // Tailwind v4 throw "Cannot apply unknown utility class" (decocms/blocks#369).
210
+ // gray-50..950 are handled below, in the gray-scale block, so a custom
211
+ // gray ramp from tailwind.config.ts overrides the default instead of
212
+ // being declared twice in the same @theme rule (last declaration wins in
213
+ // CSS — the default would otherwise silently clobber the custom one).
214
+ const twColors = ctx.tailwindConfig.colors;
215
+ const twColorKeys = Object.keys(twColors).filter(
216
+ (k) => !(`--color-${k}` in semanticColors) && !GRAY_SHADES.some((shade) => k === `gray-${shade}`),
217
+ );
218
+ if (twColorKeys.length > 0) {
219
+ themeBlock += `\n\n /* Custom colors ported from tailwind.config.ts */`;
220
+ for (const key of twColorKeys) {
221
+ themeBlock += `\n --color-${key}: ${twColors[key]};`;
222
+ }
223
+ }
224
+
225
+ const twFontFamily = ctx.tailwindConfig.fontFamily;
226
+ const twFontKeys = Object.keys(twFontFamily);
227
+ if (twFontKeys.length > 0) {
228
+ themeBlock += `\n\n /* Font stacks ported from tailwind.config.ts */`;
229
+ for (const key of twFontKeys) {
230
+ themeBlock += `\n --font-${key}: ${twFontFamily[key]};`;
231
+ }
232
+ }
233
+
234
+ const twScreens = ctx.tailwindConfig.screens;
235
+ const twScreenKeys = Object.keys(twScreens);
236
+ if (twScreenKeys.length > 0) {
237
+ themeBlock += `\n\n /* Custom breakpoints ported from tailwind.config.ts */`;
238
+ for (const key of twScreenKeys) {
239
+ themeBlock += `\n --breakpoint-${key}: ${twScreens[key]};`;
240
+ }
241
+ }
242
+
243
+ // Gray scale compat (Tailwind v3 had gray-50..gray-950 by default) — uses
244
+ // the site's own tailwind.config.ts gray-* override when present, falling
245
+ // back to the v3 default otherwise.
207
246
  themeBlock += `
208
247
 
209
- /* Gray scale (Tailwind v3 default, required for bg-gray-*, text-gray-*, etc.) */
210
- --color-gray-50: #f9fafb;
211
- --color-gray-100: #f3f4f6;
212
- --color-gray-200: #e5e7eb;
213
- --color-gray-300: #d1d5db;
214
- --color-gray-400: #9ca3af;
215
- --color-gray-500: #6b7280;
216
- --color-gray-600: #4b5563;
217
- --color-gray-700: #374151;
218
- --color-gray-800: #1f2937;
219
- --color-gray-900: #111827;
220
- --color-gray-950: #030712;
221
- }`;
248
+ /* Gray scale (Tailwind v3 default, required for bg-gray-*, text-gray-*, etc.) */`;
249
+ for (const shade of GRAY_SHADES) {
250
+ const key = `gray-${shade}`;
251
+ const value = twColors[key] ?? GRAY_SCALE_DEFAULTS[shade];
252
+ themeBlock += `\n --color-${key}: ${value};`;
253
+ }
254
+ themeBlock += `\n}`;
222
255
  sections.push(themeBlock);
223
256
 
224
257
  // ── DaisyUI v5 compat ─────────────────────────────────────────────
@@ -345,22 +378,79 @@ section[data-deferred="true"] {
345
378
  display: none;
346
379
  }`);
347
380
 
381
+ // ── Safelist (tailwind.config.ts) ──────────────────────────────────
382
+ // Tailwind v4 has no config-based safelist. Literal class names port
383
+ // directly to `@source inline(...)`; regex patterns can't be converted
384
+ // automatically and are flagged for manual review instead.
385
+ const safelist = ctx.tailwindConfig.safelist;
386
+ if (safelist.length > 0) {
387
+ sections.push(`/* Safelist ported from tailwind.config.ts (Tailwind v4 has no config safelist) */
388
+ @source inline("${safelist.join(" ")}");`);
389
+ }
390
+ for (const pattern of ctx.tailwindConfig.safelistPatterns) {
391
+ ctx.manualReviewItems.push({
392
+ file: "src/styles/app.css",
393
+ reason: `tailwind.config.ts safelist pattern ${pattern} cannot be auto-converted — add the matching classes via @source inline("...") manually (Tailwind v4 has no regex safelist)`,
394
+ severity: "warning",
395
+ });
396
+ }
397
+
398
+ // Fix gotcha #43: `oklch(var(--x))` is emitted wherever a CMS theme var
399
+ // looked like bare oklch coordinates, but if that var was actually
400
+ // declared as hex elsewhere, the wrapper is invalid CSS (icons/fills
401
+ // silently render black). Convert the hex declaration to an oklch triplet
402
+ // so the wrapper stays valid.
403
+ //
404
+ // Scoped to the generator's OWN output (`sections` so far) — NOT the
405
+ // site's original custom CSS appended below. That content should be
406
+ // passed through as close to verbatim as possible; scanning/rewriting it
407
+ // too would mean silently mutating a site-authored `--x: #hex;`
408
+ // declaration if the site happens to also use the same
409
+ // `oklch(var(--x))` idiom for an unrelated purpose.
410
+ const generatorCss = sections.join("\n\n") + "\n";
411
+ const { css: fixedGeneratorCss, fixed, flagged } = fixOklchHexMismatches(generatorCss);
412
+ for (const note of fixed) {
413
+ log(ctx, `[app.css] Fixed oklch/hex mismatch: ${note}`);
414
+ }
415
+ for (const note of flagged) {
416
+ ctx.manualReviewItems.push({
417
+ file: "src/styles/app.css",
418
+ reason: note,
419
+ severity: "warning",
420
+ });
421
+ }
422
+
348
423
  // ── Incorporate original site's custom CSS ────────────────────
349
424
  // Instead of throwing away the site's CSS, we extract and append
350
425
  // all custom rules (component overrides, @layer base, @font-face,
351
- // typography utilities, feature-specific CSS, etc.)
426
+ // typography utilities, feature-specific CSS, etc.). Appended AFTER the
427
+ // oklch fix above runs, so this content is never scanned/rewritten by it.
352
428
  const originalCss = findOriginalCss(ctx);
429
+ let originalCssBlock = "";
353
430
  if (originalCss) {
354
431
  const customCss = extractCustomCss(originalCss);
355
432
  if (customCss) {
356
- const transformed = transformApplyDirectives(customCss);
357
- sections.push(`/* ═══════════════════════════════════════════════════════════════
433
+ let transformed = transformApplyDirectives(customCss);
434
+ const cssFix = transformCss(transformed);
435
+ transformed = cssFix.css;
436
+ for (const note of cssFix.notes) {
437
+ if (note.startsWith("MANUAL:")) {
438
+ ctx.manualReviewItems.push({
439
+ file: "src/styles/app.css",
440
+ reason: note.replace(/^MANUAL:\s*/, ""),
441
+ severity: "warning",
442
+ });
443
+ } else {
444
+ log(ctx, `[app.css] ${note}`);
445
+ }
446
+ }
447
+ originalCssBlock = `/* ═══════════════════════════════════════════════════════════════
358
448
  Original site CSS (migrated from tailwind.css)
359
449
  ═══════════════════════════════════════════════════════════════ */
360
450
 
361
- ${transformed}`);
451
+ ${transformed}`;
362
452
  }
363
453
  }
364
454
 
365
- return sections.join("\n\n") + "\n";
455
+ return originalCssBlock ? `${fixedGeneratorCss}\n${originalCssBlock}\n` : fixedGeneratorCss;
366
456
  }
@@ -12,6 +12,7 @@ function makeCtx(platform: MigrationContext["platform"]): MigrationContext {
12
12
  importMap: {},
13
13
  discoveredNpmDeps: {},
14
14
  themeColors: {},
15
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
15
16
  fontFamily: null,
16
17
  files: [],
17
18
  sectionMetas: [],
@@ -174,6 +174,11 @@ export function generatePackageJson(ctx: MigrationContext): string {
174
174
  devDependencies: {
175
175
  "@cloudflare/vite-plugin": "^1.27.0",
176
176
  "@decocms/blocks-cli": `^${frameworkVersion}`,
177
+ // CLI build of src/styles/app.css — used by the migration's own
178
+ // css-compile-check.ts (phase-compile) and by `tailwind:lint`, so a
179
+ // dropped custom theme token fails migration/CI instead of shipping
180
+ // an unstyled page (decocms/blocks#369).
181
+ "@tailwindcss/cli": "^4.2.1",
177
182
  "@tailwindcss/vite": "^4.2.1",
178
183
  "@tanstack/router-cli": "1.166.7",
179
184
  "@types/react": "^19.2.14",
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fixOklchHexMismatches, hexToOklchTriplet, isOklchCoordinates } from "./color-oklch";
3
+
4
+ describe("isOklchCoordinates", () => {
5
+ it("matches bare oklch triplets", () => {
6
+ expect(isOklchCoordinates("0.55 0.2 30")).toBe(true);
7
+ expect(isOklchCoordinates("0.85 0.15 120 / 0.5")).toBe(true);
8
+ });
9
+
10
+ it("rejects hex and other color syntaxes", () => {
11
+ expect(isOklchCoordinates("#B10200")).toBe(false);
12
+ expect(isOklchCoordinates("rgb(1, 2, 3)")).toBe(false);
13
+ });
14
+ });
15
+
16
+ describe("hexToOklchTriplet", () => {
17
+ it("converts white to L=1 C=0", () => {
18
+ const triplet = hexToOklchTriplet("#ffffff");
19
+ expect(triplet).not.toBeNull();
20
+ const [l, c] = triplet!.split(" ").map(Number);
21
+ expect(l).toBeCloseTo(1, 2);
22
+ expect(c).toBeCloseTo(0, 2);
23
+ });
24
+
25
+ it("converts black to L=0 C=0", () => {
26
+ const triplet = hexToOklchTriplet("#000000");
27
+ expect(triplet).not.toBeNull();
28
+ const [l, c] = triplet!.split(" ").map(Number);
29
+ expect(l).toBeCloseTo(0, 2);
30
+ expect(c).toBeCloseTo(0, 2);
31
+ });
32
+
33
+ it("expands 3-digit hex", () => {
34
+ expect(hexToOklchTriplet("#fff")).toBe(hexToOklchTriplet("#ffffff"));
35
+ });
36
+
37
+ it("returns null for unparseable input", () => {
38
+ expect(hexToOklchTriplet("not-a-color")).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("fixOklchHexMismatches", () => {
43
+ it("converts a hex-declared var used as oklch(var(--x)) into an oklch triplet", () => {
44
+ const css = `:root {
45
+ --icon-color: #B10200;
46
+ }
47
+ .icon {
48
+ fill: oklch(var(--icon-color));
49
+ }`;
50
+
51
+ const result = fixOklchHexMismatches(css);
52
+ expect(result.fixed.length).toBe(1);
53
+ expect(result.css).toMatch(/--icon-color:\s*[\d.]+\s+[\d.]+\s+[\d.]+;/);
54
+ expect(result.css).not.toContain("#B10200");
55
+ });
56
+
57
+ it("leaves already-valid oklch coordinate declarations untouched", () => {
58
+ const css = `:root {
59
+ --icon-color: 0.55 0.2 30;
60
+ }
61
+ .icon {
62
+ fill: oklch(var(--icon-color));
63
+ }`;
64
+
65
+ const result = fixOklchHexMismatches(css);
66
+ expect(result.fixed).toEqual([]);
67
+ expect(result.flagged).toEqual([]);
68
+ expect(result.css).toBe(css);
69
+ });
70
+
71
+ it("flags rgb()-declared vars used as oklch(var(--x)) instead of guessing", () => {
72
+ const css = `:root {
73
+ --icon-color: rgb(10, 20, 30);
74
+ }
75
+ .icon {
76
+ fill: oklch(var(--icon-color));
77
+ }`;
78
+
79
+ const result = fixOklchHexMismatches(css);
80
+ expect(result.fixed).toEqual([]);
81
+ expect(result.flagged.length).toBe(1);
82
+ });
83
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * hex -> oklch triplet conversion + a post-process pass that fixes gotcha #43:
3
+ * generated CSS emits `oklch(var(--x))` (daisyUI v5's opacity-modifier
4
+ * pattern) but `--x` was assigned a hex color, not oklch coordinates.
5
+ * `oklch(#B10200)` is invalid CSS — the color silently falls back to its
6
+ * initial value, which is why SVG icons using `fill: oklch(var(--icon-color))`
7
+ * render solid black on migrated sites.
8
+ */
9
+
10
+ /**
11
+ * True if `value` looks like bare oklch coordinates: 2-3 space-separated
12
+ * numbers, optionally with a `/ alpha` suffix. E.g. "0.55 0.2 30" or
13
+ * "0.85 0.15 120 / 0.5". Distinguishes oklch coordinate values from hex
14
+ * colors or other CSS color syntaxes.
15
+ */
16
+ export function isOklchCoordinates(value: string): boolean {
17
+ const trimmed = value.trim();
18
+ return /^[\d.]+\s+[\d.]+\s+[\d.]+(\s*\/\s*[\d.]+)?$/.test(trimmed);
19
+ }
20
+
21
+ function srgbChannelToLinear(c: number): number {
22
+ const v = c / 255;
23
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
24
+ }
25
+
26
+ /**
27
+ * Convert a hex color (#rgb, #rrggbb, #rrggbbaa) to an oklch coordinate
28
+ * triplet string "L C H" (L in [0,1], C in [0, ~0.4], H in degrees
29
+ * [0, 360)), matching the format expected by `oklch(var(--x))`.
30
+ * Returns null if the input isn't a parseable hex color.
31
+ */
32
+ export function hexToOklchTriplet(hex: string): string | null {
33
+ const normalized = hex.trim().replace(/^#/, "");
34
+ let r: number, g: number, b: number;
35
+
36
+ if (normalized.length === 3) {
37
+ r = parseInt(normalized[0] + normalized[0], 16);
38
+ g = parseInt(normalized[1] + normalized[1], 16);
39
+ b = parseInt(normalized[2] + normalized[2], 16);
40
+ } else if (normalized.length === 6 || normalized.length === 8) {
41
+ r = parseInt(normalized.slice(0, 2), 16);
42
+ g = parseInt(normalized.slice(2, 4), 16);
43
+ b = parseInt(normalized.slice(4, 6), 16);
44
+ } else {
45
+ return null;
46
+ }
47
+ if ([r, g, b].some((c) => Number.isNaN(c))) return null;
48
+
49
+ const lr = srgbChannelToLinear(r);
50
+ const lg = srgbChannelToLinear(g);
51
+ const lb = srgbChannelToLinear(b);
52
+
53
+ // linear sRGB -> LMS (Björn Ottosson's OKLab matrices)
54
+ const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
55
+ const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
56
+ const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
57
+
58
+ const l_ = Math.cbrt(l);
59
+ const m_ = Math.cbrt(m);
60
+ const s_ = Math.cbrt(s);
61
+
62
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
63
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
64
+ const b_ = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
65
+
66
+ const C = Math.sqrt(a * a + b_ * b_);
67
+ let H = (Math.atan2(b_, a) * 180) / Math.PI;
68
+ if (H < 0) H += 360;
69
+
70
+ return `${L.toFixed(4)} ${C.toFixed(4)} ${H.toFixed(2)}`;
71
+ }
72
+
73
+ /**
74
+ * Scan generated CSS for `oklch(var(--x))` usages, look up how `--x` was
75
+ * declared elsewhere in the same stylesheet, and rewrite hex-valued
76
+ * declarations to oklch triplets so the wrapper stays valid CSS.
77
+ * rgb()/hsl()/named-color declarations are left as-is and reported so a
78
+ * human can convert them (no lossless closed-form conversion is attempted
79
+ * for those here).
80
+ */
81
+ export function fixOklchHexMismatches(css: string): { css: string; fixed: string[]; flagged: string[] } {
82
+ const declRe = /(--[\w-]+)\s*:\s*([^;]+);/g;
83
+ const declaredValues = new Map<string, string>();
84
+ let m: RegExpExecArray | null;
85
+ while ((m = declRe.exec(css)) !== null) {
86
+ declaredValues.set(m[1], m[2].trim());
87
+ }
88
+
89
+ const usageRe = /oklch\(var\((--[\w-]+)\)\)/g;
90
+ const fixed: string[] = [];
91
+ const flagged: string[] = [];
92
+ let result = css;
93
+
94
+ const seen = new Set<string>();
95
+ let usageMatch: RegExpExecArray | null;
96
+ while ((usageMatch = usageRe.exec(css)) !== null) {
97
+ const varName = usageMatch[1];
98
+ if (seen.has(varName)) continue;
99
+ seen.add(varName);
100
+
101
+ const value = declaredValues.get(varName);
102
+ if (!value || isOklchCoordinates(value)) continue;
103
+
104
+ const hexMatch = value.match(/^#[0-9a-fA-F]{3,8}$/);
105
+ if (hexMatch) {
106
+ const triplet = hexToOklchTriplet(value);
107
+ if (triplet) {
108
+ const declPattern = new RegExp(
109
+ `(${varName.replace(/[-]/g, "\\-")}\\s*:\\s*)${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s*;)`,
110
+ "g",
111
+ );
112
+ result = result.replace(declPattern, `$1${triplet}$2`);
113
+ fixed.push(`${varName}: ${value} -> oklch triplet ${triplet}`);
114
+ }
115
+ continue;
116
+ }
117
+
118
+ flagged.push(`${varName} is used as oklch(var(${varName})) but declared as "${value}" — not a hex or oklch-coordinate value; convert manually`);
119
+ }
120
+
121
+ return { css: result, fixed, flagged };
122
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { promoteApplyClassesToUtility, rewriteThemeHelper, transformCss } from "./css";
3
+
4
+ describe("rewriteThemeHelper", () => {
5
+ it("rewrites theme(colors.x.y) to var(--color-x-y)", () => {
6
+ const r = rewriteThemeHelper(".foo { color: theme(colors.gray.100); }");
7
+ expect(r.css).toBe(".foo { color: var(--color-gray-100); }");
8
+ expect(r.notes.length).toBe(1);
9
+ });
10
+
11
+ it("rewrites theme(spacing.4) to var(--spacing-4)", () => {
12
+ const r = rewriteThemeHelper(".foo { margin: theme(spacing.4); }");
13
+ expect(r.css).toBe(".foo { margin: var(--spacing-4); }");
14
+ });
15
+
16
+ it("preserves a fallback second argument", () => {
17
+ const r = rewriteThemeHelper(".foo { color: theme(colors.gray.100, #fff); }");
18
+ expect(r.css).toBe(".foo { color: var(--color-gray-100, #fff); }");
19
+ });
20
+
21
+ it("leaves a bare theme() call without a dotted path untouched", () => {
22
+ const r = rewriteThemeHelper(".foo { color: theme(primary); }");
23
+ expect(r.css).toBe(".foo { color: theme(primary); }");
24
+ });
25
+ });
26
+
27
+ describe("promoteApplyClassesToUtility", () => {
28
+ it("promotes a single-class @apply rule from @layer components to @utility", () => {
29
+ const css = `@layer components {
30
+ .btn-primary {
31
+ @apply px-4 py-2 bg-red-500;
32
+ }
33
+ }`;
34
+ const r = promoteApplyClassesToUtility(css);
35
+ expect(r.css).toContain("@utility btn-primary {");
36
+ expect(r.css).not.toContain("@layer components");
37
+ expect(r.notes.some((n) => n.includes("Promoted"))).toBe(true);
38
+ });
39
+
40
+ it("keeps compound selectors inside @layer components and flags them", () => {
41
+ const css = `@layer components {
42
+ .card .card-title {
43
+ @apply text-lg font-bold;
44
+ }
45
+ }`;
46
+ const r = promoteApplyClassesToUtility(css);
47
+ expect(r.css).toContain("@layer components");
48
+ expect(r.css).toContain(".card .card-title");
49
+ expect(r.notes.some((n) => n.startsWith("MANUAL:"))).toBe(true);
50
+ });
51
+
52
+ it("promotes some rules and keeps others in the same @layer block", () => {
53
+ const css = `@layer components {
54
+ .btn-primary {
55
+ @apply px-4 bg-red-500;
56
+ }
57
+ .card .title {
58
+ @apply text-lg;
59
+ }
60
+ }`;
61
+ const r = promoteApplyClassesToUtility(css);
62
+ expect(r.css).toContain("@utility btn-primary {");
63
+ expect(r.css).toContain("@layer components");
64
+ expect(r.css).toContain(".card .title");
65
+ });
66
+
67
+ it("passes through a comment-only @layer components block instead of dropping it", () => {
68
+ const css = `@layer components {
69
+ /* keep tab styles centralized here for now */
70
+ }
71
+
72
+ .after { color: red; }`;
73
+ const r = promoteApplyClassesToUtility(css);
74
+ expect(r.css).toContain("@layer components");
75
+ expect(r.css).toContain("/* keep tab styles centralized here for now */");
76
+ expect(r.css).toContain(".after { color: red; }");
77
+ });
78
+
79
+ it("passes through an empty @layer components block instead of dropping it", () => {
80
+ const css = `@layer components {
81
+ }
82
+
83
+ .after { color: red; }`;
84
+ const r = promoteApplyClassesToUtility(css);
85
+ expect(r.css).toContain("@layer components");
86
+ expect(r.css).toContain(".after { color: red; }");
87
+ });
88
+
89
+ it("is a no-op when there is no @layer components block", () => {
90
+ const css = ".foo { color: red; }";
91
+ const r = promoteApplyClassesToUtility(css);
92
+ expect(r.css).toBe(css);
93
+ expect(r.notes).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe("transformCss", () => {
98
+ it("applies both fixes in sequence", () => {
99
+ const css = `@layer components {
100
+ .btn-brand {
101
+ @apply px-4;
102
+ color: theme(colors.brand.500);
103
+ }
104
+ }`;
105
+ const r = transformCss(css);
106
+ expect(r.css).toContain("@utility btn-brand {");
107
+ expect(r.css).toContain("var(--color-brand-500)");
108
+ });
109
+ });