feat(build)!: report dead links with exact file, line and column

env.links becomes an array of { url, raw, loc } objects: the normalized
href, the destination as authored (decoded), and the exact position it was
authored at — including inside `<!-- @include -->`-ed files, which
previously reported the including page with a line in the expanded text.
Reports print the URL as written plus the resolved page path
(`(resolves to /x) in file.md:12:5`), fixing #4992 and #3774's halves of
the same complaint, and table-cell links (which had no line at all) and
links past the first line of a paragraph now carry exact positions.

Carries over the raw-URL reporting and test matrix from #5316.

BREAKING CHANGE: `env.links` entries are objects instead of strings and
`env.linkLines` is gone; `ignoreDeadLinks` strings, regexes and filter
functions now match the link as authored instead of the normalized encoded
URL, and filter functions receive a `{ file, line, column, url }` context
object instead of the source path string; `MarkdownCompileResult.deadLinks`
entries gained `resolved`/`column` and their `url` is now the authored
form. The markdown-it rule names `github-alerts`, `snippet` and
`vitepress_link_lines` are renamed/replaced by `vp_`-prefixed rules.

Co-authored-by: Bjorn Lu <34116392+bluwy@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/md-sourcemaps
Divyansh Singh 2 weeks ago
parent 62dcdf0806
commit ddb45d0484

@ -654,7 +654,7 @@ describe('node/markdown/plugins/include', () => {
// meant there // meant there
expect(html).toContain('src="./assets/a.png"') expect(html).toContain('src="./assets/a.png"')
expect(html).toContain('href="./other.html"') expect(html).toContain('href="./other.html"')
expect(env.links).toContain('./other') expect(env.links!.map((l) => l.url)).toContain('./other.html')
// urls authored in the included file still rebase // urls authored in the included file still rebase
expect(html).toContain('src="./shared/local.png"') expect(html).toContain('src="./shared/local.png"')
}) })

@ -1,6 +1,7 @@
import { slugify } from '@mdit-vue/shared' import { slugify } from '@mdit-vue/shared'
import { MarkdownItAsync } from 'markdown-it-async' import { MarkdownItAsync } from 'markdown-it-async'
import { linkPlugin } from 'node/markdown/plugins/link' import { linkPlugin } from 'node/markdown/plugins/link'
import type { MarkdownLink } from 'node/shared'
describe('node/markdown/plugins/link', () => { describe('node/markdown/plugins/link', () => {
const md = new MarkdownItAsync() const md = new MarkdownItAsync()
@ -49,17 +50,14 @@ describe('node/markdown/plugins/link', () => {
) )
}) })
test('records source line numbers for collected links', async () => { test('collects links with their destination as authored', async () => {
const env: { const env: { cleanUrls: boolean; links?: MarkdownLink[] } = {
cleanUrls: boolean cleanUrls: false
links?: string[] }
linkLines?: number[]
} = { cleanUrls: false }
await md.renderAsync('Intro\n\n[Missing](./missing.md)\n', env) await md.renderAsync('Intro\n\n[Missing](./missing.md)\n', env)
expect(env.links).toEqual(['./missing']) expect(env.links).toEqual([{ url: './missing.html', raw: './missing.md' }])
expect(env.linkLines).toEqual([3])
}) })
}) })

