// 校验侧边栏 / 导航里生成的锚点,在构建出来的 HTML 里真的存在。 // // 侧边栏是 config.mts 用一份**自己实现的** slugify 从源稿的 h2 现算出来的 // (@mdit-vue/shared 打包在 vitepress 内部,外面 import 不到)。 // 只要那份实现和 VitePress 的哪怕差一个字符,链接就会静默跳空 —— 页面照样打开, // 只是不滚动,肉眼极难发现。所以每次 build 之后自动比一遍。 // // 多页之后还多了一类错:锚点对了但**页面指错了**(/#xxx 写成 /plan#xxx)。 // 所以这里按「路由 → 该页的 id 集合」建表,逐条链接校验它落在正确的页上。 import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const DIST = path.resolve(here, '../.vitepress/dist') if (!fs.existsSync(DIST)) { console.error(`[check-anchors] 没有构建产物:${DIST}`) process.exit(1) } // VitePress 自己注入的锚点,不是正文标题 const BUILTIN = new Set(['VPContent', 'VPSidebarNav']) /** dist 下的全部 html(不含 404) */ function pages(dir, out = []) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name) if (e.isDirectory()) pages(p, out) else if (e.name.endsWith('.html') && e.name !== '404.html') out.push(p) } return out } /** 文件路径 → cleanUrls 下的路由:index.html -> '/',plan.html -> '/plan' */ function routeOf(file) { const rel = path.relative(DIST, file).split(path.sep).join('/').replace(/\.html$/, '') return rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '') } const idsByRoute = new Map() const files = pages(DIST) for (const f of files) { const html = fs.readFileSync(f, 'utf8') const ids = new Set([...html.matchAll(/]*\sid="([^"]+)"/g)].map((m) => m[1])) idsByRoute.set(routeOf(f), ids) } let checked = 0 const bad = [] for (const f of files) { const from = routeOf(f) const html = fs.readFileSync(f, 'utf8') for (const m of html.matchAll(/href="([^"]*#[^"]+)"/g)) { const href = m[1] const hash = decodeURIComponent(href.slice(href.indexOf('#') + 1)) if (BUILTIN.has(hash)) continue const rawPath = href.slice(0, href.indexOf('#')) if (rawPath && !rawPath.startsWith('/')) continue // 外链 const route = rawPath === '' ? from : rawPath === '/' ? '/' : rawPath.replace(/\/$/, '') checked++ const ids = idsByRoute.get(route) if (!ids) bad.push(`${from}:链接指向不存在的页面 ${route}`) else if (!ids.has(hash)) bad.push(`${from}:${route}#${hash} —— 该页没有这个标题`) } } if (bad.length) { console.error(`\n[check-anchors] ✗ ${bad.length} 条锚点对不上:`) for (const b of [...new Set(bad)]) console.error(` ${b}`) console.error(`\n 多半是 config.mts 里的 slugify() 与 VitePress 不一致,或侧边栏的 base 写错了页。`) for (const [route, ids] of idsByRoute) { console.error(` ${route} 的标题 id:`) for (const id of ids) console.error(` #${id}`) } process.exit(1) } const total = [...idsByRoute.values()].reduce((n, s) => n + s.size, 0) console.log( `[check-anchors] ✓ ${checked} 条锚点全部对得上` + `(${idsByRoute.size} 个页面,共 ${total} 个标题)` )