How I made blog in Typst
and a bit of Rust
23 July 2025
Table of Contents
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
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:
- instead of
f().map_err(/* error handling */)?-f()? - instead of
f().ok_or_else(/* none handling */)?-f().ok()?
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