@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.
Files changed (37) hide show
  1. package/package.json +4 -3
  2. package/scripts/lib/upgrade-6-to-7.test.ts +145 -0
  3. package/scripts/lib/upgrade-6-to-7.ts +157 -0
  4. package/scripts/migrate/analyzers/tailwind-config.test.ts +149 -0
  5. package/scripts/migrate/analyzers/tailwind-config.ts +283 -0
  6. package/scripts/migrate/css-compile-check.test.ts +115 -0
  7. package/scripts/migrate/css-compile-check.ts +137 -0
  8. package/scripts/migrate/phase-analyze.ts +13 -0
  9. package/scripts/migrate/phase-cleanup.test.ts +1 -0
  10. package/scripts/migrate/phase-compile.test.ts +48 -0
  11. package/scripts/migrate/phase-compile.ts +38 -0
  12. package/scripts/migrate/phase-report.test.ts +103 -0
  13. package/scripts/migrate/phase-report.ts +64 -4
  14. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +134 -0
  15. package/scripts/migrate/phase-verify.test.ts +1 -0
  16. package/scripts/migrate/phase-verify.ts +24 -0
  17. package/scripts/migrate/post-cleanup/migration-artifacts.test.ts +262 -0
  18. package/scripts/migrate/post-cleanup/rules.ts +321 -0
  19. package/scripts/migrate/post-cleanup/runner.test.ts +2 -0
  20. package/scripts/migrate/templates/app-css.test.ts +150 -0
  21. package/scripts/migrate/templates/app-css.ts +142 -52
  22. package/scripts/migrate/templates/hooks.test.ts +1 -0
  23. package/scripts/migrate/templates/package-json.ts +5 -0
  24. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +51 -0
  25. package/scripts/migrate/templates/server-entry.ts +53 -1
  26. package/scripts/migrate/transforms/color-oklch.test.ts +83 -0
  27. package/scripts/migrate/transforms/color-oklch.ts +122 -0
  28. package/scripts/migrate/transforms/css.test.ts +109 -0
  29. package/scripts/migrate/transforms/css.ts +186 -0
  30. package/scripts/migrate/transforms/tailwind-renames.test.ts +37 -0
  31. package/scripts/migrate/transforms/tailwind-renames.ts +224 -0
  32. package/scripts/migrate/transforms/tailwind.test.ts +74 -0
  33. package/scripts/migrate/transforms/tailwind.ts +27 -123
  34. package/scripts/migrate/types.ts +18 -0
  35. package/scripts/tailwind-lint.ts +9 -54
  36. package/scripts/upgrade-6-to-7.test.ts +80 -0
  37. 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
- * 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,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: vtexActions,
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
+ });