verify-doc-site-fragments.ts 5.3 KB

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