From e6d0bbe4a895718d87e6a7b2abbae6c67ef74e46 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 8 Jun 2026 21:44:37 +0100 Subject: [PATCH] Doc: add `anchormap` directive for anchor redirects --- Doc/conf.py | 1 + Doc/tools/check-html-ids.py | 25 +++++- Doc/tools/extensions/anchor_redirects.py | 110 +++++++++++++++++++++++ Doc/tools/removed-ids.txt | 3 - Doc/tools/static/anchor_redirects.js | 25 ++++++ Doc/tools/templates/layout.html | 4 + Doc/using/configure.rst | 4 + Doc/whatsnew/3.16.rst | 2 + 8 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 Doc/tools/extensions/anchor_redirects.py create mode 100644 Doc/tools/static/anchor_redirects.js diff --git a/Doc/conf.py b/Doc/conf.py index 9b103a594b235cf..53d9b10e3cfec84 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -21,6 +21,7 @@ # Our custom Sphinx extensions are found in Doc/Tools/extensions/ extensions = [ + 'anchor_redirects', 'audit_events', 'availability', 'c_annotations', diff --git a/Doc/tools/check-html-ids.py b/Doc/tools/check-html-ids.py index 7d86c6cc3264ad5..a8334441830f0cc 100644 --- a/Doc/tools/check-html-ids.py +++ b/Doc/tools/check-html-ids.py @@ -23,12 +23,29 @@ class IDGatherer(html.parser.HTMLParser): def __init__(self, ids): super().__init__() self.__ids = ids + self.__in_anchor_redirects_script = False + self.__anchor_redirects_chunks = [] def handle_starttag(self, tag, attrs): - for name, value in attrs: - if name == 'id': - if not IGNORED_ID_RE.fullmatch(value): - self.__ids.add(value) + element_id = dict(attrs).get('id') + if tag == 'script' and element_id == 'python-docs-anchor-redirects': + self.__in_anchor_redirects_script = True + self.__anchor_redirects_chunks = [] + elif element_id and not IGNORED_ID_RE.fullmatch(element_id): + self.__ids.add(element_id) + + def handle_data(self, data): + if self.__in_anchor_redirects_script: + self.__anchor_redirects_chunks.append(data) + + def handle_endtag(self, tag): + if tag != 'script' or not self.__in_anchor_redirects_script: + return + + redirects = json.loads(''.join(self.__anchor_redirects_chunks)) + self.__ids.update(redirects) + self.__in_anchor_redirects_script = False + self.__anchor_redirects_chunks = [] def get_ids_from_file(path): diff --git a/Doc/tools/extensions/anchor_redirects.py b/Doc/tools/extensions/anchor_redirects.py new file mode 100644 index 000000000000000..12b2041d120febe --- /dev/null +++ b/Doc/tools/extensions/anchor_redirects.py @@ -0,0 +1,110 @@ +"""Support for client-side redirects for removed HTML anchors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +from docutils import nodes +from sphinx.util.docutils import SphinxDirective + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + + +class AnchorMapEntryNode(nodes.Element): + pass + + +class AnchorMap(SphinxDirective): + has_content = True + + def run(self) -> list[nodes.Node]: + self.assert_has_content() + + entries = [] + messages = [] + + for index, line in enumerate(self.content): + if not (line := line.strip()): + continue + + old_anchor, sep, target = line.partition(": ") + old_anchor, target = old_anchor.strip(), target.strip() + + if not sep or not old_anchor or not target: + raise self.error( + "anchormap entries should be like: 'old-html-fragment: target'" + ) + + children, parse_messages = self.parse_inline( + target, + lineno=self.content_offset + index, + ) + entry = AnchorMapEntryNode("", *children, old_anchor=old_anchor) + self.set_source_info(entry) + entries.append(entry) + messages.extend(parse_messages) + + if not entries: + raise self.error("anchormap must contain at least one entry") + + return entries + messages + + +def process_anchor_maps( + app: Sphinx, + doctree: nodes.document, + _docname: str, +) -> None: + redirects = {} + + for entry in list(doctree.findall(AnchorMapEntryNode)): + target = None + references = list(entry.findall(nodes.reference)) + + if len(references) == 1: + if refuri := references[0].get("refuri"): + parts = urlsplit(refuri) + if ( + not parts.scheme and not parts.netloc + ): # Check it's internal + target = refuri + elif refid := references[0].get("refid"): + target = f"#{refid}" + + if target is not None: + redirects[entry["old_anchor"]] = target + + entry.parent.remove(entry) + + if app.builder.format == "html" and not app.builder.embedded: + doctree["anchor_redirects"] = redirects + + +def add_anchor_redirects_to_context( + app: Sphinx, + _pagename: str, + _templatename: str, + context: dict[str, object], + doctree: nodes.document | None, +) -> None: + if doctree is None: + return + + if redirects := doctree.get("anchor_redirects"): + context["anchor_redirects"] = redirects + + +def setup(app: Sphinx) -> ExtensionMetadata: + app.add_directive("anchormap", AnchorMap) + app.add_node(AnchorMapEntryNode) + app.connect("doctree-resolved", process_anchor_maps) + app.connect("html-page-context", add_anchor_redirects_to_context) + + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/Doc/tools/removed-ids.txt b/Doc/tools/removed-ids.txt index 05fc89d9fe2d278..610ad5bc57cddec 100644 --- a/Doc/tools/removed-ids.txt +++ b/Doc/tools/removed-ids.txt @@ -9,9 +9,6 @@ library/asyncio-task.html: terminating-a-task-group deprecations/index.html: pending-removal-in-python-3-15 deprecations/index.html: c-api-pending-removal-in-python-3-15 -# Removed libmpdec -using/configure.html: cmdoption-with-system-libmpdec - # Removed APIs library/symtable.html: symtable.Class.get_methods library/sys.html: sys._enablelegacywindowsfsencoding diff --git a/Doc/tools/static/anchor_redirects.js b/Doc/tools/static/anchor_redirects.js new file mode 100644 index 000000000000000..de7df723507ee62 --- /dev/null +++ b/Doc/tools/static/anchor_redirects.js @@ -0,0 +1,25 @@ +const script = document.getElementById("python-docs-anchor-redirects"); +const redirects = JSON.parse(script.textContent); + +function redirectAnchor() { + const anchor = window.location.hash.slice(1); + if (!anchor) { + return; + } + + if (document.getElementById(anchor)) { + return; + } + + const target = redirects[anchor]; + if (!target) { + return; + } + const targetUrl = new URL(target, window.location.href).href; + if (targetUrl !== window.location.href) { + window.location.replace(targetUrl); + } +} + +window.addEventListener("hashchange", redirectAnchor); +redirectAnchor(); diff --git a/Doc/tools/templates/layout.html b/Doc/tools/templates/layout.html index 54c6eb9b5ef7e47..fb85d7569bd10e8 100644 --- a/Doc/tools/templates/layout.html +++ b/Doc/tools/templates/layout.html @@ -33,6 +33,10 @@ {% if pagename == 'whatsnew/changelog' and not embedded %} {% endif %} + {% if anchor_redirects %} + + + {% endif %} {% endif %} {# custom CSS; used in asyncio docs! #} diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 22a10db976c4fc6..6f3433355ab112c 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -4,6 +4,10 @@ Configure Python .. highlight:: sh +.. anchormap:: + + cmdoption-with-system-libmpdec: :ref:`_ ` + .. _build-requirements: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 6c35124ba7b4865..bc8508dda48f5f2 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -292,6 +292,8 @@ that may require changes to your code. Build changes ============= + .. _rem-bundled-libmpdec: + * Remove the bundled copy of the libmpdec_ decimal library from the CPython source tree to simplify maintenence and updates. The :mod:`decimal` module will now unconditionally use the system's libmpdec decimal library. Also remove the