Skip to main content

dendryform_html/
lib.rs

1//! # dendryform-html
2//!
3//! Responsive HTML renderer for dendryform diagrams.
4//!
5//! Consumes a [`LayoutPlan`](dendryform_layout::LayoutPlan) and
6//! [`Theme`](dendryform_core::Theme) to produce a self-contained HTML
7//! file with embedded CSS. The output is dark-themed, responsive, and
8//! interactive with hover states and animations.
9//!
10//! ## Quick Start
11//!
12//! ```no_run
13//! use dendryform_core::Theme;
14//! use dendryform_html::render_html;
15//! use dendryform_layout::compute_layout;
16//!
17//! let diagram = dendryform_parse::parse_yaml_file("examples/taproot/architecture.yaml").unwrap();
18//! let plan = compute_layout(&diagram).unwrap();
19//! let html = render_html(&plan, &Theme::dark()).unwrap();
20//! std::fs::write("output.html", html).unwrap();
21//! ```
22
23mod css;
24mod error;
25mod render;
26
27pub use error::RenderError;
28pub use render::render_html;
29
30/// Returns the version of the dendryform-html crate.
31pub fn version() -> &'static str {
32    env!("CARGO_PKG_VERSION")
33}
34
35#[cfg(test)]
36mod tests {
37    use dendryform_core::{
38        Color, Connector, ConnectorStyle, Container, ContainerBorder, Diagram, DiagramHeader,
39        FlowLabels, Layer, LegendEntry, Node, NodeId, NodeKind, RawDiagram, Theme, Tier,
40        TierLayout, Title,
41    };
42    use dendryform_layout::compute_layout;
43
44    use super::*;
45
46    fn test_node(id: &str, color: Color) -> Node {
47        Node::builder()
48            .id(NodeId::new(id).unwrap())
49            .kind(NodeKind::System)
50            .color(color)
51            .icon("\u{25c7}")
52            .title(id)
53            .description("test node")
54            .build()
55            .unwrap()
56    }
57
58    fn make_diagram(layers: Vec<Layer>, legend: Vec<LegendEntry>) -> Diagram {
59        let raw = RawDiagram {
60            diagram: DiagramHeader::new(Title::new("test", "accent"), "subtitle", "dark"),
61            layers,
62            legend,
63            edges: vec![],
64        };
65        Diagram::try_from(raw).unwrap()
66    }
67
68    #[test]
69    fn test_version_is_set() {
70        assert_eq!(version(), "0.1.0");
71    }
72
73    #[test]
74    fn test_render_minimal() {
75        let diagram = make_diagram(
76            vec![Layer::Tier(Tier::new(
77                NodeId::new("main").unwrap(),
78                vec![test_node("app", Color::Blue)],
79            ))],
80            vec![],
81        );
82        let plan = compute_layout(&diagram).unwrap();
83        let html = render_html(&plan, &Theme::dark()).unwrap();
84
85        assert!(html.contains("<!DOCTYPE html>"));
86        assert!(html.contains("</html>"));
87        assert!(html.contains("accent"));
88        assert!(html.contains("test"));
89    }
90
91    #[test]
92    fn test_render_contains_css_variables() {
93        let diagram = make_diagram(
94            vec![Layer::Tier(Tier::new(
95                NodeId::new("main").unwrap(),
96                vec![test_node("app", Color::Blue)],
97            ))],
98            vec![],
99        );
100        let plan = compute_layout(&diagram).unwrap();
101        let html = render_html(&plan, &Theme::dark()).unwrap();
102
103        assert!(html.contains("--bg: #0a0e14"));
104        assert!(html.contains("--text: #c4cdd9"));
105        assert!(html.contains("--accent-blue: #4fc3f7"));
106    }
107
108    #[test]
109    fn test_render_contains_node() {
110        let diagram = make_diagram(
111            vec![Layer::Tier(Tier::new(
112                NodeId::new("main").unwrap(),
113                vec![test_node("myapp", Color::Green)],
114            ))],
115            vec![],
116        );
117        let plan = compute_layout(&diagram).unwrap();
118        let html = render_html(&plan, &Theme::dark()).unwrap();
119
120        assert!(html.contains("class=\"node green"));
121        assert!(html.contains("myapp"));
122    }
123
124    #[test]
125    fn test_render_contains_connector() {
126        let diagram = make_diagram(
127            vec![
128                Layer::Tier(Tier::new(
129                    NodeId::new("top").unwrap(),
130                    vec![test_node("a", Color::Blue)],
131                )),
132                Layer::Connector(Connector::with_label(ConnectorStyle::Line, "HTTPS")),
133                Layer::Tier(Tier::new(
134                    NodeId::new("bottom").unwrap(),
135                    vec![test_node("b", Color::Green)],
136                )),
137            ],
138            vec![],
139        );
140        let plan = compute_layout(&diagram).unwrap();
141        let html = render_html(&plan, &Theme::dark()).unwrap();
142
143        assert!(html.contains("protocol-label"));
144        assert!(html.contains("HTTPS"));
145    }
146
147    #[test]
148    fn test_render_contains_container() {
149        let container = Container::new(
150            "server",
151            ContainerBorder::Solid,
152            Color::Green,
153            vec![Layer::Tier(Tier::new(
154                NodeId::new("inner").unwrap(),
155                vec![test_node("api", Color::Green)],
156            ))],
157        );
158        let diagram = make_diagram(
159            vec![Layer::Tier(Tier::with_container(
160                NodeId::new("server").unwrap(),
161                container,
162            ))],
163            vec![],
164        );
165        let plan = compute_layout(&diagram).unwrap();
166        let html = render_html(&plan, &Theme::dark()).unwrap();
167
168        assert!(html.contains("container-solid"));
169        assert!(html.contains("container-label"));
170        assert!(html.contains("server"));
171    }
172
173    #[test]
174    fn test_render_contains_legend() {
175        let diagram = make_diagram(
176            vec![Layer::Tier(Tier::new(
177                NodeId::new("main").unwrap(),
178                vec![test_node("a", Color::Blue)],
179            ))],
180            vec![
181                LegendEntry::new(Color::Blue, "Clients"),
182                LegendEntry::new(Color::Green, "Servers"),
183            ],
184        );
185        let plan = compute_layout(&diagram).unwrap();
186        let html = render_html(&plan, &Theme::dark()).unwrap();
187
188        assert!(html.contains("legend"));
189        assert!(html.contains("swatch blue"));
190        assert!(html.contains("Clients"));
191    }
192
193    #[test]
194    fn test_render_contains_flow_labels() {
195        let diagram = make_diagram(
196            vec![
197                Layer::Tier(Tier::new(
198                    NodeId::new("top").unwrap(),
199                    vec![test_node("a", Color::Blue)],
200                )),
201                Layer::FlowLabels(FlowLabels::new(vec![
202                    "SQL queries".to_owned(),
203                    "cache reads".to_owned(),
204                ])),
205                Layer::Tier(Tier::new(
206                    NodeId::new("bottom").unwrap(),
207                    vec![test_node("b", Color::Red)],
208                )),
209            ],
210            vec![],
211        );
212        let plan = compute_layout(&diagram).unwrap();
213        let html = render_html(&plan, &Theme::dark()).unwrap();
214
215        assert!(html.contains("flow-labels"));
216        assert!(html.contains("SQL queries"));
217        assert!(html.contains("\u{2193}")); // down arrow
218    }
219
220    #[test]
221    fn test_render_grid_layout() {
222        let mut tier = Tier::new(
223            NodeId::new("grid").unwrap(),
224            vec![
225                test_node("a", Color::Blue),
226                test_node("b", Color::Blue),
227                test_node("c", Color::Blue),
228                test_node("d", Color::Blue),
229            ],
230        );
231        tier.set_layout(TierLayout::Grid { columns: 4 });
232        let diagram = make_diagram(vec![Layer::Tier(tier)], vec![]);
233        let plan = compute_layout(&diagram).unwrap();
234        let html = render_html(&plan, &Theme::dark()).unwrap();
235
236        assert!(html.contains("grid-4"));
237    }
238
239    #[test]
240    fn test_render_responsive_css() {
241        let diagram = make_diagram(
242            vec![Layer::Tier(Tier::new(
243                NodeId::new("main").unwrap(),
244                vec![test_node("a", Color::Blue)],
245            ))],
246            vec![],
247        );
248        let plan = compute_layout(&diagram).unwrap();
249        let html = render_html(&plan, &Theme::dark()).unwrap();
250
251        assert!(html.contains("@media (max-width: 800px)"));
252    }
253
254    #[test]
255    fn test_render_animations() {
256        let diagram = make_diagram(
257            vec![Layer::Tier(Tier::new(
258                NodeId::new("main").unwrap(),
259                vec![test_node("a", Color::Blue)],
260            ))],
261            vec![],
262        );
263        let plan = compute_layout(&diagram).unwrap();
264        let html = render_html(&plan, &Theme::dark()).unwrap();
265
266        assert!(html.contains("@keyframes fadeIn"));
267        assert!(html.contains("@keyframes slideUp"));
268    }
269
270    #[test]
271    fn test_render_no_animations_when_disabled() {
272        let diagram = make_diagram(
273            vec![Layer::Tier(Tier::new(
274                NodeId::new("main").unwrap(),
275                vec![test_node("a", Color::Blue)],
276            ))],
277            vec![],
278        );
279        let plan = compute_layout(&diagram).unwrap();
280        let mut theme = Theme::dark();
281        let overrides = dendryform_core::ThemeOverrides {
282            animate: Some(false),
283            ..Default::default()
284        };
285        theme = theme.merge(overrides);
286        let html = render_html(&plan, &theme).unwrap();
287
288        assert!(html.contains("animation: none !important"));
289        assert!(!html.contains("@keyframes fadeIn"));
290    }
291
292    #[test]
293    fn test_render_taproot_full() {
294        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
295        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
296        let plan = compute_layout(&diagram).unwrap();
297        let html = render_html(&plan, &Theme::dark()).unwrap();
298
299        // Structure checks
300        assert!(html.contains("<!DOCTYPE html>"));
301        assert!(html.contains("taproot"));
302        assert!(html.contains("system architecture"));
303        assert!(html.contains("Claude Desktop"));
304        assert!(html.contains("Streamable HTTP + SSE"));
305        assert!(html.contains("container-solid"));
306        assert!(html.contains("container-dashed"));
307        assert!(html.contains("knowledge engine"));
308        assert!(html.contains("BigQuery"));
309        assert!(html.contains("flow-labels"));
310        assert!(html.contains("SQL queries"));
311        assert!(html.contains("legend"));
312        assert!(html.contains("Client / Auth"));
313    }
314
315    #[test]
316    fn test_render_ai_kasu_full() {
317        let yaml = include_str!("../../../examples/ai-kasu/architecture.yaml");
318        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
319        let plan = compute_layout(&diagram).unwrap();
320        let html = render_html(&plan, &Theme::dark()).unwrap();
321
322        assert!(html.contains("<!DOCTYPE html>"));
323        assert!(html.contains("ai-kasu"));
324        assert!(html.contains("MCP server architecture"));
325        assert!(html.contains("Claude Code"));
326        assert!(html.contains("KnowledgeEngine"));
327        assert!(html.contains("container-solid"));
328        assert!(html.contains("container-dashed"));
329        assert!(html.contains("knowledge engine"));
330        assert!(html.contains("flow-labels"));
331        assert!(html.contains("legend"));
332        assert!(html.contains("Tool Registries"));
333    }
334
335    #[test]
336    fn test_render_oxur_lisp_full() {
337        let yaml = include_str!("../../../examples/oxur-lisp/architecture.yaml");
338        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
339        let plan = compute_layout(&diagram).unwrap();
340        let html = render_html(&plan, &Theme::dark()).unwrap();
341
342        assert!(html.contains("<!DOCTYPE html>"));
343        assert!(html.contains("oxur"));
344        assert!(html.contains("language architecture"));
345        assert!(html.contains("Stage 1: Parse"));
346        assert!(html.contains("container-solid"));
347        assert!(html.contains("container-dashed"));
348        assert!(html.contains("oxur compilation pipeline"));
349        assert!(html.contains("flow-labels"));
350        assert!(html.contains("legend"));
351        assert!(html.contains("Frontend (oxur-lang)"));
352    }
353
354    #[test]
355    fn test_html_escaping() {
356        let node = Node::builder()
357            .id(NodeId::new("test").unwrap())
358            .kind(NodeKind::System)
359            .color(Color::Blue)
360            .icon("<>")
361            .title("A & B")
362            .description("x < y > z")
363            .build()
364            .unwrap();
365        let diagram = make_diagram(
366            vec![Layer::Tier(Tier::new(
367                NodeId::new("main").unwrap(),
368                vec![node],
369            ))],
370            vec![],
371        );
372        let plan = compute_layout(&diagram).unwrap();
373        let html = render_html(&plan, &Theme::dark()).unwrap();
374
375        assert!(html.contains("&amp;"));
376        assert!(html.contains("&lt;"));
377        assert!(html.contains("&gt;"));
378        // Must not contain unescaped
379        assert!(!html.contains("A & B"));
380        assert!(!html.contains("x < y"));
381    }
382}