Personal site. Eleventy 3 (11ty) static build wrapped in a Cloudflare
Worker. Pastel HyperCard / cracktro aesthetic, cursor buddies, per-hobby
data cards (baseball, golf, music, movies), and a sardonic pixel-buddy
chatbot ("botty") that floats on every page and talks to Claude. Replaces
the prior al-folio Jekyll site (preserved on legacy/al-folio).
npm install
npm run dev # http://localhost:8080npm testUnit tests cover résumé date filters, Last.fm caching, baseball stat helpers, and cursor-buddy direction logic.
npm run buildProduces _site/ including:
- Static HTML for all 9 cards (home, résumé, projects, words, music, movies, contact, baseball, golf)
- 8 short-URL redirect stubs (
/m,/cal,/gh,/p,/li,/s,/c,/w) for the printed business card QR codes resume.pdf— copied from the committedsrc/resume.pdf(regenerated onresume.jsonchanges via theupdate-resume-pdfworkflow)
Individual steps (chained by npm run build):
| Script | What it does |
|---|---|
npm run build:bio |
Bundle src/_data/bio-bundle.json for the chatbot |
npm run build:site |
Run Eleventy → _site/ |
npm run build:resume |
Regenerate src/resume.pdf (Puppeteer) |
For static-only iteration, npm run build:eleventy runs build:bio +
build:site (skips the resume PDF). This is also what Cloudflare runs on
deploy — Puppeteer can't run there, so the committed src/resume.pdf is
passthrough-copied.
Cloudflare Workers, configured by wrangler.toml. Pushing to main
triggers the CF GitHub integration → it runs npm run build:eleventy
then npx wrangler deploy. The Worker:
- Serves
_site/as static assets via theASSETSbinding - Routes
POST /api/chatthroughworker/index.jsto Claude (handles both opening-line "quip" mode and the full conversational "chat" mode used by the botty widget)
Botty uses Cloudflare Turnstile (invisible mode) + a per-IP daily rate limit backed by a Workers KV namespace. After the first Turnstile solve, the Worker mints a session token so subsequent chat sends skip the Turnstile round-trip.
Variables / secrets the Worker needs:
| Name | Type | Purpose |
|---|---|---|
ANTHROPIC_API_KEY |
secret | Claude API access |
TURNSTILE_SECRET_KEY |
secret | Turnstile server-side verification |
LASTFM_USER |
build env var | Music card + bio bundle |
LASTFM_API_KEY |
build env var | Music card + bio bundle |
CARD_CHAT_DISABLED |
env var | "true" flips the chat kill switch |
RATE_LIMIT (KV namespace) |
binding | Per-IP rate limit + chat sessions |
The music card pulls a rich slice of Last.fm at build time: total scrobbles, recent + top tracks/artists/albums, a 12-week listening trend, a 7×24 day-of-week × hour activity heatmap (in Pacific time), a genre cloud aggregated from artist tags, and Apple Music + Last.fm deep links on every item.
Local builds need credentials in .env:
cp .env.example .env
# edit .env with LASTFM_USER + LASTFM_API_KEY
npm run buildWithout credentials the build succeeds and the card shows a stub.
All stats refresh scripts run locally (GHIN blocks datacenter IPs;
baseball pulls from a local Obsidian vault). Commit the resulting JSON
diff under src/_data/.
| Script | What it refreshes |
|---|---|
npm run update:stats |
Both — update:baseball + update:golf |
npm run update:baseball |
src/_data/baseball.json from ~/vaults/baseball + SDABL league snapshots |
npm run update:golf |
src/_data/golf-raw.json from GHIN (needs GHIN_USERNAME + GHIN_PASSWORD in .env) |
update:golf uses the doyled-it/ghin fork (pinned in package.json)
which tolerates GHIN's current response shape. Baseball also has a CI
fallback in .github/workflows/update-stats.yml.
A pixel-buddy chatbot widget pinned to every page. Resting on the bottom-right of the viewport, he blinks/smirks/shifts via a 19-expression SVG face, fires page-aware quips, and opens a chat panel on click.
Two modes share one endpoint (POST /api/chat):
- Quip mode — short opening lines. After 4s of dwell (or ~250px of
mouse movement), botty pops a contextual one-liner. Most fire from a
local 175+ entry quip bank (
src/_data/botty-quips.json); the first fresh-page quip optionally calls Claude for an LLM-generated greeting using collected visitor signals. - Chat mode — full Q&A about Michael (work, hobbies, publications, recent GitHub activity, listening habits). Strictly scoped: refuses off-topic questions and prompt-injection attempts, capped at 4 sentences and 250 output tokens, redirects to email for anything substantive.
The Worker calls Claude Haiku 4.5 with a build-time-bundled bio as a
cached system prompt. The bundle (src/_data/bio-bundle.json, gitignored)
is built by scripts/compile-bio.mjs and combines:
src/_data/bio.json— hand-curated voice guide + hobbiessrc/_data/baseball.json— per-season summary statssrc/_data/golf-raw.json— current handicap + last 5 roundssrc/_data/resume.json— publications with summaries- GitHub public API — recent repos, top languages, follower count
- Last.fm — top tracks/artists/albums, listening hours, peak hour/day, discovery rate, full genre cloud
Botty collects a wide signal panel in the browser: timezone, language,
referer, browser/platform, mac chip (WebGL renderer probe), battery
level + charging, connection type, screen aspect (ultrawide/vertical),
high-refresh-rate display, dark/reduced-motion prefs, GPC/DNT, WebGPU
support, PWA-installed, touch-on-desktop, cores/RAM, storage quota,
reload count, etc. Quips trigger on any combination via predicate
suffixes (_gte, _lte, _starts, _contains, _not, _len_gte)
and weight to bias the surprising lines over generic filler.
.eleventy.js # 11ty config
wrangler.toml # Cloudflare Worker config
worker/
index.js # Worker entrypoint (POST /api/chat)
lib/ # pure modules with unit tests
lastfm-core.mjs # rich Last.fm fetch + derivations
apple-music.mjs # iTunes Search → Apple Music URL resolver
baseball-filters.mjs
golf-transform.mjs
resume-filters.mjs
botty-quips.mjs # quip predicate evaluator + weighted picker
botty-engagement.mjs # dwell/mouse engagement state machine
scripts/
build-resume-pdf.mjs # hackmyresume → puppeteer pipeline
compile-bio.mjs # builds bio-bundle.json for the chatbot
sync-stats.mjs # update:baseball — pull baseball + SDABL
fetch-golf.mjs # update:golf — pull GHIN → golf-raw.json
gen-*.mjs # node-canvas pixel-art generators
src/
_data/ # site constants, cards, résumé, hobby JSON
botty-quips.json # quip bank (175+ weighted, predicate-keyed)
dataUpdated.js # YYYY-MM-DD of last stats commit (for footer)
pkg.js # exposes package.json version (for footer)
_includes/
botty.njk # widget markup (sprite + bubble + panel + SVG face)
footer.njk # version + data-refresh date footer
assets/css/botty.css
assets/js/botty.js # browser orchestrator (face loop, swim, quips)
words/ # markdown posts
*.njk # 9 cards + resume-print + sitemap + redirect
tests/ # node:test unit tests
vendor/ # vendored npm packages
.github/workflows/
update-stats.yml # weekly Sun 6am UTC: refresh baseball + golf
update-resume-pdf.yml # on resume.json change: regen src/resume.pdf
version-bump.yml # on PR merge: read version:* labels, bump + tag
release.yml # on tag push (or dispatch): create GH release
PRs labelled version:major / version:minor / version:patch trigger
version-bump.yml on merge: it bumps package.json, prepends a
CHANGELOG.md entry, commits with [skip ci], tags vX.Y.Z, then
explicitly dispatches release.yml to create the GitHub release with
auto-generated notes. The footer shows the current version (linked to its
release) plus data YYYY-MM-DD — the date of the most recent commit
touching the live stats files.