jcode/skills/single-file-webapp/check_html.py

86 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""check_html.py - static smoke test for a single-file web app.
python check_html.py app.html
Checks (exit code 1 on any finding):
- every <script> block parses (node --check; module scripts as .mjs)
- <script type="importmap"> is valid JSON with an "imports" object
- inline handlers (onclick="...") used together with a module script: they
cannot see module-scope functions -> use addEventListener inside the module
- ids referenced via getElementById('x') / querySelector('#x') exist in the HTML
- mobile basics: viewport meta, no alert/confirm/prompt, input font-size >= 16px,
no fixed pixel widths >= 100px on inputs, popups/modals with max-height + overflow:auto
"""
import json, re, subprocess, sys, tempfile
from pathlib import Path
def main(path: str) -> int:
html = Path(path).read_text(encoding="utf-8")
findings = []
scripts = re.findall(r"<script\b([^>]*)>(.*?)</script>", html, re.S | re.I)
has_module = any('type="module"' in a or "type='module'" in a for a, _ in scripts)
for i, (attrs, body) in enumerate(scripts, 1):
if "importmap" in attrs:
try:
im = json.loads(body)
if not isinstance(im.get("imports"), dict):
findings.append(f"script {i}: importmap has no 'imports' object")
except json.JSONDecodeError as e:
findings.append(f"script {i}: importmap is not valid JSON ({e})")
continue
if "src=" in attrs or not body.strip():
continue
suffix = ".mjs" if "module" in attrs else ".js"
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as f:
f.write(body); tmp = f.name
r = subprocess.run(["node", "--check", tmp], capture_output=True, text=True)
if r.returncode:
msg = r.stderr.strip().splitlines()
findings.append(f"script {i}: syntax error - " + " | ".join(l for l in msg if l.strip())[:300])
if has_module:
for m in re.finditer(r"\son(\w+)\s*=\s*[\"']([^\"']+)[\"']", html):
findings.append(f"inline on{m.group(1)}=\"{m.group(2)[:40]}\" with a module script "
"- handler can't reach module scope; use addEventListener")
ids = set(re.findall(r"\bid\s*=\s*[\"']([^\"']+)[\"']", html))
used = set(re.findall(r"getElementById\(\s*[\"']([^\"']+)[\"']", html))
used |= set(re.findall(r"querySelector(?:All)?\(\s*[\"']#([A-Za-z_][\w-]*)[\"']", html))
for u in sorted(used - ids):
findings.append(f"element id '{u}' is referenced in JS but does not exist in the HTML")
# --- mobile basics -------------------------------------------------------
if not re.search(r"<meta[^>]+name=[\"']viewport[\"'][^>]*width=device-width", html, re.I):
findings.append('missing <meta name="viewport" content="width=device-width, initial-scale=1"> '
"- the page renders as a tiny desktop page on phones")
for m in re.finditer(r"\b(alert|confirm|prompt)\s*\(", "\n".join(b for a, b in scripts if "src=" not in a)):
findings.append(f"{m.group(1)}() blocks and looks bad on phones - use a toast / small popup instead")
css = "\n".join(re.findall(r"<style\b[^>]*>(.*?)</style>", html, re.S | re.I))
for m in re.finditer(r"(?:^|})\s*([^{}]*\b(?:input|select|textarea)\b[^{}]*)\{([^}]*)\}", css):
fs = re.search(r"font-size\s*:\s*(\d+(?:\.\d+)?)px", m.group(2))
if fs and float(fs.group(1)) < 16:
findings.append(f"font-size {fs.group(1)}px on '{m.group(1).strip()[:40]}' - below 16px iOS zooms in on focus; "
"keep 16px and make fields compact with padding/width")
for m in re.finditer(r"(?:^|})\s*([^{}]*\b(?:input|select)\b[^{}]*)\{([^}]*)\}", css):
if re.search(r"(?<![-\w])width\s*:\s*(\d{3,})px", m.group(2)):
findings.append(f"fixed pixel width >=100px on '{m.group(1).strip()[:40]}' - size fields to their "
"content with ch units (e.g. width:5ch) or max-width")
for m in re.finditer(r"(?:^|})\s*([^{}]*(?:popup|modal|dialog|sheet|overlay)[^{}]*)\{([^}]*)\}", css, re.I):
body = m.group(2)
if re.search(r"position\s*:\s*(fixed|absolute)", body) and not (
re.search(r"max-height", body) and re.search(r"overflow(-y)?\s*:\s*(auto|scroll)", body)):
findings.append(f"popup-like '{m.group(1).strip()[:40]}' is positioned but has no max-height + overflow:auto "
"- it can cover the screen and cannot scroll")
for f_ in findings:
print("✗", f_)
print("ok" if not findings else f"{len(findings)} finding(s)")
return 1 if findings else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__); sys.exit(2)
sys.exit(main(sys.argv[1]))