import * as fs from "node:fs";
import * as path from "node:path";
import type {
DetectedPattern,
FileRecord,
MigrationContext,
Platform,
} from "./types";
import { log, logPhase } from "./types";
import { extractSectionMetadata } from "./analyzers/section-metadata";
import { classifyIslands } from "./analyzers/island-classifier";
import { inventoryLoaders } from "./analyzers/loader-inventory";
import { extractTailwindConfig } from "./analyzers/tailwind-config";
import {
COMPONENT_DELETE,
GENERATED_FILES,
LOADER_DELETE,
ROOT_DELETE,
SDK_DELETE,
STATIC_DELETE,
} from "./delete-sets";
const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
["preact-hooks", /from\s+["']preact\/hooks["']/],
["preact-signals", /from\s+["']@preact\/signals/],
["fresh-runtime", /from\s+["']\$fresh\/runtime/],
["fresh-server", /from\s+["']\$fresh\/server/],
["deco-hooks", /from\s+["']@deco\/deco\/hooks["']/],
["deco-context", /Context\.active\(\)/],
["deco-web", /from\s+["']@deco\/deco\/web["']/],
["deco-blocks", /from\s+["']@deco\/deco\/blocks["']/],
["apps-imports", /from\s+["']apps\//],
["site-imports", /from\s+["']site\//],
["class-attr", /<[a-zA-Z][^>]*\sclass\s*=/],
["onInput-handler", /onInput\s*=/],
["deno-lint-ignore", /deno-lint-ignore/],
["npm-prefix", /from\s+["']npm:/],
["component-children", /ComponentChildren/],
["jsx-types", /JSX\.(?:SVG|HTML|Generic)/],
["asset-function", /\basset\(/],
["head-component", /
]/],
["define-app", /defineApp\(/],
["invoke-proxy", /proxy {
const deps: Record = {};
const regex = /from\s+["']npm:(@?[^@"']+)(?:@([^"']+))?["']/g;
let match;
while ((match = regex.exec(content)) !== null) {
const name = match[1];
const version = match[2] || "*";
if (name.startsWith("preact") || name.startsWith("@preact/")) continue;
deps[name] = `^${version}`;
}
// Detect well-known third-party packages imported without npm: prefix
const KNOWN_THIRD_PARTY: Record = {
"@sentry/react": "^10.43.0",
"dompurify": "^3.3.3",
"fuse.js": "^7.0.0",
"swiper": "^11.2.6",
"lottie-web": "^5.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
};
for (const [pkg, version] of Object.entries(KNOWN_THIRD_PARTY)) {
const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`from\\s+["']${escaped}`, "m").test(content)) {
deps[pkg] = version;
}
}
return deps;
}
function detectPatterns(content: string): DetectedPattern[] {
const patterns: DetectedPattern[] = [];
for (const [name, regex] of PATTERN_DETECTORS) {
if (regex.test(content)) {
patterns.push(name);
}
}
return patterns;
}
function isReExport(content: string): { is: boolean; target?: string } {
const match = content.match(
/^export\s+\{\s*default\s*\}\s+from\s+["']([^"']+)["']/m,
);
if (match) return { is: true, target: match[1] };
return { is: false };
}
function categorizeFile(
relPath: string,
): FileRecord["category"] {
if (relPath.startsWith("sections/")) return "section";
if (relPath.startsWith("islands/")) return "island";
if (relPath.startsWith("components/")) return "component";
if (relPath.startsWith("sdk/")) return "sdk";
if (relPath.startsWith("loaders/")) return "loader";
if (relPath.startsWith("actions/")) return "action";
if (relPath.startsWith("routes/")) return "route";
if (relPath.startsWith("apps/")) return "app";
if (relPath.startsWith("static/") || relPath.startsWith("static-")) return "static";
if (GENERATED_FILES.has(relPath)) return "generated";
if (
relPath === "deno.json" || relPath === "tsconfig.json" ||
relPath === "tailwind.config.ts"
) {
return "config";
}
return "other";
}
function decideAction(
record: FileRecord,
): { action: FileRecord["action"]; targetPath?: string; notes?: string } {
const { path: relPath, category, isReExport: isReExp } = record;
// Generated files → delete
if (category === "generated") {
return { action: "delete" };
}
// Root config/infra → delete (will be scaffolded)
if (ROOT_DELETE.has(relPath)) {
return { action: "delete", notes: "Replaced by scaffolded config" };
}
// Routes → delete (will be scaffolded)
if (category === "route") {
return { action: "delete", notes: "Routes are scaffolded fresh" };
}
// Apps deco/ dir → delete
if (relPath.startsWith("apps/deco/")) {
return { action: "delete", notes: "Deco apps not needed in TanStack" };
}
// apps/site.ts → delete (will be scaffolded)
if (relPath === "apps/site.ts") {
return { action: "delete", notes: "Rewritten from scratch" };
}
// All remaining apps/ files → delete (platform config is in setup.ts now)
if (relPath.startsWith("apps/")) {
return { action: "delete", notes: "Old app config — platform setup is in setup.ts" };
}
// Loaders that depend on deleted admin tooling
if (LOADER_DELETE.has(relPath)) {
return {
action: "delete",
notes: "Admin icon loader — depends on deleted static/adminIcons.ts",
};
}
// SDK files to delete (replaced by scaffolded or framework equivalents)
if (SDK_DELETE.has(relPath)) {
return {
action: "delete",
notes: "Use framework equivalent from @decocms/blocks or @decocms/apps-vtex",
};
}
// Component files replaced by scaffolded versions
if (COMPONENT_DELETE.has(relPath)) {
return {
action: "delete",
notes: "Scaffolded fresh from @decocms/blocks re-exports",
};
}
// cart/ directory → delete
if (relPath.startsWith("sdk/cart/")) {
return { action: "delete", notes: "Use @decocms/apps-vtex cart hooks" };
}
// Non-platform cleanup is done in analyze() post-processing (needs ctx.platform)
// Islands — classify and route to appropriate target
if (category === "island") {
const classification = (record as any).__islandClassification;
if (classification?.type === "wrapper") {
return { action: "delete", notes: "Island wrapper — imports repointed to component" };
}
// Standalone islands go to components/, not sections/
const componentPath = relPath.replace("islands/", "components/");
return {
action: "transform",
targetPath: `src/${componentPath}`,
notes: "Standalone island moved to components",
};
}
// Sections that re-export from islands/ → TRANSFORM (rewrite to ~/components/)
// The CMS still references these section keys, so the file must exist.
// The island was moved to components/, so we rewrite the re-export target.
if (category === "section" && isReExp) {
const target = record.reExportTarget || "";
const isIslandReExport = target.includes("islands/") ||
target.includes("islands\\");
if (isIslandReExport) {
return {
action: "transform",
targetPath: `src/${relPath}`,
notes: "Section re-export — island target rewritten to ~/components/",
};
}
// Section re-exports from components/ — keep and transform
}
// Session component → delete (analytics moves to __root.tsx)
if (
relPath === "components/Session.tsx" || relPath === "sections/Session.tsx"
) {
return {
action: "delete",
notes: "Analytics SDK moved to __root.tsx scaffold",
};
}
// legacy HTMX-style dynamic-section loader → delete and flag.
// The file uses Deno-only APIs (`toFileUrl(Deno.cwd())`, `import.meta.resolve`)
// and the `useComponent(component, props)` HTMX render-and-swap pattern, none
// of which work on TanStack Start / Cloudflare Workers. The site author must
// refactor every `useComponent(...)` call site to React state, `createServerFn`
// + `useMutation`, or a direct `~/server/invoke` call BEFORE this file is
// safe to remove.
if (
relPath === "sections/Component.tsx" ||
relPath === "sections/Component.ts"
) {
return {
action: "delete",
notes:
"HTMX dynamic-section loader (useComponent) — incompatible with TanStack Start. " +
"Migrate every useComponent(...) call site to React state / createServerFn before deploy.",
};
}
// Static code/tooling files → delete
if (STATIC_DELETE.has(relPath)) {
return { action: "delete", notes: "Code/tooling file, not an asset" };
}
// Static files → move
if (category === "static") {
const publicPath = relPath.replace("static/", "public/");
return { action: "move", targetPath: publicPath };
}
// Non-code root files that shouldn't go into src/
const ext = path.extname(relPath);
const nonCodeExts = new Set([".md", ".csv", ".json", ".sh", ".lock", ".yml", ".yaml", ".xml", ".html", ".txt", ".log"]);
const keepRootFiles = new Set(["account.json"]);
if (!relPath.includes("/") && nonCodeExts.has(ext) && !keepRootFiles.has(relPath)) {
return { action: "delete", notes: "Root-level non-code file" };
}
// Root-level tooling files — not app code
const rootToolingFiles = new Set(["islands.ts", "sync.sh"]);
if (!relPath.includes("/") && rootToolingFiles.has(relPath)) {
return { action: "delete", notes: "Root-level tooling file" };
}
// Everything else → transform into src/
return { action: "transform", targetPath: `src/${relPath}` };
}
function scanDir(
dir: string,
baseDir: string,
files: FileRecord[],
) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = path.relative(baseDir, fullPath);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || entry.name.startsWith("static-")) continue;
scanDir(fullPath, baseDir, files);
continue;
}
// Skip dotfiles and known non-code files
if (SKIP_FILES.has(entry.name) || entry.name.startsWith(".")) continue;
// Only process .ts, .tsx, .css, .json files for transforms
const ext = path.extname(entry.name);
const isCode = [".ts", ".tsx", ".css", ".json"].includes(ext);
let content = "";
let patterns: DetectedPattern[] = [];
let reExport: { is: boolean; target?: string } = { is: false };
if (isCode) {
content = fs.readFileSync(fullPath, "utf-8");
patterns = detectPatterns(content);
reExport = isReExport(content);
}
const record: FileRecord = {
path: relPath,
absPath: fullPath,
category: categorizeFile(relPath),
isReExport: reExport.is,
reExportTarget: reExport.target,
patterns,
action: "transform", // placeholder
};
const decision = decideAction(record);
record.action = decision.action;
record.targetPath = decision.targetPath;
record.notes = decision.notes;
files.push(record);
}
}
export function extractGoogleFonts(
sourceDir: string,
): { preconnects: string[]; stylesheets: string[] } {
const preconnects: string[] = [];
const stylesheets: string[] = [];
const seen = new Set();
const hrefRe = /href=["'](https:\/\/fonts\.(?:googleapis|gstatic)\.com[^"']*)["']/g;
// Read _app.tsx and _layout.tsx for declarations
for (const candidate of ["routes/_app.tsx", "routes/_layout.tsx"]) {
const fp = path.join(sourceDir, candidate);
if (!fs.existsSync(fp)) continue;
const content = fs.readFileSync(fp, "utf-8");
for (const m of content.matchAll(hrefRe)) {
const href = m[1];
if (seen.has(href)) continue;
seen.add(href);
if (href.includes("/css")) {
stylesheets.push(href);
} else {
preconnects.push(href);
}
}
}
// Also check tailwind.css for @import url(https://fonts.googleapis.com/...)
for (const cssCandidate of ["tailwind.css", "styles/app.css", "styles/global.css"]) {
const fp = path.join(sourceDir, cssCandidate);
if (!fs.existsSync(fp)) continue;
const content = fs.readFileSync(fp, "utf-8");
const importRe = /@import\s+url\(['"]?(https:\/\/fonts\.googleapis\.com[^'")\s]+)/g;
for (const m of content.matchAll(importRe)) {
const href = m[1];
if (!seen.has(href)) {
seen.add(href);
stylesheets.push(href);
}
}
}
return { preconnects, stylesheets };
}
function extractGtmId(sourceDir: string): string | null {
const appPath = path.join(sourceDir, "routes", "_app.tsx");
if (!fs.existsSync(appPath)) return null;
const content = fs.readFileSync(appPath, "utf-8");
const match = content.match(/GTM-[A-Z0-9]+/);
return match ? match[0] : null;
}
function extractVtexAccount(sourceDir: string): string | null {
// Strategy 1: utils/sitename.ts — DICT_VTEX_AN mapping
for (const candidate of ["utils/sitename.ts", "sdk/sitename.ts"]) {
const fp = path.join(sourceDir, candidate);
if (!fs.existsSync(fp)) continue;
const content = fs.readFileSync(fp, "utf-8");
// Match patterns like: acme: "acmenewio"
const match = content.match(/vtexAn|VTEX_AN/i);
if (match) {
const dictMatch = content.match(/["'](\w+)["']\s*:\s*["'](\w+myvtex|\w+newio|\w+)["']/);
if (dictMatch) return dictMatch[2];
}
}
// Strategy 2: .myvtex.com references in loaders/routes
const candidates = [
"routes/_app.tsx",
"loaders/reviews/productReviews.ts",
"apps/vtex.ts",
];
for (const candidate of candidates) {
const fp = path.join(sourceDir, candidate);
if (!fs.existsSync(fp)) continue;
const content = fs.readFileSync(fp, "utf-8");
const match = content.match(/["'](\w+)\.myvtex\.com/);
if (match) return match[1];
const match2 = content.match(/account\s*[:=]\s*["'](\w+)["']/);
if (match2) return match2[1];
}
// Strategy 3: deno.json import map or apps/ config
const appsVtexPath = path.join(sourceDir, "apps", "vtex.ts");
if (fs.existsSync(appsVtexPath)) {
const content = fs.readFileSync(appsVtexPath, "utf-8");
const match = content.match(/account\s*:\s*["'](\w+)["']/);
if (match) return match[1];
}
return null;
}
export function extractPlatform(sourceDir: string): Platform {
// Strategy 2 (apps/{p}.ts existence) is the most reliable signal, but it
// only matches when the platform name appears here — so a site whose only
// app file is `apps/magento.ts` previously fell through to Strategy 3
// and got mis-detected as "vtex" because site.ts imports
// `apps/vtex/mod.ts` for color palettes. See #211.
const platforms: Platform[] = [
"vtex",
"shopify",
"wake",
"vnda",
"linx",
"nuvemshop",
"magento",
];
// Strategy 1: Check deno.json imports for platform-specific app imports
const denoPath = path.join(sourceDir, "deno.json");
if (fs.existsSync(denoPath)) {
try {
const deno = JSON.parse(fs.readFileSync(denoPath, "utf-8"));
const imports = deno.imports || {};
for (const p of platforms) {
// e.g. "apps/vtex/" or direct app import containing the platform name
const hasAppImport = Object.keys(imports).some(
(k) => k === `apps/${p}/` || k.includes(`/${p}/mod.ts`) || k.includes(`deco-apps`) && imports[k].includes(`/${p}/`),
);
const hasAppValue = Object.values(imports).some(
(v) => typeof v === "string" && (v as string).includes(`/${p}/`),
);
if (hasAppImport || hasAppValue) return p;
}
// Check if the import map value for "apps/" contains a platform hint
const appsUrl = imports["apps/"];
if (typeof appsUrl === "string") {
for (const p of platforms) {
// The apps/ URL itself doesn't indicate platform, but let's check apps/vtex.ts
const vtexAppPath = path.join(sourceDir, "apps", `${p}.ts`);
if (fs.existsSync(vtexAppPath)) return p;
}
}
} catch {}
}
// Strategy 2: Check for apps/{platform}.ts file existence
for (const p of platforms) {
if (fs.existsSync(path.join(sourceDir, "apps", `${p}.ts`))) return p;
}
// Strategy 3: Check apps/site.ts for platform type and default value
const sitePath = path.join(sourceDir, "apps", "site.ts");
if (fs.existsSync(sitePath)) {
const content = fs.readFileSync(sitePath, "utf-8");
// Look for platform default in state or props: state.platform || "vtex"
const defaultMatch = content.match(/(?:state\.platform|props\.platform)\s*\|\|\s*["'](\w+)["']/);
if (defaultMatch) {
const p = defaultMatch[1] as Platform;
if (platforms.includes(p)) return p;
}
// Look for platform in the Props type
for (const p of platforms) {
if (content.includes(`"${p}"`) && (content.includes("Platform") || content.includes("platform"))) {
return p;
}
}
}
// Strategy 4: Check .deco/blocks for platform-specific block files
const blocksDir = path.join(sourceDir, ".deco", "blocks");
if (fs.existsSync(blocksDir)) {
const blockFiles = fs.readdirSync(blocksDir);
for (const p of platforms) {
if (blockFiles.some((f) => f.includes(`deco-${p}`) || f === `${p}.json`)) return p;
}
}
return "custom";
}
function extractSiteName(sourceDir: string): string {
// Try to extract from .deco or directory name
const dirName = path.basename(path.resolve(sourceDir));
// Try deno.json
const denoPath = path.join(sourceDir, "deno.json");
if (fs.existsSync(denoPath)) {
const deno = JSON.parse(fs.readFileSync(denoPath, "utf-8"));
if (deno.name) return deno.name;
}
return dirName;
}
function extractThemeFromCms(sourceDir: string): { colors: Record; fontFamily: string | null } {
const colors: Record = {};
let fontFamily: string | null = null;
// Look for Theme config in .deco/blocks/
const blocksDir = path.join(sourceDir, ".deco", "blocks");
if (!fs.existsSync(blocksDir)) return { colors, fontFamily };
const files = fs.readdirSync(blocksDir);
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
if (!content.includes("mainColors") && !content.includes("Theme")) continue;
const data = JSON.parse(content);
// Direct Theme block (e.g. Deco.json with __resolveType: "site/sections/Theme/Theme.tsx")
if (data.mainColors) {
Object.assign(colors, data.mainColors);
if (data.complementaryColors) {
Object.assign(colors, data.complementaryColors);
}
}
// Font
if (data.font?.fonts?.[0]?.family) {
fontFamily = data.font.fonts[0].family;
}
// Check sections array (page blocks may contain Theme)
if (data.sections) {
for (const section of data.sections) {
if (section.__resolveType?.includes("Theme") && section.mainColors) {
Object.assign(colors, section.mainColors);
if (section.complementaryColors) {
Object.assign(colors, section.complementaryColors);
}
if (section.font?.fonts?.[0]?.family) {
fontFamily = section.font.fonts[0].family;
}
}
}
}
} catch {}
}
return { colors, fontFamily };
}
export function analyze(ctx: MigrationContext): void {
logPhase("Analyze");
// Parse deno.json for import map
const denoJsonPath = path.join(ctx.sourceDir, "deno.json");
if (fs.existsSync(denoJsonPath)) {
const denoJson = JSON.parse(fs.readFileSync(denoJsonPath, "utf-8"));
ctx.importMap = denoJson.imports || {};
log(
ctx,
`Found ${Object.keys(ctx.importMap).length} import map entries`,
);
}
// Extract metadata
ctx.siteName = extractSiteName(ctx.sourceDir);
ctx.platform = extractPlatform(ctx.sourceDir);
ctx.vtexAccount = ctx.platform === "vtex" ? extractVtexAccount(ctx.sourceDir) : null;
ctx.gtmId = extractGtmId(ctx.sourceDir);
ctx.googleFonts = extractGoogleFonts(ctx.sourceDir);
// If no font URLs were extracted but the file mentions googleapis (likely a
// JSX expression like href={URL_CONST}), flag it for manual review.
if (ctx.googleFonts.preconnects.length === 0 && ctx.googleFonts.stylesheets.length === 0) {
const appPath = path.join(ctx.sourceDir, "routes", "_app.tsx");
if (fs.existsSync(appPath) && fs.readFileSync(appPath, "utf-8").includes("fonts.googleapis")) {
ctx.manualReviewItems.push({
file: "routes/_app.tsx",
reason:
"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.",
severity: "warning",
});
}
}
// Extract theme colors and font from CMS
const theme = extractThemeFromCms(ctx.sourceDir);
ctx.themeColors = theme.colors;
ctx.fontFamily = theme.fontFamily;
// Extract the source site's tailwind.config.ts BEFORE it gets deleted in
// cleanup — otherwise custom colors/fontFamily/safelist are silently
// dropped (see decocms/blocks#369, gotcha #48 in the migration skill).
ctx.tailwindConfig = extractTailwindConfig(ctx.sourceDir);
const twColorCount = Object.keys(ctx.tailwindConfig.colors).length;
if (twColorCount > 0) {
console.log(` Tailwind config: ${twColorCount} custom color(s) ported from tailwind.config.ts`);
}
if (ctx.tailwindConfig.reviewItems.length > 0) {
ctx.manualReviewItems.push(...ctx.tailwindConfig.reviewItems);
}
console.log(` Site: ${ctx.siteName}`);
console.log(` Platform: ${ctx.platform}${ctx.vtexAccount ? ` (account: ${ctx.vtexAccount})` : ""}`);
console.log(` GTM ID: ${ctx.gtmId || "none"}`);
if (Object.keys(ctx.themeColors).length > 0) {
console.log(` Theme: ${Object.keys(ctx.themeColors).length} colors from CMS`);
}
if (ctx.fontFamily) {
console.log(` Font: ${ctx.fontFamily}`);
}
// Scan all files. The root scan skips `src/` (SKIP_DIRS) — for a MODERN
// layout the code lives there, so scan `src/` separately with baseDir=src so
// its files categorize as if they were at root (`src/sections/X` → `sections/X`
// → section → targetPath `src/sections/X`). The root scan still handles config
// + static/ at the repo root.
scanDir(ctx.sourceDir, ctx.sourceDir, ctx.files);
if (ctx.layout === "modern") {
const srcDir = path.join(ctx.sourceDir, "src");
if (fs.existsSync(srcDir)) scanDir(srcDir, srcDir, ctx.files);
}
// Summary
const byAction = { transform: 0, delete: 0, move: 0, scaffold: 0, "manual-review": 0 };
const byCategory: Record = {};
for (const f of ctx.files) {
byAction[f.action]++;
byCategory[f.category] = (byCategory[f.category] || 0) + 1;
}
// Scan all source files for inline npm: imports
for (const f of ctx.files) {
if (f.action === "delete") continue;
const ext = path.extname(f.path);
if (![".ts", ".tsx"].includes(ext)) continue;
try {
const content = fs.readFileSync(f.absPath, "utf-8");
const deps = extractInlineNpmDeps(content);
Object.assign(ctx.discoveredNpmDeps, deps);
} catch {}
}
if (Object.keys(ctx.discoveredNpmDeps).length > 0) {
log(ctx, `Discovered npm deps from source: ${JSON.stringify(ctx.discoveredNpmDeps)}`);
}
console.log(`\n Files found: ${ctx.files.length}`);
console.log(` By category: ${JSON.stringify(byCategory)}`);
console.log(` By action: ${JSON.stringify(byAction)}`);
// Surface the HTMX dynamic-section loader and every `useComponent` call site
// up-front. These need manual conversion to React state / createServerFn before
// the migrated site will run on Cloudflare Workers — the analyzer cannot do
// it automatically, so it must be loud enough to land in the report.
const useComponentSites = ctx.files.filter(
(f) => f.patterns.includes("use-component"),
);
const componentLoaderFile = ctx.files.find(
(f) =>
f.path === "sections/Component.tsx" ||
f.path === "sections/Component.ts",
);
if (componentLoaderFile || useComponentSites.length > 0) {
console.log("\n ⚠ HTMX dynamic-section loader detected (legacy HTMX-style)");
if (componentLoaderFile) {
console.log(` • ${componentLoaderFile.path} (will be deleted)`);
}
if (useComponentSites.length > 0) {
console.log(` • ${useComponentSites.length} useComponent(...) call site(s) need manual conversion:`);
for (const f of useComponentSites.slice(0, 10)) {
console.log(` - ${f.path}`);
}
if (useComponentSites.length > 10) {
console.log(` ... and ${useComponentSites.length - 10} more`);
}
}
console.log(
" See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section",
);
}
// Run analyzers
extractSectionMetadata(ctx);
classifyIslands(ctx);
inventoryLoaders(ctx);
// Apply island classifications to file records
const classMap = new Map(ctx.islandClassifications.map((c) => [c.path, c]));
for (const f of ctx.files) {
if (f.category !== "island") continue;
const classification = classMap.get(f.path);
if (!classification) continue;
if (classification.type === "wrapper") {
f.action = "delete";
f.notes = "Island wrapper — imports repointed to component";
// Build redirect map: island path → wrapped component's migrated import path
if (classification.wrapsComponent) {
let wrappedImport = classification.wrapsComponent;
if (wrappedImport.startsWith("./") || wrappedImport.startsWith("../")) {
// Resolve relative to the island's directory
const islandDir = path.dirname(f.path);
wrappedImport = path.posix.normalize(path.posix.join(islandDir, wrappedImport));
wrappedImport = "~/" + wrappedImport;
} else {
wrappedImport = wrappedImport.replace(/^(\$store|site)\//, "~/");
}
wrappedImport = wrappedImport.replace(/\.tsx?$/, "");
ctx.islandWrapperTargets.set(f.path, wrappedImport);
}
} else {
f.action = "transform";
f.targetPath = classification.suggestedTarget;
f.notes = "Standalone island moved to components";
}
}
// Fix section re-exports from wrapper islands — rewrite to the wrapper's target component
if (ctx.islandWrapperTargets.size > 0) {
for (const f of ctx.files) {
if (f.category !== "section" || !f.isReExport || !f.reExportTarget) continue;
let target = f.reExportTarget;
// Resolve relative paths (../../islands/X) against section's directory
if (target.startsWith("./") || target.startsWith("../")) {
target = path.posix.normalize(path.posix.join(path.dirname(f.path), target));
}
target = target.replace(/^(\$store|site)\//, "").replace(/\.tsx?$/, "");
if (!target.startsWith("islands/")) continue;
const wrappedImport = ctx.islandWrapperTargets.get(target + ".tsx") ||
ctx.islandWrapperTargets.get(target);
if (wrappedImport) {
(f as any).__resolvedReExportTarget = wrappedImport;
f.notes = `Section re-export — island wrapper resolved to ${wrappedImport}`;
}
}
}
// Delete non-platform files (e.g. shopify/linx/vnda/wake/nuvemshop on VTEX sites)
const NON_PLATFORM_PATTERNS: Record = {
vtex: /\/(shopify|linx|vnda|wake|nuvemshop)\b/i,
shopify: /\/(vtex|linx|vnda|wake|nuvemshop)\b/i,
vnda: /\/(vtex|shopify|linx|wake|nuvemshop)\b/i,
wake: /\/(vtex|shopify|linx|vnda|nuvemshop)\b/i,
linx: /\/(vtex|shopify|vnda|wake|nuvemshop)\b/i,
nuvemshop: /\/(vtex|shopify|linx|vnda|wake)\b/i,
};
const nonPlatformRe = NON_PLATFORM_PATTERNS[ctx.platform];
if (nonPlatformRe) {
for (const f of ctx.files) {
if (f.action === "delete") continue;
if (nonPlatformRe.test("/" + f.path)) {
f.action = "delete";
f.notes = `Non-${ctx.platform} platform file`;
}
}
}
}