Files
horizon-airdef/website/build.mjs
T
Ludovic Marie 57ee531c63 Host a server: a guide in docs/hosting.md, and the site's /server page built from it
What works on main today (install, game files, first run, server.toml, the commands, the web panel,
backups, services, Docker built by hand) and, plainly, what doesn't yet: offline mode only, no
published image, no secure chat or locator bar, the open PRs that are coming. The site renders the
guide into /server at build time, so it has one source. The CSP hashes now cover every page's inline
script, so the privacy page's theme script is allowed too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:45:48 +02:00

230 lines
12 KiB
JavaScript

// Builds the site into dist/: src/index.html with the README's parity table and
// the community links and contributors baked in, plus public/ and fingerprinted CSS and JS. No dependencies.
//
// Railway builds only website/, so the README and links.json come from the repo when they
// are next to it (a local checkout) and from main on GitHub otherwise. The latest releases and
// the contributors are baked in when GitHub answers, so the page reads well before (or without) JavaScript.
import { readFile, writeFile, mkdir, rm, cp } from "node:fs/promises";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { markdown, inline, section, escape } from "./src/markdown.js";
import { releasesHtml } from "./src/releases.js";
import { contributorsFrom, contributorsHtml } from "./src/contributors.js";
const site = dirname(fileURLToPath(import.meta.url));
const dist = join(site, "dist");
const REPO = "ludovic111/horizon";
const RAW = `https://raw.githubusercontent.com/${REPO}/main/`;
const DEFAULT_LINKS = { discord: "https://discord.gg/HcTeMZxe5E", sponsor: "https://github.com/sponsors/ludovic111" };
async function repoFile(path) {
const local = join(site, "..", path);
if (existsSync(local)) return [await readFile(local, "utf8"), "the checkout"];
const res = await fetch(RAW + path, { signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`${path}: ${res.status} from GitHub`);
return [await res.text(), "main on GitHub"];
}
// The README: required, a site with a stale or missing parity table is worse than no deploy.
const [readme, readmeFrom] = await repoFile("README.md");
// The server guide (/server) is docs/hosting.md from its first section on: the page has its own title and lede.
const [hosting] = await repoFile("docs/hosting.md");
const guideStart = hosting.search(/^## /m);
if (guideStart < 0) throw new Error("docs/hosting.md: no '## ' section");
let links = { ...DEFAULT_LINKS };
try {
const parsed = JSON.parse((await repoFile("launcher/links.json"))[0]);
for (const key of Object.keys(links)) if (typeof parsed[key] === "string") links[key] = parsed[key].trim();
} catch (e) {
console.warn(`links.json: ${e.message}; using the built-in links`);
}
let releases = [];
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
// An optional token (a Railway variable, or `gh auth token` locally) lifts the rate limit.
headers: { Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) },
signal: AbortSignal.timeout(15000),
});
if (res.ok) releases = (await res.json()).filter((r) => !r.draft);
else console.warn(`releases: ${res.status} from GitHub; the page will load them itself`);
} catch (e) {
console.warn(`releases: ${e.message}; the page will load them itself`);
}
let contributors = [];
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/contributors?per_page=100`, {
headers: { Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) },
signal: AbortSignal.timeout(15000),
});
if (res.ok) contributors = contributorsFrom(await res.json());
else console.warn(`contributors: ${res.status} from GitHub; the page will load them itself`);
} catch (e) {
console.warn(`contributors: ${e.message}; the page will load them itself`);
}
// --- The parity table ------------------------------------------------------------------
const status = section(readme, "Project status") ?? "";
const parityHeading = (status.match(/^###\s+(Java parity:\s*[\d.]+%)/m) || [])[1];
if (!parityHeading) throw new Error("README: no '### Java parity: N%' heading under 'Project status'");
const percent = parityHeading.match(/[\d.]+%/)[0];
const parityBody = section(readme, parityHeading) ?? "";
const rows = parityBody
.split("\n")
.filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l))
.map((l) =>
l
.replace(/^\|/, "")
.replace(/\|\s*$/, "")
.split("|")
.map((c) => c.trim()),
);
const [, ...body] = rows; // the first row is the header
if (body.length < 5) throw new Error("README: the parity table has fewer than five rows");
const harnesses = (readme.match(/all (\d[\d,]*) (?:parity )?harnesses/) || [])[1];
if (!harnesses) throw new Error("README: no 'all N harnesses' count");
const audited = (parityBody.match(/Last audited\s+(\d{4}-\d{2}-\d{2})/) || [])[1] ?? "";
const intro = status.split(/^###/m)[0].trim();
const scoreLabel = (s) => ({ 0: "Absent", 0.25: "Started", 0.5: "Playable", 0.75: "Mostly complete", 1: "Complete" })[Number(s)] ?? s;
const check = `<svg class="check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>`;
const parityCells = body
.map(([name, score, gaps], i) => {
// "None: what matches" reads as the evidence; anything else is a gap and says so.
const none = gaps.match(/^None:\s*(.*)$/);
const full = Number(score) === 1;
const detail = none ? inline(none[1].charAt(0).toUpperCase() + none[1].slice(1)) : `<span class="gap">Gaps:</span> ${inline(gaps)}`;
const state = full ? `${check}Matches vanilla` : `${escape(score)} · ${escape(scoreLabel(score))}`;
return `<li class="system${full ? " is-full" : ""}">
<p class="system-head"><span class="system-index">${String(i + 1).padStart(2, "0")}</span><span class="system-state">${state}</span></p>
<h3>${inline(name)}</h3>
<p class="system-detail">${detail}</p>
</li>`;
})
.join("\n");
const matching = body.filter(([, score]) => Number(score) === 1).length;
const parityHeadline = matching === body.length ? "Every subsystem matches vanilla." : `${matching} of ${body.length} subsystems match vanilla.`;
// --- The latest notes -------------------------------------------------
const isGame = (tag) => /^v\d{4}\.\d{1,2}\.\d{1,2}/.test(tag);
const isLauncher = (tag) => tag.startsWith("launcher-v");
const game = releases.filter((r) => isGame(r.tag_name)).slice(0, 3);
const launcher = releases.find((r) => isLauncher(r.tag_name) && !r.prerelease);
// --- The page --------------------------------------------------------------------------
const fingerprint = (text) => createHash("sha256").update(text).digest("hex").slice(0, 10);
await rm(dist, { recursive: true, force: true });
await mkdir(join(dist, "assets"), { recursive: true });
await cp(join(site, "public"), dist, { recursive: true });
await cp(join(site, "LEGAL.md"), join(dist, "LEGAL.md"));
// The icons keep their names (browsers and other sites ask for /favicon.svg and the
// rest), so the pages link them with their content's hash: a new mark reaches everyone
// at once instead of after the week the server lets browsers keep the old one.
const iconVersions = {};
for (const icon of ["favicon.svg", "favicon-32.png", "apple-touch-icon.png"]) {
iconVersions[icon] = fingerprint(await readFile(join(site, "public", icon)));
}
// /favicon.ico, which browsers request on their own: the 32-pixel PNG in an ICO
// container (one image entry; ICO has held PNG data since Windows Vista).
const png32 = await readFile(join(site, "public", "favicon-32.png"));
const ico = Buffer.alloc(22);
ico.writeUInt16LE(0, 0); // reserved
ico.writeUInt16LE(1, 2); // type: icon
ico.writeUInt16LE(1, 4); // one image
ico.writeUInt8(32, 6); // width
ico.writeUInt8(32, 7); // height
ico.writeUInt16LE(1, 10); // colour planes
ico.writeUInt16LE(32, 12); // bits per pixel
ico.writeUInt32LE(png32.length, 14);
ico.writeUInt32LE(22, 18); // the image follows the header and its entry
await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32]));
// The page's modules, fingerprinted; each import of a shared module is rewritten to its name.
const names = {};
const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
for (const file of ["markdown.js", "releases.js", "contributors.js", "main.js"]) {
const code = rewrite(await readFile(join(site, "src", file), "utf8"));
names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`;
await writeFile(join(dist, "assets", names[file]), code);
}
const jsName = names["main.js"];
// The design tokens come first: src/tokens.css is generated from crates/horizon-theme
// (`cargo run -p xtask -- tokens`), so the page shares the launcher's and the game's values.
const css = (await readFile(join(site, "src/tokens.css"), "utf8")) + "\n" + (await readFile(join(site, "src/style.css"), "utf8"));
// The stylesheet is inlined into the page, so the first paint needs no second request.
const cssName = `style.${fingerprint(css)}.css`;
await writeFile(join(dist, "assets", cssName), css);
const inlineCss = css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s*\n\s*/g, "\n").trim();
const baked = {
links,
contributors,
releases: releases.slice(0, 12).map((r) => ({
tag_name: r.tag_name,
name: r.name,
published_at: r.published_at,
html_url: r.html_url,
prerelease: r.prerelease,
body: isGame(r.tag_name) ? r.body : "",
assets: r.assets.map((a) => ({ name: a.name, browser_download_url: a.browser_download_url, size: a.size })),
})),
};
const notesHtml =
releasesHtml(game) ||
`<p class="lede">The release notes could not be loaded here.</p><a class="button secondary" href="https://github.com/${REPO}/releases">Read them on GitHub</a>`;
const values = {
CSS: `/assets/${cssName}`,
STYLE: inlineCss,
JS: `/assets/${jsName}`,
PERCENT: percent,
HARNESSES: harnesses,
AUDITED: audited,
PARITY_INTRO: markdown(intro.replace("The table below is the project's own audit", "Below is the project's own audit")),
PARITY_CELLS: parityCells,
PARITY_HEADLINE: parityHeadline,
SUBSYSTEMS: String(body.length),
NOTES: notesHtml,
LAUNCHER_META: `${launcher ? `Launcher ${escape(launcher.tag_name.replace("launcher-v", ""))} for` : "For"} Linux x86-64, macOS on Apple Silicon and Windows x64.`,
DISCORD: escape(links.discord || DEFAULT_LINKS.discord),
SPONSOR: escape(links.sponsor || DEFAULT_LINKS.sponsor),
CONTRIBUTORS: contributorsHtml(contributors),
CONTRIBUTORS_HIDDEN: contributors.length ? "" : " hidden",
BAKED: JSON.stringify(baked).replace(/</g, "\\u003c"),
YEAR: String(new Date().getUTCFullYear()),
SERVER_GUIDE: markdown(hosting.slice(guideStart)),
};
const pages = ["index.html", "404.html", "privacy.html", "server.html"];
for (const page of pages) {
let html = await readFile(join(site, "src", page), "utf8");
html = html.replace(/\{\{(\w+)\}\}/g, (all, key) => {
if (!(key in values)) throw new Error(`${page}: unknown placeholder ${all}`);
return values[key];
});
html = html.replace(/href="\/(favicon\.svg|favicon-32\.png|apple-touch-icon\.png)"/g, (all, icon) => `href="/${icon}?v=${iconVersions[icon]}"`);
await writeFile(join(dist, page), html);
}
// The pages' inline scripts (they mark the page as scripted and apply the saved theme before CSS
// applies) and the inlined stylesheet: the Content-Security-Policy in the Caddyfile allows them by hash.
const allHtml = (await Promise.all(pages.map((page) => readFile(join(dist, page), "utf8")))).join("\n");
const hashes = (re) => [...new Set([...allHtml.matchAll(re)].map((m) => `'sha256-${createHash("sha256").update(m[1]).digest("base64")}'`))].join(" ");
await writeFile(join(dist, ".csp-script"), hashes(/<script>([\s\S]*?)<\/script>/g));
await writeFile(join(dist, ".csp-style"), hashes(/<style>([\s\S]*?)<\/style>/g));
console.log(
`built dist/ from ${readmeFrom}: ${body.length} subsystems, ${percent}, ${harnesses} harnesses, ` +
`${game.length} game releases baked${launcher ? `, launcher ${launcher.tag_name}` : ""}, ${contributors.length} contributors`,
);