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"'
)
})
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,
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
}
}
}

@ -28,7 +28,7 @@ const cache = new LRUCache<string, MarkdownCompileResult>({ 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<string, any>) => {
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

@ -422,14 +422,15 @@ function logDeadLinks(
devMode = false
) {
const logged = new Set<string>()
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))}`
)
)
})

1
types/shared.d.ts vendored

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

Loading…
Cancel
Save