@decocms/blocks-cli 7.0.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 (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,366 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { MigrationContext } from "../types";
4
+ import type { ExtractedTheme } from "../analyzers/theme-extractor";
5
+
6
+ /**
7
+ * Find the original site's custom CSS file.
8
+ * Deco sites typically have their custom CSS in:
9
+ * - tailwind.css (root level, combined directives + custom)
10
+ * - static/tailwind.css (compiled output — skip this)
11
+ * - static-{brand}/tailwind.css (compiled output — skip this)
12
+ * - styles/*.css
13
+ */
14
+ function findOriginalCss(ctx: MigrationContext): string | null {
15
+ // Prefer root tailwind.css (has custom CSS + directives)
16
+ const rootCss = path.join(ctx.sourceDir, "tailwind.css");
17
+ if (fs.existsSync(rootCss)) {
18
+ return fs.readFileSync(rootCss, "utf-8");
19
+ }
20
+
21
+ // Check for styles/ directory
22
+ const stylesDir = path.join(ctx.sourceDir, "styles");
23
+ if (fs.existsSync(stylesDir)) {
24
+ for (const file of fs.readdirSync(stylesDir)) {
25
+ if (file.endsWith(".css") && file !== "tailwind.css") {
26
+ return fs.readFileSync(path.join(stylesDir, file), "utf-8");
27
+ }
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Extract custom CSS from the original site's CSS file.
36
+ * Strips TW3 directives (@tailwind base/components/utilities) and
37
+ * returns only the custom CSS (component overrides, @layer, @font-face, etc.)
38
+ */
39
+ function extractCustomCss(rawCss: string): string {
40
+ return rawCss
41
+ // Remove Tailwind v3 directives (replaced by @import "tailwindcss")
42
+ .replace(/^@tailwind\s+(?:base|components|utilities)\s*;\s*$/gm, "")
43
+ // Remove empty lines left over
44
+ .replace(/^\s*\n/gm, "")
45
+ .trim();
46
+ }
47
+
48
+ /**
49
+ * Transform @apply directives for TW3→TW4 compatibility.
50
+ * 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.
55
+ */
56
+ 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);
86
+ }
87
+
88
+ /**
89
+ * Extract the primary font family from @font-face declarations in CSS.
90
+ * Returns the first font-family name found, or null.
91
+ */
92
+ function extractPrimaryFontFromCss(css: string): string | null {
93
+ const fontFaceRe = /@font-face\s*\{[^}]*font-family:\s*["']?([^"';]+)["']?\s*;/g;
94
+ const families = new Set<string>();
95
+ let match;
96
+ while ((match = fontFaceRe.exec(css)) !== null) {
97
+ families.add(match[1].trim());
98
+ }
99
+ if (families.size === 0) return null;
100
+ // Prefer non-icon fonts
101
+ const nonIcon = [...families].filter(
102
+ (f) => !/icon|awesome|material/i.test(f),
103
+ );
104
+ return nonIcon[0] || [...families][0];
105
+ }
106
+
107
+ export function generateAppCss(ctx: MigrationContext, theme?: ExtractedTheme): string {
108
+ const sections: string[] = [];
109
+
110
+ // ── DaisyUI theme plugin ──────────────────────────────────────────
111
+ const daisyColors = theme?.daisyUiColors ?? {};
112
+ const c = ctx.themeColors;
113
+
114
+ const semanticColors: Record<string, string> = {
115
+ "--color-primary": daisyColors["--color-primary"] || c["primary"] || "#B10200",
116
+ "--color-secondary": daisyColors["--color-secondary"] || c["secondary"] || "#141414",
117
+ "--color-accent": daisyColors["--color-accent"] || c["tertiary"] || "#FFF100",
118
+ "--color-neutral": daisyColors["--color-neutral"] || c["neutral"] || "#393939",
119
+ "--color-base-100": daisyColors["--color-base-100"] || c["base-100"] || "#FFFFFF",
120
+ "--color-base-200": daisyColors["--color-base-200"] || c["base-200"] || "#F3F3F3",
121
+ "--color-base-300": daisyColors["--color-base-300"] || c["base-300"] || "#868686",
122
+ "--color-info": daisyColors["--color-info"] || c["info"] || "#006CA1",
123
+ "--color-success": daisyColors["--color-success"] || c["success"] || "#007552",
124
+ "--color-warning": daisyColors["--color-warning"] || c["warning"] || "#F8D13A",
125
+ "--color-error": daisyColors["--color-error"] || c["error"] || "#CF040A",
126
+ };
127
+
128
+ const colorLines = Object.entries(semanticColors)
129
+ .map(([k, v]) => ` ${k}: ${v};`)
130
+ .join("\n");
131
+
132
+ sections.push(`@import "tailwindcss";
133
+ @plugin "daisyui";
134
+ @plugin "daisyui/theme" {
135
+ name: "light";
136
+ default: true;
137
+ color-scheme: light;
138
+ ${colorLines}
139
+ }`);
140
+
141
+ // ── @theme block: Tailwind v3->v4 color migration ─────────────────
142
+ // Determine font family from theme, context, or original CSS @font-face
143
+ let fontFamily = theme?.fontFamily || ctx.fontFamily;
144
+ if (!fontFamily) {
145
+ const originalCssForFont = findOriginalCss(ctx);
146
+ if (originalCssForFont) {
147
+ const extracted = extractPrimaryFontFromCss(originalCssForFont);
148
+ if (extracted) fontFamily = extracted;
149
+ }
150
+ }
151
+ let fontLine = "";
152
+ if (fontFamily) {
153
+ const firstFont = fontFamily.split(",")[0].trim().replace(/['"]/g, "");
154
+ fontLine = `\n --font-sans: "${firstFont}", ui-sans-serif, system-ui, sans-serif;`;
155
+ }
156
+
157
+ let themeBlock = `/* Tailwind v4: reset default palette (old sites replaced it entirely via theme.colors)
158
+ then re-add only the colors used by this site. */
159
+ @theme {
160
+ --color-*: initial;
161
+
162
+ --color-white: #fff;
163
+ --color-black: #000;
164
+ --color-transparent: transparent;
165
+ --color-current: currentColor;
166
+ --color-inherit: inherit;${fontLine}`;
167
+
168
+ // Add extracted theme variables to @theme
169
+ // Theme variables come from the CMS Theme section (.deco/blocks/Theme-*.json).
170
+ // The CMS sets CSS custom properties on :root at runtime, so @theme entries
171
+ // should reference var(--x) instead of hardcoding values. For oklch-formatted
172
+ // values (space-separated numbers like "0.5 0.2 30"), wrap with oklch().
173
+ const vars = theme?.variables ?? {};
174
+ if (Object.keys(vars).length > 0) {
175
+ themeBlock += `\n`;
176
+ const grouped: Record<string, Array<[string, string]>> = {};
177
+ for (const [k, v] of Object.entries(vars)) {
178
+ const prefix = k.replace(/^--/, "").split("-").slice(0, 2).join("-");
179
+ if (!grouped[prefix]) grouped[prefix] = [];
180
+ grouped[prefix].push([k, v]);
181
+ }
182
+
183
+ for (const [prefix, entries] of Object.entries(grouped)) {
184
+ themeBlock += `\n /* ${prefix} */`;
185
+ for (const [k, v] of entries) {
186
+ if (!k.startsWith("--color-") && !k.startsWith("--font-") && !k.startsWith("--breakpoint-")) {
187
+ const val = v.trim();
188
+ const isColor = /^#[0-9a-fA-F]{3,8}$/.test(val) ||
189
+ /^(rgb|hsl|oklch|oklab)\(/.test(val) ||
190
+ /^(transparent|currentColor|inherit)$/.test(val) ||
191
+ isOklchCoordinates(val);
192
+ if (isColor) {
193
+ const varName = k.replace(/^--/, "");
194
+ const colorKey = `--color-${varName}`;
195
+ if (isOklchCoordinates(val)) {
196
+ themeBlock += `\n ${colorKey}: oklch(var(${k}));`;
197
+ } else {
198
+ themeBlock += `\n ${colorKey}: var(${k});`;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // Gray scale compat (Tailwind v3 had gray-50..gray-950 by default)
207
+ themeBlock += `
208
+
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
+ }`;
222
+ sections.push(themeBlock);
223
+
224
+ // ── DaisyUI v5 compat ─────────────────────────────────────────────
225
+ sections.push(`/* DaisyUI v5: flatten depth/noise to match v4 look */
226
+ :root {
227
+ --depth: 0;
228
+ --noise: 0;
229
+ }`);
230
+
231
+ // ── :root theme variables ─────────────────────────────────────────
232
+ if (Object.keys(vars).length > 0) {
233
+ let rootBlock = `:root {\n`;
234
+ for (const [k, v] of Object.entries(vars)) {
235
+ rootBlock += ` ${k}: ${v};\n`;
236
+ }
237
+ if (fontFamily) {
238
+ const firstFont = fontFamily.split(",")[0].trim().replace(/['"]/g, "");
239
+ rootBlock += ` --font-family: ${firstFont}, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;\n`;
240
+ }
241
+ rootBlock += `}`;
242
+ sections.push(rootBlock);
243
+ }
244
+
245
+ // ── DaisyUI v5 carousel overflow fix ──────────────────────────────
246
+ sections.push(`/* DaisyUI v5 removed carousel overflow — re-add for horizontal scroll */
247
+ .carousel {
248
+ -webkit-overflow-scrolling: touch;
249
+ overflow-x: auto;
250
+ scroll-snap-type: x mandatory;
251
+ scroll-behavior: smooth;
252
+ }
253
+
254
+ .carousel > * {
255
+ scroll-snap-align: start;
256
+ }
257
+
258
+ ul.carousel,
259
+ ol.carousel {
260
+ list-style: none;
261
+ padding: 0;
262
+ }`);
263
+
264
+ // ── Container utility (v3 -> v4 migration) ────────────────────────
265
+ sections.push(`/* Container: replaces Tailwind v3 container plugin config */
266
+ @utility container {
267
+ margin-inline: auto;
268
+ padding-inline: 1rem;
269
+ width: 100%;
270
+
271
+ @media (width >= 640px) { max-width: 640px; }
272
+ @media (width >= 768px) { max-width: 768px; }
273
+ @media (width >= 1024px) { max-width: 1024px; }
274
+ @media (width >= 1280px) { max-width: 1280px; }
275
+ @media (width >= 1536px) { max-width: 1536px; }
276
+ }`);
277
+
278
+ // ── Deferred section visibility ───────────────────────────────────
279
+ sections.push(`/* Deferred section visibility — reduces layout shift while loading */
280
+ section[data-deferred="true"] {
281
+ content-visibility: auto;
282
+ contain-intrinsic-size: auto 300px;
283
+ }
284
+
285
+ .deferred-section {
286
+ content-visibility: auto;
287
+ contain-intrinsic-size: auto 300px;
288
+ }`);
289
+
290
+ // ── View transitions ──────────────────────────────────────────────
291
+ sections.push(`@view-transition {
292
+ navigation: auto;
293
+ }`);
294
+
295
+ // ── Base layer resets ─────────────────────────────────────────────
296
+ sections.push(`@layer base {
297
+ *,
298
+ *::before,
299
+ *::after {
300
+ box-sizing: border-box;
301
+ }
302
+
303
+ body {
304
+ -webkit-font-smoothing: antialiased;
305
+ -moz-osx-font-smoothing: grayscale;
306
+ }
307
+
308
+ img,
309
+ picture,
310
+ video,
311
+ canvas,
312
+ svg {
313
+ display: block;
314
+ max-width: 100%;
315
+ }
316
+
317
+ /* Drawer / modal scroll lock */
318
+ body:has(dialog[open]),
319
+ body:has(.drawer-toggle:checked) {
320
+ overflow: hidden;
321
+ }
322
+
323
+ /* Tailwind v3 → v4 compat: restore the default cursor:pointer on interactive
324
+ elements. Tailwind v4 dropped this from Preflight, leaving Fresh-era sites
325
+ with hundreds of buttons looking unclickable. See:
326
+ https://tailwindcss.com/docs/upgrade-guide#default-button-cursor */
327
+ button:not(:disabled),
328
+ [role="button"]:not([aria-disabled="true"]),
329
+ label[for],
330
+ summary {
331
+ cursor: pointer;
332
+ }
333
+ button:disabled,
334
+ [role="button"][aria-disabled="true"] {
335
+ cursor: not-allowed;
336
+ }
337
+ }`);
338
+
339
+ // ── Scrollbar utility ─────────────────────────────────────────────
340
+ sections.push(`.scrollbar-none {
341
+ scrollbar-width: none;
342
+ -ms-overflow-style: none;
343
+ }
344
+ .scrollbar-none::-webkit-scrollbar {
345
+ display: none;
346
+ }`);
347
+
348
+ // ── Incorporate original site's custom CSS ────────────────────
349
+ // Instead of throwing away the site's CSS, we extract and append
350
+ // all custom rules (component overrides, @layer base, @font-face,
351
+ // typography utilities, feature-specific CSS, etc.)
352
+ const originalCss = findOriginalCss(ctx);
353
+ if (originalCss) {
354
+ const customCss = extractCustomCss(originalCss);
355
+ if (customCss) {
356
+ const transformed = transformApplyDirectives(customCss);
357
+ sections.push(`/* ═══════════════════════════════════════════════════════════════
358
+ Original site CSS (migrated from tailwind.css)
359
+ ═══════════════════════════════════════════════════════════════ */
360
+
361
+ ${transformed}`);
362
+ }
363
+ }
364
+
365
+ return sections.join("\n\n") + "\n";
366
+ }
@@ -0,0 +1,26 @@
1
+ import type { MigrationContext } from "../types";
2
+
3
+ export function generateCacheConfig(ctx: MigrationContext): string {
4
+ if (ctx.platform !== "vtex") {
5
+ return `/**
6
+ * Cache configuration — customize edge cache profiles per route type.
7
+ * See @decocms/start/sdk/cacheHeaders for available profiles.
8
+ */
9
+ // import { setCacheProfile } from "@decocms/start/sdk/cacheHeaders";
10
+ // setCacheProfile("product", { sMaxAge: 300, staleWhileRevalidate: 600 });
11
+ `;
12
+ }
13
+
14
+ return `/**
15
+ * Cache configuration for VTEX storefront.
16
+ * Overrides default cache profiles from @decocms/start/sdk/cacheHeaders.
17
+ */
18
+ // import { setCacheProfile } from "@decocms/start/sdk/cacheHeaders";
19
+
20
+ // Uncomment and adjust as needed:
21
+ // setCacheProfile("product", { sMaxAge: 300, staleWhileRevalidate: 600 });
22
+ // setCacheProfile("listing", { sMaxAge: 120, staleWhileRevalidate: 300 });
23
+ // setCacheProfile("search", { sMaxAge: 60, staleWhileRevalidate: 120 });
24
+ // setCacheProfile("static", { sMaxAge: 86400, staleWhileRevalidate: 172800 });
25
+ `;
26
+ }
@@ -0,0 +1,230 @@
1
+ import type { MigrationContext } from "../types";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ function hasLoaderByName(ctx: MigrationContext, name: string): boolean {
6
+ return ctx.loaderInventory.some(
7
+ (l) => l.path.toLowerCase().includes(name.toLowerCase()),
8
+ );
9
+ }
10
+
11
+ function fileExists(ctx: MigrationContext, relPath: string): boolean {
12
+ const full = path.join(ctx.sourceDir, relPath);
13
+ if (fs.existsSync(full)) return true;
14
+ const src = path.join(ctx.sourceDir, "src", relPath);
15
+ return fs.existsSync(src);
16
+ }
17
+
18
+ export function generateCommerceLoaders(ctx: MigrationContext): string {
19
+ const lines: string[] = [];
20
+ const hasSecrets = fileExists(ctx, "utils/secrets.ts") || fileExists(ctx, "src/utils/secrets.ts");
21
+ const hasProductReviews = hasLoaderByName(ctx, "reviews/productReviews");
22
+ const hasBuyTogether = hasLoaderByName(ctx, "product/buyTogether");
23
+ const hasAutocomplete = hasLoaderByName(ctx, "intelligenseSearch") || hasLoaderByName(ctx, "intelligentSearch");
24
+ const hasVtexAuth = hasLoaderByName(ctx, "vtex-auth-loader");
25
+ const hasCollectionPLP = hasLoaderByName(ctx, "productListPageCollection");
26
+ const hasStores = hasLoaderByName(ctx, "stores");
27
+ const hasProductCard = hasLoaderByName(ctx, "Layouts/ProductCard");
28
+ const hasSitename = fileExists(ctx, "utils/sitename.ts") || fileExists(ctx, "src/utils/sitename.ts");
29
+
30
+ lines.push(`/**`);
31
+ lines.push(` * Commerce Loaders — data fetchers registered for CMS block resolution.`);
32
+ lines.push(` *`);
33
+ lines.push(` * Standard VTEX loaders come from createVtexCommerceLoaders().`);
34
+ lines.push(` * Auto-generated pass-throughs come from loaders.gen.ts.`);
35
+ lines.push(` * This file only contains entries that need custom wiring`);
36
+ lines.push(` * (cookie injection, secrets, serialization, etc.).`);
37
+ lines.push(` */`);
38
+
39
+ if (ctx.platform === "vtex") {
40
+ lines.push(`import { getVtexConfig } from "@decocms/apps/vtex";`);
41
+ lines.push(`import { createVtexCommerceLoaders, createCachedPDPLoader } from "@decocms/apps/vtex/commerceLoaders";`);
42
+ if (hasAutocomplete) {
43
+ lines.push(`import { autocompleteSearch } from "@decocms/apps/vtex/loaders/autocomplete";`);
44
+ }
45
+ lines.push(`import { getAddressByPostalCode } from "@decocms/apps/vtex/loaders/address";`);
46
+ lines.push(`import { createAddressFromRequest, updateAddressFromRequest, deleteAddressFromRequest } from "@decocms/apps/vtex/actions/address";`);
47
+ lines.push(`import { updateProfileFromRequest, newsletterProfileFromRequest, deletePaymentFromRequest, getPasswordLastUpdate } from "@decocms/apps/vtex/actions/profile";`);
48
+ lines.push(`import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";`);
49
+
50
+ if (hasVtexAuth) {
51
+ lines.push(`import vtexAuthLoader from "../loaders/vtex-auth-loader";`);
52
+ }
53
+ if (hasProductReviews) {
54
+ lines.push(`import productReviewsLoader from "../loaders/reviews/productReviews";`);
55
+ }
56
+ if (hasBuyTogether) {
57
+ lines.push(`import buyTogetherLoader from "../loaders/product/buyTogether";`);
58
+ }
59
+ if (hasSecrets) {
60
+ lines.push(`import { secrets } from "../utils/secrets";`);
61
+ }
62
+ if (hasSitename && hasCollectionPLP) {
63
+ lines.push(`import { useAccount } from "../utils/sitename";`);
64
+ }
65
+
66
+ // Always import siteLoaders — the generate:loaders script creates this file
67
+ lines.push(`import { siteLoaders } from "../server/cms/loaders.gen";`);
68
+ lines.push(``);
69
+
70
+ lines.push(`const DOMAIN_RE = /;\\s*domain=[^;]*/gi;`);
71
+ lines.push(``);
72
+ lines.push(`export const vtexLoaders = createVtexCommerceLoaders();`);
73
+ lines.push(`export const cachedPDP = createCachedPDPLoader();`);
74
+
75
+ if (hasCollectionPLP) {
76
+ lines.push(`const cachedPLP = vtexLoaders["vtex/loaders/intelligentSearch/productListingPage.ts"];`);
77
+ }
78
+ if (hasAutocomplete) {
79
+ lines.push(`const cachedAutocomplete = createCachedLoader("vtex/autocomplete", autocompleteSearch, "search");`);
80
+ }
81
+
82
+ lines.push(``);
83
+ lines.push(``);
84
+ }
85
+
86
+ lines.push(`export const COMMERCE_LOADERS: Record<string, (props: any, request?: Request) => Promise<any>> = {`);
87
+
88
+ if (ctx.platform === "vtex") {
89
+ lines.push(` ...vtexLoaders,`);
90
+ lines.push(` ...siteLoaders,`);
91
+ lines.push(``);
92
+
93
+ // Autocomplete aliases
94
+ if (hasAutocomplete) {
95
+ lines.push(` // Autocomplete search — from @decocms/apps`);
96
+ lines.push(` "site/loaders/search/intelligenseSearch.ts": cachedAutocomplete,`);
97
+ lines.push(` "site/loaders/search/intelligenseSearch": cachedAutocomplete,`);
98
+ lines.push(``);
99
+ }
100
+
101
+ // Stores pass-through
102
+ if (hasStores) {
103
+ lines.push(` // Stores pass-through`);
104
+ lines.push(` "site/loaders/stores.ts": async (props: any) => {`);
105
+ lines.push(` const result = props.stores ?? props ?? [];`);
106
+ lines.push(` return Array.isArray(result) ? result : [];`);
107
+ lines.push(` },`);
108
+ lines.push(``);
109
+ }
110
+
111
+ // VTEX address CRUD
112
+ lines.push(` // VTEX address CRUD — request-aware wrappers from @decocms/apps`);
113
+ lines.push(` "vtex/actions/address/createAddress": createAddressFromRequest as any,`);
114
+ lines.push(` "vtex/actions/address/updateAddress": updateAddressFromRequest as any,`);
115
+ lines.push(` "vtex/actions/address/deleteAddress": deleteAddressFromRequest as any,`);
116
+ lines.push(` "vtex/loaders/address/getAddressByZIP": async (props: any) => {`);
117
+ lines.push(` return getAddressByPostalCode(props.countryCode, props.postalCode);`);
118
+ lines.push(` },`);
119
+ lines.push(``);
120
+
121
+ // Auth cookie stripping
122
+ if (hasVtexAuth) {
123
+ lines.push(` // VTEX auth (Set-Cookie forwarding)`);
124
+ lines.push(` "site/loaders/vtex-auth-loader": async (props: any) => {`);
125
+ lines.push(` const result = await vtexAuthLoader(props);`);
126
+ lines.push(` if (result instanceof Response) {`);
127
+ lines.push(` const setCookies = result.headers.getSetCookie?.() ?? [];`);
128
+ lines.push(` const strippedCookies = setCookies.map((c) => c.replace(DOMAIN_RE, ""));`);
129
+ lines.push(` const body = await result.text();`);
130
+ lines.push(` const headers = new Headers({ "Content-Type": "application/json" });`);
131
+ lines.push(` for (const cookie of strippedCookies) {`);
132
+ lines.push(` headers.append("Set-Cookie", cookie);`);
133
+ lines.push(` }`);
134
+ lines.push(` return new Response(body, { status: result.status, headers });`);
135
+ lines.push(` }`);
136
+ lines.push(` return result;`);
137
+ lines.push(` },`);
138
+ lines.push(``);
139
+ }
140
+
141
+ // ProductCard dynamic import
142
+ if (hasProductCard) {
143
+ lines.push(` // CMS-referenced site loaders`);
144
+ lines.push(` "site/loaders/Layouts/ProductCard.tsx": async (props: any) => {`);
145
+ lines.push(` const mod = await import("../loaders/Layouts/ProductCard");`);
146
+ lines.push(` return mod.default(props);`);
147
+ lines.push(` },`);
148
+ }
149
+
150
+ // Reviews with secrets
151
+ if (hasProductReviews) {
152
+ lines.push(` "site/loaders/reviews/productReviews.ts": async (props: any) => {`);
153
+ lines.push(` const { account } = getVtexConfig();`);
154
+ lines.push(` const result = await productReviewsLoader(props, null as any, { account${hasSecrets ? ", ...secrets" : ""} } as any);`);
155
+ lines.push(` if (!result) return result;`);
156
+ lines.push(` const { getProductReview: _r, reviewLikeAction: _l, reviewVote: _v, getProductsListReviews: _p, ...serializable } = result as any;`);
157
+ lines.push(` return serializable;`);
158
+ lines.push(` },`);
159
+ }
160
+
161
+ // BuyTogether with secrets
162
+ if (hasBuyTogether) {
163
+ lines.push(` "site/loaders/product/buyTogether.ts": async (props: any) => {`);
164
+ lines.push(` const { account } = getVtexConfig();`);
165
+ lines.push(` return buyTogetherLoader(props, null as any, { account${hasSecrets ? ", ...secrets" : ""} } as any);`);
166
+ lines.push(` },`);
167
+ }
168
+
169
+ // Collection PLP
170
+ if (hasCollectionPLP && hasSitename) {
171
+ lines.push(``);
172
+ lines.push(` // Collection PLP`);
173
+ lines.push(` "site/loaders/search/productListPageCollection.ts": async (props: any) => {`);
174
+ lines.push(` const url = new URL(props.__pageUrl || props.__pagePath || "/", "https://localhost");`);
175
+ lines.push(` const { search_collection_urls_cvlb } = await import("../utils/search-collection-url-cvlb");`);
176
+ lines.push(` const { createBreadcrumbFromPath } = await import("../utils/plpHelpers/plpCollection");`);
177
+ lines.push(` const { an: accountName } = useAccount();`);
178
+ lines.push(` const collections = search_collection_urls_cvlb[accountName] ?? [];`);
179
+ lines.push(``);
180
+ lines.push(` const normalize = (t: string) =>`);
181
+ lines.push(` t.normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase()`);
182
+ lines.push(` .replace(/[^a-z0-9\\s-]/g, "").replace(/\\s+/g, "-").replace(/-+/g, "-");`);
183
+ lines.push(``);
184
+ lines.push(` const slug = decodeURIComponent(url.pathname.split("/").pop() ?? "").replace(/-/g, " ");`);
185
+ lines.push(` const now = Date.now();`);
186
+ lines.push(` const collection = collections.find((c: any) =>`);
187
+ lines.push(` normalize(c.name) === normalize(slug) &&`);
188
+ lines.push(` new Date(c.dateFrom).getTime() <= now &&`);
189
+ lines.push(` now <= new Date(c.dateTo).getTime()`);
190
+ lines.push(` );`);
191
+ lines.push(` if (!collection) return null;`);
192
+ lines.push(``);
193
+ lines.push(` const collectionId = String(collection.id);`);
194
+ lines.push(` const response = await cachedPLP({`);
195
+ lines.push(` ...props,`);
196
+ lines.push(` selectedFacets: [{ key: "productClusterIds", value: collectionId }],`);
197
+ lines.push(` __pageUrl: url.toString(),`);
198
+ lines.push(` __pagePath: url.pathname,`);
199
+ lines.push(` });`);
200
+ lines.push(` if (!response) return null;`);
201
+ lines.push(``);
202
+ lines.push(` return {`);
203
+ lines.push(` ...response,`);
204
+ lines.push(` breadcrumb: createBreadcrumbFromPath(url.pathname, url, collection.name) ?? {},`);
205
+ lines.push(` seo: {`);
206
+ lines.push(` title: collection.name,`);
207
+ lines.push(` // MIGRATION TODO: replace with site-specific category description`);
208
+ lines.push(` description: collection.name,`);
209
+ lines.push(` noIndexing: false,`);
210
+ lines.push(` canonical: url.toString(),`);
211
+ lines.push(` },`);
212
+ lines.push(` };`);
213
+ lines.push(` },`);
214
+ }
215
+
216
+ lines.push(``);
217
+ // Profile actions
218
+ lines.push(` // Profile actions — request-aware wrappers from @decocms/apps`);
219
+ lines.push(` "vtex/actions/profile/updateProfile": updateProfileFromRequest as any,`);
220
+ lines.push(` "vtex/actions/profile/updateProfile.ts": updateProfileFromRequest as any,`);
221
+ lines.push(` "vtex/actions/profile/newsletterProfile": newsletterProfileFromRequest as any,`);
222
+ lines.push(` "vtex/actions/profile/newsletterProfile.ts": newsletterProfileFromRequest as any,`);
223
+ lines.push(` "vtex/actions/payments/delete": deletePaymentFromRequest as any,`);
224
+ lines.push(` "vtex/loaders/profile/passwordLastUpdate": getPasswordLastUpdate as any,`);
225
+ }
226
+
227
+ lines.push(`};`);
228
+
229
+ return lines.join("\n") + "\n";
230
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateMigrationPolicyPointerRule } from "./cursor-rules";
3
+
4
+ describe("generateMigrationPolicyPointerRule", () => {
5
+ const body = generateMigrationPolicyPointerRule("acme");
6
+
7
+ it("emits a Cursor MDC rule with alwaysApply: true frontmatter", () => {
8
+ expect(body.startsWith("---\n")).toBe(true);
9
+ expect(body).toContain("alwaysApply: true");
10
+ expect(body).toContain("description:");
11
+ });
12
+
13
+ it("interpolates the site name into the body", () => {
14
+ expect(body).toContain("`acme`");
15
+ });
16
+
17
+ it("links to the canonical rule and plan in decocms/deco-start", () => {
18
+ expect(body).toContain(
19
+ "https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc",
20
+ );
21
+ expect(body).toContain(
22
+ "https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md",
23
+ );
24
+ });
25
+
26
+ it("documents D1–D5 by ID, not just by name", () => {
27
+ expect(body).toContain("**D1**");
28
+ expect(body).toContain("**D2**");
29
+ expect(body).toContain("**D3**");
30
+ expect(body).toContain("**D4**");
31
+ expect(body).toContain("**D5**");
32
+ });
33
+
34
+ it("points at the post-cleanup --fix command rather than restating policy", () => {
35
+ expect(body).toContain("deco-post-cleanup --fix");
36
+ expect(body).toContain("deco-post-cleanup --strict");
37
+ });
38
+
39
+ it("does NOT restate the canonical rule body verbatim (pointer, not a copy)", () => {
40
+ // Length budget: pointer must stay short to discourage drift.
41
+ // The canonical rule in decocms/deco-start is ~110 lines / 4–5 KB;
42
+ // the pointer must be substantially smaller than a copy.
43
+ expect(body.length).toBeLessThan(3000);
44
+ });
45
+
46
+ it("is deterministic — same site name, same output", () => {
47
+ const a = generateMigrationPolicyPointerRule("foo");
48
+ const b = generateMigrationPolicyPointerRule("foo");
49
+ expect(a).toBe(b);
50
+ });
51
+
52
+ it("escapes nothing weird from siteName — siteName is used as a label only", () => {
53
+ // We don't sanitise; we trust the migration script to pass a real
54
+ // package name. But verify nothing surprising happens with hyphens
55
+ // (a common shape, e.g. "casaevideo-storefront").
56
+ const out = generateMigrationPolicyPointerRule("casaevideo-storefront");
57
+ expect(out).toContain("`casaevideo-storefront`");
58
+ });
59
+ });