Files
momme-world/build/build.mjs
T
momme 8d33460580
site / generated-is-current (push) Failing after 36s
site generator: commands/*.md with front matter -> k8s/site/
- build/build.mjs: one-file-per-command content (filename = command,
  underscores = spaces; help/suggestion/params/prefix/text/hidden in
  front matter; ${args} ${date} ${time} ${ls} ${help} resolved at runtime)
- generates index.html (banner + chips), commands.json (the single data
  source), hello.txt for curl, style.css via tailwind v4
- src/site.js is now dispatch-only over commands.json; typewriter kept
- src/telnet.py: stdlib telnet shell sharing commands.json
- k8s: python telnet sidecar in the pod + LoadBalancer service 23->2323
- .gitea/workflows/site.yml fails CI when k8s/site/ is not the built output
2026-10-14 01:14:28 +02:00

112 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// momme.world site generator.
//
// commands/*.md is the single source of content. Each file describes one
// command: front matter sets metadata, the body is what it prints.
//
// filename -> command name (underscores become spaces)
// name: -> overrides the filename-derived name
// help: -> one-liner for the help listing
// params: -> argument placeholder shown in help (e.g. "<text>")
// prefix: true -> command accepts anything after its name
// suggestion: true -> chip button under the terminal + tailnet MOTD hint
// hidden: true -> works, but stays out of help
// text: true -> included in the plain-text page (curl)
// ${args} ${date} ${time} ${ls} ${help} -> resolved when the command runs
//
// Output goes to k8s/site/ and is COMMITTED: ArgoCD serves those files
// straight from the repo, so `npm run build` is part of editing the site.
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const P = (p) => join(root, p);
// ---------- read commands ----------
const read = (file) => {
const raw = readFileSync(P("commands/" + file), "utf8").replace(/\r\n/g, "\n");
const m = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
if (!m) throw new Error(`${file}: missing or malformed front matter`);
const meta = {};
for (const line of m[1].split("\n")) {
const [key, ...rest] = line.split(":");
if (key) meta[key.trim()] = rest.join(":").trim().replace(/^["']|["']$/g, "");
}
const name = meta.name || file.replace(/\.md$/, "").replace(/_/g, " ");
return {
name,
help: meta.help ?? "",
params: meta.params ?? "",
prefix: meta.prefix === "true",
suggestion: meta.suggestion === "true",
hidden: meta.hidden === "true",
text: meta.text === "true",
body: m[2].trimEnd(),
};
};
const files = readdirSync(P("commands")).filter((f) => f.endsWith(".md")).sort();
const commands = files.map(read);
for (const c of commands) {
for (const other of commands) {
if (other !== c && other.name === c.name) throw new Error(`duplicate command name: ${c.name}`);
}
}
if (new Set(commands.map((c) => c.name.split(" ")[0])).size !== new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name)).size) {
// multi-word commands ("project x") must not collide with first words
const firsts = new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name));
for (const c of commands.filter((c) => c.name.includes(" "))) {
if (firsts.has(c.name.split(" ")[0])) throw new Error(`name collision: ${c.name} vs ${c.name.split(" ")[0]}`);
}
}
// ---------- index.html ----------
const chips = commands
.filter((c) => c.suggestion && !c.hidden)
.map((c) => ` <button data-run="${c.name}" class="rounded border border-line bg-panel px-3 py-1 text-xs text-dim hover:text-accent hover:border-accent transition-colors">${c.name}</button>`)
.join("\n");
const banner = readFileSync(P("templates/banner.txt"), "utf8").replace(/\s+$/, "");
const template = readFileSync(P("src/index.html"), "utf8");
for (const [ph, val] of [["<!--@chips-->", chips], ["<!--@banner-->", banner]]) {
if (!template.includes(ph)) throw new Error(`src/index.html: ${ph} placeholder missing`);
}
writeFileSync(
P("k8s/site/index.html"),
template.replace("<!--@chips-->", chips).replace("<!--@banner-->", banner)
);
// ---------- commands.json (web runtime + telnet both read this) ----------
const json = JSON.stringify({ banner, commands }, null, 1) + "\n";
writeFileSync(P("k8s/site/commands.json"), json);
// ---------- hello.txt (what curl sees) ----------
const sections = commands
.filter((c) => c.text)
.map((c) => {
const body = c.body.replace(/\$\{help\}|\$\{ls\}/g, "");
return `${c.name}\n${"=".repeat(c.name.length)}\n${body}`.trimEnd();
});
writeFileSync(
P("k8s/site/hello.txt"),
banner + "\n\n" + sections.join("\n\n") + "\n\nThe interactive version of this page is at https://momme.world/\n"
);
// ---------- copy runtimes ----------
writeFileSync(P("k8s/site/site.js"), readFileSync(P("src/site.js"), "utf8"));
writeFileSync(P("k8s/site/telnet.py"), readFileSync(P("src/telnet.py"), "utf8"));
// ---------- tailwind ----------
try {
execSync("npx --yes @tailwindcss/cli@4 -i src/input.css -o k8s/site/style.css --minify", { cwd: root, stdio: "inherit" });
} catch {
console.error("tailwind build failed — is npm install done? (style.css stays as it was)");
process.exit(1);
}
const words = (n) => String(n).padStart(2);
console.log(`built ${words(files.length)} commands -> k8s/site/ (${chips ? commands.filter((c) => c.suggestion).length + " chips" : "no chips"})`);