@decocms/start 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
  2. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
  3. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
  4. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
  5. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
  6. package/.releaserc.json +1 -0
  7. package/package.json +1 -1
  8. package/scripts/generate-blocks.ts +8 -5
  9. package/scripts/generate-loaders.ts +79 -12
  10. package/scripts/migrate/analyzers/island-classifier.ts +23 -0
  11. package/scripts/migrate/analyzers/section-metadata.ts +63 -7
  12. package/scripts/migrate/phase-analyze.ts +190 -11
  13. package/scripts/migrate/phase-cleanup.ts +1162 -7
  14. package/scripts/migrate/phase-scaffold.ts +294 -5
  15. package/scripts/migrate/phase-transform.ts +56 -3
  16. package/scripts/migrate/templates/app-css.ts +149 -2
  17. package/scripts/migrate/templates/commerce-loaders.ts +174 -69
  18. package/scripts/migrate/templates/lib-utils.ts +255 -0
  19. package/scripts/migrate/templates/package-json.ts +30 -22
  20. package/scripts/migrate/templates/routes.ts +81 -11
  21. package/scripts/migrate/templates/section-loaders.ts +369 -33
  22. package/scripts/migrate/templates/server-entry.ts +350 -80
  23. package/scripts/migrate/templates/setup.ts +78 -8
  24. package/scripts/migrate/templates/types-gen.ts +58 -0
  25. package/scripts/migrate/templates/ui-components.ts +47 -16
  26. package/scripts/migrate/templates/vite-config.ts +17 -6
  27. package/scripts/migrate/templates/wrangler.ts +3 -1
  28. package/scripts/migrate/transforms/dead-code.ts +330 -4
  29. package/scripts/migrate/transforms/deno-isms.ts +19 -0
  30. package/scripts/migrate/transforms/imports.ts +93 -30
  31. package/scripts/migrate/transforms/jsx.ts +79 -4
  32. package/scripts/migrate/transforms/section-conventions.ts +105 -3
  33. package/scripts/migrate/types.ts +9 -1
  34. package/src/cms/resolve.ts +12 -1
  35. package/src/sdk/useScript.ts +27 -6
  36. package/src/sdk/workerEntry.ts +11 -2
  37. package/src/setup.ts +1 -1
@@ -16,7 +16,8 @@ export function generateUiComponents(_ctx: MigrationContext): Record<string, str
16
16
  } from "@decocms/apps/commerce/components/Image";
