Files
momme-world/k8s/site/site.js
T
momme 5852efafd7 site.js: typewriter output for command results
print() now queues line nodes; a small state machine writes them
character by character with a blinking caret (5ms/char, 0 when
prefers-reduced-motion). Any keypress or click flushes pending output
instantly; clear aborts it.
2026-10-14 00:14:30 +02:00

368 lines
12 KiB
JavaScript

// Content lives in COMMANDS only: every entry renders in the terminal, and the
// names double as the `help` listing. A `label -> url` line becomes a real link.
// Adding a section to this site means adding one object here.
const COMMANDS = [
{
name: "hello",
help: "the welcome message",
body: `Hey :)
I'm Momme. I build and break infrastructure, and I keep the pieces in
Kubernetes so they can be put back together again.
Type help to see what this box knows.`,
},
{
name: "whoami",
help: "who is typing",
body: `Momme Jürgensen — infrastructure and developer platforms.
I like work that ends up as a short command instead of a long document.
Currently at codeanker.`,
},
{
name: "projects",
help: "projects <name> — what I'm building",
body: `nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
brahmsee.digital event management platform for DLRG Schleswig-Holstein
silvatica-web website of the Silvatica festival, Schwäbisch Hall
momme.world this site — no build step, GitOps all the way down
more of them at: https://github.com/supermomme`,
detail: {
"nix-ha-kubernetes-cluster": `An HA Kubernetes cluster on NixOS, built to learn rather than to impress.
Control plane in HA, Ceph for storage, GitOps for everything above it.
This site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster`,
"brahmsee.digital": `Event management platform for DLRG Schleswig-Holstein.
Registrations and scheduling for a state water-rescue organisation, where the
traffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital`,
"silvatica-web": `Website of the Silvatica festival in Schwäbisch Hall.
-> https://github.com/supermomme/silvatica-web`,
"momme.world": `This site. Plain files, a stock nginx image, three manifests, no build
step. ArgoCD points at the repo, so a merged commit is a deployed site.`,
},
},
{
name: "stack",
help: "the tools I reach for",
body: `kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
nix reproducible configs for servers and laptops
observability Prometheus, Loki, Grafana — dashboards I actually read
fpv drones built, flown, crashed, rebuilt`,
},
{
name: "social",
help: "where I hang out online",
body: `github -> https://github.com/supermomme
mastodon -> https://cybre.town/@momme
instagram -> https://instagram.com/supermommej`,
},
{
name: "contact",
help: "reach me directly",
body: `mail -> mailto:momme@juergensen.me
matrix -> https://matrix.to/#/@momme:chaos.jetzt`,
},
{
name: "ls",
help: "what's in here",
body: () => names("projects").join(" "),
},
{
name: "cat",
prefix: true,
help: "cat README.md",
run: (arg) => {
const f = arg.toLowerCase();
if (f === "readme.md" || f === "readme") return body("hello");
if (f === ".ssh/id_rsa") return fail("nice try.");
return fail(`cat: ${arg}: No such file or directory`, "try: cat README.md");
},
},
{
name: "help",
help: "this list",
body: () => ["usage: <command>", "", ...usages()],
run: (arg) => {
if (!arg) return body("help");
const row = usages().find((line) => line.trim().startsWith(arg + " "));
return row ? row : fail(`no command called "${arg}"`, "see: help");
},
},
{ name: "clear", help: "wipe the screen", body: "" },
{ name: "date", help: "today's date", body: () => new Date().toDateString() },
{ name: "time", help: "current time", body: () => new Date().toLocaleTimeString("de-DE") },
{ name: "echo", prefix: true, help: "echo <text> — repeat yourself", body: (arg) => arg },
{ name: "pwd", help: "working directory", body: "/world" },
{ name: "uname", prefix: true, help: "system information", body: "momme.world 1.0.0 static x86_64 GNU/freedom" },
{ name: "curl", prefix: true, help: "curl momme.world — you already did that", body: "you are already reading the output :)" },
{ name: "sudo", prefix: true, help: "elevate", body: "momme is not in the sudoers file. This incident has been reported." },
{ name: "vim", prefix: true, help: "an editor", body: "emacs, obviously." },
{ name: "emacs", prefix: true, help: "an OS with good editing", body: "a great OS, shame about the editor." },
{ name: "coffee", help: "brew coffee", body: "() ~ I'm out of coffee." },
{ name: "exit", help: "close the session", body: "close the tab, or just stop reading :)" },
];
const ALIASES = {
whois: "whoami", about: "whoami", openme: "whoami",
tech: "stack", tools: "stack",
links: "social", socials: "social",
mail: "contact", email: "contact",
repos: "projects", rm: "sudo",
};
// ---------- content helpers ----------
const byName = (name) => COMMANDS.filter((c) => c.name === name && !c.prefix)[0];
const body = (name) => render(byName(name).body, "");
const render = (value, arg) => {
const v = typeof value === "function" ? value(arg) : value;
return Array.isArray(v) ? v.join("\n") : String(v);
};
const names = (name) =>
split(body(name)).map((line) => line.trim().split(/\s{2,}/)[0]).filter(Boolean);
const commandNames = () => unique().map((c) => c.name);
const unique = () =>
COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i);
const usages = () => unique().map((c) => ` ${c.name.padEnd(9)} ${c.help}`);
const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) });
const split = (value) => value.split("\n");
// ---------- dom ----------
const out = document.getElementById("out");
const screen = document.getElementById("screen");
const form = document.getElementById("prompt");
const input = document.getElementById("cmd");
const make = (tag, cls, value) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (value != null) node.textContent = value;
return node;
};
const scrollDown = () => (screen.scrollTop = screen.scrollHeight);
// ---------- typewriter ----------
// print() never renders directly: it queues fully-built line nodes and this
// machine writes them character by character. A keypress or click while
// typing is active dumps the pending output instantly; `clear` drops it.
const TYPE_MS = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 5;
let queue = [];
let typing = null;
let fast = false;
let gen = 0;
let dumpNow = null;
// finish every pending line synchronously, then let the queue drain instantly
const flatten = (node) =>
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
const flush = () => {
if (!typing && !queue.length) return;
fast = true;
if (dumpNow) dumpNow();
else if (typing) write();
else pump();
};
const kill = () => {
gen++;
queue.length = 0;
typing = null;
dumpNow = null;
fast = false;
};
function print(value, arg) {
const resolved = typeof value === "function" ? value(arg) : value;
const lines = [];
if (resolved && resolved.error) {
lines.push(build(resolved.error, "line err"));
resolved.more.forEach((line) => lines.push(build(line, "line dim")));
} else {
split(render(resolved, arg)).forEach((line) => lines.push(build(line, "line out")));
}
queue.push(lines);
pump();
}
// Split a line into typed fragments: plain text, or a link that lands whole.
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
function build(line, cls) {
const node = make("span", cls);
const segs = [];
if (!LINK.test(line)) {
segs.push({ text: line || "\u200b" });
} else {
const arrow = line.indexOf("->");
if (arrow > 0) segs.push({ text: line.slice(0, arrow) });
(arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => {
if (!part) return;
if (!LINK.test(part)) return segs.push({ text: part });
segs.push({ href: part, text: part });
});
}
node.segs = segs;
return node;
};
const anchor = (seg) => {
const a = make("a", null, seg.text);
a.href = seg.href;
if (!seg.href.startsWith("mailto:")) a.rel = "noopener me";
return a;
};
function pump() {
if (typing || !queue.length) return;
typing = { g: gen, lines: queue.shift(), i: 0 };
write();
}
function write() {
if (!typing) return;
if (typing.i >= typing.lines.length) {
typing = null;
if (!queue.length) fast = false;
scrollDown();
return pump();
}
const node = typing.lines[typing.i];
out.append(node);
if (fast) {
flatten(node);
typing.i++;
scrollDown();
return write();
}
const g = typing.g;
typeSegs(node, g, () => {
if (typing && typing.g === g) typing.i++;
write();
});
}
function typeSegs(node, g, ready) {
const segs = node.segs;
if (!TYPE_MS) return dump(segs, null);
const caret = make("span", "cursor");
let si = 0, ci = 0, tn = null;
node.append(caret);
dumpNow = () => { dumpNow = null; dump(segs, caret); };
(function tick() {
if (g !== gen) return;
if (fast) return dump(segs, caret);
if (si >= segs.length) return dump(segs, caret);
const seg = segs[si];
if (seg.href) {
node.insertBefore(anchor(seg), caret);
si++;
return setTimeout(tick, TYPE_MS * 6);
}
if (!tn) {
tn = document.createTextNode("");
node.insertBefore(tn, caret);
}
if (ci < seg.text.length) {
tn.appendData(seg.text[ci++]);
if (ci % 8 === 0) scrollDown();
return setTimeout(tick, TYPE_MS);
}
si++, (ci = 0), (tn = null);
tick();
})();
function dump(segs, caret) {
dumpNow = null;
if (tn) {
const seg = segs[si];
if (seg && !seg.href) tn.appendData(seg.text.slice(ci));
si++, (ci = 0);
}
caret && caret.remove();
for (; si < segs.length; si++) {
const seg = segs[si];
seg.href ? node.append(anchor(seg)) : node.append(seg.text);
}
scrollDown();
ready();
}
}
// ---------- the loop ----------
const history = { items: [], at: 0 };
function recall(step) {
history.at += step;
if (history.at < 0) history.at = 0;
if (history.at >= history.items.length) {
history.at = history.items.length;
input.value = "";
return;
}
input.value = history.items[history.at];
}
function execute(raw) {
const [word = "", ...rest] = raw.trim().split(/\s+/);
const arg = rest.join(" ");
const name = word.toLowerCase();
flush();
out.append(make("div", "line cmd", raw));
if (!name) return scrollDown();
if (name === "clear") return kill(), (out.textContent = "");
const wanted = ALIASES[name] || name;
const found =
COMMANDS.find((c) => c.name === wanted && !c.prefix) ||
COMMANDS.find((c) => c.name === wanted && c.prefix);
if (!found) {
const near = commandNames().filter((n) => n !== name && n[0] === name[0]).slice(0, 3);
return print(fail(`command not found: ${name}`,
near.length ? `did you mean: ${near.join(", ")}` : "see: help"));
}
if (found.name === "projects" && arg) {
const key = arg.toLowerCase();
if (key === "list") return print(body("projects"));
return print(found.detail[key] ||
fail(`no project called "${arg}"`, "names:", ...names("projects")));
}
return print(found.run ? found.run(arg) : found.body, arg);
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const raw = input.value;
input.value = "";
if (raw.trim()) history.items.push(raw.trim());
history.at = history.items.length;
execute(raw);
});
input.addEventListener("keydown", (event) => {
flush();
if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); }
if (event.key === "ArrowDown") { event.preventDefault(); recall(1); }
if (event.key !== "Tab") return;
const word = input.value.trim().split(/\s+/)[0];
const hit = commandNames().filter((n) => word && n.startsWith(word));
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
});
screen.addEventListener("click", () => {
flush();
if (!getSelection().toString()) input.focus();
});
document.querySelectorAll("[data-run]").forEach((button) =>
button.addEventListener("click", () => {
execute(button.dataset.run);
input.focus();
})
);
print(`hello — type help for the rest of it.\n`);
input.focus();