Files
dalcode-website/scripts/convert-webflow.mjs
T
Leon-in 95eb362bfc feat: initial commit — Webflow to Next.js conversion
QuantumLab template converted to Next.js 16 + React 19 + TypeScript:
- 8 page routes (home, about, blog, contact, careers, team-members, coming-soon, 404)
- Dynamic routes for blog posts, career positions, and team members
- GSAP animations (marquee, counters, button hovers)
- IntersectionObserver-based scroll reveal (blur-to-clear transitions)
- Dark mode with next-themes
- React Hook Form + Zod contact form
- Framer Motion page transitions
- Lottie animations via lottie-web

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 18:19:56 +08:00

556 lines
19 KiB
JavaScript

#!/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(/&quot;/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(/&amp;/g, "&")
.replace(/&lt;/g, "{'<'}")
.replace(/&gt;/g, "{'>'}")
.replace(/&nbsp;/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, "&quot;");
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}</${tag}>\n`;
return out;
}
return "";
}
// ─── Extract CSS from HTML ──────────────────────────────────────────
function extractCSS(html) {
const styleRegex = /<style[^>]*>([\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 = /<link[^>]*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(`<div class="page-content">${bodyHtml}</div>`, { 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 (
<main>
${jsx} </main>
)
}
`;
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) => `<link rel="preconnect" href="${new URL(f).origin}" crossOrigin="anonymous" />`).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 (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
${fontImports}
</head>
<body>
<div className="page-wrapper">
<Header />
{children}
<Footer />
</div>
</body>
</html>
)
}
`;
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);
});