NODEDC_SITE/tools/build-local-copy.mjs

335 lines
11 KiB
JavaScript

import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceDir = process.env.NODEDC_SOURCE_DIR || "/Users/dcconstructions/site-rescue/ssscript.app";
const targetDir = repoRoot;
const pages = ["index.html", "beta-apply.html", "founders.html", "enterprise.html"];
const assetHosts = new Set([
"cdn.prod.website-files.com",
"d3e54v103j8qbb.cloudfront.net",
"ssscript-website.vercel.app",
"ssscript.dev",
"cloud.umami.is",
]);
const assetExtensions = new Set([
".avif",
".css",
".glb",
".gif",
".jpg",
".jpeg",
".js",
".mp4",
".otf",
".png",
".svg",
".ttf",
".webm",
".webp",
".woff",
".woff2",
]);
const assetMap = new Map();
const queue = [];
const failures = [];
function cleanUrl(raw) {
let value = raw.trim();
value = value.replace(/&/g, "&");
value = value.replace(/[),.;]+$/g, "");
if (value.startsWith("//")) value = `https:${value}`;
return value;
}
function extensionFor(url) {
return path.extname(decodeURIComponent(url.pathname)).toLowerCase();
}
function isAssetUrl(url) {
if (!assetHosts.has(url.hostname)) return false;
if (url.href.includes("%7B") || url.href.includes("%7D") || url.href.includes("${")) return false;
if (url.hostname === "cdn.prod.website-files.com" && url.pathname.includes("/vendor/webflow-badge-")) return false;
if (url.hostname === "cloud.umami.is") return url.pathname === "/script.js";
return assetExtensions.has(extensionFor(url));
}
function localAssetPath(url) {
const decodedPath = decodeURIComponent(url.pathname);
const basename = path.basename(decodedPath);
const ext = path.extname(basename).toLowerCase();
if (url.hostname === "cdn.prod.website-files.com") {
if (decodedPath.includes("/css/")) return path.join("assets", "webflow", "css", basename);
if (decodedPath.includes("/js/")) return path.join("assets", "webflow", "js", basename);
return path.join("assets", "webflow", "images", basename);
}
if (url.hostname === "d3e54v103j8qbb.cloudfront.net") {
return path.join("assets", "vendor", basename);
}
if (url.hostname === "ssscript-website.vercel.app") {
if (ext === ".css") return path.join("assets", "custom", "css", basename);
if (ext === ".js") return path.join("assets", "custom", "js", basename);
return path.join("assets", "custom", basename);
}
if (url.hostname === "ssscript.dev") {
return path.join("assets", "media", basename);
}
if (url.hostname === "cloud.umami.is") {
return path.join("assets", "vendor", "umami-script.js");
}
return path.join("assets", url.hostname, basename);
}
function addAsset(rawUrl, baseUrl = undefined) {
let url;
try {
url = new URL(cleanUrl(rawUrl), baseUrl);
} catch {
return null;
}
if (!isAssetUrl(url)) return null;
const canonical = url.href;
if (!assetMap.has(canonical)) {
const localPath = localAssetPath(url);
assetMap.set(canonical, localPath);
queue.push({ url, localPath });
}
return assetMap.get(canonical);
}
function extractUrls(text) {
const urls = [];
const absoluteUrlPattern = /(?:https?:)?\/\/[^\s"'<>\\]+/g;
const cssUrlPattern = /url\((['"]?)([^'")]+)\1\)/g;
const relativeAssetPattern =
/(["'`])((?:\.{1,2}\/|\/)[^"'`?#)]+\.(?:avif|css|gif|glb|jpe?g|js|mp4|otf|png|svg|ttf|webm|webp|woff2?)(?:[?#][^"'`]*)?)\1/g;
for (const match of text.matchAll(absoluteUrlPattern)) {
urls.push(match[0]);
}
for (const match of text.matchAll(cssUrlPattern)) {
urls.push(match[2]);
}
for (const match of text.matchAll(relativeAssetPattern)) {
urls.push(match[2]);
}
return urls;
}
function rel(fromFile, toFile) {
const relative = path.relative(path.dirname(fromFile), toFile).replaceAll(path.sep, "/");
const webPath = relative.split("/").map(encodeURIComponent).join("/");
return webPath.startsWith(".") ? webPath : `./${webPath}`;
}
function pageRel(localPath) {
return `./${localPath.split(path.sep).join("/").split("/").map(encodeURIComponent).join("/")}`;
}
function rewriteUrls(text, fromLocalPath, baseUrl = undefined) {
let output = text;
const isJs = fromLocalPath.endsWith(".js");
const replacements = [...assetMap.entries()].map(([remote, local]) => {
const remoteUrl = new URL(remote);
return {
remote,
local,
relative: isJs ? pageRel(local) : rel(path.join(targetDir, fromLocalPath), path.join(targetDir, local)),
noQuery: `${remoteUrl.origin}${remoteUrl.pathname}`,
};
});
for (const item of replacements) {
output = output.split(item.remote).join(item.relative);
output = output.split(item.remote.replaceAll("&", "&amp;")).join(item.relative);
output = output.split(item.noQuery).join(item.relative);
output = output.split(item.noQuery.replaceAll("&", "&amp;")).join(item.relative);
}
if (baseUrl) {
output = output.replace(/url\((['"]?)([^'")]+)\1\)/g, (full, quote, value) => {
const local = addAsset(value, baseUrl);
if (!local) return full;
const localRef = isJs ? pageRel(local) : rel(path.join(targetDir, fromLocalPath), path.join(targetDir, local));
return `url(${quote}${localRef}${quote})`;
});
output = output.replace(
/(["'`])((?:\.{1,2}\/|\/)[^"'`?#)]+\.(?:avif|css|gif|glb|jpe?g|js|mp4|otf|png|svg|ttf|webm|webp|woff2?)(?:[?#][^"'`]*)?)\1/g,
(full, quote, value) => {
const local = addAsset(value, baseUrl);
if (!local) return full;
const localRef = isJs ? pageRel(local) : rel(path.join(targetDir, fromLocalPath), path.join(targetDir, local));
return `${quote}${localRef}${quote}`;
}
);
}
if (fromLocalPath.endsWith(".html")) {
output = output
.replace(/<link href="https:\/\/cdn\.prod\.website-files\.com" rel="preconnect"[^>]*>/g, "")
.replace(/<link rel="preconnect" href="https:\/\/ssscript\.app\/cdn\.prod\.website-files\.com"[^>]*>/g, "")
.replaceAll("https://localhost:6545/app.js", "./assets/custom/js/app.js");
if (!output.includes("window.__API_ORIGIN__")) {
output = output.replace(
"<script>\n(function(d,h,host){",
'<script>\nwindow.__API_ORIGIN__="https://ssscript-website.vercel.app";\n(function(d,h,host){'
);
}
}
output = output
.replace(/\s+integrity="[^"]*"/g, "")
.replace(/\s+crossorigin="anonymous"/g, "")
.replace(/\s+crossOrigin="anonymous"/g, "");
return output;
}
async function downloadAsset(item) {
const outPath = path.join(targetDir, item.localPath);
await mkdir(path.dirname(outPath), { recursive: true });
const ext = extensionFor(item.url);
if (existsSync(outPath) && ext !== ".css" && ext !== ".js") return;
const response = await fetch(item.url.href, {
redirect: "follow",
headers: {
"User-Agent": "Mozilla/5.0 NodeDcLocalizer",
},
});
if (!response.ok) {
failures.push(`${response.status} ${item.url.href}`);
return;
}
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile(outPath, buffer);
}
async function main() {
await mkdir(targetDir, { recursive: true });
await mkdir(path.join(targetDir, "originals"), { recursive: true });
const pageSources = new Map();
for (const page of pages) {
const sourcePath = path.join(sourceDir, page);
const source = await readFile(sourcePath, "utf8");
pageSources.set(page, source);
await copyFile(sourcePath, path.join(targetDir, "originals", page));
const origPath = path.join(sourceDir, page.replace(/\.html$/, ".orig"));
if (existsSync(origPath)) {
await copyFile(origPath, path.join(targetDir, "originals", path.basename(origPath)));
}
for (const rawUrl of extractUrls(source)) addAsset(rawUrl);
}
for (let index = 0; index < queue.length; index += 1) {
const item = queue[index];
await downloadAsset(item);
const ext = extensionFor(item.url);
if (ext === ".css" || ext === ".js") {
const localPath = path.join(targetDir, item.localPath);
if (!existsSync(localPath)) continue;
const source = await readFile(localPath, "utf8");
for (const rawUrl of extractUrls(source)) addAsset(rawUrl, item.url.href);
}
}
for (const [remote, local] of assetMap.entries()) {
const url = new URL(remote);
const ext = extensionFor(url);
if (ext !== ".css" && ext !== ".js") continue;
const localPath = path.join(targetDir, local);
if (!existsSync(localPath)) continue;
const source = await readFile(localPath, "utf8");
const rewritten = rewriteUrls(source, local, remote);
await writeFile(localPath, rewritten);
}
for (const [page, source] of pageSources.entries()) {
const rewritten = rewriteUrls(source, page);
await writeFile(path.join(targetDir, page), rewritten);
}
const manifest = {
generatedAt: new Date().toISOString(),
sourceDir,
targetDir,
pages,
assets: [...assetMap.entries()].map(([url, localPath]) => ({ url, localPath })),
failures,
};
await writeFile(path.join(targetDir, "asset-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile(
path.join(targetDir, "README.md"),
`# NODE.DC Local Working Copy
This repository contains the current NODE.DC base site: a structured local copy of the recovered Webflow page, localized to Russian and prepared for the block/CMS refactor.
The rebuild source is \`${sourceDir}\`.
## Run
\`\`\`bash
cd ${targetDir}
python3 -m http.server 8081 --bind 127.0.0.1
\`\`\`
Open http://127.0.0.1:8081/
## Structure
- \`index.html\`, \`beta-apply.html\`, \`founders.html\`, \`enterprise.html\` - editable local pages.
- \`assets/webflow\` - Webflow CSS, JS, and image assets.
- \`assets/custom\` - custom NODE.DC CSS/JS published on Vercel.
- \`assets/media\` - downloaded videos from ssscript.dev.
- \`assets/vendor\` - third-party browser scripts.
- \`docs/\` - refactor notes, block model draft, and admin plan.
- \`originals\` - untouched files copied from the original wget download.
- \`asset-manifest.json\` - source URL to local file mapping.
- \`tools/build-local-copy.mjs\` - rebuild script. It uses \`NODEDC_SOURCE_DIR\` when provided, otherwise the original local rescue folder.
- \`tools/localize-ru.mjs\` - Russian localization and NODE.DC brand patch script.
External product/API/documentation links are intentionally left as external URLs.
`
);
console.log(`Pages: ${pages.length}`);
console.log(`Assets mapped: ${assetMap.size}`);
console.log(`Failures: ${failures.length}`);
for (const failure of failures) console.log(`FAILED ${failure}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});