How this site is built

This site is primarily written in Typst, a markup language that can be used as an alternative to LaTeX, Markdown, or AsciiDoc. I’ve been meaning to make my own static site generator based on Typst for a very long time, essentially ever since the Typst developers started teasing HTML export. Now HTML export is available (in an experimental capacity), and I’ve finally found the time and motivation to get my own domain and build everything out.

Why Typst?

If you’ve never heard of Typst before, my incoming praise for it will likely sound evangelical, but simply put, it’s the most delightful markup language I’ve ever used.

The core conceit of Typst is that it is both a simple markup language and an ergonomic scripting language. Typst is expression-based, and expressions can yield content that gets rendered in a PDF, or in this case, HTML. I got my hands on Typst almost as soon as it was first released, and it immediately struck me how simple, expressive, and composable Typst code is.

// the classic fib example
#let fib(n) = {
  let fibs = (0, 1)
  let a = 0
  let b = 1
  fibs = fibs + for i in range(2, n) {
    let c = a + b
    a = b
    b = c
    (c,)
  }
  fibs.slice(0, n)
}

#fib(24).map(x => str(x)).join(" ")
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657

Typst’s ergonomics are especially apparent if you have extensive experience writing LaTeX documents. If you’ve used LaTeX before, at some point it probably felt arcane or confusing. Often, if you had a specific use case, you’d find whatever package you needed, figure out its API, and move on. But how did that package actually work? What are the actual fundamentals of LaTeX programming that would allow you to write your own packages? I was usually too intimidated and time-constrained as a student to bother with finding the answer to those questions. But Typst felt different. It is so simple as to invite you to try writing your own packages.

And I did! My first Typst project was a homework template1 that I used for every class that would allow me, and I convinced all of two people to use it as well. My biggest contribution to the Typst community was a package for displaying algorithms that I also used quite extensively.2

Seriously, if you’ve never tried Typst, I invite you to play around with it. But that’s enough evangelizing. How does this website make use of it?

Notes

HTML export

Typst 0.13 added HTML as an export target. Currently, Typst’s main goal with HTML export is converting Typst content to semantic HTML, so a = heading becomes an <h2>, *bold* becomes <strong>, and a fenced code block becomes <pre><code> with syntax highlighting already applied. However, it does not translate Typst’s layout and styling primitives:

#strong, #emph, #link, raw blockssemantic tags, as you’d expect
#text(fill: red, size: 20pt)plain text; color and size are dropped
#block(fill: ..., inset: ..., radius: ...)a bare <div>
#align, #pad, #gridignored with warnings

So Typst files own the raw content and any HTML elements you explicitly create, but your styling and layout will mostly live in dedicated CSS files.

The metadata channel

A lot of my setup relies on Typst’s metadata mechanism. A Typst document can use the metadata function to emit arbitrary data that the compiler can later query by its label.

// helper function to tag passed info with `<page-meta>`
#let meta(..args) = [#metadata(args.named())<page-meta>]

I called the meta helper at the top of this document:

#meta(
  title: "How this site is built",
  date: "2026-08-11",
  desc: "Typst source, a few hundred lines of TypeScript, and a metadata channel that ties them together.",
)

And the site generator queries the <page-meta> label:

typst eval --target html --in page.typ 'query(<page-meta>).map(it => it.value)'

It uses the collected info to populate this page’s <head>, as well as the blurb you see for each post listed on the Blog page.

With this little trick, the SSG can add special handling for frontmatter, embedded scripts, stylesheets, and any other magical patterns I choose to implement:

#let style(path)        = [#metadata(path)<style-src>]
#let script(path)       = [#metadata(path)<script-src>]
#let head(tag, ..attrs) = [#metadata((tag: tag, attrs: attrs.named()))<head-tag>]

What the generator does

A build starts with two invocations of the Typst CLI:

  1. typst eval to read my site config, which holds things like the site name and the nav links.
  2. Another typst eval to verify that my internal links are all correct. I do some string transformation wherever necessary to make sure references to a .typ file correctly map to a URL.

Next, there’s one typst eval per blog post to populate a posts.json file. The contents of this file are referenced on pages that render a list of posts.3

Every .typ file then needs:

  1. typst compile --format html to get HTML output.
  2. typst eval to retrieve metadata tags (though blog posts already had this done in the previous step).

Then the SSG takes the <body> out of the HTML output, resolves/copies any scripts and stylesheets, tacks on the <head>, and writes the rendered file.

