Compare commits

..

1 Commits

Author SHA1 Message Date
7a4939cc41 chore(ui): add favicon set to index.html
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:48:52 +03:00
83 changed files with 366 additions and 6298 deletions

View File

@@ -1,369 +0,0 @@
name: deploy
# Build and roll out all three moments components on a push to main:
# - moments-api (static musl binary, read-only API) -> nikola
# - moments-worker (static musl binary, ingestion daemon) -> frootmig
# - web (prerendered static SPA + nginx vhost) -> oolon
#
# This workflow is the source of infra truth (hosts, ports, paths live in `env`
# below — there is no manifest.yml in this model). It replaces script/deploy.sh:
# instead of an operator running it from a workstation with `pass`, a Gitea
# Actions runner deploys as the dedicated `gitea_ci` user over SSH, with secrets
# from the repo settings and scoped sudo (see asset/sudoers.d/ + script/infra-setup.sh).
#
# The api/worker binaries are pure-rustls (no openssl), so they build as fully
# static musl — a runner newer than the target host can't produce an unloadable
# glibc binary (architecture/deployment-gitea-actions.md §6).
#
# Secrets (Gitea -> repo -> Settings -> Actions -> Secrets):
# RSYNC_SSH_KEY private SSH key whose pubkey script/infra-setup.sh installed
# QUERY_GITHUB_TOKEN github api token for the worker's poller
# QUERY_GITEA_TOKEN git.lair.cafe api token for the worker's poller
# (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names, hence the QUERY_ prefix.)
#
# One-time per-host provisioning (gitea_ci user, authorized_keys, scoped
# sudoers, and the postgres mTLS host cert the api/worker need) is done by
# script/infra-setup.sh — run it once per host before this workflow can succeed.
on:
push:
branches: [main]
workflow_dispatch:
# Serialize deploys; never cancel an in-flight one (a half-applied rollout is
# worse than a slightly stale one).
concurrency:
group: deploy
cancel-in-progress: false
env:
# --- infra truth (mirrors the former asset/manifest.yml) ---
API_HOST: nikola.kosherinata.internal
WORKER_HOST: frootmig.kosherinata.internal
WEB_HOST: oolon.kosherinata.internal
API_BIND: 0.0.0.0:42424
API_PORT: "42424"
SERVER_NAME: rob.tn
WEB_ROOT: /var/www/rob.tn
API_UPSTREAM_SCHEME: http
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
MUSL_TARGET: x86_64-unknown-linux-musl
# Build-time prerender base: the runner is on the internal mesh and can't
# resolve/reach the public rob.tn, so fetch the API directly over the mesh
# (same host the deploy reaches). The api binary serves its routes under /v1.
# The client bundle still ships the same-origin relative /api/v1 that nginx
# proxies (VITE_API_BASE only affects the SSR build, not the browser bundle).
VITE_API_BASE: http://nikola.kosherinata.internal:42424/v1
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
# The rust runner has cargo + musl but no node; the fedora runner has
# node + pnpm but no cargo — so the build is split across the two images.
build-binaries:
name: Build api + worker (static musl)
runs-on: rust
steps:
- uses: actions/checkout@v4
# The `rust` runner image provides cargo/clippy/rustfmt, musl-gcc, and the
# x86_64-unknown-linux-musl std. No package installs at run time.
- name: Lint + test (deploy gate)
run: |
cargo fmt --check --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
- name: Build moments-api + moments-worker (static musl release)
run: cargo build --release --target "${MUSL_TARGET}" -p moments-api -p moments-worker
- name: Stage binaries
run: |
mkdir --parents artifacts
cp "target/${MUSL_TARGET}/release/moments-api" artifacts/moments-api
cp "target/${MUSL_TARGET}/release/moments-worker" artifacts/moments-worker
- uses: actions/upload-artifact@v3
with: { name: moments-api, path: artifacts/moments-api, retention-days: 1 }
- uses: actions/upload-artifact@v3
with: { name: moments-worker, path: artifacts/moments-worker, retention-days: 1 }
build-web:
name: Build prerendered web
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
# The fedora-44 runner image bakes in node + pnpm (+ rsync) — no install.
# pnpm 10 blocks dependency build scripts unless approved, and CI doesn't
# reliably pick up the pnpm-workspace.yaml allowlist; so install with
# --ignore-scripts (no approval gate) and explicitly rebuild the two native
# deps vite needs (esbuild, @swc/core). Version-independent.
- name: Build web (vite client + prerender)
working-directory: ui
run: |
pnpm install --frozen-lockfile --ignore-scripts
pnpm rebuild @swc/core esbuild
pnpm run build
- uses: actions/upload-artifact@v3
with: { name: web-dist, path: ui/dist, retention-days: 1 }
deploy-api:
name: Deploy moments-api to nikola
needs: build-binaries
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-api, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${API_HOST}" 'hostname -f'
- name: Render config + units from infra truth
run: |
mkdir -p rendered
# HOSTNAME is the host's own fqdn (used for the postgres mTLS cert path).
python3 - "$API_HOST" "$API_BIND" "$API_PORT" <<'PY'
import sys
host, bind, port = sys.argv[1:4]
def render(src, dst, subs):
t = open(src).read()
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open(dst, "w").write(t)
render("asset/config/api.env.tmpl", "rendered/api.env",
{"HOSTNAME": host, "BIND": bind})
render("asset/systemd/moments-api-cert.path", "rendered/moments-api-cert.path",
{"HOSTNAME": host})
render("asset/firewalld/moments-api.xml.tmpl", "rendered/moments-api.xml",
{"API_PORT": port})
PY
- name: Provision service account + directories
run: |
# --mkpath: /etc/sysusers.d and /etc/firewalld/services don't exist by
# default on Fedora (only the /usr/lib variants ship).
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${API_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units, firewalld service
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-api gitea_ci@"${API_HOST}":/usr/local/bin/moments-api
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/api.env gitea_ci@"${API_HOST}":/etc/moments/api.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api-cert.path \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-api-cert-reload.service \
gitea_ci@"${API_HOST}":/etc/systemd/system/moments-api-cert-reload.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-api.xml \
gitea_ci@"${API_HOST}":/etc/firewalld/services/moments-api.xml
- name: Apply SELinux + firewalld + cert ACL (idempotent)
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
# Let the moments user read the host private key for postgres mTLS.
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
# firewalld only learns a freshly-shipped custom service after reload.
sudo /usr/bin/firewall-cmd --reload
if ! sudo /usr/bin/firewall-cmd --query-service=moments-api; then
sudo /usr/bin/firewall-cmd --add-service=moments-api --permanent
sudo /usr/bin/firewall-cmd --reload
fi'
- name: Restart moments-api
run: |
ssh gitea_ci@"${API_HOST}" '
set -euo pipefail
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-api-cert.path
sudo /usr/bin/systemctl enable moments-api.service
sudo /usr/bin/systemctl restart moments-api.service'
- name: Health probe
run: |
for i in 1 2 3 4 5 6 7 8 9 10; do
if ssh gitea_ci@"${API_HOST}" "curl -fsS http://127.0.0.1:${API_PORT}/v1/healthz"; then
echo; echo "moments-api healthy"; exit 0
fi
sleep 2
done
echo "moments-api did not become healthy" >&2
exit 1
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${API_HOST}" 'journalctl --unit moments-api.service --no-pager -n 200'
deploy-worker:
name: Deploy moments-worker to frootmig
needs: build-binaries
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: moments-worker, path: artifact }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WORKER_HOST}" 'hostname -f'
- name: Render worker config + cert.path from secrets
env:
# GITHUB_TOKEN / GITEA_TOKEN are reserved Actions secret names, so the
# repo secrets are QUERY_*; the rendered worker.env still uses the
# GITHUB_TOKEN / GITEA_TOKEN env vars the worker poller expects.
GITHUB_TOKEN: ${{ secrets.QUERY_GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.QUERY_GITEA_TOKEN }}
run: |
mkdir -p rendered
# Literal substitution via python so tokens with shell-special chars
# survive; secrets come from the environment, never a command line.
python3 - "$WORKER_HOST" <<'PY'
import os, sys
host = sys.argv[1]
t = open("asset/config/worker.env.tmpl").read()
subs = {
"HOSTNAME": host,
"GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""),
"GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""),
}
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open("rendered/worker.env", "w").write(t)
c = open("asset/systemd/moments-worker-cert.path").read().replace("{{HOSTNAME}}", host)
open("rendered/moments-worker-cert.path", "w").write(c)
PY
- name: Provision service account + directories
run: |
rsync -az --mkpath --rsync-path='sudo rsync' \
asset/systemd/moments.sysusers.conf \
gitea_ci@"${WORKER_HOST}":/etc/sysusers.d/moments.conf
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
sudo /usr/bin/systemd-sysusers
sudo /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
sudo /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments'
- name: Sync binary, env, units
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0755 \
artifact/moments-worker gitea_ci@"${WORKER_HOST}":/usr/local/bin/moments-worker
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:moments --chmod=0640 \
rendered/worker.env gitea_ci@"${WORKER_HOST}":/etc/moments/worker.env
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker.service
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/moments-worker-cert.path \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert.path
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
asset/systemd/moments-worker-cert-reload.service \
gitea_ci@"${WORKER_HOST}":/etc/systemd/system/moments-worker-cert-reload.service
- name: Apply cert ACL + SELinux, restart worker
run: |
ssh gitea_ci@"${WORKER_HOST}" '
set -euo pipefail
fqdn="$(hostname -f)"
sudo /usr/bin/setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem"
sudo /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
sudo /usr/bin/systemctl daemon-reload
sudo /usr/bin/systemctl enable --now moments-worker-cert.path
sudo /usr/bin/systemctl enable moments-worker.service
sudo /usr/bin/systemctl restart moments-worker.service
sudo /usr/bin/systemctl is-active --quiet moments-worker.service'
- name: Capture startup journal
if: always()
run: |
sleep 3
ssh gitea_ci@"${WORKER_HOST}" 'journalctl --unit moments-worker.service --no-pager -n 200'
deploy-web:
name: Deploy web to oolon
needs: build-web
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: web-dist, path: dist }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WEB_HOST}" 'hostname -f'
- name: Render nginx vhost
run: |
mkdir -p rendered
python3 - <<'PY'
import os
t = open("asset/nginx/site.conf.tmpl").read()
subs = {
"SERVER_NAME": os.environ["SERVER_NAME"],
"DOCROOT": os.environ["WEB_ROOT"],
"API_UPSTREAM_SCHEME": os.environ["API_UPSTREAM_SCHEME"],
"API_UPSTREAM_ADDR": os.environ["API_UPSTREAM_ADDR"],
}
for k, v in subs.items():
t = t.replace("{{%s}}" % k, v)
open("rendered/site.conf", "w").write(t)
PY
- name: Sync static site (prerendered)
run: |
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
--chown=root:root --chmod=D755,F644 \
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
- name: Sync nginx vhost + apply SELinux
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/site.conf \
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
ssh gitea_ci@"${WEB_HOST}" '
set -euo pipefail
# nginx proxies /api/ to the api host across the WG mesh.
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
sudo /usr/sbin/nginx -t
sudo /usr/bin/systemctl reload nginx'

View File

@@ -1,102 +0,0 @@
name: refresh
# Daily re-bake of the prerendered site. The crawler-visible HTML is a static
# snapshot taken at build time; this job rebuilds it from the current gist (CV)
# and activity API and redeploys *only* the web tier — so edits propagate to
# what crawlers and AI screeners see without a code push and without bouncing
# the api/worker. Humans already get live data via post-hydration refetch.
#
# Uses the same gitea_ci + scoped-sudo path as deploy.yml's deploy-web job;
# see asset/sudoers.d/web-host.conf and script/infra-setup.sh.
on:
schedule:
# 04:17 UTC daily — off-peak, arbitrary minute to avoid the top-of-hour herd.
- cron: '17 4 * * *'
workflow_dispatch:
concurrency:
group: deploy # share the deploy lock so a refresh never races a push deploy
cancel-in-progress: false
env:
WEB_HOST: oolon.kosherinata.internal
SERVER_NAME: rob.tn
WEB_ROOT: /var/www/rob.tn
API_PORT: "42424"
API_UPSTREAM_SCHEME: http
API_UPSTREAM_ADDR: nikola.kosherinata.internal:42424
# Internal mesh API base (the runner can't reach the public rob.tn); see
# deploy.yml. The api serves its routes under /v1.
VITE_API_BASE: http://nikola.kosherinata.internal:42424/v1
DEPLOY_KEY: |
${{ secrets.RSYNC_SSH_KEY }}
jobs:
build-web:
name: Rebuild prerendered web
runs-on: fedora-44 # image bakes in node + pnpm; that's all we need here
steps:
- uses: actions/checkout@v4
# Install without the pnpm 10 build-script gate, then rebuild the native
# deps vite needs (see deploy.yml build-web for the rationale).
- name: Build web (vite client + prerender)
working-directory: ui
run: |
pnpm install --frozen-lockfile --ignore-scripts
pnpm rebuild @swc/core esbuild
pnpm run build
- uses: actions/upload-artifact@v3
with: { name: web-dist, path: ui/dist, retention-days: 1 }
deploy-web:
name: Deploy refreshed web to oolon
needs: build-web
runs-on: fedora-44
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: web-dist, path: dist }
- name: SSH init
run: |
mkdir -p ~/.ssh
echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
gitea_ci@"${WEB_HOST}" 'hostname -f'
- name: Render nginx vhost
run: |
mkdir -p rendered
python3 - <<'PY'
import os
t = open("asset/nginx/site.conf.tmpl").read()
for k in ("SERVER_NAME", "API_UPSTREAM_SCHEME", "API_UPSTREAM_ADDR"):
t = t.replace("{{%s}}" % k, os.environ[k])
t = t.replace("{{DOCROOT}}", os.environ["WEB_ROOT"])
open("rendered/site.conf", "w").write(t)
PY
- name: Sync static site (prerendered)
run: |
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/bin/install -d -m 0755 '"${WEB_ROOT}"
rsync -az --delete --mkpath --rsync-path='sudo rsync' \
--chown=root:root --chmod=D755,F644 \
dist/ gitea_ci@"${WEB_HOST}":"${WEB_ROOT}/"
ssh gitea_ci@"${WEB_HOST}" 'sudo /usr/sbin/restorecon -R '"${WEB_ROOT}"
- name: Sync nginx vhost + reload
run: |
rsync -az --mkpath --rsync-path='sudo rsync' --chown=root:root --chmod=0644 \
rendered/site.conf \
gitea_ci@"${WEB_HOST}":/etc/nginx/conf.d/"${SERVER_NAME}".conf
ssh gitea_ci@"${WEB_HOST}" '
set -euo pipefail
sudo /usr/sbin/setsebool -P httpd_can_network_connect on
if ! sudo /usr/sbin/semanage port -l | grep -E "^http_port_t" | grep -qw '"${API_PORT}"'; then
sudo /usr/sbin/semanage port -a -t http_port_t -p tcp '"${API_PORT}"'
fi
sudo /usr/sbin/restorecon -R /etc/nginx/conf.d/'"${SERVER_NAME}"'.conf
sudo /usr/sbin/nginx -t
sudo /usr/bin/systemctl reload nginx'

2
.gitignore vendored
View File

@@ -2,12 +2,10 @@
**/*.rs.bk
.env
.env.local
.zed/
# frontend
/ui/node_modules
/ui/dist
/ui/dist-server
/ui/.vite
*.tsbuildinfo

112
CLAUDE.md
View File

@@ -1,112 +0,0 @@
# 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.

410
Cargo.lock generated
View File

@@ -88,18 +88,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-compression"
version = "0.4.42"
@@ -208,12 +196,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -238,24 +220,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -336,12 +306,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -386,15 +350,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -453,12 +408,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "der"
version = "0.7.10"
@@ -535,15 +484,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -555,15 +495,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -580,12 +511,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "flume"
version = "0.11.1"
@@ -603,29 +528,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -743,16 +645,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1049,22 +941,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1115,17 +991,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kurbo"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1153,7 +1018,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.1",
"bitflags",
"libc",
"plain",
"redox_syscall 0.7.4",
@@ -1227,15 +1092,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -1271,12 +1127,10 @@ dependencies = [
"axum",
"chrono",
"clap",
"fontdb",
"moments-core",
"moments-data",
"moments-entities",
"reqwest",
"resvg",
"serde",
"serde_json",
"tokio",
@@ -1307,11 +1161,9 @@ dependencies = [
"chrono",
"moments-core",
"moments-entities",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
"serde_yaml_ng",
"sqlx",
"thiserror",
"tracing",
@@ -1456,12 +1308,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1501,19 +1347,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1541,12 +1374,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1682,7 +1509,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
"bitflags",
]
[[package]]
@@ -1691,7 +1518,7 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
"bitflags",
]
[[package]]
@@ -1749,32 +1576,6 @@ dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "resvg"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
dependencies = [
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -1789,12 +1590,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -1862,24 +1657,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1958,19 +1735,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml_ng"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -2034,36 +1798,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -2198,7 +1938,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags 2.11.1",
"bitflags",
"byteorder",
"bytes",
"chrono",
@@ -2241,7 +1981,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags 2.11.1",
"bitflags",
"byteorder",
"chrono",
"crc",
@@ -2302,15 +2042,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -2334,16 +2065,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "syn"
version = "2.0.117"
@@ -2404,32 +2125,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -2539,7 +2234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"async-compression",
"bitflags 2.11.1",
"bitflags",
"bytes",
"futures-core",
"futures-util",
@@ -2649,15 +2344,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "typenum"
version = "1.20.0"
@@ -2670,18 +2356,6 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2703,24 +2377,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2739,33 +2395,6 @@ dependencies = [
"serde",
]
[[package]]
name = "usvg"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2919,12 +2548,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "whoami"
version = "1.6.1"
@@ -3237,12 +2860,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "yoke"
version = "0.8.2"
@@ -3351,18 +2968,3 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]

View File

@@ -19,7 +19,6 @@ thiserror = "2"
# core / data
sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls", "macros", "migrate", "chrono", "json"] }
async-trait = "0.1"
serde_yaml_ng = "0.10"
# binaries
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }
@@ -31,8 +30,6 @@ anyhow = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] }
figment = { version = "0.10", features = ["toml", "env"] }
clap = { version = "4", features = ["derive", "env"] }
resvg = "0.45"
fontdb = "0.23"
# internal
moments-entities = { path = "crates/moments-entities", version = "=0.1.0" }

View File

@@ -15,35 +15,12 @@ server {
root {{DOCROOT}};
index index.html;
# Compress text responses on the wire. text/html is always compressed when
# gzip is on (nginx won't let it be listed in gzip_types); the prerendered
# pages are large — the dashboard bakes the full all-time activity dataset —
# so the HTML alone drops from ~900 KB to ~90 KB. gzip_proxied any also
# compresses the JSON from the /api/ upstream. woff2 is already compressed,
# so it's intentionally not listed.
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/css
text/javascript
text/plain
text/xml
application/javascript
application/json
application/manifest+json
application/xml
image/svg+xml
font/ttf;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache" always;
}
location ~* ^(?!/api/)\S+\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif)$ {
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;

View File

@@ -1,16 +1,11 @@
# moments — pg_ident.conf.d drop-in template
# Maintained by script/db-perms.sh (idempotent; run from a workstation).
# Install path: /var/lib/pgsql/18/data/pg_ident.conf.d/moments.conf
#
# The file is named for the app (moments.conf), NOT the cert_cn host, so
# provisioning for other apps — which may own drop-ins for the same hosts —
# can never clobber or interleave with moments access.
#
# Rendered by script/deploy.sh per host based on manifest.yml.
# Install path: /var/lib/pgsql/18/data/pg_ident.conf.d/{{HOST_FQDN}}.conf
# Apply with: sudo systemctl reload postgresql-18 — on BOTH magrathea
# (primary) and frankie (standby) so failover doesn't lock the app out.
#
# One line per (cert_cn host, role) mapping. A host that runs both
# moments-api and moments-worker will have two lines (one for moments_ro,
# one for moments_rw).
# One line per (host, role) mapping. A host that runs both moments-api
# and moments-worker will have two lines (one for moments_ro, one for
# moments_rw).
cert_cn {{HOST_FQDN}} {{DB_ROLE}}

View File

@@ -1,35 +0,0 @@
# Scoped sudo for the gitea_ci deploy user on the moments-api host (nikola).
# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_api_gitea_ci and
# verified with `visudo -cf`. Every rule is pinned to one literal destination;
# the `*` in an rsync rule matches rsync's --server argument vector, the trailing
# path is what actually bounds it. `:` and `=` are escaped (sudoers reserves them).
# --- file pushes (rsync --rsync-path='sudo rsync') ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/api.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-api-cert-reload.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/moments-api.xml
# --- service account + directories ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments
# --- cert ACL, SELinux, firewalld ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-api /etc/moments /var/lib/moments
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --query-service\=moments-api
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api --permanent
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --add-service\=moments-api
# --- service lifecycle ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-api-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-api.service

View File

@@ -1,20 +0,0 @@
# Scoped sudo for the gitea_ci deploy user on the web host (oolon). Installed by
# script/infra-setup.sh as /etc/sudoers.d/moments_web_gitea_ci and verified with
# `visudo -cf`. Used by both deploy.yml's deploy-web and refresh.yml.
# See api-host.conf for the rsync-rule convention.
# --- docroot + static site ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -m 0755 /var/www/rob.tn
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /var/www/rob.tn/
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /var/www/rob.tn
# --- nginx vhost ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/nginx/conf.d/rob.tn.conf
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /etc/nginx/conf.d/rob.tn.conf
# --- SELinux booleans/ports for the /api proxy + reload ---
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/setsebool -P httpd_can_network_connect on
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -l
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/semanage port -a -t http_port_t -p tcp 42424
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/nginx -t
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx

View File

@@ -1,27 +0,0 @@
# Scoped sudo for the gitea_ci deploy user on the moments-worker host (frootmig).
# Installed by script/infra-setup.sh as /etc/sudoers.d/moments_worker_gitea_ci and
# verified with `visudo -cf`. See api-host.conf for the rsync-rule convention.
# --- file pushes (rsync --rsync-path='sudo rsync') ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/moments.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/moments-worker
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/moments/worker.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/moments-worker-cert-reload.service
# --- service account + directories ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g moments -m 0750 /etc/moments
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o moments -g moments -m 0750 /var/lib/moments
# --- cert ACL, SELinux ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/setfacl -m u\:moments\:r /etc/pki/tls/private/*.pem
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/moments-worker /etc/moments /var/lib/moments
# --- service lifecycle ---
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now moments-worker-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart moments-worker.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet moments-worker.service

View File

@@ -21,5 +21,3 @@ serde_json.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
resvg.workspace = true
fontdb.workspace = true

View File

@@ -7,14 +7,11 @@ use axum::{
response::IntoResponse,
routing::get,
};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use chrono::{DateTime, Utc};
use clap::Parser;
use moments_core::{EventReader, reshape};
use moments_data::PgStore;
use moments_entities::{
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem,
};
use moments_entities::{EventQuery, ProjectSummary, Source, SourceSummary, TimelineItem};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
@@ -59,14 +56,7 @@ async fn main() -> anyhow::Result<()> {
.route("/v1/events", get(list_events))
.route("/v1/sources", get(list_sources))
.route("/v1/projects", get(list_projects))
.route("/v1/blog", get(list_blog_posts))
.route("/v1/blog/{slug}", get(get_blog_post))
.route("/v1/activity/daily", get(daily_counts))
.route("/v1/activity/hourly", get(hourly_avgs))
.route("/v1/languages/daily", get(language_daily_counts))
.route("/v1/languages/repos", get(repo_languages))
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
.route("/v1/og/contributions.png", get(og_contributions))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
@@ -107,7 +97,11 @@ async fn list_events(
State(state): State<AppState>,
Query(params): Query<EventsQueryParams>,
) -> Result<Json<Vec<TimelineItem>>, ApiError> {
let sources = params.source.as_deref().map(parse_sources).transpose()?;
let sources = params
.source
.as_deref()
.map(parse_sources)
.transpose()?;
let limit = params.limit.unwrap_or(100).clamp(1, 1000);
@@ -127,10 +121,12 @@ async fn list_events(
Ok(Json(items))
}
async fn list_sources(State(state): State<AppState>) -> Result<Json<Vec<SourceSummary>>, ApiError> {
async fn list_sources(
State(state): State<AppState>,
) -> Result<Json<Vec<SourceSummary>>, ApiError> {
let summaries = state
.store
.source_summaries(/* include_private */ true)
.source_summaries(/* include_private */ false)
.await
.map_err(internal)?;
Ok(Json(summaries))
@@ -143,370 +139,6 @@ async fn list_projects(
Ok(Json(projects))
}
/// All public blog events, newest first. Blog posts live in the events
/// table (`source = 'blog'`); the payload carries the frontmatter fields
/// and the full markdown body.
async fn blog_events(state: &AppState) -> Result<Vec<Event>, ApiError> {
let query = EventQuery {
sources: Some(vec![Source::Blog]),
// Drafts are stored with public = false and stay invisible here.
include_private: false,
limit: 1000,
..Default::default()
};
state.store.list_events(&query).await.map_err(internal)
}
fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str {
event
.payload
.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
}
async fn list_blog_posts(
State(state): State<AppState>,
) -> Result<Json<Vec<BlogPostSummary>>, ApiError> {
let posts = blog_events(&state)
.await?
.iter()
.map(|ev| BlogPostSummary {
slug: payload_str(ev, "slug").to_string(),
title: payload_str(ev, "title").to_string(),
published_at: ev.occurred_at,
excerpt: moments_core::presentation::blog::excerpt(payload_str(ev, "markdown")),
})
.collect();
Ok(Json(posts))
}
async fn get_blog_post(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<BlogPost>, ApiError> {
let id = format!("blog:{slug}");
let events = blog_events(&state).await?;
let ev = events.iter().find(|ev| ev.id == id).ok_or(ApiError {
status: StatusCode::NOT_FOUND,
message: "no such post".into(),
})?;
Ok(Json(BlogPost {
slug: payload_str(ev, "slug").to_string(),
title: payload_str(ev, "title").to_string(),
published_at: ev.occurred_at,
markdown: payload_str(ev, "markdown").to_string(),
host: payload_str(ev, "_host").to_string(),
repo: payload_str(ev, "_repo").to_string(),
branch: payload_str(ev, "_branch").to_string(),
}))
}
#[derive(Debug, Deserialize)]
struct DailyCountsParams {
from: Option<NaiveDate>,
to: Option<NaiveDate>,
}
async fn daily_counts(
State(state): State<AppState>,
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<DailyCount>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state
.store
.daily_counts(from, to, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(counts))
}
async fn language_daily_counts(
State(state): State<AppState>,
Query(params): Query<DailyCountsParams>,
) -> Result<Json<Vec<LanguageDailyCount>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let counts = state
.store
.language_daily_counts(from, to, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(counts))
}
#[derive(Debug, Deserialize)]
struct HourlyAvgsParams {
from: Option<NaiveDate>,
to: Option<NaiveDate>,
/// IANA timezone name (e.g. "Europe/Helsinki"). Defaults to UTC.
/// Hour buckets are computed in this zone so the chart matches the
/// clock the user sees.
tz: Option<String>,
}
async fn hourly_avgs(
State(state): State<AppState>,
Query(params): Query<HourlyAvgsParams>,
) -> Result<Json<Vec<HourlyAvg>>, ApiError> {
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
let from = params
.from
.unwrap_or_else(|| to - chrono::Duration::days(365));
let tz = params.tz.as_deref().unwrap_or("UTC");
// Validate the tz string before handing it to postgres — a bad name
// here would surface as an opaque 500 from the DB. chrono-tz would do
// it for free but we don't depend on it; instead reject obvious shell
// injection vectors (the value is bound, not interpolated, so this is
// belt-and-braces).
if tz.len() > 64
|| tz
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')))
{
return Err(ApiError {
status: StatusCode::BAD_REQUEST,
message: "invalid tz".into(),
});
}
let avgs = state
.store
.hourly_avgs(from, to, tz, /* include_private */ true)
.await
.map_err(internal)?;
Ok(Json(avgs))
}
async fn repo_languages(
State(state): State<AppState>,
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
let langs = state.store.repo_languages().await.map_err(internal)?;
Ok(Json(langs))
}
async fn og_contributions(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
// Get date range from source summaries
let summaries = state
.store
.source_summaries(/* include_private */ true)
.await
.map_err(internal)?;
let earliest = summaries
.iter()
.filter_map(|s| s.earliest)
.min()
.unwrap_or_else(Utc::now)
.date_naive();
let today = Utc::now().date_naive();
let counts = state
.store
.daily_counts(earliest, today, /* include_private */ true)
.await
.map_err(internal)?;
let projects = state.store.list_projects().await.map_err(internal)?;
let repo_count = projects.len();
let png =
render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: e,
})?;
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "image/png"),
(axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
],
png,
))
}
fn render_contributions_png(
counts: &[DailyCount],
from: NaiveDate,
to: NaiveDate,
repo_count: usize,
) -> Result<Vec<u8>, String> {
use std::collections::HashMap;
let count_map: HashMap<NaiveDate, i64> = counts.iter().map(|d| (d.date, d.count)).collect();
// OG image canvas: 1200x630
let og_w = 1200_f64;
let og_h = 630_f64;
let padding = 40_f64;
let bg = "#2c3e50";
let year_label_w = 50_f64;
let max_cols = 53;
// Scale cell size to fill available width
let avail_w = og_w - 2.0 * padding - year_label_w;
let step = (avail_w / max_cols as f64).floor();
let gap = (step * 0.17).round();
let cell = step - gap;
let radius = cell / 2.0;
let colors = [
"rgba(255,255,255,0.05)",
"#0e4429",
"#006d32",
"#26a641",
"#39d353",
];
// Build weekly data per year
struct YearRow {
year: i32,
weeks: Vec<(NaiveDate, NaiveDate, i64)>, // start, end, count
}
let start_year = from.year();
let end_year = to.year();
let mut rows: Vec<YearRow> = Vec::new();
for yr in start_year..=end_year {
let year_start = NaiveDate::from_ymd_opt(yr, 1, 1).unwrap();
let year_end = if yr == end_year {
to
} else {
NaiveDate::from_ymd_opt(yr, 12, 31).unwrap()
};
let offset = year_start.weekday().num_days_from_sunday();
let mut cursor = year_start - chrono::Duration::days(offset as i64);
let mut weeks = Vec::new();
while cursor <= year_end {
let week_start = cursor;
let mut week_count = 0i64;
for _ in 0..7 {
week_count += count_map.get(&cursor).copied().unwrap_or(0);
cursor += chrono::Duration::days(1);
}
let week_end = cursor - chrono::Duration::days(1);
weeks.push((week_start, week_end, week_count));
}
rows.push(YearRow { year: yr, weeks });
}
// Quantile thresholds
let mut non_zero: Vec<i64> = rows
.iter()
.flat_map(|r| r.weeks.iter().map(|w| w.2))
.filter(|&c| c > 0)
.collect();
non_zero.sort();
let thresholds = if non_zero.is_empty() {
[1i64, 2, 3]
} else {
let p = |pct: f64| {
non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize]
};
[p(0.25), p(0.5), p(0.75)]
};
let color_for = |count: i64| -> &str {
if count == 0 {
colors[0]
} else if count <= thresholds[0] {
colors[1]
} else if count <= thresholds[1] {
colors[2]
} else if count <= thresholds[2] {
colors[3]
} else {
colors[4]
}
};
let n_rows = rows.len();
let graph_h = (n_rows as f64) * step;
let total: i64 = counts.iter().map(|d| d.count).sum();
let repo_text = if repo_count > 0 {
format!(" in {repo_count} repositories")
} else {
String::new()
};
// Layout: headline at top, graph vertically centered in remaining space
let offset_x = padding;
let headline_y = padding + 36.0;
let subtitle_y = headline_y + 28.0;
let graph_top = subtitle_y + 16.0;
let avail_graph_h = og_h - graph_top - padding;
let graph_y = graph_top + (avail_graph_h - graph_h).max(0.0) / 2.0;
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{og_w}" height="{og_h}" viewBox="0 0 {og_w} {og_h}"><rect width="100%" height="100%" fill="{bg}"/>"#,
);
// Headline
svg.push_str(&format!(
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="36" font-weight="bold">rob thijssen</text>"##,
x = offset_x + year_label_w,
y = headline_y,
));
// Subtitle
svg.push_str(&format!(
r##"<text x="{x}" y="{y}" fill="#ecf0f1" font-family="sans-serif" font-size="16" opacity="0.6">{total} contributions since {from}{repo_text}</text>"##,
x = offset_x + year_label_w,
y = subtitle_y,
));
let label_font_size = (step * 0.7).round().clamp(8.0, 14.0);
for (row_idx, row) in rows.iter().enumerate() {
let y_base = graph_y + (row_idx as f64) * step;
svg.push_str(&format!(
r##"<text x="{x}" y="{y}" text-anchor="end" dominant-baseline="central" fill="#ecf0f1" font-family="sans-serif" font-size="{fs}" opacity="0.6">{yr}</text>"##,
x = offset_x + year_label_w - 6.0,
y = y_base + radius,
fs = label_font_size,
yr = row.year,
));
for (col, (_, _, count)) in row.weeks.iter().enumerate() {
let cx = offset_x + year_label_w + (col as f64) * step + radius;
let cy = y_base + radius;
let fill = color_for(*count);
svg.push_str(&format!(
r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}"/>"#,
r = radius - 1.0,
));
}
}
svg.push_str("</svg>");
// Rasterize at 1200x630
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
let opts = resvg::usvg::Options {
fontdb: std::sync::Arc::new(fontdb),
font_family: "Noto Sans".to_owned(),
..Default::default()
};
let tree = resvg::usvg::Tree::from_str(&svg, &opts).map_err(|e| format!("svg parse: {e}"))?;
let mut pixmap = resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32)
.ok_or_else(|| "pixmap alloc failed".to_string())?;
resvg::render(
&tree,
resvg::tiny_skia::Transform::default(),
&mut pixmap.as_mut(),
);
pixmap.encode_png().map_err(|e| format!("png encode: {e}"))
}
/// Allowlisted forge hosts that the proxy may contact.
const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"];
@@ -531,11 +163,7 @@ async fn forge_proxy(
}
(format!("https://{host}"), "/api/v1")
}
_ => {
return Err(ApiError::bad_request(format!(
"unsupported source: {source}"
)));
}
_ => return Err(ApiError::bad_request(format!("unsupported source: {source}"))),
};
let url = format!("{base}{api_prefix}/{rest}");
@@ -571,10 +199,7 @@ fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<Source>()
.map_err(|e| ApiError::bad_request(e.to_string()))
})
.map(|s| s.parse::<Source>().map_err(|e| ApiError::bad_request(e.to_string())))
.collect()
}
@@ -603,10 +228,6 @@ fn internal<E: std::fmt::Display>(e: E) -> ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(
self.status,
Json(serde_json::json!({ "error": self.message })),
)
.into_response()
(self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
}
}

