Derived from the code. If this disagrees with the code, the code wins.
BeyondCodeFM is a static podcast website. The UI is vanilla React (Vite, React Router 6, Tailwind). Pages are prerendered to dist/ and served as static assets of a Cloudflare Worker. The only dynamic endpoint is Open Graph image generation.
This replaced a Next.js App Router app. package.json must not gain next, next-intl, or next-themes.
All three consume the same parsed feed and the same React tree (src/Root.tsx).
vite.config.ts sets appType: 'custom' for serve. vite-plugin-podcast.ts:
- On
buildStart,loadPodcastFeed()fetchesPODCAST_RSS_URL(Nodefetch). On failure it readssrc/lib/fixtures/feed.xml. - Exposes
virtual:podcast-feedexporting{ podcast, episodes, rssUrl }as JSON. - Installs middleware that SSR-renders every non-Vite GET:
ssrLoadModule('/src/entry-server.tsx')→applyHtmlTemplateintoindex.html. - Legacy
/enand/zh-CNprefixes 301 viastripLocalePrefix.
Vitest sets VITEST, which skips feed load and the middleware.
vite build emits the client bundle and an index.html shell into dist/. Then scripts/prerender.js:
- Requires
dist/index.htmlalready present. - Starts a Vite SSR server, loads
render/getPrerenderPathsfromsrc/entry-server.tsx. - Writes one HTML file per path using
pathToHtmlFile(/→index.html,/id→id/index.html). - Writes
dist/404.htmlfrom a missing episode id.
npm run preview serves dist/ only. The plugin’s preview middleware repeats the locale 301s and maps URLs onto those HTML files.
worker/index.tsx is the Worker main. It handles GET /api/og?cover= and passes anything else to env.ASSETS. It is not a Vite route. assets.run_worker_first: ["/api/og"] in wrangler.jsonc means only that path runs the Worker; everything else is served straight from dist/.
parseCoverUrl accepts only http: / https: URLs with no credentials. The card layout is src/lib/og-image.tsx (Satori: every box display: flex). nodejs_compat in wrangler.jsonc is required by @cloudflare/pages-plugin-vercel-og.
ogImageUrl prefixes /api/og?cover= with NEXT_PUBLIC_OG_URL when set. Crawlers need that absolute origin.
Typed in src/vite-env.d.ts:
declare module 'virtual:podcast-feed' {
export const podcast: Podcast
export const episodes: Episode[]
export const rssUrl: string
}This is the only client-side feed source. Components must not fetch RSS.
index.html contains placeholders:
<!--app-title-->content="<!--app-description-->"<!--app-head--><!--app-html-->
src/lib/ssr-template.ts substitutes them and adds data-path on #root. src/main.tsx hydrates only when data-path matches the current pathname; otherwise it createRoots. Changing placeholders without updating applyHtmlTemplate (or vice versa) breaks every page.
The inline theme script in index.html reads localStorage.theme and toggles html.dark before paint. ThemeProvider must keep using the same key and the same light / dark / system values.
Canonical paths are unprefixed (/, /{episodeId}).
src/lib/locale.ts:
homePath/episodePathbuild those URLsstripLocalePrefixmaps/enand/zh-CN(and nested episode paths) to the unprefixed formparsePathis used by SSR meta: one segment = episode id, extra segments = 404pathToHtmlFileis the prerender filename convention
src/Root.tsx mirrors this with <Navigate> routes for /en and /zh-CN, plus StripTrailingSlash. Cloudflare _redirects repeats the same 301s at the edge.
There is no language switcher. src/lib/i18n.tsx reads English strings from src/messages/en.json and interpolates with formatMessage (ICU-lite {name} and {n, plural, =1 {…} other {…}}).
RSS GUIDs that are URLs are illegal as path segments. encodeEpisodeId keeps non-http ids as-is; for http(s) it uses the last pathname segment, or the query string if the pathname is empty.
findEpisode decodeURIComponents the route param and matches episode.id or a link suffix.
Headless state lives in src/lib/player.ts (audioReducer + play/pause/seek helpers). AudioProvider is a thin React adapter around an <audio> element. UI buttons must call those helpers, not duplicate the reducer.
src/lib/theme.ts: stored preference is system | light | dark. nextTheme cycles light → dark → system. ThemeSwitcher only calls cycleTheme. applyTheme writes html.dark and colorScheme, with a one-frame transition disable.
podcastConfig.directories + RSS are rendered by PodcastDirectoryLink. Icons are brand SVGs or /…webp assets. Do not replace them with Lucide icons.
PodcastLayout’s decorative waveform uses a seeded PRNG so SSR HTML is stable. ogBarHeights is likewise seeded.
Cloudflare build settings set only:
| Var | Used by |
|---|---|
PODCAST_RSS_URL |
resolveRssUrl |
NEXT_PUBLIC_OG_URL |
resolveOgBaseUrl |
DEFAULT_RSS_URL in podcast-config.ts is the local/dev fallback when PODCAST_RSS_URL is unset. Do not rename the Cloudflare vars.
Cloudflare Workers with static assets (Workers Builds). wrangler.jsonc:
| Key | Value | Why |
|---|---|---|
name |
beyondcode-fm |
Must match the Worker name in the dashboard |
main |
./worker/index.tsx |
OG endpoint |
assets.directory |
./dist |
Prerendered site |
assets.run_worker_first |
["/api/og"] |
Only the OG endpoint invokes the Worker |
assets.html_handling |
drop-trailing-slash |
/{id} serves {id}/index.html with no redirect, matching StripTrailingSlash and data-path |
assets.not_found_handling |
404-page |
Unknown paths get dist/404.html with status 404 |
compatibility_flags |
nodejs_compat |
Needed by @cloudflare/pages-plugin-vercel-og |
public/_redirects is copied into dist/ and applied by Workers static assets (locale 301s).
Dashboard build settings: build command npm run build, deploy command npx wrangler deploy. Locally: npm run worker:dev / npm run worker:deploy.