mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
423 lines
13 KiB
Python
Executable File
423 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Extract mermaid code blocks from Markdown files and convert them to SVG images.
|
|
|
|
Requires: Node.js/npx + Chrome (auto-installed via --install)
|
|
|
|
Usage:
|
|
# Environment setup (first time)
|
|
python3 scripts/extract_mermaid.py --install
|
|
|
|
# Environment check
|
|
python3 scripts/extract_mermaid.py --check
|
|
|
|
# Convert all *-overview*.md in default docs directory
|
|
python3 scripts/extract_mermaid.py
|
|
|
|
# Convert single file, output to default images/ directory
|
|
python3 scripts/extract_mermaid.py docs/xxx.md
|
|
|
|
# Convert single file, specify output directory
|
|
python3 scripts/extract_mermaid.py docs/xxx.md my_svgs/
|
|
|
|
# Scan all .md in a directory
|
|
python3 scripts/extract_mermaid.py docs/implemented/
|
|
|
|
# Scan directory, specify output
|
|
python3 scripts/extract_mermaid.py docs/implemented/ my_svgs/
|
|
"""
|
|
|
|
import re
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import argparse
|
|
|
|
# Chrome binary path discovery
|
|
PUPPETEER_CACHE = os.environ.get(
|
|
"PUPPETEER_CACHE_DIR",
|
|
os.path.expanduser("~/.cache/puppeteer"),
|
|
)
|
|
|
|
DEFAULT_CHROME_PATH = None
|
|
|
|
|
|
def _discover_chrome() -> str | None:
|
|
"""Find Chrome binary in puppeteer cache directory."""
|
|
global DEFAULT_CHROME_PATH
|
|
if not os.path.exists(PUPPETEER_CACHE):
|
|
return None
|
|
for root, dirs, files in os.walk(PUPPETEER_CACHE):
|
|
for f in files:
|
|
if f == "chrome" and "chrome-linux64" in root:
|
|
DEFAULT_CHROME_PATH = os.path.join(root, f)
|
|
return DEFAULT_CHROME_PATH
|
|
return None
|
|
|
|
|
|
_discover_chrome()
|
|
|
|
MMDC_PACKAGE = "@mermaid-js/mermaid-cli@11.4.2"
|
|
IMG_DIR = "images"
|
|
|
|
|
|
def slug(text: str) -> str:
|
|
"""Convert text to a file-name-safe slug."""
|
|
text = text.lower().strip()
|
|
# Keep Chinese characters, replace others with hyphens
|
|
text = re.sub(r"[^\w\s一-鿿]", "", text)
|
|
text = re.sub(r"\s+", "-", text)
|
|
return text.strip("-")
|
|
|
|
|
|
def find_mermaid_blocks(content: str):
|
|
"""Yield (mermaid_code, section_heading, index) tuples."""
|
|
pattern = re.compile(r"```mermaid\n(.*?)```", re.DOTALL)
|
|
for i, m in enumerate(pattern.finditer(content)):
|
|
mermaid_code = m.group(1).strip()
|
|
# Find the nearest section heading above this block
|
|
before = content[: m.start()]
|
|
headings = re.findall(r"^##+ (.+)$", before, re.MULTILINE)
|
|
section = headings[-1] if headings else f"diagram_{i + 1}"
|
|
yield mermaid_code, section, i
|
|
|
|
|
|
def convert_mermaid_to_svg(
|
|
mermaid_code: str,
|
|
output_path: str,
|
|
chrome_path: str | None = None,
|
|
background: str = "transparent",
|
|
timeout: int = 30,
|
|
) -> bool:
|
|
"""Convert a mermaid code string to an SVG file."""
|
|
|
|
# Write mermaid code to a temp .mmd file
|
|
mmd_path = output_path + ".mmd"
|
|
with open(mmd_path, "w") as f:
|
|
f.write(mermaid_code)
|
|
|
|
try:
|
|
env = os.environ.copy()
|
|
chrome = chrome_path or DEFAULT_CHROME_PATH
|
|
if chrome:
|
|
env["PUPPETEER_EXECUTABLE_PATH"] = chrome
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"npx", "--yes", MMDC_PACKAGE,
|
|
"-i", mmd_path,
|
|
"-o", output_path,
|
|
"-b", background,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=env,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f" FAILED: {result.stderr.strip()[:200]}", file=sys.stderr)
|
|
return False
|
|
return True
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f" TIMEOUT after {timeout}s", file=sys.stderr)
|
|
return False
|
|
except FileNotFoundError:
|
|
print(
|
|
" ERROR: npx not found. Install Node.js first.",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
print(f" ERROR: {e}", file=sys.stderr)
|
|
return False
|
|
finally:
|
|
if os.path.exists(mmd_path):
|
|
os.remove(mmd_path)
|
|
|
|
|
|
def process_file(
|
|
md_path: str,
|
|
output_dir: str,
|
|
chrome_path: str | None = None,
|
|
) -> list[str]:
|
|
"""Process a single markdown file and return list of generated SVG paths."""
|
|
md_path = os.path.abspath(md_path)
|
|
if not os.path.isfile(md_path):
|
|
print(f"File not found: {md_path}", file=sys.stderr)
|
|
return []
|
|
|
|
with open(md_path) as f:
|
|
content = f.read()
|
|
|
|
stem = os.path.basename(md_path).replace(".en.md", "").replace(".md", "")
|
|
suffix = "-en" if md_path.endswith(".en.md") else ""
|
|
|
|
generated = []
|
|
for mermaid_code, section, idx in find_mermaid_blocks(content):
|
|
section_slug = slug(section)
|
|
img_name = f"{stem}{suffix}-{section_slug}.svg"
|
|
img_path = os.path.join(output_dir, img_name)
|
|
|
|
print(f" [{idx + 1}] {section} → {img_name} ...", end=" ")
|
|
sys.stdout.flush()
|
|
|
|
if convert_mermaid_to_svg(mermaid_code, img_path, chrome_path):
|
|
print("OK")
|
|
generated.append(img_path)
|
|
else:
|
|
print("")
|
|
|
|
return generated
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Extract mermaid diagrams from Markdown and convert to SVG."
|
|
)
|
|
parser.add_argument(
|
|
"source",
|
|
nargs="?",
|
|
default=None,
|
|
help=(
|
|
"Markdown file or directory to scan. "
|
|
"Defaults to all *-overview*.md in docs/gns3-copilot/implemented/."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"dest",
|
|
nargs="?",
|
|
default=None,
|
|
help="Output directory for SVGs (default: <source_dir>/images/).",
|
|
)
|
|
parser.add_argument(
|
|
"--chrome",
|
|
default=DEFAULT_CHROME_PATH,
|
|
help=f"Path to Chrome binary (auto-detected: {DEFAULT_CHROME_PATH})",
|
|
)
|
|
parser.add_argument(
|
|
"--background", "-b",
|
|
default="transparent",
|
|
help="SVG background color (default: transparent)",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout", "-t",
|
|
type=int,
|
|
default=30,
|
|
help="Timeout in seconds per diagram (default: 30)",
|
|
)
|
|
parser.add_argument(
|
|
"--check", "-c",
|
|
action="store_true",
|
|
help="Check environment and exit (no conversion).",
|
|
)
|
|
parser.add_argument(
|
|
"--install", "-i",
|
|
action="store_true",
|
|
help="Install missing dependencies (Chrome, mermaid-cli).",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# ── Environment install mode ──
|
|
if args.install:
|
|
print("=== Installing Dependencies ===\n")
|
|
install_ok = True
|
|
|
|
# 1. Check npx
|
|
try:
|
|
subprocess.run(["npx", "--version"], capture_output=True, timeout=10)
|
|
except FileNotFoundError:
|
|
print(" ERROR: Node.js/npx not found. Install Node.js first.")
|
|
print(" Visit: https://nodejs.org/")
|
|
sys.exit(1)
|
|
|
|
# 2. Install Chrome via puppeteer
|
|
chrome = args.chrome or DEFAULT_CHROME_PATH
|
|
if chrome and os.path.exists(chrome):
|
|
result = subprocess.run(
|
|
[chrome, "--version"], capture_output=True, text=True, timeout=10
|
|
)
|
|
version = result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
print(f" Chrome: already installed ({version})")
|
|
else:
|
|
print(" Chrome: installing via puppeteer...")
|
|
ret = subprocess.run(
|
|
["npx", "puppeteer", "browsers", "install", "chrome-headless-shell"],
|
|
timeout=120,
|
|
)
|
|
if ret.returncode == 0:
|
|
print(" Chrome: installed successfully")
|
|
# Re-discover Chrome path
|
|
_discover_chrome()
|
|
else:
|
|
print(" Chrome: installation failed")
|
|
install_ok = False
|
|
|
|
# 3. Pre-cache mermaid-cli
|
|
print(" mermaid-cli: caching...")
|
|
ret = subprocess.run(
|
|
["npx", "--yes", MMDC_PACKAGE, "--version"],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
if ret.returncode == 0:
|
|
print(" mermaid-cli: ready")
|
|
else:
|
|
print(" mermaid-cli: download failed")
|
|
install_ok = False
|
|
|
|
print(f"\n Result: {'✓ INSTALLATION COMPLETE' if install_ok else '✗ SOME INSTALLATIONS FAILED'}")
|
|
|
|
# Auto-run check after install
|
|
print()
|
|
args.check = True
|
|
# Fall through to check below (args.check is now True)
|
|
|
|
# ── Environment check mode ──
|
|
if args.check:
|
|
ok = True
|
|
|
|
print("=== Environment Check ===\n")
|
|
|
|
# 1. Python
|
|
print(f" Python: {sys.version.split()[0]}")
|
|
|
|
# 2. npx / Node.js
|
|
try:
|
|
result = subprocess.run(
|
|
["npx", "--version"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
if result.returncode == 0:
|
|
print(f" npx: {result.stdout.strip()}")
|
|
else:
|
|
print(" npx: NOT FOUND")
|
|
ok = False
|
|
except FileNotFoundError:
|
|
print(" npx: NOT FOUND (Node.js not installed)")
|
|
ok = False
|
|
|
|
# 3. Chrome
|
|
chrome = args.chrome or DEFAULT_CHROME_PATH
|
|
if chrome and os.path.exists(chrome):
|
|
try:
|
|
result = subprocess.run(
|
|
[chrome, "--version"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
version = result.stdout.strip() if result.returncode == 0 else "?"
|
|
print(f" Chrome: {version}")
|
|
print(f" Path: {chrome}")
|
|
except Exception:
|
|
print(f" Chrome: {chrome}")
|
|
else:
|
|
print(" Chrome: NOT FOUND")
|
|
print(" Run: npx puppeteer browsers install chrome-headless-shell")
|
|
ok = False
|
|
|
|
# 4. @mermaid-js/mermaid-cli
|
|
try:
|
|
result = subprocess.run(
|
|
["npx", "--yes", MMDC_PACKAGE, "--version"],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
if result.returncode == 0:
|
|
print(" mermaid-cli: available")
|
|
else:
|
|
print(" mermaid-cli: download failed")
|
|
ok = False
|
|
except Exception:
|
|
print(" mermaid-cli: FAILED")
|
|
ok = False
|
|
|
|
# 5. Puppeteer cache
|
|
if os.path.exists(PUPPETEER_CACHE):
|
|
print(f" Puppeteer cache: {PUPPETEER_CACHE}")
|
|
for item in sorted(os.listdir(PUPPETEER_CACHE)):
|
|
item_path = os.path.join(PUPPETEER_CACHE, item)
|
|
if os.path.isdir(item_path):
|
|
versions = os.listdir(item_path)
|
|
print(f" {item}: {', '.join(versions)}")
|
|
else:
|
|
print(f" Puppeteer cache: NOT FOUND")
|
|
|
|
print(f"\n Result: {'✓ ALL CHECKS PASSED' if ok else '✗ SOME CHECKS FAILED'}")
|
|
sys.exit(0 if ok else 1)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Determine source files
|
|
files_to_process = []
|
|
if args.source:
|
|
if os.path.isfile(args.source):
|
|
files_to_process.append(args.source)
|
|
elif os.path.isdir(args.source):
|
|
for f in sorted(os.listdir(args.source)):
|
|
if f.endswith(".md"):
|
|
files_to_process.append(os.path.join(args.source, f))
|
|
else:
|
|
print(f"Source not found: {args.source}", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
# Default: scan project docs directory
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
project_root = os.path.dirname(script_dir)
|
|
docs_dir = os.path.join(project_root, "docs", "gns3-copilot", "implemented")
|
|
if os.path.isdir(docs_dir):
|
|
files_to_process = sorted(
|
|
os.path.join(docs_dir, f)
|
|
for f in os.listdir(docs_dir)
|
|
if "-overview" in f and f.endswith(".md")
|
|
)
|
|
else:
|
|
print("No source specified and default docs directory not found.",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not files_to_process:
|
|
print("No markdown files found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Determine output directory
|
|
if args.dest:
|
|
output_base = args.dest
|
|
elif args.source and os.path.isfile(args.source):
|
|
output_base = os.path.join(os.path.dirname(args.source), IMG_DIR)
|
|
elif args.source and os.path.isdir(args.source):
|
|
output_base = os.path.join(args.source, IMG_DIR)
|
|
else:
|
|
# Default: use directory of first file
|
|
output_base = os.path.join(os.path.dirname(files_to_process[0]), IMG_DIR)
|
|
|
|
os.makedirs(output_base, exist_ok=True)
|
|
|
|
# Chrome check
|
|
chrome = args.chrome or DEFAULT_CHROME_PATH
|
|
if not chrome:
|
|
print(
|
|
"WARNING: Chrome not found in puppeteer cache. "
|
|
"Run: npx puppeteer browsers install chrome-headless-shell",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(f"Using Chrome: {chrome}")
|
|
print(f"Output: {output_base}")
|
|
|
|
print(f"\nProcessing {len(files_to_process)} file(s)...\n")
|
|
|
|
total_svgs = 0
|
|
for md_path in files_to_process:
|
|
rel = os.path.relpath(md_path)
|
|
print(f"== {rel} ==")
|
|
|
|
svgs = process_file(md_path, output_base, chrome)
|
|
total_svgs += len(svgs)
|
|
print()
|
|
|
|
print(f"Done. Generated {total_svgs} SVG(s).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|