1use 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#[derive(Debug, Clone)]
64pub enum TypedNode {
65 Element {
67 tag: &'static str,
68 is_void: bool,
69 attrs: Vec<(Cow<'static, str>, String)>,
70 children: Vec<TypedNode>,
71 },
72 Text(String),
74 Raw(String),
76}
77
78impl TypedNode {
79 pub fn render(&self) -> String {
81 let mut output = String::new();
82 self.render_to(&mut output);
83 output
84 }
85
86 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#[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 pub fn new() -> Self {
150 Element {
151 attrs: Vec::new(),
152 children: Vec::new(),
153 _marker: PhantomData,
154 }
155 }
156
157 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 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 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 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 pub fn id(self, id: impl Into<String>) -> Self {
193 self.attr("id", id)
194 }
195
196 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 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 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 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 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 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 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 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 pub fn render(&self) -> String {
291 let mut output = String::new();
292 self.render_to(&mut output);
293 output
294 }
295
296 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#[derive(Debug, Clone, Default)]
329pub struct Document {
330 nodes: Vec<TypedNode>,
331}
332
333impl Document {
334 pub fn new() -> Self {
336 Document { nodes: Vec::new() }
337 }
338
339 pub fn doctype(mut self) -> Self {
341 self.nodes
342 .push(TypedNode::Raw("<!DOCTYPE html>".to_string()));
343 self
344 }
345
346 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 pub fn raw(mut self, html: impl Into<String>) -> Self {
359 self.nodes.push(TypedNode::Raw(html.into()));
360 self
361 }
362
363 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 pub fn render(&self) -> String {
374 self.build()
375 }
376
377 #[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><script>alert('xss')</script></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 "hello""></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 fs::remove_file(file_path).ok();
679 }
680}