Metadata-Version: 2.4
Name: volumen
Version: 0.4.0
Summary: A small, file-based Markdown blog engine with a built-in admin.
Author-email: Petr Balvín <petrbalvin@yandex.com>
License: MIT
Project-URL: Homepage, https://sourcedock.dev/petrbalvin/volumen
Project-URL: Repository, https://sourcedock.dev/petrbalvin/volumen
Project-URL: Issues, https://sourcedock.dev/petrbalvin/volumen/issues
Project-URL: Changelog, https://sourcedock.dev/petrbalvin/volumen/blob/main/CHANGELOG.md
Project-URL: Documentation, https://sourcedock.dev/petrbalvin/volumen/blob/main/docs
Keywords: blog,markdown,fastapi,cms,static-blog,toml,rss,json-feed,engine
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: POSIX :: BSD
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Text Processing :: Markup :: Markdown
Classifier: Typing :: Typed
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi[standard]>=0.115
Requires-Dist: itsdangerous>=2.2
Requires-Dist: jinja2>=3.1
Requires-Dist: markdown>=3.7
Requires-Dist: nh3>=0.2
Requires-Dist: pymdown-extensions>=10.14
Requires-Dist: tomli-w>=1.2
Requires-Dist: python-multipart>=0.0.20
Dynamic: license-file

# 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](https://fastapi.tiangolo.com/)                               |
| App server     | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`)          |
| Markdown       | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) |
| Frontmatter    | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
| Templating     | [Jinja2](https://jinja.palletsprojects.com/)                            |
| Sessions       | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) |
| Packaging      | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management |
| Lint / format  | [Ruff](https://docs.astral.sh/ruff/)                                   |
| Tests          | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) |

---

## 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`](docs/api-reference.md).

---

## Quick start

### Production / first install

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

```bash
# 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 `+++`:

```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 = ["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:

```toml
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:

```markdown
![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>`:

```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.

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`](docs/configuration.md) for the full
schema.

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

```bash
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       |

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

```json
{
  "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

```bash
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`](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`](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)
