|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check that htmx.web-types.json matches the docs. |
| 3 | +
|
| 4 | +Checks that every link points to a real page, the version is current, |
| 5 | +and no attributes, elements, or events are missing or extra. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import html |
| 11 | +import json |
| 12 | +import re |
| 13 | +import sys |
| 14 | +from functools import lru_cache |
| 15 | +from pathlib import Path |
| 16 | +from urllib.parse import urlsplit |
| 17 | + |
| 18 | +ROOT = Path(__file__).resolve().parents[2] |
| 19 | +WEB_TYPES_PATH = ROOT / "src/editors/jetbrains/htmx.web-types.json" |
| 20 | +CONTENT_DIR = ROOT / "www/src/content" |
| 21 | +BASE_URL = "https://four.htmx.org" |
| 22 | + |
| 23 | + |
| 24 | +def frontmatter_title(path: Path) -> str: |
| 25 | + match = re.search(r"^title:\s*[\"']?(.+?)[\"']?\s*$", path.read_text(), re.MULTILINE) |
| 26 | + return html.unescape(match.group(1)) if match else "" |
| 27 | + |
| 28 | + |
| 29 | +def clean_segment(segment: str) -> str: |
| 30 | + return re.sub(r"^\d+-", "", segment) |
| 31 | + |
| 32 | + |
| 33 | +def route_for(path: Path) -> str: |
| 34 | + rel = path.relative_to(CONTENT_DIR).with_suffix("") |
| 35 | + parts = [clean_segment(part) for part in rel.parts] |
| 36 | + if parts == ["index"]: |
| 37 | + return "/" |
| 38 | + if parts[-1] == "index": |
| 39 | + parts = parts[:-1] |
| 40 | + return "/" + "/".join(parts) |
| 41 | + |
| 42 | + |
| 43 | +@lru_cache(maxsize=1) |
| 44 | +def docs_routes() -> dict[str, Path]: |
| 45 | + return { |
| 46 | + route_for(path): path |
| 47 | + for path in CONTENT_DIR.rglob("*") |
| 48 | + if path.suffix in {".md", ".mdx"} |
| 49 | + } |
| 50 | + |
| 51 | + |
| 52 | +def heading_slug(text: str) -> str: |
| 53 | + text = re.sub(r"`([^`]*)`", r"\1", text) |
| 54 | + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) |
| 55 | + text = re.sub(r"</?([a-zA-Z][\w:-]*)>", r"\1", text) |
| 56 | + text = re.sub(r"<[^>]+>", "", text) |
| 57 | + text = re.sub(r"[*_~]", "", text).lower() |
| 58 | + text = re.sub(r"[^a-z0-9_ -]", "", text) |
| 59 | + return re.sub(r"[-\s]+", "-", text).strip("-") |
| 60 | + |
| 61 | + |
| 62 | +@lru_cache(maxsize=None) |
| 63 | +def anchors_for(path: Path) -> set[str]: |
| 64 | + text = path.read_text() |
| 65 | + anchors = set(re.findall(r"\bid=[\"']([^\"']+)[\"']", text)) |
| 66 | + for line in text.splitlines(): |
| 67 | + match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", line) |
| 68 | + if match: |
| 69 | + anchors.add(heading_slug(match.group(1))) |
| 70 | + return anchors |
| 71 | + |
| 72 | + |
| 73 | +def read_web_types() -> dict: |
| 74 | + return json.loads(WEB_TYPES_PATH.read_text()) |
| 75 | + |
| 76 | + |
| 77 | +def doc_urls(value, path="$"): |
| 78 | + if isinstance(value, dict): |
| 79 | + for key, child in value.items(): |
| 80 | + child_path = f"{path}.{key}" |
| 81 | + if key == "doc-url": |
| 82 | + yield child_path, child |
| 83 | + else: |
| 84 | + yield from doc_urls(child, child_path) |
| 85 | + elif isinstance(value, list): |
| 86 | + for index, child in enumerate(value): |
| 87 | + yield from doc_urls(child, f"{path}[{index}]") |
| 88 | + |
| 89 | + |
| 90 | +def validate_doc_urls(data: dict) -> list[str]: |
| 91 | + errors = [] |
| 92 | + routes = docs_routes() |
| 93 | + for path, url in doc_urls(data): |
| 94 | + parsed = urlsplit(url) |
| 95 | + base = f"{parsed.scheme}://{parsed.netloc}" |
| 96 | + route = parsed.path.rstrip("/") or "/" |
| 97 | + if base != BASE_URL: |
| 98 | + errors.append(f"{path}: expected {BASE_URL}, got {base}") |
| 99 | + continue |
| 100 | + source = routes.get(route) |
| 101 | + if source is None: |
| 102 | + errors.append(f"{path}: {route} does not match a local docs route") |
| 103 | + continue |
| 104 | + if parsed.fragment and parsed.fragment not in anchors_for(source): |
| 105 | + errors.append(f"{path}: #{parsed.fragment} does not exist in {source.relative_to(ROOT)}") |
| 106 | + return errors |
| 107 | + |
| 108 | + |
| 109 | +def names(items: list[dict], prefix: str | None = None) -> set[str]: |
| 110 | + return { |
| 111 | + item["name"] |
| 112 | + for item in items |
| 113 | + if prefix is None or item.get("doc-url", "").startswith(BASE_URL + prefix) |
| 114 | + } |
| 115 | + |
| 116 | + |
| 117 | +def docs_titles(folder: str) -> set[str]: |
| 118 | + return { |
| 119 | + frontmatter_title(path) |
| 120 | + for path in (CONTENT_DIR / folder).glob("*.md") |
| 121 | + if path.name != "index.md" |
| 122 | + } |
| 123 | + |
| 124 | + |
| 125 | +def htmx_event_docs() -> set[str]: |
| 126 | + events = set() |
| 127 | + for path in (CONTENT_DIR / "reference/03-events").glob("*.md"): |
| 128 | + if path.name == "index.md": |
| 129 | + continue |
| 130 | + title = frontmatter_title(path) |
| 131 | + if title.startswith("htmx:") and "{" not in title: |
| 132 | + events.add(title.removeprefix("htmx:")) |
| 133 | + return events |
| 134 | + |
| 135 | + |
| 136 | +def compare(label: str, expected: set[str], actual: set[str]) -> list[str]: |
| 137 | + errors = [] |
| 138 | + missing = sorted(expected - actual) |
| 139 | + extra = sorted(actual - expected) |
| 140 | + if missing: |
| 141 | + errors.append(f"missing {label}: {', '.join(missing)}") |
| 142 | + if extra: |
| 143 | + errors.append(f"extra {label}: {', '.join(extra)}") |
| 144 | + return errors |
| 145 | + |
| 146 | + |
| 147 | +def check() -> int: |
| 148 | + data = read_web_types() |
| 149 | + html = data["contributions"]["html"] |
| 150 | + js = data["contributions"]["js"] |
| 151 | + errors = [] |
| 152 | + |
| 153 | + errors += validate_doc_urls(data) |
| 154 | + |
| 155 | + expected_version = json.loads((ROOT / "package.json").read_text())["version"] |
| 156 | + if data["version"] != expected_version: |
| 157 | + errors.append(f"web-types version is {data['version']}, expected {expected_version}") |
| 158 | + |
| 159 | + errors += compare( |
| 160 | + "core attributes", |
| 161 | + docs_titles("reference/01-attributes"), |
| 162 | + names(html["attributes"], "/reference/attributes/"), |
| 163 | + ) |
| 164 | + errors += compare( |
| 165 | + "custom elements", |
| 166 | + {title.strip("<>") for title in docs_titles("reference/06-tags")}, |
| 167 | + names(html["elements"]), |
| 168 | + ) |
| 169 | + errors += compare( |
| 170 | + "core htmx events", |
| 171 | + htmx_event_docs(), |
| 172 | + names(js["htmx-events"], "/reference/events/"), |
| 173 | + ) |
| 174 | + |
| 175 | + if errors: |
| 176 | + print("\n".join(errors), file=sys.stderr) |
| 177 | + return 1 |
| 178 | + |
| 179 | + print("htmx.web-types.json passed checks") |
| 180 | + return 0 |
| 181 | + |
| 182 | + |
| 183 | +if __name__ == "__main__": |
| 184 | + raise SystemExit(check()) |
0 commit comments