Skip to main content

domtree/serialize/
html.rs

1//! Re-serialize a parsed tree back to HTML.
2//!
3//! Iterative (explicit stack), so it handles arbitrarily deep trees without
4//! risking a stack overflow. Text and attribute values are escaped; the content
5//! of raw-text elements (`<script>`/`<style>`) is emitted verbatim, so a
6//! `parse` → `to_html` → `parse` round-trip is stable.
7//!
8//! Editing can put an end tag inside an element whose content ends only there
9//! — `<script>`, `<style>`, `<textarea>`, `<title>` — with
10//! `set_text(script, "</script><img …>")`, or an element or comment moved
11//! into one. Such a `</script` is written `<\/script`, so the output never
12//! closes the element early. A parsed document never has one (the tokenizer
13//! ends the content at the end tag), so its output is unchanged.
14
15use alloc::string::String;
16use alloc::vec::Vec;
17
18use crate::node::NodeKind;
19use crate::scan;
20use crate::tags;
21use crate::tree::{Dom, NodeRef};
22
23impl Dom {
24    /// Serialize the whole document back to an HTML string.
25    ///
26    /// ```
27    /// let dom = domtree::parse("<p class='x'>1 &lt; 2 &amp; 3</p>");
28    /// assert_eq!(dom.to_html(), r#"<p class="x">1 &lt; 2 &amp; 3</p>"#);
29    /// ```
30    pub fn to_html(&self) -> String {
31        let mut out = String::with_capacity(self.len() * 16);
32        let mut stack: Vec<Step<'_>> = Vec::new();
33        // Top-level nodes are linked siblings: push them last to first.
34        let mut root = self.roots().last();
35        while let Some(r) = root {
36            stack.push(Step::Open(r));
37            root = r.prev_sibling();
38        }
39        process(&mut out, &mut stack);
40        out
41    }
42}
43
44impl<'a> NodeRef<'a> {
45    /// Serialize just this node and its subtree back to an HTML string.
46    ///
47    /// ```
48    /// let dom = domtree::parse("<div><img src=a.png><br></div>");
49    /// let div = dom.find_by_tag("div").next().unwrap();
50    /// assert_eq!(div.to_html(), r#"<div><img src="a.png"><br></div>"#);
51    /// ```
52    pub fn to_html(&self) -> String {
53        let mut out = String::new();
54        let mut stack: Vec<Step<'a>> = Vec::new();
55        stack.push(Step::Open(*self));
56        process(&mut out, &mut stack);
57        out
58    }
59
60    /// The HTML of this node's children, without the node's own tags (the DOM
61    /// `innerHTML`). Empty for text, comment and void nodes.
62    ///
63    /// For a `<script>`, `<style>`, `<textarea>` or `<title>` it is exactly
64    /// the content [`NodeRef::to_html`] writes, with any end tag of the
65    /// element itself written `<\/…` (see [`Dom::set_text`]).
66    ///
67    /// ```
68    /// let dom = domtree::parse("<div id=x><b>hi</b> there</div>");
69    /// let div = dom.root().unwrap();
70    /// assert_eq!(div.inner_html(), "<b>hi</b> there");
71    /// ```
72    pub fn inner_html(&self) -> String {
73        let mut out = String::new();
74        let mut stack: Vec<Step<'a>> = Vec::new();
75        let mut child = self.last_child();
76        while let Some(c) = child {
77            stack.push(Step::Open(c));
78            child = c.prev_sibling();
79        }
80        process(&mut out, &mut stack);
81        if let Some(tag) = self.tag_name().filter(|t| tags::ends_only_at_end_tag(t)) {
82            neutralize_end_tags(&mut out, 0, tag);
83        }
84        out
85    }
86
87    /// This node and its subtree as HTML (the DOM `outerHTML`); the same as
88    /// [`NodeRef::to_html`].
89    ///
90    /// ```
91    /// let dom = domtree::parse("<div id=x><b>hi</b></div>");
92    /// let b = dom.find_by_tag("b").next().unwrap();
93    /// assert_eq!(b.outer_html(), "<b>hi</b>");
94    /// ```
95    pub fn outer_html(&self) -> String {
96        self.to_html()
97    }
98}
99
100enum Step<'a> {
101    Open(NodeRef<'a>),
102    Close(&'a str),
103    /// The end tag of the outermost element in the output whose content ends
104    /// only at its end tag.
105    CloseTextOnly(&'a str),
106}
107
108/// Serialize the steps on `stack` into `out`.
109fn process(out: &mut String, stack: &mut Vec<Step<'_>>) {
110    // Where the content of the outermost element that ends only at its end
111    // tag starts, while one is open. Only that one needs checking: up to its
112    // end tag, everything in it — nested ones included — is read as text.
113    let mut text_only = None;
114    while let Some(step) = stack.pop() {
115        let tag = match step {
116            Step::Open(node) => {
117                write_open(out, node, stack, &mut text_only);
118                continue;
119            }
120            Step::Close(tag) => tag,
121            Step::CloseTextOnly(tag) => {
122                if let Some(start) = text_only.take() {
123                    neutralize_end_tags(out, start, tag);
124                }
125                tag
126            }
127        };
128        out.push_str("</");
129        out.push_str(tag);
130        out.push('>');
131    }
132}
133
134/// Write `node`'s start tag (pushing its children and end tag on `stack`) or
135/// the whole node. `text_only` is where the content of the outermost element
136/// whose content ends only at its end tag started, if one is open.
137fn write_open<'a>(
138    out: &mut String,
139    node: NodeRef<'a>,
140    stack: &mut Vec<Step<'a>>,
141    text_only: &mut Option<usize>,
142) {
143    match node.kind() {
144        NodeKind::Element { tag } => {
145            out.push('<');
146            out.push_str(tag);
147            for (k, v) in node.attributes() {
148                out.push(' ');
149                out.push_str(k);
150                if !v.is_empty() {
151                    out.push_str("=\"");
152                    escape_attr(out, v);
153                    out.push('"');
154                }
155            }
156            out.push('>');
157            if tags::is_void(tag) {
158                return; // void elements have no children and no end tag
159            }
160            stack.push(if text_only.is_none() && tags::ends_only_at_end_tag(tag) {
161                *text_only = Some(out.len());
162                Step::CloseTextOnly(tag)
163            } else {
164                Step::Close(tag)
165            });
166            // Push children in reverse so they emit in document order.
167            let mut child = node.last_child();
168            while let Some(c) = child {
169                stack.push(Step::Open(c));
170                child = c.prev_sibling();
171            }
172        }
173        NodeKind::Text(t) => {
174            // Raw-text element content (script/style) is emitted verbatim.
175            let raw = node
176                .parent()
177                .and_then(|p| p.tag_name())
178                .is_some_and(tags::is_raw_text);
179            if raw {
180                out.push_str(t);
181            } else {
182                escape_text(out, t);
183            }
184        }
185        NodeKind::Comment(c) => {
186            out.push_str("<!--");
187            out.push_str(c);
188            out.push_str("-->");
189        }
190        NodeKind::Doctype(d) => {
191            out.push_str("<!doctype ");
192            out.push_str(d);
193            out.push('>');
194        }
195    }
196}
197
198/// Rewrite every `</tag` in `out[start..]` that would end the element `tag`
199/// early as `<\/tag` (`\/` is `/` inside JavaScript and CSS strings). Like
200/// the tokenizer, it matches `tag` ASCII case-insensitively, followed by
201/// whitespace, `/` or `>`. A `</tag` at the very end is left alone: the
202/// element's own end tag follows it, and `</tag<` ends nothing (parsed
203/// content can end that way: `<script>x</script</script>`).
204fn neutralize_end_tags(out: &mut String, start: usize, tag: &str) {
205    let Some(content) = out.get(start..) else {
206        return;
207    };
208    let bytes = content.as_bytes();
209    // Built only if something needs rewriting: parsed content never does.
210    let mut fixed = String::new();
211    let mut copied = 0;
212    let mut at = 0;
213    while let Some(i) = bytes.get(at..).and_then(|b| scan::find(b"</", b)) {
214        at += i + 2;
215        if let (Some(name), Some(b)) = (bytes.get(at..at + tag.len()), bytes.get(at + tag.len())) {
216            if name.eq_ignore_ascii_case(tag.as_bytes())
217                && matches!(b, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ' | b'/' | b'>')
218            {
219                fixed.push_str(content.get(copied..at - 1).unwrap_or_default());
220                fixed.push('\\');
221                copied = at - 1;
222            }
223        }
224    }
225    if copied > 0 {
226        fixed.push_str(content.get(copied..).unwrap_or_default());
227        out.truncate(start);
228        out.push_str(&fixed);
229    }
230}
231
232fn escape_text(out: &mut String, s: &str) {
233    for c in s.chars() {
234        match c {
235            '&' => out.push_str("&amp;"),
236            '<' => out.push_str("&lt;"),
237            '>' => out.push_str("&gt;"),
238            c => out.push(c),
239        }
240    }
241}
242
243fn escape_attr(out: &mut String, s: &str) {
244    for c in s.chars() {
245        match c {
246            '&' => out.push_str("&amp;"),
247            '"' => out.push_str("&quot;"),
248            c => out.push(c),
249        }
250    }
251}