@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -11,6 +11,14 @@ import { extractSectionMetadata } from "./analyzers/section-metadata";
11
11
  import { classifyIslands } from "./analyzers/island-classifier";
12
12
  import { inventoryLoaders } from "./analyzers/loader-inventory";
13
13
  import { extractTailwindConfig } from "./analyzers/tailwind-config";
14
+ import {
15
+ COMPONENT_DELETE,
16
+ GENERATED_FILES,
17
+ LOADER_DELETE,
18
+ ROOT_DELETE,
19
+ SDK_DELETE,
20
+ STATIC_DELETE,
21
+ } from "./delete-sets";
14
22
 
15
23
  const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
16
24
  ["preact-hooks", /from\s+["']preact\/hooks["']/],
@@ -33,7 +41,7 @@ const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
33
41
  ["head-component", /<Head[\s>]/],
34
42
  ["define-app", /defineApp\(/],
35
43
  ["invoke-proxy", /proxy<Manifest/],
36
- // Bagaggio-style HTMX dynamic-section loader. Both the source file and
44
+ // legacy HTMX-style dynamic-section loader. Both the source file and
37
45
  // every call site need manual conversion to React state / createServerFn.
38
46
  ["sections-component-loader", /sections\/Component\.tsx?$/m],
39
47
  ["use-component", /import\s*\{[^}]*\buseComponent\b[^}]*\}\s*from\s*["'][^"']*sections\/Component(?:\.tsx?)?["']/],
@@ -78,61 +86,6 @@ const SKIP_FILES = new Set([
78
86
  "bun.lockb",
79
87
  ]);
80
88
 
81
- /** Files that are generated and should be deleted */
82
- const GENERATED_FILES = new Set([
83
- "fresh.gen.ts",
84
- "manifest.gen.ts",
85
- "fresh.config.ts",
86
- ]);
87
-
88
- /** SDK files that have framework equivalents or are scaffolded fresh */
89
- const SDK_DELETE = new Set([
90
- "sdk/clx.ts",
91
- "sdk/useId.ts",
92
- // sdk/useOffer.ts — kept: sites often customize offer logic
93
- // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
94
- "sdk/usePlatform.tsx",
95
- "sdk/signal.ts",
96
- "sdk/format.ts",
97
- ]);
98
-
99
- /** Component files that are scaffolded fresh (old versions must not overwrite) */
100
- const COMPONENT_DELETE = new Set([
101
- "components/ui/Image.tsx",
102
- "components/ui/Picture.tsx",
103
- "components/ui/Video.tsx",
104
- ]);
105
-
106
- /** Loaders that depend on deleted admin tooling */
107
- const LOADER_DELETE = new Set([
108
- "loaders/availableIcons.ts",
109
- "loaders/icons.ts",
110
- ]);
111
-
112
- /** Root config/infra files to delete */
113
- const ROOT_DELETE = new Set([
114
- "main.ts",
115
- "dev.ts",
116
- "deno.json",
117
- "deno.lock",
118
- "tailwind.css",
119
- "tailwind.config.ts",
120
- "runtime.ts",
121
- "constants.ts",
122
- "fresh.gen.ts",
123
- "manifest.gen.ts",
124
- "fresh.config.ts",
125
- "browserslist",
126
- "bw_stats.json",
127
- "islands.ts",
128
- ]);
129
-
130
- /** Static files that are code/tooling, not assets — should be deleted */
131
- const STATIC_DELETE = new Set([
132
- "static/adminIcons.ts",
133
- "static/generate-icons.ts",
134
- "static/tailwind.css",
135
- ]);
136
89
 
137
90
  /**
138
91
  * Scan file content for inline npm: imports and return { name: version } pairs.
@@ -317,7 +270,7 @@ function decideAction(
317
270
  };
318
271
  }
319
272
 
320
- // Bagaggio-style HTMX dynamic-section loader → delete and flag.
273
+ // legacy HTMX-style dynamic-section loader → delete and flag.
321
274
  // The file uses Deno-only APIs (`toFileUrl(Deno.cwd())`, `import.meta.resolve`)
322
275
  // and the `useComponent(component, props)` HTMX render-and-swap pattern, none
323
276
  // of which work on TanStack Start / Cloudflare Workers. The site author must
@@ -418,6 +371,50 @@ function scanDir(
418
371
  }
419
372
  }
420
373
 
374
+ export function extractGoogleFonts(
375
+ sourceDir: string,
376
+ ): { preconnects: string[]; stylesheets: string[] } {
377
+ const preconnects: string[] = [];
378
+ const stylesheets: string[] = [];
379
+ const seen = new Set<string>();
380
+
381
+ const hrefRe = /href=["'](https:\/\/fonts\.(?:googleapis|gstatic)\.com[^"']*)["']/g;
382
+
383
+ // Read _app.tsx and _layout.tsx for <link> declarations
384
+ for (const candidate of ["routes/_app.tsx", "routes/_layout.tsx"]) {
385
+ const fp = path.join(sourceDir, candidate);
386
+ if (!fs.existsSync(fp)) continue;
387
+ const content = fs.readFileSync(fp, "utf-8");
388
+ for (const m of content.matchAll(hrefRe)) {
389
+ const href = m[1];
390
+ if (seen.has(href)) continue;
391
+ seen.add(href);
392
+ if (href.includes("/css")) {
393
+ stylesheets.push(href);
394
+ } else {
395
+ preconnects.push(href);
396
+ }
397
+ }
398
+ }
399
+
400
+ // Also check tailwind.css for @import url(https://fonts.googleapis.com/...)
401
+ for (const cssCandidate of ["tailwind.css", "styles/app.css", "styles/global.css"]) {
402
+ const fp = path.join(sourceDir, cssCandidate);
403
+ if (!fs.existsSync(fp)) continue;
404
+ const content = fs.readFileSync(fp, "utf-8");
405
+ const importRe = /@import\s+url\(['"]?(https:\/\/fonts\.googleapis\.com[^'")\s]+)/g;
406
+ for (const m of content.matchAll(importRe)) {
407
+ const href = m[1];
408
+ if (!seen.has(href)) {
409
+ seen.add(href);
410
+ stylesheets.push(href);
411
+ }
412
+ }
413
+ }
414
+
415
+ return { preconnects, stylesheets };
416
+ }
417
+
421
418
  function extractGtmId(sourceDir: string): string | null {
422
419
  const appPath = path.join(sourceDir, "routes", "_app.tsx");
423
420
  if (!fs.existsSync(appPath)) return null;
@@ -433,7 +430,7 @@ function extractVtexAccount(sourceDir: string): string | null {
433
430
  const fp = path.join(sourceDir, candidate);
434
431
  if (!fs.existsSync(fp)) continue;
435
432
  const content = fs.readFileSync(fp, "utf-8");
436
- // Match patterns like: casaevideo: "casaevideonewio"
433
+ // Match patterns like: acme: "acmenewio"
437
434
  const match = content.match(/vtexAn|VTEX_AN/i);
438
435
  if (match) {
439
436
  const dictMatch = content.match(/["'](\w+)["']\s*:\s*["'](\w+myvtex|\w+newio|\w+)["']/);
@@ -627,6 +624,20 @@ export function analyze(ctx: MigrationContext): void {
627
624
  ctx.platform = extractPlatform(ctx.sourceDir);
628
625
  ctx.vtexAccount = ctx.platform === "vtex" ? extractVtexAccount(ctx.sourceDir) : null;
629
626
  ctx.gtmId = extractGtmId(ctx.sourceDir);
627
+ ctx.googleFonts = extractGoogleFonts(ctx.sourceDir);
628
+ // If no font URLs were extracted but the file mentions googleapis (likely a
629
+ // JSX expression like href={URL_CONST}), flag it for manual review.
630
+ if (ctx.googleFonts.preconnects.length === 0 && ctx.googleFonts.stylesheets.length === 0) {
631
+ const appPath = path.join(ctx.sourceDir, "routes", "_app.tsx");
632
+ if (fs.existsSync(appPath) && fs.readFileSync(appPath, "utf-8").includes("fonts.googleapis")) {
633
+ ctx.manualReviewItems.push({
634
+ file: "routes/_app.tsx",
635
+ reason:
636
+ "Google Fonts reference found but could not be extracted automatically (may use a JSX expression or variable). Add the font preconnect and stylesheet links manually to src/routes/__root.tsx.",
637
+ severity: "warning",
638
+ });
639
+ }
640
+ }
630
641
 
631
642
  // Extract theme colors and font from CMS
632
643
  const theme = extractThemeFromCms(ctx.sourceDir);
@@ -655,8 +666,16 @@ export function analyze(ctx: MigrationContext): void {
655
666
  console.log(` Font: ${ctx.fontFamily}`);
656
667
  }
657
668
 
658
- // Scan all files
669
+ // Scan all files. The root scan skips `src/` (SKIP_DIRS) — for a MODERN
670
+ // layout the code lives there, so scan `src/` separately with baseDir=src so
671
+ // its files categorize as if they were at root (`src/sections/X` → `sections/X`
672
+ // → section → targetPath `src/sections/X`). The root scan still handles config
673
+ // + static/ at the repo root.
659
674
  scanDir(ctx.sourceDir, ctx.sourceDir, ctx.files);
675
+ if (ctx.layout === "modern") {
676
+ const srcDir = path.join(ctx.sourceDir, "src");
677
+ if (fs.existsSync(srcDir)) scanDir(srcDir, srcDir, ctx.files);
678
+ }
660
679
 
661
680
  // Summary
662
681
  const byAction = { transform: 0, delete: 0, move: 0, scaffold: 0, "manual-review": 0 };
@@ -699,7 +718,7 @@ export function analyze(ctx: MigrationContext): void {
699
718
  f.path === "sections/Component.ts",
700
719
  );
701
720
  if (componentLoaderFile || useComponentSites.length > 0) {
702
- console.log("\n ⚠ HTMX dynamic-section loader detected (Bagaggio-style)");
721
+ console.log("\n ⚠ HTMX dynamic-section loader detected (legacy HTMX-style)");
703
722
  if (componentLoaderFile) {
704
723
  console.log(` • ${componentLoaderFile.path} (will be deleted)`);
705
724
  }
@@ -20,7 +20,9 @@ function makeCtx(sourceDir: string, dryRun = false): MigrationContext {
20
20
  importMap: {},
21
21
  discoveredNpmDeps: {},
22
22
  themeColors: {},
23
- tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
23
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, animations: {}, keyframes: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
24
+ layout: "classic",
25
+ googleFonts: { preconnects: [], stylesheets: [] },
24
26
  fontFamily: null,
25
27
  files: [],
26
28
  sectionMetas: [],
@@ -627,7 +627,7 @@ function guardUndeclaredVariables(src: string, onFix: (msg: string) => void): st
627
627
  /**
628
628
  * Migrate account.json → src/constants/account.ts and rewrite imports.
629
629
  *
630
- * Old stack: root-level `account.json` containing e.g. `"casaevideo"`
630
+ * Old stack: root-level `account.json` containing e.g. `"acme"`
631
631
  * New stack: `src/constants/account.ts` exporting `accountName`
632
632
  *
633
633
  * Also rewrites every file that imports from `account.json` (via
@@ -16,7 +16,9 @@ function makeCtx(sourceDir: string, dryRun = false): MigrationContext {
16
16
  importMap: {},
17
17
  discoveredNpmDeps: {},
18
18
  themeColors: {},
19
- tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
19
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, animations: {}, keyframes: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
20
+ layout: "classic",
21
+ googleFonts: { preconnects: [], stylesheets: [] },
20
22
  fontFamily: null,
21
23
  files: [],
22
24
  sectionMetas: [],
@@ -22,6 +22,8 @@ describe("report — CSS Migration section", () => {
22
22
  colors: { perola: "#F5F0E8", "brand-500": "#B10200" },
23
23
  fontFamily: { "bebas-neue": "Bebas Neue, sans-serif" },
24
24
  screens: {},
25
+ animations: {},
26
+ keyframes: {},
25
27
  safelist: ["bg-red-500"],
26
28
  safelistPatterns: [],
27
29
  plugins: [],
@@ -199,7 +199,7 @@ export function report(ctx: MigrationContext): void {
199
199
  lines.push("- [ ] `src/hooks/useCart.ts` — wire to actual server functions for your platform");
200
200
  lines.push("- [ ] `src/worker-entry.ts` — verify CSP, proxy, and segment builder");
201
201
  lines.push("- [ ] See the **CSS Migration** section above for DaisyUI v4→v5 / Tailwind v3→v4 findings specific to this site");
202
- lines.push("- [ ] Run `npm run generate:blocks` and `npm run generate:schema` after migration");
202
+ lines.push("- [ ] Run `npm run generate` (unified: blocks, sections, loaders, schema) after migration");
203
203
  lines.push("");
204
204
 
205
205
  // Known Issues
@@ -250,9 +250,8 @@ export function report(ctx: MigrationContext): void {
250
250
  lines.push("# 1. Install dependencies (bun is the canonical PM)");
251
251
  lines.push("bun install");
252
252
  lines.push("");
253
- lines.push("# 2. Generate CMS blocks and schema");
254
- lines.push("bun run generate:blocks");
255
- lines.push("bun run generate:schema");
253
+ lines.push("# 2. Generate CMS artifacts (blocks, sections, loaders, schema)");
254
+ lines.push("bun run generate");
256
255
  lines.push("");
257
256
  lines.push("# 3. Generate routes");
258
257
  lines.push("bunx tsr generate");
@@ -1,29 +1,40 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { MigrationContext } from "./types";
4
- import { log, logPhase } from "./types";
5
- import { CANONICAL_BUN_VERSION, generatePackageJson } from "./templates/package-json";
6
- import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
7
- import { generateTsconfig } from "./templates/tsconfig";
8
- import { generateViteConfig } from "./templates/vite-config";
3
+ // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
4
+ // generation of all 11 shims left every site with dead code that had
5
+ // to be cleaned up by hand.
6
+ import { extractTheme } from "./analyzers/theme-extractor";
7
+ import { generateAppCss } from "./templates/app-css";
8
+ import { generateCacheConfig } from "./templates/cache-config";
9
+ import { generateCiFiles } from "./templates/ci-yml";
10
+ import { generateCommerceInit } from "./templates/commerce-init";
11
+ import { generateCommerceLoaders } from "./templates/commerce-loaders";
12
+ import { generateSyncBlocksBotYml } from "./templates/sync-blocks-bot-yml";
13
+ import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
14
+ import { generateHooks } from "./templates/hooks";
9
15
  import { generateKnipConfig } from "./templates/knip-config";
16
+ import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
17
+ import { generateMainPushGuardYml } from "./templates/main-push-guard-yml";
18
+ import {
19
+ CANONICAL_BUN_VERSION,
20
+ CANONICAL_NODE_VERSION,
21
+ generatePackageJson,
22
+ } from "./templates/package-json";
23
+ import { generateParityYml } from "./templates/parity-yml";
24
+ import { generatePerfFiles } from "./templates/perf-yml";
25
+ import { generatePlaywrightFiles } from "./templates/playwright-yml";
26
+ import { generateReactDoctorYml } from "./templates/react-doctor-yml";
10
27
  import { generateRoutes } from "./templates/routes";
11
- import { generateSetup } from "./templates/setup";
28
+ import { generateSdkFiles } from "./templates/sdk-gen";
29
+ import { generateSectionLoaders } from "./templates/section-loaders";
12
30
  import { generateServerEntry } from "./templates/server-entry";
13
- import { generateAppCss } from "./templates/app-css";
31
+ import { generateSetup } from "./templates/setup";
32
+ import { generateTsconfig } from "./templates/tsconfig";
14
33
  import { generateTypeFiles } from "./templates/types-gen";
15
34
  import { generateUiComponents } from "./templates/ui-components";
16
- import { generateHooks } from "./templates/hooks";
17
- import { generateCommerceInit } from "./templates/commerce-init";
18
- import { generateCommerceLoaders } from "./templates/commerce-loaders";
19
- import { generateSectionLoaders } from "./templates/section-loaders";
20
- import { generateCacheConfig } from "./templates/cache-config";
21
- import { generateSdkFiles } from "./templates/sdk-gen";
22
- import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
23
- // `lib-utils` is imported lazily — see end of phase-cleanup. Eager
24
- // generation of all 11 shims left every site with dead code that had
25
- // to be cleaned up by hand.
26
- import { extractTheme } from "./analyzers/theme-extractor";
35
+ import { generateViteConfig } from "./templates/vite-config";
36
+ import type { MigrationContext } from "./types";
37
+ import { log, logPhase } from "./types";
27
38
 
28
39
  function writeFile(ctx: MigrationContext, relPath: string, content: string) {
29
40
  const fullPath = path.join(ctx.sourceDir, relPath);
@@ -69,13 +80,21 @@ export function scaffold(ctx: MigrationContext): void {
69
80
  // does NOT scaffold deploy/preview/sync-secrets workflows in the site
70
81
  // repo; the operator wires the repo<->worker connection in the CF
71
82
  // dashboard once after the first push.
72
- writeFile(ctx, ".prettierrc", JSON.stringify({
73
- semi: true,
74
- singleQuote: false,
75
- trailingComma: "all" as const,
76
- printWidth: 100,
77
- tabWidth: 2,
78
- }, null, 2) + "\n");
83
+ writeFile(
84
+ ctx,
85
+ ".prettierrc",
86
+ JSON.stringify(
87
+ {
88
+ semi: true,
89
+ singleQuote: false,
90
+ trailingComma: "all" as const,
91
+ printWidth: 100,
92
+ tabWidth: 2,
93
+ },
94
+ null,
95
+ 2,
96
+ ) + "\n",
97
+ );
79
98
 
80
99
  // PR-time lockfile guardrail. Lives in the site repo (per-site, not
81
100
  // a centralised reusable workflow) because per D6.3 we are not
@@ -87,6 +106,40 @@ export function scaffold(ctx: MigrationContext): void {
87
106
  generateLockfileCheckYml(CANONICAL_BUN_VERSION),
88
107
  );
89
108
 
109
+ // Advisory per-PR performance workflow: detects which sections changed,
110
+ // maps them to CMS page paths, runs Lighthouse against the CF preview URL
111
+ // (PR vs main), and posts a comparison comment. Gate = CLS + TBT only.
112
+ writeMultiFile(ctx, generatePerfFiles(ctx.siteName));
113
+
114
+ // Per-PR quality pipeline (ci.yml) + its no-suppressions gate. BLOCKS on
115
+ // generate + build; the migration-cleanliness gates (no-suppressions,
116
+ // typecheck, format, knip) ship advisory so day-one CI is green. Node pinned
117
+ // in lockstep with the perf/playwright workflows.
118
+ writeMultiFile(ctx, generateCiFiles(CANONICAL_NODE_VERSION, CANONICAL_BUN_VERSION));
119
+
120
+ // Branch-protection surrogate: fails visibly if a commit reaches main
121
+ // without a PR (never blocks the push itself).
122
+ writeFile(ctx, ".github/workflows/main-push-guard.yml", generateMainPushGuardYml());
123
+
124
+ // Functional E2E harness (chromium + webkit) + self-contained config/smoke.
125
+ writeMultiFile(ctx, generatePlaywrightFiles(CANONICAL_BUN_VERSION));
126
+
127
+ // Advisory React lint (react-doctor): comments on PRs, never fails.
128
+ writeFile(ctx, ".github/workflows/react-doctor.yml", generateReactDoctorYml());
129
+
130
+ // Advisory parity validation: compares each PR preview against the original
131
+ // live storefront (@decocms/parity). Inert until PARITY_PROD_URL is set.
132
+ writeFile(ctx, ".github/workflows/parity.yml", generateParityYml(ctx.siteName));
133
+
134
+ // Daily content pull from the still-live storefront (`<origin>/.decofile` ->
135
+ // `.deco/blocks` -> PR). Replaces the legacy cross-repo-PAT push sync; inert
136
+ // until the operator sets the repo variable SYNC_BLOCKS_ORIGIN.
137
+ writeFile(
138
+ ctx,
139
+ ".github/workflows/sync-blocks-bot.yml",
140
+ generateSyncBlocksBotYml(CANONICAL_BUN_VERSION),
141
+ );
142
+
90
143
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
91
144
  writeMultiFile(ctx, generateServerEntry(ctx));
92
145
 
@@ -224,18 +277,48 @@ export { type Font as SiteThemeFont };
224
277
  `;
225
278
  }
226
279
 
227
- function generateWranglerConfig(ctx: MigrationContext): string {
228
- return JSON.stringify(
229
- {
230
- name: ctx.siteName,
231
- main: "src/worker-entry.ts",
232
- compatibility_date: "2025-05-01",
233
- compatibility_flags: ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
234
- kv_namespaces: [{ binding: "SITES_KV", id: "dev-sites-kv" }],
235
- },
236
- null,
237
- 2,
238
- ) + "\n";
280
+ export function generateWranglerConfig(ctx: MigrationContext): string {
281
+ // Emitted as real JSONC (not JSON.stringify) so the comments survive — every
282
+ // field below is load-bearing and the next person to edit this file needs to
283
+ // know why. The ids are placeholders: the control-plane owns them (it
284
+ // provisions the site's OWN namespaces and overrides these at build time),
285
+ // so a wrong id here can never reach production.
286
+ return `{
287
+ "name": "${ctx.siteName}",
288
+ "main": "src/worker-entry.ts",
289
+ "compatibility_date": "2025-05-01",
290
+ // no_handle_cross_request_promise_resolution: the framework's SWR/dedup
291
+ // caches hold in-flight promises across requests; without this flag the
292
+ // worker hangs.
293
+ "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
294
+ // Workers Cache — tiered cache IN FRONT of the worker. On a hit the worker
295
+ // never runs. Per-response cacheability is still driven by Cache-Control.
296
+ "cache": { "enabled": true },
297
+ // Surfaces the build sha as service.version on every span and log line.
298
+ "version_metadata": { "binding": "CF_VERSION_METADATA" },
299
+ // Tail worker. The ONLY channel that can see exceededMemory / exceededCpu:
300
+ // Cloudflare kills the isolate before any in-worker code could report them.
301
+ // Without this a memory incident is invisible — see docs/runbooks/.
302
+ "tail_consumers": [{ "service": "deco-otel-tail" }],
303
+ "kv_namespaces": [
304
+ // Fast Deploy content store. Holds decofile:<deployment-id> + the revision
305
+ // index, so a CMS publish goes live without a code deploy. The id is
306
+ // written by the control-plane at site creation and re-forced from
307
+ // CF_KV_NAMESPACE_ID on every build (per-site KV isolation).
308
+ { "binding": "DECO_KV", "id": "" }
309
+ // No SITES_KV: withABTesting (server-entry) passes through when it is
310
+ // unbound, and nothing provisions it — an empty id would fail deploy.
311
+ // Add it with the shared namespace's real id when starting an A/B test.
312
+ ],
313
+ "vars": {
314
+ "DECO_SITE_NAME": "${ctx.siteName}",
315
+ "DECO_ENV_NAME": "production",
316
+ // Fast Deploy opt-in. Requires BOTH this flag AND the DECO_KV binding
317
+ // above; inert if either is missing. Set to "0" to disable.
318
+ "DECO_FAST_DEPLOY": "1"
319
+ }
320
+ }
321
+ `;
239
322
  }
240
323
 
241
324
  function generateGitignore(): string {
@@ -340,8 +423,13 @@ export default debounce;
340
423
  }
341
424
 
342
425
  function generateSignalShim(): string {
343
- return `import { useState, useRef, useEffect, useMemo, useCallback } from "react";
344
- export { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal";
426
+ return `import { useState, useRef, useEffect, useMemo, useCallback, useSyncExternalStore } from "react";
427
+ // Import locally (so \`ReactiveSignal\` is in scope for useSignalValue below) AND
428
+ // re-export for consumers. A bare \`export { … } from\` re-export does NOT bind the
429
+ // name locally — using it as a type then fails with "Cannot find name".
430
+ import { signal, type ReactiveSignal } from "@decocms/blocks/sdk/signal";
431
+ export { signal };
432
+ export type { ReactiveSignal };
345
433
 
346
434
  /** Run a function immediately. Kept for legacy module-level side effects. */
347
435
  export function effect(fn: () => void | (() => void)): () => void {
@@ -386,6 +474,24 @@ export function useComputed<T>(compute: () => T): { readonly value: T } {
386
474
  export function useSignalEffect(cb: () => void | (() => void)): void {
387
475
  useEffect(cb);
388
476
  }
477
+
478
+ /**
479
+ * Subscribe to a ReactiveSignal and re-render when it changes.
480
+ * Required for signals from useUI() (displayCart, displayMenu, etc.) —
481
+ * reading .value directly in render is a dead read in React and freezes the UI.
482
+ *
483
+ * @example
484
+ * const { displayCart } = useUI();
485
+ * const open = useSignalValue(displayCart);
486
+ * return <Drawer open={open} />;
487
+ */
488
+ export function useSignalValue<T>(sig: ReactiveSignal<T>): T {
489
+ return useSyncExternalStore(
490
+ (cb) => sig.subscribe(cb),
491
+ () => sig.value,
492
+ () => sig.value,
493
+ );
494
+ }
389
495
  `;
390
496
  }
391
497
 
@@ -531,10 +637,7 @@ ${entries.join("\n")}
531
637
  }
532
638
 
533
639
  function hasLocationMatcher(ctx: MigrationContext): boolean {
534
- const dirs = [
535
- path.join(ctx.sourceDir, "matchers"),
536
- path.join(ctx.sourceDir, "src", "matchers"),
537
- ];
640
+ const dirs = [path.join(ctx.sourceDir, "matchers"), path.join(ctx.sourceDir, "src", "matchers")];
538
641
  for (const dir of dirs) {
539
642
  if (fs.existsSync(dir)) {
540
643
  const files = fs.readdirSync(dir);
@@ -549,7 +652,9 @@ function hasLocationMatcher(ctx: MigrationContext): boolean {
549
652
  try {
550
653
  const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
551
654
  if (content.includes("website/matchers/location")) return true;
552
- } catch { /* skip */ }
655
+ } catch {
656
+ /* skip */
657
+ }
553
658
  }
554
659
  }
555
660
  return false;