Files
momme-world/k8s/site/site.js
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

304 lines
9.0 KiB
JavaScript

// 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: "project", projects: "project", rm: "sudo",
};
// ${...} 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");
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 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));
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 lines = [];
if (value && value.error) {
lines.push(build(value.error, "text-[#ff7b72]"));
value.more.forEach((line) => lines.push(build(line, "text-[#5c6f8a]")));
} else {
split(resolve(value, arg)).forEach((line) => lines.push(build(line, "")));
}
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", "block min-h-[1.35em] " + (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", "text-accent hover:underline", 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];
}
// 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) {
flush();
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 [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"), "");
}
// 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(resolve(cmd.body, arg), 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().toLowerCase().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();
});
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: [] }, "")
);