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, revisions with restore, settings, member list, CSV export |
| Media library | Implemented | Upload (magic-byte sniffed PNG/JPEG/GIF/WebP, dedup by hash), /media/:file serving, editor picker |
| Analytics | Implemented | Cookieless daily page-view + referrer aggregates; admin overview with 7/30/90d windows |
| 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 | Implemented | Playwright spec covers bootstrap, publish, signup, confirm, public page, media upload, RSS |
| 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..0015)
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_MEDIA_DIR |
Directory uploaded media files are stored under, default ./media |
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 |
POST /admin/login and POST /signup are throttled by a small in-process, in-memory fixed-window limiter (bins/blog-rs/src/rate_limit.rs): login allows 10 attempts per 15 minutes per client IP and per normalized email, signup allows 5 per hour per client IP. Client IP is read from CF-Connecting-IP (the production Cloudflare Tunnel sets this), falling back to X-Forwarded-For's first hop, then the TCP peer address. Exceeding either limit returns 429 with a Retry-After header. These limits are fixed constants, not configurable via env vars, and reset on process restart since state is per-process.
The per-email login limit means anyone who knows the admin address can keep the admin locked out by failing logins. Put /admin* behind Cloudflare Access (see DEPLOY.md) so strangers never reach the login form.
The workspace ships several hundred 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- Coverage check:
cargo llvm-cov --workspace --fail-under-lines 70 - Playwright end-to-end against a release build of the server
To run e2e tests locally:
cargo build -p blog-rs && cd tests/e2e && npm ci && npx playwright install chromium && npx playwright test
Page views are tracked with a cookieless, aggregate-only model: a background-checked middleware on the public reader routes increments a (day, path) counter and, if the request carries a Referer header, a (day, referrer host) counter. Nothing else is stored -- no cookies, no IP addresses, no user agents, and no per-visitor identifier of any kind, so there is nothing to link two page views to the same person. Requests are skipped when they carry DNT: 1 or Sec-GPC: 1, when the User-Agent looks like a bot/crawler, when they're htmx partials, or when they target admin/asset/health routes. Aggregate rows older than 400 days are pruned automatically. See /admin/analytics for the daily chart, top pages, and top referrers, and crates/db/src/analytics.rs / bins/blog-rs/src/middleware/page_views.rs for the implementation.
- Four draft seed articles under
content/articles/(domains 2–5) contain 13<!-- TODO: diagram? -->markers. The source research doc rendered those inline values and equations as images (![][imageN]), so the text never existed in the export; the author needs to fill them in before publishing.
MIT. See LICENSE.