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>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
const TSX_FILES = [];
|
||||
function walkDir(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
||||
walkDir(full);
|
||||
} else if (entry.name.endsWith(".tsx")) {
|
||||
TSX_FILES.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walkDir(path.join(ROOT, "components"));
|
||||
|
||||
let totalReplaced = 0;
|
||||
|
||||
for (const file of TSX_FILES) {
|
||||
let content = fs.readFileSync(file, "utf8");
|
||||
let changed = false;
|
||||
|
||||
// Replace <img ... /> with <Image ... />
|
||||
// Match self-closing img tags
|
||||
content = content.replace(/<img\s([^>]*?)\s*\/>/g, (match, attrs) => {
|
||||
const srcMatch = attrs.match(/src="([^"]*)"/);
|
||||
if (!srcMatch) return match;
|
||||
const src = srcMatch[1];
|
||||
|
||||
// Skip SVG inline and external URLs
|
||||
if (src.startsWith("http://") || src.startsWith("https://")) return match;
|
||||
if (src.startsWith("data:")) return match;
|
||||
|
||||
const widthMatch = attrs.match(/width=\{(\d+)\}/);
|
||||
const heightMatch = attrs.match(/height=\{(\d+)\}/);
|
||||
const altMatch = attrs.match(/alt="([^"]*)"/);
|
||||
const classMatch = attrs.match(/className="([^"]*)"/);
|
||||
const loadingMatch = attrs.match(/loading="([^"]*)"/);
|
||||
const sizesMatch = attrs.match(/sizes="([^"]*)"/);
|
||||
|
||||
// Remove srcSet from attrs (next/image handles it)
|
||||
let cleanAttrs = attrs
|
||||
.replace(/srcSet="[^"]*"/g, "")
|
||||
.replace(/loading="[^"]*"/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Ensure width and height exist
|
||||
const width = widthMatch ? widthMatch[1] : "0";
|
||||
const height = heightMatch ? heightMatch[1] : "0";
|
||||
|
||||
// If no width/height, use fill mode
|
||||
if (width === "0" || height === "0") {
|
||||
// Remove width/height attrs
|
||||
cleanAttrs = cleanAttrs
|
||||
.replace(/width=\{\d+\}/g, "")
|
||||
.replace(/height=\{\d+\}/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
changed = true;
|
||||
totalReplaced++;
|
||||
return `<Image ${cleanAttrs} fill style={{ objectFit: "cover" }} />`;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
totalReplaced++;
|
||||
return `<Image ${cleanAttrs} />`;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
// Add Image import if not present
|
||||
if (!content.includes('import Image from "next/image"') && !content.includes("import Image from 'next/image'")) {
|
||||
// Add after existing imports or at top
|
||||
const firstImport = content.indexOf("import ");
|
||||
if (firstImport !== -1) {
|
||||
const lineEnd = content.indexOf("\n", firstImport);
|
||||
content = content.slice(0, lineEnd + 1) + 'import Image from "next/image"\n' + content.slice(lineEnd + 1);
|
||||
} else {
|
||||
content = 'import Image from "next/image"\n\n' + content;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(file, content, "utf8");
|
||||
console.log(`Updated: ${path.relative(ROOT, file)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal <img> → <Image>: ${totalReplaced}`);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
const INTERNAL_PREFIXES = ["/", "#"];
|
||||
const EXTERNAL_DOMAINS = ["http://", "https://", "mailto:", "tel:"];
|
||||
|
||||
const TSX_FILES = [];
|
||||
function walkDir(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
||||
walkDir(full);
|
||||
} else if (entry.name.endsWith(".tsx")) {
|
||||
TSX_FILES.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walkDir(path.join(ROOT, "components"));
|
||||
|
||||
let totalConverted = 0;
|
||||
|
||||
for (const file of TSX_FILES) {
|
||||
// Skip Header.tsx - already uses Link
|
||||
if (file.endsWith("Header.tsx")) continue;
|
||||
|
||||
let content = fs.readFileSync(file, "utf8");
|
||||
let changed = false;
|
||||
|
||||
// Convert <a href="..." ...> to <Link href="..." ...> for internal links
|
||||
// And add target/rel for external links
|
||||
content = content.replace(/<a\s([^>]*?)>/g, (match, attrs) => {
|
||||
const hrefMatch = attrs.match(/href="([^"]*)"/);
|
||||
if (!hrefMatch) return match;
|
||||
const href = hrefMatch[1];
|
||||
|
||||
const isExternal = EXTERNAL_DOMAINS.some(d => href.startsWith(d));
|
||||
const isInternal = INTERNAL_PREFIXES.some(p => href.startsWith(p)) && !isExternal;
|
||||
|
||||
if (isInternal) {
|
||||
changed = true;
|
||||
totalConverted++;
|
||||
// Remove Webflow-specific attrs
|
||||
let cleanAttrs = attrs
|
||||
.replace(/data-w-id="[^"]*"/g, "")
|
||||
.replace(/data-wf[^=]*="[^"]*"/g, "")
|
||||
.replace(/aria-current="page"/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return `<Link ${cleanAttrs}>`;
|
||||
}
|
||||
|
||||
if (isExternal && !attrs.includes("target=")) {
|
||||
changed = true;
|
||||
totalConverted++;
|
||||
return `<a ${attrs} target="_blank" rel="noopener noreferrer">`;
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
// Convert closing </a> to </Link> where we converted opening tag
|
||||
// Simple approach: replace all </a> in files that had changes
|
||||
// Actually we need to be smarter - only replace </a> that correspond to <Link>
|
||||
// Let's do it line by line
|
||||
if (changed) {
|
||||
// Count Link opens vs closes to fix
|
||||
const lines = content.split("\n");
|
||||
let linkDepth = 0;
|
||||
const newLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
let processed = line;
|
||||
|
||||
// Count Link opens in this line
|
||||
const linkOpens = (processed.match(/<Link\s/g) || []).length;
|
||||
const selfClosing = (processed.match(/<Link\s[^>]*\/>/g) || []).length;
|
||||
linkDepth += linkOpens - selfClosing;
|
||||
|
||||
// Replace </a> with </Link> when inside a Link context
|
||||
if (linkDepth > 0 && processed.includes("</a>")) {
|
||||
const closeTags = (processed.match(/<\/a>/g) || []).length;
|
||||
for (let i = 0; i < closeTags && linkDepth > 0; i++) {
|
||||
processed = processed.replace("</a>", "</Link>");
|
||||
linkDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
newLines.push(processed);
|
||||
}
|
||||
|
||||
content = newLines.join("\n");
|
||||
|
||||
// Add Link import
|
||||
if (!content.includes('import Link from "next/link"') && !content.includes("import Link from 'next/link'")) {
|
||||
if (content.includes('import Image from "next/image"')) {
|
||||
content = content.replace(
|
||||
'import Image from "next/image"',
|
||||
'import Image from "next/image"\nimport Link from "next/link"'
|
||||
);
|
||||
} else {
|
||||
const firstLine = content.indexOf("\n");
|
||||
content = content.slice(0, firstLine + 1) + 'import Link from "next/link"\n' + content.slice(firstLine + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, content, "utf8");
|
||||
console.log(`Updated: ${path.relative(ROOT, file)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal links converted: ${totalConverted}`);
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
const FILES = [
|
||||
"app/page.tsx",
|
||||
"app/about/page.tsx",
|
||||
"app/blog/page.tsx",
|
||||
"app/contact/page.tsx",
|
||||
];
|
||||
|
||||
for (const file of FILES) {
|
||||
const filePath = path.join(PROJECT_ROOT, file);
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
let hasLottie = false;
|
||||
|
||||
// Match any <div ...data-animation-type="lottie"... data-src="..."...>
|
||||
// Extract className and data-src regardless of attribute order
|
||||
content = content.replace(
|
||||
/<div\s[^>]*?data-animation-type="lottie"[^>]*?>/g,
|
||||
(match) => {
|
||||
const srcMatch = match.match(/data-src="([^"]*)"/);
|
||||
const clsMatch = match.match(/className="([^"]*)"/);
|
||||
if (!srcMatch) return match;
|
||||
hasLottie = true;
|
||||
const src = srcMatch[1];
|
||||
const cls = clsMatch ? clsMatch[1] : "";
|
||||
return `<LottiePlayer src="${src}" className="${cls}" loop autoplay`;
|
||||
}
|
||||
);
|
||||
|
||||
// Now close the self-closing LottiePlayer tags
|
||||
// The original <div> had a matching </div> - we need to remove it
|
||||
// Pattern: <LottiePlayer ... autoplay\n </div>
|
||||
content = content.replace(
|
||||
/(<LottiePlayer[^>]*autoplay)\n(\s*)<\/div>/g,
|
||||
"$1 />"
|
||||
);
|
||||
|
||||
if (hasLottie) {
|
||||
if (!content.includes("import LottiePlayer")) {
|
||||
if (content.startsWith("export")) {
|
||||
content = `import LottiePlayer from "@/components/LottiePlayer"\n\n${content}`;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(filePath, content, "utf8");
|
||||
console.log(`Injected LottiePlayer into ${file}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
const PAGES = [
|
||||
{
|
||||
file: "app/page.tsx",
|
||||
dir: "components/home",
|
||||
pageName: "HomePage",
|
||||
imports: ['import LottiePlayer from "@/components/LottiePlayer"', 'import VideoLightbox from "@/components/VideoLightbox"'],
|
||||
},
|
||||
{
|
||||
file: "app/about/page.tsx",
|
||||
dir: "components/about",
|
||||
pageName: "AboutPage",
|
||||
imports: ['import LottiePlayer from "@/components/LottiePlayer"'],
|
||||
},
|
||||
{
|
||||
file: "app/blog/page.tsx",
|
||||
dir: "components/blog",
|
||||
pageName: "BlogPage",
|
||||
imports: [],
|
||||
},
|
||||
{
|
||||
file: "app/contact/page.tsx",
|
||||
dir: "components/contact",
|
||||
pageName: "ContactPage",
|
||||
imports: ['import LottiePlayer from "@/components/LottiePlayer"'],
|
||||
},
|
||||
];
|
||||
|
||||
function toPascalCase(str) {
|
||||
return str
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function extractSectionName(openTag) {
|
||||
const classMatch = openTag.match(/className="([^"]*)"/);
|
||||
if (!classMatch) return "Section";
|
||||
const cls = classMatch[1];
|
||||
const meaningful = cls
|
||||
.split(/\s+/)
|
||||
.filter((c) => !["section", "section-small", "overflow-hidden", "w-variant-daeb6173-296b-8c16-4f2d-1c7fb3f823cc"].includes(c))
|
||||
.join(" ");
|
||||
if (!meaningful) return "Section";
|
||||
return toPascalCase(meaningful) || "Section";
|
||||
}
|
||||
|
||||
for (const page of PAGES) {
|
||||
const filePath = path.join(ROOT, page.file);
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Find <main> content boundaries
|
||||
let mainStart = -1;
|
||||
let mainEnd = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim() === "<main>") mainStart = i + 1;
|
||||
if (lines[i].trim() === "</main>") mainEnd = i;
|
||||
}
|
||||
if (mainStart === -1 || mainEnd === -1) {
|
||||
console.log(`Skipping ${page.file}: no <main> found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find top-level sections (indented at 6 spaces inside main)
|
||||
const sections = [];
|
||||
let currentSection = null;
|
||||
let depth = 0;
|
||||
const sectionIndent = lines[mainStart]?.search(/\S/) ?? 6;
|
||||
|
||||
for (let i = mainStart; i < mainEnd; i++) {
|
||||
const line = lines[i];
|
||||
const indent = line.search(/\S/);
|
||||
if (indent === -1) {
|
||||
if (currentSection) currentSection.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent === sectionIndent && line.trim().startsWith("<section") && !currentSection) {
|
||||
currentSection = { startLine: i, lines: [line], openTag: line.trim() };
|
||||
} else if (currentSection) {
|
||||
currentSection.lines.push(line);
|
||||
if (indent === sectionIndent && line.trim() === "</section>") {
|
||||
sections.push(currentSection);
|
||||
currentSection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentSection) sections.push(currentSection);
|
||||
|
||||
if (!sections.length) {
|
||||
console.log(`Skipping ${page.file}: no sections found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create component directory
|
||||
const compDir = path.join(ROOT, page.dir);
|
||||
fs.mkdirSync(compDir, { recursive: true });
|
||||
|
||||
const componentNames = [];
|
||||
const usedNames = new Set();
|
||||
|
||||
for (let idx = 0; idx < sections.length; idx++) {
|
||||
const section = sections[idx];
|
||||
let baseName = extractSectionName(section.openTag);
|
||||
if (usedNames.has(baseName)) baseName += (idx + 1);
|
||||
usedNames.add(baseName);
|
||||
|
||||
const compName = baseName + "Section";
|
||||
componentNames.push(compName);
|
||||
|
||||
// Determine needed imports for this section
|
||||
const sectionContent = section.lines.join("\n");
|
||||
const neededImports = [];
|
||||
if (sectionContent.includes("LottiePlayer")) neededImports.push('import LottiePlayer from "@/components/LottiePlayer"');
|
||||
if (sectionContent.includes("VideoLightbox")) neededImports.push('import VideoLightbox from "@/components/VideoLightbox"');
|
||||
|
||||
// Dedent section content
|
||||
const minIndent = sectionIndent;
|
||||
const dedented = section.lines.map((l) => {
|
||||
if (l.trim() === "") return "";
|
||||
return l.length > minIndent ? " " + l.slice(minIndent) : " " + l.trimStart();
|
||||
});
|
||||
|
||||
const tsx = `${neededImports.length ? neededImports.join("\n") + "\n\n" : ""}export default function ${compName}() {
|
||||
return (
|
||||
${dedented.join("\n")}
|
||||
)
|
||||
}
|
||||
`;
|
||||
|
||||
const compFile = path.join(compDir, `${compName}.tsx`);
|
||||
fs.writeFileSync(compFile, tsx, "utf8");
|
||||
}
|
||||
|
||||
// Write index.ts barrel
|
||||
const barrel = componentNames
|
||||
.map((n) => `export { default as ${n} } from "./${n}"`)
|
||||
.join("\n") + "\n";
|
||||
fs.writeFileSync(path.join(compDir, "index.ts"), barrel, "utf8");
|
||||
|
||||
// Rewrite page.tsx
|
||||
const relDir = page.dir.replace(/^components\//, "@/components/");
|
||||
const importLine = `import { ${componentNames.join(", ")} } from "${relDir}"`;
|
||||
const sectionJsx = componentNames.map((n) => ` <${n} />`).join("\n");
|
||||
|
||||
const newPage = `${importLine}
|
||||
|
||||
export default function ${page.pageName}() {
|
||||
return (
|
||||
<main>
|
||||
${sectionJsx}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(filePath, newPage, "utf8");
|
||||
console.log(`${page.file}: split into ${sections.length} sections → ${page.dir}/`);
|
||||
}
|
||||
Reference in New Issue
Block a user