@decocms/blocks-cli 7.21.0 → 7.22.0
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/package.json +4 -3
- package/scripts/lib/upgrade-6-to-7.test.ts +145 -0
- package/scripts/lib/upgrade-6-to-7.ts +157 -0
- package/scripts/migrate/analyzers/tailwind-config.test.ts +149 -0
- package/scripts/migrate/analyzers/tailwind-config.ts +283 -0
- package/scripts/migrate/css-compile-check.test.ts +115 -0
- package/scripts/migrate/css-compile-check.ts +137 -0
- package/scripts/migrate/phase-analyze.ts +13 -0
- package/scripts/migrate/phase-cleanup.test.ts +1 -0
- package/scripts/migrate/phase-compile.test.ts +48 -0
- package/scripts/migrate/phase-compile.ts +38 -0
- package/scripts/migrate/phase-report.test.ts +103 -0
- package/scripts/migrate/phase-report.ts +64 -4
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +134 -0
- package/scripts/migrate/phase-verify.test.ts +1 -0
- package/scripts/migrate/phase-verify.ts +24 -0
- package/scripts/migrate/post-cleanup/migration-artifacts.test.ts +262 -0
- package/scripts/migrate/post-cleanup/rules.ts +321 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +2 -0
- package/scripts/migrate/templates/app-css.test.ts +150 -0
- package/scripts/migrate/templates/app-css.ts +142 -52
- package/scripts/migrate/templates/hooks.test.ts +1 -0
- package/scripts/migrate/templates/package-json.ts +5 -0
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +51 -0
- package/scripts/migrate/templates/server-entry.ts +53 -1
- package/scripts/migrate/transforms/color-oklch.test.ts +83 -0
- package/scripts/migrate/transforms/color-oklch.ts +122 -0
- package/scripts/migrate/transforms/css.test.ts +109 -0
- package/scripts/migrate/transforms/css.ts +186 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +37 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +224 -0
- package/scripts/migrate/transforms/tailwind.test.ts +74 -0
- package/scripts/migrate/transforms/tailwind.ts +27 -123
- package/scripts/migrate/types.ts +18 -0
- package/scripts/tailwind-lint.ts +9 -54
- package/scripts/upgrade-6-to-7.test.ts +80 -0
- package/scripts/upgrade-6-to-7.ts +169 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-CSS transforms for the Tailwind v3 -> v4 migration, applied to the
|
|
3
|
+
* source site's custom CSS before it's appended to the generated
|
|
4
|
+
* `src/styles/app.css` (see templates/app-css.ts).
|
|
5
|
+
*
|
|
6
|
+
* Covers two gotchas from the migration skill (references/css-styling.md):
|
|
7
|
+
* - the v3 `theme()` CSS helper function is gone in v4 — CSS-first config
|
|
8
|
+
* means theme values live as custom properties, so `theme(colors.x.y)`
|
|
9
|
+
* must become `var(--color-x-y)` (gotcha referenced alongside #48).
|
|
10
|
+
* - a custom class defined under `@layer components { .foo { @apply ...; } }`
|
|
11
|
+
* that other code `@apply`s or applies a variant to doesn't reliably
|
|
12
|
+
* participate in v4's utility pipeline — it needs to be a `@utility`
|
|
13
|
+
* (gotcha #49).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface CssTransformResult {
|
|
17
|
+
css: string;
|
|
18
|
+
notes: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Maps a `theme()` path's first segment (Tailwind v3 `theme.colors`/
|
|
22
|
+
// `theme.extend.*` keys) to the CSS custom property prefix Tailwind v4
|
|
23
|
+
// generates for it under `@theme`.
|
|
24
|
+
const THEME_CATEGORY_TO_VAR_PREFIX: Record<string, string> = {
|
|
25
|
+
colors: "color",
|
|
26
|
+
color: "color",
|
|
27
|
+
spacing: "spacing",
|
|
28
|
+
fontFamily: "font",
|
|
29
|
+
fontSize: "text",
|
|
30
|
+
fontWeight: "font-weight",
|
|
31
|
+
borderRadius: "radius",
|
|
32
|
+
screens: "breakpoint",
|
|
33
|
+
zIndex: "z",
|
|
34
|
+
boxShadow: "shadow",
|
|
35
|
+
letterSpacing: "tracking",
|
|
36
|
+
lineHeight: "leading",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Rewrite `theme(colors.gray.100)` -> `var(--color-gray-100)`,
|
|
41
|
+
* `theme(spacing.4)` -> `var(--spacing-4)`, with fallback support:
|
|
42
|
+
* `theme(colors.gray.100, #fff)` -> `var(--color-gray-100, #fff)`.
|
|
43
|
+
* Unknown categories keep their name verbatim as the var prefix so the
|
|
44
|
+
* rewrite is still valid CSS even if not a perfect v4 token match.
|
|
45
|
+
*/
|
|
46
|
+
export function rewriteThemeHelper(css: string): CssTransformResult {
|
|
47
|
+
const notes: string[] = [];
|
|
48
|
+
const re = /\btheme\(\s*(['"]?)([\w.-]+)\1\s*(?:,\s*([^)]+))?\)/g;
|
|
49
|
+
|
|
50
|
+
const result = css.replace(re, (match, _quote, dottedPath, fallback) => {
|
|
51
|
+
const [category, ...rest] = dottedPath.split(".");
|
|
52
|
+
if (rest.length === 0) return match; // not a category.path reference, leave alone
|
|
53
|
+
|
|
54
|
+
const prefix = THEME_CATEGORY_TO_VAR_PREFIX[category] ?? category;
|
|
55
|
+
const varName = `--${prefix}-${rest.join("-")}`;
|
|
56
|
+
const replacement = fallback ? `var(${varName}, ${fallback.trim()})` : `var(${varName})`;
|
|
57
|
+
notes.push(`theme(${dottedPath}) -> var(${varName})`);
|
|
58
|
+
return replacement;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { css: result, notes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find the span of a balanced-brace block starting at `openBraceIndex`
|
|
66
|
+
* (the index of the `{`). Returns the index just after the matching `}`.
|
|
67
|
+
*/
|
|
68
|
+
function findMatchingBrace(css: string, openBraceIndex: number): number {
|
|
69
|
+
let depth = 0;
|
|
70
|
+
for (let i = openBraceIndex; i < css.length; i++) {
|
|
71
|
+
if (css[i] === "{") depth++;
|
|
72
|
+
else if (css[i] === "}") {
|
|
73
|
+
depth--;
|
|
74
|
+
if (depth === 0) return i + 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return css.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CssRule {
|
|
81
|
+
selector: string;
|
|
82
|
+
body: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Split the contents of a CSS block into its top-level rules (selector + brace-balanced body). */
|
|
86
|
+
function splitTopLevelRules(blockContent: string): CssRule[] {
|
|
87
|
+
const rules: CssRule[] = [];
|
|
88
|
+
let i = 0;
|
|
89
|
+
while (i < blockContent.length) {
|
|
90
|
+
const braceIdx = blockContent.indexOf("{", i);
|
|
91
|
+
if (braceIdx === -1) break;
|
|
92
|
+
const selector = blockContent.slice(i, braceIdx).trim();
|
|
93
|
+
const end = findMatchingBrace(blockContent, braceIdx);
|
|
94
|
+
const body = blockContent.slice(braceIdx + 1, end - 1);
|
|
95
|
+
if (selector) rules.push({ selector, body });
|
|
96
|
+
i = end;
|
|
97
|
+
}
|
|
98
|
+
return rules;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const SINGLE_CLASS_SELECTOR = /^\.([\w-]+)$/;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Promote single-class rules inside `@layer components { ... }` to
|
|
105
|
+
* top-level `@utility` blocks, which is what Tailwind v4 requires for a
|
|
106
|
+
* custom class to be `@apply`-able / variant-composable elsewhere. Rules
|
|
107
|
+
* with compound/multi-part selectors (`.foo .bar`, `.foo, .baz`) can't be
|
|
108
|
+
* mechanically promoted — they're left inside `@layer components` and
|
|
109
|
+
* flagged for manual review.
|
|
110
|
+
*/
|
|
111
|
+
export function promoteApplyClassesToUtility(css: string): CssTransformResult {
|
|
112
|
+
const notes: string[] = [];
|
|
113
|
+
const layerRe = /@layer\s+components\s*\{/g;
|
|
114
|
+
let result = "";
|
|
115
|
+
let cursor = 0;
|
|
116
|
+
let match: RegExpExecArray | null;
|
|
117
|
+
|
|
118
|
+
while ((match = layerRe.exec(css)) !== null) {
|
|
119
|
+
const blockStart = match.index;
|
|
120
|
+
const braceIdx = match.index + match[0].length - 1;
|
|
121
|
+
const blockEnd = findMatchingBrace(css, braceIdx);
|
|
122
|
+
const inner = css.slice(braceIdx + 1, blockEnd - 1);
|
|
123
|
+
|
|
124
|
+
result += css.slice(cursor, blockStart);
|
|
125
|
+
|
|
126
|
+
const rules = splitTopLevelRules(inner);
|
|
127
|
+
|
|
128
|
+
// A block with no top-level `selector { ... }` rule (empty, or only
|
|
129
|
+
// comments/at-rules) has nothing to promote or re-wrap — pass it
|
|
130
|
+
// through verbatim instead of silently dropping it, since `promoted`
|
|
131
|
+
// and `remaining` would otherwise both stay empty and nothing gets
|
|
132
|
+
// appended to `result` for this block at all.
|
|
133
|
+
if (rules.length === 0) {
|
|
134
|
+
result += css.slice(blockStart, blockEnd);
|
|
135
|
+
cursor = blockEnd;
|
|
136
|
+
layerRe.lastIndex = blockEnd;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const promoted: string[] = [];
|
|
141
|
+
const remaining: CssRule[] = [];
|
|
142
|
+
|
|
143
|
+
for (const rule of rules) {
|
|
144
|
+
const singleClass = rule.selector.match(SINGLE_CLASS_SELECTOR);
|
|
145
|
+
if (singleClass && rule.body.includes("@apply")) {
|
|
146
|
+
promoted.push(`@utility ${singleClass[1]} {${rule.body}}`);
|
|
147
|
+
notes.push(`Promoted .${singleClass[1]} from @layer components to @utility ${singleClass[1]} (Tailwind v4 requires this for @apply-ability)`);
|
|
148
|
+
} else {
|
|
149
|
+
remaining.push(rule);
|
|
150
|
+
if (rule.body.includes("@apply")) {
|
|
151
|
+
notes.push(`MANUAL: .${rule.selector} in @layer components uses @apply but has a compound selector — cannot auto-promote to @utility, verify it still resolves in Tailwind v4`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
result += promoted.join("\n\n");
|
|
157
|
+
if (promoted.length > 0 && remaining.length > 0) result += "\n\n";
|
|
158
|
+
if (remaining.length > 0) {
|
|
159
|
+
const remainingBody = remaining.map((r) => ` ${r.selector} {${r.body}}`).join("\n\n");
|
|
160
|
+
result += `@layer components {\n${remainingBody}\n}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cursor = blockEnd;
|
|
164
|
+
layerRe.lastIndex = blockEnd;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
result += css.slice(cursor);
|
|
168
|
+
|
|
169
|
+
return { css: result, notes };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Run both raw-CSS transforms in sequence. */
|
|
173
|
+
export function transformCss(css: string): CssTransformResult {
|
|
174
|
+
const notes: string[] = [];
|
|
175
|
+
let result = css;
|
|
176
|
+
|
|
177
|
+
const themeFix = rewriteThemeHelper(result);
|
|
178
|
+
result = themeFix.css;
|
|
179
|
+
notes.push(...themeFix.notes);
|
|
180
|
+
|
|
181
|
+
const utilityFix = promoteApplyClassesToUtility(result);
|
|
182
|
+
result = utilityFix.css;
|
|
183
|
+
notes.push(...utilityFix.notes);
|
|
184
|
+
|
|
185
|
+
return { css: result, notes };
|
|
186
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renameToken } from "./tailwind-renames";
|
|
3
|
+
|
|
4
|
+
describe("renameToken", () => {
|
|
5
|
+
it("renames a bare utility with no modifier prefix", () => {
|
|
6
|
+
expect(renameToken("flex-grow")).toBe("grow");
|
|
7
|
+
expect(renameToken("ring")).toBe("ring-3");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("preserves a single modifier prefix when renaming", () => {
|
|
11
|
+
expect(renameToken("md:flex-grow")).toBe("md:grow");
|
|
12
|
+
expect(renameToken("hover:ring")).toBe("hover:ring-3");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("preserves a stacked modifier chain when renaming", () => {
|
|
16
|
+
expect(renameToken("dark:hover:ring")).toBe("dark:hover:ring-3");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renames a DaisyUI class behind a modifier prefix", () => {
|
|
20
|
+
expect(renameToken("hover:badge-ghost")).toBe("hover:badge-soft");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("removes a class (and its modifier prefix) entirely when v4 drops it (e.g. transform is now automatic)", () => {
|
|
24
|
+
expect(renameToken("transform")).toBe("");
|
|
25
|
+
expect(renameToken("md:transform")).toBe("");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is a true no-op for an identity rename, even with a modifier prefix", () => {
|
|
29
|
+
expect(renameToken("transform-none")).toBe("transform-none");
|
|
30
|
+
expect(renameToken("md:transform-none")).toBe("md:transform-none");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("leaves an unknown class unchanged", () => {
|
|
34
|
+
expect(renameToken("bg-red-500")).toBe("bg-red-500");
|
|
35
|
+
expect(renameToken("md:bg-red-500")).toBe("md:bg-red-500");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for Tailwind v3 -> v4 and DaisyUI v4 -> v5 class
|
|
3
|
+
* rename tables. Previously these tables were copy-pasted into three
|
|
4
|
+
* places (`transforms/tailwind.ts`, `templates/app-css.ts`'s `@apply`
|
|
5
|
+
* rewriter, and the standalone `scripts/tailwind-lint.ts` shipped into
|
|
6
|
+
* migrated sites) and had drifted out of sync. Import from here instead of
|
|
7
|
+
* redefining a table.
|
|
8
|
+
*
|
|
9
|
+
* Renames are applied via direct map lookup on each already-tokenized
|
|
10
|
+
* utility class (not sequential string replacement), so scale-shift
|
|
11
|
+
* entries like `shadow-sm -> shadow-xs` and `shadow -> shadow-sm` can't
|
|
12
|
+
* cascade into each other in a single pass.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Direct 1:1 Tailwind v3 -> v4 utility class renames. Empty string = remove entirely. */
|
|
16
|
+
export const CLASS_RENAMES: Record<string, string> = {
|
|
17
|
+
// Flexbox/Grid
|
|
18
|
+
"flex-grow-0": "grow-0",
|
|
19
|
+
"flex-grow": "grow",
|
|
20
|
+
"flex-shrink-0": "shrink-0",
|
|
21
|
+
"flex-shrink": "shrink",
|
|
22
|
+
|
|
23
|
+
// Overflow
|
|
24
|
+
"overflow-ellipsis": "text-ellipsis",
|
|
25
|
+
|
|
26
|
+
// Decoration
|
|
27
|
+
"decoration-clone": "box-decoration-clone",
|
|
28
|
+
"decoration-slice": "box-decoration-slice",
|
|
29
|
+
|
|
30
|
+
// Transforms (v4 applies transforms automatically — the utility that used
|
|
31
|
+
// to opt in no longer exists)
|
|
32
|
+
"transform": "",
|
|
33
|
+
"transform-gpu": "",
|
|
34
|
+
"transform-none": "transform-none", // this one stays (explicit disable)
|
|
35
|
+
|
|
36
|
+
// Filters (v4 applies automatically)
|
|
37
|
+
"filter": "",
|
|
38
|
+
"backdrop-filter": "",
|
|
39
|
+
|
|
40
|
+
// Ring width default changed 3px -> 1px; explicit `ring` now means 1px,
|
|
41
|
+
// so sites relying on the old 3px default need the explicit v3 width.
|
|
42
|
+
"ring": "ring-3",
|
|
43
|
+
|
|
44
|
+
// Outline: v3 `outline-none` (outline: 2px solid transparent, for focus
|
|
45
|
+
// rings) renamed to `outline-hidden`; v4's new `outline-none` means a
|
|
46
|
+
// literal `outline: none`.
|
|
47
|
+
"outline-none": "outline-hidden",
|
|
48
|
+
|
|
49
|
+
// Shadow/blur/rounded/drop-shadow scale shifted down one step, with a new
|
|
50
|
+
// `-xs` step added at the bottom.
|
|
51
|
+
"shadow-sm": "shadow-xs",
|
|
52
|
+
"shadow": "shadow-sm",
|
|
53
|
+
"blur-sm": "blur-xs",
|
|
54
|
+
"blur": "blur-sm",
|
|
55
|
+
"rounded-sm": "rounded-xs",
|
|
56
|
+
"rounded": "rounded-sm",
|
|
57
|
+
"drop-shadow-sm": "drop-shadow-xs",
|
|
58
|
+
"drop-shadow": "drop-shadow-sm",
|
|
59
|
+
|
|
60
|
+
// Linear gradients renamed to make room for radial-gradient/conic-gradient
|
|
61
|
+
// utilities.
|
|
62
|
+
"bg-gradient-to-t": "bg-linear-to-t",
|
|
63
|
+
"bg-gradient-to-tr": "bg-linear-to-tr",
|
|
64
|
+
"bg-gradient-to-r": "bg-linear-to-r",
|
|
65
|
+
"bg-gradient-to-br": "bg-linear-to-br",
|
|
66
|
+
"bg-gradient-to-b": "bg-linear-to-b",
|
|
67
|
+
"bg-gradient-to-bl": "bg-linear-to-bl",
|
|
68
|
+
"bg-gradient-to-l": "bg-linear-to-l",
|
|
69
|
+
"bg-gradient-to-tl": "bg-linear-to-tl",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* DaisyUI v4 -> v5 class renames. Intentionally conservative — only
|
|
74
|
+
* renames with a confirmed 1:1 replacement are listed here. Structural
|
|
75
|
+
* breaks that have no mechanical class rename (collapse, btn-group,
|
|
76
|
+
* form-control — see `detectDaisyUiV5StructuralIssues` below) are flagged
|
|
77
|
+
* for manual review instead of guessed at.
|
|
78
|
+
*/
|
|
79
|
+
export const DAISYUI_RENAMES: Record<string, string> = {
|
|
80
|
+
"badge-ghost": "badge-soft",
|
|
81
|
+
"card-compact": "card-sm",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rename a single class token, preserving any `modifier:` prefix chain
|
|
86
|
+
* (`hover:`, `md:`, `dark:`, stacked or not) — e.g. `md:flex-grow` -> `md:grow`,
|
|
87
|
+
* `hover:ring` -> `hover:ring-3`. Returns `""` if the rename removes the
|
|
88
|
+
* class entirely (matching v4's dropped `transform`/`filter` utilities),
|
|
89
|
+
* or the original token unchanged if no rename applies.
|
|
90
|
+
*
|
|
91
|
+
* This is the one place that knows how to apply CLASS_RENAMES/DAISYUI_RENAMES
|
|
92
|
+
* to a token — every caller (JSX className rewriter, @apply rewriter,
|
|
93
|
+
* tailwind-lint) must go through this instead of doing its own split/lookup,
|
|
94
|
+
* or a variant-prefixed class silently stops getting renamed (as happened
|
|
95
|
+
* when the @apply rewriter did a whole-token lookup without stripping the
|
|
96
|
+
* modifier prefix first).
|
|
97
|
+
*/
|
|
98
|
+
export function renameToken(cls: string): string {
|
|
99
|
+
const parts = cls.split(":");
|
|
100
|
+
const utility = parts.pop()!;
|
|
101
|
+
|
|
102
|
+
if (CLASS_RENAMES[utility] !== undefined) {
|
|
103
|
+
const renamed = CLASS_RENAMES[utility];
|
|
104
|
+
if (renamed === "") return "";
|
|
105
|
+
if (renamed === utility) return cls;
|
|
106
|
+
parts.push(renamed);
|
|
107
|
+
return parts.join(":");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (DAISYUI_RENAMES[utility] && DAISYUI_RENAMES[utility] !== utility) {
|
|
111
|
+
parts.push(DAISYUI_RENAMES[utility]);
|
|
112
|
+
return parts.join(":");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return cls;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Spacing scale: px → Tailwind unit ───────────────────────────
|
|
119
|
+
export const PX_TO_SPACING: Record<number, string> = {};
|
|
120
|
+
for (let i = 0; i <= 96; i++) {
|
|
121
|
+
PX_TO_SPACING[i * 4] = String(i);
|
|
122
|
+
}
|
|
123
|
+
PX_TO_SPACING[2] = "0.5";
|
|
124
|
+
PX_TO_SPACING[6] = "1.5";
|
|
125
|
+
PX_TO_SPACING[10] = "2.5";
|
|
126
|
+
PX_TO_SPACING[14] = "3.5";
|
|
127
|
+
|
|
128
|
+
// Text size: px → native class
|
|
129
|
+
export const TEXT_SIZE_MAP: Record<string, string> = {
|
|
130
|
+
"12": "xs",
|
|
131
|
+
"14": "sm",
|
|
132
|
+
"16": "base",
|
|
133
|
+
"18": "lg",
|
|
134
|
+
"20": "xl",
|
|
135
|
+
"24": "2xl",
|
|
136
|
+
"30": "3xl",
|
|
137
|
+
"36": "4xl",
|
|
138
|
+
"48": "5xl",
|
|
139
|
+
"60": "6xl",
|
|
140
|
+
"72": "7xl",
|
|
141
|
+
"96": "8xl",
|
|
142
|
+
"128": "9xl",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export interface GotchaFinding {
|
|
146
|
+
/** Gotcha number in the deco-to-tanstack-migration skill's css-styling.md */
|
|
147
|
+
gotcha: number;
|
|
148
|
+
message: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Detect DaisyUI v4 classes/patterns with no mechanical v5 replacement —
|
|
153
|
+
* gotcha #37 (`.collapse` broken under Tailwind v4; `btn-group` and
|
|
154
|
+
* `form-control` were removed in DaisyUI v5 with no drop-in class rename).
|
|
155
|
+
* Returns one finding per distinct pattern found in `content`, not one per
|
|
156
|
+
* occurrence, to avoid flooding the report.
|
|
157
|
+
*/
|
|
158
|
+
export function detectDaisyUiV5StructuralIssues(content: string): GotchaFinding[] {
|
|
159
|
+
const findings: GotchaFinding[] = [];
|
|
160
|
+
|
|
161
|
+
if (/\bcollapse\b/.test(content) && /\bcollapse-(?:title|content)\b/.test(content)) {
|
|
162
|
+
findings.push({
|
|
163
|
+
gotcha: 37,
|
|
164
|
+
message:
|
|
165
|
+
"DaisyUI .collapse usage found — its expand/collapse chain breaks under Tailwind v4. " +
|
|
166
|
+
"Replace with native <details>/<summary> (see skill css-styling.md #37).",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (/\bbtn-group\b/.test(content)) {
|
|
171
|
+
findings.push({
|
|
172
|
+
gotcha: 37,
|
|
173
|
+
message:
|
|
174
|
+
"DaisyUI v4 `btn-group` was removed in v5 with no drop-in class — rebuild with `join` (`join` container + `join-item` on each button).",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (/\bform-control\b/.test(content)) {
|
|
179
|
+
findings.push({
|
|
180
|
+
gotcha: 37,
|
|
181
|
+
message:
|
|
182
|
+
"DaisyUI v4 `form-control` was removed in v5 with no drop-in class — rebuild with `fieldset` + `label`/`legend` per DaisyUI v5's form pattern.",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const MIXED_AXIS_GROUPS: Array<{ shorthand: string; sides: [string, string] }> = [
|
|
190
|
+
{ shorthand: "px", sides: ["pl", "pr"] },
|
|
191
|
+
{ shorthand: "mx", sides: ["ml", "mr"] },
|
|
192
|
+
{ shorthand: "py", sides: ["pt", "pb"] },
|
|
193
|
+
{ shorthand: "my", sides: ["mt", "mb"] },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Detect gotcha #42: an element mixing a shorthand spacing utility (`px-*`)
|
|
198
|
+
* with one of its longhand sides (`pl-*`/`pr-*`), possibly under different
|
|
199
|
+
* responsive modifiers. Tailwind v4 emits logical properties
|
|
200
|
+
* (`padding-inline` vs `padding-inline-start`), so the v3 "last one in the
|
|
201
|
+
* cascade wins" behavior no longer holds across shorthand/longhand pairs.
|
|
202
|
+
* `classes` is a single className string's already-split token list.
|
|
203
|
+
*/
|
|
204
|
+
export function detectLogicalPropertyConflict(classes: string[]): GotchaFinding[] {
|
|
205
|
+
const findings: GotchaFinding[] = [];
|
|
206
|
+
const utilities = classes.map((c) => c.split(":").pop() ?? c);
|
|
207
|
+
|
|
208
|
+
for (const { shorthand, sides } of MIXED_AXIS_GROUPS) {
|
|
209
|
+
const hasShorthand = utilities.some((u) => new RegExp(`^-?${shorthand}-`).test(u));
|
|
210
|
+
const hasSide = utilities.some((u) => sides.some((s) => new RegExp(`^-?${s}-`).test(u)));
|
|
211
|
+
if (hasShorthand && hasSide) {
|
|
212
|
+
findings.push({
|
|
213
|
+
gotcha: 42,
|
|
214
|
+
message:
|
|
215
|
+
`Mixed "${shorthand}-*" with "${sides[0]}-*"/"${sides[1]}-*" in the same className — ` +
|
|
216
|
+
`Tailwind v4's logical properties (padding-inline vs padding-inline-start) don't cascade the same as v3's physical properties. ` +
|
|
217
|
+
`Replace the shorthand with explicit longhand at every breakpoint (see skill css-styling.md #42).`,
|
|
218
|
+
});
|
|
219
|
+
break; // one finding per className string is enough signal
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return findings;
|
|
224
|
+
}
|