semdiff-html 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
semdiff/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """SemDiff: semantic HTML change detection."""
2
+
3
+ from semdiff.config import Config, NormalizationConfig, ParserBackend
4
+ from semdiff.errors import (
5
+ AlignmentBudgetExceeded,
6
+ ErrorRecord,
7
+ InputTooLargeError,
8
+ ParseError,
9
+ SemDiffError,
10
+ )
11
+ from semdiff.normalize.api import normalize
12
+ from semdiff.parse import PARSER_ID, PARSER_VERSION, ParsedDoc, parse, parser_fingerprint
13
+
14
+ __version__ = "0.1.0" # the one source; Hatchling reads it at build time (D-050)
15
+
16
+ __all__ = [
17
+ "PARSER_ID",
18
+ "PARSER_VERSION",
19
+ "AlignmentBudgetExceeded",
20
+ "Config",
21
+ "ErrorRecord",
22
+ "InputTooLargeError",
23
+ "NormalizationConfig",
24
+ "ParseError",
25
+ "ParsedDoc",
26
+ "ParserBackend",
27
+ "SemDiffError",
28
+ "__version__",
29
+ "normalize",
30
+ "parse",
31
+ "parser_fingerprint",
32
+ ]
semdiff/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """``python -m semdiff`` — same entry point as the installed ``semdiff`` command."""
2
+
3
+ from semdiff.cli import main
4
+
5
+ raise SystemExit(main())
semdiff/_models.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared pydantic base for every SemDiff model."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class FrozenModel(BaseModel):
7
+ """Immutable, and unknown keys fail loud."""
8
+
9
+ model_config = ConfigDict(frozen=True, extra="forbid")
semdiff/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Command line interface (T-20, FR-29): ``semdiff normalize file.html``.
2
+
3
+ A thin shell over the T-19 API. stdout carries normalized HTML and nothing else, so it
4
+ can always be redirected into a file; diagnostics and the ``--report`` summary go to
5
+ stderr. Every typed error from T-02 maps to its own exit code, so a script can tell an
6
+ oversized input from a parse failure without reading the message.
7
+
8
+ Rule provenance comes from ``apply_rules`` as D-033 anticipated — there is no second
9
+ reporting model.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from collections import Counter
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ from semdiff import Config, NormalizationConfig, SemDiffError, __version__
21
+ from semdiff.normalize import BUILTIN_RULES, NormalizedDoc, apply_rules
22
+ from semdiff.parse import parse, parser_fingerprint
23
+
24
+ EXIT_OK = 0
25
+ EXIT_ERROR = 1
26
+ EXIT_USAGE = 2 # argparse's own code for a bad command line
27
+ EXIT_PARSE = 3
28
+ EXIT_TOO_LARGE = 4
29
+ EXIT_CONFIG = 5
30
+ EXIT_IO = 6
31
+
32
+ _EXIT_BY_ERROR_TYPE = {"parse_error": EXIT_PARSE, "input_too_large": EXIT_TOO_LARGE}
33
+
34
+
35
+ def _read_stdin() -> bytes:
36
+ """Raw bytes, so the parser's encoding detection (T-10) still applies."""
37
+ return sys.stdin.buffer.read()
38
+
39
+
40
+ def _write_stdout(text: str) -> None:
41
+ """Always UTF-8, whatever the console claims to be: the output is a document."""
42
+ sys.stdout.buffer.write(text.encode("utf-8"))
43
+ sys.stdout.buffer.flush()
44
+
45
+
46
+ def _fail(message: str, code: int) -> int:
47
+ print(f"semdiff: {message}", file=sys.stderr)
48
+ return code
49
+
50
+
51
+ def _load_config(args: argparse.Namespace) -> Config:
52
+ config = Config()
53
+ if args.config is not None:
54
+ config = Config.from_json(Path(args.config).read_text(encoding="utf-8"))
55
+ normalization = NormalizationConfig(
56
+ disabled_rules=config.normalization.disabled_rules | frozenset(args.disable_rule),
57
+ enabled_rules=config.normalization.enabled_rules | frozenset(args.enable_rule),
58
+ )
59
+ return config.model_copy(update={"normalization": normalization})
60
+
61
+
62
+ def _report(result: NormalizedDoc) -> str:
63
+ """One line per rule that fired, in the order the rules ran, with application counts.
64
+
65
+ The parser build is named first: it is the one input to a result that the config does not
66
+ cover (D-046), so a report that omitted it could not be reproduced from what it printed.
67
+ """
68
+ header = f"parser {parser_fingerprint()}"
69
+ counts = Counter(application.rule_id for application in result.applied_rules)
70
+ if not counts:
71
+ return f"{header}\nno rules fired\n"
72
+ width = max(len(rule_id) for rule_id in counts)
73
+ lines = [header, f"{len(counts)} rules fired, {sum(counts.values())} applications"]
74
+ lines += [f" {rule_id:<{width}} {count}" for rule_id, count in counts.items()]
75
+ return "\n".join(lines) + "\n"
76
+
77
+
78
+ def _normalize(args: argparse.Namespace) -> int:
79
+ try:
80
+ config = _load_config(args)
81
+ except OSError as exc:
82
+ return _fail(f"cannot read config: {exc}", EXIT_IO)
83
+ except (ValueError, TypeError) as exc:
84
+ return _fail(f"invalid config: {exc}", EXIT_CONFIG)
85
+
86
+ try:
87
+ source = _read_stdin() if args.file in (None, "-") else Path(args.file).read_bytes()
88
+ except OSError as exc:
89
+ return _fail(f"cannot read input: {exc}", EXIT_IO)
90
+
91
+ try:
92
+ doc = parse(source, encoding=args.encoding, config=config)
93
+ result = apply_rules(doc, BUILTIN_RULES, config.normalization)
94
+ except SemDiffError as exc:
95
+ return _fail(f"{exc.type_id}: {exc.message}", _EXIT_BY_ERROR_TYPE.get(exc.type_id, EXIT_ERROR))
96
+ except ValueError as exc: # unknown rule id in a toggle
97
+ return _fail(f"invalid config: {exc}", EXIT_CONFIG)
98
+
99
+ html = result.tree.html or ""
100
+ try:
101
+ if args.output is None:
102
+ _write_stdout(html)
103
+ else:
104
+ Path(args.output).write_bytes(html.encode("utf-8"))
105
+ except OSError as exc:
106
+ return _fail(f"cannot write output: {exc}", EXIT_IO)
107
+
108
+ if args.report:
109
+ sys.stderr.write(_report(result))
110
+ return EXIT_OK
111
+
112
+
113
+ def build_parser() -> argparse.ArgumentParser:
114
+ parser = argparse.ArgumentParser(prog="semdiff", description="Semantic HTML change detection.")
115
+ parser.add_argument(
116
+ "--version", action="version", version=f"{__version__} ({parser_fingerprint()})"
117
+ )
118
+ commands = parser.add_subparsers(dest="command", required=True)
119
+
120
+ normalize_parser = commands.add_parser(
121
+ "normalize",
122
+ help="strip presentational noise from an HTML document and canonicalize it",
123
+ description="Write the normalized document to stdout, or to --output.",
124
+ )
125
+ normalize_parser.add_argument("file", nargs="?", help="input file; omit or pass - to read stdin")
126
+ normalize_parser.add_argument("-o", "--output", help="write to this file instead of stdout")
127
+ normalize_parser.add_argument("--report", action="store_true", help="list the rules that fired, on stderr")
128
+ normalize_parser.add_argument("--config", help="JSON config file (see Config.to_canonical_json)")
129
+ normalize_parser.add_argument(
130
+ "--disable-rule", action="append", default=[], metavar="ID", help="turn one rule off; repeatable"
131
+ )
132
+ normalize_parser.add_argument(
133
+ "--enable-rule", action="append", default=[], metavar="ID", help="turn one opt-in rule on; repeatable"
134
+ )
135
+ normalize_parser.add_argument("--encoding", help="override the input encoding instead of detecting it")
136
+ normalize_parser.set_defaults(handler=_normalize)
137
+ return parser
138
+
139
+
140
+ def main(argv: list[str] | None = None) -> int:
141
+ args = build_parser().parse_args(argv)
142
+ handler: Callable[[argparse.Namespace], int] = args.handler
143
+ return handler(args)
144
+
145
+
146
+ if __name__ == "__main__": # pragma: no cover
147
+ raise SystemExit(main())
semdiff/config.py ADDED
@@ -0,0 +1,74 @@
1
+ """Immutable, serializable pipeline configuration and its deterministic hash (T-01).
2
+
3
+ A ``Config`` plus the two input snapshots fully determines a diff's output (NFR-1).
4
+ ``config_hash`` covers every config field and the engine-owned built-in ruleset
5
+ version, so a change to either the user's settings or a shipped rule's definition
6
+ is visible in the output (DOMAIN_MODEL §12-J).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from pydantic import field_serializer, model_validator
17
+
18
+ from semdiff._models import FrozenModel
19
+
20
+ class ParserBackend(StrEnum):
21
+ """L0 parser selection (FR-2)."""
22
+
23
+ SELECTOLAX = "selectolax"
24
+ LXML = "lxml"
25
+
26
+
27
+ class NormalizationConfig(FrozenModel):
28
+ """Per-rule toggles for L1 (FR-11). Built-in rules are enabled unless listed here."""
29
+
30
+ disabled_rules: frozenset[str] = frozenset()
31
+ enabled_rules: frozenset[str] = frozenset()
32
+
33
+ @field_serializer("disabled_rules", "enabled_rules")
34
+ def _sorted(self, value: frozenset[str]) -> list[str]:
35
+ return sorted(value)
36
+
37
+ @model_validator(mode="after")
38
+ def _no_overlap(self) -> NormalizationConfig:
39
+ overlap = self.disabled_rules & self.enabled_rules
40
+ if overlap:
41
+ raise ValueError(f"rules both enabled and disabled: {sorted(overlap)}")
42
+ return self
43
+
44
+
45
+ class Config(FrozenModel):
46
+ """The single configuration object threaded through every pipeline layer."""
47
+
48
+ parser: ParserBackend = ParserBackend.SELECTOLAX
49
+ max_input_bytes: int = 10 * 1024 * 1024 # NFR-7 ceiling; larger input raises InputTooLargeError
50
+ normalization: NormalizationConfig = NormalizationConfig()
51
+
52
+ def to_canonical_json(self) -> str:
53
+ """Compact JSON with sorted keys and sorted sets; the only serialized form."""
54
+ return _canonical_json(self.model_dump(mode="json"))
55
+
56
+ @classmethod
57
+ def from_json(cls, text: str) -> Config:
58
+ return cls.model_validate_json(text)
59
+
60
+ @property
61
+ def config_hash(self) -> str:
62
+ # Local import: the registry depends on Config, so the dependency runs this way only here.
63
+ from semdiff.normalize.registry import builtin_ruleset_version
64
+
65
+ payload = {
66
+ "ruleset_version": builtin_ruleset_version(),
67
+ "config": self.model_dump(mode="json"),
68
+ }
69
+ digest = hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
70
+ return f"sha256:{digest}"
71
+
72
+
73
+ def _canonical_json(data: Any) -> str:
74
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
semdiff/errors.py ADDED
@@ -0,0 +1,76 @@
1
+ """Typed error hierarchy (T-02).
2
+
3
+ Every failure inside the pipeline is a ``SemDiffError`` subclass. Layers raise them;
4
+ the public API boundary converts them into ``ErrorRecord`` entries in ``errors[]``
5
+ so a caller never receives a partial diff presented as complete (FR-3, ARCHITECTURE
6
+ "Fail loud, fail typed").
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import ClassVar, Literal
12
+
13
+ from pydantic import field_serializer
14
+
15
+ from semdiff._models import FrozenModel
16
+
17
+ SnapshotLabel = Literal["old", "new"]
18
+ DetailValue = str | int | None
19
+
20
+
21
+ class ErrorRecord(FrozenModel):
22
+ """One element of ``DiffResult.errors``."""
23
+
24
+ type: str
25
+ message: str
26
+ snapshot: SnapshotLabel | None = None
27
+ detail: dict[str, DetailValue] = {}
28
+
29
+ @field_serializer("detail")
30
+ def _sorted_detail(self, value: dict[str, DetailValue]) -> dict[str, DetailValue]:
31
+ return dict(sorted(value.items()))
32
+
33
+
34
+ class SemDiffError(Exception):
35
+ """Root of the hierarchy. ``type_id`` is the stable id used in ``errors[].type``."""
36
+
37
+ type_id: ClassVar[str] = "semdiff_error"
38
+
39
+ def __init__(self, message: str, *, snapshot: SnapshotLabel | None = None) -> None:
40
+ super().__init__(message)
41
+ self.message = message
42
+ self.snapshot = snapshot
43
+
44
+ def detail(self) -> dict[str, DetailValue]:
45
+ return {}
46
+
47
+ def to_record(self) -> ErrorRecord:
48
+ return ErrorRecord(
49
+ type=self.type_id, message=self.message, snapshot=self.snapshot, detail=self.detail()
50
+ )
51
+
52
+
53
+ class ParseError(SemDiffError):
54
+ """A snapshot could not be parsed into a tree (FR-3)."""
55
+
56
+ type_id = "parse_error"
57
+
58
+
59
+ class InputTooLargeError(SemDiffError):
60
+ """A snapshot exceeds the documented input size ceiling (NFR-7)."""
61
+
62
+ type_id = "input_too_large"
63
+
64
+ def __init__(self, size: int, limit: int, *, snapshot: SnapshotLabel | None = None) -> None:
65
+ super().__init__(f"input is {size} bytes; ceiling is {limit} bytes", snapshot=snapshot)
66
+ self.size = size
67
+ self.limit = limit
68
+
69
+ def detail(self) -> dict[str, DetailValue]:
70
+ return {"size": self.size, "limit": self.limit}
71
+
72
+
73
+ class AlignmentBudgetExceeded(SemDiffError):
74
+ """Tree-edit-distance alignment ran past its budget (v0.3, T-75). Fields arrive with T-75."""
75
+
76
+ type_id = "alignment_budget_exceeded"
semdiff/locator.py ADDED
@@ -0,0 +1,141 @@
1
+ """``Locator`` — a pointer into a Document (DOMAIN_MODEL §7). Stage-relative, never absolute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+
8
+ from selectolax.lexbor import LexborNode
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Locator:
13
+ xpath: str | None
14
+ css: str | None
15
+ node_hash: str # the node itself, not its subtree (D-044); survives reordering
16
+
17
+ @classmethod
18
+ def for_node(cls, node: LexborNode, cache: PathCache | None = None) -> Locator:
19
+ """Locator for an element or text node as it is *right now* (pre-mutation).
20
+
21
+ Both fields are computed here and now, cache or no cache: a locator records the node as
22
+ the rule found it, which is why it is taken before the rewrite. ``cache`` only changes
23
+ how the css path is *arrived at* — see ``PathCache`` for the lifetime it is valid for.
24
+ """
25
+ target = node if node.tag not in ("-text", "-comment") or node.parent is None else node.parent
26
+ css = _css_path(target) if cache is None else cache.path(target)
27
+ return cls(xpath=None, css=css, node_hash=_node_hash(node))
28
+
29
+
30
+ def _segment(node: LexborNode) -> str:
31
+ """``p`` or ``p:nth-child(2)`` — this element's own step, positional among element siblings."""
32
+ position = 1
33
+ sibling = node.prev
34
+ while sibling is not None:
35
+ if sibling.is_element_node:
36
+ position += 1
37
+ sibling = sibling.prev
38
+ tag = node.tag or ""
39
+ return tag if position == 1 else f"{tag}:nth-child({position})"
40
+
41
+
42
+ def _at_root(node: LexborNode | None) -> bool:
43
+ """Whether the walk upwards has run out of path to build."""
44
+ return node is None or node.tag in ("", "#document") or node.is_document_node
45
+
46
+
47
+ def _css_path(node: LexborNode) -> str:
48
+ """``html > body > div:nth-child(2) > p`` — positional among element siblings, 1-based."""
49
+ parts: list[str] = []
50
+ current: LexborNode | None = node
51
+ while not _at_root(current):
52
+ assert current is not None
53
+ parts.append(_segment(current))
54
+ current = current.parent
55
+ return " > ".join(reversed(parts))
56
+
57
+
58
+ class PathCache:
59
+ """Element css paths already computed during **one pass over one tree** (T-27).
60
+
61
+ ``_css_path`` climbs to the root and counts previous siblings at every level: ~14 parent
62
+ hops but ~114 sibling hops per call on the corpus, and the engine makes one call per
63
+ application. Sibling applications share almost all of that walk, so an ancestor's path is
64
+ computed once and the walk stops at the first element already known.
65
+
66
+ Keyed by ``mem_id``, the underlying DOM node's address, **not** by the node: two wrappers
67
+ for the same node compare equal, but ``LexborNode.__eq__`` is proportional to the
68
+ serialized subtree — 765 µs for a 58 KB ``<html>`` against 0.6 µs for a leaf — so a
69
+ node-keyed cache spends more on dict probes than the walk it saves (F-032).
70
+
71
+ That key is an address, so a cache is only valid while every element it has seen is still
72
+ alive. Two rules keep it so, and both are the caller's to honour:
73
+
74
+ * one cache per pass, never reused across passes, so nothing survives a rule that may
75
+ reshape the tree between passes; and
76
+ * no cache at all for a pass that can destroy an element — ``DROP_NODE`` (``_apply_node``)
77
+ and user-supplied ``CANONICALIZE`` transforms, which the engine never hands one to.
78
+
79
+ Attribute rewrites, text replacement and comment removal cannot invalidate an entry: they
80
+ free text and comment nodes, which are never keys here, and they leave every element in
81
+ place. ``Locator.for_node`` resolves a text or comment node to its parent element before
82
+ asking for a path, so only elements are ever cached or looked up.
83
+
84
+ **Neither rule is enforced at runtime**, and that is a measured choice rather than an
85
+ oversight (T-27). Counting the tree's elements once per pass to detect a breach costs 58 ms
86
+ against the 192 ms this cache saves on the corpus, and reports the breach only after the
87
+ wrong paths have been handed out. Re-checking the queried node's own step on every hit
88
+ costs 59 ms and still misses the case that matters: when an *ancestor* shifts, the check
89
+ passes and the cache answers ``div:nth-child(3)`` for a node that is now
90
+ ``div:nth-child(2)``. Both spend about 30% of the gain to buy a partial guard, so the
91
+ invariant stays a convention of this module and the engine's. What would catch a violation
92
+ is the evidence that already exists: ``tests/corpus/test_fused_attribute_equivalence.py``
93
+ and the per-rule reference engine recompute every locator with no cache at all, and
94
+ ``tests/corpus/parser_contract.json`` pins every ``css`` value of all 40 snapshots.
95
+ """
96
+
97
+ __slots__ = ("_paths",)
98
+
99
+ def __init__(self) -> None:
100
+ self._paths: dict[int, str] = {}
101
+
102
+ def path(self, node: LexborNode) -> str:
103
+ """The same string ``_css_path`` returns, reusing any ancestor already computed."""
104
+ chain: list[LexborNode] = []
105
+ current: LexborNode | None = node
106
+ prefix = ""
107
+ while not _at_root(current):
108
+ assert current is not None
109
+ known = self._paths.get(current.mem_id)
110
+ if known is not None:
111
+ prefix = known
112
+ break
113
+ chain.append(current)
114
+ current = current.parent
115
+ for element in reversed(chain):
116
+ part = _segment(element)
117
+ prefix = f"{prefix} > {part}" if prefix else part
118
+ self._paths[element.mem_id] = prefix
119
+ return prefix
120
+
121
+
122
+ def _node_hash(node: LexborNode) -> str:
123
+ """Fingerprint of the node *itself* — never of its descendants (D-044, F-030).
124
+
125
+ Hashing ``node.html`` made provenance order-dependent: the same rewrite of the same
126
+ attribute recorded a different hash depending on whether a descendant had already been
127
+ rewritten, which differs between one traversal per rule and the fused attribute pass. A
128
+ locator points at a node, so its fingerprint is a property of that node: the tag and
129
+ attributes of an element, the content of a text or comment node. It still survives the node
130
+ moving, and it still changes when the node itself changes.
131
+ """
132
+ if node.tag == "-text":
133
+ raw = node.text_content or ""
134
+ elif node.is_element_node:
135
+ # ``repr`` of the tag and the sorted (name, value) pairs: deterministic, and quoted, so
136
+ # two different attribute sets cannot collide the way a flat join would. A boolean
137
+ # attribute hashes like an empty one, as the canonical serializer unifies them (D-032).
138
+ raw = repr((node.tag or "", sorted((n, v or "") for n, v in node.attributes.items())))
139
+ else:
140
+ raw = node.html or "" # a comment or doctype has no children to pull in
141
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
@@ -0,0 +1,39 @@
1
+ """L1 — normalization: rule model, phase-ordered registry (T-12) and built-in families (T-13+)."""
2
+
3
+ from semdiff.normalize.api import normalize
4
+ from semdiff.normalize.model import (
5
+ PLACEHOLDER,
6
+ Action,
7
+ EntropyMatcher,
8
+ GroupMatcher,
9
+ Matcher,
10
+ NodePredicate,
11
+ NormalizationRule,
12
+ NormalizedDoc,
13
+ RegexMatcher,
14
+ RuleApplication,
15
+ RuleFamily,
16
+ Target,
17
+ )
18
+ from semdiff.normalize.registry import BUILTIN_RULES, RuleRegistry, apply_rules
19
+
20
+ import semdiff.normalize.rules # noqa: E402, F401 (registers the built-in families; must stay last)
21
+
22
+ __all__ = [
23
+ "BUILTIN_RULES",
24
+ "PLACEHOLDER",
25
+ "Action",
26
+ "EntropyMatcher",
27
+ "GroupMatcher",
28
+ "Matcher",
29
+ "NodePredicate",
30
+ "NormalizationRule",
31
+ "NormalizedDoc",
32
+ "RegexMatcher",
33
+ "RuleApplication",
34
+ "RuleFamily",
35
+ "RuleRegistry",
36
+ "Target",
37
+ "apply_rules",
38
+ "normalize",
39
+ ]
@@ -0,0 +1,25 @@
1
+ """T-19: the public normalization entry point (FR-10).
2
+
3
+ ``semdiff.normalize()`` is L1 on its own: parse (T-10) → the phase-ordered engine over the
4
+ built-in families (T-12..T-17) → canonical serialization (D-032). It is deterministic — the
5
+ same input and config always produce the same string — and idempotent, because the
6
+ canonicalization phase runs last and reaches a fixpoint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from semdiff.config import Config
12
+ from semdiff.normalize.registry import BUILTIN_RULES, apply_rules
13
+ from semdiff.parse import parse
14
+
15
+
16
+ def normalize(html: bytes | str, config: Config | None = None, *, encoding: str | None = None) -> str:
17
+ """Return ``html`` with presentational noise removed and the tree canonicalized.
18
+
19
+ ``bytes`` are decoded as T-10 decides (declared ``encoding`` → BOM → ``<meta charset>``
20
+ → UTF-8); the result is always ``str``. Raises ``InputTooLargeError`` (NFR-7),
21
+ ``ParseError`` (FR-3), or ``ValueError`` if the config toggles an unknown rule id.
22
+ """
23
+ config = config or Config()
24
+ doc = parse(html, encoding=encoding, config=config)
25
+ return apply_rules(doc, BUILTIN_RULES, config.normalization).tree.html or ""