/** * Verify fragment links against the HTML emitted by VitePress, and that the * build carries the raw-Markdown twin of every route plus llms.txt. Markdown * and VitePress use different heading-slug algorithms, so source-link * validation alone cannot prove that a published fragment exists. * * This runs as part of `docs:build` and can also run directly after a build * with `tsx scripts/verify-doc-site-fragments.ts`. */ import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve, sep } from 'node:path' import { JSDOM } from 'jsdom' import { rawMarkdownFiles } from './project-doc-site.ts' const root = resolve(import.meta.dirname, '..') /** One fragment reference that does not resolve in the built site. */ export interface BrokenSiteFragment { /** HTML file containing the link. */ source: string /** Link value as emitted by VitePress. */ href: string /** Built HTML target, or `undefined` when the route was not emitted. */ target?: string /** Decoded fragment id requested by the link. */ fragment: string } /** Result of checking every fragment-bearing anchor in a built site. */ export interface SiteFragmentReport { /** Number of internal fragment references inspected. */ checked: number /** References whose route or fragment id is absent. */ broken: BrokenSiteFragment[] } interface BuiltPage { file: string route: string ids: Set document: Document } function posixPath(path: string): string { return path.split(sep).join('/') } function routeFor(file: string): string { if (file === 'index.html') return '/' if (file.endsWith('/index.html')) return `/${file.slice(0, -'index.html'.length)}` return `/${file.slice(0, -'.html'.length)}` } function aliasesFor(page: BuiltPage): string[] { if (page.route === '/') return ['/', '/index', '/index.html'] if (page.route.endsWith('/')) { const stem = page.route.slice(0, -1) return [page.route, stem, `${stem}/index`, `${stem}/index.html`] } return [page.route, `${page.route}.html`] } function decodedFragment(hash: string): string { try { return decodeURIComponent(hash.slice(1)) } catch (error) { if (!(error instanceof URIError)) throw error // URIError means malformed percent encoding; preserve the literal id for comparison. return hash.slice(1) } } /** * Check fragment-bearing links in a VitePress output directory. * * @param distRoot - Directory containing generated HTML files. * @returns Counted internal links and every unresolved target. */ export function inspectSiteFragments(distRoot: string): SiteFragmentReport { const files = globSync('**/*.html', { cwd: distRoot }).map(posixPath).sort() if (files.length === 0) { throw new Error(`verify-doc-site-fragments: no HTML files found under ${distRoot}; run docs:build first.`) } const pages: BuiltPage[] = files.map((file) => { const document = new JSDOM(readFileSync(resolve(distRoot, file), 'utf8')).window.document const ids = new Set() for (const element of document.querySelectorAll('[id]')) ids.add(element.id) for (const element of document.querySelectorAll('a[name]')) { const name = element.getAttribute('name') if (name !== null) ids.add(name) } return { file, route: routeFor(file), ids, document } }) const byRoute = new Map() for (const page of pages) { for (const alias of aliasesFor(page)) { const existing = byRoute.get(alias) if (existing !== undefined && existing !== page) { throw new Error( `verify-doc-site-fragments: built pages ${existing.file} and ${page.file} share route ${JSON.stringify(alias)}.`, ) } byRoute.set(alias, page) } } const origin = 'https://dsh-docs.invalid' const broken: BrokenSiteFragment[] = [] let checked = 0 for (const page of pages) { for (const anchor of page.document.querySelectorAll('a[href]')) { const href = anchor.getAttribute('href') if (href === null || !href.includes('#')) continue let targetUrl: URL try { targetUrl = new URL(href, `${origin}${page.route}`) } catch (error) { throw new Error( `verify-doc-site-fragments: ${page.file} has invalid fragment href ${JSON.stringify(href)}.`, { cause: error }, ) } if (targetUrl.origin !== origin || targetUrl.hash === '') continue const fragment = decodedFragment(targetUrl.hash) if (fragment === '') continue checked++ const target = byRoute.get(targetUrl.pathname) if (target === undefined || !target.ids.has(fragment)) { broken.push({ source: page.file, href, ...(target === undefined ? {} : { target: target.file }), fragment, }) } } } return { checked, broken } } /** * Expected files a build did not emit. * * @param distRoot - Directory containing the built site. * @param expected - Site-relative files the build must carry. * @returns The absent files, in the given order. */ export function missingSiteFiles(distRoot: string, expected: readonly string[]): string[] { return expected.filter(file => !existsSync(resolve(distRoot, file))) } function main(): number { const distRoot = resolve(root, 'website/.dist') const report = inspectSiteFragments(distRoot) const expected = rawMarkdownFiles() const missing = missingSiteFiles(distRoot, [...expected, 'llms.txt']) if (report.broken.length === 0 && missing.length === 0) { console.log( `verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve;` + ` ${expected.length} raw-Markdown file(s) and llms.txt emitted.`, ) return 0 } if (report.broken.length > 0) { console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`) for (const item of report.broken) { const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}` console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`) } } if (missing.length > 0) { console.error(`verify-doc-site-fragments: ${missing.length} expected raw-Markdown file(s) missing from the build:`) for (const file of missing) console.error(` ${file}`) } return 1 } if (import.meta.main) process.exitCode = main()