First Post
- published
- dev-log
- typst
- svelte
Hello, I will be using this blog to share my thoughts, experiments, and projects. Let’s start with a simple introduction to the blog itself.
This blog is built on SvelteKit, a framework for web-apps. I use static-adapter to generate a static site hosted on GitHub pages. There is no reason for choosing Svelte over other frameworks, but the static adapter is easy to set up, and I have experience with Svelte that made it convenient for this project. Like most static sites, the content is authored in a structured text format such as Markdown and then transformed into HTML for the browser. I chose Typst instead because I like writing in it, it has crisp syntax for mathematics, real scripting, and native support for drawing diagrams.
Currently, Typst still exposes HTML export as an experimental feature, but as of v0.15.0, this feature includes MathML export out-of-the-box, which is supported by most modern browsers. Previously, the primary way to preserve math in the HTML export was to wrap it in a html.frame which outputs an SVG1. Given that this is a technical blog, authoring math that is readable in the source and accessible in the browser is high priority.
I am definitely not the first person to write a blog using Typst, or even Typst with Svelte. So here are the acknowledgements to the resources and inspiration that inspired this project:
- Myriad Dreamin’s template (tylant) for static blogs with Astro and Typst which provided useful ideas and examples.
- The astro-typst integration.
- The Typst community that has been producing excellent tooling and discussion that has made this feasible.
Workflow
Authoring and publishing a post follows the following steps.
- Write the post in Typst — A
.typfile is made a source module in the SvelteKit project. - When Vite resolves an import of one, the plugin intercepts it and shells out to the
typstcli, twice in parallel. One invocation compiles the body to HTML over stdin; the other queries the front-matter<metadata>label for the post header. - Postprocessing is completed on the HTML via Cheerio, which normalises the MathML delimiters, matches and collects raw-source sidecars into a label map then walks node by node into a typed
PostBlock[]. Shiki highlights code blocks on this walk. - Finally, the plugin returns a JavaScript module exporting
{metadata, blocks}which the post route imports like any other module. +page.sveltemaps each block type to a defined Svelte component, which is rendered a second time at build time to produce the final static HTML output.
Keeping the post-processing explicit rather than hiding it behind a general purpose renderer makes it cheap to add features later, such as image optimization or search indexing, without touching the writing workflow. It has costs too, which the last two sections are about.
html.frame is what keeps the SVG alive in the HTML export.The diagram in Figure 1 is the same html.frame hack from the introduction, put to a different use. There it was preserving math; here it preserves the SVG that drawing libraries like cetz and fletcher produce, which the export would otherwise drop. Wrapping it in a #figure also gives it a label, and the HTML export turns that <label> into an id, so the figure can be cross-referenced like any other.
Wide diagrams need to scroll rather than break the layout, and that does not happen in the Typst layer. The plugin lifts each top-level figure out of the HTML into a figure block, and FigureBlock.svelte renders it inside a frame that carries overflow-x: auto. The rule lives in src/lib/css/article.css, which is imported by the post route alone and scoped under a single class, so it cannot leak across the site.
Highlighting
Code blocks need more care than headings or paragraphs, because the source has to survive the export before anything can highlight it. This used to need a sidecar: the pipeline replaced each rendered fence with an empty pre, emitted the raw text separately into a hidden script tagged with a matching label, and the parser paired the two back up.
That is no longer necessary. Typst’s HTML export preserves the source of a block fence, indentation and all, and tags the element with data-lang unprompted. The parser now reads the text straight off the code element and hands it to Shiki with the language beside it. Tabs are the only casualty, since the export expands them to spaces according to raw’s tab size.
Removing the sidecar took a numbering scheme with it. Labels were generated twice, once by a show rule as each fence rendered and once by a query over the finished document, and a block only found its source if the two agreed. They did agree, but nothing made them.
Highlighting runs on the server at build time, so the browser gets finished markup and no highlighting library. In the UI the code sits in a bordered card with the language label and a copy button that appears on hover; clicking it briefly swaps the icon to a tick before fading away again. The markup stays deterministic and themeable, and the interaction costs nothing on the client.
MathML
Typst v0.15.0 exports math as MathML, supported by most browsers with built in semantics and accessibility features. Writing
$ (partial u) / (partial t) = alpha (partial^2 u) / (partial x^2) $in the source gives you
in the browser: the PDE that describes the flow of heat, where is the thermal diffusivity constant, and is the temperature distribution over space and time.
This is the main benefit of using MathML over another SVG or image-based export. The markup carries semantics, a screen reader can walk it, and with some CSS it can be styled to match the rest of the page.
The tradeoff is that browser MathML support is uneven, and the usual answer is to ship a rendering library like MathJax to duct tape the gaps. I tried that and then removed it because shipping a typesetting engine (roughly a megabyte of JavaScript) to fix a handful of misrendered brackets is a bad trade on a blog that is otherwise entirely static markup. The better fix was to correct the MathML itself, once, at build time.
Stretchy delimiters
Typst does not set stretchy="false" on delimiters2, so the browser decides for itself whether a bracket should grow. Chromium tends to grow them even when the enclosed content is a single line, which makes ordinary parenthesised expressions look subtly wrong beside their neighbours.
Upstream cannot really fix this in the exporter. The answer in that issue is that at the point where the HTML is emitted, Typst does not have enough information to know whether a delimiter will end up stretched relative to what surrounds it, so it cannot decide the value of the attribute. Post-processing does not have that problem. By then the tree is finished, and whatever sits between a pair of delimiters is right there to be inspected.
So the post-processing step walks each math element and pairs opening delimiters with their partners using a depth counter, so nesting is handled correctly. For each pair it checks whether anything genuinely tall is enclosed at any depth: fractions, roots, tables, or over- and under-scripts. If none of them are present, stretchy="false" is emitted on the pair and the browser leaves them alone. Attributes that Typst already sets are never overwritten.
Alignment
Here is a two-line equation with an alignment point at each &:
It does survive the export. MathML has no native align environment like LaTeX’s, so Typst simulates one with an mtable, splitting each line at the & into a cell for the left-hand side and a cell for the operator and everything after it. It even hands you class names to hook onto:
<math display="block">
<mtable class="multiline-equation aligned">
<mtr><mtd>u(x, t)</mtd><mtd><mo form="infix">=</mo> ...</mtd></mtr>
<mtr><mtd>omega_n</mtd><mtd><mo form="infix">=</mo> ...</mtd></mtr>
</mtable>
</math>The structure is right, but the typography is not. Nothing sets the column alignment, and an mtable centres every column by default, so instead of the left-hand sides sitting flush against a shared =, each cell floats in the middle of its column and the equals signs wander. What comes out is a grid, not an alignment. There is a second, subtler problem in the same equation: the summation renders with its limits beside the operator rather than above and below it, as though the whole thing were inline. sum carries movablelimits, so its limits drop to script position whenever displaystyle is false, and nothing in the exported markup sets displaystyle on the table.
Neither is really a Typst bug, and neither needs one to be fixed. Alignment is pure presentation, and since mtd is a display: table-cell, a text-align rule against the aligned class sorts it out. displaystyle is not a CSS property, so that one has to be written into the markup by the same post-processing pass that already handles the delimiters.
How the layers are coupled
The design rests on one thing: Typst emits a marker, the parser matches that marker as a string, and Svelte maps the resulting block type to a component.
// lib.typ
#let side-note(content) = html.elem(
"div", attrs: (class: "side-note"), html.elem("p", content)
)The parser looks for exactly side-note on a top-level node, tags the block type: "side-note", and the route renders <SideNote>. Three layers, coupled by a string literal, with nothing checking that they agree. Rename the class in one place and the block silently falls through to generic HTML. It works, and it is legible, but it is the weak point of the whole arrangement.
Limitations
Three problems are worth noting because they are what the next post is about.
The same source cannot target both PDF and HTML. Typst’s primary use case is still PDF authoring, and writing for the web currently requires reaching for #html.frame and #figure to keep an SVG alive. That is a hack sitting in the content layer, where it does not belong. Authoring in the source should be compatible with both PDF and HTML export.
Typst exposes a target() function that allows the source to branch on the export target: "html" under --features html. The helper layer should branch on that, so the author never has to.
The block model cannot nest. This project started out as an MVP that worked for the simplest case: a flat list of blocks. The parser walks only the direct children of the <body>, and every block carries a string of HTML rather than child blocks.
The fix is to make the parser recursive so that it walks into a block’s children, but that is a lot of churn and it needs a properly designed block surface first. It also runs straight into the next problem.
Closed vocabulary. Adding one new block type is not one edit, it is six. The marker has to be emitted from lib.typ; sometimes registered as a selector in constants.ts; given a branch and a factory in parse.ts; declared as an interface and added to the PostBlock union in types.ts; given a branch in the route; and finally written as a component of its own.
lib.typ has no import edge at all: it is joined to the rest by a string and nothing else.Six touch points for one component is a workable cost for a personal blog with four block types at the moment, but it is a hard lock-in that makes it impossible for other people to work with the system without forking and editing the source.
Underneath all three is the same issue: this is a pipeline written for one site, with the host application’s types reaching into it. What I want instead is an integration in the shape of mdsvex or astro-typst: Typst HTML in, a typed tree out, with the host app supplying the components and the renderer. That is the subject of the next post.
Upcoming Posts
The next post rebuilds the pipeline described above into something that could be used by a site other than this one, and redesigns the authoring interface along the way. After that I have a backlog of ideas and projects to write about, which will be backdated:
- Modern Monte Carlo
- Squaredle Solver
- 1This is a well-known hack in the Typst community documented in this issue comment.
- 2See issue for details.