VitePress sidebar 文案里一对尖括号引发的页面崩溃

给 @sheng/eslint-plugin 搭文档站时遇到一个问题:GitHub Pages 上首页能打开,但点击导航完全没反应,控制台先报:

Uncaught SyntaxError: Unexpected token '<'

后面又跟着:

Cannot read properties of undefined (reading 'locales')
Cannot read properties of undefined (reading 'cleanUrls')

原因是生成后的首屏 SSR HTML 有问题,sidebar 分组标题被原样写成了这一行:

<h3 class="text">Vue <script setup></h3>

浏览器不会把这里的 <script setup> 理解成普通文本,它会把它当成一个真正的 script 标签。后面的 SSR HTML 和 VitePress 注入的初始化脚本都被这个标签打断,window.__VP_SITE_DATA__ 没有按预期执行,运行时再访问 locales、cleanUrls 就会继续报错。

VitePress 默认主题会把 sidebar 的 text 字段当成 HTML 字符串渲染,而不是普通文本插值。一对尖括号因此就可能改变首屏 HTML 的结构。

现场代码

当时项目里为了给规则分组命名,写了一个很自然的标题:

// sheng-eslint-plugin/src/rules/groups.js:53-57
export const ruleGroupDetails = {
  'vue-script-setup': {
    label: 'Vue <script setup>',
    summary: 'Vue <script setup> 宏和模板名称解析边界。',
  },
}

这个字符串后面会进入 VitePress 的 themeConfig.sidebar,成为 sidebar 分组标题。问题就在这里:text 字段看起来像纯文本,默认主题实际按 HTML 插入。Vue <script setup> 进入 SSR HTML 后,没有任何转义,浏览器解析阶段就已经坏了,后面的 Vue hydration 还没机会接管。

当前最稳的修复方式,是把会进入 VitePress sidebar 的分组标题先写成 HTML entity:

// sheng-eslint-plugin/src/rules/groups.js:53-57
export const ruleGroupDetails = {
  'vue-script-setup': {
    label: 'Vue &lt;script setup&gt;',
    summary: 'Vue &lt;script setup&gt; 宏和模板名称解析边界。',
  },
}

sidebar 生成时,直接把分组标题传给默认主题:

// sheng-eslint-plugin/docs/.vitepress/config.ts
function createRulesSidebar(): DefaultTheme.SidebarItem[] {
  return Object.entries(ruleGroups).map(([groupName, ruleNames]) => ({
    text: ruleGroupDetails[groupName]?.label ?? groupName,
    link: `/rules/groups/${groupName}`,
    collapsed: groupName !== 'vue-script-setup',
    items: createRuleItems(ruleNames),
  }))
}

这样生成的 SSR HTML 仍然是安全的:

<h3 class="text">Vue &lt;script setup&gt;</h3>

页面最终显示仍然是 Vue <script setup>,但浏览器解析 HTML 时不会把它当成标签。线上页面重新部署后,首页 HTML 里能搜到 Vue &lt;script setup&gt;,浏览器里读取 sidebar 文本则是 Vue <script setup>,点击分组页也恢复正常。

这个方案不够优雅,因为 ruleGroupDetails 里已经混入了展示端需要的 HTML 写法;但它有一个直接好处:当前 npm 版 vitepress@2.0.0-alpha.19 就能稳定构建和部署。文档站先恢复可用,比为了保持源数据漂亮而引入临时 fork 更重要。

这个行为从哪里来

排查时查阅的 VitePress 1.6.4 sidebar 文档只把 text 描述成分组标题或导航文本,没有在 sidebar 页面提醒它会按 HTML 渲染。文档示例也都是普通字符串:

export default {
  themeConfig: {
    sidebar: [
      {
        text: 'Guide',
        items: [
          { text: 'Introduction', link: '/introduction' },
        ],
      },
    ],
  },
}

真正的语义要回到 2022 年的上游历史。Issue 1486 的诉求是希望 sidebar item 的文本可以格式化成 inline code,当时提出的方案包括让 SidebarItem.text 支持 Markdown 或 HTML 字符串。随后 PR 1489 在同一天合并,说明里明确写到迁移到 v-html,用于支持 SidebarGroup heading 和 SidebarItem text 的原始 HTML 字符串。

这个 PR 的 diff 很小,但语义变化很大:

<!-- vuejs/vitepress@946c579 -->
- <h2 class="title-text">{{ text }}</h2>
+ <h2 v-html="text" class="title-text"></h2>

- <span class="link-text">{{ item.text }}</span>
+ <span v-html="item.text" class="link-text"></span>

{{ text }} 是 Vue 文本插值,会把 < 渲染成文本;v-html 会把字符串交给元素的 innerHTML。这就是 feature 的来源:作者可以写 <code>foo</code> 让 sidebar 里出现代码样式,也必须自己承担 HTML 语义。

当前 vitepress@1.6.4 的源码仍然沿用这个方向。VPSidebarItem.vue 里,链接项和非链接项都直接使用 v-html="item.text":

