diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index e5e09978..5e4bc7f1 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -48,4 +48,17 @@ describe('node/markdown/plugins/link', () => { 'href="/foo.html?title=Cat&oldid=916388819#:~:text=Claws-,Like%20almost,the%20Felidae%2C,-cats"' ) }) + + test('records source line numbers for collected links', async () => { + const env: { + cleanUrls: boolean + links?: string[] + linkLines?: number[] + } = { cleanUrls: false } + + await md.renderAsync('Intro\n\n[Missing](./missing.md)\n', env) + + expect(env.links).toEqual(['./missing']) + expect(env.linkLines).toEqual([3]) + }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts new file mode 100644 index 00000000..2a139b1b --- /dev/null +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { resolveConfig } from 'node/config' +import { createMarkdownToVueRenderFn } from 'node/markdownToVue' + +describe('node/markdownToVue', () => { + let root: string | undefined + + afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + test('records source line numbers for dead links', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) + + const file = path.join(root, 'index.md') + const src = '# Home\n\nIntro\n\n[Missing](./missing.md)\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).toContainEqual({ + url: './missing', + file, + line: 5 + }) + }) + + test('records source line numbers after frontmatter', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-')) + + const file = path.join(root, 'index.md') + const src = + '---\ntitle: Home\n---\n# Home\n\nIntro\n\n[Missing](./missing.md)\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).toContainEqual({ + url: './missing', + file, + line: 8 + }) + }) +}) diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index e606351e..51e3dceb 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -19,6 +19,20 @@ 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, @@ -41,7 +55,7 @@ export const linkPlugin = ( }) // catch localhost links as dead link if (url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost:')) { - pushLink(url, env) + pushLink(url, env, token.meta?.vpLine) } hrefAttr[1] = url } else { @@ -58,7 +72,7 @@ export const linkPlugin = ( // skip links to files (other than html/md) treatAsHtml(pathname) ) { - normalizeHref(hrefAttr, env) + normalizeHref(hrefAttr, env, token.meta?.vpLine) } else if (url.startsWith('#')) { hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1])) } @@ -75,7 +89,11 @@ export const linkPlugin = ( return self.renderToken(tokens, idx, options) } - function normalizeHref(hrefAttr: [string, string], env: MarkdownEnv) { + function normalizeHref( + hrefAttr: [string, string], + env: MarkdownEnv, + line?: number + ) { let url = hrefAttr[1] const indexMatch = url.match(indexRE) @@ -106,7 +124,7 @@ export const linkPlugin = ( } // export it for existence check - pushLink(url.replace(/\.html$/, ''), env) + pushLink(url.replace(/\.html$/, ''), env, line) // markdown-it encodes the uri hrefAttr[1] = decodeURI(url) @@ -116,8 +134,12 @@ export const linkPlugin = ( return str ? encodeURI('#' + slugify(decodeURI(str).slice(1))) : '' } - function pushLink(link: string, env: MarkdownEnv) { + 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 + } } } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 4d7a48cf..6f433bfa 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -28,7 +28,7 @@ const cache = new LRUCache({ max: 1024 }) export interface MarkdownCompileResult { vueSrc: string pageData: PageData - deadLinks: { url: string; file: string }[] + deadLinks: { url: string; file: string; line?: number }[] includes: string[] } @@ -151,17 +151,24 @@ export async function createMarkdownToVueRenderFn( } const html = await md.renderAsync(src, env) const { + content, frontmatter = {}, headers = [], + linkLines = [], links = [], sfcBlocks, title = '' } = env + const contentLineOffset = countLineBreaks( + content && src.endsWith(content) ? src.slice(0, -content.length) : '' + ) // validate data.links const deadLinks: MarkdownCompileResult['deadLinks'] = [] - const recordDeadLink = (url: string) => { - deadLinks.push({ url, file: fileOrig }) + const recordDeadLink = (url: string, line?: number) => { + deadLinks.push( + line == null ? { url, file: fileOrig } : { url, file: fileOrig, line } + ) } function shouldIgnoreDeadLink(url: string) { @@ -185,7 +192,12 @@ export async function createMarkdownToVueRenderFn( if (links && siteConfig?.ignoreDeadLinks !== true) { const dir = path.dirname(file) - for (let url of links) { + for (const [index, rawUrl] of links.entries()) { + let url = rawUrl + const line = + linkLines[index] == null + ? undefined + : linkLines[index] + contentLineOffset const { pathname } = new URL(url, 'http://a.com') if (!treatAsHtml(pathname)) continue @@ -207,7 +219,7 @@ export async function createMarkdownToVueRenderFn( !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) && !shouldIgnoreDeadLink(url) ) { - recordDeadLink(url) + recordDeadLink(url, line) } } } @@ -332,6 +344,10 @@ const inferDescription = (frontmatter: Record) => { return (head && getHeadMetaContent(head, 'description')) || '' } +function countLineBreaks(str: string) { + return str.match(/\r?\n/g)?.length ?? 0 +} + const getHeadMetaContent = (head: HeadConfig[], name: string) => { if (!head || !head.length) { return undefined diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 0dabfabc..6739a75e 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -422,14 +422,15 @@ function logDeadLinks( devMode = false ) { const logged = new Set() - deadLinks.forEach(({ url, file }, i) => { - const key = `${file}:::${url}` + deadLinks.forEach(({ url, file, line }, i) => { + const location = line == null ? file : `${file}:${line}` + const key = `${location}:::${url}` if (logged.has(key)) return logged.add(key) const prefix = '\n'.repeat(i === 0 ? (devMode ? 1 : 2) : 0) logger.warn( c.yellow( - `${prefix}(!) Found dead link ${c.cyan(url)} in file ${c.white(c.dim(file))}` + `${prefix}(!) Found dead link ${c.cyan(url)} in file ${c.white(c.dim(location))}` ) ) }) diff --git a/types/shared.d.ts b/types/shared.d.ts index cf40ea71..bd736390 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -223,6 +223,7 @@ export interface MarkdownEnv { relativePath: string cleanUrls: boolean links?: string[] + linkLines?: number[] includes?: string[] realPath?: string localeIndex?: string