Skip to main content

dmc_transform/builtin/
mermaid.rs

1use crate::pipeline::Transformer;
2use crate::visit::{NodeAction, Visitor, walk_root};
3use dmc_diagnostic::Code;
4use dmc_diagnostic::metadata::SourceMeta;
5use dmc_parser::ast::*;
6use duck_diagnostic::{Diagnostic, Label};
7use std::collections::HashMap;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::{Command, Stdio};
11use std::sync::{Mutex, OnceLock};
12
13/// Render `mermaid` code blocks to inline SVG via the external `mmdc` CLI
14/// (`@mermaid-js/mermaid-cli`). On success the `CodeBlock` is replaced with
15/// `<MermaidSvg svg="..." />`. No-ops with [`Code::MmdcUnavailable`] when
16/// the CLI is missing; per-block failures emit [`Code::MermaidRenderFailed`].
17#[derive(Default)]
18pub struct Mermaid {
19  /// Reserved for a future "write SVGs to disk + reference them" mode.
20  pub output_dir: Option<PathBuf>,
21  /// Rendered-SVG cache keyed by `code_block.hash`.
22  cache: Mutex<HashMap<u64, String>>,
23}
24
25/// One-shot CLI availability probe.
26static MMDC_AVAILABLE: OnceLock<bool> = OnceLock::new();
27
28impl Mermaid {
29  pub fn with_output(p: impl Into<PathBuf>) -> Self {
30    Self { output_dir: Some(p.into()), cache: Mutex::new(HashMap::new()) }
31  }
32
33  fn mmdc_available() -> bool {
34    *MMDC_AVAILABLE.get_or_init(|| {
35      Command::new("mmdc")
36        .arg("--version")
37        .stdout(Stdio::null())
38        .stderr(Stdio::null())
39        .status()
40        .map(|s| s.success())
41        .unwrap_or(false)
42    })
43  }
44
45  pub fn render_cached(&self, source: &str) -> Result<String, String> {
46    let key = {
47      use std::hash::{Hash, Hasher};
48      let mut hasher = std::collections::hash_map::DefaultHasher::new();
49      source.hash(&mut hasher);
50      hasher.finish()
51    };
52
53    // L1: in-memory cache
54    if let Some(svg) = self.cache.lock().unwrap().get(&key) {
55      return Ok(svg.clone());
56    }
57
58    // L2: disk cache
59    if let Some(dir) = &self.output_dir {
60      let path = dir.join(format!("{key}.svg"));
61      match std::fs::read_to_string(&path) {
62        Ok(s) => return Ok(s),
63        Err(e) => {
64          if e.kind() != std::io::ErrorKind::NotFound {
65            return Err(e.to_string());
66          }
67        },
68      }
69    }
70
71    let svg = Self::render_mmdc(source)?;
72    self.cache.lock().unwrap().insert(key, svg.clone());
73    if let Some(dir) = &self.output_dir {
74      let path = dir.join(format!("{key}.svg"));
75      let _ = std::fs::write(&path, &svg).map_err(|e| e.to_string());
76    }
77
78    Ok(svg)
79  }
80
81  /// Returns captured stderr (or a synthesised reason) on failure.
82  /// TODO: support `--background`, `--theme`, and `--configFile` flags.
83  fn render_mmdc(source: &str) -> Result<String, String> {
84    let mut child = Command::new("mmdc")
85      .args(["--input", "-", "--output", "-", "--outputFormat", "svg"])
86      .stdin(Stdio::piped())
87      .stdout(Stdio::piped())
88      .stderr(Stdio::piped())
89      .spawn()
90      .map_err(|e| format!("spawn failed: {e}"))?;
91    child
92      .stdin
93      .as_mut()
94      .ok_or_else(|| "no stdin handle".to_string())?
95      .write_all(source.as_bytes())
96      .map_err(|e| format!("stdin write failed: {e}"))?;
97    let out = child.wait_with_output().map_err(|e| format!("wait failed: {e}"))?;
98    if !out.status.success() {
99      let err = String::from_utf8_lossy(&out.stderr).into_owned();
100      return Err(if err.is_empty() { format!("exit {}", out.status) } else { err });
101    }
102    String::from_utf8(out.stdout).map_err(|e| format!("non-utf8 svg: {e}"))
103  }
104}
105
106impl Transformer for Mermaid {
107  fn name(&self) -> &str {
108    "mermaid"
109  }
110  fn transform(
111    &self,
112    doc: &mut Document,
113    _meta: &SourceMeta,
114    diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
115  ) {
116    if !Self::mmdc_available() {
117      diag_engine.emit(Diagnostic::new(
118        Code::MmdcUnavailable,
119        "mermaid: `mmdc` is not on PATH; mermaid blocks left as code (install with `npm i -g @mermaid-js/mermaid-cli`)",
120      ));
121      return;
122    }
123    let mut v = Apply { pending: Vec::new(), mermaid: self };
124    walk_root(&mut doc.children, &mut v);
125    for d in v.pending.drain(..) {
126      diag_engine.emit(d);
127    }
128  }
129}
130
131struct Apply<'a> {
132  pending: Vec<Diagnostic<Code>>,
133  mermaid: &'a Mermaid,
134}
135
136impl<'a> Visitor for Apply<'a> {
137  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
138    let Node::CodeBlock(cb) = node else { return NodeAction::Keep };
139    if cb.lang.as_deref() != Some("mermaid") {
140      return NodeAction::Keep;
141    }
142    let span = cb.span.clone();
143    match self.mermaid.render_cached(&cb.value) {
144      Ok(svg) => {
145        *node = Node::JsxSelfClosing(JsxSelfClosing {
146          name: "MermaidSvg".into(),
147          attrs: vec![JsxAttr { name: "svg".into(), value: JsxAttrValue::String(svg), span: span.clone() }],
148          span,
149        });
150        NodeAction::KeepSkipChildren
151      },
152      Err(err) => {
153        self.pending.push(
154          Diagnostic::new(Code::MermaidRenderFailed, format!("mermaid: mmdc failed - {}", err.trim()))
155            .with_label(Label::primary(span, Some("for this mermaid block".into()))),
156        );
157        NodeAction::Keep
158      },
159    }
160  }
161}