import type { TransformResult } from "../types"; /** * Import rewriting rules: from (Deno/Fresh/Preact) → to (Node/TanStack/React) * * Order matters: more specific rules should come first. */ const IMPORT_RULES: Array<[RegExp, string | null]> = [ // Fresh — remove entirely (handled by fresh-apis transform) [/^"\$fresh\/runtime\.ts"/, null], [/^"\$fresh\/server\.ts"/, null], [/^"\$fresh\//, null], // catch-all for any $fresh/* import // Preact → React [/^"preact\/hooks"$/, `"react"`], [/^"preact\/jsx-runtime"$/, null], [/^"preact\/compat"$/, `"react"`], [/^"preact"$/, `"react"`], [/^"@preact\/signals-core"$/, `"~/sdk/signal"`], [/^"@preact\/signals"$/, `"~/sdk/signal"`], // Deco framework — hooks need splitting (useDevice, useScript, useSection) [/^"@deco\/deco\/hooks"$/, `"@decocms/blocks/sdk/useScript"`], [/^"@deco\/deco\/blocks"$/, `"~/types/deco"`], [/^"@deco\/deco\/o11y"$/, null], // logger — use console.log/warn/error instead [/^"@deco\/deco\/web"$/, null], // runtime.ts is rewritten [/^"@deco\/deco\/utils\/invoke\.types\.ts"$/, null], [/^"@deco\/deco\/utils\/([^"]+)"$/, null], [/^"@deco\/deco"$/, `"~/types/deco"`], // Apps — widgets & components // Widget aliases (ImageWidget, HTMLWidget, ...) are framework-owned — // every site has the same type set, and the schema generator detects // them via type-text matching, not module identity. Re-export from // @decocms/blocks/types/widgets so we don't keep a duplicated 8-line // file in every site. [/^"apps\/admin\/widgets\.ts"$/, `"@decocms/blocks/types/widgets"`], [/^"apps\/website\/components\/Image\.tsx"$/, `"~/components/ui/Image"`], [/^"apps\/website\/components\/Picture\.tsx"$/, `"~/components/ui/Picture"`], [/^"apps\/website\/components\/Video\.tsx"$/, `"~/components/ui/Video"`], [/^"apps\/website\/components\/Theme\.tsx"$/, `"~/components/ui/Theme"`], [/^"apps\/website\/components\/_seo\/[^"]+?"$/, null], // SEO preview — framework-only, remove [/^"apps\/website\/components\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps-commerce/types"`], [/^"apps\/commerce\/mod\.ts"$/, `"~/types/commerce-app"`], [/^"apps\/commerce\/types"$/, `"@decocms/apps-commerce/types"`], // Apps — VTEX hooks: useUser/useCart/useWishlist → local hooks (react-query based @decocms/apps-vtex hooks crash Workers SSR) [/^"apps\/vtex\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`], [/^"apps\/vtex\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`], [/^"apps\/vtex\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`], [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/hooks/$1"`], // Specific VTEX utils that moved to different paths in @decocms/apps-vtex // fetchVTEX (generic fetchSafe + QS sanitization) lives at vtex/utils/fetch in apps-start. [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps-vtex/utils/fetch"`], [/^"apps\/vtex\/utils\/client(?:\.ts)?"$/, `"@decocms/apps-vtex/client"`], [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/utils/$1"`], [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/actions/$1"`], // Tier B loader path rewrites (apps-start has no `intelligentSearch/`, `legacy/`, or `paths/` subdirs). // Intelligent Search loaders moved to inline-loaders/. [ /^"apps\/vtex\/loaders\/intelligentSearch\/productList(?:\.ts)?"$/, `"@decocms/apps-vtex/inline-loaders/productList"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/productListingPage(?:\.ts)?"$/, `"@decocms/apps-vtex/inline-loaders/productListingPage"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/productDetailsPage(?:\.ts)?"$/, `"@decocms/apps-vtex/inline-loaders/productDetailsPage"`, ], [ /^"apps\/vtex\/loaders\/intelligentSearch\/suggestions(?:\.ts)?"$/, `"@decocms/apps-vtex/inline-loaders/suggestions"`, ], // Legacy product loaders are consolidated into a single file (named exports). [ /^"apps\/vtex\/loaders\/legacy\/(?:productList|productListingPage|productDetailsPage|search|category)(?:\.ts)?"$/, `"@decocms/apps-vtex/loaders/legacy"`, ], // Path-default loaders (sitemap seeds) don't exist in TanStack Start — paths resolve at request time. [/^"apps\/vtex\/loaders\/paths\/(?:[^"]+)(?:\.ts)?"$/, null], [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-vtex/loaders/$1"`], [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps-vtex/types"`], [/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`], // Apps — Shopify (hooks, utils, actions, loaders) // Shopify hooks were never a real package export — not in the pre-split // @decocms/apps monolith, not in @decocms/apps-shopify today (it ships // no src/hooks/ dir and no ./hooks/* export; verified via `git log // --diff-filter=A -- '**/shopify/hooks/**'` returning nothing across all // history). The legacy migration reference // (.agents/skills/deco-to-tanstack-migration/references/platform-hooks/README.md) // confirms Shopify's useCart/useUser/useWishlist were always meant to be // site-local no-op stubs, and templates/hooks.ts's generateHooks() still // scaffolds them at src/hooks/use{Cart,User,Wishlist}.ts for every // non-VTEX platform (shopify included). Mirror the VTEX rule shape below: // route the three known hook names to the scaffolded local files, same as // "apps/vtex/hooks/useUser" → "~/hooks/useUser" above. Do NOT reintroduce // a generic "apps/shopify/hooks/$1" → "@decocms/apps-shopify/hooks/$1" // fallback — that target has never existed. [/^"apps\/shopify\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`], [/^"apps\/shopify\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`], [/^"apps\/shopify\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`], [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/utils/$1"`], [/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/actions/$1"`], [/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-shopify/loaders/$1"`], // Apps — commerce (types, SDK, utils) [/^"apps\/commerce\/sdk\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-commerce/sdk/$1"`], [/^"apps\/commerce\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps-commerce/utils/$1"`], // Apps — shared utils (STALE, fetchSafe, createHttpClient, etc.) [/^"apps\/utils\/fetch(?:\.ts)?"$/, `"~/lib/fetch-utils"`], [/^"apps\/utils\/http(?:\.ts)?"$/, `"~/lib/http-utils"`], [/^"apps\/utils\/graphql(?:\.ts)?"$/, `"~/lib/graphql-utils"`], // Apps — catch-all (things like apps/website/mod.ts, apps/analytics/mod.ts, etc.) [/^"apps\/([^"]+)"$/, null], // Remove — site.ts is rewritten // Deco old CDN imports [/^"deco\/([^"]+)"$/, null], // Remote URL imports (esm.sh, cdn.esm.sh, skypack, etc.) — remove [/^"https?:\/\/esm\.sh\/[^"]*"$/, null], [/^"https?:\/\/cdn\.esm\.sh\/[^"]*"$/, null], [/^"https?:\/\/cdn\.skypack\.dev\/[^"]*"$/, null], [/^"https?:\/\/deno\.land\/[^"]*"$/, null], // Std lib — redirect useful utils, remove the rest [/^"std\/async\/debounce(?:\.ts)?"$/, `"~/sdk/debounce"`], [/^"std\/([^"]+)"$/, null], [/^"@std\/crypto"$/, null], // Use globalThis.crypto instead // site/sdk/* → framework equivalents (before the catch-all site/ → ~/ rule) [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], // account.json → constants/account (JSON file replaced with TS module in cleanup) [/^"\$store\/account\.json"$/, `"~/constants/account"`], [/^"site\/account\.json"$/, `"~/constants/account"`], [/^"~\/account\.json"$/, `"~/constants/account"`], // $store/ → ~/ (common Deno import map alias for project root) [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"\$store\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) [/^"\$store\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps-commerce/sdk/formatPrice"`], [/^"\$store\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], // islands → components (must be before $store catch-all) [/^"\$store\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], [/^"\$store\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`], [/^"\$store\/(.+)"$/, `"~/$1"`], // $home/ → ~/ (another common alias) [/^"\$home\/(.+)"$/, `"~/$1"`], // site/ → ~/ [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/blocks/sdk/clx"`], [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`], // useOffer and useVariantPossiblities kept as site files (~/sdk/) [/^"site\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps-commerce/sdk/formatPrice"`], [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null], // islands → components (must be before site/ catch-all) [/^"site\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], [/^"site\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`], [/^"site\/(.+)"$/, `"~/$1"`], // ~/islands/* → ~/components/* (catch any that slipped through) [/^"~\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`], [/^"~\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`], // @decocms/apps-vtex hooks → local hooks (react-query hooks crash Workers SSR at module eval) // Pre-7.x monolith path — kept for sites whose Deno import map already // aliased directly to the npm specifier instead of the "apps/vtex/..." form. [/^"@decocms\/apps\/vtex\/hooks\/useUser"$/, `"~/hooks/useUser"`], [/^"@decocms\/apps\/vtex\/hooks\/useCart"$/, `"~/hooks/useCart"`], [/^"@decocms\/apps\/vtex\/hooks\/useWishlist"$/, `"~/hooks/useWishlist"`], // Post-7.x split package path — same rationale, current package name. [/^"@decocms\/apps-vtex\/hooks\/useUser"$/, `"~/hooks/useUser"`], [/^"@decocms\/apps-vtex\/hooks\/useCart"$/, `"~/hooks/useCart"`], [/^"@decocms\/apps-vtex\/hooks\/useWishlist"$/, `"~/hooks/useWishlist"`], ]; /** * Relative import rewrites for SDK files that are deleted during migration. * These are matched against the resolved import path (after ../.. resolution). * The key is the ending of the import path, the value is the replacement specifier. */ const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [ // sdk/clx → @decocms/blocks/sdk/clx (framework utility) [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/blocks/sdk/clx"], // sdk/useId → react (useId is built-in in React 19) [/(?:\.\.\/)*sdk\/useId(?:\.tsx?)?$/, "react"], // sdk/useOffer — kept as-is (sites customize offer logic) // sdk/useVariantPossiblities — kept as-is (sites customize variant logic) // sdk/format → @decocms/apps-commerce/sdk/formatPrice [/(?:\.\.\/)*sdk\/format(?:\.tsx?)?$/, "@decocms/apps-commerce/sdk/formatPrice"], // sdk/usePlatform → remove entirely [/(?:\.\.\/)*sdk\/usePlatform(?:\.tsx?)?$/, ""], // static/adminIcons → deleted (icon loaders need rewriting) [/(?:\.\.\/)*static\/adminIcons(?:\.ts)?$/, ""], // islands/ui/* → components/ui/* (islands are merged into components) [/(?:\.\.\/)*islands\/ui\/([^"]+?)(?:\.tsx?)?$/, "~/components/ui/$1"], [/(?:\.\.\/)*islands\/([^"]+?)(?:\.tsx?)?$/, "~/components/$1"], ]; /** * Rewrites import specifiers in a file. * * Handles: * - import X from "old" → import X from "new" * - import { X } from "old" → import { X } from "new" * - import type { X } from "old" → import type { X } from "new" * - export { X } from "old" → export { X } from "new" * - import "old" → import "new" * * When a rule maps to null, the entire import line is removed. */ export function transformImports( content: string, islandWrapperTargets?: Map, ): TransformResult { const notes: string[] = []; let changed = false; // Build dynamic rules from island wrapper targets (wrapper island → actual component) const dynamicRules: Array<[RegExp, string]> = []; if (islandWrapperTargets) { for (const [islandPath, targetImport] of islandWrapperTargets) { const componentPath = islandPath.replace("islands/", "components/").replace(/\.tsx?$/, ""); // Match ~/components/X and rewrite to the wrapper's actual target const escaped = componentPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); dynamicRules.push([ new RegExp(`^"~/${escaped}"$`), `"${targetImport}"`, ]); } } // Strip BOM that prevents ^ matching on the first line if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); changed = true; } // Match import/export lines with their specifiers // The suffix group also captures import assertions (with { type: "json" }) and assert syntax const importLineRegex = /^(import\s+(?:type\s+)?(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)("[^"]+"|'[^']+')((?:\s+(?:with|assert)\s+\{[^}]*\})?;?\s*)$/gm; const reExportLineRegex = /^(export\s+(?:type\s+)?\{[^}]*\}\s+from\s+)("[^"]+"|'[^']+')((?:\s+(?:with|assert)\s+\{[^}]*\})?;?\s*)$/gm; const sideEffectImportRegex = /^(import\s+)("[^"]+"|'[^']+')((?:\s+(?:with|assert)\s+\{[^}]*\})?;?\s*)$/gm; /** * Post-process: split @deco/deco/hooks imports. * In the old stack, @deco/deco/hooks exported useDevice, useScript, useSection, etc. * In @decocms/blocks, useDevice is at @decocms/blocks/sdk/useDevice. * After import rewriting, we need to split lines like: * import { useDevice, useScript } from "@decocms/blocks/sdk/useScript" * into: * import { useDevice } from "@decocms/blocks/sdk/useDevice" * import { useScript } from "@decocms/blocks/sdk/useScript" */ function splitDecoHooksImports(code: string): string { return code.replace( /^(import\s+(?:type\s+)?\{)([^}]*\buseDevice\b[^}]*)(\}\s+from\s+["']@decocms\/blocks\/sdk\/useScript["'];?)$/gm, (_match, _prefix, importList, _suffix) => { const items = importList.split(",").map((s: string) => s.trim()).filter(Boolean); const deviceItems = items.filter((s: string) => s.includes("useDevice")); const otherItems = items.filter((s: string) => !s.includes("useDevice")); const lines: string[] = []; if (deviceItems.length > 0) { lines.push(`import { ${deviceItems.join(", ")} } from "@decocms/blocks/sdk/useDevice";`); } if (otherItems.length > 0) { lines.push(`import { ${otherItems.join(", ")} } from "@decocms/blocks/sdk/useScript";`); } return lines.join("\n"); }, ); } function applyDynamicRules(result: string): string { for (const [dynPattern, dynReplacement] of dynamicRules) { if (dynPattern.test(result)) return dynReplacement; } return result; } function rewriteSpecifier(specifier: string): string | null { // Remove quotes for matching const inner = specifier.slice(1, -1); for (const [pattern, replacement] of IMPORT_RULES) { if (pattern.test(`"${inner}"`)) { if (replacement === null) return null; let result = `"${inner}"`.replace(pattern, replacement); let resultInner = result.slice(1, -1); if ( (resultInner.startsWith("~/") || resultInner.startsWith("./") || resultInner.startsWith("../")) && (resultInner.endsWith(".ts") || resultInner.endsWith(".tsx")) ) { resultInner = resultInner.replace(/\.tsx?$/, ""); result = `"${resultInner}"`; } return applyDynamicRules(result); } } // Relative imports pointing to deleted SDK files → framework equivalents if (inner.startsWith("./") || inner.startsWith("../")) { for (const [pattern, replacement] of RELATIVE_SDK_REWRITES) { if (pattern.test(inner)) { if (replacement === "") return null; const resolved = inner.replace(pattern, replacement); return applyDynamicRules(`"${resolved}"`); } } } // npm: prefix removal if (inner.startsWith("npm:")) { const cleaned = inner .slice(4) .replace(/@[\d^~>=<.*]+$/, ""); return `"${cleaned}"`; } // Strip .ts/.tsx extensions from relative imports if ( (inner.startsWith("./") || inner.startsWith("../") || inner.startsWith("~/")) && (inner.endsWith(".ts") || inner.endsWith(".tsx")) ) { const stripped = inner.replace(/\.tsx?$/, ""); return applyDynamicRules(`"${stripped}"`); } return specifier; } function processLine( _match: string, prefix: string, specifier: string, suffix: string, ): string { const newSpec = rewriteSpecifier(specifier); if (newSpec === null) { changed = true; notes.push(`Removed import: ${specifier}`); return ""; // Remove the line } if (newSpec !== specifier) { changed = true; notes.push(`Rewrote: ${specifier} → ${newSpec}`); // Strip import assertions (with/assert { type: "json" }) when the // specifier no longer points to a JSON file (e.g. account.json → constants/account) let cleanSuffix = suffix; if (specifier.includes(".json") && !newSpec.includes(".json")) { cleanSuffix = cleanSuffix.replace(/\s*(?:with|assert)\s*\{[^}]*\}\s*/, ""); } return `${prefix}${newSpec}${cleanSuffix}`; } return `${prefix}${specifier}${suffix}`; } let result = content; result = result.replace(importLineRegex, processLine); result = result.replace(reExportLineRegex, processLine); result = result.replace(sideEffectImportRegex, processLine); // Split @deco/deco/hooks imports that contain useDevice const afterSplit = splitDecoHooksImports(result); if (afterSplit !== result) { result = afterSplit; changed = true; notes.push("Split useDevice into separate import from @decocms/blocks/sdk/useDevice"); } // Rewrite dynamic imports: route through rewriteSpecifier so sdk-specific // rules (e.g. site/sdk/useId → react) are applied consistently. const dynamicImportRe = /\bimport\(\s*(["'])([^"']+)\1\s*\)/g; result = result.replace(dynamicImportRe, (_match, quote, specifier) => { const quoted = `"${specifier}"`; const rewritten = rewriteSpecifier(quoted); if (rewritten === null) { // Rule says remove — leave the dynamic import as-is (caller must fix manually) return _match; } if (rewritten !== quoted) { const newSpecifier = rewritten.slice(1, -1); changed = true; notes.push(`Rewrote dynamic import: ${specifier} → ${newSpecifier}`); return `import(${quote}${newSpecifier}${quote})`; } return _match; }); // Clean up blank lines left by removed imports (collapse multiple to one) result = result.replace(/\n{3,}/g, "\n\n"); return { content: result, changed, notes }; }