Building Websites with Just Typst

Jeremy Gao
Work in progress

This site you’re seeing is built with Typst. Typst is the only build tool required to build the site.

The build command is quite simple: the site has a dist.typ file with a shebang:

#!/usr/bin/env bash
#let _ = ```sh
case "$1" in
  compile) typst compile --features bundle,html --format bundle $0 ;;
  watch)   typst watch   --features bundle,html --format bundle --pretty $0 ;;
  *)       echo "Unknown option: $1. Enter 'compile' or 'watch'"; exit 1 ;;
esac
exit 0
```

1 . I set the execution bit for the file, then execute the file in my command line, which will output the site to ./dist. I think this is cool.

In this post, I will cover my journey in writing websites, how I fought HTML and Typst’s quirks, and built my tools. As of now it is still a work-in-progress. Though I anticipate that I will finish it by the end of July. The only reason why I put unfinished work in my blog is because I don’t want to lose my work in case of a crash or something else.

Before you read, please keep in mind that I don’t have a lot of web development experience. I just graduated from high school and sometimes I am extremely opinionated on certain technologies.

Building a Simple Site Today

Building a site today correctly is… rather complicated. Many sites use at least one web framework or template engine. I’ve used Jinja2, WebC, and Nunjucks, as well as Eleventy and Astro.

I’ve never liked any of them. All of them except Jinja2 require spinning up Node, installing a package manager, adding a bunch of files such as packages.json and some-manager.lock, and fetching a million files into node_modules. I was also using those frameworks without understanding how they work most of the times. Sometimes I want a feature to work, so I tried reading the documentation of the framework, but that feature is hidden in multiple layers of documents, so I quit and asked an AI instead – and LLMs don’t give accurate and up-to-date info all the time. 2

Typst is a new, complicated language, and LLMs are bad at writing Typst. I’ve been writing Typst for a year, and I can confidently say that I know more than the agents. For absolute beginners, I’d suggest using Markdown or one of the more popular web frameworks instead.

Typst At a First Glance

Typst is mostly known as the “modern L a T e X alternative”, which is true. But it can do more than just producing PDFs.

Typst added HTML export in 0.13, and at first sight, it looks just like a Markdown to HTML compiler with some extra goodies, like built-in programming support, types, a new math syntax and MathML export, smartquotes support, lists, etc.. The differences might not be obvious when your only goal is to write some content, since a good Markdown compiler also generates Mermaid.js graphs, L a T e X math equations, 3 and smartquotes. However, Typst can do way more than that with its built-in programming capabilities, WASM plugin execution, and the bundle export target.

Typst 0.15 sees the introduction of the bundle target, which allows generating multiple files from a single Typst project. This massively simplifies the process of writing a blog or a website. This site, for instance, uses the bundle target.

I don’t aim to use Typst to replace MediaWiki (although it absolutely can) or React. But Typst is good enough to make my blog site. Many blog sites have the following:

We will cover all of them in the rest of the post.

Organizing the Site with Labels

The simplist site contains only index.html. We can extend that by adding new files and a stylesheet. The following will generate two html files and a stylesheet.

#asset("styles.css", read("my-styles.css"))
#document("index.html", html.html[
  #html.head(
    html.link(ref: "stylesheet", href: "/styles.css")
  )
  #html.body[
    #title[Hello, web!]
    Also see my content at #link(<page-a>)[this page]
    and #link(<my-heading>)[this place].
  ]
])
#document("page-a.html")[
  Foo bar baz
  = I am a heading <my-heading>
] <page-a>

With comments:

// reads `my-styles.css` then generate `styles.css`. CSS files are not
// PNG, PDF, or HTML, so here we use `asset` to directly pass through the
// string.
#asset("styles.css", read("my-styles.css"))

// By default, Typst generates the HTML `html`, `head`, and `body` automatically.
// You can write your own `html`, `head`, and `body` by explicity passing those
// into the document function
#document("index.html", html.html[
  #html.head(
    html.link(ref: "stylesheet", href: "/styles.css")
  )
  #html.body[
    #title[Hello, web!] // this maps to h1
    // this generates a link to `page-a.html`
    Also see my content at #link(<page-a>)[this page]
    // this line generates a link to the heading in `page-a.html`
    and #link(<my-heading>)[this place].
  ]
])

// since we are not passing in `html`, `html`, `head`, and `body` will be
// automatically generated by Typst.
#document("page-a.html")[
  Foo bar baz
  // this turns into `<h2 id="my-heading">I am a heading</h2>`
  // This way it can be referenced in URLs
  = I am a heading <my-heading>
] <page-a>

15 lines in and we are already avoiding manual bookkeeping by using labels instead of hardcoded links. Since the link function from the Typst std accepts labels in addition to strings, we added label <page-a> to page-a.html, then create a link to page-a.html by writing #link(<page-a>)[..]. Because this link is created in index.html, Typst will automatically calculate the relative path from page-a.html to index.html. All paths generated using labels are relative paths.

Tips and Tricks

Postprocessing HTML

Typst has its shortcomings. The only postprocessing provided by native Typst is HTML minification, which removes extra spaces and linebreaks from the final HTML. However, there isn’t a convenient way to inspect the generated HTMLs, not to mention integrating an existing service like Pagefind or reading from git. 4

Final Thoughts

I haven’t finished thinking yet.

Thank you for reading my blog post.


Footnotes

  1. In practice, it is probably better to setup a Justfile or Makefile to manage the commands. In my case, I only have two tasks to execute during development: build, which builds the site, and serve, which runs a local development server provided by typst. I wanted to avoid pulling in an extra dependency (even including make! Which is installed by default on many devices), so in this case I just have a polyglot dist.typ with a bash header.
  2. My personal experience: I tried using Tailwind CSS in my site that I built using 11ty. Online outdated docs tell me to use tailwind.config.js, and some other docs tells me that that file is V3 specific. Since I am using V4 I should use something else, like a global.css file. At the end of the day I just didn’t understand all this file stuff, and videcoded the entire stuff. At least it works™︎, but I don’t understand any of it.

    In addition to the Node rant, pure HTML isn’t aware of types. It only knows strings and tags. It doesn’t support calculations of any sort, (JavaScript is an option, but it is a bad option.) and it losely inherits XML’s syntax. (XHTML is the better kid in the classroom. I guess the only case I’d use it is in epub files, though.) While HTML and XML are extremely good at describing mixed content, writing them is a pain, especially if you don’t have support from a modern editor that does the cleanup for you.

    I don’t like the syntax of HTML and JavaScript. My point from the very first time I used HTML was HTML shouldn’t be edited by hand. It should be procedurally generated by templating engines, even from other langauges. And that was exactly what developers did. Frameworks, both frontend and backend, all generates HTML upon user requests or when exporting the site. That being said, HTML’s syntax remains and sticks. You’d see <foo></foo> with the closing tag everywhere. 5 and the way frameworks such as Astro mixed JavaScript into HTML made the situation even worse: JavaScript’s syntax is also confusing. Functions can be imperative, the ternary operator is weird, and confusing comparison and type casting. All of which have repelled me from writing websites.

    In short: I don’t like JS.

  3. With some JavaScript sprinkles in many cases.
  4. Technically possible via gitoxide compiled to wasm. Though nobody did that.
  5. An argument is that the closing tag makes it easier for the programmer to inspect the type of the block without having to scroll up. This would’ve made sense if LSPs were not a thing. Modern LSPs (such as rust-analyzer) support showing functions names at the end of the function’s scope. This extra closing tag is just extra maintenance burden.