@ -36,9 +36,11 @@ describe('node/markdownToVue', () => {
const result = await render(src, file) const result = await render(src, file)
expect(result.deadLinks).toContainEqual({ expect(result.deadLinks).toContainEqual({
url: './missing', url: './missing.md',
resolved: '/missing',
file, file,
line: 5 line: 5,
column: 1
}) })
}) })
@ -63,12 +65,108 @@ describe('node/markdownToVue', () => {
const result = await render(src, file) const result = await render(src, file)
expect(result.deadLinks).toContainEqual({ expect(result.deadLinks).toContainEqual({
url: './missing', url: './missing.md',
resolved: '/missing',
file, file,
line: 8 line: 8,
column: 1
}) })
}) })
test('reports dead links inside included files at their real location', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-'))
const file = path.join(root, 'index.md')
const partial = path.join(root, 'part.md')
await writeFile(
partial,
'---\nt: 1\n---\nSome text\n[x](./nope)\nMore text\n'
)
const src =
'---\ntitle: x\n---\n\n# Guide\n\n<!-- @include: ./part.md -->\n\npara [a](./a)\nand [b](./b)\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(src, file)
expect(result.deadLinks).toEqual([
{ url: './nope', resolved: '/nope', file: partial, line: 5, column: 1 },
{ url: './a', resolved: '/a', file, line: 9, column: 6 },
{ url: './b', resolved: '/b', file, line: 10, column: 5 }
])
})
test('reports the URL as authored alongside the resolved path', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-'))
const file = path.join(root, 'index.md')
const src = '[a](./a.md)\n\n[b](./b#hash)\n\n[c](./中文.md)\n\n[d](/d)\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(src, file)
expect(
result.deadLinks.map(({ url, resolved }) => ({ url, resolved }))
).toEqual([
{ url: './a.md', resolved: '/a' },
{ url: './b#hash', resolved: '/b' },
{ url: './中文.md', resolved: '/中文' },
{ url: '/d', resolved: '/d' }
])
})
test('passes the authored link and its context to ignoreDeadLinks filters', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-'))
const file = path.join(root, 'index.md')
const src = '[s](./skip.md)\nand [k](./keep.md)\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const calls: unknown[] = []
siteConfig.ignoreDeadLinks = [
(link, context) => {
calls.push([link, context])
return link === './skip.md'
}
]
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false },
'/',
false,
false,
siteConfig
)
const result = await render(src, file)
expect(calls).toEqual([
['./skip.md', { file, line: 1, column: 1, url: '/skip' }],
['./keep.md', { file, line: 2, column: 5, url: '/keep' }]
])
expect(result.deadLinks.map((l) => l.url)).toEqual(['./keep.md'])
})
test('selects included heading sections after frontmatter', async () => { test('selects included heading sections after frontmatter', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-')) root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-'))

@ -520,7 +520,7 @@ export default {
### ignoreDeadLinks ### ignoreDeadLinks
- Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, source: string) => boolean))[]` - Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, context: DeadLinkContext) => boolean))[]`
- Default: `false` - Default: `false`
When set to `true`, VitePress will not fail builds due to dead links. When set to `true`, VitePress will not fail builds due to dead links.
@ -533,20 +533,33 @@ export default {
} }
``` ```
It can also be an array of exact url string, regex patterns, or custom filter functions. It can also be an array of exact url strings, regex patterns, or custom filter functions. These match the link **as authored in the source**, decoded — for example, a link written as `[docs](./guide/index.md)` is matched as `./guide/index.md`.
```ts ```ts
export default { export default {
ignoreDeadLinks: [ ignoreDeadLinks: [
// ignore exact url "/playground" // ignore links written exactly as "/playground"
'/playground', '/playground',
// ignore all localhost links // ignore all localhost links
/^https?:\/\/localhost/, /^https?:\/\/localhost/,
// ignore all links include "/repl/"" // ignore all links including "/repl/"
/\/repl\//, /\/repl\//,
// custom function, ignore all links include "ignore" // custom function, ignore all links including "ignore"
(url) => { (link) => {
return url.toLowerCase().includes('ignore') return link.toLowerCase().includes('ignore')
}
]
}
```
Filter functions also receive the link's context — the absolute path of the file it was authored in (for links inside [included markdown](../guide/markdown#markdown-file-inclusion), the included file itself), its position, and the URL the check resolved:
```ts
export default {
ignoreDeadLinks: [
(link, context) => {
// context: { file: string; line?: number; column?: number; url: string }
return context.file.includes('/generated/')
} }
] ]
} }

