dmc_transform/builtin/
mermaid.rs1use 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#[derive(Default)]
18pub struct Mermaid {
19 pub output_dir: Option<PathBuf>,
21 cache: Mutex<HashMap<u64, String>>,
23}
24
25static 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 if let Some(svg) = self.cache.lock().unwrap().get(&key) {
55 return Ok(svg.clone());
56 }
57
58 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 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}