use std::io::{self, Write};
use crate::core::node::{write_document_root, write_html_open_tag};
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 html_attributes: Vec<Attribute>,
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),
html_attributes: Vec::new(),
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),
html_attributes: Vec::new(),
head,
body,
}
}
#[must_use]
pub fn html_attr(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
self.html_attributes.push(Attribute::new(key, value));
self
}
#[must_use]
pub fn html_attrs<K: Into<String>, V: AsRef<str>>(
mut self,
attributes: impl IntoIterator<Item = (K, V)>,
) -> Self {
self.html_attributes.extend(
attributes
.into_iter()
.map(|(key, value)| Attribute::new(key, value)),
);
self
}
#[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 mut root = match &self.lang {
Some(lang) => html_tag().attr("lang", lang),
None => html_tag(),
};
for attribute in &self.html_attributes {
root = root.add_attribute(attribute.clone());
}
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.html_attributes,
&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");
write_html_open_tag(self.lang.as_deref(), &self.html_attributes, &mut open);
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)),
Document::new(Some("en"))
.html_attr("data-theme", "dark")
.html_attr("class", "a\"b")
.body_children([div().child(p().text("x"))]),
Document::new(None).html_attr("on load", "x"),
];
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 html_attributes_follow_lang_in_every_render_path() {
use crate::elements::h1;
let page = Document::new(Some("pt-BR"))
.html_attr("data-theme", "dark")
.html_attrs([("dir", "ltr")])
.body_children([h1().text("Hi")]);
let open = "<html lang=\"pt-BR\" data-theme=\"dark\" dir=\"ltr\">";
assert_eq!(
page.render_compact(),
format!("<!DOCTYPE html>\n{open}<head></head><body><h1>Hi</h1></body></html>")
);
assert_eq!(
page.render(),
format!(
"<!DOCTYPE html>\n{open}\n <head></head>\n <body>\n <h1>Hi</h1>\n </body>\n</html>"
)
);
assert_eq!(
page.root().render(),
format!("{open}<head></head><body><h1>Hi</h1></body></html>")
);
for options in [RenderOptions::pretty(), RenderOptions::compact()] {
let mut streamed = Vec::new();
page.render_to(&mut streamed, &options).unwrap();
assert_eq!(
String::from_utf8(streamed).unwrap(),
page.render_with(&options)
);
}
}
#[test]
fn an_unsafe_html_attribute_name_is_omitted_and_a_value_is_escaped() {
let page = Document::new(None)
.html_attr("on load=alert(1) x", "1")
.html_attr("data-x", "\"><script>");
assert!(
page.render_compact()
.starts_with("<!DOCTYPE html>\n<html data-x=\""><script>\">"),
"{}",
page.render_compact()
);
}
#[test]
fn a_document_without_html_attributes_is_unchanged() {
assert_eq!(
Document::new(Some("en")).render_compact(),
"<!DOCTYPE html>\n<html lang=\"en\"><head></head><body></body></html>"
);
}
#[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)
}
}