That’s really the crux of it. There are other minor details, like deduplicating stylesheet references as necessary, and overwriting Typst’s default styling for syntax tokens in code blocks, but the overall process is straightforward. The whole build script is about 400 lines, so I’m reasonably impressed with how small it ended up being.

With the SSG in place, I was able to create some common UI components to help me write these blog posts.

Notes

Components

I defined my components using the Elembic package. Typst documents primarily use set and show rules to style elements or set default values. However, Typst currently doesn’t support applying these rules to user-defined functions. Elembic provides a workaround that kinda gets you 95% of the way there.4 That being said, since most of my styling lives in a dedicated CSS file, I mostly leverage Elembic to denote the default state of some elements, such as making detail panels start out collapsed.

I’m still experimenting with how I want to divide my setup between pure Typst code and CSS, but I’m satisfied with how things look so far.

Notes

Callouts

#note[A plain note for context.]
#warn(title: "Careful")[This one has a title.]

Collapsibles

#details("Implementation detail")[
  Uses the native `<details>` element, so it needs no JavaScript.
]
Implementation detail
Uses the native <details> element, so it needs no JavaScript.

Quotes

#blockquote(by: [Ada Lovelace])[
  The Analytical Engine weaves algebraic patterns.
]
The Analytical Engine weaves algebraic patterns.
Ada Lovelace

Popovers

Hover facts go in a #popover("pv-post", "popover")[This is just a native popover].

Hover facts go in a This is just a native popover.

Annotations

I’m sure you’ve noticed them already!

Marco#annotation[Polo]

Marco5

The implementation for these is a bit more involved. To collect all the annotations in a given section and display them at the end, I have to make use of some contextual information.

// called for every heading via a show rule, and once more at the end of the page
#let _annotation-flush(in-heading: false) = context {
  let me = here()

  // get the previous heading
  let heads = query(selector(heading).before(me, inclusive: false))
  let prev = if in-heading {
    if heads.len() >= 2 { heads.at(-2) } else { none }
  } else if heads.len() >= 1 { heads.last() } else { none }

  // get the number of annotations before the current section
  let lo = if prev == none { 0 } else {
    query(selector(<ann-note>).before(prev.location())).len()
  }

  // get number of annotations before end of section
  let hi = query(selector(<ann-note>).before(me)).len()

  if hi > lo {
    // get annotations in current section
    let notes = query(<ann-note>).slice(lo, hi)

    // ... render annotations ...
  }
}
Notes

Scripts

A show rule in the template catches raw blocks tagged inline-script and passes them to the generator instead of rendering them:

show raw.where(lang: "inline-script"): it => [#metadata(it.text)<inline-script>]

This lets me write some HTML and companion JavaScript right next to each other within a Typst document. For example, this:

#button("demo-go")[Click me] #id("demo-count")[0]

```inline-script
const out = document.getElementById("demo-count") as HTMLElement;
let n: number = 0;
document.getElementById("demo-go")!.addEventListener("click", () => {
  out.textContent = String(++n);
});
```

produces this:

0

Will I actually use this all that often? ¯\_(ツ)_/¯

I mostly added this since I was already exploring how much I could control within the bounds of a Typst document. For what it’s worth, this mechanism is how I implemented the scroll spy you see on the left if you’re reading this on a desktop. The #toc function reads in the scroll spy’s TypeScript file as an inline-script block…

raw(read("/lib/scrollspy.ts"), lang: "inline-script")

…which eventually gets rendered by the SSG. This allows me to reference it in my #blog template, denoting that all blog documents should have a table of contents.

#let blog(body) = {
  show: theme
  elem("main", { toc(); body; _annotation-flush() }, id: "content") // notice `toc` gets added to main here
  site-footer()
}

Plus, the cute boat animation on my home page was also implemented via this method.

Worth it?

I think making a custom SSG is a worthwhile endeavor for any personal site. It’s just nice to be in complete control of a tool I know I’ll use every day, and I’m happy that I can do just that for my own website.

That said, there are already things I’d like to improve. Although I was committed from the beginning to make a Typst-centric SSG, I was somewhat surprised at its current limitations. I’ll have to keep experimenting with the boundary between Typst, the SSG, and just plain HTML.

A lot of the components I made are fun, but ultimately I just want writing these blog posts to be as frictionless as possible. That’s why I intentionally made the SSG both small and extensible for my own needs. I’m sure I can grow or shrink it however I want as I get in the groove of actually writing blog posts (exciting!).

The repository is public if you want to check it out.