English
Resolving Dead Links in VitePress: From Script to Root Cause

Based on a real project apply-docs the troubleshooting record, comprehensively sorting out how dead links are generated, captured, and repaired.
Background
The project uses VitePress as the documentation site, and is undergoing an i18n documentation refactor (branch feat/i18n-docs-restructure), after which CI's dead link check failed. This article follows “First write the script → then register the command → then run it and check the results → finally analyze the root cause” in order, complete review:
Why write your own dead link checking script?
How to write a script, and how to be in
package.jsonregister inWhy does the number captured by the script differ from what VitePress reports after the first run?
These “lost
/How exactly is the strange path of '”' generated, and how to fix it?
1. Why do we need a script
VitePress itself checks for dead links during the build, but by default it only prints a warning and does not fail the process. That is, even if there are dead links in the documentation,vitepress build It may still exit with code 0, and neither CI nor pre-commit will catch it.
Our goal is:
When there is a dead link, the exit code is non-zero., causing CI / pre-commit to fail;
Group dead links by file and print them with line numbers., making it easy to locate;
Compatible with both VitePress 1.x / 2.x log formats;
Do not output ANSI colors in non-TTY environments (CI)., to avoid garbled logs.
So the approach is straightforward: wrap it in a layer.vitepress build, capture its output, parse the dead link lines, and decide the exit code yourself.
II. Compilation scripts/check-dead-links.mjs
2.1 Start the build and capture the output
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)
})Here, both stdout / stderr are accumulated into buffer in,--verbose At the same time, pass it through to the terminal, making it convenient to troubleshoot the log format. Note that on Windows, you need to use npx.cmd.
2.2 Color Degradation
The CI environment is not a TTY, so it needs to fall back to no-color output:
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 Parsing Two VitePress Log Formats
The log formats of VitePress 1.x and 2.x differ slightly, and the script needs to be compatible with both:
js
// Format A (present in both 1.x / 2.x): single warning
// (!) Found dead link ./foo in file docs/bar.md:12
const reA = /^\(!\) Found dead link (\S+) in file (\S+?)(?::(\d+))? undefined$/gm
// Format B (2.x): Summary row
// Error: 51 dead link(s) found.
const reB = /Error: (\d+) dead link\(s\) found/2.4 Quantity Alignment Verification
This step is crucial — it can uncover the issue of “regex missing matches”:
js
if (expectedCount > 0 && deadLinks.length !== expectedCount) {
console.error(c.yellow(
`\n⚠ VitePress reports ${expectedCount} dead links, and the script caught ${deadLinks.length}.` +
Please check whether the log format has changed.
))
}2.5 Output grouped by file
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✖ Found ${c.bold(deadLinks.length)} dead links, distributed across ${byFile.size} files\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 Root Cause Analysis Prompt
For expressions of the form ./xxxuick-start This kind of “loss /” mode, provide an additional prompt (the following text will explain why such paths exist):
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(`⚠ Among them, ${suspects.length} suspected "missing /" patterns, focus on checking:`))
const sample = suspects.slice(0, 8).map((s) => s.target)
console.error(c.dim(' ' + sample.join('\n ')))
console.error(c.dim(' → Check the markdown plugin configuration in docs/.vitepress/config.ts\n'))
}The complete script, when pieced together, is:
js
#!/usr/bin/env node
/**
* VitePress Dead Link check script
*
* Principle: run vitepress build once, capture the "Found dead link" lines in the output,
* If present, exit code 1, causing CI / pre-commit to fail.
*
* Usage:
* node scripts/check-dead-links.mjs
* node scripts/check-dead-links.mjs --verbose # Print the full build log
*/
import { spawn } from 'node:child_process'
import process from 'node:process'
import path from 'node:path'
const VERBOSE = process.argv.includes('--verbose')
// ---------- Colors (fall back to no color when not a TTY, compatible with CI logs) ----------
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),
}
// ---------- Starting build ----------
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✖ Unable to start build process: ${err.message}`))
process.exit(1)
})
proc.on('close', (code) => {
// ---------- Collect dead link: compatible with two formats ----------
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
// ---------- Judgment Result ----------
if (deadLinks.length === 0 && expectedCount === 0) {
if (code !== 0) {
console.error(c.red(`\n✖ vitepress build failed, exit code ${code} (not due to dead link)`))
const tail = buffer.split('\n').slice(-80).join('\n')
console.error(tail)
process.exit(code ?? 1)
}
console.log(c.green('\n✔ No dead link found'))
process.exit(0)
}
if (expectedCount > 0 && deadLinks.length !== expectedCount) {
console.error(c.yellow(
`\n⚠ VitePress reports ${expectedCount} dead links, and the script caught ${deadLinks.length}.` +
Please check whether the log format has changed.
))
}
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✖ Found ${c.bold(deadLinks.length)} dead links, distributed across ${byFile.size} files\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(`⚠ Among them, ${suspects.length} suspected "missing /" patterns, focus on checking:`))
const sample = suspects.slice(0, 8).map((s) => s.target)
console.error(c.dim(' ' + sample.join('\n ')))
console.error(c.dim(' → Check the markdown plugin configuration in docs/.vitepress/config.ts\n'))
}
process.exit(1)
})3. At package.json register command in
After the script is written, in package.json of scripts Register two commands in:
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: For daily and CI use, silent build output, printing only the result summary;docs:check-links:verbose: Used for troubleshooting log formats and locating issues, passing through the complete build log.
Then add a step in CI.npm run docs:check-links, or in pre-commit Just hang it inside.
IV. First Run: The Numbers Don’t Match
Run:
bash
npm run docs:check-linksOutput (see the appendix at the end for the complete logs):
⚠ VitePress reported 51 dead links, but the script caught 45. Please check whether the log format has changed.51 vs 45, 6 fewer.
This indicates that the regex missed some matches. There are usually two types of reasons:
Multi-line matching problem: some
Found dead linkThere is other output before the line, causing^Anchor point invalid.Path contains spaces or special characters:
\S+If no match is found, skip directly.
Use during troubleshooting --verbose The mode can be confirmed by viewing the complete log:
bash
npm run docs:check-links:verbose💡 Improvement direction: take \S+ Change it to a more lenient one.(.+?), or simply do not anchor to the start of the line, and instead change it to gm Global matching (see Section 7).
V. Root Cause Analysis: Three Types of Problems
After obtaining the 45 records, they can be divided into three categories according to path shape.
5.1 “Lost / the eerie path of
There is a batch of very strange paths:
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
./usageuick-start
./usagentroduction
./usagenstallation
./designpplyctl
./guidentroductionCompare with the normal path:
| exception path | Normally it should be |
|---|---|
./referenceenmapping-yaml | ./reference/en-mapping-yaml |
./usageuick-start | ./usage/quick-start |
./usagentroduction | ./usage/introduction |
./guidentroduction | ./guide/introduction |
The pattern is obvious: in the path, lost / and the character after it.
For example:
./usage/quick-startDelete it/q→./usageuick-start✅./reference/en-mapping-yamlDelete it/e→./referenceenmapping-yaml✅./guide/introductionDelete/i→./guidentroduction✅
This is not a VitePress bug, but rather The link in the document source file is wrong to begin with..
Why is this happening?
It is most likely Batch replacement / regex misoperation caused by. For example, during i18n refactoring, something like the following may have been executed:
bash
# Intended: replace ./guide with ./guide/
sed -i 's|\./guide|./guide/|g' docs/**/*.mdBut if the original link is already ./guide/introduction,就会变成 ./guide//introduction, and then when processed by certain tools (such as Prettier or some markdown plugin),// It was incorrectly normalized and may have swallowed one character.
Another possibility is IDE autocomplete bug: 在输入./usage/ When, after autocomplete inserts the path, the cursor position becomes disordered, overwriting / the following characters.
How can I locate it quickly?
Use grep to find all suspicious links in the source file:
bash
# Find all links of the form ./xxx immediately followed by lowercase letters and containing no /.
grep -rn '](\./[a-z]*[a-z]' docs/en docs/zh | grep -v '](\./'Or, more directly, open and inspect each file one by one from the list captured by the script:
bash
grep -n 'usageuick-start' docs/en/usage.md5.2 Incorrect relative path hierarchy
Except for “lost /", and there is another type:
docs/en/maintain/adding-a-template.md
→ ./../../maintain:188docs/en/maintain/adding-a-template.md inside ../../maintain will resolve to docs/maintain(because ../.. from docs/en/maintain/ Set off, jumped to docs/), but the actual directory is docs/en/maintain.md.
Correct writing It should be:
markdown
[Link](./../maintain.md) <!-- or [Link](/en/maintain) -->Or directly use VitePress's absolute path:
markdown
[Link](/en/maintain)5.3 Cross-language references use relative paths
zh The following appears in the document:
docs/zh/design/applyctl.md
→ ./../../en/usage:7This was during the i18n refactoring, when the zh documentation referenced the en documentation. In VitePress,Cross-language references should use absolute paths.:
markdown
<!-- ❌ Relative paths will fail -->
[Usage](../../en/usage.md)
<!-- ✅ Use absolute path -->
[Usage](/en/usage)VI. Repair List
Summary of the three types of issues to be fixed this time:
1. Recover the lost /
diff
- [Quick Start](./usageuick-start)
+ [Quick Start](./usage/quick-start)It is recommended to use grep+ Manually edit one by one,Stop using regular expressions for batch replacement.— otherwise it is easy to fall into the same trap again.
2. Fix the relative path hierarchy
diff
- [Maintain](../../maintain)
+ [Maintain](../maintain.md)3. Change cross-language links to absolute paths
diff
- [Usage](../../en/usage.md)
+ [Usage](/en/usage)VII. Two suggestions for improving the script
Based on this lesson learned, I plan to give check-dead-links.mjs Add two abilities:
1. Tolerant matching, resolve the 51 vs 45 discrepancy
js
// Do not anchor to the start of the line; allow paths to contain spaces.
const reA = /Found dead link (.+?) in file (\S+?)(?::(\d+))? undefined/gm2. Built-in “Lost / Detection prompt
The existing root cause analysis logic can already identify this type of pattern (see Section 2.6). It is recommended to add one more:Prompt the user to use grep Locate the source file, which is more practical than simply saying “check config.ts”.
VIII. Summary
| Question type | phenomenon | Repair method |
|---|---|---|
Lost / | ./usageuick-start | Change back manually ./usage/quick-start |
| Relative path hierarchy error | ../../maintain | Change to ../maintain.md or absolute path |
| Cross-language references | ../../en/usage | Change to /en/usage |
| Script missed capture | 51 vs 45 | Relax the regex and remove the start-of-line anchor. |
Key lesson:
The dead link check script needs to match the number reported by VitePress., otherwise, missed detections will mask the problem.
When refactoring i18n, link formats should be consistent.: It is recommended that all internal links use absolute paths.
/xxx/yyy, to avoid confusion in the relative path hierarchy.Don't use regex to bulk-edit Markdown links., easily causing secondary bugs that "eat" characters.
Appendix: Actual operation log
The following is the complete output in the terminal during this troubleshooting (branch 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 reported 51 dead links, but the script caught 45. Please check whether the log format has changed.
✖ Found 45 dead links, distributed across 16 files
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
⚠ Among them, 31 are suspected of a "missing /" pattern; focus on investigating:
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
./referenceenmapping-yaml
./referenceemplate-variables
./referenceile-tree
./referencexit-codes
→ Check the markdown plugin configuration in docs/.vitepress/config.ts
➜ apply-docs git:(feat/i18n-docs-restructure) ✗Three key pieces of information can be read directly from the logs:
Quantity mismatch(51 vs 45) — this indicates the regex missed some matches, so the script must be fixed before the documentation.
./xxxuick-startPaths of this kind recur repeatedly.—— typical “loss/”mode, focused ondocs/en/usage.mdanddocs/en/reference.md../../../maintain,./../../en/usageAppear in large numbers—— The relative path hierarchy was written incorrectly + cross-language references did not use absolute paths.
After fixing each item according to the checklist in Section 6, run again.npm run docs:check-links It should pass.
