"""Canonicalize HTML/CSS pairs so semantically equivalent inputs compare equal.
Stripo serializes HTML attributes in non-deterministic order and varies
inter-tag whitespace on each fetch, so byte comparison against the stored
copy yields false positives.
"""
from __future__ import annotations
import re
from lxml import html
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
_CSS_WS_RE = re.compile(r"\s+")
# `:` excluded — collapsing whitespace around it can change selector meaning
# (`.foo :hover` vs `.foo:hover`).
_CSS_AROUND_PUNCT_RE = re.compile(r"\s*([{};,])\s*")
def canonicalize_css(css: str) -> str:
s = _CSS_COMMENT_RE.sub("", css)
s = _CSS_WS_RE.sub(" ", s)
s = _CSS_AROUND_PUNCT_RE.sub(r"\1", s)
return s.strip()
def _normalize_style_attr(value: str) -> str:
decls: list[str] = []
for raw in value.split(";"):
d = _CSS_WS_RE.sub(" ", raw).strip()
if not d:
continue
if ":" in d:
k, _, v = d.partition(":")
d = f"{k.strip()}:{v.strip()}"
decls.append(d)
return ";".join(sorted(decls))
def _normalize_text(el: html.HtmlElement) -> None:
if el.tag == "style" and isinstance(el.text, str):
el.text = canonicalize_css(el.text)
elif isinstance(el.text, str):
el.text = el.text.strip() or None
def _normalize_attrs(el: html.HtmlElement) -> None:
attrs = sorted(el.items())
for k in list(el.attrib):
del el.attrib[k]
for k, v in attrs:
if k == "style":
v = _normalize_style_attr(v)
elif k == "class":
v = " ".join(sorted(v.split()))
el.set(k, v)
def canonicalize_html(source: str) -> str:
if not source.strip():
return ""
root = html.fromstring(source)
for el in root.iter():
if isinstance(el.tail, str):
el.tail = el.tail.strip() or None
if not isinstance(el.tag, str):
continue
_normalize_text(el)
if el.attrib:
_normalize_attrs(el)
return html.tostring(root, method="html", encoding="unicode")
def canonicalize(html_content: str | None, css_content: str | None) -> tuple[str, str]:
return canonicalize_html(html_content or ""), canonicalize_css(css_content or "")