The build is a directory of static files. Nothing on the server runs code, reads a database or
holds session state, so any static host serves it: Cloudflare Pages, Netlify, Vercel, GitHub
Pages, S3 behind CloudFront, or python3 -m http.server on a laptop. Cloudflare Pages is the
host the repository is set up for, and the only one with files checked in (public/_headers).
The Direct Upload project is codewiki in the tomchen account, with main as its production branch and
https://codewiki.pages.dev as its Pages address. The canonical domain is
https://codewiki.com.
The source repository is codewiki-com/codewiki.
Pushing to main runs .github/workflows/ci.yml: lint, type checks, unit tests, the static build,
link checks, browser tests and Lighthouse. Only a successful run on main publishes the exact
tested dist/ to Pages. Pull requests run the checks without deploying. The workflow can also
be started manually from GitHub Actions.
Set the repository's CLOUDFLARE_API_TOKEN Actions secret to a token with Cloudflare Pages →
Edit permission for the tomchen account. The account ID is the workflow's CLOUDFLARE_ACCOUNT_ID
environment variable; wrangler.jsonc selects the project and build output.
Credentials are never committed. No Cloudflare Git integration or Cloudflare build is needed.
The CI token does not need DNS permissions. Any token used for the initial custom-domain DNS
migration stays local and can be revoked after the domain is active.
For a manual deployment from the local working tree:
npx --yes wrangler@4.135.0 login
pnpm run deploypnpm run deploy rebuilds the current working tree, checks internal links and uploads only dist/.
It does not commit or push source code. wrangler.jsonc selects the Pages project and output
directory. The --force option keeps Wrangler 4.135.0 on Pages instead of delegating to Workers.
This is a static deployment with no Pages Functions, database or paid bindings. Pages Free allows 20,000 files and a maximum of 25 MiB per file. Use Wrangler to upload the full site; the dashboard's drag-and-drop uploader only accepts 1,000 files.
The custom domain must also be added under the project's Custom domains. An apex domain
such as codewiki.com must be an active Cloudflare zone in the same account as the Pages project.
Creating the Pages project alone does not change the domain's existing DNS or website.
References: Direct Upload, limits and custom domains.
| setting | value |
|---|---|
| build command | pnpm build |
| output directory | dist |
| Node version | 24 — read from .nvmrc; set NODE_VERSION=24 on hosts that ignore it |
| install command | pnpm install --frozen-lockfile (Cloudflare detects pnpm from the lockfile) |
| environment variables | none |
| root directory | the repository root |
pnpm build runs its prebuild lifecycle first. That invokes scripts/vendor-pyodide.mjs and
scripts/vendor-sqljs.mjs, which copy the Python and SQL runtimes into public/vendor/. The build
then runs astro build and pagefind --site dist to write the search index. All four operations
are part of the one command; a host that runs only astro build ships a site whose search, Python
runner and SQL runner are missing.
The build downloads about 17 MB of fonts into .cache/fonts for the OG image renderer. On a
host with no cache the first build is a few minutes slower; the CI workflow caches that
directory, keyed on the script that names the fonts.
Mermaid rendering shares two browser slots across Markdown processors. Unbounded MDX transforms can otherwise open hundreds of Chromium pages and exhaust a standard GitHub runner's memory. CI also caps Node's heap at 6 GiB and records build resource usage.
public/_headers is copied verbatim into dist and read by Cloudflare Pages. It sets:
- a Content Security Policy that keeps the site to its own origin — no third-party scripts, styles, fonts or connections, and no framing of the site by anyone;
X-Content-Type-Options,Referrer-Policyand aPermissions-Policythat turns off camera, microphone and geolocation;- immutable year-long caching for the fingerprinted assets (
/_astro/,/vendor/,/fonts/) and a week for the generated OG images; Cache-Control: no-cacheon/sw.js— the service worker must be revalidated on every update check, or a browser keeps an old precache and never offers the new build (docs/dev/pwa.md).
Two details are worth knowing before editing that file:
'unsafe-inline'inscript-srcis there for the theme and font-size bootstraps, which must run inline in<head>before first paint. Moving them to an external file would trade a flash of the wrong palette for a stricter policy; if that trade is ever wanted, load them withblocking="render"and drop the keyword./sandbox.htmland/sandboxhave their own rules. Pages redirects the former to the extensionless URL, so both responses need the sandbox policy. The JavaScript runner compiles the reader's snippet withnew Function, which CSP counts as eval, so that one document adds'unsafe-eval'. Pages merges every matching rule, so the sandbox block uses! Content-Security-Policyto detach the global value before setting its own complete policy. The frame is embedded withsandbox="allow-scripts"and withoutallow-same-origin, so it runs on an opaque origin and the relaxation cannot reach the site's DOM, storage or cookies.
On a host without a _headers file, translate the same values into whatever it uses — Netlify
reads _headers too, Vercel wants vercel.json, and CloudFront wants a response-headers policy.
The site works without any of them; it is simply less locked down.
The site's absolute URLs (canonical links, hreflang, sitemap and OG images) all come from
site in astro.config.mjs, currently https://codewiki.com. Changing the domain means
changing that value and rebuilding — the URLs are baked into the HTML, not resolved at runtime.
On Cloudflare Pages: add the domain under the project's Custom domains, let it create the
CNAME, and leave the *.pages.dev name in place — it stays reachable, so the canonical tags
are what keep search engines on the real domain. trailingSlash: 'always' and
build.format: 'directory' mean every page is a .../index.html, which is what Pages, and every
other static host, serves for a directory request.
Page metadata is built in src/lib/seo.ts: canonical URLs, language alternates, Open Graph and
Twitter cards, and page-specific JSON-LD. Topics expose their reviewed date, section and tags to
Open Graph and their generated image to JSON-LD. Publication dates and author profiles are omitted
when the content has no reliable source for them. Reused sharing images describe the source card.
scripts/sitemap-metadata.ts uses content review/verification dates for topic and cheatsheet
lastmod values; it never substitutes the build date. Undated index pages remain undated.
The link checker also validates destinations in structured data.
lighthouserc.json drives pnpm exec lhci autorun, which builds nothing itself: it serves the
existing dist and audits eleven URLs. The four P1 surfaces are the English home page, a track hub,
and the topic page in both languages. The P2 surfaces are Practice, Python from zero, Flashcards,
the Python cheatsheet, Prompt Builder and Playground, plus the JavaScript practice catalogue. Lighthouse uses its default mobile
emulation, with three runs per URL and median assertions. Performance is gated at 0.95; accessibility,
best practices and SEO must score 1.00. The transferred-script ceilings are 66 KiB
for P1, 100 KiB for Practice, paths, Flashcards, cheatsheets and Prompt Builder, and 300 KiB for
Playground.
Three notes on that file, since JSON cannot carry comments:
- Mobile CLS is gated, not hidden by a desktop preset. The original 0.183 CLS was real: the
topic component's processed TOC module ran after
DOMContentLoadedand collapsed the expanded mobile contents row after first paint, moving the article column. The same behavior now marks the TOC as enhanced in an inline bootstrap before the article is parsed; mobile CSS therefore starts with the row collapsed, while a browser with JavaScript disabled still sees the expanded contents. A local mobile run measured CLS 0 on all four URLs and performance 1.00, 0.99, 0.99, and 0.99 respectively, so the 0.95 performance gate remains unchanged. - Accessibility is release-gated.
color-contrastandlabel-content-name-mismatchare Lighthouse errors, and the Playwright accessibility coverage checks the contrast-sensitive UI directly. Playwright checks all reported WCAG 2.2 AA and best-practice violations across both languages and themes, plus keyboard focus, mobile menus and the open search dialog. network-dependency-tree-insightisoff. It scores zero for any critical request chain deeper than one hop, which the HTML → stylesheet → webfont chain of a self-hosted static site always has.
pnpm lint && pnpm check && pnpm test && pnpm build && pnpm check:links && pnpm test:e2e && pnpm exec lhci autorun is what CI runs, in that order. The link checker, Playwright and Lighthouse all read
dist, so the build has to come first. The Playwright config deliberately sets
reuseExistingServer: false: if any preview, including one from another worktree, already owns
port 4321, the run fails instead of silently testing that server's dist. Check the port with
ss -ltnp | grep ':4321 ' before starting a local run. In CI, CHROME_PATH is set to Playwright's
installed Chromium before LHCI runs, so the gate does not depend on whichever system Chrome
happens to be on the runner image.