1use crate::core::{Element, Node, Render, RenderOptions};
6use crate::elements::{body, head, html_tag};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Document {
32 pub lang: Option<String>,
34 pub head: Element,
36 pub body: Element,
38}
39
40impl Document {
41 #[must_use]
43 pub fn new(lang: Option<&str>) -> Self {
44 Self {
45 lang: lang.map(ToString::to_string),
46 head: head(),
47 body: body(),
48 }
49 }
50
51 #[must_use]
53 pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
54 Self {
55 lang: lang.map(ToString::to_string),
56 head,
57 body,
58 }
59 }
60
61 #[must_use]
63 pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
64 self.head = self.head.children_from(children);
65 self
66 }
67
68 #[must_use]
70 pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
71 self.body = self.body.children_from(children);
72 self
73 }
74
75 #[must_use]
80 pub fn root(&self) -> Element {
81 let root = match &self.lang {
82 Some(lang) => html_tag().attr("lang", lang),
83 None => html_tag(),
84 };
85 root.child(self.head.clone()).child(self.body.clone())
86 }
87
88 #[must_use]
90 pub fn render_with(&self, options: &RenderOptions) -> String {
91 let mut out = String::with_capacity(2048);
92 out.push_str("<!DOCTYPE html>\n");
93 self.root().write_into(&mut out, options, 0);
94 out
95 }
96
97 #[must_use]
99 pub fn render(&self) -> String {
100 self.render_with(&RenderOptions::pretty())
101 }
102
103 #[must_use]
105 pub fn render_compact(&self) -> String {
106 self.render_with(&RenderOptions::compact())
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::elements::{h1, title};
114
115 #[test]
117 fn a_document_owns_the_doctype_and_the_lang_attribute() {
118 let page = Document::new(Some("pt-BR"));
119 assert!(
120 page.render()
121 .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
122 );
123 }
124
125 #[test]
127 fn the_lang_attribute_is_omitted_when_absent() {
128 let page = Document::new(None);
129 assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
130 }
131
132 #[test]
134 fn root_returns_the_tree_without_the_doctype() {
135 let page = Document::new(Some("en"));
136 assert!(!page.root().render().contains("DOCTYPE"));
137 assert!(page.root().render().starts_with("<html lang=\"en\">"));
138 }
139
140 #[test]
143 fn a_document_renders_pretty_by_default_unlike_an_element() {
144 let page = Document::new(Some("en")).head_children([title().text("T")]);
145 assert!(page.render().contains('\n'));
146 assert!(
147 !page
148 .render_compact()
149 .trim_start_matches("<!DOCTYPE html>\n")
150 .contains('\n')
151 );
152 }
153
154 #[test]
156 fn head_and_body_children_land_in_the_right_place() {
157 let page = Document::new(Some("en"))
158 .head_children([title().text("T")])
159 .body_children([h1().text("H")]);
160 let rendered = page.render();
161 let head_at = rendered.find("<title>").expect("title rendered");
162 let body_at = rendered.find("<h1>").expect("h1 rendered");
163 assert!(head_at < body_at);
164 }
165
166 #[test]
168 fn a_document_is_a_value_and_can_be_shared_across_threads() {
169 fn assert_send_sync<T: Send + Sync>() {}
170 assert_send_sync::<Document>();
171
172 let page = Document::new(Some("en"));
173 let copy = page.clone().head_children([title().text("changed")]);
174 assert!(!page.render().contains("changed"));
175 assert!(copy.render().contains("changed"));
176 }
177
178 #[test]
180 fn body_children_accept_loops_and_conditions() {
181 let show_banner = false;
182 let page = Document::new(None)
183 .head_children([title().text("T")])
184 .body_children([crate::html! {
185 @if show_banner { div { "banner" } }
186 @for index in 1..=2 { p { "line " (index) } }
187 }]);
188
189 let rendered = page.render_compact();
190 assert!(!rendered.contains("banner"));
191 assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
192 }
193}