html_builder/
lib.rs

1//! # html-builder
2//!
3//! A minimal, zero-dependency, no-std compatible HTML builder for Rust.
4//!
5//! This crate provides two APIs:
6//!
7//! - **Untyped API**: Dynamic HTML construction with runtime tag names
8//! - **Typed API** (via [`typed`] module): Compile-time validated HTML structure
9//!
10//! ## Typed API (Recommended)
11//!
12//! The typed API uses Rust's type system to validate HTML structure at compile time:
13//!
14//! ```rust
15//! use html_builder::typed::{Document, Element};
16//! use html_elements::{Html, Head, Body, Title, H1, Div, P, Meta};
17//!
18//! let page = Document::new()
19//!     .doctype()
20//!     .root::<Html, _>(|html| {
21//!         html.attr("lang", "en")
22//!             .child::<Head, _>(|head| {
23//!                 head.child::<Meta, _>(|m| m.attr("charset", "UTF-8"))
24//!                     .child::<Title, _>(|t| t.text("My Page"))
25//!             })
26//!             .child::<Body, _>(|body| {
27//!                 body.child::<H1, _>(|h| h.text("Welcome"))
28//!                     .child::<P, _>(|p| p.text("Hello, World!"))
29//!             })
30//!     })
31//!     .build();
32//! ```
33//!
34//! Invalid nesting (e.g., `<ul><div>`) produces a compile-time error.
35//!
36//! ## Untyped API
37//!
38//! The untyped API allows dynamic HTML construction:
39//!
40//! ```rust
41//! use html_builder::{Html, Node};
42//!
43//! let html = Html::new()
44//!     .elem("table", |e| e
45//!         .attr("class", "table table-sm")
46//!         .child("thead", |e| e
47//!             .child("tr", |e| e
48//!                 .child("th", |e| e.text("Index"))
49//!                 .child("th", |e| e.text("Address"))
50//!             )
51//!         )
52//!         .child("tbody", |e| e
53//!             .child("tr", |e| e
54//!                 .child("td", |e| e.text("0"))
55//!                 .child("td", |e| e
56//!                     .child("code", |e| e.text("t1abc...xyz"))
57//!                 )
58//!             )
59//!         )
60//!     )
61//!     .build();
62//! ```
63
64#![cfg_attr(not(feature = "std"), no_std)]
65
66#[cfg(feature = "std")]
67extern crate std;
68
69extern crate alloc;
70
71#[cfg(feature = "typed")]
72pub mod typed;
73
74#[cfg(feature = "macros")]
75pub use html_macro::html;
76
77use alloc::string::{String, ToString};
78use alloc::vec::Vec;
79
80/// An HTML element with tag, attributes, and children.
81#[derive(Debug, Clone)]
82pub struct Element {
83    tag: String,
84    attrs: Vec<(String, String)>,
85    children: Vec<Node>,
86    self_closing: bool,
87}
88
89/// A node in the HTML tree - either an element or text.
90#[derive(Debug, Clone)]
91pub enum Node {
92    Element(Element),
93    Text(String),
94    Raw(String),
95}
96
97/// HTML builder for constructing HTML documents.
98#[derive(Debug, Clone, Default)]
99pub struct Html {
100    nodes: Vec<Node>,
101}
102
103impl Element {
104    /// Create a new element with the given tag name.
105    pub fn new(tag: impl Into<String>) -> Self {
106        let tag = tag.into();
107        let self_closing = matches!(
108            tag.as_str(),
109            "area"
110                | "base"
111                | "br"
112                | "col"
113                | "embed"
114                | "hr"
115                | "img"
116                | "input"
117                | "link"
118                | "meta"
119                | "source"
120                | "track"
121                | "wbr"
122        );
123        Element {
124            tag,
125            attrs: Vec::new(),
126            children: Vec::new(),
127            self_closing,
128        }
129    }
130
131    /// Add an attribute to this element.
132    pub fn attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
133        self.attrs.push((name.into(), value.into()));
134        self
135    }
136
137    /// Add a boolean attribute (no value, e.g., `disabled`, `checked`).
138    pub fn bool_attr(mut self, name: impl Into<String>) -> Self {
139        self.attrs.push((name.into(), String::new()));
140        self
141    }
142
143    /// Add a class attribute. If class already exists, appends to it.
144    pub fn class(mut self, class: impl Into<String>) -> Self {
145        let class = class.into();
146        if let Some(pos) = self.attrs.iter().position(|(k, _)| k == "class") {
147            self.attrs[pos].1.push(' ');
148            self.attrs[pos].1.push_str(&class);
149        } else {
150            self.attrs.push(("class".to_string(), class));
151        }
152        self
153    }
154
155    /// Add an id attribute.
156    pub fn id(self, id: impl Into<String>) -> Self {
157        self.attr("id", id)
158    }
159
160    /// Add a text child node.
161    pub fn text(mut self, content: impl Into<String>) -> Self {
162        self.children.push(Node::Text(content.into()));
163        self
164    }
165
166    /// Add raw HTML (not escaped).
167    pub fn raw(mut self, html: impl Into<String>) -> Self {
168        self.children.push(Node::Raw(html.into()));
169        self
170    }
171
172    /// Add a child element using a builder function.
173    pub fn child<F>(mut self, tag: impl Into<String>, f: F) -> Self
174    where
175        F: FnOnce(Element) -> Element,
176    {
177        let child = f(Element::new(tag));
178        self.children.push(Node::Element(child));
179        self
180    }
181
182    /// Add an existing node as a child.
183    pub fn node(mut self, node: Node) -> Self {
184        self.children.push(node);
185        self
186    }
187
188    /// Add multiple children from an iterator.
189    pub fn children<I, F>(mut self, items: I, f: F) -> Self
190    where
191        I: IntoIterator,
192        F: Fn(I::Item, Element) -> Element,
193    {
194        for item in items {
195            let child = f(item, Element::new(""));
196            if !child.tag.is_empty() {
197                self.children.push(Node::Element(child));
198            }
199        }
200        self
201    }
202
203    /// Conditionally add content.
204    pub fn when<F>(self, condition: bool, f: F) -> Self
205    where
206        F: FnOnce(Self) -> Self,
207    {
208        if condition {
209            f(self)
210        } else {
211            self
212        }
213    }
214
215    /// Conditionally add content with else branch.
216    pub fn when_else<F, G>(self, condition: bool, if_true: F, if_false: G) -> Self
217    where
218        F: FnOnce(Self) -> Self,
219        G: FnOnce(Self) -> Self,
220    {
221        if condition {
222            if_true(self)
223        } else {
224            if_false(self)
225        }
226    }
227
228    /// Render this element to a string.
229    pub fn render(&self) -> String {
230        let mut output = String::new();
231        self.render_to(&mut output);
232        output
233    }
234
235    /// Render this element to an existing string buffer.
236    pub fn render_to(&self, output: &mut String) {
237        output.push('<');
238        output.push_str(&self.tag);
239
240        for (name, value) in &self.attrs {
241            output.push(' ');
242            output.push_str(name);
243            if !value.is_empty() {
244                output.push_str("=\"");
245                output.push_str(&escape_attr(value));
246                output.push('"');
247            }
248        }
249
250        if self.self_closing && self.children.is_empty() {
251            output.push_str(" />");
252        } else {
253            output.push('>');
254
255            for child in &self.children {
256                child.render_to(output);
257            }
258
259            output.push_str("</");
260            output.push_str(&self.tag);
261            output.push('>');
262        }
263    }
264}
265
266impl Node {
267    /// Render this node to a string.
268    pub fn render(&self) -> String {
269        let mut output = String::new();
270        self.render_to(&mut output);
271        output
272    }
273
274    /// Render this node to an existing string buffer.
275    pub fn render_to(&self, output: &mut String) {
276        match self {
277            Node::Element(elem) => elem.render_to(output),
278            Node::Text(text) => output.push_str(&escape_html(text)),
279            Node::Raw(html) => output.push_str(html),
280        }
281    }
282}
283
284impl Html {
285    /// Create a new empty HTML builder.
286    pub fn new() -> Self {
287        Html { nodes: Vec::new() }
288    }
289
290    /// Add a root element using a builder function.
291    pub fn elem<F>(mut self, tag: impl Into<String>, f: F) -> Self
292    where
293        F: FnOnce(Element) -> Element,
294    {
295        let elem = f(Element::new(tag));
296        self.nodes.push(Node::Element(elem));
297        self
298    }
299
300    /// Add a text node at the root level.
301    pub fn text(mut self, content: impl Into<String>) -> Self {
302        self.nodes.push(Node::Text(content.into()));
303        self
304    }
305
306    /// Add raw HTML at the root level.
307    pub fn raw(mut self, html: impl Into<String>) -> Self {
308        self.nodes.push(Node::Raw(html.into()));
309        self
310    }
311
312    /// Build the final HTML string.
313    pub fn build(&self) -> String {
314        let mut output = String::new();
315        for node in &self.nodes {
316            node.render_to(&mut output);
317        }
318        output
319    }
320}
321
322/// Escape special HTML characters in text content.
323pub fn escape_html(s: &str) -> String {
324    let mut output = String::with_capacity(s.len());
325    for c in s.chars() {
326        match c {
327            '&' => output.push_str("&amp;"),
328            '<' => output.push_str("&lt;"),
329            '>' => output.push_str("&gt;"),
330            _ => output.push(c),
331        }
332    }
333    output
334}
335
336/// Escape special characters in attribute values.
337pub fn escape_attr(s: &str) -> String {
338    let mut output = String::with_capacity(s.len());
339    for c in s.chars() {
340        match c {
341            '&' => output.push_str("&amp;"),
342            '<' => output.push_str("&lt;"),
343            '>' => output.push_str("&gt;"),
344            '"' => output.push_str("&quot;"),
345            '\'' => output.push_str("&#x27;"),
346            _ => output.push(c),
347        }
348    }
349    output
350}
351
352// Convenience functions for common elements
353
354/// Create a div element.
355pub fn div<F>(f: F) -> Element
356where
357    F: FnOnce(Element) -> Element,
358{
359    f(Element::new("div"))
360}
361
362/// Create a span element.
363pub fn span<F>(f: F) -> Element
364where
365    F: FnOnce(Element) -> Element,
366{
367    f(Element::new("span"))
368}
369
370/// Create a table element.
371pub fn table<F>(f: F) -> Element
372where
373    F: FnOnce(Element) -> Element,
374{
375    f(Element::new("table"))
376}
377
378/// Create a simple text element.
379pub fn text_elem(tag: &str, content: impl Into<String>) -> Element {
380    Element::new(tag).text(content)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use alloc::vec;
387
388    #[test]
389    fn test_simple_element() {
390        let html = Element::new("div")
391            .class("container")
392            .text("Hello")
393            .render();
394        assert_eq!(html, r#"<div class="container">Hello</div>"#);
395    }
396
397    #[test]
398    fn test_nested_elements() {
399        let html = Html::new()
400            .elem("table", |e| {
401                e.class("table").child("tr", |e| {
402                    e.child("td", |e| e.text("Cell 1"))
403                        .child("td", |e| e.text("Cell 2"))
404                })
405            })
406            .build();
407
408        assert_eq!(
409            html,
410            r#"<table class="table"><tr><td>Cell 1</td><td>Cell 2</td></tr></table>"#
411        );
412    }
413
414    #[test]
415    fn test_attributes() {
416        let html = Element::new("input")
417            .attr("type", "text")
418            .attr("name", "username")
419            .bool_attr("disabled")
420            .render();
421
422        assert_eq!(html, r#"<input type="text" name="username" disabled />"#);
423    }
424
425    #[test]
426    fn test_escape_html() {
427        let html = Element::new("div")
428            .text("<script>alert('xss')</script>")
429            .render();
430        assert_eq!(
431            html,
432            r#"<div>&lt;script&gt;alert('xss')&lt;/script&gt;</div>"#
433        );
434    }
435
436    #[test]
437    fn test_escape_attr() {
438        let html = Element::new("div")
439            .attr("data-value", "say \"hello\"")
440            .render();
441        assert_eq!(html, r#"<div data-value="say &quot;hello&quot;"></div>"#);
442    }
443
444    #[test]
445    fn test_class_chaining() {
446        let html = Element::new("div")
447            .class("btn")
448            .class("btn-primary")
449            .class("active")
450            .render();
451        assert_eq!(html, r#"<div class="btn btn-primary active"></div>"#);
452    }
453
454    #[test]
455    fn test_children_iterator() {
456        let items = vec!["Apple", "Banana", "Cherry"];
457        let html = Element::new("ul")
458            .children(items, |item, _| Element::new("li").text(item))
459            .render();
460
461        assert_eq!(
462            html,
463            r#"<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"#
464        );
465    }
466
467    #[test]
468    fn test_conditional() {
469        let show_button = true;
470        let html = Element::new("div")
471            .when(show_button, |e| e.child("button", |e| e.text("Click me")))
472            .render();
473
474        assert_eq!(html, r#"<div><button>Click me</button></div>"#);
475
476        let show_button = false;
477        let html = Element::new("div")
478            .when(show_button, |e| e.child("button", |e| e.text("Click me")))
479            .render();
480
481        assert_eq!(html, r#"<div></div>"#);
482    }
483
484    #[test]
485    fn test_raw_html() {
486        let html = Element::new("div").raw("<strong>Bold</strong>").render();
487        assert_eq!(html, r#"<div><strong>Bold</strong></div>"#);
488    }
489
490    #[test]
491    fn test_self_closing_tags() {
492        let html = Element::new("br").render();
493        assert_eq!(html, r#"<br />"#);
494
495        let html = Element::new("img").attr("src", "pic.jpg").render();
496        assert_eq!(html, r#"<img src="pic.jpg" />"#);
497    }
498
499    #[test]
500    fn test_address_table_example() {
501        let addresses = vec![(0, "t1abc123", "u1xyz789"), (1, "t1def456", "u1uvw012")];
502
503        let html = Html::new()
504            .elem("table", |t| {
505                t.class("table table-sm")
506                    .child("thead", |e| {
507                        e.child("tr", |e| {
508                            e.child("th", |e| e.text("Index"))
509                                .child("th", |e| e.text("Transparent"))
510                                .child("th", |e| e.text("Unified"))
511                        })
512                    })
513                    .child("tbody", |e| {
514                        e.children(addresses, |(idx, t_addr, u_addr), _| {
515                            Element::new("tr")
516                                .child("td", |e| e.text(idx.to_string()))
517                                .child("td", |e| e.child("code", |e| e.text(t_addr)))
518                                .child("td", |e| e.child("code", |e| e.text(u_addr)))
519                        })
520                    })
521            })
522            .build();
523
524        assert!(html.contains("<table class=\"table table-sm\">"));
525        assert!(html.contains("<code>t1abc123</code>"));
526        assert!(html.contains("<code>u1xyz789</code>"));
527    }
528
529    #[test]
530    fn test_hello_world_page() {
531        let html = Html::new()
532            .raw("<!DOCTYPE html>")
533            .elem("html", |e| {
534                e.attr("lang", "en")
535                    .child("head", |e| {
536                        e.child("meta", |e| e.attr("charset", "UTF-8"))
537                            .child("title", |e| e.text("Hello"))
538                    })
539                    .child("body", |e| e.child("h1", |e| e.text("Hello, World!")))
540            })
541            .build();
542
543        assert_eq!(
544            html,
545            r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><title>Hello</title></head><body><h1>Hello, World!</h1></body></html>"#
546        );
547    }
548}