sphinx-structured-toc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sphinx_structured_toc/__init__.py +83 -0
- sphinx_structured_toc/_version.py +24 -0
- sphinx_structured_toc/directives.py +173 -0
- sphinx_structured_toc/domain-list.css +29 -0
- sphinx_structured_toc/html.py +190 -0
- sphinx_structured_toc/nodes.py +13 -0
- sphinx_structured_toc/transforms.py +175 -0
- sphinx_structured_toc-0.1.0.dist-info/METADATA +706 -0
- sphinx_structured_toc-0.1.0.dist-info/RECORD +12 -0
- sphinx_structured_toc-0.1.0.dist-info/WHEEL +5 -0
- sphinx_structured_toc-0.1.0.dist-info/licenses/LICENSE +674 -0
- sphinx_structured_toc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from sphinx.application import Sphinx
|
|
4
|
+
from sphinx.util.typing import ExtensionMetadata
|
|
5
|
+
|
|
6
|
+
from .directives import DomainDirective, SliceDirective
|
|
7
|
+
from .html import (
|
|
8
|
+
depart_domain,
|
|
9
|
+
depart_slice,
|
|
10
|
+
depart_slice_item,
|
|
11
|
+
visit_domain,
|
|
12
|
+
visit_slice,
|
|
13
|
+
visit_slice_item,
|
|
14
|
+
)
|
|
15
|
+
from .nodes import Domain, Slice, SliceItem
|
|
16
|
+
from .transforms import resolve_domains
|
|
17
|
+
|
|
18
|
+
_CSS_DIR = str(Path(__file__).parent)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("sphinx-structured-toc")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
__version__ = "dev"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def setup(app: Sphinx) -> ExtensionMetadata:
|
|
32
|
+
# register various components
|
|
33
|
+
|
|
34
|
+
# nodes
|
|
35
|
+
app.add_node(Domain, html=(visit_domain, depart_domain))
|
|
36
|
+
app.add_node(Slice, html=(visit_slice, depart_slice))
|
|
37
|
+
app.add_node(SliceItem, html=(visit_slice_item, depart_slice_item))
|
|
38
|
+
|
|
39
|
+
# directives
|
|
40
|
+
app.add_directive("domain", DomainDirective)
|
|
41
|
+
app.add_directive("slice", SliceDirective)
|
|
42
|
+
|
|
43
|
+
# transform
|
|
44
|
+
app.connect("doctree-resolved", resolve_domains)
|
|
45
|
+
|
|
46
|
+
# static asset shipping
|
|
47
|
+
app.connect("builder-inited", add_static_dir)
|
|
48
|
+
|
|
49
|
+
# reference override (decorates <a> emitted for marked slice items)
|
|
50
|
+
app.connect("builder-inited", install_reference_override)
|
|
51
|
+
|
|
52
|
+
app.add_css_file("domain-list.css")
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"version": __version__,
|
|
56
|
+
"parallel_read_safe": True,
|
|
57
|
+
"parallel_write_safe": True,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def add_static_dir(app: Sphinx) -> None:
|
|
62
|
+
"""Add the extension package directory to ``html_static_path``."""
|
|
63
|
+
|
|
64
|
+
if _CSS_DIR not in app.config.html_static_path:
|
|
65
|
+
app.config.html_static_path.append(_CSS_DIR)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def install_reference_override(app: Sphinx) -> None:
|
|
69
|
+
"""Override ``docutils.nodes.reference`` HTML visitors."""
|
|
70
|
+
|
|
71
|
+
from docutils import nodes as docutils_nodes
|
|
72
|
+
|
|
73
|
+
from .html import make_reference_visitor
|
|
74
|
+
|
|
75
|
+
visit, depart = make_reference_visitor(app)
|
|
76
|
+
app.add_node(
|
|
77
|
+
docutils_nodes.reference,
|
|
78
|
+
html=(visit, depart),
|
|
79
|
+
override=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["__version__", "setup"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g8833e90'
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from docutils import nodes
|
|
6
|
+
from docutils.parsers.rst.directives import flag
|
|
7
|
+
from sphinx.addnodes import pending_xref
|
|
8
|
+
from sphinx.util.docutils import SphinxDirective
|
|
9
|
+
from sphinx.util.typing import OptionSpec
|
|
10
|
+
|
|
11
|
+
from .nodes import Domain, Slice, SliceItem
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DomainDirective(SphinxDirective):
|
|
15
|
+
"""Outer directive: ``.. domain::`` with an optional name argument."""
|
|
16
|
+
|
|
17
|
+
optional_arguments = 1
|
|
18
|
+
final_argument_whitespace = True # allow whitespace in the name
|
|
19
|
+
has_content = True # has content that will need to be parsed
|
|
20
|
+
option_spec: ClassVar[OptionSpec] = {"suppress-warnings": flag}
|
|
21
|
+
|
|
22
|
+
def run(self) -> list[nodes.Node]:
|
|
23
|
+
"""Parse nested content and validate that only Slice children result."""
|
|
24
|
+
overridden = bool(self.arguments)
|
|
25
|
+
|
|
26
|
+
# if not supplied the name will need to be resolved later
|
|
27
|
+
name = self.arguments[0] if overridden else ""
|
|
28
|
+
|
|
29
|
+
nested = nodes.Element()
|
|
30
|
+
# parse the inner content
|
|
31
|
+
self.state.nested_parse(self.content, self.content_offset, nested)
|
|
32
|
+
|
|
33
|
+
# if slice parsing fails, forward the first error
|
|
34
|
+
messages = [c for c in nested.children if isinstance(c, nodes.system_message)]
|
|
35
|
+
if messages:
|
|
36
|
+
return [messages[0]]
|
|
37
|
+
|
|
38
|
+
slices = [child for child in nested.children if isinstance(child, Slice)]
|
|
39
|
+
non_slice = [child for child in nested.children if not isinstance(child, Slice)]
|
|
40
|
+
|
|
41
|
+
if non_slice:
|
|
42
|
+
return [self.emit_error("domain may only contain 'slice' blocks")]
|
|
43
|
+
|
|
44
|
+
if not slices:
|
|
45
|
+
return [self.emit_error("domain must contain at least one slice")]
|
|
46
|
+
|
|
47
|
+
# create the node, pass its attributes, add the slices
|
|
48
|
+
domain_node = Domain()
|
|
49
|
+
if overridden:
|
|
50
|
+
domain_node["name"] = name
|
|
51
|
+
domain_node["overridden"] = overridden
|
|
52
|
+
if "suppress-warnings" in self.options:
|
|
53
|
+
domain_node["suppress_warnings"] = True
|
|
54
|
+
domain_node.extend(slices)
|
|
55
|
+
return [domain_node]
|
|
56
|
+
|
|
57
|
+
def emit_error(self, msg: str) -> nodes.system_message:
|
|
58
|
+
return self.state_machine.reporter.error(
|
|
59
|
+
msg,
|
|
60
|
+
nodes.literal_block(self.block_text, self.block_text),
|
|
61
|
+
line=self.lineno,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SliceDirective(SphinxDirective):
|
|
66
|
+
"""Inner directive: ``.. slice:: <name>`` with item lines as content."""
|
|
67
|
+
|
|
68
|
+
required_arguments = 1
|
|
69
|
+
final_argument_whitespace = True
|
|
70
|
+
has_content = True
|
|
71
|
+
option_spec: dict = {}
|
|
72
|
+
|
|
73
|
+
def run(self) -> list[nodes.Node]:
|
|
74
|
+
"""Build a Slice node from the directive content."""
|
|
75
|
+
|
|
76
|
+
name = self.arguments[0]
|
|
77
|
+
|
|
78
|
+
slice_node = Slice()
|
|
79
|
+
slice_node["name"] = name
|
|
80
|
+
|
|
81
|
+
# adds parsed lines into the slice_node
|
|
82
|
+
for offset, line in enumerate(self.content):
|
|
83
|
+
if not line.strip():
|
|
84
|
+
continue
|
|
85
|
+
lineno = self.lineno + self.content_offset + offset
|
|
86
|
+
item = self.parse_item(line, lineno)
|
|
87
|
+
if isinstance(item, nodes.system_message):
|
|
88
|
+
return [item]
|
|
89
|
+
slice_node.append(item)
|
|
90
|
+
|
|
91
|
+
if not slice_node.children:
|
|
92
|
+
return [self.emit_error(f"slice '{name}' has no items")]
|
|
93
|
+
|
|
94
|
+
return [slice_node]
|
|
95
|
+
|
|
96
|
+
def parse_item(self, line: str, lineno: int) -> nodes.Node:
|
|
97
|
+
role_text, mark_slice, mark_domain, err = self.parse_line(line)
|
|
98
|
+
if err is not None:
|
|
99
|
+
return self.emit_error(err, lineno=lineno)
|
|
100
|
+
|
|
101
|
+
nodes_list, messages = self.state.inline_text(role_text, lineno)
|
|
102
|
+
|
|
103
|
+
if messages:
|
|
104
|
+
return self.emit_error(
|
|
105
|
+
f"slice item must be a :doc: role: {line!r}",
|
|
106
|
+
lineno=lineno,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# each line should contain one and only cross-reference and nothing else
|
|
110
|
+
xrefs = [n for n in nodes_list if isinstance(n, pending_xref)]
|
|
111
|
+
non_xrefs = [
|
|
112
|
+
n
|
|
113
|
+
for n in nodes_list
|
|
114
|
+
if not isinstance(n, pending_xref)
|
|
115
|
+
and not (isinstance(n, nodes.Text) and not str(n).strip())
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if len(xrefs) != 1 or non_xrefs:
|
|
119
|
+
return self.emit_error(
|
|
120
|
+
f"slice item must be a :doc: role: {line!r}",
|
|
121
|
+
lineno=lineno,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
xref = xrefs[0]
|
|
125
|
+
if xref.get("refdomain") != "std" or xref.get("reftype") != "doc":
|
|
126
|
+
return self.emit_error(
|
|
127
|
+
f"slice item must be a :doc: role: {line!r}",
|
|
128
|
+
lineno=lineno,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# create a SliceItem from the line
|
|
132
|
+
item = SliceItem(rawsource=line)
|
|
133
|
+
if mark_slice:
|
|
134
|
+
item["mark_slice"] = True
|
|
135
|
+
if mark_domain:
|
|
136
|
+
item["mark_domain"] = True
|
|
137
|
+
item += xref
|
|
138
|
+
return item
|
|
139
|
+
|
|
140
|
+
def parse_line(self, line: str) -> tuple[str, bool, bool, str | None]:
|
|
141
|
+
"""Parses a line into its components"""
|
|
142
|
+
|
|
143
|
+
mark_slice = mark_domain = False
|
|
144
|
+
text = line.rstrip()
|
|
145
|
+
parts = text.split()
|
|
146
|
+
|
|
147
|
+
while parts and parts[-1] in ("slice", "domain"):
|
|
148
|
+
marker = parts.pop()
|
|
149
|
+
if marker == "slice" and not mark_slice:
|
|
150
|
+
mark_slice = True
|
|
151
|
+
elif marker == "domain" and not mark_domain:
|
|
152
|
+
mark_domain = True
|
|
153
|
+
else:
|
|
154
|
+
parts.append(marker) # duplicate, hand back to error path
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if parts and parts[-1] in ("slice", "domain"):
|
|
158
|
+
return (
|
|
159
|
+
line,
|
|
160
|
+
False,
|
|
161
|
+
False,
|
|
162
|
+
f"unrecognised trailing token in slice item: {line!r}",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
role_text = " ".join(parts)
|
|
166
|
+
return role_text, mark_slice, mark_domain, None
|
|
167
|
+
|
|
168
|
+
def emit_error(self, msg: str, lineno: int | None = None) -> nodes.system_message:
|
|
169
|
+
return self.state_machine.reporter.error(
|
|
170
|
+
msg,
|
|
171
|
+
nodes.literal_block(self.block_text, self.block_text),
|
|
172
|
+
line=lineno if lineno is not None else self.lineno,
|
|
173
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.domain-list > ul > li > ul {
|
|
2
|
+
display: inline;
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.domain-list > ul > li > ul > li {
|
|
8
|
+
display: inline;
|
|
9
|
+
padding-left: 0.3em;
|
|
10
|
+
border-left: 1px solid currentColor;
|
|
11
|
+
margin-left: 0.3em;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.domain-list > ul > li > ul > li:first-child {
|
|
15
|
+
border-left: none;
|
|
16
|
+
margin-left: 0;
|
|
17
|
+
padding-left: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.domain-list-label {font-weight: bold;}
|
|
21
|
+
|
|
22
|
+
.domain-aria-target {
|
|
23
|
+
position: absolute;
|
|
24
|
+
width: 1px;
|
|
25
|
+
height: 1px;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
clip: rect(0, 0, 0, 0);
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""HTML visitors for sphinx-structured-toc nodes (Phase 8).
|
|
2
|
+
|
|
3
|
+
Renders the annotated doctree as semantic HTML:
|
|
4
|
+
|
|
5
|
+
* ``Domain`` becomes ``<nav aria-labelledby="...">`` referencing either the
|
|
6
|
+
enclosing section's heading id (when the domain name is derived from a
|
|
7
|
+
section heading) or a visually-hidden ``<span id="..." class="domain-
|
|
8
|
+
aria-target">`` holding the name (when the domain name is overridden).
|
|
9
|
+
The nameless case (no argument and no enclosing section) is a fatal
|
|
10
|
+
build error, raised in ``transforms.py`` before rendering.
|
|
11
|
+
* ``Slice`` becomes a ``<li>`` containing the slice label ``<span>`` plus
|
|
12
|
+
``": "`` and a nested ``<ul>`` of items.
|
|
13
|
+
* ``SliceItem`` becomes a ``<li>`` containing the resolved ``<a>``.
|
|
14
|
+
|
|
15
|
+
For items marked ``slice`` and/or ``domain``, the ``<a>`` element
|
|
16
|
+
receives ``id`` and ``aria-labelledby``. The ``<a>`` is emitted by
|
|
17
|
+
Sphinx's own ``visit_reference``; this extension overrides that visitor
|
|
18
|
+
(globally, via ``app.add_node(..., override=True)``) so that when the
|
|
19
|
+
reference's parent is a ``SliceItem`` with the relevant flags set, the
|
|
20
|
+
aria/id attributes are injected. All other references fall through to
|
|
21
|
+
Sphinx's default behaviour.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from docutils import nodes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def visit_domain(translator: Any, node: Any) -> None:
|
|
32
|
+
"""Open the outer container for a Domain.
|
|
33
|
+
|
|
34
|
+
Emits ``<nav aria-labelledby="{section_id}">`` when the domain name
|
|
35
|
+
is derived from a section heading (not overridden), or
|
|
36
|
+
``<nav aria-labelledby="{domain_span_id}">`` referencing a
|
|
37
|
+
visually-hidden ``<span id="{domain_span_id}" class="domain-aria-
|
|
38
|
+
target">{name}</span>`` when overridden. The nameless case (no
|
|
39
|
+
argument and no enclosing section) is a fatal build error raised in
|
|
40
|
+
``transforms.py`` and never reaches the visitor.
|
|
41
|
+
"""
|
|
42
|
+
overridden = bool(node.get("overridden", False))
|
|
43
|
+
section_id = node.get("section_id", "")
|
|
44
|
+
name = node.get("name", "")
|
|
45
|
+
|
|
46
|
+
if overridden:
|
|
47
|
+
# aria-labelledby on the nav points at a visually-hidden span
|
|
48
|
+
# inside, which is the single source of truth for the name and
|
|
49
|
+
# is also referenced by aria-labelledby on marked items.
|
|
50
|
+
span_id = node.get("domain_span_id", "")
|
|
51
|
+
translator.body.append(
|
|
52
|
+
f'<nav class="domain-list" aria-labelledby="{span_id}">'
|
|
53
|
+
)
|
|
54
|
+
# "domain-aria-target" visually hides the span while keeping it
|
|
55
|
+
# in the accessibility tree (styles in domain-list.css).
|
|
56
|
+
translator.body.append(
|
|
57
|
+
f'<span id="{span_id}" class="domain-aria-target">'
|
|
58
|
+
f'{translator.attval(name)}</span>'
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
translator.body.append(
|
|
62
|
+
f'<nav class="domain-list" aria-labelledby="{section_id}">'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# One top-level <ul> for the slices.
|
|
66
|
+
translator.body.append("<ul>")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def depart_domain(translator: Any, node: Any) -> None:
|
|
70
|
+
"""Close the outer container for a Domain."""
|
|
71
|
+
translator.body.append("</ul>")
|
|
72
|
+
translator.body.append("</nav>")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def visit_slice(translator: Any, node: Any) -> None:
|
|
76
|
+
"""Open a slice as a <li> containing the label <span> and a nested <ul>.
|
|
77
|
+
|
|
78
|
+
The colon and trailing space sit in the parent <li>, after the
|
|
79
|
+
``<span>``; not inside the span, not in CSS.
|
|
80
|
+
"""
|
|
81
|
+
label_id = node.get("label_id", "")
|
|
82
|
+
name = node.get("name", "")
|
|
83
|
+
translator.body.append("<li>")
|
|
84
|
+
translator.body.append(
|
|
85
|
+
f'<span id="{label_id}" class="domain-list-label">'
|
|
86
|
+
f"{translator.attval(name)}</span>: "
|
|
87
|
+
)
|
|
88
|
+
translator.body.append("<ul>")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def depart_slice(translator: Any, node: Any) -> None:
|
|
92
|
+
"""Close a slice's nested <ul> and the slice <li>."""
|
|
93
|
+
translator.body.append("</ul></li>")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def visit_slice_item(translator: Any, node: Any) -> None:
|
|
97
|
+
"""Open a slice item as a <li>.
|
|
98
|
+
|
|
99
|
+
The ``<a>`` itself is emitted by Sphinx's ``visit_reference`` (which
|
|
100
|
+
we override to inject ``id``/``aria-labelledby`` when the parent
|
|
101
|
+
``SliceItem`` is marked). Here we only open the wrapping ``<li>``.
|
|
102
|
+
"""
|
|
103
|
+
translator.body.append("<li>")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def depart_slice_item(translator: Any, node: Any) -> None:
|
|
107
|
+
"""Close a slice item's <li>."""
|
|
108
|
+
translator.body.append("</li>")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def make_reference_visitor(app: Any) -> tuple[Any, Any]:
|
|
112
|
+
"""Build ``visit``/``depart`` overrides for ``docutils.nodes.reference``.
|
|
113
|
+
|
|
114
|
+
Returns ``(visit_reference, depart_reference)``. The visit function
|
|
115
|
+
delegates to Sphinx's original ``visit_reference`` for any reference
|
|
116
|
+
whose parent is not a marked ``SliceItem``. For marked ``SliceItem``
|
|
117
|
+
parents it injects ``id`` and ``aria-labelledby`` onto the ``<a>``
|
|
118
|
+
before delegating, so the rest of Sphinx's reference handling
|
|
119
|
+
(classes, href, secnumber, etc.) is preserved.
|
|
120
|
+
|
|
121
|
+
The override is registered globally via
|
|
122
|
+
``app.add_node(docutils.nodes.reference, html=(...), override=True)``.
|
|
123
|
+
"""
|
|
124
|
+
from sphinx.writers.html5 import HTML5Translator
|
|
125
|
+
|
|
126
|
+
from .nodes import SliceItem
|
|
127
|
+
|
|
128
|
+
original_visit = HTML5Translator.visit_reference
|
|
129
|
+
original_depart = getattr(HTML5Translator, "depart_reference", None)
|
|
130
|
+
|
|
131
|
+
def visit_reference(self: Any, node: nodes.reference) -> None:
|
|
132
|
+
parent = node.parent
|
|
133
|
+
if isinstance(parent, SliceItem) and (
|
|
134
|
+
parent.get("mark_slice") or parent.get("mark_domain")
|
|
135
|
+
):
|
|
136
|
+
item_id = parent.get("item_id", "")
|
|
137
|
+
slice_node = parent.parent
|
|
138
|
+
domain_node = slice_node.parent if slice_node is not None else None
|
|
139
|
+
labelledby: list[str] = []
|
|
140
|
+
if item_id:
|
|
141
|
+
labelledby.append(item_id)
|
|
142
|
+
if parent.get("mark_slice") and slice_node is not None:
|
|
143
|
+
slice_label_id = slice_node.get("label_id", "")
|
|
144
|
+
if slice_label_id:
|
|
145
|
+
labelledby.append(slice_label_id)
|
|
146
|
+
if parent.get("mark_domain") and domain_node is not None:
|
|
147
|
+
if domain_node.get("overridden", False):
|
|
148
|
+
domain_id = domain_node.get("domain_span_id", "")
|
|
149
|
+
else:
|
|
150
|
+
domain_id = domain_node.get("section_id", "")
|
|
151
|
+
if domain_id:
|
|
152
|
+
labelledby.append(domain_id)
|
|
153
|
+
if item_id:
|
|
154
|
+
# ``starttag`` will emit the id from ``node['ids']``.
|
|
155
|
+
node.setdefault("ids", []).append(item_id)
|
|
156
|
+
# Delegate to Sphinx's visit_reference, then patch the
|
|
157
|
+
# opening tag it emitted to add aria-labelledby (Sphinx does
|
|
158
|
+
# not emit aria-* attributes on references by default).
|
|
159
|
+
mark = len(self.body)
|
|
160
|
+
original_visit(self, node)
|
|
161
|
+
if labelledby:
|
|
162
|
+
inject_aria_labelledby(self.body, mark, " ".join(labelledby))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
original_visit(self, node)
|
|
166
|
+
|
|
167
|
+
def depart_reference(self: Any, node: nodes.reference) -> None:
|
|
168
|
+
if original_depart is not None:
|
|
169
|
+
original_depart(self, node)
|
|
170
|
+
|
|
171
|
+
return visit_reference, depart_reference
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def inject_aria_labelledby(body: list[str], since: int, value: str) -> None:
|
|
175
|
+
"""Inject ``aria-labelledby="..."`` into the most recent ``<a ...>`` tag.
|
|
176
|
+
|
|
177
|
+
Sphinx's ``visit_reference`` appends the opening ``<a>`` tag as a
|
|
178
|
+
single string via ``self.starttag``. We locate that string in
|
|
179
|
+
``body`` (searching backwards from the end, starting at ``since``)
|
|
180
|
+
and insert the attribute before the closing ``>``.
|
|
181
|
+
"""
|
|
182
|
+
for i in range(len(body) - 1, since - 1, -1):
|
|
183
|
+
chunk = body[i]
|
|
184
|
+
if "<a " in chunk or chunk.startswith("<a"):
|
|
185
|
+
# Insert before the closing ">" of the opening tag.
|
|
186
|
+
idx = chunk.rfind(">")
|
|
187
|
+
if idx == -1:
|
|
188
|
+
continue
|
|
189
|
+
body[i] = chunk[:idx] + f' aria-labelledby="{value}"' + chunk[idx:]
|
|
190
|
+
return
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from docutils import nodes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Domain(nodes.General, nodes.Element):
|
|
5
|
+
"""Outer container for a ``.. domain::`` block."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Slice(nodes.General, nodes.Element):
|
|
9
|
+
"""A single named slice within a ``Domain``."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SliceItem(nodes.General, nodes.TextElement):
|
|
13
|
+
"""A single link entry within a ``Slice``."""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Post-transform resolution for the sphinx-structured-toc extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from docutils import nodes
|
|
6
|
+
from sphinx.util.logging import getLogger
|
|
7
|
+
|
|
8
|
+
from .nodes import Domain, Slice, SliceItem
|
|
9
|
+
|
|
10
|
+
_logger = getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def nearest_section(node: nodes.Node) -> nodes.section | None:
|
|
14
|
+
"""Return the nearest enclosing ``nodes.section`` for ``node``, or ``None``."""
|
|
15
|
+
parent = node.parent
|
|
16
|
+
while parent is not None:
|
|
17
|
+
if isinstance(parent, nodes.section):
|
|
18
|
+
return parent
|
|
19
|
+
parent = parent.parent
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def section_title(section: nodes.section) -> str:
|
|
24
|
+
"""Return the text of a section's first ``nodes.title`` child, or ``""``."""
|
|
25
|
+
title = section.next_node(nodes.title)
|
|
26
|
+
return title.astext() if title is not None else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def section_id(section: nodes.section) -> str:
|
|
30
|
+
"""Return the section's first id, or ``""`` when it has none."""
|
|
31
|
+
ids = section.get("ids", [])
|
|
32
|
+
return ids[0] if ids else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def visible_text(item: SliceItem) -> str:
|
|
36
|
+
"""Return the visible text of a SliceItem's resolved ``:doc:`` reference."""
|
|
37
|
+
|
|
38
|
+
from docutils import nodes as _nodes
|
|
39
|
+
|
|
40
|
+
for child in item.children:
|
|
41
|
+
if isinstance(child, _nodes.reference):
|
|
42
|
+
return child.astext()
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def accessible_name(item: SliceItem, slice_node: Slice, domain: Domain) -> str:
|
|
47
|
+
"""Return the accessible name of a marked item."""
|
|
48
|
+
|
|
49
|
+
parts: list[str] = [visible_text(item)]
|
|
50
|
+
if item.get("mark_slice"):
|
|
51
|
+
parts.append(slice_node["name"])
|
|
52
|
+
if item.get("mark_domain"):
|
|
53
|
+
parts.append(domain["name"])
|
|
54
|
+
return " ".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_ambiguity(doctree) -> None:
|
|
58
|
+
"""Warn at each occurrence of repeated visible text with identical accessible names."""
|
|
59
|
+
|
|
60
|
+
# Group items by visible text across the whole page, recording the
|
|
61
|
+
# accessible name and the slice/domain each came from so the
|
|
62
|
+
# same-slice special case can be detected.
|
|
63
|
+
occurrences: dict[str, list[tuple[SliceItem, Slice, Domain]]] = {}
|
|
64
|
+
for domain in doctree.findall(Domain):
|
|
65
|
+
if domain.get("suppress_warnings", False):
|
|
66
|
+
continue
|
|
67
|
+
for slice_node in domain.findall(Slice):
|
|
68
|
+
for item in slice_node.children:
|
|
69
|
+
if not isinstance(item, SliceItem):
|
|
70
|
+
continue
|
|
71
|
+
text = visible_text(item)
|
|
72
|
+
if not text:
|
|
73
|
+
continue
|
|
74
|
+
occurrences.setdefault(text, []).append((item, slice_node, domain))
|
|
75
|
+
|
|
76
|
+
for text, group in occurrences.items():
|
|
77
|
+
if len(group) < 2:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# Same-slice special case: any two items in the same slice
|
|
81
|
+
# instance with the same visible text always warn.
|
|
82
|
+
for i, (item_i, slice_i, _dom_i) in enumerate(group):
|
|
83
|
+
for j, (item_j, slice_j, _dom_j) in enumerate(group):
|
|
84
|
+
if j <= i:
|
|
85
|
+
continue
|
|
86
|
+
if slice_i is slice_j:
|
|
87
|
+
_logger.warning(
|
|
88
|
+
"ambiguous link text %r: repeated within slice %r",
|
|
89
|
+
text,
|
|
90
|
+
slice_i["name"],
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
# Cross-slice: warn unless accessible names differ.
|
|
94
|
+
name_i = accessible_name(item_i, slice_i, _dom_i)
|
|
95
|
+
name_j = accessible_name(item_j, slice_j, _dom_j)
|
|
96
|
+
if name_i == name_j:
|
|
97
|
+
_logger.warning(
|
|
98
|
+
"ambiguous link text %r: identical accessible "
|
|
99
|
+
"name %r across slices %r and %r",
|
|
100
|
+
text,
|
|
101
|
+
name_i,
|
|
102
|
+
slice_i["name"],
|
|
103
|
+
slice_j["name"],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unique_id(base: str, used: set[str]) -> str:
|
|
108
|
+
"""Return ``base`` if unused, else ``base-2``, ``base-3``, ... on clash."""
|
|
109
|
+
|
|
110
|
+
candidate = base
|
|
111
|
+
counter = 2
|
|
112
|
+
while candidate in used:
|
|
113
|
+
candidate = f"{base}-{counter}"
|
|
114
|
+
counter += 1
|
|
115
|
+
used.add(candidate)
|
|
116
|
+
return candidate
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_domains(_app, doctree, _docname) -> None:
|
|
120
|
+
"""``doctree-resolved`` handler: fill in name/section_id and assign ids."""
|
|
121
|
+
used_ids: set[str] = set()
|
|
122
|
+
|
|
123
|
+
# Seed with ids already present on the page (section heading ids,
|
|
124
|
+
# etc.) so generated ids never collide with existing ones.
|
|
125
|
+
for node in doctree.findall(nodes.Element):
|
|
126
|
+
for existing in node.get("ids", []):
|
|
127
|
+
used_ids.add(existing)
|
|
128
|
+
|
|
129
|
+
for domain in doctree.findall(Domain):
|
|
130
|
+
section = nearest_section(domain)
|
|
131
|
+
overridden = domain.get("overridden", False)
|
|
132
|
+
if section is not None:
|
|
133
|
+
domain["section_id"] = section_id(section)
|
|
134
|
+
if not overridden:
|
|
135
|
+
domain["name"] = section_title(section)
|
|
136
|
+
else:
|
|
137
|
+
domain["section_id"] = ""
|
|
138
|
+
if not overridden:
|
|
139
|
+
# No name available from anywhere: an unlabelled landmark
|
|
140
|
+
# is worse for screen reader users than no landmark at all,
|
|
141
|
+
# so fail the build rather than degrading to a nameless
|
|
142
|
+
# container.
|
|
143
|
+
_logger.error(
|
|
144
|
+
"domain directive requires an explicit name argument "
|
|
145
|
+
"or an enclosing section heading to derive one from"
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
section_slug = domain["section_id"]
|
|
150
|
+
|
|
151
|
+
if domain.get("overridden", False):
|
|
152
|
+
name_slug = nodes.make_id(domain["name"])
|
|
153
|
+
domain["domain_span_id"] = unique_id(f"{name_slug}-domain", used_ids)
|
|
154
|
+
|
|
155
|
+
for slice_node in domain.findall(Slice):
|
|
156
|
+
slice_slug = nodes.make_id(slice_node["name"])
|
|
157
|
+
if section_slug:
|
|
158
|
+
base = f"{section_slug}-{slice_slug}"
|
|
159
|
+
else:
|
|
160
|
+
base = slice_slug
|
|
161
|
+
slice_node["label_id"] = unique_id(base, used_ids)
|
|
162
|
+
|
|
163
|
+
for item in slice_node.children:
|
|
164
|
+
if not isinstance(item, SliceItem):
|
|
165
|
+
continue
|
|
166
|
+
if not (item.get("mark_slice") or item.get("mark_domain")):
|
|
167
|
+
continue
|
|
168
|
+
link_text = visible_text(item)
|
|
169
|
+
if not link_text:
|
|
170
|
+
continue
|
|
171
|
+
link_slug = nodes.make_id(link_text)
|
|
172
|
+
base = f"{slice_node['label_id']}-{link_slug}"
|
|
173
|
+
item["item_id"] = unique_id(base, used_ids)
|
|
174
|
+
|
|
175
|
+
check_ambiguity(doctree)
|