// The raw tree's directory pages: one dependency-free index.html per directory // of the extracted `main`, so /source/tree/ is browsable on a static host. // // The FILES under the tree are served as text/plain whatever their extension // (homepage/public/_headers, `/source/tree/*`); only these generated pages are // HTML. Every name is escaped for HTML and every href is URL-encoded — the tree // holds Next's dynamic-route directories (`[slug]`) and variable fonts // (`Archivo[wdth,wght].ttf`), whose brackets a browser would otherwise send // raw. import { readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { SourceRefusal } from "./sourceAudit"; export type TreeEntry = { name: string; dir: boolean; bytes: number }; export type TreeMeta = { mirrorHead: string; generatedAt: string }; /** The href of one entry, relative to its directory's page. */ export function hrefFor(entry: Pick): string { return encodeURIComponent(entry.name) + (entry.dir ? "/" : ""); } export function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export function formatTreeBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } /** Directories first, then files, each by name (code-unit order: stable everywhere). */ export function sortEntries(entries: readonly TreeEntry[]): TreeEntry[] { return [...entries].sort((a, b) => a.dir !== b.dir ? (a.dir ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); } const CSS = ` :root { color-scheme: light dark; --fg: #1d1b18; --muted: #6b665e; --bg: #faf8f4; --rule: #e4dfd6; --link: #7a5a1c; --hover: #f1ede5; } @media (prefers-color-scheme: dark) { :root { --fg: #ece8e1; --muted: #a39d93; --bg: #161512; --rule: #2e2b26; --link: #d9b46a; --hover: #221f1b; } } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } main { max-width: 64rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; } nav { font-size: 1rem; margin-bottom: 1rem; word-break: break-all; } nav .sep { color: var(--muted); padding: 0 0.25rem; } a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; } table { width: 100%; border-collapse: collapse; } td { padding: 0.3rem 0.5rem; border-top: 1px solid var(--rule); } tr:hover td { background: var(--hover); } td.size { text-align: right; color: var(--muted); white-space: nowrap; width: 1%; } td.name { word-break: break-all; } footer { margin-top: 1.5rem; color: var(--muted); font-size: 0.8125rem; } `.trim(); /** * One directory's page. `relDir` is the directory relative to the tree root * ("" for the root); every link on the page is relative, so it works under * /source/tree/ on any host and from a directory's `index.html` alike. */ export function renderTreeIndex(opts: { relDir: string; entries: readonly TreeEntry[]; mirrorHead: string; generatedAt: string; }): string { const segs = opts.relDir ? opts.relDir.split("/") : []; const depth = segs.length; const up = (n: number) => (n === 0 ? "./" : "../".repeat(n)); const crumbs: string[] = [ depth === 0 ? `archilyzer` : `archilyzer`, ]; segs.forEach((seg, i) => { const last = i === depth - 1; crumbs.push( last ? `${escapeHtml(seg)}` : `${escapeHtml(seg)}`, ); }); const rows: string[] = []; if (depth > 0) { rows.push(`..`); } for (const e of sortEntries(opts.entries)) { const label = escapeHtml(e.name) + (e.dir ? "/" : ""); const size = e.dir ? "" : `${formatTreeBytes(e.bytes)}`; rows.push( `${label}${size}`, ); } const title = depth === 0 ? "archilyzer" : `archilyzer/${opts.relDir}`; return [ "", ``, "", ``, ``, ``, `${escapeHtml(title)} · source`, ``, "", "", "
", ``, ``, ...rows, `
`, `
main @ ${escapeHtml(opts.mirrorHead.slice(0, 12))} · generated ${escapeHtml(opts.generatedAt)} · clone
`, "
", "", "", "", ].join("\n"); } /** * Write an index.html into every directory under `treeDir`, the root * included. REFUSES when a directory already holds an `index.html` (a tracked * one would be overwritten, or served in the index's place) or a `404.html` * (Pages would serve it as HTML for any missing path below it), or when * anything is a symlink. Returns the tree's own files and bytes (not the pages) and how * many directories got a page. */ export async function writeTreeIndexes( treeDir: string, meta: TreeMeta, ): Promise<{ files: number; dirs: number; bytes: number }> { const totals = { files: 0, dirs: 0, bytes: 0 }; const walk = async (rel: string): Promise => { const abs = rel ? path.join(treeDir, rel) : treeDir; const ents = await readdir(abs, { withFileTypes: true }); const entries: TreeEntry[] = []; for (const ent of ents) { const r = rel ? `${rel}/${ent.name}` : ent.name; if (ent.isSymbolicLink()) { throw new SourceRefusal(`the tree holds a symlink (${r}); the raw tree publishes files only`); } if (ent.isDirectory()) { entries.push({ name: ent.name, dir: true, bytes: 0 }); continue; } if (ent.name === "index.html") { throw new SourceRefusal( `the tree already has ${r}; its directory page would replace it — rename the file or publish without the raw tree`, ); } // Pages answers a missing path with the NEAREST 404.html, as HTML (the // directory-page rule): a tracked one would run on this origin. if (ent.name === "404.html") { throw new SourceRefusal( `the tree has ${r}; Pages would serve it, as HTML on this origin, for every missing path below it — rename the file or publish without the raw tree`, ); } const { size } = await stat(path.join(abs, ent.name)); entries.push({ name: ent.name, dir: false, bytes: size }); totals.files++; totals.bytes += size; } await writeFile( path.join(abs, "index.html"), renderTreeIndex({ relDir: rel, entries, ...meta }), ); totals.dirs++; for (const e of entries) if (e.dir) await walk(rel ? `${rel}/${e.name}` : e.name); }; await walk(""); return totals; }