Source code for repomatic.site_anchors

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Same-page fragment links, checked against the anchors the build produced.

A literal `](#fragment)` is the one cross-reference nothing resolves. A
`{ref}` or `{doc}` role goes through Sphinx, which reports a missing target
under `nitpicky`; a raw fragment is copied into the HTML untouched, so a slug
that never existed ships as a link that looks fine and lands nowhere. The
build stays green because it was never asked a question.

```{caution}
A Markdown link checker cannot stand in for this, because it has to guess the
slug. Measured against `lychee` 0.24.2 on the heading `## The pages.dev
hostname`: myst-parser builds `the-pages-dev-hostname`, lychee's GitHub-style
slugger wants `the-pagesdev-hostname`, and each reports the other as broken.
That disagreement is why this repository excludes intra-docs fragments from
`lychee` altogether, which left the class with no coverage at all until a
`#the-pagesdev-hostname` link shipped against a `the-pages-dev-hostname`
anchor.
```

The built page is the only authority, so that is what this reads. Fragments
come from the Markdown *source* rather than from the rendered HTML, which is
what keeps the check to what an author actually wrote: a theme's own footnote
backrefs and header permalinks never enter, so there is no denylist to keep.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

ANCHOR_ATTRIBUTES = frozenset({"id", "name"})
"""HTML attributes a browser will scroll a fragment to."""

DEFAULT_BUILD_DIR = Path("./docs/_build")
"""Where the Sphinx builders in this project's workflows write the site."""

DEFAULT_DOCS_DIR = Path("./docs")
"""Conventional root of a Sphinx source tree."""

FENCE_RE = re.compile(r"^\s*(?:`{3,}|~{3,})")
"""Opening or closing line of a fenced code block."""

FRAGMENT_LINK_RE = re.compile(r"]\(#(?P<fragment>[^)\s]+)")
"""An authored same-page link, `](#fragment)`.

Anchored on the `](#` sequence, which is what makes it same-page: a link to
another document carries a path before its `#` and is Sphinx's problem, not
this one.
"""

INLINE_CODE_RE = re.compile(r"(?P<ticks>`+)(?:.|\n)*?(?P=ticks)")
"""An inline code span, of any backtick width."""

MARKDOWN_SUFFIX = ".md"
"""Extension of the sources scanned for authored links."""


[docs] @dataclass(frozen=True) class MissingAnchor: """One authored fragment with no anchor to land on.""" source: Path """Markdown file that wrote the link.""" fragment: str """The fragment as authored, without its `#`.""" page: Path """Built page the fragment was looked for in.""" @property def message(self) -> str: """The finding as a single reportable line.""" return f"{self.source}: #{self.fragment} matches no anchor in {self.page}."
[docs] @dataclass class AnchorReport: """What one sweep over a docs tree found.""" missing: list[MissingAnchor] = field(default_factory=list) """Every authored fragment that resolves to nothing.""" unbuilt: list[Path] = field(default_factory=list) """Sources with no built page, so with nothing to check against. A page left out of every toctree, or a fragment file meant only to be included by another, lands here. Reported rather than failed: the build is what decides which sources become pages, and it is not this check's place to second-guess it. """ checked: int = 0 """How many authored fragments were resolved against a built page."""
class _AnchorCollector(HTMLParser): """Collect every fragment target a built page offers.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.anchors: set[str] = set() def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Record the `id` and `name` of every element.""" for name, value in attrs: if name in ANCHOR_ATTRIBUTES and value: self.anchors.add(value)
[docs] def page_anchors(html: str) -> set[str]: """Every fragment a built page can be scrolled to. :param html: Full source of one built page. :return: The `id` and `name` values it carries. """ collector = _AnchorCollector() collector.feed(html) return collector.anchors
[docs] def strip_code(text: str) -> str: """Blank out every code span and fenced block of a Markdown source. A fence showing `](#example)` documents a link rather than making one, and checking it would fail a page for its own example. Lines are replaced rather than deleted so a reported line number still points at the source. :param text: Markdown source. :return: The same text with code content emptied. """ lines = [] in_fence = False for line in text.splitlines(): if FENCE_RE.match(line): in_fence = not in_fence lines.append("") continue lines.append("" if in_fence else line) return INLINE_CODE_RE.sub("", "\n".join(lines))
[docs] def authored_fragments(text: str) -> list[str]: """Every same-page fragment a Markdown source links to. :param text: Markdown source. :return: Fragments without their `#`, in source order, duplicates kept out. """ stripped = strip_code(text) found = FRAGMENT_LINK_RE.findall(stripped) return list(dict.fromkeys(found))
[docs] def built_page(source: Path, docs_dir: Path, build_dir: Path) -> Path | None: """Locate the page a Markdown source was rendered into. Both Sphinx HTML builders are covered by trying each layout in turn: `html` writes `{name}.html`, `dirhtml` writes `{name}/index.html`. Probing rather than reading `[tool.repomatic] sphinx.builder` keeps the check honest about the tree in front of it, and correct for a caller pointed at a directory some other builder wrote. :param source: The Markdown file. :param docs_dir: Root the source tree is relative to. :param build_dir: Root of the rendered site. :return: The built page, or `None` when the source produced none. """ relative = source.relative_to(docs_dir) # Not `with_suffix`, which would read `repomatic.data.md` as stem # `repomatic` plus suffix `.data` and look for `repomatic.html`. bare = relative.parent / relative.name.removesuffix(MARKDOWN_SUFFIX) for candidate in (build_dir / f"{bare}.html", build_dir / bare / "index.html"): if candidate.is_file(): return candidate return None
[docs] def markdown_sources(docs_dir: Path, build_dir: Path) -> Iterator[Path]: """Every authored Markdown source under a docs tree. Skips the rendered site, which commonly sits inside the source tree, and every underscore-prefixed directory, Sphinx's own convention for the static and template folders that hold no authored prose. :param docs_dir: Root of the documentation sources. :param build_dir: Root of the rendered site, excluded when nested. :return: The sources, in path order. """ resolved_build = build_dir.resolve() for path in sorted(docs_dir.rglob(f"*{MARKDOWN_SUFFIX}")): if resolved_build in path.resolve().parents: continue if any(part.startswith("_") for part in path.relative_to(docs_dir).parts[:-1]): continue yield path
[docs] def check_anchors(docs_dir: Path, build_dir: Path) -> AnchorReport: """Resolve every authored fragment against the page it was built into. :param docs_dir: Root of the documentation sources. :param build_dir: Root of the rendered site. :return: What the sweep found. """ report = AnchorReport() for source in markdown_sources(docs_dir, build_dir): fragments = authored_fragments(source.read_text(encoding="UTF-8")) if not fragments: continue page = built_page(source, docs_dir, build_dir) if page is None: report.unbuilt.append(source) continue anchors = page_anchors(page.read_text(encoding="UTF-8")) for fragment in fragments: report.checked += 1 if fragment not in anchors: report.missing.append(MissingAnchor(source, fragment, page)) return report