Skip to content

Serialization

James Conway edited this page Nov 4, 2026 · 2 revisions

Four methods turn a node back into a string. Three of them write markup following the HTML fragment serialization algorithm, section 13.3 of the HTML Standard, which is what a browser's innerHTML and outerHTML getters run. The fourth gives the text. The code is in crates/parsec/src/serialize.rs.

On this page: inner_html · outer_html · Escaping · html_alone · text · What's not here yet

Method Gives
inner_html(&self, id: NodeId) -> String The markup for id's children, as innerHTML returns it.
outer_html(&self, id: NodeId) -> String The markup for id and its children, as outerHTML returns it.
html_alone(&self, id: NodeId) -> String For an element, its start tag alone. For anything else, the same as outer_html.
text(&self, id: NodeId) -> String The text of id and everything under it, in tree order, close to textContent.

All four panic on an id that's doc.len() or more, and all four allocate a new String each call. None of them recurses, so a tree tens of thousands of levels deep serializes on a small thread stack.

inner_html

Writes each child of id in order.

  • For a Document or Fragment node, that's the whole tree: doc.inner_html(doc.root()) is how you write out a page.
  • For an HTML <template>, it writes the template's contents (and then any children it has, which it only has if you added them or called adopt_template_contents).
  • For a void HTML element (area, base, basefont, bgsound, br, col, embed, frame, hr, img, input, keygen, link, meta, param, source, track, wbr), it's always "", even if an edit gave it children.
  • For text, comments, doctypes and processing instructions, it's "".

outer_html

Writes the node itself, then its children, then its end tag:

Node Written as
HTML element <name attr="value" ...>, children, </name>. Void elements get no end tag and no children.
SVG or MathML element Start tag, children, end tag, always: an empty <clipPath/> comes out as <clipPath></clipPath>.
Text The text, escaped (see below), or as it is inside raw text elements.
Comment <!--text-->
Processing instruction <?target data?>
CDATA section <![CDATA[text]]>
Doctype <!DOCTYPE name>. The public and system identifiers aren't written, as the spec says.
Document or Fragment Its children, the same as inner_html.

Element and attribute names come out as written: SVG's clipPath and viewBox, MathML's definitionURL, and prefixed names such as xlink:href and xml:lang (see attrs_written). Attribute values are always in double quotes. Comments and processing instructions are written as they are, without escaping.

let doc = parsec::parse(
    r#"<body><svg viewBox="0 0 1 1"><clipPath/></svg><br><!--c--><?t d?>"#,
    false,
);
assert_eq!(
    doc.inner_html(doc.root()),
    r#"<html><head></head><body><svg viewBox="0 0 1 1"><clipPath></clipPath></svg><br><!--c--><?t d?></body></html>"#
);

Escaping

Text and attribute values are escaped as the spec's "escaping a string" says:

  • In text: & becomes &amp;, U+00A0 NO-BREAK SPACE becomes &nbsp;, < becomes &lt; and > becomes &gt;.
  • In attribute values: the same four, plus " becomes &quot;.

Nothing else is escaped. Non-ASCII text is written as UTF-8, not as references.

Text whose parent is a raw text element is written as it is, because the parser never decoded it in the first place. Those parents are the HTML elements style, script, xmp, iframe, noembed, noframes and plaintext, and noscript when the document was parsed with scripting on.

let doc = parsec::parse(
    "<p title='a\"b<c>&d'>1 &lt; 2 &amp; 3&nbsp;!</p><script>if (a < b && c) {}</script>",
    false,
);
assert_eq!(
    doc.inner_html(doc.root()),
    "<html><head></head><body>\
     <p title=\"a&quot;b&lt;c&gt;&amp;d\">1 &lt; 2 &amp; 3&nbsp;!</p>\
     <script>if (a < b && c) {}</script></body></html>"
);

Warning

A text node you created and put inside a <script> is written unescaped too. If you put </script> in it, the output won't parse back the same way; that's true in a browser as well.

html_alone

For an element, the start tag and nothing else: name and attributes, escaped as above. For any other node it's the same as outer_html, so for the root it writes the whole document.

use parsec::css::SelectorList;

let doc = parsec::parse(r#"<p title="a&b">x &amp; y</p>"#, false);
let p = SelectorList::parse("p").unwrap().select(&doc, doc.root())[0];
assert_eq!(doc.html_alone(p), r#"<p title="a&amp;b">"#);
assert_eq!(doc.outer_html(p), r#"<p title="a&amp;b">x &amp; y</p>"#);
let text = doc.first_child(p).unwrap();
assert_eq!(doc.html_alone(text), "x &amp; y");

text

Every text node at or under id, joined in tree order. Character references are decoded, nothing is escaped, and whitespace is kept exactly as parsed.

Where it differs from the DOM's textContent:

  • Text inside <script> and <style> is included, as textContent includes it. If you want visible text only, skip those elements yourself.
  • Template contents aren't included, as in the DOM; they aren't under the template.
  • CDATA sections aren't included. The HTML parser never makes them, so this only matters for trees you built.
  • On a comment or processing instruction it gives "", where textContent gives the comment's data.
  • On the document it gives the whole page's text, where textContent gives null.
let doc = parsec::parse("<p>a<b>b</b>c<!--no--></p><script>x=1</script>", false);
assert_eq!(doc.text(doc.root()), "abcx=1");

What's not here yet

  • Pretty-printed output, and the html5lib tree format the tree-construction tests use. Planned in #9, with the html5lib serializer tests.
  • Serializing straight to a Write or a fmt::Formatter without building a String.
  • XML serialization. An Ns::Xml tree is written with the HTML rules above.

See also

Clone this wiki locally