On starting a blog with Typst
Table of contents
This is a draft post
I struggle with starting new projects and never finishing them, so I made a decision to publish drafts to get at least something out there.
You are more than welcome to read this, and I would appreciate any tips on how to make this post better, but please don’t share it anywhere until this message has been removed. Thank you!
This is my third attempt to start a blog. Previously I did not really know what to write about. This time I’ve decided to use Typst, which brings me more joy than using a conventional static site generator like Jekyll or Zola.
In this post I’ll roughly describe the steps I took while creating this blog. 1
Sidenote on styling
For styling this blog I’m using Kelp. I’ve used it a couple of times and it has become my default CSS library. You should check it out!
Typst
Recently I learned that Typst has support for html export (incomplete, but functional). I’ve decided to give it a try in implementing a blog generator. And because I’m bad with starting projects I turned to the Typst package index and lo and behold someone already wrote a website template. Since it’s MIT licensed I’m using it as a starting point.
Although I’m assuming some level of familiarity with Typst, I’ll link to relevant pages in the Typst docs throught this post. You can also check out the tutorial.
Static site generation
In order to be able to host this blog somewhere we need to turn typ files into html files. Sadly Typst currently does not support generating multiple html files with a single command, so we need to do that manually. The base template used a Makefile, but I think there’s something wrong with it, since in order to regenerate the site I had to remove the destination folder. I’d like to think that I value my time, so I decided to write a script instead of fixing the Makefile.
The directory with the blog looks like this:
.
├── Justfile // scripts
├── _site // output directory
├── assets // css,js,images
├── config.typ // global configuration, more on that later
├── src // source code for the generator
│ └── blog.typ
└── content
├── index.typ
└── posts
└── on-starting-a-blog.typ
In order to compile the sources into _site directory, first we search for all typ files in the content directory:
find content -name '*.type'
then for each source file we get a corresponding path in the _site directory:
echo $source | sed -E 's/content/_site/' | sed -E 's/\.typ/\.html/'
and finally we run the typst compiler:
typst compile --features html --format html $source $target
Now we have a _site directory full of html documents that are eager to be hosted somewhere, but before that let’s discuss templating.
Templating and HTML
Even though Typst can turn it’s markup into semantic html, it does not know how to generate a full HTML page with the exact structure that we might want. We need to create a template that will contain the markup. What’s nice about Typst is that we don’t need to resort to a seperate templating engine like jinja2.
In Typst templates are plain functions and can be applied by regular function call or by using show rules:
// template definition
#let on-gray-background(content) = {
html.div(class: ("fill", "gray", "padding-xs"))[
#content
]
}
// function call
#on-gray-background()[
This is an _example_ #highlight[content].
]
// show rule
#show: on-gray-background
This is an _example_ #highlight[content].
In both cases the end result will look the same:
only difference being that show rules are applied until the end of the document. 2
In the example above you might also notice the html.div function. It’s a part of the typed HTML module. We use functions from this module to tell Typst the exact html element that we want to emit.
Page template and config
Now equiped with the ability to create templated html, we define a base layout for the site (simplified for brevity):
#let template(
website: "example.com",
header-links: none,
title: "Example",
footer: none,
content
) = {
html.html({
html.head(
html.title(title)
)
html.body({
html.header(
html.nav({
html.div(link("/", website))
for (href, title) in header-links {
[- #link(href, title)]
}
}),
)
html.main(
html.article(content)
)
html.footer({
html.hr()
html.div(footer)
})
})
})
}
Because we’ll probably want to apply this template with the same arguments in many places, we can partially apply it in config.typ:
#let template = blog.template.with(
header-links: (
"https://codeberg.org/kzdnk": ":codeberg",
"https://github.com/kzdnk": ":github",
),
website: "kzdnk.pl",
title: "kzdnk.pl",
footer: [
Made with #link("https://typst.app/")[Typst] ...
]
)
Then whenever we want to use it:
#import "/config.typ"
#show: config.template
I feel like I should mention vsheg/tufted again, since I did not make any significant changes to this part of their package. From now on I’ve diverged from their work.
Post list
Now I’ve decided to create a list of posts. Please recall the directory structure:
.
├── config.typ
├── src
│ └── blog.typ
└── content
├── index.typ
└── posts
├── on-starting-a-blog.typ
└── some-future-post.typ
index.typ will contain the list.
The solution I arrived at is simple, but makes me a little sad, because it requires changing the source in two places when adding a new post (and I’m quite forgetfull about these sorts of things). I have some ideas on how to make this better. 3
// in a post
#let post = (
title: [On starting a blog with Typst],
address: "posts/on-starting-a-blog.html",
publish-date: datetime(year: 2025, month: 11, day: 27)
)
// in src/blog.typ
#let link-post(f: format-post, post-file, ) = {
import post-file: post
- #post.publish-date.display() \-- #link(post.address)[#post.title]
}
// in content/index.typ for each post
#blog.link-post("/content/posts/on-starting-a-blog.typ")
#blog.link-post("/content/posts/some-future-post.typ")
Preprocessing + data loading
Before compilation I could save all the paths of posts to a JSON array and then iterate on those paths with Typst data loading capabilities.
This is probably the saner solution, but I’d like to implement as much of this blog in pure Typst as possible.
Multi-module introspection + wildcard imports
This one is a little bit out there, because it requries adding new features to Typst.
Typst has some introspection capabilites. I’m particularly interested in metadata and query. metadata defines an invisible element that can be queried. It has two advantages over regular bindings (like defining post in the last code sample):
- it can be queried, where bindings can not,
- there can be many metadata elements under the same label.
Let me give you an example:
#metadata("item one") <item>
#metadata("item two") <item>
#context {
for elem in query(<item>) {
[- #elem.value]
}
}
- item one
- item two
Introspection has one issue in my opinion: it does not work across module boundary. If metadata is defined in a module and then that module is imported, query would not return that element. Please note that as of writing this I know nothing about Typst internals, but I don’t see why importing a module could not bring metadata items from those modules into the scope of the current module.
Second part of this solution is some form of importing many modules with one import statemtent, so that metadata from many modules could be available without needing to list all those modules individually. This could take form of either importing a directory or a import with wildcards (which would allow for more complex directory structures):
#import "/content/posts/"
#import "/content/posts/*"
#import "/content/posts/*/index.typ"
Now that I think about it, introspection across modules is not necessary if importing with wildcards returned an array of modules, like so:
#import "/content/posts/*" as posts
#for module in posts [
#let post = module.post
- #post.publish-date.display() \-- #link(post.address)[#post.title]
]
Future plans
- accesibility
- import with wildcard
- metadata from imports
- function.arguments()
- outline: linking to headers with labels should use the label as anchors, instead of procedurally generating one
- code blocks with source file information
- rss feed
- blog roll