1
0

verify-doc-site-fragments.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /**
  2. * Verify fragment links against the HTML emitted by VitePress, and that the
  3. * build carries the raw-Markdown twin of every route plus llms.txt. Markdown
  4. * and VitePress use different heading-slug algorithms, so source-link
  5. * validation alone cannot prove that a published fragment exists.
  6. *
  7. * This runs as part of `docs:build` and can also run directly after a build
  8. * with `tsx scripts/verify-doc-site-fragments.ts`.
  9. */
  10. import { existsSync, globSync, readFileSync } from 'node:fs'
  11. import { resolve, sep } from 'node:path'
  12. import { JSDOM } from 'jsdom'
  13. import { rawMarkdownFiles } from './project-doc-site.ts'
  14. const root = resolve(import.meta.dirname, '..')
  15. /** One fragment reference that does not resolve in the built site. */
  16. export interface BrokenSiteFragment {
  17. /** HTML file containing the link. */
  18. source: string
  19. /** Link value as emitted by VitePress. */
  20. href: string
  21. /** Built HTML target, or `undefined` when the route was not emitted. */
  22. target?: string
  23. /** Decoded fragment id requested by the link. */
  24. fragment: string
  25. }
  26. /** Result of checking every fragment-bearing anchor in a built site. */
  27. export interface SiteFragmentReport {
  28. /** Number of internal fragment references inspected. */
  29. checked: number
  30. /** References whose route or fragment id is absent. */
  31. broken: BrokenSiteFragment[]
  32. }
  33. interface BuiltPage {
  34. file: string
  35. route: string
  36. ids: Set<string>
  37. document: Document
  38. }
  39. function posixPath(path: string): string {
  40. return path.split(sep).join('/')
  41. }
  42. function routeFor(file: string): string {
  43. if (file === 'index.html') return '/'
  44. if (file.endsWith('/index.html')) return `/${file.slice(0, -'index.html'.length)}`
  45. return `/${file.slice(0, -'.html'.length)}`
  46. }
  47. function aliasesFor(page: BuiltPage): string[] {
  48. if (page.route === '/') return ['/', '/index', '/index.html']
  49. if (page.route.endsWith('/')) {
  50. const stem = page.route.slice(0, -1)
  51. return [page.route, stem, `${stem}/index`, `${stem}/index.html`]
  52. }
  53. return [page.route, `${page.route}.html`]
  54. }
  55. function decodedFragment(hash: string): string {
  56. try {
  57. return decodeURIComponent(hash.slice(1))
  58. } catch (error) {
  59. if (!(error instanceof URIError)) throw error
  60. // URIError means malformed percent encoding; preserve the literal id for comparison.
  61. return hash.slice(1)
  62. }
  63. }
  64. /**
  65. * Check fragment-bearing links in a VitePress output directory.
  66. *
  67. * @param distRoot - Directory containing generated HTML files.
  68. * @returns Counted internal links and every unresolved target.
  69. */
  70. export function inspectSiteFragments(distRoot: string): SiteFragmentReport {
  71. const files = globSync('**/*.html', { cwd: distRoot }).map(posixPath).sort()
  72. if (files.length === 0) {
  73. throw new Error(`verify-doc-site-fragments: no HTML files found under ${distRoot}; run docs:build first.`)
  74. }
  75. const pages: BuiltPage[] = files.map((file) => {
  76. const document = new JSDOM(readFileSync(resolve(distRoot, file), 'utf8')).window.document
  77. const ids = new Set<string>()
  78. for (const element of document.querySelectorAll<HTMLElement>('[id]')) ids.add(element.id)
  79. for (const element of document.querySelectorAll<HTMLAnchorElement>('a[name]')) {
  80. const name = element.getAttribute('name')
  81. if (name !== null) ids.add(name)
  82. }
  83. return { file, route: routeFor(file), ids, document }
  84. })
  85. const byRoute = new Map<string, BuiltPage>()
  86. for (const page of pages) {
  87. for (const alias of aliasesFor(page)) {
  88. const existing = byRoute.get(alias)
  89. if (existing !== undefined && existing !== page) {
  90. throw new Error(
  91. `verify-doc-site-fragments: built pages ${existing.file} and ${page.file} share route ${JSON.stringify(alias)}.`,
  92. )
  93. }
  94. byRoute.set(alias, page)
  95. }
  96. }
  97. const origin = 'https://dsh-docs.invalid'
  98. const broken: BrokenSiteFragment[] = []
  99. let checked = 0
  100. for (const page of pages) {
  101. for (const anchor of page.document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
  102. const href = anchor.getAttribute('href')
  103. if (href === null || !href.includes('#')) continue
  104. let targetUrl: URL
  105. try {
  106. targetUrl = new URL(href, `${origin}${page.route}`)
  107. } catch (error) {
  108. throw new Error(
  109. `verify-doc-site-fragments: ${page.file} has invalid fragment href ${JSON.stringify(href)}.`,
  110. { cause: error },
  111. )
  112. }
  113. if (targetUrl.origin !== origin || targetUrl.hash === '') continue
  114. const fragment = decodedFragment(targetUrl.hash)
  115. if (fragment === '') continue
  116. checked++
  117. const target = byRoute.get(targetUrl.pathname)
  118. if (target === undefined || !target.ids.has(fragment)) {
  119. broken.push({
  120. source: page.file,
  121. href,
  122. ...(target === undefined ? {} : { target: target.file }),
  123. fragment,
  124. })
  125. }
  126. }
  127. }
  128. return { checked, broken }
  129. }
  130. /**
  131. * Expected files a build did not emit.
  132. *
  133. * @param distRoot - Directory containing the built site.
  134. * @param expected - Site-relative files the build must carry.
  135. * @returns The absent files, in the given order.
  136. */
  137. export function missingSiteFiles(distRoot: string, expected: readonly string[]): string[] {
  138. return expected.filter(file => !existsSync(resolve(distRoot, file)))
  139. }
  140. function main(): number {
  141. const distRoot = resolve(root, 'website/.dist')
  142. const report = inspectSiteFragments(distRoot)
  143. const expected = rawMarkdownFiles()
  144. const missing = missingSiteFiles(distRoot, [...expected, 'llms.txt'])
  145. if (report.broken.length === 0 && missing.length === 0) {
  146. console.log(
  147. `verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve;`
  148. + ` ${expected.length} raw-Markdown file(s) and llms.txt emitted.`,
  149. )
  150. return 0
  151. }
  152. if (report.broken.length > 0) {
  153. console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`)
  154. for (const item of report.broken) {
  155. const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}`
  156. console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`)
  157. }
  158. }
  159. if (missing.length > 0) {
  160. console.error(`verify-doc-site-fragments: ${missing.length} expected raw-Markdown file(s) missing from the build:`)
  161. for (const file of missing) console.error(` ${file}`)
  162. }
  163. return 1
  164. }
  165. if (import.meta.main) process.exitCode = main()