typst编写博客

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中。

将typst中的部分内容渲染为svg:

我希望实现不经过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 $
𝑒 𝜋 𝑖 + 1 = 0
矩阵:
$ mat(1,2,3 ; 4,5,6; 7,8,9) $
( 1 2 3 4 5 6 7 8 9 )
Variants

下面的几种字体都是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的宽度都是默认占满一页的, 看起来比较奇怪。