How this website was made

An overview of Epilogue, a custom static site generator based on Typst.

Here’s the elevator pitch: I want a static site generator that’s hackable and lets me write in a markup language human-readable and writable like markdown but with the full power of a programming language.

The system should be fast, correct, maintainable, and preferably not pull in 1000 transitive dependencies. As a hard requirement, it should be able to handle advanced typesetting of mathematical formulae. And it should all work without any client side JavaScript, in the spirit of the Web (back when it was still a capital W).

Write JavaScript like it’s 2005.

I couldn’t find anything that perfectly matched all of the qualities above. Usually, it was a trade-off between slick but austere (usually only supporting markdown with some plugins) or feature-rich but slow and clunky. So I ended up rolling my own, called it Epilogue, and it now powers this site.

A longer pitch

Back when I was shopping around for a framework to write this website in, which met the criterion enumerated above, I realized there was a gap in the ecosystem. Now, the entire web development culture of churning out framework after framework and package after package is well documented at this point. There are also a great many static site generators that have been established for years. So it feels strange to declare that, actually, we don’t have enough choice already.

But I think it’s true. Let’s briefly analyze two primary ways content driven sites are deployed to the web, in so-called “modern web development”. One: choose a web framework, and write in that. Two: use a static site generator, create some html templates, and write in Markdown or a similarly minimalistic markup language. (Three: a combination of both, like Astro.)

There’s a time and place for web frameworks. I’m partial to Astro and Svelte. But for a personal website? Hell no. I’m not going to depend on 1000 npm packages and ship users ten thousand lines of JavaScript for three interactive widgets and an image carousel.

I think Markdown is fine, but we can seriously do better. Markdown is for when you’re writing in a GitHub README and want some basic formatting. It’s rather austere for a markup language that generates content on a website you control. What if you want to define a custom reusable component? What if you want to programmatically do anything?

Of course there are systems to give you more power 1See MDX, which lets you write JSX inside Markdown, but at the end of the day I think the principal issue is that you’re either hacking a programming system into a markup language or a markup language into a programming system. The gold standard would be an actual markup language that treats programming as a first class citizen, or, equivalently, a programming language where markup is a first class citizen.

This is where Typst comes in. In Typst, markup and code are fused into one. Typst is like , in that it’s programmatic and scriptable. Typst is like Markdown, in that basic markup (paragraphs, lists, tables, headings, etc.) is easy to use with dedicated syntax. But it has a much better scripting language than while being just as easy as Markdown. And, despite its primary purpose being to output beautifully typeset pdf documents like , it has html export that is surprisingly easy to use. So instead of relying on third-party conversion utilities like Pandoc, you can access the full power of the Typst language and not worry about things getting lost in translation during conversion.

Enough evangelizing. How does this website actually work? This website is generated in Typst, but it was missing some pieces. Typst doesn’t understand intrinsically how to render a collection of html pages into a website, so I hacked some additional infrastructure together. I wrote a tiny (1.3k lines of safe Rust code) static site generator called Epilogue that can parse a directory of Typst documents—representing routes—and then build it into a website. It works pretty well (you’re reading text generated by Typst right now).

Is it actually usable? Surprisingly, yes. I’ve implemented a system for obtaining metadata from Typst documents, so we can populate the website <head> for SEO. Almost every element on this site (with the exception of the navigation elements) is written somewhere in a Typst source file. I implemented a thread pooled parallel compilation infrastructure so I can build hundreds of pages in a few seconds. I can basically do everything expected of a simple markdown static site generator right now.

Remember those snazzy and symbols earlier? They’re rendered directly as embedded svgs, from this source code (stolen shamelessly from swaits on the Typst GitHub):

#let TeXRaw = {
  set text(font: "New Computer Modern")
  let t = "T"
  let e = text(baseline: 0.22em, "E")
  let x = "X"
  box(t + h(-0.14em) + e + h(-0.14em) + x)
}

#let TeX = {
  $TeXRaw$
}

#let LaTeX = {
  set text(font: "New Computer Modern")
  let l = "L"
  let a = text(baseline: -0.35em, size: 0.66em, "A")
  $#box(l + h(-0.32em) + a + h(-0.13em) + TeXRaw)$
}

Try doing that with Markdown or React!

There are a few essential features I still need to add though. We can get information out of a document, but we still need to pass information back in—for example, we might pass in a list of recent blog posts for rendering a feed. I also want stuff like Atom/RSS feeds. But overall, it’s everything I want out of a static site generator—namely, the actual experience of writing markup is amazing thanks to Typst. I can define functions, create libraries for shared utilities and components, pull in packages, introspect on my webpage, and none of it feels janky like a markdown based solution would.

For an example of something that is only possible with Typst, see my CV, which is available as both the webpage and a pdf, in both full and short variations, all generated from a single Typst source file.

This whole idea of having like a document compiler that can take a source file [and] take it to multiple platforms, publishing targets is really more important than ever. I can tell you from many years spent among the short people of the web development community that they don’t really have anything for this.

Matthew Butterick, at the fourth RacketCon

Project board

The rest of this page is my project-board where I plan out and track feature implementation. By the way, the website and static site generator source code is available on GitHub.

