mews.page/mews/css.py

137 lines
4.1 KiB
Python
Raw Normal View History

"""Comparing a site's stylesheet against the default one.
SPEC.md 5.2 lets a site host its own copy of the default stylesheet and change
nothing in it, except to add @font-face rules that load fonts from the site
itself. Checking that means comparing text, since a site may legitimately
differ in line endings and trailing whitespace after a copy-paste.
"""
from dataclasses import dataclass, field
import difflib
from functools import cache
from importlib import resources
import re
FONT_FACE = re.compile(r"@font-face\b", re.IGNORECASE)
URL_IN_SRC = re.compile(r"url\(\s*['\"]?([^'\")]+)", re.IGNORECASE)
IMPORT_RULE = re.compile(r"@import\b", re.IGNORECASE)
@dataclass
class Comparison:
"""What a candidate stylesheet differs from the default by."""
equal: bool
diff_line: int | None = None
diff: str = ""
font_faces: list[str] = field(default_factory=list)
has_import: bool = False
@property
def font_urls(self) -> list[str]:
"""Every url() the added @font-face rules load."""
return [
match.group(1).strip()
for block in self.font_faces
for match in URL_IN_SRC.finditer(block)
]
@cache
def canonical() -> str:
"""Return the normalised default stylesheet shipped with this package."""
data = resources.files("mews.data").joinpath("mews-0.1.css").read_text("utf-8")
return normalise(data)
def normalise(text: str) -> str:
"""Strip the differences a copy may pick up without changing any rule."""
text = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
lines = [line.rstrip() for line in text.split("\n")]
out: list[str] = []
for line in lines:
if line or (out and out[-1]):
out.append(line)
return "\n".join(out).strip("\n")
def strip_font_faces(text: str) -> tuple[str, list[str]]:
"""Remove top-level @font-face blocks, returning the rest and the blocks.
Only brace depth zero counts, so an @font-face inside a media query is left
in place and shows up as a difference.
"""
blocks: list[str] = []
out: list[str] = []
index = 0
depth = 0
while index < len(text):
if depth == 0 and FONT_FACE.match(text, index):
end = _block_end(text, index)
if end is None:
break
blocks.append(text[index:end])
index = end
continue
character = text[index]
if character == "{":
depth += 1
elif character == "}":
depth = max(0, depth - 1)
out.append(character)
index += 1
out.append(text[index:])
return "".join(out), blocks
def _block_end(text: str, start: int) -> int | None:
"""Index just past the brace-balanced block beginning at start."""
opened = text.find("{", start)
if opened == -1:
return None
depth = 0
for index in range(opened, len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return index + 1
return None
def compare(candidate: str) -> Comparison:
"""Compare a stylesheet against the default, allowing added @font-face rules."""
expected = canonical()
if normalise(candidate) == expected:
return Comparison(equal=True)
remainder, blocks = strip_font_faces(candidate)
found = normalise(remainder)
has_import = bool(IMPORT_RULE.search(candidate))
if found == expected:
return Comparison(equal=True, font_faces=blocks, has_import=has_import)
diff = list(
difflib.unified_diff(
expected.split("\n"), found.split("\n"), "mews-0.1.css", "your copy", n=1
)
)
line = next(
(
index + 1
for index, (left, right) in enumerate(
zip(expected.split("\n"), found.split("\n"), strict=False)
)
if left != right
),
min(len(expected.split("\n")), len(found.split("\n"))) + 1,
)
return Comparison(
equal=False,
diff_line=line,
diff="\n".join(diff[:8]),
font_faces=blocks,
has_import=has_import,
)