Skip to main content

A small, file-based Markdown blog engine with a built-in admin.

Project description

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

volumen-0.4.2.tar.gz (102.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

volumen-0.4.2-py3-none-any.whl (82.3 kB view details)

Uploaded Python 3

File details

Details for the file volumen-0.4.2.tar.gz.

File metadata

  • Download URL: volumen-0.4.2.tar.gz
  • Upload date:
  • Size: 102.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for volumen-0.4.2.tar.gz
Algorithm Hash digest
SHA256 51ea88544275f20da82fa1934b91cedb797ab7b04ee129314425abe25953b1b7
MD5 0b29d91b568a538f0475f91f3e2c5745
BLAKE2b-256 9af73f4ca8f229f127da380694cff569d417a2ef8559c607c847af999a9e7534

See more details on using hashes here.

File details

Details for the file volumen-0.4.2-py3-none-any.whl.

File metadata

  • Download URL: volumen-0.4.2-py3-none-any.whl
  • Upload date:
  • Size: 82.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for volumen-0.4.2-py3-none-any.whl
Algorithm Hash digest
SHA256 432b60f1c2ea9faf1805bdbfdb61d1463eb76fb6fd31232e0d430702948ae35d
MD5 f36056c707146f6881fcea95134f8550
BLAKE2b-256 1b47fd82f723a4f8ac63d3c97ecf76ce6745f03a2c6c4627a308b3f8b7978d87

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page