Skip to content

Commit 33cb777

Browse files
feat(nodes): add helper for creating index entries
1 parent e44a40e commit 33cb777

7 files changed

Lines changed: 156 additions & 77 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Release 9.1.1 (in development)
44
Bugs fixed
55
----------
66

7+
* #2914: Add ``sphinx.util.nodes.make_index()`` for extensions to create
8+
index entries and their cross-reference targets.
9+
710
* #14465: LaTeX: PDF build crash since LaTeX June 2026 release if tables are
811
styled with ``'colorrows'`` (which is the default).
912
Patch by Jean-François B.

doc/extdev/utils.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Utility functions
3737

3838
.. autofunction:: sphinx.util.parsing.nested_parse_to_nodes
3939

40+
.. autofunction:: sphinx.util.nodes.make_index
41+
4042

4143
Utility types
4244
-------------

sphinx/domains/index.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sphinx.util import logging
1313
from sphinx.util.docutils import ReferenceRole, SphinxDirective
1414
from sphinx.util.index_entries import split_index_msg
15-
from sphinx.util.nodes import process_index_entry
15+
from sphinx.util.nodes import make_index, process_index_entry
1616

1717
if TYPE_CHECKING:
1818
from collections.abc import Set
@@ -78,19 +78,15 @@ def run(self) -> list[Node]:
7878
if 'name' in self.options:
7979
targetname = self.options['name']
8080
targetnode = nodes.target('', '', names=[targetname])
81+
self.state.document.note_explicit_target(targetnode)
82+
targetid = targetnode['ids'][0]
83+
indexnode = addnodes.index(entries=[], inline=False)
8184
else:
82-
targetid = 'index-%s' % self.env.new_serialno('index')
83-
targetnode = nodes.target('', '', ids=[targetid])
84-
85-
self.state.document.note_explicit_target(targetnode)
86-
indexnode = addnodes.index()
87-
indexnode['entries'] = []
88-
indexnode['inline'] = False
85+
index_nodes, targetid = make_index(self.state.document, [], inline=False)
86+
indexnode, targetnode = index_nodes
8987
self.set_source_info(indexnode)
9088
for entry in arguments:
91-
indexnode['entries'].extend(
92-
process_index_entry(entry, targetnode['ids'][0])
93-
)
89+
indexnode['entries'].extend(process_index_entry(entry, targetid))
9490
return [indexnode, targetnode]
9591

9692

@@ -110,8 +106,14 @@ def run(self) -> tuple[list[Node], list[system_message]]:
110106
title = self.title
111107
entries = [('single', self.target, target_id, '', None)]
112108

113-
index = addnodes.index(entries=entries)
114-
target = nodes.target('', '', ids=[target_id])
109+
index_nodes, _targetid = make_index(
110+
self.inliner.document,
111+
[],
112+
targetid=target_id,
113+
inline=True,
114+
)
115+
index, target = index_nodes
116+
index['entries'] = entries
115117
text = nodes.Text(title)
116118
self.set_source_info(index)
117119
return [index, target, text], []

sphinx/domains/std/__init__.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from sphinx.roles import EmphasizedLiteral, XRefRole
2020
from sphinx.util import docname_join, logging, ws_re
2121
from sphinx.util.docutils import SphinxDirective
22-
from sphinx.util.nodes import clean_astext, make_id, make_refnode
22+
from sphinx.util.nodes import clean_astext, make_id, make_index, make_refnode
2323
from sphinx.util.parsing import nested_parse_to_nodes
2424

