# volumen — file-based Markdown blog engine **volumen** is a small, dependency-light blog engine written in **Python**. It serves your Markdown posts as a JSON API and includes a built-in, server- rendered admin with both a Markdown source editor and a visual editor — so any front-end (Vue, React, Svelte, plain HTML) can consume your content without re-implementing the engine. It is designed to be: - **File-based** — every post is a plain `.md` file with TOML frontmatter, safe to commit to Git and edit in your favourite editor. - **Self-contained** — runs as a single Python process (Uvicorn); no Node build step is required to operate the engine or its admin. - **Multi-user with roles** — username + password login; **admin** (full access, manages users) and **author** (posts and own account) roles. - **Multi-site friendly** — one instance per blog, configurable per instance. - **Markdown-first** — the visual editor round-trips through the same renderer that powers the public API, so what you store is always Markdown. - **IPv6-first** — the default bind address is `::` (IPv6 with automatic IPv4 fallback); bind to `::1` when running behind nginx so the admin port is not exposed to the network. --- ## Stack | Concern | Choice | |----------------|------------------------------------------------------------------------| | Language | Python 3.14+ | | Web framework | [FastAPI](https://fastapi.tiangolo.com/) | | App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) | | Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) | | Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) | | Templating | [Jinja2](https://jinja.palletsprojects.com/) | | Sessions | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) | | Packaging | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management | | Lint / format | [Ruff](https://docs.astral.sh/ruff/) | | Tests | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) | --- ## Features (target) - Markdown posts with TOML frontmatter (`title`, `date`, `lang`, `tags`, `draft`, `translations`, …) - Multi-language posts with a translations map - Clean JSON API at `/api/volumen/`: - `GET /site` — site metadata - `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit` filters - `GET /posts/{slug}` — single post (raw Markdown + rendered HTML) - `GET /tags` — tag cloud with counts - `GET /feed.xml` — RSS 2.0 feed - `GET /feed.json` — JSON Feed 1.1 - `GET /sitemap.xml` — sitemap - Server-rendered admin at `/admin/`: - Username + password login (scrypt-hashed via `hashlib.scrypt`, verified in constant time with `secrets.compare_digest`) - Settings page: change password, change username, manage users and roles (admin only) - Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens - List, create, edit, delete posts - **Markdown source** editor and a **visual** editor with live preview, both storing Markdown - "Reload from disk" to pick up out-of-band edits - Permissive CORS for the public API, same-origin only for the admin > Status: the public read API (`/api/volumen`) and the server-rendered admin > (`/admin`, login + CRUD + Markdown editor with live preview) are implemented > and tested. See [`docs/api-reference.md`](docs/api-reference.md). --- ## Quick start ### Production / first install ```bash # 1. Install the volumen CLI (once, on the machine): uv tool install volumen # or: pipx install volumen # 2. Bootstrap (once, typically as root — generates config, prompts for # an admin password, and installs a hardened systemd unit): sudo volumen init sudo systemctl status volumen # 3. Reverse-proxy with nginx and you're done — see docs/deployment.md. ``` `uv tool install` (or `pipx install`) drops a self-contained `volumen` executable on `PATH`; `volumen init` is the operational bootstrap that renders the config, creates the data dirs, generates a session key, and (with `--systemd`, on by default under root) installs a hardened unit file. See `volumen init --help` and `docs/deployment.md` for the full flow. ### Development (this checkout) ```bash # 1. Install dependencies (Python 3.14+ and uv required) just install # 2. Run just run ``` `just install` runs `uv sync` (creates `.venv/` and installs the locked dependency set from `uv.lock`). `just run` launches the `volumen` console script from the venv with the bundled sample posts. The admin will live at and the API at . The dev admin password is `admin` (the hash lives in `./config.toml`). --- ## Post format Each post is a Markdown file with a TOML frontmatter block delimited by `+++`: ```markdown +++ title = "My post title" slug = "my-post" # optional, defaults to the filename date = 2026-01-15 # first-class TOML date lang = "en" # language code author = "Petr Balvín" # optional fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution tags = ["python", "web"] # optional draft = false # optional excerpt = "Short summary" # optional, auto-derived from body if missing all_langs = false # optional, show this post in every language [translations] # optional, maps lang → slug cs = "muj-prispevek" +++ # Heading Body in **Markdown**, rendered by python-markdown. ``` Posts are organised on disk as either: - `content_dir/.md` (default language), or - `content_dir//.md` (per-language subdirectory) The `translations` table links posts in different languages together so a front-end can offer a language switcher. Set `all_langs = true` on a post to show it in **every** language (useful for posts that have no per-language translations). ### Cover caption A `cover_caption` frontmatter field renders a short caption next to the cover image — useful for photo credits or a one-line blurb: ```toml cover = "media/cover.webp" cover_caption = "Photo by Petr Balvín" ``` ### Titled images as figures A Markdown image with a **title** (`![alt](url "caption")`) is rendered as a `
` with a `
` and a small `i` info icon. Images without a title render exactly as before: ```markdown ![A sunset over the hills](media/sunset.webp "Sunset from the ridge above the village") ``` ### Fediverse attribution Set `fediverse_creator = "@user@instance.tld"` on a post to attach a Mastodon handle to it. The value is exposed as `fediverse_creator` on the post payload (`/api/volumen/posts/{slug}` and `/api/volumen/posts`), and as `` in the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the API can drop it straight into ``: ```html ``` A site-wide default can be set in `config.toml` (`[site].fediverse_creator`); a per-post value takes precedence. ### Why TOML instead of YAML - **First-class dates** — `date = 2026-01-15` is a real date, not a guess. - **Explicit typing** — no YAML implicit-coercion footguns (`lang = no` becoming `false`). - **No whitespace pitfalls** — indentation is not significant. The engine parses it with the Python 3.11+ standard library `tomllib`; no extra TOML dependency is needed at runtime. --- ## Configuration A single TOML file, auto-generated at `/etc/volumen/config.toml` when you run `volumen init` (or, for local development, by `volumen serve` on first start). See [`docs/configuration.md`](docs/configuration.md) for the full schema. ```toml [server] host = "::" # IPv6 + IPv4 fallback; use "::1" behind nginx port = 9090 env = "production" trust_proxy = true cookie_secure = true # required in production behind HTTPS content_dir = "/var/lib/volumen/posts" users_file = "/var/lib/volumen/users.toml" [site] title = "My Blog" description = "A blog powered by volumen." base_url = "https://example.com" language = "en" author = "Anonymous" [admin] password_hash = "" # populated by `volumen init` session_key = "" # populated by `volumen init` session_ttl = 86400 min_password_length = 10 cookie_secure = false # set to true in production behind HTTPS ``` `volumen init` writes the bootstrap admin hash and a fresh session key for you. To rotate the admin password later: ```bash sudo volumen hash-password # returns a scrypt$… string; paste into [admin].password_hash ``` --- ## Public API The API is at `/api/volumen/`. CORS is open (`*`) for read endpoints; admin routes are same-origin only. ### `GET /api/volumen/posts` | Name | Type | Default | Description | |-------|--------|---------|----------------------| | lang | string | — | Filter by language | | tag | string | — | Filter by tag | | q | string | — | Search in title/body | | page | int | 1 | Page number | | limit | int | 20 | Posts per page | ```json { "page": 1, "page_size": 20, "total": 42, "has_next": true, "has_prev": false, "posts": [ { "slug": "welcome", "title": "Welcome to volumen", "excerpt": "A short introduction…", "date": "2026-01-15", "lang": "en", "tags": ["intro", "python"], "author": "Petr Balvín", "translations": { "cs": "vitame-vas" }, "url": "/api/volumen/posts/welcome" } ] } ``` ### `GET /api/volumen/posts/{slug}?lang=en` Returns the full post including the raw Markdown body and the rendered HTML. ```json { "slug": "welcome", "title": "Welcome to volumen", "date": "2026-01-15", "lang": "en", "tags": ["intro", "python"], "excerpt": "…", "body": "# Welcome to **volumen**\n\n…", "html": "

