writeCodeBrowser

tcmts.site.writeCodeBrowser · #ozuzdpvs · term · runs on the server

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

used by