buildCodeBrowser
uses node:path
The docs site's code browser (code/): tcm's own codebase, every definition a page, and an index of it and the
examples, each of which tcm's server shows in a forge of its own (code/<name>/, its codebase as its setup script
builds it: tcm serve --forge); and each example's files to download (examples/<name>.tar.gz, a directory of its
name). One that's a static site is built into the site, from the codebase its setup script builds (kept in
dist/examples by what its files hash to, so it's built again only when they change, and kept a day after it was
last used, as verifies of other branches may have other files: each built one at a time). Returns what couldn't be
built, and any link in it to a page that isn't there: named exactly, case and all (a filesystem that ignores case
would find Highlighter.html for highlighter.html; the server's doesn't). env is given to the examples' setup
and builds: a slot's (withSlot), when this runs in one, so they don't wait for another. base is the path the
site is served under: an example that's a static site is built for its place in it.
import { IR_VERSION } from "#codebase/tcmts/core/hash"; import { codebaseDir, EXAMPLES, holdLock, lockHeld, repository, sh, shippedLibrariesDir } from "#codebase/tcmts/dev"; import { esc, writeCodeBrowser } from "#codebase/tcmts/site"; import type { Highlighter } from "#codebase/tcmts/site"; import { LAYOUT } from "#codebase/tcmts/store"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; export async function buildCodeBrowser( out: string, hl: Highlighter, env: Record<string, string> = {}, base = "", ): Promise<string[]> { const tcm = process.env.TCM_BIN; if (!tcm) return [ "the code browser is built with `tcm run`, which gives it the codebase's own tcm (TCM_BIN)", ]; const problems: string[] = []; const browsable: { name: string; title: string; count?: number; run?: string }[] = []; const count = writeCodeBrowser( { name: "tcm", title: "tcm-ts", dir: codebaseDir(), root: "tcmts" }, path.join(out, "code", "tcm"), hl, ); browsable.push({ name: "tcm", title: "tcm itself: the codebase manager, its editor extension, these docs", count, }); fs.mkdirSync(path.join(out, "examples"), { recursive: true }); // (the libraries tcm ships, which their setups mount: found while this is still the codebase's branch) const libraries = shippedLibrariesDir(); // (the examples' codebases are their own, not on the branch of a session verifying tcm's: put back below) const session = process.env.TCM_BRANCH; delete process.env.TCM_BRANCH; for (const ex of EXAMPLES) { const files = fs.mkdtempSync(path.join(os.tmpdir(), `tcm example ${ex.name} `)); for (const [rel, text] of Object.entries(ex.files)) { const file = path.join(files, ex.name, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text, { mode: rel.endsWith(".sh") ? 0o755 : 0o644 }); } // (and by the hash format and table layout: a codebase in another one can't be read, so it's built again) const key = createHash("sha256") .update(JSON.stringify([IR_VERSION, LAYOUT, ex.files])) .digest("hex") .slice(0, 16); const built = path.join(repository(), "dist", "examples", `${ex.name}-${key}`); // its download, kept beside the codebase it's made from: the same files make the same one const archive = `${built}.tar.gz`; // (verifies of other branches build these too, side by side: one at a time for each) const done = await holdLock(`${built}.lock`, `another verify building examples/${ex.name}`); if (!fs.existsSync(archive)) { const part = `${archive}.part-${process.pid}`; const tar = sh("tar", ["-czf", part, "-C", files, ex.name], files); if (tar.ok) fs.renameSync(part, archive); else problems.push(`examples/${ex.name}.tar.gz: ${tar.out.trim()}`); } if (fs.existsSync(archive)) fs.copyFileSync(archive, path.join(out, "examples", `${ex.name}.tar.gz`)); // one that's a static site is built into this one, under its path, from the codebase its setup makes if (ex.site && !fs.existsSync(path.join(built, ".tcm", "codebase.sqlite"))) { fs.rmSync(built, { recursive: true, force: true }); // (its tcm works on the example's codebase, not the one running this; its tests are the suites' to run, with // tcm as it's packed: `tcmts.cli.test.e2e.examples`) const r = sh(path.join(files, ex.name, "setup.sh"), [built], files, { ...env, TCM_BIN: tcm, TCM_CODEBASE: "", TCM_SHIPPED_LIBRARIES: libraries, NO_TESTS: "1", }); if (!r.ok) { // (not kept half made: the next build runs its setup again) fs.rmSync(built, { recursive: true, force: true }); problems.push( `examples/${ex.name}: setup.sh failed:\n${r.out.trim().split("\n").slice(-8).join("\n")}`, ); } } if (ex.site && fs.existsSync(path.join(built, ".tcm", "codebase.sqlite"))) { const r = sh( process.execPath, [ tcm, "build", ex.site, "--static", path.join(out, "examples", ex.name), "--base", `${base}/examples/${ex.name}`, ], built, { ...env, TCM_CODEBASE: "" }, ); if (!r.ok) problems.push( `examples/${ex.name}: tcm build --static failed:\n${r.out.trim().split("\n").slice(-8).join("\n")}`, ); } browsable.push({ name: ex.name, title: ex.title, ...(ex.app || ex.site ? { run: `../examples/${ex.name}/` } : {}), }); // what earlier files made isn't kept: once it's gone unused a day (another branch's verify may use it), and not // while it's being built const now = new Date(); const kept = [built, archive].filter((p) => fs.existsSync(p)); for (const p of kept) fs.utimesSync(p, now, now); done(); const examples = path.dirname(built); for (const e of fs.readdirSync(examples)) { const of = new RegExp(`^${ex.name}-([0-9a-f]{16})(?![^.-])`).exec(e); if (!of || kept.some((p) => path.basename(p) === e) || e.endsWith(".lock")) continue; if (lockHeld(path.join(examples, `${ex.name}-${of[1]}.lock`))) continue; let unused = Infinity; try { unused = Date.now() - fs.statSync(path.join(examples, e)).mtimeMs; } catch {} if (unused > 86_400_000) fs.rmSync(path.join(examples, e), { recursive: true, force: true }); } } if (session !== undefined) process.env.TCM_BRANCH = session; const list = browsable .map( (b) => `<dt><a href="${b.count === undefined ? `${b.name}/` : `${b.name}/index.html`}">${esc(b.name)}</a>${b.run ? ` · <a href="${b.run}">run it</a>` : ""}</dt><dd>${esc(b.title)}${b.count === undefined ? "" : ` <span class="sig">(${b.count} definitions)</span>`}</dd>`, ) .join("\n"); fs.writeFileSync( path.join(out, "code", "index.html"), `<!doctype html>\n<html lang="en"><head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>code — tcm-ts</title>\n<link rel="stylesheet" href="../style.css">\n</head><body>\n<div class="site">\n<nav class="side" aria-label="codebases">\n<a class="home" href="../index.html">tcm-ts.txt</a>\n<div class="rule" aria-hidden="true"></div>\n</nav>\n<main>\n<h1>Code</h1>\n<div class="rule" aria-hidden="true"></div>\n<p>Codebases to browse, as tcm keeps them: every definition with its doc, its code with each reference a link, where it runs, and what it uses and what uses it. tcm's own is here as pages (and in its forge, at <code>/forge/</code> on tcm's server); each example is shown in a forge of its own, the codebase its setup script builds, with its history and releases. Download one to build it yourself: <code>curl -fsSL <this site>/examples/polls.tar.gz | tar xz && polls/setup.sh /tmp/polls</code>.</p>\n<dl>\n${list}\n</dl>\n</main>\n</div>\n</body></html>\n`, ); // the apps among them, for tcm's server to run (`tcm start <app> --base /examples/<name> <flags>`): a line each, // `<name> <app> <reset: yes|no> <flags>` const apps = EXAMPLES.filter((e) => e.app); fs.writeFileSync( path.join(out, "examples", "apps.txt"), apps .map( (e) => [e.name, e.app!.name, e.app!.reset ? "yes" : "no", ...(e.app!.flags ?? [])].join(" ") + "\n", ) .join(""), ); // (where they run, and the examples' forges, are the server's, not files of the site) const running = new Set([ ...apps.map((e) => `examples/${e.name}/`), ...EXAMPLES.map((e) => `code/${e.name}/`), ]); const files = new Set<string>(); const walk = (dir: string) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p); else files.add(path.relative(out, p).split(path.sep).join("/")); } }; walk(out); for (const f of [...files].filter((f) => f.startsWith("code/") && f.endsWith(".html"))) { const html = fs.readFileSync(path.join(out, f), "utf8"); for (const [, to] of html.matchAll(/href="([^"#:]+)(?:#[^"]*)?"/g)) { const target = path.posix.normalize(path.posix.join(path.posix.dirname(f), to!)); // (a directory is its index.html: a static site's, built in) if ( !files.has(target) && !files.has(path.posix.join(target, "index.html")) && !running.has(target) ) problems.push(`${f}: link to ${to}: no such page`); } } return problems; }
uses
- tcmts.core.hash.IR_VERSION
- tcmts.dev.EXAMPLES
- tcmts.dev.codebaseDir
- tcmts.dev.holdLock
- tcmts.dev.lockHeld
- tcmts.dev.repository
- tcmts.dev.sh
- tcmts.dev.shippedLibrariesDir
- tcmts.site.Highlighter
- tcmts.site.esc
- tcmts.site.writeCodeBrowser
- tcmts.store.LAYOUT