Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/anchor-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Anchor-integrity guard

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
check:
name: build then validate in-page #fragment anchors
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
- name: Initialize submodules
run: git submodule update --init --depth=1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
cache: pip
- name: Install dependencies
run: pip install --require-hashes --no-deps -r requirements.txt
- name: Build site and validate anchors
run: scripts/check_anchors.py
212 changes: 212 additions & 0 deletions scripts/check_anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Fail on broken in-page anchor links (#fragment targets) in the built site.

Used by:
- .github/workflows/anchor-check.yml (builds the site, then scans output/)

Why: lychee (link-rot.yml) verifies that a linked *page* exists, but it does not
verify that a URL fragment resolves to a real element on that page. A link like
<a href="#the-nosec-discipline"> or <a href="tls-three-jobs.html#tls-three-jobs">
is only useful if the destination document actually contains an element whose
id (or legacy name=) is "the-nosec-discipline" / "tls-three-jobs". When a
heading is renamed the anchor silently rots: the link still 200s, it just lands
at the top of the page instead of the section. This guard catches that class.

What this catches:
- A same-page fragment (href="#id") with no matching id/name on that page.
- A cross-page fragment (href="post.html#id" or the SITEURL-absolute
href="https://rivassec.com/post.html#id") whose destination page exists in
output/ but has no matching id/name.

What this does NOT flag:
- External links to a different host (only the SITEURL host is ours).
- href="#" alone, or any link with no fragment (query-only links included).
- A cross-page fragment whose destination page is not in output/ at all: a
missing page is link rot, which lychee (link-rot.yml) already owns. We stay
in our lane and only assert fragment integrity against pages we built.

The check BUILDS the site first with the same command deploy.yml uses
(pelican content -o output -s publishconf.py) then scans output/. If a built
tree already exists (output/ or out/) it scans that. Falls back to
`python -m pelican` when the pelican console script is not on PATH.

Exits 1 with GitHub Actions ::error annotations for every broken fragment.
Exits 0 when clean.
"""
from __future__ import annotations

import posixpath
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import unquote

REPO_ROOT = Path(__file__).resolve().parent.parent

# Any href="..." / href='...' attribute value.
HREF_RE = re.compile(r"""<a\b[^>]*\bhref\s*=\s*["']([^"']*)["']""", re.IGNORECASE)
# id="..." on any element (tolerating quote style).
ID_RE = re.compile(r"""\bid\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
# Legacy <a name="..."> anchors.
NAME_RE = re.compile(r"""<a\b[^>]*\bname\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
SITEURL_RE = re.compile(r"""^\s*SITEURL\s*=\s*["']([^"']+)["']""", re.MULTILINE)


def read_siteurl() -> str:
"""Read SITEURL from publishconf.py (the config deploy builds with)."""
for name in ("publishconf.py", "pelicanconf.py"):
cfg = REPO_ROOT / name
if cfg.is_file():
m = SITEURL_RE.search(cfg.read_text(encoding="utf-8", errors="replace"))
if m:
return m.group(1).rstrip("/")
return "https://rivassec.com"


def build_site() -> None:
"""Build with the same command deploy.yml uses. Best-effort."""
cmd = ["pelican", "content", "-o", "output", "-s", "publishconf.py"]
try:
subprocess.run(cmd, cwd=REPO_ROOT, check=True)
return
except (FileNotFoundError, subprocess.CalledProcessError):
subprocess.run(
[sys.executable, "-m", "pelican", "content", "-o", "output",
"-s", "publishconf.py"],
cwd=REPO_ROOT,
check=True,
)


def find_output_dir() -> Path | None:
for name in ("output", "out"):
d = REPO_ROOT / name
if d.is_dir() and any(d.glob("**/*.html")):
return d
return None


def anchors_of(text: str) -> set[str]:
"""Every fragment target a page offers: id= on any element + <a name=>."""
return set(ID_RE.findall(text)) | set(NAME_RE.findall(text))


def resolve_target(href: str, current_rel: str, siteurl: str) -> str | None:
"""Map an href with a fragment to the output-relative page it points at.

Returns the target page's output-relative path (e.g. "post.html" or
"tag/tls.html"), "" for a same-page link, or None if the link is external
or otherwise not ours to check.
"""
# Fragment is everything after the first '#'. No '#' => nothing to check.
if "#" not in href:
return None
path, _, _frag = href.partition("#")

# Same-page link (href="#id"): destination is the current page.
if path == "":
return ""

# Strip a query string before the fragment (path?query#frag).
path = path.split("?", 1)[0]
if path == "":
return ""

scheme = path.split("://", 1)[0].lower() if "://" in path else ""
if path.startswith(siteurl + "/") or path == siteurl:
# SITEURL-absolute link: strip the host, keep the site-root path.
rest = path[len(siteurl):]
target = rest.lstrip("/")
elif scheme in ("http", "https") or path.startswith("//") or ":" in path.split("/", 1)[0]:
# A different host, protocol-relative, or a non-http scheme
# (mailto:, tel:, ...) => external, not ours to check.
return None
elif path.startswith("/"):
# Root-relative link resolves against the output root.
target = path.lstrip("/")
else:
# Document-relative link resolves against the current file's directory.
target = posixpath.normpath(
posixpath.join(posixpath.dirname(current_rel), path)
)

# A directory URL (or the site root) maps to its index.html.
if target in ("", "."):
target = "index.html"
elif target.endswith("/"):
target += "index.html"
return target


def line_of(text: str, needle: str) -> int:
idx = text.find(needle)
if idx == -1:
return 1
return text.count("\n", 0, idx) + 1


def scan(out: Path, siteurl: str) -> tuple[int, int, int]:
"""Return (pages, fragment_links_checked, violations)."""
pages = sorted(out.rglob("*.html"))
texts: dict[str, str] = {}
anchors: dict[str, set[str]] = {}
for html in pages:
rel = html.relative_to(out).as_posix()
texts[rel] = html.read_text(encoding="utf-8", errors="replace")
anchors[rel] = anchors_of(texts[rel])

checked = 0
violations = 0
for html in pages:
rel = html.relative_to(out).as_posix()
text = texts[rel]
for m in HREF_RE.finditer(text):
href = m.group(1).strip()
target = resolve_target(href, rel, siteurl)
if target is None:
continue
frag = unquote(href.partition("#")[2]).strip()
if frag == "":
continue # href="#" or href="page.html#"
if target == "":
target = rel # same-page
if target not in anchors:
# Destination page is not in output/. Missing pages are link
# rot (lychee's job); we only assert fragments on built pages.
continue
checked += 1
if frag not in anchors[target]:
lineno = line_of(text, m.group(0))
where = "same page" if target == rel else target
print(
f"::error file={out.name}/{rel},line={lineno}::"
f"broken anchor: href=\"{href}\" -> #{frag} "
f"has no matching id/name in {where}"
)
violations += 1
return len(pages), checked, violations


def main() -> int:
siteurl = read_siteurl()

out = find_output_dir()
if out is None:
build_site()
out = find_output_dir()
if out is None:
print("::error::no built site found and the build produced no output/")
return 1

pages, checked, violations = scan(out, siteurl)
print(
f"anchor guard: {pages} page(s), {checked} fragment link(s) checked, "
f"{violations} broken",
file=sys.stderr,
)
return 1 if violations else 0


if __name__ == "__main__":
sys.exit(main())
123 changes: 123 additions & 0 deletions tests/test_check_anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Tests for scripts/check_anchors.py (broken #fragment anchor guard).

The guard is a merge gate; a guard that silently breaks fails OPEN (a green
check that verified nothing). These tests build tiny fixture site trees and
assert the checker catches a broken same-page fragment and a broken cross-page
fragment, ignores external/empty/query-only links, and stays quiet on a clean
tree.

Run with:

python3 -m unittest discover tests
"""
from __future__ import annotations

import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = REPO_ROOT / "scripts"


def load_script(filename: str, modname: str):
"""Import a scripts/*.py file as a module (they have no package)."""
spec = importlib.util.spec_from_file_location(modname, SCRIPTS / filename)
mod = importlib.util.module_from_spec(spec)
sys.modules[modname] = mod
spec.loader.exec_module(mod)
return mod


class TestCheckAnchors(unittest.TestCase):
"""check_anchors.py: catch broken in-page #fragment targets."""

SITEURL = "https://rivassec.com" # the script's default when no conf exists

def setUp(self):
self.mod = load_script("check_anchors.py", "check_anchors_under_test")
self.tmp = Path(tempfile.mkdtemp())
self.out = self.tmp / "output"
self.out.mkdir(parents=True)
self.mod.REPO_ROOT = self.tmp

def page(self, rel: str, body: str):
path = self.out / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
f"<html><head></head><body>{body}</body></html>",
encoding="utf-8",
)

