From 8d33460580a68518a93f68988f663de580a1d95f Mon Sep 17 00:00:00 2001 From: momme Date: Wed, 14 Oct 2026 01:14:28 +0200 Subject: [PATCH] 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 --- .gitea/workflows/site.yml | 30 + .gitignore | 1 + README.md | 122 +- build/build.mjs | 111 ++ commands/cat.md | 7 + commands/clear.md | 4 + commands/coffee.md | 5 + commands/contact.md | 8 + commands/curl.md | 6 + commands/date.md | 5 + commands/echo.md | 7 + commands/emacs.md | 6 + commands/exit.md | 5 + commands/hello.md | 10 + commands/help.md | 6 + commands/ls.md | 5 + commands/project.md | 11 + commands/project_brahmsee.digital.md | 7 + commands/project_fpv.md | 7 + commands/project_momme.world.md | 7 + commands/project_nix-ha-kubernetes-cluster.md | 7 + commands/project_silvatica-web.md | 6 + commands/pwd.md | 5 + commands/social.md | 9 + commands/stack.md | 10 + commands/sudo.md | 6 + commands/time.md | 5 + commands/uname.md | 6 + commands/vim.md | 6 + commands/whoami.md | 9 + k8s/deployment.yaml | 38 + k8s/kustomization.yaml | 4 + k8s/service.yaml | 19 + k8s/site/commands.json | 265 ++++ k8s/site/hello.txt | 48 +- k8s/site/index.html | 81 +- k8s/site/nginx.conf | 5 + k8s/site/site.js | 248 ++-- k8s/site/style.css | 138 +- k8s/site/telnet.py | 165 +++ package-lock.json | 1170 +++++++++++++++++ package.json | 13 + src/index.html | 57 + src/input.css | 31 + src/site.js | 303 +++++ src/telnet.py | 165 +++ templates/banner.txt | 8 + 47 files changed, 2773 insertions(+), 424 deletions(-) create mode 100644 .gitea/workflows/site.yml create mode 100644 .gitignore create mode 100644 build/build.mjs create mode 100644 commands/cat.md create mode 100644 commands/clear.md create mode 100644 commands/coffee.md create mode 100644 commands/contact.md create mode 100644 commands/curl.md create mode 100644 commands/date.md create mode 100644 commands/echo.md create mode 100644 commands/emacs.md create mode 100644 commands/exit.md create mode 100644 commands/hello.md create mode 100644 commands/help.md create mode 100644 commands/ls.md create mode 100644 commands/project.md create mode 100644 commands/project_brahmsee.digital.md create mode 100644 commands/project_fpv.md create mode 100644 commands/project_momme.world.md create mode 100644 commands/project_nix-ha-kubernetes-cluster.md create mode 100644 commands/project_silvatica-web.md create mode 100644 commands/pwd.md create mode 100644 commands/social.md create mode 100644 commands/stack.md create mode 100644 commands/sudo.md create mode 100644 commands/time.md create mode 100644 commands/uname.md create mode 100644 commands/vim.md create mode 100644 commands/whoami.md create mode 100644 k8s/site/commands.json create mode 100644 k8s/site/telnet.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.html create mode 100644 src/input.css create mode 100644 src/site.js create mode 100644 src/telnet.py create mode 100644 templates/banner.txt diff --git a/.gitea/workflows/site.yml b/.gitea/workflows/site.yml new file mode 100644 index 0000000..9c59ad2 --- /dev/null +++ b/.gitea/workflows/site.yml @@ -0,0 +1,30 @@ +name: site + +# The cluster serves what is committed in k8s/site/ — so CI just enforces that +# k8s/site/ is exactly what the generator produces from commands/ + src/. +# If this job fails, run `npm run build` and commit the result. + +on: + push: + branches: ["**"] + pull_request: + +jobs: + generated-is-current: + runs-on: ubuntu-latest + container: + image: node:22-alpine + steps: + - uses: actions/checkout@v4 + + - run: npm ci --no-audit --no-fund + - run: npm run build + + - name: generated output is committed + run: | + git add -A k8s/site + if ! git diff --staged --quiet; then + echo "k8s/site/ is not in sync with commands/ + src/ — run 'npm run build' and commit:" + git diff --staged --stat + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md index f2b4854..c84b72e 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,76 @@ # momme.world Personal site at [momme.world](https://momme.world) — a terminal-style page -that behaves like a small shell (`whoami`, `projects`, `social`, …), plus a -plain-text rendering for `curl momme.world`. +that behaves like a small shell, a plain-text rendering for +`curl momme.world`, and a real telnet shell for the tailnet. -## Layout +## How it is put together -``` -k8s/ - kustomization.yaml the whole deployment: generated ConfigMaps + refs - deployment.yaml one unprivileged nginx, content served from ConfigMaps - service.yaml ClusterIP, :80 -> :8080 - site/ the actual website, served as-is - index.html markup + noscript fallback - style.css - site.js the terminal; ALL site content lives in COMMANDS here - nginx.conf mounted as /etc/nginx/conf.d/default.conf - hello.txt what curl gets -``` +**`commands/*.md` is the content.** One file per command, front matter for +metadata, body for output. The filename is the command — underscores are +spaces: -There is deliberately **no build step**: no package.json, no bundler, no CI. -Edit a file under `k8s/site/`, merge to `main`, ArgoCD syncs it. The content -of the site is the repo — the kustomize `configMapGenerator` hashes the files -into their ConfigMap names, which rolls the Deployment automatically. +| file | command | notes | +|---|---|---| +| `help.md` | `help` | | +| `project_fpv.md` | `project fpv` | multi-word commands just work | +| `echo.md` | `echo hello world` | `params: ` + `${args}` in the body | + +Front matter keys (all optional except the body): + +| key | meaning | +|---|---| +| `name:` | override the filename-derived command name | +| `help:` | one-line description; shown in `help` (no help = listed by `ls`, not in `help`) | +| `params:` | argument placeholder shown in help, e.g. `` | +| `prefix: true` | command swallows anything typed after its name | +| `suggestion: true` | rendered as a chip button under the terminal | +| `hidden: true` | works, but stays out of `help` | +| `text: true` | included in the plain-text page curl clients see | + +Dynamic placeholders inside a body, resolved when the command runs: +`${args}` (the arguments), `${help}` (the full listing), `${ls}` (subcommand +names), `${date}`, `${time}`. ## Editing the site -- New terminal command: add one object to `COMMANDS` in `k8s/site/site.js`. - It appears in `help` and in tab-completion automatically. `label -> url` - lines become links. -- The noscript block in `index.html` mirrors the main facts by hand; keep it - in sync when changing `whoami`/`contact` content. -- `curl`-UA detection is the `$root_doc` map in `k8s/site/nginx.conf`. - -## How it deploys - -The `momme/infra` module `app-momme-world` owns the namespace, the TLS -certificate and the HTTPProxy, and creates an ArgoCD `Application` that points -at `k8s/` in this repo. Nothing in this repo talks to the cluster. - -## Local preview - -Any static server works, e.g.: - -``` -cd k8s/site && python -m http.server 8080 +```bash +npm install +# edit commands/, src/index.html or src/input.css +npm run build # regenerates k8s/site/ +git add -A && git commit && git push ``` +`npm run build` (build/build.mjs) generates everything in `k8s/site/`: +the HTML (banner + chips injected), `commands.json` (the single data source +both the browser runtime and the telnet shell read), `hello.txt` for curl, and +`style.css` via Tailwind v4. **The generated files are committed on purpose** — +that is what ArgoCD serves — and CI fails if `k8s/site/` is out of sync with +the sources, so a forgotten build can never desync the site. + +Styling is [Tailwind v4](https://tailwindcss.com): theme tokens live in +`src/input.css`, class names in the template and chips. `src/site.js` is the +browser runtime (terminal loop + typewriter), `src/telnet.py` the tailnet +shell — both are dispatch over `commands.json`, neither carries content. + +## Deploy + +`k8s/` is kustomize: one pod with nginx (site) and python (telnet shell), all +content as ConfigMaps, so a content change rolls the pod without any image +build. Deployed by ArgoCD from the `app-momme-world` module of the infra repo +(git.momme.cloud/momme/infra). + ## Open decisions -Things left deliberately open for you: - -1. **DNS** — `momme.world` currently has *no A record*. The zone lives in - Cloudflare (nameservers `fred/jessica.ns.cloudflare.com`), the same place - `momme.cloud` lives. An `A momme.world -> 31.18.25.47` (grey cloud, the - same public entry all `*.momme.cloud` services use) is all that's needed. - Also check that the Cloudflare API token behind the `cf` ClusterIssuer - (Infisical `/global`, `cloudflare_api_token`) is allowed to do DNS-01 in - the momme.world zone — if it's scoped to momme.cloud only, widen it. -2. **www redirect** — not implemented. If you want `www.momme.world` to work, - add a second DNS name + HTTPProxy route (infra PR can carry it). -3. **Analytics / visits** — none included. Self-hosted (Plausible) or none is - an easy later call; nothing in this repo blocks it. -4. **Blog later** — you mentioned a Markdown-driven blog could be nice - someday. The current no-build design can take it as far as "one static - page per post written in HTML"; as soon as you want real Markdown + - templates, the natural step is a generator (Lume like adb.sh, or - Hugo) plus a built image. Decision deferred, structure unaffected. -5. **Content facts** — taken from the old site's commands (work at - codeanker, projects list, socials). Say the word for anything outdated; - they all live in `COMMANDS` in `site.js`. +* **DNS** — `momme.world` still needs an A record (Cloudflare) pointing at the + cluster's public entry `31.18.25.47`; until then no certificate and no + public HTTPS. Also check that the `cf` ClusterIssuer's API token covers the + `momme.world` zone (it was created for `momme.cloud`). +* **telnet exposure** — currently tailnet-only: `momme-world-telnet` is a + LoadBalancer service that needs a one-line IP-pool entry in + `infra/sys-cilium/lb.tf` (pattern: `gitea-ssh`). Public port 23 would need a + TCPRoute on the njalla/Envoy funnel — doable, left open on purpose. +* The footer advertises `telnet momme.world 23`; if port 23 stays tailnet-only, + change it to the LB IP (or drop the hint). +* GitHub Pages still serves the old site at supermomme.github.io/momme.world — + archive or delete that repo once this one is live. diff --git a/build/build.mjs b/build/build.mjs new file mode 100644 index 0000000..7eb4f88 --- /dev/null +++ b/build/build.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// momme.world site generator. +// +// commands/*.md is the single source of content. Each file describes one +// command: front matter sets metadata, the body is what it prints. +// +// filename -> command name (underscores become spaces) +// name: -> overrides the filename-derived name +// help: -> one-liner for the help listing +// params: -> argument placeholder shown in help (e.g. "") +// prefix: true -> command accepts anything after its name +// suggestion: true -> chip button under the terminal + tailnet MOTD hint +// hidden: true -> works, but stays out of help +// text: true -> included in the plain-text page (curl) +// ${args} ${date} ${time} ${ls} ${help} -> resolved when the command runs +// +// Output goes to k8s/site/ and is COMMITTED: ArgoCD serves those files +// straight from the repo, so `npm run build` is part of editing the site. + +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const P = (p) => join(root, p); + +// ---------- read commands ---------- +const read = (file) => { + const raw = readFileSync(P("commands/" + file), "utf8").replace(/\r\n/g, "\n"); + const m = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/); + if (!m) throw new Error(`${file}: missing or malformed front matter`); + const meta = {}; + for (const line of m[1].split("\n")) { + const [key, ...rest] = line.split(":"); + if (key) meta[key.trim()] = rest.join(":").trim().replace(/^["']|["']$/g, ""); + } + const name = meta.name || file.replace(/\.md$/, "").replace(/_/g, " "); + return { + name, + help: meta.help ?? "", + params: meta.params ?? "", + prefix: meta.prefix === "true", + suggestion: meta.suggestion === "true", + hidden: meta.hidden === "true", + text: meta.text === "true", + body: m[2].trimEnd(), + }; +}; + +const files = readdirSync(P("commands")).filter((f) => f.endsWith(".md")).sort(); +const commands = files.map(read); + +for (const c of commands) { + for (const other of commands) { + if (other !== c && other.name === c.name) throw new Error(`duplicate command name: ${c.name}`); + } +} +if (new Set(commands.map((c) => c.name.split(" ")[0])).size !== new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name)).size) { + // multi-word commands ("project x") must not collide with first words + const firsts = new Set(commands.filter((c) => !c.name.includes(" ")).map((c) => c.name)); + for (const c of commands.filter((c) => c.name.includes(" "))) { + if (firsts.has(c.name.split(" ")[0])) throw new Error(`name collision: ${c.name} vs ${c.name.split(" ")[0]}`); + } +} + +// ---------- index.html ---------- +const chips = commands + .filter((c) => c.suggestion && !c.hidden) + .map((c) => ` `) + .join("\n"); + +const banner = readFileSync(P("templates/banner.txt"), "utf8").replace(/\s+$/, ""); +const template = readFileSync(P("src/index.html"), "utf8"); +for (const [ph, val] of [["", chips], ["", banner]]) { + if (!template.includes(ph)) throw new Error(`src/index.html: ${ph} placeholder missing`); +} +writeFileSync( + P("k8s/site/index.html"), + template.replace("", chips).replace("", banner) +); + +// ---------- commands.json (web runtime + telnet both read this) ---------- +const json = JSON.stringify({ banner, commands }, null, 1) + "\n"; +writeFileSync(P("k8s/site/commands.json"), json); + +// ---------- hello.txt (what curl sees) ---------- +const sections = commands + .filter((c) => c.text) + .map((c) => { + const body = c.body.replace(/\$\{help\}|\$\{ls\}/g, ""); + return `${c.name}\n${"=".repeat(c.name.length)}\n${body}`.trimEnd(); + }); +writeFileSync( + P("k8s/site/hello.txt"), + banner + "\n\n" + sections.join("\n\n") + "\n\nThe interactive version of this page is at https://momme.world/\n" +); + +// ---------- copy runtimes ---------- +writeFileSync(P("k8s/site/site.js"), readFileSync(P("src/site.js"), "utf8")); +writeFileSync(P("k8s/site/telnet.py"), readFileSync(P("src/telnet.py"), "utf8")); + +// ---------- tailwind ---------- +try { + execSync("npx --yes @tailwindcss/cli@4 -i src/input.css -o k8s/site/style.css --minify", { cwd: root, stdio: "inherit" }); +} catch { + console.error("tailwind build failed — is npm install done? (style.css stays as it was)"); + process.exit(1); +} + +const words = (n) => String(n).padStart(2); +console.log(`built ${words(files.length)} commands -> k8s/site/ (${chips ? commands.filter((c) => c.suggestion).length + " chips" : "no chips"})`); diff --git a/commands/cat.md b/commands/cat.md new file mode 100644 index 0000000..bf1a33b --- /dev/null +++ b/commands/cat.md @@ -0,0 +1,7 @@ +--- +help: cat README.md +prefix: true +--- + +cat README.md the welcome message, piped +.ssh/id_rsa nice try. \ No newline at end of file diff --git a/commands/clear.md b/commands/clear.md new file mode 100644 index 0000000..e8af075 --- /dev/null +++ b/commands/clear.md @@ -0,0 +1,4 @@ +--- +help: wipe the screen +--- + diff --git a/commands/coffee.md b/commands/coffee.md new file mode 100644 index 0000000..bbe999a --- /dev/null +++ b/commands/coffee.md @@ -0,0 +1,5 @@ +--- +help: brew coffee +--- + +() ~ I'm out of coffee. \ No newline at end of file diff --git a/commands/contact.md b/commands/contact.md new file mode 100644 index 0000000..80847be --- /dev/null +++ b/commands/contact.md @@ -0,0 +1,8 @@ +--- +help: reach me directly +suggestion: true +text: true +--- + +mail -> mailto:momme@juergensen.me +matrix -> https://matrix.to/#/@momme:chaos.jetzt \ No newline at end of file diff --git a/commands/curl.md b/commands/curl.md new file mode 100644 index 0000000..eb6e54b --- /dev/null +++ b/commands/curl.md @@ -0,0 +1,6 @@ +--- +help: curl momme.world — you already did that +prefix: true +--- + +you are already reading the output :) \ No newline at end of file diff --git a/commands/date.md b/commands/date.md new file mode 100644 index 0000000..e65cc4b --- /dev/null +++ b/commands/date.md @@ -0,0 +1,5 @@ +--- +help: today's date +--- + +${date} \ No newline at end of file diff --git a/commands/echo.md b/commands/echo.md new file mode 100644 index 0000000..6463274 --- /dev/null +++ b/commands/echo.md @@ -0,0 +1,7 @@ +--- +help: echo — repeat yourself +prefix: true +params: +--- + +${args} \ No newline at end of file diff --git a/commands/emacs.md b/commands/emacs.md new file mode 100644 index 0000000..e8488dd --- /dev/null +++ b/commands/emacs.md @@ -0,0 +1,6 @@ +--- +help: an OS with good editing +prefix: true +--- + +a great OS, shame about the editor. \ No newline at end of file diff --git a/commands/exit.md b/commands/exit.md new file mode 100644 index 0000000..cd4f8a4 --- /dev/null +++ b/commands/exit.md @@ -0,0 +1,5 @@ +--- +help: close the session +--- + +close the tab, or just stop reading :) \ No newline at end of file diff --git a/commands/hello.md b/commands/hello.md new file mode 100644 index 0000000..254d52f --- /dev/null +++ b/commands/hello.md @@ -0,0 +1,10 @@ +--- +help: the welcome message +suggestion: true +text: true +--- + +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. \ No newline at end of file diff --git a/commands/help.md b/commands/help.md new file mode 100644 index 0000000..bbdfd3a --- /dev/null +++ b/commands/help.md @@ -0,0 +1,6 @@ +--- +help: this list +params: +--- + +${help} \ No newline at end of file diff --git a/commands/ls.md b/commands/ls.md new file mode 100644 index 0000000..d1078b4 --- /dev/null +++ b/commands/ls.md @@ -0,0 +1,5 @@ +--- +help: what's in here +--- + +${ls} \ No newline at end of file diff --git a/commands/project.md b/commands/project.md new file mode 100644 index 0000000..c18abd9 --- /dev/null +++ b/commands/project.md @@ -0,0 +1,11 @@ +--- +help: project — what I'm building +suggestion: true +text: true +--- + +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 \ No newline at end of file diff --git a/commands/project_brahmsee.digital.md b/commands/project_brahmsee.digital.md new file mode 100644 index 0000000..be5995d --- /dev/null +++ b/commands/project_brahmsee.digital.md @@ -0,0 +1,7 @@ +--- +name: project 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 \ No newline at end of file diff --git a/commands/project_fpv.md b/commands/project_fpv.md new file mode 100644 index 0000000..d3a2f32 --- /dev/null +++ b/commands/project_fpv.md @@ -0,0 +1,7 @@ +--- +name: project fpv +help: the flying camera budget +--- + +FPV drones — built, flown, crashed, rebuilt. +The honest way to learn soldering, PID tuning and how fast a frame becomes parts. \ No newline at end of file diff --git a/commands/project_momme.world.md b/commands/project_momme.world.md new file mode 100644 index 0000000..d28777f --- /dev/null +++ b/commands/project_momme.world.md @@ -0,0 +1,7 @@ +--- +name: project momme.world +--- + +This site. Markdown in, website out: build/build.mjs generates the commands, +the tailnet shell and this page. One nginx pod, GitOps all the way down. +-> https://git.momme.cloud/momme/momme-world \ No newline at end of file diff --git a/commands/project_nix-ha-kubernetes-cluster.md b/commands/project_nix-ha-kubernetes-cluster.md new file mode 100644 index 0000000..3e9f4ab --- /dev/null +++ b/commands/project_nix-ha-kubernetes-cluster.md @@ -0,0 +1,7 @@ +--- +name: project 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 \ No newline at end of file diff --git a/commands/project_silvatica-web.md b/commands/project_silvatica-web.md new file mode 100644 index 0000000..c1a62ab --- /dev/null +++ b/commands/project_silvatica-web.md @@ -0,0 +1,6 @@ +--- +name: project silvatica-web +--- + +Website of the Silvatica festival in Schwäbisch Hall. +-> https://github.com/supermomme/silvatica-web \ No newline at end of file diff --git a/commands/pwd.md b/commands/pwd.md new file mode 100644 index 0000000..9ef4d63 --- /dev/null +++ b/commands/pwd.md @@ -0,0 +1,5 @@ +--- +help: working directory +--- + +/world \ No newline at end of file diff --git a/commands/social.md b/commands/social.md new file mode 100644 index 0000000..b2e1b21 --- /dev/null +++ b/commands/social.md @@ -0,0 +1,9 @@ +--- +help: where I hang out online +suggestion: true +text: true +--- + +github -> https://github.com/supermomme +mastodon -> https://cybre.town/@momme +instagram -> https://instagram.com/supermommej \ No newline at end of file diff --git a/commands/stack.md b/commands/stack.md new file mode 100644 index 0000000..481ac42 --- /dev/null +++ b/commands/stack.md @@ -0,0 +1,10 @@ +--- +help: the tools I reach for +suggestion: true +text: true +--- + +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 \ No newline at end of file diff --git a/commands/sudo.md b/commands/sudo.md new file mode 100644 index 0000000..81367bc --- /dev/null +++ b/commands/sudo.md @@ -0,0 +1,6 @@ +--- +help: elevate +prefix: true +--- + +momme is not in the sudoers file. This incident has been reported. \ No newline at end of file diff --git a/commands/time.md b/commands/time.md new file mode 100644 index 0000000..0695b7c --- /dev/null +++ b/commands/time.md @@ -0,0 +1,5 @@ +--- +help: current time +--- + +${time} \ No newline at end of file diff --git a/commands/uname.md b/commands/uname.md new file mode 100644 index 0000000..7900baf --- /dev/null +++ b/commands/uname.md @@ -0,0 +1,6 @@ +--- +help: system information +prefix: true +--- + +momme.world 1.0.0 static x86_64 GNU/freedom \ No newline at end of file diff --git a/commands/vim.md b/commands/vim.md new file mode 100644 index 0000000..42a453f --- /dev/null +++ b/commands/vim.md @@ -0,0 +1,6 @@ +--- +help: an editor +prefix: true +--- + +emacs, obviously. \ No newline at end of file diff --git a/commands/whoami.md b/commands/whoami.md new file mode 100644 index 0000000..c43347d --- /dev/null +++ b/commands/whoami.md @@ -0,0 +1,9 @@ +--- +help: who is typing +suggestion: true +text: true +--- + +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. \ No newline at end of file diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 30ba92b..0c3ae26 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -63,6 +63,41 @@ spec: readOnlyRootFilesystem: true capabilities: drop: ["ALL"] + # the telnet shell — same commands.json as the website, stdlib python + - name: telnet + image: docker.io/library/python:3.14-alpine@sha256:7f2ec713f0436c59df2362d973b6a05af9e87ba78f0e94f5245c302984a7a1fd + command: ["python", "-u", "/app/telnet.py"] + env: + - name: TELNET_PORT + value: "2323" + ports: + - name: telnet + containerPort: 2323 + volumeMounts: + - name: telnet + mountPath: /app/telnet.py + subPath: telnet.py + readOnly: true + - name: site + mountPath: /app/commands.json + subPath: commands.json + readOnly: true + startupProbe: + tcpSocket: + port: telnet + failureThreshold: 30 + periodSeconds: 2 + resources: + requests: + cpu: 10m + memory: 48Mi + limits: + memory: 96Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] volumes: - name: site configMap: @@ -70,5 +105,8 @@ spec: - name: config configMap: name: config + - name: telnet + configMap: + name: telnet - name: tmp emptyDir: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index f67439e..c11037e 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -10,9 +10,13 @@ configMapGenerator: - site/index.html - site/style.css - site/site.js + - site/commands.json - site/hello.txt - site/favicon.svg - site/robots.txt - name: config files: - site/nginx.conf + - name: telnet + files: + - site/telnet.py diff --git a/k8s/service.yaml b/k8s/service.yaml index 0184c49..5de5f47 100644 --- a/k8s/service.yaml +++ b/k8s/service.yaml @@ -11,3 +11,22 @@ spec: - name: http port: 80 targetPort: http +--- +# The telnet shell, for the tailnet. Cilium assigns the IP from the LB pool +# entry in sys-cilium/lb.tf (same pattern as gitea-ssh). Reach it with +# `telnet 23` from inside the tailnet. +apiVersion: v1 +kind: Service +metadata: + name: momme-world-telnet + labels: + app: momme-world +spec: + type: LoadBalancer + externalTrafficPolicy: Local + selector: + app: momme-world + ports: + - name: telnet + port: 23 + targetPort: telnet diff --git a/k8s/site/commands.json b/k8s/site/commands.json new file mode 100644 index 0000000..bb5f2fa --- /dev/null +++ b/k8s/site/commands.json @@ -0,0 +1,265 @@ +{ + "banner": " _ _ \n | | | |\n _ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| |\n | '_ ` _ \\ / _ \\| '_ ` _ \\| '_ ` _ \\ / _ \\ \\ \\ /\\ / / _ \\| '__| |/ _` |\n | | | | | | (_) | | | | | | | | | | | __/ _ \\ V V / (_) | | | | (_| |\n |_| |_| |_|\\___/|_| |_| |_|_| |_| |_|\\___| (_) \\_/\\_/ \\___/|_| |_|\\__,_|\n\n0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100", + "commands": [ + { + "name": "cat", + "help": "cat README.md", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "cat README.md the welcome message, piped\n.ssh/id_rsa nice try." + }, + { + "name": "clear", + "help": "wipe the screen", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "" + }, + { + "name": "coffee", + "help": "brew coffee", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "() ~ I'm out of coffee." + }, + { + "name": "contact", + "help": "reach me directly", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "mail -> mailto:momme@juergensen.me\nmatrix -> https://matrix.to/#/@momme:chaos.jetzt" + }, + { + "name": "curl", + "help": "curl momme.world — you already did that", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "you are already reading the output :)" + }, + { + "name": "date", + "help": "today's date", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "${date}" + }, + { + "name": "echo", + "help": "echo — repeat yourself", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "${args}" + }, + { + "name": "emacs", + "help": "an OS with good editing", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "a great OS, shame about the editor." + }, + { + "name": "exit", + "help": "close the session", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "close the tab, or just stop reading :)" + }, + { + "name": "hello", + "help": "the welcome message", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "Hey :)\nI'm Momme. I build and break infrastructure, and I keep the pieces in\nKubernetes so they can be put back together again.\nType help to see what this box knows." + }, + { + "name": "help", + "help": "this list", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "${help}" + }, + { + "name": "ls", + "help": "what's in here", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "${ls}" + }, + { + "name": "project", + "help": "project — what I'm building", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on\nbrahmsee.digital event management platform for DLRG Schleswig-Holstein\nsilvatica-web website of the Silvatica festival, Schwäbisch Hall\nmomme.world this site — no build step, GitOps all the way down\n more of them at: https://github.com/supermomme" + }, + { + "name": "project brahmsee.digital", + "help": "", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "Event management platform for DLRG Schleswig-Holstein.\nRegistrations and scheduling for a state water-rescue organisation, where the\ntraffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital" + }, + { + "name": "project fpv", + "help": "the flying camera budget", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "FPV drones — built, flown, crashed, rebuilt.\nThe honest way to learn soldering, PID tuning and how fast a frame becomes parts." + }, + { + "name": "project momme.world", + "help": "", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "This site. Markdown in, website out: build/build.mjs generates the commands,\nthe tailnet shell and this page. One nginx pod, GitOps all the way down.\n-> https://git.momme.cloud/momme/momme-world" + }, + { + "name": "project nix-ha-kubernetes-cluster", + "help": "", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "An HA Kubernetes cluster on NixOS, built to learn rather than to impress.\nControl plane in HA, Ceph for storage, GitOps for everything above it.\nThis site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster" + }, + { + "name": "project silvatica-web", + "help": "", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "Website of the Silvatica festival in Schwäbisch Hall.\n-> https://github.com/supermomme/silvatica-web" + }, + { + "name": "pwd", + "help": "working directory", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "/world" + }, + { + "name": "social", + "help": "where I hang out online", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "github -> https://github.com/supermomme\nmastodon -> https://cybre.town/@momme\ninstagram -> https://instagram.com/supermommej" + }, + { + "name": "stack", + "help": "the tools I reach for", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest\nnix reproducible configs for servers and laptops\nobservability Prometheus, Loki, Grafana — dashboards I actually read\nfpv drones built, flown, crashed, rebuilt" + }, + { + "name": "sudo", + "help": "elevate", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "momme is not in the sudoers file. This incident has been reported." + }, + { + "name": "time", + "help": "current time", + "params": "", + "prefix": false, + "suggestion": false, + "hidden": false, + "text": false, + "body": "${time}" + }, + { + "name": "uname", + "help": "system information", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "momme.world 1.0.0 static x86_64 GNU/freedom" + }, + { + "name": "vim", + "help": "an editor", + "params": "", + "prefix": true, + "suggestion": false, + "hidden": false, + "text": false, + "body": "emacs, obviously." + }, + { + "name": "whoami", + "help": "who is typing", + "params": "", + "prefix": false, + "suggestion": true, + "hidden": false, + "text": true, + "body": "Momme Jürgensen — infrastructure and developer platforms.\nI like work that ends up as a short command instead of a long document.\nCurrently at codeanker." + } + ] +} diff --git a/k8s/site/hello.txt b/k8s/site/hello.txt index 5d9567d..004875d 100644 --- a/k8s/site/hello.txt +++ b/k8s/site/hello.txt @@ -5,37 +5,45 @@ | | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| | |_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_| -0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100 +0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100 +contact +======= +mail -> mailto:momme@juergensen.me +matrix -> https://matrix.to/#/@momme:chaos.jetzt + +hello +===== 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. -whoami ------- -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. +project +======= +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 + +social +====== +github -> https://github.com/supermomme +mastodon -> https://cybre.town/@momme +instagram -> https://instagram.com/supermommej stack ------ +===== 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 -projects --------- -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 - -contact -------- -mail momme@juergensen.me -matrix @momme:juergensen.me -github https://github.com/supermomme -mastodon https://cybre.town/@momme +whoami +====== +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. The interactive version of this page is at https://momme.world/ diff --git a/k8s/site/index.html b/k8s/site/index.html index ec11415..4975bd5 100644 --- a/k8s/site/index.html +++ b/k8s/site/index.html @@ -13,81 +13,56 @@ - - -
+ -
-
- - momme@world: ~ +
+ +
+
+ + + + momme@world: ~
-
-
-                                                                        _     _ 
+    
+
                                                                        _     _ 
                                                                        | |   | |
       _ __ ___   ___  _ __ ___  _ __ ___   ___      __      _____  _ __| | __| |
      | '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \     \ \ /\ / / _ \| '__| |/ _` |
      | | | | | | (_) | | | | | | | | | | |  __/  _   \ V  V / (_) | |  | | (_| |
      |_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_)   \_/\_/ \___/|_|  |_|\__,_|
 
-0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100 
-      
+0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100
-
+
-
- + + - +
-
diff --git a/k8s/site/nginx.conf b/k8s/site/nginx.conf index d4e1a8d..3db0b5f 100644 --- a/k8s/site/nginx.conf +++ b/k8s/site/nginx.conf @@ -32,6 +32,11 @@ server { add_header Cache-Control "no-store"; } + # the command data the terminal fetches at boot: same freshness rule + location = /commands.json { + add_header Cache-Control "no-store"; + } + location ~* \.(css|js|svg)$ { add_header Cache-Control "public, max-age=604800"; } diff --git a/k8s/site/site.js b/k8s/site/site.js index 6fdc6df..6be2ed1 100644 --- a/k8s/site/site.js +++ b/k8s/site/site.js @@ -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 — 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: ", "", ...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 — 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: ", "", ...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(); diff --git a/k8s/site/style.css b/k8s/site/style.css index 53a5a7a..d96f9de 100644 --- a/k8s/site/style.css +++ b/k8s/site/style.css @@ -1,136 +1,2 @@ -:root { - --bg: #0b0e14; - --panel: #11151f; - --border: #232b3a; - --fg: #d6deeb; - --dim: #7c879b; - --accent: #7ee787; - --accent-2: #6cb6ff; - --warn: #f2cc60; - --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Mono", - Menlo, Consolas, monospace; -} - -* { box-sizing: border-box; } - -html, body { margin: 0; } - -body { - background: var(--bg); - color: var(--fg); - font-family: var(--mono); - font-size: 15px; - line-height: 1.6; - -webkit-font-smoothing: antialiased; - display: flex; - justify-content: center; - padding: clamp(12px, 4vw, 48px) clamp(10px, 3vw, 24px) 40px; -} - -main { width: 100%; max-width: 46rem; } - -.term { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); -} - -.titlebar { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: #151a26; - border-bottom: 1px solid var(--border); - font-size: 12px; - color: var(--dim); -} -.dot { width: 11px; height: 11px; border-radius: 50%; } -.dot.r { background: #ff5f57; } -.dot.y { background: #febc2e; } -.dot.g { background: #28c840; } -.title { margin-left: 8px; } - -.screen { - padding: 18px clamp(12px, 2.5vw, 22px) 20px; - min-height: 58vh; - max-height: 78vh; - overflow-y: auto; - overscroll-behavior: contain; - outline: none; -} - -.hello { - color: var(--accent-2); - font-size: clamp(6.5px, 1.55vw, 12px); - line-height: 1.25; - margin: 0 0 18px; - overflow-x: auto; -} - -#out { white-space: pre-wrap; word-break: break-word; } - -.line { margin: 0; display: block; } -.line.cmd { color: var(--fg); } -.line.cmd::before { content: "momme@world:~$ "; color: var(--dim); } -.line.out { color: var(--fg); margin-bottom: 6px; } -.line.dim { color: var(--dim); } -.line.err { color: #ff6b6b; } -a { color: var(--accent-2); text-decoration: none; } -a:hover, a:focus-visible { text-decoration: underline; } -.hl { color: var(--accent); } -.key { color: var(--warn); } - -.cursor { - display: inline-block; - width: 8px; - height: 1.05em; - background: var(--accent); - vertical-align: text-bottom; - animation: blink 1.1s steps(1) infinite; -} -@keyframes blink { 50% { opacity: 0; } } - -#prompt { display: flex; align-items: baseline; gap: 10px; margin-top: 4px; } -.ps1 { color: var(--dim); white-space: nowrap; } -#cmd { - flex: 1; - min-width: 0; - background: none; - border: 0; - outline: none; - color: var(--fg); - font: inherit; - caret-color: var(--accent); -} -#cmd::placeholder { color: #4b5566; } - -.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; } -.chips button { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 999px; - color: var(--dim); - font: inherit; - font-size: 13px; - padding: 5px 14px; - cursor: pointer; - transition: color .15s, border-color .15s; -} -.chips button:hover, .chips button:focus-visible { - color: var(--accent); - border-color: var(--accent); - outline: none; -} - -.curl { color: var(--dim); font-size: 13px; margin: 16px 2px 0; } -.curl code { background: var(--panel); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; } - -.facts h2 { font-size: 15px; color: var(--accent); margin: 26px 0 8px; } -.facts ul { padding-left: 20px; } - -@media (prefers-reduced-motion: reduce) { - .cursor { animation: none; } -} +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:#0a0e14;--color-panel:#10161f;--color-bar:#161d29;--color-line:#1f2937;--color-fg:#d7e3d7;--color-dim:#5c6f8a;--color-accent:#7dd3a0}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mt-1{margin-top:var(--spacing)}.mt-4{margin-top:calc(var(--spacing) * 4)}.-ml-\[0\.6em\]{margin-left:-.6em}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.flex{display:flex}.hidden{display:none}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.h-\[62dvh\]{height:62dvh}.min-h-\[1\.35em\]{min-height:1.35em}.min-h-dvh{min-height:100dvh}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-text{cursor:text}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-line{border-color:var(--color-line)}.bg-\[\#27c93f\]{background-color:#27c93f}.bg-\[\#ff5f56\]{background-color:#ff5f56}.bg-\[\#ffbd2e\]{background-color:#ffbd2e}.bg-bar{background-color:var(--color-bar)}.bg-bg{background-color:var(--color-bg)}.bg-panel{background-color:var(--color-panel)}.bg-transparent{background-color:#0000}.p-0{padding:0}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[13\.5px\]{font-size:13.5px}.leading-\[1\.35\]{--tw-leading:1.35;line-height:1.35}.break-words{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#5c6f8a\]{color:#5c6f8a}.text-\[\#ff7b72\]{color:#ff7b72}.text-accent{color:var(--color-accent)}.text-dim{color:var(--color-dim)}.text-fg{color:var(--color-fg)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.caret-transparent{caret-color:#0000}.shadow-\[0_0_40px_rgba\(0\,0\,0\,\.5\)\]{--tw-shadow:0 0 40px var(--tw-shadow-color,#00000080);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.selection\:bg-accent\/30 ::selection{background-color:#7dd3a04d}@supports (color:color-mix(in lab, red, red)){.selection\:bg-accent\/30 ::selection{background-color:color-mix(in oklab, var(--color-accent) 30%, transparent)}}.selection\:bg-accent\/30::selection{background-color:#7dd3a04d}@supports (color:color-mix(in lab, red, red)){.selection\:bg-accent\/30::selection{background-color:color-mix(in oklab, var(--color-accent) 30%, transparent)}}.placeholder\:text-dim\/60::placeholder{color:#5c6f8a99}@supports (color:color-mix(in lab, red, red)){.placeholder\:text-dim\/60::placeholder{color:color-mix(in oklab, var(--color-dim) 60%, transparent)}}@media (hover:hover){.hover\:border-accent:hover{border-color:var(--color-accent)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:text-\[15px\]{font-size:15px}}}.cursor{background:var(--color-accent);block-size:1.1em;inline-size:.6em;animation:1.1s step-end infinite blink;display:inline-block;translate:0 .2em}@keyframes blink{50%{opacity:0}}@media (prefers-reduced-motion:reduce){.cursor{animation:none}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file diff --git a/k8s/site/telnet.py b/k8s/site/telnet.py new file mode 100644 index 0000000..3dbb01b --- /dev/null +++ b/k8s/site/telnet.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""momme.world telnet shell. + +Serves the same commands as the website, to real terminals: + telnet momme.world 23 + +Content lives in commands.json next to this file (generated from commands/*.md +by build/build.mjs) — this server is only a REPL. Standard library only. It +answers enough telnet negotiation to keep classic clients happy; it is not a +login shell, there is nothing here to log into. + +Inside the cluster it listens on TELNET_PORT (2323); the public port 23 +arrives via Envoy Gateway's TCPRoute on the tailscale funnel. +""" + +import json +import os +import re +import select +import socketserver +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +COMMANDS_FILE = os.environ.get("COMMANDS_FILE", os.path.join(HERE, "commands.json")) +PORT = int(os.environ.get("TELNET_PORT", "2323")) +IDLE_TIMEOUT = 300 # seconds; five idle minutes is a long enough hello + +# telnet negotiation: answer WILL/WONT so clients stop asking +IAC, WONT, ECHO, SGA = 255, 252, 1, 3 + +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", "quit": "exit", +} + +with open(COMMANDS_FILE, encoding="utf-8") as fh: + SITE = json.load(fh) +COMMANDS = {c["name"]: c for c in SITE["commands"]} +BANNER = SITE["banner"] + + +def usages(): + return [ + f" {c['name']:<22} {c['help']}" + for c in SITE["commands"] + if not c.get("hidden") and c.get("help") + ] + + +def substitute(body, arg): + def rep(match): + key = match.group(1) + if key == "args": + return arg + if key == "date": + return time.strftime("%a %b %d %Y") + if key == "time": + return time.strftime("%H:%M:%S") + if key == "help": + return "\n".join(["usage: ", ""] + usages()) + if key == "ls": + return " ".join(n.split(" ", 1)[1] for n in COMMANDS if " " in n) + return "" + return re.sub(r"\$\{(\w+)\}", rep, body) + + +def lookup(words): + for length in range(len(words), 0, -1): + joined = " ".join(words[:length]) + cmd = COMMANDS.get(ALIASES.get(joined, joined)) + if cmd: + return cmd, " ".join(words[length:]) + return None, "" + + + +def run(line): + """Return (text, quit_after). Empty text means print nothing.""" + words = [w for w in line.strip().lower().split() if w] + if not words: + return "", False + cmd, arg = lookup(words) + if not cmd: + near = [n for n in COMMANDS if n != words[0] and n.startswith(words[0])][:3] + hint = "did you mean: " + ", ".join(near) if near else "see: help" + return f"command not found: {words[0]}\n{hint}", False + if cmd["name"] == "clear": + return "\033[2J\033[H", False + if cmd["name"] == "exit": + return "bye — https://momme.world/", True + if arg and not cmd.get("params") and not cmd.get("prefix"): + kids = [n[len(cmd["name"]) + 1:] for n in COMMANDS if n.startswith(cmd["name"] + " ")] + if kids: + kid = COMMANDS.get(cmd["name"] + " " + arg) + if kid: + return substitute(kid["body"], ""), False + names = ", ".join(kids) + return f'no {cmd["name"]} called "{arg}"\nnames: {names}', False + return substitute(cmd["body"], arg), cmd["name"] == "quit" + + +class Shell(socketserver.BaseRequestHandler): + def send(self, text): + try: + self.request.sendall(text.replace("\n", "\r\n").encode("utf-8")) + except OSError: + raise ConnectionError + + def setup(self): + try: + self.request.sendall(bytes([IAC, WONT, ECHO, IAC, WONT, SGA])) + self.send(BANNER + "\n\nhello — type help for the rest of it.\n") + self.loop() + except (ConnectionError, ConnectionResetError, BrokenPipeError): + pass + + def loop(self): + line = "" + self.send("momme@world:~$ ") + while True: + ready, _, _ = select.select([self.request], [], [], IDLE_TIMEOUT) + if not ready: + return + data = self.request.recv(1024) + if not data: + return + i = 0 + while i < len(data): + byte = data[i] + if byte == IAC: + i += 3 # skip a negotiation triple wholesale + continue + i += 1 + if byte in (13, 10): + self.send("\n") + text, goodbye = run(line) + if text: + self.send(text + "\n") + if goodbye: + self.send("closing connection\n") + return + line = "" + self.send("momme@world:~$ ") + elif byte in (127, 8): + if line: + line = line[:-1] + self.send("\b \b") + elif 32 <= byte < 127: + line += chr(byte) + self.send(chr(byte)) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + with Server(("0.0.0.0", PORT), Shell) as srv: + print(f"momme.world telnet shell on :{PORT}", file=sys.stderr, flush=True) + srv.serve_forever() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ab9c371 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1170 @@ +{ + "name": "momme-world", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "momme-world", + "devDependencies": { + "@tailwindcss/cli": "^4.1.0", + "tailwindcss": "^4.1.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d98b3c3 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "momme-world", + "private": true, + "type": "module", + "description": "momme.world — commands/*.md in, website out", + "scripts": { + "build": "node build/build.mjs" + }, + "devDependencies": { + "tailwindcss": "^4.1.0", + "@tailwindcss/cli": "^4.1.0" + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..c46feda --- /dev/null +++ b/src/index.html @@ -0,0 +1,57 @@ + + + + + +momme.world — Momme Jürgensen + + + + + + + + + + + + +
+ +
+
+ + + + momme@world: ~ +
+ +
+
+ +
+ +
+ + + +
+
+
+ + + +

+ Terminal people: curl momme.world +  ·  tailnet: telnet momme.world 23 +  ·  source: git.momme.cloud/momme/momme-world +

+ +
+ + + diff --git a/src/input.css b/src/input.css new file mode 100644 index 0000000..fc45db1 --- /dev/null +++ b/src/input.css @@ -0,0 +1,31 @@ +@import "tailwindcss"; + +/* Source scanning: the generated output (template + chips + runtime) carries + the class names; build order guarantees it exists before this runs. */ +@source "../k8s/site"; + +@theme { + --color-bg: #0a0e14; + --color-panel: #10161f; + --color-bar: #161d29; + --color-line: #1f2937; + --color-fg: #d7e3d7; + --color-dim: #5c6f8a; + --color-accent: #7dd3a0; + --font-mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; +} + +/* block caret: the blinking square, reused by the typewriter */ +.cursor { + display: inline-block; + inline-size: 0.6em; + block-size: 1.1em; + translate: 0 0.2em; + background: var(--color-accent); + animation: blink 1.1s steps(1) infinite; +} +@keyframes blink { 50% { opacity: 0 } } + +@media (prefers-reduced-motion: reduce) { + .cursor { animation: none; } +} diff --git a/src/site.js b/src/site.js new file mode 100644 index 0000000..6be2ed1 --- /dev/null +++ b/src/site.js @@ -0,0 +1,303 @@ +// 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: [] }, "") +); diff --git a/src/telnet.py b/src/telnet.py new file mode 100644 index 0000000..3dbb01b --- /dev/null +++ b/src/telnet.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""momme.world telnet shell. + +Serves the same commands as the website, to real terminals: + telnet momme.world 23 + +Content lives in commands.json next to this file (generated from commands/*.md +by build/build.mjs) — this server is only a REPL. Standard library only. It +answers enough telnet negotiation to keep classic clients happy; it is not a +login shell, there is nothing here to log into. + +Inside the cluster it listens on TELNET_PORT (2323); the public port 23 +arrives via Envoy Gateway's TCPRoute on the tailscale funnel. +""" + +import json +import os +import re +import select +import socketserver +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +COMMANDS_FILE = os.environ.get("COMMANDS_FILE", os.path.join(HERE, "commands.json")) +PORT = int(os.environ.get("TELNET_PORT", "2323")) +IDLE_TIMEOUT = 300 # seconds; five idle minutes is a long enough hello + +# telnet negotiation: answer WILL/WONT so clients stop asking +IAC, WONT, ECHO, SGA = 255, 252, 1, 3 + +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", "quit": "exit", +} + +with open(COMMANDS_FILE, encoding="utf-8") as fh: + SITE = json.load(fh) +COMMANDS = {c["name"]: c for c in SITE["commands"]} +BANNER = SITE["banner"] + + +def usages(): + return [ + f" {c['name']:<22} {c['help']}" + for c in SITE["commands"] + if not c.get("hidden") and c.get("help") + ] + + +def substitute(body, arg): + def rep(match): + key = match.group(1) + if key == "args": + return arg + if key == "date": + return time.strftime("%a %b %d %Y") + if key == "time": + return time.strftime("%H:%M:%S") + if key == "help": + return "\n".join(["usage: ", ""] + usages()) + if key == "ls": + return " ".join(n.split(" ", 1)[1] for n in COMMANDS if " " in n) + return "" + return re.sub(r"\$\{(\w+)\}", rep, body) + + +def lookup(words): + for length in range(len(words), 0, -1): + joined = " ".join(words[:length]) + cmd = COMMANDS.get(ALIASES.get(joined, joined)) + if cmd: + return cmd, " ".join(words[length:]) + return None, "" + + + +def run(line): + """Return (text, quit_after). Empty text means print nothing.""" + words = [w for w in line.strip().lower().split() if w] + if not words: + return "", False + cmd, arg = lookup(words) + if not cmd: + near = [n for n in COMMANDS if n != words[0] and n.startswith(words[0])][:3] + hint = "did you mean: " + ", ".join(near) if near else "see: help" + return f"command not found: {words[0]}\n{hint}", False + if cmd["name"] == "clear": + return "\033[2J\033[H", False + if cmd["name"] == "exit": + return "bye — https://momme.world/", True + if arg and not cmd.get("params") and not cmd.get("prefix"): + kids = [n[len(cmd["name"]) + 1:] for n in COMMANDS if n.startswith(cmd["name"] + " ")] + if kids: + kid = COMMANDS.get(cmd["name"] + " " + arg) + if kid: + return substitute(kid["body"], ""), False + names = ", ".join(kids) + return f'no {cmd["name"]} called "{arg}"\nnames: {names}', False + return substitute(cmd["body"], arg), cmd["name"] == "quit" + + +class Shell(socketserver.BaseRequestHandler): + def send(self, text): + try: + self.request.sendall(text.replace("\n", "\r\n").encode("utf-8")) + except OSError: + raise ConnectionError + + def setup(self): + try: + self.request.sendall(bytes([IAC, WONT, ECHO, IAC, WONT, SGA])) + self.send(BANNER + "\n\nhello — type help for the rest of it.\n") + self.loop() + except (ConnectionError, ConnectionResetError, BrokenPipeError): + pass + + def loop(self): + line = "" + self.send("momme@world:~$ ") + while True: + ready, _, _ = select.select([self.request], [], [], IDLE_TIMEOUT) + if not ready: + return + data = self.request.recv(1024) + if not data: + return + i = 0 + while i < len(data): + byte = data[i] + if byte == IAC: + i += 3 # skip a negotiation triple wholesale + continue + i += 1 + if byte in (13, 10): + self.send("\n") + text, goodbye = run(line) + if text: + self.send(text + "\n") + if goodbye: + self.send("closing connection\n") + return + line = "" + self.send("momme@world:~$ ") + elif byte in (127, 8): + if line: + line = line[:-1] + self.send("\b \b") + elif 32 <= byte < 127: + line += chr(byte) + self.send(chr(byte)) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + with Server(("0.0.0.0", PORT), Shell) as srv: + print(f"momme.world telnet shell on :{PORT}", file=sys.stderr, flush=True) + srv.serve_forever() diff --git a/templates/banner.txt b/templates/banner.txt new file mode 100644 index 0000000..2554e07 --- /dev/null +++ b/templates/banner.txt @@ -0,0 +1,8 @@ + _ _ + | | | | + _ __ ___ ___ _ __ ___ _ __ ___ ___ __ _____ _ __| | __| | + | '_ ` _ \ / _ \| '_ ` _ \| '_ ` _ \ / _ \ \ \ /\ / / _ \| '__| |/ _` | + | | | | | | (_) | | | | | | | | | | | __/ _ \ V V / (_) | | | | (_| | + |_| |_| |_|\___/|_| |_| |_|_| |_| |_|\___| (_) \_/\_/ \___/|_| |_|\__,_| + +0110110101101111011011010110110101100101001011100111011101101111011100100110110001100100