17
17
  `;
18
18
 
19
- files["src/components/ui/Picture.tsx"] = `import {
19
+ files["src/components/ui/Picture.tsx"] = `import type { ReactNode } from "react";
20
+ import {
20
21
  Image,
21
22
  getSrcSet,
22
23
  type FitOptions,
@@ -36,6 +37,19 @@ export interface PictureProps extends Omit<ImageProps, "sizes"> {
36
37
  sources: PictureSourceProps[];
37
38
  }
38
39
 
40
+ export function Source(props: PictureSourceProps & { fit?: FitOptions }) {
41
+ const srcSet = getSrcSet(props.src, props.width, props.height, props.fit ?? "cover");
42
+ return (
43
+ <source
44
+ srcSet={srcSet}
45
+ media={props.media}
46
+ width={props.width}
47
+ height={props.height}
48
+ sizes={props.sizes ?? \`\${props.width}px\`}
49
+ />
50
+ );
51
+ }
52
+
39
53
  export function Picture({
40
54
  sources,
41
55
  src,
@@ -43,24 +57,15 @@ export function Picture({
43
57
  height,
44
58
  fit = "cover",
45
59
  preload,
60
+ children,
46
61
  ...rest
47
- }: PictureProps) {
62
+ }: PictureProps & { children?: ReactNode }) {
48
63
  return (
49
64
  <picture>
50
- {sources.map((source, i) => {
51
- const srcSet = getSrcSet(source.src, source.width, source.height, source.fit ?? fit);
52
- return (
53
- <source
54
- key={i}
55
- srcSet={srcSet}
56
- media={source.media}
57
- width={source.width}
58
- height={source.height}
59
- sizes={source.sizes ?? \`\${source.width}px\`}
60
- />
61
- );
62
- })}
63
- <Image src={src} width={width} height={height} fit={fit} preload={preload} {...rest} />
65
+ {children ?? sources?.map((source, i) => (
66
+ <Source key={i} {...source} fit={source.fit ?? fit} />
67
+ ))}
68
+ {src && <Image src={src} width={width} height={height} fit={fit} preload={preload} {...rest} />}
64
69
  </picture>
65
70
  );
66
71
  }
@@ -107,6 +112,32 @@ export default function Video({
107
112
  />
108
113
  );
109
114
  }
115
+ `;
116
+
117
+ files["src/components/ui/Seo.tsx"] = `export interface Props {
118
+ title?: string;
119
+ description?: string;
120
+ canonical?: string;
121
+ image?: string;
122
+ noIndexing?: boolean;
123
+ jsonLDs?: unknown[];
124
+ }
125
+
126
+ export default function Seo({ jsonLDs }: Props) {
127
+ if (!jsonLDs?.length) return null;
128
+
129
+ return (
130
+ <>
131
+ {jsonLDs.map((jsonLD, i) => (
132
+ <script
133
+ key={i}
134
+ type="application/ld+json"
135
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLD) }}
136
+ />
137
+ ))}
138
+ </>
139
+ );
140
+ }
110
141
  `;
111
142
 
112
143
  return files;
@@ -3,21 +3,30 @@ import type { MigrationContext } from "../types.ts";
3
3
  export function generateViteConfig(ctx: MigrationContext): string {
4
4
  const isVtex = ctx.platform === "vtex";
5
5
 
6
+ const vtexAccount = ctx.vtexAccount || ctx.siteName.replace(/-migrated$/, "").replace(/-storefront$/, "");
7
+
6
8
  const vtexProxy = isVtex ? `
7
9
  // VTEX API proxy for local development
8
10
  proxy: {
9
11
  "/api/": {
10
- target: "https://\${process.env.VTEX_ACCOUNT || "${ctx.siteName}"}.vtexcommercestable.com.br",
12
+ target: VTEX_ORIGIN,
11
13
  changeOrigin: true,
12
- secure: true,
14
+ cookieDomainRewrite: { "*": "" },
13
15
  },
14
- "/checkout/": {
15
- target: "https://\${process.env.VTEX_ACCOUNT || "${ctx.siteName}"}.vtexcommercestable.com.br",
16
+ "/checkout": {
17
+ target: VTEX_ORIGIN,
16
18
  changeOrigin: true,
17
- secure: true,
19
+ cookieDomainRewrite: { "*": "" },
18
20
  },
19
21
  },` : "";
20
22
 
23
+ const vtexConstants = isVtex ? `
24
+ const VTEX_ACCOUNT = "${vtexAccount}";
25
+ const VTEX_ENVIRONMENT = "vtexcommercestable";
26
+ const VTEX_DOMAIN = "com.br";
27
+ const VTEX_ORIGIN = \`https://\${VTEX_ACCOUNT}.\${VTEX_ENVIRONMENT}.\${VTEX_DOMAIN}\`;
28
+ ` : "";
29
+
21
30
  return `import { cloudflare } from "@cloudflare/vite-plugin";
22
31
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
23
32
  import { decoVitePlugin } from "@decocms/start/vite";
@@ -27,7 +36,7 @@ import { defineConfig } from "vite";
27
36
  import path from "path";
28
37
 
29
38
  const srcDir = path.resolve(__dirname, "src");
30
-
39
+ ${vtexConstants}
31
40
  export default defineConfig({
32
41
  server: {
33
42
  allowedHosts: [".decocdn.com"],${vtexProxy}
@@ -106,6 +115,8 @@ export default defineConfig({
106
115
  },
107
116
  resolve: {
108
117
  dedupe: [
118
+ "@decocms/start",
119
+ "@decocms/apps",
109
120
  "@tanstack/react-start",
110
121
  "@tanstack/react-router",
111
122
  "@tanstack/react-start-server",
@@ -8,12 +8,14 @@ export function generateWrangler(ctx: MigrationContext): string {
8
8
 
9
9
  return `{
10
10
  "name": "${workerName}-tanstack",
11
+ // TODO: Set your Cloudflare account_id for deployment
12
+ // "account_id": "YOUR_ACCOUNT_ID",
11
13
  "compatibility_date": "2026-02-14",
12
14
  "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
13
15
  "main": "./src/worker-entry.ts",
14
16
  "workers_dev": true,
15
17
  "preview_urls": true,
16
- // Uncomment for redirect/AB testing support via KV:
18
+ // Uncomment and set KV namespace ID for redirect/AB testing:
17
19
  // "kv_namespaces": [
18
20
  // { "binding": "SITES_KV", "id": "YOUR_KV_NAMESPACE_ID" }
19
21
  // ],
@@ -57,7 +57,114 @@ function removeExportConstBlock(src: string, name: string): string {
57
57
  return src; // unbalanced braces, don't touch
58
58
  }
59
59
 
60
- export function transformDeadCode(content: string): TransformResult {
60
+ const ALL_PLATFORMS = ["vtex", "shopify", "linx", "vnda", "wake", "nuvemshop"];
61
+
62
+ /**
63
+ * Strip imports, JSX conditionals, and if-blocks for non-active platforms.
64
+ * E.g. on a VTEX site, remove all shopify/linx/vnda/wake/nuvemshop branches.
65
+ */
66
+ function stripNonPlatformCode(src: string, platform: string): { content: string; stripped: boolean } {
67
+ const deadPlatforms = ALL_PLATFORMS.filter((p) => p !== platform);
68
+ if (deadPlatforms.length === 0) return { content: src, stripped: false };
69
+
70
+ const deadRe = new RegExp(`\\b(${deadPlatforms.join("|")})\\b`, "i");
71
+ if (!deadRe.test(src)) return { content: src, stripped: false };
72
+
73
+ let result = src;
74
+ const removedIdentifiers: string[] = [];
75
+
76
+ // 1. Remove import lines referencing dead platforms
77
+ const importLineRe = /^import\s+(\w+)\s+from\s+["'][^"']*["'];?\s*$/gm;
78
+ result = result.replace(importLineRe, (line, ident) => {
79
+ if (deadRe.test(line)) {
80
+ removedIdentifiers.push(ident);
81
+ return "";
82
+ }
83
+ return line;
84
+ });
85
+
86
+ // Also handle: import type { X } from "path/platform"
87
+ const importTypeLineRe = /^import\s+type\s+\{[^}]*\}\s+from\s+["'][^"']*["'];?\s*$/gm;
88
+ result = result.replace(importTypeLineRe, (line) => {
89
+ if (deadRe.test(line)) return "";
90
+ return line;
91
+ });
92
+
93
+ // Handle lazy dynamic imports: const X = lazy(() => import("./platform/Cart"));
94
+ const lazyImportRe = /^const\s+(\w+)\s*=\s*lazy\(\s*\(\)\s*=>\s*import\(["'][^"']*["']\)\s*\);?\s*$/gm;
95
+ result = result.replace(lazyImportRe, (line, ident) => {
96
+ if (deadRe.test(line)) {
97
+ removedIdentifiers.push(ident);
98
+ return "";
99
+ }
100
+ return line;
101
+ });
102
+
103
+ // 2. Remove JSX conditionals: {platform === "shopify" && (...)} using paren-counting
104
+ for (const dp of deadPlatforms) {
105
+ const jsxPatternStr = `\\{\\s*platform\\s*===\\s*["']${dp}["']\\s*&&\\s*\\(`;
106
+ const jsxPattern = new RegExp(jsxPatternStr);
107
+ let match: RegExpExecArray | null;
108
+ while ((match = jsxPattern.exec(result)) !== null) {
109
+ const start = match.index;
110
+ // Find the opening paren after &&
111
+ let pos = result.indexOf("(", match.index + match[0].length - 1);
112
+ if (pos === -1) break;
113
+ let depth = 0;
114
+ for (; pos < result.length; pos++) {
115
+ if (result[pos] === "(") depth++;
116
+ else if (result[pos] === ")") {
117
+ depth--;
118
+ if (depth === 0) break;
119
+ }
120
+ }
121
+ // pos is at the closing paren. Find closing }
122
+ let end = pos + 1;
123
+ while (end < result.length && /\s/.test(result[end])) end++;
124
+ if (end < result.length && result[end] === "}") end++;
125
+ // Remove trailing newline
126
+ if (end < result.length && result[end] === "\n") end++;
127
+ result = result.slice(0, start) + result.slice(end);
128
+ }
129
+ }
130
+
131
+ // 3. Remove if-blocks: if (platform === "shopify") { ... }
132
+ for (const dp of deadPlatforms) {
133
+ const ifPatternStr = `if\\s*\\(\\s*platform\\s*===\\s*["']${dp}["']\\s*\\)\\s*\\{`;
134
+ const ifPattern = new RegExp(ifPatternStr);
135
+ let match: RegExpExecArray | null;
136
+ while ((match = ifPattern.exec(result)) !== null) {
137
+ const start = match.index;
138
+ let pos = result.indexOf("{", match.index);
139
+ if (pos === -1) break;
140
+ let depth = 0;
141
+ for (; pos < result.length; pos++) {
142
+ if (result[pos] === "{") depth++;
143
+ else if (result[pos] === "}") {
144
+ depth--;
145
+ if (depth === 0) break;
146
+ }
147
+ }
148
+ let end = pos + 1;
149
+ if (end < result.length && result[end] === "\n") end++;
150
+ result = result.slice(0, start) + result.slice(end);
151
+ }
152
+ }
153
+
154
+ // 4. Remove JSX references to removed identifiers: <RemovedIdent ... />
155
+ for (const ident of removedIdentifiers) {
156
+ // Self-closing: <Ident ... />
157
+ const selfClose = new RegExp(`\\s*<${ident}\\b[^>]*/>`, "g");
158
+ result = result.replace(selfClose, "");
159
+ // Open/close pair (rare for platform buttons, but handle it)
160
+ const openClose = new RegExp(`\\s*<${ident}\\b[^>]*>[\\s\\S]*?</${ident}>`, "g");
161
+ result = result.replace(openClose, "");
162
+ }
163
+
164
+ return { content: result, stripped: result !== src };
165
+ }
166
+
167
+ export function transformDeadCode(content: string, platform?: string): TransformResult {
61
168
  const notes: string[] = [];
62
169
  let changed = false;
63
170
  let result = content;
@@ -118,9 +225,228 @@ export function transformDeadCode(content: string): TransformResult {
118
225
  notes.push("Replaced logger.* with console.* (logger from @deco/deco/o11y removed)");
119
226
  }
120
227
 
121
- // invoke.* calls are server RPC via runtime.ts proxy → keep as-is
122
- // The runtime.ts scaffolded file creates a proxy that routes to /deco/invoke/*
123
- // where the CMS config (API keys, tokens) is available server-side.
228
+ // Remove re-exports of framework-only SEO Preview components
229
+ const seoPreviewRe = /^export\s*\{[^}]*\}\s*from\s*["'][^"']*_seo[^"']*["'];?\s*$/gm;
230
+ if (seoPreviewRe.test(result)) {
231
+ result = result.replace(seoPreviewRe, "");
232
+ changed = true;
233
+ notes.push("Removed re-export of _seo/Preview (framework-only component)");
234
+ }
235
+
236
+ // Replace imports of removed framework APIs with inline stubs
237
+ const removedApis: Array<{ import: RegExp; replacement: string; note: string }> = [
238
+ {
239
+ import: /import\s+\{([^}]*)\bgetSegmentFromBag\b([^}]*)\}\s+from\s+["'][^"']*segment["'];?\n?/,
240
+ replacement: `const getSegmentFromBag = (_ctx: any) => ({ value: {} as any });\n`,
241
+ note: "Stubbed getSegmentFromBag (Deco bag API removed — uses empty segment)",
242
+ },
243
+ // createHttpClient is now provided by ~/lib/http-utils (generated during scaffold)
244
+ // — no need to remove it here; the import rewrite handles it.
245
+ {
246
+ import: /import\s+\{([^}]*)\bfetchSafe\b([^}]*)\}\s+from\s+["'][^"']*["'];?\n?/,
247
+ replacement: `const fetchSafe = async (url: string | URL | Request, init?: RequestInit) => { const r = await fetch(url, init); if (!r.ok) throw new Error(\`fetchSafe: \${r.status}\`); return r; };\n`,
248
+ note: "Stubbed fetchSafe (old Deco fetch utility — using native fetch with error check)",
249
+ },
250
+ {
251
+ import: /import\s+\{([^}]*)\bgetISCookiesFromBag\b([^}]*)\}\s+from\s+["'][^"']*intelligentSearch["'];?\n?/,
252
+ replacement: `const getISCookiesFromBag = (_ctx: any) => ({});\n`,
253
+ note: "Stubbed getISCookiesFromBag (Deco bag API removed — returns empty cookies)",
254
+ },
255
+ ];
256
+
257
+ for (const api of removedApis) {
258
+ if (api.import.test(result)) {
259
+ const match = result.match(api.import);
260
+ if (match) {
261
+ const otherImports = (match[1] + match[2])
262
+ .split(",")
263
+ .map((s: string) => s.trim())
264
+ .filter((s: string) => s && s !== "getSegmentFromBag");
265
+ let importLine = "";
266
+ if (otherImports.length > 0) {
267
+ const fromMatch = match[0].match(/from\s+["']([^"']+)["']/);
268
+ const fromPath = fromMatch ? fromMatch[1] : "";
269
+ importLine = `import { ${otherImports.join(", ")} } from "${fromPath}";\n`;
270
+ }
271
+ result = result.replace(api.import, importLine + api.replacement);
272
+ changed = true;
273
+ notes.push(api.note);
274
+ }
275
+ }
276
+ }
277
+
278
+ // Strip code for non-active platforms (e.g. remove shopify/linx imports on VTEX site)
279
+ if (platform) {
280
+ const platformResult = stripNonPlatformCode(result, platform);
281
+ if (platformResult.stripped) {
282
+ result = platformResult.content;
283
+ changed = true;
284
+ notes.push(`Stripped non-${platform} platform code`);
285
+ }
286
+
287
+ // Remove usePlatform() — the hook and all platform prop threading.
288
+ // On a single-platform site the golden reference removes it entirely:
289
+ // - `const platform = usePlatform()` → deleted
290
+ // - `{platform === "vtex" && (<X />)}` → `<X />`
291
+ // - `platform={platform}` / `platform={usePlatform()}` JSX attrs → deleted
292
+ // - `platform: ReturnType<typeof usePlatform>` in types → deleted
293
+ // - `platform,` in destructuring / param lists → deleted
294
+ if (result.includes("usePlatform")) {
295
+ const before = result;
296
+
297
+ // a) Remove `const platform = usePlatform();` declarations
298
+ result = result.replace(/^\s*const\s+platform\s*=\s*usePlatform\(\);?\s*\n?/gm, "");
299
+
300
+ // b) Collapse JSX conditionals for the ACTIVE platform:
301
+ // {platform === "vtex" && (<Component .../>)} → <Component .../>
302
+ // {platform === "vtex" && <Component .../> } → <Component .../>
303
+ const activeJsxParen = new RegExp(
304
+ `\\{\\s*platform\\s*===\\s*["']${platform}["']\\s*&&\\s*\\(([\\s\\S]*?)\\)\\s*\\}`,
305
+ "g",
306
+ );
307
+ result = result.replace(activeJsxParen, (_m, inner) => inner.trim());
308
+
309
+ const activeJsxNoParen = new RegExp(
310
+ `\\{\\s*platform\\s*===\\s*["']${platform}["']\\s*&&\\s*(<[^{}]*/>)\\s*\\}`,
311
+ "g",
312
+ );
313
+ result = result.replace(activeJsxNoParen, (_m, jsx) => jsx.trim());
314
+
315
+ // c) Remove platform JSX attributes: platform={platform}, platform={usePlatform()}
316
+ result = result.replace(/\s+platform=\{usePlatform\(\)\}/g, "");
317
+ result = result.replace(/\s+platform=\{platform\}/g, "");
318
+
319
+ // d) Remove platform from type definitions / interfaces
320
+ // platform: ReturnType<typeof usePlatform>;
321
+ result = result.replace(/^\s*platform\s*:\s*ReturnType<typeof usePlatform>;?\s*\n?/gm, "");
322
+ // platform: Platform; (when from ~/apps/site.ts)
323
+ result = result.replace(/^\s*platform\??\s*:\s*Platform;?\s*\n?/gm, "");
324
+ // platform?: string;
325
+ result = result.replace(/^\s*platform\??\s*:\s*string;?\s*\n?/gm, "");
326
+
327
+ // e) Remove `platform` from Omit<..., "platform"> → just the base type
328
+ result = result.replace(/Omit<(\w+),\s*["']platform["']>/g, "$1");
329
+
330
+ // f) Clean up remaining references in destructuring and param lists
331
+ // { platform, ...rest } → { ...rest } (or just remove the comma'd entry)
332
+ result = result.replace(/\bplatform\s*,\s*/g, "");
333
+ result = result.replace(/,\s*platform\b/g, "");
334
+
335
+ if (result !== before) {
336
+ changed = true;
337
+ notes.push(`Removed usePlatform() — collapsed to ${platform}-only code`);
338
+ }
339
+ }
340
+ }
341
+
342
+ // ── Module-scope React hook calls ────────────────────────────────────
343
+ // In Preact + signals, hooks like useUser()/useCart() can be called at module
344
+ // scope because they return signals (no component context needed).
345
+ // In React, hooks MUST be inside components. Detect and move them.
346
+ //
347
+ // Pattern: `const { a, b: alias } = useHookName();` at the top level
348
+ // (not indented, not inside a function body).
349
+ const moduleScopeHookRe = /^(const\s+\{([^}]+)\}\s*=\s*(use\w+)\(\);?\s*$)/gm;
350
+ const moduleScopeHooks: Array<{
351
+ fullMatch: string;
352
+ vars: string[];
353
+ hookCall: string;
354
+ hookName: string;
355
+ }> = [];
356
+
357
+ let hookMatch: RegExpExecArray | null;
358
+ while ((hookMatch = moduleScopeHookRe.exec(result)) !== null) {
359
+ const line = hookMatch[1];
360
+ const destructured = hookMatch[2];
361
+ const hookName = hookMatch[3];
362
+
363
+ // Skip non-React hooks (useAccount from ~/utils/sitename is just a function,
364
+ // useUI returns signals — both are safe at module scope)
365
+ const knownUnsafeHooks = ["useUser", "useCart", "useWishlist"];
366
+ if (!knownUnsafeHooks.includes(hookName)) continue;
367
+
368
+ const vars = destructured
369
+ .split(",")
370
+ .map((v) => {
371
+ const trimmed = v.trim();
372
+ const aliasMatch = trimmed.match(/(\w+)\s*:\s*(\w+)/);
373
+ return aliasMatch ? aliasMatch[2] : trimmed;
374
+ })
375
+ .filter(Boolean);
376
+
377
+ moduleScopeHooks.push({
378
+ fullMatch: line,
379
+ vars,
380
+ hookCall: `const { ${destructured.trim()} } = ${hookName}();`,
381
+ hookName,
382
+ });
383
+ }
384
+
385
+ if (moduleScopeHooks.length > 0) {
386
+ for (const hook of moduleScopeHooks) {
387
+ // Remove the module-scope line
388
+ result = result.replace(hook.fullMatch + "\n", "");
389
+ result = result.replace(hook.fullMatch, "");
390
+
391
+ // Find exported component functions and locate their body opener.
392
+ // Arrow: `export const Foo = (...) => {` — body starts after `=> {`
393
+ // Function: `export function Foo(...) {` — body starts after `) {`
394
+ const insertions: Array<{ index: number; hookCall: string }> = [];
395
+
396
+ // Arrow function body openers: "=> {"
397
+ const arrowBodyRe = /=>\s*\{/g;
398
+ // Function body openers: line starting with export ... function ... ) {
399
+ const funcDeclRe = /^export\s+(?:default\s+)?function\s+\w+/gm;
400
+
401
+ // Strategy: find all `=> {` and `) {` that follow an export declaration,
402
+ // then check the body for variable references.
403
+ const lines = result.split("\n");
404
+ let inExportDecl = false;
405
+ let charOffset = 0;
406
+
407
+ for (let i = 0; i < lines.length; i++) {
408
+ const line = lines[i];
409
+
410
+ // Track exported function/const declarations
411
+ if (/^export\s+(default\s+)?(function|const)\s+\w+/.test(line)) {
412
+ inExportDecl = true;
413
+ }
414
+
415
+ if (inExportDecl) {
416
+ // Look for body opener: `=> {` or `) {` (for function declarations)
417
+ let bodyOpenerIdx = -1;
418
+ const arrowMatch = line.match(/=>\s*\{/);
419
+ const funcMatch = line.match(/\)\s*\{$/);
420
+
421
+ if (arrowMatch && arrowMatch.index !== undefined) {
422
+ bodyOpenerIdx = charOffset + arrowMatch.index + arrowMatch[0].length;
423
+ } else if (funcMatch && funcMatch.index !== undefined) {
424
+ bodyOpenerIdx = charOffset + funcMatch.index + funcMatch[0].length;
425
+ }
426
+
427
+ if (bodyOpenerIdx >= 0) {
428
+ inExportDecl = false;
429
+ const bodySlice = result.slice(bodyOpenerIdx, bodyOpenerIdx + 3000);
430
+ const usesVars = hook.vars.some((v) => new RegExp(`\\b${v}\\b`).test(bodySlice));
431
+ if (usesVars) {
432
+ insertions.push({ index: bodyOpenerIdx, hookCall: `\n ${hook.hookCall}` });
433
+ }
434
+ }
435
+ }
436
+
437
+ charOffset += line.length + 1; // +1 for \n
438
+ }
439
+
440
+ for (const ins of insertions.reverse()) {
441
+ result = result.slice(0, ins.index) + ins.hookCall + result.slice(ins.index);
442
+ }
443
+
444
+ if (insertions.length > 0) {
445
+ changed = true;
446
+ notes.push(`Moved module-scope ${hook.hookName}() into ${insertions.length} component(s)`);
447
+ }
448
+ }
449
+ }
124
450
 
125
451
  // Clean up blank lines
126
452
  result = result.replace(/\n{3,}/g, "\n\n");
@@ -45,6 +45,25 @@ export function transformDenoIsms(content: string): TransformResult {
45
45
  notes.push("Replaced @ts-ignore with @ts-expect-error");
46
46
  }
47
47
 
48
+ // Remove jsdom/dompurify — these don't work on Cloudflare Workers.
49
+ // CMS rich-text content is trusted, so sanitization is unnecessary.
50
+ if (/from\s+["']jsdom["']/.test(result) || /from\s+["']dompurify["']/.test(result)) {
51
+ result = result.replace(/^import\s+.*from\s+["']jsdom["'];?\s*\n?/gm, "");
52
+ result = result.replace(/^import\s+.*from\s+["']dompurify["'];?\s*\n?/gm, "");
53
+ // Replace JSDOM + DOMPurify sanitization pattern with pass-through
54
+ result = result.replace(
55
+ /const\s+window\s*=\s*new\s+JSDOM\([^)]*\)\.window;\s*\n\s*const\s+DOMPurifyServer\s*=\s*DOMPurify\(window\);\s*\n\s*const\s+(\w+)\s*=\s*DOMPurifyServer\.sanitize\([^)]+\);/g,
56
+ "const $1 = text;",
57
+ );
58
+ // Simpler pattern: const sanitized = DOMPurify.sanitize(text)
59
+ result = result.replace(
60
+ /const\s+(\w+)\s*=\s*DOMPurify(?:Server)?\.sanitize\([^)]+\);/g,
61
+ "const $1 = text;",
62
+ );
63
+ changed = true;
64
+ notes.push("Stripped jsdom/dompurify — CMS content is trusted on Workers");
65
+ }
66
+
48
67
  // Remove Deno.* API usages — flag for manual review
49
68
  if (result.includes("Deno.")) {
50
69
  notes.push("MANUAL: Deno.* API usage found — needs Node.js equivalent");