Skip to main content

winged_rust/
document.rs

1//! Whole-page assembly.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/core/Document.swift`.
4
5use crate::core::{Element, Node, Render, RenderOptions};
6use crate::elements::{body, head, html_tag};
7
8/// A complete HTML document: a language, a `<head>` and a `<body>`.
9///
10/// The `Document` owns the doctype — [`Document::render`] prepends `<!DOCTYPE html>\n`,
11/// while [`Document::root`] gives you the bare `<html>` element without it.
12///
13/// # Rendering default
14///
15/// **`Document::render` defaults to pretty, while [`Element::render`] defaults to
16/// compact.** That asymmetry is in the Swift original and the golden fixtures depend on it,
17/// so it is preserved rather than tidied up.
18///
19/// # Examples
20/// ```
21/// use winged_rust::prelude::*;
22/// use winged_rust::Document;
23///
24/// let page = Document::new(Some("pt-BR"))
25///     .head_children([title().text("RideKeeper")])
26///     .body_children([h1().text("Track every service")]);
27///
28/// assert!(page.render().starts_with("<!DOCTYPE html>\n<html lang=\"pt-BR\">"));
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Document {
32    /// The document language, rendered as `<html lang="…">`. Omitted when `None`.
33    pub lang: Option<String>,
34    /// The `<head>` element.
35    pub head: Element,
36    /// The `<body>` element.
37    pub body: Element,
38}
39
40impl Document {
41    /// Creates an empty document with the given language.
42    #[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    /// Creates a document from an existing `<head>` and `<body>`.
52    #[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    /// Appends nodes to the `<head>`.
62    #[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    /// Appends nodes to the `<body>`.
69    #[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    /// The `<html>` element, **without** the doctype.
76    ///
77    /// Kept separate from [`render`](Self::render) because the static site generator and
78    /// several tests want the tree rather than the serialized page.
79    #[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    /// Renders the document with the given options, prefixed by the doctype.
89    #[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    /// Renders the document **pretty-printed** — the Swift default for a document.
98    #[must_use]
99    pub fn render(&self) -> String {
100        self.render_with(&RenderOptions::pretty())
101    }
102
103    /// Renders the document on a single line.
104    #[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    /// Ports `DocumentTests.rendersDoctypeAndLanguage`.
116    #[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    /// Ports `DocumentTests.languageIsOptional`.
126    #[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    /// Ports `DocumentTests.rootExposesTheHTMLElementWithoutTheDoctype`.
133    #[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    /// Ports `DocumentTests.prettyIsTheDefaultForDocuments`. The asymmetry with `Element::render`
141    /// is deliberate — see the type docs.
142    #[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    /// Ports `DocumentTests.acceptsAnExistingHeadAndBody`.
155    #[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    /// Ports `DocumentTests.documentIsAValue`.
167    #[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    /// Ports `DocumentTests.buildersSupportLoopsAndConditions`.
179    #[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}