site.ts

54.2 kB · typescript · 1118 lines

1import { existsSync, readFileSync, readdirSync } from "node:fs";2import { dirname, join, relative, resolve } from "node:path";3import { createElement as h } from "react";4import { renderToStaticMarkup } from "react-dom/server";5import katex from "katex";6import { build, bytes, walk, type Node, type Output, type Route, type Site, type Spec } from "../kit/ssg/build.ts";7import { isGit } from "../kit/git/git.ts";8import { resolve as resolveLink } from "../kit/ssg/links.ts";9import { escape, front, inline, plain, render as md, summary, title } from "../lib/md.js";10import { sidebar, tree } from "../lib/tree.js";11import { Glyph, Grid, Menu, Shell } from "../kit/ui/chrome.jsx";12import { headScript, tintCss } from "../kit/ui/config.js";13import SITE from "../lib/site.js";14import { shelf } from "./shelf.ts";1516const org = resolve(import.meta.dir, "..");17const dist = process.env.MRLY_DIST ? resolve(process.env.MRLY_DIST) : join(org, "dist");18const BLOG = join(org, "blog");19const postFile = (slug: string) => join(BLOG, `${slug}.md`);20const root = (process.env.MRLY_SITE ?? SITE.root).replace(/\/$/, "");21const AUTHOR = "MrlyProd";22const LIST = /^- \[([^\]]+)\]\([^)]*\) - (.+)$/gm;23const HEADING = /<h([23]) id="([^"]+)">(.*?)<\/h\1>/g;24const AVATAR = /^(!\[avatar\]\(figures\/avatar\.png\)|<picture>.*?figures\/avatar-light\.png.*?<\/picture>)\n?/m;25const WORD = renderToStaticMarkup(h(Glyph, { text: SITE.title.toUpperCase() }));26const BOOT = headScript(SITE.prefix);27const TINT = `<style>${tintCss(SITE.tint)}</style>`;28const ICONS = [29  `<link rel="icon" href="/favicon.svg" type="image/svg+xml">`,30  `<link rel="icon" href="/favicon.png" type="image/png" sizes="40x40">`,31  `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`,32  `<link rel="manifest" href="/manifest.webmanifest">`,33].join("\n");3435const read = (p: string) => readFileSync(p, "utf8");36const math = (tex: string, display: boolean) =>37  katex.renderToString(tex, { output: "mathml", throwOnError: false, displayMode: display });38const untag = (html: string) =>39  html.replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');40const brand = (name: string) => (name === SITE.title ? name : `${name} · ${SITE.title}`);4142/* LINKS */4344const NAME = /^[a-z0-9-]+$/;4546function links(site: Site, from: string, out?: Output[]) {47  const home = site.input("figures").path;48  return (url: string) => {49    if (out && NAME.test(url) && existsSync(join(home, `${url}.png`))) {50      const path = `figures/${url}.png`;51      if (!out.some((item) => item.path === path)) out.push({ path, bytes: bytes(join(home, `${url}.png`)) });52      return `/${path}`;53    }54    return resolveLink(site, from, url);55  };56}5758/* FIGURES */5960const SIDES = ["dark", "light"] as const;6162function figure(home: string, name: string, route: string) {63  const file = join(home, `${name}.png`);64  if (!existsSync(file)) throw new Error(`site: ${name}.png missing from ${relative(org, home)} for ${route}; draw it with bun run figures`);65  return file;66}6768function press(site: Site, out: Output[]) {69  const home = site.input("figures").path;70  return (name: string, route: string) => {71    const pair = { dark: "", light: "" };72    for (const side of SIDES) {73      const file = figure(home, `${name}-${side}`, route);74      const path = `figures/${name}-${side}.png`;75      if (!out.some((item) => item.path === path)) out.push({ path, bytes: bytes(file) });76      pair[side] = `/${path}`;77    }78    return pair;79  };80}8182type Fig = ReturnType<typeof press>;8384const pic = (fig: Fig, name: string, route: string, alt: string, extra = "", cls = "") => {85  const pair = fig(name, route);86  return SIDES.map((side) => `<img class="${cls}${cls ? " " : ""}${side}" src="${pair[side]}" alt="${escape(alt)}" width="1024" height="1024"${extra}>`).join("");87};8889const hero = (fig: Fig, name: string, route: string, alt: string) =>90  `<figure class="opener">${pic(fig, name, route, alt)}</figure>`;9192const grid = (nodes: Node[]) => renderToStaticMarkup(h(Grid, { nodes }));9394/* HEAD */9596type Picture = { url: string; width: number; height: number };9798const OG: Picture = { url: `${root}/og.png`, width: 1200, height: 630 };99100function picture(site: Site, name: string, route: string, fig?: Fig): Picture {101  const file = figure(site.input("figures").path, `${name}-dark`, route);102  if (fig) fig(name, route);103  const png = bytes(file);104  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);105  return { url: `${root}/figures/${name}-dark.png`, width: view.getUint32(16), height: view.getUint32(20) };106}107108function meta(route: string, name: string, description: string, type: string, image = OG) {109  const url = root + route;110  return [111    `<link rel="canonical" href="${url}">`,112    `<meta name="description" content="${escape(description)}">`,113    `<meta property="og:title" content="${escape(name)}">`,114    `<meta property="og:description" content="${escape(description)}">`,115    `<meta property="og:url" content="${url}">`,116    `<meta property="og:type" content="${type}">`,117    `<meta property="og:site_name" content="${escape(SITE.title)}">`,118    `<meta property="og:image" content="${image.url}">`,119    `<meta property="og:image:width" content="${image.width}">`,120    `<meta property="og:image:height" content="${image.height}">`,121    `<meta name="twitter:card" content="summary_large_image">`,122    `<meta name="twitter:image" content="${image.url}">`,123    ICONS,124  ].join("\n");125}126127function headings(body: string) {128  return [...body.matchAll(HEADING)].map((m) => ({ level: Number(m[1]), id: m[2], text: untag(m[3]) }));129}130131type Leaf = {132  route: string;133  name: string;134  description: string;135  body: string;136  type?: string;137  wide?: boolean;138  bare?: boolean;139  code?: boolean;140  data?: object;141  tree?: Node[];142  image?: Picture;143  scripts?: string[];144};145146function shell(site: Site, leaf: Leaf) {147  const { route, name, description, body, type = "article", wide = false, bare = false, code = false, data } = leaf;148  const article = h(bare ? "div" : "article", { className: bare ? undefined : "prose", dangerouslySetInnerHTML: { __html: body } });149  const main = renderToStaticMarkup(h(Shell, { route, tree: leaf.tree ?? site.nav, contents: headings(body), wide }, article));150  const ld = data ? `<script type="application/ld+json">${JSON.stringify(data)}</script>\n` : "";151  const more = (leaf.scripts ?? []).map((src) => `\n<script type="module" src="${src}"></script>`).join("");152  const image = leaf.image ?? (code ? picture(site, "site-code", route) : OG);153  return `<!doctype html>154<html lang="en" data-prefix="${SITE.prefix}">155<head>156<meta charset="utf-8">157<meta name="viewport" content="width=device-width, initial-scale=1">158${BOOT}159<title>${escape(brand(name))}</title>160${meta(route, name, description, type, image)}161<link rel="stylesheet" href="${site.asset("palette.css")}">162<link rel="stylesheet" href="${site.asset("tokens.css")}">163<link rel="stylesheet" href="${site.asset("base.css")}">164<link rel="stylesheet" href="${site.asset("chrome.css")}">165<link rel="stylesheet" href="${site.asset("fonts/fonts.css")}">166<link rel="stylesheet" href="/pages.css">167${TINT}168${code ? `<link rel="stylesheet" href="${site.asset("code.css")}">\n<link rel="stylesheet" href="${site.asset("seti/seti.css")}">\n` : ""}${ld}<script type="module" src="${site.asset("chrome.js")}"></script>${more}169</head>170<body>171${main}172</body>173</html>174`;175}176177/* DEMOS */178179type Card = { name: string; title: string; blurb: string; shelf: string; order: number; reads: Read[] };180181type Read = { name: string; href: string };182183type Shelf = { key: string; group: string; title: string; blurb: string };184185type Bay = Node & { key: string };186187const SHELVES = (SITE.shelves ?? []) as Shelf[];188189const TITLE = /<title>([^<]*)<\/title>/;190191const OWN = /[ \t]*<meta name="?(?:description|shelf|order)"?[^>]*>\n?/g;192193const tag = (html: string, name: string) => {194  const found = html.match(new RegExp(`<meta name="${name}" content="([^"]*)">`));195  return found ? untag(found[1]) : "";196};197198function demoNames(site: Site) {199  const home = site.input("demos").path;200  return site201    .input("demos")202    .files.filter((f) => f.endsWith("/index.html"))203    .map((f) => dirname(f).slice(home.length + 1))204    .sort((a, b) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));205}206207const demoRoute = (name: string) => (name ? `/demos/${name}/` : "/demos/");208209function cards(site: Site): Card[] {210  const home = site.input("demos").path;211  return demoNames(site).map((name) => {212    const html = read(join(home, name, "index.html"));213    const found = html.match(TITLE);214    return {215      name,216      title: found ? untag(found[1]) : name,217      blurb: tag(html, "description"),218      shelf: tag(html, "shelf"),219      order: Number(tag(html, "order")) || 0,220      reads: [],221    };222  });223}224225const MENTION = (name: string) => new RegExp(`demos/${name}/`);226227function readers(list: Card[], pages: { name: string; href: string; md: string }[]) {228  for (const card of list) {229    if (!card.name) continue;230    const seen = MENTION(card.name);231    card.reads = pages.filter((p) => seen.test(p.md)).map((p) => ({ name: p.name, href: p.href }));232  }233}234235export function shelved(list: Card[]): Bay[] {236  return SHELVES.map((one) => ({237    key: one.key,238    name: one.title,239    nodes: list240      .filter((d) => d.shelf === one.key)241      .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))242      .map((d) => ({ name: d.title, href: demoRoute(d.name), text: d.blurb })),243  })).filter((one) => one.nodes.length);244}245246export const demoTree = (site: Site) => shelved(cards(site));247248function demoGroup(site: Site): Route {249  const home = site.input("demos").path;250  const list = cards(site);251  const inputs = ["demos", "lib", "pkg", "ui"].flatMap((one) => site.input(one).files);252  return {253    route: "/demos/",254    kind: "demos",255    name: "Demos",256    data: list,257    source: home,258    inputs,259    urls: list.map((d) => ({ route: demoRoute(d.name), name: d.title, source: join(home, d.name) })),260  };261}262263function seo(source: string, card: Card, nav: string, image: Picture) {264  const html = source265    .replace(/<html([^>]*)>/, (_, attrs: string) => `<html${attrs.replace(/ data-prefix="[^"]*"/, "")} data-prefix="${SITE.prefix}">`)266    .replace(/<script data-boot>[\s\S]*?<\/script>\n?/, "")267    .replace(OWN, "");268  const route = demoRoute(card.name);269  const found = html.match(TITLE);270  const name = found ? untag(found[1]) : card.title;271  const tags = meta(route, name, card.blurb || name, "website", image);272  const reads = `<script type="application/json" id="${SITE.prefix}reads">${JSON.stringify(card.reads).replace(/</g, "\\u003c")}</script>`;273  const block = `${BOOT}\n<title>${escape(brand(name))}</title>\n${tags}\n${nav}\n${reads}\n<link rel="stylesheet" href="/ui/fonts/fonts.css">`;274  const page = found ? html.replace(found[0], block) : html.replace("<head>", `<head>\n${block}`);275  return page.replace("</head>", `${TINT}\n</head>`);276}277278const widgetFiles = (site: Site) => site.input("demos").files.filter((f) => f.endsWith("/widget.jsx"));279280async function demos(site: Site, route: Route): Promise<Output[]> {281  const list = route.data as Card[];282  const home = site.input("demos").path;283  const built = await Bun.build({284    entrypoints: [...list.map((d) => join(home, d.name, "index.html")), ...widgetFiles(site)],285    root: org,286    splitting: true,287    minify: true,288    define: { "process.env.NODE_ENV": '"production"' },289    naming: { chunk: "lib-[hash].[ext]", asset: "[name]-[hash].[ext]" },290  });291  if (!built.success) throw new Error(`site: the demos failed to bundle\n${built.logs.join("\n")}`);292  const shells = new Map(list.map((d) => [`${demoRoute(d.name).slice(1)}index.html`, d]));293  const json = JSON.stringify(shelved(list)).replace(/</g, "\\u003c");294  const nav = `<script type="application/json" id="${SITE.prefix}tree">${json}</script>`;295  const out: Output[] = [{ path: "demos/tree.json", bytes: json, type: "application/json" }];296  const fig = press(site, out);297  for (const d of list) if (d.name) fig(`demo-${d.name}`, "/demos/");298  for (const item of built.outputs) {299    const path = item.path.replace(/^\.\//, "");300    const card = shells.get(path);301    const image = card ? picture(site, card.name ? `demo-${card.name}` : "site-demos", demoRoute(card.name), fig) : OG;302    out.push({ path, bytes: card ? seo(await item.text(), card, nav, image) : new Uint8Array(await item.arrayBuffer()) });303  }304  return out;305}306307/* PAPERS */308309type Paper = { slug: string; name: string; lead: string; date: string; revised: string; figure: string; shelf: string; body: string; file: string };310311function papers(site: Site): Paper[] {312  const home = site.input("papers");313  return home.files314    .map((file) => {315      const slug = file.slice(home.path.length + 1, -3);316      const { data, body } = front(read(file));317      return { slug, name: data.title ?? slug, lead: data.lead ?? summary(body), date: data.date ?? "", revised: data.revised ?? "", figure: data.figure || `paper-${slug}`, shelf: data.shelf ?? "", body, file };318    })319    .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug.localeCompare(b.slug)));320}321322const marked = (p: Paper) => [p.date, p.revised && `revised ${p.revised}`].filter(Boolean) as string[];323324function written(site: Site, route: Route): Output[] {325  const p = route.data as Paper;326  const out: Output[] = [];327  const fig = press(site, out);328  const when = [p.date && `First published ${p.date}`, p.revised && `revised ${p.revised}`].filter(Boolean).join(", ");329  const avatar = pic(fig, p.figure, route.route, p.name, "", "avatar");330  const shelf = p.shelf ? `\n<p class="meta"><a href="https://github.com/carlomitchener/carlomitchener/tree/main/research/${escape(p.shelf)}">The LaTeX and PDF of the first edition, on the shelf</a></p>` : "";331  const plate = `<div class="plate paper">${avatar}<h1 id="${escape(p.slug)}">${escape(p.name)}</h1><p class="by">${escape(AUTHOR)}</p><p class="by">${escape(when)}</p></div>${shelf}`;332  const body = `${plate}\n${md(p.body, { math, link: links(site, p.file, out) })}`;333  const data = {334    "@context": "https://schema.org",335    "@type": "ScholarlyArticle",336    headline: p.name,337    description: p.lead,338    url: root + route.route,339    image: `${root}/figures/${p.figure}-dark.png`,340    author: { "@type": "Organization", name: AUTHOR },341    datePublished: p.date || undefined,342    dateModified: p.revised || p.date || undefined,343    license: "https://creativecommons.org/licenses/by/4.0/",344  };345  out.push({ path: `papers/${p.slug}/index.html`, bytes: shell(site, { route: route.route, name: p.name, description: p.lead, body, data, image: picture(site, p.figure, route.route, fig) }) });346  return out;347}348349type Lane = { slug: string; blurb: string; name: string; md: string; published: string; revised: string; pdf: boolean };350351let SHELF = "";352353function lanes(): Lane[] {354  const readme = read(join(SHELF, "README.md"));355  const order = [...readme.matchAll(LIST)].map((m) => ({ slug: m[1], blurb: plain(m[2]) }));356  const found = readdirSync(SHELF).filter(357    (d) => d !== "template" && existsSync(join(SHELF, d, "README.md")) && existsSync(join(SHELF, d, "paper.tex")),358  );359  const known = new Set(order.map((o) => o.slug));360  const list = order.filter((o) => found.includes(o.slug)).concat(found.filter((l) => !known.has(l)).map((slug) => ({ slug, blurb: "" })));361  return list.map(({ slug, blurb }) => {362    const lane = join(SHELF, slug);363    const doc = read(join(lane, "README.md"));364    const tex = read(join(lane, "paper.tex"));365    const date = tex.match(/\\date\{First published (\d{4}-\d{2}-\d{2})(?:, revised (\d{4}-\d{2}-\d{2}))?\}/);366    return {367      slug,368      blurb,369      name: title(doc) || slug,370      md: doc,371      published: date?.[1] ?? "",372      revised: date?.[2] ?? "",373      pdf: existsSync(join(lane, "paper.pdf")),374    };375  });376}377378const dated = (p: Lane) => p.revised || p.published;379const stamps = (p: Lane) => [p.published, p.revised && `revised ${p.revised}`].filter(Boolean) as string[];380381function paper(site: Site, route: Route): Output[] {382  const p = route.data as Lane;383  const out: Output[] = [];384  const fig = press(site, out);385  const lane = join(SHELF, p.slug);386  const at = `papers/${p.slug}`;387  if (p.pdf) out.push({ path: `${at}/paper.pdf`, bytes: bytes(join(lane, "paper.pdf")) });388  out.push({ path: `${at}/paper.tex`, bytes: bytes(join(lane, "paper.tex")) });389  for (const file of walk(join(lane, "figures"))) out.push({ path: `${at}/figures/${file.slice(join(lane, "figures").length + 1)}`, bytes: bytes(file) });390  const when = [p.published && `First published ${p.published}`, p.revised && `revised ${p.revised}`].filter(Boolean).join(", ");391  const files = [p.pdf && `<a href="paper.pdf">PDF</a>`, `<a href="paper.tex">TeX</a>`].filter(Boolean).join(" · ");392  const avatar = pic(fig, `paper-${p.slug}`, route.route, p.name, "", "avatar");393  const plate = `<div class="plate paper">${avatar}<h1 id="${escape(p.slug)}">${escape(p.name)}</h1><p class="by">${escape(AUTHOR)}</p><p class="by">${escape(when)}</p></div>\n<p class="meta">${files}</p>`;394  const body = `${plate}\n${md(p.md.replace(/^# .+\n/, "").replace(AVATAR, ""), { math, link: links(site, join(lane, "README.md"), out) })}`;395  const data = {396    "@context": "https://schema.org",397    "@type": "ScholarlyArticle",398    headline: p.name,399    description: p.blurb || summary(p.md),400    url: root + route.route,401    image: `${root}/figures/paper-${p.slug}-dark.png`,402    author: { "@type": "Organization", name: AUTHOR },403    datePublished: p.published || undefined,404    dateModified: dated(p) || undefined,405    license: "https://creativecommons.org/licenses/by/4.0/",406  };407  out.push({ path: `${at}/index.html`, bytes: shell(site, { route: route.route, name: p.name, description: p.blurb || summary(p.md), body, data, image: picture(site, `paper-${p.slug}`, route.route, fig) }) });408  return out;409}410411function paperIndex(site: Site, route: Route): Output[] {412  const out: Output[] = [];413  const fig = press(site, out);414  const lead = "Write-ups of MrlyMath, each claim a theorem with a proof, a computational fact with its exact finite domain, or a conjecture labelled as one; markdown that prints as a paper.";415  const fresh = new Set(DRESS.papers.map((p) => `/papers/${p.slug}/`));416  const nodes = wear(site, "/papers/", fig, route.route);417  const shelfNote = `<section><h2 id="shelf">The shelf</h2><p class="lead">The first editions, in LaTeX with a PDF each, deprecated: every paper is rewritten here in turn and the shelf is never edited.</p>${grid(nodes.filter((n) => !fresh.has(n.href ?? "")))}</section>`;418  const body = `<div class="lede"><h1 id="papers">Papers</h1><p class="lead">${escape(lead)}</p></div>\n${grid(nodes.filter((n) => fresh.has(n.href ?? "")))}\n${nodes.some((n) => !fresh.has(n.href ?? "")) ? shelfNote : ""}`;419  out.push({ path: "papers/index.html", bytes: shell(site, { route: route.route, name: "Papers", description: lead, body, type: "website", wide: true, bare: true, image: picture(site, "site-papers", route.route, fig) }) });420  return out;421}422423/* RESEARCH */424425type Note = { file: string; name: string; md: string; home: boolean; topic: boolean; title: string; lead: string; figure: string };426427const SHARED = new Set(["REFS"]);428429const ROW = /<tr><td>(?:<code>)?([0-9a-f]{8})(?:<\/code>)?<\/td>/g;430431function anchored(html: string) {432  const seen = new Set<string>();433  return html.replace(ROW, (whole, id: string) => {434    if (seen.has(id)) return whole;435    seen.add(id);436    return `<tr id="${id}"><td><code>${id}</code></td>`;437  });438}439440function notes(site: Site): Note[] {441  const dir = site.input("research");442  if (dir.missing) {443    console.warn(`site: no research tree at ${relative(org, dir.path)}, research skipped`);444    return [];445  }446  const shared = dir.files.map((source) => {447    const file = source.slice(dir.path.length + 1);448    const name = file.slice(0, -3);449    const md = read(source);450    const figure = SHARED.has(name) ? "research-index" : `research-${name}`;451    return { file, name, md, home: file === "README.md", topic: false, title: title(md) || name, lead: summary(md), figure };452  });453  const home = site.input("notes");454  const topics = home.files.map((source) => {455    const name = source.slice(home.path.length + 1, -3);456    const { data, body } = front(read(source));457    return { file: `notes/${name}.md`, name, md: body, home: false, topic: true, title: data.title ?? name, lead: data.lead ?? summary(body), figure: data.figure || `research-${name}` };458  });459  return [...shared, ...topics].sort((a, b) => (a.home ? -1 : b.home ? 1 : a.name.localeCompare(b.name)));460}461462function researchIndex(site: Site, route: Route): Output[] {463  const { note: n } = route.data as { note: Note; cards: string[][] };464  const out: Output[] = [];465  const fig = press(site, out);466  out.push({ path: `research/${n.file}`, bytes: n.md });467  const lead = summary(n.md);468  const prose = md(n.md.replace(/^# .+\n/, ""), { math, link: links(site, join(site.input("research").path, n.file), out) });469  const body = `<div class="lede"><h1 id="research">Research</h1><p class="lead">${escape(lead)}</p></div>\n${grid(wear(site, "/research/", fig, route.route))}\n<article class="prose readme">${prose}</article>`;470  out.push({ path: "research/index.html", bytes: shell(site, { route: route.route, name: "Research", description: lead, body, type: "website", wide: true, bare: true, image: picture(site, "site-research", route.route, fig) }) });471  return out;472}473474function note(site: Site, route: Route): Output[] {475  const n = route.data as Note;476  const out: Output[] = [];477  const fig = press(site, out);478  out.push({ path: `research/${n.file}`, bytes: n.md });479  const name = n.title;480  const lead = n.lead;481  const head = n.topic ? `<h1 id="${escape(n.name)}">${escape(name)}</h1>\n` : "";482  const used = new Set<string>();483  const prose = md(n.md, { math, link: links(site, join(site.input("research").path, n.file), out), widget: widgets(site, used) });484  const body = `${hero(fig, n.figure, route.route, name)}\n${head}${n.name === "sequences" ? anchored(prose) : prose}`;485  const data = {486    "@context": "https://schema.org",487    "@type": "Article",488    headline: name,489    description: lead,490    url: root + route.route,491    image: `${root}/figures/${n.figure}-dark.png`,492    author: { "@type": "Organization", name: AUTHOR },493  };494  const at = n.home ? "research/index.html" : `research/${n.name}/index.html`;495  const scripts = [...used].sort().map((name) => `/demos/${name}/widget.js`);496  out.push({ path: at, bytes: shell(site, { route: route.route, name, description: lead, body, type: n.home ? "website" : "article", data, image: picture(site, n.figure, route.route, fig), scripts }) });497  return out;498}499500/* CLAIMS */501502type Claim = { slug: string; title: string; md: string; file: string };503504const TAGS = ["Proved", "Verified", "Conjecture", "Refuted"];505const LINE = /<li>(\d{4}-\d{2}-\d{2}) \[(Proved|Verified|Conjecture|Refuted)\] /g;506507function claims(site: Site): Claim[] {508  const home = site.input("claims");509  return home.files510    .map((file) => {511      const slug = file.slice(home.path.length + 1, -3);512      const md = read(file);513      return { slug, title: title(md) || slug, md, file };514    })515    .sort((a, b) => a.title.localeCompare(b.title));516}517518function discoveries(site: Site, route: Route): Output[] {519  const list = route.data as Claim[];520  const out: Output[] = [];521  const fig = press(site, out);522  const lead = "Every claim of the tree on one dated, tagged line with its witness, one section per topic, filtered by tag, topic and date.";523  const counts = new Map<string, number>(TAGS.map((tag) => [tag, 0]));524  const sections = list.map((c) => {525    const html = md(c.md.replace(/^# .+\n/, ""), { math, link: links(site, c.file, out) }).replace(LINE, (_, date: string, tag: string) => {526      counts.set(tag, (counts.get(tag) ?? 0) + 1);527      return `<li data-date="${date}" data-tag="${tag.toLowerCase()}"><time>${date}</time> <b class="tag ${tag.toLowerCase()}">${tag}</b> `;528    });529    return `<section class="claims" id="${escape(c.slug)}" data-slug="${escape(c.slug)}"><h2 id="${escape(c.slug)}-claims">${escape(c.title)}</h2>${html}</section>`;530  });531  const total = [...counts.values()].reduce((a, b) => a + b, 0);532  const chips = ["", ...TAGS].map((tag) => `<button type="button" data-tag="${tag.toLowerCase()}"${tag ? "" : ' class="on"'}>${tag || "All"} <span>${tag ? counts.get(tag) : total}</span></button>`).join("");533  const topics = `<select aria-label="Topic"><option value="">Every topic</option>${list.map((c) => `<option value="${escape(c.slug)}">${escape(c.title)}</option>`).join("")}</select>`;534  const since = `<label>Since <input type="date" aria-label="Since"></label>`;535  const bar = `<form class="filter" onsubmit="return false">${chips}${topics}${since}<output>${total} claims</output></form>`;536  const script = `<script>(()=>{const f=document.querySelector("form.filter"),b=[...f.querySelectorAll("button")],s=f.querySelector("select"),d=f.querySelector("input"),o=f.querySelector("output"),secs=[...document.querySelectorAll("section.claims")];let tag="";const run=()=>{let n=0;for(const sec of secs){let k=0;for(const li of sec.querySelectorAll("li[data-tag]")){const on=(!tag||li.dataset.tag===tag)&&(!s.value||sec.dataset.slug===s.value)&&(!d.value||li.dataset.date>=d.value);li.hidden=!on;if(on)k++}sec.hidden=!k;n+=k}o.textContent=n+" claims"};for(const x of b)x.addEventListener("click",()=>{tag=x.dataset.tag;for(const y of b)y.classList.toggle("on",y===x);run()});s.addEventListener("change",run);d.addEventListener("input",run)})()</script>`;537  const body = `${hero(fig, "research-index", route.route, "Discoveries")}\n<h1 id="discoveries">Discoveries</h1><p class="lead">${escape(lead)}</p>\n${bar}\n${sections.join("\n")}\n${script}`;538  const data = {539    "@context": "https://schema.org",540    "@type": "Article",541    headline: "Discoveries",542    description: lead,543    url: root + route.route,544    image: `${root}/figures/research-index-dark.png`,545    author: { "@type": "Organization", name: AUTHOR },546  };547  out.push({ path: "research/discoveries/index.html", bytes: shell(site, { route: route.route, name: "Discoveries", description: lead, body, data, image: picture(site, "research-index", route.route, fig) }) });548  return out;549}550551/* BLOG */552553type Post = { slug: string; name: string; date: string; lead: string; figure: string; body: string };554555function posts(): Post[] {556  if (!existsSync(BLOG)) return [];557  return readdirSync(BLOG)558    .filter((f) => f.endsWith(".md"))559    .map((file) => {560      const { data, body } = front(read(join(BLOG, file)));561      const slug = file.slice(0, -3);562      return { slug, name: data.title ?? slug, date: data.date ?? "", lead: data.lead ?? summary(body), figure: data.figure || `blog-${slug}`, body };563    })564    .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : a.slug < b.slug ? 1 : -1));565}566567function post(site: Site, route: Route): Output[] {568  const p = route.data as Post;569  const out: Output[] = [];570  const fig = press(site, out);571  const head = `${hero(fig, p.figure, route.route, p.name)}\n<div class="plate"><h1 id="${escape(p.slug)}">${escape(p.name)}</h1><p class="by">${escape(p.date)} · ${escape(AUTHOR)}</p></div>`;572  const data = {573    "@context": "https://schema.org",574    "@type": "BlogPosting",575    headline: p.name,576    description: p.lead,577    url: root + route.route,578    image: `${root}/figures/${p.figure}-dark.png`,579    author: { "@type": "Organization", name: AUTHOR },580    datePublished: p.date || undefined,581  };582  out.push({ path: `blog/${p.slug}/index.html`, bytes: shell(site, { route: route.route, name: p.name, description: p.lead, body: `${head}\n${md(p.body, { math, link: links(site, postFile(p.slug), out) })}`, data, image: picture(site, p.figure, route.route, fig) }) });583  return out;584}585586function blogIndex(site: Site, route: Route): Output[] {587  const out: Output[] = [];588  const fig = press(site, out);589  const lead = "Notes on what lands on this site and in the crates behind it.";590  const body = `<div class="lede"><h1 id="blog">Blog</h1><p class="lead">${escape(lead)}</p></div>\n${grid(wear(site, "/blog/", fig, route.route))}`;591  out.push({ path: "blog/index.html", bytes: shell(site, { route: route.route, name: "Blog", description: lead, body, type: "website", wide: true, bare: true }) });592  return out;593}594595/* PAGES */596597function page(site: Site, route: Route): Output[] {598  const source = route.source as string;599  const slug = route.route.slice(1, -1);600  const { data, body } = front(read(source));601  const name = data.title ?? slug;602  const lead = data.lead ?? summary(body);603  const out: Output[] = [];604  const fig = press(site, out);605  const open = data.figure ? `${hero(fig, data.figure, route.route, name)}\n` : "";606  const head = `<div class="lede"><h1 id="${escape(slug)}">${escape(name)}</h1><p class="lead">${escape(lead)}</p></div>`;607  const act = data.button && data.link ? `\n<p><a class="button primary" href="${escape(data.link)}">${escape(data.button)}</a></p>` : "";608  const own = data.figure || FIXED[route.route];609  const image = own ? picture(site, own, route.route, fig) : OG;610  const html = shell(site, { route: route.route, name, description: lead, body: `${open}${head}\n${md(body, { math, link: links(site, source, out) })}${act}`, type: "website", image });611  out.push({ path: `${slug}/index.html`, bytes: html });612  return out;613}614615type Dress = { lanes: Lane[]; papers: Paper[]; notes: Note[]; posts: Post[]; demos: Card[]; wiki: Entry[] };616617type Mark = { figure: string; text: string; dates?: string[] };618619let DRESS: Dress = { lanes: [], papers: [], notes: [], posts: [], demos: [], wiki: [] };620621const FIXED: Record<string, string> = {622  "/": "site-home",623  "/wiki/": "site-wiki",624  "/book/": "site-wiki",625  "/tools/": "site-tools",626  "/math/": "site-math",627  "/git/": "site-code",628  "/about/": "site-icon",629  "/contact/": "site-contact",630  "/donate/": "site-donate",631};632633function marks(data: Dress): Map<string, Mark> {634  const map = new Map<string, Mark>();635  for (const d of data.demos) if (d.name) map.set(demoRoute(d.name), { figure: `demo-${d.name}`, text: d.blurb });636  for (const p of data.papers) map.set(`/papers/${p.slug}/`, { figure: p.figure, text: p.lead, dates: marked(p) });637  for (const p of data.lanes) map.set(`/papers/${p.slug}/`, { figure: `paper-${p.slug}`, text: p.blurb, dates: stamps(p) });638  for (const n of data.notes) {639    if (n.home) continue;640    map.set(`/research/${n.name}/`, { figure: n.figure, text: n.lead });641  }642  for (const p of data.posts) map.set(`/blog/${p.slug}/`, { figure: p.figure, text: p.lead, dates: [p.date] });643  for (const e of data.wiki) map.set(wikiRoute(e.slug), { figure: e.figure, text: e.lead });644  for (const [href, figure] of Object.entries(FIXED)) map.set(href, { figure, text: "" });645  return map;646}647648function dress(nodes: Node[], map: Map<string, Mark>, fig: Fig, route: string): Node[] {649  return nodes.map((node) => {650    if (node.nodes?.length) return { ...node, nodes: dress(node.nodes, map, fig, route) };651    const mark = node.href ? map.get(node.href) : undefined;652    if (!mark) return node;653    return { ...node, figure: fig(mark.figure, route), text: mark.text || undefined, dates: mark.dates };654  });655}656657let NAV: Node[] = [];658659const wear = (site: Site, href: string, fig: Fig, route: string) => dress(NAV.find((node) => node.href === href)?.nodes ?? [], marks(DRESS), fig, route);660661function elsewhere() {662  const links = SITE.socials.map((s) => `<li><a href="${escape(s.href)}">${escape(s.name)}</a></li>`).join("");663  const mail = `<li><a href="mailto:${escape(SITE.contact)}">${escape(SITE.contact)}</a></li>`;664  return `<section class="elsewhere"><h2>Elsewhere</h2><ul>${links}${mail}</ul></section>`;665}666667function menu(site: Site, route: Route): Output[] {668  const lead = "Every page on mrly.net.";669  const out: Output[] = [];670  const fig = press(site, out);671  const nav = dress(NAV, marks(route.data as Dress), fig, route.route);672  const list = renderToStaticMarkup(h(Menu, { tree: nav }));673  const body = `<div class="hero"><h1><span role="img" aria-label="${escape(SITE.title)}">${WORD}</span></h1><p>${escape(lead)}</p></div>\n${list}\n${elsewhere()}`;674  out.push({ path: "menu/index.html", bytes: shell(site, { route: route.route, name: "Menu", description: lead, body, type: "website", wide: true, bare: true }) });675  return out;676}677678function cart(site: Site, route: Route): Output[] {679  const lead = "Coming soon.";680  const body = `<div class="lede"><h1 id="cart">Cart</h1><p class="lead">${escape(lead)}</p></div>\n<p>mrly.net has no shop yet.</p>\n<p><a href="/">Back to the home page</a>.</p>`;681  return [{ path: "cart/index.html", bytes: shell(site, { route: route.route, name: "Cart", description: lead, body, type: "website" }) }];682}683684const MISSION = SITE.tagline;685686const DOORS = [687  { name: "Wiki", href: "/wiki/", figure: "site-wiki", text: "One concept per page, in the order you need them, for a reader with school mathematics." },688  { name: "Demos", href: "/demos/", figure: "site-demos", text: "Browser pages that draw a design and the numbers around it, live." },689  { name: "Discoveries", href: "/research/discoveries/", figure: "research-index", text: "Every claim of the tree on one dated, tagged line with its witness." },690  { name: "Papers", href: "/papers/", figure: "site-papers", text: "Write-ups that print as papers, every claim tagged and every number generated." },691  { name: "Research", href: "/research/", figure: "site-research", text: "The working notes behind the demos and the papers, one page per idea." },692];693694type Newest = { date: string; tag: string; text: string; slug: string; file: string };695696function newest(list: Claim[]): Newest | null {697  let best: Newest | null = null;698  for (const c of list) {699    for (const line of c.md.split("\n")) {700      const hit = line.match(/^- (\d{4}-\d{2}-\d{2}) \[(Proved|Verified|Conjecture|Refuted)\] (.+)$/);701      if (hit && (!best || hit[1]! > best.date)) best = { date: hit[1]!, tag: hit[2]!, text: hit[3]!, slug: c.slug, file: c.file };702    }703  }704  return best;705}706707function home(site: Site, route: Route): Output[] {708  const { lanes: list, papers: fresh, posts: posted, claim } = route.data as { lanes: Lane[]; papers: Paper[]; posts: Post[]; claim: Newest | null };709  const out: Output[] = [];710  const fig = press(site, out);711  const latestClaim = claim712    ? `<section><h2 id="newest">Newest claim</h2><p class="lead"><time>${claim.date}</time> <b class="chip ${claim.tag.toLowerCase()}">${claim.tag}</b> ${inline(claim.text, { math, link: links(site, claim.file, out) })}</p><p class="lead"><a href="/research/discoveries/#${escape(claim.slug)}">Every claim, dated and tagged</a></p></section>`713    : "";714  const latest = [...fresh.map((p) => ({ name: p.name, slug: p.slug, at: p.revised || p.date })), ...list.map((p) => ({ name: p.name, slug: p.slug, at: dated(p) }))]715    .map((p, i) => ({ p, i }))716    .sort((a, b) => (a.p.at < b.p.at ? 1 : a.p.at > b.p.at ? -1 : a.i - b.i))717    .slice(0, 3)718    .map(({ p }) => ({ name: p.name, href: `/papers/${p.slug}/` }));719  const doors = DOORS.map((d) => ({ name: d.name, href: d.href, figure: fig(d.figure, "/"), text: d.text }));720  const first = posted[0];721  const news = first722    ? `<section><h2 id="latest">From the blog</h2><p class="lead"><a href="/blog/${first.slug}/">${escape(first.name)}</a> · ${escape(first.date)}</p><p class="lead">${escape(first.lead)}</p></section>`723    : "";724  const what = `<section class="what"><h2 id="mrlymath">What is MrlyMath</h2><p>A design is a rule on the corners of a cube: a code says which of the eight corners are filled. The Kronecker product grows that rule into itself, level by level, and the object it converges to is a fractal - the Sierpinski carpet and the Menger sponge are two of them.</p><p>Everything else is measurement. Count the fills, the voids and the exposed faces; cut the solid with a plane; join the filled cells into a graph and read its spectrum; collect the integer sequences the counts write down. The Rust crates do the arithmetic, the browser only paints, and a claim is either proved, checked over a stated finite domain, or labelled a conjecture.</p></section>`;725  const body = `<div class="home">726${hero(fig, "site-home", "/", SITE.title)}727<div class="hero"><h1><span role="img" aria-label="${escape(SITE.title)}">${WORD}</span></h1><p>${escape(MISSION)}</p></div>728<section><h2 id="doors">Five doors</h2>${grid(doors)}</section>729${latestClaim}730<section><h2 id="shelf">Latest papers</h2>${grid(dress(latest, marks(DRESS), fig, "/"))}</section>731${news}732${what}733</div>`;734  out.push({ path: "index.html", bytes: shell(site, { route: "/", name: SITE.title, description: MISSION, body, type: "website", wide: true, bare: true, image: picture(site, "site-home", "/", fig) }) });735  return out;736}737738function missing(site: Site, route: Route): Output[] {739  const doors = DOORS.map((d) => `<a href="${d.href}">${d.name}</a>`);740  const body = `<div class="lede"><h1 id="lost">Nothing here</h1><p class="lead">That page does not exist. The <a href="/menu/">Menu</a> lists every page on this site, and the doors are ${doors.slice(0, -1).join(", ")} and ${doors[doors.length - 1]}.</p></div>`;741  return [{ path: "404.html", bytes: shell(site, { route: route.route, name: "Nothing here", description: "That page does not exist.", body, type: "website", bare: true }) }];742}743744/* THIN */745746const THIN: Record<string, { name: string; figure: string; lead: string; note: string }> = {747  "/tools/": {748    name: "Tools",749    figure: "site-tools",750    lead: "A canvas for mrly objects: tiles, slices, rings and roulettes on one sheet, the perforator first.",751    note: `The first tool is on its way. The <a href="/demos/">demos</a> already draw every object it will place, and <a href="/math/">the standard</a> names them.`,752  },753};754755function thin(site: Site, route: Route): Output[] {756  const t = THIN[route.route]!;757  const out: Output[] = [];758  const fig = press(site, out);759  const slug = route.route.slice(1, -1);760  const body = `${hero(fig, t.figure, route.route, t.name)}\n<div class="lede"><h1 id="${slug}">${t.name}</h1><p class="lead">${escape(t.lead)}</p></div>\n<p>${t.note}</p>`;761  out.push({ path: `${slug}/index.html`, bytes: shell(site, { route: route.route, name: t.name, description: t.lead, body, type: "website", image: picture(site, t.figure, route.route, fig) }) });762  return out;763}764765/* WIKI */766767type Entry = { slug: string; name: string; lead: string; figure: string; needs: string[]; body: string; file: string };768769type Concept = Omit<Entry, "body" | "file"> & { before: { slug: string; name: string }[]; after: { slug: string; name: string }[] };770771const WIKI = "One concept per page, in the order you need them, for a reader with school mathematics.";772773function ordered(list: Entry[]): Entry[] {774  const byslug = new Map(list.map((e) => [e.slug, e]));775  const done = new Set<string>();776  const out: Entry[] = [];777  const pending = [...list].sort((a, b) => a.slug.localeCompare(b.slug));778  while (pending.length) {779    const at = pending.findIndex((e) => e.needs.every((need) => done.has(need)));780    if (at < 0) throw new Error(`site: the wiki prerequisites run in a circle through ${pending.map((e) => e.slug).join(", ")}`);781    const [next] = pending.splice(at, 1);782    done.add(next!.slug);783    out.push(byslug.get(next!.slug)!);784  }785  const deep = depths(out);786  return out.sort((a, b) => deep.get(a.slug)! - deep.get(b.slug)! || a.slug.localeCompare(b.slug));787}788789function wiki(site: Site): Entry[] {790  const home = site.input("wiki");791  if (home.missing) return [];792  const list = home.files.map((file) => {793    const slug = file.slice(home.path.length + 1, -3);794    const { data, body } = front(read(file));795    const needs = (data.prerequisites ?? "").split(",").map((s: string) => s.trim()).filter(Boolean);796    return { slug, name: data.title ?? slug, lead: data.lead ?? summary(body), figure: data.figure || `wiki-${slug}`, needs, body, file };797  });798  const known = new Set(list.map((e) => e.slug));799  for (const e of list) for (const need of e.needs) if (!known.has(need)) throw new Error(`site: wiki/${e.slug}.md needs ${need}, and wiki/${need}.md does not exist`);800  return ordered(list);801}802803const wikiRoute = (slug: string) => `/wiki/${slug}/`;804805function concepts(list: Entry[]): [Concept, string][] {806  const name = (slug: string) => ({ slug, name: list.find((e) => e.slug === slug)?.name ?? slug });807  return list.map(({ body: _, file, ...e }) => [{ ...e, before: e.needs.map(name), after: list.filter((o) => o.needs.includes(e.slug)).map((o) => name(o.slug)) }, file]);808}809810const cite = (rows: { slug: string; name: string }[]) => rows.map((r) => `<a href="${wikiRoute(r.slug)}">${escape(r.name)}</a>`).join(", ");811812/* WIDGETS */813814const EMBEDS = /^!\[[^\]]*\]\(demos\/([a-z0-9-]+)\/[a-z0-9-]+\)$/gm;815816const VIEW = (view: string) => new RegExp(`^export (?:function|const) ${view}\\b`, "m");817818const widgetFile = (site: Site, name: string) => join(site.input("demos").path, name, "widget.jsx");819820const embeds = (site: Site, body: string) => [...body.matchAll(EMBEDS)].map((m) => widgetFile(site, m[1]!));821822function widgets(site: Site, used: Set<string>) {823  return (name: string, view: string, caption: string) => {824    const file = widgetFile(site, name);825    if (!existsSync(file)) throw new Error(`site: demos/${name}/ has no widget.jsx to embed`);826    if (!VIEW(view).test(read(file))) throw new Error(`site: demos/${name}/widget.jsx exports no view named ${view}`);827    used.add(name);828    return `<figure class="widget" data-demo="${name}" data-view="${view}"><div class="mount"></div><figcaption>${caption}</figcaption></figure>`;829  };830}831832function concept(site: Site, route: Route): Output[] {833  const c = route.data as Concept;834  const file = route.source as string;835  const out: Output[] = [];836  const fig = press(site, out);837  const used = new Set<string>();838  const before = c.before.length ? `\n<p class="meta">Before this: ${cite(c.before)}.</p>` : "";839  const after = c.after.length ? `\n<section><h2 id="next">Read next</h2><p>${cite(c.after)}.</p></section>` : "";840  const head = `<div class="lede"><h1 id="${escape(c.slug)}">${escape(c.name)}</h1><p class="lead">${escape(c.lead)}</p></div>`;841  const prose = md(front(read(file)).body, { math, link: links(site, file, out), widget: widgets(site, used) });842  const body = `${hero(fig, c.figure, route.route, c.name)}\n${head}${before}\n${prose}${after}`;843  const data = {844    "@context": "https://schema.org",845    "@type": "Article",846    headline: c.name,847    description: c.lead,848    url: root + route.route,849    image: `${root}/figures/${c.figure}-dark.png`,850    author: { "@type": "Organization", name: AUTHOR },851  };852  const scripts = [...used].sort().map((name) => `/demos/${name}/widget.js`);853  out.push({ path: `wiki/${c.slug}/index.html`, bytes: shell(site, { route: route.route, name: c.name, description: c.lead, body, data, image: picture(site, c.figure, route.route, fig), scripts }) });854  return out;855}856857type Leaf_ = { slug: string; name: string; lead: string; figure: string; needs: string[] };858859const STEPS = ["Start here", "One step in", "Two steps in", "Three steps in", "Four steps in", "Five steps in", "Six steps in"];860861const step = (n: number) => STEPS[n] ?? `${n} steps in`;862863function depths(list: Leaf_[]): Map<string, number> {864  const deep = new Map<string, number>();865  for (const e of list) deep.set(e.slug, e.needs.length ? 1 + Math.max(...e.needs.map((need) => deep.get(need) ?? 0)) : 0);866  return deep;867}868869function tile(fig: Fig, route: string, e: Leaf_, names: Map<string, string>) {870  const pair = fig(e.figure, route);871  const needs = e.needs.length ? `<p class="needs">After ${e.needs.map((need) => `<a href="${wikiRoute(need)}">${escape(names.get(need) ?? need)}</a>`).join(", ")}</p>` : "";872  const img = SIDES.map((side) => `<img class="${side}" src="${pair[side]}" alt="" width="1024" height="1024" loading="lazy" decoding="async">`).join("");873  return `<div class="tile"><a href="${wikiRoute(e.slug)}">${img}<h2>${escape(e.name)}</h2><p>${escape(e.lead)}</p></a>${needs}</div>`;874}875876function wikiIndex(site: Site, route: Route): Output[] {877  const list = (route.data as [string, string, string, string, string[]][]).map(([slug, name, lead, figure, needs]) => ({ slug, name, lead, figure, needs }));878  const out: Output[] = [];879  const fig = press(site, out);880  const names = new Map(list.map((e) => [e.slug, e.name]));881  const deep = depths(list);882  const rows = new Map<number, Leaf_[]>();883  for (const e of list) rows.set(deep.get(e.slug)!, [...(rows.get(deep.get(e.slug)!) ?? []), e]);884  const sections = [...rows.keys()].sort((a, b) => a - b).map((n) => `<section><h2 id="step-${n}">${step(n)}</h2><div class="gallery grid graph">${rows.get(n)!.map((e) => tile(fig, route.route, e, names)).join("")}</div></section>`);885  const book = list.length ? `<p class="lead">Every page needs only the pages above it, and <a href="/book/">the book</a> reads them all in that order on one page.</p>` : "";886  const body = `${hero(fig, "site-wiki", route.route, "Wiki")}\n<div class="lede"><h1 id="wiki">Wiki</h1><p class="lead">${escape(WIKI)}</p>${book}</div>\n${sections.join("\n")}`;887  out.push({ path: "wiki/index.html", bytes: shell(site, { route: route.route, name: "Wiki", description: WIKI, body, type: "website", wide: true, bare: true, image: picture(site, "site-wiki", route.route, fig) }) });888  return out;889}890891/* BOOK */892893const BOOK = "The wiki read in prerequisite order as one page: every concept once, each after the ones it needs.";894895function book(site: Site, route: Route): Output[] {896  const list = (route.data as [string, string, string, string, string[]][]).map(([slug, name, lead, figure, needs]) => ({ slug, name, lead, figure, needs }));897  const home = site.input("wiki").path;898  const out: Output[] = [];899  const fig = press(site, out);900  const used = new Set<string>();901  const names = new Map(list.map((e) => [e.slug, e.name]));902  const chapters = list.map((e) => {903    const file = join(home, `${e.slug}.md`);904    const prose = md(front(read(file)).body, { math, link: links(site, file, out), widget: widgets(site, used) }).replace(/<h([23]) id="([^"]+)"/g, (_, n: string, id: string) => `<h${Number(n) + 1} id="${escape(e.slug)}-${id}"`);905    const needs = e.needs.length ? `<p class="meta">After ${e.needs.map((need) => `<a href="#${need}">${escape(names.get(need) ?? need)}</a>`).join(", ")}.</p>` : "";906    return `<section class="chapter">${hero(fig, e.figure, route.route, e.name)}\n<h2 id="${escape(e.slug)}">${escape(e.name)}</h2><p class="lead">${escape(e.lead)}</p>${needs}\n${prose}\n<p class="meta"><a href="${wikiRoute(e.slug)}">This page on its own</a></p></section>`;907  });908  const body = `<div class="lede"><h1 id="book">The book</h1><p class="lead">${escape(BOOK)}</p></div>\n${chapters.join("\n")}`;909  const data = {910    "@context": "https://schema.org",911    "@type": "Book",912    name: "The book",913    description: BOOK,914    url: root + route.route,915    image: `${root}/figures/site-wiki-dark.png`,916    author: { "@type": "Organization", name: AUTHOR },917  };918  const scripts = [...used].sort().map((name) => `/demos/${name}/widget.js`);919  out.push({ path: "book/index.html", bytes: shell(site, { route: route.route, name: "The book", description: BOOK, body, data, image: picture(site, "site-wiki", route.route, fig), scripts }) });920  return out;921}922923/* MATH */924925const STANDARD = "A design is one string: one JSON object per named thing, and every other name a view cut from it.";926927function standard(site: Site, route: Route): Output[] {928  const out: Output[] = [];929  const fig = press(site, out);930  const file = route.source as string;931  const body = `${hero(fig, "site-math", route.route, "MrlyMath")}\n<h1 id="math">MrlyMath</h1><p class="lead">${escape(STANDARD)}</p>\n${md(read(file).replace(/^# .+\n/, ""), { math, link: links(site, file, out) })}`;932  out.push({ path: "math/index.html", bytes: shell(site, { route: route.route, name: "Math", description: STANDARD, body, type: "website", image: picture(site, "site-math", route.route, fig) }) });933  return out;934}935936/* COLLECT */937938const counts = { papers: 0, research: 0, blog: 0, demos: 0, wiki: 0 };939940async function collect(site: Site) {941  SHELF = await shelf();942  const paperList = papers(site);943  const fresh = new Set(paperList.map((p) => p.slug));944  const laneList = lanes().filter((p) => !fresh.has(p.slug));945  const noteList = notes(site);946  const postList = posts();947  const group = demoGroup(site);948  const demoList = group.data as Card[];949  const claimList = claims(site);950  const wikiList = wiki(site);951  readers(demoList, [952    ...noteList.filter((n) => !n.home).map((n) => ({ name: n.title, href: `/research/${n.name}/`, md: n.md })),953    ...paperList.map((p) => ({ name: p.name, href: `/papers/${p.slug}/`, md: p.body })),954    ...wikiList.map((e) => ({ name: e.name, href: wikiRoute(e.slug), md: e.body })),955  ]);956  counts.papers = laneList.length + paperList.length;957  counts.research = noteList.length;958  counts.blog = postList.length;959  counts.demos = demoList.length;960  counts.wiki = wikiList.length;961  const lists = {962    wiki: wikiList.map((e) => ({ name: e.name, href: wikiRoute(e.slug) })),963    demos: shelved(demoList),964    papers: [...paperList, ...laneList].map((p) => ({ name: p.name, href: `/papers/${p.slug}/` })),965    research: [...(claimList.length ? [{ name: "Discoveries", href: "/research/discoveries/" }] : []), ...noteList.filter((n) => !n.home).map((n) => ({ name: n.title, href: `/research/${n.name}/` }))],966    blog: postList.map((p) => ({ name: p.name, href: `/blog/${p.slug}/` })),967  };968  NAV = tree(lists);969  const nav = sidebar(lists);970  const routes: Route[] = [];971  const readme = join(org, "README.md");972  routes.push({973    route: "/",974    kind: "home",975    name: SITE.title,976    data: { lanes: laneList, papers: paperList, posts: postList, claim: newest(claimList) },977    source: readme,978    inputs: [readme],979  });980  for (const route of Object.keys(THIN)) routes.push({ route, kind: "thin", name: THIN[route]!.name });981  const shelfOfWiki = site.input("wiki");982  routes.push({ route: "/wiki/", kind: "wiki", name: "Wiki", data: wikiList.map((e) => [e.slug, e.name, e.lead, e.figure, e.needs]), source: shelfOfWiki.path, inputs: shelfOfWiki.missing ? [] : wikiList.map((e) => e.file) });983  for (const [c, file] of concepts(wikiList)) {984    routes.push({ route: wikiRoute(c.slug), kind: "concept", name: c.name, data: c, source: file, inputs: [file, ...embeds(site, read(file))] });985  }986  if (wikiList.length) {987    const pages = wikiList.map((e) => [e.slug, e.name, e.lead, e.figure, e.needs]);988    routes.push({ route: "/book/", kind: "book", name: "The book", data: pages, inputs: wikiList.flatMap((e) => [e.file, ...embeds(site, e.body)]) });989  }990  const names = site.input("names");991  if (!names.missing) routes.push({ route: "/math/", kind: "math", name: "Math", source: names.files[0]!, inputs: names.files });992  routes.push(group);993  if (laneList.length || paperList.length) {994    const index = join(SHELF, "README.md");995    routes.push({ route: "/papers/", kind: "papers", name: "Papers", data: { lanes: laneList, papers: paperList.map((p) => [p.slug, p.name, p.lead, p.date, p.revised, p.figure]) }, source: index, inputs: [index, ...paperList.map((p) => p.file)] });996    for (const p of paperList) {997      routes.push({ route: `/papers/${p.slug}/`, kind: "written", name: p.name, data: p, source: p.file, inputs: [p.file] });998    }999    for (const p of laneList) {1000      const lane = join(SHELF, p.slug);1001      routes.push({ route: `/papers/${p.slug}/`, kind: "paper", name: p.name, data: p, source: lane, inputs: [lane] });1002    }1003  }1004  const notesHome = site.input("research").path;1005  if (claimList.length) {1006    routes.push({1007      route: "/research/discoveries/",1008      kind: "discoveries",1009      name: "Discoveries",1010      data: claimList.map((c) => c.slug),1011      source: site.input("claims").path,1012      inputs: claimList.map((c) => c.file),1013    });1014  }1015  for (const n of noteList) {1016    const source = join(notesHome, n.file);1017    routes.push({1018      route: n.home ? "/research/" : `/research/${n.name}/`,1019      kind: n.home ? "research" : "note",1020      name: n.home ? "Research" : n.title,1021      data: n.home ? { note: n, cards: noteList.map((one) => [one.name, one.title, one.lead, one.figure]) } : n,1022      source,1023      inputs: [source, ...embeds(site, n.md)],1024    });1025  }1026  if (postList.length) {1027    const files = postList.map((p) => postFile(p.slug));1028    routes.push({ route: "/blog/", kind: "blog", name: "Blog", data: postList, source: BLOG, inputs: files });1029    for (const p of postList) {1030      const source = postFile(p.slug);1031      routes.push({ route: `/blog/${p.slug}/`, kind: "post", name: p.name, data: p, source, inputs: [source] });1032    }1033  }1034  const written = site.input("pages");1035  for (const source of written.files) {1036    const slug = source.slice(written.path.length + 1, -3);1037    const { data } = front(read(source));1038    routes.push({ route: `/${slug}/`, kind: "page", name: data.title ?? slug, source, inputs: [source] });1039  }1040  DRESS = { lanes: laneList, papers: paperList, notes: noteList, posts: postList, demos: demoList, wiki: wikiList };1041  routes.push({1042    route: "/menu/",1043    kind: "menu",1044    name: "Menu",1045    data: DRESS,1046  });1047  routes.push({ route: "/cart/", kind: "cart", name: "Cart", hidden: true });1048  routes.push({ route: "/404.html", kind: "missing", name: "Nothing here", hidden: true });1049  return { routes, nav };1050}10511052/* RENDER */10531054const KINDS: Record<string, (site: Site, route: Route) => Output[] | Promise<Output[]>> = {1055  home,1056  demos,1057  papers: paperIndex,1058  paper,1059  written,1060  note,1061  research: researchIndex,1062  discoveries,1063  blog: blogIndex,1064  post,1065  page,1066  menu,1067  cart,1068  missing,1069  thin,1070  math: standard,1071  wiki: wikiIndex,1072  concept,1073  book,1074};10751076function draw(site: Site, route: Route) {1077  const fn = KINDS[route.kind ?? ""];1078  if (!fn) throw new Error(`site: no template for ${route.route}`);1079  if (route.kind === "discoveries") return fn(site, { ...route, data: claims(site) });1080  return fn(site, route);1081}10821083/* EXTRAS */10841085function extras(site: Site): Output[] {1086  const out: Output[] = [];1087  const home = site.input("figures").path;1088  out.push({ path: "og.png", bytes: bytes(figure(home, "site-og-dark", "/og.png")) });1089  return out;1090}10911092/* SPEC */10931094export const MANIFEST = process.env.MRLY_DIST ? join(dist, ".manifest.json") : ".cache/manifest.json";10951096export const counted = () => ({ ...counts });10971098export const spec: Spec = {1099  root: org,1100  out: dist,1101  templates: ["lib", "scripts"],1102  collect,1103  render: draw,1104  globals: extras,1105  git: {1106    page: shell,1107    md: (site, text, from) => md(text, { math, link: links(site, from) }),1108  },1109};11101111if (import.meta.main) {1112  const done = await build(spec, { manifest: MANIFEST });1113  const site = done.site;1114  const code = site.routes.filter(isGit).length;1115  console.log(1116    `site: ${site.routes.length} routes, ${counts.demos} demo shells, ${counts.papers} papers, ${counts.research} research pages, ${counts.blog} posts, ${code} code pages, ${done.rendered} rendered, ${done.written} files written, ${done.removed} removed`,1117  );1118}