Content sources
Realm splits "content" into three layers, and no single layer is the complete source of truth — they complement each other and fall back to each other.
1. Bundled typed data (data/)
All structured content (works, experience, life, friend links, skills, site metadata) lives under data/, split by topic and locale:
Static registry
lib/i18n-data.ts is the explicit static registry from locale to module, exposing:
resolveWebProject(id, locale) and resolveLifeItem(slug, locale) provide per-item fallback chains:
The returned TranslationStatus ('source' | 'translated' | 'fallback') lets detail pages render a LocaleFallbackBanner.
Why a static registry
Do not replace the explicit registry with dynamic require. Pages Router reads data on the Node side; dynamic require triggers webpack's Critical dependency warnings and makes bundling less predictable. Both GOTCHAS.md and CLAUDE.md flag this rule.
2. Bundled fallback (content/)
Only one entry: content/blog/init/. It contains six locale variants of a built-in fallback post (zh-CN.mdx zh-TW.mdx en.mdx ja.mdx ru.mdx fr.mdx) used when the external content repo is not configured.
3. External GitHub content repository
When the following env vars are present, the site reads at runtime from a private GitHub repo:
Expected repository shape:
UI locales and content locales are two sets. UI locales are only zh-CN / zh-TW / en; content locales add optional ja / ru / fr. Extra content locales show up in the per-post language switcher on blog detail pages; they do not change the UI language.
When a requested UI locale lacks a post variant, the blog layer falls back to the post's default / source locale and marks the translation state so the detail page can show a fallback banner.
4. Tweet stress mode
In development, set TWEETS_STRESS_TEST=1 to render synthetic data on the tweets page (no external repo needed). Knobs:
TWEETS_STRESS_YEARSTWEETS_STRESS_MONTHS_PER_YEARTWEETS_STRESS_TWEETS_PER_MONTH
Never enable this in production.
Security constraint
lib/content/github.ts strictly normalizes content paths before calling the GitHub Contents API:
- Reject absolute URLs and protocol-relative URLs
- Reject leading
/, backslashes, query strings, hash fragments, traversal, and encoded traversal - Split the path into segments and encode each segment explicitly
- Build the final URL from a fixed trusted GitHub API base
GOTCHAS.md item 22 highlights this. Any change that lets user input flow into lib/content/github.ts must re-read that item first.