@decocms/blocks-cli 7.20.10 → 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.
- package/package.json +2 -2
- 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.test.ts +1 -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/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
|
@@ -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
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
--color
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
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
|
+
});
|