为 Shiki 不认识的语言加上语法高亮
بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ
你不会注意到的问题
这个站点上有两篇关于 Caddy 的博客文章。两篇都用 ```caddy 这种带语言标识的围栏代码块来展示 Caddyfile 配置。Expressive Code 负责渲染,Shiki 负责分词,页面构建一路无报错。
只是每一段 Caddy 代码块都被渲染成了纯文本。没有语法高亮,没有颜色,没有视觉结构。只有暗色背景上单调一色的字符。
那些代码块看起来像代码。它们有行号、有外框、有复制按钮。Expressive Code 该加的都加上了。缺的是 Shiki 该加的那一层:真正的高亮。指令、字符串、注释、值,全是一个颜色。
是把博客和 VS Code 一对比才注意到的。同一份 Caddy 配置,在编辑器里色彩齐全,在页面上却是一片单色。
为什么会这样
Expressive Code 用 Shiki 做语法高亮,而 Shiki 内置了 250 多种语言的语法。JavaScript、Python、TypeScript、Bash、HTML、CSS、Go、Rust。也就是大多数技术博客会写到的那些语言。
Caddy 不在其中。Traefik、HAProxy、Docker Compose 也不在,这些都是自托管基础设施里常见的工具。当 Shiki 遇到一个它不认识的语言标识时,它会回退到纯文本。没有报错,构建也不会失败。只在构建输出里埋着一行你大概率不会去读的警告:
[astro-expressive-code] Error while highlighting code block using language "caddy".The language could not be found. Using "txt" instead.这里的 「error」 用得宽容了。什么也没坏。页面照样渲染。代码块看起来还是代码块。它只是没在做一个语法高亮器本来该做的那一件事。
语法文件从哪里来
Shiki 用的是 TextMate 语法,和 VS Code 用的是同一种格式。每一个 VS Code 的语言扩展都会带一份 .tmLanguage.json 文件,定义这门语言要如何被分词:什么是关键字,什么是字符串,什么是注释。
如果 VS Code 能正确高亮你这门语言,那这份语法文件就已经存在了。它就躺在你的扩展目录里。
对 Caddy 来说,官方扩展是 caddyserver/vscode-caddyfile,发布名为 matthewpi.caddyfile-support。MIT 协议。
语法文件的位置是:
~/.vscode/extensions/matthewpi.caddyfile-support-0.4.0/syntaxes/caddyfile.tmLanguage.json让 VS Code 能够高亮 Caddyfile 的,就是这一份文件。同一份文件也能让 Expressive Code 高亮起来。把它拷贝进项目,把配置指向它,就完事了。
五行配置
Expressive Code 通过 ec.config.mjs 里的 shiki.langs 选项接收自定义语法:
import { defineEcConfig } from 'astro-expressive-code'import fs from 'node:fs'
const caddyfile_Grammar = { ...JSON.parse(fs.readFileSync('./src/Scripts/Build/Grammars/caddyfile.tmLanguage.json', 'utf-8')), name: 'caddy',}
export default defineEcConfig({ shiki: { langs: [caddyfile_Grammar], langAlias: { caddyfile: 'caddy' }, },})加载语法,把它加到 langs 里,按需把别的名字别名过去。配置的其余部分原封不动。
把站点构建一遍。警告没了。代码块亮起来了。
为什么它还是没起作用
只是它并没有亮起来。第一次没有。
语法文件加载的时候没有报错。构建跑得干干净净。代码块依然被渲染成纯文本。没有警告,也没有任何迹象表明哪里出了问题。
问题出在语法文件的 name 字段上。Caddyfile 的语法把自己声明为 name: "Caddyfile",大写 C,大写 F。可是 MDX 文件里围栏代码块写的是 ```caddy,全小写。Shiki 在匹配语言名时区分大小写。"Caddyfile" 匹配不上 "caddy"。
langAlias 这个选项乍一看就是答案。把 caddy 映射到 caddyfile。但是别名是在检查已加载的语言之前就解析的,而那份语法文件是以 "Caddyfile" 注册的,不是 "caddyfile"。别名指向了一个不存在的名字。
修复方式:在加载语法文件时直接把它的 name 属性覆盖掉。
const caddyfile_Grammar = { ...JSON.parse(fs.readFileSync('./path/to/caddyfile.tmLanguage.json', 'utf-8')), name: 'caddy', // override "Caddyfile" to match ```caddy fences}一行代码。语法文件现在以 "caddy" 注册,围栏写的是 caddy,Shiki 找到了匹配。一切就跑起来了。
这是那种不会主动昭告自己的 bug。没有堆栈,没有错误信息,构建也不会失败。语法文件加载成功。Shiki 只是从来没有把它和你的代码块对上号。你得知道 Shiki 的名字匹配是大小写敏感的,还得知道语法文件内部的 name 未必和你在 Markdown 里用的标识符一致。这两件事在 Expressive Code 的接入指南里都没有写。
套路
给 Expressive Code 加一门自定义语言,是一个三步流程:
-
**找到语法文件。**到你的 VS Code 扩展目录里翻一翻。如果你的编辑器能高亮这门语言,那
.tmLanguage.json就存在。把它拷贝进项目,这样构建就不再依赖你本机的编辑器配置。检查一下许可证。这份内置进项目的副本是你自己要维护的:上游的修复不会自动到你的构建里,除非你再拷贝一次。 -
**注册它。**把它加到 Expressive Code 配置里的
shiki.langs。如果想让多个标识符都解析到同一份语法,就用langAlias。 -
**对齐名字。**把语法文件的
name属性覆盖成你在围栏代码块里实际用的那个名字。不要假设语法内部的 name 和你的围栏标识符一致。
整个流程,一旦你知道了,是很短的。真正费功夫的,是发现你需要走这套流程,以及搞清楚为什么第一次尝试会悄无声息地失败。
现在的样子
之前:
handle_errors { rewrite * /404.html file_server}之后:
handle_errors { rewrite * /404.html file_server}内容是一样的。差别在于读者的眼睛是能一眼看清结构,还是要逐词去读。对一篇想要教别人配 Caddy 的文章来说,这个差别因沙安拉就是 「有用」 和 「让人挫败」 之间的距离。
技术博客上的代码块是一种安静的可信度信号。当它们带着完整的高亮渲染出来,没有人会注意。当它们没有,会注意到的人,恰恰就是你最希望读你东西的那一群人。
航空航天工程师
公开透明的道德创业者
你专注你的事业
我负责数字化的部分