The page was generated by a one-off script that no longer exists, so a text change meant rebuilding the tooling first. build/build.mjs turns whitepaper.md into index.html byte-for-byte as published; the page chrome and the two diagrams are checked in beside it (the folder needs an exception from the generic build/ ignore rule). README.md records the publishing steps for both mirrors and the editorial decisions the text follows.
95 lines
4.7 KiB
JavaScript
95 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// Builds ../index.html (the theseus.x whitepaper page) from ../whitepaper.md.
|
|
//
|
|
// node build/build.mjs # write ../index.html
|
|
// node build/build.mjs --check # exit 1 if ../index.html is not what the build produces
|
|
//
|
|
// whitepaper.md is the Markdown export of the editable document (see ../README.md).
|
|
// The page chrome lives in template.html; the two diagrams are the figure-*.html
|
|
// fragments and are placed where the export left its "[embedded content: <name> · …]"
|
|
// marker. Markdown handled: #/## headings, paragraphs, "- " and "1. " lists, pipe
|
|
// tables, **bold**, `code`, [text](url), backslash escapes. Each paragraph is one line.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.join(here, "..");
|
|
const md = fs.readFileSync(path.join(root, "whitepaper.md"), "utf8").replace(/\r\n/g, "\n");
|
|
const template = fs.readFileSync(path.join(here, "template.html"), "utf8");
|
|
const figure = (name) => {
|
|
const f = path.join(here, `figure-${name}.html`);
|
|
if (!fs.existsSync(f)) throw new Error(`no figure fragment for "${name}" (expected ${f})`);
|
|
return fs.readFileSync(f, "utf8").replace(/\n$/, "");
|
|
};
|
|
|
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
const unescapeMd = (s) => s.replace(/\\([\\`*_{}\[\]()#+\-.!|>~"'])/g, "$1");
|
|
|
|
// Inline Markdown → HTML. Code spans are protected first so nothing inside them is touched.
|
|
function inline(src) {
|
|
const codes = [];
|
|
let s = src.replace(/`([^`]+)`/g, (_, c) => { codes.push(`<code>${esc(c)}</code>`); return `\u0000${codes.length - 1}\u0000`; });
|
|
s = esc(unescapeMd(s));
|
|
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, u) => `<a href="${u}">${t}</a>`);
|
|
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[+i]);
|
|
}
|
|
|
|
const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
const cells = (row) => row.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split(/(?<!\\)\|/).map((c) => c.trim());
|
|
|
|
const lines = md.split("\n");
|
|
let title = "", byline = "";
|
|
const toc = [], out = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!line.trim()) continue;
|
|
if (line.startsWith("# ")) { title = line.slice(2).trim(); continue; }
|
|
if (line.startsWith("## ")) {
|
|
const text = line.slice(3).trim(), id = slug(text);
|
|
toc.push(` <a href="#${id}">${inline(text)}</a>`);
|
|
out.push(`<h2 id="${id}">${inline(text)}</h2>`);
|
|
continue;
|
|
}
|
|
if (!toc.length && !byline) { byline = inline(line.trim()); continue; }
|
|
const embed = line.match(/^(?:\[|[)embedded content:\s*([a-z0-9-]+)\s*(?:·.*)?(?:\]|\\\]|])\s*$/i);
|
|
if (embed) { out.push(figure(embed[1])); continue; }
|
|
if (/^[-*] /.test(line) || /^\d+\. /.test(line)) {
|
|
const ordered = /^\d+\. /.test(line);
|
|
const items = [];
|
|
while (i < lines.length && (ordered ? /^\d+\. / : /^[-*] /).test(lines[i])) items.push(lines[i++].replace(/^(?:[-*]|\d+\.) /, ""));
|
|
i--;
|
|
out.push(`<${ordered ? "ol" : "ul"}>${items.map((t) => `<li>${inline(t)}</li>`).join("")}</${ordered ? "ol" : "ul"}>`);
|
|
continue;
|
|
}
|
|
if (line.trimStart().startsWith("|")) {
|
|
const rows = [];
|
|
while (i < lines.length && lines[i].trimStart().startsWith("|")) rows.push(lines[i++]);
|
|
i--;
|
|
const head = cells(rows[0]), body = rows.slice(2).filter((r) => !/^\s*\|?\s*(:?-+:?\s*\|)+/.test(r) || true);
|
|
const tr = (cs, tag) => `<tr>${cs.map((c) => `<${tag}>${inline(c)}</${tag}>`).join("")}</tr>`;
|
|
out.push(`<table><thead>${tr(head, "th")}</thead><tbody>${rows.slice(2).map((r) => tr(cells(r), "td")).join("")}</tbody></table>`);
|
|
continue;
|
|
}
|
|
out.push(`<p>${inline(line.trim())}</p>`);
|
|
}
|
|
if (!title || !byline) throw new Error("whitepaper.md needs a '# title' line and a byline paragraph before the first '## ' heading");
|
|
|
|
// "Theseus Navigator Whitepaper" → last word in the accent colour, like the other theseus.x pages.
|
|
const titleHtml = esc(title).replace(/\s(\S+)$/, ' <span class="g">$1</span>');
|
|
const html = template
|
|
.replace("{{title}}", titleHtml)
|
|
.replace("{{byline}}", byline)
|
|
.replace("{{toc}}", toc.join("\n"))
|
|
.replace("{{article}}", out.join("\n"));
|
|
|
|
const target = path.join(root, "index.html");
|
|
if (process.argv.includes("--check")) {
|
|
const cur = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
|
if (cur !== html) { console.error("index.html differs from the build of whitepaper.md"); process.exit(1); }
|
|
console.log("index.html is up to date");
|
|
} else {
|
|
fs.writeFileSync(target, html);
|
|
console.log(`wrote ${path.relative(process.cwd(), target)} (${html.length} bytes, ${toc.length} sections)`);
|
|
}
|