""" CSS-линтер для miniapp/assets/ Проверяет: запрещённые паттерны, читаемость цветов (контраст WCAG), явные цвета в ключевых классах, версии кэша. Запуск: python -X utf8 tests/lint_css.py Возвращает exit code 1 если найдены проблемы. """ import re import sys import math from pathlib import Path from datetime import datetime ROOT = Path(__file__).parent.parent / "miniapp" / "assets" ISSUES = [] # критические — блокируют коммит WARNINGS = [] # предупреждения — не блокируют, но стоит исправить def issue(file: str, line: int, msg: str): ISSUES.append(f" ❌ {file}:{line} {msg}") def warn(file: str, line: int, msg: str): WARNINGS.append(f" ⚠️ {file}:{line} {msg}") # ════════════════════════════════════════════════════════════════ # WCAG-контраст # ════════════════════════════════════════════════════════════════ def _hex_to_rgb(h: str) -> tuple[int, int, int] | None: h = h.lstrip("#") if len(h) == 3: h = "".join(c*2 for c in h) if len(h) != 6: return None try: return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) except ValueError: return None def _relative_luminance(r: int, g: int, b: int) -> float: def c(x): x /= 255 return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4 return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b) def contrast_ratio(hex1: str, hex2: str) -> float | None: rgb1 = _hex_to_rgb(hex1) rgb2 = _hex_to_rgb(hex2) if not rgb1 or not rgb2: return None L1 = _relative_luminance(*rgb1) L2 = _relative_luminance(*rgb2) lighter, darker = max(L1, L2), min(L1, L2) return (lighter + 0.05) / (darker + 0.05) def contrast_grade(ratio: float | None) -> str: if ratio is None: return "?" if ratio >= 7.0: return "AAA" if ratio >= 4.5: return "AA" if ratio >= 3.0: return "AA-Large" return "FAIL" # Известные фоны карточек по темам (для проверки текста без background в блоке) CARD_BACKGROUNDS = [ "#FFFFFF", # Default light "#EAE3CC", # Foundry "#EDE5D0", # Boardroom "#E9EBEF", # Atelier ] PAGE_BACKGROUNDS = [ "#FAFAF7", # Default light "#14130E", # Default dark "#EFE9D8", # Foundry "#F2E9D6", # Boardroom "#E9EBEF", # Atelier ] # ════════════════════════════════════════════════════════════════ # Паттерны-запреты (построчные) # ════════════════════════════════════════════════════════════════ FORBIDDEN_LINE_PATTERNS = [ ( r"(? 0: brace_depth += stripped.count("{") - stripped.count("}") block_lines.append(stripped) if brace_depth == 0: _lint_block(fname, current_selector.strip(), block_lines, block_start) current_selector = "" block_lines = [] def _lint_block(fname: str, selector: str, block_lines: list[str], start_line: int): block_text = " ".join(block_lines) # ── Запрет var(--card) / var(--paper) для текстовых классов ── for bad_var in (r"var\(--card\)", r"var\(--paper\)"): if re.search(rf"(? str | None: m = re.search(pattern, text, re.IGNORECASE) return m.group(1) if m else None def _check_color_against_known_backgrounds( fname: str, line: int, selector: str, color_hex: str ): """Проверяет цвет текста против всех известных фонов карточек.""" fails = [] for bg in CARD_BACKGROUNDS: ratio = contrast_ratio(color_hex, bg) if ratio is not None and ratio < 4.5: fails.append(f"{bg}={ratio:.1f}:1") if len(fails) == len(CARD_BACKGROUNDS): # Плохой контраст против ВСЕХ фонов — критично issue(fname, line, f"`{selector}` color:{color_hex} — низкий контраст против всех " f"фонов карточек: {', '.join(fails)}. Текст нечитаем во всех темах") elif fails: warn(fname, line, f"`{selector}` color:{color_hex} — низкий контраст в некоторых темах: " f"{', '.join(fails)}. Проверь Foundry/Boardroom/Atelier") # ════════════════════════════════════════════════════════════════ # Проверка версий в index.html # ════════════════════════════════════════════════════════════════ def lint_versions(): index = Path(__file__).parent.parent / "miniapp" / "index.html" if not index.exists(): return text = index.read_text(encoding="utf-8") versions: dict[str, str] = {} for m in re.finditer(r'(?:href|src)="assets/([^"]+)\?v=([^"]+)"', text): versions[m.group(1)] = m.group(2) today = datetime.now().strftime("%Y%m%d") for fname, ver in versions.items(): if re.match(r"^\d{8}", ver): days_diff = (datetime.strptime(today, "%Y%m%d") - datetime.strptime(ver[:8], "%Y%m%d")).days if days_diff > 30: warn("index.html", 0, f"{fname} версия «{ver}» не обновлялась {days_diff} дней — " f"проверь, не забыли ли поднять версию после изменений") if not re.match(r"^\d{8}[a-z]$", ver): warn("index.html", 0, f"{fname} версия «{ver}» не соответствует формату YYYYMMDDx") # ════════════════════════════════════════════════════════════════ # Main # ════════════════════════════════════════════════════════════════ def main(): print("🔍 CSS-линтер miniapp/assets/\n") for f in sorted(ROOT.glob("*.css")): lint_file(f) lint_versions() if WARNINGS: print("Предупреждения (не блокируют, но стоит исправить):\n") for w in WARNINGS: print(w) print() if ISSUES: print("Критические проблемы (блокируют коммит):\n") for iss in ISSUES: print(iss) print(f"\n🚫 Итого: {len(ISSUES)} ошибок, {len(WARNINGS)} предупреждений. " f"Исправь ошибки перед коммитом.\n") sys.exit(1) elif WARNINGS: print(f"⚠️ Итого: 0 ошибок, {len(WARNINGS)} предупреждений.\n") sys.exit(0) else: print("✅ Всё чисто — ни ошибок, ни предупреждений.\n") sys.exit(0) if __name__ == "__main__": main()