ARCHITECTURE
How this site works
Content is plain text files in a git repo; one command compiles it into a full set of static artifacts; pushing deploys it. No CMS, no database, no server runtime — production is static files on a CDN.
That single constraint is the root of everything else: with no server, search has to run in the client, endpoints have to be files written at build time, there can be no auth, and nothing can depend on a third-party API at request time.
Big picture: one file to production
Four layers — source → contract → build → artifacts — then a push to the CDN. Only the first layer is written by hand.
① Source · the only hand-written part
src/content/<coll>/
Chinese content
src/content/<coll>En/
English parallel
src/data/*.json
bio / socials
src/i18n/ui.ts
UI strings
│
② Contract · Zod schema
src/content/config.ts
missing field / wrong type → build fails (not a blank page in production)
│
pnpm build
③ Build · Astro
field shapes: src/lib/api.ts (one copy, shared by zh / en)
│
④ Artifacts · all static files
5 content types × 2 languages
59 entries × 2
HTML pages
every page in both languages + 404
24 endpoint kinds
12 × 2 languages → 118 JSON files
2 RSS feeds
/rss.xml · /en/rss.xml
2 llms.txt
agent discovery entry
sitemap · robots · _headers
hreflang / Content-Signal / CORS
│
git push origin main
Cloudflare Pages
live in 1–2 minutes
│
humans
browser
zhanglu.net · /en/
agents
JSON endpoints
/api · /en/api
CLI
npm package
npx zhanglu-net
crawlers
sitemap + robots
Content-Signal
5 content types
Each type has parallel Chinese and English content, 1:1 in count. One collection = one directory; one entry = one markdown file.
| Collection | Entries | Pages | Endpoints | Notes |
|---|---|---|---|---|
| projects Projects | 8 × 2 | list + detail | list + detail | carries loc / persona / cover |
| articles Writing | 5 × 2 | list only | list only | a writing index — links point to the original source |
| presentations Decks | 4 × 2 | list only | list only | cards link straight out |
| skills Skills | 41 × 2 | list + detail | list + detail | 16 auto-synced + 14 hand-written |
| weekly Weekly | 1 × 2 | list + detail | list + detail | redacted public notes |
Write once, appear in 7 places
This is where the structure pays for itself. Take
src/content/projectsEn/boss.md —
write one file and it shows up in every one of these, with
nothing to sync by hand.
projectsEn/boss.md
│ pnpm build
The Chinese projects/boss.md produces 7 more
How the bilingual setup works
Routing
Chinese at the root /, English under /en/. Assets, /api, external links and anchors are never prefixed — only page routes are.
Detection
Components call getLangFromUrl(Astro.url) and detect for themselves, rather than threading a prop down every level.
Content
Parallel *En collections, not one collection with a language field — the latter needs filtering in a dozen consumers, and missing one leaks the wrong language. The cost is writing content twice.
UI strings
A dictionary in src/i18n/ui.ts (zh / en). Page-specific prose stays in each language’s own page file, out of the dictionary.
First visit
Redirects by navigator.language — an English browser opening zhanglu.net lands on /en/.
Manual switch
The header “中 / EN” writes the choice to localStorage; after that your choice wins and the auto-redirect stops.
SEO
<html lang>, hreflang (zh-CN / en / x-default), og:locale, per-language RSS, sitemap i18n.
The layer built for machines
Everything is published as JSON too, so agents never have to parse HTML. Full usage lives at /en/agents.
Discovery entry
/en/llms.txt · /llms.txt
↓
manifest
/en/api/index.json — counts + every endpoint + cross-language links
↓
Lists · 8
projects · articles · presentations · skills · weekly · about · social · search
Details · 3 (with body_md)
projects/{slug} · skills/{slug} · weekly/{slug}
- · Every response carries a
langfield, so you can confirm what you got. - · Missing paths return a real 404 — status codes are reliable.
- · CORS is declared explicitly in
public/_headers, not left to the host's defaults. - · Search is client-side: fetch the whole corpus once, match substrings — because there is no server.
A few constraints, and why
These aren't abstract principles — each one came out of a real piece of rework.
No server
Production is nothing but static files. Always up, zero ops, a build is a snapshot. Every other decision follows from this — that is why search runs client-side, why endpoints are files written at build time, and why there is no auth.
Schema is enforced
Zod schemas live in src/content/config.ts. A missing field or wrong type fails the build — instead of shipping a blank page to production.
Field shapes defined once
All endpoint field shapes live in src/lib/api.ts, shared by the zh and en endpoints. Early on each endpoint inlined its own fields and they drifted: the list had loc/persona/cover, the detail did not.
Drifty numbers are computed
Every count on this page, and the CLI line count and version on /agents, are read at build time. The copy once hardcoded “270 lines” long after it had passed 500.
Status codes are honest
A missing path returns a real 404, not 200 plus the homepage. An agent can trust the status code to tell whether an endpoint exists.
Table-driven
The CLI's KINDS table: adding a content type touches one place, and list / get / help text all follow automatically.
From one edit to production
edit src/content/... or src/data/...
│
├── pnpm dev hot reload locally
├── pnpm build must pass (schema check + artifact generation)
├── docs/dev-log/ leave a process record
└── git push main → Cloudflare Pages → live in 1–2 minutes The full working rules live in AGENTS.md — the authoritative guide for AI agents and for future me, covering schemas, traps already hit, and the release flow. The architecture doc behind this page: docs/architecture.md.