momme.world: terminal-style personal site, nginx + ConfigMap deploy
Static site (no build step) with a browser terminal, plain-text output for curl, and plain k8s manifests under k8s/ for ArgoCD.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.svg text eol=lf
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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`.
|
||||
@@ -0,0 +1,74 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: momme-world
|
||||
labels:
|
||||
app: momme-world
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: momme-world
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: momme-world
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
fsGroup: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: nginx
|
||||
image: docker.io/nginxinc/nginx-unprivileged:1.31.6-alpine@sha256:884e0b7f42f303641b0f4e78755aea647521dca0b89ca3be7992ad78b5c07434
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: site
|
||||
mountPath: /var/www/html
|
||||
readOnly: true
|
||||
- name: config
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /hello.txt
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /hello.txt
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: site
|
||||
configMap:
|
||||
name: site
|
||||
- name: config
|
||||
configMap:
|
||||
name: config
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: app-momme-world
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
configMapGenerator:
|
||||
- name: site
|
||||
files:
|
||||
- site/index.html
|
||||
- site/style.css
|
||||
- site/site.js
|
||||
- site/hello.txt
|
||||
- site/favicon.svg
|
||||
- site/robots.txt
|
||||
- name: config
|
||||
files:
|
||||
- site/nginx.conf
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: momme-world
|
||||
labels:
|
||||
app: momme-world
|
||||
spec:
|
||||
selector:
|
||||
app: momme-world
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0b0e14"/>
|
||||
<rect x="5" y="9" width="22" height="15" rx="2" fill="none" stroke="#7ee787" stroke-width="2"/>
|
||||
<path d="M9 15l4 3-4 3M16 21h6" stroke="#7ee787" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,39 @@
|
||||
|
||||
__ __ __ .
|
||||
| \/ | ___ _ __ ___ _ __ \ \ __ | | ___ ___
|
||||
| |\/| |/ _ \| '_ ` _ \| '_ \ \ \ / _` |/ _ \ / _ \
|
||||
| | | | (_) | | | | | | | | | \ V (_| | (_) | __/
|
||||
|_| |_|\___/|_| |_| |_|_| |_| \_/\__,_|\___/ \___|
|
||||
|
||||
Hey :)
|
||||
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
||||
Kubernetes so they can be put back together again.
|
||||
|
||||
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.
|
||||
|
||||
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:chaos.jetzt
|
||||
github https://github.com/supermomme
|
||||
mastodon https://cybre.town/@momme
|
||||
|
||||
The interactive version of this page is at https://momme.world/
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>momme.world — Momme Jürgensen</title>
|
||||
<meta name="description" content="Hi, I'm Momme. I build and break infrastructure: Kubernetes, NixOS, self-hosting, and FPV drones. Say hello on the terminal.">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:title" content="momme.world">
|
||||
<meta property="og:description" content="Hi, I'm Momme. I build and break infrastructure: Kubernetes, NixOS, self-hosting, and FPV drones.">
|
||||
<meta property="og:url" content="https://momme.world/">
|
||||
<link rel="canonical" href="https://momme.world/">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script>document.documentElement.classList.add("js")</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<div class="term" id="term">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title">momme@world: ~</span>
|
||||
</div>
|
||||
|
||||
<div class="screen" id="screen" tabindex="-1">
|
||||
<pre class="hello">
|
||||
__ __ __ .
|
||||
| \/ | ___ _ __ ___ _ __ \ \ __ | | ___ ___
|
||||
| |\/| |/ _ \| '_ ` _ \| '_ \ \ \ / _` |/ _ \ / _ \
|
||||
| | | | (_) | | | | | | | | | \ V (_| | (_) | __/
|
||||
|_| |_|\___/|_| |_| |_|_| |_| \_/\__,_|\___/ \___|
|
||||
</pre>
|
||||
|
||||
<div id="out"></div>
|
||||
|
||||
<form id="prompt" autocomplete="off">
|
||||
<span class="ps1" aria-hidden="true">momme@world:~$</span>
|
||||
<input id="cmd" aria-label="Run a command" placeholder="type a command — try `help`"
|
||||
autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="caret" aria-hidden="true"></span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="chips" aria-label="Commands">
|
||||
<button data-run="whoami">whoami</button>
|
||||
<button data-run="projects">projects</button>
|
||||
<button data-run="stack">stack</button>
|
||||
<button data-run="social">social</button>
|
||||
<button data-run="contact">contact</button>
|
||||
</nav>
|
||||
|
||||
<p class="curl">
|
||||
Terminal people: <code>curl momme.world</code>
|
||||
· source: <a href="https://github.com/supermomme">github.com/supermomme</a>
|
||||
</p>
|
||||
|
||||
<!-- Readable and copy-pasteable with JavaScript turned off. The terminal
|
||||
above is the same information, delivered as a conversation. -->
|
||||
<noscript>
|
||||
<section class="facts">
|
||||
<h2>whoami</h2>
|
||||
<p>I'm <strong>Momme Jürgensen</strong>. I work on infrastructure and developer
|
||||
platforms — the kind of work where the best interface is a short command.
|
||||
Currently at <a href="https://codeanker.de">codeanker</a>.</p>
|
||||
|
||||
<h2>stack</h2>
|
||||
<ul>
|
||||
<li><strong>Kubernetes</strong> — self-hosted cluster, GitOps, the whole garden.</li>
|
||||
<li><strong>NixOS</strong> — reproducible configs for servers and laptops.</li>
|
||||
<li><strong>Observability</strong> — Prometheus, Loki, dashboards I actually read.</li>
|
||||
<li><strong>FPV drones</strong> — built, flown, crashed, rebuilt.</li>
|
||||
</ul>
|
||||
|
||||
<h2>contact</h2>
|
||||
<ul>
|
||||
<li>Mail: <a href="mailto:momme@juergensen.me">momme@juergensen.me</a></li>
|
||||
<li>Matrix: <a href="https://matrix.to/#/@momme:chaos.jetzt">@momme:chaos.jetzt</a></li>
|
||||
<li>Mastodon: <a href="https://cybre.town/@momme">@momme@cybre.town</a></li>
|
||||
<li>GitHub: <a href="https://github.com/supermomme">supermomme</a></li>
|
||||
<li>Instagram: <a href="https://instagram.com/supermommej">@supermommej</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</noscript>
|
||||
|
||||
</main>
|
||||
<script src="/site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
# Mounted at /etc/nginx/conf.d/default.conf — everything here is http context.
|
||||
|
||||
# terminal people get the plain text version of the page
|
||||
map $http_user_agent $root_doc {
|
||||
default /index.html;
|
||||
"~*(curl|wget|httpie|lynx|w3m|nc/)" /hello.txt;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
absolute_redirect off;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript text/plain image/svg+xml;
|
||||
gzip_vary on;
|
||||
|
||||
# index.html is the app itself: never cache it, so a deploy is visible now
|
||||
location = / {
|
||||
try_files $root_doc =404;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /hello.txt {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location ~* \.(css|js|svg)$ {
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -0,0 +1,244 @@
|
||||
// Content lives in COMMANDS only: every entry renders in the terminal, and the
|
||||
// names double as the `help` listing. A `label -> url` line becomes a real link.
|
||||
// Adding a section to this site means adding one object here.
|
||||
const COMMANDS = [
|
||||
{
|
||||
name: "hello",
|
||||
help: "the welcome message",
|
||||
body: `Hey :)
|
||||
I'm Momme. I build and break infrastructure, and I keep the pieces in
|
||||
Kubernetes so they can be put back together again.
|
||||
Type help to see what this box knows.`,
|
||||
},
|
||||
{
|
||||
name: "whoami",
|
||||
help: "who is typing",
|
||||
body: `Momme Jürgensen — infrastructure and developer platforms.
|
||||
I like work that ends up as a short command instead of a long document.
|
||||
Currently at codeanker.`,
|
||||
},
|
||||
{
|
||||
name: "projects",
|
||||
help: "projects <name> — what I'm building",
|
||||
body: `nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on
|
||||
brahmsee.digital event management platform for DLRG Schleswig-Holstein
|
||||
silvatica-web website of the Silvatica festival, Schwäbisch Hall
|
||||
momme.world this site — no build step, GitOps all the way down
|
||||
more of them at: https://github.com/supermomme`,
|
||||
detail: {
|
||||
"nix-ha-kubernetes-cluster": `An HA Kubernetes cluster on NixOS, built to learn rather than to impress.
|
||||
Control plane in HA, Ceph for storage, GitOps for everything above it.
|
||||
This site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster`,
|
||||
"brahmsee.digital": `Event management platform for DLRG Schleswig-Holstein.
|
||||
Registrations and scheduling for a state water-rescue organisation, where the
|
||||
traffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital`,
|
||||
"silvatica-web": `Website of the Silvatica festival in Schwäbisch Hall.
|
||||
-> https://github.com/supermomme/silvatica-web`,
|
||||
"momme.world": `This site. Plain files, a stock nginx image, four manifests, no build
|
||||
step. ArgoCD points at the repo, so a merged commit is a deployed site.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stack",
|
||||
help: "the tools I reach for",
|
||||
body: `kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest
|
||||
nix reproducible configs for servers and laptops
|
||||
observability Prometheus, Loki, Grafana — dashboards I actually read
|
||||
fpv drones built, flown, crashed, rebuilt`,
|
||||
},
|
||||
{
|
||||
name: "social",
|
||||
help: "where I hang out online",
|
||||
body: `github -> https://github.com/supermomme
|
||||
mastodon -> https://cybre.town/@momme
|
||||
instagram -> https://instagram.com/supermommej`,
|
||||
},
|
||||
{
|
||||
name: "contact",
|
||||
help: "reach me directly",
|
||||
body: `mail -> mailto:momme@juergensen.me
|
||||
matrix -> https://matrix.to/#/@momme:chaos.jetzt`,
|
||||
},
|
||||
{
|
||||
name: "ls",
|
||||
help: "what's in here",
|
||||
body: () => names("projects").join(" "),
|
||||
},
|
||||
{
|
||||
name: "cat",
|
||||
prefix: true,
|
||||
help: "cat README.md",
|
||||
run: (arg) => {
|
||||
const f = arg.toLowerCase();
|
||||
if (f === "readme.md" || f === "readme") return body("hello");
|
||||
if (f === ".ssh/id_rsa") return fail("nice try.");
|
||||
return fail(`cat: ${arg}: No such file or directory`, "try: cat README.md");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
help: "this list",
|
||||
body: () => ["usage: <command>", "", ...usages()],
|
||||
run: (arg) => {
|
||||
if (!arg) return body("help");
|
||||
const row = usages().find((line) => line.trim().startsWith(arg + " "));
|
||||
return row ? row : fail(`no command called "${arg}"`, "see: help");
|
||||
},
|
||||
},
|
||||
{ name: "clear", help: "wipe the screen", body: "" },
|
||||
{ name: "date", help: "today's date", body: () => new Date().toDateString() },
|
||||
{ name: "time", help: "current time", body: () => new Date().toLocaleTimeString("de-DE") },
|
||||
{ name: "echo", prefix: true, help: "echo <text> — repeat yourself", body: (arg) => arg },
|
||||
{ name: "pwd", help: "working directory", body: "/world" },
|
||||
{ name: "uname", prefix: true, help: "system information", body: "momme.world 1.0.0 static x86_64 GNU/freedom" },
|
||||
{ name: "curl", prefix: true, help: "curl momme.world — you already did that", body: "you are already reading the output :)" },
|
||||
{ name: "sudo", prefix: true, help: "elevate", body: "momme is not in the sudoers file. This incident has been reported." },
|
||||
{ name: "vim", prefix: true, help: "an editor", body: "emacs, obviously." },
|
||||
{ name: "emacs", prefix: true, help: "an OS with good editing", body: "a great OS, shame about the editor." },
|
||||
{ name: "coffee", help: "brew coffee", body: "() ~ I'm out of coffee." },
|
||||
{ name: "exit", help: "close the session", body: "close the tab, or just stop reading :)" },
|
||||
];
|
||||
|
||||
const ALIASES = {
|
||||
whois: "whoami", about: "whoami", openme: "whoami",
|
||||
tech: "stack", tools: "stack",
|
||||
links: "social", socials: "social",
|
||||
mail: "contact", email: "contact",
|
||||
repos: "projects", 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");
|
||||
|
||||
// ---------- 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);
|
||||
|
||||
function print(value, arg) {
|
||||
const resolved = typeof value === "function" ? value(arg) : value;
|
||||
if (resolved && resolved.error) {
|
||||
out.append(make("div", "line err", resolved.error));
|
||||
resolved.more.forEach((line) => out.append(make("div", "line dim", line)));
|
||||
} else {
|
||||
split(render(resolved, arg)).forEach((line) => out.append(linked(line)));
|
||||
}
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
// Turn `label -> url` (or a bare url) into text plus real anchors.
|
||||
const LINK = /(https?:\/\/\S+|mailto:\S+)/;
|
||||
|
||||
function linked(line) {
|
||||
const node = make("span", "line out");
|
||||
if (!LINK.test(line)) return (node.textContent = line), node;
|
||||
const arrow = line.indexOf("->");
|
||||
if (arrow > 0) node.append(line.slice(0, arrow));
|
||||
(arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => {
|
||||
if (!LINK.test(part)) return node.append(part);
|
||||
const anchor = make("a", null, part);
|
||||
anchor.href = part;
|
||||
if (part.startsWith("http")) anchor.rel = "noopener me";
|
||||
node.append(anchor);
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---------- 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];
|
||||
}
|
||||
|
||||
function execute(raw) {
|
||||
const [word = "", ...rest] = raw.trim().split(/\s+/);
|
||||
const arg = rest.join(" ");
|
||||
const name = word.toLowerCase();
|
||||
|
||||
out.append(make("div", "line cmd", raw));
|
||||
if (!name) return scrollDown();
|
||||
if (name === "clear") return (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"));
|
||||
}
|
||||
|
||||
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")));
|
||||
}
|
||||
|
||||
return print(found.run ? found.run(arg) : found.body, 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) => {
|
||||
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 hit = commandNames().filter((n) => word && n.startsWith(word));
|
||||
if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; }
|
||||
});
|
||||
|
||||
screen.addEventListener("click", () => {
|
||||
if (!getSelection().toString()) input.focus();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-run]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
execute(button.dataset.run);
|
||||
input.focus();
|
||||
})
|
||||
);
|
||||
|
||||
print(`hello — type help for the rest of it.\n`);
|
||||
input.focus();
|
||||
@@ -0,0 +1,136 @@
|
||||
: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; }
|
||||
}
|
||||
Reference in New Issue
Block a user