#!/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. """ import argparse import re import sys from pathlib import Path 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" HEX_RE = re.compile( r"(?]*>", re.IGNORECASE | re.DOTALL) HREF_RE = re.compile(r"\bhref\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", "system-ui", "sans-serif", "serif", "monospace", "ui-monospace", } CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"} def normalize_hex(value): value = value.lower() if len(value) == 4: return "#" + "".join(character * 2 for character in value[1:]) return value def table_hexes(markdown, heading): """Return hex colors from the first Markdown table after *heading*.""" start = markdown.find(heading) if start < 0: raise ValueError(f"missing {heading!r} in {STYLE_GUIDE}") table_started = False colors = set() for line in markdown[start:].splitlines()[1:]: if line.startswith("|"): table_started = True colors.update(normalize_hex(match.group()) for match in HEX_RE.finditer(line)) elif table_started: break return colors def allowed_colors(): markdown = STYLE_GUIDE.read_text(encoding="utf-8") colors = table_hexes(markdown, "### Semantic roles") colors.update(table_hexes(markdown, "### Series palette")) colors.update(table_hexes(markdown, "### Terminal skin")) colors.update({"#fff", "#ffffff"}) rgb_triplets = set() for color in colors: expanded = normalize_hex(color) if len(expanded) == 7: rgb_triplets.add( tuple(int(expanded[offset : offset + 2], 16) for offset in (1, 3, 5)) ) return colors, rgb_triplets def line_number(text, offset): return text.count("\n", 0, offset) + 1 def display_path(path): try: return path.resolve().relative_to(ROOT).as_posix() except ValueError: return str(path) def named_families(value): families = [] for raw_family in value.split(","): family = raw_family.strip().strip("'\"").strip() lowered = family.casefold() if not family or lowered in CSS_FONT_KEYWORDS or lowered.startswith("var("): continue if lowered not in ALLOWED_FONTS: families.append(family) return families def lint_text(text, colors, rgb_triplets): findings = [] def add(offset, category, message): findings.append((line_number(text, offset), offset, category, message)) for match in HEX_RE.finditer(text): value = match.group() normalized = normalize_hex(value) if normalized == "#000000": add(match.start(), "pure-black", f"pure black {value} is not allowed") elif normalized not in colors: add(match.start(), "color", f"{value} is not in the style-guide palette") for match in RGBA_RE.finditer(text): rgb = tuple(int(match.group(index)) for index in (1, 2, 3)) if rgb not in rgb_triplets: add( match.start(), "color", f"{match.group()} is not derived from an allowed palette color", ) for match in BLACK_RGB_RE.finditer(text): add(match.start(), "pure-black", "pure black rgb(0,0,0) is not allowed") for match in SCRIPT_RE.finditer(text): add(match.start(), "script", "