#!/usr/bin/env python3
"""Lint NEW and CHANGED diagram examples against the current visual skin.
Pre-2.0 examples may legitimately fail because they were built against an older
skin. Use ``--all --baseline`` to skip those documented legacy files.
"""
from __future__ import annotations
import argparse
import hashlib
import math
import re
import sys
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parent.parent
STYLE_GUIDE = ROOT / "skills/diagram-design/references/style-guide.md"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
BASELINE = ROOT / "scripts/lint-skin-baseline.txt"
MOTION_TEMPLATE = ASSET_DIR / "template-motion.html"
HEX_RE = re.compile(
r"(?[^>]*)>", re.IGNORECASE | re.DOTALL)
SCRIPT_BLOCK_RE = re.compile(
r"",
re.IGNORECASE | re.DOTALL,
)
SRC_HTTP_RE = re.compile(r"\bsrc\s*=\s*(['\"])\s*(?:https?:)?//", re.IGNORECASE)
IMPORT_HTTP_RE = re.compile(
r"@import\s+(?:url\(\s*)?(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE
)
URL_HTTP_RE = re.compile(r"url\(\s*(?:['\"]\s*)?(?:https?:)?//", re.IGNORECASE)
IMPORT_ANY_RE = re.compile(r"@import\b", re.IGNORECASE)
URL_ANY_RE = re.compile(r"url\(\s*([^)]+?)\s*\)", re.IGNORECASE)
LINK_RE = re.compile(r"]*>", re.IGNORECASE | re.DOTALL)
HREF_RE = re.compile(r"\bhref\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
REL_RE = re.compile(r"\brel\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL)
FONT_CSS_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.IGNORECASE)
FONT_ATTR_RE = re.compile(
r"\bfont-family\s*=\s*(['\"])(.*?)\1", re.IGNORECASE | re.DOTALL
)
ALLOWED_FONTS = {
"instrument serif",
"geist",
"geist mono",
"hiragino sans",
"noto sans jp",
"yu gothic",
"noto sans mono cjk jp",
"apple sd gothic neo",
"noto sans kr",
"malgun gothic",
"noto sans mono cjk kr",
"system-ui",
"sans-serif",
"serif",
"monospace",
"ui-monospace",
}
CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"}
def normalized_controller(body):
"""Return a platform-stable representation of an inline controller."""
return body.replace("\r\n", "\n").replace("\r", "\n").strip()
def canonical_controller_digest():
"""Hash the one reviewed controller shipped by the motion template."""
source = MOTION_TEMPLATE.read_text(encoding="utf-8")
blocks = list(SCRIPT_BLOCK_RE.finditer(source))
if len(blocks) != 1:
raise RuntimeError("template-motion.html must contain exactly one controller")
body = normalized_controller(blocks[0].group("body"))
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def google_fonts_families(url):
"""Return exact families from an approved Google Fonts css2 stylesheet."""
try:
parsed = urlparse(url)
port = parsed.port
except ValueError:
return set()
if (
parsed.scheme.casefold() != "https"
or parsed.hostname is None
or parsed.hostname.casefold() != "fonts.googleapis.com"
or port not in (None, 443)
or parsed.path != "/css2"
or parsed.fragment
):
return set()
families = set()
for value in parse_qs(parsed.query, keep_blank_values=True).get("family", []):
family = value.split(":", 1)[0].strip()
if family:
families.add(family.casefold())
return families
class ResourceParser(HTMLParser):
"""Collect remote resource dependencies without executing the document."""
RESOURCE_ATTRS = {
"base": ("href",),
"link": ("href",),
"script": ("src",),
"img": ("src", "srcset"),
"iframe": ("src",),
"object": ("data",),
"embed": ("src",),
"image": ("href", "xlink:href"),
"use": ("href", "xlink:href"),
"video": ("src", "poster"),
"audio": ("src",),
"source": ("src", "srcset"),
}
def __init__(self):
super().__init__(convert_charrefs=True)
self.remote: list[tuple[int, str, str, str]] = []
self.executable: list[tuple[int, str, str]] = []
self.fonts: set[str] = set()
@staticmethod
def is_remote(value):
try:
parsed = urlparse(value.strip())
except ValueError:
return True
return bool(parsed.scheme) or bool(parsed.netloc)
def handle_starttag(self, tag, attrs):
tag = tag.casefold()
data = {name.casefold(): value or "" for name, value in attrs}
line, _column = self.getpos()
for name, value in data.items():
compact = "".join(character for character in value if ord(character) > 32).casefold()
if (
name.startswith("on")
or name == "srcdoc"
or tag == "base"
or compact.startswith("javascript:")
or compact.startswith("data:text/html")
):
self.executable.append((line, tag, name))
for attr in self.RESOURCE_ATTRS.get(tag, ()):
value = data.get(attr, "").strip()
candidates = (
[item.strip().split()[0] for item in value.split(",") if item.strip()]
if attr == "srcset"
else [value]
)
for candidate in candidates:
if not candidate or not self.is_remote(candidate):
continue
rel = data.get("rel", "").casefold().split()
approved_fonts = (
google_fonts_families(candidate)
if tag == "link" and attr == "href" and "stylesheet" in rel
else set()
)
if approved_fonts:
self.fonts.update(approved_fonts)
else:
self.remote.append((line, tag, attr, candidate))
def style_guide_families():
"""Read only the Family cells in the style guide's Typography table."""
markdown = STYLE_GUIDE.read_text(encoding="utf-8")
match = re.search(
r"^## Typography\s*$.*?^\| Role \| Family \|.*?\n(?P.*?)(?=^### |^## |\Z)",
markdown,
re.MULTILINE | re.DOTALL,
)
if not match:
return set()
families = set()
for line in match.group("rows").splitlines():
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) < 2 or not cells[0].startswith("`"):
continue
family_stack = re.sub(r"\s*\([^)]*\)\s*$", "", cells[1]).strip(" `*")
family_stack = re.sub(r"\s+\*[^*]+\*\s*$", "", family_stack).strip()
for family in family_stack.split(","):
family = family.strip().strip("'\"")
if family:
families.add(family.casefold())
return families
@dataclass
class SvgTextElement:
tag: str
element_id: str | None
line: int
offset: int
text: list[str] = field(default_factory=list)
@dataclass
class SvgElement:
attrs: dict[str, str | None]
line: int
offset: int
content_depth: int
first_child: str | None = None
ids: set[str] = field(default_factory=set)
bare_id_locations: list[tuple[int, int]] = field(default_factory=list)
titles: list[SvgTextElement] = field(default_factory=list)
descriptions: list[SvgTextElement] = field(default_factory=list)
class AccessibleSvgParser(HTMLParser):
"""Collect accessible-name data without rendering or executing HTML.
Trust boundary: input files are untrusted source text. ``HTMLParser`` only
tokenizes that text; this linter never evaluates scripts, fetches URLs, or
opens referenced resources.
"""
def __init__(self, text):
super().__init__(convert_charrefs=True)
self.line_offsets = [0]
self.line_offsets.extend(
match.end() for match in re.finditer(r"\n", text)
)
self.element_stack = []
self.svg_stack = []
self.svgs = []
self.open_text = []
self.id_locations = {}
def source_offset(self):
line, column = self.getpos()
return self.line_offsets[line - 1] + column
def handle_starttag(self, tag, attrs):
tag = tag.casefold()
attr_map = {name.casefold(): value for name, value in attrs}
offset = self.source_offset()
line, _column = self.getpos()
element_id = attr_map.get("id")
if element_id:
self.id_locations.setdefault(element_id, []).append((line, offset))
if self.svg_stack:
parent = self.svg_stack[-1]
if len(self.element_stack) == parent.content_depth:
if parent.first_child is None:
parent.first_child = tag
if element_id:
for svg in self.svg_stack:
svg.ids.add(element_id)
if element_id in {"title", "desc"}:
svg.bare_id_locations.append((line, offset))
if tag == "svg":
svg = SvgElement(
attrs=attr_map,
line=line,
offset=offset,
content_depth=len(self.element_stack) + 1,
)
self.svgs.append(svg)
self.svg_stack.append(svg)
elif tag in {"title", "desc"} and self.svg_stack:
svg = self.svg_stack[-1]
if len(self.element_stack) == svg.content_depth:
element = SvgTextElement(
tag=tag,
element_id=attr_map.get("id"),
line=line,
offset=offset,
)
if tag == "title":
svg.titles.append(element)
else:
svg.descriptions.append(element)
self.open_text.append(element)
self.element_stack.append(tag)
def handle_startendtag(self, tag, attrs):
self.handle_starttag(tag, attrs)
self.handle_endtag(tag)
def handle_endtag(self, tag):
tag = tag.casefold()
for index in range(len(self.open_text) - 1, -1, -1):
if self.open_text[index].tag == tag:
del self.open_text[index]
break
if tag == "svg" and self.svg_stack:
self.svg_stack.pop()
for index in range(len(self.element_stack) - 1, -1, -1):
if self.element_stack[index] == tag:
del self.element_stack[index:]
break
def handle_data(self, data):
for element in self.open_text:
element.text.append(data)
def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False):
parser = AccessibleSvgParser(text)
parser.feed(text)
parser.close()
findings = []
def add(line, offset, message):
findings.append((line, offset, "a11y", message))
naming_ids = {
element.element_id
for svg in parser.svgs
if (svg.attrs.get("aria-hidden") or "").casefold() != "true"
for element in svg.titles + svg.descriptions
if element.element_id
}
for element_id in sorted(naming_ids):
locations = parser.id_locations[element_id]
if len(locations) > 1:
line, offset = locations[1]
add(line, offset, f'duplicate accessible-name id="{element_id}" is not allowed')
for svg in parser.svgs:
aria_hidden = (svg.attrs.get("aria-hidden") or "").casefold()
if aria_hidden == "true":
continue
if (svg.attrs.get("role") or "").casefold() != "img":
add(svg.line, svg.offset, 'diagram