Skip to main content

rdom_core/
markup.rs

1//! Markup serialization — `outer_markup()` / `inner_markup()`.
2//!
3//! Walks the tree and writes HTML-ish text. Attributes and text are
4//! entity-encoded. Void elements (no children + known self-closing tags)
5//! emit `<hr/>` style. Round-trip-compatible with the DOMParser in
6//! Phase 10 — `Parser::parse(x.outer_markup()).outer_markup() == x.outer_markup()`.
7
8use crate::dom::Dom;
9use crate::node::NodeData;
10use crate::node_id::NodeId;
11
12/// The void elements of HTML §13.3 (serialization) — elements that
13/// never have children and are serialized without an end tag. Shared
14/// with `rdom-parser`, which treats a start tag of one of these as the
15/// whole element; keeping one list means what the parser accepts is
16/// exactly what the serializer emits.
17pub const VOID_ELEMENTS: &[&str] = &[
18    "area", "base", "basefont", "bgsound", "br", "col", "embed", "frame", "hr", "img", "input",
19    "keygen", "link", "meta", "param", "source", "track", "wbr",
20];
21
22/// Is `tag` (lowercase) one of [`VOID_ELEMENTS`]?
23pub fn is_void_element(tag: &str) -> bool {
24    VOID_ELEMENTS.contains(&tag)
25}
26
27/// Entity-encode a string for use inside an attribute value or text node.
28/// Matches the minimal set browsers require: `& < > " '`.
29fn escape_for_attr(s: &str, out: &mut String) {
30    for c in s.chars() {
31        match c {
32            '&' => out.push_str("&amp;"),
33            '<' => out.push_str("&lt;"),
34            '>' => out.push_str("&gt;"),
35            '"' => out.push_str("&quot;"),
36            '\'' => out.push_str("&#39;"),
37            c => out.push(c),
38        }
39    }
40}
41
42/// HTML §13.3 step 2 "text node" branch: a text child of one of these
43/// elements is serialized verbatim, because their content model is raw
44/// text — escaping `<` / `&` inside `<style>` would corrupt the CSS on
45/// round-trip. `textarea` and `title` are *escapable* raw text and are
46/// deliberately not listed.
47fn serializes_children_raw(tag: &str) -> bool {
48    matches!(
49        tag,
50        "style" | "script" | "xmp" | "iframe" | "noembed" | "noframes" | "plaintext"
51    )
52}
53
54/// Text nodes don't need to escape quotes but must escape the three html-critical chars.
55fn escape_for_text(s: &str, out: &mut String) {
56    for c in s.chars() {
57        match c {
58            '&' => out.push_str("&amp;"),
59            '<' => out.push_str("&lt;"),
60            '>' => out.push_str("&gt;"),
61            c => out.push(c),
62        }
63    }
64}
65
66impl<Ext> Dom<Ext> {
67    /// Serialize `id` and its subtree to HTML-ish markup.
68    ///
69    /// - Elements: `<tag attr="v" class="c d">children</tag>`
70    /// - Void elements: `<hr/>`
71    /// - Text: entity-encoded content
72    /// - Comments: `<!-- data -->`
73    /// - Fragments: concatenated children (no wrapper tag)
74    pub fn outer_markup(&self, id: NodeId) -> String {
75        let mut out = String::new();
76        self.write_node(id, &mut out, false);
77        out
78    }
79
80    /// Children serialized without `id`'s own wrapper. For an Element
81    /// this is the classic `innerHTML`; for a Fragment it's identical to
82    /// `outer_markup`; for Text/Comment it returns an empty string.
83    pub fn inner_markup(&self, id: NodeId) -> String {
84        let mut out = String::new();
85        let Some(node) = self.get_node(id) else {
86            return out;
87        };
88        match &node.data {
89            NodeData::Element { tag, .. } => {
90                self.write_children(id, &mut out, serializes_children_raw(tag));
91            }
92            NodeData::Fragment => self.write_children(id, &mut out, false),
93            _ => {}
94        }
95        out
96    }
97
98    fn write_children(&self, id: NodeId, out: &mut String, raw_text: bool) {
99        let mut child = self.get_node(id).and_then(|n| n.first_child);
100        while let Some(c) = child {
101            self.write_node(c, out, raw_text);
102            child = self.get_node(c).and_then(|n| n.next_sibling);
103        }
104    }
105
106    /// `raw_text` is true when `id`'s parent serializes its text
107    /// children verbatim (see [`serializes_children_raw`]). Iterative —
108    /// an explicit stack of open nodes and pending end tags — so any
109    /// depth is safe (`P7G-CORE-SMALL-1`).
110    fn write_node(&self, id: NodeId, out: &mut String, raw_text: bool) {
111        enum Step<'a> {
112            Node(NodeId, bool),
113            EndTag(&'a str),
114        }
115        let mut stack = vec![Step::Node(id, raw_text)];
116        // Children go on the stack last-first, so they pop in order.
117        let push_children = |stack: &mut Vec<Step<'_>>, id: NodeId, raw: bool| {
118            let mut child = self.get_node(id).and_then(|n| n.last_child);
119            while let Some(c) = child {
120                stack.push(Step::Node(c, raw));
121                child = self.get_node(c).and_then(|n| n.prev_sibling);
122            }
123        };
124        while let Some(step) = stack.pop() {
125            let (id, raw_text) = match step {
126                Step::EndTag(tag) => {
127                    out.push_str("</");
128                    out.push_str(tag);
129                    out.push('>');
130                    continue;
131                }
132                Step::Node(id, raw) => (id, raw),
133            };
134            let Some(node) = self.get_node(id) else {
135                continue;
136            };
137            match &node.data {
138                NodeData::Element {
139                    tag,
140                    attrs,
141                    classes,
142                    ..
143                } => {
144                    out.push('<');
145                    out.push_str(tag);
146                    // class attribute (if any).
147                    if !classes.is_empty() {
148                        out.push_str(" class=\"");
149                        let mut first = true;
150                        for c in classes {
151                            if !first {
152                                out.push(' ');
153                            }
154                            first = false;
155                            escape_for_attr(c, out);
156                        }
157                        out.push('"');
158                    }
159                    // Other attributes, alphabetically via BTreeMap — skip
160                    // "class" because we rendered it from the classList above.
161                    for (k, v) in attrs.iter().filter(|(k, _)| k.as_str() != "class") {
162                        out.push(' ');
163                        out.push_str(k);
164                        if !v.is_empty() {
165                            out.push_str("=\"");
166                            escape_for_attr(v, out);
167                            out.push('"');
168                        } else {
169                            // Boolean attribute — `disabled`, `hidden`, etc.
170                            // Empty string value is the canonical "present"
171                            // form in HTML5.
172                        }
173                    }
174
175                    if is_void_element(tag) && node.first_child.is_none() {
176                        out.push_str("/>");
177                        continue;
178                    }
179                    out.push('>');
180                    stack.push(Step::EndTag(tag));
181                    push_children(&mut stack, id, serializes_children_raw(tag));
182                }
183                NodeData::Text { data } => {
184                    if raw_text {
185                        out.push_str(data);
186                    } else {
187                        escape_for_text(data, out);
188                    }
189                }
190                NodeData::Comment { data } => {
191                    out.push_str("<!--");
192                    out.push_str(data); // comments pass through unescaped in HTML
193                    out.push_str("-->");
194                }
195                NodeData::Fragment => push_children(&mut stack, id, false),
196            }
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use crate::Dom;
204
205    #[test]
206    fn element_without_attrs() {
207        let mut dom: Dom = Dom::new();
208        let el = dom.create_element("div");
209        assert_eq!(dom.outer_markup(el), "<div></div>");
210    }
211
212    #[test]
213    fn element_with_attrs() {
214        let mut dom: Dom = Dom::new();
215        let el = dom.create_element("div");
216        dom.set_attribute(el, "role", "banner").unwrap();
217        dom.set_attribute(el, "data-x", "1").unwrap();
218        // Attributes in alphabetic order.
219        assert_eq!(
220            dom.outer_markup(el),
221            r#"<div data-x="1" role="banner"></div>"#
222        );
223    }
224
225    #[test]
226    fn element_with_classes() {
227        let mut dom: Dom = Dom::new();
228        let el = dom.create_element("div");
229        dom.add_class(el, "two").unwrap();
230        dom.add_class(el, "one").unwrap();
231        // class attribute ordered by BTreeSet (alphabetic).
232        assert_eq!(dom.outer_markup(el), r#"<div class="one two"></div>"#);
233    }
234
235    #[test]
236    fn void_tag_self_closes() {
237        let mut dom: Dom = Dom::new();
238        let el = dom.create_element("hr");
239        assert_eq!(dom.outer_markup(el), "<hr/>");
240    }
241
242    #[test]
243    fn nested_elements() {
244        let mut dom: Dom = Dom::new();
245        let div = dom.create_element("div");
246        let span = dom.create_element("span");
247        let text = dom.create_text_node("hi");
248        dom.append_child(span, text).unwrap();
249        dom.append_child(div, span).unwrap();
250        assert_eq!(dom.outer_markup(div), "<div><span>hi</span></div>");
251    }
252
253    #[test]
254    fn text_content_is_escaped() {
255        let mut dom: Dom = Dom::new();
256        let div = dom.create_element("div");
257        let t = dom.create_text_node("a & b <c>");
258        dom.append_child(div, t).unwrap();
259        assert_eq!(dom.outer_markup(div), "<div>a &amp; b &lt;c&gt;</div>");
260    }
261
262    /// HTML §13.3 serialization: text under `style` / `script` (and the
263    /// other raw-text elements) is emitted as-is; `textarea` / `title`
264    /// are escapable raw text and keep the escaping.
265    #[test]
266    fn raw_text_element_children_are_not_escaped() {
267        let mut dom: Dom = Dom::new();
268        let style = dom.create_element("style");
269        let css = dom.create_text_node("a > b { content: \"<&\"; }");
270        dom.append_child(style, css).unwrap();
271        assert_eq!(
272            dom.outer_markup(style),
273            "<style>a > b { content: \"<&\"; }</style>"
274        );
275        assert_eq!(dom.inner_markup(style), "a > b { content: \"<&\"; }");
276
277        let ta = dom.create_element("textarea");
278        let t = dom.create_text_node("<b>&");
279        dom.append_child(ta, t).unwrap();
280        assert_eq!(dom.outer_markup(ta), "<textarea>&lt;b&gt;&amp;</textarea>");
281    }
282
283    /// `PARSER-VOID-TAGS-1`: one exported list, exactly HTML §13.3's
284    /// void elements. `vr` was never an HTML element.
285    #[test]
286    fn void_elements_are_the_html_serialization_set() {
287        let expected = [
288            "area", "base", "basefont", "bgsound", "br", "col", "embed", "frame", "hr", "img",
289            "input", "keygen", "link", "meta", "param", "source", "track", "wbr",
290        ];
291        assert_eq!(crate::VOID_ELEMENTS, &expected);
292        assert!(crate::is_void_element("br"));
293        assert!(!crate::is_void_element("vr"));
294        assert!(!crate::is_void_element("div"));
295        let mut dom: Dom = Dom::new();
296        let vr = dom.create_element("vr");
297        assert_eq!(dom.outer_markup(vr), "<vr></vr>");
298    }
299
300    #[test]
301    fn attribute_values_are_escaped() {
302        let mut dom: Dom = Dom::new();
303        let el = dom.create_element("div");
304        dom.set_attribute(el, "title", r#"she said "hi""#).unwrap();
305        assert_eq!(
306            dom.outer_markup(el),
307            r#"<div title="she said &quot;hi&quot;"></div>"#
308        );
309    }
310
311    #[test]
312    fn comment_node_serializes() {
313        let mut dom: Dom = Dom::new();
314        let c = dom.create_comment(" note ");
315        assert_eq!(dom.outer_markup(c), "<!-- note -->");
316    }
317
318    #[test]
319    fn fragment_is_childrens_concat() {
320        let mut dom: Dom = Dom::new();
321        let frag = dom.create_document_fragment();
322        let a = dom.create_element("a");
323        let b = dom.create_element("b");
324        dom.append_child(frag, a).unwrap();
325        dom.append_child(frag, b).unwrap();
326        assert_eq!(dom.outer_markup(frag), "<a></a><b></b>");
327    }
328
329    #[test]
330    fn inner_markup_omits_wrapper() {
331        let mut dom: Dom = Dom::new();
332        let div = dom.create_element("div");
333        let span = dom.create_element("span");
334        let text = dom.create_text_node("inner");
335        dom.append_child(span, text).unwrap();
336        dom.append_child(div, span).unwrap();
337        assert_eq!(dom.inner_markup(div), "<span>inner</span>");
338    }
339
340    #[test]
341    fn boolean_attribute_emits_bare_name() {
342        let mut dom: Dom = Dom::new();
343        let el = dom.create_element("input");
344        dom.toggle_attribute(el, "disabled").unwrap(); // becomes empty-valued
345        // Attribute value is "" so we omit the `="..."` part.
346        assert_eq!(dom.outer_markup(el), "<input disabled/>");
347    }
348
349    #[test]
350    fn class_attribute_goes_through_class_list() {
351        // Setting `class` directly via set_attribute stores it separately
352        // from classList. outer_markup renders from classList (the
353        // source-of-truth) and ignores raw `class` attr to avoid doubling.
354        let mut dom: Dom = Dom::new();
355        let el = dom.create_element("div");
356        dom.add_class(el, "a").unwrap();
357        dom.add_class(el, "b").unwrap();
358        // classList has a, b. Never set via set_attribute("class", …).
359        assert_eq!(dom.outer_markup(el), r#"<div class="a b"></div>"#);
360    }
361
362    #[test]
363    fn deep_tree() {
364        let mut dom: Dom = Dom::new();
365        let html = dom.create_element("html");
366        let body = dom.create_element("body");
367        let h1 = dom.create_element("h1");
368        let t = dom.create_text_node("Welcome");
369        dom.append_child(h1, t).unwrap();
370        dom.append_child(body, h1).unwrap();
371        dom.append_child(html, body).unwrap();
372        assert_eq!(
373            dom.outer_markup(html),
374            "<html><body><h1>Welcome</h1></body></html>"
375        );
376    }
377}