Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.
Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).
Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
QUERY_GITEA_TOKEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
113 lines
5.8 KiB
Markdown
113 lines
5.8 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Project Overview
|
|
|
|
**moments** is a personal activity timeline and portfolio site. It ingests developer activity from multiple forges (GitHub, Gitea, Mercurial, Bugzilla), stores raw JSON payloads in PostgreSQL, and serves a React frontend showing contribution graphs, a ranked project dashboard, and a filterable activity timeline.
|
|
|
|
## Architecture
|
|
|
|
Hexagonal (ports & adapters) Rust backend with a React/TypeScript frontend.
|
|
|
|
### Crate Dependency Graph
|
|
|
|
```
|
|
moments-entities — pure types/DTOs, no DB or HTTP deps
|
|
^
|
|
moments-core — port traits (EventReader, EventWriter, EventSource, PollerStateStore)
|
|
+ presentation reshape + poller loop
|
|
^
|
|
moments-data — sole adapter: PgStore implements all core traits
|
|
+ EventSource impls (github, gitea, hg, bugzilla)
|
|
+ SQL migrations
|
|
^
|
|
moments-api — axum HTTP API binary (read-only, connects as moments_ro)
|
|
moments-worker — ingestion daemon binary (runs migrations, connects as moments_rw)
|
|
```
|
|
|
|
### Key Design Decisions
|
|
|
|
- **Raw payload storage**: upstream JSON is stored verbatim in `events.payload` (JSONB). The `reshape()` function in `moments-core/src/presentation.rs` transforms payloads into `TimelineItem` at request time — no re-ingestion needed to change presentation.
|
|
- **Public/private gate**: `events.public` boolean controls API visibility. Only `public = true` rows are served.
|
|
- **Wire types are hand-maintained**: `ui/src/api/client.ts` mirrors Rust entity types manually.
|
|
- **Migrations**: run automatically on worker startup via `sqlx::migrate!`. The API binary never runs migrations.
|
|
|
|
### Frontend
|
|
|
|
React 19 + Vite 6 (SWC) + TypeScript + Bootstrap 5. State/data via `@tanstack/react-query`. Package manager is **pnpm**.
|
|
|
|
Routes: `/` (dashboard), `/activity` (timeline), `/project/:source/*` (project detail), `/blog` + `/blog/:slug` (blog), `/cv` (resume).
|
|
|
|
## Build & Dev Commands
|
|
|
|
### Rust
|
|
|
|
```sh
|
|
cargo build --workspace # build all crates
|
|
cargo build --workspace --release # release build
|
|
cargo clippy --workspace # lint
|
|
cargo fmt --check # format check
|
|
cargo test --workspace # run tests
|
|
|
|
# Run binaries (need DATABASE_URL)
|
|
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
|
|
DATABASE_URL=postgres://localhost/moments cargo run -p moments-worker
|
|
```
|
|
|
|
### Frontend
|
|
|
|
```sh
|
|
cd ui
|
|
pnpm install # install deps
|
|
pnpm dev # dev server on :5173 (proxies /api/* to localhost:8080)
|
|
pnpm lint # tsc --noEmit type-check
|
|
pnpm build # production build: client bundle, then prerender
|
|
```
|
|
|
|
The build is three steps (see `ui/package.json`): `tsc -b` → `vite build` (client
|
|
SPA) → `pnpm run prerender` (an SSR build of `src/entry-server.tsx`, driven by
|
|
`run-prerender.mjs`, that bakes one static `index.html` per route into `ui/dist/`).
|
|
The prerender fetches data at build time from `VITE_API_BASE` (default
|
|
`https://rob.tn/api/v1`) and inlines the dehydrated react-query cache as
|
|
`window.__RQ_STATE__`; the client hydrates it and refetches live. So a plain
|
|
`curl` of any route returns full content (for crawlers / AI screeners), while the
|
|
browser keeps full interactivity. Date formatting in the shared tree is pinned to
|
|
UTC + explicit field widths so SSR and client hydration match byte-for-byte.
|
|
|
|
## Database
|
|
|
|
PostgreSQL with three migrations in `crates/moments-data/migrations/`. Two roles: `moments_rw` (worker, full access) and `moments_ro` (API, SELECT-only).
|
|
|
|
## API Endpoints
|
|
|
|
All under `/v1/`: `healthz`, `events`, `sources`, `projects`, `blog`, `blog/{slug}`, `activity/daily`, `forge/{source}/*`, `og/contributions.png`.
|
|
|
|
Blog posts are markdown files with YAML frontmatter (`title`, `slug`, `date`; optional `draft`/`public`) in the `grenade/blog` Gitea repo. The worker's `BlogSource` polls the repo (branch-tip sha as change detection) and upserts posts into `events` with `source='blog'` and `occurred_at` from the frontmatter date, so imported posts keep their original publish dates. The repo is the source of truth for the full set of posts: publishing, editing, renaming, and deleting are all just pushes — each poll upserts the current tree and prunes `source='blog'` rows that are no longer in it.
|
|
|
|
## Deployment
|
|
|
|
CI-driven via **Gitea Actions** (`.gitea/workflows/`), the source of infra truth
|
|
(hosts/ports/paths live in the workflow `env`, not a manifest):
|
|
|
|
- `deploy.yml` — on push to `main` (or manual dispatch): lint/test gate, build the
|
|
api + worker as static musl binaries (pure-rustls, so no glibc skew) and the
|
|
prerendered web bundle, then deploy each component over SSH as the `gitea_ci`
|
|
user with scoped sudo (`asset/sudoers.d/`). Services run under systemd with
|
|
hardened units; the api/worker reach postgres over mTLS using the host cert.
|
|
- `refresh.yml` — daily `schedule:` (+ manual): rebuilds and redeploys only the
|
|
web tier, re-baking the prerendered crawler snapshot from the current gist (CV)
|
|
and activity API without bouncing the api/worker.
|
|
|
|
One-time per-host provisioning (the `gitea_ci` user, its `authorized_keys`, the
|
|
scoped sudoers drop-in) is `script/infra-setup.sh`, run once per host by an
|
|
operator. Gitea repo secrets: `RSYNC_SSH_KEY`, `QUERY_GITHUB_TOKEN`,
|
|
`QUERY_GITEA_TOKEN` (the bare `GITHUB_TOKEN`/`GITEA_TOKEN` names are reserved by
|
|
Actions, so the worker poller's tokens use the `QUERY_` prefix).
|
|
Nginx reverse-proxies `/api/` to the API host and serves the per-route static
|
|
files via `try_files $uri $uri/ /index.html`.
|
|
|
|
`./script/deploy.sh` is the legacy operator-driven path (workstation + `pass`);
|
|
it still works and the Gitea workflow supersedes it. Remove it once the workflow
|
|
is validated on the live hosts.
|