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
expect(html).toContain('src="./assets/a.png"')
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
expect(html).toContain('src="./shared/local.png"')
})

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

@ -36,9 +36,11 @@ describe('node/markdownToVue', () => {
const result = await render(src, file)
expect(result.deadLinks).toContainEqual({
url: './missing',
url: './missing.md',
resolved: '/missing',
file,
line: 5
line: 5,
column: 1
})
})
@ -63,12 +65,108 @@ describe('node/markdownToVue', () => {
const result = await render(src, file)
expect(result.deadLinks).toContainEqual({
url: './missing',
url: './missing.md',
resolved: '/missing',
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 () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-'))

@ -520,7 +520,7 @@ export default {
### ignoreDeadLinks
- Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, source: string) => boolean))[]`
- Type: `boolean | 'localhostLinks' | (string | RegExp | ((link: string, context: DeadLinkContext) => boolean))[]`
- Default: `false`
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
export default {
ignoreDeadLinks: [
// ignore exact url "/playground"
// ignore links written exactly as "/playground"
'/playground',
// ignore all localhost links
/^https?:\/\/localhost/,
// ignore all links include "/repl/""
// ignore all links including "/repl/"
/\/repl\//,
// custom function, ignore all links include "ignore"
(url) => {
return url.toLowerCase().includes('ignore')
// custom function, ignore all links including "ignore"
(link) => {
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)
md.core.ruler.after('block', 'github-alerts', (state) => {
md.core.ruler.after('block', 'vp_github_alerts', (state) => {
const tokens = state.tokens
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'blockquote_open') {

@ -1,10 +1,12 @@
// markdown-it plugin for:
// 1. adding target="_blank" to external links
// 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 type { MarkdownItAsync } from 'markdown-it-async'
import type Token from 'markdown-it/lib/token.mjs'
import {
EXTERNAL_URL_RE,
@ -24,20 +26,6 @@ export const linkPlugin = (
base: 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 = (
tokens,
idx,
@ -52,6 +40,10 @@ export const linkPlugin = (
token.attrGet('class') !== 'header-anchor' // header anchors are already normalized
) {
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)
hrefAttr[1] = url
if (isExternal(url)) {
@ -60,7 +52,7 @@ export const linkPlugin = (
})
// catch localhost links as dead link
if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) {
pushLink(url, env, token.meta?.vpLine)
pushLink(url, raw, env, token)
}
hrefAttr[1] = url
} else {
@ -77,7 +69,7 @@ export const linkPlugin = (
// skip links to files (other than html/md)
treatAsHtml(pathname)
) {
normalizeHref(hrefAttr, env, token.meta?.vpLine)
normalizeHref(hrefAttr, env, raw, token)
} else if (url.startsWith('#')) {
hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1]))
}
@ -105,7 +97,8 @@ export const linkPlugin = (
function normalizeHref(
hrefAttr: [string, string],
env: MarkdownEnv,
line?: number
raw: string,
token: Token
) {
let url = hrefAttr[1]
@ -143,7 +136,7 @@ export const linkPlugin = (
}
// export it for existence check
pushLink(url.replace(/\.html$/, ''), env, line)
pushLink(url, raw, env, token)
// markdown-it encodes the uri
hrefAttr[1] = decodeURI(url)
@ -153,12 +146,15 @@ export const linkPlugin = (
return str ? encodeURI('#' + slugify(decodeURI(str).slice(1))) : ''
}
function pushLink(link: string, env: MarkdownEnv, line?: number) {
const links = env.links || (env.links = [])
links.push(link)
if (line != null) {
const linkLines = env.linkLines || (env.linkLines = [])
linkLines[links.length - 1] = line
}
function pushLink(url: string, raw: string, env: MarkdownEnv, token: Token) {
;(env.links ??= []).push({ url, raw, loc: token.meta?.vpLoc })
}
}
function safeDecodeURI(str: string): string {
try {
return decodeURI(str)
} catch {
return str
}
}

@ -46,7 +46,7 @@ export function snippetPlugin(
options: Options = {},
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!
md.renderer.rules.fence = createSnippetRenderer(renderFence, options, logger)
}

@ -22,8 +22,10 @@ import {
treatAsHtml,
type HeadConfig,
type MarkdownEnv,
type MarkdownLink,
type PageData
} from './shared'
import type { DeadLinkContext } from './siteConfig'
import { getGitTimestamp } from './utils/getGitTimestamp'
const debug = createDebug('vitepress:md')
@ -49,10 +51,22 @@ let __ts: number
export interface MarkdownCompileResult {
vueSrc: string
pageData: PageData
deadLinks: { url: string; file: string; line?: number }[]
deadLinks: DeadLink[]
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) {
if (!relativePath) {
cache.clear()
@ -177,7 +191,6 @@ export async function createMarkdownToVueRenderFn(
frontmatter = {},
headers = [],
includes = [],
linkLines = [],
links = [],
sfcBlocks,
title = ''
@ -185,13 +198,8 @@ export async function createMarkdownToVueRenderFn(
// validate data.links
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) {
return false
}
@ -199,22 +207,27 @@ export async function createMarkdownToVueRenderFn(
return true
}
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) => {
if (typeof ignore === 'string') return url === ignore
if (ignore instanceof RegExp) return ignore.test(url)
if (typeof ignore === 'function') return ignore(url, fileOrig)
if (typeof ignore === 'string') return link.raw === ignore
if (ignore instanceof RegExp) return ignore.test(link.raw)
if (typeof ignore === 'function') return ignore(link.raw, context)
return false
})
}
if (links && siteConfig?.ignoreDeadLinks !== true) {
const dir = path.dirname(file)
for (const [index, rawUrl] of links.entries()) {
let url = rawUrl
const line = linkLines[index] == null ? undefined : linkLines[index]
for (const link of links) {
let url = link.url
const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue
@ -237,6 +250,10 @@ export async function createMarkdownToVueRenderFn(
? undefined
: siteConfig?.rewrites.map[resolved + '.md']
const resolvedPath = EXTERNAL_URL_RE.test(link.url)
? undefined
: '/' + resolved
if (
(!pages.includes(resolved) ||
(rewritten != null && rewritten !== resolved + '.md')) &&
@ -244,9 +261,16 @@ export async function createMarkdownToVueRenderFn(
siteConfig?.publicDir &&
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
) {
const logged = new Set<string>()
deadLinks.forEach(({ url, file, line }, i) => {
const location = line == null ? file : `${file}:${line}`
deadLinks.forEach(({ url, resolved, file, line, column }, i) => {
const location =
line == null
? file
: `${file}:${line}${column == null ? '' : `:${column}`}`
const key = `${location}:::${url}`
if (logged.has(key)) return
logged.add(key)
const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0)
const target =
resolved && resolved !== url ? ` (resolves to ${c.cyan(resolved)})` : ''
logger.warn(
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]`.
*/
/**
* 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<
ThemeConfig = any
> extends LocaleSpecificConfig<ThemeConfig> {
@ -234,12 +256,19 @@ export interface UserConfig<
* Don't fail builds due to dead links. Accepts `true` (ignore all),
* `'localhostLinks'` (only ignore localhost links), or an array of
* 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
*/
ignoreDeadLinks?:
| boolean
| '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
* links. Requires matching support from the hosting platform.

27
types/shared.d.ts vendored

@ -590,13 +590,9 @@ export interface MarkdownEnv {
*/
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[]
/**
* The line numbers at which each of `links` appears in the source.
*/
linkLines?: number[]
links?: MarkdownLink[]
/**
* The absolute paths of the files inlined via `<!--@include-->` and
* imported via `<<<` code snippets, used for watch invalidation.
@ -632,6 +628,25 @@ export interface MarkdownEnv {
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.
*/

Loading…
Cancel
Save