Skip to content

Latest commit

 

History

144 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

blog-rs

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.

Status

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

Workspace layout

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

Requirements

  • Rust 1.78 or newer (the toolchain file pins stable)
  • SQLite is linked through sqlx features; no system install required for build
  • Node 20+ if you want to run the Playwright end-to-end test locally

Quick start

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

Sample posts

Three runnable showcase posts live under content/samples/:

  • hello-world.md short intro post with a callout, a Rust playground block, and an image.
  • interactive-blog-platform.md meta-post about the engine itself; demonstrates a chart with both inline data and an external src, an animation block, and an embedded Rust playground.
  • markdown-shortcode-tour.md exhaustive 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.

Authoring model

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.

Configuration

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

Testing

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):

  • content frontmatter, CommonMark, the shortcode lexer, the render pipeline, the asset manifest, golden snapshots.
  • shortcodes args parser plus each shortcode implementation.
  • db every table module against an in-memory SQLite pool, the migrations runner, and the FTS5 triggers.
  • auth argon2id round-trips, CSRF double-submit, HMAC tokens with TTL and tamper-detection.
  • bins/blog-rs unit 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-research parse 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.

Build hygiene

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.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request:

  1. cargo fmt --all -- --check
  2. cargo clippy --workspace --all-targets -- -D warnings
  3. cargo test --workspace
  4. 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.

Known follow-ups

  • db::members::enqueue_confirm writes the confirm-purpose outbox row with post_id = 0. Integration tests seed a sentinel posts(id = 0) row; production needs either a sentinel migration or a schema change that allows null post_id on confirm-purpose rows.
  • The members::unsubscribe SQL overwrites unsubscribed_at on every call; observable behaviour is idempotent but the timestamp is bumped on repeats. A COALESCE keeps 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.

License

MIT. See LICENSE.

About

Self-hosted blog engine in Rust with markdown plus interactive shortcodes (charts, animations, code playgrounds)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages