284 lines
8.6 KiB
Markdown
284 lines
8.6 KiB
Markdown
# 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](https://sinatrarb.com) |
|
|
| Markdown | [kramdown](https://kramdown.gettalong.org) |
|
|
| Frontmatter | TOML via [`toml-rb`](https://github.com/emancu/toml-rb) |
|
|
| App server | [Puma](https://puma.io) |
|
|
| 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`](docs/api-reference.md).
|
|
|
|
---
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
# 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 `+++`:
|
|
|
|
```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 = ["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).
|
|
|
|
### 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** (``) is rendered as a
|
|
`<figure>` with a `<figcaption>` and a small `i` info icon. Images without a
|
|
title render exactly as before:
|
|
|
|
```markdown
|
|

|
|
```
|
|
|
|
### 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>`:
|
|
|
|
```html
|
|
<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-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`](docs/configuration.md) for the full
|
|
schema.
|
|
|
|
```toml
|
|
[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 |
|
|
|
|
```json
|
|
{
|
|
"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.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```bash
|
|
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`](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)
|