verify-mermaid.ts 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. /**
  2. * Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence
  3. * checks cannot. Scope intentionally matches the Markdown link gate, including standing docs,
  4. * package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`.
  5. */
  6. import { globSync, readFileSync, realpathSync } from 'node:fs'
  7. import { resolve } from 'node:path'
  8. import { fromMarkdown } from 'mdast-util-from-markdown'
  9. import { gfmFromMarkdown } from 'mdast-util-gfm'
  10. import { gfm } from 'micromark-extension-gfm'
  11. import { JSDOM } from 'jsdom'
  12. import type { Nodes } from 'mdast'
  13. const root = resolve(import.meta.dirname, '..')
  14. const PATTERNS = [
  15. 'README.md',
  16. 'README.zh.md',
  17. 'docs/**/*.md',
  18. 'packages/*/*.md',
  19. 'packages/*/*/*.md',
  20. 'examples/**/*.md',
  21. 'AGENTS.md',
  22. 'packages/AGENTS.md',
  23. '.agents/skills/**/*.md',
  24. ]
  25. interface Block {
  26. file: string
  27. line: number
  28. source: string
  29. }
  30. interface Violation {
  31. file: string
  32. line: number
  33. message: string
  34. }
  35. function extractMermaidBlocks(file: string): Block[] {
  36. const source = readFileSync(resolve(root, file), 'utf8')
  37. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  38. const out: Block[] = []
  39. const visit = (node: Nodes): void => {
  40. if (node.type === 'code' && node.lang === 'mermaid') {
  41. out.push({ file, line: node.position?.start.line ?? 0, source: node.value })
  42. }
  43. if ('children' in node) {
  44. for (const child of node.children) visit(child)
  45. }
  46. }
  47. visit(tree)
  48. return out
  49. }
  50. function formatError(error: unknown): string {
  51. if (error instanceof Error) return error.message.replace(/\s+/g, ' ').trim()
  52. return String(error).replace(/\s+/g, ' ').trim()
  53. }
  54. const blocks: Block[] = []
  55. const seen = new Set<string>()
  56. let checkedFiles = 0
  57. for (const pattern of PATTERNS) {
  58. for (const match of globSync(pattern, { cwd: root })) {
  59. const real = realpathSync(resolve(root, match))
  60. if (seen.has(real)) continue
  61. seen.add(real)
  62. checkedFiles++
  63. blocks.push(...extractMermaidBlocks(match))
  64. }
  65. }
  66. const violations: Violation[] = []
  67. const { window } = new JSDOM('')
  68. Object.defineProperty(globalThis, 'window', { value: window })
  69. Object.defineProperty(globalThis, 'document', { value: window.document })
  70. Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
  71. const mermaid = (await import('mermaid')).default
  72. mermaid.initialize({ startOnLoad: false })
  73. for (const block of blocks) {
  74. try {
  75. await mermaid.parse(block.source, { suppressErrors: false })
  76. } catch (error: unknown) {
  77. violations.push({ file: block.file, line: block.line, message: formatError(error) })
  78. }
  79. }
  80. if (violations.length === 0) {
  81. console.log(`verify-mermaid: ${blocks.length} mermaid block(s) parsed across ${checkedFiles} file(s).`)
  82. process.exit(0)
  83. }
  84. console.error('verify-mermaid: Mermaid syntax errors found:')
  85. for (const violation of violations) {
  86. console.error(` ${violation.file}:${violation.line} ${violation.message}`)
  87. }
  88. process.exit(1)