validator/ holds mewslint (lint, css, fetch, DTD/CSS data); directory/ holds the mews.page service (app, db, check, pages, cli) and depends on validator/; quickstart/ holds mews-quickstart and md2mews.py for third parties who want to create Mews content without cloning the repo; spec/ consolidates doc/ and dtd/ with the canonical mews-0.1.css; site/ becomes a Pelican project with the forward-facing pages as Markdown, the check form kept as a raw HTML snippet. Root pyproject.toml is now a virtual uv workspace over validator, directory and quickstart, with cross-cutting tests that span more than one of them staying at the repo root. Makefile and flake.nix updated to match.
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,
|
||
)
|