verify-mermaid.ts 3.5 KB

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