2525
if TYPE_CHECKING:
@@ -101,14 +101,15 @@ def result_nodes(
101101
if not is_ref:
102102
return [node], []
103103
varname = node['reftarget']
104-
tgtid = 'index-%s' % env.new_serialno('index')
105-
indexnode = addnodes.index()
106-
indexnode['entries'] = [
107-
('single', varname, tgtid, '', None),
108-
('single', _('environment variable; %s') % varname, tgtid, '', None),
109-
]
110-
targetnode = nodes.target('', '', ids=[tgtid])
111-
document.note_explicit_target(targetnode)
104+
index_nodes, _targetid = make_index(
105+
document,
106+
[
107+
('single', varname),
108+
('single', _('environment variable; %s') % varname),
109+
],
110+
inline=True,
111+
)
112+
indexnode, targetnode = index_nodes
112113
return [indexnode, targetnode, node], []
113114

114115

@@ -200,25 +201,35 @@ def run(self) -> list[Node]:
200201
# normalize whitespace in fullname like XRefRole does
201202
fullname = ws_re.sub(' ', self.arguments[0].strip())
202203
node_id = make_id(self.env, self.state.document, self.name, fullname)
203-
node = nodes.target('', '', ids=[node_id])
204-
self.set_source_info(node)
205-
self.state.document.note_explicit_target(node)
206-
ret: list[Node] = [node]
207204
if self.indextemplate:
208205
indexentry = self.indextemplate % (fullname,)
209206
indextype = 'single'
210207
colon = indexentry.find(':')
211208
if colon != -1:
212209
indextype = indexentry[:colon].strip()
213210
indexentry = indexentry[colon + 1 :].strip()
214-
inode = addnodes.index(entries=[(indextype, indexentry, node_id, '', None)])
215-
ret.insert(0, inode)
211+
index_nodes, _targetid = make_index(
212+
self.state.document,
213+
[(indextype, indexentry)],
214+
targetid=node_id,
215+
inline=True,
216+
)
217+
indexnode, targetnode = index_nodes
218+
self.set_source_info(targetnode)
219+
ret = [indexnode, targetnode]
220+
target_location = targetnode
221+
else:
222+
node = nodes.target('', '', ids=[node_id])
223+
self.set_source_info(node)
224+
self.state.document.note_explicit_target(node)
225+
ret = [node]
226+
target_location = node
216227
name = self.name
217228
if ':' in self.name:
218229
name = self.name.partition(':')[-1]
219230

220231
std = self.env.domains.standard_domain
221-
std.note_object(name, fullname, node_id, location=node)
232+
std.note_object(name, fullname, node_id, location=target_location)
222233

223234
return ret
224235

sphinx/roles.py

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sphinx.locale import _, __
1414
from sphinx.util import ws_re
1515
from sphinx.util.docutils import ReferenceRole, SphinxRole, _normalize_options
16+
from sphinx.util.nodes import make_index
1617

1718
if TYPE_CHECKING:
1819
from collections.abc import Sequence
@@ -198,20 +199,17 @@ class CVE(ReferenceRole):
198199
_BASE_URL: Final = 'https://www.cve.org/CVERecord?id=CVE-'
199200

200201
def run(self) -> tuple[list[Node], list[system_message]]:
201-
target_id = f'index-{self.env.new_serialno("index")}'
202-
entries = [
203-
(
204-
'single',
205-
_('Common Vulnerabilities and Exposures; CVE %s') % self.target,
206-
target_id,
207-
'',
208-
None,
209-
)
210-
]
211-
212-
index = addnodes.index(entries=entries)
213-
target = nodes.target('', '', ids=[target_id])
214-
self.inliner.document.note_explicit_target(target)
202+
index_nodes, _targetid = make_index(
203+
self.inliner.document,
204+
[
205+
(
206+
'single',
207+
_('Common Vulnerabilities and Exposures; CVE %s') % self.target,
208+
)
209+
],
210+
inline=True,
211+
)
212+
index, target = index_nodes
215213

216214
try:
217215
refuri = self.build_uri()
@@ -243,20 +241,12 @@ class CWE(ReferenceRole):
243241
_BASE_URL: Final = 'https://cwe.mitre.org/data/definitions/'
244242

245243
def run(self) -> tuple[list[Node], list[system_message]]:
246-
target_id = f'index-{self.env.new_serialno("index")}'
247-
entries = [
248-
(
249-
'single',
250-
_('Common Weakness Enumeration; CWE %s') % self.target,
251-
target_id,
252-
'',
253-
None,
254-
)
255-
]
256-
257-
index = addnodes.index(entries=entries)
258-
target = nodes.target('', '', ids=[target_id])
259-
self.inliner.document.note_explicit_target(target)
244+
index_nodes, _targetid = make_index(
245+
self.inliner.document,
246+
[('single', _('Common Weakness Enumeration; CWE %s') % self.target)],
247+
inline=True,
248+
)
249+
index, target = index_nodes
260250

261251
try:
262252
refuri = self.build_uri()
@@ -286,20 +276,12 @@ def build_uri(self) -> str:
286276

287277
class PEP(ReferenceRole):
288278
def run(self) -> tuple[list[Node], list[system_message]]:
289-
target_id = 'index-%s' % self.env.new_serialno('index')
290-
entries = [
291-
(
292-
'single',
293-
_('Python Enhancement Proposals; PEP %s') % self.target,
294-
target_id,
295-
'',
296-
None,
297-
)
298-
]
299-
300-
index = addnodes.index(entries=entries)
301-
target = nodes.target('', '', ids=[target_id])
302-
self.inliner.document.note_explicit_target(target)
279+
index_nodes, _targetid = make_index(
280+
self.inliner.document,
281+
[('single', _('Python Enhancement Proposals; PEP %s') % self.target)],
282+
inline=True,
283+
)
284+
index, target = index_nodes
303285

304286
try:
305287
refuri = self.build_uri()
@@ -331,13 +313,13 @@ def build_uri(self) -> str:
331313

332314
class RFC(ReferenceRole):
333315
def run(self) -> tuple[list[Node], list[system_message]]:
334-
target_id = 'index-%s' % self.env.new_serialno('index')
335316
formatted_target = _format_rfc_target(self.target)
336-
entries = [('single', f'RFC; {formatted_target}', target_id, '', None)]
337-
338-
index = addnodes.index(entries=entries)
339-
target = nodes.target('', '', ids=[target_id])
340-
self.inliner.document.note_explicit_target(target)
317+
index_nodes, _targetid = make_index(
318+
self.inliner.document,
319+
[('single', f'RFC; {formatted_target}')],
320+
inline=True,
321+
)
322+
index, target = index_nodes
341323

342324
try:
343325
refuri = self.build_uri()

sphinx/util/nodes.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,43 @@ def make_id(
637637
return node_id
638638

639639

640+
def make_index(
641+
document: nodes.document,
642+
entries: Iterable[tuple[str, str]],
643+
*,
644+
indexname: str = 'index',
645+
targetid: str | None = None,
646+
inline: bool = False,
647+
) -> tuple[list[Node], str]:
648+
"""Create an index node and its target node.
649+
650+
``entries`` contains pairs of index entry type and entry text. The helper
651+
expands each pair to the full index entry tuple expected by Sphinx and
652+
points all entries at the generated (or supplied) target ID.
653+
654+
:param document: the document receiving the target node
655+
:param entries: index entry type and text pairs
656+
:param indexname: serial number prefix used when generating a target ID
657+
:param targetid: explicit target ID; generated when omitted
658+
:param inline: whether the index node is inserted inline
659+
:return: the index and target nodes, followed by the target ID
660+
"""
661+
if targetid is None:
662+
env = document.settings.env
663+
targetid = f'{indexname}-{env.new_serialno(indexname)}'
664+
665+
targetnode = nodes.target('', '', ids=[targetid])
666+
document.note_explicit_target(targetnode)
667+
indexnode = addnodes.index(
668+
entries=[
669+
(entry_type, entry_text, targetid, '', None)
670+
for entry_type, entry_text in entries
671+
],
672+
inline=inline,
673+
)
674+
return [indexnode, targetnode], targetid
675+
676+
640677
def find_pending_xref_condition(
641678
node: addnodes.pending_xref, condition: str
642679
) -> Element | None:

tests/test_util/test_util_nodes.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
from docutils.parsers import rst
1111
from docutils.utils import new_document
1212

13+
from sphinx import addnodes
1314
from sphinx.transforms import ApplySourceWorkaround
1415
from sphinx.util.nodes import (
1516
NodeMatcher,
1617
apply_source_workaround,
1718
clean_astext,
1819
extract_messages,
1920
make_id,
21+
make_index,
2022
split_explicit_title,
2123
)
2224

@@ -240,6 +242,46 @@ def test_make_id_sequential(app):
240242
assert make_id(app.env, document, 'term') == 'term-1'
241243

242244

245+
@pytest.mark.sphinx('html', testroot='root')
246+
def test_make_index(app):
247+
document = create_new_document()
248+
document.settings.env = app.env
249+
250+
index_nodes, target_id = make_index(
251+
document,
252+
[('single', 'first'), ('pair', 'second')],
253+
)
254+
255+
assert target_id == 'index-0'
256+
assert isinstance(index_nodes[0], addnodes.index)
257+
assert isinstance(index_nodes[1], nodes.target)
258+
assert index_nodes[0]['entries'] == [
259+
('single', 'first', target_id, '', None),
260+
('pair', 'second', target_id, '', None),
261+
]
262+
assert index_nodes[0]['inline'] is False
263+
assert index_nodes[1]['ids'] == [target_id]
264+
assert document.ids[target_id] is index_nodes[1]
265+
266+
267+
@pytest.mark.sphinx('html', testroot='root')
268+
def test_make_index_custom_target_and_inline(app):
269+
document = create_new_document()
270+
document.settings.env = app.env
271+
272+
index_nodes, target_id = make_index(
273+
document,
274+
[('single', 'entry')],
275+
indexname='custom',
276+
targetid='custom-target',
277+
inline=True,
278+
)
279+
280+
assert target_id == 'custom-target'
281+
assert index_nodes[0]['inline'] is True
282+
assert index_nodes[0]['entries'] == [('single', 'entry', target_id, '', None)]
283+
284+
243285
@pytest.mark.parametrize(
244286
('title', 'expected'),
245287
[

0 commit comments

Comments
 (0)