Slidev 预解析器(Pre-Parser)扩展配置指南:用 setup/preparser.ts 实现自定义语法
在 Slidev 中,如果你想在 slides.md 里发明新的语法(例如一行 @cover: 直接生成封面页、用自定义 frontmatter 批量缩放幻灯片),就必须在「Markdown 解析之前」的预处理阶段介入。本文围绕官方文档《Configure Pre-Parser》展开,完整覆盖预解析器扩展的文件位置、API 结构与三个官方用例,并结合 @slidev/parser、@slidev/types 的源码讲清扩展的加载与执行时机,帮助你在理解三步解析管线的前提下安全地扩展 Slidev 语法层。
Slidev 的三步解析管线
官方文档将 Slidev 对演示文件(如 slides.md)的解析描述为三个步骤:
- 预解析(preparsing):文件先被拆分成分页,分页依据是
---分隔符,并考虑可能存在的 frontmatter 块; - 逐页解析:每一页的 Markdown 内容由外部库(markdown-it 生态)解析为 Vue 模板;
src解析:Slidev 解析特殊的 frontmatter 属性src: ...,从而把其他 md 文件的内容引入当前演示。
从源码结构看,这三步对应着不同的模块:
- 第 1 步的核心是 parse 函数(
@slidev/parser包)。它逐行扫描文件,遇到---就切出一个 slide 片段;代码块(```)和 HTML 注释(<!-- -->)内部的分隔符会被正确跳过,这正是「considering the possible frontmatter blocks」以及注释不产生假分页的实现所在(advanceHtmlCommentState,core.ts)。 - 第 2 步发生在 Vite 层,由内置的 markdown 插件完成,配置方式见下文 Markdown Parser 配置。
- 第 3 步在 load 函数(
packages/parser/src/fs.ts)中实现:当某页 frontmatter 带有src时,按相对/绝对路径解析被引入文件,做循环引用检测与项目根目录逃逸检查后再递归加载。
理解这条管线是选择扩展点的前提:如果你要改的是「Markdown 语法 → 模板」的转换(第 2 步),应该用 Transformers 或 Vite 内部插件;如果你要改的是「文件 → 分页结构」本身(第 1 步和第 3 步),才需要本文的主角——预解析器扩展。
Markdown Parser 的配置
第 2 步所用的 Markdown 解析器,不是通过 preparser 配置的。官方文档给出的方式是:通过配置 Vite 内部插件来完成,具体见 Configure Vite and Plugins 中的 “Configure Internal Plugins” 一节。在那里你可以通过 vite.config.ts 的 slidev.vue / slidev.markdown 等字段覆盖内置的 vite-plugin-vue-markdown(markdown-it)配置,例如在 markdownSetup(md) 中挂载自定义 markdown-it 插件。
文档同时给出了一个使用上的重要提示(info 框原文语义):
自定义 pre-parser 不应该被频繁使用。对于自定义语法,通常优先使用 Transformers。
也就是说,preparser 的定位是高级功能(advanced feature):它运行在解析管线最前端、影响面最大,能用 Transformers 解决的语法问题不要上 preparser。
Preparser 扩展:API 与类型定义
预解析器扩展自 v0.37.0 起可用。官方文档中有两条必须牢记的注意事项:
- ::: warning 修改 preparser 配置后,需要完全停止并重新启动 Slidev(仅仅热重启可能不够)。
- 扩展 preparser 属于高级特性,由于它会隐式改变 md 文件的语法,有可能破坏编辑器集成(如 Side Editor)——编辑器看到的仍是原始语法(如
@cover:),而运行时分页结构已经被扩展改写。
启用方式为在项目根目录(或主题根目录)创建 ./setup/preparser.ts 文件:
import { definePreparserSetup } from '@slidev/types'
export default definePreparserSetup(({ filepath, headmatter, mode }) => {
return [
{
transformRawLines(lines) {
for (const i in lines) {
if (lines[i] === '@@@')
lines[i] = 'HELLO'
}
},
}
]
})
这个例子会把所有 @@@ 行替换为 HELLO 行。围绕它,文档定义了以下核心概念,类型定义均可在源码中一一对应:
definePreparserSetup必须以一个函数为参数调用。它定义于 packages/types/src/setups.ts,本质是defineSetup<PreparserSetup>的别名,即只做类型标注、原样返回函数。- 该函数收到的上下文包含三个字段,对应 PreparserSetup 类型:
filepath:根演示文件(entry)的路径;headmatter:md 文件头部(headmatter)解析出的对象;mode:自 v0.48.0 起提供,取值如dev、build、export。可据此启用不同的扩展,例如“仅在导出 PDF 时生效”。
- 函数必须返回一个 preparser 扩展的数组。
- 单个扩展(
SlidevPreparserExtension,定义见 packages/types/src/types.ts)可以包含以下成员:transformRawLines(lines):在 headmatter 解析完成后立即执行,收到 md 文件的全部行(字符串数组),函数可任意变更这个数组(增删改行均可,官方示例正是靠splice改写行数);transformSlide(content, frontmatter):对每一页调用,时机在文件切分之后;收到页内容字符串与页 frontmatter 对象。函数可以变更 frontmatter,并必须返回内容字符串——允许返回修改后的字符串,也允许返回undefined表示未做修改;transformNote(note, frontmatter):同样对每一页调用,收到演讲者备注(字符串或undefined)与 frontmatter 对象;可以变更 frontmatter,返回修改后的备注字符串,返回undefined表示未修改;name:可选的扩展名称(便于调试与区分顺序)。
从源码看执行顺序是明确的:在 parse 函数中,所有扩展的 transformRawLines 先行执行(对全文件逐行生效);随后才进行 --- 切分,并在 slice 阶段对每个 slide 依次调用各扩展的 transformSlide 与 transformNote。另外源码还有一处细节:transformSlide 修改 frontmatter 后,若 title / level 被设为 string / number,会被同步到 slide 的标题与层级字段(core.ts),这意味着你的扩展可以通过注入 frontmatter 间接影响页面标题。
用例 1:紧凑语法的顶层演示(@cover: / @src:)
设想你的演示(的一部分)主要是封面图与引入其他 md 文件,希望用紧凑记法书写 slides.md:
@cover: /nice.jpg
# Welcome
@src: page1.md
@src: page2.md
@cover: /break.jpg
@src: pages3-4.md
@cover: https://cover.sli.dev
# Questions?
see you next time
要支持这种 @src: 与 @cover: 语法,创建 ./setup/preparser.ts:
import { definePreparserSetup } from '@slidev/types'
export default definePreparserSetup(() => {
return [
{
transformRawLines(lines) {
let i = 0
while (i < lines.length) {
const l = lines[i]
if (/^@cover:/i.test(l)) {
lines.splice(
i,
1,
'---',
'layout: cover',
`background: ${l.replace(/^@cover: */i, '')}`,
'---',
''
)
continue
}
if (/^@src:/i.test(l)) {
lines.splice(
i,
1,
'---',
`src: ${l.replace(/^@src: */i, '')}`,
'---',
''
)
continue
}
i++
}
}
},
]
})
原理拆解:
@cover: xxx被splice原地展开为一整段标准 frontmatter(layout: cover+background: xxx)加空行——相当于把一行“宏”翻译成 Slidev 原生分页语法,之后无需任何额外代码,第 1 步的切分逻辑就能正常把它识别为独立的 cover 页;@src: xxx.md则展开为src: xxx.md的 frontmatter 页,复用第 3 步既有的src引入机制(含循环引用与根目录逃逸检查),所以引入文件的行为与原生写法完全一致。
这正是 transformRawLines 的典型用法:在分页发生前把自定义标记重写为原生结构。仓库的测试 parser.test.ts 中 parse with-extension eg-easy-cover 用例验证了同类逻辑:把 @cov 1.jpg 展开成 cover frontmatter 后,断言各页 frontmatter 为 { layout: 'cover', background: '1.jpg' } 等,分页结果正确。
用例 2:用自定义 frontmatter 包裹幻灯片
当你经常想对某些幻灯片做缩放,但又不想为此新建布局(想继续复用现有布局库)时,可以用自定义 frontmatter。注意命名上用下划线前缀 _scale 来避免与既有 frontmatter 属性冲突(不带下划线的 scale 可能产生冲突)。slides.md 写法:
---
layout: quote
_scale: 0.75
---
# Welcome
> great!
---
_scale: 4
---
# Break
---
# Ok
---
layout: center
_scale: 2.5
---
# Questions?
see you next time
处理 _scale: ... 的 ./setup/preparser.ts:
import { definePreparserSetup } from '@slidev/types'
export default definePreparserSetup(() => {
return [
{
async transformSlide(content, frontmatter) {
if ('_scale' in frontmatter) {
return [
`<Transform :scale=${frontmatter._scale}>`,
'',
content,
'',
'</Transform>'
].join('\n')
}
},
}
]
})
要点:
- 这里用的是
transformSlide(按页处理),而非transformRawLines。函数收到已经过切分与 frontmatter 解析后的页内容,判断 frontmatter 中是否存在_scale后,返回用 Transform 内置组件包裹后的新内容字符串;没有命中时隐式返回undefined,该页保持原样——与类型定义中「possiblyundefinedif no modifications have been done」完全一致。 - 包裹后的模板会在第 2 步被 markdown 插件渲染为真实的 Vue 组件调用,因此缩放是运行时生效的,且对任意
layout:通用。 - 由于
_scale属性本身仍保留在 frontmatter 中,若不希望它泄漏到运行时页面数据,可以在扩展里delete frontmatter._scale——frontmatter 对象是可变(mutable)的。
用例 3:用自定义 frontmatter 替换演讲者备注
设想你想把某一页的默认备注替换为外部文件中的备注。slides.md 写法(同样使用下划线避免属性冲突):
---
layout: quote
_note: notes/note.md
---
# Welcome
> great!
<!--
Default slide notes
-->
处理 _note: ... 的 ./setup/preparser.ts:
import fs, { promises as fsp } from 'node:fs'
import { definePreparserSetup } from '@slidev/types'
export default definePreparserSetup(() => {
return [
{
async transformNote(note, frontmatter) {
if ('_note' in frontmatter && fs.existsSync(frontmatter._note)) {
try {
const newNote = await fsp.readFile(frontmatter._note, 'utf8')
return newNote
}
catch (err) {
}
}
return note
},
}
]
})
说明:
transformNote在每页切分后、拿到页备注(来自页尾 HTML 注释,见 parseSlide)后被调用。命中_note且文件存在时,返回从文件读入的新备注,覆盖原备注;任何未命中或读取失败的路径都应return note保持原值。- 该函数支持
async,因此可以在预处理阶段做文件 IO;这也是三个回调中唯一在官方示例里显式使用async的一个。
扩展是如何被加载和执行的(源码视角)
把官方文档的抽象描述落到当前仓库的实现上,整条调用链如下:
- 入口注入:packages/slidev/node/setups/preparser.ts 中的
setupPreparser()调用injectPreparserExtensionLoader,把「加载扩展」的职责挂到@slidev/parser上; - 加载 setup 文件:loader 内部调用 loadSetups(
roots, 'preparser.ts', [{ filepath, headmatter, mode }])。它遍历每个 root(项目与主题的根目录),检查setup/preparser.ts是否存在,存在则用loadModule加载并以其默认导出调用,参数即文档中说的{ filepath, headmatter, mode }上下文;所有 root 的返回值经returns.flat()合并成扩展数组。这也解释了多扩展的执行顺序:数组按加载顺序展开,测试 parser.test.ts 的 sequence 用例 验证了多个transformRawLines扩展会按声明顺序依次作用于同一行; - headmatter 先行识别:在 load 函数 中,
@slidev/parser会先用与解析代码一致的严格规则识别文件头部的 frontmatter 块并 YAML 解析,再把结果传给 loader——这就是 setup 函数里headmatter参数的来源,也意味着你可以按 headmatter 的内容决定启用哪些扩展(例如按演示文件开启特定扩展); - 扩展到所有 md 文件:
loadMarkdown在 加载每个 md 文件时 都调用parse(raw, path, extensions),即扩展不仅作用于 entry 文件,也作用于src:引入的子文件; - 扩展生效点:
transformRawLines在切分前执行,transformSlide/transformNote在每页切分后执行(详见前文 core.ts 的分析)。
@slidev/parser 的公共 API 快照(fs.snapshot.d.ts)确认了 injectPreparserExtensionLoader、parse 等入口,测试目录 test/parser.test.ts 则系统性地覆盖了 transformRawLines(替换、自定义分隔符 SEPARATOR → ---、紧凑封面)与 transformSlide 包裹(含前插/后插内容与注入 frontmatter 的组合断言)等行为,可作为扩展行为边界的参考。
使用限制与最佳实践
汇总文档与源码给出的约束,实际使用 preparser 扩展时建议遵守:
- 改完必须完全重启:修改
setup/preparser.ts后需停止再启动 Slidev,热更新可能不生效; - 优先 Transformers:如文档 info 框所述,常规自定义语法请先考虑 Transformers;只有需要改分页结构、
src引入或按 headmatter/mode 条件启用的场景才用 preparser; - 警惕编辑器一致性:preparser 改动是「隐式语法变更」,Side Editor 等编辑器看到的原文与运行时结构会不一致,属于文档明确提示的破坏风险点(side-editor 文档);
- 善用
mode参数(v0.48.0+):可在导出 PDF 与开发预览之间启用不同扩展,避免调试语法污染正式导出; - 命名加下划线前缀:自定义 frontmatter 属性(如
_scale、_note)避免与内置属性冲突; - 保持扩展幂等且最小:
transformRawLines拿到的是全文件行数组,任意splice/push都合法但需谨慎;未命中规则时不要产生副作用。
按上述方式配置后,@cover: / @src: 这类紧凑语法、_scale 缩放包裹、_note 外部备注均可直接在你的 slides.md 中使用——这正是官方文档以 “And that's it.” 收尾的原因:一次性的 setup 文件写好后,后续写作即可全程使用自定义语法。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00