skillhub/weekly/scripts/validate_site.py
XiaoSeS 09db71ffa7 docs(weekly): mirror reports into project Pages
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-07-31 11:18:22 +08:00

264 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate built routes and basic accessibility hooks for the weekly site."""
from __future__ import annotations
import argparse
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
VOID_ELEMENTS = {
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
NON_CONTENT_ELEMENTS = {"caption", "h1", "h2", "h3", "h4", "h5", "h6", "th"}
ALLOWED_PANELS = {"panel-overview", "panel-health", "panel-flow", "panel-method"}
class PageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.h1_count = 0
self.tabs = 0
self.panels = 0
self.tab_controls: set[str] = set()
self.panel_ids: set[str] = set()
self.panel_modules: dict[str, int] = {}
self.current_panel: str | None = None
self.panel_depth = 0
self.module_stack: list[dict[str, object]] = []
self.module_counts: dict[str, int] = {}
self.module_panels: dict[str, set[str]] = {}
self.module_names: set[str] = set()
self.empty_modules: set[str] = set()
self.table_stack: list[dict[str, int]] = []
self.empty_table_count = 0
self.ids: set[str] = set()
self.duplicate_ids: set[str] = set()
self.external_assets: list[str] = []
self.non_content_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
is_void = tag in VOID_ELEMENTS
if self.current_panel is not None and not is_void:
self.panel_depth += 1
if not is_void:
for module in self.module_stack:
module["depth"] = int(module["depth"]) + 1
for table in self.table_stack:
table["depth"] += 1
if tag in NON_CONTENT_ELEMENTS:
self.non_content_depth += 1
values = dict(attrs)
element_id = values.get("id")
if element_id:
if element_id in self.ids:
self.duplicate_ids.add(element_id)
self.ids.add(element_id)
if tag == "h1":
self.h1_count += 1
if values.get("role") == "tab":
self.tabs += 1
controls = values.get("aria-controls")
if controls:
self.tab_controls.add(controls)
if values.get("role") == "tabpanel":
self.panels += 1
panel_id = values.get("id") or ""
if panel_id:
self.panel_ids.add(panel_id)
self.panel_modules.setdefault(panel_id, 0)
self.current_panel = panel_id
self.panel_depth = 1
if tag == "table":
self.table_stack.append({"depth": 1, "data_cells": 0})
elif tag == "td":
for table in self.table_stack:
table["data_cells"] += 1
module_name = values.get("data-module")
if module_name:
self.module_names.add(module_name)
self.module_counts[module_name] = self.module_counts.get(module_name, 0) + 1
if self.current_panel:
self.module_panels.setdefault(module_name, set()).add(self.current_panel)
if is_void:
self.empty_modules.add(module_name)
else:
self.module_stack.append(
{"depth": 1, "name": module_name, "has_meaningful_content": False}
)
if self.current_panel:
self.panel_modules[self.current_panel] = self.panel_modules.get(self.current_panel, 0) + 1
if tag == "script" and values.get("src"):
self.external_assets.append(values["src"] or "")
if tag == "link" and "stylesheet" in (values.get("rel") or ""):
self.external_assets.append(values.get("href") or "")
def handle_startendtag(
self, tag: str, attrs: list[tuple[str, str | None]]
) -> None:
self.handle_starttag(tag, attrs)
def handle_data(self, data: str) -> None:
if data.strip() and not self.non_content_depth:
for module in self.module_stack:
module["has_meaningful_content"] = True
def handle_endtag(self, tag: str) -> None:
if tag in NON_CONTENT_ELEMENTS and self.non_content_depth:
self.non_content_depth -= 1
for module in self.module_stack:
module["depth"] = int(module["depth"]) - 1
while self.module_stack and int(self.module_stack[-1]["depth"]) == 0:
module = self.module_stack.pop()
if not module["has_meaningful_content"]:
self.empty_modules.add(str(module["name"]))
for table in self.table_stack:
table["depth"] -= 1
while self.table_stack and self.table_stack[-1]["depth"] == 0:
table = self.table_stack.pop()
if table["data_cells"] == 0:
self.empty_table_count += 1
if self.current_panel is not None:
self.panel_depth -= 1
if self.panel_depth == 0:
self.current_panel = None
def validate(root: Path) -> list[str]:
errors: list[str] = []
required = (root / "index.html", root / "archive.html", root / ".nojekyll")
for path in required:
if not path.exists():
errors.append(f"missing built route: {path}")
manifest_path = root / "reports.json"
if not manifest_path.is_file():
errors.append(f"missing manifest: {manifest_path}")
return errors
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
latest = payload.get("latest")
report_paths: list[Path] = []
for report in payload.get("reports", []):
report_file = root / str(report.get("path", "")) / "index.html"
if not report_file.is_file():
errors.append(f"missing report: {report_file}")
else:
report_paths.append(report_file)
if not latest:
errors.append("manifest latest is empty")
archive_path = root / "archive.html"
if archive_path.is_file():
archive_source = archive_path.read_text(encoding="utf-8")
archive_parser = PageParser()
archive_parser.feed(archive_source)
if archive_parser.h1_count != 1:
errors.append(
f"{archive_path}: expected one h1, found {archive_parser.h1_count}"
)
if 'data-site-theme="notion-light"' not in archive_source:
errors.append(f"{archive_path}: missing notion-light theme marker")
report_link_count = archive_source.count('class="report-link"')
if report_link_count != len(payload.get("reports", [])):
errors.append(
f"{archive_path}: expected one archive link per report, "
f"found {report_link_count}"
)
if archive_parser.external_assets:
errors.append(
f"{archive_path}: external assets are not allowed: "
f"{archive_parser.external_assets}"
)
pages_to_validate = [root / "index.html", *report_paths]
for index_path in pages_to_validate:
if not index_path.is_file():
continue
parser = PageParser()
parser.feed(index_path.read_text(encoding="utf-8"))
if parser.h1_count != 1:
errors.append(f"{index_path}: expected one h1, found {parser.h1_count}")
if not 2 <= parser.tabs <= 4 or parser.tabs != parser.panels:
errors.append(
f"{index_path}: expected two to four matching report tabs/panels, "
f"found {parser.tabs}/{parser.panels}"
)
if parser.tab_controls != parser.panel_ids:
errors.append(f"{index_path}: tab aria-controls values do not match panel ids")
if not {"panel-overview", "panel-method"}.issubset(parser.panel_ids):
errors.append(f"{index_path}: overview and data panels are required")
unexpected_panels = sorted(parser.panel_ids - ALLOWED_PANELS)
if unexpected_panels:
errors.append(f"{index_path}: unexpected panels: {unexpected_panels}")
if "repository-summary" not in parser.module_names:
errors.append(f"{index_path}: missing required repository-summary module")
elif parser.module_panels.get("repository-summary") != {"panel-overview"}:
errors.append(
f"{index_path}: repository-summary must appear in panel-overview"
)
duplicate_modules = sorted(
name for name, count in parser.module_counts.items() if count > 1
)
if duplicate_modules:
errors.append(f"{index_path}: duplicate module names: {duplicate_modules}")
if parser.empty_modules:
errors.append(
f"{index_path}: modules without meaningful content: "
f"{sorted(parser.empty_modules)}"
)
if parser.empty_table_count:
errors.append(
f"{index_path}: empty tables without data cells: "
f"{parser.empty_table_count}"
)
if parser.module_stack:
errors.append(f"{index_path}: unclosed data-module element")
if parser.table_stack:
errors.append(f"{index_path}: unclosed table element")
empty_panels = sorted(
panel_id for panel_id, module_count in parser.panel_modules.items() if module_count == 0
)
if empty_panels:
errors.append(f"{index_path}: panels without modules: {empty_panels}")
if parser.duplicate_ids:
errors.append(f"{index_path}: duplicate ids: {sorted(parser.duplicate_ids)}")
if parser.external_assets:
errors.append(f"{index_path}: external assets are not allowed: {parser.external_assets}")
if "{{" in index_path.read_text(encoding="utf-8"):
errors.append(f"{index_path}: unresolved template placeholder")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("site", type=Path, nargs="?", default=Path("_site"))
args = parser.parse_args()
errors = validate(args.site.resolve())
for error in errors:
print(f"ERROR: {error}")
if errors:
print(f"FAIL: {len(errors)} error(s)")
return 1
print(f"PASS: {args.site.resolve()}")
return 0
if __name__ == "__main__":
sys.exit(main())