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}
37
38impl Element {
39 pub fn new(tag: impl Into<String>) -> Self {
41 Self {
42 tag: tag.into(),
43 attributes: Vec::new(),
44 content: None,
45 children: Vec::new(),
46 }
47 }
48
49 #[must_use]
53 pub fn tag(&self) -> &str {
54 &self.tag
55 }
56
57 #[must_use]
59 pub fn attributes(&self) -> &[Attribute] {
60 &self.attributes
61 }
62
63 #[must_use]
65 pub fn content(&self) -> Option<&str> {
66 self.content.as_deref()
67 }
68
69 #[must_use]
71 pub fn children(&self) -> &[Node] {
72 &self.children
73 }
74
75 #[must_use]
81 pub fn text(mut self, content: impl AsRef<str>) -> Self {
82 let raw = content.as_ref();
83 let mut escaped = String::with_capacity(raw.len());
84 write_escaped_text(&mut escaped, raw, false);
85 self.content = Some(escaped);
86 self
87 }
88
89 #[must_use]
94 pub fn raw_text(mut self, content: impl Into<String>) -> Self {
95 self.content = Some(content.into());
96 self
97 }
98
99 #[must_use]
101 pub fn child(mut self, child: impl Into<Node>) -> Self {
102 self.children.push(child.into());
103 self
104 }
105
106 #[must_use]
108 pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
109 self.children.extend(children.into_iter().map(Into::into));
110 self
111 }
112
113 #[must_use]
117 pub fn add_attribute(mut self, attribute: Attribute) -> Self {
118 self.attributes.push(attribute);
119 self
120 }
121
122 #[must_use]
126 pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
127 self.add_attribute(Attribute::new(key, value))
128 }
129
130 #[must_use]
132 pub fn bool_attr(self, key: impl Into<String>) -> Self {
133 self.add_attribute(Attribute::boolean(key))
134 }
135
136 #[must_use]
138 pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
139 self.attr(format!("data-{}", key.as_ref()), value)
140 }
141
142 #[must_use]
148 pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
149 mut self,
150 data: impl IntoIterator<Item = (K, V)>,
151 ) -> Self {
152 for (key, value) in data {
153 self = self.data_attr(key, value);
154 }
155 self
156 }
157
158 #[must_use]
160 pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
161 self.attr(format!("aria-{}", key.as_ref()), value)
162 }
163
164 #[must_use]
166 pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
167 mut self,
168 aria: impl IntoIterator<Item = (K, V)>,
169 ) -> Self {
170 for (key, value) in aria {
171 self = self.aria_attr(key, value);
172 }
173 self
174 }
175
176 #[must_use]
183 pub fn set_id(self, id: impl AsRef<str>) -> Self {
184 self.replace_attribute("id", id.as_ref())
185 }
186
187 #[must_use]
189 pub fn set_style(self, style: impl AsRef<str>) -> Self {
190 self.replace_attribute("style", style.as_ref())
191 }
192
193 #[must_use]
195 pub fn set_role(self, role: impl AsRef<str>) -> Self {
196 self.replace_attribute("role", role.as_ref())
197 }
198
199 fn replace_attribute(mut self, key: &str, value: &str) -> Self {
200 self.attributes.retain(|a| a.key() != key);
201 self.attributes.push(Attribute::new(key, value));
202 self
203 }
204
205 #[must_use]
213 pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
214 let raw = class_name.as_ref();
215 if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
216 let mut merged = existing.value().to_string();
217 merged.push(' ');
218 write_escaped_attribute(&mut merged, raw);
219 *existing = Attribute::raw("class", merged);
221 } else {
222 self.attributes.push(Attribute::new("class", raw));
223 }
224 self
225 }
226
227 #[must_use]
229 pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
230 for name in class_names {
231 self = self.add_class(name);
232 }
233 self
234 }
235}
236
237impl Render for Element {
238 fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
239 crate::core::node::write_element_tree(self, out, options, depth);
240 }
241}
242
243impl Drop for Element {
252 fn drop(&mut self) {
253 let mut pending = core::mem::take(&mut self.children);
254 while let Some(node) = pending.pop() {
255 match node {
256 Node::Element(mut element) => pending.append(&mut element.children),
257 Node::Fragment(mut children) => pending.append(&mut children),
258 Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
259 }
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::elements::{
268 a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
269 nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
270 };
271
272 #[test]
274 fn add_class_appends_to_one_attribute() {
275 assert_eq!(
276 div().add_class("a").add_class("b").render(),
277 r#"<div class="a b"></div>"#
278 );
279 }
280
281 #[test]
283 fn chaining_add_class_does_not_double_escape() {
284 let rendered = div().add_class("a&b").add_class("c").render();
285 assert_eq!(rendered, r#"<div class="a&b c"></div>"#);
286 assert!(!rendered.contains("&amp;"));
287 }
288
289 #[test]
291 fn a_quote_in_a_class_name_cannot_break_out() {
292 let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
293 assert!(!rendered.contains("onload=\""));
294 assert!(rendered.contains("""));
295 }
296
297 #[test]
299 fn add_classes_appends_all_of_them_in_order() {
300 assert_eq!(
301 div().add_classes(["card", "p-4", "shadow"]).render(),
302 r#"<div class="card p-4 shadow"></div>"#
303 );
304 }
305
306 #[test]
308 fn set_id_replaces_rather_than_appending() {
309 let rendered = div().set_id("first").set_id("second").render();
310 assert_eq!(rendered, r#"<div id="second"></div>"#);
311 }
312
313 #[test]
315 fn set_style_and_set_role_also_replace() {
316 let rendered = div()
317 .set_style("color:red")
318 .set_style("color:blue")
319 .set_role("main")
320 .render();
321 assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
322 }
323
324 #[test]
327 fn data_and_aria_attributes_get_their_prefixes() {
328 let rendered = span()
329 .data_attr("id", "7")
330 .aria_attr("label", "Close")
331 .render();
332 assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
333 }
334
335 #[test]
337 fn bulk_attribute_order_is_stable() {
338 let build = || {
339 div()
340 .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
341 .render()
342 };
343 let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
344 for _ in 0..16 {
345 assert_eq!(build(), expected);
346 }
347 }
348
349 #[test]
352 fn plain_attributes_are_appended_without_deduplication() {
353 assert_eq!(
354 div().attr("data-x", "1").attr("data-x", "2").render(),
355 r#"<div data-x="1" data-x="2"></div>"#
356 );
357 }
358
359 #[test]
365 fn text_is_escaped_and_raw_text_is_not() {
366 assert_eq!(p().text("<b>").render(), "<p><b></p>");
367 assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
368 }
369
370 #[test]
371 fn boolean_attributes_render_bare() {
372 assert_eq!(
373 button().bool_attr("disabled").render(),
374 "<button disabled></button>"
375 );
376 }
377
378 #[test]
379 fn children_from_appends_a_sequence() {
380 let list = div().children_from([p().text("a"), p().text("b")]);
381 assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
382 }
383
384 #[test]
389 fn a_tag_renders_its_attributes_then_its_content() {
390 let tag = Element::new("p")
391 .attr("class", "text")
392 .text("Hello, World!");
393
394 assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
395 }
396
397 #[test]
403 fn extra_attributes_can_precede_the_typed_ones() {
404 let tag = img()
405 .attr("width", "100")
406 .attr("height", "100")
407 .attr("src", "image.png")
408 .attr("alt", "An image");
409
410 assert_eq!(
411 tag.render(),
412 r#"<img width="100" height="100" src="image.png" alt="An image">"#
413 );
414 }
415
416 #[test]
418 fn nested_children_render_in_order() {
419 let document = html_tag().child(
420 div()
421 .child(p().text("This is a paragraph."))
422 .child(img().attr("src", "image.png").attr("alt", "An image")),
423 );
424
425 assert_eq!(
426 document.render(),
427 concat!(
428 "<html><div><p>This is a paragraph.</p>",
429 r#"<img src="image.png" alt="An image"></div></html>"#,
430 )
431 );
432 }
433
434 #[test]
436 fn a_container_renders_its_class_before_its_children() {
437 let document = html_tag().child(
438 div()
439 .add_class("main-body")
440 .child(p().text("Title"))
441 .child(p().text("This is a paragraph.")),
442 );
443
444 assert_eq!(
445 document.render(),
446 r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
447 );
448 }
449
450 #[test]
452 fn a_table_renders_its_rows_and_cells() {
453 let document = html_tag().child(
454 table()
455 .add_class("table")
456 .child(
457 tr().child(th().text("Header 1"))
458 .child(th().text("Header 2")),
459 )
460 .child(
461 tr().child(td().text("Row 1, Cell 1"))
462 .child(td().text("Row 1, Cell 2")),
463 )
464 .child(
465 tr().child(td().text("Row 2, Cell 1"))
466 .child(td().text("Row 2, Cell 2")),
467 ),
468 );
469
470 assert_eq!(
471 document.render(),
472 concat!(
473 r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
474 "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
475 "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
476 )
477 );
478 }
479
480 #[test]
482 fn an_unordered_list_renders_its_items() {
483 let document = html_tag().child(
484 ul().add_class("unordered-list")
485 .child(li().text("Item 1"))
486 .child(li().text("Item 2"))
487 .child(li().text("Item 3")),
488 );
489
490 assert_eq!(
491 document.render(),
492 concat!(
493 r#"<html><ul class="unordered-list">"#,
494 "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
495 )
496 );
497 }
498
499 #[test]
501 fn an_ordered_list_renders_its_items() {
502 let document = html_tag().child(
503 ol().add_class("ordered-list")
504 .child(li().text("First"))
505 .child(li().text("Second"))
506 .child(li().text("Third")),
507 );
508
509 assert_eq!(
510 document.render(),
511 concat!(
512 r#"<html><ol class="ordered-list">"#,
513 "<li>First</li><li>Second</li><li>Third</li></ol></html>",
514 )
515 );
516 }
517
518 #[test]
524 fn a_description_list_renders_terms_and_descriptions() {
525 let document = html_tag().child(
526 Element::new("dl")
527 .add_class("description-list")
528 .child(Element::new("dt").text("Term 1"))
529 .child(Element::new("dd").text("Description 1"))
530 .child(Element::new("dt").text("Term 2"))
531 .child(Element::new("dd").text("Description 2")),
532 );
533
534 assert_eq!(
535 document.render(),
536 concat!(
537 r#"<html><dl class="description-list">"#,
538 "<dt>Term 1</dt><dd>Description 1</dd>",
539 "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
540 )
541 );
542 }
543
544 #[test]
546 fn the_structural_tags_nest_into_a_page() {
547 let document = html_tag()
548 .child(
549 head()
550 .child(
551 meta()
552 .attr("name", "description")
553 .attr("content", "A description of the page"),
554 )
555 .child(stylesheet("styles.css")),
556 )
557 .child(
558 body()
559 .child(
560 header().child(
561 nav()
562 .child(a().attr("href", "#home").text("Home"))
563 .child(a().attr("href", "#about").text("About"))
564 .child(a().attr("href", "#contact").text("Contact")),
565 ),
566 )
567 .child(main_tag().child(p().text("Welcome to our website!")))
568 .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
569 );
570
571 assert_eq!(
572 document.render(),
573 concat!(
574 r#"<html><head><meta name="description" content="A description of the page">"#,
575 r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
576 r##"<a href="#home">Home</a><a href="#about">About</a>"##,
577 r##"<a href="#contact">Contact</a></nav></header>"##,
578 "<main><p>Welcome to our website!</p></main>",
579 "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
580 )
581 );
582 }
583
584 #[test]
590 fn a_script_body_is_not_escaped() {
591 let document = html_tag().child(
592 script()
593 .attr("type", "text/javascript")
594 .raw_text("alert('Hello World');"),
595 );
596
597 assert_eq!(
598 document.render(),
599 r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
600 );
601 }
602
603 #[test]
605 fn meta_renders_both_the_named_and_the_charset_form() {
606 let document = html_tag()
607 .child(
608 meta()
609 .attr("name", "description")
610 .attr("content", "A description of the page"),
611 )
612 .child(meta().attr("charset", "utf-8"));
613
614 assert_eq!(
615 document.render(),
616 concat!(
617 r#"<html><meta name="description" content="A description of the page">"#,
618 r#"<meta charset="utf-8"></html>"#,
619 )
620 );
621 }
622
623 #[test]
625 fn a_title_renders_its_text() {
626 let document = html_tag().child(title().text("Title my site"));
627
628 assert_eq!(
629 document.render(),
630 "<html><title>Title my site</title></html>"
631 );
632 }
633
634 #[test]
636 fn a_span_renders_its_attributes_and_content() {
637 let tag = span().attr("class", "text").text("Hello, World!");
638
639 assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
640 }
641
642 #[test]
648 fn a_button_carries_its_type_after_its_class() {
649 let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
650
651 assert_eq!(
652 document.render(),
653 r#"<html><button class="button-class" type="button"></button></html>"#
654 );
655 }
656
657 #[test]
659 fn a_button_renders_its_children() {
660 let document = html_tag().child(
661 button()
662 .add_class("button-class")
663 .attr("type", "button")
664 .child(span().add_class("icon-bar")),
665 );
666
667 assert_eq!(
668 document.render(),
669 concat!(
670 r#"<html><button class="button-class" type="button">"#,
671 r#"<span class="icon-bar"></span></button></html>"#,
672 )
673 );
674 }
675
676 #[test]
684 fn chaining_helpers_keeps_the_element_usable() {
685 let card: Element = div().add_class("card").set_id("hero").set_role("region");
686
687 assert_eq!(
688 card.render(),
689 r#"<div class="card" id="hero" role="region"></div>"#
690 );
691 }
692
693 #[test]
695 fn set_style_escapes_quotes() {
696 let rendered = div()
697 .set_style(r#"font-family: "Inter", sans-serif"#)
698 .render();
699
700 assert_eq!(
701 rendered,
702 r#"<div style="font-family: "Inter", sans-serif"></div>"#
703 );
704 }
705
706 #[test]
708 fn a_single_class_renders_on_its_own() {
709 assert_eq!(
710 div().add_class("container").render(),
711 r#"<div class="container"></div>"#
712 );
713 }
714
715 #[test]
717 fn set_id_renders_an_id_attribute() {
718 assert_eq!(
719 div().set_id("main-content").render(),
720 r#"<div id="main-content"></div>"#
721 );
722 }
723
724 #[test]
726 fn the_helpers_chain_in_the_order_they_are_called() {
727 let rendered = div()
728 .set_id("content")
729 .add_class("container")
730 .add_class("active")
731 .set_style("padding: 20px;")
732 .render();
733
734 assert_eq!(
735 rendered,
736 r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
737 );
738 }
739
740 #[test]
746 fn several_data_attributes_keep_the_order_they_are_given() {
747 let rendered = div()
748 .data_attrs([("id", "123"), ("type", "product")])
749 .render();
750
751 assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
752 }
753
754 #[test]
756 fn several_aria_attributes_keep_the_order_they_are_given() {
757 let rendered = nav()
758 .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
759 .render();
760
761 assert_eq!(
762 rendered,
763 r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
764 );
765 }
766
767 #[test]
772 fn attr_appends_arbitrary_attributes() {
773 let rendered = input_named("text", "email")
774 .attr("placeholder", "Enter email")
775 .attr("required", "true")
776 .render();
777
778 assert_eq!(
779 rendered,
780 r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
781 );
782 }
783}