首页
/ Slidev 预解析器(Pre-Parser)扩展配置指南:用 setup/preparser.ts 实现自定义语法

Slidev 预解析器(Pre-Parser)扩展配置指南:用 setup/preparser.ts 实现自定义语法

2026-09-05 10:13:22作者:沈韬淼Beryl

在 Slidev 中,如果你想在 slides.md 里发明新的语法(例如一行 @cover: 直接生成封面页、用自定义 frontmatter 批量缩放幻灯片),就必须在「Markdown 解析之前」的预处理阶段介入。本文围绕官方文档《Configure Pre-Parser》展开,完整覆盖预解析器扩展的文件位置、API 结构与三个官方用例,并结合 @slidev/parser@slidev/types 的源码讲清扩展的加载与执行时机,帮助你在理解三步解析管线的前提下安全地扩展 Slidev 语法层。

Slidev 的三步解析管线

官方文档将 Slidev 对演示文件(如 slides.md)的解析描述为三个步骤:

  1. 预解析(preparsing):文件先被拆分成分页,分页依据是 --- 分隔符,并考虑可能存在的 frontmatter 块;
  2. 逐页解析:每一页的 Markdown 内容由外部库(markdown-it 生态)解析为 Vue 模板;
  3. src 解析:Slidev 解析特殊的 frontmatter 属性 src: ...,从而把其他 md 文件的内容引入当前演示。

从源码结构看,这三步对应着不同的模块:

  • 第 1 步的核心是 parse 函数@slidev/parser 包)。它逐行扫描文件,遇到 --- 就切出一个 slide 片段;代码块(```)和 HTML 注释(<!-- -->)内部的分隔符会被正确跳过,这正是「considering the possible frontmatter blocks」以及注释不产生假分页的实现所在(advanceHtmlCommentStatecore.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.tsslidev.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 起可用。官方文档中有两条必须牢记的注意事项:

  1. ::: warning 修改 preparser 配置后,需要完全停止并重新启动 Slidev(仅仅热重启可能不够)。
  2. 扩展 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 起提供,取值如 devbuildexport。可据此启用不同的扩展,例如“仅在导出 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 依次调用各扩展的 transformSlidetransformNote。另外源码还有一处细节: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: xxxsplice 原地展开为一整段标准 frontmatter(layout: cover + background: xxx)加空行——相当于把一行“宏”翻译成 Slidev 原生分页语法,之后无需任何额外代码,第 1 步的切分逻辑就能正常把它识别为独立的 cover 页;
  • @src: xxx.md 则展开为 src: xxx.md 的 frontmatter 页,复用第 3 步既有的 src 引入机制(含循环引用与根目录逃逸检查),所以引入文件的行为与原生写法完全一致。

这正是 transformRawLines 的典型用法:在分页发生前把自定义标记重写为原生结构。仓库的测试 parser.test.tsparse 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,该页保持原样——与类型定义中「possibly undefined if 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 的一个。

扩展是如何被加载和执行的(源码视角)

把官方文档的抽象描述落到当前仓库的实现上,整条调用链如下:

  1. 入口注入packages/slidev/node/setups/preparser.ts 中的 setupPreparser() 调用 injectPreparserExtensionLoader,把「加载扩展」的职责挂到 @slidev/parser 上;
  2. 加载 setup 文件:loader 内部调用 loadSetupsroots, 'preparser.ts', [{ filepath, headmatter, mode }])。它遍历每个 root(项目与主题的根目录),检查 setup/preparser.ts 是否存在,存在则用 loadModule 加载并以其默认导出调用,参数即文档中说的 { filepath, headmatter, mode } 上下文;所有 root 的返回值经 returns.flat() 合并成扩展数组。这也解释了多扩展的执行顺序:数组按加载顺序展开,测试 parser.test.ts 的 sequence 用例 验证了多个 transformRawLines 扩展会按声明顺序依次作用于同一行;
  3. headmatter 先行识别:在 load 函数 中,@slidev/parser 会先用与解析代码一致的严格规则识别文件头部的 frontmatter 块并 YAML 解析,再把结果传给 loader——这就是 setup 函数里 headmatter 参数的来源,也意味着你可以按 headmatter 的内容决定启用哪些扩展(例如按演示文件开启特定扩展);
  4. 扩展到所有 md 文件loadMarkdown加载每个 md 文件时 都调用 parse(raw, path, extensions),即扩展不仅作用于 entry 文件,也作用于 src: 引入的子文件;
  5. 扩展生效点transformRawLines 在切分前执行,transformSlide / transformNote 在每页切分后执行(详见前文 core.ts 的分析)。

@slidev/parser 的公共 API 快照(fs.snapshot.d.ts)确认了 injectPreparserExtensionLoaderparse 等入口,测试目录 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 文件写好后,后续写作即可全程使用自定义语法。

登录后查看全文
热门项目推荐
相关项目推荐