Written by: algebnaly
Date: 2026-03-29T16:45:12.000Z
尽管有astro-typst这样的 astro集成可以直接在astro中使用typst渲染博客页面。但是它基于主线typst release版本, 而截至这篇博客编写的时候 mkorje的mathml分支 还没有被合并进主线, 但是我已经按耐不住想用typst编写数学博客的心情, 开始着手实现使用mkorje的mathml分支渲染typst博客页面。
基本上代码全是gemini写的, 基本的做法就是通过vite plugin把typst文件编译成html, 然后使用正则表达式取出<style>和<body> 合并成一个html片段字符串, 然后使用typst query 命令取出frontmatter的内容。然后把这两个部分作为js模块的两个导出变量。
return {
code: `
export const frontmatter = ${JSON.stringify(frontmatter)};
export const html = ${JSON.stringify(finalHtml)};
export default html;
`,
map: null
};
接着就是使用astro提供的[…slug].astro生成动态路由, 把那两个导出的变量填进astro页面的模板中, 就能访问typst生成的博客页面了。
也就是说vite插件实际上是把typst的渲染结果(html)作为js变量导入到astro中。
我希望实现不经过vite插件时, 博客的typst文件就像正常的typst文件一样, 可以正常渲染成pdf文档。 经过vite插件时, 大部分内容渲染成html, 但是有一部分内容需要渲染成svg。
这个部分稍微复杂一点, 需要用到typst的state(状态变量)机制, 它将需要渲染的内容写入状态变量, 然后通过一个极小的wrapper typst文件, 把需要渲染的内容包裹起来, 在编译typst文件时, 把源typst文件include进一个0x0大小的盒子中, 然后将状态变量中的内容作为这个wrapper文件的实际显示的内容。
具体细节如下: 首先是utils.typ文件, 提供状态变量和isolated函数:
// isolated(id, body)
//
// 三种模式:
// - x-extract-id 存在: 仅把匹配 id 的 body 写入状态, 原位置不渲染
// - x-target=html: 写入查询标记并输出占位符,后续由 Vite 插件替换成 SVG
// - 其它模式: 原样渲染(PDF/本地预览无侵入)
#let extract-state = state("svg-extract-state", none)
#let isolated(id, body) = {
let target = sys.inputs.at("x-target", default: "pdf")
let extract-id = sys.inputs.at("x-extract-id", default: none)
if extract-id != none {
if id == extract-id {
extract-state.update(_ => body)
}
none
} else if target == "html" {
[#metadata(id) <svg-extract-list>]
"[[SVG_PLACEHOLDER_" + id + "]]"
} else {
body
}
}
然后, 一个普通的typst文件(hello.typ)使用isolated函数:
#import "utils.typ": isolated
这是测试文档
#isolated("test", [
$ e^(pi i) + 1 = 0 $
])
最后是wrapper typst文件:
#set page(width: auto, height: auto, margin: 0pt)
#import "utils.typ": extract-state, isolated
#box(width: 0pt, height: 0pt, clip: true)[
#include "hello.typ"
]
#context box(extract-state.final())
基本上全是gemini写的, 我只负责告诉gemini我想要什么、问gemini还有没有更好的方案, 直到gemini拿出当前这个我十分满意的方案出来。
编译的命令也很简单:
typst compile -f svg --input x-exract-id=test wrapper.typ
这样就能看到hello.typ中isolated块的内容被渲染成了svg了。
我使用cloudflare pages托管我的博客, 由于我使用的是自己编译的特定版本的typst, 构建环境并不能直接获取这个特殊版本的typst可执行文件, 我编译了一个musl静态链接的typst可执行文件(至于为什么要使用musl, 那就是另一个故事了), 并上传到cloudflare R2上, 然后在vite插件中下载该typst可执行文件, 这样就搞定了!
$ e^(pi i) + 1 = 0 $
$ mat(1,2,3 ; 4,5,6; 7,8,9) $
下面的几种字体都是typst官网的例子:
sans:
$ sans(A B C) $
frak:
$ frak(P) $
mono:
$ mono(x + y = z) $
bb:
$ bb(b) $
$ bb(N) = NN $
$ f: NN -> RR $
cal:
Let $cal(P)$ be the set of ...
Let be the set of …
scr:
$scr(L)$ is not the set of linear maps $cal(L)$.
is not the set of linear maps .
看起来Script font还是有点问题。不过我已经很满意了。
接着前面提到的渲染为svg的例子, 可以展示svg渲染下Script font的效果:
下面是使用cetz绘图的例子:
#isolated("cetz_graph",[
#import "@preview/cetz:0.4.2"
#import "@preview/cetz-plot:0.1.3"
#cetz.canvas({
import cetz.draw: *
import cetz-plot: *
let opts = (x-tick-step: none, y-tick-step: none, size: (2,1))
let data = plot.add(((-1,-1), (1,1),), mark: "o")
for name in (none, "school-book", "left", "scientific") {
plot.plot(axis-style: name, ..opts, data, name: "plot")
content(((0,-1), "-|", "plot.south"), repr(name))
set-origin((3.5,0))
}
})
])
由于我还还不太清楚如何实现在typst中写css并集成到astro中, 因此这些svg的宽度都是默认占满一页的, 看起来比较奇怪。