html_builder/
typed.rs

1//! # Typed HTML Builder
2//!
3//! Type-safe HTML construction with compile-time validation of element
4//! nesting following the [WHATWG HTML Living Standard](https://html.spec.whatwg.org/).
5//!
6//! ## Design
7//!
8//! The typed builder uses Rust's type system to enforce valid HTML structure:
9//!
10//! - **Generic elements**: `Element<E>` is parameterized by the element type
11//! - **Compile-time validation**: The `CanContain` trait ensures valid parent-child
12//!   relationships at compile time
13//! - **Zero runtime overhead**: Element types are zero-sized, adding no cost
14//!
15//! ## Example
16//!
17//! ```rust
18//! use html_builder::typed::{Element, TypedNode};
19//! use html_elements::{Div, Span, P, Ul, Li, A, Text};
20//!
21//! // Build a navigation list
22//! let nav = Element::<Ul>::new()
23//!     .class("nav")
24//!     .child::<Li, _>(|li| {
25//!         li.child::<A, _>(|a| {
26//!             a.attr("href", "/").text("Home")
27//!         })
28//!     })
29//!     .child::<Li, _>(|li| {
30//!         li.child::<A, _>(|a| {
31//!             a.attr("href", "/about").text("About")
32//!         })
33//!     });
34//!
35//! let html = nav.render();
36//! assert!(html.contains(r#"<ul class="nav">"#));
37//! assert!(html.contains(r#"<a href="/">Home</a>"#));
38//! ```
39//!
40//! ## Compile-Time Safety
41//!
42//! Invalid nesting produces a compilation error:
43//!
44//! ```rust,compile_fail
45//! use html_builder::typed::Element;
46//! use html_elements::{Ul, Div};
47//!
48//! // This fails to compile: Ul cannot contain Div
49//! let invalid = Element::<Ul>::new()
50//!     .child::<Div, _>(|d| d);
51//! ```
52
53use alloc::borrow::Cow;
54use alloc::string::{String, ToString};
55use alloc::vec::Vec;
56use core::marker::PhantomData;
57use html_attributes::AttributeValue;
58use html_elements::{CanContain, HtmlElement, Text};
59
60use crate::{escape_attr, escape_html};
61
62/// A node in the typed HTML tree.
63#[derive(Debug, Clone)]
64pub enum TypedNode {
65    /// An element with tag, attributes, and children.
66    Element {
67        tag: &'static str,
68        is_void: bool,
69        attrs: Vec<(Cow<'static, str>, String)>,
70        children: Vec<TypedNode>,
71    },
72    /// Escaped text content.
73    Text(String),
74    /// Raw HTML (not escaped).
75    Raw(String),
76}
77
78impl TypedNode {
79    /// Render this node to a string.
80    pub fn render(&self) -> String {
81        let mut output = String::new();
82        self.render_to(&mut output);
83        output
84    }
85
86    /// Render this node to an existing string buffer.
87    pub fn render_to(&self, output: &mut String) {
88        match self {
89            TypedNode::Element {
90                tag,
91                is_void,
92                attrs,
93                children,
94            } => {
95                output.push('<');
96                output.push_str(tag);
97
98                for (name, value) in attrs {
99                    output.push(' ');
100                    output.push_str(name);
101                    if !value.is_empty() {
102                        output.push_str("=\"");
103                        output.push_str(&escape_attr(value));
104                        output.push('"');
105                    }
106                }
107
108                if *is_void && children.is_empty() {
109                    output.push_str(" />");
110                } else {
111                    output.push('>');
112
113                    for child in children {
114                        child.render_to(output);
115                    }
116
117                    output.push_str("</");
118                    output.push_str(tag);
119                    output.push('>');
120                }
121            }
122            TypedNode::Text(text) => output.push_str(&escape_html(text)),
123            TypedNode::Raw(html) => output.push_str(html),
124        }
125    }
126}
127
128/// A type-safe HTML element builder.
129///
130/// The type parameter `E` must implement [`HtmlElement`] and determines:
131/// - The tag name (via `E::TAG`)
132/// - Whether it's a void element (via `E::VOID`)
133/// - Which children are allowed (via `CanContain<Child>` implementations)
134#[derive(Debug, Clone)]
135pub struct Element<E: HtmlElement> {
136    attrs: Vec<(Cow<'static, str>, String)>,
137    children: Vec<TypedNode>,
138    _marker: PhantomData<E>,
139}
140
141impl<E: HtmlElement> Default for Element<E> {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl<E: HtmlElement> Element<E> {
148    /// Create a new empty element.
149    pub fn new() -> Self {
150        Element {
151            attrs: Vec::new(),
152            children: Vec::new(),
153            _marker: PhantomData,
154        }
155    }
156
157    /// Add an attribute with a string value.
158    pub fn attr(mut self, name: impl Into<Cow<'static, str>>, value: impl Into<String>) -> Self {
159        self.attrs.push((name.into(), value.into()));
160        self
161    }
162
163    /// Add an attribute with a type-safe value.
164    pub fn attr_value<V: AttributeValue>(
165        mut self,
166        name: impl Into<Cow<'static, str>>,
167        value: V,
168    ) -> Self {
169        self.attrs.push((name.into(), value.to_attr_value().into()));
170        self
171    }
172
173    /// Add a boolean attribute (no value, e.g., `disabled`, `checked`).
174    pub fn bool_attr(mut self, name: impl Into<Cow<'static, str>>) -> Self {
175        self.attrs.push((name.into(), String::new()));
176        self
177    }
178
179    /// Add a class. Multiple calls append to the class list.
180    pub fn class(mut self, class: impl Into<String>) -> Self {
181        let class = class.into();
182        if let Some(pos) = self.attrs.iter().position(|(k, _)| k == "class") {
183            self.attrs[pos].1.push(' ');
184            self.attrs[pos].1.push_str(&class);
185        } else {
186            self.attrs.push((Cow::Borrowed("class"), class));
187        }
188        self
189    }
190
191    /// Add an id attribute.
192    pub fn id(self, id: impl Into<String>) -> Self {
193        self.attr("id", id)
194    }
195
196    /// Add a data-* attribute.
197    pub fn data(self, name: &str, value: impl Into<String>) -> Self {
198        let attr_name = alloc::format!("data-{}", name);
199        self.attr(attr_name, value)
200    }
201
202    /// Add a child element.
203    ///
204    /// The child type must be allowed by the parent's content model.
205    /// This is enforced at compile time via the `CanContain` trait.
206    pub fn child<C, F>(mut self, f: F) -> Self
207    where
208        E: CanContain<C>,
209        C: HtmlElement,
210        F: FnOnce(Element<C>) -> Element<C>,
211    {
212        let child = f(Element::<C>::new());
213        self.children.push(child.into_node());
214        self
215    }
216
217    /// Add text content.
218    ///
219    /// Only available for elements that can contain text (via `CanContain<Text>`).
220    pub fn text(mut self, content: impl Into<String>) -> Self
221    where
222        E: CanContain<Text>,
223    {
224        self.children.push(TypedNode::Text(content.into()));
225        self
226    }
227
228    /// Add raw HTML content (not escaped).
229    ///
230    /// Use with caution - this bypasses XSS protection.
231    pub fn raw(mut self, html: impl Into<String>) -> Self
232    where
233        E: CanContain<Text>,
234    {
235        self.children.push(TypedNode::Raw(html.into()));
236        self
237    }
238
239    /// Add multiple children from an iterator.
240    pub fn children<C, I, F>(mut self, items: I, f: F) -> Self
241    where
242        E: CanContain<C>,
243        C: HtmlElement,
244        I: IntoIterator,
245        F: Fn(I::Item, Element<C>) -> Element<C>,
246    {
247        for item in items {
248            let child = f(item, Element::<C>::new());
249            self.children.push(child.into_node());
250        }
251        self
252    }
253
254    /// Conditionally add content.
255    pub fn when<F>(self, condition: bool, f: F) -> Self
256    where
257        F: FnOnce(Self) -> Self,
258    {
259        if condition {
260            f(self)
261        } else {
262            self
263        }
264    }
265
266    /// Conditionally add content with else branch.
267    pub fn when_else<F, G>(self, condition: bool, if_true: F, if_false: G) -> Self
268    where
269        F: FnOnce(Self) -> Self,
270        G: FnOnce(Self) -> Self,
271    {
272        if condition {
273            if_true(self)
274        } else {
275            if_false(self)
276        }
277    }
278
279    /// Convert this element into a renderable node.
280    pub fn into_node(self) -> TypedNode {
281        TypedNode::Element {
282            tag: E::TAG,
283            is_void: E::VOID,
284            attrs: self.attrs,
285            children: self.children,
286        }
287    }
288
289    /// Render this element to a string.
290    pub fn render(&self) -> String {
291        let mut output = String::new();
292        self.render_to(&mut output);
293        output
294    }
295
296    /// Render this element to an existing string buffer.
297    pub fn render_to(&self, output: &mut String) {
298        output.push('<');
299        output.push_str(E::TAG);
300
301        for (name, value) in &self.attrs {
302            output.push(' ');
303            output.push_str(name);
304            if !value.is_empty() {
305                output.push_str("=\"");
306                output.push_str(&escape_attr(value));
307                output.push('"');
308            }
309        }
310
311        if E::VOID && self.children.is_empty() {
312            output.push_str(" />");
313        } else {
314            output.push('>');
315
316            for child in &self.children {
317                child.render_to(output);
318            }
319
320            output.push_str("</");
321            output.push_str(E::TAG);
322            output.push('>');
323        }
324    }
325}
326
327/// A typed HTML document builder.
328#[derive(Debug, Clone, Default)]
329pub struct Document {
330    nodes: Vec<TypedNode>,
331}
332
333impl Document {
334    /// Create a new empty document.
335    pub fn new() -> Self {
336        Document { nodes: Vec::new() }
337    }
338
339    /// Add the HTML5 doctype declaration.
340    pub fn doctype(mut self) -> Self {
341        self.nodes
342            .push(TypedNode::Raw("<!DOCTYPE html>".to_string()));
343        self
344    }
345
346    /// Add a root element.
347    pub fn root<E, F>(mut self, f: F) -> Self
348    where
349        E: HtmlElement,
350        F: FnOnce(Element<E>) -> Element<E>,
351    {
352        let elem = f(Element::<E>::new());
353        self.nodes.push(elem.into_node());
354        self
355    }
356
357    /// Add raw HTML at the document level.
358    pub fn raw(mut self, html: impl Into<String>) -> Self {
359        self.nodes.push(TypedNode::Raw(html.into()));
360        self
361    }
362
363    /// Build the final HTML string.
364    pub fn build(&self) -> String {
365        let mut output = String::new();
366        for node in &self.nodes {
367            node.render_to(&mut output);
368        }
369        output
370    }
371
372    /// Render the document to a string (alias for `build`).
373    pub fn render(&self) -> String {
374        self.build()
375    }
376
377    /// Write the document to a file.
378    ///
379    /// Requires the `std` feature.
380    ///
381    /// ## Example
382    ///
383    /// ```rust
384    /// use html_builder::typed::Document;
385    /// use html_elements::{Html, Head, Body, Title, P};
386    ///
387    /// let doc = Document::new()
388    ///     .doctype()
389    ///     .root::<Html, _>(|html| {
390    ///         html.child::<Head, _>(|h| h.child::<Title, _>(|t| t.text("Hello")))
391    ///             .child::<Body, _>(|b| b.child::<P, _>(|p| p.text("Hello, World!")))
392    ///     });
393    ///
394    /// // Write to a temp file
395    /// let temp_path = std::env::temp_dir().join("html_builder_doctest.html");
396    /// doc.write_to_file(&temp_path).expect("Failed to write file");
397    ///
398    /// // Verify the file was written correctly
399    /// let content = std::fs::read_to_string(&temp_path).unwrap();
400    /// assert!(content.contains("<!DOCTYPE html>"));
401    /// assert!(content.contains("<title>Hello</title>"));
402    ///
403    /// // Clean up
404    /// std::fs::remove_file(&temp_path).ok();
405    /// ```
406    #[cfg(feature = "std")]
407    pub fn write_to_file(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
408        std::fs::write(path, self.build())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use html_elements::*;
416
417    #[test]
418    fn test_simple_element() {
419        let html = Element::<Div>::new()
420            .class("container")
421            .text("Hello")
422            .render();
423        assert_eq!(html, r#"<div class="container">Hello</div>"#);
424    }
425
426    #[test]
427    fn test_nested_elements() {
428        let html = Element::<Table>::new()
429            .class("table")
430            .child::<Tr, _>(|tr| {
431                tr.child::<Td, _>(|td| td.text("Cell 1"))
432                    .child::<Td, _>(|td| td.text("Cell 2"))
433            })
434            .render();
435
436        assert_eq!(
437            html,
438            r#"<table class="table"><tr><td>Cell 1</td><td>Cell 2</td></tr></table>"#
439        );
440    }
441
442    #[test]
443    fn test_list() {
444        let items = ["Apple", "Banana", "Cherry"];
445        let html = Element::<Ul>::new()
446            .children(items, |item, li: Element<Li>| li.text(item))
447            .render();
448
449        assert_eq!(
450            html,
451            r#"<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"#
452        );
453    }
454
455    #[test]
456    fn test_void_element() {
457        let html = Element::<Img>::new()
458            .attr("src", "image.jpg")
459            .attr("alt", "An image")
460            .render();
461
462        assert_eq!(html, r#"<img src="image.jpg" alt="An image" />"#);
463    }
464
465    #[test]
466    fn test_document() {
467        let html = Document::new()
468            .doctype()
469            .root::<Html, _>(|html| {
470                html.attr("lang", "en")
471                    .child::<Head, _>(|head| {
472                        head.child::<Meta, _>(|meta| meta.attr("charset", "UTF-8"))
473                            .child::<Title, _>(|title| title.text("Hello"))
474                    })
475                    .child::<Body, _>(|body| body.child::<H1, _>(|h1| h1.text("Hello, World!")))
476            })
477            .build();
478
479        assert_eq!(
480            html,
481            r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><title>Hello</title></head><body><h1>Hello, World!</h1></body></html>"#
482        );
483    }
484
485    #[test]
486    fn test_class_chaining() {
487        let html = Element::<Div>::new()
488            .class("btn")
489            .class("btn-primary")
490            .class("active")
491            .render();
492
493        assert_eq!(html, r#"<div class="btn btn-primary active"></div>"#);
494    }
495
496    #[test]
497    fn test_data_attributes() {
498        let html = Element::<Div>::new()
499            .data("id", "123")
500            .data("action", "submit")
501            .render();
502
503        assert_eq!(html, r#"<div data-id="123" data-action="submit"></div>"#);
504    }
505
506    #[test]
507    fn test_conditional() {
508        let show = true;
509        let html = Element::<Div>::new()
510            .when(show, |e| e.child::<Span, _>(|s| s.text("Visible")))
511            .render();
512
513        assert_eq!(html, r#"<div><span>Visible</span></div>"#);
514
515        let hide = false;
516        let html = Element::<Div>::new()
517            .when(hide, |e| e.child::<Span, _>(|s| s.text("Hidden")))
518            .render();
519
520        assert_eq!(html, r#"<div></div>"#);
521    }
522
523    #[test]
524    fn test_escape_text() {
525        let html = Element::<Div>::new()
526            .text("<script>alert('xss')</script>")
527            .render();
528
529        assert_eq!(
530            html,
531            r#"<div>&lt;script&gt;alert('xss')&lt;/script&gt;</div>"#
532        );
533    }
534
535    #[test]
536    fn test_escape_attr() {
537        let html = Element::<Div>::new()
538            .attr("data-value", "say \"hello\"")
539            .render();
540
541        assert_eq!(html, r#"<div data-value="say &quot;hello&quot;"></div>"#);
542    }
543
544    #[test]
545    fn test_type_safe_attribute_value() {
546        use html_attributes::{InputType, Loading};
547
548        let html = Element::<Input>::new()
549            .attr_value(html_attributes::input::TYPE, InputType::Email)
550            .attr("name", "email")
551            .render();
552
553        assert_eq!(html, r#"<input type="email" name="email" />"#);
554
555        let html = Element::<Img>::new()
556            .attr("src", "large.jpg")
557            .attr_value(html_attributes::img::LOADING, Loading::Lazy)
558            .render();
559
560        assert_eq!(html, r#"<img src="large.jpg" loading="lazy" />"#);
561    }
562
563    #[test]
564    fn test_form() {
565        use html_attributes::Method;
566
567        let html = Element::<Form>::new()
568            .attr("action", "/submit")
569            .attr_value(html_attributes::form::METHOD, Method::Post)
570            .child::<Input, _>(|i| {
571                i.attr("type", "text")
572                    .attr("name", "username")
573                    .attr("placeholder", "Username")
574            })
575            .child::<Input, _>(|i| i.attr("type", "password").attr("name", "password"))
576            .child::<Button, _>(|b| b.attr("type", "submit").text("Login"))
577            .render();
578
579        assert!(html.contains(r#"<form action="/submit" method="post">"#));
580        assert!(html.contains(r#"<input type="text" name="username""#));
581        assert!(html.contains(r#"<button type="submit">Login</button>"#));
582    }
583
584    #[test]
585    fn test_anchor_link() {
586        use html_attributes::Target;
587
588        let html = Element::<A>::new()
589            .attr("href", "https://example.com")
590            .attr_value(html_attributes::anchor::TARGET, Target::Blank)
591            .attr("rel", "noopener noreferrer")
592            .text("External Link")
593            .render();
594
595        assert_eq!(
596            html,
597            r#"<a href="https://example.com" target="_blank" rel="noopener noreferrer">External Link</a>"#
598        );
599    }
600
601    #[test]
602    fn test_select_options() {
603        let html = Element::<Select>::new()
604            .attr("name", "country")
605            .child::<Option_, _>(|o| o.attr("value", "us").text("United States"))
606            .child::<Option_, _>(|o| o.attr("value", "uk").text("United Kingdom"))
607            .child::<Option_, _>(|o| o.attr("value", "ca").text("Canada"))
608            .render();
609
610        assert!(html.contains(r#"<select name="country">"#));
611        assert!(html.contains(r#"<option value="us">United States</option>"#));
612        assert!(html.contains(r#"<option value="uk">United Kingdom</option>"#));
613    }
614
615    #[test]
616    fn test_definition_list() {
617        let html = Element::<Dl>::new()
618            .child::<Dt, _>(|dt| dt.text("HTML"))
619            .child::<Dd, _>(|dd| dd.text("HyperText Markup Language"))
620            .child::<Dt, _>(|dt| dt.text("CSS"))
621            .child::<Dd, _>(|dd| dd.text("Cascading Style Sheets"))
622            .render();
623
624        assert!(html.contains("<dl>"));
625        assert!(html.contains("<dt>HTML</dt>"));
626        assert!(html.contains("<dd>HyperText Markup Language</dd>"));
627    }
628
629    #[test]
630    fn test_complex_table() {
631        let html = Element::<Table>::new()
632            .class("table")
633            .child::<Thead, _>(|thead| {
634                thead.child::<Tr, _>(|tr| {
635                    tr.child::<Th, _>(|th| th.text("Name"))
636                        .child::<Th, _>(|th| th.text("Age"))
637                })
638            })
639            .child::<Tbody, _>(|tbody| {
640                tbody
641                    .child::<Tr, _>(|tr| {
642                        tr.child::<Td, _>(|td| td.text("Alice"))
643                            .child::<Td, _>(|td| td.text("30"))
644                    })
645                    .child::<Tr, _>(|tr| {
646                        tr.child::<Td, _>(|td| td.text("Bob"))
647                            .child::<Td, _>(|td| td.text("25"))
648                    })
649            })
650            .render();
651
652        assert!(html.contains("<thead>"));
653        assert!(html.contains("<th>Name</th>"));
654        assert!(html.contains("<tbody>"));
655        assert!(html.contains("<td>Alice</td>"));
656    }
657
658    #[cfg(feature = "std")]
659    #[test]
660    fn test_write_to_file() {
661        use std::fs;
662        let temp_dir = std::env::temp_dir();
663        let file_path = temp_dir.join("test_html_builder_output.html");
664
665        let doc = Document::new().doctype().root::<Html, _>(|html| {
666            html.child::<Head, _>(|h| h.child::<Title, _>(|t| t.text("Test")))
667                .child::<Body, _>(|b| b.child::<P, _>(|p| p.text("Hello")))
668        });
669
670        doc.write_to_file(&file_path).expect("Failed to write file");
671
672        let content = fs::read_to_string(&file_path).expect("Failed to read file");
673        assert!(content.contains("<!DOCTYPE html>"));
674        assert!(content.contains("<title>Test</title>"));
675        assert!(content.contains("<p>Hello</p>"));
676
677        // Clean up
678        fs::remove_file(file_path).ok();
679    }
680}