1use std::fmt::Write;
4
5use dendryform_core::Theme;
6use dendryform_layout::{
7 ConnectorGeometry, ContainerGeometry, FlowLabelsGeometry, LayerGeometry, LayoutPlan,
8 NodeGeometry, TierGeometry,
9};
10
11use crate::css::generate_css;
12use crate::error::RenderError;
13
14pub fn render_html(plan: &LayoutPlan<'_>, theme: &Theme) -> Result<String, RenderError> {
16 let mut html = String::with_capacity(16384);
17
18 write_document_head(&mut html, plan, theme)?;
19 write_body(&mut html, plan, theme)?;
20
21 Ok(html)
22}
23
24fn escape_html(s: &str) -> String {
25 s.replace('&', "&")
26 .replace('<', "<")
27 .replace('>', ">")
28 .replace('"', """)
29}
30
31fn write_document_head(
32 html: &mut String,
33 plan: &LayoutPlan<'_>,
34 theme: &Theme,
35) -> Result<(), RenderError> {
36 writeln!(html, "<!DOCTYPE html>")?;
37 writeln!(html, "<html lang=\"en\">")?;
38 writeln!(html, "<head>")?;
39 writeln!(html, "<meta charset=\"UTF-8\">")?;
40 writeln!(
41 html,
42 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
43 )?;
44 writeln!(
45 html,
46 "<title>{} \u{00b7} {}</title>",
47 escape_html(&plan.header.title_accent),
48 escape_html(&plan.header.title_text),
49 )?;
50
51 let display_font = theme.fonts().display().replace(' ', "+");
53 let body_font = theme.fonts().body().replace(' ', "+");
54 writeln!(
55 html,
56 "<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family={display_font}:wght@300;400;500;600&family={body_font}:wght@300;400;500;600;700&display=swap\">"
57 )?;
58
59 writeln!(html, "<style>")?;
60 let css = generate_css(theme)?;
61 write!(html, "{css}")?;
62 writeln!(html, "</style>")?;
63 writeln!(html, "</head>")?;
64
65 Ok(())
66}
67
68fn write_body(html: &mut String, plan: &LayoutPlan<'_>, _theme: &Theme) -> Result<(), RenderError> {
69 writeln!(html, "<body>")?;
70 writeln!(html, "<div class=\"canvas\">")?;
71
72 write_header(html, plan)?;
74
75 for layer in &plan.layers {
77 write_layer(html, layer)?;
78 }
79
80 write_legend(html, plan)?;
82
83 writeln!(html, "</div>")?;
84 writeln!(html, "</body>")?;
85 writeln!(html, "</html>")?;
86
87 Ok(())
88}
89
90fn write_header(html: &mut String, plan: &LayoutPlan<'_>) -> Result<(), RenderError> {
91 writeln!(html, " <div class=\"header\">")?;
92 writeln!(
93 html,
94 " <h1><span>{}</span> \u{00b7} {}</h1>",
95 escape_html(&plan.header.title_accent),
96 escape_html(&plan.header.title_text),
97 )?;
98 writeln!(
99 html,
100 " <div class=\"subtitle\">{}</div>",
101 escape_html(&plan.header.subtitle),
102 )?;
103 writeln!(html, " </div>")?;
104 Ok(())
105}
106
107fn write_layer(html: &mut String, layer: &LayerGeometry<'_>) -> Result<(), RenderError> {
108 match layer {
109 LayerGeometry::Tier(tier) => write_tier(html, tier),
110 LayerGeometry::Connector(conn) => write_connector(html, conn),
111 LayerGeometry::FlowLabels(labels) => write_flow_labels(html, labels),
112 }
113}
114
115fn write_tier(html: &mut String, tier: &TierGeometry<'_>) -> Result<(), RenderError> {
116 writeln!(html, " <div class=\"tier\">")?;
117
118 if let Some(container) = &tier.container {
119 write_container(html, container, tier.label.as_deref())?;
120 } else {
121 if let Some(label) = &tier.label {
122 writeln!(
123 html,
124 " <div class=\"tier-label\">{}</div>",
125 escape_html(label)
126 )?;
127 }
128 write_node_grid(html, &tier.nodes, tier.columns, tier.columns == 1)?;
129 }
130
131 writeln!(html, " </div>")?;
132 Ok(())
133}
134
135fn write_node_grid(
136 html: &mut String,
137 nodes: &[NodeGeometry<'_>],
138 columns: usize,
139 is_single: bool,
140) -> Result<(), RenderError> {
141 if nodes.is_empty() {
142 return Ok(());
143 }
144
145 writeln!(html, " <div class=\"grid-{columns}\">")?;
146 for ng in nodes {
147 write_node(html, ng, is_single)?;
148 }
149 writeln!(html, " </div>")?;
150 Ok(())
151}
152
153fn write_node(
154 html: &mut String,
155 ng: &NodeGeometry<'_>,
156 is_single: bool,
157) -> Result<(), RenderError> {
158 let node = ng.node;
159 let color = node.color();
160 let single_class = if is_single { " client-node" } else { "" };
161
162 writeln!(html, " <div class=\"node {color}{single_class}\">")?;
163 writeln!(
164 html,
165 " <div class=\"node-title\"><span class=\"icon\">{}</span> {}</div>",
166 escape_html(node.icon()),
167 escape_html(node.title()),
168 )?;
169 writeln!(
170 html,
171 " <div class=\"node-desc\">{}</div>",
172 escape_html(node.description()),
173 )?;
174
175 let tech = node.tech();
176 if !tech.is_empty() {
177 write!(html, " <div class=\"node-tech\">")?;
178 for t in tech {
179 write!(html, "<span>{}</span>", escape_html(&t.to_string()))?;
180 }
181 writeln!(html, "</div>")?;
182 }
183
184 writeln!(html, " </div>")?;
185 Ok(())
186}
187
188fn write_connector(html: &mut String, conn: &ConnectorGeometry) -> Result<(), RenderError> {
189 if conn.is_internal {
190 writeln!(html, " <div class=\"internal-connector\">")?;
191 writeln!(html, " <div class=\"dots\">")?;
192 for _ in 0..5 {
193 write!(html, " <div class=\"dot\"></div>")?;
194 }
195 writeln!(html)?;
196 writeln!(html, " </div>")?;
197 writeln!(html, " </div>")?;
198 } else {
199 writeln!(html, " <div class=\"connector\">")?;
200 writeln!(html, " <div class=\"line\"></div>")?;
201 if let Some(label) = &conn.label {
202 writeln!(
203 html,
204 " <div class=\"protocol-label\">{}</div>",
205 escape_html(label),
206 )?;
207 }
208 writeln!(html, " </div>")?;
209 }
210 Ok(())
211}
212
213fn write_flow_labels(html: &mut String, labels: &FlowLabelsGeometry) -> Result<(), RenderError> {
214 writeln!(html, " <div class=\"flow-labels\">")?;
215 for label in &labels.items {
216 writeln!(
217 html,
218 " <div class=\"flow-label\"><span class=\"arrow\">\u{2193}</span> {}</div>",
219 escape_html(label),
220 )?;
221 }
222 writeln!(html, " </div>")?;
223 Ok(())
224}
225
226fn write_container(
227 html: &mut String,
228 container: &ContainerGeometry<'_>,
229 parent_label: Option<&str>,
230) -> Result<(), RenderError> {
231 let border_class = format!("container-{}", container.border);
232 let label_color = container.label_color;
233
234 if let Some(label) = parent_label {
235 writeln!(
236 html,
237 " <div class=\"tier-label\">{}</div>",
238 escape_html(label)
239 )?;
240 }
241
242 writeln!(html, " <div class=\"{border_class}\">")?;
243 writeln!(
244 html,
245 " <div class=\"container-label\" style=\"color: var(--accent-{label_color})\">{}</div>",
246 escape_html(&container.label),
247 )?;
248
249 for layer in &container.layers {
250 match layer {
251 LayerGeometry::Tier(tier) => {
252 if let Some(label) = &tier.label {
253 writeln!(
254 html,
255 " <div class=\"tier-label\">{}</div>",
256 escape_html(label)
257 )?;
258 }
259 if let Some(nested_container) = &tier.container {
260 write_container(html, nested_container, None)?;
261 } else {
262 write_node_grid(html, &tier.nodes, tier.columns, false)?;
263 }
264 }
265 LayerGeometry::Connector(conn) => write_connector(html, conn)?,
266 LayerGeometry::FlowLabels(labels) => write_flow_labels(html, labels)?,
267 }
268 }
269
270 writeln!(html, " </div>")?;
271 Ok(())
272}
273
274fn write_legend(html: &mut String, plan: &LayoutPlan<'_>) -> Result<(), RenderError> {
275 if plan.legend.entries.is_empty() {
276 return Ok(());
277 }
278
279 writeln!(html, " <div class=\"legend\">")?;
280 for entry in &plan.legend.entries {
281 let color = entry.color();
282 writeln!(
283 html,
284 " <div class=\"legend-item\"><div class=\"swatch {color}\"></div> {}</div>",
285 escape_html(entry.label()),
286 )?;
287 }
288 writeln!(html, " </div>")?;
289 Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use dendryform_core::{
296 Color, Connector, ConnectorStyle, Container, ContainerBorder, Diagram, DiagramHeader,
297 FlowLabels, Layer, LegendEntry, Node, NodeId, NodeKind, RawDiagram, Tech, Tier, TierLayout,
298 Title,
299 };
300 use dendryform_layout::compute_layout;
301
302 fn test_node(id: &str, color: Color) -> Node {
303 Node::builder()
304 .id(NodeId::new(id).unwrap())
305 .kind(NodeKind::System)
306 .color(color)
307 .icon("\u{25c7}")
308 .title(id)
309 .description("test node")
310 .build()
311 .unwrap()
312 }
313
314 fn test_node_with_tech(id: &str, color: Color, techs: Vec<&str>) -> Node {
315 Node::builder()
316 .id(NodeId::new(id).unwrap())
317 .kind(NodeKind::System)
318 .color(color)
319 .icon("\u{25c7}")
320 .title(id)
321 .description("test node with tech")
322 .tech(techs.into_iter().map(Tech::new).collect())
323 .build()
324 .unwrap()
325 }
326
327 fn make_diagram(layers: Vec<Layer>, legend: Vec<LegendEntry>) -> Diagram {
328 let raw = RawDiagram {
329 diagram: DiagramHeader::new(Title::new("test", "accent"), "subtitle", "dark"),
330 layers,
331 legend,
332 edges: vec![],
333 };
334 Diagram::try_from(raw).unwrap()
335 }
336
337 #[test]
338 fn test_escape_html_function() {
339 assert_eq!(escape_html("a & b"), "a & b");
340 assert_eq!(escape_html("<tag>"), "<tag>");
341 assert_eq!(escape_html("a \"b\""), "a "b"");
342 assert_eq!(escape_html("no special"), "no special");
343 }
344
345 #[test]
346 fn test_render_internal_connector() {
347 let container = Container::new(
348 "server",
349 ContainerBorder::Solid,
350 Color::Green,
351 vec![
352 Layer::Tier(Tier::new(
353 NodeId::new("inner1").unwrap(),
354 vec![test_node("a", Color::Green)],
355 )),
356 Layer::Connector(Connector::new(ConnectorStyle::Dots)),
357 Layer::Tier(Tier::new(
358 NodeId::new("inner2").unwrap(),
359 vec![test_node("b", Color::Green)],
360 )),
361 ],
362 );
363 let diagram = make_diagram(
364 vec![Layer::Tier(Tier::with_container(
365 NodeId::new("server").unwrap(),
366 container,
367 ))],
368 vec![],
369 );
370 let plan = compute_layout(&diagram).unwrap();
371 let html = render_html(&plan, &Theme::dark()).unwrap();
372
373 assert!(html.contains("internal-connector"));
374 assert!(html.contains("dot"));
375 }
376
377 #[test]
378 fn test_render_nested_dashed_container() {
379 let inner_container = Container::new(
380 "inner-service",
381 ContainerBorder::Dashed,
382 Color::Purple,
383 vec![Layer::Tier(Tier::new(
384 NodeId::new("deep").unwrap(),
385 vec![test_node("deep-api", Color::Purple)],
386 ))],
387 );
388 let outer_container = Container::new(
389 "outer-server",
390 ContainerBorder::Solid,
391 Color::Green,
392 vec![Layer::Tier(Tier::with_container(
393 NodeId::new("inner-tier").unwrap(),
394 inner_container,
395 ))],
396 );
397 let diagram = make_diagram(
398 vec![Layer::Tier(Tier::with_container(
399 NodeId::new("outer").unwrap(),
400 outer_container,
401 ))],
402 vec![],
403 );
404 let plan = compute_layout(&diagram).unwrap();
405 let html = render_html(&plan, &Theme::dark()).unwrap();
406
407 assert!(html.contains("container-solid"));
408 assert!(html.contains("container-dashed"));
409 assert!(html.contains("outer-server"));
410 assert!(html.contains("inner-service"));
411 assert!(html.contains("deep-api"));
412 }
413
414 #[test]
415 fn test_render_node_with_tech() {
416 let diagram = make_diagram(
417 vec![Layer::Tier(Tier::new(
418 NodeId::new("main").unwrap(),
419 vec![test_node_with_tech(
420 "app",
421 Color::Blue,
422 vec!["Rust", "axum"],
423 )],
424 ))],
425 vec![],
426 );
427 let plan = compute_layout(&diagram).unwrap();
428 let html = render_html(&plan, &Theme::dark()).unwrap();
429
430 assert!(html.contains("node-tech"));
431 assert!(html.contains("Rust"));
432 assert!(html.contains("axum"));
433 }
434
435 #[test]
436 fn test_render_single_node_layout() {
437 let mut tier = Tier::new(
438 NodeId::new("main").unwrap(),
439 vec![test_node("app", Color::Blue)],
440 );
441 tier.set_layout(TierLayout::Single);
442 let diagram = make_diagram(vec![Layer::Tier(tier)], vec![]);
443 let plan = compute_layout(&diagram).unwrap();
444 let html = render_html(&plan, &Theme::dark()).unwrap();
445
446 assert!(html.contains("client-node"));
447 }
448
449 #[test]
450 fn test_render_connector_without_label() {
451 let diagram = make_diagram(
452 vec![
453 Layer::Tier(Tier::new(
454 NodeId::new("top").unwrap(),
455 vec![test_node("a", Color::Blue)],
456 )),
457 Layer::Connector(Connector::new(ConnectorStyle::Line)),
458 Layer::Tier(Tier::new(
459 NodeId::new("bottom").unwrap(),
460 vec![test_node("b", Color::Green)],
461 )),
462 ],
463 vec![],
464 );
465 let plan = compute_layout(&diagram).unwrap();
466 let html = render_html(&plan, &Theme::dark()).unwrap();
467
468 assert!(html.contains("class=\"connector\""));
469 assert!(html.contains("class=\"line\""));
470 assert!(!html.contains("<div class=\"protocol-label\">"));
472 }
473
474 #[test]
475 fn test_render_empty_legend() {
476 let diagram = make_diagram(
477 vec![Layer::Tier(Tier::new(
478 NodeId::new("main").unwrap(),
479 vec![test_node("a", Color::Blue)],
480 ))],
481 vec![],
482 );
483 let plan = compute_layout(&diagram).unwrap();
484 let html = render_html(&plan, &Theme::dark()).unwrap();
485
486 assert!(!html.contains("class=\"legend\""));
488 }
489
490 #[test]
491 fn test_render_tier_with_label() {
492 let mut tier = Tier::new(
493 NodeId::new("main").unwrap(),
494 vec![test_node("a", Color::Blue)],
495 );
496 tier.set_label("My Section");
497 let diagram = make_diagram(vec![Layer::Tier(tier)], vec![]);
498 let plan = compute_layout(&diagram).unwrap();
499 let html = render_html(&plan, &Theme::dark()).unwrap();
500
501 assert!(html.contains("tier-label"));
502 assert!(html.contains("My Section"));
503 }
504
505 #[test]
506 fn test_render_container_with_flow_labels() {
507 let container = Container::new(
508 "server",
509 ContainerBorder::Solid,
510 Color::Green,
511 vec![
512 Layer::Tier(Tier::new(
513 NodeId::new("top").unwrap(),
514 vec![test_node("a", Color::Green)],
515 )),
516 Layer::FlowLabels(FlowLabels::new(vec!["queries".to_owned()])),
517 Layer::Tier(Tier::new(
518 NodeId::new("bottom").unwrap(),
519 vec![test_node("b", Color::Green)],
520 )),
521 ],
522 );
523 let diagram = make_diagram(
524 vec![Layer::Tier(Tier::with_container(
525 NodeId::new("server").unwrap(),
526 container,
527 ))],
528 vec![],
529 );
530 let plan = compute_layout(&diagram).unwrap();
531 let html = render_html(&plan, &Theme::dark()).unwrap();
532
533 assert!(html.contains("flow-labels"));
534 assert!(html.contains("queries"));
535 }
536
537 #[test]
538 fn test_render_container_with_tier_label() {
539 let container = Container::new(
540 "server",
541 ContainerBorder::Solid,
542 Color::Green,
543 vec![{
544 let mut t = Tier::new(
545 NodeId::new("inner").unwrap(),
546 vec![test_node("api", Color::Green)],
547 );
548 t.set_label("Inner Label");
549 Layer::Tier(t)
550 }],
551 );
552 let diagram = make_diagram(
553 vec![{
554 let mut t = Tier::with_container(NodeId::new("outer").unwrap(), container);
555 t.set_label("Outer Label");
556 Layer::Tier(t)
557 }],
558 vec![],
559 );
560 let plan = compute_layout(&diagram).unwrap();
561 let html = render_html(&plan, &Theme::dark()).unwrap();
562
563 assert!(html.contains("Outer Label"));
564 assert!(html.contains("Inner Label"));
565 }
566
567 #[test]
568 fn test_render_legend_with_entries() {
569 let diagram = make_diagram(
570 vec![Layer::Tier(Tier::new(
571 NodeId::new("main").unwrap(),
572 vec![test_node("a", Color::Blue)],
573 ))],
574 vec![
575 LegendEntry::new(Color::Blue, "Clients"),
576 LegendEntry::new(Color::Green, "Servers"),
577 LegendEntry::new(Color::Amber, "Data"),
578 ],
579 );
580 let plan = compute_layout(&diagram).unwrap();
581 let html = render_html(&plan, &Theme::dark()).unwrap();
582
583 assert!(html.contains("class=\"legend\""));
584 assert!(html.contains("Clients"));
585 assert!(html.contains("Servers"));
586 assert!(html.contains("Data"));
587 assert!(html.contains("swatch blue"));
588 assert!(html.contains("swatch green"));
589 assert!(html.contains("swatch amber"));
590 }
591
592 #[test]
593 fn test_render_connector_with_label() {
594 let diagram = make_diagram(
595 vec![
596 Layer::Tier(Tier::new(
597 NodeId::new("top").unwrap(),
598 vec![test_node("a", Color::Blue)],
599 )),
600 Layer::Connector(Connector::with_label(ConnectorStyle::Line, "gRPC")),
601 Layer::Tier(Tier::new(
602 NodeId::new("bottom").unwrap(),
603 vec![test_node("b", Color::Green)],
604 )),
605 ],
606 vec![],
607 );
608 let plan = compute_layout(&diagram).unwrap();
609 let html = render_html(&plan, &Theme::dark()).unwrap();
610
611 assert!(html.contains("protocol-label"));
612 assert!(html.contains("gRPC"));
613 }
614
615 #[test]
616 fn test_render_top_level_flow_labels() {
617 let diagram = make_diagram(
618 vec![
619 Layer::Tier(Tier::new(
620 NodeId::new("top").unwrap(),
621 vec![test_node("a", Color::Blue)],
622 )),
623 Layer::FlowLabels(FlowLabels::new(vec![
624 "SQL queries".to_owned(),
625 "REST calls".to_owned(),
626 ])),
627 Layer::Tier(Tier::new(
628 NodeId::new("bottom").unwrap(),
629 vec![test_node("b", Color::Green)],
630 )),
631 ],
632 vec![],
633 );
634 let plan = compute_layout(&diagram).unwrap();
635 let html = render_html(&plan, &Theme::dark()).unwrap();
636
637 assert!(html.contains("flow-labels"));
638 assert!(html.contains("SQL queries"));
639 assert!(html.contains("REST calls"));
640 assert!(html.contains("\u{2193}")); }
642
643 #[test]
644 fn test_render_container_without_parent_label() {
645 let container = Container::new(
646 "server",
647 ContainerBorder::Solid,
648 Color::Green,
649 vec![Layer::Tier(Tier::new(
650 NodeId::new("inner").unwrap(),
651 vec![test_node("api", Color::Green)],
652 ))],
653 );
654 let diagram = make_diagram(
656 vec![Layer::Tier(Tier::with_container(
657 NodeId::new("outer").unwrap(),
658 container,
659 ))],
660 vec![],
661 );
662 let plan = compute_layout(&diagram).unwrap();
663 let html = render_html(&plan, &Theme::dark()).unwrap();
664
665 assert!(html.contains("container-solid"));
666 assert!(html.contains("server"));
667 }
668
669 #[test]
670 fn test_render_multi_column_grid() {
671 let mut tier = Tier::new(
672 NodeId::new("grid").unwrap(),
673 vec![
674 test_node("a", Color::Blue),
675 test_node("b", Color::Green),
676 test_node("c", Color::Purple),
677 ],
678 );
679 tier.set_layout(TierLayout::Grid { columns: 3 });
680 let diagram = make_diagram(vec![Layer::Tier(tier)], vec![]);
681 let plan = compute_layout(&diagram).unwrap();
682 let html = render_html(&plan, &Theme::dark()).unwrap();
683
684 assert!(html.contains("grid-3"));
685 assert!(html.contains("a"));
686 assert!(html.contains("b"));
687 assert!(html.contains("c"));
688 }
689
690 #[test]
691 fn test_render_nested_container_with_connector() {
692 let container = Container::new(
693 "server",
694 ContainerBorder::Solid,
695 Color::Green,
696 vec![
697 Layer::Tier(Tier::new(
698 NodeId::new("top-inner").unwrap(),
699 vec![test_node("api", Color::Green)],
700 )),
701 Layer::Connector(Connector::new(ConnectorStyle::Dots)),
702 Layer::Tier(Tier::new(
703 NodeId::new("bot-inner").unwrap(),
704 vec![test_node("db", Color::Amber)],
705 )),
706 ],
707 );
708 let diagram = make_diagram(
709 vec![Layer::Tier(Tier::with_container(
710 NodeId::new("outer").unwrap(),
711 container,
712 ))],
713 vec![],
714 );
715 let plan = compute_layout(&diagram).unwrap();
716 let html = render_html(&plan, &Theme::dark()).unwrap();
717
718 assert!(html.contains("internal-connector"));
719 assert!(html.contains("dot"));
720 assert!(html.contains("api"));
721 assert!(html.contains("db"));
722 }
723
724 #[test]
725 fn test_render_full_example_taproot() {
726 let yaml = include_str!("../../../examples/taproot/architecture.yaml");
727 let diagram: Diagram = dendryform_parse::parse_yaml(yaml).unwrap();
728 let plan = compute_layout(&diagram).unwrap();
729 let html = render_html(&plan, &Theme::dark()).unwrap();
730
731 assert!(html.contains("<!DOCTYPE html>"));
732 assert!(html.contains("</html>"));
733 assert!(html.contains("taproot"));
734 assert!(html.contains("Streamable HTTP"));
735 assert!(html.contains("class=\"legend\""));
737 }
738
739 #[test]
740 fn test_render_full_example_ai_kasu() {
741 let yaml = include_str!("../../../examples/ai-kasu/architecture.yaml");
742 let diagram: Diagram = dendryform_parse::parse_yaml(yaml).unwrap();
743 let plan = compute_layout(&diagram).unwrap();
744 let html = render_html(&plan, &Theme::dark()).unwrap();
745
746 assert!(html.contains("<!DOCTYPE html>"));
747 assert!(html.contains("ai-kasu"));
748 assert!(html.contains("container-solid"));
750 assert!(html.contains("container-dashed"));
751 }
752
753 #[test]
754 fn test_render_dashed_container() {
755 let container = Container::new(
756 "internal",
757 ContainerBorder::Dashed,
758 Color::Purple,
759 vec![Layer::Tier(Tier::new(
760 NodeId::new("inner").unwrap(),
761 vec![test_node("svc", Color::Purple)],
762 ))],
763 );
764 let diagram = make_diagram(
765 vec![Layer::Tier(Tier::with_container(
766 NodeId::new("outer").unwrap(),
767 container,
768 ))],
769 vec![],
770 );
771 let plan = compute_layout(&diagram).unwrap();
772 let html = render_html(&plan, &Theme::dark()).unwrap();
773
774 assert!(html.contains("container-dashed"));
775 assert!(html.contains("internal"));
776 }
777
778 #[test]
779 fn test_escape_html_all_special_chars() {
780 let result = escape_html("a & b < c > d \"e\"");
781 assert_eq!(result, "a & b < c > d "e"");
782 }
783}