README current as of 2f3dec8 (2026-08-21).
A web-based markdown+ document editor. Write posts in a SvelteKit PWA, persist them to a git repo via isomorphic-git, and publish through Astro (or Hugo, etc.) deployed to Netlify or Cloudflare.
- Local-first — posts live in IndexedDB; sync to the git repo happens in the background
- Editor — markdown+ with autosave, offline editing, and tags autocomplete
- Post list — sort by edited / created / published date; filter by tag, draft, or page
- Publishing model — drafts excluded from production builds;
pageposts excluded from listings/RSS - PWA — installable, works offline
┌──────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ apps/web │────▶│ apps/proxy │────▶│ apps/api │ │ Git repo │
│ (SvelteKit) │ │ (Go CORS │ │ (Go) │ │ (content + │
│ Editor SPA │ │ proxy) │ │ Auth + │ │ Astro SSG) │
│ │◀────│ │◀────│ secrets │ │ │
└──────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
local-first │ ▲
IndexedDB + │ │
isomorphic-git ─ ─ ─ ─ ─ ─ ─ git push ─ ─ ┘ with server- ─ ─ ─ ┘
stored PAT
| Layer | Component | Description |
|---|---|---|
| Frontend | apps/web |
SvelteKit 5, adapter-static SPA, Svelte 5 runes, local-first PWA |
| Backend | apps/api |
Go + chi, SQLite, username/password auth, encrypted token storage |
| Proxy | apps/proxy |
Go CORS proxy for Git remote API calls |
| Storage | Git repos | Content repos per user/project; isomorphic-git pushes from the browser |
| Publish | builder-templates/ |
SSG templates (submodules) — users fork and connect to a provider |
- Node ≥ 24, pnpm ≥ 10
- Go ≥ 1.25
- golangci-lint (for linting the API)
This repo uses git submodules for the builder templates. Use a recursive clone:
git clone --recurse-submodules https://github.com/btsv-space/btsv.gitIf you've already cloned without --recurse-submodules:
git submodule update --init --recursive# Start all services (proxy, API, web)
make dev
# Or individually
make dev-web # http://localhost:5173
make dev-api # http://localhost:8080
make dev-proxy # http://localhost:9999crypto.subtle (used for isomorphic-git) requires a secure context. Localhost
works in HTTP, but testing from other devices on your LAN needs HTTPS.
First install mkcert and generate certs:
brew install mkcert # macOS
mkcert -install
mkdir -p .certs
mkcert -cert-file .certs/dev.pem -key-file .certs/dev-key.pem \
192.168.0.63 localhost 127.0.0.1Replace
192.168.0.63with your machine's actual LAN IP, or add extra IPs.
Then start everything with HTTPS:
make dev-hostThe dev-host target detects your LAN IP automatically, sets ALLOW_ORIGIN=*,
and passes TLS certs to all three services (Vite, API, proxy):
https://192.168.0.63:5173 Editor (Vite + SvelteKit)
https://192.168.0.63:8080 API (Go + chi)
https://192.168.0.63:9999 CORS proxy (Go)
Your browser will trust the certs automatically (mkcert root CA). On a phone:
- iOS: AirDrop
~/Library/Application\ Support/mkcert/rootCA.pemto the phone → Settings → General → Profiles → install → Settings → General → About → Certificate Trust Settings → enable. - Android: Copy
rootCA.pemto the phone → Settings → Security → Install from storage.
Without the CA, browsers show a warning — tap "Proceed anyway" (works fine).
make build
# Frontend → apps/web/build/
# API → apps/api/bin/servermake lintmake test # Unit tests (web, api, proxy)
make test-e2e-web # Playwright E2E — boots API + vite itself (uses .env.e2e)The three services are dockerized for production deployment:
| Service | Image | Port | Domain |
|---|---|---|---|
| Web | nginx (static SPA) | 8103 |
app.example.com |
| API | Go binary on alpine | 8101 |
api.example.com |
| Proxy | Go binary on alpine | 8102 |
proxy.example.com |
- Docker & Docker Compose
docker compose --env-file .env.production up --build -dProduction domain URLs (VITE_API_URL, VITE_PROXY_URL, ALLOW_ORIGIN) are
set as Docker build args and environment variables in docker-compose.yml.
Update .env.production if you run make start locally with production
builds instead of Docker.
| Variable | Set in | Purpose |
|---|---|---|
VITE_API_URL |
web build arg | API base URL baked into the SPA bundle |
VITE_PROXY_URL |
web build arg | Git CORS proxy URL baked into the SPA bundle |
ALLOW_ORIGIN |
api, proxy env vars | CORS origin header (the web app's domain) |
PORT |
api & proxy env vars | Internal listen port |
DATA_DIR |
api env var | SQLite database path (persisted via named volume) |
COOKIE_DOMAIN |
api env var | Session cookie domain; when set, also enables the Secure flag. Use .example.com in production; leave empty in dev |
Builder templates live as git submodules under builder-templates/. Each is an
independent repo that users fork to create their own blog site. We develop them
alongside the editor to keep the content contract in sync.
# Pull the latest commit from the submodule's main branch
git submodule update --remote builder-templates/btsv-template-astro
# Commit the updated submodule pointer in the main repo
git add builder-templates/btsv-template-astro
git commit -m "Update btsv-template-astro submodule"cd builder-templates/btsv-template-astro
# Work on the template, commit, push
git checkout main
# ... make changes ...
git add -A
git commit -m "Description of changes"
git push origin main
# Back in the main repo, record the new submodule pointer
cd ../..
git add builder-templates/btsv-template-astro
git commit -m "Update btsv-template-astro: description"The contract/ directory is the single source of truth between the editor and all builder templates. It contains a JSON Schema that defines the frontmatter shape.
| Field | Type | Required | Description |
|---|---|---|---|
title |
string |
yes | Post title |
dateCreated |
datetime |
yes | Original creation date |
dateUpdated |
datetime |
yes | Last modified date |
datePublished |
datetime |
no | Publication date (set when a draft is first published) |
description |
string |
no | SEO/social preview |
tags |
string[] |
no | Tag list |
draft |
boolean |
no | Exclude from production builds |
id |
string |
no | Internal identifier (auto-generated) |
slug |
string |
no | Custom URL slug |
page |
boolean |
no | Standalone page, excluded from listings/RSS |
Datetimes are ISO 8601 UTC (e.g. 2026-08-19T14:30:22Z); legacy day-precision
values (YYYY-MM-DD) remain valid.
The schema is strict (additionalProperties: false), but users can add any extra
fields to their frontmatter — the editor's parser captures them into extra,
passes them through untouched, and builder templates can read them. Core fields get
dedicated editor UI; custom fields don't (yet).
Posts use GitHub-flavored Markdown via MDX, plus:
<Callout>,<Figure>— MDX components shipped by builder templates@@ ... @@@— editor-only comment blocks stripped at build time
See contract/README.md for the full specification.
The backend exposes a REST API consumed by the frontend (apps/web/src/lib/api.ts).
The API base URL defaults to http://localhost:8080/api and can be overridden with
VITE_API_URL.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/auth/register |
— | { username, password, encryptedDek, kekSalt } → user |
POST |
/api/auth/login |
— | { username, password } → { user, encryptedDek, kekSalt }, sets session cookie |
POST |
/api/auth/logout |
— | Clears session cookie |
GET |
/api/auth/me |
session | → { id, username, encryptedDek, kekSalt } or null |
POST |
/api/auth/change-password |
session | { oldPassword, newPassword, encryptedDek, kekSalt } → 204 |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/projects |
session | → [{ id, name, repoUrl, createdAt }] |
POST |
/api/projects |
session | { name, repoUrl } → project |
GET |
/api/projects/:id/secret |
session | → { ciphertext, iv } (client-encrypted git token) |
POST |
/api/projects/:id/secret |
session | { ciphertext, iv } → 204 |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/user/preferences |
session | → preferences JSON |
PATCH |
/api/user/preferences |
session | Partial update → preferences JSON |
| Method | Path | Description |
|---|---|---|
GET |
/health |
{ "status": "ok" } |
- Git tokens are encrypted client-side (AES-GCM) with a per-user data key (DEK) before being sent to the API — the server only ever stores ciphertext
- The DEK is wrapped by a key derived from the user's password (PBKDF2) and stored
as
encryptedDek+kekSalt; changing the password re-wraps the DEK - Plaintext tokens are used in-memory by the frontend and never written to
localStorageor IndexedDB - Session cookies are
HttpOnly,SameSite=Strict, random 256-bit tokens with 14-day expiry
btsv/
├── apps/
│ ├── web/ SvelteKit 5 SPA (adapter-static)
│ ├── api/ Go + chi REST server
│ ├── proxy/ Go CORS proxy (GitHub / GitLab)
├── builder-templates/
│ ├── btsv-template-astro/ Astro blog template (git submodule)
│ └── btsv-template-hugo/ Hugo blog template (git submodule)
├── contract/
│ ├── frontmatter.schema.json Canonical JSON Schema
│ └── README.md Contract specification
├── Makefile Top-level dev/build/lint commands
├── .editorconfig
└── .gitignore
MIT