搜索
File 2026.03.25前端开发

Astro 博客里 Expressive Code 的配置

Date / 日期
Updated / 更新
2026.10.12
Reading / 阅读
9 Min
Words / 字数
1742
Category / 分类
前端开发Astro

Astro 博客里 Expressive Code 的配置

· 更新于 2026年10月12日· 约 9 分钟· 1742 字

用在哪、怎么挂上去

博客的代码高亮交给 Expressive Code。它在 Shiki(VS Code 同款高亮引擎)之上加了编辑器/终端窗口框架、代码块标题、行号、行标记这些东西,正好覆盖写技术笔记时的需求,所以没有自己拼高亮方案。

它在 astro.config.mjs 里作为一个 Astro 集成挂上去:

astro.config.mjs
import astroExpressiveCode from 'astro-expressive-code'
export default defineConfig({
integrations: [
astroExpressiveCode({
// ...
}),
],
})

Tailwind 走的是另一条路,它现在是 Vite 插件(@tailwindcss/vite),和 Expressive Code 的集成互不干扰:

astro.config.mjs
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
})

样式内联,而不是单独发一个 CSS 文件

有一个看起来不重要的开关:

JavaScript
astroExpressiveCode({
emitExternalStylesheet: false,
})

默认情况下 Expressive Code 会把代码块样式生成成独立的 CSS 文件。关掉它会把样式内联进首个代码块,省一个请求。

但真正的原因不是性能,是页面过渡。站点用了 <ClientRouter /> 做站内切换,如果样式在外部文件里,切换瞬间会先渲染出一块没有样式的代码,再”闪”一下变成正常的。内联之后这个问题就没了。

四套主题,两两互斥

JavaScript
themes: [
'one-light',
'one-dark-pro',
'github-light-high-contrast',
'github-dark-high-contrast',
],

普通和高对比各两套。高对比那两套是给视力不佳的读者准备的,它会把注释和标点的对比也一起拉满,所以配色风格和普通主题明显不同。这一点是刻意的,不是没调好。

站点用 <html data-theme="light|dark"> 手动切明暗,所以关掉媒体查询自动切换:

JavaScript
useDarkModeMediaQuery: false,

四套主题需要互斥,否则会同时命中。用 themeCssSelector 给每套主题算出一个选择器:

JavaScript
themeCssSelector: (theme) => {
const mode = theme.type === 'dark' ? 'dark' : 'light'
const highContrast = String(theme.name ?? '').includes('high-contrast')
return highContrast
? `[data-theme='${mode}'][data-contrast='high']`
: `[data-theme='${mode}']:not([data-contrast='high'])`
},

普通主题排除掉 data-contrast='high' 的情况,高对比主题只在它存在时生效。站点的”高对比”开关就是往 <html> 上写这个属性,代码块跟着一起切。

几处样式覆盖

JavaScript
styleOverrides: {
borderRadius: '2px',
frames: {
editorActiveTabForeground: 'var(--c-muted)',
terminalTitlebarForeground: 'var(--c-muted)',
},
codeFontFamily:
"'JetBrainsMono Nerd Font', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace",
textMarkers: {
markHue: '32',
borderOpacity: '50%',
},
},

圆角收到 2px:默认皮肤(莱茵生命)整体没有圆角,代码块留着默认的圆角会显得格格不入。

标题文字色指向 var(--c-muted):主题自带的标题色接近纯黑或纯白,和站点其他地方对不上。指向 CSS 变量的好处是高对比模式下自动跟着走,不用再写特例,那个变量在高对比下本身就是纯黑/纯白。

markHue 从 310 改成 32:行标记的底色原本是品红(310),跟站点的杏金底色打架。挪到强调色的色相上就顺了。borderOpacity 是给标记加一圈浅边,让它在深色背景下也看得清边界。

字体:JetBrainsMono Nerd Font 在前,是为了让代码块里的 Nerd Font 图标能正常显示;后面两个是给没装 Nerd Font 的机器兜底。

行号:默认开,shell 类关掉

