My blog My notes

How I made blog in Typst

and a bit of Rust

23 July 2025

Читать на русском

Table of Contents

Syntax highlighting
Highlighting with the plugin
Plugin initializing
Generation
Converting result to HTML on the Typst side
Fonts
Deploying with GitHub Workflows
Installing apps with Nix
Publish to GitHub Pages

Source code

In Typst 0.13 (released February 19) experimental HTML export has been added, development also continued in June and July, for example, typed API has been added:

// instead
#html.elem("div", attrs: (class: "block"))
// you can now write
#html.div(class: "block")

As a result, I used the latest version at that time

Syntax highlighting

Since highlighting the code by splitting it into separate spans with the required classes wasn't supported, I initially did it by embedding SVG:

#show raw: it => {
let render-code(it, text-color, class-name) = {
set text(fill: text-color)
let render = html.frame(it)
if not it.block { box(render) } else { render }
}
// to suport dark/light theme switching
render-code(it, text-dark, "dark")
render-code(it, text-light, "light")
}

But with this approach text selection doesn't work, because now Typst rasterises text (feature request exists to insert text as-is) when exporting to SVG, and #html.frame() just renders some content in SVG

this is not recorded in git history because I squashed everything and left only the final version

Switching between blocks for dark/light theme is done using CSS depending on the body's class:

body:not(.light) span.light {
display: none;
}
body.light span.dark {
display: none;
}

Highlighting with the plugin

I wrote a small plugin in Rust for code highlighting using syntect library (Typst uses the same) + two-face (additional syntaxes)

Updated 26 July 2025

In the zebraw library I found out that it's possible to do this native. Typst already knows, what color should the elements be, you just need to export them to HTML:

show raw: it => {
show text: it => context {
html.span(style: "color:" + text.fill.to-hex(), it)
}
show: html.pre
for line in it.lines {
line.body
}
}

And the plugin is no longer needed

Plugin initializing

Initializing is required to use plugin transition API. Data is saved in static:

pub struct SyntectData {
syntaxes: SyntaxSet,
themes: ThemeSet,
}
struct Context(Option<SyntectData>);
static DATA: Mutex<Context> = Mutex::new(Context(None));

when init() is called data is saved in DATA:

#[wasm_func]
pub fn init() -> Vec<u8> {
DATA.lock().unwrap().0.replace(SyntectData {
syntaxes: two_face::syntax::extra_newlines(),
themes: ThemeSet::load_defaults(),
});
"ok".to_string().into_bytes()
}

and plugin is created on the Typst side like this:

#let _plugin = plugin("syntect_plugin.wasm")
#let _plugin = plugin.transition(_plugin.init)

The way data is stored is inspired by typst-ctxjs-package

Generation

Error handling is omited below:

Function is simple:

#[wasm_func]
pub fn highlight_html(args: &[u8]) -> Result<Vec<u8>, String> {
let args: HighlightInput = ciborium::from_reader(args)?;
let data = /* get DATA */;
let mut result = vec![];
// ...
let mut out = vec![];
ciborium::into_writer(&result, &mut out)?;
Ok(out)
}

It takes cbor encoded parameters:

#[derive(Debug, Deserialize)]
struct HighlightInput {
extension: String,
text: String,
#[serde(default = "default_theme")]
theme: String,
}
fn default_theme() -> String {
"base16-ocean.dark".to_string()
}

Searches syntax by extension and color scheme by name:

let syntax = data
.syntaxes
.find_syntax_by_extension(&args.extension)
.ok()?;
let theme = data.themes.themes.get(&args.theme).ok()?;
let mut highlighter = HighlightLines::new(syntax, theme);

Collects resulting colors and texts:

let mut result = vec![];
for line in LinesWithEndings::from(&args.text) {
let ranges = highlighter
.highlight_line(line, &data.syntaxes)?;
for (color, s) in ranges {
result.push(HighlightOutput::new(color.foreground, s));
}
}

And returns cbor encoded list of elements with respective colors:

#[derive(Debug, Serialize)]
struct HighlightOutput {
color: syntect::highlighting::Color,
text: String,
}
Converting result to HTML on the Typst side

For every element create span with respective color:

#let highlight-html(lang, theme: none, text) = {
let args = (/* construct arguments */)
let res = cbor(_plugin.highlight_html(cbor.encode(args)))
html.pre(
for (color: c, text) in res {
let rgb = (c.r, c.g, c.b).map(str).join(", ")
html.span(style: "color: rgb(" + rgb + ")", text)
}
)
}

Code is actually a bit more complicated, it handles generation for dark/light theme, is pre or code required, and sets a default theme

Fonts

I wanted to use font directly from Google Fonts, something like this:

@import url('https://fonts.googleapis.com/css2?family=Nunito&display=swap');

but with this approach the moment when font is changing from default was noticeable. I decided to download it separately, but I didn't want to store it in Git. But Google Fonts doesn't provide an API for font downloading. I found a google-webfonts-helper, you can download from there

In the result archive with fonts is downloading when building, unpacked, and Typst generates CSS. Function takes parameters:

#let gen-css-fonts(
name,
format: "woff2",
variants: (("normal", 200)),
path-fn: (style, weight) => style + weight + ".ttf",
) = {
// attr, string, url, format-value ...

// ...
}

and @font-face is created for each variant:

let res = ""
for (style, weight) in variants {
let face = "@font-face {\n";
face += attr("font-display", "swap")
face += attr("font-family", string(name))
face += attr("font-style", style)
face += attr("font-weight", weight)
// Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+
face += attr(
"src",
url(path-fn(style, weight)) + " "
+ format-value(format),
)
res += face + "}\n"
}
res

Deploying with GitHub Workflows

I'm writing this section only because I found an easy way to install apps on CI without the need to use any other actions for each specific app or to install it manually

Installing apps with Nix

We need 2 actions, that are enabled like this:

steps:
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main

and after this we can install any app that available in Nix packages repository:

- name: install tools
run: |
nix profile add "nixpkgs#just" && just -V
nix profile add "nixpkgs#fd" && fd -V

or any app that has flake.nix, for example, Typst:

- name: install typst
run: |
nix profile add "github:typst/typst?rev=b790c6d59ceaf"
typst -V

Publish to GitHub Pages

For this we need in repository setting in "Pages" choose Source - GitHub Actions and add this:

- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: ./dist/blog
- uses: actions/deploy-pages@v4

Thats all! See source code for details