diff --git a/docs/README.skills.md b/docs/README.skills.md
index aa492a833..dea68ab6c 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -273,6 +273,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [meeting-minutes](../skills/meeting-minutes/SKILL.md)
`gh skills install github/awesome-copilot meeting-minutes` | Generate concise, actionable meeting minutes for internal meetings. Includes metadata, attendees, agenda, decisions, action items (owner + due date), and follow-up steps. | None |
| [memory-merger](../skills/memory-merger/SKILL.md)
`gh skills install github/awesome-copilot memory-merger` | Merges mature lessons from a domain memory file into its instruction file. Syntax: `/memory-merger >domain [scope]` where scope is `global` (default), `user`, `workspace`, or `ws`. | None |
| [mentoring-juniors](../skills/mentoring-juniors/SKILL.md)
`gh skills install github/awesome-copilot mentoring-juniors` | Socratic mentoring for junior developers and AI newcomers. Guides through questions, never answers. Triggers: "help me understand", "explain this code", "I'm stuck", "Im stuck", "I'm confused", "Im confused", "I don't understand", "I dont understand", "can you teach me", "teach me", "mentor me", "guide me", "what does this error mean", "why doesn't this work", "why does not this work", "I'm a beginner", "Im a beginner", "I'm learning", "Im learning", "I'm new to this", "Im new to this", "walk me through", "how does this work", "what's wrong with my code", "what's wrong", "can you break this down", "ELI5", "step by step", "where do I start", "what am I missing", "newbie here", "junior dev", "first time using", "how do I", "what is", "is this right", "not sure", "need help", "struggling", "show me", "help me debug", "best practice", "too complex", "overwhelmed", "lost", "debug this", "/socratic", "/hint", "/concept", "/pseudocode". Progressive clue systems, teaching techniques, and success metrics. | None |
+| [mermaid-md](../skills/mermaid-md/SKILL.md)
`gh skills install github/awesome-copilot mermaid-md` | Extract every mermaid code block from a Markdown file (README, design doc, RFC) and render each one to a PNG/SVG/PDF image, optionally inserting the image under its block. Use when Markdown that already contains mermaid diagrams has to be published somewhere that cannot render them — Word, PDF, Confluence, slide decks, email — or when a diagram needs to be attached to a pull request or chat as a picture. | `scripts/mermaid_md.py` |
| [microsoft-agent-framework](../skills/microsoft-agent-framework/SKILL.md)
`gh skills install github/awesome-copilot microsoft-agent-framework` | Create, update, refactor, explain, or review Microsoft Agent Framework solutions using shared guidance plus language-specific references for .NET and Python. | `references/dotnet.md`
`references/python.md` |
| [microsoft-code-reference](../skills/microsoft-code-reference/SKILL.md)
`gh skills install github/awesome-copilot microsoft-code-reference` | Look up Microsoft API references, find working code samples, and verify SDK code is correct. Use when working with Azure SDKs, .NET libraries, or Microsoft APIs—to find the right method, check parameters, get working examples, or troubleshoot errors. Catches hallucinated methods, wrong signatures, and deprecated patterns by querying official docs. | None |
| [microsoft-docs](../skills/microsoft-docs/SKILL.md)
`gh skills install github/awesome-copilot microsoft-docs` | Query official Microsoft documentation to find concepts, tutorials, and code examples across Azure, .NET, Agent Framework, Aspire, VS Code, GitHub, and more. Uses Microsoft Learn MCP as the default, with Context7 and Aspire MCP for content that lives outside learn.microsoft.com. | None |
diff --git a/skills/mermaid-md/SKILL.md b/skills/mermaid-md/SKILL.md
new file mode 100644
index 000000000..8e5daec52
--- /dev/null
+++ b/skills/mermaid-md/SKILL.md
@@ -0,0 +1,241 @@
+---
+name: mermaid-md
+description: Extract every mermaid code block from a Markdown file (README, design doc, RFC) and render each one to a PNG/SVG/PDF image, optionally inserting the image under its block. Use when Markdown that already contains mermaid diagrams has to be published somewhere that cannot render them — Word, PDF, Confluence, slide decks, email — or when a diagram needs to be attached to a pull request or chat as a picture.
+---
+
+# Mermaid in Markdown to Images
+
+Renders the ` ```mermaid ` blocks that already live inside a Markdown file. One image per
+block, named after the block, with the Markdown left as the single source of truth.
+
+This is the opposite direction from authoring a diagram: nothing here writes new `.mmd`
+files. Syntax errors are reported against the block's line range **in the `.md`**, so the
+diagram gets fixed where it lives.
+
+## When to Use This Skill
+
+Use this skill when you need to:
+
+- Publish Markdown that contains mermaid diagrams to a target that cannot render them —
+ Word/`.docx`, PDF, Confluence, SharePoint, slide decks, email
+- Attach a diagram from a doc to a pull request, issue, or chat message as an image
+- Check in CI that every mermaid block in the docs still parses
+- Refresh the exported images after editing a diagram in the Markdown
+
+**Do not** use it when the target renders mermaid natively — GitHub, GitLab, Obsidian,
+Docusaurus and MkDocs Material all do. Leave the code block as text there; exported images
+go stale the moment the block changes.
+
+## Prerequisites
+
+| Requirement | Version | Notes |
+| --- | --- | --- |
+| **Python** | 3.8+ | Runs the bundled script; standard library only |
+| **Node.js** | 18+ | `mermaid-cli` is ESM-only |
+| **`@mermaid-js/mermaid-cli`** | 11+ | `npm install -g @mermaid-js/mermaid-cli` |
+| **Chrome / Chromium** | any recent | mermaid-cli renders through Puppeteer |
+
+```bash
+npm install -g @mermaid-js/mermaid-cli
+npx puppeteer browsers install chrome-headless-shell # skip if a system Chrome exists
+```
+
+Rendering is entirely local — no diagram content is sent to any service.
+
+## Core Capabilities
+
+### 1. Inventory the file before rendering
+
+`--list` reports every block with its index, line range, diagram type and title, and renders
+nothing. Cheap, and it tells you exactly what a doc contains.
+
+### 2. Render each block to an image
+
+PNG (default, `-s 2` scale for crisp text), SVG, or PDF. Filenames are derived from the
+block: `design-03-auth-flow.png`, taken from a `%% title:` comment, the diagram's
+front-matter `title:`, or the nearest Markdown heading. Accents are folded to ASCII, so
+non-English headings still produce readable filenames.
+
+### 3. Report failures against the Markdown
+
+A block that fails to parse is reported as `docs/design.md:633-654` — the block's line range
+in the source file — followed by mermaid's own message. Fix the block in the `.md`, then
+re-render only that block with `--only 3`.
+
+> mermaid's `Parse error on line N` counts tokens, not source lines. Trust the line range,
+> not that number.
+
+### 4. Insert the images back into the Markdown
+
+`--rewrite` writes a copy, `--in-place` updates the file. The mermaid block is **kept** and
+the image is added under it (`--rewrite-mode replace` swaps it out instead). Re-running
+refreshes the existing image line rather than stacking duplicates, so `--in-place` is safe
+in a pre-commit hook.
+
+### 5. Validate in CI
+
+`--check` renders every block into a temporary directory, keeps nothing, and exits non-zero
+if any block fails.
+
+### 6. Resolve its own toolchain
+
+`mmdc` is a `#!/usr/bin/env node` script, so an active conda/nvm environment with an old
+Node hijacks it and fails with `SyntaxError: Unexpected token import`. The script checks
+`node -v` first and, when it is too old, runs mermaid-cli's entry point through a newer Node
+it finds (`/usr/bin/node`, `/usr/local/bin`, `/opt/homebrew/bin`, nvm, volta). It resolves
+Chrome the same way — Puppeteer's cached browser *or* a system Chrome/Chromium/Edge — and
+retries with `--no-sandbox` when the sandbox is unavailable (root, containers). Both choices
+are printed:
+
+```text
+renderer: mmdc (node v20 /usr/bin/node, chrome: /usr/bin/google-chrome)
+```
+
+## Usage Examples
+
+### Example 1: Export a design doc's diagrams
+
+```bash
+# 1. What's in the file?
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md --list
+
+# 2. Render every block into docs/assets/
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md -o docs/assets/
+```
+
+```text
+7 mermaid block(s) in docs/design.md
+
+ [1] lines 12-31 sequence Login flow
+ [2] lines 58-77 flowchart Ingest pipeline
+ ...
+
+renderer: mmdc (node v20, chrome: /usr/bin/google-chrome)
+OK [1] docs/assets/design-1-login-flow.png
+OK [2] docs/assets/design-2-ingest-pipeline.png
+7 rendered, 0 failed
+```
+
+### Example 2: Fix a broken block and re-render only that one
+
+```text
+FAIL [3] docs/design.md:88-104: Error: Parse error on line 4: | Expecting 'SQE', ... got 'PS'
+```
+
+The label contains parentheses, so it needs quotes — edit line 88-104 in `docs/design.md`:
+
+```mermaid
+flowchart LR
+ A["Fetch offers (throttled)"] --> B[Store]
+```
+
+```bash
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md -o docs/assets/ --only 3
+```
+
+### Example 3: Keep images embedded in the doc
+
+```bash
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md -o docs/assets/ --in-place
+```
+
+````markdown
+```mermaid
+sequenceDiagram
+ C->>S: login
+```
+
+
+````
+
+### Example 4: Gate the docs in CI
+
+```bash
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md --check # exits 1 on failure
+```
+
+## Options
+
+| Flag | Meaning |
+| --- | --- |
+| `--list` | List blocks (index, line range, type, title) — renders nothing |
+| `--check` | Validate only, into a temp dir; non-zero exit if any block fails |
+| `-o, --outdir` | Image output directory (default `.`) |
+| `-f, --format` | `png` (default) / `svg` / `pdf` |
+| `--only` | Subset by index: `3`, `2,5`, `2-4,7` |
+| `--prefix` | Image name prefix (default: the Markdown file's stem) |
+| `-s, --scale` | Pixel scale, default 2 for PNG — the sharpness knob |
+| `-w, --width` | Render viewport width, default 2048 (affects wrapping, not resolution) |
+| `-t, --theme` | `default` / `dark` / `neutral` / `forest` |
+| `-b, --background` | Background color, default `white` (`transparent` for slides) |
+| `-c, --config` | Mermaid config JSON (fonts, `themeVariables`, …) |
+| `-p, --puppeteer-config` | Puppeteer config JSON for custom browser flags |
+| `--chrome`, `--node` | Pin the Chrome / Node binary explicitly |
+| `--rewrite [OUT.md]` | Markdown copy with image links (default `.rendered.md`) |
+| `--in-place` | Rewrite the input file itself |
+| `--rewrite-mode` | `append` (default) keeps the block; `replace` swaps it for the image |
+
+## Guidelines
+
+1. **Never split blocks into `.mmd` files** — render straight from the `.md`. The script
+ handles extraction, and keeping one source avoids two copies drifting apart.
+2. **`--list` before rendering** — a 40-page RFC may hold 20 diagrams; know the scope first.
+3. **Fix at the reported line range, then `--only N`** — re-rendering everything after one
+ edit wastes a browser launch per block.
+4. **Put images next to the doc** (`docs/assets/`) — paths in the rewritten Markdown are
+ relative to the output file, so images outside the doc tree produce fragile `../../..`
+ links.
+5. **Look at the output** — valid syntax is not readable output. Check for clipped labels,
+ cramped layout, or a wrong orientation, and fix the block (`
` wrapping, `TD`↔`LR`,
+ `subgraph` grouping).
+6. **Confirm before `--in-place`** — it rewrites the user's document. `--rewrite` produces a
+ separate file and is the safer default.
+7. **`Could not find Chrome` is a setup error, not a diagram error** — do not rewrite valid
+ mermaid to chase it. Install a browser or pass `--chrome`.
+
+## Common Patterns
+
+### Pattern: Markdown → images → Word
+
+Pairs with the `md-to-docx` skill, which embeds PNGs referenced by the Markdown:
+
+```bash
+python3 skills/mermaid-md/scripts/mermaid_md.py report.md -o assets/ --rewrite report.docx.md
+node skills/md-to-docx/scripts/md-to-docx.mjs report.docx.md report.docx
+```
+
+Use `--rewrite-mode replace` for that intermediate file so the `.docx` gets the picture
+instead of a wall of diagram source.
+
+### Pattern: pre-commit hook
+
+```bash
+# Refresh every diagram image; the rewrite is idempotent, so this is a no-op when nothing changed
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/architecture.md -o docs/assets/ --in-place
+git add docs/assets docs/architecture.md
+```
+
+### Pattern: dark-theme images for a dark site
+
+```bash
+python3 skills/mermaid-md/scripts/mermaid_md.py docs/design.md -o docs/assets/ \
+ -t dark -b transparent
+```
+
+## What Counts as a Block
+
+Fenced with ` ``` ` or `~~~`, info string starting with `mermaid`. Correctly **skipped**:
+mermaid fences nested inside a longer outer fence (documentation showing mermaid examples),
+fences in other languages, and YAML front matter. Blocks indented inside list items are
+picked up and de-indented.
+
+## Limitations
+
+- **Requires a real browser** — mermaid-cli renders through Puppeteer; there is no
+ pure-Python fallback. A headless-Chrome-free environment cannot use this skill.
+- **PDF output needs mermaid-cli's PDF path** and produces one file per block, not a
+ combined document.
+- **Images are a copy of the diagram** — they go stale when the block changes. Re-run the
+ script (or wire `--check` into CI) rather than hand-editing exported files.
+- **No layout control** — mermaid does its own layout. Readability is improved by editing
+ the diagram (direction, grouping, shorter labels), not by flags.
diff --git a/skills/mermaid-md/scripts/mermaid_md.py b/skills/mermaid-md/scripts/mermaid_md.py
new file mode 100755
index 000000000..bfafea173
--- /dev/null
+++ b/skills/mermaid-md/scripts/mermaid_md.py
@@ -0,0 +1,501 @@
+#!/usr/bin/env python3
+"""Extract ```mermaid blocks from a Markdown file and render each one to an image.
+
+Usage:
+ python3 mermaid_md.py doc.md --list
+ python3 mermaid_md.py doc.md -o assets/
+ python3 mermaid_md.py doc.md -o assets/ --rewrite doc.rendered.md
+ python3 mermaid_md.py doc.md --only 2,5
+
+Rendering always goes through the mmdc CLI. Exit code is non-zero if any
+selected block fails to render.
+"""
+
+from __future__ import annotations
+
+import argparse
+import glob
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import unicodedata
+
+FENCE_RE = re.compile(r'^(?P[ \t]{0,3})(?P`{3,}|~{3,})[ \t]*(?P.*)$')
+HEADING_RE = re.compile(r'^[ \t]{0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$')
+TITLE_RE = re.compile(r'^\s*%%\s*(?:title|name)\s*:\s*(.+?)\s*$', re.IGNORECASE)
+YAML_TITLE_RE = re.compile(r'^\s*title\s*:\s*(.+?)\s*$', re.IGNORECASE)
+
+# First keyword of a mermaid diagram -> friendly type name.
+TYPES = [
+ ('flowchart', 'flowchart'), ('graph', 'flowchart'), ('sequencediagram', 'sequence'),
+ ('classdiagram', 'class'), ('erdiagram', 'er'), ('statediagram', 'state'),
+ ('gantt', 'gantt'), ('pie', 'pie'), ('gitgraph', 'gitgraph'), ('journey', 'journey'),
+ ('mindmap', 'mindmap'), ('timeline', 'timeline'), ('quadrantchart', 'quadrant'),
+ ('requirementdiagram', 'requirement'), ('c4context', 'c4'), ('c4container', 'c4'),
+ ('c4component', 'c4'), ('architecture-beta', 'architecture'), ('usecase-beta', 'usecase'),
+ ('cynefin-beta', 'cynefin'), ('eventmodeling', 'eventmodeling'), ('treeview-beta', 'treeview'),
+ ('wardley-beta', 'wardley'), ('sankey-beta', 'sankey'), ('xychart-beta', 'xychart'),
+ ('block-beta', 'block'), ('packet-beta', 'packet'), ('kanban', 'kanban'), ('radar-beta', 'radar'),
+]
+
+
+def first_error_line(raw):
+ """Pull the informative lines out of mmdc's stderr (skipping the stack trace)."""
+ text = raw.decode('utf-8', 'replace') if isinstance(raw, bytes) else (raw or '')
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
+ lines = [l for l in lines if not l.startswith('at ') and 'node_modules' not in l]
+ picked = [l for l in lines
+ if re.search(r'error|cannot|could not|unsupported|expecting', l, re.IGNORECASE)]
+ if not picked:
+ return lines[0][:300] if lines else ''
+ return ' | '.join(l[:200] for l in picked[:2])[:400]
+
+
+class Block:
+ """One ```mermaid fenced block found in the Markdown source."""
+
+ source = '' # set to the Markdown path once parsed
+
+ def __init__(self, index, code, start_line, end_line, heading, indent):
+ self.index = index # 1-based order in the file
+ self.code = code # diagram source, fence stripped
+ self.start_line = start_line # 1-based line of the opening fence
+ self.end_line = end_line # 1-based line of the closing fence
+ self.heading = heading # nearest preceding Markdown heading, or ''
+ self.indent = indent # leading whitespace of the opening fence
+ self.out_path = None
+ self.error = None
+
+ @property
+ def diagram_type(self):
+ lines = self.code.splitlines()
+ if lines and lines[0].strip() == '---': # skip YAML front matter
+ end = next((i for i, l in enumerate(lines[1:], 1) if l.strip() == '---'), 0)
+ lines = lines[end + 1:]
+ for line in lines:
+ s = line.strip()
+ if not s or s.startswith('%%'):
+ continue
+ head = s.split()[0].lower().rstrip(':')
+ for key, name in TYPES:
+ if head.startswith(key):
+ return name
+ return head[:20]
+ return 'empty'
+
+ @property
+ def title(self):
+ """%% title: ... comment, then front-matter title:, then nearest heading."""
+ lines = self.code.splitlines()
+ for line in lines[:3]:
+ m = TITLE_RE.match(line)
+ if m:
+ return m.group(1)
+ if lines and lines[0].strip() == '---':
+ for line in lines[1:]:
+ if line.strip() == '---':
+ break
+ m = YAML_TITLE_RE.match(line)
+ if m:
+ return m.group(1).strip('"\'')
+ return self.heading
+
+
+def slugify(text, fallback=''):
+ """ASCII filename slug; folds accents so non-English headings stay readable."""
+ text = (text or '').replace('\u0111', 'd').replace('\u0110', 'D') # đ/Đ have no NFKD form
+ text = unicodedata.normalize('NFKD', text)
+ text = ''.join(c for c in text if not unicodedata.combining(c))
+ slug = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
+ return slug[:40].rstrip('-') or fallback
+
+
+def extract(md_text):
+ """Return the list of mermaid Blocks, ignoring mermaid fences nested in other fences."""
+ lines = md_text.splitlines()
+ blocks, heading = [], ''
+ open_fence = None # (char, length, indent, is_mermaid, start_line, heading)
+ buf = []
+ in_front_matter = bool(lines) and lines[0].strip() == '---'
+
+ for i, line in enumerate(lines):
+ n = i + 1
+ if in_front_matter:
+ if n > 1 and line.strip() in ('---', '...'):
+ in_front_matter = False
+ continue
+
+ m = FENCE_RE.match(line)
+ if open_fence is None:
+ if m:
+ char, length = m.group('fence')[0], len(m.group('fence'))
+ info = m.group('info').strip()
+ # A tilde fence's info string may contain backticks; a backtick fence's may not.
+ is_mermaid = bool(re.match(r'^mermaid\b', info, re.IGNORECASE)) and (
+ char == '~' or '`' not in info)
+ open_fence = (char, length, m.group('indent'), is_mermaid, n, heading)
+ buf = []
+ else:
+ h = HEADING_RE.match(line)
+ if h:
+ heading = h.group(2).strip()
+ continue
+
+ char, length, indent, is_mermaid, start, blk_heading = open_fence
+ # Closing fence: same char, at least as long, nothing after it.
+ if m and m.group('fence')[0] == char and len(m.group('fence')) >= length \
+ and not m.group('info').strip():
+ if is_mermaid:
+ blocks.append(Block(len(blocks) + 1, '\n'.join(buf).strip('\n'),
+ start, n, blk_heading, indent))
+ open_fence = None
+ continue
+ if is_mermaid:
+ buf.append(line[len(indent):] if line.startswith(indent) else line.lstrip())
+
+ if open_fence is not None and open_fence[3]: # unterminated mermaid fence
+ char, length, indent, _, start, blk_heading = open_fence
+ blocks.append(Block(len(blocks) + 1, '\n'.join(buf).strip('\n'),
+ start, len(lines), blk_heading, indent))
+ return blocks
+
+
+def locate(block):
+ """Where the block lives in the Markdown file.
+
+ Mermaid's own "Parse error on line N" counts tokens, not source lines, so it is not
+ mapped onto the Markdown — the block's line range is the reliable pointer.
+ """
+ return '%s:%d-%d' % (block.source, block.start_line, block.end_line)
+
+
+def parse_only(spec, total):
+ """'2,5,7-9' -> sorted set of 1-based indices within range."""
+ if not spec:
+ return set(range(1, total + 1))
+ picked = set()
+ for part in spec.split(','):
+ part = part.strip()
+ if not part:
+ continue
+ if '-' in part:
+ a, b = part.split('-', 1)
+ picked.update(range(int(a), int(b) + 1))
+ else:
+ picked.add(int(part))
+ return {i for i in picked if 1 <= i <= total}
+
+
+CHROME_CANDIDATES = [
+ 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser',
+ 'chrome', 'microsoft-edge', 'microsoft-edge-stable',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
+]
+
+# mermaid-cli 11 is ESM-only: an old `node` on PATH dies with "Unexpected token import".
+MIN_NODE = 18
+NODE_CANDIDATES = ['/usr/bin/node', '/bin/node', '/usr/local/bin/node',
+ '/opt/homebrew/bin/node', '/usr/local/n/versions/node/*/bin/node',
+ os.path.expanduser('~/.nvm/versions/node/*/bin/node'),
+ os.path.expanduser('~/.volta/tools/image/node/*/bin/node')]
+
+_MMDC = {} # resolved once: {'env':…, 'extra':…, 'chrome':…, 'cmd':…, 'node':…}
+PROBE = 'flowchart LR\n A-->B\n'
+
+
+def _run_mmdc(cmd, env=None, timeout=300):
+ full = dict(os.environ)
+ full.update(env or {})
+ return subprocess.run(cmd, capture_output=True, timeout=timeout, env=full)
+
+
+def _node_major(path):
+ """Major version of a node binary, or None if it doesn't run."""
+ try:
+ r = subprocess.run([path, '-v'], capture_output=True, timeout=30)
+ except (OSError, subprocess.SubprocessError):
+ return None
+ m = re.match(r'v(\d+)', r.stdout.decode('utf-8', 'replace').strip())
+ return int(m.group(1)) if r.returncode == 0 and m else None
+
+
+def resolve_node(args):
+ """Pick the node that will run mmdc.
+
+ `mmdc` is a `#!/usr/bin/env node` script, so an active conda/nvm environment with an
+ ancient node hijacks it. Returns (cmd_prefix, env_patch, label).
+ """
+ if args.node:
+ major = _node_major(args.node)
+ if major is None or major < MIN_NODE:
+ sys.exit('--node %s is not a usable node (need >= v%d)' % (args.node, MIN_NODE))
+ return [args.node], {'PATH': os.path.dirname(os.path.abspath(args.node))
+ + os.pathsep + os.environ.get('PATH', '')}, \
+ 'node v%d %s' % (major, args.node)
+
+ current = shutil.which('node')
+ major = _node_major(current) if current else None
+ if major is not None and major >= MIN_NODE:
+ return [], {}, 'node v%d' % major
+
+ found = []
+ for pattern in NODE_CANDIDATES:
+ for path in (glob.glob(pattern) if '*' in pattern else [pattern]):
+ if os.path.exists(path) and path != current:
+ m = _node_major(path)
+ if m is not None and m >= MIN_NODE:
+ found.append((m, path))
+ if not found:
+ sys.exit('No node >= v%d found (mermaid-cli is ESM-only).%s\n'
+ 'Fix with one of:\n'
+ ' conda deactivate # an active env often shadows the system node\n'
+ ' nvm use 20\n'
+ ' --node /path/to/node' % (
+ MIN_NODE,
+ ' Current `node` is v%d.' % major if major is not None else ''))
+ found.sort(reverse=True)
+ best_major, best = found[0]
+ return [best], {'PATH': os.path.dirname(best) + os.pathsep + os.environ.get('PATH', '')}, \
+ 'node v%d %s' % (best_major, best)
+
+
+def _try_probe(base, env, extra):
+ """Render a trivial diagram; return None on success, else the error text."""
+ with tempfile.TemporaryDirectory() as tmp:
+ src, out = os.path.join(tmp, 'p.mmd'), os.path.join(tmp, 'p.svg')
+ with open(src, 'w') as fh:
+ fh.write(PROBE)
+ try:
+ r = _run_mmdc(base + ['-i', src, '-o', out] + extra, env=env, timeout=180)
+ except (OSError, subprocess.SubprocessError) as exc:
+ return str(exc)
+ if r.returncode == 0 and os.path.exists(out) and os.path.getsize(out) > 0:
+ return None
+ return (r.stderr or r.stdout).decode('utf-8', 'replace')
+
+
+def _no_sandbox_config():
+ """A puppeteer config enabling --no-sandbox (needed as root / in containers)."""
+ fd, path = tempfile.mkstemp(prefix='puppeteer-', suffix='.json')
+ with os.fdopen(fd, 'w') as fh:
+ fh.write('{"args": ["--no-sandbox", "--disable-setuid-sandbox"]}')
+ return path
+
+
+def setup_mmdc(args):
+ """Find a working mmdc + Chrome combination once, or exit with a clear message.
+
+ Order: an explicit --chrome / PUPPETEER_EXECUTABLE_PATH, then puppeteer's own
+ cached browser, then a system Chrome/Chromium/Edge on PATH.
+ """
+ if _MMDC:
+ return _MMDC
+ mmdc = shutil.which('mmdc')
+ if not mmdc:
+ sys.exit('mmdc not found. Install it:\n'
+ ' npm install -g @mermaid-js/mermaid-cli\n'
+ ' npx puppeteer browsers install chrome-headless-shell')
+
+ node_cmd, node_env, node_label = resolve_node(args)
+ # Invoking the CLI's real entry point through the chosen node bypasses the shebang.
+ base = node_cmd + [os.path.realpath(mmdc)] if node_cmd else [mmdc]
+
+ explicit = args.chrome or os.environ.get('PUPPETEER_EXECUTABLE_PATH')
+ if explicit:
+ candidates = [(explicit, 'chrome: %s' % explicit)]
+ else:
+ candidates = [(None, 'chrome: puppeteer cache')]
+ seen = set()
+ for name in CHROME_CANDIDATES:
+ path = shutil.which(name) if os.sep not in name else (
+ name if os.path.exists(name) else None)
+ if path and path not in seen:
+ seen.add(path)
+ candidates.append((path, 'chrome: %s' % path))
+
+ errors = []
+ for path, label in candidates:
+ env = dict(node_env)
+ if path:
+ env['PUPPETEER_EXECUTABLE_PATH'] = path
+ for extra in ([], ['-p', _no_sandbox_config()]):
+ err = _try_probe(base, env, extra)
+ if err is None:
+ _MMDC.update(env=env, extra=extra, chrome=label, cmd=base, node=node_label)
+ return _MMDC
+ errors.append('%s%s -> %s' % (label, ' (--no-sandbox)' if extra else '',
+ first_error_line(err) or 'failed'))
+ if 'sandbox' not in err.lower():
+ break
+
+ sys.exit('mmdc cannot render — no usable Chrome found.\n ' + '\n '.join(errors) +
+ '\n\nFix with one of:\n'
+ ' npx puppeteer browsers install chrome-headless-shell\n'
+ ' --chrome /path/to/chrome (or export PUPPETEER_EXECUTABLE_PATH)')
+
+
+def render(code, out_path, args):
+ """Render one diagram with mmdc. Returns None on success, else an error string."""
+ setup = setup_mmdc(args)
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, 'diagram.mmd')
+ with open(src, 'w') as fh:
+ fh.write(code + '\n')
+ cmd = setup['cmd'] + ['-i', src, '-o', out_path, '-b', args.background] + setup['extra']
+ if args.format != 'pdf':
+ cmd += ['-w', str(args.width)]
+ if args.theme:
+ cmd += ['-t', args.theme]
+ scale = args.scale if args.scale else (2 if args.format == 'png' else None)
+ if scale:
+ cmd += ['-s', str(scale)]
+ if args.config:
+ cmd += ['-c', args.config]
+ if args.puppeteer_config:
+ cmd += ['-p', args.puppeteer_config]
+ try:
+ r = _run_mmdc(cmd, env=setup['env'])
+ except (OSError, subprocess.SubprocessError) as exc:
+ return 'mmdc failed: %s' % exc
+ if r.returncode != 0 or not os.path.exists(out_path):
+ return first_error_line(r.stderr or r.stdout) or 'mmdc failed'
+ return None
+
+
+IMG_LINE_RE = re.compile(r'^\s*(!\[[^\]]*\]\([^)]*\)|![b.title]()