########### Changelog ########### .. towncrier-draft-entries:: Unreleased .. towncrier release notes start ********************* v1.2.0 (2026-07-09) ********************* Backward incompatible changes - 1.2.0 ===================================== - :func:`turbohtml.detect.detect` reports ``windows-1252``, not ``ascii``, for pure-ASCII input; :class:`~turbohtml.detect.EncodingMatch` gained a trailing ``codec`` field; and :class:`~turbohtml.IncrementalParser` takes WHATWG labels, so ``latin-1`` raises where ``iso-8859-1`` works. (:issue:`622`) Features - 1.2.0 ================ - Add :attr:`EncodingMatch.codec `; ``data.decode(match.codec)`` reproduces what :func:`turbohtml.parse` saw, where ``match.encoding`` corrupted or raised. (:issue:`622`) Bug fixes - 1.2.0 ================= - Lock free-threaded DOM tree reads and attribute views; concurrent mutation no longer crashes those readers. (:issue:`617`) - Resolve local ``file://`` stylesheet URLs for ``xsl:import``; ``Path.as_uri()`` bases now load sibling imports. (:issue:`618`) - Normalize configured ``Linkify.skip_tags`` names before matching parsed HTML tags; ``CODE`` now skips ```` text. (:issue:`619`) - Keep controls inside the first ```` child of a disabled ``
`` in ``form_data()`` output. (:issue:`621`) - Decode legacy bytes with the WHATWG decoders rather than CPython's same-named codecs, whose tables and error handling both differ: ``koi8-u`` is KOI8-RU, and GBK ``0x80`` is the euro sign. The ```` prescan, the content detector, and :class:`~turbohtml.IncrementalParser` now follow the spec too. (:issue:`622`) ********************* v1.1.1 (2026-07-08) ********************* No significant changes. ********************* v1.1.0 (2026-07-08) ********************* Features - 1.1.0 ================ - :class:`~turbohtml.clean.Policy` gained ``strip_template_markers``: with it on, sanitizing collapses template-engine expressions (``{{ }}``, ``${ }``, ``<% %>``) in kept text and attribute values to a single space, so the output cannot re-inject when a template engine renders it. This matches DOMPurify's ``SAFE_FOR_TEMPLATES``. (:issue:`527`) - :func:`turbohtml.clean.sanitize_report` (and :meth:`turbohtml.clean.Sanitizer.sanitize_report`) sanitize a fragment and return what the policy dropped alongside the cleaned HTML: one :class:`turbohtml.clean.Removed` record per removed element or stripped attribute, in walk order. This matches DOMPurify's ``DOMPurify.removed``. (:issue:`528`) - :func:`turbohtml.convert.css_specificity` returns the ``(a, b, c)`` specificity of each selector in a comma-separated list, per CSS Selectors Level 4 ยง17, the value ``cssselect`` exposes as ``Selector.specificity()``. It weighs the parsed selector in one C pass, with ``:is()``/``:not()``/``:has()`` taking their most specific argument and ``:where()`` contributing zero. (:issue:`529`) - :func:`turbohtml.extract.feed` normalizes an RSS 2.0, Atom 1.0, or RDF/RSS-1.0 document into one frozen, typed :class:`~turbohtml.extract.Feed` of :class:`~turbohtml.extract.Entry` records, the ``feedparser.parse`` entry point over :meth:`turbohtml.Document.feed`. It detects the format from the root element and maps each dialect's spelling of a field -- the entry ``title``, ``link``, ``id``, ``updated``/``published``, ``summary``/``content``, and ``author`` -- onto one shape in a single C walk of the parsed tree, over 12x faster than feedparser on a 30-item feed. (:issue:`530`) - :class:`turbohtml.clean.Policy` gains ``transform_tags``: a map that renames elements while sanitizing, sanitize-html's ``transformTags``. Map a source tag to a string to rename it, or to a :class:`turbohtml.clean.Transform` to rename it and add attributes (sanitize-html's ``simpleTransform``). The rename runs before the allowlist in the same C walk, so the renamed element is re-checked from scratch -- a transform decides an element's name but never its safety: mapping a tag to ``script`` still drops it, and an added attribute is scrubbed like the element's own. (:issue:`531`) - A new :mod:`turbohtml.saxparse` module adds a DOM-less, event-driven parse. :func:`turbohtml.saxparse.sax_parse` drives a document through the WHATWG tree builder and fires a callback on a :class:`turbohtml.saxparse.SaxHandler` subclass for each construct it builds -- a start tag, an end tag, a run of text, a comment, the doctype, and a ```` processing instruction -- while :func:`turbohtml.saxparse.iter_events` yields the same stream as typed records. The events reflect the fully spec-correct tree (implied ``html``/``head``/``body``, foster parenting, the adoption agency), so unlike :class:`html.parser.HTMLParser` you see the tree the parser built; no per-node Python object is created and nothing is retained after the parse, so a one-pass extraction never builds a document-sized object graph. The tokenization, tree construction, and walk all run in C. (:issue:`532`) - ``turbohtml.clean.Policy`` gains ``allowed_styles``, a per-element, per-property value allowlist for the ``style`` attribute keyed ``{tag: {property: [pattern, ...]}}`` with ``"*"`` matching every tag. A declaration survives only when its value matches one of the property's patterns, porting sanitize-html's ``allowedStyles``. It narrows ``css_properties`` by value and never weakens the baseline that drops ``expression()`` and disallowed-scheme ``url()``. (:issue:`533`) - :class:`~turbohtml.clean.Policy` gained ``isolate_named_props``: with it on, sanitizing prefixes every kept ``id`` and ``name`` value with ``user-content-``, moving it out of the property namespace so it cannot shadow a built-in ``document`` or form property through named access (DOM clobbering, where ```` makes ``form.attributes`` resolve to the input and ```` hides ``document.body``). An already-prefixed value is left alone, so re-sanitizing is a fixpoint. This matches DOMPurify's ``SANITIZE_NAMED_PROPS``. (:issue:`534`) - :class:`~turbohtml.Html` gained ``xml``: with ``xml=True``, :meth:`~turbohtml.Node.serialize`, :meth:`~turbohtml.Node.encode`, and :meth:`~turbohtml.Node.serialize_iter` emit XML/XHTML instead of HTML -- the equivalent of lxml's ``tostring(method="xml")``. Every empty element self-closes (``
``), foreign SVG and MathML subtrees carry their namespace declarations, and text and attribute values follow the XML escaping rules, with no HTML void-element or raw-text special casing. It composes with ``sort_attributes`` and an :class:`~turbohtml.Indent` layout. (:issue:`535`) - :class:`~turbohtml.clean.Policy` gained a predicate-based custom-element allowance and split content profiles, porting DOMPurify's ``CUSTOM_ELEMENT_HANDLING`` and ``USE_PROFILES``. ``custom_element_check`` keeps an unlisted hyphenated custom element (``my-widget``, ``x-card``) when a caller-supplied matcher admits its name, ``custom_attribute_check`` extends the same idea to that element's attributes, and ``allow_customized_builtins`` keeps an ``is`` attribute whose value names a custom element. ``allow_html``, ``allow_svg``, and ``allow_mathml`` gate each namespace independently, so a policy can keep SVG but drop MathML, or the reverse. All of it runs in the one C sanitize walk, and the non-configurable safety baseline -- ``on*`` handlers, ``javascript:`` URLs, unsafe tags -- still applies to whatever a matcher keeps. (:issue:`536`) - :mod:`turbohtml.transform` adds a full XSLT 1.0 processor, the job `lxml `_'s ``etree.XSLT`` does. :class:`turbohtml.transform.Transform` compiles a stylesheet (parsed with :func:`turbohtml.parse_xml`) and applies it to source documents, and :func:`turbohtml.transform.transform` does both in one call. The whole transform runs in the C extension, reusing turbohtml's XPath 1.0 engine for every match pattern and select expression. It covers the entire XSLT 1.0 instruction set: templates with ``match``/``name``/``mode``/``priority``, ``apply-templates`` with ``sort`` and ``with-param``, ``call-template``, ``for-each``, ``if``, ``choose``, ``value-of``, ``copy``/``copy-of``, ``element``/ ``attribute``/``text``, ``variable``/``param``, multi-level ``number``, ``key`` with the ``key()`` function, ``strip-space``/``preserve-space``, ``attribute-set`` with ``use-attribute-sets``, ``namespace-alias``, ``fallback``, simplified literal-result-element stylesheets, ``xsl:import`` with import precedence (resolved against a ``base_url``), ``cdata-section-elements``, and the ``xml``/``html``/``text`` output methods (html auto-selected for a null-namespace ``html`` root, with ```` injection). Validated against libxslt's XSLT 1.0 Recommendation corpus at 76 of 79 cases byte-for-byte; the three remaining need a locale-collation, DTD, or XPath-namespace-axis layer turbohtml does not carry. (:issue:`537`) - :meth:`turbohtml.Node.canonicalize` serializes a subtree to Canonical XML (c14n), the byte-exact form an XML signature signs. A :class:`turbohtml.Canonical` config selects the algorithm: Canonical XML 1.0 or 1.1, the exclusive variant that renders only the namespaces a subtree visibly uses, the with-comments variant, and an ``inclusive_ns_prefixes`` prefix list for exclusive mode. Attributes are reordered (namespace declarations first, then by namespace URI and local name), redundant namespace declarations are dropped, empty elements are written as start-end pairs, and character references are normalized, matching ``lxml``'s ``tostring(method="c14n")`` byte-for-byte over the same infoset. (:issue:`538`) - :class:`turbohtml.validate.XMLSchema` and :class:`turbohtml.validate.RelaxNG` validate a document parsed with :func:`turbohtml.parse_xml` against an XSD 1.0 or RELAX NG schema, mirroring lxml's ``etree.XMLSchema`` / ``etree.RelaxNG``. A schema compiles once in the C core and each :meth:`~turbohtml.validate.XMLSchema.validate` returns a :class:`~turbohtml.validate.ValidationResult` -- a ``valid`` flag plus one :class:`~turbohtml.validate.ValidationError` per violation, each with the document-order path that located it. XSD covers the element/attribute declarations, the sequence/choice/all content models with ``minOccurs``/``maxOccurs``, references, complex/simple types with extension, the built-in datatypes, and the constraining facets; RELAX NG covers the full XML-syntax pattern set (including ``interleave``) through the derivative algorithm. (:issue:`539`) - :func:`turbohtml.parse_xml` parses a document under XML 1.0 well-formedness instead of the WHATWG HTML tree builder, returning the same navigable :class:`~turbohtml.Document`. Names stay case-sensitive, ```` self-closes any element, CDATA sections and processing instructions become :class:`~turbohtml.CData` and :class:`~turbohtml.ProcessingInstruction` nodes, only the five predefined entities and numeric references resolve, and a namespace prefix must be declared with ``xmlns``. Names follow the exact XML 1.0 ``NameStartChar`` /``NameChar`` productions, and the Namespaces in XML 1.0 well-formedness constraints hold in full: the reserved ``xml`` and ``xmlns`` prefixes and their namespace names cannot be rebound, a prefix declaration cannot be empty, a processing-instruction target carries no colon, and no two attributes share an expanded name. There is no HTML recovery: the first well-formedness violation -- a mismatched or unclosed tag, an undeclared prefix, an undefined entity, a duplicate attribute -- raises :exc:`~turbohtml.HTMLParseError`. This is the equivalent of ``lxml.etree.fromstring`` / ``etree.XMLParser`` over turbohtml's dependency-free, fully typed node API. (:issue:`540`) - Added an HTML5 authoring-conformance checker with a severity model. :func:`turbohtml.conformance.check` walks a parsed document and returns a :class:`~turbohtml.conformance.ConformanceReport` -- a ``valid`` verdict plus every :class:`~turbohtml.conformance.ConformanceMessage`, each carrying a stable ``code``, a ``severity`` (``"error"``, ``"warning"``, or ``"info"``), a human-readable message, and a source line and column. It flags the document-conformance requirements the parser does not raise as a :class:`~turbohtml.ParseError`: a missing ``img`` alt, obsolete presentational elements and attributes, duplicate ids, invalid or redundant ARIA roles, empty headings, a ``section`` without a heading, and a document with no title or ``lang``. The document is valid exactly when nothing is an error, so warnings and info notes never change the verdict. The whole walk runs in the C core against the WHATWG authoring rules and WAI-ARIA 1.2, the model the Nu Html Checker (validator.nu) uses; :func:`~turbohtml.conformance.check_html` parses a markup string first. (:issue:`541`) - :meth:`~turbohtml.Node.xpath` gained the string subset of XPath 2.0: ``ends-with``, ``string-join(seq, sep)``, ``lower-case`` and ``upper-case`` (Unicode case mapping), and the regex ``matches(input, pattern[, flags])`` and ``replace(input, pattern, repl[, flags])`` spellings, where ``replace`` reads ``$1``-style group references and rewrites every match. They dispatch in the compiled-C engine alongside the XPath 1.0 core and the EXSLT namespaces, so an expression ported from ``elementpath``, ``lxml``, or ``htmlquery`` that leans on them runs without registration. (:issue:`542`) - :func:`turbohtml.detect.normalize` returns text in a Unicode normalization form (UAX #15) -- ``NFC``, ``NFD``, ``NFKC``, or ``NFKD`` -- the C successor to :func:`python:unicodedata.normalize`, and :func:`turbohtml.detect.is_normalized` tests membership. Both run over tables generated from the interpreter's own ``unicodedata``, so they agree with it exactly, and a quick check returns already-normalized text without allocating. (:issue:`543`) - :func:`turbohtml.rewrite.rewrite` transforms HTML in a single streaming pass without building a tree, the model Cloudflare's lol-html popularized. It runs the WHATWG tokenizer over the input while tracking only the open-element stack, hands each element a CSS selector matches -- and, on request, each run of text, each comment, and the doctype -- to a Python handler that edits it in place (set or remove an attribute, insert markup before, after, or around it, replace its inner content, unwrap it, or drop it), and emits the result incrementally. Working memory stays proportional to the open-element depth, not the document size, so a multi-megabyte page rewrites in a fixed footprint, and an untouched construct is reproduced verbatim. Because the pass never looks ahead, the matchable selector subset is the one decidable from an element and its ancestors -- type, universal, id, class, and attribute selectors, the descendant and child combinators, ``:root``, and ``:is()``/``:where()``/``:not()`` over that subset; a sibling combinator, a positional or structural pseudo-class, or ``:has()`` raises :class:`~turbohtml.SelectorSyntaxError`. (:issue:`544`) - A new :mod:`turbohtml.treebuild` module retargets the parser at a tree of your own. :func:`turbohtml.treebuild.parse_into` runs the WHATWG tree builder and drives a builder object -- a ``create_*`` method per node kind plus an ``append`` that links a child under its parent -- to construct the tree directly, returning whatever the builder made its document root. No navigable :class:`turbohtml.Node` is materialized and the tree is walked only once, so an index, a diff tree, or another library's nodes is populated straight from the parse rather than by a second descent. Each element carries its namespace URI and its attributes as ``(name, value)`` pairs, a ``