mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
import re
|
|
|
|
from .build import _iter_source_files
|
|
from .config import GatewayConfig, SiteConfig
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuditIssue:
|
|
site_id: str
|
|
path: Path
|
|
severity: str
|
|
code: str
|
|
message: str
|
|
|
|
|
|
def audit_gateway(config: GatewayConfig) -> list[AuditIssue]:
|
|
issues: list[AuditIssue] = []
|
|
for site in config.sites:
|
|
issues.extend(audit_site(site))
|
|
return issues
|
|
|
|
|
|
def audit_site(site: SiteConfig) -> list[AuditIssue]:
|
|
issues: list[AuditIssue] = []
|
|
for path in _iter_source_files(site.source.path):
|
|
if path.suffix.lower() not in {".html", ".htm"}:
|
|
continue
|
|
html = path.read_text(encoding="utf-8", errors="replace")
|
|
rel_path = path.relative_to(site.source.path)
|
|
page_issues = SemanticHtmlAuditor(site.id, rel_path).audit(html)
|
|
issues.extend(page_issues)
|
|
return issues
|
|
|
|
|
|
class SemanticHtmlAuditor(HTMLParser):
|
|
def __init__(self, site_id: str, path: Path) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self.site_id = site_id
|
|
self.path = path
|
|
self.issues: list[AuditIssue] = []
|
|
self.last_heading_level = 0
|
|
self.h1_count = 0
|
|
self.anchor_stack: list[tuple[str, list[str], bool]] = []
|
|
self.skip_stack: list[str] = []
|
|
|
|
def audit(self, html: str) -> list[AuditIssue]:
|
|
self.feed(html)
|
|
self.close()
|
|
if self.h1_count == 0:
|
|
self._warn("missing_h1", "page has no h1 heading")
|
|
elif self.h1_count > 1:
|
|
self._warn("multiple_h1", f"page has {self.h1_count} h1 headings")
|
|
return self.issues
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
tag = tag.lower()
|
|
attrs_dict = {name.lower(): value for name, value in attrs}
|
|
if tag in {"script", "style"}:
|
|
self.skip_stack.append(tag)
|
|
return
|
|
if self.skip_stack:
|
|
return
|
|
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
self._handle_heading(int(tag[1]))
|
|
elif tag == "img":
|
|
alt = attrs_dict.get("alt")
|
|
if alt is None or not _normalize_text(alt):
|
|
self._warn("missing_alt", "image is missing meaningful alt text")
|
|
if self.anchor_stack:
|
|
href, text, _ = self.anchor_stack.pop()
|
|
self.anchor_stack.append((href, text, True))
|
|
elif tag == "a":
|
|
href = attrs_dict.get("href")
|
|
if href:
|
|
self.anchor_stack.append((href, [], False))
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
tag = tag.lower()
|
|
if self.skip_stack:
|
|
if self.skip_stack[-1] == tag:
|
|
self.skip_stack.pop()
|
|
return
|
|
if tag == "a" and self.anchor_stack:
|
|
href, text_parts, has_image = self.anchor_stack.pop()
|
|
text = _normalize_text("".join(text_parts))
|
|
if not text and not has_image and not href.startswith("#"):
|
|
self._warn("empty_link_text", f"link to {href!r} has no text")
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self.skip_stack:
|
|
return
|
|
if self.anchor_stack:
|
|
href, text_parts, has_image = self.anchor_stack.pop()
|
|
text_parts.append(data)
|
|
self.anchor_stack.append((href, text_parts, has_image))
|
|
|
|
def _handle_heading(self, level: int) -> None:
|
|
if level == 1:
|
|
self.h1_count += 1
|
|
if self.last_heading_level and level > self.last_heading_level + 1:
|
|
self._warn(
|
|
"heading_level_skip",
|
|
f"heading jumps from h{self.last_heading_level} to h{level}",
|
|
)
|
|
self.last_heading_level = level
|
|
|
|
def _warn(self, code: str, message: str) -> None:
|
|
self.issues.append(
|
|
AuditIssue(
|
|
site_id=self.site_id,
|
|
path=self.path,
|
|
severity="warn",
|
|
code=code,
|
|
message=message,
|
|
)
|
|
)
|
|
|
|
|
|
def _normalize_text(value: str) -> str:
|
|
return re.sub(r"\s+", " ", value).strip()
|