"""Template lint rules and cross-template consistency checks. Splits out from build.py: - scan_file: per-line + per-block lint for HTML/CSS/PPTX templates. - check_all: scan every template and aggregate findings by rule. - check_cross_template_consistency: pair CN/EN templates and report :root variable drift outside the allowlist. Each `Finding` is anchored to a file path + line number so editors can jump straight to the violation. Rules encode real WeasyPrint pitfalls (rgba on background, thin border with border-radius, etc.), not style preferences. """ from __future__ import annotations import json import re from dataclasses import dataclass from pathlib import Path from shared import ( COOL_GRAY_BLOCKLIST, HTML_TEMPLATES, ROOT, SCREEN_TEMPLATES, TEMPLATES, TOKENS_FILE, iter_template_files, ) from tokens import ROOT_BLOCK, parse_root_vars # Font-stack vars legitimately differ between a base template and its locale # variants (-en, -ko); every other :root var must match across the pair. CROSS_TEMPLATE_ALLOWED_VARS = {"--serif", "--sans", "--mono", "--latin-ui"} RGBA_BG_DIRECT = re.compile(r"background(?:-color)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) RGBA_VAR_DEF = re.compile(r"--([\w-]+)\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) BG_VAR_USE = re.compile(r"background(?:-color)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) RGBA_BORDER_DIRECT = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*rgba\s*\(", re.IGNORECASE) BORDER_VAR_USE = re.compile(r"border(?:-\w+)?\s*:\s*[^;]*var\s*\(\s*--([\w-]+)", re.IGNORECASE) LINE_HEIGHT_LOOSE = re.compile(r"line-height\s*:\s*1\.[6-9]\d*", re.IGNORECASE) UNICODE_ARROW = re.compile(r"→") # U+2192; should not appear in EN template body HEX_ANY = re.compile(r"#[0-9a-fA-F]{3,6}\b") # Thin closed border: border shorthand (not single-side) with sub-1pt width -- pitfall #2 THIN_CLOSED_BORDER = re.compile( r"border(?!-(?:left|right|top|bottom))\s*:\s*[^;]*0\.\d+pt", re.IGNORECASE, ) BORDER_RADIUS_PROP = re.compile(r"border-radius\s*:", re.IGNORECASE) CSS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) SVG_BLOCK_RE = re.compile(r"", re.DOTALL | re.IGNORECASE) # WeasyPrint-unsafe artifacts of un-normalized beautiful-mermaid SVG. These must # never reach a PDF-bound template/diagram: WeasyPrint does not resolve # color-mix(), render , or fetch a runtime web font. The author # must pipe Mermaid output through scripts/mermaid_normalize.py first. Screen-only # landing pages are exempt (color-mix in CSS is fine in a real browser). MERMAID_UNSAFE = { "mermaid-color-mix": re.compile(r"color-mix\s*\(", re.IGNORECASE), "mermaid-foreignobject": re.compile(r" str: """Replace `/* ... */` with spaces of the same length so commented-out rgba()/cool-gray literals don't trip the per-line scan. Length-preserving so line numbers and per-line search offsets remain correct. """ def repl(m: re.Match[str]) -> str: return "".join(ch if ch == "\n" else " " for ch in m.group(0)) return CSS_BLOCK_COMMENT_RE.sub(repl, text) def scan_file(path: Path) -> list[Finding]: findings: list[Finding] = [] raw_text = path.read_text(encoding="utf-8", errors="replace") text = _strip_css_block_comments(raw_text) lines = text.splitlines() # Pass 1: collect variable names that hold rgba(...) so the tag-background # bug can be detected through one level of indirection. rgba_vars: set[str] = set() for raw in lines: m = RGBA_VAR_DEF.search(raw) if m: rgba_vars.add(m.group(1)) is_en = path.name.endswith("-en.html") # Screen-only templates (landing pages) never go through WeasyPrint, so the # Mermaid-unsafe-SVG rule does not apply to them. is_screen = path.name in set(SCREEN_TEMPLATES.values()) # Pass 2: per-line rule checks is_python = path.suffix == ".py" for i, raw in enumerate(lines, start=1): line = raw.strip() if not line: continue # Skip comment lines. Note: '#' alone is NOT a CSS or HTML comment; it # is the start of a CSS id selector (e.g. `#hero-bg { ... }`) or part of # a hex literal. Only treat '#' as a comment when scanning Python. if line.startswith("//"): continue if line.startswith("