Previously the store only checked the content directory's own mtime, which does not change when an existing file is modified in place. Now track the newest mtime across all .md files as well, so edits picked up from disk (manual changes, git pull) are detected without a restart.
volumen — file-based Markdown blog engine
volumen is a small, dependency-light blog engine written in Ruby. 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
.mdfile with TOML frontmatter, safe to commit to Git and edit in your favourite editor. - Self-contained — runs as a single Ruby process; 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.
Stack
| Concern | Choice |
|---|---|
| Language | CRuby (≥ 3.2) |
| Web framework | Sinatra |
| Markdown | kramdown |
| Frontmatter | TOML via toml-rb |
| App server | Puma |
| Packaging | a Ruby gem with an exe/volumen CLI |
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 metadataGET /posts— paginated list withlang,tag,page,limitfiltersGET /posts/{slug}— single post (raw Markdown + rendered HTML)GET /tags— tag cloud with countsGET /feed.xml— RSS 2.0 feedGET /sitemap.xml— sitemap
- Server-rendered admin at
/admin/:- Username + password login (scrypt-hashed via Ruby's
OpenSSL::KDF) - 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
- Username + password login (scrypt-hashed via Ruby's
- 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. Seedocs/api-reference.md.
Quick start
# 1. Install dependencies (Ruby ≥ 3.2)
just install
# 2. Run
just run
The admin will live at http://localhost:9090/admin/ and the API at http://localhost:9090/api/volumen/posts.
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 = ["ruby", "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 kramdown.
Posts are organised on disk as either:
content_dir/<slug>.md(default language), orcontent_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).
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 dates —
date = 2026-01-15is a real date, not a guess. - Explicit typing — no YAML implicit-coercion footguns (
lang = nobecomingfalse). - No whitespace pitfalls — indentation is not significant.
Configuration
A single TOML file, auto-generated at /etc/volumen/config.toml on first
start. See docs/configuration.md for the full
schema.
[server]
host = "0.0.0.0"
port = 9090
content_dir = "/var/lib/volumen/posts"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[admin]
password_hash = ""
session_key = ""
session_ttl = 86400
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 |
| page | int | 1 | Page number |
| limit | int | 20 | Posts per page |
{
"page": 1,
"page_size": 20,
"total": 42,
"posts": [
{
"slug": "welcome",
"title": "Welcome to volumen",
"excerpt": "A short introduction…",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "ruby"],
"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", "ruby"],
"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 # bundle install
just run # run the dev server (exe/volumen serve)
just build # rubocop (zero warnings) + build the gem → pkg/volumen.gem
just test # bundle exec rake test (minitest)
just uninstall # remove build artifacts (pkg/, *.gem)
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
docs/architecture.md— components and request flowdocs/configuration.md— config schema (TOML)docs/api-reference.md— public JSON APIdocs/deployment.md— systemd, nginx, first rundocs/security.md— threat model and controls
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