View File

@@ -5,11 +5,7 @@ pub use presentation::reshape;
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
use async_trait::async_trait;
use chrono::NaiveDate;
use moments_entities::{
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
SourceSummary,
};
use moments_entities::{Event, EventQuery, ProjectSummary, SourceSummary};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
@@ -21,44 +17,12 @@ pub enum StoreError {
#[async_trait]
pub trait EventReader: Send + Sync {
async fn list_events(&self, query: &EventQuery) -> Result<Vec<Event>, StoreError>;
async fn source_summaries(
&self,
include_private: bool,
) -> Result<Vec<SourceSummary>, StoreError>;
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError>;
async fn list_projects(&self) -> Result<Vec<ProjectSummary>, StoreError>;
async fn daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<DailyCount>, StoreError>;
async fn language_daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<LanguageDailyCount>, StoreError>;
async fn hourly_avgs(
&self,
from: NaiveDate,
to: NaiveDate,
tz: &str,
include_private: bool,
) -> Result<Vec<HourlyAvg>, StoreError>;
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError>;
}
/// Write-side port consumed by `moments-worker`. Idempotent upserts on `id`.
#[async_trait]
pub trait EventWriter: Send + Sync {
async fn upsert_events(&self, events: &[Event]) -> Result<usize, StoreError>;
async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result<usize, StoreError>;
/// Delete events of `source` whose id is not in `keep_ids`. For sources
/// whose upstream is authoritative for the full set (e.g. the blog repo),
/// this reconciles deletes and renames that upserts alone never would.
async fn prune_events(
&self,
source: moments_entities::Source,
keep_ids: &[String],
) -> Result<usize, StoreError>;
}

View File

@@ -5,7 +5,6 @@
use moments_entities::{Event, Source, TimelineItem};
pub mod blog;
mod bugzilla;
mod gitea;
mod github;
@@ -17,6 +16,5 @@ pub fn reshape(event: &Event) -> TimelineItem {
Source::Gitea => gitea::reshape(event),
Source::Hg => hg::reshape(event),
Source::Bugzilla => bugzilla::reshape(event),
Source::Blog => blog::reshape(event),
}
}

View File

@@ -1,139 +0,0 @@
use moments_entities::{Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment};
use serde_json::Value;
const EXCERPT_MAX_CHARS: usize = 280;
pub(crate) fn reshape(event: &Event) -> TimelineItem {
let p = &event.payload;
let title_text = p
.get("title")
.and_then(Value::as_str)
.unwrap_or("(untitled post)");
let slug = p.get("slug").and_then(Value::as_str).unwrap_or("");
let markdown = p.get("markdown").and_then(Value::as_str).unwrap_or("");
let title = vec![
TitleSegment::text("published "),
TitleSegment::link(title_text, format!("/blog/{slug}")),
];
let summary = excerpt(markdown);
let body = (!summary.is_empty()).then_some(TimelineBody::Markdown { text: summary });
TimelineItem {
id: event.id.clone(),
source: Source::Blog,
action: event.action.clone(),
occurred_at: event.occurred_at,
icon: TimelineIcon::Post,
title,
subtitle: None,
body,
}
}
/// First paragraph of prose from a markdown document — skips headings,
/// images, and other block furniture — truncated on a word boundary.
/// Reused by the API for `GET /v1/blog` summaries.
pub fn excerpt(markdown: &str) -> String {
let para = markdown
.split("\n\n")
.map(str::trim)
.find(|block| {
!block.is_empty()
&& !block.starts_with('#')
&& !block.starts_with("![")
&& !block.starts_with("```")
&& !block.starts_with('>')
&& !block.starts_with("---")
})
.unwrap_or("");
let para = para.replace('\n', " ");
if para.chars().count() <= EXCERPT_MAX_CHARS {
return para;
}
let cut: String = para.chars().take(EXCERPT_MAX_CHARS).collect();
let trimmed = match cut.rfind(' ') {
Some(idx) => &cut[..idx],
None => cut.as_str(),
};
format!("{}", trimmed.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use serde_json::json;
fn ev() -> Event {
Event {
id: "blog:last-week".into(),
source: Source::Blog,
action: "publish_post".into(),
occurred_at: Utc.with_ymd_and_hms(2026, 6, 12, 0, 0, 0).unwrap(),
public: true,
payload: json!({
"title": "a watchdog, a torrent tracker, and an unhinged friday",
"slug": "last-week",
"markdown": "## monday\n\nthe week opened deep in helexa's neuron engine.\n",
"_host": "git.lair.cafe",
"_repo": "grenade/blog",
"_branch": "main",
}),
}
}
#[test]
fn reshape_links_to_blog_route() {
let item = reshape(&ev());
assert_eq!(item.icon, TimelineIcon::Post);
assert_eq!(
item.title,
vec![
TitleSegment::text("published "),
TitleSegment::link(
"a watchdog, a torrent tracker, and an unhinged friday",
"/blog/last-week"
),
]
);
match item.body {
Some(TimelineBody::Markdown { ref text }) => {
assert_eq!(text, "the week opened deep in helexa's neuron engine.")
}
other => panic!("expected markdown body, got {other:?}"),
}
}
#[test]
fn excerpt_skips_headings_and_images() {
let md =
"# title\n\n![alt](img.jpg)\n\nfirst real paragraph\nwith a wrapped line\n\nsecond";
assert_eq!(excerpt(md), "first real paragraph with a wrapped line");
}
#[test]
fn excerpt_truncates_on_word_boundary() {
let long: String = (1..=100)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" ");
let e = excerpt(&long);
assert!(e.chars().count() <= EXCERPT_MAX_CHARS + 1);
assert!(e.ends_with('…'));
let prefix = e.trim_end_matches('…');
assert!(long.starts_with(prefix), "excerpt must be a prefix: {e}");
assert_eq!(
long.as_bytes()[prefix.len()],
b' ',
"cut should land on a word boundary: {e}"
);
}
#[test]
fn excerpt_of_empty_or_heading_only_doc_is_empty() {
assert_eq!(excerpt(""), "");
assert_eq!(excerpt("# only a heading\n"), "");
}
}

View File

@@ -73,9 +73,7 @@ mod tests {
assert!(r.contains("filed bug #1158879 in mozilla.org"), "got: {r}");
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text(
"Commit Access (Level 1) for Rob Thijssen"
)]
vec![TitleSegment::text("Commit Access (Level 1) for Rob Thijssen")]
);
}
}

View File

