335 lines
11 KiB
JavaScript
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("&", "&")).join(item.relative);
|
|
output = output.split(item.noQuery).join(item.relative);
|
|
output = output.split(item.noQuery.replaceAll("&", "&")).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);
|
|
});
|