Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

171 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

danavner.com

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.


Stack

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.

Analytics, but nothing on the page

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.

Running it

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 only

Node 22+. npm test builds nothing itself - run npm run build first, or let CI do it.

Formatting and linting

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-staged runs eslint --fix then prettier --write over 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 typecheck runs 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.

CI

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

Why the data jobs are named in deploy.yml

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 main by name on a workflow_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/.


Structure

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.

How content works

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: true posts 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.


Adding a blog post

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.


Adding a show

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-night

The 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: festival keeps the event's title out of the "bands seen" count while its lineup still counts.
  • Ratings fill proportionally. 3.2 really shows a fifth of the fourth horn. Omitting rating renders nothing - unrated is not zero, and unrated entries stay out of the average.
  • Partial dates are fine. 2026 renders with no day label under the 2026 heading, 2026-06 renders as "Jun", a full date as "Jun 20".

Sharing a show

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.

Why a Spotify link looks better than an image, and where it does not

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.


The record collection

/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/.

Why it is committed rather than fetched

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:

  1. The interesting half needs a token. Unauthenticated, /users/dnafam/collection/folders returns a single "All" folder and /collection/value returns nothing. The Dan/Alexis split and every value stat are authenticated reads, and a token cannot ship in a client bundle.
  2. The site does not phone home. tests/links.spec.ts fails if any request leaves the origin except the GoatCounter beacon. A live Discogs call breaks the test and the claim it protects.
  3. 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.

Running it

DISCOGS_TOKEN=... node scripts/update-vinyl.mjs

The 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.

The valuation is whole-collection only, and the page says so

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.


The comic shelf

/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.mjs

No 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.

Keep the browser profile current - it is load-bearing

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 - HeadlessChrome is a 403 every time - and with a spoofed one it lands exactly where impit already lands. It is issued no cf_clearance cookie 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.json had only ever been committed by hand while vinyl.json was 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.

The now page

/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

/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.

Setting it up

The stats endpoint is the one part of Fortnite-API behind a key.

  1. Get a free key at dash.fortnite-api.com.
  2. Add it as the FORTNITE_API_KEY repository secret.
  3. 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.
  4. Run the Fortnite workflow 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.mjs

The season calendar

src/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.mjs

which 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.

Backfilling a season that already ended

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.mjs

This 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.

Why it is not as simple as passing the season's dates

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:

  • rollover in the season calendar - the measured instant each bucket closes, found by sweeping endTime a 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.

Editing the résumé side

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.


Design system

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.


Deployment

.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.


Implementation notes

Things that were not obvious, in case you hit them too:

  • No theme flash. A tiny inline script in index.html sets the dark class before first paint; src/lib/theme.ts reads 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.tsx forces auto, scrolls, and re-asserts on the next frame.
  • Narrowing the syntax highlighter is not worth it. rehype-highlight defaults to lowlight's common set, 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.tsx catches 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.html is a copy of index.html with one line removed. GitHub Pages has no SPA rewrite, so it serves 404.html for 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 from 404.html so a deep link into /fortnite does 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.tsx runs the Doom fire routine on a low-resolution canvas scaled up with image-rendering: pixelated. It pauses via IntersectionObserver when it is below the fold, which is most of the time, and settles into a single still frame under prefers-reduced-motion.
  • lib/show-summary.ts imports 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 or virtual: import in it would break the Node side.

Making it yours

Fork it, then:

  1. Replace src/content/profile.ts with your details.
  2. Delete src/content/blog/*.md and src/content/shows/*.md, or replace them. Both pages handle being empty.
  3. Swap the photos in public/img/ and the favicon in public/favicon.svg.
  4. 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, set base in vite.config.ts to /<repo>/.
  5. Retheme via the token blocks at the top of src/index.css. Change the fonts in src/fonts.css and the --font-* values in the @theme block.
  6. Update .github/workflows/deploy.yml if your default branch is not main, and .github/workflows/links.yml with 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.

License

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages