- Rust 71%
- Nix 21%
- Sass 8%
- JavaScript <1%
lvdar.nl #
My personal website and blog. Posts are written in Typst, compiled to HTML at build time, and served by a Leptos SSR application.
Why Typst #
Until recently, publishing a Typst document on the web meant publishing a picture of one — a page of SVG, unsearchable and unreadable by a screen reader. Typst 0.15 added HTML export with equations as MathML, so an equation in a post is real, selectable, indexable text.
HTML export is still marked experimental and lives behind --features html
(here, Feature::Html on the Library). Typst's own documentation site is
built with it.
How it fits together #
content/posts/*.typ
│ crates/typst-render (typst + typst-html, a real filesystem World)
▼
crates/content/build.rs ──► $OUT_DIR/generated.rs (a static table)
│
▼
crates/app (Leptos: SSR + hydrate) ──► crates/server (Axum)
Typst runs in the server, not in the browser: the WASM bundle is ~600 KB rather than the several megabytes it would take to ship a compiler to the client.
Content is compiled at runtime and swapped in without a restart, which is what makes publishing a push rather than a deploy. That was a deliberate trade — an earlier version compiled everything in a build script, which was faster and safer but could not pick up a post without a full rebuild and redeploy. What the build used to guarantee is now re-established elsewhere:
- A broken post cannot take the site down. The loader skips what it cannot compile and serves the rest; a refresh that would publish an empty site over a non-empty one is refused; an unreachable remote leaves the previous revision live.
- A broken post is caught before it is pushed, by
content-checkrunning as a pre-commit hook in the content repository — the same code the server loads with, so it enforces exactly what production enforces. - The service still has no state. The git checkout is a
CacheDirectory, re-clonable at any time, deliberately not persisted.
Where the content lives #
Production content is a separate repository,
lvdar.nl/blog, polled by the running
service.
content/ in this repository is the fallback shipped inside the package and
the fixture cargo leptos watch uses. It is not the blog.
Writing a post #
In the content repository. A file named YYYY-MM-DD-slug.typ; the date prefix
orders the directory and is stripped from the URL.
#import "../lib/post.typ": post
#show: post.with(
title: "A title",
date: datetime(year: 2026, month: 8, day: 27),
tags: ("nix", "rust"),
summary: "One or two sentences, shown in listings and in the feed.",
)
Body text. Equations like $e^(i pi) + 1 = 0$ become MathML.
draft: true keeps a post out of release builds entirely, while leaving it
readable by direct URL in a debug build so it can be previewed with
cargo leptos watch. There is no unlisted-but-live middle state.
content/pages/ works the same way and is for standalone pages (about).
Development #
nix develop # or `direnv allow`
cargo leptos watch # http://127.0.0.1:3000, reloads on content edits
cargo test --workspace --exclude frontend
cargo fmt # strict, needs the nightly rustfmt the shell provides
To look at a single post's raw export:
typst compile --features html --format html content/posts/<name>.typ
Configuration #
Leptos reads LEPTOS_SITE_ADDR, LEPTOS_SITE_ROOT, LEPTOS_SITE_PKG_DIR,
LEPTOS_OUTPUT_NAME and LEPTOS_ENV. On top of those:
| Variable | Default | Purpose |
|---|---|---|
SITE_BASE_URL |
http://127.0.0.1:3000 |
Public origin. The feed and sitemap emit absolute URLs, so getting this wrong produces a feed pointing at localhost. |
SITE_REDIRECT_HOST |
unset | Host to 301 to SITE_BASE_URL (e.g. www.lvdar.nl). |
SITE_TITLE / SITE_AUTHOR |
Lars van Dartel |
Feed identity. |
SITE_CONTENT_REPO |
unset | Content repository to poll. Unset serves the bundled content/. |
SITE_CONTENT_BRANCH |
main |
Branch to track. |
SITE_CONTENT_SUBDIR |
(root) | Subdirectory holding posts/ and pages/. |
SITE_CONTENT_INTERVAL |
60 |
Seconds between polls. An unchanged poll skips the compile. |
SITE_CONTENT_PATH |
content |
Directory to read when no repository is set. |
SITE_CONTENT_CHECKOUT |
$CACHE_DIRECTORY/content |
Where the working copy goes. |
SITE_SHOW_DRAFTS |
debug only | Serve draft: true posts. |
SITE_REFRESH_TOKEN |
unset | Enables POST /api/refresh. Unset leaves it 404. |
RUST_LOG |
info,hyper=warn,tower=warn |
Tracing filter. |
Refreshing immediately #
Polling bounds how stale the site can get; the webhook removes the wait:
curl -X POST -H "Authorization: Bearer $TOKEN" https://lvdar.nl/api/refresh
Unauthenticated it is a free way to make the server run a git fetch and a Typst compile, so it answers 404 until a token is configured.
Deploying #
nixosModules.default exposes services.site:
services.site = {
enable = true;
port = 3031;
address = "0.0.0.0";
baseUrl = "https://lvdar.nl";
redirectHost = "www.lvdar.nl";
content.repository = "https://tangled.org/lvdar.nl/blog";
};
The unit runs as a DynamicUser with ProtectSystem = "strict". Its one
writable path is a CacheDirectory holding the content checkout — a cache, not
state, and deliberately not persisted: a persisted StateDirectory under
DynamicUser lands in /var/lib/private, and an impermanence entry over that
path is the EBUSY that has already broken three services in this fleet.
The www redirect is handled in the application rather than in nginx because
the host this runs on is edge-terminated: TLS is terminated elsewhere and
nothing local is in a position to rewrite a request.
Known rough edges #
- Code blocks carry Typst's own syntax colours as inline
styleattributes, chosen for a white page. They are legible on the dark theme but not tuned for it, and inline styles cannot be overridden from the stylesheet without discarding the highlighting entirely. - HTML export is experimental. Expect to do a little work at each Typst bump;
the version is pinned in
Cargo.tomlfor that reason.
License #
MIT OR Apache-2.0.