feat: page validator for both section 9.1 checks
This commit is contained in:
parent
99dc9f7a88
commit
b3c436bf1a
13 changed files with 2703 additions and 0 deletions
136
mews/css.py
Normal file
136
mews/css.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue