@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,150 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createContext } from "../types";
|
|
6
|
+
import { generateAppCss } from "./app-css";
|
|
7
|
+
|
|
8
|
+
describe("generateAppCss — tailwind.config.ts porting", () => {
|
|
9
|
+
it("emits custom colors, fonts, and breakpoints ported from tailwind.config.ts", () => {
|
|
10
|
+
const ctx = createContext("/tmp/does-not-exist");
|
|
11
|
+
ctx.tailwindConfig = {
|
|
12
|
+
colors: { perola: "#F5F0E8", "brand-500": "#B10200" },
|
|
13
|
+
fontFamily: { "bebas-neue": "Bebas Neue, sans-serif" },
|
|
14
|
+
screens: { "3xl": "1920px" },
|
|
15
|
+
safelist: ["bg-red-500", "text-brand-500"],
|
|
16
|
+
safelistPatterns: [],
|
|
17
|
+
plugins: [],
|
|
18
|
+
reviewItems: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const css = generateAppCss(ctx);
|
|
22
|
+
|
|
23
|
+
expect(css).toContain("--color-perola: #F5F0E8;");
|
|
24
|
+
expect(css).toContain("--color-brand-500: #B10200;");
|
|
25
|
+
expect(css).toContain("--font-bebas-neue: Bebas Neue, sans-serif;");
|
|
26
|
+
expect(css).toContain("--breakpoint-3xl: 1920px;");
|
|
27
|
+
expect(css).toContain('@source inline("bg-red-500 text-brand-500");');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not duplicate a custom color that collides with a daisyUI semantic key", () => {
|
|
31
|
+
const ctx = createContext("/tmp/does-not-exist");
|
|
32
|
+
ctx.tailwindConfig.colors = { primary: "#000000" };
|
|
33
|
+
|
|
34
|
+
const css = generateAppCss(ctx);
|
|
35
|
+
// The daisyUI theme's own --color-primary is emitted once in the plugin
|
|
36
|
+
// block; the ported tailwind.config.ts value must not add a second,
|
|
37
|
+
// conflicting @theme declaration.
|
|
38
|
+
const themeBlockMatch = css.match(/@theme \{[\s\S]*?\n\}/);
|
|
39
|
+
expect(themeBlockMatch).not.toBeNull();
|
|
40
|
+
const occurrences = (themeBlockMatch![0].match(/--color-primary:/g) ?? []).length;
|
|
41
|
+
expect(occurrences).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("flags unconvertible safelist regex patterns as manual review items", () => {
|
|
45
|
+
const ctx = createContext("/tmp/does-not-exist");
|
|
46
|
+
ctx.tailwindConfig.safelistPatterns = ["/^grid-cols-/"];
|
|
47
|
+
|
|
48
|
+
generateAppCss(ctx);
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
ctx.manualReviewItems.some((i) => i.reason.includes("/^grid-cols-/")),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses a custom tailwind.config.ts gray-* override instead of declaring the v3 default gray scale a second time", () => {
|
|
56
|
+
const ctx = createContext("/tmp/does-not-exist");
|
|
57
|
+
ctx.tailwindConfig.colors = { "gray-50": "#111111", "gray-500": "#222222" };
|
|
58
|
+
|
|
59
|
+
const css = generateAppCss(ctx);
|
|
60
|
+
const themeBlockMatch = css.match(/@theme \{[\s\S]*?\n\}/);
|
|
61
|
+
expect(themeBlockMatch).not.toBeNull();
|
|
62
|
+
const themeBlock = themeBlockMatch![0];
|
|
63
|
+
|
|
64
|
+
// gray-50/500 must be declared exactly once, with the custom value —
|
|
65
|
+
// not once with the custom value and once more with the hardcoded
|
|
66
|
+
// default (which would silently win via CSS's last-declaration-wins).
|
|
67
|
+
expect((themeBlock.match(/--color-gray-50:/g) ?? []).length).toBe(1);
|
|
68
|
+
expect(themeBlock).toContain("--color-gray-50: #111111;");
|
|
69
|
+
expect(themeBlock).not.toContain("--color-gray-50: #f9fafb;");
|
|
70
|
+
expect((themeBlock.match(/--color-gray-500:/g) ?? []).length).toBe(1);
|
|
71
|
+
expect(themeBlock).toContain("--color-gray-500: #222222;");
|
|
72
|
+
// Untouched shades still fall back to the v3 default.
|
|
73
|
+
expect(themeBlock).toContain("--color-gray-950: #030712;");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("generateAppCss — oklch fixer scope (gotcha #43)", () => {
|
|
78
|
+
let tmpDir: string;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "app-css-oklch-scope-test-"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not rewrite a hex/oklch(var()) pair declared in the site's own original CSS", () => {
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(tmpDir, "tailwind.css"),
|
|
91
|
+
`@tailwind base;
|
|
92
|
+
@tailwind components;
|
|
93
|
+
@tailwind utilities;
|
|
94
|
+
|
|
95
|
+
:root {
|
|
96
|
+
--icon-color: #1a1a1a;
|
|
97
|
+
}
|
|
98
|
+
.icon {
|
|
99
|
+
fill: oklch(var(--icon-color));
|
|
100
|
+
}
|
|
101
|
+
`,
|
|
102
|
+
"utf-8",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const ctx = createContext(tmpDir);
|
|
106
|
+
const css = generateAppCss(ctx);
|
|
107
|
+
|
|
108
|
+
// The site's own declaration must survive untouched — the oklch fixer
|
|
109
|
+
// must not reach into content it doesn't own.
|
|
110
|
+
expect(css).toContain("--icon-color: #1a1a1a;");
|
|
111
|
+
expect(css).not.toMatch(/--icon-color:\s*[\d.]+\s+[\d.]+\s+[\d.]+;/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("generateAppCss — @apply rewrites classes behind a modifier prefix", () => {
|
|
116
|
+
let tmpDir: string;
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "app-css-apply-variant-test-"));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renames a variant-prefixed class inside @apply, not just bare tokens", () => {
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
path.join(tmpDir, "tailwind.css"),
|
|
129
|
+
`@tailwind base;
|
|
130
|
+
@tailwind components;
|
|
131
|
+
@tailwind utilities;
|
|
132
|
+
|
|
133
|
+
@layer components {
|
|
134
|
+
.btn-brand {
|
|
135
|
+
@apply md:flex-grow hover:ring rounded;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
"utf-8",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const ctx = createContext(tmpDir);
|
|
143
|
+
const css = generateAppCss(ctx);
|
|
144
|
+
|
|
145
|
+
expect(css).toContain("md:grow");
|
|
146
|
+
expect(css).toContain("hover:ring-3");
|
|
147
|
+
expect(css).not.toContain("md:flex-grow");
|
|
148
|
+
expect(css).not.toMatch(/hover:ring(?!-3)/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -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,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createContext } from "../types";
|
|
3
|
+
import type { MigrationContext } from "../types";
|
|
4
|
+
import { generateServerEntry } from "./server-entry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression guard for #368: createUseUser/createUseWishlist
|
|
8
|
+
* (@decocms/apps-vtex/hooks) require invoke.vtex.loaders.user/wishlist and
|
|
9
|
+
* invoke.vtex.actions.addToWishlist/removeFromWishlist. Before this fix, the
|
|
10
|
+
* scaffolded invoke only wired `vtex.actions` from invoke.gen.ts, so any
|
|
11
|
+
* scaffolded site rendering useUser()/useWishlist() crashed the Header on
|
|
12
|
+
* mount (`Cannot read properties of undefined (reading 'user')`).
|
|
13
|
+
*/
|
|
14
|
+
function makeVtexCtx(): MigrationContext {
|
|
15
|
+
const ctx = createContext("/tmp/vtex-invoke-scaffold-fixture-site");
|
|
16
|
+
ctx.siteName = "acme-storefront";
|
|
17
|
+
ctx.platform = "vtex";
|
|
18
|
+
ctx.vtexAccount = "acme";
|
|
19
|
+
return ctx;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("VTEX invoke scaffold wires vtex.loaders + wishlist actions (#368)", () => {
|
|
23
|
+
const files = generateServerEntry(makeVtexCtx());
|
|
24
|
+
const invokeSrc = files["src/server/invoke.ts"];
|
|
25
|
+
|
|
26
|
+
it("scaffolds invoke.vtex.loaders.user and invoke.vtex.loaders.wishlist", () => {
|
|
27
|
+
expect(invokeSrc).toContain("loaders: {");
|
|
28
|
+
expect(invokeSrc).toContain("user: _vtexUser");
|
|
29
|
+
expect(invokeSrc).toContain("wishlist: _vtexWishlist");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("scaffolds invoke.vtex.actions.addToWishlist and removeFromWishlist", () => {
|
|
33
|
+
expect(invokeSrc).toContain("addToWishlist: _addToWishlist");
|
|
34
|
+
expect(invokeSrc).toContain("removeFromWishlist: _removeFromWishlist");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("still spreads the generated checkout/session/masterData/newsletter actions", () => {
|
|
38
|
+
expect(invokeSrc).toContain("...vtexActions,");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("imports getUser/getWishlist/addItem/removeItem from @decocms/apps-vtex", () => {
|
|
42
|
+
expect(invokeSrc).toContain('from "@decocms/apps-vtex/loaders/user"');
|
|
43
|
+
expect(invokeSrc).toContain('from "@decocms/apps-vtex/loaders/wishlist"');
|
|
44
|
+
expect(invokeSrc).toContain('from "@decocms/apps-vtex/actions/wishlist"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("derives shopperId from the auth JWT instead of a hardcoded value", () => {
|
|
48
|
+
expect(invokeSrc).toContain("getShopperId");
|
|
49
|
+
expect(invokeSrc).toContain("parseVtexAuthJwt");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -326,8 +326,12 @@ ${hasVtexAuthLoader ? `import vtexAuthLoader from "../loaders/vtex-auth-loader";
|
|
|
326
326
|
performVtexLogout,
|
|
327
327
|
parseVtexAuthJwt,
|
|
328
328
|
} from "@decocms/apps-vtex/utils/authHelpers";
|
|
329
|
+
import { getUser, type Person } from "@decocms/apps-vtex/loaders/user";
|
|
330
|
+
import { getWishlist, type WishlistItem } from "@decocms/apps-vtex/loaders/wishlist";
|
|
331
|
+
import { addItem as addToWishlistItem, removeItem as removeFromWishlistItem } from "@decocms/apps-vtex/actions/wishlist";
|
|
329
332
|
|
|
330
333
|
export type { OrderForm } from "./invoke.gen";
|
|
334
|
+
export type { Person, WishlistItem };
|
|
331
335
|
|
|
332
336
|
function getVtexCookies(): string {
|
|
333
337
|
return extractVtexCookiesFromHeader(getRequestHeader("cookie") ?? "");
|
|
@@ -361,9 +365,57 @@ const _getUserFromJwt = createServerFn({ method: "POST" }).handler(
|
|
|
361
365
|
},
|
|
362
366
|
);
|
|
363
367
|
|
|
368
|
+
// createUseUser/createUseWishlist (@decocms/apps-vtex/hooks) require
|
|
369
|
+
// invoke.vtex.loaders.user/wishlist and invoke.vtex.actions.addToWishlist/
|
|
370
|
+
// removeFromWishlist — without these the hooks throw on mount and take
|
|
371
|
+
// down the whole section (e.g. the Header) via the error boundary.
|
|
372
|
+
function getShopperId(): string | null {
|
|
373
|
+
return parseVtexAuthJwt(getRequestHeader("cookie") ?? "")?.email ?? null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const _vtexUser = createServerFn({ method: "GET" }).handler(
|
|
377
|
+
async (): Promise<Person | null> => {
|
|
378
|
+
return getUser(getVtexCookies());
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const _vtexWishlist = createServerFn({ method: "GET" }).handler(
|
|
383
|
+
async (): Promise<WishlistItem[]> => {
|
|
384
|
+
const shopperId = getShopperId();
|
|
385
|
+
if (!shopperId) return [];
|
|
386
|
+
return getWishlist(getVtexCookies(), { shopperId, allRecords: true });
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const _addToWishlist = createServerFn({ method: "POST" })
|
|
391
|
+
.inputValidator(
|
|
392
|
+
(data: { data: { productId: string; sku: string; title?: string } }) => data,
|
|
393
|
+
)
|
|
394
|
+
.handler(async ({ data }): Promise<WishlistItem[]> => {
|
|
395
|
+
const shopperId = getShopperId();
|
|
396
|
+
if (!shopperId) throw new Error("User must be logged in to add to wishlist");
|
|
397
|
+
return addToWishlistItem(data.data, shopperId, getVtexCookies());
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const _removeFromWishlist = createServerFn({ method: "POST" })
|
|
401
|
+
.inputValidator((data: { data: { id: string } }) => data)
|
|
402
|
+
.handler(async ({ data }): Promise<WishlistItem[]> => {
|
|
403
|
+
const shopperId = getShopperId();
|
|
404
|
+
if (!shopperId) throw new Error("User must be logged in to remove from wishlist");
|
|
405
|
+
return removeFromWishlistItem(data.data.id, shopperId, getVtexCookies());
|
|
406
|
+
});
|
|
407
|
+
|
|
364
408
|
export const invoke = {
|
|
365
409
|
vtex: {
|
|
366
|
-
actions:
|
|
410
|
+
actions: {
|
|
411
|
+
...vtexActions,
|
|
412
|
+
addToWishlist: _addToWishlist,
|
|
413
|
+
removeFromWishlist: _removeFromWishlist,
|
|
414
|
+
},
|
|
415
|
+
loaders: {
|
|
416
|
+
user: _vtexUser,
|
|
417
|
+
wishlist: _vtexWishlist,
|
|
418
|
+
},
|
|
367
419
|
},
|
|
368
420
|
site: {
|
|
369
421
|
loaders: {
|
|
@@ -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
|
+
});
|