@@ -40,7 +40,9 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
"create_pull_request" => {
pr_action("opened", TimelineIcon::PullRequest, host, repo, content)
}
"close_pull_request" => pr_action("closed", TimelineIcon::PullRequest, host, repo, content),
"close_pull_request" => {
pr_action("closed", TimelineIcon::PullRequest, host, repo, content)
}
"reopen_pull_request" => {
pr_action("reopened", TimelineIcon::PullRequest, host, repo, content)
}
@@ -51,13 +53,15 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
"approve_pull_request" => {
pr_action("approved", TimelineIcon::PullRequest, host, repo, content)
}
"reject_pull_request" => pr_action(
"requested changes on",
TimelineIcon::PullRequest,
host,
repo,
content,
),
"reject_pull_request" => {
pr_action(
"requested changes on",
TimelineIcon::PullRequest,
host,
repo,
content,
)
}
"publish_release" => publish_release(host, repo, content),
_ => fallback(host, repo, &event.action),
};
@@ -299,7 +303,8 @@ fn pr_action(
TitleSegment::text(" in "),
repo_link(host, repo),
];
let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let subtitle =
(!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
(icon, title, subtitle, None)
}
@@ -347,7 +352,8 @@ fn comment_on_pr(
TitleSegment::text(" in "),
repo_link(host, repo),
];
let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let subtitle =
(!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -358,10 +364,7 @@ fn publish_release(host: &str, repo: Option<&str>, content: Option<&str>) -> Res
let repo = repo.unwrap_or("(unknown repo)");
let name = content.unwrap_or("");
let title = if name.is_empty() {
vec![
TitleSegment::text("published a release in "),
repo_link(host, repo),
]
vec![TitleSegment::text("published a release in "), repo_link(host, repo)]
} else {
vec![
TitleSegment::text(format!("released {name} in ")),
@@ -449,7 +452,10 @@ mod tests {
let item = reshape(&ev("create_issue", raw));
assert_eq!(item.icon, TimelineIcon::Issue);
let r = render(&item);
assert!(r.contains("opened issue #1 in grenade/moments"), "got: {r}");
assert!(
r.contains("opened issue #1 in grenade/moments"),
"got: {r}"
);
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text(

View File

@@ -13,10 +13,7 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
}
let p = &event.payload;
let repo_name = p
.get("repo")
.and_then(|r| r.get("name"))
.and_then(Value::as_str);
let repo_name = p.get("repo").and_then(|r| r.get("name")).and_then(Value::as_str);
let actor_login = p
.get("actor")
.and_then(|a| a.get("display_login").or_else(|| a.get("login")))
@@ -195,14 +192,8 @@ fn pull_request(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(Value::as_str)
.unwrap_or("touched");
let pr = p.and_then(|v| v.get("pull_request"));
let number = p
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let number = p.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let merged = pr
.and_then(|v| v.get("merged"))
.and_then(Value::as_bool)
@@ -233,14 +224,8 @@ fn pull_request(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn pull_request_review(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let pr = p.and_then(|v| v.get("pull_request"));
let number = pr
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let state = p
.and_then(|v| v.get("review"))
.and_then(|r| r.get("state"))
@@ -260,14 +245,8 @@ fn pull_request_review(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn pull_request_review_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let pr = p.and_then(|v| v.get("pull_request"));
let number = pr
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let pr_title = pr
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let body_text = p
.and_then(|v| v.get("comment"))
.and_then(|c| c.get("body"))
@@ -294,14 +273,8 @@ fn issues(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(Value::as_str)
.unwrap_or("touched");
let issue = p.and_then(|v| v.get("issue"));
let number = issue
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let issue_title = issue
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let title = vec![
TitleSegment::text(format!("{action} issue ")),
@@ -309,22 +282,15 @@ fn issues(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
TitleSegment::text(" in "),
repo_link(repo),
];
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
(TimelineIcon::Issue, title, subtitle, None)
}
fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let issue = p.and_then(|v| v.get("issue"));
let number = issue
.and_then(|v| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let issue_title = issue
.and_then(|v| v.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or("");
let body_text = p
.and_then(|v| v.get("comment"))
.and_then(|c| c.get("body"))
@@ -337,8 +303,7 @@ fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
TitleSegment::text(" in "),
repo_link(repo),
];
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -347,10 +312,7 @@ fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn create(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let ref_type = p
.and_then(|v| v.get("ref_type"))
.and_then(Value::as_str)
.unwrap_or("ref");
let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref");
let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str);
let mut title = vec![TitleSegment::text(format!("created {ref_type} "))];
@@ -365,14 +327,8 @@ fn create(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn delete(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let ref_type = p
.and_then(|v| v.get("ref_type"))
.and_then(Value::as_str)
.unwrap_or("ref");
let ref_name = p
.and_then(|v| v.get("ref"))
.and_then(Value::as_str)
.unwrap_or("");
let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref");
let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str).unwrap_or("");
let title = vec![
TitleSegment::text(format!("deleted {ref_type} {ref_name} in ")),
@@ -384,9 +340,7 @@ fn delete(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn fork(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let forkee = p.and_then(|v| v.get("forkee"));
let forkee_full = forkee
.and_then(|f| f.get("full_name"))
.and_then(Value::as_str);
let forkee_full = forkee.and_then(|f| f.get("full_name")).and_then(Value::as_str);
let mut title = vec![TitleSegment::text("forked "), repo_link(repo)];
if let Some(full) = forkee_full {
@@ -412,9 +366,7 @@ fn release(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(|r| r.get("name").or_else(|| r.get("tag_name")))
.and_then(Value::as_str)
.unwrap_or("(release)");
let url = release
.and_then(|r| r.get("html_url"))
.and_then(Value::as_str);
let url = release.and_then(|r| r.get("html_url")).and_then(Value::as_str);
let label = if let Some(u) = url {
TitleSegment::link(name.to_string(), u.to_string())
@@ -437,10 +389,7 @@ fn commit_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
.and_then(|c| c.get("body"))
.and_then(Value::as_str)
.unwrap_or("");
let title = vec![
TitleSegment::text("commented on a commit in "),
repo_link(repo),
];
let title = vec![TitleSegment::text("commented on a commit in "), repo_link(repo)];
let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown {
text: body_text.to_string(),
});
@@ -449,11 +398,7 @@ fn commit_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped {
fn public(repo: Option<&str>) -> Reshaped {
let repo = repo.unwrap_or("(unknown repo)");
let title = vec![
TitleSegment::text("made "),
repo_link(repo),
TitleSegment::text(" public"),
];
let title = vec![TitleSegment::text("made "), repo_link(repo), TitleSegment::text(" public")];
(TimelineIcon::Generic, title, None, None)
}
@@ -499,15 +444,11 @@ fn search_reshape(event: &Event) -> TimelineItem {
title.push(TitleSegment::text(" "));
}
title.push(TitleSegment::text(format!("{verb} {kind} ")));
title.push(TitleSegment::link(
format!("#{number}"),
html_url.to_string(),
));
title.push(TitleSegment::link(format!("#{number}"), html_url.to_string()));
title.push(TitleSegment::text(" in "));
title.push(repo_link(&repo));
let subtitle =
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
TimelineItem {
id: event.id.clone(),
@@ -539,7 +480,6 @@ fn commit_reshape(event: &Event) -> TimelineItem {
.get("repository")
.and_then(|r| r.get("full_name"))
.and_then(Value::as_str)
.or_else(|| p.get("_repo").and_then(Value::as_str))
.unwrap_or("(unknown repo)");
let author_login = p
.get("author")
@@ -559,8 +499,8 @@ fn commit_reshape(event: &Event) -> TimelineItem {
title.push(TitleSegment::text(" in "));
title.push(repo_link(repo));
let subtitle =
(!message_first_line.is_empty()).then(|| vec![TitleSegment::text(message_first_line)]);
let subtitle = (!message_first_line.is_empty())
.then(|| vec![TitleSegment::text(message_first_line)]);
TimelineItem {
id: event.id.clone(),
@@ -584,7 +524,10 @@ fn repo_from_url(url: &str) -> Option<String> {
fn fallback(repo: Option<&str>, action: &str) -> Reshaped {
let title = match repo {
Some(r) => vec![TitleSegment::text(format!("{action} on ")), repo_link(r)],
Some(r) => vec![
TitleSegment::text(format!("{action} on ")),
repo_link(r),
],
None => vec![TitleSegment::text(action.to_string())],
};
(TimelineIcon::Generic, title, None, None)
@@ -634,10 +577,7 @@ mod tests {
TitleSegment::Link { text, .. } => text.clone(),
})
.collect();
assert!(
rendered.contains("pushed 2 commits to grenade/vortex:main"),
"got: {rendered}"
);
assert!(rendered.contains("pushed 2 commits to grenade/vortex:main"), "got: {rendered}");
match item.body.unwrap() {
TimelineBody::Commits { commits } => {
assert_eq!(commits.len(), 2);
@@ -699,10 +639,7 @@ mod tests {
let item = reshape(&ev("PushEvent", raw));
assert_eq!(item.icon, TimelineIcon::GitPush);
let r = render(&item);
assert!(
r.contains("force-pushed 1 commit to grenade/x:main"),
"got: {r}"
);
assert!(r.contains("force-pushed 1 commit to grenade/x:main"), "got: {r}");
}
#[test]
@@ -846,16 +783,11 @@ mod tests {
TitleSegment::Link { text, .. } => text.clone(),
})
.collect();
assert!(
rendered.contains("committed a6fcefb in faith1337z/Trade"),
"got: {rendered}"
);
assert!(rendered.contains("committed a6fcefb in faith1337z/Trade"), "got: {rendered}");
// body of the commit message is dropped; only first line in subtitle
assert_eq!(
item.subtitle.unwrap(),
vec![TitleSegment::text(
"split multiline message into multiple irc messages"
)]
vec![TitleSegment::text("split multiline message into multiple irc messages")]
);
}

View File

@@ -23,7 +23,10 @@ pub(crate) fn reshape(event: &Event) -> TimelineItem {
.next()
.unwrap_or("")
.to_string();
let author = p.get("author").and_then(Value::as_str).map(author_name);
let author = p
.get("author")
.and_then(Value::as_str)
.map(author_name);
let mut title = Vec::new();
if let Some(name) = author {

View File

@@ -17,5 +17,3 @@ tracing.workspace = true
async-trait.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_yaml_ng.workspace = true
percent-encoding = "2"

View File

@@ -1,9 +0,0 @@
CREATE TABLE repo_languages (
source TEXT NOT NULL,
repo TEXT NOT NULL,
language TEXT NOT NULL,
bytes BIGINT NOT NULL,
color TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source, repo, language)
);

View File

@@ -1,55 +0,0 @@
-- Collapse duplicate Gitea events introduced by polling both the user
-- activity feed and per-org activity feeds.
--
-- Gitea writes one Action row per interested user-context: a push to
-- helexa/cortex by user grenade produces two rows, one with
-- user_id=grenade and one with user_id=helexa. Everything else (op_type,
-- act_user_id, repo_id, ref_name, comment_id, created) is identical.
-- Our prior id scheme (gitea:{action_row_id}) gave them different ids,
-- so the upsert-on-id dedup never fired and the timeline rendered each
-- push twice.
--
-- This migration re-keys every existing gitea row to the same canonical
-- formula `parse_gitea_event` now emits, deleting duplicates encountered
-- along the way. Idempotent: running it again is a no-op because the
-- canonical id of a canonical id is itself.
-- Snapshot the canonical id for every gitea row.
CREATE TEMP TABLE _gitea_canonical AS
SELECT
id AS old_id,
'gitea:'
|| coalesce(payload->>'op_type', '') || ':'
|| coalesce(payload->>'act_user_id', payload->'act_user'->>'id', '0') || ':'
|| coalesce(payload->>'repo_id', payload->'repo'->>'id', '0') || ':'
|| coalesce(payload->>'ref_name', '') || ':'
|| coalesce(payload->>'comment_id', '0') || ':'
|| coalesce(payload->>'created', '')
AS new_id
FROM events
WHERE source = 'gitea';
-- For each canonical id, keep the row whose current id is lexicographically
-- smallest (stable, arbitrary tie-break) and delete the rest. The "old id
-- already matches the new id" case lands here too — DELETE skips it because
-- rn = 1 for any singleton group.
DELETE FROM events
WHERE id IN (
SELECT old_id FROM (
SELECT old_id, new_id,
row_number() OVER (PARTITION BY new_id ORDER BY old_id) AS rn
FROM _gitea_canonical
) ranked
WHERE rn > 1
);
-- Rename remaining rows to the canonical id. Postgres defers PK uniqueness
-- to statement end, so swapping ids across rows in one UPDATE is fine
-- provided the final set is unique (dedup above guarantees that).
UPDATE events e
SET id = c.new_id
FROM _gitea_canonical c
WHERE e.id = c.old_id
AND e.id <> c.new_id;
DROP TABLE _gitea_canonical;

View File

@@ -1,343 +0,0 @@
//! Blog post ingestion from a Gitea-hosted markdown repo.
//!
//! Posts are markdown files with YAML frontmatter (`title`, `slug`, `date`)
//! at the root of a repo. The poll cycle is cheap: one request for the
//! branch tip sha, compared against `poller_state.etag`; only when the repo
//! has new commits are the file listing and contents fetched.
//!
//! `occurred_at` comes from the frontmatter `date`, so imported posts keep
//! their original publish dates. Edits re-upsert under the same
//! `blog:{slug}` id.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, Source};
use reqwest::{Client, header};
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, warn};
const SOURCE_NAME: &str = "blog";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
#[derive(Clone, Debug)]
pub struct BlogConfig {
/// e.g. `git.lair.cafe`. Stamped into each payload as `_host` so the
/// reshape layer and UI can build raw-content URLs without config.
pub host: String,
/// Repo holding the posts, e.g. `grenade/blog`.
pub repo: String,
pub branch: String,
/// Only needed if the repo is private (which breaks raw image URLs in
/// the UI — keep it public).
pub token: Option<String>,
}
impl Default for BlogConfig {
fn default() -> Self {
Self {
host: "git.lair.cafe".into(),
repo: "grenade/blog".into(),
branch: "main".into(),
token: None,
}
}
}
pub struct BlogSource {
client: Client,
writer: Arc<dyn EventWriter>,
state: Arc<dyn PollerStateStore>,
config: BlogConfig,
}
impl BlogSource {
pub fn new(
client: Client,
writer: Arc<dyn EventWriter>,
state: Arc<dyn PollerStateStore>,
config: BlogConfig,
) -> Self {
Self {
client,
writer,
state,
config,
}
}
fn api_url(&self, rest: &str) -> String {
format!(
"https://{}/api/v1/repos/{}/{rest}",
self.config.host, self.config.repo
)
}
fn apply_headers(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req = req.header(header::USER_AGENT, USER_AGENT);
if let Some(token) = &self.config.token {
req = req.header(header::AUTHORIZATION, format!("token {token}"));
}
req
}
async fn get(&self, url: &str) -> Result<reqwest::Response, SourceError> {
let resp = self
.apply_headers(self.client.get(url))
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(SourceError::Http(format!("{} GET {}", resp.status(), url)));
}
Ok(resp)
}
/// Tip sha of the configured branch — the change-detection key.
async fn branch_tip(&self) -> Result<String, SourceError> {
let url = self.api_url(&format!("branches/{}", self.config.branch));
let body: Value = self
.get(&url)
.await?
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
body.get("commit")
.and_then(|c| c.get("id"))
.and_then(Value::as_str)
.map(String::from)
.ok_or_else(|| SourceError::Parse("branch response missing commit.id".into()))
}
/// Markdown file paths at the repo root, excluding READMEs.
async fn list_post_paths(&self) -> Result<Vec<String>, SourceError> {
let url = self.api_url(&format!("contents?ref={}", self.config.branch));
let entries: Vec<Value> = self
.get(&url)
.await?
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
Ok(entries
.iter()
.filter(|e| e.get("type").and_then(Value::as_str) == Some("file"))
.filter_map(|e| e.get("path").and_then(Value::as_str))
.filter(|p| {
p.to_ascii_lowercase().ends_with(".md") && !p.eq_ignore_ascii_case("readme.md")
})
.map(String::from)
.collect())
}
async fn fetch_raw(&self, path: &str) -> Result<String, SourceError> {
let url = self.api_url(&format!("raw/{path}?ref={}", self.config.branch));
self.get(&url)
.await?
.text()
.await
.map_err(|e| SourceError::Parse(e.to_string()))
}
}
#[async_trait]
impl EventSource for BlogSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
async fn poll(&self) -> Result<usize, SourceError> {
let tip = self.branch_tip().await?;
let prior = self.state.load(SOURCE_NAME).await?;
if prior.as_ref().and_then(|p| p.etag.as_deref()) == Some(tip.as_str()) {
self.state.touch(SOURCE_NAME).await?;
return Ok(0);
}
let mut events = Vec::new();
for path in self.list_post_paths().await? {
let content = self.fetch_raw(&path).await?;
match parse_post(&path, &content, &self.config) {
Some(event) => events.push(event),
None => warn!(path = %path, "blog post missing or invalid frontmatter; skipping"),
}
}
let total = self.writer.upsert_events(&events).await?;
// The repo is the source of truth for the full set of posts: anything
// stored under source='blog' that no longer parses out of the current
// tree (deleted file, renamed slug/filename, broken frontmatter) is
// pruned. Nothing is lost — git still has it, and fixing or restoring
// the file re-ingests it on the next tip change.
let keep: Vec<String> = events.iter().map(|e| e.id.clone()).collect();
let pruned = self.writer.prune_events(Source::Blog, &keep).await?;
if pruned > 0 {
tracing::info!(pruned, "blog posts removed upstream; pruned from store");
}
self.state.save(SOURCE_NAME, Some(&tip), None).await?;
debug!(ingested = total, tip = %tip, "blog poll complete");
Ok(total)
}
}
#[derive(Debug, Deserialize)]
struct Frontmatter {
title: String,
slug: Option<String>,
date: String,
public: Option<bool>,
draft: Option<bool>,
}
/// Split `content` into (frontmatter yaml, markdown body). The file must
/// open with a `---` fence on the first line.
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
// The closing fence is a `---` alone on a line.
let mut offset = 0;
for line in rest.split_inclusive('\n') {
if line.trim_end() == "---" {
let yaml = &rest[..offset];
let body = &rest[offset + line.len()..];
return Some((yaml, body));
}
offset += line.len();
}
// Closing fence with no trailing newline at EOF.
if rest[offset..].trim_end() == "---" && offset > 0 {
return Some((&rest[..offset], ""));
}
None
}
/// Accept RFC3339 (`2026-06-12T09:30:00Z`) or a bare date (`2026-06-12`,
/// treated as midnight UTC) so imported posts keep original publish dates.
fn parse_date(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
Some(date.and_time(NaiveTime::MIN).and_utc())
}
fn parse_post(path: &str, content: &str, config: &BlogConfig) -> Option<Event> {
let (yaml, body) = split_frontmatter(content)?;
let fm: Frontmatter = serde_yaml_ng::from_str(yaml)
.map_err(|e| warn!(path = %path, error = %e, "blog frontmatter parse failed"))
.ok()?;
let occurred_at = parse_date(&fm.date)?;
let slug = fm.slug.unwrap_or_else(|| {
path.rsplit('/')
.next()
.unwrap_or(path)
.trim_end_matches(".md")
.to_string()
});
let public = fm.public.unwrap_or(true) && !fm.draft.unwrap_or(false);
Some(Event {
id: format!("blog:{slug}"),
source: Source::Blog,
action: "publish_post".into(),
occurred_at,
public,
payload: json!({
"title": fm.title,
"slug": slug,
"markdown": body.trim_start(),
"_host": config.host,
"_repo": config.repo,
"_branch": config.branch,
"_path": path,
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> BlogConfig {
BlogConfig::default()
}
const POST: &str = "---\ntitle: a watchdog, a torrent tracker, and an unhinged friday\nslug: last-week\ndate: 2026-06-12\n---\n\nthe week opened deep in helexa's neuron engine.\n";
#[test]
fn parse_post_with_bare_date() {
let ev = parse_post("last-week.md", POST, &config()).expect("parses");
assert_eq!(ev.id, "blog:last-week");
assert_eq!(ev.source, Source::Blog);
assert_eq!(ev.action, "publish_post");
assert_eq!(ev.occurred_at.to_rfc3339(), "2026-06-12T00:00:00+00:00");
assert!(ev.public);
assert_eq!(
ev.payload.get("title").and_then(Value::as_str),
Some("a watchdog, a torrent tracker, and an unhinged friday")
);
assert_eq!(
ev.payload.get("markdown").and_then(Value::as_str),
Some("the week opened deep in helexa's neuron engine.\n")
);
assert_eq!(
ev.payload.get("_repo").and_then(Value::as_str),
Some("grenade/blog")
);
}
#[test]
fn parse_post_with_rfc3339_date() {
let post = "---\ntitle: t\ndate: 2019-03-04T12:30:00+02:00\n---\nbody\n";
let ev = parse_post("old-post.md", post, &config()).expect("parses");
assert_eq!(ev.occurred_at.to_rfc3339(), "2019-03-04T10:30:00+00:00");
}
#[test]
fn slug_falls_back_to_filename_stem() {
let post = "---\ntitle: t\ndate: 2020-01-01\n---\nbody\n";
let ev = parse_post("posts/my-old-entry.md", post, &config()).expect("parses");
assert_eq!(ev.id, "blog:my-old-entry");
}
#[test]
fn draft_is_private() {
let post = "---\ntitle: t\ndate: 2020-01-01\ndraft: true\n---\nbody\n";
let ev = parse_post("p.md", post, &config()).expect("parses");
assert!(!ev.public);
}
#[test]
fn explicit_public_false_is_private() {
let post = "---\ntitle: t\ndate: 2020-01-01\npublic: false\n---\nbody\n";
let ev = parse_post("p.md", post, &config()).expect("parses");
assert!(!ev.public);
}
#[test]
fn missing_frontmatter_is_skipped() {
assert!(parse_post("p.md", "# just a heading\n\nbody\n", &config()).is_none());
}
#[test]
fn invalid_date_is_skipped() {
let post = "---\ntitle: t\ndate: last tuesday\n---\nbody\n";
assert!(parse_post("p.md", post, &config()).is_none());
}
#[test]
fn split_handles_crlf_and_eof_fence() {
let (yaml, body) = split_frontmatter("---\r\ntitle: t\r\n---\r\nbody").expect("splits");
assert!(yaml.contains("title: t"));
assert_eq!(body, "body");
let (yaml, body) = split_frontmatter("---\ntitle: t\n---").expect("splits at eof");
assert_eq!(yaml, "title: t\n");
assert_eq!(body, "");
}
}

View File

@@ -21,7 +21,11 @@ use serde_json::Value;
use tracing::debug;
const SOURCE_NAME: &str = "bugzilla";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
#[derive(Clone, Debug)]
pub struct BugzillaConfig {

View File

@@ -9,19 +9,22 @@
//! Each item carries a self-contained payload — including the event-emitting
//! host — so the reshape layer can construct URLs without needing config.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, RepoLanguage, Source};
use moments_entities::{Event, Source};
use reqwest::{Client, header};
use serde_json::Value;
use tracing::debug;
const SOURCE_NAME: &str = "gitea";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
const MAX_BACKFILL_PAGES: u32 = 20;
#[derive(Clone, Debug)]
@@ -123,19 +126,17 @@ impl GiteaSource {
/// for org feeds which contain all members' activity).
///
/// `base_url` should contain everything except the `&page=N` suffix.
/// Returns (ingested_count, set_of_repo_full_names).
async fn poll_feed(
&self,
state_key: &str,
base_url: &str,
filter_user: bool,
) -> Result<(usize, HashSet<String>), SourceError> {
) -> Result<usize, SourceError> {
let prior = self.state.load(state_key).await?;
let first_run = prior.is_none();
let max_pages = if first_run { MAX_BACKFILL_PAGES } else { 1 };
let mut total = 0usize;
let mut repos = HashSet::new();
for page in 1..=max_pages {
let url = format!("{base_url}&page={page}");
let req = self.apply_headers(self.client.get(&url));
@@ -154,17 +155,6 @@ impl GiteaSource {
break;
}
// Collect repo names from feed items
for item in &items {
if let Some(name) = item
.get("repo")
.and_then(|r| r.get("full_name"))
.and_then(Value::as_str)
{
repos.insert(name.to_string());
}
}
let events: Vec<Event> = items
.iter()
.filter(|it| {
@@ -187,44 +177,6 @@ impl GiteaSource {
}
self.state.touch(state_key).await?;
Ok((total, repos))
}
/// Fetch language breakdowns for the given repos via the Gitea REST API.
async fn fetch_languages(&self, repos: &HashSet<String>) -> Result<usize, SourceError> {
let mut total = 0usize;
for repo in repos {
let url = format!(
"https://{}/api/v1/repos/{}/languages",
self.config.host, repo
);
let req = self.apply_headers(self.client.get(&url));
let resp = req
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
tracing::warn!(repo = %repo, status = %resp.status(), "gitea language fetch failed; skipping");
continue;
}
let lang_map: std::collections::HashMap<String, i64> = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
let languages: Vec<RepoLanguage> = lang_map
.into_iter()
.map(|(language, bytes)| RepoLanguage {
source: Source::Gitea,
repo: repo.clone(),
language,
bytes,
color: None, // Gitea doesn't return colors
})
.collect();
total += self.writer.upsert_repo_languages(&languages).await?;
}
debug!(total, repos = repos.len(), "gitea repo languages updated");
Ok(total)
}
}
@@ -236,12 +188,9 @@ impl EventSource for GiteaSource {
}
async fn poll(&self) -> Result<usize, SourceError> {
let mut all_repos = HashSet::new();
// Poll user's own activity feed (existing behavior).
let user_url = self.user_feed_base_url();
let (mut total, repos) = self.poll_feed(SOURCE_NAME, &user_url, false).await?;
all_repos.extend(repos);
let mut total = self.poll_feed(SOURCE_NAME, &user_url, false).await?;
// Discover orgs and poll each org's activity feed, filtering for
// events performed by this user.
@@ -250,20 +199,13 @@ impl EventSource for GiteaSource {
let state_key = format!("gitea:org:{org}");
let org_url = self.org_feed_base_url(org);
match self.poll_feed(&state_key, &org_url, true).await {
Ok((n, repos)) => {
total += n;
all_repos.extend(repos);
}
Ok(n) => total += n,
Err(e) => {
tracing::warn!(org = %org, error = %e, "failed to poll org feed");
}
}
}
if let Err(e) = self.fetch_languages(&all_repos).await {
tracing::warn!(error = %e, "gitea language fetch failed; continuing");
}
debug!(ingested = total, orgs = orgs.len(), "gitea poll complete");
Ok(total)
}
@@ -272,25 +214,14 @@ impl EventSource for GiteaSource {
/// Convert a Gitea activity feed item into our Event row. The host gets
/// stamped into the payload as `_host` so the reshape layer can build
/// web URLs without needing global config.
///
/// The id is content-derived rather than using Gitea's `id` field directly:
/// Gitea creates one Action row per interested user-context, so a push to
/// an org repo by user U produces two rows (one under U's context, one
/// under the org's), distinguished only by `id` and `user_id`. Keying on
/// `(op_type, act_user_id, repo_id, ref_name, comment_id, created)` makes
/// those two rows collapse to the same event on upsert.
fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
let id = item.get("id").and_then(Value::as_i64)?;
let op_type = item.get("op_type").and_then(Value::as_str)?.to_string();
let created_str = item.get("created").and_then(Value::as_str)?;
let occurred_at = DateTime::parse_from_rfc3339(created_str)
.ok()?
.with_timezone(&Utc);
let private = item
.get("is_private")
.and_then(Value::as_bool)
.unwrap_or(false);
let id = gitea_canonical_id(item, &op_type, created_str);
let private = item.get("is_private").and_then(Value::as_bool).unwrap_or(false);
let mut payload = item.clone();
if let Some(obj) = payload.as_object_mut() {
@@ -298,7 +229,7 @@ fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
}
Some(Event {
id,
id: format!("gitea:{id}"),
source: Source::Gitea,
action: op_type,
occurred_at,
@@ -307,33 +238,6 @@ fn parse_gitea_event(item: &Value, host: &str) -> Option<Event> {
})
}
/// Build the canonical, content-derived id for a Gitea action. Must stay
/// in lockstep with the SQL formula in migration 0005 so back-fill and
/// new writes share the same id space.
fn gitea_canonical_id(item: &Value, op_type: &str, created: &str) -> String {
let act_user_id = item
.get("act_user_id")
.and_then(Value::as_i64)
.or_else(|| {
item.get("act_user")
.and_then(|u| u.get("id"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let repo_id = item
.get("repo_id")
.and_then(Value::as_i64)
.or_else(|| {
item.get("repo")
.and_then(|r| r.get("id"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let ref_name = item.get("ref_name").and_then(Value::as_str).unwrap_or("");
let comment_id = item.get("comment_id").and_then(Value::as_i64).unwrap_or(0);
format!("gitea:{op_type}:{act_user_id}:{repo_id}:{ref_name}:{comment_id}:{created}")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -344,19 +248,14 @@ mod tests {
let raw = json!({
"id": 973,
"op_type": "commit_repo",
"act_user_id": 42,
"repo_id": 7,
"ref_name": "refs/heads/main",
"is_private": false,
"content": "{\"Commits\":[{\"Sha1\":\"abc123\"}],\"Len\":1}",
"created": "2026-05-03T16:37:45Z",
"repo": { "id": 7, "full_name": "grenade/moments" }
"repo": { "full_name": "grenade/moments" }
});
let ev = parse_gitea_event(&raw, "git.lair.cafe").expect("parses");
assert_eq!(
ev.id,
"gitea:commit_repo:42:7:refs/heads/main:0:2026-05-03T16:37:45Z"
);
assert_eq!(ev.id, "gitea:973");
assert_eq!(ev.source, Source::Gitea);
assert_eq!(ev.action, "commit_repo");
assert!(ev.public);
@@ -367,43 +266,6 @@ mod tests {
);
}
#[test]
fn dup_action_rows_for_user_and_org_contexts_collapse_to_same_id() {
// Gitea creates two Action rows when grenade pushes to helexa/cortex:
// one with user_id=grenade (surfaced by the user feed), one with
// user_id=helexa (surfaced by the org feed). Everything except `id`
// and `user_id` is identical. The canonical id ignores both.
let user_ctx = json!({
"id": 1322,
"user_id": 42,
"op_type": "commit_repo",
"act_user_id": 42,
"act_user": { "login": "grenade", "id": 42 },
"repo_id": 99,
"repo": { "id": 99, "full_name": "helexa/cortex" },
"ref_name": "refs/heads/main",
"comment_id": 0,
"is_private": false,
"created": "2026-05-20T04:32:50Z"
});
let org_ctx = json!({
"id": 1323,
"user_id": 7,
"op_type": "commit_repo",
"act_user_id": 42,
"act_user": { "login": "grenade", "id": 42 },
"repo_id": 99,
"repo": { "id": 99, "full_name": "helexa/cortex" },
"ref_name": "refs/heads/main",
"comment_id": 0,
"is_private": false,
"created": "2026-05-20T04:32:50Z"
});
let a = parse_gitea_event(&user_ctx, "git.lair.cafe").expect("parses");
let b = parse_gitea_event(&org_ctx, "git.lair.cafe").expect("parses");
assert_eq!(a.id, b.id, "duplicate action rows must collide on id");
}
#[test]
fn org_event_user_filter_predicate() {
let by_user = json!({

View File

@@ -8,7 +8,11 @@ use reqwest::{Client, StatusCode, header};
use tracing::debug;
const SOURCE_NAME: &str = "github";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
/// Cap on initial backfill pagination. GitHub returns ~300 events max
/// across pages; this is a safety net, not an expected limit.
@@ -162,9 +166,7 @@ impl EventSource for GithubSource {
}
}
self.state
.save(SOURCE_NAME, latest_etag.as_deref(), None)
.await?;
self.state.save(SOURCE_NAME, latest_etag.as_deref(), None).await?;
Ok(total)
}
}
@@ -180,10 +182,7 @@ fn parse_github_event(raw: serde_json::Value) -> Option<Event> {
// `/events/public` are always true; `/events` may include false. Default
// to true if missing — that matches the safer-of-the-two-mistakes (under-
// expose) and the `/events/public` endpoint behaviour.
let public = raw
.get("public")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let public = raw.get("public").and_then(serde_json::Value::as_bool).unwrap_or(true);
Some(Event {
id: format!("github:{id}"),
source: Source::Github,
@@ -202,10 +201,7 @@ fn parse_link_next(header: Option<&header::HeaderValue>) -> Option<String> {
let part = part.trim();
// Each part: `<url>; rel="next"`
let (url_part, rel_part) = part.split_once(';')?;
let url = url_part
.trim()
.trim_start_matches('<')
.trim_end_matches('>');
let url = url_part.trim().trim_start_matches('<').trim_end_matches('>');
let rel = rel_part.trim();
if rel.eq_ignore_ascii_case("rel=\"next\"") {
return Some(url.to_string());

View File

@@ -1,57 +1,32 @@
//! Per-repo commit enumeration for full GitHub history.
//!
//! Discovers repos via two sources:
//! 1. REST `/user/repos` — repos where the user is owner, collaborator,
//! or org member.
//! 2. GraphQL `repositoriesContributedTo` — repos the user has committed
//! to, opened issues/PRs on, or reviewed, even without collaborator
//! status. No result cap (cursor-paginated).
//!
//! Then walks each branch's commit history via
//! `/repos/{owner}/{repo}/commits?author={user}&sha={branch}` with a
//! per-branch `since` cursor to avoid re-fetching known commits. Walking
//! every branch (not just the default) is what catches work-in-progress
//! on feature branches and pushes to fork branches that never get merged
//! upstream — neither the user events feed nor /search/commits surface
//! those reliably.
//! The Search API caps at 1000 results; this source enumerates all repos
//! the user can access via `/user/repos` and walks each repo's commit
//! history via `/repos/{owner}/{repo}/commits?author={user}` — no cap.
//!
//! Events use `github-commit:{sha}` as their ID, matching the scheme in
//! `github_search`, so duplicates are resolved via idempotent upsert
//! (the same commit reached via two branches just upserts twice).
//! `github_search`, so duplicates are resolved via idempotent upsert.
//!
//! Per-repo poller state keys (`github-repo:{owner}/{repo}`) track which
//! repos have been fully backfilled. First run paginates the full history;
//! subsequent runs fetch only page 1.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use moments_core::{EventSource, EventWriter, PollerStateStore, SourceError};
use moments_entities::{Event, RepoLanguage, Source};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use moments_entities::{Event, Source};
use reqwest::{Client, header};
use serde_json::Value;
use tracing::{debug, warn};
/// Encode characters that have meaning in a URL query — branch names can
/// contain `/`, `#`, `?`, etc. Whitelisting is too fragile; encode anything
/// outside the unreserved set plus a few safe characters.
const BRANCH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'&')
.add(b'=')
.add(b'+')
.add(b'%');
const SOURCE_NAME: &str = "github-repo";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
const MAX_BACKFILL_PAGES: u32 = 100;
#[derive(Clone, Debug)]
@@ -139,343 +114,22 @@ impl GithubRepoSource {
break;
}
}
// Supplement with repos from GraphQL repositoriesContributedTo.
// This catches repos where the user contributed via PRs but isn't
// an owner, collaborator, or org member — no result cap.
let mut known: HashSet<String> = repos.iter().map(|r| r.full_name.clone()).collect();
let contributed = self.discover_contributed_repos().await;
match contributed {
Ok(extra) => {
for r in extra {
if known.insert(r.full_name.clone()) {
repos.push(r);
}
}
}
Err(e) => {
warn!(error = %e, "GraphQL contributed-repos discovery failed; continuing with known repos");
}
}
Ok(repos)
}
/// Discover repos the user has contributed to via GraphQL.
/// Uses cursor-based pagination with no result cap.
async fn discover_contributed_repos(&self) -> Result<Vec<Repo>, SourceError> {
let token = match &self.config.token {
Some(t) => t,
None => return Ok(vec![]),
};
let mut repos = Vec::new();
let mut cursor: Option<String> = None;
loop {
let after = match &cursor {
Some(c) => format!(", after: \"{}\"", c),
None => String::new(),
};
let query = format!(
r#"{{ user(login: "{}") {{ repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, PULL_REQUEST, ISSUE]{}) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ nameWithOwner isPrivate }} }} }} }}"#,
self.config.user, after
);
let body = serde_json::json!({ "query": query });
let resp = self
.client
.post("https://api.github.com/graphql")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::USER_AGENT, USER_AGENT)
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(SourceError::Http(format!("{} POST graphql", resp.status())));
}
let data: Value = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
// Check for GraphQL-level errors
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
if let Some(msg) = errors
.first()
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
{
return Err(SourceError::Http(format!("GraphQL error: {msg}")));
}
}
let contributed = &data["data"]["user"]["repositoriesContributedTo"];
let nodes = contributed["nodes"].as_array();
if let Some(nodes) = nodes {
for node in nodes {
let full_name = node.get("nameWithOwner").and_then(Value::as_str);
let private = node
.get("isPrivate")
.and_then(Value::as_bool)
.unwrap_or(false);
if let Some(name) = full_name {
repos.push(Repo {
full_name: name.to_string(),
private,
});
}
}
}
let has_next = contributed["pageInfo"]["hasNextPage"]
.as_bool()
.unwrap_or(false);
if !has_next {
break;
}
cursor = contributed["pageInfo"]["endCursor"]
.as_str()
.map(String::from);
}
debug!(
repos = repos.len(),
"discovered contributed repos via GraphQL"
);
Ok(repos)
}
/// Branch discovery via GraphQL, filtered to branches whose HEAD
/// commit was authored by the user. Skips the long tail of
/// upstream-contributor branches in large forks (e.g. azure-docs).
///
/// Why HEAD author and not `history(author:).totalCount`: the latter
/// forces GraphQL to walk full commit history per branch looking for
/// matches, which times out (502) on forks with thousands of branches.
/// Checking the HEAD commit's author is O(1) per branch. The blind
/// spot — branches with the user's older commits but a different
/// HEAD author — is rare in practice for forks/feature branches.
///
/// On any GraphQL failure, callers should fall back to `list_branches`
/// (REST, walks everything; 500s from empty branches are silenced
/// inside `scan_repo_branch`).
async fn list_branches_with_commits(
&self,
repo: &Repo,
user_login: &str,
) -> Result<Vec<String>, SourceError> {
let token = match &self.config.token {
Some(t) => t,
None => return Err(SourceError::Http("no token; graphql unavailable".into())),
};
let parts: Vec<&str> = repo.full_name.splitn(2, '/').collect();
if parts.len() != 2 {
return Ok(Vec::new());
}
let (owner, name) = (parts[0], parts[1]);
let mut branches = Vec::new();
let mut cursor: Option<String> = None;
// Cap pages to bound cost on pathological repos. 50 pages × 100
// branches = 5000; well past anything plausible for a human user.
for _ in 0..50u32 {
let after = match &cursor {
Some(c) => format!(", after: \"{}\"", c),
None => String::new(),
};
// `author.user.login` resolves the commit's GitHub user (may
// differ from the raw commit author name); falling back to
// `author.email` is intentionally omitted to keep the query
// shape minimal — false negatives there are caught by the
// REST fallback on the next poll cycle.
let query = format!(
r#"{{ repository(owner: "{owner}", name: "{name}") {{ refs(refPrefix: "refs/heads/", first: 100{after}) {{ pageInfo {{ hasNextPage endCursor }} nodes {{ name target {{ ... on Commit {{ author {{ user {{ login }} }} }} }} }} }} }} }}"#,
);
let body = serde_json::json!({ "query": query });
let resp = self
.client
.post("https://api.github.com/graphql")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::USER_AGENT, USER_AGENT)
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(SourceError::Http(format!(
"{} POST graphql (branches {}/{})",
resp.status(),
owner,
name
)));
}
let data: Value = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
if let Some(msg) = errors
.first()
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
{
return Err(SourceError::Http(format!(
"GraphQL error listing branches: {msg}"
)));
}
}
let refs = &data["data"]["repository"]["refs"];
if refs.is_null() {
// Repo may be deleted or inaccessible — treat as empty.
return Ok(Vec::new());
}
if let Some(nodes) = refs["nodes"].as_array() {
for node in nodes {
let branch = node["name"].as_str();
let head_login = node["target"]["author"]["user"]["login"].as_str();
if let (Some(b), Some(login)) = (branch, head_login) {
if login.eq_ignore_ascii_case(user_login) {
branches.push(b.to_string());
}
}
}
}
let has_next = refs["pageInfo"]["hasNextPage"].as_bool().unwrap_or(false);
if !has_next {
break;
}
cursor = refs["pageInfo"]["endCursor"].as_str().map(String::from);
}
Ok(branches)
}
/// List every branch in a repo. Returns an empty vec for empty (409)
/// or missing (404) repos; surfaces rate-limit / transport errors so the
/// caller can decide whether to bail.
async fn list_branches(&self, repo: &Repo) -> Result<Vec<String>, SourceError> {
let mut branches = Vec::new();
for page in 1..=10u32 {
let url = format!(
"https://api.github.com/repos/{}/branches?per_page={}&page={}",
repo.full_name, self.config.per_page, page
);
let req = self.apply_headers(self.client.get(&url));
let resp = req
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 404 || status.as_u16() == 409 {
return Ok(Vec::new());
}
if status.as_u16() == 403 || status.as_u16() == 429 {
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
if !status.is_success() {
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
let items: Vec<Value> = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if items.is_empty() {
break;
}
for item in &items {
if let Some(name) = item.get("name").and_then(Value::as_str) {
branches.push(name.to_string());
}
}
if items.len() < self.config.per_page as usize {
break;
}
}
Ok(branches)
}
/// Fetch commits for a single repo across all branches the user has
/// touched. Per-branch state keys (`github-repo:{full_name}@{branch}`)
/// hold the newest seen commit timestamp so each branch can be
/// incremented independently — important because a brand new branch's
/// `since` cursor must start unset even when the default branch has
/// been polled many times already.
///
/// When `user_id` is supplied, branches are pre-filtered via GraphQL
/// to those with at least one commit by the user — vastly cheaper for
/// large upstream forks where most branches were never touched. On
/// GraphQL failure (or no token), falls back to the REST branch list
/// and relies on the per-branch 500-as-empty handling to discard the
/// noise.
/// Fetch commits for a single repo, paginating fully on first run.
async fn scan_repo(&self, repo: &Repo) -> Result<usize, SourceError> {
let branches = if self.config.token.is_some() {
match self
.list_branches_with_commits(repo, &self.config.user)
.await
{
Ok(b) => b,
Err(e) => {
warn!(repo = %repo.full_name, error = %e, "graphql branch filter failed; falling back to REST");
self.list_branches(repo).await?
}
}
} else {
self.list_branches(repo).await?
};
if branches.is_empty() {
return Ok(0);
}
let mut total = 0usize;
// Dedup commits seen via multiple branches in one tick. Without this
// the same SHA appears in the upsert batch twice (postgres rejects
// duplicate conflict targets in a single INSERT).
let mut seen_in_tick: HashSet<String> = HashSet::new();
for branch in &branches {
match self.scan_repo_branch(repo, branch, &mut seen_in_tick).await {
Ok(n) => total += n,
Err(SourceError::Http(ref msg))
if msg.starts_with("403") || msg.starts_with("429") =>
{
return Err(SourceError::Http(msg.clone()));
}
Err(e) => {
warn!(repo = %repo.full_name, branch = %branch, error = %e, "branch scan failed; continuing");
}
}
}
Ok(total)
}
async fn scan_repo_branch(
&self,
repo: &Repo,
branch: &str,
seen_in_tick: &mut HashSet<String>,
) -> Result<usize, SourceError> {
let state_key = format!("github-repo:{}@{}", repo.full_name, branch);
let state_key = format!("github-repo:{}", repo.full_name);
let prior = self.state.load(&state_key).await?;
let since = prior.as_ref().and_then(|s| s.last_modified);
let encoded_branch = utf8_percent_encode(branch, BRANCH_ENCODE_SET).to_string();
let first_run = prior.is_none();
let max_pages = if first_run { MAX_BACKFILL_PAGES } else { 1 };
let mut total = 0usize;
let mut newest: Option<DateTime<Utc>> = since;
for page in 1..=MAX_BACKFILL_PAGES {
let mut url = format!(
"https://api.github.com/repos/{}/commits?author={}&sha={}&per_page={}&page={}",
repo.full_name, self.config.user, encoded_branch, self.config.per_page, page
for page in 1..=max_pages {
let url = format!(
"https://api.github.com/repos/{}/commits?author={}&per_page={}&page={}",
repo.full_name, self.config.user, self.config.per_page, page
);
if let Some(since_dt) = since {
url.push_str(&format!("&since={}", since_dt.to_rfc3339()));
}
let req = self.apply_headers(self.client.get(&url));
let resp = req
.send()
@@ -488,20 +142,11 @@ impl GithubRepoSource {
break;
}
if status.as_u16() == 403 || status.as_u16() == 429 {
warn!(repo = %repo.full_name, branch = %branch, status = %status, "rate limited; stopping early");
warn!(repo = %repo.full_name, status = %status, "rate limited; stopping early");
return Err(SourceError::Http(format!("{} GET {}", status, url)));
}
if status.as_u16() == 404 {
warn!(repo = %repo.full_name, branch = %branch, "repo or branch not found; skipping");
break;
}
// GitHub's `/repos/.../commits?author=X&sha=branch` returns 500
// (not an empty array) when the user has zero commits on the
// specified branch. Treat it as "no commits on this branch"
// rather than a server error — surfacing it as a warning floods
// logs on forks whose branches were all authored by upstream.
if status.as_u16() == 500 {
debug!(repo = %repo.full_name, branch = %branch, "no commits by author on branch (500)");
warn!(repo = %repo.full_name, "repo not found; skipping");
break;
}
if !status.is_success() {
@@ -516,33 +161,10 @@ impl GithubRepoSource {
break;
}
let mut events = Vec::with_capacity(items.len());
for item in &items {
if let Some(ev) = parse_commit(item, repo) {
if seen_in_tick.insert(ev.id.clone()) {
if let Some(n) = newest {
if ev.occurred_at > n {
newest = Some(ev.occurred_at);
}
} else {
newest = Some(ev.occurred_at);
}
events.push(ev);
} else {
// Already ingested via another branch this tick;
// still advance `newest` so the per-branch cursor
// doesn't get stuck behind shared history.
let occurred = parse_commit_date(item);
if let Some(t) = occurred {
newest = Some(match newest {
Some(n) if t > n => t,
Some(n) => n,
None => t,
});
}
}
}
}
let events: Vec<Event> = items
.iter()
.filter_map(|item| parse_commit(item, repo))
.collect();
total += self.writer.upsert_events(&events).await?;
if items.len() < self.config.per_page as usize {
@@ -550,110 +172,7 @@ impl GithubRepoSource {
}
}
self.state.save(&state_key, None, newest).await?;
Ok(total)
}
/// Batch-fetch language breakdowns for repos via GraphQL, upserting
/// into repo_languages. Repos are batched using GraphQL aliases to
/// minimise round trips.
async fn fetch_languages(&self, repos: &[Repo]) -> Result<usize, SourceError> {
let token = match &self.config.token {
Some(t) => t,
None => return Ok(0),
};
let mut total = 0usize;
for chunk in repos.chunks(20) {
let mut fragments = Vec::with_capacity(chunk.len());
for (i, repo) in chunk.iter().enumerate() {
let parts: Vec<&str> = repo.full_name.splitn(2, '/').collect();
if parts.len() != 2 {
continue;
}
fragments.push(format!(
r#"r{i}: repository(owner: "{}", name: "{}") {{ languages(first: 20, orderBy: {{field: SIZE, direction: DESC}}) {{ edges {{ size node {{ name color }} }} }} }}"#,
parts[0], parts[1]
));
}
if fragments.is_empty() {
continue;
}
let query = format!("{{ {} }}", fragments.join(" "));
let body = serde_json::json!({ "query": query });
let resp = self
.client
.post("https://api.github.com/graphql")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::USER_AGENT, USER_AGENT)
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|e| SourceError::Http(e.to_string()))?;
if !resp.status().is_success() {
warn!(status = %resp.status(), "GraphQL language fetch failed");
break;
}
let data: Value = resp
.json()
.await
.map_err(|e| SourceError::Parse(e.to_string()))?;
if let Some(errors) = data.get("errors").and_then(Value::as_array) {
if let Some(msg) = errors
.first()
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
{
warn!(error = %msg, "GraphQL language fetch had errors");
}
}
let data_obj = match data.get("data") {
Some(d) => d,
None => continue,
};
let mut languages = Vec::new();
for (i, repo) in chunk.iter().enumerate() {
let alias = format!("r{i}");
let edges = data_obj
.get(&alias)
.and_then(|r| r.get("languages"))
.and_then(|l| l.get("edges"))
.and_then(Value::as_array);
if let Some(edges) = edges {
for edge in edges {
let size = edge.get("size").and_then(Value::as_i64).unwrap_or(0);
let name = edge
.get("node")
.and_then(|n| n.get("name"))
.and_then(Value::as_str);
let color = edge
.get("node")
.and_then(|n| n.get("color"))
.and_then(Value::as_str);
if let Some(name) = name {
languages.push(RepoLanguage {
source: Source::Github,
repo: repo.full_name.clone(),
language: name.to_string(),
bytes: size,
color: color.map(String::from),
});
}
}
}
}
total += self.writer.upsert_repo_languages(&languages).await?;
}
debug!(total, "repo languages updated");
self.state.touch(&state_key).await?;
Ok(total)
}
}
@@ -677,9 +196,7 @@ impl EventSource for GithubRepoSource {
}
total += n;
}
Err(SourceError::Http(ref msg))
if msg.starts_with("403") || msg.starts_with("429") =>
{
Err(SourceError::Http(ref msg)) if msg.starts_with("403") || msg.starts_with("429") => {
warn!("rate limited during repo scan; ending poll early");
break;
}
@@ -689,16 +206,8 @@ impl EventSource for GithubRepoSource {
}
}
if let Err(e) = self.fetch_languages(&repos).await {
warn!(error = %e, "language fetch failed; continuing");
}
self.state.touch(SOURCE_NAME).await?;
debug!(
ingested = total,
repos = repos.len(),
"github-repo poll complete"
);
debug!(ingested = total, repos = repos.len(), "github-repo poll complete");
Ok(total)
}
}
@@ -711,17 +220,15 @@ struct Repo {
fn parse_repo(item: &Value) -> Option<Repo> {
let full_name = item.get("full_name").and_then(Value::as_str)?;
let private = item
.get("private")
.and_then(Value::as_bool)
.unwrap_or(false);
let private = item.get("private").and_then(Value::as_bool).unwrap_or(false);
Some(Repo {
full_name: full_name.to_string(),
private,
})
}
fn parse_commit_date(item: &Value) -> Option<DateTime<Utc>> {
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
let sha = item.get("sha").and_then(Value::as_str)?;
let date_str = item
.get("commit")
.and_then(|c| c.get("author"))
@@ -733,21 +240,9 @@ fn parse_commit_date(item: &Value) -> Option<DateTime<Utc>> {
.and_then(|c| c.get("date"))
.and_then(Value::as_str)
})?;
Some(
DateTime::parse_from_rfc3339(date_str)
.ok()?
.with_timezone(&Utc),
)
}
fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
let sha = item.get("sha").and_then(Value::as_str)?;
let occurred_at = parse_commit_date(item)?;
let mut payload = item.clone();
if let Some(obj) = payload.as_object_mut() {
obj.insert("_repo".into(), Value::String(repo.full_name.clone()));
}
let occurred_at = DateTime::parse_from_rfc3339(date_str)
.ok()?
.with_timezone(&Utc);
Some(Event {
id: format!("github-commit:{sha}"),
@@ -755,7 +250,7 @@ fn parse_commit(item: &Value, repo: &Repo) -> Option<Event> {
action: "Commit".into(),
occurred_at,
public: !repo.private,
payload,
payload: item.clone(),
})
}

View File

@@ -26,7 +26,11 @@ use serde_json::Value;
use tracing::{debug, warn};
const SOURCE_NAME: &str = "github-search";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
#[derive(Clone, Debug)]
pub struct GithubSearchConfig {
@@ -109,11 +113,8 @@ impl GithubSearchSource {
) -> Result<usize, SourceError> {
let mut total = 0usize;
for page in 1..=self.config.max_pages {
// `fork:true` opts forks into the search — by default GitHub's
// search API excludes them entirely, which means commits on a
// user's fork (regardless of branch) never surface here.
let url = format!(
"https://api.github.com/search/commits?q=author:{}+fork:true&sort=author-date&order=desc&per_page={}&page={}",
"https://api.github.com/search/commits?q=author:{}&sort=author-date&order=desc&per_page={}&page={}",
self.config.user, self.config.per_page, page
);
let req = self.apply_headers(self.client.get(&url));
@@ -374,10 +375,7 @@ mod tests {
"repository": { "full_name": "faith1337z/Trade", "private": false }
});
let ev = parse_commit_event(&raw).expect("parses");
assert_eq!(
ev.id,
"github-commit:a6fcefbe909a97ad5a049b9fa48bc74309af10d9"
);
assert_eq!(ev.id, "github-commit:a6fcefbe909a97ad5a049b9fa48bc74309af10d9");
assert_eq!(ev.action, "Commit");
assert!(ev.public);
}

View File

@@ -21,7 +21,11 @@ use serde_json::Value;
use tracing::{debug, warn};
const SOURCE_NAME: &str = "hg";
const USER_AGENT: &str = concat!("moments/", env!("CARGO_PKG_VERSION"), " (+https://rob.tn)");
const USER_AGENT: &str = concat!(
"moments/",
env!("CARGO_PKG_VERSION"),
" (+https://rob.tn)"
);
/// Maximum changesets returned per json-log request.
const REV_COUNT: u32 = 500;
@@ -144,7 +148,10 @@ impl HgSource {
let mut payload = entry.clone();
if let Some(obj) = payload.as_object_mut() {
obj.insert("_repo".into(), Value::String(repo.into()));
obj.insert("_host".into(), Value::String(self.config.host.clone()));
obj.insert(
"_host".into(),
Value::String(self.config.host.clone()),
);
}
all_events.push(Event {
id: format!("hg:{repo}:{node}"),
@@ -241,19 +248,6 @@ mod tests {
) -> Result<usize, moments_core::StoreError> {
Ok(0)
}
async fn upsert_repo_languages(
&self,
_languages: &[moments_entities::RepoLanguage],
) -> Result<usize, moments_core::StoreError> {
Ok(0)
}
async fn prune_events(
&self,
_source: moments_entities::Source,
_keep_ids: &[String],
) -> Result<usize, moments_core::StoreError> {
Ok(0)
}
}
struct NoopState;
#[async_trait]

View File

@@ -1,4 +1,3 @@
pub mod blog;
pub mod bugzilla;
pub mod gitea;
pub mod github;
@@ -7,13 +6,9 @@ pub mod github_search;
pub mod hg;
use async_trait::async_trait;
use chrono::NaiveDate;
use chrono::{DateTime, Utc};
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
use moments_entities::{
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
Source, SourceSummary,
};
use moments_entities::{Event, EventQuery, ProjectSummary, Source, SourceSummary};
use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::str::FromStr;
@@ -62,8 +57,7 @@ impl EventReader for PgStore {
AND ($6::text IS NULL OR (CASE source
WHEN 'github' THEN COALESCE(
payload->'repo'->>'name',
payload->'repository'->>'full_name',
payload->>'_repo'
payload->'repository'->>'full_name'
)
WHEN 'gitea' THEN COALESCE(
payload->'repo'->>'full_name',
@@ -102,10 +96,7 @@ impl EventReader for PgStore {
.collect()
}
async fn source_summaries(
&self,
include_private: bool,
) -> Result<Vec<SourceSummary>, StoreError> {
async fn source_summaries(&self, include_private: bool) -> Result<Vec<SourceSummary>, StoreError> {
let rows = sqlx::query(
r#"
SELECT source,
@@ -153,8 +144,7 @@ impl EventReader for PgStore {
CASE source
WHEN 'github' THEN COALESCE(
payload->'repo'->>'name',
payload->'repository'->>'full_name',
payload->>'_repo'
payload->'repository'->>'full_name'
)
WHEN 'gitea' THEN COALESCE(
payload->'repo'->>'full_name',
@@ -193,209 +183,15 @@ impl EventReader for PgStore {
source: Source::from_str(&source_str).map_err(map_err)?,
repo: r.try_get("repo").map_err(map_err)?,
host: r.try_get("host").map_err(map_err)?,
commit_count: r
.try_get::<i64, _>("commit_count")
.map_err(map_err)
.unwrap_or(0),
issue_count: r
.try_get::<i64, _>("issue_count")
.map_err(map_err)
.unwrap_or(0),
pr_count: r
.try_get::<i64, _>("pr_count")
.map_err(map_err)
.unwrap_or(0),
commit_count: r.try_get::<i64, _>("commit_count").map_err(map_err).unwrap_or(0),
issue_count: r.try_get::<i64, _>("issue_count").map_err(map_err).unwrap_or(0),
pr_count: r.try_get::<i64, _>("pr_count").map_err(map_err).unwrap_or(0),
first_activity: r.try_get("first_activity").map_err(map_err)?,
last_activity: r.try_get("last_activity").map_err(map_err)?,
})
})
.collect()
}
async fn daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<DailyCount>, StoreError> {
let rows = sqlx::query(
r#"
SELECT d::date AS date,
COUNT(e.id)::bigint AS count
FROM generate_series($1::date, $2::date, '1 day') d
LEFT JOIN events e
ON e.occurred_at >= (d::date || 'T00:00:00Z')::timestamptz
AND e.occurred_at < ((d::date + 1) || 'T00:00:00Z')::timestamptz
AND ($3::bool OR e.public = true)
GROUP BY d::date
ORDER BY d::date
"#,
)
.bind(from)
.bind(to)
.bind(include_private)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
Ok(DailyCount {
date: r.try_get("date").map_err(map_err)?,
count: r.try_get("count").map_err(map_err)?,
})
})
.collect()
}
async fn language_daily_counts(
&self,
from: NaiveDate,
to: NaiveDate,
include_private: bool,
) -> Result<Vec<LanguageDailyCount>, StoreError> {
let rows = sqlx::query(
r#"
SELECT date, language, color,
ROUND(SUM(weight))::bigint AS commits
FROM (
SELECT d::date AS date,
rl.language,
COALESCE(rl.color,
(SELECT color FROM repo_languages
WHERE language = rl.language AND color IS NOT NULL
LIMIT 1)
) AS color,
rl.bytes::float / NULLIF(rt.total, 0) AS weight
FROM generate_series($1::date, $2::date, '1 day') d
JOIN events e
ON e.occurred_at >= (d::date || 'T00:00:00Z')::timestamptz
AND e.occurred_at < ((d::date + 1) || 'T00:00:00Z')::timestamptz
AND ($3::bool OR e.public = true)
AND e.action IN ('Commit', 'PushEvent', 'commit_repo')
JOIN repo_languages rl
ON rl.source = e.source
AND rl.repo = CASE e.source
WHEN 'github' THEN COALESCE(
e.payload->'repo'->>'name',
e.payload->'repository'->>'full_name',
e.payload->>'_repo'
)
WHEN 'gitea' THEN COALESCE(
e.payload->'repo'->>'full_name',
e.payload->'repo'->>'name'
)
ELSE NULL
END
JOIN LATERAL (
SELECT SUM(bytes)::float AS total
FROM repo_languages r2
WHERE r2.source = rl.source AND r2.repo = rl.repo
) rt ON true
) weighted
GROUP BY date, language, color
HAVING ROUND(SUM(weight)) > 0
ORDER BY date, commits DESC
"#,
)
.bind(from)
.bind(to)
.bind(include_private)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
Ok(LanguageDailyCount {
date: r.try_get("date").map_err(map_err)?,
language: r.try_get("language").map_err(map_err)?,
color: r.try_get("color").map_err(map_err)?,
commits: r.try_get("commits").map_err(map_err)?,
})
})
.collect()
}
async fn hourly_avgs(
&self,
from: NaiveDate,
to: NaiveDate,
tz: &str,
include_private: bool,
) -> Result<Vec<HourlyAvg>, StoreError> {
// GREATEST guards against from > to (returns NaN-via-div-by-zero
// otherwise). EXTRACT(hour FROM tz-shifted timestamp) buckets each
// event into the user's local hour rather than UTC, so the chart
// matches the labels they'd see on a clock.
let rows = sqlx::query(
r#"
WITH params AS (
SELECT GREATEST(($2::date - $1::date + 1), 1)::float8 AS day_count
),
bucketed AS (
SELECT EXTRACT(hour FROM (occurred_at AT TIME ZONE $3))::int AS hour
FROM events
WHERE occurred_at >= ($1::date::timestamp AT TIME ZONE 'UTC')
AND occurred_at < (($2::date + 1)::timestamp AT TIME ZONE 'UTC')
AND ($4::bool OR public = true)
)
SELECT g.h::int AS hour,
(COUNT(b.hour)::float8 / (SELECT day_count FROM params)) AS avg
FROM generate_series(0, 23) AS g(h)
LEFT JOIN bucketed b ON b.hour = g.h
GROUP BY g.h
ORDER BY g.h
"#,
)
.bind(from)
.bind(to)
.bind(tz)
.bind(include_private)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
Ok(HourlyAvg {
hour: r.try_get("hour").map_err(map_err)?,
avg: r.try_get("avg").map_err(map_err)?,
})
})
.collect()
}
async fn repo_languages(&self) -> Result<Vec<RepoLanguage>, StoreError> {
let rows = sqlx::query(
r#"
SELECT source, repo, language, bytes,
COALESCE(color,
(SELECT color FROM repo_languages r2
WHERE r2.language = repo_languages.language AND r2.color IS NOT NULL
LIMIT 1)
) AS color
FROM repo_languages
ORDER BY repo, bytes DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
rows.into_iter()
.map(|r| {
let source_str: String = r.try_get("source").map_err(map_err)?;
Ok(RepoLanguage {
source: Source::from_str(&source_str).map_err(map_err)?,
repo: r.try_get("repo").map_err(map_err)?,
language: r.try_get("language").map_err(map_err)?,
bytes: r.try_get("bytes").map_err(map_err)?,
color: r.try_get("color").map_err(map_err)?,
})
})
.collect()
}
}
#[async_trait]
@@ -503,53 +299,4 @@ impl EventWriter for PgStore {
tx.commit().await.map_err(map_err)?;
Ok(inserted)
}
async fn prune_events(&self, source: Source, keep_ids: &[String]) -> Result<usize, StoreError> {
let n = sqlx::query(
r#"
DELETE FROM events
WHERE source = $1 AND NOT (id = ANY($2))
"#,
)
.bind(source.as_str())
.bind(keep_ids)
.execute(&self.pool)
.await
.map_err(map_err)?
.rows_affected();
Ok(n as usize)
}
async fn upsert_repo_languages(&self, languages: &[RepoLanguage]) -> Result<usize, StoreError> {
if languages.is_empty() {
return Ok(0);
}
let mut tx = self.pool.begin().await.map_err(map_err)?;
let mut count = 0usize;
for lang in languages {
let n = sqlx::query(
r#"
INSERT INTO repo_languages (source, repo, language, bytes, color, fetched_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (source, repo, language) DO UPDATE
SET bytes = EXCLUDED.bytes,
color = EXCLUDED.color,
fetched_at = EXCLUDED.fetched_at
"#,
)
.bind(lang.source.as_str())
.bind(&lang.repo)
.bind(&lang.language)
.bind(lang.bytes)
.bind(&lang.color)
.execute(&mut *tx)
.await
.map_err(map_err)?
.rows_affected();
count += n as usize;
}
tx.commit().await.map_err(map_err)?;
Ok(count)
}
}

View File

@@ -8,7 +8,6 @@ pub enum Source {
Gitea,
Hg,
Bugzilla,
Blog,
}
impl Source {
@@ -17,7 +16,6 @@ impl Source {
Source::Gitea,
Source::Hg,
Source::Bugzilla,
Source::Blog,
];
pub fn as_str(&self) -> &'static str {
@@ -26,7 +24,6 @@ impl Source {
Source::Gitea => "gitea",
Source::Hg => "hg",
Source::Bugzilla => "bugzilla",
Source::Blog => "blog",
}
}
}
@@ -40,7 +37,6 @@ impl std::str::FromStr for Source {
"gitea" => Ok(Source::Gitea),
"hg" => Ok(Source::Hg),
"bugzilla" => Ok(Source::Bugzilla),
"blog" => Ok(Source::Blog),
other => Err(ParseSourceError(other.to_string())),
}
}
@@ -88,21 +84,6 @@ pub struct SourceSummary {
pub latest: Option<DateTime<Utc>>,
}
/// Per-day event count for the contribution graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyCount {
pub date: chrono::NaiveDate,
pub count: i64,
}
/// Average events per day at a given hour of the day, computed in a
/// caller-supplied IANA timezone. 24 entries (0..=23).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HourlyAvg {
pub hour: i32,
pub avg: f64,
}
/// Per-repo activity rollup for the dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSummary {
@@ -116,25 +97,6 @@ pub struct ProjectSummary {
pub last_activity: Option<DateTime<Utc>>,
}
/// Per-language daily commit count for the language stream graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageDailyCount {
pub date: chrono::NaiveDate,
pub language: String,
pub color: Option<String>,
pub commits: i64,
}
/// Per-repo language breakdown from the forge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoLanguage {
pub source: Source,
pub repo: String,
pub language: String,
pub bytes: i64,
pub color: Option<String>,
}
// ---------------------------------------------------------------------
// Presentation shape — what `GET /v1/events` actually returns.
// The API reshapes raw payloads into these so the frontend stays dumb.
@@ -208,28 +170,5 @@ pub enum TimelineIcon {
Star,
Release,
Bug,
Post,
Generic,
}
/// Blog index entry returned by `GET /v1/blog`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlogPostSummary {
pub slug: String,
pub title: String,
pub published_at: DateTime<Utc>,
pub excerpt: String,
}
/// Full blog post returned by `GET /v1/blog/{slug}`. The host/repo/branch
/// triple lets the UI resolve relative image srcs to forge raw URLs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlogPost {
pub slug: String,
pub title: String,
pub published_at: DateTime<Utc>,
pub markdown: String,
pub host: String,
pub repo: String,
pub branch: String,
}

View File

@@ -4,7 +4,6 @@ use clap::Parser;
use moments_core::{EventSource, run_poller};
use moments_data::{
PgStore,
blog::{BlogConfig, BlogSource},
bugzilla::{BugzillaConfig, BugzillaSource},
gitea::{GiteaConfig, GiteaSource},
github::{GithubConfig, GithubSource},
@@ -103,19 +102,6 @@ struct Args {
/// Seconds between bugzilla creator-query polls (defaults to 24h).
#[arg(long, env = "BUGZILLA_POLL_INTERVAL_SECS", default_value = "86400")]
bugzilla_interval_secs: u64,
/// Gitea repo holding blog posts (markdown + frontmatter at the repo
/// root, on `GITEA_HOST`). Empty string disables blog ingestion.
#[arg(long, env = "BLOG_REPO", default_value = "grenade/blog")]
blog_repo: String,
#[arg(long, env = "BLOG_BRANCH", default_value = "main")]
blog_branch: String,
/// Seconds between blog repo polls (cheap: one branch-tip request when
/// nothing changed).
#[arg(long, env = "BLOG_POLL_INTERVAL_SECS", default_value = "600")]
blog_interval_secs: u64,
}
#[tokio::main]
@@ -126,7 +112,9 @@ async fn main() -> anyhow::Result<()> {
let store = Arc::new(PgStore::connect(&args.database_url).await?);
store.migrate().await?;
let http = Client::builder().timeout(Duration::from_secs(30)).build()?;
let http = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let github = Arc::new(GithubSource::new(
http.clone(),
@@ -197,20 +185,6 @@ async fn main() -> anyhow::Result<()> {
},
)) as Arc<dyn EventSource>;
let blog = (!args.blog_repo.is_empty()).then(|| {
Arc::new(BlogSource::new(
http.clone(),
store.clone(),
store.clone(),
BlogConfig {
host: args.gitea_host.clone(),
repo: args.blog_repo.clone(),
branch: args.blog_branch.clone(),
token: args.gitea_token.clone(),
},
)) as Arc<dyn EventSource>
});
info!(
github_user = args.github_user,
gitea_host = args.gitea_host,
@@ -227,9 +201,6 @@ async fn main() -> anyhow::Result<()> {
gitea_interval_secs = args.gitea_interval_secs,
hg_interval_secs = args.hg_interval_secs,
bugzilla_interval_secs = args.bugzilla_interval_secs,
blog_repo = args.blog_repo,
blog_branch = args.blog_branch,
blog_interval_secs = args.blog_interval_secs,
"worker started"
);
@@ -239,7 +210,6 @@ async fn main() -> anyhow::Result<()> {
let gitea_interval = Duration::from_secs(args.gitea_interval_secs);
let hg_interval = Duration::from_secs(args.hg_interval_secs);
let bugzilla_interval = Duration::from_secs(args.bugzilla_interval_secs);
let blog_interval = Duration::from_secs(args.blog_interval_secs);
let github_task = tokio::spawn(async move { run_poller(github, interval).await });
let github_search_task =
@@ -248,9 +218,8 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(async move { run_poller(github_repo, repo_interval).await });
let gitea_task = tokio::spawn(async move { run_poller(gitea, gitea_interval).await });
let hg_task = tokio::spawn(async move { run_poller(hg, hg_interval).await });
let bugzilla_task = tokio::spawn(async move { run_poller(bugzilla, bugzilla_interval).await });
let blog_task =
blog.map(|src| tokio::spawn(async move { run_poller(src, blog_interval).await }));
let bugzilla_task =
tokio::spawn(async move { run_poller(bugzilla, bugzilla_interval).await });
tokio::signal::ctrl_c().await?;
info!("shutdown signal received");
@@ -260,9 +229,6 @@ async fn main() -> anyhow::Result<()> {
gitea_task.abort();
hg_task.abort();
bugzilla_task.abort();
if let Some(task) = blog_task {
task.abort();
}
Ok(())
}

127
readme.md
View File

@@ -1,87 +1,42 @@
# moments
personal activity timeline and portfolio site. polls public sources (github, gitea, mercurial, bugzilla), stores raw payloads in postgres, and serves a dashboard + project detail views to a react frontend.
Personal activity timeline. Polls public sources (GitHub, Gitea, Mercurial, Bugzilla), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
successor to the now-defunct [grenade-events-react](https://github.com/grenade/grenade-events-react), which depended on mongodb stitch (retired by mongodb in september 2022).
Successor to the now-defunct [grenade-events-react](https://github.com/grenade/grenade-events-react), which depended on MongoDB Stitch (retired by MongoDB in September 2022).
## layout
## Layout
```
crates/
moments-entities/ # types and dtos (event, source, project/daily summaries)
moments-core/ # ingestion traits, presentation reshape, poller loop
moments-data/ # postgres adapter, migrations, all event-source impls
moments-api/ # axum read-only http api + forge proxy + og image (binary)
moments-entities/ # types and DTOs
moments-core/ # ingestion + reshape logic
moments-data/ # postgres adapter + migrations
moments-api/ # axum read-only HTTP API (binary)
moments-worker/ # ingestion daemon (binary)
ui/ # vite + react + swc + typescript frontend
ui/ # vite + react + swc + ts frontend
asset/ # systemd, nginx, firewalld, manifest.yml
script/
deploy.sh # manifest-driven deploy to prod
hg-ingest.sh # one-shot local hg clone + psql ingest
certify.sh # letsencrypt cert management
teardown.sh # service removal
db-perms.sh # postgres role + ident setup
script/deploy.sh
```
architectural conventions follow [grenade/architecture/generic.md](https://git.lair.cafe/grenade/architecture/src/branch/main/generic.md).
Architectural conventions follow [grenade/architecture/generic.md](https://git.lair.cafe/grenade/architecture/src/branch/main/generic.md).
## data sources
| source | impl | endpoint | notes |
|--------|------|----------|-------|
| github events | `github.rs` | `/users/{user}/events` | last 90 days, etag-optimised polling |
| github search | `github_search.rs` | `/search/commits` + `/search/issues` | historical backfill, 1000-result cap |
| github repo | `github_repo.rs` | `/user/repos` + `/repos/{o}/{r}/commits` | full commit history, no cap, weekly poll |
| gitea | `gitea.rs` | user + org activity feeds | auto-discovers orgs, filters by user |
| mercurial | `hg.rs` | `json-log?rev=author()` | revset-based, one-shot backfill then skip |
| bugzilla | `bugzilla.rs` | `/rest/bug?creator=` | mozilla bugzilla |
hg repos are archived (mozilla retired hg). the worker skips hg after the first successful scan. for bulk ingestion, `script/hg-ingest.sh` clones repos locally and inserts via psql, avoiding rate limits on hg-edge.mozilla.org.
## frontend routes
| path | page | description |
|------|------|-------------|
| `/` or `/dash` | dashboard | contribution graphs (daily + all-time weekly) + ranked project cards with forge icons and language info |
| `/activity` | timeline | filterable activity feed with source toggles, date range slider, and event limit |
| `/activity/:timespan` | timeline | pre-filtered by date (`YYYY-MM-DD`) or range (`YYYY-MM-DD..YYYY-MM-DD`) |
| `/project/:source/*` | project detail | repo readme, language breakdown bar, per-repo activity timeline |
| `/cv` | resume | loaded from github gist, markdown-rendered |
shared layout provides nav header (dash, activity, cv + external links) and footer across all routes.
## api endpoints
| method | path | description |
|--------|------|-------------|
| GET | `/v1/healthz` | liveness probe |
| GET | `/v1/events?from=&to=&source=&repo=&limit=` | reshaped timeline items |
| GET | `/v1/sources` | per-source summary (count, earliest, latest) |
| GET | `/v1/projects` | per-repo aggregated stats (commits, issues, prs, date range) |
| GET | `/v1/activity/daily?from=&to=` | per-day event counts for contribution graphs |
| GET | `/v1/forge/{source}/*?host=` | proxy to github/gitea apis (avoids cors) |
| GET | `/v1/og/contributions.png` | server-rendered contribution graph as png (resvg) |
the og image endpoint renders the all-time weekly contribution graph as svg, rasterizes to png via resvg, and serves it with a 1-hour cache. used as the `og:image` meta tag for social media previews.
## local development
## Local development
```sh
cargo build --workspace
cargo run -p moments-api # serves on 127.0.0.1:8080
cargo run -p moments-worker # starts all pollers
cd ui && npm install && npm run dev # vite dev server on :5173
cargo run -p moments-worker # one-shot ingest tick (until --interval is wired up)
```
the api expects a postgres reachable at `DATABASE_URL`. in production this is an mtls connection using the host cert. for local dev against a throwaway database:
The API expects a Postgres reachable at `DATABASE_URL`. In production this is an mTLS connection using the host cert. For local dev against a throwaway database:
```sh
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
```
migrations live in `crates/moments-data/migrations/` and run automatically on worker startup. the api connects as `moments_ro` and never runs migrations — the worker (as `moments_rw`) is the schema owner.
Migrations live in `crates/moments-data/migrations/` and run automatically on worker startup. The API connects as `moments_ro` and never runs migrations — the worker (as `moments_rw`) is the schema owner.
## deployment
## Deployment
```sh
./script/deploy.sh <env> all # api + worker + web
@@ -90,47 +45,25 @@ migrations live in `crates/moments-data/migrations/` and run automatically on wo
./script/deploy.sh <env> all --dry-run
```
concrete hosts, ports, and the site's `server_name` live in `asset/manifest.yml`. the shape of the deployment:
Concrete hosts, ports, and the site's `server_name` live in `asset/manifest.yml`. The shape of the deployment:
| component | notes |
|-----------|-------|
| api | binds the port from `api.config.bind`; firewalld service `moments-api` |
| worker | no listening port; pollers only |
| web | per-site nginx ingress; `/api/*` reverse-proxies to the api host |
| db | postgres mtls, passwordless |
| Component | Notes |
| --------- | ---------------------------------------------------------------------- |
| api | binds the port from `api.config.bind`; firewalld service `moments-api` |
| worker | no listening port; pollers only |
| web | per-site nginx ingress; `/api/*` reverse-proxies to the api host |
| db | postgres mTLS, passwordless |
postgres roles `moments_rw` and `moments_ro` must exist on the primary, with `pg_ident.conf.d/<host>.conf` mapping the api host's fqdn to `moments_ro` and the worker host's fqdn to `moments_rw`. see `asset/sql/bootstrap-moments.sql`, `asset/postgres/ident.conf.tmpl`, and `script/db-perms.sh`.
Postgres roles `moments_rw` and `moments_ro` must exist on the primary, with `pg_ident.conf.d/<host>.conf` mapping the api host's FQDN → `moments_ro` and the worker host's FQDN → `moments_rw`. See `asset/sql/bootstrap-moments.sql`, `asset/postgres/ident.conf.tmpl`, and `script/db-perms.sh` (idempotently adds the cert_cn lines on the postgres primary + standby and reloads postgres).
secrets are resolved at deploy time via `pass`. the mapping of env-var name to pass-store path lives under `worker.secrets` in `manifest.yml`; `deploy.sh` iterates the map, fetches each secret, and substitutes the matching `{{NAME}}` placeholder in `worker.env.tmpl`.
Inter-host traffic over the WG mesh: web's nginx connects to the api host in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to `https://`.
## environment variables
Secrets are resolved at deploy time via `pass`. The mapping of env-var name → pass-store path lives under `worker.secrets` in `manifest.yml`; `deploy.sh` iterates the map, fetches each secret, and substitutes the matching `{{NAME}}` placeholder in `worker.env.tmpl`. To add a secret: add a `worker.secrets` entry, add `NAME={{NAME}}` to `worker.env.tmpl`, and ensure `pass show <path>` returns the value on the deploying machine.
### worker
### First-time setup
| variable | default | description |
|----------|---------|-------------|
| `DATABASE_URL` | required | postgres connection string |
| `GITHUB_USER` | `grenade` | github username |
| `GITHUB_TOKEN` | optional | github pat for higher rate limits + private events |
| `POLL_INTERVAL_SECS` | `600` | github events api poll interval |
| `SEARCH_POLL_INTERVAL_SECS` | `86400` | github search backfill interval |
| `REPO_POLL_INTERVAL_SECS` | `604800` | github per-repo commit enumeration (weekly) |
| `GITEA_HOST` | `git.lair.cafe` | gitea instance hostname |
| `GITEA_USER` | `grenade` | gitea username |
| `GITEA_TOKEN` | optional | gitea token for org discovery |
| `GITEA_POLL_INTERVAL_SECS` | `600` | gitea activity feed poll interval |
| `HG_HOST` | `hg-edge.mozilla.org` | mercurial host |
| `HG_GROUPS` | `build,integration` | hg repo groups to discover |
| `HG_REPOS` | `mozilla-central` | individual hg repos |
| `HG_AUTHOR_TERMS` | `rthijssen,grenade` | author substrings for revset queries |
| `HG_POLL_INTERVAL_SECS` | `86400` | hg poll interval (skips after first scan) |
| `BUGZILLA_HOST` | `bugzilla.mozilla.org` | bugzilla instance |
| `BUGZILLA_EMAIL` | `rthijssen@mozilla.com` | bugzilla creator email filter |
| `BUGZILLA_POLL_INTERVAL_SECS` | `86400` | bugzilla poll interval |
After the first successful prod deploy:
### api
| variable | default | description |
|----------|---------|-------------|
| `DATABASE_URL` | required | postgres connection string (read-only role) |
| `BIND_ADDR` | `127.0.0.1:8080` | api listen address |
1. Point public DNS for the site at the web host's public IP (unproxied).
2. Confirm `curl --fail --silent --show-error https://<site>/api/v1/healthz` returns `ok`.
3. If migrating from a predecessor, archive the old repo with a pointer to this one.

View File

@@ -1,14 +1,8 @@
#!/usr/bin/env bash
#
# Idempotently maintain the moments cert_cn → role mappings in an app-owned
# pg_ident.conf.d/moments.conf on the moments postgres primary and standby,
# then reload postgres so the changes take effect. Re-running is a no-op
# (no duplicate lines, no spurious reload).
#
# The file is named for the app, not the cert_cn host, so provisioning for
# other apps (which may own files for the same hosts) can never clobber or
# interleave with moments access. Any moments_* lines found in legacy
# host-named files are migrated here and removed from the legacy file.
# Idempotently add cert_cn → role mappings to pg_ident.conf.d on the moments
# postgres primary and standby, then reload postgres so the changes take
# effect. Re-running is a no-op (no duplicate lines, no spurious reload).
#
# Run from a workstation with ssh access to both pg hosts. This script ssh's
# out; do NOT run it on magrathea/frankie directly.
@@ -20,63 +14,32 @@ worker_host=frootmig.kosherinata.internal
pg_hosts=(
magrathea.kosherinata.internal
frankie.hanzalova.internal
frankie.kosherinata.internal
)
# Each (cert_cn host, role) pair becomes one cert_cn line in
# pg_ident.conf.d/moments.conf on every pg host listed above.
# pg_ident.conf.d/<cert_cn host>.conf on every pg host listed above.
mapping_pairs=(
"$api_host" moments_ro
"$worker_host" moments_rw
)
ident_dir=/var/lib/pgsql/18/data/pg_ident.conf.d
app_file=moments.conf
failed=0
for pg_host in "${pg_hosts[@]}"; do
printf '==> %s\n' "$pg_host"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$pg_host" \
"sudo bash -s -- ${ident_dir@Q} ${app_file@Q} ${mapping_pairs[@]@Q}" <<'REMOTE_EOF'
ssh -o BatchMode=yes "$pg_host" "sudo bash -s -- ${ident_dir@Q} ${mapping_pairs[@]@Q}" <<'REMOTE_EOF'
set -euo pipefail
ident_dir="$1"; shift
file="${ident_dir}/$1"; shift
changed=0
# Migrate any moments_* lines out of legacy host-named files into the
# app-owned file, so other apps' provisioning (which may own files for the
# same hosts) can never clobber or drop moments access. Lines are preserved
# verbatim (this covers mappings beyond mapping_pairs, e.g. a workstation's).
# A legacy file left empty is removed.
moments_re='^cert_cn[[:space:]]+[^[:space:]]+[[:space:]]+moments_(ro|rw)[[:space:]]*$'
for legacy in "$ident_dir"/*.conf; do
[[ "$legacy" == "$file" ]] && continue
grep --extended-regexp --quiet "$moments_re" "$legacy" || continue
while read -r line; do
if [[ -f "$file" ]] && grep --fixed-strings --line-regexp --quiet -- "$line" "$file"; then
printf ' already in %s, dropping from %s: %s\n' "${file##*/}" "${legacy##*/}" "$line"
else
printf '%s\n' "$line" | sudo -u postgres tee --append "$file" >/dev/null
printf ' migrated from %s: %s\n' "${legacy##*/}" "$line"
fi
done < <(grep --extended-regexp "$moments_re" "$legacy")
remaining="$(grep --extended-regexp --invert-match "$moments_re" "$legacy" || true)"
if [[ -n "$remaining" ]]; then
printf '%s\n' "$remaining" | sudo -u postgres tee "$legacy" >/dev/null
else
rm "$legacy"
printf ' removed empty legacy file %s\n' "${legacy##*/}"
fi
changed=1
done
while [[ $# -gt 0 ]]; do
cert_cn_host="$1"
role="$2"
shift 2
line="cert_cn ${cert_cn_host} ${role}"
file="${ident_dir}/${cert_cn_host}.conf"
# The heredoc runs as root via sudo bash, so [[ -f ]] and grep are fine
# without dropping privs. tee --append runs as postgres so a newly-created
@@ -97,9 +60,4 @@ else
echo " no changes; reload skipped"
fi
REMOTE_EOF
then
echo " WARNING: ${pg_host} unreachable or failed — re-run when it is back"
failed=1
fi
done
exit "$failed"

View File

@@ -48,31 +48,6 @@ ssh_run() {
fi
}
# Ensure /tmp on the remote is world-writable + sticky (mode 1777). Some
# hosts in this fleet have had /tmp reset to root-owned 0755 by an
# unrelated configuration step, which silently breaks the rsync of the
# deploy stage dir under our unprivileged user. Check the mode first so a
# correctly-configured host doesn't incur a needless sudo call.
ensure_tmp_writable() {
local host="$1"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m ssh %s -- stat /tmp; chmod 1777 if needed\n' "$host" >&2
return 0
fi
local mode
mode="$(ssh -o BatchMode=yes "$host" 'stat -c %a /tmp')" || {
warn "could not stat /tmp on $host"
return 1
}
if [[ "$mode" != "1777" ]]; then
warn "/tmp on $host is mode $mode; fixing to 1777"
ssh -o BatchMode=yes "$host" 'sudo chmod 1777 /tmp' || {
warn "failed to chmod /tmp on $host"
return 1
}
fi
}
[[ $# -ge 1 ]] || usage
environment="$1"; shift
components=()
@@ -85,24 +60,10 @@ while [[ $# -gt 0 ]]; do
done
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
command -v podman >/dev/null 2>&1 || die "podman is required (used for the deploy build container)"
# Rust binaries are built inside a Debian container so the resulting ELF
# links against an older glibc than this workstation's. Building natively
# on f44 (glibc 2.43) produces binaries that won't load on f42 / f43
# servers — the dynamic loader refuses them outright. Debian bookworm's
# glibc 2.36 is older than every Fedora release we deploy to, so its
# binaries are forward-compatible.
#
# The artifacts land in target/deploy/release/ so a native `cargo build`
# in this checkout (for tests, clippy, dev runs) doesn't compete with
# the container for incremental state, and vice-versa.
rust_build_image="docker.io/library/rust:1-bookworm"
rust_target_dir="${repo_root}/target/deploy"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
# Resolve component list ----------------------------------------------------
@@ -132,20 +93,8 @@ for c in "${components[@]}"; do
done
if (( needs_rust )); then
log "cargo build --release in ${rust_build_image} (api, worker)"
install --directory "$rust_target_dir"
# Named volumes cache the cargo registry and git index across runs so
# subsequent builds don't re-fetch every crate. CARGO_TARGET_DIR
# redirects build output into the host-mounted target/deploy.
# :Z relabels the bind mount for SELinux on Fedora hosts.
run podman run --rm \
--volume "${repo_root}:/workspace:Z" \
--volume moments-deploy-cargo-registry:/usr/local/cargo/registry \
--volume moments-deploy-cargo-git:/usr/local/cargo/git \
--workdir /workspace \
--env CARGO_TARGET_DIR=/workspace/target/deploy \
"$rust_build_image" \
cargo build --release --bin moments-api --bin moments-worker
log "cargo build --release (api, worker)"
run cargo build --release --bin moments-api --bin moments-worker --manifest-path "${repo_root}/Cargo.toml"
fi
if (( needs_web )); then
@@ -207,7 +156,7 @@ deploy_api() {
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${rust_target_dir}/release/moments-api" "$stage/usr/local/bin/moments-api"
install --mode=0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
chmod 0640 "$stage/etc/moments/api.env"
@@ -217,8 +166,6 @@ deploy_api() {
# live system dirs.
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
ensure_tmp_writable "$host" || return 1
rsync \
--archive \
--hard-links \
@@ -363,7 +310,7 @@ deploy_worker() {
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${rust_target_dir}/release/moments-worker" "$stage/usr/local/bin/moments-worker"
install --mode=0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
chmod 0640 "$stage/etc/moments/worker.env"
@@ -371,8 +318,6 @@ deploy_worker() {
# path via the heredoc. Never rsync into /.
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
ensure_tmp_writable "$host" || return 1
rsync \
--archive \
--hard-links \

View File

@@ -1,132 +0,0 @@
#!/usr/bin/env bash
#
# One-time setup for the gitea_ci deploy-user on every host that
# .gitea/workflows/{deploy,refresh}.yml targets:
# - create the gitea_ci system user (if missing)
# - install the runner's pubkey into ~gitea_ci/.ssh/authorized_keys
# - add gitea_ci to the systemd-journal group (so the workflow can capture
# `journalctl -u moments-*.service` without a sudoers entry)
# - install the host-appropriate /etc/sudoers.d/moments_*_gitea_ci drop-in,
# verified with `visudo -cf` so a typo can't lock the host out
# - check the postgres mTLS host cert the api/worker need already exists
#
# Run this from a workstation with ssh + sudo access to the hosts, once per
# host, before the deploy workflow can succeed. Idempotent — safe to re-run.
# Application config is NOT shipped here: the workflow renders /etc/moments/*.env
# from Gitea secrets on every deploy.
#
# Never suppresses errors; hosts that fail to provision are reported and skipped
# so one offline host doesn't block the rest.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_path="$(cd "${script_dir}/.." && pwd)"
# host -> sudoers drop-in (the source of infra truth, mirroring the workflow env)
api_host="nikola.kosherinata.internal"
worker_host="frootmig.kosherinata.internal"
web_host="oolon.kosherinata.internal"
api_sudoers="${repo_path}/asset/sudoers.d/api-host.conf"
worker_sudoers="${repo_path}/asset/sudoers.d/worker-host.conf"
web_sudoers="${repo_path}/asset/sudoers.d/web-host.conf"
pubkey="${HOME}/.ssh/id_gitea_ci.pub"
if [[ ! -f "${pubkey}" ]]; then
echo "fatal: ${pubkey} not found" >&2
echo " generate with: ssh-keygen -t ed25519 -f ${pubkey%.pub} -C gitea_ci" >&2
echo " then add the matching private key as the RSYNC_SSH_KEY Gitea secret" >&2
exit 1
fi
# Create gitea_ci, install its authorized_keys, and grant journal read access.
provision_user() {
local host="$1"
echo "==> ${host}: provisioning gitea_ci"
if ! ssh "${host}" '
set -eu
if id -u gitea_ci >/dev/null 2>&1; then
echo " gitea_ci user already present"
else
sudo useradd --system --create-home \
--home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
echo " gitea_ci user created"
fi
# `install -o` does its own fresh user lookup, avoiding the brief NSS
# cache lag that makes `sudo -u gitea_ci` fail right after useradd.
sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
sudo usermod -aG systemd-journal gitea_ci
'; then
echo " failed to provision gitea_ci — skipping ${host}"
return 1
fi
if rsync --archive --compress \
--chown gitea_ci:gitea_ci --chmod 0600 \
--rsync-path 'sudo rsync' \
"${pubkey}" \
"${host}:/var/lib/gitea_ci/.ssh/authorized_keys"; then
echo " authorized_keys synced"
else
echo " failed to sync authorized_keys to ${host}"
return 1
fi
}
# Install the sudoers drop-in and verify it parses, so a typo can't lock out.
install_sudoers() {
local host="$1" template="$2" name="$3"
local dest="/etc/sudoers.d/${name}"
echo "==> ${host}: installing ${dest}"
if ! rsync --archive --compress \
--chown root:root --chmod 0440 \
--rsync-path 'sudo rsync' \
"${template}" \
"${host}:${dest}"; then
echo " failed to sync ${template##*/}"
return 1
fi
if ssh "${host}" "sudo visudo -cf ${dest}" >/dev/null; then
echo " installed and verified"
else
echo " WARNING: visudo rejected the installed file — review on ${host}"
return 1
fi
}
# The api/worker connect to postgres with mTLS using the host's own cert. The
# workflow only ACLs it to the moments user; it must already exist (provisioned
# by the host's PKI/step convention — see architecture/internal-tls.md).
check_pg_cert() {
local host="$1"
echo "==> ${host}: checking postgres mTLS host cert"
if ssh "${host}" '
fqdn="$(hostname -f)"
test -f "/etc/pki/tls/private/${fqdn}.pem" && test -f "/etc/pki/tls/misc/${fqdn}.pem"
'; then
echo " host cert present"
else
echo " WARNING: /etc/pki/tls/{private,misc}/<fqdn>.pem missing on ${host}"
echo " the moments-{api,worker} service will fail to reach postgres until it exists"
fi
}
setup_host() {
local host="$1" sudoers="$2" name="$3"
provision_user "${host}" && install_sudoers "${host}" "${sudoers}" "${name}" \
|| { echo " ${host}: setup incomplete"; return 1; }
}
setup_host "${api_host}" "${api_sudoers}" moments_api_gitea_ci
check_pg_cert "${api_host}"
setup_host "${worker_host}" "${worker_sudoers}" moments_worker_gitea_ci
check_pg_cert "${worker_host}"
setup_host "${web_host}" "${web_sudoers}" moments_web_gitea_ci
echo "==> done."
echo " Gitea repo secrets to set (Settings -> Actions -> Secrets):"
echo " RSYNC_SSH_KEY private key matching ${pubkey}"
echo " QUERY_GITHUB_TOKEN github api token for the worker poller"
echo " QUERY_GITEA_TOKEN git.lair.cafe api token for the worker poller"
echo " (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names — hence QUERY_.)"

View File

@@ -1,74 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rob.tn</title>
<meta
name="description"
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
/>
<meta
property="og:title"
content="rob thijssen: developer activity and contribution history"
/>
<meta
property="og:description"
content="a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012."
/>
<meta
property="og:image"
content="https://rob.tn/api/v1/og/contributions.png"
width="1200"
height="630"
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://rob.tn/" />
<meta property="og:site_name" content="rob.tn" />
<meta property="og:locale" content="en_US" />
<meta property="og:logo" content="https://rob.tn/icon-512.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:image"
content="https://rob.tn/api/v1/og/contributions.png"
width="1200"
height="630"
/>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32.png"
/>
<link
rel="icon"
type="image/png"
sizes="48x48"
href="/favicon-48.png"
/>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/icon-192.png"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/icon-512.png"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rob.tn</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -5,8 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && VITE_API_BASE= vite build && pnpm run prerender",
"prerender": "VITE_API_BASE=\"${VITE_API_BASE:-https://rob.tn/api/v1}\" vite build --ssr src/entry-server.tsx --outDir dist-server --emptyOutDir && node run-prerender.mjs",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
@@ -20,13 +19,9 @@
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^7.14.2",
"react-vertical-timeline-component": "^3.6.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
"react-vertical-timeline-component": "^3.6.0"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-vertical-timeline-component": "^3.3.6",

354
ui/pnpm-lock.yaml generated
View File

@@ -38,19 +38,7 @@ importers:
react-vertical-timeline-component:
specifier: ^3.6.0
version: 3.6.0(react@19.2.5)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
devDependencies:
'@types/node':
specifier: ^25.9.3
version: 25.9.3
'@types/react':
specifier: ^19.0.0
version: 19.2.14
@@ -62,13 +50,13 @@ importers:
version: 3.3.6
'@vitejs/plugin-react-swc':
specifier: ^3.7.2
version: 3.11.0(@swc/helpers@0.5.21)(vite@6.4.2(@types/node@25.9.3))
version: 3.11.0(@swc/helpers@0.5.21)(vite@6.4.2)
typescript:
specifier: ~5.7.0
version: 5.7.3
vite:
specifier: ^6.0.0
version: 6.4.2(@types/node@25.9.3)
version: 6.4.2
packages:
@@ -535,9 +523,6 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -640,19 +625,11 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -673,36 +650,15 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -735,33 +691,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
mdast-util-from-markdown@2.0.3:
resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
mdast-util-gfm-autolink-literal@2.0.1:
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
mdast-util-gfm-footnote@2.1.0:
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
mdast-util-gfm-strikethrough@2.0.0:
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
mdast-util-gfm-table@2.0.0:
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
mdast-util-gfm-task-list-item@2.0.0:
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -786,27 +718,6 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-gfm-autolink-literal@2.1.0:
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
micromark-extension-gfm-footnote@2.1.0:
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
micromark-extension-gfm-strikethrough@2.1.0:
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
micromark-extension-gfm-table@2.1.1:
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
micromark-extension-gfm-tagfilter@2.0.0:
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
micromark-extension-gfm-task-list-item@2.1.0:
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -882,9 +793,6 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1001,24 +909,12 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
rollup@4.60.2:
resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -1074,9 +970,6 @@ packages:
peerDependencies:
react: '>=16.14.0'
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -1100,9 +993,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -1152,9 +1042,6 @@ packages:
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -1458,10 +1345,6 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
'@types/prop-types@15.7.15': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)':
@@ -1488,11 +1371,11 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react-swc@3.11.0(@swc/helpers@0.5.21)(vite@6.4.2(@types/node@25.9.3))':
'@vitejs/plugin-react-swc@3.11.0(@swc/helpers@0.5.21)(vite@6.4.2)':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.27
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
vite: 6.4.2(@types/node@25.9.3)
vite: 6.4.2
transitivePeerDependencies:
- '@swc/helpers'
@@ -1545,8 +1428,6 @@ snapshots:
'@babel/runtime': 7.29.2
csstype: 3.2.3
entities@6.0.1: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -1576,8 +1457,6 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
escape-string-regexp@5.0.0: {}
estree-util-is-identifier-name@3.0.0: {}
extend@3.0.2: {}
@@ -1589,43 +1468,6 @@ snapshots:
fsevents@2.3.3:
optional: true
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.1.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -1646,32 +1488,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
inline-style-parser@0.2.7: {}
invariant@2.2.4:
@@ -1699,15 +1521,6 @@ snapshots:
dependencies:
js-tokens: 4.0.0
markdown-table@3.0.4: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
escape-string-regexp: 5.0.0
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.3:
dependencies:
'@types/mdast': 4.0.4
@@ -1725,63 +1538,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
'@types/mdast': 4.0.4
ccount: 2.0.1
devlop: 1.1.0
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
dependencies:
mdast-util-from-markdown: 2.0.3
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -1873,64 +1629,6 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-autolink-literal@2.1.0:
dependencies:
micromark-util-character: 2.1.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-footnote@2.1.0:
dependencies:
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-normalize-identifier: 2.0.1
micromark-util-sanitize-uri: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-strikethrough@2.1.0:
dependencies:
devlop: 1.1.0
micromark-util-chunked: 2.0.1
micromark-util-classify-character: 2.0.1
micromark-util-resolve-all: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-table@2.1.1:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm-tagfilter@2.0.0:
dependencies:
micromark-util-types: 2.0.2
micromark-extension-gfm-task-list-item@2.1.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-gfm@3.0.0:
dependencies:
micromark-extension-gfm-autolink-literal: 2.1.0
micromark-extension-gfm-footnote: 2.1.0
micromark-extension-gfm-strikethrough: 2.1.0
micromark-extension-gfm-table: 2.1.1
micromark-extension-gfm-tagfilter: 2.0.0
micromark-extension-gfm-task-list-item: 2.1.0
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -2061,10 +1759,6 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse5@7.3.0:
dependencies:
entities: 6.0.1
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -2219,28 +1913,6 @@ snapshots:
react@19.2.5: {}
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -2258,12 +1930,6 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remark-stringify@11.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
rollup@4.60.2:
dependencies:
'@types/estree': 1.0.8
@@ -2341,8 +2007,6 @@ snapshots:
dependencies:
react: 19.2.5
undici-types@7.24.6: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -2380,11 +2044,6 @@ snapshots:
dependencies:
react: 19.2.5
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -2395,7 +2054,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@6.4.2(@types/node@25.9.3):
vite@6.4.2:
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
@@ -2404,13 +2063,10 @@ snapshots:
rollup: 4.60.2
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.9.3
fsevents: 2.3.3
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
web-namespaces@2.0.1: {}
zwitch@2.0.4: {}

View File

@@ -1,10 +0,0 @@
# pnpm 10+ no longer reads build-script settings from package.json's "pnpm"
# field — they live here. esbuild and @swc/core need their postinstall to place
# native binaries for vite; allow those, and explicitly ignore the harmless
# react-vertical-timeline-component postinstall, so a fresh CI install doesn't
# fail with ERR_PNPM_IGNORED_BUILDS.
onlyBuiltDependencies:
- '@swc/core'
- esbuild
ignoredBuiltDependencies:
- react-vertical-timeline-component

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-label="Gitea"
role="img"
viewBox="0 0 512 512"
>
<rect rx="15%" height="512" width="512" fill="#ffffff" />
<path
d="M419 150c-98 7-186 2-276-1-27 0-63 19-61 67 3 75 71 82 99 83 3 14 35 62 59 65h104c63-5 109-213 75-214zm-311 67c-3-21 7-42 42-42 3 39 10 61 22 96-32-5-59-15-64-54z"
fill="#592"
/>
<path d="m293 152v70" stroke="#ffffff" stroke-width="9" />
<g transform="rotate(25.7 496 -423)" stroke-width="7" fill="#592">
<path d="M561 246h97" stroke="#592" />
<rect x="561" y="246" width="97" height="97" rx="16" fill="#ffffff" />
<path d="M592 245v75" stroke="#592" />
<path d="M592 273c45 0 38-5 38 48" fill="none" stroke="#592" />
<circle cx="592" cy="320" r="10" />
<circle cx="630" cy="320" r="10" />
<circle cx="592" cy="273" r="10" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 951 B

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg
fill="#ffffff"
width="800px"
height="800px"
viewBox="0 0 24 24"
role="img"
xmlns="http://www.w3.org/2000/svg"
>
<title>github</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
width="800px"
height="800px"
viewBox="-10 -5 1034 1034"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
>
<path
fill="#ffffff"
d="M411 237q-31 1 -64 10q-27 8 -54 22q-20 9 -38 20l-14 10q-50 33 -120 94q-67 57 -108 100q8 1 23 -5q12 -4 18 -5l99 -27l-32 26q-47 38 -69 59q-39 36 -52 62q11 -4 42 -19t46 -21q26 -11 40 -12l-18 16q-33 30 -49 46q-28 27 -38 44q1 1 1 4v2l10 -6q33 -20 52 -29
q32 -16 47 -18q-77 83 -89 125q19 -13 55 -32.5t55 -26.5q-3 8 -15 23q-9 12 -12.5 18.5t-15.5 21.5t-17 23q-8 14 -9 25q30 -29 73 -53q-4 10 -15 27q-10 15 -14 23q-7 13 -9 26q9 -10 27 -18q11 -5 33 -12l11 -4l-4 4q-27 21 -42 39l8 -1q2 -1 9 1l6 1l-32 13
q143 188 395 166l-13 -28q10 -18 19 -63t19 -66q16 -34 48 -51l25 -14q24 -14 37 -17q18 -4 49 2q8 1 32 7q35 9 52 12q30 6 46 2l10 -5q36 -22 58 -39q24 5 34.5 1.5t18.5 -16.5l5 -14q18 -50 28 -72l1 -15q-7 -21 -11 -75q-2 -32 -5 -36q-13 -18 -45 -34q-20 -10 -67 -27
q-54 -20 -79 -33q-43 -21 -66 -47q8 -25 -5 -51q-10 -19 -32 -40q-25 -18 -63 -16q-24 1 -64 12q-30 -6 -86 -24q-29 -9 -44 -14q-8 -1 -18 -1h-4zM410 285q22 0 64 12q27 7 41 9q-1 4 -6.5 8t-6.5 7l-4 6q-4 5 -5.5 8.5t0.5 7.5l32 -12q13 -4 38 -12q32 -11 48 -14
q28 -6 51 -3l24 31q-24 -20 -55 -15q-19 3 -56 20q-33 15 -49 18q-11 2 -33 8q-17 5 -25 7q29 22 75 17q3 15 18 34.5t29 22.5q-1 -6 -7 -20.5t-7 -22.5q-3 -14 4 -25l9 -6q17 -10 26 -13q16 -5 27 4q-10 1 -26 11l-8 5q-6 4 -8.5 14t-0.5 14q7 20 39 33q36 14 90 14
q-3 -4 -16 -18.5t-18 -21.5q-9 -11 -8 -17q30 30 81 55q30 14 89 35q35 12 49 18q22 10 31 19q1 5 -13 22q-7 9 -9 13q-4 7 -2 10q-3 7 7 22q5 8 22 29l3 4q-3 -25 -1.5 -36.5t6 -9.5t8.5 14.5t3 28.5q0 19 -7 37l-35 -8q-47 -3 -84 -15q-33 -9 -66 -28q-21 -12 -65 -42
l-30 -20q-27 -18 -79 -60l-14 -11l-50 -19q14 17 36 41q20 22 25 30q7 12 5 24.5t-16 36.5l-17 -31l-7 -55q-11 40 -13 66q-2 36 13 62q17 30 58 49l50 7q-22 -18 -30 -29q-12 -16 -8 -31q15 30 78 55q35 14 105 32l24 6q15 6 24 15q-7 6 -13 9q-4 2 -13 4.5t-15 4.5l-28 -9
q-36 -11 -51 -18q-26 -11 -36 -25q4 6 -27 14q-18 4 -63 13q-29 5 -37 7q-13 3 -3 4q-35 -1 -72 -23q-33 -19 -61 -50l-9 -3q0 17 11 35q6 11 22.5 32t22.5 31l38 17l-10 6q-16 10 -23 16q-13 10 -19 21l-2 6q-5 9 -6 14q-2 8 1 14q21 -23 61 -33l-16 25q2 18 -9 49
q-2 -5 -5 -10q-47 -20 -71 -39q6 20 25 44q16 21 37 38q-5 12 -12 23q-36 -25 -54 -42q5 9 11 27q9 24 15 34q-133 -10 -217 -82l60 -34q-10 2 -47 11l-30 8l-7 -8l24 -12q29 -14 42 -22q23 -14 29 -26v0l-6 2l-61 3q-4 -13 0 -27q2 -9 10.5 -24.5t9.5 -23.5q2 -13 -7 -25
q-35 -46 -49 -80q-20 -46 -15 -90l1 2q7 15 13 20l2 -17q2 -18 5 -26v1q6 20 11 29q8 16 21 24l1 -2q-14 -50 -2 -95q13 -51 56 -78q-6 2 -23 4q-26 4 -41 10q2 -1 5 -10q11 -29 23 -39q6 -4 15 -13q11 -10 19 -15q24 -19 43 -28q26 -14 59 -19q8 -1 16 -1h4zM425 332
q-28 0 -60 12l-13 10q-14 17 -19 26q-10 14 -12 29l3 4q13 -16 37 -28q0 18 9.5 33.5t25.5 24.5h3q-3 -19 -1 -39.5t9 -37.5q7 -6 23 -14q18 -10 25 -17q-14 -4 -30 -3zM625 391v0q8 0 15 3q-6 3 -8 11q-1 4 0 7q-11 -8 -11 -21h4zM462 392q2 7 14 22l9 11q-28 2 -60 11
q5 4 14.5 9t13.5 9l-16 6q-21 7 -31 11q-16 8 -28 19l5 2q31 -6 66 -5.5t67 7.5l3 -2l-25 -46l38 -8q-39 -37 -70 -46zM662 406q8 7 15 16q-11 3 -22 0q5 -3 7 -10v-6zM599 430q13 39 37 41q33 6 62 1q-12 -7 -39 -13q-22 -6 -32 -10q-17 -7 -28 -19zM368 492
q-23 17 -32.5 42t-5.5 53q2 7 1 17q-1 6 -4 17q-4 17 -3 25q0 13 8 22q22 25 63 55l-2 -8q-35 -41 -46 -74l17 6q36 13 55 16q-6 -10 -22 -29q-28 -34 -36 -53q-14 -32 -4 -62q0 -5 6 -12q7 -10 5 -15zM728 621q2 0 5 1q8 2 9 4q-3 2 -8.5 7t-8.5 6l1 -6q0 -9 2 -12z
M768 642v0q4 0 9 4h1l-13 13l1 -17h2zM798 653v0l10 6q-1 0 -6 7t-5.5 6.5t-0.5 -6.5q0 -13 2 -13zM829 667v0l9 2q-1 1 -4 7q-6 11 -11 11l4 -8v-9q0 -3 2 -3zM861 673l12 3l-13 22zM896 679q1 0 8 2l4 2l-15 11q-1 -9 -0.5 -12t3.5 -3zM933 685q5 0 7 2q0 5 -7 12
q-4 4 -5 7v-6q2 -10 0 -15h5z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,14 +0,0 @@
User-agent: *
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: LinkedInBot
Allow: /
User-agent: WhatsApp
Allow: /

View File

@@ -1,143 +0,0 @@
// Drives the SSR bundle (built by `vite build --ssr`) to bake one static HTML
// file per route into dist/. Reads the client build's dist/index.html as the
// template, then for each route injects: route <title>/description/og tags, a
// schema.org JSON-LD block, the server-rendered markup into #root, and the
// dehydrated react-query cache as window.__RQ_STATE__ for hydration.
//
// nginx serves these via `try_files $uri $uri/ /index.html`: /cv → cv/index.html,
// /blog/<slug> → blog/<slug>/index.html, etc., with unbaked routes (e.g.
// /activity/:timespan) SPA-falling-back to the home page and rendering client-side.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const distDir = join(here, 'dist');
const SITE_URL = 'https://rob.tn';
const { collectRoutes, renderRoute } = await import(
pathToFileURL(join(here, 'dist-server', 'entry-server.js')).href
);
const escapeText = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeAttr = (s) => escapeText(s).replace(/"/g, '&quot;');
// JS line/paragraph separators are valid in JSON strings but illegal in a
// <script>; written here as escape sequences so the source file stays ASCII.
const JS_LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g');
/** Safe for embedding JSON inside an inline <script>: neutralise `<` (so a
* `</script>` in the data can't close the tag) and the line separators. */
const serialize = (value) =>
JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(JS_LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16));
function replaceOrAppendHead(html, regex, tag) {
return regex.test(html)
? html.replace(regex, tag)
: html.replace('</head>', ` ${tag}\n </head>`);
}
function applyHead(html, head, url) {
let out = html.replace(
/<title>[\s\S]*?<\/title>/,
`<title>${escapeText(head.title)}</title>`,
);
const desc = escapeAttr(head.description);
out = replaceOrAppendHead(
out,
/<meta\s+name="description"[^>]*>/i,
`<meta name="description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:title"[^>]*>/i,
`<meta property="og:title" content="${escapeAttr(head.title)}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:description"[^>]*>/i,
`<meta property="og:description" content="${desc}" />`,
);
out = replaceOrAppendHead(
out,
/<meta\s+property="og:url"[^>]*>/i,
`<meta property="og:url" content="${escapeAttr(url)}" />`,
);
const ld = `<script type="application/ld+json">${serialize(head.jsonLd)}</script>`;
return out.replace('</head>', ` ${ld}\n </head>`);
}
function outPathFor(route) {
if (route === '/') return join(distDir, 'index.html');
return join(distDir, route.replace(/^\//, ''), 'index.html');
}
const template = await readFile(join(distDir, 'index.html'), 'utf8');
let routes = await collectRoutes();
// Guard: the dynamic routes (blog posts, projects) are enumerated from the
// moments API. If none came back, the API was unreachable at build time — fail
// loudly rather than bake and deploy an empty, content-free snapshot. (The CV,
// sourced from the public GitHub gist, can still succeed independently.)
const dynamicRouteCount = routes.filter(
(r) => r.startsWith('/project/') || r.startsWith('/blog/'),
).length;
if (dynamicRouteCount === 0) {
console.error(
'prerender: 0 dynamic routes enumerated — the moments API was unreachable ' +
'at build time. Aborting so an empty snapshot is never deployed. ' +
`(VITE_API_BASE=${process.env.VITE_API_BASE ?? '(unset)'})`,
);
process.exit(1);
}
// Optional filter args: `node run-prerender.mjs /activity /cv` bakes only the
// routes whose path starts with one of the given prefixes (useful for quick
// iteration). With no args, every route is baked.
const filters = process.argv.slice(2);
if (filters.length > 0) {
routes = routes.filter((r) => filters.some((f) => r === f || r.startsWith(f)));
}
let ok = 0;
let failed = 0;
for (const route of routes) {
const url = route === '/' ? `${SITE_URL}/` : `${SITE_URL}${route}`;
try {
const { html, state, head } = await renderRoute(route);
let page = applyHead(template, head, url);
page = page.replace(
'<div id="root"></div>',
`<div id="root">${html}</div>\n <script>window.__RQ_STATE__=${serialize(state)}</script>`,
);
const outPath = outPathFor(route);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, page);
ok += 1;
console.log(`prerendered ${route}`);
} catch (err) {
// Don't fail the whole build for one route — write the bare template so the
// route still resolves and hydrates client-side, and surface the error.
failed += 1;
console.error(`prerender FAILED for ${route}: ${err?.stack || err}`);
const outPath = outPathFor(route);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, template);
}
}
console.log(`prerender complete: ${ok} ok, ${failed} failed, ${routes.length} routes`);
// A handful of failed routes still ship a working client-rendered fallback, so
// don't block the deploy for those. Only fail the build if nothing prerendered
// at all (e.g. the API was unreachable), which signals a genuinely broken build.
if (ok === 0) {
console.error('prerender produced no pages — failing the build');
process.exitCode = 1;
}

View File

@@ -77,23 +77,6 @@ a.hot-pink {
opacity: 0.3;
}
.graph-label {
fill: #ecf0f1;
font-size: 9px;
opacity: 0.6;
}
.graph-cell {
cursor: pointer;
transition: opacity 0.15s;
}
.graph-cell:hover {
opacity: 0.8;
stroke: #ecf0f1;
stroke-width: 1;
}
.project-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -105,14 +88,6 @@ a.hot-pink {
font-size: 0.9rem;
}
.forge-icon {
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: -2px;
opacity: 0.7;
}
.project-card a {
color: #ff4081;
}
@@ -178,52 +153,6 @@ a.hot-pink {
max-width: 100%;
}
.blog-post img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.blog-post pre {
background: rgba(0, 0, 0, 0.2);
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
}
.blog-post h1,
.blog-post h2,
.blog-post h3,
.blog-post h4 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.blog-post h1 {
font-size: 1.4rem;
}
.blog-post h2 {
font-size: 1.25rem;
}
.blog-post h3 {
font-size: 1.1rem;
}
.blog-post h4 {
font-size: 1rem;
}
.blog-list-title {
font-size: 1.4rem;
}
.blog-date {
font-size: 85%;
opacity: 0.6;
}
.site-footer {
margin-top: 3rem;
padding: 1rem 0;

View File

@@ -1,16 +1,15 @@
import { Routes, Route } from 'react-router-dom';
// CSS imports live in the client entry (`main.tsx`), not here, so this module
// stays importable under Node during the prerender build (renderToString can't
// process CSS imports).
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import { Layout } from './components/Layout';
import { DashPage } from './pages/DashPage';
import { TimelineHome } from './pages/TimelineHome';
import { ProjectPage } from './pages/ProjectPage';
import { CvPage } from './pages/CvPage';
import { BlogIndexPage } from './pages/BlogIndexPage';
import { BlogPostPage } from './pages/BlogPostPage';
export default function App() {
return (
@@ -19,10 +18,7 @@ export default function App() {
<Route index element={<DashPage />} />
<Route path="/dash" element={<DashPage />} />
<Route path="/activity" element={<TimelineHome />} />
<Route path="/activity/:timespan" element={<TimelineHome />} />
<Route path="/project/:source/*" element={<ProjectPage />} />
<Route path="/blog" element={<BlogIndexPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route path="/cv" element={<CvPage />} />
</Route>
</Routes>

View File

@@ -2,7 +2,7 @@
// Hand-maintained for now; if drift becomes a problem, generate them
// from the Rust crate via ts-rs or specta.
export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla' | 'blog';
export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla';
export type TitleSegment =
| { kind: 'text'; text: string }
@@ -34,7 +34,6 @@ export type TimelineIcon =
| 'star'
| 'release'
| 'bug'
| 'post'
| 'generic';
export interface TimelineItem {
@@ -66,23 +65,6 @@ export interface ProjectSummary {
last_activity: string | null;
}
export interface DailyCount {
date: string;
count: number;
}
export interface HourlyAvg {
hour: number;
avg: number;
}
export interface LanguageDailyCount {
date: string;
language: string;
color: string | null;
commits: number;
}
export interface EventQuery {
from?: Date;
to?: Date;
@@ -91,10 +73,7 @@ export interface EventQuery {
limit?: number;
}
// Browser builds use a same-origin relative base (nginx proxies `/api/`).
// The prerender build runs under Node, which has no relative-URL origin, so it
// sets VITE_API_BASE to the absolute public API. See `vite-env.d.ts`.
const API_BASE = import.meta.env.VITE_API_BASE || '/api/v1';
const API_BASE = '/api/v1';
/** Decode base64 content as UTF-8 (atob only handles Latin-1). */
function decodeBase64Utf8(b64: string): string {
@@ -123,74 +102,12 @@ export async function fetchSources(): Promise<SourceSummary[]> {
return resp.json();
}
export async function fetchDailyCounts(from: string, to: string): Promise<DailyCount[]> {
const resp = await fetch(`${API_BASE}/activity/daily?from=${from}&to=${to}`);
if (!resp.ok) throw new Error(`daily-counts: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchHourlyAvgs(from: string, to: string, tz: string): Promise<HourlyAvg[]> {
const qs = new URLSearchParams({ from, to, tz });
const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`);
if (!resp.ok) throw new Error(`hourly-avgs: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchLanguageDailyCounts(from: string, to: string): Promise<LanguageDailyCount[]> {
const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`);
if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`);
return resp.json();
}
export interface RepoLanguageEntry {
source: Source;
repo: string;
language: string;
bytes: number;
color: string | null;
}
export async function fetchRepoLanguages(): Promise<RepoLanguageEntry[]> {
const resp = await fetch(`${API_BASE}/languages/repos`);
if (!resp.ok) throw new Error(`repo-languages: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchProjects(): Promise<ProjectSummary[]> {
const resp = await fetch(`${API_BASE}/projects`);
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
return resp.json();
}
export interface BlogPostSummary {
slug: string;
title: string;
published_at: string;
excerpt: string;
}
export interface BlogPost {
slug: string;
title: string;
published_at: string;
markdown: string;
host: string;
repo: string;
branch: string;
}
export async function fetchBlogPosts(): Promise<BlogPostSummary[]> {
const resp = await fetch(`${API_BASE}/blog`);
if (!resp.ok) throw new Error(`blog: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchBlogPost(slug: string): Promise<BlogPost> {
const resp = await fetch(`${API_BASE}/blog/${encodeURIComponent(slug)}`);
if (!resp.ok) throw new Error(`blog post: HTTP ${resp.status}`);
return resp.json();
}
/** Fetch repo README as raw markdown via the forge proxy. */
export async function fetchReadme(source: Source, host: string, repo: string): Promise<string | null> {
if (source === 'github') {
@@ -216,3 +133,12 @@ export async function fetchReadme(source: Source, host: string, repo: string): P
}
return null;
}
/** Fetch repo languages as { language: bytes } map via the forge proxy. */
export async function fetchLanguages(source: Source, host: string, repo: string): Promise<Record<string, number> | null> {
if (source !== 'github' && source !== 'gitea') return null;
const hostParam = source === 'gitea' ? `?host=${encodeURIComponent(host)}` : '';
const resp = await fetch(`${API_BASE}/forge/${source}/repos/${repo}/languages${hostParam}`);
if (!resp.ok) return null;
return resp.json();
}

View File

@@ -57,24 +57,13 @@ export async function fetchCv(): Promise<CvData> {
}
const gist = (await resp.json()) as GistResponse;
// Drop inlined `content` for non-text files (the photo, company logos, and
// pdf/docx/odt exports). The CV only ever references those by their raw gist
// URL — keeping the base64 here would bloat every render, and balloons the
// prerendered /cv page by ~1 MB of uncompressible data.
const files: Record<string, GistFile> = {};
for (const [name, file] of Object.entries(gist.files)) {
const isText =
file.type.startsWith('text/') || file.type === 'application/json';
files[name] = isText ? file : { ...file, content: '' };
}
const cfgFile = files[CONFIG_FILENAME];
const cfgFile = gist.files[CONFIG_FILENAME];
if (!cfgFile) {
throw new Error(`gist: missing ${CONFIG_FILENAME}`);
}
const config = JSON.parse(cfgFile.content) as CvConfig;
return { config, files };
return { config, files: gist.files };
}
// Pick out the gist files whose names start with the given prefix, applying

View File

@@ -1,464 +0,0 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
fetchDailyCounts,
fetchLanguageDailyCounts,
fetchProjects,
fetchSources,
} from "../api/client";
const CELL_SIZE = 12;
const GAP = 3;
const RADIUS = CELL_SIZE / 2;
const ROWS = 7;
const LEFT_LABEL_WIDTH = 28;
const TOP_LABEL_HEIGHT = 16;
const DAY_LABELS = ["", "mon", "", "wed", "", "fri", ""];
const MONTH_LABELS = [
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
];
const EMPTY_COLOR = "rgba(255,255,255,0.05)";
const FALLBACK_COLOR = "#39d353";
/** Daily contribution graph — last 1 year, one circle per day. */
export function ContributionGraph() {
const to = new Date();
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const dailyQ = useQuery({
queryKey: ["daily-counts", fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000,
});
const langQ = useQuery({
queryKey: ["language-daily", fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000,
});
const projectsQ = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
staleTime: 60_000,
});
const repoCount = useMemo(() => {
if (!projectsQ.data) return 0;
const fromMs = from.getTime();
const toMs = to.getTime();
return projectsQ.data.filter((p) => {
const first = p.first_activity
? new Date(p.first_activity).getTime()
: Infinity;
const last = p.last_activity ? new Date(p.last_activity).getTime() : 0;
return last >= fromMs && first <= toMs;
}).length;
}, [projectsQ.data]);
const navigate = useNavigate();
// Build map of date → dominant language color
const dayColorMap = useMemo(() => {
return buildDominantColorMap(langQ.data ?? []);
}, [langQ.data]);
const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? [];
const countMap = new Map(counts.map((d) => [d.date, d.count]));
const start = new Date(from);
start.setDate(start.getDate() - start.getDay());
const weeks: { date: string; count: number; col: number; row: number }[][] =
[];
const monthMarkers: { col: number; label: string }[] = [];
let col = 0;
let prevMonth = -1;
const cursor = new Date(start);
while (cursor <= to) {
const week: (typeof weeks)[0] = [];
for (let row = 0; row < ROWS; row++) {
const dateStr = fmt(cursor);
const count = countMap.get(dateStr) ?? 0;
week.push({ date: dateStr, count, col, row });
if (row === 0) {
const m = cursor.getMonth();
if (m !== prevMonth) {
monthMarkers.push({ col, label: MONTH_LABELS[m] });
prevMonth = m;
}
}
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
col++;
}
const nonZero = counts
.map((d) => d.count)
.filter((c) => c > 0)
.sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
return { weeks, monthMarkers, thresholds, totalCount };
}, [dailyQ.data]);
const cols = weeks.length;
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP);
if (dailyQ.isLoading)
return <p style={{ fontSize: "0.8rem" }}>loading contribution graph...</p>;
if (dailyQ.isError) return null;
return (
<div className="contribution-graph mb-3">
<p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
{new Intl.NumberFormat().format(totalCount)} contributions
{repoCount > 0 && `, across ${repoCount} repositories, `}
in the last year
</p>
<div>
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
width="100%"
className="d-block"
>
{DAY_LABELS.map((label, i) =>
label ? (
<text
key={i}
x={LEFT_LABEL_WIDTH - 6}
y={TOP_LABEL_HEIGHT + i * (CELL_SIZE + GAP) + CELL_SIZE / 2}
textAnchor="end"
dominantBaseline="central"
className="graph-label"
>
{label}
</text>
) : null,
)}
{monthMarkers.map(({ col, label }, i) => (
<text
key={i}
x={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
y={10}
textAnchor="middle"
className="graph-label"
>
{label}
</text>
))}
{weeks.flatMap((week) =>
week.map(({ date, count, col, row }) => (
<circle
key={date}
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1}
fill={
count === 0
? EMPTY_COLOR
: (dayColorMap.get(date) ?? FALLBACK_COLOR)
}
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
className="graph-cell"
onClick={() => navigate(`/activity/${date}`)}
>
<title>{`${date}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
</circle>
)),
)}
</svg>
</div>
</div>
);
}
/** All-time monthly contribution graph — years on X axis, months on Y axis. */
export function AllTimeGraph() {
const sourcesQ = useQuery({
queryKey: ["sources"],
queryFn: fetchSources,
staleTime: 60_000,
});
const earliest = useMemo(() => {
if (!sourcesQ.data) return null;
const dates = sourcesQ.data
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0
? new Date(Math.min(...dates.map((d) => d.getTime())))
: null;
}, [sourcesQ.data]);
const projectsQ = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
staleTime: 60_000,
});
const repoCount = projectsQ.data?.length ?? 0;
const to = new Date();
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const dailyQ = useQuery({
queryKey: ["daily-counts-alltime", fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),
enabled: !!earliest,
staleTime: 10 * 60_000,
});
const langQ = useQuery({
queryKey: ["language-daily-alltime", fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
enabled: !!earliest,
staleTime: 10 * 60_000,
});
const navigate = useNavigate();
// Aggregate daily language data to month level: pick the language with most commits
const monthColorMap = useMemo(() => {
const entries = langQ.data ?? [];
if (entries.length === 0) return new Map<string, string>();
const map = new Map<
string,
Map<string, { commits: number; color: string }>
>();
for (const e of entries) {
const key = e.date.slice(0, 7); // YYYY-MM
if (!map.has(key)) map.set(key, new Map());
const langMap = map.get(key)!;
const cur = langMap.get(e.language);
if (cur) {
cur.commits += e.commits;
} else {
langMap.set(e.language, {
commits: e.commits,
color: e.color ?? FALLBACK_COLOR,
});
}
}
const result = new Map<string, string>();
for (const [key, langMap] of map) {
let best = { commits: 0, color: FALLBACK_COLOR };
for (const v of langMap.values()) {
if (v.commits > best.commits) best = v;
}
result.set(key, best.color);
}
return result;
}, [langQ.data]);
const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? [];
if (counts.length === 0)
return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
const countMap = new Map(counts.map((d) => [d.date, d.count]));
const startYear = from.getFullYear();
const endYear = to.getFullYear();
const years: number[] = [];
for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
// Build a 12 x years grid of monthly totals
const monthGrid: {
year: number;
month: number;
count: number;
monthStart: string;
monthEnd: string;
monthKey: string;
}[][] = [];
for (let m = 0; m < 12; m++) {
const row: (typeof monthGrid)[0] = [];
for (const yr of years) {
const monthStart = new Date(yr, m, 1);
const monthEnd = new Date(yr, m + 1, 0); // last day of month
const monthKey = `${yr}-${String(m + 1).padStart(2, "0")}`;
// Don't include months entirely outside our data range
if (monthStart > to || monthEnd < from) {
row.push({
year: yr,
month: m,
count: 0,
monthStart: fmt(monthStart),
monthEnd: fmt(monthEnd),
monthKey,
});
continue;
}
let total = 0;
const cursor = new Date(monthStart);
while (cursor <= monthEnd && cursor <= to) {
total += countMap.get(fmt(cursor)) ?? 0;
cursor.setDate(cursor.getDate() + 1);
}
row.push({
year: yr,
month: m,
count: total,
monthStart: fmt(monthStart),
monthEnd: fmt(monthEnd),
monthKey,
});
}
monthGrid.push(row);
}
const allCounts = monthGrid.flat().map((c) => c.count);
const nonZero = allCounts.filter((c) => c > 0).sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
return { years, monthGrid, thresholds, totalCount };
}, [dailyQ.data]);
if (!earliest || dailyQ.isLoading) return null;
if (dailyQ.isError) return null;
if (years.length === 0) return null;
const monthLabelWidth = 28;
const topLabelHeight = 16;
const numCols = years.length;
const svgWidth = monthLabelWidth + numCols * (CELL_SIZE + GAP);
const svgHeight = topLabelHeight + 12 * (CELL_SIZE + GAP);
return (
<div className="contribution-graph mb-4">
<p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
{new Intl.NumberFormat().format(totalCount)} contributions
{repoCount > 0 && `, across ${repoCount} repos, `}
since {fmt(from).split("-")[0]}
</p>
<div>
<svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
width="100%"
className="d-block"
>
{/* Year labels along the top */}
{years.map((year, colIdx) => (
<text
key={year}
x={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
y={10}
textAnchor="middle"
className="graph-label"
>
{String(year).slice(2)}
</text>
))}
{/* Month labels along the left */}
{MONTH_LABELS.map((label, rowIdx) => (
<text
key={rowIdx}
x={monthLabelWidth - 6}
y={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + CELL_SIZE / 2}
textAnchor="end"
dominantBaseline="central"
className="graph-label"
>
{label}
</text>
))}
{/* Monthly contribution circles */}
{monthGrid.map((row, rowIdx) =>
row.map(
({ year, count, monthStart, monthEnd, monthKey }, colIdx) => (
<circle
key={`${year}-${rowIdx}`}
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1}
fill={
count === 0
? EMPTY_COLOR
: (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)
}
opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
className="graph-cell"
onClick={() =>
navigate(`/activity/${monthStart}..${monthEnd}`)
}
>
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
</circle>
),
),
)}
</svg>
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Build a map of date → dominant (highest commit count) language color. */
function buildDominantColorMap(
entries: {
date: string;
language: string;
color: string | null;
commits: number;
}[],
): Map<string, string> {
const map = new Map<string, { commits: number; color: string }>();
for (const e of entries) {
const cur = map.get(e.date);
if (!cur || e.commits > cur.commits) {
map.set(e.date, { commits: e.commits, color: e.color ?? FALLBACK_COLOR });
}
}
const result = new Map<string, string>();
for (const [date, { color }] of map) {
result.set(date, color);
}
return result;
}
/** Map count to opacity (0.3 1.0) based on quartile thresholds. */
function opacityFor(count: number, thresholds: number[]): number {
if (count <= thresholds[0]) return 0.35;
if (count <= thresholds[1]) return 0.55;
if (count <= thresholds[2]) return 0.75;
return 1;
}
function computeThresholds(sorted: number[]): number[] {
if (sorted.length === 0) return [1, 2, 3];
const p = (pct: number) =>
sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)];
return [p(0.25), p(0.5), p(0.75)];
}

View File

@@ -1,200 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client';
export function ContributionStats() {
const sourcesQ = useQuery({
queryKey: ['sources'],
queryFn: fetchSources,
staleTime: 60_000,
});
const earliest = useMemo(() => {
if (!sourcesQ.data) return null;
const dates = sourcesQ.data
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null;
}, [sourcesQ.data]);
const to = new Date();
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const dailyQ = useQuery({
queryKey: ['daily-counts-alltime', fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),
enabled: !!earliest,
staleTime: 10 * 60_000,
});
// Bucket hour-of-day in the user's local timezone so the chart matches
// the clock they see. Browser may report e.g. "Europe/Helsinki"; fall
// back to UTC if the resolver returns something the server won't
// accept (it validates the string before binding).
const tz = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[],
);
const hourlyQ = useQuery({
queryKey: ['hourly-avgs-alltime', fromStr, toStr, tz],
queryFn: () => fetchHourlyAvgs(fromStr, toStr, tz),
enabled: !!earliest,
staleTime: 10 * 60_000,
});
const stats = useMemo(() => {
const counts = dailyQ.data ?? [];
if (counts.length === 0) return null;
// Build a set of dates with contributions
const countMap = new Map(counts.map((d) => [d.date, d.count]));
// Current streak (consecutive days ending today or yesterday with contributions)
let currentStreak = 0;
const cursor = new Date(to);
// Allow today to have 0 (day isn't over yet) — start from yesterday if today is 0
if ((countMap.get(fmt(cursor)) ?? 0) > 0) {
currentStreak = 1;
cursor.setDate(cursor.getDate() - 1);
} else {
cursor.setDate(cursor.getDate() - 1);
if ((countMap.get(fmt(cursor)) ?? 0) > 0) {
currentStreak = 1;
cursor.setDate(cursor.getDate() - 1);
}
}
if (currentStreak > 0) {
while ((countMap.get(fmt(cursor)) ?? 0) > 0) {
currentStreak++;
cursor.setDate(cursor.getDate() - 1);
}
}
// Longest streak
let longestStreak = 0;
let streak = 0;
const sorted = [...counts].sort((a, b) => a.date.localeCompare(b.date));
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].count > 0) {
streak++;
if (streak > longestStreak) longestStreak = streak;
} else {
streak = 0;
}
}
// Busiest day
const busiest = sorted.reduce((best, d) => (d.count > best.count ? d : best), sorted[0]);
// Day-of-week averages
const dayTotals = [0, 0, 0, 0, 0, 0, 0];
const dayCounts = [0, 0, 0, 0, 0, 0, 0];
for (const d of sorted) {
const dow = new Date(d.date + 'T00:00:00').getDay();
dayTotals[dow] += d.count;
dayCounts[dow]++;
}
const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const dayAvgs = dayNames.map((name, i) => ({
name,
avg: dayCounts[i] > 0 ? dayTotals[i] / dayCounts[i] : 0,
}));
const maxAvg = Math.max(...dayAvgs.map((d) => d.avg));
// Total active days
const activeDays = sorted.filter((d) => d.count > 0).length;
return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays };
}, [dailyQ.data]);
const hourly = useMemo(() => {
const data = hourlyQ.data ?? [];
if (data.length === 0) return null;
const byHour = new Array(24).fill(0);
for (const { hour, avg } of data) {
if (hour >= 0 && hour < 24) byHour[hour] = avg;
}
const max = Math.max(...byHour);
return { hours: byHour, max };
}, [hourlyQ.data]);
if (!stats) return null;
return (
<div>
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>contribution stats</p>
<div className="d-flex flex-column gap-2" style={{ fontSize: '0.8rem' }}>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>current streak</span>
<span>{stats.currentStreak} {stats.currentStreak === 1 ? 'day' : 'days'}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>longest streak</span>
<span>{stats.longestStreak} {stats.longestStreak === 1 ? 'day' : 'days'}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>busiest day</span>
<span>{stats.busiest.count} on {stats.busiest.date}</span>
</div>
<div className="d-flex justify-content-between">
<span style={{ opacity: 0.7 }}>active days</span>
<span>{stats.activeDays.toLocaleString()}</span>
</div>
<div className="mt-1">
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by weekday</span>
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
{stats.dayAvgs.map(({ name, avg }) => (
<div key={name} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
<span style={{ fontSize: '0.65rem', opacity: 0.6, marginBottom: 2 }}>{avg.toFixed(1)}</span>
<div style={{ width: '100%', maxWidth: 20, borderRadius: 3, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<div
style={{
height: stats.maxAvg > 0 ? `${(avg / stats.maxAvg) * 100}%` : '0%',
borderRadius: 3,
backgroundColor: '#39d353',
opacity: 0.7,
}}
/>
</div>
<span style={{ fontSize: '0.65rem', opacity: 0.7, marginTop: 2 }}>{name}</span>
</div>
))}
</div>
</div>
{hourly && (
<div className="mt-3">
<span style={{ opacity: 0.7, fontSize: '0.75rem' }}>avg by hour ({tz})</span>
<div className="d-flex align-items-end gap-1 mt-1" style={{ height: 64 }}>
{hourly.hours.map((avg, h) => (
<div key={h} className="d-flex flex-column align-items-center" style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}>
<div style={{ width: '100%', borderRadius: 2, background: 'rgba(255,255,255,0.05)', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<div
style={{
height: hourly.max > 0 ? `${(avg / hourly.max) * 100}%` : '0%',
borderRadius: 2,
backgroundColor: '#39d353',
opacity: 0.7,
}}
title={`${h.toString().padStart(2, '0')}:00 — ${avg.toFixed(2)}/day`}
/>
</div>
<span style={{ fontSize: '0.6rem', opacity: 0.7, marginTop: 2, minHeight: '0.7rem' }}>
{h % 4 === 0 ? h.toString().padStart(2, '0') : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}

View File

@@ -4,7 +4,7 @@ import Row from 'react-bootstrap/Row';
import Slider from 'rc-slider';
import type { Source, SourceSummary } from '../api/client';
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla'];
interface Props {
enabledSources: Record<Source, boolean>;
@@ -88,14 +88,11 @@ export function Filters({
}
function formatDate(ts: number): string {
// Format in UTC so the prerendered (Node) and client (browser) output match
// regardless of the viewer's timezone — otherwise hydration mismatches.
return new Date(ts)
.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
}

View File

@@ -1,35 +0,0 @@
export function LanguageBar({ languages, colorMap, compact }: {
languages: Record<string, number>;
colorMap: Record<string, string>;
compact?: boolean;
}) {
const total = Object.values(languages).reduce((a, b) => a + b, 0);
if (total === 0) return null;
const sorted = Object.entries(languages).sort(([, a], [, b]) => b - a);
return (
<div className={compact ? 'mb-1' : 'mt-2'}>
<div className="language-bar">
{sorted.map(([lang, bytes]) => (
<div
key={lang}
className="language-bar-segment"
style={{ width: `${(bytes / total) * 100}%`, backgroundColor: colorMap[lang] ?? '#8b8b8b' }}
title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`}
/>
))}
</div>
{!compact && (
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{sorted.slice(0, 8).map(([lang, bytes]) => (
<span key={lang}>
<span className="language-dot" style={{ backgroundColor: colorMap[lang] ?? '#8b8b8b' }} />
{lang} {((bytes / total) * 100).toFixed(1)}%
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -1,198 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchLanguageDailyCounts } from '../api/client';
const HEIGHT = 160;
const LABEL_HEIGHT = 16;
/** Language stream graph — stacked area showing language usage over time. */
export function LanguageStreamGraph() {
const to = new Date();
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
const fromStr = fmt(from);
const toStr = fmt(to);
const langQ = useQuery({
queryKey: ['language-daily', fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000,
});
const { languages, paths, legendItems } = useMemo(() => {
const raw = langQ.data ?? [];
if (raw.length === 0)
return { weeks: [], languages: [], paths: [], legendItems: [] };
// Aggregate daily counts into weekly buckets
const colorMap = new Map<string, string>();
const weeklyMap = new Map<string, Map<string, number>>();
for (const d of raw) {
if (d.color) colorMap.set(d.language, d.color);
// Bucket to ISO week (Monday-based, keyed by Monday date)
const dt = new Date(d.date + 'T00:00:00Z');
const day = dt.getUTCDay();
const monday = new Date(dt);
monday.setUTCDate(monday.getUTCDate() - ((day + 6) % 7));
const weekKey = monday.toISOString().slice(0, 10);
if (!weeklyMap.has(weekKey)) weeklyMap.set(weekKey, new Map());
const langs = weeklyMap.get(weekKey)!;
langs.set(d.language, (langs.get(d.language) ?? 0) + d.commits);
}
const weeks = [...weeklyMap.keys()].sort();
// Rank languages by total commits to pick top N + "other"
const totals = new Map<string, number>();
for (const langs of weeklyMap.values()) {
for (const [lang, count] of langs) {
totals.set(lang, (totals.get(lang) ?? 0) + count);
}
}
const ranked = [...totals.entries()].sort(([, a], [, b]) => b - a);
const topN = 8;
const topLangs = ranked.slice(0, topN).map(([l]) => l);
const hasOther = ranked.length > topN;
const languages = hasOther ? [...topLangs, 'Other'] : topLangs;
// Build stacked data per week
const stacked: number[][] = weeks.map((wk) => {
const langs = weeklyMap.get(wk)!;
const values = topLangs.map((l) => langs.get(l) ?? 0);
if (hasOther) {
let other = 0;
for (const [l, c] of langs) {
if (!topLangs.includes(l)) other += c;
}
values.push(other);
}
return values;
});
// Compute stream layout (centered baseline)
const maxTotal = Math.max(...stacked.map((row) => row.reduce((a, b) => a + b, 0)), 1);
const chartHeight = HEIGHT - LABEL_HEIGHT;
// For each week, compute y0 (centered) then stack upward
const layerCount = languages.length;
const y0s: number[][] = [];
const y1s: number[][] = [];
for (let w = 0; w < weeks.length; w++) {
const total = stacked[w].reduce((a, b) => a + b, 0);
const scaledTotal = (total / maxTotal) * chartHeight;
let baseline = (chartHeight - scaledTotal) / 2 + LABEL_HEIGHT;
const wy0: number[] = [];
const wy1: number[] = [];
for (let l = 0; l < layerCount; l++) {
const h = (stacked[w][l] / maxTotal) * chartHeight;
wy0.push(baseline);
baseline += h;
wy1.push(baseline);
}
y0s.push(wy0);
y1s.push(wy1);
}
// Build SVG paths for each language layer using smooth curves
const xFor = (w: number) =>
weeks.length > 1 ? (w / (weeks.length - 1)) * 100 : 50;
const paths = languages.map((_, l) => {
if (weeks.length === 0) return '';
const topPts = weeks.map((_, w) => [xFor(w), y0s[w][l]] as [number, number]);
const bottomPts = weeks
.map((_, w) => [xFor(w), y1s[w][l]] as [number, number])
.reverse();
return `M${topPts[0][0]},${topPts[0][1]} ${smoothLine(topPts)} L${bottomPts[0][0]},${bottomPts[0][1]} ${smoothLine(bottomPts)} Z`;
});
// Default colors for "Other" and fallback
const FALLBACK_COLORS = [
'#e34c26', '#563d7c', '#3178c6', '#dea584',
'#f1e05a', '#89e051', '#00ADD8', '#438eff',
];
const legendItems = languages.map((lang, i) => ({
language: lang,
color:
lang === 'Other'
? 'rgba(255,255,255,0.2)'
: colorMap.get(lang) ?? FALLBACK_COLORS[i % FALLBACK_COLORS.length],
total: ranked[i]?.[1] ?? 0,
}));
return { weeks, languages, paths, legendItems };
}, [langQ.data]);
if (langQ.isLoading) return <p style={{ fontSize: '0.8rem' }}>loading language graph...</p>;
if (langQ.isError || languages.length === 0) return null;
return (
<div className="contribution-graph mb-4">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>languages by commit activity</p>
<svg viewBox={`0 0 100 ${HEIGHT}`} width="100%" preserveAspectRatio="none" className="d-block" style={{ height: `${HEIGHT}px` }}>
{paths.map((d, i) => (
<path
key={legendItems[i].language}
d={d}
fill={legendItems[i].color}
opacity={0.85}
>
<title>{legendItems[i].language}</title>
</path>
))}
</svg>
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{legendItems.map(({ language, color }) => (
<span key={language} className="d-flex align-items-center gap-1">
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: color,
display: 'inline-block',
}}
/>
{language}
</span>
))}
</div>
</div>
);
}
function fmt(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Convert a series of points into smooth cubic bezier curve commands.
* Uses Catmull-Rom to Bezier conversion with tension 0.5. */
function smoothLine(pts: [number, number][]): string {
if (pts.length < 2) return '';
if (pts.length === 2)
return `L${pts[1][0]},${pts[1][1]}`;
const commands: string[] = [];
for (let i = 1; i < pts.length; i++) {
const p0 = pts[Math.max(i - 2, 0)];
const p1 = pts[i - 1];
const p2 = pts[i];
const p3 = pts[Math.min(i + 1, pts.length - 1)];
const t = 0.5;
const cp1x = p1[0] + (p2[0] - p0[0]) * t / 3;
const cp1y = p1[1] + (p2[1] - p0[1]) * t / 3;
const cp2x = p2[0] - (p3[0] - p1[0]) * t / 3;
const cp2y = p2[1] - (p3[1] - p1[1]) * t / 3;
commands.push(`C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`);
}
return commands.join(' ');
}

View File

@@ -17,7 +17,6 @@ export function Layout() {
<nav className="d-flex flex-wrap gap-3 align-items-center">
<NavLink to="/" end>dash</NavLink>
<NavLink to="/activity">activity</NavLink>
<NavLink to="/blog">blog</NavLink>
<NavLink to="/cv">cv</NavLink>
<span className="nav-divider">|</span>
{externalLinks.map((el) => (

View File

@@ -1,69 +0,0 @@
import ReactMarkdown, { type UrlTransform } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
// rehype-sanitize defaults are conservative — markdown authors lean on raw
// HTML for layout (centered headers, collapsible sections, image
// dimensions). Extend the schema to permit those tags/attributes while
// still blocking script-y or interactive content (iframe, object, etc.).
const sanitizeSchema = {
...defaultSchema,
tagNames: [
...(defaultSchema.tagNames ?? []),
'details',
'summary',
'picture',
'source',
'kbd',
'sub',
'sup',
'mark',
'abbr',
'cite',
'figure',
'figcaption',
'center',
],
attributes: {
...defaultSchema.attributes,
'*': [
...((defaultSchema.attributes && defaultSchema.attributes['*']) || []),
'align',
'style',
],
a: [
...((defaultSchema.attributes && defaultSchema.attributes.a) || []),
'target',
'rel',
],
img: [
...((defaultSchema.attributes && defaultSchema.attributes.img) || []),
'width',
'height',
'align',
'srcset',
],
source: ['srcset', 'media', 'type'],
details: ['open'],
},
};
interface Props {
text: string;
/** Rewrite URLs (e.g. resolve relative image paths to forge raw URLs). */
urlTransform?: UrlTransform;
}
/** GFM + sanitized embedded HTML, shared by READMEs and blog posts. */
export function Markdown({ text, urlTransform }: Props) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
urlTransform={urlTransform}
>
{text}
</ReactMarkdown>
);
}

View File

@@ -74,9 +74,6 @@ function renderSegment(seg: TitleSegment, i: number) {
}
function formatDate(iso: string): string {
// Format in UTC so the prerendered (Node) and client (browser) renders are
// byte-identical regardless of the viewer's timezone — otherwise the dates
// mismatch and React rejects the hydration (error #418).
const d = new Date(iso);
const date = d
.toLocaleDateString('en-GB', {
@@ -84,21 +81,10 @@ function formatDate(iso: string): string {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
const time = d
.toLocaleTimeString('en-GB', {
timeZone: 'UTC',
// Explicit 2-digit fields + 24h cycle: en-GB's *default* hour rendering
// isn't padded identically across JS engines (Node emits "9:53", Firefox
// "09:53"), which breaks hydration. Pinning the fields makes it match.
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
})
.toLocaleTimeString('en-GB', { timeZoneName: 'short' })
.toLowerCase();
return `${date}${time}`;
}

View File

@@ -1,60 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchRepoLanguages } from '../api/client';
const MAX_LANGS = 14;
export function TopLanguages() {
const langsQ = useQuery({
queryKey: ['repo-languages'],
queryFn: fetchRepoLanguages,
staleTime: 10 * 60_000,
});
const ranked = useMemo(() => {
if (!langsQ.data) return [];
const totals = new Map<string, { bytes: number; color: string }>();
for (const e of langsQ.data) {
const cur = totals.get(e.language);
if (cur) {
cur.bytes += e.bytes;
} else {
totals.set(e.language, { bytes: e.bytes, color: e.color ?? '#8b8b8b' });
}
}
return [...totals.entries()]
.sort(([, a], [, b]) => b.bytes - a.bytes)
.slice(0, MAX_LANGS);
}, [langsQ.data]);
if (!langsQ.data || ranked.length === 0) return null;
const maxBytes = ranked[0][1].bytes;
return (
<div>
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}>
top languages by code volume
</p>
<div className="d-flex flex-column gap-1">
{ranked.map(([lang, { bytes, color }]) => (
<div key={lang} className="d-flex align-items-center gap-2" style={{ fontSize: '0.75rem' }}>
<span style={{ width: 70, textAlign: 'right', opacity: 0.8, flexShrink: 0 }}>{lang}</span>
<div style={{ flex: 1, height: 10, borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
<div
style={{
width: `${(bytes / maxBytes) * 100}%`,
height: '100%',
borderRadius: 3,
backgroundColor: color,
opacity: 0.85,
}}
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
// Server entry for the prerender. Built once with `vite build --ssr` and then
// driven by `run-prerender.mjs` under Node. For each route it prefetches the
// route's data, renders the real React tree to an HTML string, and returns the
// markup alongside the dehydrated react-query cache and the route's <head>
// metadata. No browser globals are touched on the render path.
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
dehydrate,
type DehydratedState,
} from '@tanstack/react-query';
import App from './App';
import { prefetchRoute } from './prerender/prefetch';
import { headForRoute, type HeadMeta } from './prerender/meta';
export { collectRoutes } from './prerender/routes';
export type { HeadMeta };
export interface RenderResult {
html: string;
state: DehydratedState;
head: HeadMeta;
}
export async function renderRoute(path: string): Promise<RenderResult> {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Treat prefetched data as fresh + non-collectable so it survives
// until dehydration and `useQuery` reads it synchronously during
// renderToString instead of trying to refetch.
staleTime: Infinity,
gcTime: Infinity,
},
},
});
await prefetchRoute(queryClient, path);
const html = renderToString(
<StrictMode>
<QueryClientProvider client={queryClient}>
<StaticRouter location={path}>
<App />
</StaticRouter>
</QueryClientProvider>
</StrictMode>,
);
return {
html,
state: dehydrate(queryClient),
head: headForRoute(path, queryClient),
};
}

View File

@@ -8,7 +8,6 @@ import {
DashCircle,
Diagram3,
ExclamationCircle,
PencilSquare,
PlusCircle,
StarFill,
Tag,
@@ -29,7 +28,6 @@ const map: Record<TimelineIcon, typeof Wrench> = {
star: StarFill,
release: Tag,
bug: Bug,
post: PencilSquare,
generic: Wrench,
};
@@ -46,7 +44,6 @@ const colors: Record<TimelineIcon, string> = {
star: '#f9a825',
release: '#6a1b9a',
bug: '#c62828',
post: '#00695c',
generic: '#546e7a',
};

View File

@@ -1,71 +0,0 @@
// Date-range helpers shared by the dashboard/timeline components and the
// build-time prerender prefetch. Centralising the window maths keeps the
// react-query *keys* identical between the SSR snapshot and the client's
// first render, so hydration reuses the inlined cache instead of refetching.
//
// All windows are stamped to the UTC day (YYYY-MM-DD). Same-day loads hydrate
// from the baked snapshot; loads on a later day compute a different key and
// refetch live — the intended freshness tradeoff for the activity data.
import type { SourceSummary } from '../api/client';
export function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Contribution graph + language stream: trailing 365 days. */
export function lastYearRange(now: Date = new Date()) {
const to = new Date(now);
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Earliest activity date across all sources, or null if none reported. */
export function earliestFrom(sources: SourceSummary[]): Date | null {
const dates = sources
.map((s) => s.earliest)
.filter((d): d is string => d != null)
.map((d) => new Date(d));
return dates.length > 0
? new Date(Math.min(...dates.map((d) => d.getTime())))
: null;
}
/** All-time graphs/stats: earliest source date (fallback 5y) through now. */
export function allTimeRange(earliest: Date | null, now: Date = new Date()) {
const to = new Date(now);
const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1);
return { from, to, fromStr: fmtDate(from), toStr: fmtDate(to) };
}
/** Timeline slider upper bound, bucketed to end-of-day (UTC) so the SSR and
* client renders agree on the slider scale and labels (no hydration mismatch). */
export function endOfTodayMs(now: Date = new Date()): number {
const d = new Date(now);
d.setUTCHours(23, 59, 59, 999);
return d.getTime();
}
/** Timeline default window: trailing 30 days, with both bounds bucketed to the
* UTC day so the slider's millisecond values are identical across the
* prerender and the client's first render. */
export function defaultActivityRange(now: number = Date.now()) {
const to = endOfTodayMs(new Date(now));
const from = to - 30 * 24 * 60 * 60 * 1000;
return {
from,
to,
fromStr: fmtDate(new Date(from)),
toStr: fmtDate(new Date(to)),
};
}
/** The hour-of-day histogram buckets in the viewer's timezone. */
export function resolvedTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}

View File

@@ -1,30 +1,9 @@
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
hydrate,
type DehydratedState,
} from '@tanstack/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
// Global stylesheets are imported here, in the client entry only — keeping
// them out of the shared component tree lets the prerender build import that
// tree under Node (where `import './x.css'` would otherwise fail).
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import './pages/CvPage.css';
declare global {
interface Window {
/** Dehydrated react-query cache inlined by the prerender step. */
__RQ_STATE__?: DehydratedState;
}
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -34,29 +13,12 @@ const queryClient = new QueryClient({
},
});
// Seed the cache from the prerendered snapshot so the first client render
// matches the server HTML (no loading flash) and refetches only when stale.
const dehydratedState = window.__RQ_STATE__;
if (dehydratedState) {
hydrate(queryClient, dehydratedState);
}
const tree = (
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
</StrictMode>,
);
const container = document.getElementById('root')!;
// Hydrate the prerendered markup when present; fall back to a fresh render
// (e.g. SPA-fallback routes that weren't prerendered, or a dev server).
if (dehydratedState && container.firstChild) {
hydrateRoot(container, tree);
} else {
createRoot(container).render(tree);
}

View File

@@ -1,51 +0,0 @@
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { fetchBlogPosts } from '../api/client';
export function BlogIndexPage() {
const postsQ = useQuery({
queryKey: ['blog-posts'],
queryFn: fetchBlogPosts,
staleTime: 60_000,
});
if (postsQ.isLoading) return <p>loading...</p>;
if (postsQ.isError) return <p>error: {(postsQ.error as Error).message}</p>;
const posts = postsQ.data ?? [];
return (
<Row>
<Col md={{ span: 8, offset: 2 }}>
{posts.length === 0 && <p>nothing here yet.</p>}
{posts.map((post) => (
<article key={post.slug} className="mb-4">
<h3 className="blog-list-title mb-1">
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
</h3>
<p className="blog-date mb-1">
{formatDate(post.published_at)}
</p>
{post.excerpt && <p className="mb-0">{post.excerpt}</p>}
</article>
))}
</Col>
</Row>
);
}
export function formatDate(iso: string): string {
// UTC-fixed so prerender (Node) and client (browser) agree — see formatDate
// in TimelineEntry for the hydration rationale.
return new Date(iso)
.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})
.toLowerCase();
}

View File

@@ -1,49 +0,0 @@
import { Link, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { fetchBlogPost } from '../api/client';
import { Markdown } from '../components/Markdown';
import { formatDate } from './BlogIndexPage';
export function BlogPostPage() {
const { slug } = useParams();
const postQ = useQuery({
queryKey: ['blog-post', slug],
queryFn: () => fetchBlogPost(slug ?? ''),
enabled: !!slug,
staleTime: 60_000,
});
if (postQ.isLoading) return <p>loading...</p>;
if (postQ.isError) return <p>error: {(postQ.error as Error).message}</p>;
const post = postQ.data;
if (!post) return null;
// Posts reference images committed alongside them in the blog repo;
// resolve relative srcs to the forge's raw-content URL.
const resolveUrl = (url: string) =>
/^[a-z][a-z0-9+.-]*:|^\/|^#/i.test(url)
? url
: `https://${post.host}/${post.repo}/raw/branch/${post.branch}/${url}`;
return (
<Row>
<Col md={{ span: 8, offset: 2 }}>
<p className="mb-2" style={{ fontSize: '85%' }}>
<Link to="/blog"> blog</Link>
</p>
<h2 className="mb-1">{post.title}</h2>
<p className="blog-date">
{formatDate(post.published_at)}
</p>
<div className="blog-post">
<Markdown text={post.markdown} urlTransform={resolveUrl} />
</div>
</Col>
</Row>
);
}

View File

@@ -10,8 +10,7 @@ import { fetchCv, filesForSection } from '../api/cv';
import { CvHeader } from '../components/cv/CvHeader';
import { CvSection } from '../components/cv/CvSection';
import { CvTimeline } from '../components/cv/CvTimeline';
// CvPage.css is imported from the client entry (`main.tsx`) so this page stays
// importable under Node during the prerender build.
import './CvPage.css';
export function CvPage() {
const { hash } = useLocation();

View File

@@ -1,15 +1,9 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
import { LanguageBar } from '../components/LanguageBar';
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
import { ContributionStats } from '../components/ContributionStats';
import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
import { TopLanguages } from '../components/TopLanguages';
import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client';
export function DashPage() {
const projectsQ = useQuery({
@@ -18,32 +12,8 @@ export function DashPage() {
refetchInterval: 60_000,
});
const langsQ = useQuery({
queryKey: ['repo-languages'],
queryFn: fetchRepoLanguages,
staleTime: 10 * 60_000,
});
const langsByRepo = useMemo(() => {
const map = new Map<string, Record<string, number>>();
for (const entry of langsQ.data ?? []) {
const key = `${entry.source}:${entry.repo}`;
if (!map.has(key)) map.set(key, {});
map.get(key)![entry.language] = entry.bytes;
}
return map;
}, [langsQ.data]);
const langColors = useMemo(() => {
const map: Record<string, string> = {};
for (const e of langsQ.data ?? []) {
if (e.color && !map[e.language]) map[e.language] = e.color;
}
return map;
}, [langsQ.data]);
const projects = projectsQ.data ?? [];
const ranked = rankProjects(projects);
const ranked = rankProjects(projects).slice(0, 24);
return (
<>
@@ -55,19 +25,6 @@ export function DashPage() {
</p>
</Col>
</Row>
<ContributionGraph />
<LanguageStreamGraph />
<Row xs={1} md={2} lg={3} className="g-3 mb-3">
<Col>
<AllTimeGraph />
</Col>
<Col>
<TopLanguages />
</Col>
<Col>
<ContributionStats />
</Col>
</Row>
{projectsQ.isLoading && <p>loading...</p>}
{projectsQ.isError && (
<p>error: {(projectsQ.error as Error).message}</p>
@@ -75,7 +32,7 @@ export function DashPage() {
<Row xs={1} md={2} lg={3} className="g-3">
{ranked.map((p) => (
<Col key={`${p.source}:${p.repo}`}>
<ProjectCard project={p} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} colorMap={langColors} />
<ProjectCard project={p} />
</Col>
))}
</Row>
@@ -83,12 +40,25 @@ export function DashPage() {
);
}
function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary; langs: Record<string, number> | null; colorMap: Record<string, string> }) {
function ProjectCard({ project: p }: { project: ProjectSummary }) {
const langsQ = useQuery({
queryKey: ['languages', p.source, p.host, p.repo],
queryFn: () => fetchLanguages(p.source as Source, p.host, p.repo),
enabled: p.source === 'github' || p.source === 'gitea',
staleTime: 10 * 60_000,
});
const langs = langsQ.data;
const topLangs = langs ? topLanguages(langs, 3) : null;
return (
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
<div className="project-card p-3">
<h5 className="mb-1"><img src={forgeIcon(p.source)} alt={p.source} className="forge-icon" />{p.repo}</h5>
{langs && <LanguageBar languages={langs} colorMap={colorMap} compact />}
<h5 className="mb-1">{p.repo}</h5>
<small className="text-muted d-block mb-2">
{p.source}
{topLangs && ` · ${topLangs}`}
</small>
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
@@ -102,20 +72,17 @@ function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary;
);
}
function forgeIcon(source: string): string {
switch (source) {
case 'github': return '/github.svg';
case 'gitea': return '/gitea.svg';
case 'hg': return '/mozilla.svg';
default: return '/github.svg';
}
function topLanguages(langs: Record<string, number>, n: number): string {
return Object.entries(langs)
.sort(([, a], [, b]) => b - a)
.slice(0, n)
.map(([lang]) => lang.toLowerCase())
.join(', ');
}
function formatRange(first: string | null, last: string | null): string {
const fmt = (iso: string) =>
new Date(iso)
.toLocaleDateString('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' })
.toLowerCase();
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
if (first && last) return `${fmt(first)} — ${fmt(last)}`;
if (last) return fmt(last);
return '';
@@ -133,7 +100,6 @@ function rankProjects(projects: ProjectSummary[]): ProjectSummary[] {
return [...projects].sort((a, b) => score(b) - score(a));
function score(p: ProjectSummary): number {
if (p.commit_count >= 10000) return -1;
const volume = (p.commit_count + p.issue_count + p.pr_count) / (maxVolume || 1);
const recency = p.last_activity
? (new Date(p.last_activity).getTime() - oldest) / range

View File

@@ -1,13 +1,11 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import ReactMarkdown from 'react-markdown';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
import { LanguageBar } from '../components/LanguageBar';
import { Markdown } from '../components/Markdown';
import { fetchEvents, fetchReadme, fetchLanguages, fetchProjects, type Source } from '../api/client';
import { TimelineEntry } from '../components/TimelineEntry';
export function ProjectPage() {
@@ -42,39 +40,23 @@ export function ProjectPage() {
staleTime: 5 * 60_000,
});
const repoLangsQ = useQuery({
queryKey: ['repo-languages'],
queryFn: fetchRepoLanguages,
staleTime: 10 * 60_000,
const langsQ = useQuery({
queryKey: ['languages', source, host, repo],
queryFn: () => fetchLanguages(source as Source, host, repo),
enabled: !!host && (source === 'github' || source === 'gitea'),
staleTime: 5 * 60_000,
});
const langs = useMemo(() => {
if (!repoLangsQ.data || !source) return null;
const entries = repoLangsQ.data.filter(
(e) => e.source === source && e.repo === repo,
);
if (entries.length === 0) return null;
const result: Record<string, number> = {};
for (const e of entries) result[e.language] = e.bytes;
return result;
}, [repoLangsQ.data, source, repo]);
const langColors = useMemo(() => {
const map: Record<string, string> = {};
for (const e of repoLangsQ.data ?? []) {
if (e.color && !map[e.language]) map[e.language] = e.color;
}
return map;
}, [repoLangsQ.data]);
const events = eventsQ.data ?? [];
const langs = langsQ.data;
return (
<>
<Row className="mb-3">
<Col>
<h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2>
{langs && <LanguageBar languages={langs} colorMap={langColors} />}
<h2>{repo}</h2>
<small className="text-muted">{source}</small>
{langs && <LanguageBar languages={langs} />}
</Col>
</Row>
@@ -82,7 +64,7 @@ export function ProjectPage() {
<Row className="mb-4">
<Col>
<div className="project-readme">
<Markdown text={readmeQ.data} />
<ReactMarkdown>{readmeQ.data}</ReactMarkdown>
</div>
</Col>
</Row>
@@ -108,22 +90,55 @@ export function ProjectPage() {
);
}
function repoUrl(source: string, host: string, repo: string): string {
switch (source) {
case 'github': return `https://github.com/${repo}`;
case 'gitea': return `https://${host}/${repo}`;
case 'hg': return `https://${host}/${repo}`;
default: return '#';
}
function LanguageBar({ languages }: { languages: Record<string, number> }) {
const total = Object.values(languages).reduce((a, b) => a + b, 0);
if (total === 0) return null;
const sorted = Object.entries(languages).sort(([, a], [, b]) => b - a);
return (
<div className="mt-2">
<div className="language-bar">
{sorted.map(([lang, bytes]) => (
<div
key={lang}
className="language-bar-segment"
style={{ width: `${(bytes / total) * 100}%`, backgroundColor: langColor(lang) }}
title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`}
/>
))}
</div>
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{sorted.slice(0, 8).map(([lang, bytes]) => (
<span key={lang}>
<span className="language-dot" style={{ backgroundColor: langColor(lang) }} />
{lang} {((bytes / total) * 100).toFixed(1)}%
</span>
))}
</div>
</div>
);
}
function forgeIcon(source: string): string {
switch (source) {
case 'github': return '/github.svg';
case 'gitea': return '/gitea.svg';
case 'hg': return '/mozilla.svg';
default: return '/github.svg';
}
const LANG_COLORS: Record<string, string> = {
Rust: '#dea584',
TypeScript: '#3178c6',
JavaScript: '#f1e05a',
Python: '#3572a5',
Go: '#00add8',
Shell: '#89e051',
HTML: '#e34c26',
CSS: '#563d7c',
C: '#555555',
'C++': '#f34b7d',
Java: '#b07219',
Ruby: '#701516',
Nix: '#7e7eff',
Makefile: '#427819',
Dockerfile: '#384d54',
SCSS: '#c6538c',
};
function langColor(lang: string): string {
return LANG_COLORS[lang] ?? '#8b8b8b';
}

View File

@@ -1,59 +1,27 @@
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client';
import { fetchEvents, fetchSources, type Source } from '../api/client';
import { Filters } from '../components/Filters';
import { TimelineEntry } from '../components/TimelineEntry';
import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = endOfTodayMs();
function parseDate(s: string): number {
// Accept YYYY-MM-DD or full ISO datetime
const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime();
return isNaN(t) ? NaN : t;
}
function endOfDay(s: string): number {
const t = new Date(s.includes('T') ? s : s + 'T23:59:59Z').getTime();
return isNaN(t) ? NaN : t;
}
function parseTimespan(timespan?: string): [number, number] | null {
if (!timespan) return null;
if (timespan.includes('..')) {
const [a, b] = timespan.split('..');
const from = parseDate(a);
const to = endOfDay(b);
if (!isNaN(from) && !isNaN(to)) return [from, to];
} else {
const from = parseDate(timespan);
const to = endOfDay(timespan);
if (!isNaN(from)) return [from, to];
}
return null;
}
const RANGE_MAX = Date.now();
export function TimelineHome() {
const { timespan } = useParams();
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
github: true,
gitea: true,
hg: true,
bugzilla: true,
blog: true,
});
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const parsed = parseTimespan(timespan);
if (parsed) return parsed;
const { from, to } = defaultActivityRange();
return [from, to];
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
return [thirtyDaysAgo, now];
});
const [limit, setLimit] = useState<number>(100);
@@ -69,13 +37,8 @@ export function TimelineHome() {
[enabledSources],
);
// Day-stamped keys (rather than raw millisecond bounds) so the prerendered
// snapshot and the client's first render agree on the same UTC day.
const fromStr = fmtDate(new Date(rangeValue[0]));
const toStr = fmtDate(new Date(rangeValue[1]));
const eventsQ = useQuery({
queryKey: ['events', fromStr, toStr, activeSources, limit],
queryKey: ['events', rangeValue, activeSources, limit],
queryFn: () =>
fetchEvents({
from: new Date(rangeValue[0]),
@@ -88,17 +51,6 @@ export function TimelineHome() {
const events = eventsQ.data ?? [];
const dailyQ = useQuery({
queryKey: ['daily-counts', fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000,
});
const totalCount = useMemo(
() => (dailyQ.data ?? []).reduce((sum, d) => sum + d.count, 0),
[dailyQ.data],
);
const privateCount = totalCount - events.length;
return (
<>
<Filters
@@ -122,7 +74,7 @@ export function TimelineHome() {
? 'loading…'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `showing ${events.length} public ${events.length === 1 ? 'activity' : 'activities'}${privateCount > 0 ? `, ${privateCount} private` : ''}`}
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (

View File

@@ -1,133 +0,0 @@
// Per-route <head> metadata baked into the prerendered HTML: a real title and
// description for each route, plus a schema.org Person + Occupation JSON-LD
// block so crawlers and AI screening tools get structured identity data even
// without running JS.
import type { QueryClient } from '@tanstack/react-query';
import type { BlogPost, ProjectSummary } from '../api/client';
export interface HeadMeta {
title: string;
description: string;
/** schema.org JSON-LD object, serialised into a <script type=ld+json>. */
jsonLd: object;
}
const SITE_URL = 'https://rob.tn';
const DEFAULT_DESCRIPTION =
'a timeline of open source contributions across github, gitea, and mozilla hg. ranked projects, language trends, and commit activity since 2012.';
/** schema.org Person + Occupation — stable across every route. */
function personJsonLd(): object {
return {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Rob Thijssen',
alternateName: 'grenade',
url: SITE_URL,
jobTitle: 'Software Engineer',
description:
'software engineer working across systems, infrastructure, and web — contributing to open source since 2012.',
sameAs: [
'https://github.com/grenade',
'https://git.lair.cafe/grenade',
'https://linkedin.com/in/thijssen/',
'https://stackoverflow.com/users/68115/grenade',
],
hasOccupation: {
'@type': 'Occupation',
name: 'Software Engineer',
occupationalCategory: '15-1252.00',
description:
'designs, builds, and operates software systems and the infrastructure they run on.',
},
};
}
/** First ~155 chars of a markdown body, stripped of the noisiest syntax. */
function excerpt(markdown: string, limit = 155): string {
const text = markdown
.replace(/^---[\s\S]*?---/, '') // drop frontmatter if present
.replace(/```[\s\S]*?```/g, ' ') // code fences
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text
.replace(/[#>*_`~|-]/g, ' ') // residual markdown punctuation
.replace(/\s+/g, ' ')
.trim();
if (text.length <= limit) return text;
return text.slice(0, limit).replace(/\s+\S*$/, '') + '…';
}
export function headForRoute(path: string, qc: QueryClient): HeadMeta {
const jsonLd = personJsonLd();
if (path === '/cv') {
return {
title: 'rob thijssen — cv',
description:
'rob thijssen — software engineer. résumé, work history, and skills, including roles at mozilla and across open source infrastructure.',
jsonLd,
};
}
if (path === '/activity') {
return {
title: 'activity — rob thijssen',
description:
'a chronological timeline of rob thijssens open source activity: commits, pull requests, issues, and releases across github, gitea, and mozilla hg.',
jsonLd,
};
}
if (path === '/blog') {
return {
title: 'blog — rob thijssen',
description: 'writing by rob thijssen on software, infrastructure, and open source.',
jsonLd,
};
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
const post = qc.getQueryData<BlogPost>(['blog-post', slug]);
return {
title: post ? `${post.title} — rob thijssen` : 'blog — rob thijssen',
description: post ? excerpt(post.markdown) : DEFAULT_DESCRIPTION,
jsonLd,
};
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = slash === -1 ? rest : rest.slice(0, slash);
const repo = slash === -1 ? '' : rest.slice(slash + 1);
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const project = projects.find((p) => p.source === source && p.repo === repo);
const counts = project
? [
project.commit_count ? `${project.commit_count} commits` : null,
project.pr_count ? `${project.pr_count} pull requests` : null,
project.issue_count ? `${project.issue_count} issues` : null,
]
.filter(Boolean)
.join(', ')
: '';
return {
title: `${repo} — rob thijssen`,
description: counts
? `rob thijssens contributions to ${repo}: ${counts}.`
: `rob thijssens open source activity and readme for ${repo}.`,
jsonLd,
};
}
// Home / dashboard.
return {
title: 'rob thijssen — developer activity and contribution history',
description: DEFAULT_DESCRIPTION,
jsonLd,
};
}

View File

@@ -1,153 +0,0 @@
// Build-time data fetching for the prerender. For each route we populate a
// fresh QueryClient with exactly the queries that route's components read,
// under the *same* query keys those components compute at runtime — so the
// dehydrated cache hydrates cleanly on the client. Keys that depend on the
// current day (contribution windows, the activity range) match same-day loads
// and refetch live afterwards; see `lib/ranges.ts`.
import type { QueryClient } from '@tanstack/react-query';
import {
fetchBlogPost,
fetchBlogPosts,
fetchDailyCounts,
fetchEvents,
fetchHourlyAvgs,
fetchLanguageDailyCounts,
fetchProjects,
fetchReadme,
fetchRepoLanguages,
fetchSources,
type ProjectSummary,
type Source,
type SourceSummary,
} from '../api/client';
import { fetchCv } from '../api/cv';
import {
allTimeRange,
defaultActivityRange,
earliestFrom,
lastYearRange,
resolvedTimeZone,
} from '../lib/ranges';
// Sources the timeline shows by default, in the same insertion order as
// TimelineHome's `enabledSources` (the array order is part of the query key).
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
const TIMELINE_LIMIT = 100;
const PROJECT_EVENT_LIMIT = 500;
async function prefetchDash(qc: QueryClient): Promise<void> {
await qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources });
const sources = (qc.getQueryData(['sources']) as SourceSummary[]) ?? [];
const year = lastYearRange();
const all = allTimeRange(earliestFrom(sources));
const tz = resolvedTimeZone();
await Promise.all([
qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects }),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
qc.prefetchQuery({
queryKey: ['daily-counts', year.fromStr, year.toStr],
queryFn: () => fetchDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily', year.fromStr, year.toStr],
queryFn: () => fetchLanguageDailyCounts(year.fromStr, year.toStr),
}),
qc.prefetchQuery({
queryKey: ['daily-counts-alltime', all.fromStr, all.toStr],
queryFn: () => fetchDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['language-daily-alltime', all.fromStr, all.toStr],
queryFn: () => fetchLanguageDailyCounts(all.fromStr, all.toStr),
}),
qc.prefetchQuery({
queryKey: ['hourly-avgs-alltime', all.fromStr, all.toStr, tz],
queryFn: () => fetchHourlyAvgs(all.fromStr, all.toStr, tz),
}),
]);
}
async function prefetchActivity(qc: QueryClient): Promise<void> {
// Only the default `/activity` view is prerendered. Timespan-parameterised
// routes (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the
// client and refetch.
const range = defaultActivityRange();
await Promise.all([
qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }),
qc.prefetchQuery({
queryKey: ['events', range.fromStr, range.toStr, ALL_SOURCES, TIMELINE_LIMIT],
queryFn: () =>
fetchEvents({
from: new Date(range.from),
to: new Date(range.to),
sources: ALL_SOURCES,
limit: TIMELINE_LIMIT,
}),
}),
qc.prefetchQuery({
queryKey: ['daily-counts', range.fromStr, range.toStr],
queryFn: () => fetchDailyCounts(range.fromStr, range.toStr),
}),
]);
}
async function prefetchProject(
qc: QueryClient,
source: Source,
repo: string,
): Promise<void> {
await qc.prefetchQuery({ queryKey: ['projects'], queryFn: fetchProjects });
const projects = (qc.getQueryData(['projects']) as ProjectSummary[]) ?? [];
const host = projects.find((p) => p.source === source && p.repo === repo)?.host ?? '';
const tasks = [
qc.prefetchQuery({
queryKey: ['project-events', source, repo],
queryFn: () =>
fetchEvents({ sources: [source], repo, limit: PROJECT_EVENT_LIMIT }),
}),
qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }),
];
if (host && (source === 'github' || source === 'gitea')) {
tasks.push(
qc.prefetchQuery({
queryKey: ['readme', source, host, repo],
queryFn: () => fetchReadme(source, host, repo),
}),
);
}
await Promise.all(tasks);
}
/** Populate `qc` with the data the given route renders from. */
export async function prefetchRoute(qc: QueryClient, path: string): Promise<void> {
if (path === '/' || path === '/dash') return prefetchDash(qc);
if (path === '/activity' || path.startsWith('/activity/')) return prefetchActivity(qc);
if (path === '/blog') {
await qc.prefetchQuery({ queryKey: ['blog-posts'], queryFn: fetchBlogPosts });
return;
}
if (path.startsWith('/blog/')) {
const slug = decodeURIComponent(path.slice('/blog/'.length));
await qc.prefetchQuery({
queryKey: ['blog-post', slug],
queryFn: () => fetchBlogPost(slug),
});
return;
}
if (path === '/cv') {
await qc.prefetchQuery({ queryKey: ['cv-gist'], queryFn: fetchCv });
return;
}
if (path.startsWith('/project/')) {
const rest = path.slice('/project/'.length);
const slash = rest.indexOf('/');
const source = (slash === -1 ? rest : rest.slice(0, slash)) as Source;
const repo = slash === -1 ? '' : rest.slice(slash + 1);
return prefetchProject(qc, source, repo);
}
}

View File

@@ -1,22 +0,0 @@
// Enumerate every route to prerender. The four static routes are fixed; the
// dynamic ones are derived from the same APIs the site reads, so publishing a
// blog post or gaining a project automatically adds its prerendered page on
// the next build.
import { fetchBlogPosts, fetchProjects } from '../api/client';
export async function collectRoutes(): Promise<string[]> {
const routes = ['/', '/activity', '/blog', '/cv'];
const [posts, projects] = await Promise.all([
fetchBlogPosts().catch(() => []),
fetchProjects().catch(() => []),
]);
for (const post of posts) routes.push(`/blog/${post.slug}`);
for (const project of projects) {
routes.push(`/project/${project.source}/${project.repo}`);
}
return routes;
}

14
ui/src/vite-env.d.ts vendored
View File

@@ -1,14 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/**
* Absolute base for API calls during the build-time prerender (Node has no
* relative-URL origin). Unset in the client build so the browser keeps the
* same-origin relative `/api/v1` that nginx proxies. See `api/client.ts`.
*/
readonly VITE_API_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -7,18 +7,10 @@ import react from '@vitejs/plugin-react-swc';
// frontend's URL shape is identical in both environments.
export default defineConfig({
plugins: [react()],
// For the prerender (`vite build --ssr`), bundle dependencies into the server
// output rather than externalising them. Several deps (react-bootstrap,
// react-markdown, …) use subpath/ESM-directory imports or pull in CSS that a
// raw Node `import` of the externalised package can't resolve; bundling lets
// vite resolve the subpaths and strip the CSS. Has no effect on client builds.
ssr: {
noExternal: true,
},
server: {
proxy: {
'/api': {
target: process.env.API_PROXY_TARGET ?? 'http://localhost:8080',
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},