Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions .github/scripts/validate-mermaid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/usr/bin/env node
// Parses Mermaid figures with the same grammar GitHub renders them with, so a
// bot that posts a diagram finds out about a syntax error before a reviewer
// does. Takes `.mmd` files, or `.md` files whose fenced ```mermaid blocks are
// extracted, or the figure on stdin. Exit 0 means every block parses.
//
// node .github/scripts/validate-mermaid.mjs figure.mmd
// node .github/scripts/validate-mermaid.mjs comment.md
// cat figure.mmd | node .github/scripts/validate-mermaid.mjs -
//
// Mermaid is fetched from npm on first use and cached under the runner's temp
// directory. Without a network the script still applies the lint rules below,
// reports that the grammar check did not run, and exits 2.

import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

// GitHub renders comments with Mermaid 11.x. jsdom is what lets Mermaid's
// sanitizer run outside a browser; without it every label fails to parse.
const PACKAGES = ['mermaid@^11', 'jsdom@^27'];
const CACHE = path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'mermaid-validate');

// Each rule names a construct that the grammar accepts nowhere useful, or that
// renders into something other than what it says. `where` narrows a rule to the
// diagram types it applies to.
const RULES = [
{
id: 'unquoted-parens-in-label',
where: /^(flowchart|graph)\b/,
test: (line) => {
const edge = line.match(/(?:--|-\.|==)[->.=]*\|([^|]*)\|/);
const unquoted = (text) => /[()]/.test(text) && !/^\s*".*"\s*$/.test(text);
return (edge && unquoted(edge[1]))
|| /[[({][^\]")}]*\w[^\]")}(]*\(/.test(line.replace(/"[^"]*"/g, '""'));
},
says: 'a label holding parentheses must be quoted: A["calls foo()"], -->|"calls foo()"|',
},
{
id: 'backtick-opens-label',
test: (line) => /[[({]\s*"`/.test(line),
says: 'a backtick right after the quote starts a Mermaid markdown string; write ["@PWA offline"], not ["`@PWA` offline"]',
},
{
id: 'semicolon-in-message-text',
where: /^sequenceDiagram\b/,
test: (line) => {
const colon = line.indexOf(':');
return colon !== -1 && line.slice(colon + 1).includes(';');
},
says: 'a semicolon ends the statement; use a comma or a second Note',
},
{
id: 'quoted-message-text',
where: /^sequenceDiagram\b/,
test: (line) => /(?:->>|-->>|-x|--x)\s*[^:]*:\s*".*"\s*$/.test(line),
says: 'message text is free text — the quotes would be drawn',
},
{
id: 'init-directive',
test: (line) => line.includes('%%{init'),
says: 'an %%{init}%% block overrides the reader\'s own theme',
},
{
id: 'raw-html',
test: (line) => /<br\s*\/?>|<\/?(?:b|i|em|strong|span|div|font)\b/i.test(line),
says: 'raw HTML does not survive GitHub\'s sanitizer',
},
{
id: 'fill-without-color',
test: (line) => /classDef\b/.test(line) && /\bfill:/.test(line) && !/\bcolor:/.test(line),
says: 'a classDef that sets fill: must set color: too, or the dark theme keeps its near-white label',
},
];

// The diagram type is the first line that is neither blank nor a comment.
function typeOf(text) {
return text.split('\n').map((line) => line.trim())
.find((line) => line && !line.startsWith('%%')) || '';
}

function lint(text, type) {
const findings = [];
text.split('\n').forEach((line, i) => {
if (line.trim().startsWith('%%')) {
return;
}
for (const rule of RULES) {
if (rule.where && !rule.where.test(type)) {
continue;
}
if (rule.test(line)) {
findings.push({ line: i + 1, id: rule.id, says: rule.says, text: line.trim() });
}
}
});
return findings;
}

async function loadMermaid() {
const require = createRequire(path.join(CACHE, 'noop.js'));
for (const attempt of [0, 1]) {
try {
const { JSDOM } = await import(require.resolve('jsdom'));
shim(new JSDOM('<!DOCTYPE html><body></body>').window);
return (await import(require.resolve('mermaid'))).default;
} catch (error) {
if (attempt) {
throw error;
}
fs.mkdirSync(CACHE, { recursive: true });
execFileSync('npm', ['install', '--no-save', '--no-audit', '--no-fund',
'--silent', '--prefix', CACHE, ...PACKAGES], { stdio: 'pipe' });
}
}
}

// Mermaid sanitizes every label through DOMPurify, which needs a document.
function shim(window) {
globalThis.window = window;
globalThis.document = window.document;
Object.defineProperty(globalThis, 'navigator',
{ value: window.navigator, configurable: true });
for (const name of ['Element', 'SVGElement', 'Node', 'HTMLElement', 'DOMParser',
'XMLSerializer', 'MutationObserver', 'getComputedStyle']) {
if (window[name] !== undefined) {
globalThis[name] = window[name];
}
}
}

// The parse error names a line of the figure; quoting that line saves the
// reader from counting.
function quote(text, message) {
const at = message.match(/^Parse error on line (\d+)|^Lexical error on line (\d+)/);
if (!at) {
return '';
}
const line = (text.split('\n')[Number(at[1] || at[2]) - 1] || '').trim();
return line ? `\n line ${at[1] || at[2]}: ${line}` : '';
}

function blocks(file) {
const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
if (!file.endsWith('.md')) {
return [{ name: file, text }];
}
const found = [...text.matchAll(/```+mermaid\s*\n([\s\S]*?)```+/g)];
return found.map((match, i) => ({ name: `${file} block ${i + 1}`, text: match[1] }));
}

const files = process.argv.slice(2);
if (!files.length) {
console.error('usage: validate-mermaid.mjs <file.mmd|file.md|-> ...');
process.exit(64);
}

let mermaid;
let loadError;
try {
mermaid = await loadMermaid();
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
} catch (error) {
loadError = error;
}

let failed = false;
let linted = 0;
for (const file of files) {
for (const { name, text } of blocks(file)) {
linted += 1;
const problems = lint(text, typeOf(text)).map((f) => ` lint ${name}: line ${f.line}, ${f.says}\n ${f.text}`);
let parse = null;
if (mermaid) {
try {
await mermaid.parse(text);
} catch (error) {
const message = (error && (error.message || error.str)) || String(error);
parse = ` PARSE ${name}\n ${message.split('\n').slice(0, 2).join(' ')}${quote(text, message)}`;
}
}
if (parse) {
failed = true;
console.log(parse);
}
if (problems.length) {
failed = true;
console.log(problems.join('\n'));
}
if (!parse && !problems.length) {
console.log(` OK ${name}`);
}
}
}

if (!linted) {
console.log('no mermaid block found');
}
if (loadError) {
console.log(`\nthe grammar check did not run: ${loadError.message.split('\n')[0]}`);
console.log('only the lint rules above were applied — check the figure by hand');
process.exit(2);
}
process.exit(failed ? 1 : 0);
11 changes: 11 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ The diagram is a Mermaid block, which GitHub renders inline in the
comment. A re-run hides the previous comment instead of stacking another
diagram onto the conversation.

A block that does not parse renders as an error box rather than a
picture, so the bot checks its figure before posting with
`.github/scripts/validate-mermaid.mjs`, which parses it with the same
Mermaid version GitHub uses and reports the line at fault. The script
takes a `.mmd` file, a `.md` file whose fenced `mermaid` blocks it
extracts, or the figure on stdin, and it is useful by hand too:

```bash
node .github/scripts/validate-mermaid.mjs .github/workflows/diagram-bot.md
```

To ask for a diagram on a pull request the bot passed over, add the
`diagram` label. That skips the decision and draws the most useful figure
the change supports.
Expand Down
32 changes: 27 additions & 5 deletions .github/workflows/diagram-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,12 @@ Use one accent, not a palette — everything you mark is marked the same way. Ne
| `stateDiagram-v2` | Lifecycle and state machines: attach/detach, connection state, navigation phases. |
| `classDiagram` | Only when the type relationships themselves are the change. |

**Syntax that survives GitHub's renderer.** The quoting rule differs per diagram type, and getting it wrong either breaks the render or draws the quotes:
**Syntax that survives GitHub's renderer.** Every figure this bot has failed to render in this repository broke on one of the first three rules. Step 4 checks them mechanically; know them anyway, so the figure comes out right the first time.

- In `flowchart` and `classDiagram`, a node label containing punctuation, parentheses, `<`, `>`, `:` or `,` must be quoted: `A["StateTree.collectChanges()"]`.
- In `sequenceDiagram`, the text after `as`, after `:` on a message, and after `Note over X:` is free text. Do not quote it — the quotes would be drawn. Parentheses are fine there, but a second `:` in a message ends the label, so leave colons out of message text.
- In `flowchart` and `classDiagram`, **quote every label that is not a bare word — edge labels as much as node labels**. Parentheses are what usually breaks it, and `-->|calls foo() first|` fails exactly as `A[calls foo() first]` does. Write `A["StateTree.collectChanges()"]` and `-->|"calls collectChanges()"|`.
- **Never open a label with a backtick.** ``A["`@Push` moved earlier"]`` starts a Mermaid markdown string, and the rest of the label is a lexical error. Write `A["@Push moved earlier"]`.
- In `sequenceDiagram`, **no semicolon in message or note text**. A `;` ends the statement and what follows is read as a new one, so `Note over A,B: runs in the build JVM; agents travel via MAVEN_OPTS` does not parse. Use a comma, a dash, or a second `Note`.
- In `sequenceDiagram`, the text after `as`, after `:` on a message, and after `Note over X:` is free text. Do not quote it — the quotes would be drawn. Parentheses and a second `:` are fine there.
- Everywhere: no raw HTML, no `click` directives, no images, and no styling beyond the `classDef` form above. Keep node ids, class names and participant aliases short and alphanumeric.

**Lay a before/after pair out side by side.** Mermaid orders disconnected subgraphs however it likes: leave the two halves unconnected and they come out stacked, often with `After` on top. Pin the layout down instead — `flowchart LR` for the frame, one subgraph per side, `direction TB` inside both so neither side sprawls, and the invisible edge `Before ~~~ After` to fix which comes first:
Expand Down Expand Up @@ -227,7 +229,27 @@ sequenceDiagram
```
````

## Step 4 — Post, or do not
## Step 4 — Check that it renders

A figure that does not parse is worse than no figure: the reviewer gets a red error box where the picture should be. Write the block to a file and run the repository's validator, which parses it with the same Mermaid version GitHub renders comments with:

```bash
cat > /tmp/figure.mmd <<'MERMAID'
sequenceDiagram
...the figure, without the fences...
MERMAID
node .github/scripts/validate-mermaid.mjs /tmp/figure.mmd
```

It prints `OK` and exits 0 when the figure is sound. Otherwise every problem comes with the line it is on:

- A `PARSE` line is what GitHub would show as an error box instead of the picture. Fix the figure and run the validator again.
- A `lint` line is a figure that parses but draws something other than what it says. Fix it too.
- Fix the figure, never the check.
- If it still does not pass after three attempts, `noop` with the parse error as the reason. Never post a figure you could not get to parse.
- Exit code 2 means the validator could not reach npm and only its lint rules ran. Read the syntax rules in step 3 against your figure line by line before you post.

## Step 5 — Post, or do not

When you decided to draw, add exactly one comment in this shape:

Expand All @@ -245,7 +267,7 @@ When you decided not to draw, call the `noop` tool with a one-sentence reason, f
1. Every node is a symbol you actually read in this repository. Nothing is invented, and nothing rests on the linked issue alone.
2. Every arrow carries a label naming something the code does.
3. The figure shows what the change is about, not the surrounding subsystem.
4. Twelve nodes or fewer; labels with punctuation are quoted; no HTML, no `%%{init}%%`; every `classDef` that sets `fill:` also sets `color:`.
4. `validate-mermaid.mjs` printed `OK` for the figure exactly as you are about to post it. Twelve nodes or fewer; no HTML, no `%%{init}%%`; every `classDef` that sets `fill:` also sets `color:`.
5. A before/after pair reads left to right — both halves are subgraphs of one `flowchart LR`, joined by `Before ~~~ After`, and neither sits above the other.
6. The caption makes one claim, attributes or marks its intent statement, and contains no verdict and no instruction to the reviewer.
7. If a check fails and you cannot fix it, `noop` instead of posting.
Loading