chore: update html extract script - inject fonts

This commit is contained in:
2026-08-31 08:52:26 +05:00
parent 586c66a159
commit ecc8efcc45
39 changed files with 1459 additions and 158 deletions
+251 -158
View File
@@ -1,159 +1,252 @@
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);
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, dirname, basename, extname } from "node:path";
import { fileURLToPath } from "node:url";
// 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");
// --- fonts: inline @font-face + copy .woff2 next to the pages ---
// The Next reference never actually loads its `--font-sans/--font-mono`
// ("IBM Plex Sans"/"IBM Plex Mono"), so the extracted snapshots render with
// the system fallback. Self-host the fontsource in a `assets/fonts/` dir next
// to the HTML so the refs show the intended typefaces.
const FONT_ASSETS_DIR = "assets/fonts";
// fontsource packages relative to `web/` — same weights as design2.css imports.
const FONT_SOURCES = [
"ibm-plex-sans/400.css",
"ibm-plex-sans/500.css",
"ibm-plex-sans/600.css",
"ibm-plex-sans/700.css",
"ibm-plex-mono/400.css",
"ibm-plex-mono/500.css",
];
// Resolve `node_modules/@fontsource/<pkg>/<weight>.css`:
// 1. sibling repo dir (root `web/…` or standalone `refs/…` layouts);
// 2. upward walk from this script (monorepo with hoisted root deps).
function findFontsourceRoot() {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [join(here, "..", "web", "node_modules", "@fontsource")];
for (let dir = here; ; ) {
candidates.push(join(dir, "node_modules", "@fontsource"));
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const candidate of candidates) {
if (existsSync(join(candidate, FONT_SOURCES[0]))) return candidate;
}
return null;
}
function buildFontFaces(dest) {
const fontsource = findFontsourceRoot();
if (!fontsource) {
console.warn("WARN: @fontsource not found (web/node_modules missing?) — skipping font embedding");
return { style: "", copied: [] };
}
const seen = new Set();
const raw = [];
for (const rel of FONT_SOURCES) {
const pkg = dirname(rel); // e.g. ibm-plex-sans
const name = basename(rel, extname(rel)); // e.g. 400.css -> 400
const file = join(fontsource, pkg, `${name}.css`);
if (!existsSync(file)) continue;
raw.push([file, readFileSync(file, "utf8"), pkg]);
}
// Drop duplicate @font-face blocks (each `<name>.css` repeats across weights).
let css = raw.map(([, text]) => text.replace(/\/\*[^*]*\*\//g, "").trim()).join("\n");
css = css.replace(/@font-face\s*\{[\s\S]*?\}/g, (block) =>
seen.has(block) ? "" : (seen.add(block), block)
);
// Copy the woff2 files and point the css at `assets/fonts/`. Drop the woff
// entries — modern browsers only need woff2 (dev-only static snapshots).
const copied = [];
const destFonts = join(dest, FONT_ASSETS_DIR);
css = css.replace(/url\(\.\/files\/([^)]+\.woff2)\)\s*format\('woff2'\)\s*,\s*url\(\.\/files\/[^)]*\.woff\)\s*format\('woff'\)/g, (_m, f) => {
const src = (() => {
for (const [, , pkg] of raw) {
const candidate = join(fontsource, pkg, "files", f);
if (existsSync(candidate)) return candidate;
}
return null;
})();
if (!src) {
console.warn(`WARN: font file not found: ${f}`);
return "";
}
mkdirSync(destFonts, { recursive: true });
writeFileSync(join(destFonts, f), readFileSync(src));
if (!copied.includes(f)) copied.push(f);
return `url(${FONT_ASSETS_DIR}/${f}) format('woff2')`;
});
return { style: "<style>\n" + css.trim() + "\n</style>", copied };
}
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(", "));
const { style: fontFacesStyle, copied: fontFiles } = buildFontFaces(dest);
if (fontFiles.length) {
console.log(`fonts: ${fontFiles.length} file(s) -> ${join(dest, FONT_ASSETS_DIR)}`);
}
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>");
// fonts first, so any later @font-face overrides win for the same family
if (fontFacesStyle) html = html.replace("<head>", "<head>\n " + fontFacesStyle.trim());
html = fmtHtml(html);
html = injectThemeToggle(html);
const file = join(dest, out);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, html);
console.log("written:", file);
}
const file = join(dest, out);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, html);
console.log("written:", file);
}