import type { TransformResult } from "../types";
/**
* Removes dead code patterns from the old Deco stack that don't work
* in TanStack Start:
*
* - `crypto.subtle.digestSync(...)` (Deno-only sync API)
* - non-active platform branches (see `stripNonPlatformCode`)
*
* NOTE: `export const cache` / `export const cacheKey` are KEPT — they are no
* longer dead. `@decocms/blocks` wires them via `createLoaderEntry` so
* `cache: "stale-while-revalidate"` drives single-flight dedup keyed by
* `cacheKey(props, req)` (the #339 N+1 fix). Only flagged in the notes so the
* author verifies the `(props, req)` signature.
* NOTE: `export const loader` is kept — it's a server-side function the CMS calls.
* NOTE: invoke.* calls are NOT migrated — they are RPC calls to the server
* where the CMS config (API keys, etc.) is available. The runtime.ts invoke
* proxy handles routing them to /deco/invoke/*.
*/
const ALL_PLATFORMS = ["vtex", "shopify", "linx", "vnda", "wake", "nuvemshop"];
/**
* Strip imports, JSX conditionals, and if-blocks for non-active platforms.
* E.g. on a VTEX site, remove all shopify/linx/vnda/wake/nuvemshop branches.
*/
function stripNonPlatformCode(src: string, platform: string): { content: string; stripped: boolean } {
const deadPlatforms = ALL_PLATFORMS.filter((p) => p !== platform);
if (deadPlatforms.length === 0) return { content: src, stripped: false };
const deadRe = new RegExp(`\\b(${deadPlatforms.join("|")})\\b`, "i");
if (!deadRe.test(src)) return { content: src, stripped: false };
let result = src;
const removedIdentifiers: string[] = [];
// 1. Remove import lines referencing dead platforms
const importLineRe = /^import\s+(\w+)\s+from\s+["'][^"']*["'];?\s*$/gm;
result = result.replace(importLineRe, (line, ident) => {
if (deadRe.test(line)) {
removedIdentifiers.push(ident);
return "";
}
return line;
});
// Also handle: import type { X } from "path/platform"
const importTypeLineRe = /^import\s+type\s+\{[^}]*\}\s+from\s+["'][^"']*["'];?\s*$/gm;
result = result.replace(importTypeLineRe, (line) => {
if (deadRe.test(line)) return "";
return line;
});
// Handle lazy dynamic imports: const X = lazy(() => import("./platform/Cart"));
const lazyImportRe = /^const\s+(\w+)\s*=\s*lazy\(\s*\(\)\s*=>\s*import\(["'][^"']*["']\)\s*\);?\s*$/gm;
result = result.replace(lazyImportRe, (line, ident) => {
if (deadRe.test(line)) {
removedIdentifiers.push(ident);
return "";
}
return line;
});
// 2. Remove JSX conditionals: {platform === "shopify" && (...)} using paren-counting
for (const dp of deadPlatforms) {
const jsxPatternStr = `\\{\\s*platform\\s*===\\s*["']${dp}["']\\s*&&\\s*\\(`;
const jsxPattern = new RegExp(jsxPatternStr);
let match: RegExpExecArray | null;
while ((match = jsxPattern.exec(result)) !== null) {
const start = match.index;
// Find the opening paren after &&
let pos = result.indexOf("(", match.index + match[0].length - 1);
if (pos === -1) break;
let depth = 0;
for (; pos < result.length; pos++) {
if (result[pos] === "(") depth++;
else if (result[pos] === ")") {
depth--;
if (depth === 0) break;
}
}
// pos is at the closing paren. Find closing }
let end = pos + 1;
while (end < result.length && /\s/.test(result[end])) end++;
if (end < result.length && result[end] === "}") end++;
// Remove trailing newline
if (end < result.length && result[end] === "\n") end++;
result = result.slice(0, start) + result.slice(end);
}
}
// 3. Remove if-blocks: if (platform === "shopify") { ... }
for (const dp of deadPlatforms) {
const ifPatternStr = `if\\s*\\(\\s*platform\\s*===\\s*["']${dp}["']\\s*\\)\\s*\\{`;
const ifPattern = new RegExp(ifPatternStr);
let match: RegExpExecArray | null;
while ((match = ifPattern.exec(result)) !== null) {
const start = match.index;
let pos = result.indexOf("{", match.index);
if (pos === -1) break;
let depth = 0;
for (; pos < result.length; pos++) {
if (result[pos] === "{") depth++;
else if (result[pos] === "}") {
depth--;
if (depth === 0) break;
}
}
let end = pos + 1;
if (end < result.length && result[end] === "\n") end++;
result = result.slice(0, start) + result.slice(end);
}
}
// 4. Remove JSX references to removed identifiers:
for (const ident of removedIdentifiers) {
// Self-closing:
const selfClose = new RegExp(`\\s*<${ident}\\b[^>]*/>`, "g");
result = result.replace(selfClose, "");
// Open/close pair (rare for platform buttons, but handle it)
const openClose = new RegExp(`\\s*<${ident}\\b[^>]*>[\\s\\S]*?${ident}>`, "g");
result = result.replace(openClose, "");
}
return { content: result, stripped: result !== src };
}
export function transformDeadCode(content: string, platform?: string): TransformResult {
const notes: string[] = [];
let changed = false;
let result = content;
// KEEP `export const cache` / `export const cacheKey`. These are NO LONGER
// dead: `@decocms/blocks` wires them via `createLoaderEntry` (generated in
// `.deco/loaders.gen.ts`) so `cache: "stale-while-revalidate"` opts the loader
// into single-flight dedup keyed by `cacheKey(props, req)`. Stripping them
// would silently reintroduce the #339 N+1 (same loader run once per section).
// Flag them so the author verifies the `(props, req)` signature — old
// `deco-cx/apps` `cacheKey(props, req, ctx)` loses its 3rd `ctx` arg here.
if (/^export\s+const\s+cache\s*=/m.test(result)) {
notes.push(
"KEPT `export const cache` — now drives single-flight dedup via createLoaderEntry (run `generate` to wire it).",
);
}
if (/^export\s+const\s+cacheKey\s*=/m.test(result)) {
notes.push(
"MANUAL: `export const cacheKey` is kept and now active — verify its signature is `(props, req)`; the old `ctx` 3rd arg is no longer passed.",
);
}
// NOTE: `export const loader` is kept — these are server-side functions
// that the CMS calls to modify section props before rendering.
// Replace crypto.subtle.digestSync (Deno-only) with a note
if (result.includes("digestSync")) {
result = result.replace(
/crypto\.subtle\.digestSync\(/g,
"/* MIGRATION: digestSync is Deno-only, use await crypto.subtle.digest( */ crypto.subtle.digest(",
);
changed = true;
notes.push("MANUAL: crypto.subtle.digestSync is Deno-only — replaced with crypto.subtle.digest (needs await)");
}
// Replace logger usage from @deco/deco/o11y with console
if (result.includes("logger.")) {
result = result.replace(/\blogger\.error\b/g, "console.error");
result = result.replace(/\blogger\.warn\b/g, "console.warn");
result = result.replace(/\blogger\.info\b/g, "console.info");
result = result.replace(/\blogger\.debug\b/g, "console.debug");
result = result.replace(/\blogger\.log\b/g, "console.log");
changed = true;
notes.push("Replaced logger.* with console.* (logger from @deco/deco/o11y removed)");
}
// Remove re-exports of framework-only SEO Preview components
const seoPreviewRe = /^export\s*\{[^}]*\}\s*from\s*["'][^"']*_seo[^"']*["'];?\s*$/gm;
if (seoPreviewRe.test(result)) {
result = result.replace(seoPreviewRe, "");
changed = true;
notes.push("Removed re-export of _seo/Preview (framework-only component)");
}
// Replace imports of removed framework APIs with inline stubs
const removedApis: Array<{ import: RegExp; replacement: string; note: string }> = [
{
import: /import\s+\{([^}]*)\bgetSegmentFromBag\b([^}]*)\}\s+from\s+["'][^"']*segment["'];?\n?/,
replacement: `const getSegmentFromBag = (_ctx: any) => ({ value: {} as any });\n`,
note: "Stubbed getSegmentFromBag (Deco bag API removed — uses empty segment)",
},
// createHttpClient is now provided by ~/lib/http-utils (generated during scaffold)
// — no need to remove it here; the import rewrite handles it.
{
import: /import\s+\{([^}]*)\bfetchSafe\b([^}]*)\}\s+from\s+["'][^"']*["'];?\n?/,
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`,
note: "Stubbed fetchSafe (old Deco fetch utility — using native fetch with error check)",
},
{
import: /import\s+\{([^}]*)\bgetISCookiesFromBag\b([^}]*)\}\s+from\s+["'][^"']*intelligentSearch["'];?\n?/,
replacement: `const getISCookiesFromBag = (_ctx: any) => ({});\n`,
note: "Stubbed getISCookiesFromBag (Deco bag API removed — returns empty cookies)",
},
];
for (const api of removedApis) {
if (api.import.test(result)) {
const match = result.match(api.import);
if (match) {
const otherImports = (match[1] + match[2])
.split(",")
.map((s: string) => s.trim())
.filter((s: string) => s && s !== "getSegmentFromBag");
let importLine = "";
if (otherImports.length > 0) {
const fromMatch = match[0].match(/from\s+["']([^"']+)["']/);
const fromPath = fromMatch ? fromMatch[1] : "";
importLine = `import { ${otherImports.join(", ")} } from "${fromPath}";\n`;
}
result = result.replace(api.import, importLine + api.replacement);
changed = true;
notes.push(api.note);
}
}
}
// Strip code for non-active platforms (e.g. remove shopify/linx imports on VTEX site)
if (platform) {
const platformResult = stripNonPlatformCode(result, platform);
if (platformResult.stripped) {
result = platformResult.content;
changed = true;
notes.push(`Stripped non-${platform} platform code`);
}
// Remove usePlatform() — the hook and all platform prop threading.
// On a single-platform site the golden reference removes it entirely:
// - `const platform = usePlatform()` → deleted
// - `{platform === "vtex" && ()}` → ``
// - `platform={platform}` / `platform={usePlatform()}` JSX attrs → deleted
// - `platform: ReturnType` in types → deleted
// - `platform,` in destructuring / param lists → deleted
if (result.includes("usePlatform")) {
const before = result;
// a) Remove `const platform = usePlatform();` declarations
result = result.replace(/^\s*const\s+platform\s*=\s*usePlatform\(\);?\s*\n?/gm, "");
// b) Collapse JSX conditionals for the ACTIVE platform:
// {platform === "vtex" && ()} →
// {platform === "vtex" && } →
const activeJsxParen = new RegExp(
`\\{\\s*platform\\s*===\\s*["']${platform}["']\\s*&&\\s*\\(([\\s\\S]*?)\\)\\s*\\}`,
"g",
);
result = result.replace(activeJsxParen, (_m, inner) => inner.trim());
const activeJsxNoParen = new RegExp(
`\\{\\s*platform\\s*===\\s*["']${platform}["']\\s*&&\\s*(<[^{}]*/>)\\s*\\}`,
"g",
);
result = result.replace(activeJsxNoParen, (_m, jsx) => jsx.trim());
// c) Remove platform JSX attributes: platform={platform}, platform={usePlatform()}
result = result.replace(/\s+platform=\{usePlatform\(\)\}/g, "");
result = result.replace(/\s+platform=\{platform\}/g, "");
// d) Remove platform from type definitions / interfaces
// platform: ReturnType;
result = result.replace(/^\s*platform\s*:\s*ReturnType;?\s*\n?/gm, "");
// platform: Platform; (when from ~/apps/site.ts)
result = result.replace(/^\s*platform\??\s*:\s*Platform;?\s*\n?/gm, "");
// platform?: string;
result = result.replace(/^\s*platform\??\s*:\s*string;?\s*\n?/gm, "");
// e) Remove `platform` from Omit<..., "platform"> → just the base type
result = result.replace(/Omit<(\w+),\s*["']platform["']>/g, "$1");
// f) Clean up remaining references in destructuring and param lists
// { platform, ...rest } → { ...rest } (or just remove the comma'd entry)
result = result.replace(/\bplatform\s*,\s*/g, "");
result = result.replace(/,\s*platform\b/g, "");
if (result !== before) {
changed = true;
notes.push(`Removed usePlatform() — collapsed to ${platform}-only code`);
}
}
}
// ── Module-scope React hook calls ────────────────────────────────────
// In Preact + signals, hooks like useUser()/useCart() can be called at module
// scope because they return signals (no component context needed).
// In React, hooks MUST be inside components. Detect and move them.
//
// Pattern: `const { a, b: alias } = useHookName();` at the top level
// (not indented, not inside a function body).
const moduleScopeHookRe = /^(const\s+\{([^}]+)\}\s*=\s*(use\w+)\(\);?\s*$)/gm;
const moduleScopeHooks: Array<{
fullMatch: string;
vars: string[];
hookCall: string;
hookName: string;
}> = [];
let hookMatch: RegExpExecArray | null;
while ((hookMatch = moduleScopeHookRe.exec(result)) !== null) {
const line = hookMatch[1];
const destructured = hookMatch[2];
const hookName = hookMatch[3];
// Skip non-React hooks (useAccount from ~/utils/sitename is just a function,
// useUI returns signals — both are safe at module scope)
const knownUnsafeHooks = ["useUser", "useCart", "useWishlist"];
if (!knownUnsafeHooks.includes(hookName)) continue;
const vars = destructured
.split(",")
.map((v) => {
const trimmed = v.trim();
const aliasMatch = trimmed.match(/(\w+)\s*:\s*(\w+)/);
return aliasMatch ? aliasMatch[2] : trimmed;
})
.filter(Boolean);
moduleScopeHooks.push({
fullMatch: line,
vars,
hookCall: `const { ${destructured.trim()} } = ${hookName}();`,
hookName,
});
}
if (moduleScopeHooks.length > 0) {
for (const hook of moduleScopeHooks) {
// Remove the module-scope line
result = result.replace(hook.fullMatch + "\n", "");
result = result.replace(hook.fullMatch, "");
// Find exported component functions and locate their body opener.
// Arrow: `export const Foo = (...) => {` — body starts after `=> {`
// Function: `export function Foo(...) {` — body starts after `) {`
const insertions: Array<{ index: number; hookCall: string }> = [];
// Arrow function body openers: "=> {"
const arrowBodyRe = /=>\s*\{/g;
// Function body openers: line starting with export ... function ... ) {
const funcDeclRe = /^export\s+(?:default\s+)?function\s+\w+/gm;
// Strategy: find all `=> {` and `) {` that follow an export declaration,
// then check the body for variable references.
const lines = result.split("\n");
let inExportDecl = false;
let charOffset = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Track exported function/const declarations
if (/^export\s+(default\s+)?(function|const)\s+\w+/.test(line)) {
inExportDecl = true;
}
if (inExportDecl) {
// Look for body opener: `=> {` or `) {` (for function declarations)
let bodyOpenerIdx = -1;
const arrowMatch = line.match(/=>\s*\{/);
const funcMatch = line.match(/\)\s*\{$/);
if (arrowMatch && arrowMatch.index !== undefined) {
bodyOpenerIdx = charOffset + arrowMatch.index + arrowMatch[0].length;
} else if (funcMatch && funcMatch.index !== undefined) {
bodyOpenerIdx = charOffset + funcMatch.index + funcMatch[0].length;
}
if (bodyOpenerIdx >= 0) {
inExportDecl = false;
const bodySlice = result.slice(bodyOpenerIdx, bodyOpenerIdx + 3000);
const usesVars = hook.vars.some((v) => new RegExp(`\\b${v}\\b`).test(bodySlice));
if (usesVars) {
insertions.push({ index: bodyOpenerIdx, hookCall: `\n ${hook.hookCall}` });
}
}
}
charOffset += line.length + 1; // +1 for \n
}
for (const ins of insertions.reverse()) {
result = result.slice(0, ins.index) + ins.hookCall + result.slice(ins.index);
}
if (insertions.length > 0) {
changed = true;
notes.push(`Moved module-scope ${hook.hookName}() into ${insertions.length} component(s)`);
}
}
}
// Clean up blank lines
result = result.replace(/\n{3,}/g, "\n\n");
return { content: result, changed, notes };
}