My blog My notes

Как я сделал блог на Typst

и немного Rust

23 июля 2025 #typst

Read in English

Содержание

Исходный код

В Typst 0.13 (релиз 19 февраля) добавили экспериментальный экспорт в HTML, также в июне и июле продолжалась разработка, например, добавили типизированное API:

// вместо
#html.elem("div", attrs: (class: "block"))
// можно писать
#html.div(class: "block")

По итогу я использовал самую последнюю версию на тот момент

Подсветка синтаксиса

Т.к. не было поддержки преобразования блоков кода с разбиением на отдельные span с нужными классами, сначала я сделал через встраивание 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 }
    }
    // для поддержки переключения темной и светлой темы
    render-code(it, text-dark, "dark")
    render-code(it, text-light, "light")
}

Но с таким подходом не работает выделение текста, потому что пока что Typst растеризует текст (есть запрос, чтобы текст вставлялся как есть), когда экспортирует в SVG, а #html.frame() как раз рендерит контент в SVG

этого кода нет в истории т.к. я все схлопнул и оставил только итоговую версию

Переключение блоков светлой/темной темы сделано через CSS в зависимости от того, какой класс стоит у body:

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

Подсветка через плагин

Я сделал небольшой плагин на Rust, который бы подсвечивал код, используя библиотеку syntect (Typst использует ту же библиотеку) + two-face (дополнительные синтаксисы)

Обновление от 26 июля 2025

В библиотеке zebraw я обнаружил, что можно это делать нативно. Typst уже знает, какого цвета должны быть элементы, надо просто экспортировать их в 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
    }
}

И плагин больше не нужен

Инициализация плагина

Инициализация нужна, чтобы можно было использовать plugin transition API. Данные нужно хранить в static:

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

при вызове init() данные сохраняются в 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()
}

а плагин на стороне Typst создается так:

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

Идея, как хранить данные, подсмотрена в typst-ctxjs-package

Генерация

В коде ниже опущена обработка ошибок:

Сигнатура простая:

#[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)
}

Принимает на вход закодированный через cbor набор параметров:

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

Ищет синтаксис по расширению и цветовую схему по названию:

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);

Собирает получившийся набор цветов и текста:

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));
    }
}

Возвращает закодированный через cbor массив элементов с их цветом:

#[derive(Debug, Serialize)]
struct HighlightOutput {
    color: syntect::highlighting::Color,
    text: String,
}
Конвертация результата в HTML на стороне Typst

Для каждого элемента создаем span с нужным цветом:

#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)
        }
    )
}

Код на самом деле немного сложнее, т.к. он еще обрабатывает генерацию для светлой/темной темы, нужно ли использовать pre или code, и ставит тему по умолчанию

Шрифты

Поначалу я хотел использовать шрифт напрямую с Google Fonts, примерно так:

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

но с таким подходом был заметен момент изменения шрифтов с дефолтного на этот. Решил скачивать их локально, но при этом не хотелось их добавлять в Git. А в Google Fonts нет API для скачивания шрифтов. Нашёл google-webfonts-helper, оттуда можно скачивать

В результате скачивается архив со шрифтами при сборке, распаковывается, и Typst генерирует CSS. В функцию передаются параметры генерации:

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

    // ...
}

и для каждого варианта создается @font-face:

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

Деплой через GitHub Workflows

Пишу эту секцию только потому, что нашел классный и простой способ установки приложений в CI без необходимости использовать сторонние actions для установки конкретных приложений, или устанавливать вручную

Установка приложений через Nix

Нам нужны 2 action-а, которые включаются так:

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

и после этого можно установить любое приложение, которое доступно в репозитории пакетов Nix:

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

или любое, у которого есть flake.nix, например, Typst:

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

Публикация в GitHub Pages

Для этого способа надо в настройках репозитория в «Pages» выбрать Source - GitHub Actions и добавить это:

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

На этом все! Смотрите исходный код для деталей