#!/usr/bin/env node import fs from "fs"; import fsp from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import * as cheerio from "cheerio"; import https from "https"; import http from "http"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); const SOURCE_DIR = "/Users/leon/本地开发项目/claude-code/dalcode 官网"; const PAGES = [ { src: "code 首页.html", route: "", name: "Home" }, { src: "关于.html", route: "about", name: "About" }, { src: "博客.html", route: "blog", name: "Blog" }, { src: "联系.html", route: "contact", name: "Contact" }, ]; // ─── HTML attr → JSX attr mapping ─────────────────────────────────── const ATTR_MAP = { class: "className", for: "htmlFor", srcset: "srcSet", tabindex: "tabIndex", autoplay: "autoPlay", playsinline: "playsInline", crossorigin: "crossOrigin", frameborder: "frameBorder", allowfullscreen: "allowFullScreen", maxlength: "maxLength", minlength: "minLength", autocomplete: "autoComplete", autofocus: "autoFocus", readonly: "readOnly", contenteditable: "contentEditable", novalidate: "noValidate", "accept-charset": "acceptCharset", enctype: "encType", referrerpolicy: "referrerPolicy", "http-equiv": "httpEquiv", srcdoc: "srcDoc", colspan: "colSpan", rowspan: "rowSpan", "stroke-width": "strokeWidth", "stroke-linecap": "strokeLinecap", "stroke-linejoin": "strokeLinejoin", "clip-rule": "clipRule", "fill-rule": "fillRule", "fill-opacity": "fillOpacity", "stroke-opacity": "strokeOpacity", "stop-color": "stopColor", "stop-opacity": "stopOpacity", "clip-path": "clipPath", "font-family": "fontFamily", "font-size": "fontSize", "text-anchor": "textAnchor", "xlink:href": "xlinkHref", "xml:space": "xmlSpace", viewbox: "viewBox", }; const VOID_TAGS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr", ]); const BOOLEAN_ATTRS = new Set([ "disabled", "checked", "selected", "readOnly", "multiple", "muted", "autoPlay", "loop", "controls", "playsInline", "hidden", "required", "open", "allowFullScreen", "autoFocus", "noValidate", ]); const NUMERIC_ATTRS = new Set([ "maxLength", "minLength", "size", "cols", "rows", "colSpan", "rowSpan", "tabIndex", "span", "height", "width", ]); function mapAttrName(name) { const lower = name.toLowerCase(); return ATTR_MAP[lower] || ATTR_MAP[name] || name; } function hyphenToCamel(prop) { const p = String(prop).trim(); if (p.startsWith("--")) return p; if (p.startsWith("-webkit-")) { const rest = p.slice(8).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return "Webkit" + rest[0].toUpperCase() + rest.slice(1); } if (p.startsWith("-moz-")) { const rest = p.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return "Moz" + rest[0].toUpperCase() + rest.slice(1); } if (p.startsWith("-ms-")) { const rest = p.slice(4).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return "ms" + rest[0].toUpperCase() + rest.slice(1); } return p.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function escapeJsString(str) { return String(str).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"'); } function styleToJsx(styleText) { const parts = String(styleText).split(";"); const entries = []; for (const part of parts) { const seg = part.trim(); if (!seg) continue; const idx = seg.indexOf(":"); if (idx === -1) continue; const rawKey = seg.slice(0, idx).trim(); const key = rawKey.startsWith("--") ? rawKey : hyphenToCamel(rawKey); let val = seg.slice(idx + 1).trim(); val = val.replace(/"/g, '"'); entries.push(`"${key}": "${escapeJsString(val)}"`); } return `{{${entries.join(", ")}}}`; } // ─── Node → JSX converter ────────────────────────────────────────── function nodeToJsx($, node, indent = 2) { const pad = " ".repeat(indent); if (node.type === "text") { const txt = (node.data || "").replace(/\s+/g, (m) => (m.includes("\n") ? "\n" : " ")); if (!txt.trim()) return ""; const escaped = txt.replace(/\{/g, "{'{'}") .replace(/\}/g, "{'}'}") .replace(//g, "{'>'}") .replace(/&/g, "&") .replace(/</g, "{'<'}") .replace(/>/g, "{'>'}") .replace(/ /g, "{' '}"); return escaped; } if (node.type === "comment") return ""; if (node.type === "tag" || node.type === "script" || node.type === "style") { const $el = $(node); const tag = node.tagName || node.name; if (tag === "script") return ""; if (tag === "style") return ""; const attribs = $el.attr() || {}; const attrParts = []; const spreadParts = {}; for (const [rawName, value] of Object.entries(attribs)) { const name = mapAttrName(rawName); const val = String(value); const isDataOrAria = rawName.startsWith("data-") || rawName.startsWith("aria-"); if (rawName === "style") { attrParts.push(` style=${styleToJsx(val)}`); continue; } const hasHyphen = rawName.includes("-"); if (!isDataOrAria && hasHyphen && name === rawName) { spreadParts[rawName] = val; continue; } const mappedIsBool = BOOLEAN_ATTRS.has(name); if (mappedIsBool && (val === "" || val.toLowerCase() === rawName.toLowerCase())) { attrParts.push(` ${name}`); continue; } if (NUMERIC_ATTRS.has(name) && /^\d+$/.test(val)) { attrParts.push(` ${name}={${val}}`); continue; } const escaped = val.replace(/"/g, """); attrParts.push(` ${name}="${escaped}"`); } let spreadStr = ""; if (Object.keys(spreadParts).length) { spreadStr = ` {...${JSON.stringify(spreadParts)}}`; } const attrsStr = attrParts.join("") + spreadStr; const children = node.children || []; const hasChildren = children.length > 0 && children.some((c) => c.type !== "comment"); if (VOID_TAGS.has(tag) && !hasChildren) { return `${pad}<${tag}${attrsStr} />\n`; } let out = `${pad}<${tag}${attrsStr}>\n`; for (const child of children) { out += nodeToJsx($, child, indent + 2); } out += `${pad}\n`; return out; } return ""; } // ─── Extract CSS from HTML ────────────────────────────────────────── function extractCSS(html) { const styleRegex = /]*>([\s\S]*?)<\/style>/gi; const styles = []; let m; while ((m = styleRegex.exec(html)) !== null) { styles.push(m[1]); } return styles.join("\n\n"); } // ─── Extract Google Fonts ─────────────────────────────────────────── function extractGoogleFonts(html) { const fontLinks = []; const linkRegex = /]*href="(https:\/\/fonts\.googleapis\.com[^"]*)"[^>]*>/gi; let m; while ((m = linkRegex.exec(html)) !== null) { fontLinks.push(m[1]); } return fontLinks; } // ─── Collect all remote URLs from CSS + HTML ──────────────────────── function collectRemoteUrls(css, bodyHtml) { const urls = new Set(); const patterns = [ /url\(["']?(https?:\/\/[^"')\s]+)["']?\)/gi, /(?:src|href|poster|srcset)=["'](https?:\/\/[^"'\s]+)["']/gi, /(?:src|href|poster|srcset)=["']?(\/\/[^"'\s]+)["']?/gi, ]; const text = css + "\n" + bodyHtml; for (const pattern of patterns) { let m; while ((m = pattern.exec(text)) !== null) { let url = m[1]; if (url.startsWith("//")) url = "https:" + url; if (!url.includes("fonts.googleapis.com") && !url.includes("fonts.gstatic.com") && !url.includes("google-analytics") && !url.includes("googletagmanager")) { urls.add(url); } } } return [...urls]; } // ─── Download a single URL ────────────────────────────────────────── function downloadFile(url, destPath) { return new Promise((resolve, reject) => { const dir = path.dirname(destPath); fs.mkdirSync(dir, { recursive: true }); const protocol = url.startsWith("https") ? https : http; const request = protocol.get(url, { timeout: 15000 }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { downloadFile(res.headers.location, destPath).then(resolve).catch(reject); return; } if (res.statusCode !== 200) { resolve(false); return; } const stream = fs.createWriteStream(destPath); res.pipe(stream); stream.on("finish", () => { stream.close(); resolve(true); }); stream.on("error", () => resolve(false)); }); request.on("error", () => resolve(false)); request.on("timeout", () => { request.destroy(); resolve(false); }); }); } // ─── URL → local path mapping ─────────────────────────────────────── function urlToLocalPath(url) { try { const parsed = new URL(url); const host = parsed.hostname.replace(/\./g, "-"); let pathname = decodeURIComponent(parsed.pathname); pathname = pathname.replace(/^\/+/, ""); return `/assets/${host}/${pathname}`; } catch { return null; } } // ─── Download assets and rewrite URLs ─────────────────────────────── async function mirrorAssets(css, allBodies) { const combined = css + "\n" + Object.values(allBodies).join("\n"); const urls = collectRemoteUrls(css, combined); console.log(`Found ${urls.length} remote URLs to mirror`); let rewrittenCss = css; const rewrittenBodies = { ...allBodies }; const downloads = []; const urlMap = new Map(); for (const url of urls) { const localPath = urlToLocalPath(url); if (!localPath) continue; urlMap.set(url, localPath); const destPath = path.join(PROJECT_ROOT, "public", localPath); if (!fs.existsSync(destPath)) { downloads.push({ url, destPath }); } } // Download in batches of 10 const BATCH_SIZE = 10; let downloaded = 0; let failed = 0; for (let i = 0; i < downloads.length; i += BATCH_SIZE) { const batch = downloads.slice(i, i + BATCH_SIZE); const results = await Promise.all( batch.map(({ url, destPath }) => downloadFile(url, destPath)) ); results.forEach((ok) => ok ? downloaded++ : failed++); process.stdout.write(`\r Downloaded: ${downloaded}/${downloads.length} (${failed} failed)`); } if (downloads.length) console.log(); // Rewrite URLs for (const [url, localPath] of urlMap) { const escapedUrl = url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(escapedUrl, "g"); rewrittenCss = rewrittenCss.replace(regex, localPath); for (const key of Object.keys(rewrittenBodies)) { rewrittenBodies[key] = rewrittenBodies[key].replace(regex, localPath); } } return { css: rewrittenCss, bodies: rewrittenBodies }; } // ─── Generate component from cheerio element ──────────────────────── function generateComponent($, el, componentName) { const jsx = nodeToJsx($, el, 4); return `export default function ${componentName}() { return ( ${jsx} ) } `; } // ─── Main ─────────────────────────────────────────────────────────── async function main() { console.log("=== Webflow → Next.js Converter ===\n"); // 1. Read all HTML files console.log("Step 1: Reading HTML files..."); const htmlContents = {}; for (const page of PAGES) { const filePath = path.join(SOURCE_DIR, page.src); htmlContents[page.src] = await fsp.readFile(filePath, "utf8"); console.log(` Read: ${page.src} (${(htmlContents[page.src].length / 1024).toFixed(0)} KB)`); } // 2. Extract CSS (from first file since all share the same styles) console.log("\nStep 2: Extracting CSS..."); const css = extractCSS(htmlContents[PAGES[0].src]); console.log(` Extracted ${(css.length / 1024).toFixed(0)} KB of CSS`); // 3. Extract Google Fonts const fonts = extractGoogleFonts(htmlContents[PAGES[0].src]); console.log(` Found ${fonts.length} Google Font imports`); // 4. Parse HTML with cheerio and extract sections console.log("\nStep 3: Parsing HTML and extracting components..."); const headerHtml = {}; const footerHtml = {}; const pageBodyHtml = {}; for (const page of PAGES) { const $ = cheerio.load(htmlContents[page.src], { decodeEntities: false }); const pageWrapper = $(".page-wrapper").first(); // Extract header const header = pageWrapper.find(".header-wrapper").first(); headerHtml[page.src] = $.html(header); // Extract footer const footer = pageWrapper.find(".footer-wrapper").first(); footerHtml[page.src] = $.html(footer); // Extract page body (everything between header and footer inside page-wrapper) const bodyChildren = []; pageWrapper.children().each((_, child) => { const cls = $(child).attr("class") || ""; if (!cls.includes("header-wrapper") && !cls.includes("footer-wrapper")) { bodyChildren.push($.html(child)); } }); pageBodyHtml[page.src] = bodyChildren.join("\n"); console.log(` ${page.name}: header=${(headerHtml[page.src].length / 1024).toFixed(0)}KB, ` + `body=${(pageBodyHtml[page.src].length / 1024).toFixed(0)}KB, ` + `footer=${(footerHtml[page.src].length / 1024).toFixed(0)}KB`); } // 5. Mirror assets console.log("\nStep 4: Mirroring assets..."); const allBodies = {}; for (const page of PAGES) { allBodies[page.src] = pageBodyHtml[page.src]; } allBodies["__header__"] = headerHtml[PAGES[0].src]; allBodies["__footer__"] = footerHtml[PAGES[0].src]; const mirrored = await mirrorAssets(css, allBodies); // 6. Write CSS console.log("\nStep 5: Writing CSS..."); const cssPath = path.join(PROJECT_ROOT, "app", "webflow.css"); await fsp.writeFile(cssPath, mirrored.css, "utf8"); console.log(` Written: app/webflow.css (${(mirrored.css.length / 1024).toFixed(0)} KB)`); // 7. Convert header to TSX component console.log("\nStep 6: Generating components..."); const headerDom = cheerio.load(mirrored.bodies["__header__"], { decodeEntities: false }); const headerEl = headerDom("body").children().first().get(0); if (headerEl) { const headerTsx = `"use client"\n\n` + generateComponent(headerDom, headerEl, "Header"); const headerPath = path.join(PROJECT_ROOT, "components", "Header.tsx"); await fsp.mkdir(path.dirname(headerPath), { recursive: true }); await fsp.writeFile(headerPath, headerTsx, "utf8"); console.log(` Written: components/Header.tsx`); } // 8. Convert footer to TSX component const footerDom = cheerio.load(mirrored.bodies["__footer__"], { decodeEntities: false }); const footerEl = footerDom("body").children().first().get(0); if (footerEl) { const footerTsx = `"use client"\n\n` + generateComponent(footerDom, footerEl, "Footer"); const footerPath = path.join(PROJECT_ROOT, "components", "Footer.tsx"); await fsp.writeFile(footerPath, footerTsx, "utf8"); console.log(` Written: components/Footer.tsx`); } // 9. Convert page bodies to TSX console.log("\nStep 7: Generating page components..."); for (const page of PAGES) { const bodyHtml = mirrored.bodies[page.src]; const pageDom = cheerio.load(`
${bodyHtml}
`, { decodeEntities: false }); const wrapper = pageDom(".page-content").first().get(0); let jsx = ""; if (wrapper) { const children = wrapper.children || []; for (const child of children) { jsx += nodeToJsx(pageDom, child, 6); } } const componentName = page.name + "Page"; const pageTsx = `export default function ${componentName}() { return (
${jsx}
) } `; const routeDir = page.route ? path.join(PROJECT_ROOT, "app", page.route) : path.join(PROJECT_ROOT, "app"); await fsp.mkdir(routeDir, { recursive: true }); const pagePath = path.join(routeDir, "page.tsx"); await fsp.writeFile(pagePath, pageTsx, "utf8"); console.log(` Written: app/${page.route ? page.route + "/" : ""}page.tsx (${componentName})`); } // 10. Generate layout.tsx console.log("\nStep 8: Generating layout..."); const fontImports = fonts.map((f) => ``).join("\n "); const layoutTsx = `import type { Metadata } from "next" import Header from "@/components/Header" import Footer from "@/components/Footer" import "./webflow.css" import "./globals.css" export const metadata: Metadata = { title: "DalCode", description: "DalCode - AI Code Intelligence", } export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( ${fontImports}
{children}
) } `; await fsp.writeFile(path.join(PROJECT_ROOT, "app", "layout.tsx"), layoutTsx, "utf8"); console.log(" Written: app/layout.tsx"); // 11. Update globals.css const globalsCss = `@import "tailwindcss"; /* Webflow base resets are in webflow.css */ `; await fsp.writeFile(path.join(PROJECT_ROOT, "app", "globals.css"), globalsCss, "utf8"); // 12. Update next.config.ts for remote images const nextConfig = `import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "cdn.prod.website-files.com", }, ], }, }; export default nextConfig; `; await fsp.writeFile(path.join(PROJECT_ROOT, "next.config.ts"), nextConfig, "utf8"); console.log(" Written: next.config.ts"); console.log("\n=== Conversion complete! ==="); console.log("Next steps:"); console.log(" cd dalcode-website && npm run dev"); } main().catch((e) => { console.error("Error:", e); process.exit(1); });