136 lines
4.1 KiB
Python
136 lines
4.1 KiB
Python
"""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,
|
||
)
|