160 lines
4.7 KiB
JavaScript
160 lines
4.7 KiB
JavaScript
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
import { join, dirname, basename } from "node:path";
|
|
|
|
// Usage: node extract-static.mjs <source-out-dir> <dest-dir>
|
|
// e.g. node extract-static.mjs .next\server\app ..\refs-html
|
|
|
|
const root = process.argv[2];
|
|
const dest = process.argv[3];
|
|
|
|
// --- inject a minimal theme toggle (extraction strips all <script> tags, so the
|
|
// ref pages would otherwise have no working dark-mode switch) ---
|
|
const THEME_TOGGLE = `
|
|
<script>
|
|
(function () {
|
|
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
|
var shell = document.querySelector('.app-shell');
|
|
if (!btn || window.__themeToggleInjected) return;
|
|
window.__themeToggleInjected = true;
|
|
btn.addEventListener('click', function () {
|
|
document.documentElement.classList.toggle('dark-mode');
|
|
if (shell) shell.classList.toggle('dark-mode');
|
|
});
|
|
})();
|
|
</script>
|
|
`;
|
|
|
|
function injectThemeToggle(html) {
|
|
if (html.includes("__themeToggleInjected")) return html;
|
|
const tag = "</body>";
|
|
if (html.includes(tag)) return html.replace(tag, THEME_TOGGLE + "\n" + tag);
|
|
if (html.includes("</html>")) return html.replace("</html>", THEME_TOGGLE + "\n</html>");
|
|
return html + "\n" + THEME_TOGGLE;
|
|
}
|
|
|
|
// --- CSS: find the compiled chunk and pretty-print it ---
|
|
const cssChunks = join(root, "..", "..", "static", "chunks");
|
|
const cssFile = readdirSync(cssChunks)
|
|
.filter((f) => f.endsWith(".css"))
|
|
.sort((a, b) => statSync(join(cssChunks, b)).size - statSync(join(cssChunks, a)).size)[0];
|
|
const css = readFileSync(join(cssChunks, cssFile), "utf8");
|
|
|
|
const prettyCss = css
|
|
.replace(/\r/g, "")
|
|
.replace(/\{/g, " {\n ")
|
|
.replace(/;/g, ";\n ")
|
|
.replace(/\}/g, "\n}\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
|
|
// --- conservative html formatter ---
|
|
|
|
const VOID = new Set([
|
|
"area",
|
|
"base",
|
|
"br",
|
|
"col",
|
|
"embed",
|
|
"hr",
|
|
"img",
|
|
"input",
|
|
"link",
|
|
"meta",
|
|
"param",
|
|
"source",
|
|
"track",
|
|
"wbr",
|
|
]);
|
|
const RAW = new Set(["pre", "textarea", "script", "style"]);
|
|
|
|
function fmtHtml(html) {
|
|
const tokens = html.match(/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi) || [];
|
|
const out = [];
|
|
let depth = 0;
|
|
let rawTag = null;
|
|
|
|
for (const tk of tokens) {
|
|
if (rawTag) {
|
|
out.push(tk);
|
|
if (new RegExp(`</${rawTag}\\s*>`, "i").test(tk)) {
|
|
depth--;
|
|
rawTag = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const isOpen = /^<[a-zA-Z]/.test(tk);
|
|
const isClose = /^<\//.test(tk);
|
|
let name = "";
|
|
if (isOpen || isClose) {
|
|
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || "";
|
|
}
|
|
|
|
if (isOpen && RAW.has(name)) {
|
|
out.push("\n" + " ".repeat(depth) + tk);
|
|
if (!/\/>$/.test(tk)) {
|
|
depth++;
|
|
rawTag = name;
|
|
}
|
|
} else if (isOpen && !RAW.has(name)) {
|
|
out.push("\n" + " ".repeat(depth) + tk);
|
|
if (!/\/>$/.test(tk) && !VOID.has(name)) depth++;
|
|
} else if (isClose) {
|
|
depth = Math.max(0, depth - 1);
|
|
out.push("\n" + " ".repeat(depth) + tk);
|
|
} else if (/^</.test(tk)) {
|
|
out.push("\n" + " ".repeat(depth) + tk);
|
|
} else {
|
|
const t = tk.replace(/\s+/g, " ");
|
|
if (t.trim()) out.push(t);
|
|
}
|
|
}
|
|
return out
|
|
.join("")
|
|
.replace(/^\n/, "")
|
|
.replace(/\n{3,}/g, "\n\n");
|
|
}
|
|
|
|
// --- auto-discover pages by walking the export dir for *.html ---
|
|
function walk(dir, base = "") {
|
|
const found = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
const rel = base ? join(base, entry) : entry;
|
|
if (statSync(full).isDirectory()) {
|
|
found.push(...walk(full, rel));
|
|
} else if (entry.endsWith(".html")) {
|
|
found.push(rel);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
const pages = walk(root)
|
|
// skip Next internals
|
|
.filter((rel) => !basename(rel).startsWith("_") && !basename(rel).startsWith("404"))
|
|
// `index.html` нам не нужен как реф, поэтому не генерируем его.
|
|
.filter((rel) => basename(rel).toLowerCase() !== "index.html")
|
|
.map((rel) => [rel, basename(rel)]);
|
|
|
|
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(", "));
|
|
|
|
for (const [src, out] of pages) {
|
|
let html = readFileSync(join(root, src), "utf8");
|
|
|
|
// strip scripts — pure static snapshot
|
|
html = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
|
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, "");
|
|
|
|
// inline compiled css instead of linking /_next/...
|
|
html = html.replace(/<link[^>]+rel="stylesheet"[^>]*>/g, () => "<style>\n" + prettyCss + "\n</style>");
|
|
|
|
html = fmtHtml(html);
|
|
html = injectThemeToggle(html);
|
|
|
|
const file = join(dest, out);
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
writeFileSync(file, html);
|
|
console.log("written:", file);
|
|
}
|