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:
Leon-in
2026-04-26 18:19:56 +08:00
commit 95eb362bfc
134 changed files with 25831 additions and 0 deletions
+92
View File
@@ -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}`);
+116
View File
@@ -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}`);
+555
View File
@@ -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(/&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);
});
+53
View File
@@ -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}`);
}
}
+168
View 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}/`);
}