Skip to main content

dendryform_html/
render.rs

1//! HTML rendering — transforms a LayoutPlan + Theme into self-contained HTML.
2
3use 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
14/// Renders a layout plan and theme into a self-contained HTML string.
15pub 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('&', "&amp;")
26        .replace('<', "&lt;")
27        .replace('>', "&gt;")
28        .replace('"', "&quot;")
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    // Font import
52    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    // Header
73    write_header(html, plan)?;
74
75    // Layers
76    for layer in &plan.layers {
77        write_layer(html, layer)?;
78    }
79
80    // Legend
81    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 &amp; b");
340        assert_eq!(escape_html("<tag>"), "&lt;tag&gt;");
341        assert_eq!(escape_html("a \"b\""), "a &quot;b&quot;");
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        // No protocol label element (CSS always defines the class, but no element is emitted)
471        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        // Legend div should not be present
487        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}")); // down arrow
641    }
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        // No parent tier label set
655        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        // Legend should have entries
736        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        // Should have nested containers
749        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 &amp; b &lt; c &gt; d &quot;e&quot;");
782    }
783}