- Astro 58.5%
- TypeScript 28.1%
- CSS 5.9%
- Shell 2.9%
- JavaScript 2.2%
- Other 2.4%
|
All checks were successful
[Workflow] On Source Change / Static Analysis (Prettier, ESLint, Typecheck, Audit, Knip, Outdated) (push) Successful in 1m35s
[Workflow] On Source Change / Static Analysis (push) Successful in 0s
[Workflow] On Source Change / Container Parity (push) Successful in 44s
[Workflow] On Source Change / Node.js test suite (push) Successful in 1m19s
[Workflow] On Source Change / Unit Tests (push) Successful in 0s
[Workflow] On Source Change / Node.js test suite-1 (push) Successful in 1m13s
[Workflow] On Source Change / Content Validation (push) Successful in 0s
[Workflow] On Source Change / Node.js test suite-2 (push) Successful in 1m19s
[Workflow] On Source Change / Build Site (push) Successful in 0s
[Workflow] On Source Change / Deploy Preview (push) Has been skipped
[Workflow] On Source Change / Deploy Production (push) Successful in 1m24s
[Workflow] On Source Change / Lighthouse Budgets (push) Successful in 6m44s
Three documents disagreed with the repository, and one workflow disagreed with itself. ADR 0008 sat at Proposed while deciding jobs and companies were "kept but demoted … reachable via the footer", and explicitly rejecting deleting them. |
||
|---|---|---|
| .forgejo | ||
| docs | ||
| ops | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| .releaserc.json | ||
| AGENTS.md | ||
| astro.config.mjs | ||
| CODEOWNERS | ||
| CONTRIBUTING.md | ||
| eslint.config.js | ||
| justfile | ||
| LICENSE | ||
| lighthouserc.json | ||
| mise.lock | ||
| mise.toml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json | ||
| tsconfig.json | ||
| wrangler.toml | ||
twente.dev
Twente's practitioner-led technology community — a shared calendar, directory, archive and newsletter, built around numbered flagship events (first up: twente.dev/001 — Reconnect, 7 October 2026, Enschede). Bilingual NL/EN. We build it. We run it. We share it.
A static Astro site on Cloudflare's free tier, built and deployed from Forgejo Actions. No backend, no database, no cookies. Total recurring cost: one domain renewal.
- Plan:
docs/plan/10x-plan.md, updated bydocs/plan/playbook-alignment.md(ADR 0008) - Decisions:
docs/adrs/ - Contributing:
CONTRIBUTING.md
Quick start
mise pins the toolchain, just runs the tasks.
mise install # Node 24 + just, per mise.toml
just setup # corepack enable + pnpm install
just # list every task
Then:
just dev # http://localhost:4321, hot reload
just preview # http://localhost:8080, the production image
just parity # assert the container matches Cloudflare
just check # every gate CI runs
No local Node at all? just dev-docker runs the dev server in a container.
pnpm is deliberately not pinned in mise.toml — its version is packageManager
in package.json, which corepack reads and which the Dockerfile and CI use too.
Pinning it twice is how a laptop and a container quietly end up on different
pnpm versions.
Scripts
Every just recipe wraps a pnpm script, so both work. just --list is the
canonical index; the scripts themselves are:
| Command | What it does |
|---|---|
pnpm dev |
Dev server |
pnpm build |
Static build into dist/, then the Pagefind search index |
pnpm preview |
Serve the built output locally |
pnpm typecheck |
astro check — also the i18n completeness gate (see below) |
pnpm lint / pnpm format |
ESLint / Prettier |
pnpm test |
Unit tests for src/lib |
pnpm validate:content |
Cross-entry content checks (references, duplicate slugs, translations) |
How it fits together
Two locales, explicit prefixes. Everything lives under /nl/… or /en/…; / redirects. Route
segments are localized too (/nl/bedrijven ↔ /en/companies), which is why URLs are always built with
routePath() from src/i18n/routes.ts and never by string-swapping a prefix.
hreflang alternates come from the same table, so they cannot point at a page that was never built.
Missing translations are a compile error. src/i18n/ui.ts types the English
dictionary as Record<UIKey, string> against the Dutch one. Add a key to nl and forget en, and
pnpm typecheck fails. There is no runtime fallback — an English page can never silently render
Dutch.
Content is the contribution contract. The four collections in
src/content.config.ts are Zod-validated at build time, so a malformed
submission fails CI before a human reviews it. pnpm validate:content adds the cross-entry checks a
per-entry schema cannot see.
Past events archive themselves. Nothing deletes files; the selectors in
src/lib/content.ts filter on the current time and the nightly rebuild
re-runs them. That is the whole expiry mechanism.
Structured data is the distribution channel. src/lib/jsonld.ts emits
Event JSON-LD so Google Events indexes the content directly.
It is the highest-leverage code here per line and the easiest to break silently — Google ignores
malformed entries rather than reporting them, so check changes against the Rich Results Test.
Content
⚠️ The companies and events currently in
src/content/are fixtures with fictional organisations. Delete them before launch — seesrc/content/README.md.
Local containers
The container is not the deploy target (ADR 0007).
It exists because astro dev and astro preview do not exercise any of Cloudflare's serving rules —
extensionless URLs, a real 404 status, cache policy, security headers — which leaves a class of bug
with no local signal at all.
| Path | What |
|---|---|
ops/docker/web/Dockerfile |
Multi-stage build; nginx-unprivileged serves dist/ |
ops/docker/web/nginx.conf |
Mirrors html_handling and not_found_handling from wrangler.toml |
ops/docker/web/security-headers.conf |
Mirror of public/_headers |
ops/local/docker-compose.yml |
dev (hot reload) and preview (production image) |
ops/local/parity-check.sh |
Asserts the mirroring over real HTTP |
The parity claim is a test, not a comment. just parity checks routing, status codes, content
types and headers against the running container. Its first run caught a types { } block in
nginx.conf that replaced nginx's entire mime map — serving every page as application/octet-stream,
i.e. a site that downloads rather than renders. Nothing else in the toolchain would have caught it.
Two things worth knowing if you edit the serving config:
- Response headers are defined twice, in
public/_headers(Cloudflare) andsecurity-headers.conf(nginx). Change one, change both — the parity check fails if they drift. add_headerdoes not inherit into an nginx location that declares its own. That is why every locationincludes the shared snippet rather than relying on the server block.
The main CSP is not in either file: Astro emits it as a <meta> element with per-page hashes for
every inline script and scoped style (security.csp in astro.config.mjs), so the policy travels
with the HTML and is identical under both servers — no unsafe-inline despite the inline theme
script. Only frame-ancestors, which meta-delivered CSP ignores, needs a real header.
Deployment
Cloudflare Workers Static Assets, deployed with wrangler from Forgejo Actions. Cloudflare's native
git integration supports GitHub and GitLab only, so there is no "connect the repo" path for a Forgejo
consumer — wrangler.toml plus the CI job is the deployment contract.
CI needs two Forgejo secrets: CLOUDFLARE_API_TOKEN (scoped to Workers Scripts: Edit + Account:
Read — not a global key) and CLOUDFLARE_ACCOUNT_ID.
Pushes to any branch get a preview URL; main deploys to production and runs a smoke test.
Licence
Code MIT, content CC BY 4.0. See LICENSE.