#!/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 ", 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"]+name=[\"']viewport[\"'][^>]*width=device-width", html, re.I): findings.append('missing ' "- 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"]*>(.*?)", 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"(?=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]))