Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/docs-link-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: README documentation links

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
check-readme-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install documentation dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r docs/requirements.txt
- name: Test the link checker
run: python -m unittest discover -s docs/tests
- name: Build documentation
run: sphinx-build docs/source docs/_build/html
- name: Check README documentation links
run: python docs/check_readme_links.py
88 changes: 88 additions & 0 deletions docs/check_readme_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Validate links from README.md to the rendered Quark Engine documentation."""

from __future__ import annotations

import argparse
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlparse


DOCS_HOST = "quark-engine.readthedocs.io"
DOCS_PREFIX = "/en/latest/"
URL_PATTERN = re.compile(r"https?://[^\s)>]+")


class AnchorParser(HTMLParser):
"""Collect HTML ids without depending on an external parser."""

def __init__(self) -> None:
super().__init__()
self.ids: set[str] = set()

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
for name, value in attrs:
if name == "id" and value:
self.ids.add(value)


def extract_doc_links(readme: Path) -> list[tuple[str, str | None]]:
"""Return local documentation page paths and optional anchors from README."""
links: list[tuple[str, str | None]] = []

for match in URL_PATTERN.finditer(readme.read_text(encoding="utf-8")):
parsed = urlparse(match.group(0))
if parsed.netloc != DOCS_HOST or not parsed.path.startswith(DOCS_PREFIX):
continue

page = unquote(parsed.path.removeprefix(DOCS_PREFIX)) or "index.html"
anchor = unquote(parsed.fragment) or None
links.append((page, anchor))

return links


def validate_links(readme: Path, html_dir: Path) -> list[str]:
"""Return one descriptive error for every missing documentation target."""
errors: list[str] = []

for page, anchor in extract_doc_links(readme):
target = html_dir / page
if not target.is_file():
errors.append(f"Missing documentation page: {page}")
continue

if anchor:
parser = AnchorParser()
parser.feed(target.read_text(encoding="utf-8"))
if anchor not in parser.ids:
errors.append(f"Missing anchor '{anchor}' in {page}")

return errors


def main() -> int:
parser = argparse.ArgumentParser(
description="Check README links against built documentation."
)
parser.add_argument("--readme", type=Path, default=Path("README.md"))
parser.add_argument(
"--html-dir", type=Path, default=Path("docs/_build/html")
)
args = parser.parse_args()

errors = validate_links(args.readme, args.html_dir)
if errors:
print("README documentation link check failed:", file=sys.stderr)
print(*errors, sep="\n", file=sys.stderr)
return 1

print(f"Validated {len(extract_doc_links(args.readme))} README documentation links.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
72 changes: 72 additions & 0 deletions docs/tests/test_check_readme_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path


SCRIPT_PATH = Path(__file__).parents[1] / "check_readme_links.py"
SPEC = importlib.util.spec_from_file_location("check_readme_links", SCRIPT_PATH)
check_readme_links = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(check_readme_links)


class ReadmeLinkCheckTests(unittest.TestCase):
def write_readme(self, directory: Path, links: list[str]) -> Path:
readme = directory / "README.md"
readme.write_text("\n".join(links), encoding="utf-8")
return readme

def test_extracts_only_quark_documentation_links(self):
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
readme = self.write_readme(
directory,
[
"https://quark-engine.readthedocs.io/en/latest/guide.html#intro",
"https://example.com/en/latest/guide.html#ignored",
],
)

self.assertEqual(
check_readme_links.extract_doc_links(readme),
[("guide.html", "intro")],
)

def test_accepts_existing_page_and_anchor(self):
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
html_dir = directory / "html"
html_dir.mkdir()
(html_dir / "guide.html").write_text(
'<section id="intro">Guide</section>', encoding="utf-8"
)
readme = self.write_readme(
directory,
["https://quark-engine.readthedocs.io/en/latest/guide.html#intro"],
)

self.assertEqual(check_readme_links.validate_links(readme, html_dir), [])

def test_reports_missing_pages_and_anchors(self):
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
html_dir = directory / "html"
html_dir.mkdir()
(html_dir / "guide.html").write_text(
'<section id="intro">Guide</section>', encoding="utf-8"
)
readme = self.write_readme(
directory,
[
"https://quark-engine.readthedocs.io/en/latest/missing.html",
"https://quark-engine.readthedocs.io/en/latest/guide.html#missing",
],
)

self.assertEqual(
check_readme_links.validate_links(readme, html_dir),
[
"Missing documentation page: missing.html",
"Missing anchor 'missing' in guide.html",
],
)
Loading