repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

6.0 KB · 137 lines · markdown Raw History
  1> **Archived.** This project is no longer in use and no longer maintained. Last updated July 2026.
  2
  3# blog.bythewood.me
  4
  5My personal blog. It is a single Rust axum binary that renders Markdown files: posts, templates, Vite-built static assets, and the binary all live in this one repo. There is no database. To publish, you write a Markdown file and restart the process.
  6
  7It started life as a Flask app. The Rust rewrite uses far less memory, serves far more requests, and answers in well under a millisecond per request in release mode, which is why it exists in this form.
  8
  9## Features
 10
 11- Markdown posts with YAML frontmatter (title, slug, date, publish_date, tags, description, cover_image)
 12- Server-rendered tag and year archives
 13- Server-rendered search plus a live JSON search endpoint (`/search/` and `/search/live/`)
 14- Per-post PDF export via embedded Typst (no chromium subprocess)
 15- Per-post raw markdown download
 16- Dynamic OG image generation per post
 17- Single-binary deploy via `git push server master`
 18
 19## Tech stack
 20
 21| Concern         | Crate / Tool                 |
 22|-----------------|------------------------------|
 23| Web framework   | axum + tokio                 |
 24| Template engine | minijinja                    |
 25| Markdown        | comrak                       |
 26| PDF             | embedded Typst (no chromium) |
 27| Static assets   | Vite + Bun                   |
 28
 29Why these: axum is the most-used async framework, minijinja is the Rust engine that accepts upstream Jinja2 syntax unchanged, comrak's partial-formatter hook is the closest match to the Mistune renderer-override pattern the original Flask version used, and embedded Typst renders PDFs in-process without spawning a browser.
 30
 31## Quickstart
 32
 33```sh
 34cp samplefiles/env.sample .env
 35make
 36```
 37
 38`make` (alias `make run`) installs frontend deps if needed, then runs Vite watch and `cargo run` concurrently on port 8000. Visit http://localhost:8000.
 39
 40You need these on your `PATH` for local dev:
 41
 42| Tool | Why | Version |
 43|---|---|---|
 44| `rustc` / `cargo` | Build the axum binary | 2021 edition, stable 1.70+ |
 45| `bun` | Frontend deps + Vite | 1.x |
 46| `make` | Run the dev/build targets | any |
 47
 48If you only deploy via Docker you do not need any of these on the host; the `Dockerfile` reproduces the toolchain on `rust:alpine` + `alpine:3.23`.
 49
 50## Writing a post
 51
 521. Create a Markdown file under `content/posts/`, for example `content/posts/my-post.md`.
 532. Give it YAML frontmatter:
 54
 55   ```yaml
 56   ---
 57   title: My Post
 58   slug: my-post
 59   date: 2026-06-19
 60   publish_date: 2026-06-19
 61   tags: [rust, notes]
 62   description: A short summary used in listings and OG images.
 63   cover_image: my-cover.webp
 64   ---
 65   ```
 66
 67   Body Markdown follows the frontmatter.
 683. Put any images in `content/images/`; they are served at `/content/images/`.
 694. **Restart the process.** Posts are loaded once at startup, so a new or edited post only appears after a restart. A `publish_date` in the future hides the post until that date (and still needs a restart once the date has passed).
 70
 71## Configuration
 72
 73All config comes from `.env` (loaded via `dotenvy`):
 74
 75| Variable | Required | Purpose |
 76|---|---|---|
 77| `PORT` | no (default `8000`) | HTTP listen port |
 78| `BLOG_ROOT` | no | Override the project root (where `templates/`, `dist/`, and `content/` are read from) |
 79
 80## Make targets
 81
 82| Target | What it does |
 83|---|---|
 84| `make run` (default) | Vite watch + `cargo run` on port 8000 |
 85| `make build` | Vite assets + release binary (`target/release/blog`) |
 86| `make start` | Run the release binary (after `make build`) |
 87| `make bench` | `oha` load test sweep across the main routes. Compares against a Flask server on port 8002 if running |
 88| `make push` | `git push` to every configured remote |
 89| `make clean` | Remove `target/`, `dist/`, and `frontend/node_modules/` |
 90
 91There are no tests or linters configured.
 92
 93## Routes
 94
 95- `/posts/<slug>/`: single post (old `/blog/<slug>/` 301-redirects here)
 96- `/posts/<slug>/pdf/`: PDF export
 97- `/posts/<slug>/md/`: raw markdown download
 98- `/blog/`: post index, plus `/blog/tag/<tag>/` and `/blog/year/<year>/`
 99- `/search/?q=...` and `/search/live/?q=...`: search
100- `/og/<slug>.svg`: per-post OG image
101
102## Layout
103
104```
105blog.bythewood.me/
106├── Cargo.toml, Cargo.lock        # rust deps
107├── Makefile, README.md, bench/   # top-level
108├── src/                          # rust source
109│   ├── main.rs       # tiny entry: server boot
110│   ├── app.rs        # AppState + Router assembly
111│   ├── render.rs     # render_html helper
112│   ├── middleware.rs # request log
113│   ├── routes/       # home, blog, post, search, seo, errors
114│   ├── posts.rs      # frontmatter + post loading
115│   ├── markdown.rs   # comrak custom renderer
116│   ├── templates.rs  # minijinja env, url_for, vite_asset, Jinja2-compat formatter
117│   └── pdf.rs        # embedded Typst renderer
118├── templates/                    # jinja2 source + blog_post.typ
119├── content/                      # markdown source
120│   ├── posts/        # markdown posts with YAML frontmatter
121│   └── images/       # served at /content/images/
122├── frontend/                     # JS pipeline (package.json, vite.config.js, static_src/)
123├── dist/                         # vite build output (gitignored, served at /static/)
124├── target/                       # cargo build output (gitignored)
125└── samplefiles/                  # Caddyfile.sample, env.sample, post-receive.sample
126```
127
128The binary reads `templates/`, `dist/`, and `content/` from the current working directory. Override with `BLOG_ROOT=<path>`.
129
130## Deploy
131
132Production runs on Docker. The flow is `git push server master` to a remote whose post-receive hook runs `docker compose up --build --detach`. The `alpine:3.23` runtime image installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so the embedded Typst renderer can find body, mono, and fallback fonts. Sample files in `samplefiles/`:
133
134- `Caddyfile.sample`: reverse proxy with TLS
135- `env.sample`: the `.env` shown above
136- `post-receive.sample`: the git hook