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 字符串渲染。
现场代码
当时项目里为了给规则分组命名,写了一个很自然的标题:
// 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 <script setup>',
summary: 'Vue <script setup> 宏和模板名称解析边界。',
},
}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 <script setup></h3>页面最终显示仍然是 Vue <script setup>,但浏览器解析 HTML 时不会把它当成标签。线上页面重新部署后,首页 HTML 里能搜到 Vue <script setup>,浏览器里读取 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 后,作者把普通文本写成了 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 的站点。更稳的上游改进方向有两个:
- 文档补 warning:
themeConfig.sidebar[*].text会作为 HTML 渲染;需要展示字面量</>时写成</>。 - 补完当年 issue 里没有落地的 Markdown 语义:让默认主题的短文案字段支持 inline Markdown。这样作者可以写
Vue \