// 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: ", "", ...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: [] }, "") );