diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index fadfa216..772106b0 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -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"') }) diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 119132c3..7845411f 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -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' }]) }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index f0f12bb1..bcdafcf4 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -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\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-')) diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index 7f214127..f435c2b3 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -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/') } ] } diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 88c7c87b..125e223c 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -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') { diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index e9c52105..593b909d 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -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 } } diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index 3fe32cc4..edfd397c 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -46,7 +46,7 @@ export function snippetPlugin( options: Options = {}, logger: Pick = 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) } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index ceb67235..26a1afdd 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -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 }) + }) } } } diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 886bae0d..9592597b 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -477,15 +477,20 @@ function logDeadLinks( devMode = false ) { const logged = new Set() - 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))}` ) ) }) diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 2b4d1eb4..418121ae 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -82,6 +82,28 @@ export interface TransformPageContext { /** * 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 + * ``-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 { @@ -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 ``-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. diff --git a/types/shared.d.ts b/types/shared.d.ts index 028238c9..75af49bc 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -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 `` 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. */