#!/usr/bin/env python3 """Sanitize frontend globals.css into WeChat WXSS-safe nomadro.css. WXSS rejects: *, color-mix(), @supports, @layer, :has/:is/:where, html/body tag chains that confuse the compiler, etc. Keep H5 class names & tokens. """ from __future__ import annotations import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SRC = ROOT / "frontend" / "src" / "app" / "globals.css" OUT = ROOT / "wemini" / "src" / "styles" / "nomadro.css" def unwrap_blocks(text: str, opener_re: str) -> str: """Remove @foo { ... } blocks (balanced braces), keep inner only for @layer.""" keep_inner = "@layer" in opener_re while True: m = re.search(opener_re, text) if not m: break start = m.start() i = m.end() depth = 1 while i < len(text) and depth: if text[i] == "{": depth += 1 elif text[i] == "}": depth -= 1 i += 1 inner = text[m.end() : i - 1] text = text[:start] + (inner if keep_inner else "") + text[i:] return text def drop_rules_matching(text: str, selector_test) -> str: """Drop full CSS rules whose selector string fails selector_test(sel)->bool keep.""" out = [] i = 0 n = len(text) while i < n: if text[i] == "/" and i + 1 < n and text[i + 1] == "*": j = text.find("*/", i + 2) if j < 0: break out.append(text[i : j + 2]) i = j + 2 continue if text[i] == "@" and not text[i:].startswith("@keyframes") and not text[i:].startswith("@media") and not text[i:].startswith("@font-face"): # skip unknown at-rules with blocks m = re.match(r"@[^{;]+[;{]", text[i:]) if m and m.group(0).endswith("{"): depth = 1 j = i + m.end() while j < n and depth: if text[j] == "{": depth += 1 elif text[j] == "}": depth -= 1 j += 1 i = j continue elif m and m.group(0).endswith(";"): i = i + m.end() continue # find next { for a rule brace = text.find("{", i) if brace < 0: out.append(text[i:]) break # if @keyframes / @media — copy whole balanced block chunk_head = text[i:brace].lstrip() if chunk_head.startswith("@keyframes") or chunk_head.startswith("@media") or chunk_head.startswith("@font-face"): depth = 1 j = brace + 1 while j < n and depth: if text[j] == "{": depth += 1 elif text[j] == "}": depth -= 1 j += 1 block = text[i:j] # still sanitize color-mix inside out.append(block) i = j continue sel = text[i:brace] depth = 1 j = brace + 1 while j < n and depth: if text[j] == "{": depth += 1 elif text[j] == "}": depth -= 1 j += 1 body = text[brace:j] if selector_test(sel): out.append(sel + body) i = j return "".join(out) def selector_ok(sel: str) -> bool: s = sel.strip() if not s: return False # comments-only if s.startswith("/*") and "*/" in s and s.rstrip().endswith("*/"): return True # wildcard if re.search(r"(^|[\s,])\*", s): return False if ":has(" in s or ":is(" in s or ":where(" in s: return False # raw HTML tags as primary selectors (weapp has no div/span/html/body) bad_tags = ( r"\bhtml\b", r"\bbody\b", r"\bdiv\b", r"\bspan\b", r"\ba\b", r"\bbutton\b", r"\binput\b", r"\bselect\b", r"\btextarea\b", r"\bimg\b", r"\bsvg\b", r"\bpath\b", r"\bcircle\b", r"\bellipse\b", r"\bul\b", r"\bol\b", r"\bli\b", r"\bh[1-6]\b", r"\bp\b", r"\bnav\b", r"\bfooter\b", r"\bheader\b", r"\bsection\b", r"\barticle\b", r"\bmain\b", r"\baside\b", r"\blabel\b", r"\btable\b", r"\btr\b", r"\btd\b", r"\bth\b", ) # Allow .class h2 etc? Those still break WXSS for unknown tags — drop rules with tag segments # Keep if selector is only classes/ids/pseudo on classes parts = re.split(r"\s*,\s*", s) for part in parts: part = part.strip() if not part or part.startswith("/*"): continue # strip :pseudo and ::pseudo bare = re.sub(r"::?[a-zA-Z0-9_-]+(\([^)]*\))?", "", part) bare = re.sub(r"\[[^\]]*\]", "", bare) # if any raw tag token remains tokens = re.findall(r"[.#]?[A-Za-z_][\w-]*", bare) for t in tokens: if t.startswith(".") or t.startswith("#"): continue if t.lower() in { "html", "body", "div", "span", "a", "button", "input", "select", "textarea", "img", "svg", "path", "circle", "ellipse", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "p", "nav", "footer", "header", "section", "article", "main", "aside", "label", "table", "tr", "td", "th", "from", "to", "small", "strong", "em", "code", "pre", "blockquote", }: return False return True def replace_color_mix(text: str) -> str: # color-mix(in srgb, var(--x) N%, transparent) → approx rgba via opacity on solid fallback def repl(m: re.Match) -> str: inner = m.group(1) # var(--token) pct%, transparent mm = re.search( r"var\((--[\w-]+)\)\s+(\d+)%\s*,\s*transparent", inner, ) if mm: var, pct = mm.group(1), int(mm.group(2)) # keep a readable fallback; opacity via 8-digit hex not always ok — use the var at lower intent return f"rgba(255,255,255,{pct/100 * 0.35:.2f}) /* was color-mix {var} {pct}% */" mm2 = re.search(r"transparent\s*,\s*var\((--[\w-]+)\)\s+(\d+)%", inner) if mm2: return f"var({mm2.group(1)})" return "transparent" return re.sub(r"color-mix\(([^)]*)\)", repl, text) def main() -> int: src = SRC.read_text(encoding="utf-8") src = re.sub( r"html:has\(\[data-ebook-root\]\)[^{]*\{(?:[^{}]|\{[^{}]*\})*\}", "", src, ) src = unwrap_blocks(src, r"@layer\s+[\w-]+\s*\{") src = unwrap_blocks(src, r"@supports\b[^{]*\{") # second pass: any leftover @supports while True: m = re.search(r"@supports\b[^{]*\{", src) if not m: break i = m.end() depth = 1 while i < len(src) and depth: if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 i += 1 src = src[: m.start()] + src[i:] src = src.replace("var(--font-outfit), ", "").replace(", var(--font-outfit)", "") src = replace_color_mix(src) src = re.sub(r"\binset:\s*0\s*;", "top:0;right:0;bottom:0;left:0;", src) src = re.sub(r"\binset:\s*([^;]+);", r"top:\1;right:\1;bottom:\1;left:\1;", src) # drop bad rules src = drop_rules_matching(src, selector_ok) # page root from :root + body-ish defaults page_patch = """ page { background: var(--bg-primary, #0c0b12); color: var(--text-primary, #f4f0ea); font-size: 28rpx; line-height: 1.6; box-sizing: border-box; } """ header = "/* WXSS-safe mirror of frontend globals.css (H5 class names kept) */\n" OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(header + src + page_patch, encoding="utf-8") # quick scan bad = [] for pat, name in [ (r"(^|[\s,])\*\s*[,{]", "*"), (r"color-mix\(", "color-mix"), (r"@supports", "@supports"), (r"@layer", "@layer"), (r":has\(", ":has"), ]: if re.search(pat, OUT.read_text(encoding="utf-8"), re.M): bad.append(name) print(f"wrote {OUT} ({OUT.stat().st_size} bytes) leftover={bad or 'none'}") return 0 if __name__ == "__main__": raise SystemExit(main())