@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
@@ -72,3 +72,34 @@ function matchTomlString(body: string, key: string): string | null {
72
72
  const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*["']([^"']+)["']`));
73
73
  return m ? m[1] : null;
74
74
  }
75
+
76
+ /**
77
+ * Set the `id` of a `kv_namespaces` entry in a `wrangler.jsonc` SOURCE string,
78
+ * preserving comments and formatting.
79
+ *
80
+ * Deliberately text-level rather than parse-and-restringify: the scaffolded
81
+ * `wrangler.jsonc` carries the comments explaining why each binding exists, and
82
+ * a round-trip through `JSON.parse`/`stringify` would drop every one of them.
83
+ *
84
+ * Returns the original string unchanged when the binding is absent — callers
85
+ * treat that as "nothing to personalize", never as an error, because the
86
+ * builder re-forces the id from `CF_KV_NAMESPACE_ID` on every build anyway.
87
+ */
88
+ export function setKvNamespaceIdInJsonc(
89
+ src: string,
90
+ id: string,
91
+ binding = DEFAULT_BINDING,
92
+ ): string {
93
+ // Match the object literal holding this binding, in either field order, and
94
+ // rewrite only its `id` value. Bounded to a single `{...}` so it cannot reach
95
+ // past the entry into the next one.
96
+ const entry = new RegExp(`\\{[^{}]*?"binding"\\s*:\\s*"${binding}"[^{}]*?\\}`, "s");
97
+ const m = src.match(entry);
98
+ if (!m) return src;
99
+
100
+ const patched = m[0].includes('"id"')
101
+ ? m[0].replace(/"id"\s*:\s*"[^"]*"/, `"id": ${JSON.stringify(id)}`)
102
+ : m[0].replace(/\}$/, `, "id": ${JSON.stringify(id)} }`);
103
+
104
+ return src.slice(0, m.index) + patched + src.slice(m.index! + m[0].length);
105
+ }
@@ -116,7 +116,7 @@ describe("classify (pure)", () => {
116
116
  });
117
117
 
118
118
  it("treats hx-on with a fetch attr as click-swap (the fetch wins)", () => {
119
- // Real example from als (EmailAndPassword button): hx-get +
119
+ // Real example from a production htmx storefront (EmailAndPassword button): hx-get +
120
120
  // hx-target + hx-trigger=click — engineer often piles
121
121
  // hx-on alongside, but the dominant migration path is the
122
122
  // click-swap recipe.
@@ -127,11 +127,11 @@ describe("classify (pure)", () => {
127
127
  });
128
128
 
129
129
  /* ------------------------------------------------------------------ */
130
- /* analyzeFile() — JSX walker tests using real als-shaped fixtures */
130
+ /* analyzeFile() — JSX walker tests using real htmx-shaped fixtures */
131
131
  /* ------------------------------------------------------------------ */
132
132
 
133
133
  describe("analyzeFile (real shapes)", () => {
134
- it("detects hx-on:click={useScript(...)} click handler (als AddToBagButton shape)", () => {
134
+ it("detects hx-on:click={useScript(...)} click handler (AddToBagButton shape)", () => {
135
135
  const file = `
136
136
  import { useScript } from "@deco/deco/hooks";
137
137
  export default function AddToBagButton() {
@@ -157,7 +157,7 @@ export default function AddToBagButton() {
157
157
  expect(out[0].attrs).toEqual(["hx-on:click"]);
158
158
  });
159
159
 
