verify-mermaid.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  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. import { isArchivedAgentNotePath } from './repo-files.ts'
  14. const root = resolve(import.meta.dirname, '..')
  15. const PATTERNS = [
  16. 'README.md',
  17. 'README.zh.md',
  18. '.agents/notes/**/*.md',
  19. 'docs/**/*.md',
  20. 'packages/*/*.md',
  21. 'packages/*/*/*.md',
  22. 'examples/**/*.md',
  23. 'AGENTS.md',
  24. 'packages/AGENTS.md',
  25. '.agents/skills/**/*.md',
  26. 'skills/**/*.md',
  27. ]
  28. interface Block {
  29. file: string
  30. line: number
  31. source: string
  32. }
  33. interface Violation {
  34. file: string
  35. line: number
  36. message: string
  37. }
  38. function extractMermaidBlocks(file: string): Block[] {
  39. const source = readFileSync(resolve(root, file), 'utf8')
  40. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  41. const out: Block[] = []
  42. const visit = (node: Nodes): void => {
  43. if (node.type === 'code' && node.lang === 'mermaid') {
  44. out.push({ file, line: node.position?.start.line ?? 0, source: node.value })
  45. }
  46. if ('children' in node) {
  47. for (const child of node.children) visit(child)
  48. }
  49. }
  50. visit(tree)
  51. return out
  52. }
  53. function formatError(error: unknown): string {
  54. if (error instanceof Error) return error.message.replace(/\s+/g, ' ').trim()
  55. return String(error).replace(/\s+/g, ' ').trim()
  56. }
  57. const blocks: Block[] = []
  58. const seen = new Set<string>()
  59. let checkedFiles = 0
  60. for (const pattern of PATTERNS) {
  61. for (const match of globSync(pattern, { cwd: root })) {
  62. if (isArchivedAgentNotePath(match)) continue
  63. const real = realpathSync(resolve(root, match))
  64. if (seen.has(real)) continue
  65. seen.add(real)
  66. checkedFiles++
  67. blocks.push(...extractMermaidBlocks(match))
  68. }
  69. }
  70. const violations: Violation[] = []
  71. const { window } = new JSDOM('')
  72. Object.defineProperty(globalThis, 'window', { value: window })
  73. Object.defineProperty(globalThis, 'document', { value: window.document })
  74. Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
  75. const mermaid = (await import('mermaid')).default
  76. // maxEdges: mermaid's default 500-edge render guard; the module graph grows
  77. // with every package edge and crossed it legitimately. Raise the guard here
  78. // (a secure config settable only via initialize) rather than trimming edges.
  79. mermaid.initialize({ startOnLoad: false, maxEdges: 1000 })
  80. for (const block of blocks) {
  81. try {
  82. await mermaid.parse(block.source, { suppressErrors: false })
  83. } catch (error: unknown) {
  84. violations.push({ file: block.file, line: block.line, message: formatError(error) })
  85. }
  86. }
  87. if (violations.length === 0) {
  88. console.log(`verify-mermaid: ${blocks.length} mermaid block(s) parsed across ${checkedFiles} file(s).`)
  89. process.exit(0)
  90. }
  91. console.error('verify-mermaid: Mermaid syntax errors found:')
  92. for (const violation of violations) {
  93. console.error(` ${violation.file}:${violation.line} ${violation.message}`)
  94. }
  95. process.exit(1)