The podcast's content pipeline and website. Episode outlines and transcripts
live as markdown in content/episodes/; everything published — the site, the
RSS feed, the chapters, the newsletter body, the search index — is derived from
those files at build time.
cp .env.example .env # fill in what you need; every variable is optional for the site
npm install
npm run dev # http://localhost:3000TanStack Start, fully prerendered to static files. There is no server runtime in
production: dist/client is the whole deployment.
Two rendering paths share one Document component and one stylesheet:
- Static documents —
/episodes/<yyyy-mm>and/links. A server route handler renders the whole page withrenderToStaticMarkup. Content appears once in the HTML, there is no hydration, and the only JavaScript is a short inline script (timestamp seeking; link filtering). - Router pages —
/,/about,/search. Ordinary prerendered routes with the framework runtime, about 109 KB gzipped. RSC is enabled in the build for future supporting pages.
Transcripts are deliberately not rendered through RSC or a router loader: both inline a second serialized copy of the content for hydration, which doubles an hour-long episode page.
| Command | Effect |
|---|---|
npm run dev |
dev server on http://localhost:3000 |
npm run build |
prerenders every route to dist/client, then runs Pagefind |
npx pagefind --site dist/client |
rebuilds the search index by hand (postbuild does this normally) |
npm run check |
asserts the build output and prints sizes |
npm test |
unit tests |
Every variable in this repo — build variables and script credentials alike —
lives in one gitignored .env at the repo root. Set it up with:
cp .env.example .env.env.example is committed and is the authoritative list, one comment per
variable; this README does not duplicate it. Vite reads .env natively for the
VITE_-prefixed variables, and the npm scripts pass Node's
--env-file-if-exists=.env for the rest. There is no dotenv dependency.
The site's variables are all optional and all read at build time:
VITE_SITE_NAME, VITE_SITE_URL, VITE_BUTTONDOWN_USER,
VITE_ATPROTO_PUBLICATION_URI, VITE_ATPROTO_DID, VITE_BLUESKY_PROFILE_URL.
Each one that is unset simply omits its feature — the newsletter form, the
/.well-known file, the comments section, the Bluesky link.
Set the same values in Netlify's build environment (.env is not deployed).
VITE_SITE_URL must be set before launch or the feed will carry placeholder
URLs; it is also what publish-atproto writes as the publication url, so the
two can never disagree.
- Record and edit in Descript; publish the audio to Transistor.
- Write the outline in
content/episodes/<yyyy>-<mm>.md— a nested list of topics, each with a link and a timestamp. That one list becomes the table of contents, the chapters, the link index and the newsletter body. npm run publish-transcript <yyyy-mm> <descript-project-id>— appends the speaker-labelled transcript and pushes an SRT to Transistor.npm run ingest— fills in audio URL, duration, season, episode number and people from the Transistor feed.- Commit and push. Netlify builds the site, prerenders every page and
regenerates the search index, link index and RSS feed. Buttondown picks up
the new
/feed.xmlitem and mails the outline to subscribers. npm run publish-atproto -- <yyyy-mm>— writes the episode'ssite.standard.documentrecord to the show's AT Protocol account, links it to the announcement post, and stores the record's AT URI in front matter asatUri. Commit that one-line change.
The site is a standard.site publication. /.well-known/site.standard.publication
carries the publication's AT URI, each episode page carries
<link rel="site.standard.document">, and — when the episode has an
announcement post — a "Reply on Bluesky" link plus a comments section that
fetches the reply thread from the public Bluesky API with a small inline
script. That fetch is the only external request an episode page makes, and the
page reads fine without it.
/ · /episodes/<yyyy-mm> · /links · /search · /about · /feed.xml ·
/episodes/<yyyy-mm>/chapters.json
Every one is a static file after the build. A broken internal link fails the
build, because the prerenderer crawls <a href> from /.
From a full production build of all 39 episodes on 2026-09-08
(npm run build && npm run check):
| Output | Raw | Gzip |
|---|---|---|
| Router-page JS, all 5 chunks | 348,891 B | 111,943 B |
Largest episode document (2025-12) |
202,568 B | 52,362 B |
Newest episode document (2026-07) |
140,274 B | 37,407 B |
/links |
517,905 B | 77,456 B |
/feed.xml |
192,573 B | 55,130 B |
Pagefind index (dist/client/pagefind, on disk) |
2,609,725 B | — |
Only the three router pages load the JS; episode and links documents reference
zero /assets/*.js — npm run check asserts that on every episode, along with
each transcript appearing exactly once.