// 校验侧边栏 / 导航里生成的锚点,在构建出来的 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} 个标题)`
)