1use crate::core::attribute::Attribute;
8use crate::core::escape::{write_escaped_attribute, write_escaped_text};
9use crate::core::node::Node;
10use crate::core::render::{Render, RenderOptions};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Element {
32 tag: String,
33 attributes: Vec<Attribute>,
34 content: Option<String>,
35 children: Vec<Node>,
36 self_closing: bool,
37}
38
39impl Element {
40 pub fn new(tag: impl Into<String>) -> Self {
42 Self {
43 tag: tag.into(),
44 attributes: Vec::new(),
45 content: None,
46 children: Vec::new(),
47 self_closing: false,
48 }
49 }
50
51 #[must_use]
55 pub fn tag(&self) -> &str {
56 &self.tag
57 }
58
59 #[must_use]
61 pub fn attributes(&self) -> &[Attribute] {
62 &self.attributes
63 }
64
65 #[must_use]
67 pub fn content(&self) -> Option<&str> {
68 self.content.as_deref()
69 }
70
71 #[must_use]
73 pub fn children(&self) -> &[Node] {
74 &self.children
75 }
76
77 #[must_use]
89 pub fn is_self_closing(&self) -> bool {
90 self.self_closing
91 }
92
93 #[must_use]
99 pub fn text(mut self, content: impl AsRef<str>) -> Self {
100 let raw = content.as_ref();
101 let mut escaped = String::with_capacity(raw.len());
102 write_escaped_text(&mut escaped, raw, false);
103 self.content = Some(escaped);
104 self
105 }
106
107 #[must_use]
112 pub fn raw_text(mut self, content: impl Into<String>) -> Self {
113 self.content = Some(content.into());
114 self
115 }
116
117 #[must_use]
119 pub fn child(mut self, child: impl Into<Node>) -> Self {
120 self.children.push(child.into());
121 self
122 }
123
124 #[must_use]
126 pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
127 self.children.extend(children.into_iter().map(Into::into));
128 self
129 }
130
131 #[must_use]
135 pub fn add_attribute(mut self, attribute: Attribute) -> Self {
136 self.attributes.push(attribute);
137 self
138 }
139
140 #[must_use]
144 pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
145 self.add_attribute(Attribute::new(key, value))
146 }
147
148 #[must_use]
150 pub fn bool_attr(self, key: impl Into<String>) -> Self {
151 self.add_attribute(Attribute::boolean(key))
152 }
153
154 #[must_use]
169 pub fn trusted_url(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
170 let attribute = Attribute::trusted_url(key, value);
171 let name = attribute.key().to_string();
172 self.attributes.retain(|existing| existing.key() != name);
173 self.attributes.push(attribute);
174 self
175 }
176
177 #[must_use]
196 pub fn self_closing(mut self, yes: bool) -> Self {
197 self.self_closing = yes;
198 self
199 }
200
201 #[must_use]
203 pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
204 self.attr(format!("data-{}", key.as_ref()), value)
205 }
206
207 #[must_use]
213 pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
214 mut self,
215 data: impl IntoIterator<Item = (K, V)>,
216 ) -> Self {
217 for (key, value) in data {
218 self = self.data_attr(key, value);
219 }
220 self
221 }
222
223 #[must_use]
225 pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
226 self.attr(format!("aria-{}", key.as_ref()), value)
227 }
228
229 #[must_use]
231 pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
232 mut self,
233 aria: impl IntoIterator<Item = (K, V)>,
234 ) -> Self {
235 for (key, value) in aria {
236 self = self.aria_attr(key, value);
237 }
238 self
239 }
240
241 #[must_use]
248 pub fn set_id(self, id: impl AsRef<str>) -> Self {
249 self.replace_attribute("id", id.as_ref())
250 }
251
252 #[must_use]
254 pub fn set_style(self, style: impl AsRef<str>) -> Self {
255 self.replace_attribute("style", style.as_ref())
256 }
257
258 #[must_use]
260 pub fn set_role(self, role: impl AsRef<str>) -> Self {
261 self.replace_attribute("role", role.as_ref())
262 }
263
264 fn replace_attribute(mut self, key: &str, value: &str) -> Self {
265 self.attributes.retain(|a| a.key() != key);
266 self.attributes.push(Attribute::new(key, value));
267 self
268 }
269
270 #[must_use]
278 pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
279 let raw = class_name.as_ref();
280 if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
281 let mut merged = existing.value().to_string();
282 merged.push(' ');
283 write_escaped_attribute(&mut merged, raw);
284 *existing = Attribute::raw("class", merged);
286 } else {
287 self.attributes.push(Attribute::new("class", raw));
288 }
289 self
290 }
291
292 #[must_use]
294 pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
295 for name in class_names {
296 self = self.add_class(name);
297 }
298 self
299 }
300}
301
302impl Render for Element {
303 fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
304 crate::core::node::write_element_tree(self, out, options, depth);
305 }
306}
307
308impl Drop for Element {
317 fn drop(&mut self) {
318 let mut pending = core::mem::take(&mut self.children);
319 while let Some(node) = pending.pop() {
320 match node {
321 Node::Element(mut element) => pending.append(&mut element.children),
322 Node::Fragment(mut children) => pending.append(&mut children),
323 Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
324 }
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::core::RenderOptions;
333 use crate::elements::{
334 a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
335 nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
336 };
337
338 #[test]
340 fn trusted_url_replaces_a_blocked_value() {
341 assert_eq!(
342 a().attr("href", "javascript:x")
343 .trusted_url("href", "javascript:print()")
344 .render(),
345 r#"<a href="javascript:print()"></a>"#
346 );
347 }
348
349 #[test]
350 fn a_non_void_tag_marked_self_closing_drops_its_children() {
351 let tag = div()
352 .attr("id", "x")
353 .self_closing(true)
354 .child(span().text("no"));
355 assert_eq!(tag.render(), r#"<div id="x">"#);
356 assert_eq!(
357 tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
358 r#"<div id="x" />"#
359 );
360 assert_eq!(tag.render_pretty(), r#"<div id="x">"#);
361 assert!(!div().is_self_closing());
362 }
363
364 #[test]
366 fn add_class_appends_to_one_attribute() {
367 assert_eq!(
368 div().add_class("a").add_class("b").render(),
369 r#"<div class="a b"></div>"#
370 );
371 }
372
373 #[test]
375 fn chaining_add_class_does_not_double_escape() {
376 let rendered = div().add_class("a&b").add_class("c").render();
377 assert_eq!(rendered, r#"<div class="a&b c"></div>"#);
378 assert!(!rendered.contains("&amp;"));
379 }
380
381 #[test]
383 fn a_quote_in_a_class_name_cannot_break_out() {
384 let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
385 assert!(!rendered.contains("onload=\""));
386 assert!(rendered.contains("""));
387 }
388
389 #[test]
391 fn add_classes_appends_all_of_them_in_order() {
392 assert_eq!(
393 div().add_classes(["card", "p-4", "shadow"]).render(),
394 r#"<div class="card p-4 shadow"></div>"#
395 );
396 }
397
398 #[test]
400 fn set_id_replaces_rather_than_appending() {
401 let rendered = div().set_id("first").set_id("second").render();
402 assert_eq!(rendered, r#"<div id="second"></div>"#);
403 }
404
405 #[test]
407 fn set_style_and_set_role_also_replace() {
408 let rendered = div()
409 .set_style("color:red")
410 .set_style("color:blue")
411 .set_role("main")
412 .render();
413 assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
414 }
415
416 #[test]
419 fn data_and_aria_attributes_get_their_prefixes() {
420 let rendered = span()
421 .data_attr("id", "7")
422 .aria_attr("label", "Close")
423 .render();
424 assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
425 }
426
427 #[test]
429 fn bulk_attribute_order_is_stable() {
430 let build = || {
431 div()
432 .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
433 .render()
434 };
435 let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
436 for _ in 0..16 {
437 assert_eq!(build(), expected);
438 }
439 }
440
441 #[test]
444 fn plain_attributes_are_appended_without_deduplication() {
445 assert_eq!(
446 div().attr("data-x", "1").attr("data-x", "2").render(),
447 r#"<div data-x="1" data-x="2"></div>"#
448 );
449 }
450
451 #[test]
457 fn text_is_escaped_and_raw_text_is_not() {
458 assert_eq!(p().text("<b>").render(), "<p><b></p>");
459 assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
460 }
461
462 #[test]
463 fn boolean_attributes_render_bare() {
464 assert_eq!(
465 button().bool_attr("disabled").render(),
466 "<button disabled></button>"
467 );
468 }
469
470 #[test]
471 fn children_from_appends_a_sequence() {
472 let list = div().children_from([p().text("a"), p().text("b")]);
473 assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
474 }
475
476 #[test]
481 fn a_tag_renders_its_attributes_then_its_content() {
482 let tag = Element::new("p")
483 .attr("class", "text")
484 .text("Hello, World!");
485
486 assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
487 }
488
489 #[test]
495 fn extra_attributes_can_precede_the_typed_ones() {
496 let tag = img()
497 .attr("width", "100")
498 .attr("height", "100")
499 .attr("src", "image.png")
500 .attr("alt", "An image");
501
502 assert_eq!(
503 tag.render(),
504 r#"<img width="100" height="100" src="image.png" alt="An image">"#
505 );
506 }
507
508 #[test]
510 fn nested_children_render_in_order() {
511 let document = html_tag().child(
512 div()
513 .child(p().text("This is a paragraph."))
514 .child(img().attr("src", "image.png").attr("alt", "An image")),
515 );
516
517 assert_eq!(
518 document.render(),
519 concat!(
520 "<html><div><p>This is a paragraph.</p>",
521 r#"<img src="image.png" alt="An image"></div></html>"#,
522 )
523 );
524 }
525
526 #[test]
528 fn a_container_renders_its_class_before_its_children() {
529 let document = html_tag().child(
530 div()
531 .add_class("main-body")
532 .child(p().text("Title"))
533 .child(p().text("This is a paragraph.")),
534 );
535
536 assert_eq!(
537 document.render(),
538 r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
539 );
540 }
541
542 #[test]
544 fn a_table_renders_its_rows_and_cells() {
545 let document = html_tag().child(
546 table()
547 .add_class("table")
548 .child(
549 tr().child(th().text("Header 1"))
550 .child(th().text("Header 2")),
551 )
552 .child(
553 tr().child(td().text("Row 1, Cell 1"))
554 .child(td().text("Row 1, Cell 2")),
555 )
556 .child(
557 tr().child(td().text("Row 2, Cell 1"))
558 .child(td().text("Row 2, Cell 2")),
559 ),
560 );
561
562 assert_eq!(
563 document.render(),
564 concat!(
565 r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
566 "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
567 "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
568 )
569 );
570 }
571
572 #[test]
574 fn an_unordered_list_renders_its_items() {
575 let document = html_tag().child(
576 ul().add_class("unordered-list")
577 .child(li().text("Item 1"))
578 .child(li().text("Item 2"))
579 .child(li().text("Item 3")),
580 );
581
582 assert_eq!(
583 document.render(),
584 concat!(
585 r#"<html><ul class="unordered-list">"#,
586 "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
587 )
588 );
589 }
590
591 #[test]
593 fn an_ordered_list_renders_its_items() {
594 let document = html_tag().child(
595 ol().add_class("ordered-list")
596 .child(li().text("First"))
597 .child(li().text("Second"))
598 .child(li().text("Third")),
599 );
600
601 assert_eq!(
602 document.render(),
603 concat!(
604 r#"<html><ol class="ordered-list">"#,
605 "<li>First</li><li>Second</li><li>Third</li></ol></html>",
606 )
607 );
608 }
609
610 #[test]
616 fn a_description_list_renders_terms_and_descriptions() {
617 let document = html_tag().child(
618 Element::new("dl")
619 .add_class("description-list")
620 .child(Element::new("dt").text("Term 1"))
621 .child(Element::new("dd").text("Description 1"))
622 .child(Element::new("dt").text("Term 2"))
623 .child(Element::new("dd").text("Description 2")),
624 );
625
626 assert_eq!(
627 document.render(),
628 concat!(
629 r#"<html><dl class="description-list">"#,
630 "<dt>Term 1</dt><dd>Description 1</dd>",
631 "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
632 )
633 );
634 }
635
636 #[test]
638 fn the_structural_tags_nest_into_a_page() {
639 let document = html_tag()
640 .child(
641 head()
642 .child(
643 meta()
644 .attr("name", "description")
645 .attr("content", "A description of the page"),
646 )
647 .child(stylesheet("styles.css")),
648 )
649 .child(
650 body()
651 .child(
652 header().child(
653 nav()
654 .child(a().attr("href", "#home").text("Home"))
655 .child(a().attr("href", "#about").text("About"))
656 .child(a().attr("href", "#contact").text("Contact")),
657 ),
658 )
659 .child(main_tag().child(p().text("Welcome to our website!")))
660 .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
661 );
662
663 assert_eq!(
664 document.render(),
665 concat!(
666 r#"<html><head><meta name="description" content="A description of the page">"#,
667 r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
668 r##"<a href="#home">Home</a><a href="#about">About</a>"##,
669 r##"<a href="#contact">Contact</a></nav></header>"##,
670 "<main><p>Welcome to our website!</p></main>",
671 "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
672 )
673 );
674 }
675
676 #[test]
682 fn a_script_body_is_not_escaped() {
683 let document = html_tag().child(
684 script()
685 .attr("type", "text/javascript")
686 .raw_text("alert('Hello World');"),
687 );
688
689 assert_eq!(
690 document.render(),
691 r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
692 );
693 }
694
695 #[test]
697 fn meta_renders_both_the_named_and_the_charset_form() {
698 let document = html_tag()
699 .child(
700 meta()
701 .attr("name", "description")
702 .attr("content", "A description of the page"),
703 )
704 .child(meta().attr("charset", "utf-8"));
705
706 assert_eq!(
707 document.render(),
708 concat!(
709 r#"<html><meta name="description" content="A description of the page">"#,
710 r#"<meta charset="utf-8"></html>"#,
711 )
712 );
713 }
714
715 #[test]
717 fn a_title_renders_its_text() {
718 let document = html_tag().child(title().text("Title my site"));
719
720 assert_eq!(
721 document.render(),
722 "<html><title>Title my site</title></html>"
723 );
724 }
725
726 #[test]
728 fn a_span_renders_its_attributes_and_content() {
729 let tag = span().attr("class", "text").text("Hello, World!");
730
731 assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
732 }
733
734 #[test]
740 fn a_button_carries_its_type_after_its_class() {
741 let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
742
743 assert_eq!(
744 document.render(),
745 r#"<html><button class="button-class" type="button"></button></html>"#
746 );
747 }
748
749 #[test]
751 fn a_button_renders_its_children() {
752 let document = html_tag().child(
753 button()
754 .add_class("button-class")
755 .attr("type", "button")
756 .child(span().add_class("icon-bar")),
757 );
758
759 assert_eq!(
760 document.render(),
761 concat!(
762 r#"<html><button class="button-class" type="button">"#,
763 r#"<span class="icon-bar"></span></button></html>"#,
764 )
765 );
766 }
767
768 #[test]
776 fn chaining_helpers_keeps_the_element_usable() {
777 let card: Element = div().add_class("card").set_id("hero").set_role("region");
778
779 assert_eq!(
780 card.render(),
781 r#"<div class="card" id="hero" role="region"></div>"#
782 );
783 }
784
785 #[test]
787 fn set_style_escapes_quotes() {
788 let rendered = div()
789 .set_style(r#"font-family: "Inter", sans-serif"#)
790 .render();
791
792 assert_eq!(
793 rendered,
794 r#"<div style="font-family: "Inter", sans-serif"></div>"#
795 );
796 }
797
798 #[test]
800 fn a_single_class_renders_on_its_own() {
801 assert_eq!(
802 div().add_class("container").render(),
803 r#"<div class="container"></div>"#
804 );
805 }
806
807 #[test]
809 fn set_id_renders_an_id_attribute() {
810 assert_eq!(
811 div().set_id("main-content").render(),
812 r#"<div id="main-content"></div>"#
813 );
814 }
815
816 #[test]
818 fn the_helpers_chain_in_the_order_they_are_called() {
819 let rendered = div()
820 .set_id("content")
821 .add_class("container")
822 .add_class("active")
823 .set_style("padding: 20px;")
824 .render();
825
826 assert_eq!(
827 rendered,
828 r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
829 );
830 }
831
832 #[test]
838 fn several_data_attributes_keep_the_order_they_are_given() {
839 let rendered = div()
840 .data_attrs([("id", "123"), ("type", "product")])
841 .render();
842
843 assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
844 }
845
846 #[test]
848 fn several_aria_attributes_keep_the_order_they_are_given() {
849 let rendered = nav()
850 .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
851 .render();
852
853 assert_eq!(
854 rendered,
855 r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
856 );
857 }
858
859 #[test]
864 fn attr_appends_arbitrary_attributes() {
865 let rendered = input_named("text", "email")
866 .attr("placeholder", "Enter email")
867 .attr("required", "true")
868 .render();
869
870 assert_eq!(
871 rendered,
872 r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
873 );
874 }
875}