From parse5¶
parse5 is the reference JavaScript WHATWG parser – the tree builder behind
jsdom, Angular, and much of the Node HTML ecosystem. It is unusual among the libraries here in being JavaScript rather
than Python, so this guide is a cross-language reference: it maps the parse5 API, and its sourceCodeLocationInfo
source-location model in particular, onto turbohtml for teams moving an HTML pipeline from Node to Python.
Both build the same WHATWG tree from the same algorithm, so the port is mechanical. Where parse5 hands back a plain
object tree and leaves querying, mutation, and serialization to other packages (@parse5/tools, dom-serializer,
your own walks), turbohtml keeps you inside one typed Document for all of it.
turbohtml vs parse5¶
Dimension |
turbohtml |
parse5 |
|---|---|---|
Language |
Python, over a C engine |
JavaScript (Node/browser) |
Scope |
Parse, query, mutate, and serialize in one library |
Parse and serialize; querying and mutation are your own walks or extra packages |
Source locations |
|
|
Querying |
CSS |
none built in; walk |
Typing |
Fully type annotated with bundled stubs |
TypeScript types shipped with the package |
Performance |
Native C engine straight into the native tree; see the table below |
JavaScript over the same algorithm |
Source-location parity¶
parse5’s sourceCodeLocationInfo option is the model turbohtml’s source_locations matches. Both attach, to each
element, the span of its start tag, its end tag, and each attribute; both count a 1-based line, a 0-based column, and a
code-point offset; and both leave the end tag absent when the source never closed the element. The field names differ
but line up one for one:
parse5 |
turbohtml |
|---|---|
|
|
|
|
|
|
|
|
|
|
One difference: parse5 also fills a sourceCodeLocation on text, comment, and document-fragment nodes, and a
whole-element span (its startOffset/endOffset across children). turbohtml scopes
source_location to elements and reports None for other node types; a text run’s own offsets
are not exposed. Read a node’s source_line/source_col for the coarse
position that stays available on every parse.
Performance¶
turbohtml records the same spans in its C engine, so parsing the same document with locations on runs 1.5x to 3.5x faster than parse5 measured in-process (Node startup excluded):
document |
turbohtml |
|
|---|---|---|
daring fireball (10 kB) |
249 µs |
643 µs (2.6x) |
ars technica (56 kB) |
1.2 ms |
2.97 ms (2.5x) |
mozilla blog (95 kB) |
2.37 ms |
3.72 ms (1.6x) |
whatwg spec (235 kB) |
4.54 ms |
15.8 ms (3.5x) |
Declarative shadow DOM¶
parse5 has no shadow-tree model: it builds a plain <template> element with a content fragment for a <template
shadowrootmode> and leaves the shadow-root attachment to its consumer (jsdom does it in its own layer; a custom
treeAdapter would do it in yours). turbohtml attaches the shadow root in the parser itself and hands it back on the
host, so there is nothing extra to wire up:
from turbohtml import parse
host = parse("<section id=h><template shadowrootmode=open><slot></slot></template><p>x</p></section>").find(id="h")
print(host.shadow_root.mode)
open
The switch maps to the allow_declarative_shadow_roots option, which follows the WHATWG per-document flag: on by
default for whole-document parse() (a browser navigation), off for parse_fragment()
(an innerHTML assignment). Read the attached tree through shadow_root,
assigned_nodes(), and flattened_children – the same API a scripted
attach_shadow() produces.
Custom tree adapters¶
parse5’s treeAdapter option lets you retarget the parser at a tree of your own rather than its default object model,
the way jsdom plugs in its own node types. turbohtml’s turbohtml.treebuild.parse_into() is the same idea: pass a
builder object and it drives the WHATWG tree builder straight into your representation, no Document
built on the way. The method set lines up with a parse5 adapter – create_element receives a namespace URI and the
attributes as (name, value) pairs, append links a child under its parent – so an adapter ports to a builder
method for method:
from turbohtml.treebuild import parse_into
class Adapter:
def create_document(self):
return {"tag": "#document", "children": []}
def create_doctype(self, name, public_id, system_id):
return {"tag": "#doctype", "children": []}
def create_element(self, name, namespace, attrs):
return {"tag": name, "ns": namespace, "children": []}
def create_text(self, data):
return {"tag": "#text", "children": []}
def create_comment(self, data):
return {"tag": "#comment", "children": []}
def create_pi(self, data):
return {"tag": "#pi", "children": []}
def append(self, parent, child):
parent["children"].append(child)
document = parse_into("<a href=/x>link</a>", Adapter())
anchor = document["children"][0]["children"][1]["children"][0]
print(anchor["tag"], anchor["ns"])
a http://www.w3.org/1999/xhtml
Where parse5 folds <?...> into a comment, turbohtml routes it to a separate create_pi so you can keep it
distinct, and a <template>’s content is appended straight under the template handle. See Parse into your own tree
for the full builder recipe.
How to migrate¶
// parse5
import { parse } from "parse5";
const document = parse(html, { sourceCodeLocationInfo: true });
const el = document.childNodes[1]; // walk childNodes by hand
const loc = el.sourceCodeLocation;
html.slice(loc.startTag.startOffset, loc.startTag.endOffset);
html.slice(loc.attrs.id.startOffset, loc.attrs.id.endOffset);
The turbohtml port parses the same way, then reads the span through source_location and slices
with plain offsets:
from turbohtml import parse
source = '<html><body><div id="x">y</div></body></html>'
div = parse(source, source_locations=True).find("div")
location = div.source_location
print(source[location.start_tag.start_offset : location.start_tag.end_offset])
print(source[location.attrs["id"].start_offset : location.attrs["id"].end_offset])
<div id="x">
id="x"
Gotchas and pitfalls¶
Opt in explicitly. parse5’s
sourceCodeLocationInfodefaults off; so does turbohtml’ssource_locations. Passparse(html, source_locations=True)– readingsource_locationon a tree parsed without it returnsNone, never raises.Only elements carry the record. parse5 attaches
sourceCodeLocationto text and comment nodes too; turbohtml reportsNonefor non-element nodes. Port any code that read a text node’s offsets to work from its surrounding elements.Offsets are code points, not UTF-16 units. parse5’s offsets index a JavaScript string (UTF-16 code units), so an astral character counts as two; turbohtml’s offsets are Python code points, so it counts as one. Slice each library’s offsets against its own string type and the results agree.
Newlines are normalized. As in parse5, a
\r\ncollapses to one line break before the counters advance, so line and offset numbers match the normalized source rather than the raw bytes.No pluggable tree adapter. parse5’s
treeAdapterlets you build a custom node shape; turbohtml always builds its ownDocument. To feed another toolchain, serialize withhtmland reparse there.Declarative shadow is on for documents. Unlike parse5, turbohtml attaches a shadow root for a
<template shadowrootmode>during a whole-documentparse(), so such templates leave the light tree. Passallow_declarative_shadow_roots=Falseto keep parse5’s plain-template behavior, or opt a fragment in withparse_fragment(..., allow_declarative_shadow_roots=True).