fix: check heading hashes in dead links

pull/5293/head
wang5333 2 weeks ago
parent 8e38b1069b
commit af1f3419ee

@ -67,6 +67,60 @@ describe('node/markdownToVue', () => {
})
})
test('reports missing heading hashes on existing pages', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-'))
const file = path.join(root, 'index.md')
const target = path.join(root, 'guide.md')
const src = '# Home\n\n[Missing](./guide.md#missing-section)\n'
await writeFile(file, src)
await writeFile(target, '# Guide\n\n## Existing Section\n')
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false, headers: true },
'/',
false,
false,
siteConfig
)
const result = await render(src, file, 'public')
expect(result.deadLinks).toContainEqual({
url: './guide.html#missing-section',
file,
line: 3
})
})
test('reports missing same-page heading hashes', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-dead-link-'))
const file = path.join(root, 'index.md')
const src = '# Home\n\n[Missing](#missing-section)\n\n## Existing Section\n'
await writeFile(file, src)
const siteConfig = await resolveConfig(root, 'build', 'production')
const render = await createMarkdownToVueRenderFn(
siteConfig.srcDir,
{ cache: false, headers: true },
'/',
false,
false,
siteConfig
)
const result = await render(src, file, 'public')
expect(result.deadLinks).toContainEqual({
url: '#missing-section',
file,
line: 3
})
})
test('selects included heading sections after frontmatter', async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-'))

@ -75,6 +75,9 @@ export const linkPlugin = (
normalizeHref(hrefAttr, env, token.meta?.vpLine)
} else if (url.startsWith('#')) {
hrefAttr[1] = decodeURI(normalizeHash(hrefAttr[1]))
if (hrefAttr[1].length > 1) {
pushLink(hrefAttr[1], env, token.meta?.vpLine)
}
}
// append base to internal (non-relative) urls

@ -24,6 +24,7 @@ import { processIncludes } from './utils/processIncludes'
const debug = createDebug('vitepress:md')
const cache = new LRUCache<string, MarkdownCompileResult>({ max: 1024 })
const htmlIdRE = /\sid=(['"])(.*?)\1/g
export interface MarkdownCompileResult {
vueSrc: string
@ -51,6 +52,26 @@ function normalizeDriveLetter(file: string) {
return file.replace(/^[a-z]:/i, (drive) => drive.toLowerCase())
}
function getLinkHash(url: string) {
const hash = new URL(url, 'http://a.com').hash
if (hash.length <= 1) return
const decodedHash = hash.slice(1).split(':~:', 1)[0]
try {
return decodeURI(decodedHash)
} catch {
return decodedHash
}
}
function getHtmlIds(html: string) {
const ids = new Set<string>()
for (const match of html.matchAll(htmlIdRE)) {
ids.add(match[2])
}
return ids
}
function getResolutionCache(siteConfig: SiteConfig) {
// @ts-expect-error internal
if (siteConfig.__dirty) {
@ -98,6 +119,32 @@ export async function createMarkdownToVueRenderFn(
base,
siteConfig?.logger
)
const anchorCache = new Map<string, { mtimeMs: number; ids: Set<string> }>()
const getFileAnchors = async (file: string) => {
const stats = await fs.stat(file).catch(() => undefined)
if (!stats) return
const cacheKey = normalizeDriveLetter(slash(file))
const cached = anchorCache.get(cacheKey)
if (cached?.mtimeMs === stats.mtimeMs) return cached.ids
const relativePath = slash(path.relative(srcDir, file))
const includes: string[] = []
let src = await fs.readFile(file, 'utf-8')
src = processIncludes(md, srcDir, src, file, includes, cleanUrls)
const html = await md.renderAsync(src, {
path: file,
relativePath,
cleanUrls,
includes,
realPath: file,
localeIndex: getLocaleForPath(siteConfig?.site, relativePath)
})
const ids = getHtmlIds(html)
anchorCache.set(cacheKey, { mtimeMs: stats.mtimeMs, ids })
return ids
}
return async (
src: string,
@ -196,34 +243,55 @@ export async function createMarkdownToVueRenderFn(
if (links && siteConfig?.ignoreDeadLinks !== true) {
const dir = path.dirname(file)
const currentPageIds = getHtmlIds(html)
for (const [index, rawUrl] of links.entries()) {
let url = rawUrl
const line =
linkLines[index] == null
? undefined
: linkLines[index] + contentLineOffset
const isSamePageHash = rawUrl.startsWith('#')
const hash = getLinkHash(rawUrl)
const { pathname } = new URL(url, 'http://a.com')
if (!treatAsHtml(pathname)) continue
if (!isSamePageHash && !treatAsHtml(pathname)) continue
url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '')
url = isSamePageHash
? relativePath.replace(/\.md$/, '')
: 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))
isSamePageHash
? url
: url.startsWith('/')
? url.slice(1)
: path.relative(srcDir, path.resolve(dir, url))
)
)
resolved =
siteConfig?.rewrites.inv[resolved + '.md']?.slice(0, -3) || resolved
const pageExists =
pages.includes(resolved) ||
fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`))
if (!pageExists && !shouldIgnoreDeadLink(url)) {
recordDeadLink(url, line)
continue
}
if (
!pages.includes(resolved) &&
!fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) &&
hash &&
!shouldIgnoreDeadLink(rawUrl) &&
!shouldIgnoreDeadLink(url)
) {
recordDeadLink(url, line)
const ids = isSamePageHash
? currentPageIds
: await getFileAnchors(path.resolve(srcDir, `${resolved}.md`))
if (ids && !ids.has(hash)) {
recordDeadLink(rawUrl, line)
}
}
}
}

Loading…
Cancel
Save