From e432ee426fde72dca487dcb97988f0254311d39b Mon Sep 17 00:00:00 2001 From: wang5333 Date: Thu, 2 Jul 2026 05:18:51 +0800 Subject: [PATCH] fix: report dead links from sidebar config --- __tests__/unit/node/deadLinks.test.ts | 46 ++++++++ src/node/deadLinks.ts | 151 ++++++++++++++++++++++++++ src/node/plugin.ts | 11 +- 3 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 __tests__/unit/node/deadLinks.test.ts create mode 100644 src/node/deadLinks.ts diff --git a/__tests__/unit/node/deadLinks.test.ts b/__tests__/unit/node/deadLinks.test.ts new file mode 100644 index 00000000..b96c5e08 --- /dev/null +++ b/__tests__/unit/node/deadLinks.test.ts @@ -0,0 +1,46 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { resolveConfig } from 'node/config' +import { findSidebarDeadLinks } from 'node/deadLinks' + +describe('node/deadLinks', () => { + let root: string | undefined + + afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } + }) + + test('reports dead links from sidebar config', async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-sidebar-dead-link-')) + + await writeFile(path.join(root, 'index.md'), '# Home\n') + await mkdir(path.join(root, '.vitepress')) + await writeFile( + path.join(root, '.vitepress', 'config.ts'), + `export default { + themeConfig: { + sidebar: [ + { + text: 'Guide', + items: [ + { text: 'Intro', link: '/' }, + { text: 'Missing', link: '/missing' } + ] + } + ] + } + }` + ) + + const siteConfig = await resolveConfig(root, 'build', 'production') + + expect(findSidebarDeadLinks(siteConfig, 'public')).toContainEqual({ + url: '/missing', + file: siteConfig.configPath! + }) + }) +}) diff --git a/src/node/deadLinks.ts b/src/node/deadLinks.ts new file mode 100644 index 00000000..36ace939 --- /dev/null +++ b/src/node/deadLinks.ts @@ -0,0 +1,151 @@ +import fs from 'fs-extra' +import path from 'node:path' +import type { SiteConfig } from './config' +import { EXTERNAL_URL_RE, isExternal, slash, treatAsHtml } from './shared' + +export interface DeadLink { + url: string + file: string + line?: number +} + +interface SidebarLink { + url: string + file: string +} + +function shouldIgnoreDeadLink( + url: string, + file: string, + siteConfig: SiteConfig +) { + if (!siteConfig.ignoreDeadLinks) { + return false + } + if (siteConfig.ignoreDeadLinks === true) { + return true + } + if (siteConfig.ignoreDeadLinks === 'localhostLinks') { + return url.replace(EXTERNAL_URL_RE, '').startsWith('//localhost') + } + + return siteConfig.ignoreDeadLinks.some((ignore) => { + if (typeof ignore === 'string') return url === ignore + if (ignore instanceof RegExp) return ignore.test(url) + if (typeof ignore === 'function') return ignore(url, file) + return false + }) +} + +function applySidebarBase(link: string, base?: string) { + if (!base || isExternal(link)) return link + return base + link.replace(/^\//, base.endsWith('/') ? '' : '/') +} + +function collectSidebarLinks( + sidebar: unknown, + file: string, + links: SidebarLink[], + base?: string +) { + if (Array.isArray(sidebar)) { + for (const item of sidebar) { + if (!item || typeof item !== 'object') continue + + const sidebarItem = item as { + base?: string + link?: string + items?: unknown + } + const itemBase = sidebarItem.base || base + + if (typeof sidebarItem.link === 'string') { + links.push({ + url: applySidebarBase(sidebarItem.link, itemBase), + file + }) + } + + collectSidebarLinks(sidebarItem.items, file, links, itemBase) + } + return + } + + if (sidebar && typeof sidebar === 'object') { + for (const value of Object.values(sidebar)) { + if (Array.isArray(value)) { + collectSidebarLinks(value, file, links) + } else if (value && typeof value === 'object') { + const group = value as { base?: string; items?: unknown } + collectSidebarLinks(group.items, file, links, group.base) + } + } + } +} + +function resolveInternalLink( + rawUrl: string, + dir: string, + publicDir: string, + siteConfig: SiteConfig +) { + if ( + EXTERNAL_URL_RE.test(rawUrl) || + rawUrl.startsWith('#') || + rawUrl.startsWith('?') + ) { + return + } + + let url = rawUrl + const { pathname } = new URL(url, 'http://a.com') + if (!treatAsHtml(pathname)) return + + url = url.replace(/[?#].*$/, '').replace(/\.(html|md)$/, '') + if (url.endsWith('/')) url += `index` + + let resolved = decodeURIComponent( + slash( + url.startsWith('/') + ? url.slice(1) + : path.relative(siteConfig.srcDir, path.resolve(dir, url)) + ) + ) + resolved = siteConfig.rewrites.inv[resolved + '.md']?.slice(0, -3) || resolved + + if ( + !siteConfig.pages + .map((p) => slash(p.replace(/\.md$/, ''))) + .includes(resolved) && + !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) && + !shouldIgnoreDeadLink( + url, + siteConfig.configPath || siteConfig.srcDir, + siteConfig + ) + ) { + return url + } +} + +export function findSidebarDeadLinks( + siteConfig: SiteConfig, + publicDir: string +): DeadLink[] { + const sidebar = siteConfig.site.themeConfig?.sidebar + if (!sidebar || siteConfig.ignoreDeadLinks === true) return [] + + const file = siteConfig.configPath || siteConfig.srcDir + const links: SidebarLink[] = [] + collectSidebarLinks(sidebar, file, links) + + return links.flatMap(({ url, file }) => { + const deadUrl = resolveInternalLink( + url, + siteConfig.srcDir, + publicDir, + siteConfig + ) + return deadUrl ? [{ url: deadUrl, file }] : [] + }) +} diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 6739a75e..db3831f2 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -19,6 +19,7 @@ import { resolveAliases } from './alias' import { isAdditionalConfigFile, resolvePages, type SiteConfig } from './config' +import { findSidebarDeadLinks } from './deadLinks' import { clearCache, createMarkdownToVueRenderFn, @@ -237,14 +238,18 @@ export async function createVitePressPlugin( }, renderStart() { - if (allDeadLinks.length > 0) { - logDeadLinks(allDeadLinks, siteConfig.logger) + const deadLinks = [ + ...allDeadLinks, + ...findSidebarDeadLinks(siteConfig, config.publicDir) + ] + if (deadLinks.length > 0) { + logDeadLinks(deadLinks, siteConfig.logger) siteConfig.logger.info( c.cyan( '\nIf this is expected, you can disable this check via config. Refer: https://vitepress.dev/reference/site-config#ignoredeadlinks\n' ) ) - throw new Error(`${allDeadLinks.length} dead link(s) found.`) + throw new Error(`${deadLinks.length} dead link(s) found.`) } },