mews.page/mews/css.py

136 lines
4.1 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
)