Welcome to volumen

…", "translations": { "cs": "vitame-vas" } } ``` --- ## Development ```bash just # list recipes just install # uv sync (creates .venv/ from uv.lock) just run # run the dev server (volumen serve) just dev # run against ./posts and ./config.toml on :9090 just build # ruff check + ruff format --check (zero warnings) just test # uv run pytest just fmt # ruff format + ruff check --fix just uninstall # remove build artifacts (dist/, __pycache__, .pytest_cache, .ruff_cache) ``` The console script lives at `src/volumen/cli.py` and is registered as `volumen` via the `[project.scripts]` entry in `pyproject.toml`; `uv run` shims it onto `PATH` inside `.venv/`. In production the CLI is installed globally via `uv tool install volumen` (or `pipx install volumen`) and the operational bootstrap is done with `sudo volumen init` — see [`docs/deployment.md`](docs/deployment.md). --- ## Public site integration volumen is headless for the front-end: it does not render your public site, it only renders its own admin. The public website (e.g. [`petrbalvin.org`](https://petrbalvin.org), a Vue 3 + Vite + Tailwind SPA) consumes the JSON API. If you proxy `/api/volumen/*` through nginx on the same origin, no front-end configuration is required. --- ## Documentation - [`docs/architecture.md`](docs/architecture.md) — components and request flow - [`docs/configuration.md`](docs/configuration.md) — config schema (TOML) - [`docs/api-reference.md`](docs/api-reference.md) — public JSON API - [`docs/deployment.md`](docs/deployment.md) — systemd, nginx, first run - [`docs/security.md`](docs/security.md) — threat model and controls ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, commit conventions, the pull request flow, and how releases are cut. ## License MIT — see [LICENSE](LICENSE). Copyright © 2026 [Petr Balvín](https://petrbalvin.org)