petrbalvin 4abb5585df perf: memoize published_posts within a single request
Store the filtered and sorted list of published posts in an instance
variable so repeated calls during the same request (posts list, tags,
feeds, sitemap) reuse the cached result instead of re-filtering and
re-sorting from scratch each time. Sinatra resets instance variables
between requests, so the cache stays fresh.
2026-06-26 20:34:37 +02:00
2026-06-25 22:12:55 +02:00

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 .md file 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 metadata
    • GET /posts — paginated list with lang, tag, 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 /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
  • 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

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

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.

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

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%