Building and editing a tree

Everything so far read a document that already existed. You can also build one. Construct nodes from their classes and assemble them with append(); the text setter fills an element with a single text child:

import turbohtml
from turbohtml import Element, Comment

article = turbohtml.Element("article", {"class": "post"})
title = turbohtml.Element("h1")
title.text = "Tea"
article.append(title)
article.append(Comment("draft"))
print(article.html)
<article class="post"><h1>Tea</h1><!--draft--></article>

A list value for a token-list attribute (class, rel, …) joins on a space, and None (or "") sets an empty attribute, which reads back as the empty string:

print(turbohtml.Element("input", {"class": ["a", "b"], "disabled": None}).html)
<input class="a b" disabled="">

Editing a parsed tree uses the BeautifulSoup vocabulary (insert_before, replace_with, wrap, unwrap, decompose), and element.attrs is a live mapping you assign to. A node already in a tree moves; a node from another tree is adopted by copy:

doc = turbohtml.parse("<p>keep <b>bold</b> <span>drop</span></p>")
print(doc.find("b").unwrap())
doc.find("span").decompose()
doc.find("p").attrs["class"] = "lead"
print(doc.find("p").html)
Element('b')
<p class="lead">keep bold </p>

Duplicate a subtree with copy.deepcopy() (or pickle); the clone is a standalone tree you can edit without touching the original:

import copy

clone = copy.deepcopy(article)
clone.append(turbohtml.Element("footer"))
print(clone.html == article.html)
False

Reshaping is not limited to trees you built. When you clean untrusted markup, the sanitizer can rename tags in the same pass: a Policy with transform_tags rewrites a source tag to a target (a Transform also adds attributes). The rename runs before the allowlist, so the result is re-checked – modernizing legacy tags while the safety baseline still applies:

from turbohtml.clean import sanitize, Policy, Transform

policy = Policy(
    tags=frozenset({"strong", "em", "div"}),
    attributes={"div": frozenset({"class"})},
    transform_tags={"b": "strong", "i": "em", "center": Transform("div", {"class": "center"})},
)
print(sanitize("<center><b>Old</b> <i>markup</i></center>", policy))
<div class="center"><strong>Old</strong> <em>markup</em></div>

The same pass can defuse DOM clobbering. An id or name whose value matches a built-in property (form.attributes, document.body) shadows it through named access, and the allowlist keeps such an attribute because it is otherwise ordinary. Turn on isolate_named_props to prefix every kept id and name value with user-content-, moving it out of the property namespace:

policy = Policy(
    tags=frozenset({"form", "input"}),
    attributes={"form": frozenset({"id"}), "input": frozenset({"name"})},
    isolate_named_props=True,
)
print(sanitize('<form id="settings"><input name="attributes"></form>', policy))
<form id="user-content-settings"><input name="user-content-attributes"></form>

To keep an app’s own custom elements without listing every one, hand the policy a matcher instead of a name set: custom_element_check decides whether an unlisted hyphenated element survives, and custom_attribute_check which of its attributes do. The safety baseline is unchanged, so the onclick here is dropped even though the matcher keeps the element:

policy = Policy(
    tags=frozenset({"p"}),
    custom_element_check=lambda tag: tag.startswith("x-"),
    custom_attribute_check=lambda _tag, name: name.startswith("data-"),
)
print(sanitize('<p>see <x-rating data-stars="5" onclick="steal()">stars</x-rating></p>', policy))
<p>see <x-rating data-stars="5">stars</x-rating></p>

When you serialize, set an Html config’s layout to a Minify to shrink the output without changing what it means: it folds insignificant whitespace, omits optional tags, unquotes safe attributes, and strips comments, and the result reparses to the same tree. Here the </li> tags stay because real whitespace separates the items:

from turbohtml import Html, Minify

page = turbohtml.parse("<ul>\n  <li>one</li>\n  <li>two</li>\n</ul>")
print(page.find("ul").serialize(Html(layout=Minify())))
<ul> <li>one</li> <li>two</li> </ul>

To keep a record of the edits you make, register a MutationObserver on a node before changing it, then read the changes back with take_records. Delivery is synchronous – turbohtml has no event loop, so the records are ready the moment you ask, not on a later microtask:

from turbohtml import Element, MutationObserver

page = turbohtml.parse("<ul><li>one</li></ul>")
listing = page.find("ul")
observer = MutationObserver()
observer.observe(listing, child_list=True)
listing.append(Element("li"))
(record,) = observer.take_records()
print(record.type, [added.tag for added in record.added_nodes])
childList ['li']

With a tree you can build and reshape, continue to Exporting to text to turn it back into text.