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