nomadweb/wemini/scripts/sanitize_wxss_css.py
eric 79dda36dd3 Ship Taro WeChat mini-app with H5-aligned UI and WeChat login.
Document wemini credentials and native weapp workflow in README; add jscode2session auth endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:05:56 -05:00

259 lines
7.3 KiB
Python

#!/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())