From 36f9a2185c8226d1a3122eac6dfbd0e278583c0f Mon Sep 17 00:00:00 2001 From: bluwy Date: Tue, 14 Jul 2026 11:48:35 +0800 Subject: [PATCH 1/3] fix: report raw url for dead links --- .../unit/node/markdown/plugins/link.test.ts | 6 ++-- __tests__/unit/node/markdownToVue.test.ts | 36 +++++++++++++++++-- src/node/markdown/plugins/link.ts | 23 +++++++----- src/node/markdownToVue.ts | 19 +++++----- types/shared.d.ts | 2 +- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 5e4bc7f199c2..6c9e027299d0 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -53,12 +53,12 @@ describe('node/markdown/plugins/link', () => { const env: { cleanUrls: boolean links?: string[] - linkLines?: number[] + linkMetadatas?: { rawLink: string; line?: number }[] } = { 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(['./missing.html']) + expect(env.linkMetadatas).toEqual([{ rawLink: './missing.md', line: 3 }]) }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 232496dfe05d..f881d3e68edb 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -14,6 +14,38 @@ describe('node/markdownToVue', () => { } }) + test('records link path as written for dead links', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-link-path-')) + + const file = path.join(root, 'index.md') + const src = [ + '[a](./a.md)', + '[b](./b#hash)', + '[c](./中文.md)', + '[d](/d)' + ].join('\n\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, 'public') + + expect(result.deadLinks).toEqual([ + { url: './a.md', file, line: 1 }, + { url: './b#hash', file, line: 3 }, + { url: './中文.md', file, line: 5 }, + { url: '/d', file, line: 7 } + ]) + }) + test('records source line numbers for dead links', async () => { root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) @@ -34,7 +66,7 @@ describe('node/markdownToVue', () => { const result = await render(src, file, 'public') expect(result.deadLinks).toContainEqual({ - url: './missing', + url: './missing.md', file, line: 5 }) @@ -61,7 +93,7 @@ describe('node/markdownToVue', () => { const result = await render(src, file, 'public') expect(result.deadLinks).toContainEqual({ - url: './missing', + url: './missing.md', file, line: 8 }) diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index 51e3dcebdb7d..7b5e4a16a260 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -47,6 +47,7 @@ export const linkPlugin = ( token.attrGet('class') !== 'header-anchor' // header anchors are already normalized ) { const hrefAttr = token.attrs![hrefIndex] + const rawUrl = decodeURI(hrefAttr[1]) let [url, frag] = hrefAttr[1].split(':~:', 2) hrefAttr[1] = url if (isExternal(url)) { @@ -55,7 +56,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, rawUrl, env, token.meta?.vpLine) } hrefAttr[1] = url } else { @@ -95,6 +96,7 @@ export const linkPlugin = ( line?: number ) { let url = hrefAttr[1] + const rawUrl = decodeURI(url) const indexMatch = url.match(indexRE) if (indexMatch) { @@ -124,7 +126,7 @@ export const linkPlugin = ( } // export it for existence check - pushLink(url.replace(/\.html$/, ''), env, line) + pushLink(url, rawUrl, env, line) // markdown-it encodes the uri hrefAttr[1] = decodeURI(url) @@ -134,12 +136,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( + link: string, + rawLink: string, + env: MarkdownEnv, + line?: number + ) { + env.links ??= [] + env.links.push(link) + env.linkMetadatas ??= [] + env.linkMetadatas.push({ rawLink, line }) } } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 6f744acc6a80..ce03fae6f854 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -158,8 +158,8 @@ export async function createMarkdownToVueRenderFn( content, frontmatter = {}, headers = [], - linkLines = [], links = [], + linkMetadatas = [], sfcBlocks, title = '' } = env @@ -198,22 +198,19 @@ export async function createMarkdownToVueRenderFn( const dir = path.dirname(file) for (const [index, rawUrl] of links.entries()) { let url = rawUrl + const metadata = linkMetadatas[index] const line = - linkLines[index] == null - ? undefined - : linkLines[index] + contentLineOffset + metadata?.line != null ? metadata.line + contentLineOffset : undefined const { pathname } = new URL(url, 'http://a.com') if (!treatAsHtml(pathname)) continue url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '') if (url.endsWith('/')) url += `index` - let resolved = decodeURIComponent( - slash( - url.startsWith('/') - ? url.slice(1) - : path.relative(srcDir, path.resolve(dir, url)) - ) + let resolved = slash( + url.startsWith('/') + ? url.slice(1) + : path.relative(srcDir, path.resolve(dir, url)) ) resolved = siteConfig?.rewrites.inv[resolved + '.md']?.slice(0, -3) || resolved @@ -223,7 +220,7 @@ export async function createMarkdownToVueRenderFn( !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) && !shouldIgnoreDeadLink(url) ) { - recordDeadLink(url, line) + recordDeadLink(metadata.rawLink, line) } } } diff --git a/types/shared.d.ts b/types/shared.d.ts index d049f2e6a478..95a32ec9ce0c 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -259,7 +259,7 @@ export interface MarkdownEnv { relativePath: string cleanUrls: boolean links?: string[] - linkLines?: number[] + linkMetadatas?: { rawLink: string; line?: number }[] includes?: string[] realPath?: string localeIndex?: string From 085ae48a791f8931bec0cbd88b4e5b0bb4061a2d Mon Sep 17 00:00:00 2001 From: bluwy Date: Fri, 7 Aug 2026 12:25:40 +0800 Subject: [PATCH 2/3] chore: update jsdoc --- types/shared.d.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/types/shared.d.ts b/types/shared.d.ts index c4be255d0dda..95c77394bdc5 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -591,7 +591,9 @@ export interface MarkdownEnv { */ links?: string[] /** - * The line numbers at which each of `links` appears in the source. + * The metadata of the links collected from the page. + * - `rawLink`: The url as written + * - `line`: The line number at which the link appears in the source. */ linkMetadatas?: { rawLink: string; line?: number }[] /** From a996410722923231608742026d8cb0af56afd7e5 Mon Sep 17 00:00:00 2001 From: bluwy Date: Fri, 7 Aug 2026 14:20:30 +0800 Subject: [PATCH 3/3] chore: fix test type --- __tests__/unit/node/markdownToVue.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 7ae3b8a7e7ea..b336db00ca5e 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -36,7 +36,7 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render(src, file, 'public') + const result = await render(src, file) expect(result.deadLinks).toEqual([ { url: './a.md', file, line: 1 },