95eb362bfc
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>
556 lines
19 KiB
JavaScript
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(/"/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, "{'>'}")
|
|
.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}</${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);
|
|
});
|