My personal site - work, writing, a log of every show I have been to, and where I have travelled. Live at danavner.com.
It is a static React site with no backend and no CMS. All the content is markdown files and one TypeScript file, read and validated at build time. If you want to fork it and make it yours, see Making it yours.
| Layer | Choice | Why |
|---|---|---|
| Build | Vite 7 | Fast dev server, sane defaults, minimal config |
| UI | React 19 + TypeScript | Type errors catch content mistakes before the browser does |
| Styling | Tailwind CSS v4 | CSS-first config - no tailwind.config.js to maintain |
| Components | shadcn/ui | Copied into the repo, so they are mine to edit or delete |
| Carousel | Embla | What shadcn/ui's Carousel is built on |
| Icons | lucide-react | Consistent 24px stroke set, tree-shakeable, ISC |
| Type | Fontsource | Self-hosted Anton / Inter / JetBrains Mono |
| Routing | React Router 8 | Client routes, with a 404 fallback for GitHub Pages |
| Markdown | react-markdown + remark-gfm | GFM tables, task lists, fenced code |
| Highlighting | rehype-highlight | lowlight's common set, themed to the palette |
| Hosting | GitHub Pages | Free, already where the repo lives |
| Linting | ESLint + typescript-eslint | Correctness only; every stylistic rule is off |
| Formatting | Prettier, printWidth: 100 |
The width the codebase was already written at |
| Hooks | husky + lint-staged | Formats and lints what you staged, then type-checks |
Every dependency is permissively licensed (MIT, ISC, Apache-2.0, BSD-3-Clause,
OFL-1.1 for the fonts). The only thing that leaves the origin is a
GoatCounter pageview beacon - cookie-free, no
personal data. Otherwise nothing here phones home: no font CDN, no third-party
scripts, no other analytics. tests/links.spec.ts fails the build if any
request goes anywhere but this origin and GoatCounter.
Pageviews are recorded with GoatCounter and read in its dashboard. There is no visitor counter on the site any more.
There was one - a hit-counter odometer on the landing page - and it is gone. Not
for privacy reasons; GoatCounter is cookie-free and stores nothing personal. It
went because it could not be relied on to show a number. Ad blockers and DNS
blocklists match on domain rather than on intent, so goatcounter.com is on the
lists whatever it does or does not collect, and anyone running uBlock Origin,
AdGuard or Pi-hole saw a dead counter. The only real fix is proxying the read
through this domain to make it first-party, and this is static hosting with no
backend to do that with.
The same blocking applies to the recording beacon, so the dashboard sees fewer visits than actually arrive - GoatCounter's author estimates the shortfall at around a third. That is fine for a number nobody but me looks at. It was not fine for a number printed on the page.
npm install
npm run dev # http://localhost:5173
npm run build # type-check, then build to dist/
npm run preview # serve the built site
npm run typecheck # types only, no build
npm test # Playwright, against the production build
npm run test:ui # the same suite, interactively
npm run lint # ESLint
npm run lint:fix # ESLint, fixing what it can
npm run format # Prettier, writing
npm run format:check # Prettier, reporting onlyNode 22+. npm test builds nothing itself - run npm run build first, or let
CI do it.
Prettier formats, ESLint checks correctness, and eslint-config-prettier
switches off every stylistic ESLint rule so the two cannot disagree about a line
break. printWidth is 100 - the width the codebase was already written at,
picked by measuring rather than by taste. Prettier's default of 80 would have
reformatted 50 of 74 files; 100 reformats far fewer and leaves the long tail,
which is mostly unbreakable strings, alone.
.prettierignore skips the generated content JSON. Those files are written by
the nightly jobs with their own JSON.stringify formatting, and a bot commit
landing while a hook reformats them is a fight with no upside.
fortnite-seasons.json is deliberately not skipped, because that one is
hand-kept.
A pre-commit hook runs both. npm install sets it up - the prepare script
runs husky, which points git at .husky/. On commit:
lint-stagedrunseslint --fixthenprettier --writeover the staged files only, and restages what it rewrote, so a commit lands formatted instead of failing over a line break. Anything ESLint cannot fix on its own fails the commit and names the file.npm run typecheckruns over the whole project, not just the staged files. A type error is rarely in the file that caused it: change a shared type and it is the callers that break, and those are exactly the files not in the commit. It costs about two seconds.
The Playwright suite is not in the hook. It needs a production build and takes
the better part of a minute, which is too much per commit - CI runs it on every
push and every PR. CI also repeats the lint and format checks, because a hook is
a convenience rather than a gate: --no-verify skips it.
| Workflow | When | What |
|---|---|---|
ci.yml |
every push to main and every PR |
lint, format check, type-check, build, Playwright suite |
deploy.yml |
push to main, or a data job finishing |
builds and publishes to GitHub Pages |
links.yml |
weekly, Mondays | external link check; opens an issue if anything is dead |
vinyl.yml |
nightly | reads the Discogs collection, commits it if it moved |
comics.yml |
nightly | reads the comic collection, commits it if it moved |
fortnite.yml |
nightly | reads the Fortnite stats, commits them if they moved |
now-archive.yml |
push touching now.md |
files the entry the update replaced |
The nightly jobs commit their JSON to main, and the site is built from those
files, so every refresh needs a rebuild to reach the page. That looks like it
should happen on its own - deploy.yml runs on push to main, and a commit is
a push.
It does not. GitHub will not start a workflow from a push made with the
default GITHUB_TOKEN, which is the loop guard, and there is no opting out of
it. So the bot commits land on main and the push trigger never sees them.
This was live for a while before anyone noticed, which is the point worth
recording: the vinyl refresh of 10 August sat on main from 09:08 until 16:03,
when an unrelated human push finally carried it out. Nothing was broken or red.
The data was simply as old as the last time someone happened to push.
So deploy.yml also triggers on workflow_run for Vinyl, Comics, Fortnite and
Now archive. A new data workflow has to be added to that list, or its
numbers will go stale in exactly the same silent way.
Two details in there are load-bearing:
- The build is gated on
conclusion == 'success', since a failed data job has committed nothing worth publishing. - The checkout takes
mainby name on aworkflow_run. The SHA in that event is the one the triggering run started from - the commit before the data job wrote anything - so checking it out would rebuild the tree without the refresh and publish it under a green tick.
The cost is a deploy on nights when a job finds nothing changed: a minute of CI for no change. That is cheaper than a long-lived PAT to dodge the token rule, and much cheaper than data that quietly stops updating.
The Playwright suite runs against the production build, on desktop and mobile viewports, and covers four things:
- Behaviour - routing, titles, the writing filter and its URL state, theme persistence, markdown rendering, and the derived show stats.
- Accessibility - axe (WCAG 2.1 A and AA) on every route in both themes. The palettes are independent, and contrast is the easiest thing to break.
- Links and assets - every in-site link resolves to a real route rather than the SPA's 404 fallback, and no image is broken.
- No third parties - fails if any request leaves the origin except the GoatCounter beacon, which keeps the claim above honest.
External links are deliberately excluded from CI. They rot for reasons no commit caused - a venue folds, a host starts refusing bots - and a red build nobody can fix is worse than no check. They get the weekly run instead.
Content mistakes never reach the tests: the build validates every markdown
file, down to checking that a photo path actually exists in public/.
public/ served as-is (CNAME, favicon, photos, fetched covers)
scripts/
optimize-photos.mjs resizes any image and strips its EXIF
make-share-fallback.mjs draws the social image for a show with no photos
make-site-card.mjs draws the site's own link-preview card
gen-font-fallbacks.mjs measures the fonts so fallbacks match their metrics
archive-now.mjs files the now entry an update replaced
update-vinyl.mjs reads the Discogs collection nightly, saves the sleeves
update-comics.mjs reads League of Comic Geeks nightly, saves the covers
update-fortnite.mjs reads the Fortnite stats nightly, keeps a season archive
backfill-fortnite.mjs fills past seasons in from Epic, run by hand not by CI
fetch-fortnite-skins.mjs downloads the render for each season's main outfit
eslint.config.js the browser half, the Node half, prettier last
.prettierrc.json printWidth 100, and .prettierignore beside it
.husky/pre-commit lint-staged, then a whole-project type-check
.git-blame-ignore-revs formatting-only commits, skipped by git blame
vite-plugin-content.ts reads + validates every content collection at build time
vite-plugin-share-pages.ts writes one HTML file per show so links preview properly
vite.config.ts aliases, Tailwind, and the 404.html fallback
src/
content/
profile.ts everything the Home / About / Career pages render
accounts.json the handles the fetch scripts read
blog/*.md one markdown file per post
shows/*.md one markdown file per show
now.md + now/ the current now entry, and the ones it replaced
vinyl.json the record collection, written nightly from Discogs
comics.json the comic shelf, written nightly
fortnite.json the stats, written nightly and backfilled once
fortnite-seasons.json the season calendar - the one Fortnite file edited by hand
routes/ one file per page
components/
ui/ shadcn/ui, vendored: Badge, Button, Carousel, Checkbox,
Empty, Label, NavigationMenu, Popover, ScrollArea,
Select, Sheet, Skeleton, Toggle, ToggleGroup
framed-photo.tsx the site's photo frame, caption printed on the image
source-line.tsx "N shown · Read from <source> <date>", on every fetched page
route-boundary.tsx recovers a lazy route whose chunk a deploy deleted
scrolling-text.tsx a tile line that slides to reveal its tail when it overflows
filter-toggle.tsx the filter pills, and the one place a control's height lives
select-control.tsx the single-choice control, on shadcn's Select
fact-line.tsx a detail page's own facts, set under its title
lib/
blog.ts post helpers over the plugin's output
shows.ts sorting, year grouping, derived show stats
show-summary.ts one-line show description, shared with the Node build
show-card.ts draws the shareable poster on a canvas
vinyl.ts filtering, sorting, and derived collection stats
comics.ts the shelves, and the issue counts derived from them
fortnite.ts windows, playlists, placement tiers, and the deltas
now.ts the current entry and its archive
site.ts the nav, shared by the header, footer and tests
stale-chunk.ts the once-only guard behind route-boundary.tsx
photo.ts the Photo type, shared by the markdown collections
theme.ts light/dark store, synced with the pre-paint script
index.css design tokens, utilities, and the poster primitives
fonts.css self-hosted @font-face declarations
tests/ Playwright: behaviour, accessibility, links
playwright.config.ts runs the suite against the production build
shadcn/ui components are vendored, not installed - the CLI copies source into
src/components/ui/ and it becomes yours. Anything built on top (the photo
strip, the solo badge, the filter row) composes those primitives rather than
reimplementing them.
vite-plugin-content.ts reads every collection in Node at build time,
validates it, and exposes it as a virtual module:
| Source | Module | Written by |
|---|---|---|
content/blog/*.md |
virtual:blog |
you |
content/shows/*.md |
virtual:shows |
you |
content/now.md + now/ |
virtual:now |
you |
content/vinyl.json |
virtual:vinyl |
update-vinyl.mjs, nightly |
content/comics.json |
virtual:comics |
update-comics.mjs, nightly |
content/fortnite.json + fortnite-seasons.json |
virtual:fortnite |
the job, and you for the calendar |
The generated files are validated exactly as strictly as the hand-written ones, which is the point: a fetch that half-worked is the likeliest way bad data gets in. The Fortnite reader also refuses figures that cannot be true - more wins than matches, a negative death count - because a stat board is only worth anything if it will not render nonsense. Three things fall out of doing all this in Node rather than in the browser:
- Bad frontmatter fails the build, naming the offending file, instead of rendering a broken card on the live site.
draft: trueposts are absent from the production bundle, rather than shipped and merely hidden.- The YAML parser never reaches the client - it runs at build time only.
A file whose name starts with _ is ignored, which is how each collection keeps
its own reference next to its content.
Create src/content/blog/<slug>.md. The filename becomes the URL, so
hello-world.md publishes at /blog/hello-world.
---
title: Hello world
date: 2026-08-03
category: personal
summary: One or two sentences, shown on the card and used as the meta description.
tags: [writing]
draft: false
photos: # optional; alt and caption required on each
- src: /img/blog/hello-world/table.jpg
alt: A long table from above, plates cleared, one candle still going
caption: The end of it
---
Body in markdown. GFM tables, task lists, and fenced code blocks with syntax
highlighting all work.| Field | Required | Notes |
|---|---|---|
title |
yes | |
date |
yes | YYYY-MM-DD. Posts sort newest-first. |
category |
yes | work or personal - drives the filter on /blog |
summary |
no | Recommended; used on cards and for link previews |
tags |
no | Free-form list, shown on the post page |
photos |
no | Same rules and same carousel as a show |
draft |
no | true keeps it in npm run dev and out of the build |
Reading time is computed from the word count. Renaming a file changes its URL.
Markdown can embed an image inline with ![](), but nothing checks those - no
required alt text, no required caption, and no build-time check that the file is
there. Use photos when the pictures are part of the post rather than an
illustration inside it: they are validated like every other photo on the site and
render in the same carousel, below the writing. The carousel itself is loaded on
demand, so a post without photos never fetches it.
Create src/content/shows/<slug>.md. Everything on /shows - the totals, year
groups, most-seen act, average rating, standouts ticker - is derived from these
files, so adding a show is dropping in a file and nothing else.
lineup is the whole bill, top billing first. Openers count, toward the
display and toward "bands seen".
---
lineup:
- Knocked Loose # tops the bill, shown large
- Show Me the Body # everyone below is listed as "w/ …"
- Speed
date: 2026-06-20
venue: Hollywood Palladium
city: Los Angeles, CA
rating: 4.5
bestSong: Locked Out of Heaven
with: [Jasmine P.]
video: https://youtube.com/playlist?list=xxxxx
photos:
- /img/shows/knocked-loose-2026/pit.jpg
- src: /img/shows/knocked-loose-2026/stage.jpg
alt: Underoath mid-set, lit red
caption: Underoath
standout: true
---
Free-form markdown about the night. Optional.| Field | Required | Notes |
|---|---|---|
lineup |
shows | Every band, top billing first. No duplicates. |
title |
festivals | The event name. On a show, shorthand for a one-band night. |
type |
no | show (default) or festival |
date |
yes | YYYY, YYYY-MM, or YYYY-MM-DD - use what you remember |
endDate |
no | Multi-day festivals. Renders as "Nov 15–16". |
city |
yes | |
venue |
no | Omit for festivals with no fixed venue |
capacity |
no | How many the place holds for a night like this. Confirm it; never guess. |
subtitle |
no | Qualifier under the heading, e.g. "Day 1" |
rating |
no | 0–5 🤘, decimals allowed. Omit for unrated. |
with |
no | List of names. Mutually exclusive with solo. |
solo |
no | true renders a SOLO RUN · 1P badge |
video |
no | Full URL. A YouTube playlist labels itself "Playlist". |
setlists |
no | { band, url } pairs. Each band must be in lineup. Renders as per-band buttons. |
photos |
no | Objects with src / alt / caption. All three required. |
standout |
no | Adds a flame and pins it to the ticker |
Photos live in public/img/shows/<slug>/ and render as a swipeable strip with
prev/next buttons and a counter. One photo drops the controls.
Run phone photos through the optimizer before committing them. It resizes to a 1600px long edge, re-encodes, bakes in the EXIF rotation, and then strips the metadata - which matters, because phone photos carry GPS coordinates and a show log is a list of places you were at a known time:
node scripts/optimize-photos.mjs shows/<show-slug> ~/Pictures/that-nightThe first argument is a folder under public/img/, so the same script handles
every image on the site - node scripts/optimize-photos.mjs about photo.jpg
writes public/img/about/photo.jpg. Add --name=<basename> to rename a single
photo, or --max=<px> to cap the long edge below the 1600px default for photos
that only ever render small.
Every photo on the site carries a caption printed over the bottom of the image,
via components/framed-photo.tsx for one-offs and the carousel for show strips.
Show photos are validated at build time and fail without an alt and a
caption.
There is also an add-show skill in .claude/skills/ that runs the whole
routine: looks up the tour name, venue, and openers, optimizes the photos,
writes their alt text and captions, and produces the markdown file.
solo: true renders a SOLO RUN / 1P badge. When the only name in with is
the partner named in profile.ts, the entry renders MY DUO / 2P instead.
Every subsection is conditional - no rating, no lineup, no companions, no photos, no notes means nothing renders in its place. The stat row works the same way: a figure only appears once it has something to say.
Three more details worth knowing:
- A festival is not a band.
type: festivalkeeps the event'stitleout of the "bands seen" count while itslineupstill counts. - Ratings fill proportionally.
3.2really shows a fifth of the fourth horn. Omittingratingrenders nothing - unrated is not zero, and unrated entries stay out of the average. - Partial dates are fine.
2026renders with no day label under the 2026 heading,2026-06renders as "Jun", a full date as "Jun 20".
Every entry has its own page at /shows/<slug> and a Share button.
The button renders a 1080×1920 poster from the entry on a canvas: photo, lineup, rating, venue, date, and the URL. It then opens a panel offering the poster and the link as separate actions.
That separation is the whole design. Handing navigator.share() a payload with
a file and a URL and a body of text lets each app decide what to do with all
three, and Messages decides to stack them, so you get a full-height poster, the
entire lineup as a paragraph, and the link underneath. Sending one thing at a
time means an Instagram story gets the poster and a text message gets a link
that previews itself. On a desktop, where navigator.share is usually missing,
the panel offers the same poster to save and the link to copy.
The link itself previews properly because vite-plugin-share-pages.ts writes a
real dist/shows/<slug>/index.html per show at build time, each with its own
title, description, and og:image. Crawlers behind iMessage, Slack, and
WhatsApp read the served HTML and never run the router, so without those files
every shared show would preview as the same generic site card.
Two different mechanisms get confused for each other.
In Messages, WhatsApp, Slack, Discord a Spotify link expands into a card because Spotify serves Open Graph tags and the app fetches them. That is exactly what the per-show HTML above does, so sending the link already behaves the same way - the show's photo, its title, and its summary, rendered by the receiving app. Nothing more is needed there.
In an Instagram story Spotify gets a tappable sticker, and that is not Open
Graph. It is Instagram's native Sharing to Stories integration: the app writes
com.instagram.sharedSticker.stickerImage and
com.instagram.sharedSticker.contentURL to the system pasteboard and opens
instagram-stories://share?source_application=<Meta App ID>. The contentURL is
what becomes the link.
That path needs a native app, a registered Meta App ID, and Meta's approval.
navigator.share() cannot write pasteboard sticker keys or pass an app ID, so a
static site has no route to it - the image arrives in the story editor as a
plain photo. Instagram also ignores anything embedded in the image itself, and
feed captions do not linkify.
So the honest split: the link is already as good as Spotify's; the image can
never be. The workarounds are adding a link sticker by hand in the story
editor, or putting a QR on the poster so the picture leads somewhere on its own
(see TODO.md).
og:image is the show's first photo. A show with no photos gets
public/img/share-card.jpg instead of the site portrait, because a festival
link that previews as a headshot looks like the wrong link. That image is
generated once by scripts/make-share-fallback.mjs and committed; the build
does not call it, since rendering a card at build time would mean shipping a
headless browser or a font stack with the site.
/vinyl is the one page whose content nobody writes. It is the Discogs
collection for the user dnafam, read nightly by scripts/update-vinyl.mjs,
committed as src/content/vinyl.json, and validated at build time like
everything else under src/content/.
The obvious version of this page calls the Discogs API from the browser on load. It cannot work here, for three reasons in descending order of how binding they are:
- The interesting half needs a token. Unauthenticated,
/users/dnafam/collection/foldersreturns a single "All" folder and/collection/valuereturns nothing. The Dan/Alexis split and every value stat are authenticated reads, and a token cannot ship in a client bundle. - The site does not phone home.
tests/links.spec.tsfails if any request leaves the origin except the GoatCounter beacon. A live Discogs call breaks the test and the claim it protects. - The sleeves are Discogs' bandwidth. Hotlinking their CDN for every
visitor is not ours to spend, so the covers are downloaded, squared off to
500px WebP, and served from
public/img/vinyl/.
A failed read writes nothing, so the last good collection stays committed and keeps showing, and the file only changes when the shelf actually moved.
DISCOGS_TOKEN=... node scripts/update-vinyl.mjsThe token is a personal access token from
discogs.com/settings/developers.
CI reads it from the DISCOGS_TOKEN repository secret; the workflow skips the
fetch entirely when the secret is missing, because a tokenless run would commit
a payload with the owner filter and every price stripped out.
Whose record is whose comes from the Discogs folder it sits in - a folder named
Dan becomes the dan filter, Alexis becomes alexis. Adding a third
folder adds a third button with no code change. A record left in Discogs'
Uncategorized folder still counts in the totals but belongs to nobody.
Discogs values a collection. It will not value a record, and it will not value a folder.
The endpoint that prices one release, /marketplace/price_suggestions, is
gated behind seller privileges - on a buyer's account it returns an empty
object for every release, which is exactly what it did here on the first run.
So there is no per-record number to sum, and therefore no way to answer "what
is Alexis' shelf worth".
/users/dnafam/collection/value does work, and returns a low, median, and high
figure for everything together, pre-formatted with a currency symbol. Those are
carried through as strings rather than parsed and reformatted.
Because they cannot follow the owner filter, they are deliberately not in
the stat grid. They sit in their own block under a heading that names the count
they cover ("All 51 records"), so filtering down to Alexis' nine never leaves a
seventeen-hundred-dollar figure sitting next to them. tests/site.spec.ts
asserts that heading still reads the full count after the filter is applied.
Everything in the stat grid above it - records, discs, artists, labels, colored wax - is computed in the browser from the filtered list, so those all do follow the filter.
/comics is the record collection's twin: scripts/update-comics.mjs reads
League of Comic Geeks nightly, writes src/content/comics.json, saves the covers
into public/img/comics/, and commits both. Same reasoning as Discogs - the
site's standing promise is that nothing phones home, so the fetch happens in CI
and the result is committed.
It can also be run by hand, which is the fallback if CI ever stops being able to reach the source:
node scripts/update-comics.mjsNo API key, because there is no API. League of Comic Geeks has never published one, so the script talks to the same endpoint their own front end does and parses what comes back. The header of the script explains why that is not a smaller commitment than the npm library it replaced, which had already drifted.
The site is behind Cloudflare, which scores how a client looks and where it is
calling from. A plain curl gets 403 from anywhere; impit presents a real
Chrome TLS handshake and gets 200. That part was always fine.
What was not fine: impit's browser: "chrome" alias resolves to Chrome
124, released early 2024, and sends a matching two-year-old user agent. From a
home connection that passes - every profile impit offers is answered 200 there.
From a GitHub runner's datacenter address, where the score starts marginal, the
stale user agent was the signal that tipped it, and the job returned nothing but
403s.
Pinning a current profile fixed it. So bump browser: in
scripts/update-comics.mjs when impit ships a newer one - the whole reason
this broke is that a profile pinned by default aged out, and it will age out
again.
Two things worth keeping from working that out:
- A real browser is not the answer. Headless Chrome is refused instantly on
its default user agent -
HeadlessChromeis a 403 every time - and with a spoofed one it lands exactly where impit already lands. It is issued nocf_clearancecookie either, because there is no challenge to clear. It knows nothing impit does not, and costs a 150 MB Chromium in CI. - The failure used to be invisible. The script warned and exited 0, so the
workflow went green having committed nothing - identical to a night where the
shelf had not changed.
comics.jsonhad only ever been committed by hand whilevinyl.jsonwas committed by the bot nightly, from the same CI. Both scripts now exit non-zero when they read nothing, which is what made this diagnosable at all.
Discogs has a real API and does not care what you look like or where you call from, which is why that shelf never had any of these problems.
/now is one markdown file, src/content/now.md, and it is the only page here
nothing generates.
The archive is the part worth knowing. You only ever edit now.md; bumping its
updated date is what turns the previous text into an archived entry, and
.github/workflows/now-archive.yml moves it into src/content/now/ on push.
Editing without touching the date is a correction and archives nothing, which
keeps a typo fix from becoming a second entry saying the same thing.
src/content/now/_README.md has the frontmatter and the rest of the rules.
/fortnite shows wins, kills, K/D and the supporting numbers for the Epic
account danwiththeyams, browseable by season and by playlist, with each
season's rates set against the lifetime figure underneath them.
Two files behind it, and only one of them is written by hand:
| File | Written by | Holds |
|---|---|---|
src/content/fortnite.json |
update-fortnite.mjs, nightly |
the numbers |
src/content/fortnite-seasons.json |
you | the season calendar: names, dates, main outfit |
Both are validated at build time like every other collection. A recorded season whose key is not in the calendar fails the build, because the calendar is where everything except the numbers comes from.
The stats endpoint is the one part of Fortnite-API behind a key.
- Get a free key at dash.fortnite-api.com.
- Add it as the
FORTNITE_API_KEYrepository secret. - In Fortnite, turn on Settings > Account and Privacy > Show on Career Leaderboard. Epic defaults this off, and with it off the API answers 403 to everyone including you.
- Run the
Fortniteworkflow by hand once, rather than waiting for the night.
Until that first run there is no fortnite.json, and the page renders an empty
state saying so. That is the same way the record shelf behaves before its first
fetch - a missing file is not a build error.
Locally:
FORTNITE_API_KEY=... node scripts/update-fortnite.mjssrc/content/fortnite-seasons.json is the hand-kept half, and the only Fortnite
file anyone edits. One entry per season, newest first:
{
"key": "ch6-s1",
"chapter": "Chapter 6",
"season": "Season 1",
"name": "Hunters",
"start": "2024-12-01",
"end": "2025-02-21",
"main": { "name": "Jade", "id": "...", "image": "/img/fortnite/jade.png" }
}end is exclusive - the day the next season began - so consecutive ranges
meet exactly rather than leaving a day in neither. These dates decide which
entry the nightly job files today's numbers under, so a typo does not fail the
build, it quietly attributes a month of matches to the wrong season.
main is the outfit worn all season. Epic's stats do not carry it, so it is
written down rather than read. Add the name and run
node scripts/fetch-fortnite-skins.mjswhich resolves it against Fortnite-API's cosmetics catalogue, downloads the
render into public/img/fortnite/, and writes the resolved id and image path
back into the calendar. No key needed - the cosmetics routes are the free half.
Add an entry when a new season starts. Missing one is not damaging: the new season's matches accrue into the previous entry until it is added.
Fortnite-API answers for two windows and no others: lifetime, and the season
running right now. Epic's own service, which it wraps, takes an arbitrary
window, and that is where the season history came from:
node scripts/backfill-fortnite.mjs --url # prints where to get a code
EPIC_AUTHORIZATION_CODE=... node scripts/backfill-fortnite.mjsThis is run by hand and never in CI. The credential that would let a scheduled job mint its own token is a device auth, which can log in as you - a real key to the account rather than a scoped read-only one. A finished season is a fixed set of numbers, so there is nothing for a nightly job to notice. Run it, commit the JSON, let the short-lived token expire.
Epic retires its game clients without notice - fortniteIOSGameClient went dead
after Fortnite left the App Store - so the client is a flag: --client=android
or --client=launcher. A code only works for the client it was issued for, so
switching means getting a new one.
Epic does not aggregate over the window you ask for. It compacts stats into buckets and returns the buckets falling wholly inside the window, and the big ones are per season - a whole season collapses into one bucket whose far edge is the instant that season rolled over.
A window ending at midnight on the day the next season began does not contain that bucket. It does not error; it answers a smaller number that looks like a season:
| Window | Matches |
|---|---|
| Ch6 S1, midnight boundaries | 42 |
| Ch6 S1, ending after the real rollover | 443 |
| All nine seasons, midnight boundaries, summed | 846 |
| Lifetime | 3765 |
Two things make it come out right:
rolloverin the season calendar - the measured instant each bucket closes, found by sweepingendTimea week at a time and binary searching each jump. They land on the published rollover times (Ch6 S1 at 07:00 UTC on 21 February 2025), which is the cross-check that they are real.- Cumulative windows, subtracted - every window runs from the beginning of time to one rollover, so only its right edge can be wrong, and a season is the difference between two of them. Asking for each season's own window instead means both edges have to clear a bucket, and an hour's error on the left silently drops the whole season: that returned 6 matches for Ch6 S3 against its real 461.
The script refuses to write unless the seasons reconstruct the lifetime match count. Every failure this went through would have been caught by that check, and the numbers on the page now reconcile exactly: 3765 of 3765.
The first field on each season entry is the date its numbers start from. A
backfilled season starts from its own first day; one the nightly job saw
part-way through carries a Tracked from ... line on the page, because partial
numbers that look whole are worse than none.
A new role, skill, or interest is one edit to src/content/profile.ts.
Nothing else changes - Home, About, and Career all read from it.
The email address is stored as emailUser and emailDomain and only joined in a
click handler, so it never lands in the served HTML. <EmailReveal /> is the only
thing that puts it on screen.
Everything lives in src/index.css.
Palette. Void black, bone type, an --ember accent for heat and an --ion
cyan for readouts. Light mode is the same poster printed on newsprint. Tokens
follow the shadcn/ui naming (--background, --primary, …) so shadcn
components drop in unchanged, plus --ember, --ion, --star, and --glow.
Retheming is editing the two blocks at the top of that file.
Type. Anton for display, set large, uppercase and tight. Inter for body.
JetBrains Mono for readouts, labels, and code. Self-hosted latin subsets only -
three woff2 files, ~108 kB, declared by hand in src/fonts.css so dist/
carries exactly three font files rather than every subset Fontsource ships.
Primitives. .display for poster type, .display-outline /
.display-outline-ember for the stroked variants, .readout / .readout-dim
for the mono labels, .rule-ticks for the ruler edge, .cut-corners for
registration marks on hover, .solo-badge for the shimmer. Grain and the
starfield live in components/backdrop.tsx. Panels are plain gap-px grids
over a bg-border parent, which is what produces the hairline seams.
Motion is minimal and all of it respects prefers-reduced-motion.
Contrast is enforced, not assumed. Both palettes are checked by axe in CI. That is not decoration: it caught white-on-ember at 3.35:1, which is why button text on the ember accent is near-black rather than bone.
.github/workflows/deploy.yml builds on every push to the default branch and
publishes dist/ to GitHub Pages.
One-time setup: in Settings → Pages, set Source to GitHub Actions. Serving from the branch root would serve the un-built
index.html.
The custom domain lives in public/CNAME so it survives every deploy. Deep
links work because the build writes a 404.html copy of index.html - Pages
serves it for unknown paths and the client router takes over.
Things that were not obvious, in case you hit them too:
- No theme flash. A tiny inline script in
index.htmlsets thedarkclass before first paint;src/lib/theme.tsreads back whatever it decided. - Route changes beat smooth scrolling.
html { scroll-behavior: smooth }means an in-flight scroll can outlive a route change and leave the next page scrolled part way down.components/scroll-to-top.tsxforcesauto, scrolls, and re-asserts on the next frame. - Narrowing the syntax highlighter is not worth it. rehype-highlight
defaults to lowlight's
commonset, roughly 37 languages. Registering only eight measured 189.87 kB against 189.75 kB for the default - the grammars are tiny and the weight is all in the markdown pipeline. The default stays. - Heavy routes are lazy, and the small ones are not worth splitting. The markdown renderer and the syntax highlighter together outweigh the rest of the site - blog posts alone are 57 kB gzipped - so those routes load on demand. The entry bundle is about 131 kB gzipped. Folding the small pages back in was measured and rejected: Comics is 2 kB gzipped and Fortnite 5 kB, but making them eager pulls everything they share into the entry too, which came to +24 kB. A route chunk earns its request or it does not.
- A deploy can delete the chunk an open tab is about to ask for. Chunk names
are content-hashed, so a deploy writes new ones and removes the old. A tab
opened before it still points at the old names, and clicking a lazy route used
to render a blank page - three nightly jobs each trigger a deploy, so this
happened most nights.
components/route-boundary.tsxcatches it and reloads once. The guard is a timestamp rather than a flag on purpose: the obvious version clears the flag when the app mounts, which happens before the chunk fails again, and loops forever. 404.htmlis a copy ofindex.htmlwith one line removed. GitHub Pages has no SPA rewrite, so it serves404.htmlfor every path that is not/- which makes it the one place the two entry points can differ. The home page's hero photo is preloaded from the HTML, because React renders it and the URL otherwise does not exist until the bundle has run (1.5s of dead time on mobile). That preload is stripped from404.htmlso a deep link into/fortnitedoes not pay 47 kB for a photo it never shows. The build fails if the markers that make the strip possible go missing.- The footer fire is a simulation, not a sprite.
components/pixel-fire.tsxruns the Doom fire routine on a low-resolution canvas scaled up withimage-rendering: pixelated. It pauses viaIntersectionObserverwhen it is below the fold, which is most of the time, and settles into a single still frame underprefers-reduced-motion. lib/show-summary.tsimports nothing. It is called from the browser by the share button and from Node by the build that writes the per-show HTML, so a single@/alias orvirtual:import in it would break the Node side.
Fork it, then:
- Replace
src/content/profile.tswith your details. - Delete
src/content/blog/*.mdandsrc/content/shows/*.md, or replace them. Both pages handle being empty. - Swap the photos in
public/img/and the favicon inpublic/favicon.svg. - Put your domain in
public/CNAME, or delete it and use<user>.github.io. If you deploy to a project page rather than a user page, setbaseinvite.config.tsto/<repo>/. - Retheme via the token blocks at the top of
src/index.css. Change the fonts insrc/fonts.cssand the--font-*values in the@themeblock. - Update
.github/workflows/deploy.ymlif your default branch is notmain, and.github/workflows/links.ymlwith the hosts that wall off your links.
The /shows section is the most reusable piece if you keep any kind of log -
it is a markdown collection, a validator, and a stats derivation, and none of it
is specific to gigs.
MIT - see LICENSE. Use whatever is useful.
The photographs of me in public/img/ and the writing under
src/content/blog/ are not covered; please swap those out.