JavaScript
defaultProps: {
showLineNumbers: true,
overridesByLang: {
'bash,sh,zsh,shell,console,terminal,fish': { showLineNumbers: false },
},
},

默认开行号是因为笔记里的代码基本都需要指着某一行说话。但终端命令不一样:那几行是”照着敲一遍”用的,行号既没用又占宽度,所以按语言关掉。

另外还有一个语言别名,用来兼容文章里写法不规范的围栏:

JavaScript
shiki: {
langAlias: { Julia: 'julia' },
},

自己写的两个插件

装上 Expressive Code 之后还有两件事它默认不管,于是各写了一个插件。

没写标题的时候,显示语言名

代码块可以用 title="hello.js" 指定标题,但不写的时候头部是空的,非终端框架甚至连整条头部都不显示,一篇文章里有的块有头、有的没头,看起来很乱。

这个插件的作用是:没有标题的代码块,把语言名补到头部去(Julia 显示成 Julia,py 显示成 Python)。

一开始我想直接用 defaultProps 塞一个默认标题,但那样不行。Expressive Code 的 frames 插件本身有个能力:没写 title 时,会从代码前 4 行的注释里抽文件名当标题(比如代码开头写 // hello.js)。提前塞默认值会把这个功能顶掉。

所以插件挂在 postprocessRenderedBlock 钩子上,它在标题(包括自动抽出来的那个)已经定下来之后才跑。这时候还没有标题的,才是真正该显示语言名的那些。

代价是插件依赖了 Expressive Code 的几个类名(frame、has-title、header、title),这些属于它对外公开的样式配置项,相对稳定,将来大版本升级时留个心就行。

把「自动换行」做成运行时开关

Expressive Code 的换行(代码块加 wrap)本来是构建期决定的:加了就在输出里写死,没加就没有。而这个博客想要的是一个能随时切的开关。

麻烦在于悬挂缩进,换行之后的续行要对齐到原来那一行的缩进位置,靠的是 --ecIndent 这个内联变量。Expressive Code 里负责这件事的两条规则是常驻的:

CSS
.expressive-code .ec-line .code {
padding-inline-start: calc(var(--ecIndent, 0ch) + var(--ec-codePadInl) - var(--ecGtrBrdWd));
text-indent: calc(var(--ecIndent, 0ch) * -1);
}

--ecIndent 缺省是 0ch,两条规则正好抵消,所以平时看不出区别。既然如此,只要提前把 --ecIndent 写好,打开换行时就只剩 white-space 和 overflow-wrap 要切,不需要运行时脚本,也不会有开关一按就重新布局的闪烁。

缩进长度没有自己去解析源码,而是读 Expressive Code 自己渲染出来的 <span class="indent">(行首空白原样放在里面)。这样跟它的渲染结果永远一致,不会因为它改了渲染方式而对不上。

写法速查

代码块的通栏语法如下,本站目前只用到 title,其余留着备用。

写法效果
```js指定语言,同时决定头部显示的语言名
```js title="hello.js"自定义标题(会覆盖语言名)
```js {2,4-6}高亮指定行
```js ins={2} del={4-5}插入/删除标记
```js "return" /data/用字符串或正则标记行内文本
```js frame="terminal"换成终端窗口样式(code / none 同理)
```js showLineNumbers=false单个块关掉行号
```js wrap自动换行(配合上面那个运行时开关)

几个例子:

markdown
```js title="hello.js" {2} ins={3}
function hello(name) {
console.log(`Hello, ${name}!`)
}
```

.astro 和 .mdx 里还可以用 <Code> 组件渲染字符串:

组件里
---
import { Code } from 'astro-expressive-code'
const codeString = `console.log('Hello!')`
---
<Code code={codeString} lang="javascript" />

一个顺带的坑

上面提到的”从首行注释抽标题”这个功能,和语言标识插件是相冲的。如果哪天发现某个代码块的头部长得不对,先看它的前几行有没有类似 // 文件名 的注释,那会被 frames 插件当成标题,语言标识就自动让位了。

Comment评论

添加新评论