Skip to content

Commit e6d0bbe

Browse files
Doc: add anchormap directive for anchor redirects
1 parent 29a920e commit e6d0bbe

8 files changed

Lines changed: 167 additions & 7 deletions

File tree

Doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
# Our custom Sphinx extensions are found in Doc/Tools/extensions/
2323
extensions = [
24+
'anchor_redirects',
2425
'audit_events',
2526
'availability',
2627
'c_annotations',

Doc/tools/check-html-ids.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,29 @@ class IDGatherer(html.parser.HTMLParser):
2323
def __init__(self, ids):
2424
super().__init__()
2525
self.__ids = ids
26+
self.__in_anchor_redirects_script = False
27+
self.__anchor_redirects_chunks = []
2628

2729
def handle_starttag(self, tag, attrs):
28-
for name, value in attrs:
29-
if name == 'id':
30-
if not IGNORED_ID_RE.fullmatch(value):
31-
self.__ids.add(value)
30+
element_id = dict(attrs).get('id')
31+
if tag == 'script' and element_id == 'python-docs-anchor-redirects':
32+
self.__in_anchor_redirects_script = True
33+
self.__anchor_redirects_chunks = []
34+
elif element_id and not IGNORED_ID_RE.fullmatch(element_id):
35+
self.__ids.add(element_id)
36+
37+
def handle_data(self, data):
38+
if self.__in_anchor_redirects_script:
39+
self.__anchor_redirects_chunks.append(data)
40+
41+
def handle_endtag(self, tag):
42+
if tag != 'script' or not self.__in_anchor_redirects_script:
43+
return
44+
45+
redirects = json.loads(''.join(self.__anchor_redirects_chunks))
46+
self.__ids.update(redirects)
47+
self.__in_anchor_redirects_script = False
48+
self.__anchor_redirects_chunks = []
3249

3350

3451
def get_ids_from_file(path):
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Support for client-side redirects for removed HTML anchors."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from urllib.parse import urlsplit
7+
8+
from docutils import nodes
9+
from sphinx.util.docutils import SphinxDirective
10+
11+
if TYPE_CHECKING:
12+
from sphinx.application import Sphinx
13+
from sphinx.util.typing import ExtensionMetadata
14+
15+
16+
class AnchorMapEntryNode(nodes.Element):
17+
pass
18+
19+
20+
class AnchorMap(SphinxDirective):
21+
has_content = True
22+
23+
def run(self) -> list[nodes.Node]:
24+
self.assert_has_content()
25+
26+
entries = []
27+
messages = []
28+
29+
for index, line in enumerate(self.content):
30+
if not (line := line.strip()):
31+
continue
32+
33+
old_anchor, sep, target = line.partition(": ")
34+
old_anchor, target = old_anchor.strip(), target.strip()
35+
36+
if not sep or not old_anchor or not target:
37+
raise self.error(
38+
"anchormap entries should be like: 'old-html-fragment: target'"
39+
)
40+
41+
children, parse_messages = self.parse_inline(
42+
target,
43+
lineno=self.content_offset + index,
44+
)
45+
entry = AnchorMapEntryNode("", *children, old_anchor=old_anchor)
46+
self.set_source_info(entry)
47+
entries.append(entry)
48+
messages.extend(parse_messages)
49+
50+
if not entries:
51+
raise self.error("anchormap must contain at least one entry")
52+
53+
return entries + messages
54+
55+
56+
def process_anchor_maps(
57+
app: Sphinx,
58+
doctree: nodes.document,
59+
_docname: str,
60+
) -> None:
61+
redirects = {}
62+
63+
for entry in list(doctree.findall(AnchorMapEntryNode)):
64+
target = None
65+
references = list(entry.findall(nodes.reference))
66+
67+
if len(references) == 1:
68+
if refuri := references[0].get("refuri"):
69+
parts = urlsplit(refuri)
70+
if (
71+
not parts.scheme and not parts.netloc
72+
): # Check it's internal
73+
target = refuri
74+
elif refid := references[0].get("refid"):
75+
target = f"#{refid}"
76+
77+
if target is not None:
78+
redirects[entry["old_anchor"]] = target
79+
80+
entry.parent.remove(entry)
81+
82+
if app.builder.format == "html" and not app.builder.embedded:
83+
doctree["anchor_redirects"] = redirects
84+
85+
86+
def add_anchor_redirects_to_context(
87+
app: Sphinx,
88+
_pagename: str,
89+
_templatename: str,
90+
context: dict[str, object],
91+
doctree: nodes.document | None,
92+
) -> None:
93+
if doctree is None:
94+
return
95+
96+
if redirects := doctree.get("anchor_redirects"):
97+
context["anchor_redirects"] = redirects
98+
99+
100+
def setup(app: Sphinx) -> ExtensionMetadata:
101+
app.add_directive("anchormap", AnchorMap)
102+
app.add_node(AnchorMapEntryNode)
103+
app.connect("doctree-resolved", process_anchor_maps)
104+
app.connect("html-page-context", add_anchor_redirects_to_context)
105+
106+
return {
107+
"version": "1.0",
108+
"parallel_read_safe": True,
109+
"parallel_write_safe": True,
110+
}

Doc/tools/removed-ids.txt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ library/asyncio-task.html: terminating-a-task-group
99
deprecations/index.html: pending-removal-in-python-3-15
1010
deprecations/index.html: c-api-pending-removal-in-python-3-15
1111

12-
# Removed libmpdec
13-
using/configure.html: cmdoption-with-system-libmpdec
14-
1512
# Removed APIs
1613
library/symtable.html: symtable.Class.get_methods
1714
library/sys.html: sys._enablelegacywindowsfsencoding
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const script = document.getElementById("python-docs-anchor-redirects");
2+
const redirects = JSON.parse(script.textContent);
3+
4+
function redirectAnchor() {
5+
const anchor = window.location.hash.slice(1);
6+
if (!anchor) {
7+
return;
8+
}
9+
10+
if (document.getElementById(anchor)) {
11+
return;
12+
}
13+
14+
const target = redirects[anchor];
15+
if (!target) {
16+
return;
17+
}
18+
const targetUrl = new URL(target, window.location.href).href;
19+
if (targetUrl !== window.location.href) {
20+
window.location.replace(targetUrl);
21+
}
22+
}
23+
24+
window.addEventListener("hashchange", redirectAnchor);
25+
redirectAnchor();

Doc/tools/templates/layout.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
<link rel="canonical" href="https://docs.python.org/3/{{pagename}}.html">
3434
{% if pagename == 'whatsnew/changelog' and not embedded %}
3535
<script type="text/javascript" src="{{ pathto('_static/changelog_search.js', 1) }}"></script>{% endif %}
36+
{% if anchor_redirects %}
37+
<script id="python-docs-anchor-redirects" type="application/json">{{ anchor_redirects|tojson }}</script>
38+
<script type="module" src="{{ pathto('_static/anchor_redirects.js', 1) }}"></script>
39+
{% endif %}
3640
{% endif %}
3741

3842
{# custom CSS; used in asyncio docs! #}

Doc/using/configure.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Configure Python
44

55
.. highlight:: sh
66

7+
.. anchormap::
8+
9+
cmdoption-with-system-libmpdec: :ref:`_ <rem-bundled-libmpdec>`
10+
711

812
.. _build-requirements:
913

Doc/whatsnew/3.16.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ that may require changes to your code.
292292
Build changes
293293
=============
294294

295+
.. _rem-bundled-libmpdec:
296+
295297
* Remove the bundled copy of the libmpdec_ decimal library from the CPython source tree
296298
to simplify maintenence and updates. The :mod:`decimal` module will now
297299
unconditionally use the system's libmpdec decimal library. Also remove the

0 commit comments

Comments
 (0)