verify-md-links.spec.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * Acceptance-path coverage for fragment validation in `verify-md-links`: a
  3. * `#fragment` onto a Markdown target — same-file anchors included — must name
  4. * a real heading slug or explicit `<a id>`, while non-Markdown fragments and
  5. * external targets stay out of scope.
  6. */
  7. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { afterEach, describe, expect, it } from 'vitest'
  11. import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
  12. const roots: string[] = []
  13. afterEach(() => {
  14. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  15. })
  16. function layout(files: Record<string, string>): string {
  17. const root = mkdtempSync(join(tmpdir(), 'md-links-'))
  18. roots.push(root)
  19. for (const [rel, content] of Object.entries(files)) {
  20. mkdirSync(join(root, rel, '..'), { recursive: true })
  21. writeFileSync(join(root, rel), content)
  22. }
  23. return root
  24. }
  25. function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
  26. return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
  27. }
  28. describe('documentAnchors', () => {
  29. it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
  30. const anchors = documentAnchors([
  31. '# My Doc',
  32. '## Live `events` — mode!',
  33. '## Repeat',
  34. '## Repeat',
  35. '<a id="hand-anchor"></a>',
  36. '',
  37. ].join('\n'))
  38. expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
  39. expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
  40. })
  41. it('keeps underscores the way GitHub does', () => {
  42. expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
  43. expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
  44. })
  45. it('slugs a heading containing a link from its rendered text', () => {
  46. expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
  47. })
  48. it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
  49. const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
  50. expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
  51. })
  52. it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
  53. const anchors = documentAnchors([
  54. '# Doc',
  55. '```md',
  56. '<a id="fenced"></a>',
  57. '```',
  58. 'Inline `<a id="inline"></a>` sample.',
  59. '<!-- <a id="commented"></a> -->',
  60. '<a id="real"></a>',
  61. '',
  62. ].join('\n'))
  63. expect(anchors).toEqual(new Set(['doc', 'real']))
  64. })
  65. })
  66. describe('findViolations fragments', () => {
  67. it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => {
  68. const root = layout({
  69. 'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n',
  70. 'b.md': '# B\n\n## Part two\n',
  71. 'x.ts': 'export {}\n',
  72. })
  73. expect(violationsIn(root, 'a.md')).toEqual([])
  74. })
  75. it('rejects a same-file fragment that names no heading or <a id>', () => {
  76. const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' })
  77. expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
  78. })
  79. it('rejects a case-variant fragment: element ids are case-sensitive', () => {
  80. const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
  81. expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
  82. })
  83. it('rejects a cross-file fragment missing from the target document', () => {
  84. const root = layout({
  85. 'a.md': '# A\n\n[stale](b.md#old-heading)\n',
  86. 'b.md': '# B\n\n## New heading\n',
  87. })
  88. expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }])
  89. })
  90. it('still rejects a missing target file, reported as target not anchor', () => {
  91. const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' })
  92. expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }])
  93. })
  94. })