Markdown → beautiful themed HTML, with a collaborative viewer.
Documents, comments, invites, sessions, and assets are persisted on the server. The browser only stores local auth/session helpers and UI state such as invite tokens, display name, recent docs, and theme.
See REQUIREMENTS.md and PLAN.md.
packages/renderer— core library: Markdown → HTML + metadatapackages/cli—marginaliaCLIpackages/element—<marginalia-doc>web component (Shadow DOM)packages/react— thin React wrapperpackages/themes— CSS themesapps/server— Hono + Bun collaborative serverapps/web— Vite + React SPA viewer/editor
bun install
bun test
# Dev servers (server on :3434, Vite on :5173)
bun run dev
# Production build + runtime
bun run build
bun run startDocuments reference images and (planned) include files by a name that
appears in the source —  in markdown, image::cat.png[]
in AsciiDoc. Those names resolve against a per-document asset store.
- Uploads happen through the editor: drop or paste an image into the editor pane, or click the dropzone that replaces a missing-image reference. The file is bound to the exact name used in source.
- Viewers request assets through
/api/documents/:uid/assets/:refName, which goes through the same authorization check as the document itself — a user without access to the document cannot fetch its assets, even with a direct URL. - Blobs are content-addressed (sha256). The same bytes uploaded under two different names — or to two different documents — are stored once. Detaching the last reference garbage-collects the blob.
Asset endpoints (all gated by per-document authz; writes require
editor+):
GET /api/documents/:uid/assets— list attached assetsPOST /api/documents/:uid/assets— multipart upload (file,ref_name, optionalkind)GET /api/documents/:uid/assets/:refName— fetch bytes (ETag +Cache-Control: private, max-age=0, must-revalidateso access is re-checked on every hit; non-image mimes are served asContent-Disposition: attachment)DELETE /api/documents/:uid/assets/:refName— detach (and GC the blob if nothing else references it)
Upload size defaults to 16 MiB; override with maxAssetBytes in
config.ts.
Two backends; same interface, one config switch.
Filesystem (default, zero-config). Blobs land under
MARGINALIA_DATA_DIR/blobs/<sha[0:2]>/<sha>. Good for self-hosting,
Docker volumes, and local development.
S3-compatible (any of AWS S3, Cloudflare R2, MinIO, Backblaze B2,
DigitalOcean Spaces…). Enable with MARGINALIA_BLOB_STORAGE=s3 plus:
MARGINALIA_BLOB_STORAGE=s3
MARGINALIA_S3_BUCKET=marginalia-blobs
MARGINALIA_S3_ACCESS_KEY_ID=...
MARGINALIA_S3_SECRET_ACCESS_KEY=...
# Optional — AWS S3 picks the right endpoint from the region if omitted.
MARGINALIA_S3_ENDPOINT=http://localhost:9000 # e.g. MinIO
MARGINALIA_S3_REGION=auto
MARGINALIA_S3_PREFIX=prod/blobs/ # key prefix inside the bucket
MARGINALIA_S3_VIRTUAL_HOSTED=1 # if your endpoint needs itCredentials must be readable at startup; the server fails loudly if any required S3 var is missing. All reads still go through the per-document proxy — never share bucket credentials or pre-signed URLs with end users.
Documents can be exported and imported as versioned JSON bundles through the server API:
GET /api/documents/:uid/exportdownloads a.marginalia.jsonbundlePOST /api/documents/importcreates a new document from a previously exported bundle
The bundle includes:
- document metadata and markdown source
- comment threads
- renderer metadata (
frontmatter, TOC, assets, mermaid blocks, block map, warnings)
That makes the export readable by external tools while still round-tripping back into Marginalia.
This repo includes Docker-based deployment automation:
- Docker image built and pushed to GHCR by build-and-push.yml
- dev auto-deploy via deploy-dev.yml
- prod manual deploy via deploy-prod.yml
- host-side rollout script at deploy-instance.sh
These are the main runtime env vars the container understands:
PORT— HTTP listen port inside the container. Default:3434MARGINALIA_DATA_DIR— persistent data directory. Default: repo-root.data/in local dev,/app/.data/in DockerMARGINALIA_WEB_DIR— built SPA directory. Default:/app/apps/web/distAPP_ENV_LABEL— optional label appended to the browser title, e.g.DEVMARGINALIA_BLOB_STORAGE—fs(default) ors3. See Blob storage backend for the S3 env vars.
If you use the bundled GitHub Actions deploy workflows, configure:
- Secrets:
VPS_HOST,VPS_USER,VPS_SSH_PRIVATE_KEY,DEPLOY_PATH - Variable:
DOMAIN
On the deployment host, the deploy script looks for:
$DEPLOY_PATH/.env.dev$DEPLOY_PATH/.env.prod
Those files can define the runtime env vars above, plus optional deploy-time overrides like:
HOST_PORT— host port to publish the container onHOST_BIND_IP— defaults to127.0.0.1CONTAINER_NETWORK— optional Docker network name
docker build -t marginalia .
docker run -d \
--name marginalia \
-p 3434:3434 \
-v "$PWD/.data:/app/.data" \
-e APP_ENV_LABEL=DEV \
-e MARGINALIA_DATA_DIR=/app/.data \
-e MARGINALIA_WEB_DIR=/app/apps/web/dist \
marginaliaAll persistent server state lives in a single directory — the repo-root
.data/ by default in local development, or the path in
MARGINALIA_DATA_DIR (see config.ts):
.data/
├── db.sqlite SQLite DB: documents, invites, sessions, doc_users,
│ comments, comment_mentions, edit_proposals,
│ assets, document_assets
├── db.sqlite-wal WAL file (journal mode)
├── db.sqlite-shm Shared-memory index for the WAL
├── repo/ Git repo holding every document as <uid>.md
└── blobs/ Content-addressed asset binaries (FS backend only;
absent when MARGINALIA_BLOB_STORAGE=s3)
Stop the server, then delete the data directory:
rm -rf .data/Next startup recreates the directory, an empty SQLite schema, and a fresh
git init. Every document, invite, and comment is gone.
To reset while preserving the git history for manual inspection, delete only the DB files:
rm -f .data/db.sqlite .data/db.sqlite-wal .data/db.sqlite-shmNote that the git repo references documents by uid; dropping the DB
orphans the .md files (they're no longer accessible via any URL).
The blobs/ directory is similarly orphaned — those files are
addressable only through the assets / document_assets tables, so
once the DB is gone they're dead weight and safe to remove.
This only clears browser-held auth/session helpers and UI state. It does not remove any server-stored documents, comments, history, invites, or assets.
Everything the web app persists locally lives in localStorage under the
marginalia.* prefix. Password-protected docs also use the
marginalia_session cookie. Or just wipe the site in browser settings.