petrbalvin bce4f5e223
Release / release (push) Successful in 1m11s
chore: merge development into main (v0.4.2)
2026-07-27 10:37:47 +02:00
2026-07-26 18:15:29 +02:00
2026-07-26 18:15:29 +02:00
2026-07-26 18:15:29 +02:00
2026-07-27 10:36:44 +02:00
2026-07-26 18:15:29 +02:00
2026-07-26 18:15:29 +02:00
2026-07-27 10:36:44 +02:00
2026-07-26 18:15:29 +02:00

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
App server Uvicorn (via fastapi[standard])
Markdown python-markdown + pymdown-extensions
Frontmatter TOML via stdlib tomllib + tomli-w
Templating Jinja2
Sessions Starlette SessionMiddleware (signed cookies via itsdangerous)
Packaging Wheel + sdist via setuptools; uv for dependency management
Lint / format Ruff
Tests pytest + httpx

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.


Quick start

Production / first install

# 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)

# 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 http://localhost:9090/admin/ and the API at http://localhost:9090/api/volumen/posts. 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 +++:

+++
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/<slug>.md (default language), or
  • content_dir/<lang>/<slug>.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:

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 <figure> with a <figcaption> and a small i info icon. Images without a title render exactly as before:

![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 <dc:creator> in the RSS feed and authors[].name in the JSON feed. A front-end consuming the API can drop it straight into <head>:

<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">

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 datesdate = 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 for the full schema.

[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:

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
{
  "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.

{
  "slug": "welcome",
  "title": "Welcome to volumen",
  "date": "2026-01-15",
  "lang": "en",
  "tags": ["intro", "python"],
  "excerpt": "…",
  "body": "# Welcome to **volumen**\n\n…",
  "html": "<h1>Welcome to <strong>volumen</strong></h1>…",
  "translations": { "cs": "vitame-vas" }
}

Development

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.


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, 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

Contributing

See CONTRIBUTING.md for development setup, code style, commit conventions, the pull request flow, and how releases are cut.

License

MIT — see LICENSE. Copyright © 2026 Petr Balvín

S
Description
A small, file-based Markdown blog engine in Python. Posts are plain-text files with TOML frontmatter — no database. Ships a JSON API, a server-rendered admin with a visual editor, RSS / JSON Feed, and full-text search.
Readme MIT
604 KiB
v0.4.2
Latest
2026-07-27 08:38:11 +00:00
Languages
Python 74.4%
Jinja 25.5%
Just 0.1%