def test_valid_same_page_fragment_passes(self):
self.page(
"post.html",
'<a href="#intro">jump</a><h2 id="intro">Intro</h2>',
)
self.assertEqual(self.mod.main(), 0)

def test_broken_same_page_fragment_fails(self):
self.page(
"post.html",
'<a href="#missing">jump</a><h2 id="intro">Intro</h2>',
)
self.assertEqual(self.mod.main(), 1)

def test_valid_cross_page_fragment_passes(self):
self.page("a.html", '<a href="b.html#target">to b</a>')
self.page("b.html", '<h2 id="target">Target</h2>')
self.assertEqual(self.mod.main(), 0)

def test_broken_cross_page_fragment_fails(self):
self.page("a.html", '<a href="b.html#nope">to b</a>')
self.page("b.html", '<h2 id="target">Target</h2>')
self.assertEqual(self.mod.main(), 1)

def test_broken_siteurl_absolute_fragment_fails(self):
self.page("a.html", f'<a href="{self.SITEURL}/b.html#nope">to b</a>')
self.page("b.html", '<h2 id="target">Target</h2>')
self.assertEqual(self.mod.main(), 1)

def test_valid_siteurl_absolute_fragment_passes(self):
self.page("a.html", f'<a href="{self.SITEURL}/b.html#target">to b</a>')
self.page("b.html", '<h2 id="target">Target</h2>')
self.assertEqual(self.mod.main(), 0)

def test_legacy_name_anchor_resolves(self):
self.page(
"post.html",
'<a href="#old">jump</a><a name="old"></a>',
)
self.assertEqual(self.mod.main(), 0)

def test_external_host_fragment_ignored(self):
# Different host: not ours to check, even with a bogus fragment.
self.page("a.html", '<a href="https://example.com/x.html#nope">ext</a>')
self.assertEqual(self.mod.main(), 0)

def test_bare_hash_and_query_only_ignored(self):
# href="#" alone and a query-only link have no fragment to resolve.
self.page(
"a.html",
'<a href="#">top</a><a href="b.html?x=1">q</a>',
)
self.page("b.html", "<p>b</p>")
self.assertEqual(self.mod.main(), 0)

def test_missing_target_page_ignored(self):
# A fragment to a page not in output/ is link rot (lychee's job),
# not an anchor violation.
self.page("a.html", '<a href="gone.html#whatever">to gone</a>')
self.assertEqual(self.mod.main(), 0)

def test_query_before_fragment_resolves(self):
self.page("a.html", '<a href="b.html?v=2#target">to b</a>')
self.page("b.html", '<h2 id="target">Target</h2>')
self.assertEqual(self.mod.main(), 0)


if __name__ == "__main__":
unittest.main()
Loading