<!-- vuejs/vitepress@v1.6.4 src/client/theme-default/components/VPSidebarItem.vue:67-77 -->
<VPLink v-if="item.link" ...>
  <component :is="textTag" class="text" v-html="item.text" />
</VPLink>
<component v-else :is="textTag" class="text" v-html="item.text" />

移动端导航里也有同类写法,VPNavScreenMenuLink.vue 会把 item.text 放进 v-html。另外,上一页 / 下一页标题、首页 hero 文案等位置也能看到类似设计,只是这次触发点在 sidebar。

Vue 的 v-html 文档说明,它更新元素的 innerHTML,内容会作为普通 HTML 插入,Vue 模板语法不会再处理;任意 HTML 动态渲染有安全风险,只应该用于可信内容。VitePress 配置通常来自站点源码,本身是可信内容,所以这里不是典型 XSS 场景,而是作者把普通技术文案写进了会按 HTML 解析的可信配置。

字符串怎样消费,决定尖括号是文本还是标签

Bug 还是 feature

从实现历史看,这不是一个单纯 bug。text 改成 v-html 是有明确 issue 和 PR 支撑的 feature,目的就是让 sidebar 文案支持 HTML。

真正的问题在接口语义上:字段名叫 text,文档里也按普通文本示例介绍,但默认主题实际把它当 html 用。对使用者来说,这个 API 很容易让人写出 Vue <script setup>、Array<T>、Promise<Result> 这类普通技术文案。只要它们出现在 sidebar、nav、pager 这类默认主题 v-html 位置,就可能被浏览器当成标签解析。

如果现在把 VitePress 源码从 v-html 改回文本插值,会破坏已经依赖 HTML sidebar 的站点。更稳的上游改进方向有两个:

  1. 文档补 warning:themeConfig.sidebar[*].text 会作为 HTML 渲染;需要展示字面量 < / > 时写成 &lt; / &gt;。
  2. 补完当年 issue 里没有落地的 Markdown 语义:让默认主题的短文案字段支持 inline Markdown。这样作者可以写 Vue `<script setup>`,最后得到安全的 <code>&lt;script setup&gt;</code>,已有 <code>foo</code> 这类 HTML label 也能继续工作。

这两个方向后来都补进了上游 PR。我给 VitePress 提了 PR #5390:它保留 raw HTML label,同时在 Node 侧把 nav、sidebar、docFooter 等默认主题文案先渲染成 inline Markdown;英文和已有本地化 reference 页面也同步写清楚:这些字段支持 inline Markdown 和 HTML,想显示字面量尖括号时优先写成行内代码或 HTML entity。第一点是文档兜底,第二点是新的写法通道。

所以这次我更倾向把它归类成「feature 带来的文档和命名陷阱」。VitePress 本身按设计工作,但这个设计需要在使用侧补护栏。

为什么暂时不切 Markdown

更理想的写法,是把技术字面量放进行内代码;手写 &lt; / &gt; 只适合过渡期:

label: 'Vue `<script setup>`'

这个写法符合 Markdown 直觉,也更像普通文档标题。问题在于,PR 合并并发布到 npm 之前,vitepress@2.0.0-alpha.19 还不会把 sidebar text 当 inline Markdown 预渲染。源码里改成上面的字符串,构建命令仍然能通过,但产物里的 sidebar 会显示成字面量 Vue `<script setup>`,不是期望的代码样式;在其他裸尖括号场景里,它也不能替我们转义 HTML。

临时过渡有三种选择:

  • 继续用 HTML entity。label: 'Vue &lt;script setup&gt;' 现在就能稳定部署,不依赖 fork,也不需要 patch 安装包。代价是数据源里混入了展示端写法,后续 VitePress 支持 inline Markdown 后要再改回来。
  • 依赖 GitHub fork。pnpm 支持从 Git repository 安装依赖,也可以固定 branch 或 commit;但 VitePress 包的 exports 指向 dist/node/index.js,而源码仓库里的 dist 是构建产物,不适合作为普通源码依赖直接消费。除非维护一个带 dist 的 fork 分支或 tarball,否则这条路的 CI 可重复性不够好。
  • 使用 pnpm patch。pnpm 官方提供 pnpm patch 和 pnpm patch-commit,可以给 vitepress@2.0.0-alpha.19 打本地补丁并随仓库提交。它比 fork 更可控,但 patch 的对象通常是发布包里的 dist 产物,可读性和升级体验都不如等上游发布。

@sheng/eslint-plugin 的文档站最终选了第一种。这个选择牺牲了一点数据源的自然度,换来当前最稳的部署路径:先保证线上页面不会再被浏览器 HTML parser 打断,等 VitePress 上游的 inline Markdown label 能随正式版本使用后,再把分组标题改回 Vue `<script setup>`。

现成 Lint 能不能扫出来

现成规则能覆盖相邻风险,但很难直接覆盖这个点。

vue/no-v-html 会报告 Vue 模板里的 v-html,用于降低不安全 HTML 注入风险。它适合管自己项目写的 .vue 文件,比如业务组件里误用 v-html;这次的 v-html 在 VitePress 默认主题源码里,站点作者写的是配置字符串,不是自己的模板指令。把 vue/no-v-html 打开也不会扫描 themeConfig.sidebar 里的 text。

