c88dd8326d
site / generated-is-current (push) Successful in 21s
- banner scales down to fit narrow screens instead of scrolling sideways - smaller base font/padding under sm: to stop the crooked mobile look - block caret tracks the cursor position inside the input (ghost-span measurement) instead of sitting at the line end - executed commands echo with the momme@world:~$ prompt in front
331 lines
10 KiB
JavaScript
331 lines
10 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);
|
|
|
|
// banner: shrink to fit instead of scrolling sideways
|
|
const banner = document.getElementById("banner");
|
|
const fitBanner = () => {
|
|
banner.style.transform = "";
|
|
banner.style.height = "";
|
|
// clientWidth = box the banner had to fit in; scrollWidth = what it wants
|
|
const scale = Math.min(1, banner.clientWidth / banner.scrollWidth);
|
|
banner.style.transform = `scale(${scale})`;
|
|
banner.style.height = `${banner.offsetHeight * scale}px`;
|
|
};
|
|
addEventListener("resize", fitBanner);
|
|
|
|
// caret: follow the text actually typed in the input (the ghost span measures it)
|
|
const caretEl = document.getElementById("caret");
|
|
const ghost = document.getElementById("ghost");
|
|
const moveCaret = () => {
|
|
const pos = input.selectionStart ?? input.value.length;
|
|
ghost.textContent = input.value.slice(0, pos);
|
|
caretEl.style.left = `${ghost.getBoundingClientRect().width - input.scrollLeft}px`;
|
|
};
|
|
for (const ev of ["input", "keyup", "click", "focus"]) input.addEventListener(ev, moveCaret);
|
|
|
|
// ---------- 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();
|
|
const echo = make("div", "block mt-4");
|
|
echo.append(make("span", "text-accent", "momme@world:~$ "), make("span", "text-fg", raw));
|
|
out.append(echo);
|
|
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 = "";
|
|
moveCaret();
|
|
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();
|
|
})
|
|
);
|
|
fitBanner();
|
|
print("hello — type help for the rest of it.", "");
|
|
input.focus();
|
|
moveCaret();
|
|
},
|
|
() => print({ error: "commands.json did not load — did the site generator run?", more: [] }, "")
|
|
);
|