1#![forbid(unsafe_code)]
2pub mod baseline;
10pub mod common;
11pub mod common_db;
12pub mod config;
13pub mod detect;
14pub mod diagram;
15pub mod diagrams;
16pub mod entities;
17pub mod error;
18mod family;
19pub mod generated;
20pub mod geom;
21pub mod models;
22pub mod preprocess;
23mod runtime;
24pub mod sanitize;
25mod theme;
26pub mod time;
27pub mod utils;
28
29pub use config::MermaidConfig;
30pub use detect::{Detector, DetectorRegistry};
31pub use diagram::{
32 DiagramRegistry, DiagramSemanticParser, ParsedDiagram, ParsedDiagramRender,
33 RenderDiagramRegistry, RenderSemanticModel, RenderSemanticParser,
34};
35pub use error::{Error, Result};
36pub use preprocess::{PreprocessResult, preprocess_diagram, preprocess_diagram_with_known_type};
37
38pub const MAX_DIAGRAM_NESTING_DEPTH: usize = 256;
40
41pub fn supported_themes() -> &'static [&'static str] {
43 theme::SUPPORTED_THEME_NAMES
44}
45
46pub fn supported_diagrams() -> &'static [&'static str] {
48 family::supported_diagram_metadata_ids()
49}
50
51#[derive(Debug, Clone, Copy, Default)]
53pub struct ParseOptions {
54 pub suppress_errors: bool,
56}
57
58impl ParseOptions {
59 pub fn strict() -> Self {
61 Self {
62 suppress_errors: false,
63 }
64 }
65
66 pub fn lenient() -> Self {
68 Self {
69 suppress_errors: true,
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct ParseMetadata {
77 pub diagram_type: String,
79 pub config: MermaidConfig,
82 pub effective_config: MermaidConfig,
84 pub title: Option<String>,
86}
87
88#[derive(Debug, Clone)]
93pub struct Engine {
94 registry: DetectorRegistry,
95 diagram_registry: DiagramRegistry,
96 render_diagram_registry: RenderDiagramRegistry,
97 site_config: MermaidConfig,
98 fixed_today_local: Option<chrono::NaiveDate>,
99 fixed_local_offset_minutes: Option<i32>,
100}
101
102impl Default for Engine {
103 fn default() -> Self {
104 let site_config = generated::default_site_config();
105
106 Self {
107 registry: DetectorRegistry::for_pinned_mermaid_baseline(),
108 diagram_registry: DiagramRegistry::for_pinned_mermaid_baseline(),
109 render_diagram_registry: RenderDiagramRegistry::for_pinned_mermaid_baseline(),
110 site_config,
111 fixed_today_local: None,
112 fixed_local_offset_minutes: None,
113 }
114 }
115}
116
117impl Engine {
118 fn parse_timing_enabled() -> bool {
119 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
120 *ENABLED.get_or_init(|| {
121 matches!(
122 std::env::var("MERMAN_PARSE_TIMING").as_deref(),
123 Ok("1") | Ok("true")
124 )
125 })
126 }
127
128 pub fn new() -> Self {
130 Self::default()
131 }
132
133 pub fn with_fixed_today(mut self, today: Option<chrono::NaiveDate>) -> Self {
138 self.fixed_today_local = today;
139 self
140 }
141
142 pub fn with_fixed_local_offset_minutes(mut self, offset_minutes: Option<i32>) -> Self {
148 self.fixed_local_offset_minutes = offset_minutes;
149 self
150 }
151
152 pub fn with_site_config(mut self, mut site_config: MermaidConfig) -> Self {
154 config::mirror_legacy_font_family_into_theme_variables(&mut site_config);
156 self.site_config.deep_merge(site_config.as_value());
157 self
158 }
159
160 pub fn registry(&self) -> &DetectorRegistry {
162 &self.registry
163 }
164
165 pub fn registry_mut(&mut self) -> &mut DetectorRegistry {
167 &mut self.registry
168 }
169
170 pub fn diagram_registry(&self) -> &DiagramRegistry {
172 &self.diagram_registry
173 }
174
175 pub fn diagram_registry_mut(&mut self) -> &mut DiagramRegistry {
177 &mut self.diagram_registry
178 }
179
180 pub fn render_diagram_registry(&self) -> &RenderDiagramRegistry {
182 &self.render_diagram_registry
183 }
184
185 pub fn render_diagram_registry_mut(&mut self) -> &mut RenderDiagramRegistry {
187 &mut self.render_diagram_registry
188 }
189
190 pub fn parse_metadata_sync(
196 &self,
197 text: &str,
198 options: ParseOptions,
199 ) -> Result<Option<ParseMetadata>> {
200 let Some((_, meta)) = self.preprocess_and_detect(text, options)? else {
201 return Ok(None);
202 };
203 Ok(Some(meta))
204 }
205
206 pub fn parse_metadata_with_type_sync(
238 &self,
239 diagram_type: &str,
240 text: &str,
241 options: ParseOptions,
242 ) -> Result<Option<ParseMetadata>> {
243 let Some((_, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)? else {
244 return Ok(None);
245 };
246 Ok(Some(meta))
247 }
248
249 pub async fn parse_metadata(
254 &self,
255 text: &str,
256 options: ParseOptions,
257 ) -> Result<Option<ParseMetadata>> {
258 self.parse_metadata_sync(text, options)
259 }
260
261 pub async fn parse_metadata_with_type(
265 &self,
266 diagram_type: &str,
267 text: &str,
268 options: ParseOptions,
269 ) -> Result<Option<ParseMetadata>> {
270 self.parse_metadata_with_type_sync(diagram_type, text, options)
271 }
272
273 pub fn parse_diagram_sync(
278 &self,
279 text: &str,
280 options: ParseOptions,
281 ) -> Result<Option<ParsedDiagram>> {
282 let timing_enabled = Self::parse_timing_enabled();
283 let total_start = timing_enabled.then(web_time::Instant::now);
284
285 let preprocess_start = timing_enabled.then(web_time::Instant::now);
286 let Some((code, meta)) = self.preprocess_and_detect(text, options)? else {
287 return Ok(None);
288 };
289 let preprocess = preprocess_start.map(|s| s.elapsed());
290
291 let parse_start = timing_enabled.then(web_time::Instant::now);
292 let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
293 crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
294 diagram::parse_or_unsupported(
295 &self.diagram_registry,
296 &meta.diagram_type,
297 &code,
298 &meta,
299 )
300 })
301 });
302
303 let mut model = match parse {
304 Ok(v) => v,
305 Err(err) => {
306 if !options.suppress_errors {
307 return Err(err);
308 }
309
310 if let Some(start) = total_start {
311 let parse = parse_start.map(|s| s.elapsed()).unwrap_or_default();
312 eprintln!(
313 "[parse-timing] diagram=error total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
314 start.elapsed(),
315 preprocess.unwrap_or_default(),
316 parse,
317 web_time::Duration::default(),
318 text.len(),
319 );
320 }
321 return Ok(Some(
322 crate::diagrams::error_diagram::suppressed_error_diagram(&meta),
323 ));
324 }
325 };
326 let parse = parse_start.map(|s| s.elapsed());
327
328 let sanitize_start = timing_enabled.then(web_time::Instant::now);
329 common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
330 let sanitize = sanitize_start.map(|s| s.elapsed());
331
332 if let Some(start) = total_start {
333 eprintln!(
334 "[parse-timing] diagram={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
335 meta.diagram_type,
336 start.elapsed(),
337 preprocess.unwrap_or_default(),
338 parse.unwrap_or_default(),
339 sanitize.unwrap_or_default(),
340 text.len(),
341 );
342 }
343 Ok(Some(ParsedDiagram { meta, model }))
344 }
345
346 pub async fn parse_diagram(
350 &self,
351 text: &str,
352 options: ParseOptions,
353 ) -> Result<Option<ParsedDiagram>> {
354 self.parse_diagram_sync(text, options)
355 }
356
357 pub fn parse_diagram_for_render_model_sync(
366 &self,
367 text: &str,
368 options: ParseOptions,
369 ) -> Result<Option<ParsedDiagramRender>> {
370 self.parse_diagram_for_render_model_inner(text, options, |engine| {
371 engine.preprocess_and_detect(text, options)
372 })
373 }
374
375 pub async fn parse_diagram_for_render_model(
379 &self,
380 text: &str,
381 options: ParseOptions,
382 ) -> Result<Option<ParsedDiagramRender>> {
383 self.parse_diagram_for_render_model_sync(text, options)
384 }
385
386 pub fn parse_diagram_for_render_model_with_type_sync(
393 &self,
394 diagram_type: &str,
395 text: &str,
396 options: ParseOptions,
397 ) -> Result<Option<ParsedDiagramRender>> {
398 self.parse_diagram_for_render_model_inner(text, options, |engine| {
399 engine.preprocess_and_assume_type(diagram_type, text, options)
400 })
401 }
402
403 fn parse_diagram_for_render_model_inner(
404 &self,
405 text: &str,
406 options: ParseOptions,
407 preprocess: impl FnOnce(&Self) -> Result<Option<(String, ParseMetadata)>>,
408 ) -> Result<Option<ParsedDiagramRender>> {
409 let timing_enabled = Self::parse_timing_enabled();
410 let total_start = timing_enabled.then(web_time::Instant::now);
411
412 let preprocess_start = timing_enabled.then(web_time::Instant::now);
413 let Some((code, meta)) = preprocess(self)? else {
414 return Ok(None);
415 };
416 let preprocess = preprocess_start.map(|s| s.elapsed());
417
418 let parse_start = timing_enabled.then(web_time::Instant::now);
419 let parse_res = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
420 crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
421 self.parse_render_semantic_model(&code, &meta)
422 })
423 });
424 let parse = parse_start.map(|s| s.elapsed());
425
426 let mut model = match parse_res {
427 Ok(v) => v,
428 Err(err) => {
429 if !options.suppress_errors {
430 return Err(err);
431 }
432
433 if let Some(start) = total_start {
434 eprintln!(
435 "[parse-render-timing] diagram=error model=json total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
436 start.elapsed(),
437 preprocess.unwrap_or_default(),
438 parse.unwrap_or_default(),
439 web_time::Duration::default(),
440 text.len(),
441 );
442 }
443 return Ok(Some(
444 crate::diagrams::error_diagram::suppressed_error_render_diagram(&meta),
445 ));
446 }
447 };
448
449 let sanitize_start = timing_enabled.then(web_time::Instant::now);
450 model.sanitize_common_db_fields(&meta.effective_config);
451 let sanitize = sanitize_start.map(|s| s.elapsed());
452
453 if let Some(start) = total_start {
454 eprintln!(
455 "[parse-render-timing] diagram={} model={} total={:?} preprocess={:?} parse={:?} sanitize={:?} input_bytes={}",
456 meta.diagram_type,
457 model.kind(),
458 start.elapsed(),
459 preprocess.unwrap_or_default(),
460 parse.unwrap_or_default(),
461 sanitize.unwrap_or_default(),
462 text.len(),
463 );
464 }
465
466 Ok(Some(ParsedDiagramRender { meta, model }))
467 }
468
469 fn parse_render_semantic_model(
470 &self,
471 code: &str,
472 meta: &ParseMetadata,
473 ) -> Result<RenderSemanticModel> {
474 if let Some(parser) = self.render_diagram_registry.get(&meta.diagram_type) {
475 return parser(code, meta);
476 }
477
478 if !family::permits_json_render_fallback(&meta.diagram_type) {
479 return Err(Error::DiagramParse {
480 diagram_type: meta.diagram_type.clone(),
481 message: format!(
482 "built-in diagram type `{}` is missing a typed render parser; JSON render fallback is reserved for error and custom diagram adapters",
483 meta.diagram_type
484 ),
485 });
486 }
487
488 diagram::parse_or_unsupported(&self.diagram_registry, &meta.diagram_type, code, meta)
489 .map(RenderSemanticModel::Json)
490 }
491
492 pub async fn parse_diagram_for_render_model_with_type(
496 &self,
497 diagram_type: &str,
498 text: &str,
499 options: ParseOptions,
500 ) -> Result<Option<ParsedDiagramRender>> {
501 self.parse_diagram_for_render_model_with_type_sync(diagram_type, text, options)
502 }
503
504 pub fn parse_diagram_with_type_sync(
526 &self,
527 diagram_type: &str,
528 text: &str,
529 options: ParseOptions,
530 ) -> Result<Option<ParsedDiagram>> {
531 let Some((code, meta)) = self.preprocess_and_assume_type(diagram_type, text, options)?
532 else {
533 return Ok(None);
534 };
535
536 let parse = crate::runtime::with_fixed_today_local(self.fixed_today_local, || {
537 crate::runtime::with_fixed_local_offset_minutes(self.fixed_local_offset_minutes, || {
538 diagram::parse_or_unsupported(
539 &self.diagram_registry,
540 &meta.diagram_type,
541 &code,
542 &meta,
543 )
544 })
545 });
546
547 let mut model = match parse {
548 Ok(v) => v,
549 Err(err) => {
550 if !options.suppress_errors {
551 return Err(err);
552 }
553
554 return Ok(Some(
555 crate::diagrams::error_diagram::suppressed_error_diagram(&meta),
556 ));
557 }
558 };
559 common_db::apply_common_db_sanitization(&mut model, &meta.effective_config);
560 Ok(Some(ParsedDiagram { meta, model }))
561 }
562
563 pub async fn parse_diagram_with_type(
567 &self,
568 diagram_type: &str,
569 text: &str,
570 options: ParseOptions,
571 ) -> Result<Option<ParsedDiagram>> {
572 self.parse_diagram_with_type_sync(diagram_type, text, options)
573 }
574
575 pub async fn parse(&self, text: &str, options: ParseOptions) -> Result<Option<ParseMetadata>> {
577 self.parse_metadata(text, options).await
578 }
579
580 fn preprocess_and_detect(
581 &self,
582 text: &str,
583 options: ParseOptions,
584 ) -> Result<Option<(String, ParseMetadata)>> {
585 let pre = preprocess_diagram(text, &self.registry)?;
586 if pre.code.trim_start().starts_with("---") {
587 return Err(Error::MalformedFrontMatter);
588 }
589
590 let mut effective_config = self.site_config.clone();
591 effective_config.deep_merge(pre.config.as_value());
592
593 let diagram_type = match self
594 .registry
595 .detect_type_precleaned(&pre.code, &mut effective_config)
596 {
597 Ok(t) => t.to_string(),
598 Err(err) => {
599 if options.suppress_errors {
600 return Ok(None);
601 }
602 return Err(err);
603 }
604 };
605 theme::apply_theme_defaults(&mut effective_config);
606
607 let title = pre
608 .title
609 .as_ref()
610 .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
611 .filter(|t| !t.is_empty());
612
613 Ok(Some((
614 pre.code,
615 ParseMetadata {
616 diagram_type,
617 config: pre.config,
618 effective_config,
619 title,
620 },
621 )))
622 }
623
624 fn preprocess_and_assume_type(
625 &self,
626 diagram_type: &str,
627 text: &str,
628 _options: ParseOptions,
629 ) -> Result<Option<(String, ParseMetadata)>> {
630 let pre = preprocess_diagram_with_known_type(text, &self.registry, Some(diagram_type))?;
631 if pre.code.trim_start().starts_with("---") {
632 return Err(Error::MalformedFrontMatter);
633 }
634
635 let mut effective_config = self.site_config.clone();
636 effective_config.deep_merge(pre.config.as_value());
637 family::apply_known_type_detector_side_effects(diagram_type, &mut effective_config);
638 theme::apply_theme_defaults(&mut effective_config);
639
640 let title = pre
641 .title
642 .as_ref()
643 .map(|t| crate::sanitize::sanitize_text(t, &effective_config))
644 .filter(|t| !t.is_empty());
645
646 Ok(Some((
647 pre.code,
648 ParseMetadata {
649 diagram_type: diagram_type.to_string(),
650 config: pre.config,
651 effective_config,
652 title,
653 },
654 )))
655 }
656}
657
658#[cfg(test)]
659mod tests;