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 < 2 & 3</p>");
28 /// assert_eq!(dom.to_html(), r#"<p class="x">1 < 2 & 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("&"),
236 '<' => out.push_str("<"),
237 '>' => out.push_str(">"),
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("&"),
247 '"' => out.push_str("""),
248 c => out.push(c),
249 }
250 }
251}