Rewrite HTML without building a tree¶
Transform a document on the wire – retag links, lazy-load images, strip trackers, redact text – in a single pass that
never builds a tree and keeps only the open-element stack in memory. Reach for turbohtml.rewrite.rewrite() when
you want to change markup as it streams, the way Cloudflare’s lol-html rewrites at the edge, rather than parse a whole
document to edit a few nodes. For observing without editing, use Parse with SAX callbacks; to load a document you will navigate and
mutate freely, use turbohtml.parse().
Edit attributes¶
Register a (selector, handler) pair under elements. The handler is called with an
Element for each match; set or remove attributes on it and the start tag is rebuilt, while
everything you do not touch is copied through verbatim:
from turbohtml.rewrite import rewrite
def lazy(img):
img.set_attribute("loading", "lazy")
print(rewrite("<img src=hero.jpg><img src=x.png loading=eager>", elements=[("img", lazy)]))
<img src="hero.jpg" loading="lazy"><img src="x.png" loading="lazy">
Read the current value with get (it takes a default) or test presence with has_attribute; tag and attrs
expose the name and the (name, value) pairs.
Insert markup around¶
before and after insert content just outside the element’s tags. Content is HTML-escaped by default; pass
html=True to insert raw markup:
def spoiler(element):
element.before("<details><summary>spoiler</summary>", html=True)
element.after("</details>", html=True)
print(rewrite("<p class=spoiler>ending</p>", elements=[("p.spoiler", spoiler)]))
<details><summary>spoiler</summary><p class=spoiler>ending</p></details>
prepend and append insert inside the element, as its first and last content.
Replace, redact, unwrap¶
remove drops an element and its whole subtree; replace swaps it for other markup. Removing every <script>
strips it and its content in one pass:
def strip_tracker(element):
element.remove()
print(rewrite("<article>text<script src=track.js></script></article>", elements=[("script", strip_tracker)]))
<article>text</article>
set_content keeps the element’s tags but replaces what is between them – redacting a value without losing the
wrapper:
def redact(element):
element.set_content("[redacted]")
print(rewrite("<span class=ssn>123-45-6789</span>", elements=[("span.ssn", redact)]))
<span class=ssn>[redacted]</span>
remove_and_keep_content does the opposite – it drops the element’s own tags but keeps its children – to unwrap a
legacy element:
def unfont(element):
element.remove_and_keep_content()
print(rewrite("<p><font color=red>keep</font></p>", elements=[("font", unfont)]))
<p>keep</p>
Text, comments, and doctype¶
Beyond elements, pass text, comments, or doctype handlers. A text handler reads text and rewrites it
with set_text:
def shout(text):
text.set_text(text.text.upper())
print(rewrite("<h1>title</h1>", text=shout))
<h1>TITLE</h1>
A comment handler can drop comments the same way an element handler removes elements:
def nocomment(comment):
comment.remove()
print(rewrite("<p>hi<!-- TODO --></p>", comments=nocomment))
<p>hi</p>
What selectors can stream¶
Because the rewriter never looks ahead, only selectors decidable from an element and its ancestors match: type,
universal, id, class, and attribute selectors, the descendant (space) and child (>) combinators, :root, and
:is()/:where()/:not() over that subset. A sibling combinator (+, ~), a positional or structural pseudo-class
(:nth-child, :last-of-type, :only-child, :empty), or :has() needs content the stream has not reached
yet and raises SelectorSyntaxError. The streaming rewrite model explains why, and what to do when
you need one of them.