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