補足
執筆環境
| ソフト名 | バージョン | 補足 |
|---|---|---|
| Typst | 0.14 | TYPST_FEATURES=html |
| Tinymist Typst | 0.14.4 | — |
1. はじめに
Typst、とてもいいですよね。 Markdownのような手軽な書き心地でありながら、図表や脚注、参考文献まで美しく扱えるため気に入っています。
そんなTypstですが、バージョン0.14でHTMLエクスポート機能が大幅に強化されました。 セマンティックな要素のほとんど※1例えばカスタムHTML内でのfootnoteなど、一部未対応の機能もあります。が適切なHTMLタグに変換されるようになったほか[1]、html.elem を使うことで任意のHTMLタグを生成可能になりました。 つまり、TypstからHTMLのDOMツリーを直接操作できるようになったのです。
そこで今回はこの機能を活用して、Typstだけで記述可能なブログシステムを作ってみました。 一般的なSSG(静的サイトジェネレータ)を使わず、記事の執筆からメタデータ管理までをTypst内部で完結させる仕組みです。
なお本システムのスクリプトはWTFPLライセンスで公開していますが、あくまで個人利用を目的とした実験的なものです。 その点はご了承ください。
2. ファイル構成
本ブログのファイル構成は以下の通りです(コード 1)。
ルートディレクトリには、ビルドの起点となるファイルを配置しています。
index.typ: トップページ(https://bibouroku.minimarimo3.jp)の生成元。style.css: サイト全体のデザイン定義。template.typ: Typst側の公開APIをまとめたエントリーポイント。typst/: レイアウト、ウィジェット、執筆支援関数などの実装を分割して配置するディレクトリ。build.py: 各記事の先頭に書いたメタデータを読み取り、Typstコンパイルや静的ファイルの配置を一括で行うビルドスクリプト。
ビルドの成果物は public ディレクトリに出力され、この中身がそのままWebサイトとして公開されます。
各記事は個別のディレクトリで管理しており、そこに執筆した index.typ の先頭でメタデータを定義することでビルド対象として認識される仕組みです。 なお、記事ファイル名を index.typ としているのは、ビルド後の index.html(ディレクトリへのアクセス時にデフォルトで表示されるファイル)に対応させるためです。
BIBOUROKU.MINIMARIMO3.JP
│ index.typ # トップページ
│ style.css # サイト全体のテーマを設定するファイル
│ template.typ # 記事から読み込むTypst API
│ build.py # 各記事のメタデータを走査してビルドするスクリプト
│
├─public # ビルド後の出力先
│ │ index.html
│ │ style.css
│ │ feed.xml # build.pyによって生成されます
│ │ sitemap.xml # build.pyによって生成されます
│ │
│ ├─Typstでブログを書く
│ │ index.html
│ │ index.pdf
│ │
│ └─テスト
│ index.html
│ index.pdf
│ テスト用画像.png
│
├─typst
│ ├─core # article/home/shared などの骨格
│ ├─components # widgets/alerts/embeds などの部品
│ └─generated # build.py が生成する一覧用データ
│
├─Typstでブログを書く # 記事1
│ index.pdf
│ index.typ
│ Typstでブログを書く.yaml
│
├─テスト # 記事2
│ index.pdf
│ index.typ
│ reference.bib
│ テスト用画像.png
│
└─.github
└─workflows
deploy.yml
3. 実装
3.1. html.htmlで出力されるHTMLの構造をカスタマイズ
TypstのHTMLエクスポートは通常、文書内容を <body> タグ内に出力します。しかし、<head> 内にOGPタグや外部CSS読み込みを記述したい場合、これでは不十分です。 そこで typst/core/article.typ では、html.html 関数を使用して <html> タグから始まる完全なDOM構造を定義しました。
html.html(lang: "ja", {
html.head({
html.meta(charset: "utf-8")
html.meta(name: "viewport", content: "width=device-width, initial-scale=1")
html.title(title)
// OGP設定やGoogle Fontsの読み込み
if description != "" {
html.meta(name: "description", content: description)
}
html.elem("meta", attrs: (property: "og:title", content: title))
html.link(rel: "preconnect", href: "https://fonts.googleapis.com")
html.link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous")
html.script(src: "/script.js")
html.link(rel: "stylesheet", href: "/style.css")
})
html.body({
html.div(class: "site-container", {
// ヘッダー、記事本文、サイドバーなどを自由に配置
html.main(class: "main-content", body)
html.aside(class: "sidebar", { ... })
})
})
})
これにより、TypstだけでSEO対策やスタイリングに必要な構造を自由自在に作り込むことができます。
3.2. その他の記事のランダム生成
ブログとしての回遊性を高めるため、記事下部にほかの記事を表示しています。 単にランダムに選ぶとビルドのたびに内容が変わってしまうため、記事タイトルのハッシュ値をシード(種)として使用し、乱数生成器を初期化することで、ランダムでありながら常に同じ結果が得られるように工夫しました。
// typst/core/article.typより抜粋
import "@preview/suiji:0.5.0": *
// 自分以外かつ作成日が自分より若い記事が対象
let other-posts = post-data.pairs().filter(p => p.first() != slug).filter(p => p.last().create < create)
// slugとタイトルを数値化してシードにする
let seed-src = slug + title
let seed = int(seed-src.clusters().map(str.to-unicode).map(str).join().slice(0, 14))
let rng = gen-rng(seed)
// 記事リストをシャッフル
let (_, indices) = shuffle-f(rng, range(other-posts.len()))
// 上位3件を取得
let picks = indices.slice(0, 3).map(i => other-posts.at(i))
3.3. 記事の情報をTypstで管理する
トップページの記事一覧やRSSフィードを生成するには、全記事のメタデータ(タイトルや更新日)が必要です。 今回は各記事のindex.typ冒頭にメタデータを記述し、typst queryでそれを収集するアーキテクチャを採用しました。
#import "../template.typ": article, d
#let meta = (
slug: "Typstでブログを書く",
title: "Typstでブログを書く",
create: d(2025, 12, 14),
update: d(2025, 12, 21),
description: "Typst v0.14の新機能を使って、Typstだけでブログシステムを構築する試み。",
tags: ("Typst", "HTML"),
)
#metadata(meta) <post-meta>
#show: article.with(..meta)
Typstファイル内ではそのまま#show: article.with(..meta)に流し込めます。一方で、トップページや関連記事用の一覧はビルド時に自動生成したファイルをimportして扱います:
#import "/typst/generated/posts.typ": post-data
#for post in post-data.values() {
[#post.title]
}
一方、ビルドスクリプト(Python)からはtypst queryコマンドを使用することで同じ情報をJSONで取得できます。
result = subprocess.run(
["typst", "query", "--root", ".", "--features", "html", "article/index.typ", "<post-meta>"],
capture_output=True,
text=True,
check=True,
encoding="utf-8"
)
data = json.loads(result.stdout)
dataはこんな感じ:
[
{
"slug": "Typstでブログを書く",
"title": "Typstでブログを書く",
"create": "datetime(year: 2025, month: 12, day: 14)",
"update": "datetime(year: 2025, month: 12, day: 21)",
"description": "Typst v0.14の新機能を使って、Typstだけでブログシステムを構築する試み。",
"tags": [
"Typst",
"HTML"
]
}
]
これにより、MarkdownのFrontmatterのようなメタデータ管理をTypstの文法だけで統一しつつ、記事を自己完結した単位として扱えるようになりました。
3.4. 未実装機能への対処
HTMLエクスポートは発展途上のため数式や脚注などで工夫が必要な場面がありました。
3.4.1. 数式(Math)をSVG化して埋め込む
現状、数式をHTMLに変換することはできないようです。 これについては、html.frameを使用して数式を一度フレーム(画像扱い)にし、SVGとしてHTML内に埋め込む回避方法がTypstのissueに紹介されていたためこの方法を採用しています。
show math.equation.where(block: false): it => {
html.elem("span", attrs: (role: "math"), html.frame(it))
}
show math.equation.where(block: true): it => {
html.elem("figure", attrs: (role: "math"), html.frame(it))
}
3.4.2. カスタムHTML構造での注釈(Footnote)
html.htmlで独自のDOM構造を作ると、標準のfootnoteがエラーになる制限があります(コード 2)。 これに対しては、Typstのcounter機能を使って自前で脚注システムを実装することで解決しました。
error: footnotes are not currently supported in combination with a custom `<html>` or `<body>` element
┌─ \\?\G:\マイドライブ\bibouroku.minimarimo3.jp\テスト\index.typ:97:16
│
97 │ これがノートを付けられる対象1#footnote[footnoteの中身1]
│ ^^^^^^^^^^^^^^^^^^^^^^^^^
│
= hint: you can still use footnotes with a custom footnote show rule
let note-counter = counter("my-footnote")
show footnote: it => {
note-counter.step()
let num = note-counter.get().first()
// CSSでツールチップ表示するためのHTML構造を出力
html.span(class: "footnote-wrapper", {
html.span(class: "footnote-marker", "※" + str(num))
html.span(class: "footnote-content", it.body)
})
}
4. まとめ
TypstのHTML生成機能はまだ実験的な側面もありますが、個人のブログやドキュメントサイト構築には十分実用できるレベルに達していると感じました。 何より、普段のドキュメント作成で慣れ親しんだTypst記法がそのままWebサイトとして出力される体験は非常に快適です。
皆さんもぜひ、Typstで自分だけのWebサイトを作ってみてください!
参考文献
- [1] L. Mädje と M. Haug, 「Typst: Typst 0.14: Now accessible – Typst Blog」, Typst. 参照: 2025年12月19日. [Online]. 入手先: https://typst.app/blog/2025/typst-0.14#richer-html-export:~:text=Most%20semantic%20elements%20(those%20from%20the%20Model%20category)%20are%20now%20properly%20mapped%20to%20semantic%20HTML.%20We%27ve%20also%20improved%20handling%20of%20textual%20content%20in%20HTML%20export.