@ -195,7 +195,7 @@ export const gitHubAlertsPlugin = (
) => { ) => {
const titles = resolveTitlesByLocale(options, locales) const titles = resolveTitlesByLocale(options, locales)
md.core.ruler.after('block', 'github-alerts', (state) => { md.core.ruler.after('block', 'vp_github_alerts', (state) => {
const tokens = state.tokens const tokens = state.tokens
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'blockquote_open') { if (tokens[i].type === 'blockquote_open') {

@ -1,10 +1,12 @@
// markdown-it plugin for: // markdown-it plugin for:
// 1. adding target="_blank" to external links // 1. adding target="_blank" to external links
// 2. normalize internal links to end with `.html` // 2. normalize internal links to end with `.html`
// 3. collecting links, with their source positions, for the dead link check
import { URL } from 'node:url' import { URL } from 'node:url'
import type { MarkdownItAsync } from 'markdown-it-async' import type { MarkdownItAsync } from 'markdown-it-async'
import type Token from 'markdown-it/lib/token.mjs'
import { import {
EXTERNAL_URL_RE, EXTERNAL_URL_RE,
@ -24,20 +26,6 @@ export const linkPlugin = (
base: string, base: string,
slugify: (str: string) => string slugify: (str: string) => string
) => { ) => {
md.core.ruler.after('inline', 'vitepress_link_lines', (state) => {
for (const token of state.tokens) {
if (token.type !== 'inline' || !token.children || !token.map) continue
const line = token.map[0] + 1
for (const child of token.children) {
if (child.type === 'link_open') {
child.meta ??= {}
child.meta.vpLine = line
}
}
}
})
md.renderer.rules.link_open = ( md.renderer.rules.link_open = (
tokens, tokens,
idx, idx,
@ -52,6 +40,10 @@ export const linkPlugin = (
token.attrGet('class') !== 'header-anchor' // header anchors are already normalized token.attrGet('class') !== 'header-anchor' // header anchors are already normalized
) { ) {
const hrefAttr = token.attrs![hrefIndex] const hrefAttr = token.attrs![hrefIndex]
// the destination as authored, for dead link reporting - the source
// positions plugin captures it before include rebasing runs; fall back
// to the current href for tokens it did not see
const raw: string = token.meta?.vpRaw ?? safeDecodeURI(hrefAttr[1])
let [url, frag] = hrefAttr[1].split(':~:', 2) let [url, frag] = hrefAttr[1].split(':~:', 2)
hrefAttr[1] = url hrefAttr[1] = url
if (isExternal(url)) { if (isExternal(url)) {
@ -60,7 +52,7 @@ export const linkPlugin = (
}) })
// catch localhost links as dead link // catch localhost links as dead link
if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) { if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) {
pushLink(url, env, token.meta?.vpLine) pushLink(url, raw, env, token)
} }
hrefAttr[1] = url hrefAttr[1] = url
} else { } else {
@ -77,7 +69,7 @@ export const linkPlugin = (
// skip links to files (other than html/md) // skip links to files (other than html/md)
treatAsHtml(pathname) treatAsHtml(pathname)
) { ) {
normalizeHref(hrefAttr, env, token.meta?.vpLine) normalizeHref(hrefAttr, env, raw, token)
} else if (url.startsWith('#')) { } else if (url.startsWith('#')) {
hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1])) hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1]))
} }
@ -105,7 +97,8 @@ export const linkPlugin = (
function normalizeHref( function normalizeHref(
hrefAttr: [string, string], hrefAttr: [string, string],
env: MarkdownEnv, env: MarkdownEnv,
line?: number raw: string,
token: Token
) { ) {
let url = hrefAttr[1] let url = hrefAttr[1]
@ -143,7 +136,7 @@ export const linkPlugin = (
} }
// export it for existence check // export it for existence check
pushLink(url.replace(/\.html$/, ''), env, line) pushLink(url, raw, env, token)
// markdown-it encodes the uri // markdown-it encodes the uri
hrefAttr[1] = decodeURI(url) hrefAttr[1] = decodeURI(url)
@ -153,12 +146,15 @@ export const linkPlugin = (
return str ? encodeURI('#' + slugify(decodeURI(str).slice(1))) : '' return str ? encodeURI('#' + slugify(decodeURI(str).slice(1))) : ''
} }
function pushLink(link: string, env: MarkdownEnv, line?: number) { function pushLink(url: string, raw: string, env: MarkdownEnv, token: Token) {
const links = env.links || (env.links = []) ;(env.links ??= []).push({ url, raw, loc: token.meta?.vpLoc })
links.push(link)
if (line != null) {
const linkLines = env.linkLines || (env.linkLines = [])
linkLines[links.length - 1] = line
} }
}
function safeDecodeURI(str: string): string {
try {
return decodeURI(str)
} catch {
return str
} }
} }

@ -46,7 +46,7 @@ export function snippetPlugin(
options: Options = {}, options: Options = {},
logger: Pick<Logger, 'warn'> = console logger: Pick<Logger, 'warn'> = console
) { ) {
md.block.ruler.before('fence', 'snippet', createSnippetParser(srcDir)) md.block.ruler.before('fence', 'vp_snippet', createSnippetParser(srcDir))
const renderFence = md.renderer.rules.fence! const renderFence = md.renderer.rules.fence!
md.renderer.rules.fence = createSnippetRenderer(renderFence, options, logger) md.renderer.rules.fence = createSnippetRenderer(renderFence, options, logger)
} }

