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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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}