writeCodeBrowser
uses node:path
Write the pages to browse a codebase into out (code/<name>/ in the docs site): one per namespace (what's in
it, its README first) and one per definition (its doc, its code with every reference a link, where it runs, what
it uses and what uses it), as tcm's editor pages show them. Returns how many definitions.
import { Codebase } from "#codebase/tcmts/cli/codebase"; import { definitionPage, docSummary, GROUP_OVER, roleOf, ROLES } from "#codebase/tcmts/cli/pages"; import { Placement } from "#codebase/tcmts/cli/placement"; import { esc, LANGS } from "#codebase/tcmts/site"; import type { Browsable, CodePage, Highlighter } from "#codebase/tcmts/site"; import { Marked } from "marked"; import type { Tokens } from "marked"; import fs from "node:fs"; import path_1 from "node:path"; export function writeCodeBrowser(b: Browsable, out: string, hl: Highlighter): number { const cb = Codebase.open(b.dir); const placement = new Placement(cb); // (an empty root is the whole codebase) const under = (p: string) => !b.root || p === b.root || p.startsWith(`${b.root}.`); const rows = cb.store.names(undefined, true).filter((r) => under(r.path)); const parentOf = (p: string) => p.slice(0, Math.max(0, p.lastIndexOf("."))); const leaf = (p: string) => p.slice(p.lastIndexOf(".") + 1); const namespaces = new Set<string>([b.root]); for (const r of rows) for (let ns = parentOf(r.path); ns && under(ns); ns = parentOf(ns)) namespaces.add(ns); // where each thing's page is, relative to the codebase's directory. Files are named apart ignoring case: on a // filesystem that ignores it (macOS's), `highlighter` and `Highlighter` would be one file, and the site, served // from one that doesn't, would lack the other; a type and a value can even share a name. The first to want a // file has it, and the next `-2`, … const taken = new Set<string>(); const claim = (base: string, ext: string) => { let f = `${base}${ext}`; for (let i = 2; taken.has(f.toLowerCase()); i++) f = `${base}-${i}${ext}`; taken.add(f.toLowerCase()); return f; }; const dirs = new Map<string, string>([[b.root, ""]]); const depth = (ns: string) => ns.split(".").length; for (const ns of [...namespaces] .filter((n) => n !== b.root) .sort((x, y) => depth(x) - depth(y) || (x < y ? -1 : 1))) dirs.set( ns, claim(path_1.posix.join(dirs.get(parentOf(ns)) ?? "", leaf(ns)), "/").slice(0, -1), ); const nsFile = (ns: string) => path_1.posix.join(dirs.get(ns) ?? "", "index.html"); // (a definition named `index` doesn't take its namespace's page) for (const ns of namespaces) taken.add(nsFile(ns).toLowerCase()); const files = new Map<string, string>(); const rowKey = (r: { path: string; hash: string }) => `${r.path} ${r.hash}`; for (const r of rows) files.set( rowKey(r), claim(path_1.posix.join(dirs.get(parentOf(r.path)) ?? "", leaf(r.path)), ".html"), ); const defFile = (r: { path: string; hash: string }) => files.get(rowKey(r))!; const pageOf = new Map<string, string>(); for (const r of rows) if (!pageOf.has(r.hash)) pageOf.set(r.hash, defFile(r)); const href = (from: string, to: string) => path_1.posix.relative(path_1.posix.dirname(from), to) || path_1.posix.basename(to); const up = (from: string) => "../".repeat(from.split("/").length + 1); const childrenOf = (ns: string) => ({ namespaces: [...namespaces].filter((n) => n !== ns && parentOf(n) === ns).sort(), defs: rows.filter((r) => parentOf(r.path) === ns).sort((x, y) => (x.path < y.path ? -1 : 1)), }); // a big namespace's definitions by what they're for, as the editor's Codebase view groups them: apps, types, // data, jobs, then code by where it runs, tests and files last const roles = new Map(rows.map((r) => [rowKey(r), roleOf(cb, r.path, r.hash, placement)])); const grouped = <R extends { path: string; hash: string }>(defs: R[]) => defs.length <= GROUP_OVER ? [{ label: "", defs }] : ROLES.map(([role, label]) => ({ label, defs: defs.filter((r) => roles.get(rowKey(r)) === role), })).filter((g) => g.defs.length); // a page's relative link to a file or directory beside it, as tcm writes them out (`polls/model.ts`, `notes/`): // the page of the definition or namespace of that name, from the page's namespace const within = (ns: string, to: string) => { if (/^[a-z][a-z0-9+.-]*:|^\/|^#/i.test(to)) return undefined; const parts = to.replace(/[#?].*$/, "").split("/"); const segs = ns ? ns.split(".") : []; for (const s of parts.slice(0, -1)) { if (s === "..") segs.pop(); else if (s && s !== ".") segs.push(s); } const last = parts.at(-1)!.replace(/\.[^.]+$/, ""); const name = [...segs, ...(last ? [last] : [])].join("."); const row = rows.find((r) => r.path === name); return row ? defFile(row) : namespaces.has(name) ? nsFile(name) : undefined; }; const markdown = (md: string, from: string, ns: string) => new Marked({ gfm: true, renderer: { link({ href: to, tokens }: Tokens.Link) { const text = this.parser.parseInline(tokens); const page = to.startsWith("tcm-page:") ? pageOf.get(to.slice(9)) : undefined; if (to.startsWith("tcm-page:")) return page ? `<a href="${href(from, page)}">${text}</a>` : text; const local = within(ns, to); if (local) return `<a href="${href(from, local)}">${text}</a>`; return `<a href="${esc(to)}">${text}</a>`; }, code({ text, lang }: Tokens.Code) { if (lang && LANGS.includes(lang)) return ( hl.codeToHtml(text, { lang, themes: { light: "github-light", dark: "github-dark" }, defaultColor: false, }) + "\n" ); return `<pre>${esc(text)}</pre>\n`; }, }, }).parse(md) as string; // references in code: a definition's page, a namespace's const linked = (html: string, from: string) => html .replace(/<a class="([^"]*)" data-hash="([a-z2-7]+)">/g, (_, cls: string, h: string) => pageOf.has(h) ? `<a class="${cls}" href="${href(from, pageOf.get(h)!)}">` : `<a class="${cls}">`, ) .replace(/<a class="([^"]*)" data-namespace="([^"]*)">/g, (_, cls: string, ns: string) => namespaces.has(ns) ? `<a class="${cls}" href="${href(from, nsFile(ns))}">` : `<a class="${cls}">`, ) .replace( /<a class="([^"]*)" data-package-name="[^"]*" data-package-range="[^"]*">/g, '<a class="$1">', ); const menu = (ns: string, from: string, here?: { path: string; hash: string }) => { // the way down from the codebase's root, by namespace const chain: string[] = []; for (let n = ns; n !== b.root && under(n); n = parentOf(n)) chain.unshift(n); chain.unshift(b.root); const crumbs = chain.map((n) => { const label = n === b.root ? b.name : leaf(n); return n === ns && !here ? `<b>${esc(label)}</b>` : `<a href="${href(from, nsFile(n))}">${esc(label)}</a>`; }); const c = childrenOf(ns); const items = [ ...c.namespaces.map( (n) => `<li class="ns"><a href="${href(from, nsFile(n))}">${esc(leaf(n))}/</a></li>`, ), ...grouped(c.defs).flatMap((g) => [ ...(g.label ? [`<li class="group">${esc(g.label)}</li>`] : []), ...g.defs.map((r) => here && rowKey(r) === rowKey(here) ? `<li class="here"><span>${esc(leaf(r.path))}</span></li>` : `<li><a href="${href(from, defFile(r))}">${esc(leaf(r.path))}</a></li>`, ), ]), ]; return `<p class="crumbs">${crumbs.join(" . ")}</p>\n<ol class="code-list">\n${items.join("\n")}\n</ol>`; }; const page = (file: string, title: string, side: string, body: string) => { const to = path_1.join(out, file); fs.mkdirSync(path_1.dirname(to), { recursive: true }); fs.writeFileSync( to, `<!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>${esc(title)} — ${esc(b.title)}</title>\n<link rel="stylesheet" href="${up(file)}style.css">\n</head><body>\n<div class="site">\n<nav class="side" aria-label="codebase">\n<a class="home" href="${up(file)}index.html">tcm-ts.txt</a> <a class="home" href="${up(file)}code/index.html">/ code</a>\n<div class="rule" aria-hidden="true"></div>\n${side}\n</nav>\n<main>\n${body}\n</main>\n</div>\n</body></html>\n`, ); }; for (const ns of namespaces) { const file = nsFile(ns); const c = childrenOf(ns); const readme = c.defs.find((r) => leaf(r.path) === "README"); const readmePage = readme && (definitionPage(cb, readme.hash, placement, { viewUri: () => "" }) as CodePage | undefined); const intro = readmePage?.docMarkdown ? markdown(readmePage.docMarkdown, file, ns) : ""; const subs = c.namespaces.map( (n) => `<dt><a href="${href(file, nsFile(n))}">${esc(leaf(n))}/</a></dt><dd>${rows.filter((r) => r.path.startsWith(`${n}.`)).length} definitions</dd>`, ); const item = (r: { path: string; hash: string }) => { const rec = cb.store.getDef(r.hash)!; const what = rec.kind === "file" ? "file" : rec.kind === "type" ? "type" : ((sig) => esc(sig.length > 100 ? `${sig.slice(0, 99)}…` : sig))( cb.store.shownSignature(rec), ); return `<dt><a href="${href(file, defFile(r))}">${esc(leaf(r.path))}</a> <span class="sig">${what}</span></dt><dd>${esc(docSummary(rec) ?? "")}</dd>`; }; const sections = [ ...(subs.length ? [`<dl>\n${subs.join("\n")}\n</dl>`] : []), ...grouped(c.defs).map( (g) => `${g.label ? `<h2>${esc(g.label)}</h2>\n` : ""}<dl>\n${g.defs.map(item).join("\n")}\n</dl>`, ), ]; page( file, ns || b.title, menu(ns, file), `<h1>${esc(ns || b.title)}</h1>\n<div class="rule" aria-hidden="true"></div>\n${intro}\n${sections.join("\n")}`, ); } for (const r of rows) { const file = defFile(r); const p = definitionPage(cb, r.hash, placement, { viewUri: () => "" }) as CodePage | undefined; if (!p) continue; const item = (x: { hash: string; name: string; signature?: string }) => `<li>${pageOf.has(x.hash) ? `<a href="${href(file, pageOf.get(x.hash)!)}">${esc(x.name)}</a>` : esc(x.name)}</li>`; const meta = [ `<code>${esc(r.path)}</code>`, esc(p.short), p.kind === "file" ? `${esc(p.format || "file")} file` : p.kind, ...(p.place ? [ `runs ${p.place === "anywhere" ? "anywhere" : p.place === "server" ? "on the server" : `in the ${p.place}`}`, ] : []), ].join(" · "); const why = p.why ? `<p class="why">${esc(String(p.why))}</p>` : ""; const image = p.image ? `<p><img class="file-image" src="${p.image}" alt="${esc(leaf(r.path))}"></p>` : ""; const doc = p.docMarkdown ? markdown(p.docMarkdown, file, parentOf(r.path)) : ""; const code = p.sourceHtml && !(p.kind === "file" && p.docMarkdown) ? `<pre class="code">${linked(p.sourceHtml, file)}</pre>` : ""; const lists = [ p.uses.length ? `<h2>uses</h2>\n<ul class="refs">${p.uses.map(item).join("")}</ul>` : "", p.usedBy.length ? `<h2>used by</h2>\n<ul class="refs">${p.usedBy.map(item).join("")}</ul>` : "", ].join("\n"); page( file, r.path, menu(parentOf(r.path), file, r), `<h1>${esc(leaf(r.path))}</h1>\n<div class="rule" aria-hidden="true"></div>\n<p class="meta">${meta}</p>\n${why}\n${doc}\n${image}\n${code}\n${lists}`, ); } cb.store.close(); return rows.length; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.pages.GROUP_OVER
- tcmts.cli.pages.ROLES
- tcmts.cli.pages.definitionPage
- tcmts.cli.pages.docSummary
- tcmts.cli.pages.roleOf
- tcmts.cli.placement.Placement
- tcmts.site.Browsable
- tcmts.site.CodePage
- tcmts.site.Highlighter
- tcmts.site.LANGS
- tcmts.site.esc