Skip to content

Commit 39ae347

Browse files
committed
feat: build-time math rendering with KaTeX via Satteri plugin
1 parent b548f7b commit 39ae347

8 files changed

Lines changed: 194 additions & 3 deletions

File tree

‎astro.config.mjs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
11
// @ts-check
22

3+
import { satteri } from '@astrojs/markdown-satteri';
34
import mdx from '@astrojs/mdx';
45
import sitemap from '@astrojs/sitemap';
56
import tailwindcss from '@tailwindcss/vite';
67
import { defineConfig, fontProviders } from 'astro/config';
78

9+
import { satteriKatex } from './src/plugins/satteri-katex.mjs';
10+
811
// https://astro.build/config
912
export default defineConfig({
1013
// 仓库名匹配 <username>.github.io,因此不需要配置 base
1114
site: 'https://kingpython123.github.io',
1215

1316
integrations: [mdx(), sitemap()],
1417

18+
markdown: {
19+
// Astro 7 的默认 Markdown 管线是 Sätteri(不是 remark/rehype),
20+
// 所以数学公式走它的 math 解析特性 + 自写的 KaTeX 渲染插件,
21+
// 不需要换成 unified,也就不用引入整套 remark 生态。
22+
// mdx() 默认继承这份配置,.mdx 文件同样生效。
23+
processor: satteri({
24+
features: {
25+
// $$...$$ 行间公式,$...$ 行内公式
26+
math: { singleDollarTextMath: true },
27+
},
28+
mdastPlugins: [satteriKatex()],
29+
}),
30+
},
31+
1532
fonts: [
1633
{
1734
// Astro 在构建时下载并自托管字体文件,运行时不请求 Google

‎package-lock.json‎

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
"astro": "astro"
1313
},
1414
"dependencies": {
15+
"@astrojs/markdown-satteri": "0.3.4",
1516
"@astrojs/mdx": "^7.0.3",
1617
"@astrojs/rss": "^4.0.19",
1718
"@astrojs/sitemap": "^3.7.3",
1819
"@tailwindcss/vite": "^4.3.3",
1920
"astro": "^7.1.3",
21+
"katex": "0.18.1",
2022
"sharp": "^0.35.0",
2123
"tailwindcss": "^4.3.3"
2224
}

‎src/components/BaseHead.astro‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
---
2+
// KaTeX 的样式表放在 global.css 之前导入,这样我们自己的 .prose 规则
3+
// 在同等优先级下能覆盖它。字体文件由 Vite 解析 url() 后一起打包自托管,
4+
// 浏览器只在页面真的出现公式字形时才会去下载。
5+
import 'katex/dist/katex.min.css';
26
// Import the global.css file here so that it is included on
37
// all pages through the use of the <BaseHead /> component.
48
import '../styles/global.css';

‎src/consts.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
// 全站共用的常量。SITE_TITLE 会同时出现在页头、页脚、RSS 和浏览器标签上。
2-
// TODO: 换成你的真名或惯用署名
3-
export const SITE_TITLE = 'Kingpython123';
4-
export const SITE_DESCRIPTION = '机器学习与系统方向的技术学习笔记。';
2+
export const SITE_TITLE = 'sup';
3+
export const SITE_DESCRIPTION = 'sup的技术学习笔记。';

‎src/content/blog/math-test.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: '公式渲染测试'
3+
description: '验证 KaTeX 在构建时的渲染效果,确认无误后可以删掉这篇。'
4+
pubDate: '2026-07-25'
5+
---
6+
7+
这是一篇临时的验证文章,确认公式管线正常后可以直接删除。
8+
9+
## 行内公式
10+
11+
设注意力头数为 $h$,模型维度为 $d_{\text{model}}$,则每个头的维度是 $d_k = d_{\text{model}} / h$。当 $h = 8$、$d_{\text{model}} = 512$ 时 $d_k = 64$。
12+
13+
行内公式不应该影响行高,这一段里混着 $\alpha$、$\sum_{i=1}^{n} x_i$ 和 $\mathcal{O}(n \log n)$,行距应当保持均匀。
14+
15+
## 行间公式
16+
17+
缩放点积注意力:
18+
19+
$$
20+
\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
21+
$$
22+
23+
多行对齐环境:
24+
25+
$$
26+
\begin{aligned}
27+
\nabla_\theta \mathcal{L}(\theta) &= \nabla_\theta \left[ -\frac{1}{N} \sum_{i=1}^{N} \log p_\theta(y_i \mid x_i) \right] \\
28+
&= -\frac{1}{N} \sum_{i=1}^{N} \nabla_\theta \log p_\theta(y_i \mid x_i)
29+
\end{aligned}
30+
$$
31+
32+
矩阵和分段函数:
33+
34+
$$
35+
W = \begin{bmatrix} w_{11} & \cdots & w_{1n} \\ \vdots & \ddots & \vdots \\ w_{m1} & \cdots & w_{mn} \end{bmatrix}
36+
\qquad
37+
\text{ReLU}(x) = \begin{cases} x & x > 0 \\ 0 & x \le 0 \end{cases}
38+
$$
39+
40+
一条很长的公式,用来检查窄屏下是否横向滚动而不是撑破版面:
41+
42+
$$
43+
p(x_1, \dots, x_T) = \prod_{t=1}^{T} p(x_t \mid x_{<t}) = \prod_{t=1}^{T} \frac{\exp(z_{t, x_t})}{\sum_{v \in \mathcal{V}} \exp(z_{t, v})} \quad \text{where} \quad z_t = W_o \, \text{LayerNorm}(h_t) + b_o
44+
$$
45+
46+
## 与代码块共存
47+
48+
公式和代码块的字体、字号应当各自独立:
49+
50+
```python
51+
d_k = d_model // n_heads
52+
scores = (q @ k.transpose(-2, -1)) / math.sqrt(d_k)
53+
```
54+
55+
## 错误降级
56+
57+
下面这条公式故意写错(`\frac` 缺参数),应当降级成带颜色的等宽原文,并在构建日志里出现一条 warning,但构建仍然成功:
58+
59+
$$
60+
\frac{1}
61+
$$

‎src/plugins/satteri-katex.mjs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import katex from 'katex';
2+
3+
/**
4+
* 把 Sätteri 解析出的 math / inlineMath 节点在构建时渲染成 KaTeX 的 HTML。
5+
*
6+
* Sätteri 的 math 特性只负责「解析」,把 $...$ 和 $$...$$ 变成 AST 节点,
7+
* 渲染需要自己接。这里返回 { raw, mdxExpressions: false }:Sätteri 文档指明
8+
* 注入 KaTeX 这类含花括号的生成 HTML 时必须关掉 mdxExpressions,
9+
* 否则在 .mdx 文件里 {} 会被当成 JS 表达式求值。
10+
*
11+
* 公式在构建时就变成静态 HTML + MathML,浏览器端零 JS。
12+
*
13+
* @param {import('katex').KatexOptions} [userOptions]
14+
*/
15+
export function satteriKatex(userOptions = {}) {
16+
// 传工厂而非定义对象,这样每篇文档拿到独立的插件实例,
17+
// 文档内用 \gdef 定义的宏不会泄漏到下一篇。
18+
return () => {
19+
/** @param {{ value?: string }} node */
20+
const render = (node, ctx, displayMode) => {
21+
const source = node.value ?? '';
22+
23+
try {
24+
const html = katex.renderToString(source, {
25+
...userOptions,
26+
displayMode,
27+
// 出错时抛出,由下面接住并降级,而不是把红色报错直接印在页面上
28+
throwOnError: true,
29+
// 同时输出 HTML 和 MathML,屏幕阅读器能正确朗读公式
30+
output: 'htmlAndMathml',
31+
// 迁移过来的笔记里难免有非标准写法,别为此刷屏
32+
strict: 'ignore',
33+
});
34+
return { raw: html, mdxExpressions: false };
35+
} catch (error) {
36+
// 不让一条写错的公式炸掉整个构建,降级成等宽原文。
37+
// 注意:实测 Astro 7.1.3 不会把 Sätteri 的 diagnostics 打到构建日志里,
38+
// 所以下面这条 report 目前只是留给未来(或自行调用 Sätteri 时)用的;
39+
// 真正能看见的信号是页面上那段带底色的 .math-error 原文。
40+
ctx.report({
41+
message: `KaTeX 渲染失败:${error instanceof Error ? error.message : String(error)}`,
42+
node,
43+
severity: 'warning',
44+
});
45+
const escaped = source
46+
.replace(/&/g, '&amp;')
47+
.replace(/</g, '&lt;')
48+
.replace(/>/g, '&gt;');
49+
return {
50+
raw: `<code class="math-error" title="KaTeX 渲染失败">${escaped}</code>`,
51+
mdxExpressions: false,
52+
};
53+
}
54+
};
55+
56+
return {
57+
name: 'satteri-katex',
58+
math: (node, ctx) => render(node, ctx, true),
59+
inlineMath: (node, ctx) => render(node, ctx, false),
60+
};
61+
};
62+
}

‎src/styles/global.css‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,25 @@
220220
border-block-start: 1px solid var(--color-rule);
221221
}
222222

223+
/* 数学公式:KaTeX 默认按 1.21em 放大,衬线正文里显得过大,压回接近正文字号 */
224+
.prose .katex {
225+
font-size: 1.06em;
226+
}
227+
228+
/* 长公式在窄屏上横向滚动,而不是撑破版面 */
229+
.prose .katex-display {
230+
margin-block: 1.5em;
231+
padding-block: 0.25em;
232+
overflow-x: auto;
233+
overflow-y: hidden;
234+
}
235+
236+
/* 写错的公式降级成原文,用颜色标出来好排查 */
237+
.prose .math-error {
238+
color: #a3324b;
239+
background-color: #fdf0f2;
240+
}
241+
223242
.prose sup a {
224243
text-decoration: none;
225244
}

0 commit comments

Comments
 (0)