Skip to content

Commit c0f7473

Browse files
committed
Found a broken mermaid diagram, fixed it, but also added a lint step to hopefully catch future issues
1 parent 0e6ab9d commit c0f7473

5 files changed

Lines changed: 184 additions & 2 deletions

File tree

.github/workflows/mermaid-ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Mermaid CI
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "docs/**"
7+
- "scripts/lint-mermaid.mjs"
8+
- "package.json"
9+
- "yarn.lock"
10+
- ".github/workflows/mermaid-ci.yml"
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
mermaid:
17+
name: Validate Mermaid syntax
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout Repository
21+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
22+
23+
- name: Setup Node.js Environment
24+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
25+
with:
26+
node-version: "24"
27+
cache: "yarn"
28+
29+
- name: Install Dependencies
30+
run: yarn install --frozen-lockfile
31+
32+
- name: Lint Mermaid Diagrams
33+
run: yarn lint:mermaid

.github/workflows/vale-ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ jobs:
2222
level: info
2323
filter_mode: diff_context
2424
fail_on_error: false
25-
vale_flags: "--config=.vale-ci.ini"
25+
vale_flags: "--config=.vale-ci.ini docs"

docs/design-patterns/early-return-local-activities.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ sequenceDiagram
4444
W->>S: Complete ActivityTask
4545
end
4646
47-
Note over C,W: Client has its response; Phase 2 continues in the background
47+
Note over C,W: Client has its response and Phase 2 continues in the background
4848
```
4949

5050
**Numbered walkthrough:**

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"lint:py": "vale docs/develop/python/*.mdx",
2424
"lint:ruby": "vale docs/develop/ruby/*.mdx",
2525
"lint:ts": "vale docs/develop/typescript/*.mdx",
26+
"lint:mermaid": "node ./scripts/lint-mermaid.mjs",
2627
"fix-vale": "node vale/auto-fix-vale.js vale-output.json",
2728
"serve": "docusaurus serve",
2829
"snipsync": "snipsync",

scripts/lint-mermaid.mjs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
import DOMPurify from 'dompurify';
4+
import mermaidModule from 'mermaid';
5+
6+
const mermaid = mermaidModule.default ?? mermaidModule;
7+
const DOCS_ROOT = path.resolve('docs');
8+
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']);
9+
10+
function isMarkdownFile(filePath) {
11+
return MARKDOWN_EXTENSIONS.has(path.extname(filePath));
12+
}
13+
14+
async function walk(dir) {
15+
const entries = await fs.readdir(dir, { withFileTypes: true });
16+
const results = [];
17+
for (const entry of entries) {
18+
const fullPath = path.join(dir, entry.name);
19+
if (entry.isDirectory()) {
20+
results.push(...(await walk(fullPath)));
21+
} else if (entry.isFile() && isMarkdownFile(fullPath)) {
22+
results.push(fullPath);
23+
}
24+
}
25+
return results;
26+
}
27+
28+
function extractMermaidBlocks(content) {
29+
const lines = content.split(/\r?\n/);
30+
const blocks = [];
31+
let inBlock = false;
32+
let blockStartLine = 0;
33+
let blockLines = [];
34+
35+
for (let i = 0; i < lines.length; i += 1) {
36+
const line = lines[i];
37+
if (!inBlock) {
38+
if (/^\s*```mermaid\s*$/.test(line)) {
39+
inBlock = true;
40+
blockStartLine = i + 2; // first content line, 1-indexed
41+
blockLines = [];
42+
}
43+
continue;
44+
}
45+
46+
if (/^\s*```\s*$/.test(line)) {
47+
blocks.push({
48+
startLine: blockStartLine,
49+
content: blockLines.join('\n'),
50+
});
51+
inBlock = false;
52+
blockLines = [];
53+
continue;
54+
}
55+
56+
blockLines.push(line);
57+
}
58+
59+
if (inBlock) {
60+
blocks.push({
61+
startLine: blockStartLine,
62+
content: blockLines.join('\n'),
63+
unterminated: true,
64+
});
65+
}
66+
67+
return blocks;
68+
}
69+
70+
function extractRelativeLineFromError(errorMessage) {
71+
const match = errorMessage.match(/line\s+(\d+)/i);
72+
return match ? Number(match[1]) : null;
73+
}
74+
75+
async function lintFile(filePath) {
76+
const content = await fs.readFile(filePath, 'utf8');
77+
const blocks = extractMermaidBlocks(content);
78+
const errors = [];
79+
80+
for (const block of blocks) {
81+
if (block.unterminated) {
82+
errors.push({
83+
filePath,
84+
line: block.startLine,
85+
message: 'Unterminated mermaid code fence.',
86+
});
87+
continue;
88+
}
89+
90+
if (!block.content.trim()) {
91+
continue;
92+
}
93+
94+
try {
95+
await mermaid.parse(block.content);
96+
} catch (error) {
97+
const message = error instanceof Error ? error.message : String(error);
98+
const relativeLine = extractRelativeLineFromError(message);
99+
const absoluteLine = relativeLine ? block.startLine + relativeLine - 1 : block.startLine;
100+
errors.push({
101+
filePath,
102+
line: absoluteLine,
103+
message,
104+
});
105+
}
106+
}
107+
108+
return errors;
109+
}
110+
111+
async function main() {
112+
// Mermaid's parser may call DOMPurify hooks for some diagram syntaxes.
113+
// In Node (without a browser window), dompurify can resolve to a minimal
114+
// implementation that lacks hook APIs; provide no-op hooks so syntax-only
115+
// parsing still works in CI.
116+
if (typeof DOMPurify.addHook !== 'function') {
117+
DOMPurify.addHook = () => {};
118+
}
119+
if (typeof DOMPurify.removeHook !== 'function') {
120+
DOMPurify.removeHook = () => {};
121+
}
122+
if (typeof DOMPurify.sanitize !== 'function') {
123+
DOMPurify.sanitize = (value) => value;
124+
}
125+
126+
mermaid.initialize({ startOnLoad: false });
127+
128+
const markdownFiles = await walk(DOCS_ROOT);
129+
const allErrors = [];
130+
131+
for (const filePath of markdownFiles) {
132+
const fileErrors = await lintFile(filePath);
133+
allErrors.push(...fileErrors);
134+
}
135+
136+
if (allErrors.length > 0) {
137+
console.error(`Mermaid lint failed with ${allErrors.length} error(s):`);
138+
for (const err of allErrors) {
139+
const relPath = path.relative(process.cwd(), err.filePath);
140+
console.error(`- ${relPath}:${err.line} ${err.message}`);
141+
}
142+
process.exit(1);
143+
}
144+
145+
console.log(`Mermaid lint passed. Checked ${markdownFiles.length} markdown files.`);
146+
}
147+
148+
await main();

0 commit comments

Comments
 (0)