Appearance
Reviewing with dokieli
The book ships with dokieli wired into every chapter for inline annotation. This page explains how it works, how to leave a comment that persists, and how to deploy the storage backend behind nginx on a VPS.
Quick start for reviewers
Open any chapter URL and append
?review=dokieli. For example:texthttps://book.yushkevi.ch/book/chapters/01_trade/DRAFT?review=dokieliA panel appears at the bottom-right of the page. Internal links from this point on stay in review mode automatically.
Type your name in the Reviewer field once. It persists in your browser via
localStorage, so every comment you leave from then on is stamped with your name. There's no Solid sign-in, no WebID, no password.Select any text in the chapter. Dokieli's annotation toolbar appears.
Pick a motivation (commenting, replying, approving, disapproving), write the comment, and click Save. The annotation gets POSTed to the book's inbox service and becomes visible to every other reviewer who opens this page.
The Reviewer panel shows a count ("3 comments") for the page once any annotations exist on it.
To leave review mode, click Turn off in the panel.
How persistence works
Dokieli is a "bring your own storage" annotation client. The Save button publishes to a storage endpoint declared on the document via:
html
<link rel="http://www.w3.org/ns/ldp#inbox" href="https://book.yushkevi.ch/dokieli/inbox">The companion inbox/ service is a small Express + SQLite store that implements just enough of the LDN spec to satisfy dokieli: it accepts application/ld+json POSTs of Web Annotation payloads, stores them keyed on target.source, and serves them back as an AnnotationCollection when a reviewer opens the page.
The client-side wiring (.vitepress/theme/dokieli-review.ts) does four things in review mode:
- Injects the LDN inbox
<link>into the page head. - Wraps
window.fetchso any POST to the inbox carries the sharedAuthorization: Bearer …token and gets the reviewer's name (fromlocalStorage) stamped into the annotation'screator.namefield before send. - Fetches the inbox collection scoped to the current page on load and surfaces the comment count in the Reviewer panel.
- Hides dokieli's heavier menu items (Open, Save As, Embed Data, Subscribe, Archive, Robustify, Print, Solid Sign In) so the reviewer only sees the annotation toolbar.
Deploying on a VPS with host nginx
The compose stack runs the book and inbox as containers bound to 127.0.0.1 only. Host nginx (which you already have) handles TLS and reverse-proxies both to the same hostname.
sh
# On the VPS, in the repo root:
cp .env.example .env
# Edit .env: set BOOK_ORIGIN, VITE_DOKIELI_INBOX_URL, INBOX_PUBLIC_URL,
# and REVIEW_TOKEN (openssl rand -hex 24)
docker compose up -d --buildThen drop nginx.conf.example into /etc/nginx/sites-available/the-house-edge.conf, symlink from sites-enabled/, and reload:
sh
sudo cp nginx.conf.example /etc/nginx/sites-available/the-house-edge.conf
sudo ln -sf ../sites-available/the-house-edge.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# TLS via certbot (one-shot, then auto-renew via the certbot systemd timer):
sudo certbot --nginx -d book.yushkevi.chURL layout
The default config puts both services on the same hostname, with the inbox at /dokieli/. This keeps everything same-origin so dokieli's fetch doesn't trigger a CORS preflight.
text
https://book.yushkevi.ch/ → VitePress book (container :80, proxied via 127.0.0.1:8080)
https://book.yushkevi.ch/dokieli/inbox → POST/GET annotations (container :4174, proxied via 127.0.0.1:4174)
https://book.yushkevi.ch/dokieli/annotations/… → Individual annotation resourcesFiles involved:
| File | Purpose |
|---|---|
inbox/server.js | Express + SQLite LDN inbox (~200 lines) |
inbox/Dockerfile | node:20-alpine image, non-root, SQLite data volume |
Dockerfile.book | Multi-stage: vitepress build → nginx serve |
docker-compose.yml | book + inbox services, both bound to 127.0.0.1 |
nginx.conf.example | Host-nginx server block for the front-door TLS termination |
.env.example | Template for the env vars the compose stack needs |
Subdomain layout (alternative)
If you'd rather serve the inbox at annotations.yushkevi.ch instead of as a subpath, the second server block in nginx.conf.example shows how. You'll need a second certbot run and matching env vars:
ini
BOOK_ORIGIN=https://book.yushkevi.ch
VITE_DOKIELI_INBOX_URL=https://annotations.yushkevi.ch/inbox
INBOX_PUBLIC_URL=https://annotations.yushkevi.chThe inbox already sets the right CORS headers based on ALLOWED_ORIGIN (which compose populates from BOOK_ORIGIN).
Local development
Run the inbox and book together on your laptop:
sh
# Terminal 1
cd inbox
npm install
PUBLIC_URL=http://localhost:4174 ALLOWED_ORIGIN=http://localhost:5173 \
node server.js
# Terminal 2
VITE_DOKIELI_INBOX_URL=http://localhost:4174/inbox npm run docs:devOpen http://localhost:5173/book/chapters/01_trade/DRAFT?review=dokieli, type your name, select some text, save the annotation, refresh — the comment is still there.
Backing up annotations
The inbox stores everything in a single SQLite file inside the annotations Docker volume.
sh
docker exec the-house-edge-inbox-1 \
sqlite3 /app/data/annotations.db '.backup /tmp/snap.db'
docker cp the-house-edge-inbox-1:/tmp/snap.db ./annotations-$(date +%F).dbPipe that into a nightly cron and upload to S3 / Backblaze if you want.
Things that aren't here
- Threaded replies in the UI. Dokieli supports them; the server stores annotations flat. The client renders threads from the body's
replyTo/inReplyTopointer if present. - Per-reviewer authentication. A single shared bearer token gates POSTs. Reviewer identity comes from the
Reviewertext field, which is honour-system. If you need real auth, front the inbox with nginx'sauth_basicand map each reviewer to a separatehtpasswdcredential. - Notification fan-out. No email or Slack ping on new annotation. Add a
fetch()call ininbox/server.js's POST handler if you want one. - Solid pod federation. This setup is a single-service inbox, not a node in a Solid network. If you want federated identity, sign in via dokieli's normal Solid flow instead and let it bypass our localStorage path entirely.