Central platform for European atproto.<cc> country community websites
website AGENTS.md
16 kB
Markdown
at main

atproto.nl — website #

Community website for the AT Protocol community in the Netherlands, for anyone active in NL, Dutch or not. Static-first Astro site, English default, bilingual EN/NL, dark/light, built as AT-Protocol-native as practical. Independent project (not affiliated with any company). Home base on Tangled.

(CLAUDE.md is a symlink to this file — one source of truth for all agent tools.)

Workspace: Notes, research, and decisions for atproto Europe live in the Obsidian vault at ~/Documents/CoreNotes/Workspaces/atproto-europe, not in this repo. Terminal alias: cc-atprotoeu.

Tech Stack #

Component Technology
Framework Astro (static output + Node adapter for on-demand routes)
Language TypeScript (strict, no any, no @ts-ignore)
Styling Tailwind (v4) + CSS custom-property tokens; no inline styles
Design Handover from Claude Design pending — theme tokens are placeholders
i18n Astro i18n (en default at /, nl under /nl/); strings in src/i18n/ui.ts
Theming color-scheme: light dark + light-dark(); two-state toggle, system default
Testing Vitest (+ axe for a11y)
atproto @atproto/api behind a service layer

Coding Standards #

  1. TDD — write tests before implementation (Vitest). Aim >=80% coverage.
  2. Strict TypeScript — explicit types, no any, no @ts-ignore.
  3. ES modules, built-in fetch, async/await over Promise chains.
  4. Path aliases, named exports, components PascalCase / vars camelCase.
  5. Conventional commits — type(scope): description.
  6. Input validation (Zod) on external input; sanitize user-generated / atproto content before render.
  7. Pin exact versions — no ^/~ (tighten before release).
  8. CI must pass — lint, typecheck, tests, a11y, content + trademark + copy scans.

Astro #

  • Fully static. output: 'static', no server/on-demand routes. Reads happen at build time (public, no auth); all atproto writes run client-side via the visitor's OAuth session. (Superseded the earlier plan to use prerender = false write routes — see "As-built patterns".)
  • Small components (~150 lines), composable.
  • Client islands (client:*) are the exception — justify with a comment.

Accessibility & SEO #

  • WCAG 2.2 AA, semantic HTML + ARIA, axe in the test suite, light and dark both tested.
  • Mobile-first (build at 375px). Optimize images, validate content in CI.

atproto #

  • Users own their data on their own PDS; identity is portable via DIDs — no lock-in.
  • Never call @atproto/api directly from components/pages — use a service layer.
  • Reference DIDs internally; resolve handles at display time. Never persist *.atproto.nl (or any) handle strings as identity keys.
  • Events use the standard community.lexicon.calendar.*. Membership/forum lexicon: TBD.

Trademark compliance (public-facing text — our own copy only) #

Comply with the AT Protocol trademark policy. A check-trademark CI lint runs over text we author (pages, src/i18n/ui.ts, meta/OG, README) — NOT over ingested atproto content we don't control (posts/profiles/events from other DIDs); CI must not fail on that.

  • Approved: AT Protocol, atproto, atprotocol.
  • Flag/fail: at-proto, AtProto, AT-Protocol, atProto, Atproto; version/fork terms (AT Protocol v2, atproto-next, official AT Protocol fork); official-status phrases (Official AT Protocol, AT Protocol Certified, Certified/endorsed by Bluesky); concatenated brand forms (Atproto[A-Z]).
  • Allow-list code identifiers, package names, DIDs, and the atproto.nl domain literal.
  • Keep our own community name at least as prominent as the mark; don't imply official status.

Copy scan (voice / AI-writing — our own copy only) #

A check-copy CI scan checks our authored copy for AI-writing tells. Same own-text-only scope as the trademark lint.

Domain note #

