Appearance
解决 VitePress 中的 Dead Link:从脚本到根因

基于真实项目
apply-docs的排查记录,完整梳理 dead link 的产生、捕获与修复思路。
背景
项目使用 VitePress 作为文档站,在进行 i18n 文档重构(分支 feat/i18n-docs-restructure)后,CI 的 dead link 检查失败了。本文按“先写脚本 → 再注册命令 → 再运行看结果 → 最后分析根因”的顺序,完整复盘:
- 为什么要自己写一个 dead link 检查脚本
- 脚本怎么写、怎么在
package.json里注册 - 第一次运行后,为什么脚本抓到的数量和 VitePress 报告的不一致
- 这些“丢失
/”的诡异路径到底是怎么产生的,以及怎么修
一、为什么需要一个脚本
VitePress 构建时本身会检查 dead link,但默认只是打印警告,不会让进程失败。也就是说,即使文档里有死链,vitepress build 仍然可能以退出码 0 结束,CI 和 pre-commit 都不会拦住它。
我们的目标是:
- 有 dead link 时,退出码非 0,让 CI / pre-commit 失败;
- 把死链按文件分组、带行号打印出来,方便定位;
- 兼容 VitePress 1.x / 2.x 两种日志格式;
- 非 TTY 环境(CI)下不输出 ANSI 颜色,避免日志乱码。
所以思路很直接:包一层 vitepress build,捕获它的输出,解析出 dead link 行,自己决定退出码。
二、编写 scripts/check-dead-links.mjs
2.1 启动构建并捕获输出
js
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import process from 'node:process'
import path from 'node:path'
const VERBOSE = process.argv.includes('--verbose')
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const proc = spawn(npx, ['vitepress', 'build', 'docs'], {
shell: false,
cwd: path.resolve(process.cwd()),
})
let buffer = ''
proc.stdout.on('data', (chunk) => {
const s = chunk.toString()
buffer += s
if (VERBOSE) process.stdout.write(s)
})
proc.stderr.on('data', (chunk) => {
const s = chunk.toString()
buffer += s
if (VERBOSE) process.stderr.write(s)
})这里把 stdout / stderr 都累加到 buffer 里,--verbose 时同时透传到终端,方便排查日志格式。注意 Windows 下要用 npx.cmd。
2.2 颜色降级
CI 环境不是 TTY,需要降级为无色输出:
js
const isTTY = process.stdout.isTTY
const c = {
red: (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),
green: (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s),
yellow: (s) => (isTTY ? `\x1b[33m${s}\x1b[0m` : s),
cyan: (s) => (isTTY ? `\x1b[36m${s}\x1b[0m` : s),
dim: (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s),
bold: (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s),
}2.3 解析两种 VitePress 日志格式
VitePress 1.x 和 2.x 的日志格式略有差异,脚本需要同时兼容:
js
// 格式 A(1.x / 2.x 都有):单条警告
// (!) Found dead link ./foo in file docs/bar.md:12
const reA = /^\(!\) Found dead link (\S+) in file (\S+?)(?::(\d+))? undefined$/gm
// 格式 B(2.x):汇总行
// Error: 51 dead link(s) found.
const reB = /Error: (\d+) dead link\(s\) found/2.4 数量对齐校验
这一步很关键 —— 它能发现“正则漏抓”的问题:
js
if (expectedCount > 0 && deadLinks.length !== expectedCount) {
console.error(c.yellow(
`\n⚠ VitePress 报告 ${expectedCount} 个 dead link,脚本抓到 ${deadLinks.length} 个。` +
`请检查日志格式是否变了。`
))
}2.5 按文件分组输出
js
const rel = (p) => path.relative(process.cwd(), p)
const byFile = new Map()
for (const d of deadLinks) {
const key = rel(d.file)
if (!byFile.has(key)) byFile.set(key, [])
byFile.get(key).push(d)
}
console.error(c.red(
`\n✖ 发现 ${c.bold(deadLinks.length)} 个 dead link,分布在 ${byFile.size} 个文件\n`
))
for (const [file, items] of [...byFile.entries()].sort()) {
console.error(c.cyan(` ${file}`))
for (const it of items) {
const loc = it.line ? c.dim(`:${it.line}`) : ''
console.error(` ${c.yellow('→')} ${it.target}${loc}`)
}
console.error('')
}2.6 根因分析提示
对形如 ./xxxuick-start 这种“丢失 /”的模式做一次额外提示(下文会解释为什么会有这类路径):
js
const suspects = deadLinks.filter((d) =>
/^\.\/[a-z]+\/[a-z]/.test(d.target) === false
&& /^\.\/[a-z]+[a-z-]+$/.test(d.target)
&& /^\.\/(maintain|usage|guide|design|reference)/.test(d.target)
)
if (suspects.length > 0) {
console.error(c.yellow(`⚠ 其中 ${suspects.length} 个疑似"丢失 /"模式,重点排查:`))
const sample = suspects.slice(0, 8).map((s) => s.target)
console.error(c.dim(' ' + sample.join('\n ')))
console.error(c.dim(' → 检查 docs/.vitepress/config.ts 的 markdown 插件配置\n'))
}完整脚本拼接起来就是:
js
#!/usr/bin/env node
/**
* VitePress Dead Link 检查脚本
*
* 原理:跑一次 vitepress build,捕获输出里的 "Found dead link" 行,
* 若存在则退出码 1,让 CI / pre-commit 失败。
*
* 用法:
* node scripts/check-dead-links.mjs
* node scripts/check-dead-links.mjs --verbose # 打印完整构建日志
*/
import { spawn } from 'node:child_process'
import process from 'node:process'
import path from 'node:path'
const VERBOSE = process.argv.includes('--verbose')
// ---------- 颜色(非 TTY 时降级为无色,兼容 CI 日志) ----------
const isTTY = process.stdout.isTTY
const c = {
red: (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s),
green: (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s),
yellow: (s) => (isTTY ? `\x1b[33m${s}\x1b[0m` : s),
cyan: (s) => (isTTY ? `\x1b[36m${s}\x1b[0m` : s),
dim: (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s),
bold: (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s),
}
// ---------- 启动构建 ----------
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const proc = spawn(npx, ['vitepress', 'build', 'docs'], {
shell: false,
cwd: path.resolve(process.cwd()),
})
let buffer = ''
proc.stdout.on('data', (chunk) => {
const s = chunk.toString()
buffer += s
if (VERBOSE) process.stdout.write(s)
})
proc.stderr.on('data', (chunk) => {
const s = chunk.toString()
buffer += s
if (VERBOSE) process.stderr.write(s)
})
proc.on('error', (err) => {
console.error(c.red(`\n✖ 无法启动构建进程: ${err.message}`))
process.exit(1)
})
proc.on('close', (code) => {
// ---------- 收集 dead link:兼容两种格式 ----------
const deadLinks = []
const reA = /^\(!\) Found dead link (\S+) in file (\S+?)(?::(\d+))? undefined$/gm
let m
while ((m = reA.exec(buffer)) !== null) {
deadLinks.push({ target: m[1], file: m[2], line: m[3] ? Number(m[3]) : null })
}
const reB = /Error: (\d+) dead link\(s\) found/
const summaryMatch = buffer.match(reB)
const expectedCount = summaryMatch ? Number(summaryMatch[1]) : 0
// ---------- 判断结果 ----------
if (deadLinks.length === 0 && expectedCount === 0) {
if (code !== 0) {
console.error(c.red(`\n✖ vitepress build 失败,退出码 ${code}(非 dead link 原因)`))
const tail = buffer.split('\n').slice(-80).join('\n')
console.error(tail)
process.exit(code ?? 1)
}
console.log(c.green('\n✔ 没有发现 dead link'))
process.exit(0)
}
if (expectedCount > 0 && deadLinks.length !== expectedCount) {
console.error(c.yellow(
`\n⚠ VitePress 报告 ${expectedCount} 个 dead link,脚本抓到 ${deadLinks.length} 个。` +
`请检查日志格式是否变了。`
))
}
const rel = (p) => path.relative(process.cwd(), p)
const byFile = new Map()
for (const d of deadLinks) {
const key = rel(d.file)
if (!byFile.has(key)) byFile.set(key, [])
byFile.get(key).push(d)
}
console.error(c.red(
`\n✖ 发现 ${c.bold(deadLinks.length)} 个 dead link,分布在 ${byFile.size} 个文件\n`
))
for (const [file, items] of [...byFile.entries()].sort()) {
console.error(c.cyan(` ${file}`))
for (const it of items) {
const loc = it.line ? c.dim(`:${it.line}`) : ''
console.error(` ${c.yellow('→')} ${it.target}${loc}`)
}
console.error('')
}
const suspects = deadLinks.filter((d) => /^\.\/[a-z]+\/[a-z]/.test(d.target) === false
&& /^\.\/[a-z]+[a-z-]+$/.test(d.target)
&& /^\.\/(maintain|usage|guide|design|reference)/.test(d.target))
if (suspects.length > 0) {
console.error(c.yellow(`⚠ 其中 ${suspects.length} 个疑似"丢失 /"模式,重点排查:`))
const sample = suspects.slice(0, 8).map((s) => s.target)
console.error(c.dim(' ' + sample.join('\n ')))
console.error(c.dim(' → 检查 docs/.vitepress/config.ts 的 markdown 插件配置\n'))
}
process.exit(1)
})三、在 package.json 里注册命令
脚本写好后,在 package.json 的 scripts 里注册两个命令:
json
"scripts": {
"docs:check-links": "node scripts/check-dead-links.mjs",
"docs:check-links:verbose": "node scripts/check-dead-links.mjs --verbose"
}docs:check-links:日常和 CI 用,静默构建输出,只打印结果摘要;docs:check-links:verbose:排查日志格式、定位问题时用,透传完整构建日志。
之后在 CI 里加一步 npm run docs:check-links,或在 pre-commit 里挂上即可。
四、第一次运行:数量对不上
运行:
bash
npm run docs:check-links输出(完整日志见文末附录):
⚠ VitePress 报告 51 个 dead link,脚本抓到 45 个。请检查日志格式是否变了。51 vs 45,少了 6 个。
这说明正则漏抓了。原因通常有两类:
- 多行匹配问题:某些
Found dead link行前面有别的输出,导致^锚点失效。 - 路径含空格或特殊字符:
\S+匹配不到,直接跳过。
排查时用 --verbose 模式看完整日志即可确认:
bash
npm run docs:check-links:verbose💡 改进方向:把
\S+改成更宽容的(.+?),或者直接不锚定行首,改为gm全局匹配(见第七节)。
五、根因分析:三类问题
拿到 45 条记录后,按路径形态可以分成三类。
5.1 “丢失 /”的诡异路径
有一批路径非常奇怪:
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
./usageuick-start
./usagentroduction
./usagenstallation
./designpplyctl
./guidentroduction对比正常路径:
| 异常路径 | 正常应该是 |
|---|---|
./referenceenmapping-yaml | ./reference/en-mapping-yaml |
./usageuick-start | ./usage/quick-start |
./usagentroduction | ./usage/introduction |
./guidentroduction | ./guide/introduction |
规律很明显:路径中丢失了 / 及其后一个字符。
比如:
./usage/quick-start→ 删掉/q→./usageuick-start✅./reference/en-mapping-yaml→ 删掉/e→./referenceenmapping-yaml✅./guide/introduction→ 删掉/i→./guidentroduction✅
这不是 VitePress 的 bug,而是 文档源文件里的链接本身就写错了。
为什么会这样?
大概率是 批量替换 / 正则误操作 导致的。比如在 i18n 重构时,可能执行了类似:
bash
# 本意:把 ./guide 替换成 ./guide/
sed -i 's|\./guide|./guide/|g' docs/**/*.md但如果原链接已经是 ./guide/introduction,就会变成 ./guide//introduction,再经过某些工具(比如 Prettier 或某个 markdown 插件)处理时,// 被错误归一化,可能吃掉了一个字符。
另一种可能是 IDE 的自动补全 bug:在输入 ./usage/ 时,自动补全插入路径后光标位置错乱,覆盖了 / 后面的字符。
怎么快速定位?
用 grep 找出源文件里所有可疑的链接:
bash
# 找出所有形如 ./xxx 后面直接跟小写字母、没有 / 的链接
grep -rn '](\./[a-z]*[a-z]' docs/en docs/zh | grep -v '](\./'或者更直接地,从脚本抓到的清单里,逐个文件打开检查:
bash
grep -n 'usageuick-start' docs/en/usage.md5.2 相对路径层级写错
除了“丢失 /”,还有一类是:
docs/en/maintain/adding-a-template.md
→ ./../../maintain:188docs/en/maintain/adding-a-template.md 里的 ../../maintain 会解析到 docs/maintain(因为 ../.. 从 docs/en/maintain/ 出发,跳到了 docs/),但实际目录是 docs/en/maintain.md。
正确写法应该是:
markdown
[链接](./../maintain.md) <!-- 或者 [链接](/en/maintain) -->或者直接用 VitePress 的绝对路径:
markdown
[链接](/en/maintain)5.3 跨语言引用用了相对路径
zh 文档里出现了:
docs/zh/design/applyctl.md
→ ./../../en/usage:7这是 i18n 重构时,zh 文档引用了 en 文档。在 VitePress 中,跨语言引用应该用绝对路径:
markdown
<!-- ❌ 相对路径会失效 -->
[Usage](../../en/usage.md)
<!-- ✅ 用绝对路径 -->
[Usage](/en/usage)六、修复清单
汇总这次要修的三类问题:
1. 补回丢失的 /
diff
- [Quick Start](./usageuick-start)
+ [Quick Start](./usage/quick-start)建议用 grep + 手动逐条改,不要再用正则批量替换 —— 否则容易再次踩坑。
2. 修正相对路径层级
diff
- [Maintain](../../maintain)
+ [Maintain](../maintain.md)3. 跨语言链接改为绝对路径
diff
- [Usage](../../en/usage.md)
+ [Usage](/en/usage)七、给脚本的两点改进建议
基于这次踩坑,我打算给 check-dead-links.mjs 加两个能力:
1. 宽容匹配,解决 51 vs 45 的差异
js
// 不锚定行首,允许路径含空格
const reA = /Found dead link (.+?) in file (\S+?)(?::(\d+))? undefined/gm2. 内置“丢失 /”检测提示
现有的根因分析逻辑已经能识别这类模式(见 2.6 节),建议再补一条:提示用户用 grep 定位源文件,比单纯说“检查 config.ts”更实用。
八、总结
| 问题类型 | 现象 | 修复方式 |
|---|---|---|
丢失 / | ./usageuick-start | 手动改回 ./usage/quick-start |
| 相对路径层级错 | ../../maintain | 改为 ../maintain.md 或绝对路径 |
| 跨语言引用 | ../../en/usage | 改为 /en/usage |
| 脚本漏抓 | 51 vs 45 | 放宽正则,去掉行首锚定 |
核心教训:
- dead link 检查脚本要能对齐 VitePress 报告的数量,否则漏抓会掩盖问题。
- i18n 重构时,链接写法要统一:站内链接建议全部用绝对路径
/xxx/yyy,避免相对路径层级混乱。 - 不要用正则批量改 markdown 链接,容易产生“吃掉字符”的二次 bug。
附录:实际运行日志
以下是本次排查时终端里的完整输出(分支 feat/i18n-docs-restructure):
text
➜ apply-docs git:(feat/i18n-docs-restructure) npm run docs:check-links
> [email protected] docs:check-links
> node scripts/check-dead-links.mjs
⚠ VitePress 报告 51 个 dead link,脚本抓到 45 个。请检查日志格式是否变了。
✖ 发现 45 个 dead link,分布在 16 个文件
docs/en/maintain.md
→ ./maintainorkflow:99
→ ./maintainelease
docs/en/maintain/adding-a-template.md
→ ./../../maintain:188
docs/en/reference.md
→ ./referenceenmapping-yaml
→ ./referenceemplate-variables
→ ./referenceile-tree
→ ./referencexit-codes
→ ./referenceenmapping-yaml:86
→ ./referenceemplate-variables:87
→ ./referenceile-tree:88
→ ./referencexit-codes:89
docs/en/reference/exit-codes.md
→ ./../../reference:200
docs/en/reference/template-variables.md
→ ./../../reference:230
docs/en/usage.md
→ ./usageuick-start:15
→ ./usageenerated-structure
→ ./usageaq
→ ./designpplyctl:92
→ ./guidentroduction:94
→ ./usagentroduction
→ ./usagenstallation
→ ./usageuick-start
→ ./usageli-reference
→ ./usageli-reference:131
→ ./usageenerated-structure:132
→ ./usageaq:133
→ ./guidentroduction
→ ./usageuick-start:173
→ ./usagentroduction:177
→ ./usagenstallation:178
→ ./usageuick-start:179
→ ./usageli-reference:180
→ ./usageenerated-structure:181
→ ./usageaq:182
→ ./guidentroduction:183
docs/en/usage/faq.md
→ ./../../maintain:235
docs/en/usage/installation.md
→ ./../../usage:275
docs/en/usage/introduction.md
→ ./../../usage
→ ./../../usage:151
docs/zh/design/applyctl.md
→ ./../../en/usage:7
docs/zh/design/decisions.md
→ ./../../en/design:5
docs/zh/maintain/adding-a-template.md
→ ./../../en/maintain:188
docs/zh/reference/exit-codes.md
→ ./../../en/reference:200
docs/zh/reference/template-variables.md
→ ./../../en/reference:230
docs/zh/usage/faq.md
→ ./../../en/maintain:235
docs/zh/usage/introduction.md
→ ./../../en/usage:151
⚠ 其中 31 个疑似"丢失 /"模式,重点排查:
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
→ 检查 docs/.vitepress/config.ts 的 markdown 插件配置
➜ apply-docs git:(feat/i18n-docs-restructure) ✗从日志中可以直接读出三个关键信息:
- 数量不一致(51 vs 45)—— 说明正则存在漏抓,需先修脚本再修文档。
./xxxuick-start这类路径反复出现 —— 典型的“丢失/”模式,集中在docs/en/usage.md和docs/en/reference.md。./../../maintain、./../../en/usage大量出现 —— 相对路径层级写错 + 跨语言引用未用绝对路径。
按第六节的清单逐项修复后,再次运行 npm run docs:check-links 应能通过。
