fix(build): show dead link line numbers (#5230)

pull/5235/head
T 2 months ago committed by GitHub
parent b2cc1e0d69
commit c37bde6308
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -48,4 +48,17 @@ describe('node/markdown/plugins/link', () => {
'href="/foo.html?title=Cat&oldid=916388819#:~:text=Claws-,Like%20almost,the%20Felidae%2C,-cats"' '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])
})
}) })

@ -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
})
})
})

@ -19,6 +19,20 @@ 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,
@ -41,7 +55,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) pushLink(url, env, token.meta?.vpLine)
} }
hrefAttr[1] = url hrefAttr[1] = url
} else { } else {
@ -58,7 +72,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) normalizeHref(hrefAttr, env, token.meta?.vpLine)
} else if (url.startsWith('#')) { } else if (url.startsWith('#')) {
hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1])) hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1]))
} }
@ -75,7 +89,11 @@ export const linkPlugin = (
return self.renderToken(tokens, idx, options) 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] let url = hrefAttr[1]
const indexMatch = url.match(indexRE) const indexMatch = url.match(indexRE)
@ -106,7 +124,7 @@ export const linkPlugin = (
} }
// export it for existence check // export it for existence check
pushLink(url.replace(/\.html$/, ''), env) pushLink(url.replace(/\.html$/, ''), env, line)
// markdown-it encodes the uri // markdown-it encodes the uri
hrefAttr[1] = decodeURI(url) hrefAttr[1] = decodeURI(url)
@ -116,8 +134,12 @@ 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) { function pushLink(link: string, env: MarkdownEnv, line?: number) {
const links = env.links || (env.links = []) const links = env.links || (env.links = [])
links.push(link) links.push(link)
if (line != null) {
const linkLines = env.linkLines || (env.linkLines = [])
linkLines[links.length - 1] = line
}
} }
} }

@ -28,7 +28,7 @@ const cache = new LRUCache<string, MarkdownCompileResult>({ max: 1024 })
export interface MarkdownCompileResult { export interface MarkdownCompileResult {
vueSrc: string vueSrc: string
pageData: PageData pageData: PageData
deadLinks: { url: string; file: string }[] deadLinks: { url: string; file: string; line?: number }[]
includes: string[] includes: string[]
} }
@ -151,17 +151,24 @@ export async function createMarkdownToVueRenderFn(
} }
const html = await md.renderAsync(src, env) const html = await md.renderAsync(src, env)
const { const {
content,
frontmatter = {}, frontmatter = {},
headers = [], headers = [],
linkLines = [],
links = [], links = [],
sfcBlocks, sfcBlocks,
title = '' title = ''
} = env } = env
const contentLineOffset = countLineBreaks(
content && src.endsWith(content) ? src.slice(0, -content.length) : ''
)
// validate data.links // validate data.links
const deadLinks: MarkdownCompileResult['deadLinks'] = [] const deadLinks: MarkdownCompileResult['deadLinks'] = []
const recordDeadLink = (url: string) => { const recordDeadLink = (url: string, line?: number) => {
deadLinks.push({ url, file: fileOrig }) deadLinks.push(
line == null ? { url, file: fileOrig } : { url, file: fileOrig, line }
)
} }
function shouldIgnoreDeadLink(url: string) { function shouldIgnoreDeadLink(url: string) {
@ -185,7 +192,12 @@ export async function createMarkdownToVueRenderFn(
if (links && siteConfig?.ignoreDeadLinks !== true) { if (links && siteConfig?.ignoreDeadLinks !== true) {
const dir = path.dirname(file) 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') const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue if (!treatAsHtml(pathname)) continue
@ -207,7 +219,7 @@ export async function createMarkdownToVueRenderFn(
!fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) && !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) &&
!shouldIgnoreDeadLink(url) !shouldIgnoreDeadLink(url)
) { ) {
recordDeadLink(url) recordDeadLink(url, line)
} }
} }
} }
@ -332,6 +344,10 @@ const inferDescription = (frontmatter: Record<string, any>) => {
return (head && getHeadMetaContent(head, 'description')) || '' return (head && getHeadMetaContent(head, 'description')) || ''
} }
function countLineBreaks(str: string) {
return str.match(/\r?\n/g)?.length ?? 0
}
const getHeadMetaContent = (head: HeadConfig[], name: string) => { const getHeadMetaContent = (head: HeadConfig[], name: string) => {
if (!head || !head.length) { if (!head || !head.length) {
return undefined return undefined

@ -422,14 +422,15 @@ function logDeadLinks(
devMode = false devMode = false
) { ) {
const logged = new Set<string>() const logged = new Set<string>()
deadLinks.forEach(({ url, file }, i) => { deadLinks.forEach(({ url, file, line }, i) => {
const key = `${file}:::${url}` const location = line == null ? file : `${file}:${line}`
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)
logger.warn( logger.warn(
c.yellow( 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))}`
) )
) )
}) })

1
types/shared.d.ts vendored

@ -223,6 +223,7 @@ export interface MarkdownEnv {
relativePath: string relativePath: string
cleanUrls: boolean cleanUrls: boolean
links?: string[] links?: string[]
linkLines?: number[]
includes?: string[] includes?: string[]
realPath?: string realPath?: string
localeIndex?: string localeIndex?: string

Loading…
Cancel
Save