Personal portal — Astro + Tailwind CSS v4.
Static site, terminal / phosphor aesthetic, zero framework JS. Config-driven and reusable: every site-specific
value (host, identity, links) lives in src/site.config.ts.
Design: a draggable, resizable terminal window over a desktop. Seven CRT
themes (green with a "Matrix" digital-rain background, amber, ice,
synthwave, cyberpunk with an animated neon-grid background, white, red)
and switchable wallpapers. A bottom dock gives one-click access to help (a
clickable command palette), the local AI agent (ask AI), a new shell window,
the theme / wallpaper switchers, fullscreen and the source code (fork me on
GitHub). Fonts VT323 (CRT display) and
IBM Plex Mono (body). Effects: scanlines, grain, vignette, phosphor glow,
blinking cursor — all disabled under prefers-reduced-motion.
- Node.js ≥ 22.18 — native TypeScript execution for
scripts/,server/and the tests (CI runs the latest Node 22).
npm install
npm run dev # dev server with HMR -> http://localhost:4321| Script | Purpose |
|---|---|
npm run dev |
Development server (hot reload) |
npm run build |
Optimized static build → dist/ |
npm run preview |
Preview the dist/ build |
npm run check |
Type checking / Astro diagnostics |
npm run check:commands |
Validate + lint the commands (root/bin/*.md) |
npm test |
Unit tests (Vitest) |
npm run coverage |
Tests + coverage report, refreshes the README badges |
npm run test:ci |
Tests + JUnit / Cobertura reports (used by Azure DevOps) |
npm run test:watch |
Tests in watch mode |
npm run lint |
ESLint |
npm run format |
Format the code (Prettier) |
npm run format:check |
Check formatting without writing |
npm run upgrade |
List dependency updates |
npm run upgrade:apply |
Apply updates + rebuild |
All of these checks (format:check, lint, check:commands, check, test, build)
run automatically in CI on every push and pull request — on GitHub Actions
(.github/workflows/ci.yml) and on Azure DevOps (azure-pipelines.yml, which also
publishes the unit-test results and the code coverage to the run's Tests and
Code Coverage tabs, via npm run test:ci).
public_html/
├── astro.config.mjs ← Astro config (reads the URL from site.config.ts)
├── src/
│ ├── site.config.ts ← ⭐ SINGLE SOURCE: identity, SEO, shell, links/profiles
│ ├── pages/index.astro ← home page (terminal + dock)
│ ├── pages/[command].astro ← one static page per command/document (deep links)
│ ├── pages/sitemap.xml.ts ← generated sitemap
│ ├── pages/sw.js.ts ← service worker, generated (content-derived cache version)
│ ├── pages/shell-*.json.ts ← externalised FS tree + command registry (fetched once)
│ ├── layouts/Layout.astro ← <head>, SEO, Open Graph, theme, fonts, SW + update toast
│ ├── components/ ← Terminal, Dock (+ its buttons), LlmWidget, JsonLd, MatrixRain
│ ├── lib/terminal.ts ← shell session: I/O, prompt, completion, the `ctx` object
│ ├── lib/executor.ts ← pipelines (`|`), redirections (`>`, `>>`), headless capture
│ ├── lib/shell-parse.ts ← command-line lexer/parser (quotes, pipes, redirections)
│ ├── lib/shell-ctx.ts ← typed `ctx` API contract for the commands
│ ├── lib/vfs.ts ← virtual filesystem (paths, su, persisted mutations)
│ ├── lib/llm.ts ← the single WebLLM loader / engine manager
│ ├── lib/windows.ts ← window chrome (drag, raise, min/max) + iframe windows
│ ├── lib/content.ts ← walks root/ at build time → FS tree + command registry
│ ├── lib/commands.ts ← command parsing + validation (shared)
│ ├── lib/html.ts ← HTML escaping / safe links (client + build)
│ └── styles/global.css ← Tailwind + terminal themes (CSS variables, CRT effects)
├── root/ ← ⭐ THE FAKE FILESYSTEM (a real on-disk directory tree)
│ ├── bin/ ← one command = one markdown (frontmatter + optional js)
│ ├── home/guest/ ← visitor's ~ : docs browsed via ls/cat (about/projects/contact)
│ └── etc/, var/, usr/, … ← explorable "decor" directories
├── server/ ← `msg` email relay (Node, zero dependencies)
├── public/ ← served as-is (icons, fonts, manifest, OG image, vendor/)
├── deploy/*.template ← nginx, systemd unit, relay env (rendered by deploy:config)
├── scripts/ ← site-config bootstrap + command validation
├── tests/ ← Vitest tests (parsing, execution, VFS, rendering, relay)
├── docs/screenshot.png ← the screenshot above
└── dist/ ← GENERATED OUTPUT (= the web root to serve)
- Identity, SEO and profiles →
src/site.config.ts: name, role, company, tagline, bio, URL, OG image, Twitter handle, Google token, and thelinkslist (each entry feeds theopencommand, the schema.orgsameAs, or both). - Browsed content → the
root/tree (see "Interactive shell").
The dock tiles are components in src/components/. help and ask AI run
help / denree in the shell window the visitor used last, through
runCommandLine() (src/lib/terminal.ts); help lists every command and file
as clickable links. The fork me on GitHub tile links to site.sourceUrl
(set it to an empty string to hide it). The dock buttons use astro-icon with the lucide set.
src/site.config.tsis gitignored (it holds your personal identity). It is created automatically fromsrc/site.config.example.tsonnpm install(and on anydev/build/check/testrun). An existing file is never overwritten — copy it by hand withcp src/site.config.example.ts src/site.config.tsif you prefer.
- Edit
src/site.config.ts(identity, URL, profiles, shell host/user). - Replace the contents of
root/home/<user>/(your.mddocuments;<user>isshell.user, e.g.guest) and, if needed, the "decor" files underroot/etc,root/var, etc. - Add/remove commands in
root/bin/(see below). - Replace the icons (
public/icons/*,public/favicon.ico,public/apple-touch-icon.png) and the OG image (public/ludovic-toinel.jpg). npm run lint && npm test && npm run build.
The home directory (
shell.home) must stay consistent with theroot/home/...tree.
On load, the portal simulates an SSH connection to the configured host
(shell.host in src/site.config.ts) and prints the message of the day
(motd), then hands over control. The visitor then types commands to explore
the content. The window is draggable (grab the title bar), resizable
(handle at the bottom right) and has close / minimize / maximize buttons
(double-click the bar to maximize); the dock's + opens more shell windows.
The content lives in the root/ tree (= the fake filesystem):
-
root/home/<user>/(theshell.user, e.g.guest) = the visitor's~directory: documents browsed withls/cat(about.md,projects.md,contact.md). To add a document, createroot/home/<user>/my-file.md: it becomes reachable viacat my-file.md(or justmy-file), shows up inlsand gets its own/my-filepage. -
root/bin/= one command = one markdown; the build (content.ts) discovers commands automatically by listing this directory (they also appear in/bin). Frontmatter:name: command namedesc: description (shown byhelp)alias(optional): comma/space-separated alternate names (e.g.cls→clear)page: false(optional): no standalone/<name>landing page — for control commands, argument-required utilities and side effectsindex: false(optional): the landing page exists but stays out of search engines (noindex, not in the sitemap) — for thin pages with no search intentdemo(optional): a command line the landing page runs instead of opening the manual (e.g.demo: cal,demo: nslookup %HOST%), so/cal/shows a calendarman: |(optional): authored manual page, shown byman <name>js: |(optional): JavaScript executed if the command is dynamic- the markdown body: shown as-is if the command is static (no
js)
--- name: date desc: date and time js: | ctx.line(new Date().toString()); ---
The
jsis an async function body receiving actxobject — its full, typed contract issrc/lib/shell-ctx.ts. In short:- input:
args,body,cfg,stdin(piped input),history,commands,signal(aborted by Ctrl+C),ask(question, { secret }); - output:
print(markdown),line,raw,error(stderr),sysLine,append(trusted HTML),escape,clear,sleep,type; - filesystem:
cwd,cwdLabel,cd,list,read,fileList,resolveFile, and the persisted mutationsmkdir,touch,write,rm; - session & windows:
open,iframe,theme/themes/currentTheme,su,exit,close,exec,capture(line)(run a command line headlessly and get its output); - background work:
worker(url, name)(a Web Worker tracked by the shell, shown bytop) andworkers(); llm: the local WebLLM manager (ensure,chat,models,catalog,recommended, cache ops,unload,interrupt,state).
Files in
root/bin/*.mdare validated at build time and bynpm run check:commands: frontmatter, name, JS syntax (parsed, never executed), duplicates — plus an ESLint pass (recommended rules) over everyjs:block.
The command line supports pipes (ls | grep md | wc -l), output redirection
(echo hi > notes.txt, >> to append) and single/double quotes
(grep "a | b" notes.txt).
Supported markdown: # Title, ## Subtitle, > note, - bullet,
**bold**, `code` and links [text](https://…) or […](mailto:…).
⚠️ Dynamic commands run viaAsyncFunction(eval). Only allow trusted commands. The deployed CSP (deploy/nginx.conf.template) is therefore a hardening layer, not a sandbox: it allowsunsafe-eval(for the command engine) andblob:workers (forhashcat).
Built-in commands
- Filesystem:
ls/ll,cd,pwd,cat,nano/edit,tree,du,find,grep,wc,mkdir,touch,rm(FS mutations persist per-browser inlocalStorage). - System / identity:
whoami,uname,date,echo,motd,su/sudo,theme,bell,clear/cls,history,help,man,boot,exit,shutdown/reboot. - Network:
nslookup,whois,ping,httperf,httpstest,checkip,geoip,useragent,weather,open <name>,iframed <url>. - Tools / crypto:
bc/calc,base64,sha256sum,md5sum/md5,jwt,uuid,password/pwgen(unbiasedcrypto.getRandomValuesgenerator),qr,hashcat(multi-core MD5 brute-forcer using Web Workers). - Productivity:
todo/task(task list in localStorage),kanban/board(the same tasks as a Unicode board),pomodoro/pomo(background focus timer with a chime and a flash),cal/calendar(month / year grid, ISO weeks). - System monitor:
top/htop— live JS heap (performance.memory), device hints, LLM engine, service worker and the Web Workers started by commands. - Fun:
fortune/quote(quote or developer joke from public APIs, offline fallback),say/tts(speech synthesis),asciiart/banner(big text in ten styles — block, shadow, 3d, slant, …). - Local AI (WebGPU, fully in-browser):
llm(engine / cache manager),miaougpt/chat,glaude/soupe,denree/?(a reasoning agent that drives the terminal: it plans, chains commands — one result feeding the next — reflects on each output and critiques its candidate answers; with no question it asks for one),webllmfit(which models fit this machine). No model is downloaded without consent; the top-right widget shows the loaded model and token counts. - Messaging:
msg <text>emails the owner.
Each command carries its own code: a root/bin/*.md file is
self-contained — readable JS, no third-party library, no command-specific module
(todo and kanban share their tasks through the ltsh.todo localStorage key,
qr embeds its own QR encoder, asciiart its own pixel font). src/lib/ holds
only the transverse core engine; its one third-party runtime library is the
WebLLM engine, loaded lazily on first use. Command logic is unit-tested by
running the .md itself against a fake ctx (tests/run-command.ts).
su simulates switching to root (a # prompt, access to /root); exit returns
to the user. Plus: persistent history (↑/↓), autocompletion (Tab), line editing
(Ctrl+A/E/U/K/W), Ctrl+L (clear), Ctrl+C (interrupts a running command).
This terminal can be used as an on-screen prop in films, series, ads or video games — the "hacker at work" or "mainframe console" screen — and it is built to be easy to stage:
- Looks the part: a real, responsive shell with a believable SSH boot sequence, typewriter output and CRT effects (scanlines, glow, grain). Seven color themes match the grading of a scene: green Matrix rain, amber, ice, synthwave, cyberpunk neon grid, white, red.
- Actually works: everything on screen is real and repeatable take after
take —
topmonitors live memory,hashcatcracks an MD5 hash on every CPU core,nslookup,whois,pingandhttpstestquery real networks,asciiartprints big banners, anddenreeshows an AI agent reasoning step by step. - Any name, any machine: the prompt shows the domain the page is served
from, so serving it as
mainframe.acme(an/etc/hostsentry is enough) makes the prompt readguest@mainframe.acme. The identity, the files (root/) and the boot text are plain config and markdown. - Scripted scenes: a command is one markdown file (
root/bin/*.md), so a plot-specific command — a fake "access granted" sequence, a countdown, a decoding animation — takes minutes to write. Landing on/<command>/runs it straight away (seedemo:in "Interactive shell"), so a scene can start on cue from a URL. - Clean shots: the dock's fullscreen button hides the browser chrome;
prefers-reduced-motionturns off every animation when a still frame is needed; it runs offline (npm run build && npm run preview) except for the commands that query the internet.
Rights: the code is under the MIT license, which allows
commercial use, productions included. The fonts (VT323, IBM Plex Mono) are under
the SIL Open Font License. The optional wallpapers come from Unsplash — check
the Unsplash license for your use, or turn them off. Replace the personal
identity and links (src/site.config.ts, root/home/) and any third-party
brand before shooting. An on-screen or end-credit mention is appreciated but not
required; open an issue on GitHub if you use it — it is always nice to know.
src/pages/sw.js.ts generates /sw.js at build time with a content-derived
cache version. It caches the externalised shell data and — via Layout.astro —
surfaces an "update available" toast when a new build's service worker is
waiting. Manifest and icons (theme-colored terminal glyph) live in public/
(manifest.json, icons/).
The web root (DocumentRoot) must point to dist/. The server block is
rendered from deploy/nginx.conf.template by npm run deploy:config.
npm install && npm run build # -> dist/