Repository Wiki
LyraVoid/Mizuki

代码块增强与 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 配置,原因可以从源码中直接读出:

  1. 零客户端脚本的双主题:themes: [lightTheme, darkTheme] 同时注册两套主题(github-light / github-dark),高亮结果在构建期输出为带主题属性的 CSS,浏览器切换主题时只需切换 CSS 变量/选择器,无需重新解析代码。
  2. 声明式可裁剪的插件管道:语言徽标、语言 Logo、折叠区、行号、复制按钮全部是插件;其中徽标/Logo 通过配置开关条件注册,用户改一个布尔值即可启用或移除。
  3. 视觉与站点主题解耦: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

Loading diagram...

上图描述了数据从声明到产出的单向流动:

  • 配置层: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 顶部(未包含在本页摘录范围内)。

插件装配的运行时决策

条件插件不是简单的静态数组,而是在配置载入时展开:

Loading diagram...

这个"spread + 三元 + 空数组回退"的模式(...(cond ? [plugin] : []))让"关闭一个功能 = 改一行布尔值",而不是从数组里手工删插件——插件与开关永远成对出现,降低了配置漂移的风险。

配置文件解析

配置的唯一入口是 expressiveCodeConfig.ts,它整体实现了 ExpressiveCodeConfig 接口:

typescript
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 检索到的关键片段:

typescript
defaultWrap: boolean; hideDuringThemeTransition?: boolean; // 是否在主题切换时隐藏代码块 languageBadge: {

Source: config.ts

从这一片段可以确认两件事:

  1. defaultWrap 是必填(boolean,无 ?),而 hideDuringThemeTransition 是可选(boolean?)——因为它是后来为了解决主题切换卡顿而补充的体验项,标记为可选保证旧配置无需迁移。
  2. 每个功能块(languageBadge、languageLogo、collapsible、codeGroup)都是一个含 enable 字段的子对象,enable 是统一的功能开关语义,所有功能开关都在同一个配置平面上,运维心智模型一致。

Astro 集成详解

astro.config.mjs 是所有配置汇聚的地方。以下摘录展示了 expressiveCode() 的完整调用结构:

javascript
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)

javascript
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。整个时序如下:

Loading diagram...

关键点在于最后两步在浏览器侧发生:由于双主题 CSS 在构建期都已生成,切换主题不需要任何 JS 重新高亮;hideDuringThemeTransition 只是在切换瞬间把代码块短暂隐藏,属于纯视觉补偿策略,不影响内容正确性。

Configuration Options

所有可调项集中在 src/config/expressiveCodeConfig.ts(类型定义见 src/types/config.ts 的 ExpressiveCodeConfig):

选项类型默认值说明
lightThemestring"github-light"浅色主题名(注册于 themes[0],作为默认主题)
darkThemestring"github-dark"深色主题名(注册于 themes[1],以暗色选择器叠加)
defaultWrapbooleantrue代码块默认软换行(传给 Expressive Code 的 defaultProps.wrap)
hideDuringThemeTransitionboolean?true主题切换时隐藏代码块以避免大量代码块同时重绘导致的卡顿(客户端行为,不传入集成层)
languageBadge.enablebooleantrue显示语言徽标插件(pluginLanguageBadge)
languageLogo.enablebooleanfalse显示语言 Logo 插件(pluginLanguageLogo)
languageLogo.colorstring"mono"Logo 颜色模式;集成层用 ?? "mono" 兜底
languageLogo.excludedLangsstring[]["text", "plaintext"]不显示 Logo 的语言列表;集成层用 ?? [] 兜底
collapsible.enablebooleantrue启用折叠插件(pluginCollapsibleSections)
collapsible.lineThresholdnumber20超过该行数才提供折叠控件
collapsible.previewLinesnumber10折叠态显示的预览行数
collapsible.defaultCollapsedbooleantrue默认折叠
codeGroup.enablebooleantrue代码组(标签页容器)功能

另有构建期硬编码、不走配置文件的项(直接写死在 astro.config.mjs 中,如需修改需编辑该文件):

硬编码项值位置
overridesByLang.shellsession.showLineNumbersfalseastro.config.mjs L169
overridesByLang.{bash,shell,sh,zsh}.frame"code"astro.config.mjs L170-L173
borderRadius"0.75rem"astro.config.mjs L178
codeFontSize / codeLineHeight0.875rem / 1.5remastro.config.mjs L180/L183
frames.showCopyToClipboardButtonfalseastro.config.mjs L202
textMarkers.{delHue,insHue,markHue}0 / 180 / 250astro.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)

  1. 新增功能开关:在 src/types/config.ts 的 ExpressiveCodeConfig 中加字段(建议沿用 { enable: boolean, ...params } 的子对象形态),在 expressiveCodeConfig.ts 补默认值,再在 astro.config.mjs 用 ...(enable ? [plugin] : []) 展开注册。三步对应“类型契约 → 默认值 → 装配”,是本能力内置的扩展模式。
  2. 新增语言级行为:直接在 defaultProps.overridesByLang 追加语言键。例如希望 json 不显示行号,只需加一行 json: { showLineNumbers: false }。
  3. 替换/新增插件:插件按数组顺序注册,顺序影响渲染叠加次序。pluginCustomCopyButton 已演示了"关闭官方按钮 + 注册自定义插件”的替换范式。
  4. 调整视觉:颜色一律改 --codeblock-bg、--primary 等 CSS 变量;字号/圆角/行高/文本标记色相改 styleOverrides;更细粒度的补充样式可放入 src/styles/expressive-code.css(该文件在仓库中存在,作为样式补充入口)。

使用示例(写作侧)

以下写法是作者在文章中使用该能力的方式(围栏语法仅作示意,非源文件摘录;实际行为由上述配置决定):

markdown
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 类语言:

markdown
```bash npm run build ```

由于 overridesByLang.bash.frame = "code",此块渲染为普通代码框(无终端标题栏);shellsession 则会同时隐藏行号。

Sources

(2 files)