代码块增强与 Expressive Code
Mizuki 通过 astro-expressive-code 集成为 Markdown/MDX 中的围栏代码块提供"开箱即用"的增强渲染:双主题(明/暗)高亮、行号、折叠区、语言徽标、自定义复制按钮与终端风格的统一外观。本页覆盖该能力从配置声明到构建期渲染的完整链路。
Purpose and Scope
本页是 Markdown 管线(markdown-pipeline)下专门讨论代码块增强的参考页,覆盖以下内容:
- 配置层:
src/config/expressiveCodeConfig.ts中的全部可调项,以及src/types/config.ts中ExpressiveCodeConfig的类型契约 - 集成层:
astro.config.mjs中expressiveCode()集成入口的插件编排、defaultProps语言级覆盖、styleOverrides样式映射 - 运行时表现:双主题切换、主题过渡期的隐藏策略、CSS 变量驱动的视觉统一
- 边界情况与扩展点:如何新增插件、如何按语言定制行为
以下相关主题有意留给兄弟页面,本页只做引用不展开:
- KaTeX 数学公式、Mermaid/PlantUML 图表、Callout 提示框、Wiki Link、图片网格等其余 Markdown 扩展 —— 属于 Markdown 管线整体(For the full pipeline, see Markdown Pipeline)
--codeblock-bg、--primary等 CSS 变量本身的定义与主题系统 —— 属于主题系统相关页面src/styles/expressive-code.css作为补充样式的入口文件存在,其内部规则不在本页摘录范围内
Overview
代码块是技术博客中出现频率最高、也最影响阅读体验的元素。Mizuki 选择 Expressive Code 而非原生 Astro Shiki 配置,原因可以从源码中直接读出:
- 零客户端脚本的双主题:
themes: [lightTheme, darkTheme]同时注册两套主题(github-light/github-dark),高亮结果在构建期输出为带主题属性的 CSS,浏览器切换主题时只需切换 CSS 变量/选择器,无需重新解析代码。 - 声明式可裁剪的插件管道:语言徽标、语言 Logo、折叠区、行号、复制按钮全部是插件;其中徽标/Logo 通过配置开关条件注册,用户改一个布尔值即可启用或移除。
- 视觉与站点主题解耦:
styleOverrides把 Expressive Code 的全部背景/边框/字体键位映射到var(--codeblock-bg)、var(--primary)等站点级 CSS 变量,代码块外观自动跟随站点明暗主题,不需要在两处维护颜色。
上游依赖(来自 package.json):astro-expressive-code、@expressive-code/core、@expressive-code/plugin-collapsible-sections、@expressive-code/plugin-line-numbers,全部锁定在 ^0.44.1 同一版本线,避免跨包版本漂移。
Architecture
上图描述了数据从声明到产出的单向流动:
- 配置层:
src/types/config.ts先定义ExpressiveCodeConfig接口(字段含defaultWrap、hideDuringThemeTransition、languageBadge等),src/config/expressiveCodeConfig.ts按该接口导出具体值。类型在前、配置在后的写法让编辑器能对错误字段即时报错。 - Astro 集成层:
astro.config.mjs导入expressiveCodeConfig,在expressiveCode({ ... })中把配置翻译成 themes、plugins、defaultProps、styleOverrides 四类参数。 - 插件管道:5 个插件按数组顺序注册;其中两个官方插件(折叠区、行号)无条件注册,徽标/Logo 两个由配置开关控制展开(spread + 三元),
pluginCustomCopyButton无条件注册以替换官方复制按钮。 - 输出层:构建期生成静态 HTML + 主题化 CSS;视觉最终由站点级 CSS 变量(
--codeblock-bg、--primary)与补充样式文件src/styles/expressive-code.css决定。
值得注意的是:pluginLanguageBadge、pluginLanguageLogo、pluginCustomCopyButton 并未出现在 package.json 的 @expressive-code/* 官方依赖清单中(官方依赖仅 core / plugin-collapsible-sections / plugin-line-numbers / astro-expressive-code),因此它们是项目自定义插件,导入语句位于 astro.config.mjs 顶部(未包含在本页摘录范围内)。
插件装配的运行时决策
条件插件不是简单的静态数组,而是在配置载入时展开:
这个"spread + 三元 + 空数组回退"的模式(...(cond ? [plugin] : []))让"关闭一个功能 = 改一行布尔值",而不是从数组里手工删插件——插件与开关永远成对出现,降低了配置漂移的风险。
配置文件解析
配置的唯一入口是 expressiveCodeConfig.ts,它整体实现了 ExpressiveCodeConfig 接口:
1import type { ExpressiveCodeConfig } from "../types/config";
2
3// 代码块样式配置
4export const expressiveCodeConfig: ExpressiveCodeConfig = {
5 darkTheme: "github-dark",
6 lightTheme: "github-light",
7 defaultWrap: true,
8 // 是否在主题切换时隐藏代码块以避免卡顿问题
9 hideDuringThemeTransition: true,
10 languageBadge: {
11 enable: true,
12 },
13 languageLogo: {
14 enable: false,
15 color: "mono",
16 excludedLangs: ["text", "plaintext"],
17 },
18 collapsible: {
19 enable: true,
20 lineThreshold: 20,
21 previewLines: 10,
22 defaultCollapsed: true,
23 },
24 codeGroup: {
25 enable: true,
26 },
27};Source: expressiveCodeConfig.ts
各字段的设计意图:
darkTheme/lightTheme:注册到 Expressive Code 的两套主题名。双主题是双列表渲染的前提,也是主题切换零成本的原因。defaultWrap: true:默认对代码块启用软换行,而不是默认出现横向滚动条——面向博客阅读场景(手机上更明显)而非 IDE 场景。hideDuringThemeTransition:带有中文注释“是否在主题切换时隐藏代码块以避免卡顿问题”。这是一个纯客户端体验开关——高亮过的代码块切换主题时只换 CSS 类,但大量代码块同时重绘仍可能掉帧,切换瞬间隐藏可以掩盖这一卡顿。注意它不传入expressiveCode()(集成层没有引用它),因此它服务于客户端主题切换逻辑,属于主题系统与代码块的协作点。languageBadge.enable: true:在代码块角标处显示语言名,帮助读者无需读完上下文就能识别语言。languageLogo.enable: false:语言 Logo 默认关闭(徽标已足够),但保留了color: "mono"与excludedLangs: ["text", "plaintext"]的完整参数结构——纯文本语言没有可辨识 Logo,排除它们避免渲染空位。collapsible.*:长代码折叠。lineThreshold: 20表示超过 20 行才出现折叠控件;previewLines: 10表示折叠态只显示前 10 行;defaultCollapsed: true表示默认折叠。codeGroup.enable:代码组(同一容器的多个标签页代码块)开关。
类型契约
类型定义在 src/types/config.ts。通过 Grep 检索到的关键片段:
defaultWrap: boolean;
hideDuringThemeTransition?: boolean; // 是否在主题切换时隐藏代码块
languageBadge: {Source: config.ts
从这一片段可以确认两件事:
defaultWrap是必填(boolean,无?),而hideDuringThemeTransition是可选(boolean?)——因为它是后来为了解决主题切换卡顿而补充的体验项,标记为可选保证旧配置无需迁移。- 每个功能块(
languageBadge、languageLogo、collapsible、codeGroup)都是一个含enable字段的子对象,enable是统一的功能开关语义,所有功能开关都在同一个配置平面上,运维心智模型一致。
Astro 集成详解
astro.config.mjs 是所有配置汇聚的地方。以下摘录展示了 expressiveCode() 的完整调用结构:
1 expressiveCode({
2 themes: [expressiveCodeConfig.lightTheme, expressiveCodeConfig.darkTheme],
3 plugins: [
4 pluginCollapsibleSections(),
5 pluginLineNumbers(),
6 ...(expressiveCodeConfig.languageBadge.enable
7 ? [pluginLanguageBadge()]
8 : []),
9 ...(expressiveCodeConfig.languageLogo.enable
10 ? [
11 pluginLanguageLogo({
12 color: expressiveCodeConfig.languageLogo.color ?? "mono",
13 excludedLangs:
14 expressiveCodeConfig.languageLogo.excludedLangs ?? [],
15 }),
16 ]
17 : []),
18 pluginCustomCopyButton(),
19 ],
20 defaultProps: {
21 wrap: expressiveCodeConfig.defaultWrap,
22 overridesByLang: {
23 shellsession: { showLineNumbers: false },
24 bash: { frame: "code" },
25 shell: { frame: "code" },
26 sh: { frame: "code" },
27 zsh: { frame: "code" },
28 },
29 },Source: astro.config.mjs
主题注册顺序的语义
themes: [lightTheme, darkTheme] 的数组顺序并非任意:Expressive Code 将第一个主题作为默认主题输出主 CSS 规则,第二个主题以 [data-theme="dark"] 选择器叠加。把 light 放在首位意味着默认渲染浅色、在暗色模式下叠加 dark 规则,这与站点“默认浅色 + 可切深色”的主题模型一致。
语言级 props 覆盖(overridesByLang)
overridesByLang 是对"每类代码块各自需要不同行为"这一需求的回应,从源码可读出两类定制:
shellsession: { showLineNumbers: false }:终端会话输出(含提示符、命令与结果混排)逐行编号没有意义,反而干扰复制粘贴,因此关闭行号。bash / shell / sh / zsh: { frame: "code" }:把 shell 类语言的渲染从“终端模拟框”降级为“普通代码框”。终端框的假标题栏对博客中的短命令片段是视觉噪音,这一行让 shell 代码块与其它语言视觉一致。
注意这里用 defaultProps 而非硬编码在 Markdown 的围栏标记里——读者在写作时不需要记住 frame="code" 这类属性,站点级策略集中收敛在配置层。
样式映射(styleOverrides)
1 styleOverrides: {
2 codeBackground: "var(--codeblock-bg)",
3 borderRadius: "0.75rem",
4 borderColor: "none",
5 codeFontSize: "0.875rem",
6 codeFontFamily:
7 "var(--font-jetbrains-mono, ui-monospace), SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
8 codeLineHeight: "1.5rem",
9 frames: {
10 editorBackground: "var(--codeblock-bg)",
11 terminalBackground: "var(--codeblock-bg)",
12 terminalTitlebarBackground: "var(--codeblock-bg)",
13 editorTabBarBackground: "var(--codeblock-bg)",
14 editorActiveTabBackground: "none",
15 editorActiveTabIndicatorBottomColor: "var(--primary)",
16 editorActiveTabIndicatorTopColor: "none",
17 editorTabBarBorderBottomColor: "var(--codeblock-bg)",
18 terminalTitlebarBorderBottomColor: "none",
19 },
20 textMarkers: {
21 delHue: 0,
22 insHue: 180,
23 markHue: 250,
24 },
25 },
26 frames: {
27 showCopyToClipboardButton: false,
28 },Source: astro.config.mjs
这段覆盖的设计意图非常清晰——把 Expressive Code 的颜色空间全量收敛到站点的 CSS 变量:
- 所有
*Background键位统一指向var(--codeblock-bg),无论编辑器框、终端框还是标签栏,代码块在页面上呈现为一个视觉整体,主题切换时由 CSS 变量驱动整体变色。 editorActiveTabIndicatorBottomColor: "var(--primary)"让激活标签页的下划线使用站点主色,使代码组(code group)标签页与站点 UI 的强调色保持一致。borderColor: "none"与editorActiveTabIndicatorTopColor: "none"通过清除边框/上指示线来减少视觉噪音,只保留0.75rem圆角与背景色。codeFontFamily优先使用var(--font-jetbrains-mono)并给出完整等宽字体回退链——既尊重站点可选的 JetBrains Mono 自托管字体,又保证在字体未加载/不支持时退化到系统等宽字体。textMarkers的三个色相值(delHue: 0、insHue: 180、markHue: 250)分别控制ins/del/mark文本标记的色相:删除红(0°)、插入绿(180°)、标记蓝紫(250°),与常见 diff 语义一致。frames.showCopyToClipboardButton: false关闭官方复制按钮——因为管道中已注册pluginCustomCopyButton(),用项目自定义按钮替换,避免出现两个复制按钮。
与 markdown 处理器的关系
expressiveCode() 是作为 Astro 集成注册的,而同文件中的 markdown.processor: unified({...}) 负责 remark/rehype 层的数学公式、Mermaid、PlantUML、Wiki Link 等扩展(见 astro.config.mjs 第 211 行起的 markdown 配置块)。两者各管一段:remark 管语法扩展,Expressive Code 管围栏代码块自身的渲染与样式。这一分工是"代码块增强"与"其它 Markdown 扩展"分属不同 Wiki 页的源码依据。
渲染流程:从围栏代码块到最终 HTML
Expressive Code 的处理发生在构建期(服务端/SSG 阶段),浏览器拿到的已经是完整样式化的静态 HTML。整个时序如下:
关键点在于最后两步在浏览器侧发生:由于双主题 CSS 在构建期都已生成,切换主题不需要任何 JS 重新高亮;hideDuringThemeTransition 只是在切换瞬间把代码块短暂隐藏,属于纯视觉补偿策略,不影响内容正确性。
Configuration Options
所有可调项集中在 src/config/expressiveCodeConfig.ts(类型定义见 src/types/config.ts 的 ExpressiveCodeConfig):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
lightTheme | string | "github-light" | 浅色主题名(注册于 themes[0],作为默认主题) |
darkTheme | string | "github-dark" | 深色主题名(注册于 themes[1],以暗色选择器叠加) |
defaultWrap | boolean | true | 代码块默认软换行(传给 Expressive Code 的 defaultProps.wrap) |
hideDuringThemeTransition | boolean? | true | 主题切换时隐藏代码块以避免大量代码块同时重绘导致的卡顿(客户端行为,不传入集成层) |
languageBadge.enable | boolean | true | 显示语言徽标插件(pluginLanguageBadge) |
languageLogo.enable | boolean | false | 显示语言 Logo 插件(pluginLanguageLogo) |
languageLogo.color | string | "mono" | Logo 颜色模式;集成层用 ?? "mono" 兜底 |
languageLogo.excludedLangs | string[] | ["text", "plaintext"] | 不显示 Logo 的语言列表;集成层用 ?? [] 兜底 |
collapsible.enable | boolean | true | 启用折叠插件(pluginCollapsibleSections) |
collapsible.lineThreshold | number | 20 | 超过该行数才提供折叠控件 |
collapsible.previewLines | number | 10 | 折叠态显示的预览行数 |
collapsible.defaultCollapsed | boolean | true | 默认折叠 |
codeGroup.enable | boolean | true | 代码组(标签页容器)功能 |
另有构建期硬编码、不走配置文件的项(直接写死在 astro.config.mjs 中,如需修改需编辑该文件):
| 硬编码项 | 值 | 位置 |
|---|---|---|
overridesByLang.shellsession.showLineNumbers | false | astro.config.mjs L169 |
overridesByLang.{bash,shell,sh,zsh}.frame | "code" | astro.config.mjs L170-L173 |
borderRadius | "0.75rem" | astro.config.mjs L178 |
codeFontSize / codeLineHeight | 0.875rem / 1.5rem | astro.config.mjs L180/L183 |
frames.showCopyToClipboardButton | false | astro.config.mjs L202 |
textMarkers.{delHue,insHue,markHue} | 0 / 180 / 250 | astro.config.mjs L195-L199 |
API / 集成参考
本能力对外暴露的是配置对象 + Astro 集成函数,而非运行时 API:
expressiveCodeConfig: ExpressiveCodeConfig
- 位置:
src/config/expressiveCodeConfig.ts的具名导出 - 类型:
ExpressiveCodeConfig(定义于src/types/config.ts) - 消费方:仅
astro.config.mjs一处导入(第 25 行附近),随后在第 148/152/155/158/160/167 行被解构使用 - 约束:
defaultWrap必填;languageLogo.color、languageLogo.excludedLangs在集成层均有??兜底,因此即使省略也不会抛错
expressiveCode(options): AstroIntegration
- 位置:
astro-expressive-code包的默认导出(astro.config.mjs第 10 行导入),第 147-204 行完成调用 - 关键入参(按本仓库实际用法):
themes: [string, string]— 双主题,首位为默认plugins: ExpressiveCodePlugin[]— 5 个插件按序注册(2 个官方 + 2 个条件 + 1 个自定义)defaultProps.wrap: boolean与defaultProps.overridesByLang: Record<string, Partial<Props>>styleOverrides— 颜色/字体/圆角/文本标记色相的全量覆盖映射frames.showCopyToClipboardButton: boolean
- 返回:标准 Astro 集成实例,挂到
astro.config.mjs的integrations数组中,位于icon()之后、svelte()之前
边界情况与设计取舍(Failure Modes & Edge Cases)
- 双主题主题名无效:
darkTheme/lightTheme直接以字符串传入 Expressive Code,主题名必须是其可解析的注册名(如github-dark/github-light或以github-dark-前缀派生的变体)。写错主题名会在构建期失败,属于“快速失败”——因为整个代码块渲染发生在 SSG 阶段,不会污染运行时。 - 插件开关与官方按钮冲突:
frames.showCopyToClipboardButton: false与pluginCustomCopyButton()是成对出现的——若只关官方按钮而不注册自定义按钮,会丢失复制功能;若只注册自定义按钮而不关官方按钮,会出现两个复制按钮。这是维护时最容易踩的联动点。 hideDuringThemeTransition与集成层脱钩:该字段只被src/types/config.ts和配置文件本身引用,expressiveCode()并未消费它(摘录的 L147-L204 范围内无引用)。它服务于客户端主题切换逻辑(属于主题系统的职责),因此改动它不会影响构建产物中的代码块 HTML,只影响切换时的视觉效果。这本身是一个刻意的解耦:渲染归构建期,过渡体验归浏览器端。- 语言级覆盖的匹配方式:
overridesByLang按语言的规范化 id 匹配;bash/shell/sh/zsh需要逐个列出而非通配,新增 shell 方言(如fish)时需要显式追加,否则会退回终端框外观。 - 版本一致性:四个
@expressive-code/*与astro-expressive-code全部锁^0.44.1(package.json L38-L47)。Expressive Code 各包之间的插件接口在 minor 版本间存在变化,跨版本混装是升级时的主要风险点;升级时应整组同步。 - 折叠区的阈值语义:
lineThreshold: 20与previewLines: 10组合意味着“21 行以上的代码默认折叠为 10 行预览”,对读者是显著的行为变化;调整任一值都会改变长代码的首屏观感与可发现性。
性能与运维要点(Performance & Operations)
- 构建期渲染 = 零运行时成本:所有高亮、行号、折叠标记、徽标在 SSG 阶段产出为静态 HTML/CSS,浏览器不加载任何与代码高亮相关的 JS。代价是构建时间随文章代码块数量线性增长,插件越多构建越慢——这也是为什么徽标/Logo 默认只开一个(
languageLogo.enable: false)。 - 主题切换的成本模型:双主题 CSS 均已在构建期内联,切换主题只是 CSS 变量/选择器切换,复杂度为 O(1) 而非 O(代码块数 × 行数)。
hideDuringThemeTransition: true处理的是浏览器重绘层面的卡顿(大量 DOM 同时换色),而非重新计算高亮。 - 字体加载:
codeFontFamily优先var(--font-jetbrains-mono),回退链完整覆盖 macOS/Windows/Linux 系统等宽字体。自托管字体未就绪时代码块仍可正常阅读,不阻塞渲染。 - 可维护性:所有视觉常量收敛到
astro.config.mjs的一个styleOverrides块内,颜色则全部外置到 CSS 变量。修改代码块外观时,颜色改主题变量、结构/字号改这一块即可,两处职责清晰。
扩展点(Extension Points)
- 新增功能开关:在
src/types/config.ts的ExpressiveCodeConfig中加字段(建议沿用{ enable: boolean, ...params }的子对象形态),在expressiveCodeConfig.ts补默认值,再在astro.config.mjs用...(enable ? [plugin] : [])展开注册。三步对应“类型契约 → 默认值 → 装配”,是本能力内置的扩展模式。 - 新增语言级行为:直接在
defaultProps.overridesByLang追加语言键。例如希望json不显示行号,只需加一行json: { showLineNumbers: false }。 - 替换/新增插件:插件按数组顺序注册,顺序影响渲染叠加次序。
pluginCustomCopyButton已演示了"关闭官方按钮 + 注册自定义插件”的替换范式。 - 调整视觉:颜色一律改
--codeblock-bg、--primary等 CSS 变量;字号/圆角/行高/文本标记色相改styleOverrides;更细粒度的补充样式可放入src/styles/expressive-code.css(该文件在仓库中存在,作为样式补充入口)。
使用示例(写作侧)
以下写法是作者在文章中使用该能力的方式(围栏语法仅作示意,非源文件摘录;实际行为由上述配置决定):
1```ts title="src/config/expressiveCodeConfig.ts" {4}
2// 代码块增强默认开启,无需额外标记
3export const expressiveCodeConfig = { /* ... */ };
4```构建后会得到:语言徽标(languageBadge.enable: true)、行号(pluginLineNumbers,ts 未被覆盖因此显示行号)、高亮行(textMarkers 以 insHue=180 标注)、自定义复制按钮(pluginCustomCopyButton),长于 20 行的块自动折叠为 10 行预览。
而对 shell 类语言:
```bash
npm run build
```由于 overridesByLang.bash.frame = "code",此块渲染为普通代码框(无终端标题栏);shellsession 则会同时隐藏行号。
Related Links
- 源码:expressiveCodeConfig.ts · astro.config.mjs · src/types/config.ts · expressive-code.css · package.json
- 同级主题:Markdown 管线整体(remark/rehype、KaTeX、Mermaid、PlantUML、Wiki Link、图片网格等)请参见 Markdown Pipeline 相关页面;
--codeblock-bg、--primary等 CSS 变量与hideDuringThemeTransition的消费方请参见主题系统相关页面 - 官方文档:Expressive Code
- 项目说明:README.md 中"增强代码块,基于 Expressive Code"(README.md)与配置文件指引(README.md)