markdownlint 的 MD033 / no-inline-html 会报告 Markdown 里的裸 HTML。它能提醒正文里直接写 <script setup> 这类内容时要用代码格式或转义,但 VitePress sidebar 往往来自 .vitepress/config.ts、导航生成脚本或项目自己的元数据对象。MD033 管 Markdown 源文件,不管 JS / TS 配置字符串。

HTML 校验和浏览器 smoke test 可以在产物层发现问题。比如构建后检查 dist/index.html,或者用 Playwright 打开页面并收集 pageerror / console error,都能拦住「页面已经被 HTML parser 解析坏」这类结果。但这属于构建后验证,不是源码阶段的 Lint;它能告诉你页面坏了,不一定能直接指出是哪一个分组标题没有转义。

更贴近这个问题的做法是项目内规则。它有两种形态:

  1. 对生成 sidebar 的元数据做专门检查。比如 @sheng/eslint-plugin 这次把规则分组标题、摘要里的裸 < / > 拦在 docs:check 里。
  2. 如果 sidebar 直接手写在 .vitepress/config.ts,可以加一条 ESLint 自定义规则,或者先用 no-restricted-syntax 做窄范围检查,只在 VitePress 配置文件里禁止 text / label / summary 这类字段出现裸尖括号。

临时规则可以长这样,只放在文档配置相关文件的 override 里,不要全仓扫所有字符串:

// eslint.config.mjs
export default [
  {
    files: ['docs/.vitepress/**/*.{js,ts}', 'src/rules/groups.js'],
    rules: {
      'no-restricted-syntax': [
        'error',
        {
          selector:
            'Property[key.name=/^(text|label|summary)$/] > Literal[value=/[<>]/]',
          message:
            'VitePress 默认主题会把部分 text 文案按 HTML 渲染;展示字面量 < 或 > 时请写成 &lt; / &gt;。',
        },
      ],
    },
  },
]

这条写法只能处理普通字符串字面量。遇到模板字符串、变量拼接、从 Markdown 标题自动生成 sidebar、或者像本项目这样从规则元数据再生成 VitePress 配置,就更适合写成项目脚本或真正的 ESLint 自定义规则:先找到数据源,再统一 escape 或统一报错。

这些护栏拦住的是不同位置:vue/no-v-html 和 MD033 是旁路提醒,产物校验能在发布前兜底;能稳定定位这次问题的,还是理解项目文档数据流的本地检查。

项目内怎么防退化

项目里最后保留了一个轻量检查:声明为纯技术文案的规则分组标题和摘要不能出现裸 < / >,合法 HTML label 不在这个禁用范围内。只要这些纯文案字段会进入 VitePress 默认主题的 HTML 渲染链路,就必须显式写成 HTML entity,或者等未来上游支持 inline Markdown 后统一切换。普通 Markdown 正文中的 <script setup> 同样应写进行内代码或转义。

// sheng-eslint-plugin/scripts/check-docs.ts
for (const [groupName, details] of Object.entries(ruleGroupDetails)) {
  for (const [fieldName, value] of Object.entries(details)) {
    if (typeof value === 'string' && /[<>]/.test(value)) {
      fail(`规则分组 ${groupName}.${fieldName} 包含裸尖括号,VitePress sidebar SSR 可能把它当成 HTML 标签。`)
    }
  }
}

这条检查故意放在 docs:check 里,而不是只靠人工 review。问题的触发点很隐蔽:源码里只是一个标题字符串,构建也可能成功,损坏发生在浏览器 HTML parser 阶段。CI 直接拦住裸尖括号,比等页面发布后看控制台更可靠。

更通用的项目规则可以这样定:

  • 写入 themeConfig.nav、themeConfig.sidebar、docFooter、home hero text 这类默认主题文案时,把它们当 HTML 字符串处理。
  • 本文验证的版本里,需要展示字面量尖括号时写成 HTML entity,例如 Vue &lt;script setup&gt;、Array&lt;T&gt;。
  • 如果项目已经接入支持 inline Markdown label 的 VitePress 版本,可以改成 Vue `<script setup>` 这类 Markdown 写法。
  • 如果确实需要 HTML 样式,尽量只用简单 inline tag,例如 <code>foo</code>,不要从外部输入拼接 HTML。
  • 自动生成 sidebar 时,给文案数据源加检查。不要让裸 < / > 靠人工 review 混进配置。
  • 发布后验证不仅看资源是否 200,还要用真实浏览器点一下导航,并收集 pageerror 和 console error。

SSR HTML 会先经过浏览器自己的 HTML parser,再交给 Vue 接管。一旦配置字符串在这个阶段变成了真实标签,后面的客户端脚本就算路径完全正确,也可能接不到一个完整页面。

以后遇到导航失效、初始化数据 undefined,且第一条错误是 Unexpected token '<',我会先看首屏 HTML 是否已被配置文案改变结构。这里的损坏发生在 Vue 接管之前,单查脚本路径很容易漏掉它。升级 VitePress 后,也要重新确认 label 的渲染方式和本地护栏是否还匹配。