160
- it("detects hx-post + hx-target + hx-trigger=keyup on an input as auto-fetch (als SearchInput shape)", () => {
160
+ it("detects hx-post + hx-target + hx-trigger=keyup on an input as auto-fetch (SearchInput shape)", () => {
161
161
  const file = `
162
162
  <input
163
163
  id={searchInputId}
@@ -181,7 +181,7 @@ export default function AddToBagButton() {
181
181
  expect(out[0].attrs).toContain("hx-target");
182
182
  });
183
183
 
184
- it("detects hx-post + hx-target + hx-swap on a form as form-swap (als EmailAndPassword shape)", () => {
184
+ it("detects hx-post + hx-target + hx-swap on a form as form-swap (EmailAndPassword shape)", () => {
185
185
  const file = `
186
186
  <form
187
187
  class="flex flex-col w-full"
@@ -202,7 +202,7 @@ export default function AddToBagButton() {
202
202
  expect(out[0].attrs).toContain("hx-target");
203
203
  });
204
204
 
205
- it("detects hx-get on a button as click-swap (als ForgotPassword shape)", () => {
205
+ it("detects hx-get on a button as click-swap (ForgotPassword shape)", () => {
206
206
  const file = `
207
207
  <button
208
208
  type="button"
@@ -24,6 +24,10 @@ export interface TailwindConfigExtract {
24
24
  fontFamily: Record<string, string>;
25
25
  /** Custom breakpoints, e.g. "3xl" -> "1920px" */
26
26
  screens: Record<string, string>;
27
+ /** Custom animations, e.g. "fade-in" -> "fadeIn 0.5s ease-in" */
28
+ animations: Record<string, string>;
29
+ /** Custom keyframe stops, e.g. "fadeIn" -> { "0%": "opacity: 0", "100%": "opacity: 1" } */
30
+ keyframes: Record<string, Record<string, string>>;
27
31
  /** Literal safelist entries (string class names) */
28
32
  safelist: string[];
29
33
  /** Safelist regex patterns, kept as their source text */
@@ -40,6 +44,8 @@ function emptyExtract(): TailwindConfigExtract {
40
44
  colors: {},
41
45
  fontFamily: {},
42
46
  screens: {},
47
+ animations: {},
48
+ keyframes: {},
43
49
  safelist: [],
44
50
  safelistPatterns: [],
45
51
  plugins: [],
@@ -184,6 +190,84 @@ function extractScreens(obj: ObjectLiteralExpression): Record<string, string> {
184
190
  return result;
185
191
  }
186
192
 
193
+ function extractAnimations(
194
+ obj: ObjectLiteralExpression,
195
+ reviewItems: ReviewItem[],
196
+ ): Record<string, string> {
197
+ const result: Record<string, string> = {};
198
+ for (const prop of obj.getProperties()) {
199
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
200
+ const name = stripQuotes(prop.getName());
201
+ const init = prop.getInitializer();
202
+ if (!init) continue;
203
+ if (
204
+ init.isKind(SyntaxKind.StringLiteral) ||
205
+ init.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
206
+ ) {
207
+ result[name] = init.getLiteralText();
208
+ } else {
209
+ reviewItems.push({
210
+ file: CONFIG_REL_PATH,
211
+ reason: `theme.extend.animation.${name} is not a static string literal (${init.getKindName()}) — port manually to @theme { --animate-${name}: ...; } in src/styles/app.css`,
212
+ severity: "warning",
213
+ });
214
+ }
215
+ }
216
+ return result;
217
+ }
218
+
219
+ function extractKeyframes(
220
+ obj: ObjectLiteralExpression,
221
+ reviewItems: ReviewItem[],
222
+ ): Record<string, Record<string, string>> {
223
+ const result: Record<string, Record<string, string>> = {};
224
+ for (const frameProp of obj.getProperties()) {
225
+ if (!frameProp.isKind(SyntaxKind.PropertyAssignment)) continue;
226
+ const frameName = stripQuotes(frameProp.getName());
227
+ const frameInit = frameProp.getInitializer();
228
+ if (!frameInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
229
+ reviewItems.push({
230
+ file: CONFIG_REL_PATH,
231
+ reason: `theme.extend.keyframes.${frameName} is not an object literal — port manually to @keyframes ${frameName} { ... } in src/styles/app.css`,
232
+ severity: "warning",
233
+ });
234
+ continue;
235
+ }
236
+ const stops: Record<string, string> = {};
237
+ for (const stopProp of frameInit.getProperties()) {
238
+ if (!stopProp.isKind(SyntaxKind.PropertyAssignment)) continue;
239
+ const stopKey = stripQuotes(stopProp.getName()); // "0%", "100%", "from", "to"
240
+ const stopInit = stopProp.getInitializer();
241
+ if (!stopInit?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
242
+ const cssParts: string[] = [];
243
+ for (const cssProp of stopInit.getProperties()) {
244
+ if (!cssProp.isKind(SyntaxKind.PropertyAssignment)) continue;
245
+ const cssName = stripQuotes(cssProp.getName());
246
+ const cssVal = cssProp.getInitializer();
247
+ if (
248
+ cssVal?.isKind(SyntaxKind.StringLiteral) ||
249
+ cssVal?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
250
+ ) {
251
+ // camelCase -> kebab-case (e.g. "transformOrigin" -> "transform-origin")
252
+ const kebab = cssName.replace(/([A-Z])/g, "-$1").toLowerCase();
253
+ cssParts.push(`${kebab}: ${cssVal.getLiteralText()}`);
254
+ }
255
+ }
256
+ if (cssParts.length > 0) {
257
+ stops[stopKey] = cssParts.join("; ");
258
+ } else {
259
+ reviewItems.push({
260
+ file: CONFIG_REL_PATH,
261
+ reason: `theme.extend.keyframes.${frameName}["${stopKey}"] has no static string literal properties — port this keyframe stop manually to @keyframes ${frameName} { ... } in src/styles/app.css`,
262
+ severity: "warning",
263
+ });
264
+ }
265
+ }
266
+ if (Object.keys(stops).length > 0) result[frameName] = stops;
267
+ }
268
+ return result;
269
+ }
270
+
187
271
  function extractSafelist(
188
272
  arr: import("ts-morph").ArrayLiteralExpression,
189
273
  reviewItems: ReviewItem[],
@@ -258,6 +342,16 @@ export function extractTailwindConfig(sourceDir: string): TailwindConfigExtract
258
342
  (themeObj && getObjectChild(themeObj, "screens"));
259
343
  const screens = screensObj ? extractScreens(screensObj) : {};
260
344
 
345
+ const animationObj =
346
+ (extendObj && getObjectChild(extendObj, "animation")) ||
347
+ (themeObj && getObjectChild(themeObj, "animation"));
348
+ const animations = animationObj ? extractAnimations(animationObj, reviewItems) : {};
349
+
350
+ const keyframesObj =
351
+ (extendObj && getObjectChild(extendObj, "keyframes")) ||
352
+ (themeObj && getObjectChild(themeObj, "keyframes"));
353
+ const keyframes = keyframesObj ? extractKeyframes(keyframesObj, reviewItems) : {};
354
+
261
355
  let safelist: string[] = [];
262
356
  let safelistPatterns: string[] = [];
263
357
  const safelistProp = root.getProperty("safelist");
@@ -279,5 +373,5 @@ export function extractTailwindConfig(sourceDir: string): TailwindConfigExtract
279
373
  }
280
374
  }
281
375
 
282
- return { colors, fontFamily, screens, safelist, safelistPatterns, plugins, reviewItems };
376
+ return { colors, fontFamily, screens, animations, keyframes, safelist, safelistPatterns, plugins, reviewItems };
283
377
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Looks for `.deco-migrate.config.json` next to the source root. The file
5
5
  * is optional — without it the script falls back to a baked-in default set
6
- * of section-convention names that work for `casaevideo` and most other
6
+ * of section-convention names that work for `acme` and most other
7
7
  * Deco/VTEX sites that derived from the same template.
8
8
  *
9
9
  * The defaults are kept here (not in `transforms/section-conventions.ts`)
@@ -61,7 +61,7 @@ export interface SectionConventionSets {
61
61
  }
62
62
 
63
63
  /**
64
- * Built-in defaults. Originally extracted from `casaevideo` migration —
64
+ * Built-in defaults. Originally extracted from a production migration —
65
65
  * these names are common across Deco/VTEX storefronts that share the
66
66
  * lineage. Sites that don't have these sections are unaffected (the
67
67
  * matcher just never fires).
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The paths the migration deletes outright. Single source of truth: `decideAction`
3
+ * (phase-analyze) decides with them, and `deco-reconcile` filters with them — an
4
+ * upstream change to a file the migration deleted has no target equivalent, so
5
+ * reporting it just makes the agent consider migrating a file that must not exist.
6
+ *
7
+ * Only the unambiguous sets live here. Rules that are conditional on context
8
+ * (routes/ and apps/ are rescaffolded, root-level docs) stay in `decideAction`:
9
+ * reconcile must stay conservative, since dropping a real upstream change is a
10
+ * worse failure than showing one file too many.
11
+ */
12
+
13
+ /** Files that are generated and should be deleted */
14
+ export const GENERATED_FILES = new Set([
15
+ "fresh.gen.ts",
16
+ "manifest.gen.ts",
17
+ "fresh.config.ts",
18
+ ]);
19
+
20
+ /** SDK files that have framework equivalents or are scaffolded fresh */
21
+ export const SDK_DELETE = new Set([
22
+ "sdk/clx.ts",
23
+ "sdk/useId.ts",
24
+ // sdk/useOffer.ts — kept: sites often customize offer logic
25
+ // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
26
+ "sdk/usePlatform.tsx",
27
+ "sdk/signal.ts",
28
+ "sdk/format.ts",
29
+ ]);
30
+
31
+ /** Component files that are scaffolded fresh (old versions must not overwrite) */
32
+ export const COMPONENT_DELETE = new Set([
33
+ "components/ui/Image.tsx",
34
+ "components/ui/Picture.tsx",
35
+ "components/ui/Video.tsx",
36
+ ]);
37
+
38
+ /** Loaders that depend on deleted admin tooling */
39
+ export const LOADER_DELETE = new Set([
40
+ "loaders/availableIcons.ts",
41
+ "loaders/icons.ts",
42
+ ]);
43
+
44
+ /** Root config/infra files to delete */
45
+ export const ROOT_DELETE = new Set([
46
+ "main.ts",
47
+ "dev.ts",
48
+ "deno.json",
49
+ "deno.lock",
50
+ "tailwind.css",
51
+ "tailwind.config.ts",
52
+ "runtime.ts",
53
+ "constants.ts",
54
+ "fresh.gen.ts",
55
+ "manifest.gen.ts",
56
+ "fresh.config.ts",
57
+ "browserslist",
58
+ "bw_stats.json",
59
+ "islands.ts",
60
+ ]);
61
+
62
+ /** Static files that are code/tooling, not assets — should be deleted */
63
+ export const STATIC_DELETE = new Set([
64
+ "static/adminIcons.ts",
65
+ "static/generate-icons.ts",
66
+ "static/tailwind.css",
67
+ ]);
68
+
69
+ /** True when the migration deletes this source path outright. */
70
+ export function isDeletedByMigration(relPath: string): boolean {
71
+ return GENERATED_FILES.has(relPath) || ROOT_DELETE.has(relPath) ||
72
+ SDK_DELETE.has(relPath) || COMPONENT_DELETE.has(relPath) ||
73
+ LOADER_DELETE.has(relPath) || STATIC_DELETE.has(relPath) ||
74
+ relPath.startsWith("sdk/cart/") || relPath.startsWith("apps/deco/");
75
+ }
@@ -0,0 +1,130 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { parseJsonc } from "../lib/jsonc";
6
+ import { generateWranglerConfig } from "./phase-scaffold";
7
+ import { checks } from "./phase-verify";
8
+ import { generateSetup } from "./templates/setup";
9
+ import type { MigrationContext } from "./types";
10
+
11
+ /**
12
+ * Fast Deploy is inert unless THREE things line up: the `DECO_KV` binding,
13
+ * `DECO_FAST_DEPLOY=1`, and `setup.ts` handing the KV resolver to
14
+ * `@decocms/blocks-admin` (which cannot import `@decocms/tanstack` itself).
15
+ *
16
+ * Two of three is the worst state: it looks configured, a Studio publish
17
+ * reports success, and nothing ever reaches KV. These tests pin the scaffold to
18
+ * emit all three and pin the verify check to actually catch a partial config.
19
+ */
20
+
21
+ const FIXTURE: MigrationContext = {
22
+ sourceDir: "",
23
+ siteName: "test-site",
24
+ platform: "custom",
25
+ vtexAccount: null,
26
+ gtmId: null,
27
+ importMap: {},
28
+ discoveredNpmDeps: {},
29
+ themeColors: {},
30
+ tailwindConfig: {
31
+ colors: {},
32
+ fontFamily: {},
33
+ screens: {},
34
+ safelist: [],
35
+ safelistPatterns: [],
36
+ plugins: [],
37
+ reviewItems: [],
38
+ animations: {},
39
+ keyframes: {},
40
+ },
41
+ fontFamily: null,
42
+ googleFonts: [],
43
+ layout: null,
44
+ files: [],
45
+ sectionMetas: [],
46
+ islandClassifications: [],
47
+ loaderInventory: [],
48
+ patterns: {},
49
+ scaffoldedFiles: [],
50
+ transformedFiles: [],
51
+ deletedFiles: [],
52
+ movedFiles: [],
53
+ manualReview: [],
54
+ dryRun: false,
55
+ verbose: false,
56
+ } as unknown as MigrationContext;
57
+
58
+ const fastDeployCheck = checks.find((c) => c.name === "Fast Deploy wired end to end");
59
+
60
+ /** Write a site tree with the given wrangler + setup contents. */
61
+ function makeSite(wrangler: string, setup: string): MigrationContext {
62
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-scaffold-"));
63
+ fs.writeFileSync(path.join(dir, "wrangler.jsonc"), wrangler);
64
+ fs.mkdirSync(path.join(dir, "src"), { recursive: true });
65
+ fs.writeFileSync(path.join(dir, "src", "setup.ts"), setup);
66
+ return { ...FIXTURE, sourceDir: dir };
67
+ }
68
+
69
+ describe("scaffolded wrangler.jsonc", () => {
70
+ // Parse it rather than grep it: the template is hand-written JSONC, and a
71
+ // stray comma or comment that breaks parsing silently disables fast-deploy
72
+ // everywhere at once (the builder, the control-plane and the verify check
73
+ // below all read this file through a JSONC parser).
74
+ const cfg = parseJsonc(generateWranglerConfig(FIXTURE)) as {
75
+ kv_namespaces: Array<{ binding: string }>;
76
+ vars: Record<string, string>;
77
+ tail_consumers?: unknown[];
78
+ };
79
+
80
+ it("declares the DECO_KV binding and DECO_FAST_DEPLOY together", () => {
81
+ expect(cfg.kv_namespaces.map((n) => n.binding)).toContain("DECO_KV");
82
+ expect(cfg.vars.DECO_FAST_DEPLOY).toBe("1");
83
+ expect(cfg.vars.DECO_SITE_NAME).toBe("test-site");
84
+ });
85
+
86
+ it("declares a tail consumer, without which exceededMemory is unobservable", () => {
87
+ // Cloudflare kills the isolate over the 128MB cap before any in-worker code
88
+ // could report it — the tail worker is the only channel that sees it.
89
+ expect(cfg.tail_consumers).toBeTruthy();
90
+ });
91
+ });
92
+
93
+ describe("scaffolded setup.ts", () => {
94
+ it("calls setupTanstackFastDeploy, or the Studio publish write-through no-ops", () => {
95
+ const setup = generateSetup(FIXTURE);
96
+ expect(setup).toContain("setupTanstackFastDeploy()");
97
+ expect(setup).toMatch(/import \{[^}]*setupTanstackFastDeploy[^}]*\} from "@decocms\/tanstack"/);
98
+ });
99
+ });
100
+
101
+ describe('verify check "Fast Deploy wired end to end"', () => {
102
+ const FULL_WRANGLER = `{
103
+ // comment + trailing comma: real wrangler.jsonc, not JSON
104
+ "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }],
105
+ "vars": { "DECO_FAST_DEPLOY": "1" },
106
+ }`;
107
+ const FULL_SETUP = "setupTanstackFastDeploy();\n";
108
+
109
+ it("passes on a fully wired site", () => {
110
+ expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, FULL_SETUP))).toBe(true);
111
+ });
112
+
113
+ it("fails when the DECO_KV binding is missing", () => {
114
+ const w = '{ "kv_namespaces": [], "vars": { "DECO_FAST_DEPLOY": "1" } }';
115
+ expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
116
+ });
117
+
118
+ it("fails when DECO_FAST_DEPLOY is not set", () => {
119
+ const w = '{ "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }] }';
120
+ expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
121
+ });
122
+
123
+ it("fails when setup.ts never hands over the KV resolver", () => {
124
+ expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, "// nothing\n"))).toBe(false);
125
+ });
126
+
127
+ it("is an error, not a warning — a silent half-config is the failure mode", () => {
128
+ expect(fastDeployCheck?.severity).toBe("error");
129
+ });
130
+ });
@@ -0,0 +1,63 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { reconcileIconCasing } from "./phase-transform";
6
+ import { createContext, type MigrationContext } from "./types";
7
+
8
+ let dir: string;
9
+ let ctx: MigrationContext;
10
+
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "icon-casing-"));
13
+ mkdirSync(join(dir, "src", "components", "ui"), { recursive: true });
14
+ mkdirSync(join(dir, "src", "sections"), { recursive: true });
15
+ writeFileSync(
16
+ join(dir, "src", "components", "ui", "Icon.tsx"),
17
+ `export type AvailableIcons =\n | "Close"\n | "Menu"\n | "ChevronDown";\n`,
18
+ );
19
+ ctx = createContext(dir);
20
+ });
21
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
22
+
23
+ describe("reconcileIconCasing", () => {
24
+ it("case-corrects <Icon id> to match the declared AvailableIcons", () => {
25
+ const f = join(dir, "src", "sections", "Drawer.tsx");
26
+ writeFileSync(f, `<Icon id="close" /> <Icon id="menu" /> <Icon id="Close" />`);
27
+ reconcileIconCasing(ctx);
28
+ const out = readFileSync(f, "utf8");
29
+ expect(out).toContain(`id="Close"`);
30
+ expect(out).toContain(`id="Menu"`);
31
+ expect(out).not.toContain(`id="close"`);
32
+ expect(out).not.toContain(`id="menu"`);
33
+ });
34
+
35
+ it("flags a used icon that is NOT in AvailableIcons (no blind rename)", () => {
36
+ const f = join(dir, "src", "sections", "Search.tsx");
37
+ writeFileSync(f, `<Icon id="search" />`);
38
+ reconcileIconCasing(ctx);
39
+ // left as-is
40
+ expect(readFileSync(f, "utf8")).toContain(`id="search"`);
41
+ // flagged for manual review, once
42
+ const flags = ctx.manualReviewItems.filter((m) => m.reason.includes(`id="search"`));
43
+ expect(flags).toHaveLength(1);
44
+ expect(flags[0]!.file).toBe("src/sections/Search.tsx");
45
+ });
46
+
47
+ it("only touches <Icon id>, not other id= attributes", () => {
48
+ const f = join(dir, "src", "sections", "Nav.tsx");
49
+ writeFileSync(f, `<div id="close-button"><Icon id="close" /></div>`);
50
+ reconcileIconCasing(ctx);
51
+ const out = readFileSync(f, "utf8");
52
+ expect(out).toContain(`<div id="close-button">`); // untouched
53
+ expect(out).toContain(`<Icon id="Close" />`); // corrected
54
+ });
55
+
56
+ it("no-op when the site declares no AvailableIcons", () => {
57
+ rmSync(join(dir, "src", "components", "ui", "Icon.tsx"));
58
+ const f = join(dir, "src", "sections", "X.tsx");
59
+ writeFileSync(f, `<Icon id="close" />`);
60
+ reconcileIconCasing(ctx);
61
+ expect(readFileSync(f, "utf8")).toContain(`id="close"`); // unchanged
62
+ });
63
+ });
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { extractPlatform } from "./phase-analyze";
5
+ import { extractGoogleFonts, extractPlatform } from "./phase-analyze";
6
6
 
7
7
  // `extractPlatform` runs four strategies in order:
8
8
  // 1. deno.json imports referencing apps/{platform}/
@@ -10,12 +10,74 @@ import { extractPlatform } from "./phase-analyze";
10
10
  // 3. apps/site.ts loose string match against platform name + "platform"
11
11
  // 4. .deco/blocks filenames hinting at platform
12
12
  // Strategy 3 is the false-positive trap that ate Magento sites before #211:
13
- // helsinki/granadobr's apps/site.ts imports `apps/vtex/mod.ts` for the color
13
+ // a production site's apps/site.ts imports `apps/vtex/mod.ts` for the color
14
14
  // palette, so the content matches `"vtex"` even though the real platform is
15
15
  // declared via `apps/magento.ts`. The fix adds "magento" to the platforms
16
16
  // list so Strategy 2 fires first and short-circuits before Strategy 3 can
17
17
  // over-match.
18
18
 
19
+ describe("extractGoogleFonts", () => {
20
+ let tmp: string;
21
+
22
+ beforeEach(() => {
23
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "extract-gfonts-"));
24
+ fs.mkdirSync(path.join(tmp, "routes"), { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ fs.rmSync(tmp, { recursive: true, force: true });
29
+ });
30
+
31
+ it("extracts preconnects and stylesheet from _app.tsx", () => {
32
+ fs.writeFileSync(
33
+ path.join(tmp, "routes", "_app.tsx"),
34
+ `<link rel="preconnect" href="https://fonts.googleapis.com" />
35
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
36
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" />`,
37
+ );
38
+ const result = extractGoogleFonts(tmp);
39
+ expect(result.preconnects).toEqual([
40
+ "https://fonts.googleapis.com",
41
+ "https://fonts.gstatic.com",
42
+ ]);
43
+ expect(result.stylesheets).toEqual([
44
+ "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
45
+ ]);
46
+ });
47
+
48
+ it("deduplicates URLs present in both _app.tsx and _layout.tsx", () => {
49
+ const sharedUrl = "https://fonts.googleapis.com/css2?family=Roboto&display=swap";
50
+ fs.writeFileSync(
51
+ path.join(tmp, "routes", "_app.tsx"),
52
+ `<link rel="stylesheet" href="${sharedUrl}" />`,
53
+ );
54
+ fs.writeFileSync(
55
+ path.join(tmp, "routes", "_layout.tsx"),
56
+ `<link rel="stylesheet" href="${sharedUrl}" />`,
57
+ );
58
+ const result = extractGoogleFonts(tmp);
59
+ expect(result.stylesheets).toHaveLength(1);
60
+ });
61
+
62
+ it("extracts Google Fonts @import from tailwind.css", () => {
63
+ fs.writeFileSync(
64
+ path.join(tmp, "tailwind.css"),
65
+ `@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');`,
66
+ );
67
+ const result = extractGoogleFonts(tmp);
68
+ expect(result.stylesheets).toEqual([
69
+ "https://fonts.googleapis.com/css2?family=Poppins&display=swap",
70
+ ]);
71
+ });
72
+
73
+ it("returns empty arrays when no Google Fonts are declared", () => {
74
+ fs.writeFileSync(path.join(tmp, "routes", "_app.tsx"), `<link rel="icon" href="/favicon.ico" />`);
75
+ const result = extractGoogleFonts(tmp);
76
+ expect(result.preconnects).toHaveLength(0);
77
+ expect(result.stylesheets).toHaveLength(0);
78
+ });
79
+ });
80
+
19
81
  describe("extractPlatform", () => {
20
82
  let tmp: string;
21
83
 
@@ -28,7 +90,7 @@ describe("extractPlatform", () => {
28
90
  fs.rmSync(tmp, { recursive: true, force: true });
29
91
  });
30
92
 
31
- it("detects magento from apps/magento.ts (granadobr/helsinki shape)", () => {
93
+ it("detects magento from apps/magento.ts (a production site shape)", () => {
32
94
  fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
33
95
  // Also write a site.ts that imports vtex for color palettes — this is
34
96
  // what tripped Strategy 3 before #211.
@@ -51,7 +113,7 @@ describe("extractPlatform", () => {
51
113
  });
52
114
 
53
115
  it("prefers magento over vtex when both an apps/magento.ts AND a vtex string match exist", () => {
54
- // Reproduce the exact granadobr trap: apps/magento.ts is the real signal,
116
+ // Reproduce the exact production trap: apps/magento.ts is the real signal,
55
117
  // but apps/site.ts loosely contains "vtex" + "platform".
56
118
  fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
57
119
  fs.writeFileSync(