use std::io::{self, Write};
use crate::core::node::write_document_root;
use crate::core::{Attribute, Element, Node, Render, RenderOptions};
use crate::elements::{body, head, html_tag};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Document {
pub lang: Option<String>,
pub head: Element,
pub body: Element,
}
impl Default for Document {
fn default() -> Self {
Self::new(None)
}
}
impl std::fmt::Display for Document {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
impl Document {
#[must_use]
pub fn new(lang: Option<&str>) -> Self {
Self {
lang: lang.map(ToString::to_string),
head: head(),
body: body(),
}
}
#[must_use]
pub fn with_parts(lang: Option<&str>, head: Element, body: Element) -> Self {
Self {
lang: lang.map(ToString::to_string),
head,
body,
}
}
#[must_use]
pub fn head_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
self.head = self.head.children_from(children);
self
}
#[must_use]
pub fn body_children<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
self.body = self.body.children_from(children);
self
}
#[must_use]
pub fn root(&self) -> Element {
let root = match &self.lang {
Some(lang) => html_tag().attr("lang", lang),
None => html_tag(),
};
root.child(self.head.clone()).child(self.body.clone())
}
#[must_use]
pub fn render_with(&self, options: &RenderOptions) -> String {
let mut out = String::with_capacity(2048);
out.push_str("<!DOCTYPE html>\n");
write_document_root(
self.lang.as_deref(),
&self.head,
&self.body,
&mut out,
options,
);
out
}
#[must_use]
pub fn render(&self) -> String {
self.render_with(&RenderOptions::pretty())
}
#[must_use]
pub fn render_compact(&self) -> String {
self.render_with(&RenderOptions::compact())
}
pub fn render_to(&self, mut sink: impl Write, options: &RenderOptions) -> io::Result<()> {
if options.max_depth.is_some() || !streams_by_section(&self.body) {
return sink.write_all(self.render_with(options).as_bytes());
}
sink.write_all(self.html_open().as_bytes())?;
if options.pretty {
self.write_pretty_sections(&mut sink, options)
} else {
self.write_compact_sections(&mut sink, options)
}
}
fn html_open(&self) -> String {
let mut open = String::from("<!DOCTYPE html>\n<html");
if let Some(lang) = &self.lang {
Attribute::new_static("lang", lang).write_into(&mut open);
}
open.push('>');
open
}
fn write_pretty_sections(
&self,
sink: &mut impl Write,
options: &RenderOptions,
) -> io::Result<()> {
let mut head_markup = String::from("\n");
self.head.write_into(&mut head_markup, options, 1);
sink.write_all(head_markup.as_bytes())?;
let mut body_open = String::from("\n");
options.write_indent(&mut body_open, 1);
write_open_tag(&self.body, &mut body_open);
sink.write_all(body_open.as_bytes())?;
for child in self.body.children() {
let mut rendered = String::new();
child.write_into(&mut rendered, options, 2);
if !rendered.is_empty() {
sink.write_all(b"\n")?;
sink.write_all(rendered.as_bytes())?;
}
}
let mut close = String::from("\n");
options.write_indent(&mut close, 1);
close.push_str("</body>\n</html>");
sink.write_all(close.as_bytes())
}
fn write_compact_sections(
&self,
sink: &mut impl Write,
options: &RenderOptions,
) -> io::Result<()> {
let mut head_markup = String::new();
self.head.write_into(&mut head_markup, options, 0);
sink.write_all(head_markup.as_bytes())?;
let mut body_open = String::new();
write_open_tag(&self.body, &mut body_open);
sink.write_all(body_open.as_bytes())?;
for child in self.body.children() {
let mut rendered = String::new();
child.write_into(&mut rendered, options, 0);
sink.write_all(rendered.as_bytes())?;
}
sink.write_all(b"</body></html>")
}
}
fn streams_by_section(body: &Element) -> bool {
body.tag() == "body"
&& body.content().is_none()
&& !body.children().is_empty()
&& !body.is_self_closing()
}
fn write_open_tag(element: &Element, out: &mut String) {
out.push('<');
out.push_str(element.tag());
for attribute in element.attributes() {
attribute.write_into(out);
}
out.push('>');
}
#[cfg(test)]
mod tests {
#[test]
fn a_depth_limit_gives_the_same_bytes_streamed_and_whole() {
use crate::elements::{div, p, title};
let page = Document::new(Some("en"))
.head_children([title().text("T")])
.body_children([
div().child(div().child(p().text("deep"))),
div().text("flat"),
]);
for depth in 0..6 {
for base in [RenderOptions::pretty(), RenderOptions::compact()] {
let options = base.with_max_depth(depth);
let mut streamed = Vec::new();
page.render_to(&mut streamed, &options)
.expect("a Vec never fails");
assert_eq!(
String::from_utf8(streamed).expect("utf-8"),
page.render_with(&options),
"depth {depth}"
);
}
}
let rendered = page.render_with(&RenderOptions::compact().with_max_depth(2));
assert_eq!(
rendered,
"<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
);
}
use super::*;
use crate::core::Node;
use crate::elements::{body, h1, h2, head, img, meta, p, section, title};
#[test]
fn rendering_matches_the_cloned_root_tree_byte_for_byte() {
use crate::elements::{div, p, pre, title};
let documents = [
Document::new(None),
Document::new(Some("pt-BR")).head_children([title().text("T")]),
Document::new(Some("a\"b")).body_children([div().child(p().text("x")), div()]),
Document::new(Some("en")).body_children([Node::fragment([]), Node::text("")]),
Document::with_parts(Some("en"), head(), pre().text(" keep ")),
Document::with_parts(None, Element::new("bad tag"), body().text("t")),
Document::with_parts(None, head(), body().self_closing(true)),
];
for document in &documents {
for options in [
RenderOptions::pretty(),
RenderOptions::compact(),
RenderOptions::pretty().with_xhtml_self_closing(true),
RenderOptions::compact().with_indent("\t"),
] {
let mut expected = String::from("<!DOCTYPE html>\n");
document.root().write_into(&mut expected, &options, 0);
assert_eq!(document.render_with(&options), expected, "{document:?}");
}
}
}
#[test]
fn a_document_owns_the_doctype_and_the_lang_attribute() {
let page = Document::new(Some("pt-BR"));
assert!(
page.render()
.starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">")
);
}
#[test]
fn the_lang_attribute_is_omitted_when_absent() {
let page = Document::new(None);
assert!(page.render().starts_with("<!DOCTYPE html>\n<html>"));
}
#[test]
fn root_returns_the_tree_without_the_doctype() {
let page = Document::new(Some("en"));
assert!(!page.root().render().contains("DOCTYPE"));
assert!(page.root().render().starts_with("<html lang=\"en\">"));
}
#[test]
fn a_document_renders_pretty_by_default_unlike_an_element() {
let page = Document::new(Some("en")).head_children([title().text("T")]);
assert!(page.render().contains('\n'));
assert!(
!page
.render_compact()
.trim_start_matches("<!DOCTYPE html>\n")
.contains('\n')
);
}
#[test]
fn head_and_body_children_land_in_the_right_place() {
let page = Document::new(Some("en"))
.head_children([title().text("T")])
.body_children([h1().text("H")]);
let rendered = page.render();
let head_at = rendered.find("<title>").expect("title rendered");
let body_at = rendered.find("<h1>").expect("h1 rendered");
assert!(head_at < body_at);
}
#[test]
fn a_document_is_a_value_and_can_be_shared_across_threads() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Document>();
let page = Document::new(Some("en"));
let copy = page.clone().head_children([title().text("changed")]);
assert!(!page.render().contains("changed"));
assert!(copy.render().contains("changed"));
}
#[test]
fn body_children_accept_loops_and_conditions() {
let show_banner = false;
let page = Document::new(None)
.head_children([title().text("T")])
.body_children([crate::html! {
@if show_banner { div { "banner" } }
@for index in 1..=2 { p { "line " (index) } }
}]);
let rendered = page.render_compact();
assert!(!rendered.contains("banner"));
assert!(rendered.contains("<p>line 1</p><p>line 2</p>"));
}
#[test]
fn render_to_matches_render_with() {
let options = [
RenderOptions::pretty(),
RenderOptions::compact(),
RenderOptions::pretty()
.with_indent("\t")
.with_xhtml_self_closing(true),
];
let documents = [
streaming_page(3, false),
streaming_page(400, false),
streaming_page(2, true),
Document::with_parts(None, head(), body()),
Document::with_parts(Some("en"), head(), body().raw_text("<raw>")),
];
for page in &documents {
for options in &options {
let mut written = Vec::new();
page.render_to(&mut written, options).expect("write");
assert_eq!(written, page.render_with(options).into_bytes());
}
}
}
fn streaming_page(sections: usize, empty: bool) -> Document {
let mut nodes = Vec::new();
if empty {
nodes.push(Node::fragment([]));
}
for index in 0..sections {
nodes.push(
section()
.add_class("s")
.child(h2().text(format!("Section {index}")))
.child(p().text("text & more ".repeat(50)))
.child(img().attr("src", format!("/{index}.png")).attr("alt", ""))
.into(),
);
}
if empty {
nodes.push(Node::fragment([]));
}
Document::new(Some("en"))
.head_children([title().text("Streaming"), meta().attr("charset", "UTF-8")])
.body_children(nodes)
}
}