README.md

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-check running 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 style attributes, 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.toml for that reason.

License #

MIT OR Apache-2.0.