We use atproto.nl with permission (we don't own it). Never route email, OAuth, or account recovery through the domain — nothing we can't re-home should depend on it.

Git Workflow #

  • Home base is Tangled (tangled.org/atcommons.eu/website), not GitHub. It is the single source of truth; deploy pushes go to Tangled only (see the Deploy section).
  • Never commit directly to the default branch. Branch/worktree per change: type/short-description. Extensive changes: short PRD first, work in a worktree.

Local dev #

  • npm run dev — dev server at localhost:4321.
  • npm run build — production build to ./dist/.
  • npm run preview — preview the build.
  • npm run astro -- check — typecheck.
  • Docs: https://docs.astro.build (routing, components, content collections, styling, i18n).

As-built patterns & defaults #

Concrete conventions this codebase actually follows. When in doubt, match these.

Deploy (Tangled Spindle CI → Codeberg Pages) #

Tangled is the single source of truth; deploy = push main to Tangled. A push to main triggers the Tangled Spindle pipeline in .tangled/workflows/deploy.yml. It is publish-only: it runs npm ci, then builds every scope from src/config/scopes.ts (iterating node scripts/gen-scopes.mjs --list, so the deploy set is never hand-listed; SITE_SCOPE=<cc>; SCOPES.md is the generated human-readable table, kept in sync by npm run gen:scopes + a vitest gate) and force-pushes each dist/ to its own Codeberg Pages repo (atproto<cc>-website, pages branch), which Codeberg Pages serves at the matching domain. No manual step: push main and it deploys.

Quality gates do NOT gate the publish (decoupled 2026-09-22; see the workspace decision 2026-09-22-deploy-pipeline-hardening.md). Coupling them was the #1 failure: one banned character or flaky test aborted the run and froze all 12 live sites silently. Tests + copy-voice now run pre-merge — the .husky/pre-push hook (the real enforcement) and the checks.yml PR workflow (advisory until Tangled exposes required checks). deploy.yml still runs them as a non-blocking advisory step that only reports; a hit never withholds a publish (fix forward). Every network op retries, each build is timeout-bounded, a collapsed-content guard refuses to force-push a blank homepage over a good one, and on any per-scope failure the job POSTs to the optional deploy_alert_url secret and exits non-zero. Each pages branch carries a .deployed-sha marker (the source commit that built it) for a future scheduled reconciler.

Push to Tangled ONLY. Never push the Codeberg source mirror. origin is Tangled for both fetch and push (a single push URL). Writing to the Codeberg source mirror (atprotonl-website main) is what caused the 2026-09-22 freeze: it and Tangled main diverged two ways, so neither push fast-forwarded and the Spindle stopped firing for ~11h. That mirror is now abandoned/frozen; nothing reads or writes it. Push with:

git push origin main            # origin is Tangled-only
git push tangled.org:did:plc:aqrkn66k2alnwxfrhstp3unq/website HEAD:main   # explicit, from a worktree

git remote -v should show a SINGLE origin push URL, the Tangled one. If a codeberg.org/gxjansen/atprotonl-website push URL ever reappears on origin, remove it: git remote set-url --delete --push origin codeberg.org:gxjansen/atprotonl-website.git.

Stats refresh (NAS, every 6h) is Tangled-only too. The scheduled NAS job (scripts/nl-stats-publish.sh) clones Tangled, regenerates the stats/facts JSON, commits, and pushes main to Tangled only, which retriggers the Spindle so fresh stats (and new members, enumerated live at build) reach every site. It reads its own script + python helpers from Tangled raw with curl -fsSL (the -L matters: Tangled raw 302-redirects to its blob backend). It never touches Codeberg source.

Spindle prerequisites (set once in the Tangled repo settings; if a push doesn't deploy, check these first):

  • A Spindle attached via the repo Pipelines tab (hosted tangled.org spindle). Nothing runs without one.
  • A repo secret named atprotocc_sites = a Codeberg token with write:repository on all the atproto*-website Pages repos. Tangled injects it as an env var of the same name; the workflow reads $atprotocc_sites (Tangled rejects - in secret names, hence the underscore).
  • Optional repo secret deploy_alert_url = a webhook that turns a POST body into an alert (e.g. an ntfy topic URL). If set, a failed deploy POSTs the stale scopes there so a freeze is not silent; if unset, failures still log to the run. Never use a channel on a borrowed atproto.* domain.

When it breaks — where to look:

  • Read a run's log: ssh -t -p 3333 tangled.org did:plc:fekl5h677qdyo5e4dpl3c4hl <commit-sha> — a TUI that needs a REAL terminal (it does not scrape headless), or the web Pipelines tab. The push output prints this exact command.
  • Verify what actually deployed: each Codeberg pages branch's latest commit is authored atproto.nl deploy, message deploy <scope> <sha>. Check: curl -s "https://codeberg.org/api/v1/repos/gxjansen/atproto<cc>-website/commits?sha=pages&limit=1". Author nl-stats deploy instead = an old direct deploy from the scheduled stats job (it no longer publishes pages; only the Spindle does).
  • Failure shape: npm ci is the only early step that can fail the run now (tests + copy-voice are advisory and never block). A red run therefore points at the deploy step: a missing atprotocc_sites secret (aborts before any scope publishes), or a per-scope build/guard/push failure (the job names the stale scopes in the deploy_alert_url alert + the log and exits non-zero; the other scopes still published). A transient early failure — re-trigger with an empty commit: git commit --allow-empty -m "ci: re-trigger" && git push origin HEAD:main.

Manual fallback (always works — Spindle down, token expired, urgent hotfix): from a Mac checkout at the target commit, build+push each scope to its Pages repo via the codeberg* SSH remotes (full account access, no Spindle or token needed). Loop over the scopes in src/config/scopes.ts (the source of truth); as of 2026-09-22: for s in $(node scripts/gen-scopes.mjs --list); do SITE_SCOPE=$s bash scripts/deploy-codeberg.sh; done (add the codeberg-<scope> SSH remote first if missing; the script prints the exact git remote add).

Notes:

  • Every scope redeploys on every push, by design. The pages fetch live data at build time (members, builders via sifa.id, feeds, time-filtered events), so their output drifts run-to-run and a content-diff almost never skips, so we don't bother diffing, just publish them all (~2-min run vs the hosted spindle's 15-min cap). Builds ARE deterministic (src/lib/seeded-shuffle.ts seeds the list shuffles) so the display order stays stable between deploys.
  • A scheduled 6h stats job (scripts/nl-stats-publish.sh, run on the NAS, git author nl-stats deploy) clones Tangled, refreshes the stats JSON, commits it, and pushes main to Tangled only; that push retriggers the Spindle, which republishes with the fresh stats. It does not deploy pages itself (the Spindle is the sole publisher), and it never writes the Codeberg source mirror. (History: it used to push both remotes; the two mains diverged and froze the deploy for ~11h on 2026-09-22, which is why it is Tangled-only now.)
  • Custom domain per scope needs the A/AAAA + _git-pages-repository TXT and the Codeberg webhook (the webhook is the piece people forget; without it Pages/TLS never updates).
  • The Tangled SSH key is passphrase-protected but in the macOS keychain — a plain git push loads it via UseKeychain. Never run an explicit ssh-add (that forces a passphrase prompt).

Pages & i18n #

  • Shared body components in src/components/pages/*Page.astro take a lang prop; route files (src/pages/**) are thin <XPage lang="en"/> wrappers. NL lives under /nl/.
  • Every user-facing string goes in src/i18n/ui.ts with both en and nl keys.

Client-side actions (like / repost / follow / subscribe / RSVP) #

  • One delegated handler per action in src/components/*Actions.astro, mounted once in Base.astro. Buttons carry data-* (uri/cid/did/url/labels); the handler uses window.atn.agent.
  • Graceful fallback is the rule: logged out or any write failure → open the canonical page (mu.social profile/post, atmo.rsvp event) in a new tab. Never show a login-wall or raw error. Reply and quote always redirect (no native path).
  • On atn:signedin, each handler's refresh() reflects existing state (liked/following/ subscribed/going) and stores the record rkey so the button can toggle off.

OAuth scopes (handle with care — a bad scope breaks login for everyone) #

  • Single source: src/lib/oauth-scope.ts feeds both the dev loopback client_id and the hosted /client-metadata.json. Requested scope therefore always equals declared metadata.
  • repo:<nsid>?action=create|delete for record writes; rpc:<method>?aud=* for RPC calls (preferences / saved-feeds live on the PDS via app.bsky.actor.get|putPreferences).
  • Adding a scope means existing sessions lack it → users hit the graceful re-login prompt once; fresh logins get it automatically.
  • Always PAR-verify a scope change against bsky.social and eurosky.social before trusting it: POST a Pushed Authorization Request (endpoint from /.well-known/oauth-authorization-server) with the full scope; a request_uri back = safe, invalid_scope = would break login.

Build-time data (all public, no auth) #

  • Members: list/profiles + Sifa enrichment (getSifaInfo; positions honor the hidden flag).
  • Homepage feed: a curated custom feed generator via getFeed (src/data/feed.ts).
  • Events: community.lexicon.calendar.event records on the atproto.nl account (src/data/events-account.ts) — publishing an event there makes it appear on the next build — merged with hardcoded ecosystem events (src/data/linked-events.ts). RSVP → atmo.rsvp.
  • Sifa is rate-limited (60/min): never Promise.all a burst; use bounded mapLimit + a retry-on-429 backoff (see getSifaInfo + home.ts). Trades build time for complete data — a throttled build silently drops locations/roles (the homepage map vanishes).

Formatting defaults #

  • Numbers: nl-NL (dot thousands, comma decimals) on both language sides — NL community site.
  • Dates: post dates DD-MM-YYYY; the homepage feed shows relative age ("3d ago"), computed client-side (a static build would freeze it) with the absolute date as the no-JS fallback/title. Event dates use "25 Jun 2026" (day, short month, year); calendar events append a 24h time range.
  • Preserve line breaks in ingested text with white-space: pre-line.
  • No em dashes (—) in our copy — use a colon or comma.
  • Profile/post links go to mu.social, never bsky.app — enforced by src/profile-links.test.ts.
  • a11y gate: e2e/a11y.spec.ts (Playwright + axe, light and dark) must pass; muted text ≥ 4.5:1.
  • Analytics: cookieless self-hosted Umami, documented on /privacy.
  • No private infra anywhere public (repo, commits, issues): no host names/IPs/paths. A public atproto fact like the PDS host (eurosky.social) is fine. No AI attribution in git artifacts.