General next steps: now that we can pass data from each document to the site generator, we need to figure out how to pass data from the site generator back into the document, so that we can generate things like RSS feeds, navigation pages, sitemaps, etc.

Started

  • 🕐 Set up a templating system that can embed the HTML (see hypertext).

    • 🕐 Introspection on the site at build time.
    • ✅ Integrate metadata system into templating system.
    • ✅ Component system so site can share common header, footer, nav, etc.

Triage

  • ☐ Implement the “prefixes” system. Basically, rather than classifying pages based on their directory (e.g. /blog contains all blog posts), I’d like to prefix the source file to tell Epilogue what kind of page it is. Right now, the prefix + is already used for all routes to indicate that they are a route, so it should be as simple as extracting the prefix and matching it against a lookup and storing it along with the metadata.

    This is a blocker on the implementation of the below.

  • ☐ Set up the meta-pages that collect posts automatically.

    • ☐ Figure out how to pass data from the static site generator back into the website.
  • rss/Atom feed.
  • ☐ Figure out how image hosting will work

    • cdn? That would introduce complexity, but I don’t like the idea of hosting static assets in GitHub using gh pages

      • ☐ Would have to set up deployment pipeline
      • ☐ Alternatively could move hosting off of gh pages and onto a personal server. Would have to write some sort of axum backend for this site (thus making it no longer static)

      I think I have an idea for this. We obviously don’t want to store big files in the source tree of this site—instead, I could store files using a version control system for large files like Syncthing. We’d push all our blobs to an S3-compatible bucket, like Cloudflare R2. Then, it remains to implement a tiny utility that can crawl the blob storage and generate a machine-readable manifest, tagging some sort of unique ID to each blob. In this website, then, we could read this manifest (for reproducibility, it would be published via a Git repository and tracked as a Nix flake input). When referencing images and files in text, instead of linking directly, we just write the ID and it would be replaced with the real link from cdn.youwen.dev at compile time.

Done

  • ✅ Parallelized all expensive operations, shrinking run times by an order of magnitude.
  • ✅ Allow some routes to be PDFs instead of webpages. So e.g. we could introduce a file pattern like $doc.typ and in the place where it would’ve been as a webpage, it’s a PDF instead.
  • ✅ Set up syntax highlighting with syntect or tree-sitter-highlight.

    • Used prism.js for now.
  • ✅ Set up TailwindCSS and a nice Big Beautiful Stylesheet.
  • ✅ Basic utilities with interacting with the world, e.g. Typst compiler, build intermediate artifacts.

    • ✅ Typst compiler wrapper
    • ✅ Build list of Typst dirs into HTML outputs
    • ✅ Automatically generate routes using Typst. The rule for this should be a special dir (routes?) where capitalized filename (e.g. About.typ) or nested directory (e.g. about/Me.typ) indicates routes.
  • ✅ Ingest a rendered HTML artifact and then process it to remove <head> and <doctype> tags amongst other extraneous tags.
  • ✅ "nested" templating for implementing Navbar.
  • ✅ Figure out how to do metadata…should be able to extract it from Typst source files?

    • ✅ Metadata is now possible, but slow. I’m thinking of doing a cache system where we keep a .json or .toml file that caches extracted metadata in .epilogue for fast development (and a switch in the code to skip inspecting metadata and trust the cache).

      • Eventually once we have file-watching hot reload this file will work better. But it’s faster than multi-second build steps.
      • Maybe also look into extracting multiple pieces of metadata at once. If not possible in Typst CLI, then will have to wait until CLI is replaced by embedding the typst create directly.
      • NOTE: I’ve marked this task done but the above has not been implemented. Rather I’ve just figured out how to make it much faster by parsing JSON out of a single query. However hot reload is still in question.

Wishlist

  • Directly link to Typst crate rather than calling the CLI

    • Should reduce overhead of IO. Possibly also increase performance via memoization of query and compile step.
  • Zettelkasten style system

    • Introduce #wikilink function or similar based on tags
    • Each document also has a corresponding ID
    • Expose all the tags in a document during the query step
    • Match up each tag to the route of the document with its (unique) ID, then pass JSON back providing each document with all of its tags, which it can then use in render step
    • Also can provide backlinks, etc
  • Advanced print functionality: by compiling a PDF in parallel with HTML, we can provide each page with a beautifully typeset PDF to print/save offline instead of janky browser print.

    • Could use typst.ts
    • Implemented partial version of this for the CV
  • Comments system, using htmx?

    • Currently using Giscus.

Testing code

Currently code highlighting is implemented using prism.js. In the future it may be done at compile time with more advanced methods.

impl TypstDoc {
    pub fn new(path_to_html: &Path) -> Result<TypstDoc, WorldError> {
        let doc = TypstDoc {
            source_path: path_to_html.to_path_buf(),
            metadata: None,
        };

        Ok(doc)
    }
}
#let webimg = (src, alt, extraClass: none) => {
let base-classes = "rounded-md mx-auto shadow-sm dark:shadow-none shadow-gray-900"
let classes = if extraClass != none {
base-classes + " " + extraClass
} else {
base-classes
}
html.elem("img", attrs: (src: src, alt: alt, class: classes))
}