1use std::io::{self, Write};
6
7use crate::core::{Attribute, Element, Node, Render, RenderOptions};
8use crate::elements::{body, head, html_tag};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Document {
34 pub lang: Option<String>,
36 pub head: Element,
38 pub body: Element,
40}
41
42impl Document {
43 #[must_use]
45 pub fn new(lang: Option<&str>) -> Self {
46 Self {
47 lang: lang.map(ToString::to_string),
48 head: head(),
49 body: body(),
50 }
51 }
52
53 #[must_use]
55 pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
56 Self {
57 lang: lang.map(ToString::to_string),
58 head,
59 body,
60 }
61 }
62
63 #[must_use]
65 pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
66 self.head = self.head.children_from(children);
67 self
68 }
69
70 #[must_use]
72 pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
73 self.body = self.body.children_from(children);
74 self
75 }
76
77 #[must_use]
82 pub fn root(&self) -> Element {
83 let root = match &self.lang {
84 Some(lang) => html_tag().attr("lang", lang),
85 None => html_tag(),
86 };
87 root.child(self.head.clone()).child(self.body.clone())
88 }
89
90 #[must_use]
92 pub fn render_with(&self, options: &RenderOptions) -> String {
93 let mut out = String::with_capacity(2048);
94 out.push_str("<!DOCTYPE html>\n");
95 self.root().write_into(&mut out, options, 0);
96 out
97 }
98
99 #[must_use]
101 pub fn render(&self) -> String {
102 self.render_with(&RenderOptions::pretty())
103 }
104
105 #[must_use]
107 pub fn render_compact(&self) -> String {
108 self.render_with(&RenderOptions::compact())
109 }
110
111 pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
133 if !streams_by_section(&self.body) {
134 return sink.write_all(self.render_with(options).as_bytes());
135 }
136 sink.write_all(self.html_open().as_bytes())?;
137 if options.pretty {
138 self.write_pretty_sections(&mut sink, options)
139 } else {
140 self.write_compact_sections(&mut sink, options)
141 }
142 }
143
144 fn html_open(&self) -> String {
145 let mut open = String::from("<!DOCTYPE html>\n<html");
146 if let Some(lang) = &self.lang {
147 Attribute::new("lang", lang).write_into(&mut open);
148 }
149 open.push('>');
150 open
151 }
152
153 fn write_pretty_sections(
154 &self,
155 sink: &mut impl Write,
156 options: &RenderOptions,
157 ) -> io::Result<()> {
158 let mut head_markup = String::from("\n");
159 self.head.write_into(&mut head_markup, options, 1);
160 sink.write_all(head_markup.as_bytes())?;
161
162 let mut body_open = String::from("\n");
163 options.write_indent(&mut body_open, 1);
164 write_open_tag(&self.body, &mut body_open);
165 sink.write_all(body_open.as_bytes())?;
166
167 for child in self.body.children() {
168 let mut rendered = String::new();
169 child.write_into(&mut rendered, options, 2);
170 if !rendered.is_empty() {
171 sink.write_all(b"\n")?;
172 sink.write_all(rendered.as_bytes())?;
173 }
174 }
175
176 let mut close = String::from("\n");
177 options.write_indent(&mut close, 1);
178 close.push_str("</body>\n</html>");
179 sink.write_all(close.as_bytes())
180 }
181
182 fn write_compact_sections(
183 &self,
184 sink: &mut impl Write,
185 options: &RenderOptions,
186 ) -> io::Result<()> {
187 let mut head_markup = String::new();
188 self.head.write_into(&mut head_markup, options, 0);
189 sink.write_all(head_markup.as_bytes())?;
190
191 let mut body_open = String::new();
192 write_open_tag(&self.body, &mut body_open);
193 sink.write_all(body_open.as_bytes())?;
194
195 for child in self.body.children() {
196 let mut rendered = String::new();
197 child.write_into(&mut rendered, options, 0);
198 sink.write_all(rendered.as_bytes())?;
199 }
200 sink.write_all(b"</body></html>")
201 }
202}
203
204fn streams_by_section(body: &Element) -> bool {
207 body.tag() == "body"
208 && body.content().is_none()
209 && !body.children().is_empty()
210 && !body.is_self_closing()
211}
212
213fn write_open_tag(element: &Element, out: &mut String) {
214 out.push('<');
215 out.push_str(element.tag());
216 for attribute in element.attributes() {
217 attribute.write_into(out);
218 }
219 out.push('>');
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::core::Node;
226 use crate::elements::{body, h1, h2, head, img, meta, p, section, title};
227
228 #[test]
230 fn a_document_owns_the_doctype_and_the_lang_attribute() {
231 let page = Document::new(Some("pt-BR"));
232 assert!(
233 page.render()
234 .starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
235 );
236 }
237
238 #[test]
240 fn the_lang_attribute_is_omitted_when_absent() {
241 let page = Document::new(None);
242 assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
243 }
244
245 #[test]
247 fn root_returns_the_tree_without_the_doctype() {
248 let page = Document::new(Some("en"));
249 assert!(!page.root().render().contains("DOCTYPE"));
250 assert!(page.root().render().starts_with("<html lang=\"en\">"));
251 }
252
253 #[test]
256 fn a_document_renders_pretty_by_default_unlike_an_element() {
257 let page = Document::new(Some("en")).head_children([title().text("T")]);
258 assert!(page.render().contains('\n'));
259 assert!(
260 !page
261 .render_compact()
262 .trim_start_matches("<!DOCTYPE html>\n")
263 .contains('\n')
264 );
265 }
266
267 #[test]
269 fn head_and_body_children_land_in_the_right_place() {
270 let page = Document::new(Some("en"))
271 .head_children([title().text("T")])
272 .body_children([h1().text("H")]);
273 let rendered = page.render();
274 let head_at = rendered.find("<title>").expect("title rendered");
275 let body_at = rendered.find("<h1>").expect("h1 rendered");
276 assert!(head_at < body_at);
277 }
278
279 #[test]
281 fn a_document_is_a_value_and_can_be_shared_across_threads() {
282 fn assert_send_sync<T: Send + Sync>() {}
283 assert_send_sync::<Document>();
284
285 let page = Document::new(Some("en"));
286 let copy = page.clone().head_children([title().text("changed")]);
287 assert!(!page.render().contains("changed"));
288 assert!(copy.render().contains("changed"));
289 }
290
291 #[test]
293 fn body_children_accept_loops_and_conditions() {
294 let show_banner = false;
295 let page = Document::new(None)
296 .head_children([title().text("T")])
297 .body_children([crate::html! {
298 @if show_banner { div { "banner" } }
299 @for index in 1..=2 { p { "line " (index) } }
300 }]);
301
302 let rendered = page.render_compact();
303 assert!(!rendered.contains("banner"));
304 assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
305 }
306
307 #[test]
310 fn render_to_matches_render_with() {
311 let options = [
312 RenderOptions::pretty(),
313 RenderOptions::compact(),
314 RenderOptions::pretty()
315 .with_indent("\t")
316 .with_xhtml_self_closing(true),
317 ];
318 let documents = [
319 streaming_page(3, false),
320 streaming_page(400, false),
321 streaming_page(2, true),
322 Document::with_parts(None, head(), body()),
323 Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
324 ];
325 for page in &documents {
326 for options in &options {
327 let mut written = Vec::new();
328 page.render_to(&mut written, options).expect("write");
329 assert_eq!(written, page.render_with(options).into_bytes());
330 }
331 }
332 }
333
334 fn streaming_page(sections: usize, empty: bool) -> Document {
335 let mut nodes = Vec::new();
336 if empty {
337 nodes.push(Node::fragment([]));
338 }
339 for index in 0..sections {
340 nodes.push(
341 section()
342 .add_class("s")
343 .child(h2().text(format!("Section {index}")))
344 .child(p().text("text & more ".repeat(50)))
345 .child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
346 .into(),
347 );
348 }
349 if empty {
350 nodes.push(Node::fragment([]));
351 }
352 Document::new(Some("en"))
353 .head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
354 .body_children(nodes)
355 }
356}