Rewrite it in Typst
2026.04.03
I’ve just finished (for the most part) a rewrite of this website using Typst, the new-ish LaTeX alternative, using its experimental support for HTML output. Since I don’t normally do this sort of thing, I thought it’d be good to have some quick notes about what’s going on.
Motivation
Previously, I had everything set up using mdBook, which is a simple system for building online documentation using a system of Markdown files. There isn’t anything particularly wrong with mdBook–it’s still a great solution for what it’s built for–but I was (mis)using it mostly for my blog, which meant that I needed quite a bit of math rendering. I had been using the mdBook-katex preprocessor for this and it served its purpose, but sometime in the last several months, mdBook underwent a minor (ostensibly) version update that completely broke mdBook-katex, so I thought I’d take the opportunity to switch everything over to Typst which, of course, has built-in support for math. And, to be honest, I also just like Typst a lot more than Markdown (and LaTeX).
First attempt with shiroa
When I first embarked on this, I thought I’d be sticking to this project called Shiroa, which aims to be simply a Typst version of mdBook. I gave it a shot and it looks pretty good, but it ended up having some significant drawbacks that ultimately killed it for me.
Firstly, Shiroa is in an awkward state. It has support for math (which unfortunately turned out to be a little non-trivial; see below), code styling, diagrammatic content (graphs and other visual elements), and even theming in two styles. For for all the generality of its current state, it’s not very mature–there are a handful of important bits that are not supported to my satisfaction, like having auto-collapsing sidebar headers (as in mdBook), tables not looking nice without manual styling intervention, and no clear way to do custom theming.
Finally, I also want to be able to build this site with Nix (mostly to learn it, since the hosting for this site doesn’t really require any CI). Shiroa, as of v0.3.0, is not already packaged in the Nix ecosystem and has an opaque and convoluted build process that pulls in code from a handful of git repositories as well as depends on Yarn. To my current knowledge of Nix, at least, all of this ultimately precludes me from using it.
What I ended up with
Instead, I decided to draw more inspiration from this site by someone named istudyatuni. The linked post mentions writing a small Rust plugin for syntax highlighting in code blocks, but as of July 26 2025 such a thing is no longer needed1.
Ultimately, this site has been cobbled together from a mixture of both istudyatuni’s site in broad structure and certain smaller elements drawn from the pure-Typst side of shiroa. This section will give a high-level good/bad summary of the site, while the next section will describe non-trivial elements in greater detail.
The nice bits
Typst is a pretty great language. Every once in a while I run into a pain point or two with its lack of strong typing or some syntactic weirdness, but as a DSL with scripting capabilities for laying content out on a page with actual, structured data, Typst is absolutely excellent. And, really, the scripting thing is the really important part because having ergonomic scripting means that there’s really no need for anything like a preprocessor in probably at least 90% of use cases. In fact, ordinary Typst is powerful enough on its own to implement–easily–things like bibliographies, tables of content, and footnotes, the last of which I’ll be describing below.
The fact that Typst is all about functional patterns, then, is just icing on the cake since it makes everything related to styling templates and functions that need to be automatically applied when certain conditions are fulfilled as easy as defining a function, thanks to show rules.
If you’re unfamiliar, show is a built-in keyword that searches for a pattern, and then applies a function to all elements in a document that match it. For example, we can draw all inline equations in red with the following rule:
#show math.equation.where(block: false): text.with(fill: red)
where math.equation.where(...) gives the pattern with block: false describing all equations not displayed in their own block-level element. text.with(...) then is a function (text) the argument fill: red partially applied. In ordinary contexts (i.e. with PDF output), it’s typical to do the equivalent of a LaTeX \documentclass with a show rule that matches everything.
// simple document template
#let document(title: none, author: none, body) = {
set align(center) // local alignment setting
if title != none {
text(size: 2em, title)
} else {
text(size: 2em)[Untitled document]
}
if author != none {
text(size: 1.5em)[\ By: #author]
}
body
}
// apply the template to all following content with an empty selector
#show: document.with(title: [My document], author: [Me!])
In this example, document is an ordinary function with optional title and author keyword arguments, that will automatically display their values (if provided) center-aligned at the top of the document. Its third argument, body, takes unspecific content and, when it gets applied to the document via show: (with an empty pattern), eventually the entire document that follows that application. In the context of my website, I have a post template that handles the application of all necessary other show rules (for equations and such), as well as inserting the default CSS template, <head>, webpage metadata, and rendering the navbar, such that posts are created in only a couple lines:
// library code importable from anywhere
#import "/site/preamble.typ": post
#show: post.with(/* post metadata */)
Another nice functional pattern I have in this site (specifically on the Research page) is a simple abstraction to add hyperlinking for a paper citation:
// journal: full url to journal page
// arxiv: slug of the corresponding arXiv upload
// body: function taking link functions constructed from `journal` and `arxiv`
#let pub(journal: none, arxiv: none, body) = {
body((link.with(journal), link.with("https://arxiv.org/abs/" + arxiv)))
}
such that we can have
#pub(
journal: "https://journals.aps.org/prxquantum/abstract/10.1103/28h7-jl1v",
arxiv: "2507.18426",
((journal, arxiv)) => [
*W. Huie*\*, C. Conefrey-Shinozaki\*, Z. Jia, P. Draper, and J. P. Covey,
"Three-qubit encoding in ytterbium-171 atoms for simulating 1+1D QCD."
#journal[Phys. Rev. X Quantum *7*, 010327 (2026)]. [#arxiv[arXiv]]
]
)
resulting in
W. Huie*, C. Conefrey-Shinozaki*, Z. Jia, P. Draper, and J. P. Covey, “Three-qubit encoding in ytterbium-171 atoms for simulating 1+1D QCD.” Phys. Rev. X Quantum 7, 010327 (2026). [arXiv]
It must also be said that the Typst ecosystem already has many nice packages, including:
physicafor physics and extended math notation;fletcherfor graph-like diagrams;pavematfor fancy matrices with substructure;quillfor quantum circuit diagrams;- and many more.
Whereas mdBook would’ve had me drawing SVG figures in Inkscape or defining my own KaTeX macros, I can just simply add the appropriate #import "@preview/..." statement at the top of a source Typst file, and the compiler will automatically take care of downloading the package for local use.
The disappointing bits
There are some shortcomings, however. First and foremost, HTML output is still considered experimental, which means that several ordinary Typst built-ins for content are simply ignored due to currently unimplemented functionality. These include the relatively important align, table, certain parameters of text and box, and surprisingly also math.equation. Convenient workarounds do exist, though, and will be described below.
In the meantime, I can also note that internal linking requires a bit of overhead out of the box. Although the Typst <label> and @ref systems do work by default (provided the thing that @ref references has some kind of numbering, which Typst will tell you about with an error), the problem is that in the output HTML, an id attribute will only be added to the referent if a @ref exists elsewhere in the source document. Here’s a concrete example. Say I have some source files file1.typ and file2.typ that get compiled to file1.html and file2.html. file1.typ looks like this:
// file1.typ
#set heading(numbering: "1") // so that `@my-section` is valid
= Section title
<my-section>
#figure(image("content.svg"))
<my-figure>
// ...
@my-section
@my-figure
In file1.html, we end up with <h2 id="my-section">... and <figure id="my-figure">... as one would expect, which enables us to then reference these locations from using an ordinary URL:
// file2.typ
#link("file1.html#my-section")[link to section]
#link("file1.html#my-figure")[link to figure]
But if we take away @my-section and @my-figure from file1.typ, we end up losing the id attributes on both elements and both links in file2.typ (as well as any other links of that kind in file1.typ become invalid. To me this seems backwards–why should the attribute depend on the existence of a reference, rather than a label?–but I don’t know anything about the internals of the Typst compiler, much less enough to say whether this would be a simple fix. But what I do know is that istudyatuni’s site has a simple frontend fix for it, which I’ve copied and extended (again, below).
There are also no native path tools in Typst. For security reasons, the Typst compiler will not permit any content within a document to refer to any files outside the directory containing the source file (or a particular file system path named by the --root compiler option). In some ways this is kind of nice because, for instance, it means you can succinctly refer to a common root from anywhere within a project, but it also means that path management is sort of a non-goal, and so no path utilities have been provided in the standard library. The standard library does provide things like regexes, string searching and splitting, and nice array/list utilities (mapping and joining and such), so things like joinpath are easy to implement. But, importantly, there are no tools to locate where a particular source file lies in a project’s directory structure, or to discover others. There are still workarounds (described below), but they’re not very elegant.
The final disappointment is that Typst doesn’t (yet) have any support for multi-file compilation. Obviously import works to spread scripting code across different files and you can use include to do the same for content. What I mean is that Typst can’t take multiple source files and write to multiple output files, nor will it keep track of multiple files in its watch command, so it’s just a bit awkward to work on multiple source files at once.
Technical details
Now we can get into the nitty-gritty. Obviously I don’t want to list out every aspect of this project, but there are a handful of things I had to figure out to get everything working smoothly that I think are worth mentioning.
html substitutions
First up are the basic show rules that were necessary for what I consider to be normal writing. I wanted–at minimum–the basic features I had with mdBook, which were
- image inclusion;
- hyperlinking;
- tables;
- lists/enumeration;
- code blocks with syntax highlighting;
plus math support.
Most of these were covered out of the box: image works (still with proper sizing relative to the environment, not the image size!), link works, table works (with some extra CSS), and obviously list and enum work since they’re native concepts in HTML anyway. Surprisingly, though, equations were not: If you have a basic source file
// test.typ
$
hat(f)(omega) = 1 / sqrt(2 pi) integral_oo "d"t med f(t) e^(-i omega t)
$
and naively compile it with
typst compile --features=html --format=html test.typ
you get this nice little compiler message
warning: equation was ignored during HTML export
┌─ test.typ:7:0
│
1 │ ╭ $
2 │ │ hat(f)(omega) = 1 / sqrt(2 pi) integral_oo "d"t med f(t) e^(-i omega t)
3 │ │ $
│ ╰─^
with a blank test.html file as a result. I kinda feel like this should be an error instead of a warning, but whatever. The solution for equations, taken from Shiroa, was pretty much to just wrap everything in a html.frame block:
show math.equation.where(block: true): eqn => {
let attrs = (class: "block-frame", role: "math")
let framed = html.frame(eqn)
html.elem("p", attrs: attrs, framed)
}
show math.equation.where(block: false): eqn => {
let attrs = (class: "inline-frame", role: "math")
let frame = html.frame(eqn)
html.elem("span", attrs: attrs, framed)
}
html.frame is kind of an important thing–it’s a backdoor to ordinary, non-HTML (“paged”) compilation where the compiler will render everything inside the frame as an SVG and inline the result into the output HTML. Our show rules above then wrap the frame element in a p element for block equations and span for inline equations, and I have the following CSS classes (also from Shiroa):
.block-frame {
font-size: 1.1em;
display: grid;
place-items: center;
overflow-x: auto;
}
.inline-frame {
font-size: 1.1em;
display: inline-block;
width: fit-content;
}
which results in the above equation being rendered like so:
This works for ordinary rendering, but one drawback is that anything that gets put in a frame is entirely non-interactive because it’s all pre-rendered. And because all this data is inlined, it’s actually not even selectable by clicking and dragging or savable as an image. Obviously it’s non-ideal. I don’t have any ideas for fixes at the moment, but it’s definitely something I’d like to revisit in the future.
Typst also has native (HTML) support for syntax-highlighted code blocks, so I feel a little guilty about including them as an item here. For code blocks, I had originally planned on having everything formatted using zebraw, which adds a layer onto the default renderer to add things like line numbering, nice long-form, commented line highlighting, small automatic callouts for what language is being used, and a button to copy the contents of a block. Here’s a nice example:
1data Option a = None2 | Some a45-- | Return @True@ if an `Option` is `Some`.6isSome :: Option a -> Bool7isSome opt =8 case opt of9 None -> False10 Some _ -> True
Pretty slick, huh? But see those indentation markers on lines 2-4? I didn’t turn them on and they don’t show up in regular paged output, and they’re the wrong width for Haskell, which normally uses a tab size of 2. To me this is extremely annoying, so I opted to just use the default Typst processing and CSS styling for raw elements since Typst comes with its own syntax highlighting anyway.
Lastly I can note the fixes to the <label>/@ref/id attribute problem I noted above. Because Typst natively allows inspection of an element to see whether it has a label attached, the fix is actually pretty simple. Here is istudyatuni’s fix specifically for heading elements:
#show heading: it => {
if target() != "html" or not it.has("label") { return it }
let label-name = str(it.label)
// headings start at level 2 by default for some reason
let html-level = calc.min(it.level + 1, 6)
// render the next html element with these settings
set html.elem(
"h" + str(html-level),
attrs: (
id: label-name, // add the id attribute manually
class: "pointer heading-id", // just a CSS class to highlight on hover
onclick: "set_heading(\"" + label-name + "\")",
)
)
it
}
where the main purpose is just to manually add an id attribute in the case that a <label> exists for the heading.
Of course we can then extend this to figures:
#show figure: it => {
if target() != "html" or not it.has("label") { return it }
let label-name = str(it.label)
set html.elem("figure", attrs: (id: label-name))
it
}
But since equations are already getting wrapped as a html.frame above, it’s cleanest to modify that show rule instead of writing another. The rule above for block equations then gets modified as:
#show math.equation.where(block: true): it => context {
if paged-mode.get() { return it }
let attrs = {
if it.has("label") {
let label-name = str(it.label)
(class: "block-frame", role: "math", id: label-name)
} else {
(class: "block-frame", role: "math")
}
}
let framed = html.frame(it)
html.elem("p", attrs: attrs, framed)
}
Finally we can add a utility function to use figures as simple markers for locatable content:
#let loc = figure.with(kind: "loc", supplement: "loc", numbering: none)
such that we can also co-opt the short-form syntax for refs for concise intra-page linking to numberless headings/figures/equations:
#show ref: it => {
let el = it.element
if (
(el.func() == heading and el.numbering == none)
or (el.func() == figure and (el.kind == "loc" or el.numbering == none))
or (el.func() == math.equation and el.numbering == none)
) {
let lntext = if it.supplement == auto { [link] } else { it.supplement }
link(el.location(), lntext)
} else {
it
}
}
With these in place, we end up with a nice system for concisely referring to labeled content:
= My heading
<my-heading>
See @my-heading[here] for info.
will render as
My heading
See here for info.
(We still need the long-form #link("path/to/file.html#my-heading")[...] for references between pages, however.)2
Navbar/file path management
The process by which I render the navbar and links to sub-pages is taken directly from istudyatuni’s site. As I mentioned above, the basic problem is that Typst doesn’t provide a clean way for a particular source file to know where it lies in a file hierarchy. So the navbar, where I highlight which subsection of the site the current page lies in and renders a path to the current page, relies on a few rather home-baked components:
- a bit of hard-coding to declare a few “categories” (of subpages) with associated subdirectories in the file hierarchy;
- use of the
--inputflag of thetypst compilecommand-line interface; and - a simple system where each subpage locally declares a small bit of metadata.
As a bit of a preview to the section on my build system, I’ll say here that it was cleanest to directly compile every src/**.typ source file directly to an output public/**.html output file (as is usually the case). I think opting into this kind of system tends to minimize the amount of path management one has to set up in source code, but that minimum is still not quite zero, as we’ll see below.
We start off with the subpage categories. At the time of writing, I have four, which are “Home”, “Blog”, “Notes”, and “Food”. Home is simply the main landing page, and requires just a bit of special treatment because I want it to refer to single top-level src/index.typ file, rather than something like src/blog/post.typ for the other categories–in other words, this part of the site structure is not reflected in the hierarchy of source files. Hence, we have to negotiate this difference with a simple central structure:
// `name` is the display name used in final renders;
// `path` is the enclosing directory relative to `src`
#let categories = (
home: (name: "Home", path: ""),
blog: (name: "Blog", path: "blog"),
notes: (name: "Notes", path: "notes"),
food: (name: "Food", path: "food"),
)
where said file hierarchy goes something like this:
src
├── index.typ
├── home-subpage1.typ
├── home-subpage2.typ
├── ...
├── blog
│ ├── index.typ
│ ├── post1.typ
│ ├── post2.typ
│ └── ...
├── food
│ ├── index.typ
│ ├── post1.typ
│ ├── post2.typ
│ └── ....typ
└── notes
├── index.typ
├── post1.typ
├── post2.typ
└── ...
If we want the navbar to contain links back to all categories’ index.typ files from anywhere in the site, then we need a way to pass a base absolute path to a navbar drawing function (from which we then generate the links simply as <base> + categories.<category>.path + "index.html"). As previously mentioned, files don’t know their locations, so the solution here is to pass --input to typst compile with the path to the file being compiled. Overall, the command will then look something like
typst compile \
--features html --format html \
--input base=${base} \
--input srcdir=src \
--input filename=$(basename ${input_file%.typ} \
--input category-path=$(echo ${input_file} | sed -r 's/^.*src\/(.+)\/.*$/\1/') \
${input_file} \
${output_file}
where ${input_file} is a path to a .typ file relative to the build script (see below). ${output_file} is easily obtained from ${input_file} by simply replacing src -> public and .typ -> .html. ${base} can be something like $(pwd)/public but also–as an added benefit–swapped out for a public URL like http://my.site.dev depending on whether the pages should be built for testing/writing or for actual publication. Some simple path manipulation functions can then be written:
// join an arbitrary number of path elements, ignoring leading `http[s]?://`
#let joinpath(..parts) = {
let nonempty = parts.pos().filter(p => p != "")
if nonempty.len() == 0 { return "" }
let concat = nonempty.join("/")
let http-match = concat.find(regex("^http[s]?://"))
if http-match == none {
concat.replace(regex("//{1,}"), "/")
} else {
http-match + concat.slice(http-match.len()).replace(regex("//{1,}"), "/")
}
}
// construct final output paths using `sys.inputs.base`
#let outpath(path) = {
if path.starts-with(base) {
path
} else {
let no-src = {
if path.starts-with(sys.inputs.srcdir) {
path.slice(sys.inputs.srcdir.len())
} else if path.starts-with("/" + sys.inputs.srcdir) {
path.slice(sys.inputs.srcdir.len() + 1)
} else {
path
}
}
let typ-to-html = {
if no-src.ends-with(".typ") {
no-src.slice(0, -4) + ".html"
} else {
no-src
}
}
joinpath(base, typ-to-html)
}
}
which in turn allows category index file link destinations to be easily generated:
#let category-pubdir(category) = outpath(categories.at(category).path)
The final component is to set up a simple metadata system through which a subpage can declare itself to be a member of a certain category, as well as declare a page title, creation date, etc. In principle, we don’t really need a full metadata system if the goal is simply to render pages (as we’ll soon see), but it having it in place turns out to be useful for a couple other things and assists with how the Home category is treated.
Metadata is managed as an ordinary dictionary created through a simple function,
#let metadata(
id: sys.inputs.filename,
category-path: sys.inputs.category-path,
title: none,
subtitle: none,
created: none,
draft: false,
) = (
id: id,
category-path: category-path,
title: title,
subtitle: subtitle,
created: created,
draft: draft,
)
and the goal is to pass it to a master show rule that will handle all of the templating, e.g.
// some-post.typ
#import "/site/core.typ": metadata, post
#let meta = metadata(
title: "An ordinary blog post",
created: datetime(year: 2026, month: 4, day: 1),
)
#show: post.with(meta)
// ...
So then what should post be like? Essentially, it’s just one big function that handles all the HTML boilerplate, draws the navbar, sets other relevant show rules, and draws the title and creation date. Broadly like this:
#let post(meta, content) = {
// html metadata and such
show: html.html.with(lang: "en")
html.meta(charset: "utf-8")
html.meta(name: "viewport", content: "width=device-width, initial-scale=1")
html.head(html.style(read("/assets/main.css")))
// ...
// start main content
show: html.main
let category = meta.category-path.split("/").first()
let curlink-dest = { // navbar link to the current category's root
if meta.id == "index" {
none // leave out the link if we're already at the root
} else {
joinpath(category-pubdir(category), "index.html")
}
}
navbar(current-category: category, curlink-dest: curlink-dest)
sitepath(meta) // path to current page with hyperlinked elements
// other show rules...
[
#show-title(meta.title)
#show-subtitle(meta.subtitle)
#html.elem("p", attrs: (class: "post-notes"))[
#show-created(meta.created)
#show-draft(meta.draft)
]
#html.br()
]
content
}
where navbar in particular is simply a map + join over the items in categories:
#let navbar(
// current category, should be a key of `categories`
// if `none`, treat all categories on equal footing (i.e. without a highlight)
current-category: none,
// destination for a link placed under the current category name
curlink-dest: none,
) = {
let links = {
categories.pairs()
.map(((c, cdata)) => {
if c == current-category {
let cur = {
if curlink-dest != none {
link(curlink-dest, cdata.name)
} else {
[#(cdata.name)]
}
}
html.span(class: "active", cur)
} else {
let ln = link(joinpath(category-pubdir(c), "index.html"), cdata.name)
html.span(class: "other", ln)
}
})
.join()
}
html.header(html.span(class: "header-links", links))
}
Finally, I also wanted to have an easy way to display links to a set of subpages, e.g. in src/blog/index.typ, and it’s here that metadata comes in handy. The nice thing about Typst source code is that any .typ file can be imported, which means we can then access data included in meta (as created in the example some-post.typ above) from other files, provided we know what file names to import. Hence, we can write a simple post-list function that takes a list of paths, imports meta dictionaries from each one, and does some rendering for the posts’ titles3:
#let post-list(..paths) = paths.pos().map(post-link).join()
// resolve a relative path into an absolute path from the project root
let projpath(path) = {
if path.starts-with("/") {
path
} else {
"/" + joinpath(sys.inputs.dir, path)
}
}
#let post-link(path) = {
let fpath = if path.ends-with(".typ") { path } else { path + ".typ" }
import projpath(fpath)
html.elem("div", attrs: (class: "list-post"))[
#link(outpath(fpath), show-post-list-title(meta.title))
#show-post-list-subtitle(meta.subtitle)
#html.elem("p", attrs: (class: post-notes-class))[
#show-post-list-created(meta.created)
#show-post-list-draft(meta.draft)
]
]
}
Figure management
Figures and diagrammatic content suffers from a similar problem to equations, but I think probably for a different reason. Here, I’m mainly talking about content generated by CeTZ, the Typst equivalent to TikZ, and Fletcher (which is based on CetZ), but also other packages like Quill and Pavemat. The common property here (as far as I know) is that these are so-called “layout-dependent” packages that require access to Typst’s full layout engine. For example, we can generate a nice commutative diagram using Fletcher like so:
#import "@preview/fletcher:0.5.8" as fl
#fl.diagram(axes: (ltr, btt), spacing: 5em, mark-scale: 80%, {
fl.node((0, 0), name: <a>)[$A$]
fl.node((1, 0.25), name: <b>)[$B$]
fl.node((0.5, 1), name: <c>)[$C$]
fl.edge(<a>, "->", <b>)[$f$]
fl.edge(<b>, "->", <c>)[$g$]
fl.edge(<a>, "->", <c>)[$g compose f$]
})
HTML is, of course, more restrictive in its ability to describe precise layouts than PDF or SVG, so Typst’s current behavior is to simply ignore these parts of a page. It would ordinarily make sense to then simply treat this content like we would an equation and wrap it in html.frame, but here we run into a problem because we have an existing show rule for equations that also wraps them in html.frame. Inside the outer html.frame, though, rendering is performed in paged mode, so when the show rule fires on the math content the resulting HTML elements end up getting ignored.
#html.frame( // in paged mode
fl.diagram({
// ...
fl.node(/* ... */)[
$ /* ... */ $ // this math content is wrapped in an html.frame by our
// show rule even though we're in paged mode here
]
})
)
My original solution (before I really understood what was going on with html.frame) was to add in a system to manage separate, “standalone” Typst source files marked with a .svg.typ extension that would be pre-rendered to SVGs (in paged mode), which could then be included in a main post as an ordinary image.
/// resolve a path relative to the source file being compiled into an absolute
/// from the project root, and rebase from `srcdir` to `builddir`.
#let buildpath(path) = {
let abs = projpath(path)
if abs.starts-with("/" + srcdir) {
"/" + builddir + abs.slice(srcdir.len() + 1)
} else {
abs
}
}
/// include the rendered contents of a standalone ".svg.typ" file. Path
/// manipulation follows `ilink` rules, but replaces ".svg.typ" with ".svg". The
/// path should be to the source ".svg.typ" file.
#let source-standalone(
path,
width: auto,
height: auto,
alt: none,
fit: "cover",
) = {
assert(path.ends-with(".svg.typ"))
image(
buildpath(path.slice(0, -4)),
width: width,
height: height,
alt: alt,
fit: fit,
)
}
This was pretty cumbersome, though, for all the obvious reasons.
My new solution is to have a better way of managing content that is to be locally rendered in paged mode. The key feature of Typst here is a state object–although Typst is indeed fully pure/functional, state objects allow for stateful-like processing of content. The way it all works is through invisible pieces of content that, when detected by the compiler, mark exactly where a state object gets updated. When the “current” value of a state object is needed, the dependent content is marked with the context keyword that tells the compiler it needs to trace how the state is updated in order to fully resolve a value. Here’s a function for local paged rendering:
// state variable to keep track of whether we're in an html.frame
#let paged-mode = state("paged-mode", false)
// render locally in paged mode
#let paged(inline: false, content) = context {
set text(font: "New Computer Modern", fill: main-text-color)
let prev = paged-mode.get()
// this returns a piece of invisible content describing the update -- if it
// were to somehow not be rendered, the state wouldn't actually be "updated"!
paged-mode.update(true)
// content here is rendered with paged-mode == true
if inline {
html.elem("span", attrs: (class: "inline-frame"), html.frame(content))
} else {
html.elem("div", attrs: (class: "block-frame"), html.frame(content))
}
// reset to the previous value
paged-mode.update(prev)
}
Then we can use paged-mode to avoid wrapping equations where paged-mode == true by updating their show rules:
#show math.equation.where(block: true): it => context {
let attrs = (class: "block-frame", role: "math")
let framed = html.frame(it)
if paged-mode.get() { it } else { html.elem("p", attrs: attrs, framed) }
}
#show math.equation.where(block: false): it => context {
let attrs = (class: "inline-frame", role: "math")
let framed = html.frame(it)
if paged-mode.get() { it } else { html.elem("span", attrs: attrs, framed) }
}
and now things work out:
#paged[ // in paged mode
#fl.diagram({ // use as normal
// ...
fl.node(/* ... */)[
$ /* ... */ $ // math content is _not_ wrapped in html.frame here!
]
})
]
Implementing footnotes
Footnotes, although provided in the standard library, are another element that gets ignored by default during HTML output. They’re actually relatively easy to re-implement, though. Here we use another state variable to record the content and order of footnotes in ordinary content. The goal is to be able to use footnotes like this:
// snip
A dagger compact category allows one to distinguish between an "input" and
"output" of a process. In the diagrammatic calculus, it allows wires to be bent,
allowing for a less restricted transfer of information. In particular, it allows
entangled states and measurements, and gives elegant descriptions of protocols
such as quantum teleportation
#footnote[ // render here as a link to bottom of the page
Abramsky, Samson; Coecke, Bob (2004). "A categorical semantics of quantum
protocols". Proceedings of the 19th IEEE conference on Logic in Computer
Science (LiCS'04). IEEE. arXiv:quant-ph/0402130.
].
In quantum theory, it being compact closed is related to the
Choi-Jamiołkowski isomorphism (also known as process-state duality), while the
dagger structure captures the ability to take adjoints of linear maps.
// snap
#footnote-list() // display footnote text here
The solution leverages the fact that, apparently, links are label-able objects and hence Typst will automatically generate link locations for content that looks like
#link(/* ... */)[/* content */] <label>
Additionally, <label> objects in themselves can be easily converted back and forth between ordinary strings, so all we have to do is some simple state manipulation:
#let footnotes = state("notelist", (/* empty array */))
// add a footnote using custom labels: we work with a base name "fn{k}", where k
// is the number of the footnote; marks are referenced as "{basename}.mark" and
// notes are referenced as "{basename}.note"
#let footnote(content) = context {
let num = footnotes.get().len() + 1
let fn-basename = "fn" + str(num)
let update = footnotes.update(notes => {
notes.push((fn-basename, content))
notes
})
let fn-marklabel = label(fn-basename + ".mark")
let notelink = link(label(fn-basename + ".note"))[#num]
[#update #super[#notelink #fn-marklabel]]
}
// show all footnotes
#let footnote-list(with-header: true) = context {
let notelist = footnotes.final()
if with-header and notelist.len() > 0 { heading(numbering: none)[Footnotes] }
// notelist has notes in order, so just use build-in enumerated list
enum(
tight: true,
..notelist.map(it => {
let (basename, note) = it
let backlink = link(label(basename + ".mark"))[↵]
let fn-notelabel = label(basename + ".note")
[#backlink #fn-notelabel #note]
})
)
}
A small downside to this formulation is that, since the link objects are what’s actually being linked to, I need to place the backlink at the beginning of each note (or otherwise have the superscripted link to the note in the main text point to the end of the actual note text). It’s what Wikipedia does, but personally I don’t love how it looks. But otherwise I think it’s all pretty nice.
Build system
So now we can talk about my build system. In istudyatuni’s original formulation, everything was driven with Just, but I’ve since switched over to a Nushell script–after daily-driving it in my main terminal for several years now, I’m simply much more familiar with it, but there are two significant reasons that make it nicer to work with for this application.
The first is that because Typst doesn’t have multi-file compilation support means that every source file needs to be compiled using a separate invocation of typst compile, which makes it slow to build the whole site. Rather than mess around with forked processes in Bash shells spawned by Just, I just used Nushell’s par-each (i.e. parallelized map on a list) to trim build times down to scales much more appropriate for a site of this size.
The second reason is simply that structured data and pipeline-oriented syntax are so much nicer to work with than make-like recipes.
So here are the important parts of the build script4. Source file discovery happens via fd:
# file: run.nu
# parent directory of the build script
const ROOT = path self .
# for source .typ files
let src_dir = "src"
# for build files, e.g. .svg.typ renders
let build_dir = "build"
# for built .html files
let out_dir = "public"
# for link destinations in local builds
let dev_base = $ROOT | path join $out_dir
# for link destinations in public builds
let pub_base = "https://will.huie.dev"
def discover [
root: path
pattern: string
exclude?: string
]: nothing -> list<string> {
if $exclude == null {
fd --color never $pattern $root | lines
} else {
fd --color never --exclude $exclude $pattern $root | lines
}
}
Discovered files are fed to a main wrapper around typst compile:
def typ [
fpath: path
out_dir: string
out_ext: string
inputs: list<record<key: string, val: string>>
]: nothing -> nothing {
# form appropriate `--input` flags
let input_flags = (
$inputs
| each { |kv| ["--input" $"($kv.key)=($kv.val)"] }
)
# determine output file path
let outpath = (
$fpath
| str replace $src_dir $out_dir
| str replace ".typ" $out_ext
)
# concise compilation output
print (
$"(ansi green_bold) Compiling(ansi reset) ($fpath)\n"
+ $"(ansi green_bold) ↳(ansi reset) ($outpath)"
)
# call `typst compile` and capture output to detect warnings and errors
let out = (
["typst" "compile" "--features" "html" "--root" "."]
| append $input_flags
| append [$fpath $outpath]
| flatten
| run-external ...$in
| complete
)
# if we have an error, raise it formally
if $out.exit_code != 0 {
print $"(ansi red_bold) Failed(ansi reset) ($fpath)"
exterr $out
} else {
if not (has_warning $out) {
print $"(ansi green_bold) Finished(ansi reset) ($fpath)"
} else {
let warn_msg = extwarn $out
print $"(ansi yellow_bold) Warning(ansi reset) ($fpath)\n($warn_msg)"
}
}
return null
}
And our main build command calls this for each discovered path in parallel5:
def "main build" [ --pub, ...fpaths: path ]: nothing -> nothing {
let base_inputs = [
{ key: "base", val: (if $pub { $pub_base } else { $dev_base }) }
{ key: "srcdir", val: $src_dir }
{ key: "builddir", val: $build_dir }
]
let to_compile = (
if ($fpaths | length) == 0 {
discover $src_dir '^.*\.typ$' '*.svg.typ'
} else {
$fpaths
}
)
$to_compile
| par-each { |fpath|
let extra_inputs = [
{ key: "dir", val: ($fpath | path dirname) }
{ key: "filename", val: ($fpath | path parse | get stem) }
{ key: "category-path", val: (category_path $fpath) }
]
let inputs = $base_inputs | append $extra_inputs
ensure_dir $fpath
typ $fpath $out_dir ".html" $inputs
}
return null
}
Building with Nix
This is great and works well, but we also want everything to be buildable with Nix. The significant issue here is that, while it’s great that Typst will automatically download packages from import "@preview/..." statements, this actually works against us when it comes to a Nix flake build because, of course, the Nix build environment has no network access. Fortunately the Nix package ecosystem includes a typstPackages set, though.
So now we need some way to figure out exactly what Typst packages are needed by all source files (including transient dependencies) so that we can add them as explicit dependencies in our Nix flake. Here Nushell is again stellar, thanks to its amazingly ergonomic string processing utilities.
It’s a pretty trivial thing to find all the explicit imports across all source files with Ripgrep:
def "main get-packages" [ --nix, --deep ]: nothing -> nothing {
let package_list = (
rg --no-filename --no-line-number --color never '^\#import "\@preview\/'
| lines
| where $it != ""
| str replace --regex '^\#import "\@preview\/(.+)".*' '$1'
| uniq
| sort
| split column --number 2 ':' name ver
)
# ...
We can take it a step further by also fetching the latest versions of each package available via Nix:
# ...
if $nix {
let with_nix = (
$package_list
| each { |pkg|
let nix_ver = (
try {
let url = "nixpkgs/nixpkgs-unstable"
let nix_pkg = $"typstPackages.($pkg.name)"
nix eval --raw $"($url)#($nix_pkg).version"
} catch { |_|
"not found"
}
)
{ name: $pkg.name, ver: $pkg.ver, nix: $nix_ver }
}
)
if $deep { print "Main imports:" }
print $with_nix
} else {
if $deep { print "Main imports:" }
print $package_list
}
# ...
And then finally use the --deps flag of typst compile to get transitive dependencies by piping in a simple list of #import statements and reading the output file that gets generated.
# ...
if $deep {
let deplist_file = $build_dir | path join "deps.json"
ensure_dir $deplist_file
$package_list
| each { |pkg| $"#import \"@preview/($pkg.name):($pkg.ver)\"" }
| str join "\n"
| typst compile --deps $"($deplist_file)" - -
| ignore
let deep_deps = (
open $deplist_file
| get inputs
| str replace -r '.*\/preview\/(.+)\/([0-9.]+)\/.*' '$1:$2'
| sort
| uniq
| split column --number 2':' name ver
| where name != "<stdin>"
| where not ($it in $package_list)
)
print "Extra dependencies:"
print $deep_deps
}
return null
}
The final output of get-packages --nix --deep then looks something like this:
Main imports:
╭───┬──────────┬───────┬───────╮
│ # │ name │ ver │ nix │
├───┼──────────┼───────┼───────┤
│ 0 │ cetz │ 0.4.2 │ 0.4.2 │
│ 1 │ curryst │ 0.6.0 │ 0.6.0 │
│ 2 │ fletcher │ 0.5.8 │ 0.5.8 │
│ 3 │ ouset │ 0.2.0 │ 0.2.0 │
│ 4 │ pavemat │ 0.2.0 │ 0.2.0 │
│ 5 │ physica │ 0.9.7 │ 0.9.7 │
│ 6 │ quill │ 0.7.2 │ 0.7.2 │
│ 7 │ tidy │ 0.4.3 │ 0.4.3 │
│ 8 │ zebraw │ 0.6.1 │ 0.6.1 │
╰───┴──────────┴───────┴───────╯
Extra dependencies:
╭───┬────────┬───────╮
│ # │ name │ ver │
├───┼────────┼───────┤
│ 0 │ cetz │ 0.3.4 │
│ 1 │ oxifmt │ 0.2.1 │
│ 2 │ oxifmt │ 1.0.0 │
╰───┴────────┴───────╯
and we can figure out whether we can upgrade a package or need to downgrade in order to match what’s available.
Then the final flake.nix is about what you’d expect, except that we need to do a bit of extra work assembling all the Typst packages, since they don’t install to the default Typst cache location:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
};
outputs = { self, nixpkgs, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
eachSystem = f:
builtins.foldl'
( acc: system:
acc // builtins.mapAttrs
(k: v: (acc.${k} or {}) // { ${system} = v; }) (f system) )
{} systems;
in eachSystem (system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
# collect all typst dependencies into a single location
typstDeps = pkgs.symlinkJoin {
name = "typst-dependencies";
paths = with pkgs.typstPackages; [
cetz
cetz_0_3_4
curryst
fletcher
ouset
oxifmt
oxifmt_0_2_1
pavemat
physica
quill
tidy
zebraw
];
};
# typst requires a parent "preview" directory to hold all "cached"
# packages, so just symlink to the output directory of `typstDeps`
typstPackageCache = pkgs.runCommand "typst-package-cache" {} ''
mkdir -p $out/typst/packages
ln -s ${typstDeps}/lib/typst-packages $out/typst/packages/preview
'';
in
{
packages.default = pkgs.stdenv.mkDerivation {
name = "will.huie.dev";
src = self;
nativeBuildInputs = with pkgs; [ fd nushell typst ];
TYPST_PACKAGE_CACHE_PATH = "${typstPackageCache}/typst/packages";
buildPhase = ''
nu run.nu build-pub
'';
installPhase = ''
mkdir -p $out
cp -r public/* $out
'';
};
}
);
}
and, finally, that’s that.
Footnotes
- ↵ And, as far as I can tell, the
istudyatuni’s substitutedshowrule for code blocks also isn’t needed. -
↵ If we wanted, we could actually add parsing for the text of a
@refreference to write our own syntax for extra-file references. Such a thing would look something like this:#show ref: it => {
let targ = str(it.target)
if is-extra-file(targ) { // detect special syntax
let (loc, sub) = parse(targ)
let url = loc + "#" + sub
let lntext = if it.supplement == auto { [link] } else { it.supplement }
link(url, lntext)
} else { // as before
let el = it.element
if (
(el.func() == heading and el.numbering == none)
or (el.func() == figure and (el.kind == "loc" or el.numbering == none))
or (el.func() == math.equation and el.numbering == none)
) {
let lntext = if it.supplement == auto { [link] } else { it.supplement }
link(el.location(), lntext)
} else {
it
}
}
}But because most symbols (including “/” and almost all punctuation) are not allowed in label/reference text, the syntax for referring to e.g. a heading in a relatively deeply nested file would likely be pretty gnarly.
- ↵ It’s important to note that if
metadatais called with default arguments foridandcategory-path, then those elements of the output dictionary won’t be correct if they’re read from another source file. This is because their values are ultimately determined by the file that imports them, not the file that creates them. This hasn’t turned out to be a big deal in my usage, but it’s worth noting as a drawback of a rather hack-y solution. - ↵ Apologies for no syntax highlighting here, I guess Nushell hasn’t yet made its way to the standard set of languages that web devs will think about.
-
↵ One very nice thing about Nushell scripts like this is that any function defined in the script as
"main <command>is automatically exposed with auto-generated--helptext from the script and runnable vianu run.nu <command>
Changelog
- Originally posted 2026.04.03
- 2026.04.04: fix awkward
{|ligature in Nushell code examples - 2026.04.05: add bits about new intra-page linking system
Optionis just another name forMaybe