No description
  • Astro 58.5%
  • TypeScript 28.1%
  • CSS 5.9%
  • Shell 2.9%
  • JavaScript 2.2%
  • Other 2.4%
Find a file
Ryan Grippeling 1f05ac6a58
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
docs: reconcile the decision record and the status page with what shipped
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.
ea4a550 then deleted the job board outright. Rather than rewrite that record
to match — a decision log that gets edited to look correct is worth nothing —
ADR 0009 records what actually shipped and supersedes that one clause. ADR
0008 is ratified to Accepted with a dated history entry pointing forward; its
body stands as decided, and the note says why.

playbook-alignment.md claimed Lighthouse CI had "no runner job … needs a
Chrome-capable Forgejo runner; verify before wiring". Both halves were stale:
the lighthouse job has run on runs-on: docker since ff82bee, and it proves the
runner is Chrome-capable, so that blocker never applied to axe either — axe is
now the only §6.6 gate genuinely missing. The milestone table said "built",
which was true of the repo and misleading about the world; it tracks live now.
The mailbox prerequisite said "before the branch deploys" — the branch has
deployed, so hello@/conduct@/press@ are overdue, not pending, and the
code-of-conduct page publishes a reporting address that currently goes nowhere.

nightly-rebuild.yml explained itself entirely in terms of the deleted job
board, and ran `pnpm expire:jobs` — a script deleted with it. The step is
continue-on-error, so it failed silently every night while the job reported
success; run #7 was green with it broken. Removed, and the comment now names
the real mechanism: the time-based selectors in src/lib/content.ts.

VIK-680
VIK-681
VIK-688

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:58:31 +02:00
.forgejo docs: reconcile the decision record and the status page with what shipped 2026-08-13 11:58:31 +02:00
docs docs: reconcile the decision record and the status page with what shipped 2026-08-13 11:58:31 +02:00
ops fix(ci): unblock deploy, parity, and lighthouse jobs 2026-08-12 15:10:04 +02:00
public fix(ops): pagefind cache split, real front-door redirects, brand caching, Lighthouse gate 2026-08-11 23:08:48 +02:00
scripts fix(search): unblock the Pagefind initialiser, and guard the whole class in CI 2026-08-13 11:50:47 +02:00
src fix(content): point CoderDojo Enschede at a link that actually opens 2026-08-13 11:54:12 +02:00
.dockerignore build: replace make with mise and just 2026-08-04 06:41:33 +02:00
.editorconfig feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
.gitignore feat: align site with the founding pack — flagship /001, trust pages, brand system 2026-08-11 13:55:58 +02:00
.prettierignore feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
.prettierrc.json feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
.releaserc.json feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
AGENTS.md docs(agents): how to actually read Forgejo Actions logs 2026-08-13 09:06:16 +02:00
astro.config.mjs fix(seo): honest 404 head, uncontradicted sitemap, richer structured data, valid feeds 2026-08-12 06:34:02 +02:00
CODEOWNERS feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
CONTRIBUTING.md feat(scope): lean launch — drop the job board, placeholder directory pages 2026-08-12 06:57:27 +02:00
eslint.config.js feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
justfile feat(scope): lean launch — drop the job board, placeholder directory pages 2026-08-12 06:57:27 +02:00
LICENSE feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
lighthouserc.json fix(search): unblock the Pagefind initialiser, and guard the whole class in CI 2026-08-13 11:50:47 +02:00
mise.lock build: replace make with mise and just 2026-08-04 06:41:33 +02:00
mise.toml build: replace make with mise and just 2026-08-04 06:41:33 +02:00
package.json fix(search): unblock the Pagefind initialiser, and guard the whole class in CI 2026-08-13 11:50:47 +02:00
pnpm-lock.yaml fix(ci): security floors for nanoid and undici so the audit gate passes 2026-08-12 13:35:01 +02:00
pnpm-workspace.yaml fix(ci): security floors for nanoid and undici so the audit gate passes 2026-08-12 13:35:01 +02:00
README.md feat(scope): lean launch — drop the job board, placeholder directory pages 2026-08-12 06:57:27 +02:00
renovate.json feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
tsconfig.json feat: bilingual static site with Forgejo CI/CD and Cloudflare deploy 2026-08-03 23:21:07 +02:00
wrangler.toml fix(deploy): use a route, not a custom domain, so the stale record stops mattering 2026-08-13 10:03:19 +02:00

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.

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 — see src/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) and security-headers.conf (nginx). Change one, change both — the parity check fails if they drift.
  • add_header does not inherit into an nginx location that declares its own. That is why every location includes 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.