A self-hosted blog engine written in Rust. Each post is a composable document of typed content blocks: markdown plus a small registry of interactive shortcodes (code playgrounds, charts, animations, callouts, images, embeds). The engine runs as a single binary on a small VPS, with SQLite for storage, an htmx admin dashboard, a free-member newsletter, and per-block JavaScript loaded only on the pages that need it.
| Subsystem | Status | Notes |
|---|---|---|
| Content pipeline | Implemented | Frontmatter, CommonMark, shortcode lexer, render pipeline |
| Shortcode registry | Implemented | callout, code, image, chart, animate, playable, embed |
CLI renderer (blog-rs-render) |
Implemented | Markdown file in, HTML plus asset manifest out |
| SQLite data layer | Implemented | Users, posts, tags, sessions, members, outbox, settings, FTS5 search |
| Auth | Implemented | argon2id passwords, sessions, double-submit CSRF, HMAC-signed member tokens |
| HTTP server | Implemented | Axum, tower middleware stack, healthz, readyz, embedded assets, correlation IDs |
| Public reader | Implemented | Home, post detail, tags, series, search (FTS5), RSS, sitemap, robots |
| Admin dashboard | Implemented | htmx editor, post CRUD, publish + fan-out, settings, member list, CSV export |
| Members and newsletter | Implemented | Signup, HMAC-confirm, one-click unsubscribe, preferences, background outbox worker |
| Research importer | Implemented | tools/import-research converts a markdown research dump into per-domain posts |
| End-to-end test | Scaffolded | Playwright spec covers bootstrap, publish, signup, confirm, public page |
| CI | Configured | GitHub Actions: fmt, clippy, workspace tests, Playwright e2e |
blog-rs/
Cargo.toml workspace manifest
rust-toolchain.toml pins stable channel
justfile build, test, lint recipes
migrations/ SQLx migration files (0001..0004)
assets/ static CSS / JS / fonts embedded at build time
content/
samples/ three showcase posts that exercise every shortcode
articles/ seed articles generated by the research importer
crates/
content/ markdown plus shortcode parser and render pipeline
shortcodes/ Shortcode trait, args parser, seven block types
db/ SQLx pool, queries, FTS5 triggers
auth/ argon2id, sessions, CSRF, HMAC tokens
bins/
blog-rs/ the server binary (Axum, htmx admin, members, worker)
blog-rs-render/ CLI that renders one markdown file to HTML
tools/
import-research/ markdown research dump to seed-articles converter
tests/
fixtures/ input markdown used by golden tests
e2e/ Playwright end-to-end scaffold
.github/workflows/ci.yml CI: fmt, clippy, workspace tests, Playwright
- Rust 1.78 or newer (the toolchain file pins stable)
- SQLite is linked through
sqlxfeatures; no system install required for build - Node 20+ if you want to run the Playwright end-to-end test locally
Clone and run the test suite:
git clone https://github.com/Bunty9/blog-rs
cd blog-rs
cargo test --workspace
Render any markdown file to HTML through the CLI:
cargo run -p blog-rs-render -- \
content/samples/markdown-shortcode-tour.md \
--assets-out /tmp/assets.json \
--frontmatter-out /tmp/fm.yaml \
> /tmp/out.html
Boot the server locally with an SQLite database in the current directory:
# 32-byte URL-safe-base64 secret for HMAC signing of member tokens
export BLOG_RS__SIGNING_KEY=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')
# First-boot admin seed (ignored after the users table is populated)
export BLOG_RS__ADMIN_BOOTSTRAP__EMAIL=admin@example.com
export BLOG_RS__ADMIN_BOOTSTRAP__PASSWORD=changeme
# SQLite URL (default is sqlite://blog-rs.db)
export BLOG_RS__DATABASE_URL=sqlite://./blog.db?mode=rwc
# Public-facing URL used in RSS, sitemap, canonical links, og:url
export BLOG_BASE_URL=http://127.0.0.1:8080
export BLOG_TITLE='blog-rs'
# Mail goes to ./test-mailbox.eml instead of SMTP
export BLOG_RS_MAIL=test
cargo run -p blog-rs
On first boot the server seeds the admin row from BLOG_RS__ADMIN_BOOTSTRAP__* and applies the migration set. After that, those bootstrap variables are ignored and the password lives only as an argon2id hash in the users table. BLOG_RS__SIGNING_KEY is mandatory; the server refuses to start with an empty or non-base64 value.
Open http://127.0.0.1:8080/admin/login to log in as JSON (POST {"email","password"} returns the session cookies). Reader side at http://127.0.0.1:8080/. Healthchecks at /healthz and /readyz (the latter also verifies the outbox worker heartbeat).
Three runnable showcase posts live under content/samples/:
hello-world.mdshort intro post with a callout, a Rust playground block, and an image.interactive-blog-platform.mdmeta-post about the engine itself; demonstrates a chart with both inlinedataand an externalsrc, an animation block, and an embedded Rust playground.markdown-shortcode-tour.mdexhaustive tour that exercises every shortcode and every preset.
Render any of them through the CLI or import them into the running server's database to see the full reader pipeline.
Posts are markdown files with a YAML frontmatter block. Interactive content uses shortcodes with the Hugo-style syntax {{< name args >}} for self-closing blocks and a paired {{< /name >}} for blocks with a body.
Example:
---
title: Bare-metal Rust on Cortex-M4
tags: [rust, embedded]
status: draft
---
{{< callout type="info" >}}
Bare-metal Rust drops the standard library entirely.
{{< /callout >}}
{{< code lang="rust" playground="true" >}}
#![no_std]
#![no_main]
{{< /code >}}
{{< chart type="bar" src="data/cycles.json" caption="Preempt cycles" >}}
Built-in shortcode names and the assets they pull in:
| Name | Body | Notable args | Assets |
|---|---|---|---|
callout |
required | type (info, warn, tip, danger) |
one CSS file |
code |
required | lang, playground (rust-only, bool) |
CodeMirror bundle |
image |
none | src, alt, caption, width, aspect |
one CSS file |
chart |
none | type, src or data, caption |
Chart.js plus glue |
animate |
required | preset (fade, slide-up, slide-left, scale, custom), keyframes |
Motion One plus glue |
playable |
none | id (currently rust-playground), gist |
none |
embed |
none | url (YouTube, Twitter, link fallback) |
none |
Adding a new block type means writing a struct that implements the Shortcode trait in crates/shortcodes/src/ and registering it in default_registry(). The render pipeline picks it up automatically; the page templates pick up its asset manifest entries automatically through the per-page injection helper.
Runtime configuration is layered via figment: built-in defaults, then a TOML file at BLOG_CONFIG_PATH (optional), then environment variables prefixed BLOG_RS__.
Important environment variables:
| Variable | Purpose |
|---|---|
BLOG_RS__BIND |
Address to bind, default 127.0.0.1:8080 |
BLOG_RS__DATABASE_URL |
SQLite URL, default sqlite://blog-rs.db |
BLOG_RS__SIGNING_KEY |
URL-safe-base64, >= 32 bytes. Mandatory; server refuses empty |
BLOG_RS__ADMIN_BOOTSTRAP__EMAIL |
First-boot admin email (ignored after the users table is seeded) |
BLOG_RS__ADMIN_BOOTSTRAP__PASSWORD |
First-boot admin password (ignored after seed) |
BLOG_RS__SESSION_LIFETIME_SECONDS |
Session cookie lifetime, default 14 days |
BLOG_RS__CONFIRM_TOKEN_TTL_SECONDS |
HMAC-signed member token TTL, default 24 hours |
BLOG_RS__LOG_LEVEL |
tracing EnvFilter expression |
BLOG_RS__MAX_DB_CONNECTIONS |
SQLx pool size, default 8 |
BLOG_BASE_URL |
Public URL used in RSS, sitemap, canonical, og:url |
BLOG_TITLE |
Site title shown in templates and RSS |
BLOG_DESCRIPTION |
Site description for RSS and meta tags |
BLOG_RS_MAIL |
test writes mail to ./test-mailbox.eml; unset uses SMTP |
BLOG_SMTP_HOST, BLOG_SMTP_PORT, BLOG_SMTP_USERNAME, BLOG_SMTP_PASSWORD, BLOG_SMTP_FROM |
SMTP transport |
OUTBOX_POLL_INTERVAL |
Outbox worker poll interval in seconds, default 5 |
OUTBOX_RECLAIM_AFTER |
Stale-claim recovery threshold, default 300 seconds |
The workspace ships 447 tests across the crates, integration test binaries, and the importer:
cargo test --workspace
Breakdown (counts grow as #[path]-included modules are re-tested in each integration binary):
contentfrontmatter, CommonMark, the shortcode lexer, the render pipeline, the asset manifest, golden snapshots.shortcodesargs parser plus each shortcode implementation.dbevery table module against an in-memory SQLite pool, the migrations runner, and the FTS5 triggers.authargon2id round-trips, CSRF double-submit, HMAC tokens with TTL and tamper-detection.bins/blog-rsunit tests on each route handler, plus integration tests that boot the full router (health, embedded assets, signup to confirm to unsubscribe, publish fan-out to confirmed members, outbox worker tick).tools/import-researchparse plus emit unit tests plus a synthetic-fixture round-trip integration test.
Snapshot tests use insta. To accept regenerated snapshots after an intentional output change, run INSTA_UPDATE=always cargo test -p content --test golden and inspect the diff in the .snap files before committing.
The justfile shortcuts the common tasks:
just # test
just build # cargo build --workspace
just test # cargo test --workspace
just fmt # cargo fmt --all
just lint # cargo clippy --workspace --all-targets -- -D warnings
The clippy bar is set to -D warnings. The current workspace is clean.
.github/workflows/ci.yml runs on every push and pull request:
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace- Playwright end-to-end against a release build of the server
The Playwright job needs tests/e2e/package-lock.json for npm ci. Generate it once with (cd tests/e2e && npm install) and commit the lockfile before the e2e job will go green.
db::members::enqueue_confirmwrites the confirm-purpose outbox row withpost_id = 0. Integration tests seed a sentinelposts(id = 0)row; production needs either a sentinel migration or a schema change that allows nullpost_idon confirm-purpose rows.- The
members::unsubscribeSQL overwritesunsubscribed_aton every call; observable behaviour is idempotent but the timestamp is bumped on repeats. ACOALESCEkeeps the original. - Five seed articles under
content/articles/retain<!-- TODO: chart? -->markers where the source research had numeric tables; author review is intended before public publish.
MIT. See LICENSE.