#!/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 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",
"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