mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
264 lines
10 KiB
Python
Executable file
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())
|