@ -22,8 +22,10 @@ import {
treatAsHtml, treatAsHtml,
type HeadConfig, type HeadConfig,
type MarkdownEnv, type MarkdownEnv,
type MarkdownLink,
type PageData type PageData
} from './shared' } from './shared'
import type { DeadLinkContext } from './siteConfig'
import { getGitTimestamp } from './utils/getGitTimestamp' import { getGitTimestamp } from './utils/getGitTimestamp'
const debug = createDebug('vitepress:md') const debug = createDebug('vitepress:md')
@ -49,10 +51,22 @@ let __ts: number
export interface MarkdownCompileResult { export interface MarkdownCompileResult {
vueSrc: string vueSrc: string
pageData: PageData pageData: PageData
deadLinks: { url: string; file: string; line?: number }[] deadLinks: DeadLink[]
includes: string[] includes: string[]
} }
export interface DeadLink {
/** the URL as authored in the source, decoded */
url: string
/** the site page path it resolved to, for internal links */
resolved?: string
/** absolute path of the file the link was authored in */
file: string
/** 1-based position in `file`, when known */
line?: number
column?: number
}
export function clearCache(relativePath?: string) { export function clearCache(relativePath?: string) {
if (!relativePath) { if (!relativePath) {
cache.clear() cache.clear()
@ -177,7 +191,6 @@ export async function createMarkdownToVueRenderFn(
frontmatter = {}, frontmatter = {},
headers = [], headers = [],
includes = [], includes = [],
linkLines = [],
links = [], links = [],
sfcBlocks, sfcBlocks,
title = '' title = ''
@ -185,13 +198,8 @@ export async function createMarkdownToVueRenderFn(
// validate data.links // validate data.links
const deadLinks: MarkdownCompileResult['deadLinks'] = [] const deadLinks: MarkdownCompileResult['deadLinks'] = []
const recordDeadLink = (url: string, line?: number) => {
deadLinks.push(
line == null ? { url, file: fileOrig } : { url, file: fileOrig, line }
)
}
function shouldIgnoreDeadLink(url: string) { function shouldIgnoreDeadLink(link: MarkdownLink, resolved: string) {
if (!siteConfig?.ignoreDeadLinks) { if (!siteConfig?.ignoreDeadLinks) {
return false return false
} }
@ -199,22 +207,27 @@ export async function createMarkdownToVueRenderFn(
return true return true
} }
if (siteConfig.ignoreDeadLinks === 'localhostLinks') { if (siteConfig.ignoreDeadLinks === 'localhostLinks') {
return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') return link.url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost')
} }
const context: DeadLinkContext = {
file: link.loc?.file ?? fileOrig,
line: link.loc?.line,
column: link.loc?.column,
url: resolved
}
return siteConfig.ignoreDeadLinks.some((ignore) => { return siteConfig.ignoreDeadLinks.some((ignore) => {
if (typeof ignore === 'string') return url === ignore if (typeof ignore === 'string') return link.raw === ignore
if (ignore instanceof RegExp) return ignore.test(url) if (ignore instanceof RegExp) return ignore.test(link.raw)
if (typeof ignore === 'function') return ignore(url, fileOrig) if (typeof ignore === 'function') return ignore(link.raw, context)
return false return false
}) })
} }
if (links && siteConfig?.ignoreDeadLinks !== true) { if (links && siteConfig?.ignoreDeadLinks !== true) {
const dir = path.dirname(file) const dir = path.dirname(file)
for (const [index, rawUrl] of links.entries()) { for (const link of links) {
let url = rawUrl let url = link.url
const line = linkLines[index] == null ? undefined : linkLines[index]
const { pathname } = new URL(url, 'http://a.com') const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue if (!treatAsHtml(pathname)) continue
@ -237,6 +250,10 @@ export async function createMarkdownToVueRenderFn(
? undefined ? undefined
: siteConfig?.rewrites.map[resolved + '.md'] : siteConfig?.rewrites.map[resolved + '.md']
const resolvedPath = EXTERNAL_URL_RE.test(link.url)
? undefined
: '/' + resolved
if ( if (
(!pages.includes(resolved) || (!pages.includes(resolved) ||
(rewritten != null && rewritten !== resolved + '.md')) && (rewritten != null && rewritten !== resolved + '.md')) &&
@ -244,9 +261,16 @@ export async function createMarkdownToVueRenderFn(
siteConfig?.publicDir && siteConfig?.publicDir &&
fs.existsSync(path.join(siteConfig.publicDir, `${resolved}.html`)) fs.existsSync(path.join(siteConfig.publicDir, `${resolved}.html`))
) && ) &&
!shouldIgnoreDeadLink(url) !shouldIgnoreDeadLink(link, resolvedPath ?? link.url)
) { ) {
recordDeadLink(url, line) const { loc } = link
deadLinks.push({
url: link.raw,
...(resolvedPath != null && { resolved: resolvedPath }),
file: loc?.file ?? fileOrig,
...(loc != null && { line: loc.line }),
...(loc?.column != null && { column: loc.column })
})
} }
} }
} }

@ -477,15 +477,20 @@ function logDeadLinks(
devMode = false devMode = false
) { ) {
const logged = new Set<string>() const logged = new Set<string>()
deadLinks.forEach(({ url, file, line }, i) => { deadLinks.forEach(({ url, resolved, file, line, column }, i) => {
const location = line == null ? file : `${file}:${line}` const location =
line == null
? file
: `${file}:${line}${column == null ? '' : `:${column}`}`
const key = `${location}:::${url}` const key = `${location}:::${url}`
if (logged.has(key)) return if (logged.has(key)) return
logged.add(key) logged.add(key)
const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0) const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0)
const target =
resolved && resolved !== url ? ` (resolves to ${c.cyan(resolved)})` : ''
logger.warn( logger.warn(
c.yellow( c.yellow(
`${prefix}(!) Found dead link ${c.cyan(url)} in file ${c.white(c.dim(location))}` `${prefix}(!) Found dead link ${c.cyan(url)}${target} in file ${c.white(c.dim(location))}`
) )
) )
}) })

@ -82,6 +82,28 @@ export interface TransformPageContext<ThemeConfig = any> {
/** /**
* VitePress config, usually defined in `.vitepress/config.[ext]`. * VitePress config, usually defined in `.vitepress/config.[ext]`.
*/ */
/**
* Where and how a checked link was authored, passed to `ignoreDeadLinks`
* filter functions.
*/
export interface DeadLinkContext {
/**
* Absolute path of the file the link was authored in for links inside
* `<!--@include-->`-ed content, the included file.
*/
file: string
/**
* 1-based position in `file`, when known.
*/
line?: number
column?: number
/**
* The URL the dead link check resolved: the site page path for internal
* links, the normalized URL otherwise.
*/
url: string
}
export interface UserConfig< export interface UserConfig<
ThemeConfig = any ThemeConfig = any
> extends LocaleSpecificConfig<ThemeConfig> { > extends LocaleSpecificConfig<ThemeConfig> {
@ -234,12 +256,19 @@ export interface UserConfig<
* Don't fail builds due to dead links. Accepts `true` (ignore all), * Don't fail builds due to dead links. Accepts `true` (ignore all),
* `'localhostLinks'` (only ignore localhost links), or an array of * `'localhostLinks'` (only ignore localhost links), or an array of
* exact strings, regexes, and custom filter functions. * exact strings, regexes, and custom filter functions.
*
* Strings, regexes and filter functions match the link as authored in the
* source, decoded. Filter functions also receive the link's context: the
* file it was authored in (for links inside `<!--@include-->`-ed content,
* the included file), its position, and the URL the check resolved.
* @default false * @default false
*/ */
ignoreDeadLinks?: ignoreDeadLinks?:
| boolean | boolean
| 'localhostLinks' | 'localhostLinks'
| (string | RegExp | ((link: string, source: string) => boolean))[] | (
string | RegExp | ((link: string, context: DeadLinkContext) => boolean)
)[]
/** /**
* Generate `/foo` instead of `/foo.html` for pages and internal * Generate `/foo` instead of `/foo.html` for pages and internal
* links. Requires matching support from the hosting platform. * links. Requires matching support from the hosting platform.

27
types/shared.d.ts vendored

@ -590,13 +590,9 @@ export interface MarkdownEnv {
*/ */
relativizeUrls?: boolean relativizeUrls?: boolean
/** /**
* The URLs of the links collected from the page for the dead link check. * The links collected from the page for the dead link check.
*/ */
links?: string[] links?: MarkdownLink[]
/**
* The line numbers at which each of `links` appears in the source.
*/
linkLines?: number[]
/** /**
* The absolute paths of the files inlined via `<!--@include-->` and * The absolute paths of the files inlined via `<!--@include-->` and
* imported via `<<<` code snippets, used for watch invalidation. * imported via `<<<` code snippets, used for watch invalidation.
@ -632,6 +628,25 @@ export interface MarkdownEnv {
eagerInterpolations?: { expression: string; value: string }[] eagerInterpolations?: { expression: string; value: string }[]
} }
/**
* A link collected while rendering markdown.
*/
export interface MarkdownLink {
/**
* The normalized URL the link renders with, used to resolve the target
* page for the dead link check.
*/
url: string
/**
* The destination as authored in the source, decoded.
*/
raw: string
/**
* Where the link was authored, when known.
*/
loc?: MarkdownSourceLoc
}
/** /**
* A position in a source file, in editor coordinates. * A position in a source file, in editor coordinates.
*/ */

Loading…
Cancel
Save