Hello there
Welcome to , a Static-Site Generator written in Typst.
I’ve always been a huge SSG fan. I used Hakyll for a while, and then moved to Zola. When I saw that Typst’s version 0.14 mentioned HTML export, even if it was behind a feature flag, I knew I had to give it a shot.
If you have ever used Zola before, you’ll find that ‘s architecture is heavily inspired by it. You’ll need four things:
- The package,
- A couple of template files,
- Some content to populate your website,
- Some way to automate the build.
Let’s dive into each and see how they fit together.
The package
Okay, this one won’t be as easy as with the usual Typst packages you import using #import "@preview/package:version". And, let’s be honest, this one is on me. At the time of writing, the only way to publish packages is to push the code to github. And, well, I don’t wanna feed the beast. So, yeah, there are other ways, Just not with @preview.
A git submodule
Git, is a fantastic tool. It has this submodule command that allows you to clone one or more repositories inside another. It acts as a native dependency manager for your project.
So, yeah,
git submodule add \
https://gitlab.com/pcoves/theta.git \
packages/local/theta/0.3.1
You can later update it with git submodule update. Don’t forget to move the submodule to a path that reflects the correct version.
A nix flake
Well, that’s exactly what I’m doing here. I have a flake that fetches the git repository for me and creates a symlink to the correct path Just like one would do with the git submodule.
Feel free to copy the code from the pages branch of ‘s repository.
The template files
Unlike Zola, comes with some existing templates. They are ready-to-use Typst show rules, nothing fancy here. Let’s look at the page template, for example:
“Please keep in mind that is new and code may change before version 1.0.0”
#import "./base.typ": *
#let page(
title: none,
author: (),
description: none,
date: none,
header: none,
footer: none,
render: (content) => {
std.title()
content
},
content,
) = {
show: base.with(
title: title,
author: author,
description: description,
date: date,
header: header,
footer: footer,
render: render,
)
content
}
What do we have here? A page takes several parameters, a render function, and some content. And it passes all this to yet another show rule. That latest show rule is actually the one that renders the whole HTML structure. You can see it here if you want.
Okay, back to the page function. It renders a page, as opposed to a section. These are concepts I brought with me from Zola. So, a page must have a title and a description will error if you forget them. The author and date are optional, except for blog pages, as we’ll see later. The default render simply shows the title followed by the content.
Now, I haven’t talked about the header and footer yet. base builds a very simple HTML structure:
html:
head: ~
body:
header: ~
main: ~
footer: ~
By default, the header and footer are none. Meaning they’re supposed to be user-defined, as there’s no one-size-fits-all for such content. Which bring me to the next part.
User defined templates
Do you imagine having to specify both the header and the footer for every page you write ? That’d completely defeat the purpose of a SSG.
So, you have your directory with the package as seen earlier. You can create your own template based on ones to factorize some code. Here is the page template I use for this website:
#import "base.typ": base
#let page(
title: none,
author: (),
description: none,
date: none,
render: (content) => {
std.title()
content
},
content,
) = {
show: base.with(
title: title,
author: author,
description: description,
date: date,
render: render,
)
content
}
Similar… But different. Here, I still import a base but the one I also defined locally. In this base, I defined the header and footer I want to use in every single page and section of my website. So, I don’t need to repeatedly specify those each time I want to output some HTML.
So, there’s ‘s templates, called my user-defined templates. So, there are ‘s templates, and then there are my user-defined templates. In the end, all they do is call some show functions to render some content right ?
Your content
Let’s keep digging on the page side of the force. You’ll want to create a content directory with some typst files in it. The content/index.typ will give the website’s index page while content/about.typ will generate the About page. About that, how is this one done ?
#import "/templates/page.typ": page
#show: page.with(
title: "About",
description: "About this website"
)
#lorem(60)
Okay, can you see where we’re going ? I imported my user-defined page, gave it a title, a description and some content. Simple as that, almost everything is factorized in the templates.
Great ! brings some unified structure, your templates fill the default values, your content is now rendered in pages and sections.
Well, no, not really. Unlike Zola, there is no executable program you can run to make the magic happen. But, I wrote one for you.
The build system
Let’s be honest, Typst currently has some limitations. For example, you can’t list files and directories from within a Typst document. People much smarter than me are working on making a path type that is different from the string type. It’s said that it might bring some access to the file-system along the way. But we’re not there yet (can’t wait, Typst is awesome!).
At the moment, we can’t start from the content/index.typ file a treat each collocated Typst file as a page and every subdirectory as a section. All this logic must come from outside Typst and outside . A long time ago, people would have used a Makefile; This could work. But, it’s 2025, let’s use Just and make our life easier.
I’ll not dive into details here. Simply grab the Justfile I wrote here and run just build. It’ll generate every pages and sections from your content directory inside a public directory. It’ll also copy the content of the static directory inside the public one so that you can refer to fonts and images from everywhere.
—
I hope this page gives enough information to get started with . I’ll cover sections and provided render functions later.
If you have any question or remark on this work, feel free to contact me on Mastodon or shoot me an email.
is still a work in progress. But, hey, I’m having fun and I hope you’ll have some too if you decide to use it.