site generator: commands/*.md with front matter -> k8s/site/
site / generated-is-current (push) Failing after 36s
site / generated-is-current (push) Failing after 36s
- 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
This commit is contained in:
+92
-156
@@ -1,127 +1,52 @@
|
||||
// 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 :)" },
|
||||
];
|
||||
// momme.world browser runtime. Content comes from commands.json — generated
|
||||
// by build/build.mjs from commands/*.md; edit those files, not this one.
|
||||
// This file only implements the loop, the typewriter and command dispatch.
|
||||
|
||||
let COMMANDS = [];
|
||||
let INDEX = new Map();
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/commands.json", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
COMMANDS = data.commands;
|
||||
INDEX = new Map(COMMANDS.map((c) => [c.name, c]));
|
||||
};
|
||||
|
||||
// ---------- content helpers ----------
|
||||
const visible = () => COMMANDS.filter((c) => !c.hidden);
|
||||
const unique = () => COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i);
|
||||
const commandNames = () => unique().map((c) => c.name);
|
||||
const usages = () =>
|
||||
visible().filter((c) => c.help).map((c) => ` ${c.name.padEnd(22)} ${c.help}`);
|
||||
const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) });
|
||||
const split = (value) => String(value).split("\n");
|
||||
|
||||
const ALIASES = {
|
||||
whois: "whoami", about: "whoami", openme: "whoami",
|
||||
tech: "stack", tools: "stack",
|
||||
links: "social", socials: "social",
|
||||
mail: "contact", email: "contact",
|
||||
repos: "projects", rm: "sudo",
|
||||
repos: "project", projects: "project", 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");
|
||||
// ${...} placeholders a command body may contain; resolved at run time.
|
||||
const substitutions = (arg) => ({
|
||||
args: arg,
|
||||
date: () => new Date().toDateString(),
|
||||
time: () => new Date().toLocaleTimeString("de-DE"),
|
||||
help: () => ["usage: <command>", "", ...usages()].join("\n"),
|
||||
ls: () =>
|
||||
commandNames()
|
||||
.filter((n) => n.includes(" "))
|
||||
.map((n) => n.split(" ").slice(1).join(" "))
|
||||
.join(" "),
|
||||
});
|
||||
|
||||
const resolve = (body, arg) =>
|
||||
String(body).replace(/\$\{(\w+)\}/g, (_, key) => {
|
||||
const sub = substitutions(arg)[key];
|
||||
return sub === undefined ? "" : typeof sub === "function" ? sub() : sub;
|
||||
});
|
||||
|
||||
// ---------- dom ----------
|
||||
const out = document.getElementById("out");
|
||||
@@ -149,7 +74,7 @@ let fast = false;
|
||||
let gen = 0;
|
||||
let dumpNow = null;
|
||||
|
||||
// finish every pending line synchronously, then let the queue drain instantly
|
||||
// finish the line being typed synchronously; the queue then drains instantly
|
||||
const flatten = (node) =>
|
||||
node.segs.forEach((seg) => node.append(seg.href ? anchor(seg) : seg.text));
|
||||
|
||||
@@ -170,13 +95,12 @@ const kill = () => {
|
||||
};
|
||||
|
||||
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")));
|
||||
if (value && value.error) {
|
||||
lines.push(build(value.error, "text-[#ff7b72]"));
|
||||
value.more.forEach((line) => lines.push(build(line, "text-[#5c6f8a]")));
|
||||
} else {
|
||||
split(render(resolved, arg)).forEach((line) => lines.push(build(line, "line out")));
|
||||
split(resolve(value, arg)).forEach((line) => lines.push(build(line, "")));
|
||||
}
|
||||
queue.push(lines);
|
||||
pump();
|
||||
@@ -186,7 +110,7 @@ function print(value, arg) {
|
||||
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
||||
|
||||
function build(line, cls) {
|
||||
const node = make("span", cls);
|
||||
const node = make("span", "block min-h-[1.35em] " + (cls || ""));
|
||||
const segs = [];
|
||||
if (!LINK.test(line)) {
|
||||
segs.push({ text: line || "\u200b" });
|
||||
@@ -201,10 +125,10 @@ function build(line, cls) {
|
||||
}
|
||||
node.segs = segs;
|
||||
return node;
|
||||
};
|
||||
}
|
||||
|
||||
const anchor = (seg) => {
|
||||
const a = make("a", null, seg.text);
|
||||
const a = make("a", "text-accent hover:underline", seg.text);
|
||||
a.href = seg.href;
|
||||
if (!seg.href.startsWith("mailto:")) a.rel = "noopener me";
|
||||
return a;
|
||||
@@ -301,35 +225,43 @@ function recall(step) {
|
||||
input.value = history.items[history.at];
|
||||
}
|
||||
|
||||
// Resolve input words to [command, argument]. Longest multi-word name wins so
|
||||
// "project fpv" beats any one-word command; a command with params swallows
|
||||
// the rest.
|
||||
function lookup(words) {
|
||||
for (let len = words.length; len > 0; len--) {
|
||||
const joined = words.slice(0, len).join(" ");
|
||||
const cmd = INDEX.get(ALIASES[joined] || joined);
|
||||
if (cmd) return [cmd, words.slice(len).join(" ")];
|
||||
}
|
||||
return [null, ""];
|
||||
}
|
||||
|
||||
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 = "");
|
||||
out.append(make("div", "block text-fg mt-4", raw));
|
||||
const words = raw.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return scrollDown();
|
||||
if (words[0] === "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"));
|
||||
const [cmd, arg] = lookup(words);
|
||||
if (!cmd) {
|
||||
const near = commandNames().filter((n) => n !== words[0] && n.startsWith(words[0])).slice(0, 3);
|
||||
return print(fail(`command not found: ${words[0]}`,
|
||||
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")));
|
||||
// argument on a parent: either a known subcommand or a name error
|
||||
if (arg && !cmd.params && !cmd.prefix) {
|
||||
const kids = commandNames().filter((n) => n.startsWith(cmd.name + " "));
|
||||
if (kids.length) {
|
||||
const kid = INDEX.get(cmd.name + " " + arg);
|
||||
if (kid) return print(resolve(kid.body, ""), "");
|
||||
return print(fail(`no ${cmd.name} called "${arg}"`,
|
||||
"names: " + kids.map((k) => k.slice(cmd.name.length + 1)).join(", ")), "");
|
||||
}
|
||||
}
|
||||
|
||||
return print(found.run ? found.run(arg) : found.body, arg);
|
||||
return print(resolve(cmd.body, arg), arg);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
@@ -346,7 +278,7 @@ input.addEventListener("keydown", (event) => {
|
||||
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 word = input.value.trim().toLowerCase().split(/\s+/)[0];
|
||||
const hit = commandNames().filter((n) => word && n.startsWith(word));
|
||||
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
|
||||
});
|
||||
@@ -356,12 +288,16 @@ screen.addEventListener("click", () => {
|
||||
if (!getSelection().toString()) input.focus();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-run]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
execute(button.dataset.run);
|
||||
load().then(
|
||||
() => {
|
||||
document.querySelectorAll("[data-run]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
execute(button.dataset.run);
|
||||
input.focus();
|
||||
})
|
||||
);
|
||||
print("hello — type help for the rest of it.", "");
|
||||
input.focus();
|
||||
})
|
||||
},
|
||||
() => print({ error: "commands.json did not load — did the site generator run?", more: [] }, "")
|
||||
);
|
||||
|
||||
print(`hello — type help for the rest of it.\n`);
|
||||
input.focus();
|
||||
|
||||
Reference in New Issue
Block a user