Files
2026-07-13 13:05:52 +08:00

154 lines
5.4 KiB
Python

"""Token-sync checker for kami templates.
Splits out from build.py: scans `:root { ... }` blocks across HTML templates
and `RGBColor(0xXX, 0xXX, 0xXX)` constants in the PPTX slide scripts, and
reports drift from `references/tokens.json` (the canonical color tokens).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from shared import ROOT, TEMPLATES, TOKENS_FILE, iter_template_files
ROOT_BLOCK = re.compile(r":root\s*\{([^}]*)\}", re.DOTALL)
CSS_VAR = re.compile(r"--([\w-]+)\s*:\s*([^;]+);")
def parse_root_vars(text: str) -> dict[str, str]:
"""Return {'--name': value} merged across every `:root { ... }` block.
Scanning all blocks (not just the first) keeps a future dark-mode or print
override inside a second `:root` from silently escaping the drift checks.
"""
found: dict[str, str] = {}
for block in ROOT_BLOCK.finditer(text):
for m in CSS_VAR.finditer(block.group(1)):
found[f"--{m.group(1)}"] = m.group(2).strip()
return found
PY_RGB = re.compile(
r"^([A-Z][A-Z_]+)\s*=\s*RGBColor\(\s*0x([0-9a-fA-F]{2})\s*,"
r"\s*0x([0-9a-fA-F]{2})\s*,\s*0x([0-9a-fA-F]{2})\s*\)",
re.MULTILINE,
)
# Python const name -> tokens.json key. Only constants that mirror a CSS token.
PY_TOKEN_MAP = {
"PARCHMENT": "--parchment",
"IVORY": "--ivory",
"BRAND": "--brand",
"NEAR_BLACK": "--near-black",
"DARK_WARM": "--dark-warm",
"CHARCOAL": "--charcoal",
"OLIVE": "--olive",
"STONE": "--stone",
}
MERMAID_THEME_FILE = ROOT / "references" / "mermaid-theme.json"
MERMAID_THEME_TOKEN_MAP = {
"bg": "--parchment",
"fg": "--near-black",
"line": "--olive",
"accent": "--brand",
"muted": "--stone",
"surface": "--ivory",
"border": "--border",
}
def _mermaid_theme_drift(canonical: dict[str, str]) -> list[str]:
if not MERMAID_THEME_FILE.exists():
return [f"mermaid-theme.json not found at {MERMAID_THEME_FILE.relative_to(ROOT)}"]
try:
theme = json.loads(MERMAID_THEME_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [f"mermaid-theme.json is malformed: {exc}"]
colors = theme.get("colors", {})
roles = theme.get("roles", {})
drift: list[str] = []
for role, token in MERMAID_THEME_TOKEN_MAP.items():
expected = canonical.get(token)
actual = colors.get(role)
if expected is None:
drift.append(f"{role}: canonical token {token} missing")
continue
if actual is None:
drift.append(f"{role}: color missing, expected {expected} from {token}")
elif actual.lower() != expected.lower():
drift.append(f"{role}: expected {expected} from {token}, got {actual}")
role_doc = str(roles.get(role, ""))
if token not in role_doc:
drift.append(f"{role}: role doc should mention {token}")
return drift
def sync_check(verbose: bool = False) -> int:
if not TOKENS_FILE.exists():
print(f"ERROR: tokens.json not found at {TOKENS_FILE.relative_to(ROOT)}")
return 2
try:
canonical: dict[str, str] = json.loads(TOKENS_FILE.read_text())
except json.JSONDecodeError as exc:
print(f"ERROR: tokens.json is malformed: {exc}")
return 2
targets = iter_template_files(include_diagrams=True, include_marp_css=True)
py_targets: list[Path] = list(TEMPLATES.glob("*.py"))
if not targets and not py_targets:
print("ERROR: no templates found to token-check (bad checkout?)")
return 2
drift: list[tuple[str, str, str, str]] = [] # (file, token, expected, actual)
for path in targets:
text = path.read_text(encoding="utf-8", errors="replace")
found = parse_root_vars(text)
if not found:
if verbose:
print(f" (skip {path.name}: no :root block)")
continue
rel = path.relative_to(ROOT)
for token, expected in canonical.items():
actual = found.get(token)
# Only flag if the template defines the token but with a wrong value.
# Templates that don't use a token don't need to define it.
if actual is not None and actual.lower() != expected.lower():
drift.append((str(rel), token, expected, actual))
for path in sorted(py_targets):
text = path.read_text(encoding="utf-8", errors="replace")
rel = path.relative_to(ROOT)
for m in PY_RGB.finditer(text):
name = m.group(1)
token = PY_TOKEN_MAP.get(name)
if token is None:
continue
expected = canonical.get(token)
if expected is None:
continue
actual = f"#{m.group(2)}{m.group(3)}{m.group(4)}"
if actual.lower() != expected.lower():
drift.append((str(rel), token, expected, actual))
theme_drift = _mermaid_theme_drift(canonical)
if not drift and not theme_drift:
scanned = len(targets) + len(py_targets)
print(f"OK: tokens in sync across {scanned} template(s) and mermaid-theme.json")
return 0
if drift:
print(f"\nERROR: [token-drift] {len(drift)}")
for file, token, expected, actual in drift:
print(f" {file}: {token} expected {expected}, got {actual}")
if theme_drift:
print(f"\nERROR: [mermaid-theme-drift] {len(theme_drift)}")
for issue in theme_drift:
print(f" {issue}")
return 1