Serialization and export¶
One parsed tree fans out into several output forms. The same arena tree serializes back to conformant HTML, folds into minified HTML, or walks into Markdown, plain text, and annotated text. The purple source is the tree; every green node is one rendering of it.
flowchart LR
tree([parsed tree])
tree --> html["html / serialize / encode"]
tree --> minify["Minify"]
tree --> md["to_markdown"]
tree --> text["text"]
tree --> annotated["to_annotated_text"]
classDef src fill:#ede7f6,stroke:#4527a0,color:#311b92
classDef out fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
class tree src
class html,minify,md,text,annotated out
Serialization modes and minify¶
A Minify is a serialization mode on that same conformant tree, and its design rule is that the
minified bytes must reparse to the same tree: the hard part, a spec-correct parse, is already done, so minifying is only
allowed to drop or fold what the parser reconstructs on the way back in. That gives a single correctness gate:
minify(parse(minify(parse(src)))) equals minify(parse(src)), checked across the html5lib-tests corpus and real
pages. Whitespace folds to one space rather than disappearing (a single space reparses in place, so the fold is
idempotent); optional tags are omitted only away from open formatting elements, because the adoption agency would
otherwise reconstruct one across the gap and shift the tree; and a value is unquoted only when no character could end or
re-open it. The transforms that would not round-trip (deleting whitespace between block elements, or omitting a tag
whose reparse changes nesting) are exactly the ones turbohtml declines to make.
HTML vs XML serialization¶
The WHATWG DOM standard defines two ways to turn a node tree into markup: HTML fragment serialization and XML
serialization. They are different algorithms, not two escaping levels of one, and the same tree lands in both. The
xml field on Html selects the XML one – lxml’s method="xml", libxml2’s serializer, and the
XMLSerializer a browser exposes all target the same form. turbohtml keeps a single tree walk and branches on a flag
at the four points the algorithms diverge, so the HTML fast path is untouched.
The first divergence is empty elements. HTML has a closed list of void elements (br, img, input, …) that
take a start tag and never an end tag, and every other empty element still writes <div></div>. XML has no such list:
any element with no children self-closes as <div/> and a childless <br> becomes <br/> for the same reason a
<div> does, not a special case. The second is raw text. HTML copies a <script> or <style> body verbatim,
because those elements switch the tokenizer into a raw-text state on the way back in; XML has no raw-text elements, so a
< inside a script escapes like any other text and reparses to the same character. The third is escaping. XML
predefines only & < > " ', so the HTML no-break-space shortcut – undefined without a
DTD – cannot appear; turbohtml writes a literal U+00A0 (the output is Unicode), while the whitespace characters XML
would otherwise normalize away inside an attribute (tab, newline, carriage return) become numeric references so the
value round-trips.
The fourth is namespaces. turbohtml parses HTML, so an inline <svg> or <math> lands in the SVG or MathML
namespace inside an otherwise-HTML tree, with no namespace prefixes – HTML has none. XML has no such implicit
namespaces, so serializing that subtree well-formed means declaring them: the root of a foreign subtree gets the default
xmlns for its namespace, and an element carrying an xlink:-prefixed attribute gets the xlink prefix declared
on itself. Declaring the prefix on the element that uses it (rather than tracking which ancestor already declared it)
keeps any subtree well-formed on its own, at the cost of a redundant-but-legal redeclaration on a nested user. The
result parses with any XML reader, which is the whole point: an HTML tree becomes input for an XSLT or XPath 2.0
pipeline that would reject HTML’s unclosed tags. A Minify layout is inherently HTML (its
optional-tag and unquoted-attribute rules are HTML-parser rules), so it stays HTML even under xml=True.
Minifying JavaScript¶
Markup minification copies inline <script> text verbatim, because shrinking JavaScript is a different problem with a
different correctness rule. minify_js() is turbohtml’s own minifier, a native-C subsystem built
on the same infrastructure as the rest of the library, not a port of any existing tool. It is a real front end: lex,
parse to a flat arena AST, run the size-reducing passes, print back as code. A tokenizer-only minifier of the jsmin
school cannot tell a / that starts a regular expression from a / that means division (that needs grammar
position, not just the token stream), so it is known-incorrect, and turbohtml’s spec-authoritative rule rules it out.
The same lex-parse-optimize-print shape already runs, correctly and fast, in the XPath engine.
The passes split the way esbuild and terser split them. Whitespace, comment and number-literal minification is
unconditional; mangle renames bindings and fold runs the structural rewrites and dead-code elimination, each
toggled by JSMinify. Renaming is scope-aware: a script’s top level is the global scope, whose
names are observable, so only bindings local to a function are renamed, by reference count (the most-used binding in a
scope gets the shortest base-54 name, the frequency model esbuild uses). The correctness gate is idempotence plus
AST equivalence: the un-mangled output must reparse to the same tree, and minify(minify(x)) must equal
minify(x), checked across the competitors’ own conformance corpora rather than a homegrown oracle.
Every transform names the ECMA-262 clause that makes it behavior-preserving; the spec is the authority, not parity with
another tool. § numbers below link into the ECMA-262 standard.
transform |
example |
spec |
|---|---|---|
whitespace / comment removal, explicit |
|
|
number canonicalisation |
|
|
BigInt separator removal |
|
|
|
|
|
|
|
|
constant fold / dead-code elimination |
|
|
member |
|
|
identifier mangling (locals, nested function/class names, labels) |
|
|
|
|
|
guard clause to conditional return |
|
|
statement to comma sequence |
|
|
negation flip |
|
|
declaration merge |
|
Each transform carries the guard its clause forces: a constant branch drops only when it hoists no var or function
(§B.3.3), a
function declaration renames only in a function scope (a block declaration keeps its Annex-B hoisted twin), with and
direct eval (§19.2.1) leave the whole program unrenamed, and a
__proto__ data key keeps its quotes. Arithmetic folding (1+2 to 3) is deliberately omitted: an equivalence
proof across -0, NaN, IEEE-754 rounding and valueOf coercion is where minifiers ship miscompiles.
Inside HTML the pass is opt-in (Minify’s minify_js) and conservative. A <script> is
rewritten only when its type marks it as JavaScript - absent, empty, module, or a WHATWG JavaScript MIME essence
- so a type="application/json" or importmap block, which is data that merely resembles code, is never handed to
the JS parser; minifying it as JavaScript could change its quoting or numbers and break it. And a script the parser
cannot handle is emitted byte-for-byte, so a single malformed or not-yet-supported <script> degrades to verbatim
rather than breaking the surrounding document. The standalone minify_js(), by contrast, raises on
such input, because a caller asking to minify one script wants to hear that it could not.
Exporting to Markdown¶
to_markdown() is a second serializer that walks the same arena tree but emits GitHub-Flavored
Markdown instead of HTML. The survey of the field (the Python html2text, markdownify, and inscriptis, Go’s
html-to-markdown, and Rust’s htmd) converged on one architecture: a recursive visit over a real DOM (not a
streaming parse), with the block context threaded through the recursion rather than re-derived by walking parent
pointers. turbohtml already has the tree, so the exporter is a single pass over it into one growing buffer, classifying
each element as block (its own line, with collapsed blank-line margins) or inline (wrapped in a marker), the CSS
normal-flow distinction.
The one subtle part is whitespace, and it is where the reference libraries differ. turbohtml never emits a space from
text eagerly: a run of whitespace sets a pending flag, and the owed space is written only just before the next visible
character, and dropped at a line or block start. Because a closing emphasis marker does not flush that pending space, a
trailing space inside <b>bold </b> lands after the ** rather than producing the invalid **bold **; because
the opening marker is itself deferred until the first visible character, a leading space moves out the same way. The
common case (a run of plain prose with nothing to escape) is bulk-copied in one memcpy after its first character,
the borrow-or-copy fast path Rust’s htmd uses.
Three places where the field is inconsistent, turbohtml does the correct thing: an inline code span is fenced with one
more backtick than the longest run inside it (so ` `a``b` ` never splits), a | inside a table cell is escaped,
and a nested ordered list keeps its own counter through the recursion stack rather than a single mutable field that a
naive implementation corrupts on nesting. The output is opinionated GFM with no options, validated both by golden cases
and by rendering it back to HTML with a reference Markdown engine and checking that no visible text was lost.
The walk holds no state outside its stack frame (no module-level buffers, no per-converter object), so two threads
exporting two trees never interfere, and the binding takes the same per-tree critical section
text and html use so a concurrent mutation cannot rewire the tree
mid-walk (a no-op under the GIL build). Where Go’s html-to-markdown reaches for a mutex, the stateless visitor needs
none.
Where markdownify makes extensibility a subclass with a convert_<tag> method per tag, turbohtml exposes the same
power as a converters mapping: tag name to callable(element, content) -> str. The C walk checks it only on an
element and only when the mapping is present (one NULL test on the no-hook path), so the dispatch is free unless a
tag is actually registered. When one matches, the engine renders that element’s children into a sub-buffer (sharing the
document’s reference-link accumulator), hands the callable a real Element and that inner Markdown,
and splices the result back into the stream with block or inline framing from the tag. The callable runs inside the
walk’s critical section, so reading the element is safe; CPython suspends and resumes the section around any reentrant
tree access the callable makes, so it cannot deadlock.
A lighter knob unwraps whole tags without a callable: strip (a denylist) and convert (an allowlist), the same
pair markdownify carries. Both compile to one 256-bit set indexed by the interned tag atom, so the per-element test
is a constant-time bit lookup with no bound check: a stripped element renders its children in place of its own markup.
The interning is what makes a name the tag table does not know fold to no entry, mirroring how markdownify ignores a
tag it has no converter for.
Annotation output processors¶
to_annotated_text() walks the tree once and returns the rendered text together with a list of
(start, end, label) spans over its code points. inscriptis pairs that extraction step with a separate set of output
processors that turn the spans into a usable artifact, and turbohtml keeps the same split:
annotation_surface() and annotation_tags() are pure transforms over the (text,
spans) pair, never the tree. They take no node, no arena, and no shared handle, so unlike the serializers they need no
critical section at all: the input string is immutable and the spans sequence is only read, which makes them
free-threading safe by construction rather than by locking. Keeping extraction (the tree walk) and rendering (the span
transform) apart means one walk can feed several renderings, and the renderings compose with any spans of that shape,
not only the ones to_annotated_text() happens to emit.
The surface extractor is the easy half: bucket each span’s text[start:end] slice under its label, in document order.
The inline-tagged exporter is where nesting has to be handled, because two spans can share a boundary. It expands each
span into an open and a close event and sorts them so the result is always well-formed: at one position a non-zero-width
span closes before any opens, an outer span opens before an inner one and closes after it (the innermost always closes
first), and a zero-width span emits its own <label></label> intact rather than splitting it across a neighbor’s
boundary. The sort key carries the span’s other endpoint and its original index, so the order is total and the output
deterministic even when several spans coincide.
Lossless, byte-preserving output¶
The serializers above all normalize: they rebuild each start tag from the parsed attributes, so the author’s quoting,
tag-name case, and character-reference spelling are gone by the time bytes come out. That is the right default – the
output is canonical and diff-stable – but it is the wrong tool for a surgical edit, where you want every byte you did
not touch left exactly as written. to_source() is that tool. It is the tree-based counterpart to
the streaming turbohtml.rewrite.rewrite(): where the rewriter never builds a tree, to_source walks a tree you
have parsed, queried, and mutated, and re-emits the verbatim source of everything the mutation left alone.
The mechanism is the per-element source spans a parse with source_locations=True records (the same spans
source_location exposes). The walk emits each node’s own bytes and composes them: an element
copies its start-tag span, recurses, and copies its end-tag span; a text run copies its slice of the input while it is
still the zero-copy span the parse left. Because contiguous child spans tile the parent’s content, concatenating the
pieces reproduces the original – no separate whole-subtree copy is needed, and each clean node costs one memcpy.
The walk is iterative, descending through first-child and ascending through parent pointers, so an arbitrarily deep tree
never grows the C stack.
Three edits are reflected without any bookkeeping, because the walk reads the tree’s current state: an inserted element carries no source location, so it reserializes canonically; a removed element is simply absent from the child list; and an edited text run has been materialized off its span, so it re-escapes. The one edit a span cannot reveal is a changed attribute value – the start-tag bytes live in the source, independent of the attribute array – so the mutation API flags a located element’s start tag when it sets or deletes an attribute, and the walk rebuilds that one tag from the current attributes while leaving its end tag, its children, and its siblings verbatim.
What round-trips byte for byte is therefore the tree that faithfully mirrors the source: input that parses without
implied elements or content reordering. A document that spells out its structure (<html>, <head>, <body>, an
explicit <tbody>) reproduces exactly; a fragment reproduces through its children. Two normalizations the parser
itself performs are not reversible from the tree and so are not preserved: a lowercase <!doctype html> re-emits as
<!DOCTYPE html>, and a character reference the tokenizer resolved re-emits in its canonical spelling (&,
<, >, and survive, since the escaper re-encodes them; a legacy or numeric reference becomes its
character). Where every byte must survive an error-recovering or reordering parse, reach for the streaming rewriter,
which sees the token stream the tree has already discarded.