Skip to content

Commit 97d2c5d

Browse files
latosha-maltbaAA-Turner
andauthored
Add the :no-typesetting: option for only creating targets (#10478)
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
1 parent 05a14ff commit 97d2c5d

13 files changed

Lines changed: 346 additions & 19 deletions

File tree

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ Features added
2929
* #6319: viewcode: Add :confval:`viewcode_line_numbers` to control
3030
whether line numbers are added to rendered source code.
3131
Patch by Ben Krikler.
32+
* #9662: Add the ``:no-typesetting:`` option to suppress textual output
33+
and only create a linkable anchor.
34+
Patch by Latosha Maltba.
3235

3336
Bugs fixed
3437
----------

doc/usage/restructuredtext/domains.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ can give the directive option flag ``:nocontentsentry:``.
5454
If you want to typeset an object description, without even making it available
5555
for cross-referencing, you can give the directive option flag ``:noindex:``
5656
(which implies ``:noindexentry:``).
57+
If you do not want to typeset anything, you can give the directive option flag
58+
``:no-typesetting:``. This can for example be used to create only a target and
59+
index entry for later reference.
5760
Though, note that not every directive in every domain may support these
5861
options.
5962

@@ -65,6 +68,10 @@ options.
6568
The directive option ``:nocontentsentry:`` in the Python, C, C++, Javascript,
6669
and reStructuredText domains.
6770

71+
.. versionadded:: 7.2
72+
The directive option ``no-typesetting`` in the Python, C, C++, Javascript,
73+
and reStructuredText domains.
74+
6875
An example using a Python domain directive::
6976

7077
.. py:function:: spam(eggs)
@@ -91,6 +98,23 @@ you could say ::
9198
As you can see, both directive and role names contain the domain name and the
9299
directive name.
93100

101+
The directive option ``:no-typesetting:`` can be used to create a target
102+
(and index entry) which can later be referenced
103+
by the roles provided by the domain.
104+
This is particularly useful for literate programming:
105+
106+
.. code-block:: rst
107+
108+
.. py:function:: spam(eggs)
109+
:no-typesetting:
110+
111+
.. code::
112+
113+
def spam(eggs):
114+
pass
115+
116+
The function :py:func:`spam` does nothing.
117+
94118
.. rubric:: Default Domain
95119

96120
For documentation describing objects from solely one domain, authors will not

sphinx/directives/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class ObjectDescription(SphinxDirective, Generic[ObjDescT]):
5757
'noindex': directives.flag,
5858
'noindexentry': directives.flag,
5959
'nocontentsentry': directives.flag,
60+
'no-typesetting': directives.flag,
6061
}
6162

6263
# types of doc fields that this directive handles, see sphinx.util.docfields
@@ -218,6 +219,7 @@ def run(self) -> list[Node]:
218219
node['noindex'] = noindex = ('noindex' in self.options)
219220
node['noindexentry'] = ('noindexentry' in self.options)
220221
node['nocontentsentry'] = ('nocontentsentry' in self.options)
222+
node['no-typesetting'] = ('no-typesetting' in self.options)
221223
if self.domain:
222224
node['classes'].append(self.domain)
223225
node['classes'].append(node['objtype'])
@@ -270,6 +272,19 @@ def run(self) -> list[Node]:
270272
DocFieldTransformer(self).transform_all(contentnode)
271273
self.env.temp_data['object'] = None
272274
self.after_content()
275+
276+
if node['no-typesetting']:
277+
# Attempt to return the index node, and a new target node
278+
# containing all the ids of this node and its children.
279+
# If ``:noindex:`` is set, or there are no ids on the node
280+
# or any of its children, then just return the index node,
281+
# as Docutils expects a target node to have at least one id.
282+
if node_ids := [node_id for el in node.findall(nodes.Element)
283+
for node_id in el.get('ids', ())]:
284+
target_node = nodes.target(ids=node_ids)
285+
self.set_source_info(target_node)
286+
return [self.indexnode, target_node]
287+
return [self.indexnode]
273288
return [self.indexnode, node]
274289

275290

sphinx/domains/c.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3166,6 +3166,7 @@ class CObject(ObjectDescription[ASTDeclaration]):
31663166
option_spec: OptionSpec = {
31673167
'noindexentry': directives.flag,
31683168
'nocontentsentry': directives.flag,
3169+
'no-typesetting': directives.flag,
31693170
'single-line-parameter-list': directives.flag,
31703171
}
31713172

sphinx/domains/cpp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7214,6 +7214,7 @@ class CPPObject(ObjectDescription[ASTDeclaration]):
72147214
option_spec: OptionSpec = {
72157215
'noindexentry': directives.flag,
72167216
'nocontentsentry': directives.flag,
7217+
'no-typesetting': directives.flag,
72177218
'tparam-line-spec': directives.flag,
72187219
'single-line-parameter-list': directives.flag,
72197220
}

sphinx/domains/javascript.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class JSObject(ObjectDescription[tuple[str, str]]):
4646
'noindex': directives.flag,
4747
'noindexentry': directives.flag,
4848
'nocontentsentry': directives.flag,
49+
'no-typesetting': directives.flag,
4950
'single-line-parameter-list': directives.flag,
5051
}
5152

@@ -293,6 +294,7 @@ class JSModule(SphinxDirective):
293294
option_spec: OptionSpec = {
294295
'noindex': directives.flag,
295296
'nocontentsentry': directives.flag,
297+
'no-typesetting': directives.flag,
296298
}
297299

298300
def run(self) -> list[Node]:
@@ -316,12 +318,13 @@ def run(self) -> list[Node]:
316318
domain.note_object(mod_name, 'module', node_id,
317319
location=(self.env.docname, self.lineno))
318320

319-
target = nodes.target('', '', ids=[node_id], ismod=True)
320-
self.state.document.note_explicit_target(target)
321-
ret.append(target)
321+
# The node order is: index node first, then target node
322322
indextext = _('%s (module)') % mod_name
323323
inode = addnodes.index(entries=[('single', indextext, node_id, '', None)])
324324
ret.append(inode)
325+
target = nodes.target('', '', ids=[node_id], ismod=True)
326+
self.state.document.note_explicit_target(target)
327+
ret.append(target)
325328
ret.extend(content_node.children)
326329
return ret
327330

sphinx/domains/python.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ class PyObject(ObjectDescription[tuple[str, str]]):
662662
'noindex': directives.flag,
663663
'noindexentry': directives.flag,
664664
'nocontentsentry': directives.flag,
665+
'no-typesetting': directives.flag,
665666
'single-line-parameter-list': directives.flag,
666667
'single-line-type-parameter-list': directives.flag,
667668
'module': directives.unchanged,
@@ -1262,6 +1263,7 @@ class PyModule(SphinxDirective):
12621263
'synopsis': lambda x: x,
12631264
'noindex': directives.flag,
12641265
'nocontentsentry': directives.flag,
1266+
'no-typesetting': directives.flag,
12651267
'deprecated': directives.flag,
12661268
}
12671269

@@ -1294,10 +1296,11 @@ def run(self) -> list[Node]:
12941296

12951297
# the platform and synopsis aren't printed; in fact, they are only
12961298
# used in the modindex currently
1297-
ret.append(target)
12981299
indextext = f'module; {modname}'
12991300
inode = addnodes.index(entries=[('pair', indextext, node_id, '', None)])
1301+
# The node order is: index node first, then target node.
13001302
ret.append(inode)
1303+
ret.append(target)
13011304
ret.extend(content_node.children)
13021305
return ret
13031306

sphinx/domains/rst.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ReSTMarkup(ObjectDescription[str]):
3737
'noindex': directives.flag,
3838
'noindexentry': directives.flag,
3939
'nocontentsentry': directives.flag,
40+
'no-typesetting': directives.flag,
4041
}
4142

4243
def add_target_and_index(self, name: str, sig: str, signode: desc_signature) -> None:

sphinx/transforms/__init__.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,77 @@ def apply(self, **kwargs: Any) -> None:
414414
)
415415

416416

417+
class ReorderConsecutiveTargetAndIndexNodes(SphinxTransform):
418+
"""Index nodes interspersed between target nodes prevent other
419+
Transformations from combining those target nodes,
420+
e.g. ``PropagateTargets``. This transformation reorders them:
421+
422+
Given the following ``document`` as input::
423+
424+
<document>
425+
<target ids="id1" ...>
426+
<index entries="...1...">
427+
<target ids="id2" ...>
428+
<target ids="id3" ...>
429+
<index entries="...2...">
430+
<target ids="id4" ...>
431+
432+
The transformed result will be::
433+
434+
<document>
435+
<index entries="...1...">
436+
<index entries="...2...">
437+
<target ids="id1" ...>
438+
<target ids="id2" ...>
439+
<target ids="id3" ...>
440+
<target ids="id4" ...>
441+
"""
442+
443+
# This transform MUST run before ``PropagateTargets``.
444+
default_priority = 220
445+
446+
def apply(self, **kwargs: Any) -> None:
447+
for target in self.document.findall(nodes.target):
448+
_reorder_index_target_nodes(target)
449+
450+
451+
def _reorder_index_target_nodes(start_node: nodes.target) -> None:
452+
"""Sort target and index nodes.
453+
454+
Find all consecutive target and index nodes starting from ``start_node``,
455+
and move all index nodes to before the first target node.
456+
"""
457+
nodes_to_reorder: list[nodes.target | addnodes.index] = []
458+
459+
# Note that we cannot use 'condition' to filter,
460+
# as we want *consecutive* target & index nodes.
461+
node: nodes.Node
462+
for node in start_node.findall(descend=False, siblings=True):
463+
if isinstance(node, (nodes.target, addnodes.index)):
464+
nodes_to_reorder.append(node)
465+
continue
466+
break # must be a consecutive run of target or index nodes
467+
468+
if len(nodes_to_reorder) < 2:
469+
return # Nothing to reorder
470+
471+
parent = nodes_to_reorder[0].parent
472+
if parent == nodes_to_reorder[-1].parent:
473+
first_idx = parent.index(nodes_to_reorder[0])
474+
last_idx = parent.index(nodes_to_reorder[-1])
475+
if first_idx + len(nodes_to_reorder) - 1 == last_idx:
476+
parent[first_idx:last_idx + 1] = sorted(nodes_to_reorder, key=_sort_key)
477+
478+
479+
def _sort_key(node: nodes.Node) -> int:
480+
# Must be a stable sort.
481+
if isinstance(node, addnodes.index):
482+
return 0
483+
if isinstance(node, nodes.target):
484+
return 1
485+
raise ValueError(f'_sort_key called with unexpected node type {type(node)!r}')
486+
487+
417488
def setup(app: Sphinx) -> dict[str, Any]:
418489
app.add_transform(ApplySourceWorkaround)
419490
app.add_transform(ExtraTranslatableNodes)
@@ -430,6 +501,7 @@ def setup(app: Sphinx) -> dict[str, Any]:
430501
app.add_transform(DoctreeReadEvent)
431502
app.add_transform(ManpageLink)
432503
app.add_transform(GlossarySorter)
504+
app.add_transform(ReorderConsecutiveTargetAndIndexNodes)
433505

434506
return {
435507
'version': 'builtin',
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests the directives"""
2+
3+
import pytest
4+
from docutils import nodes
5+
6+
from sphinx import addnodes
7+
from sphinx.testing import restructuredtext
8+
from sphinx.testing.util import assert_node
9+
10+
DOMAINS = [
11+
# directive, noindex, noindexentry, signature of f, signature of g, index entry of g
12+
('c:function', False, True, 'void f()', 'void g()', ('single', 'g (C function)', 'c.g', '', None)),
13+
('cpp:function', False, True, 'void f()', 'void g()', ('single', 'g (C++ function)', '_CPPv41gv', '', None)),
14+
('js:function', True, True, 'f()', 'g()', ('single', 'g() (built-in function)', 'g', '', None)),
15+
('py:function', True, True, 'f()', 'g()', ('pair', 'built-in function; g()', 'g', '', None)),
16+
('rst:directive', True, False, 'f', 'g', ('single', 'g (directive)', 'directive-g', '', None)),
17+
('cmdoption', True, False, 'f', 'g', ('pair', 'command line option; g', 'cmdoption-arg-g', '', None)),
18+
('envvar', True, False, 'f', 'g', ('single', 'environment variable; g', 'envvar-g', '', None)),
19+
]
20+
21+
22+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
23+
def test_object_description_no_typesetting(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
24+
text = (f'.. {directive}:: {sig_f}\n'
25+
f' :no-typesetting:\n')
26+
doctree = restructuredtext.parse(app, text)
27+
assert_node(doctree, (addnodes.index, nodes.target))
28+
29+
30+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
31+
def test_object_description_no_typesetting_twice(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
32+
text = (f'.. {directive}:: {sig_f}\n'
33+
f' :no-typesetting:\n'
34+
f'.. {directive}:: {sig_g}\n'
35+
f' :no-typesetting:\n')
36+
doctree = restructuredtext.parse(app, text)
37+
# Note that all index nodes come before the target nodes
38+
assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target))
39+
40+
41+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
42+
def test_object_description_no_typesetting_noindex_orig(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
43+
if not noindex:
44+
pytest.skip(f'{directive} does not support :noindex: option')
45+
text = (f'.. {directive}:: {sig_f}\n'
46+
f' :noindex:\n'
47+
f'.. {directive}:: {sig_g}\n')
48+
doctree = restructuredtext.parse(app, text)
49+
assert_node(doctree, (addnodes.index, addnodes.desc, addnodes.index, addnodes.desc))
50+
assert_node(doctree[2], addnodes.index, entries=[index_g])
51+
52+
53+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
54+
def test_object_description_no_typesetting_noindex(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
55+
if not noindex:
56+
pytest.skip(f'{directive} does not support :noindex: option')
57+
text = (f'.. {directive}:: {sig_f}\n'
58+
f' :noindex:\n'
59+
f' :no-typesetting:\n'
60+
f'.. {directive}:: {sig_g}\n'
61+
f' :no-typesetting:\n')
62+
doctree = restructuredtext.parse(app, text)
63+
assert_node(doctree, (addnodes.index, addnodes.index, nodes.target))
64+
assert_node(doctree[0], addnodes.index, entries=[])
65+
assert_node(doctree[1], addnodes.index, entries=[index_g])
66+
67+
68+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
69+
def test_object_description_no_typesetting_noindexentry(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
70+
if not noindexentry:
71+
pytest.skip(f'{directive} does not support :noindexentry: option')
72+
text = (f'.. {directive}:: {sig_f}\n'
73+
f' :noindexentry:\n'
74+
f' :no-typesetting:\n'
75+
f'.. {directive}:: {sig_g}\n'
76+
f' :no-typesetting:\n')
77+
doctree = restructuredtext.parse(app, text)
78+
assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target))
79+
assert_node(doctree[0], addnodes.index, entries=[])
80+
assert_node(doctree[1], addnodes.index, entries=[index_g])
81+
82+
83+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
84+
def test_object_description_no_typesetting_code(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
85+
text = (f'.. {directive}:: {sig_f}\n'
86+
f' :no-typesetting:\n'
87+
f'.. {directive}:: {sig_g}\n'
88+
f' :no-typesetting:\n'
89+
f'.. code::\n'
90+
f'\n'
91+
f' code\n')
92+
doctree = restructuredtext.parse(app, text)
93+
# Note that all index nodes come before the targets
94+
assert_node(doctree, (addnodes.index, addnodes.index, nodes.target, nodes.target, nodes.literal_block))
95+
96+
97+
@pytest.mark.parametrize(('directive', 'noindex', 'noindexentry', 'sig_f', 'sig_g', 'index_g'), DOMAINS)
98+
def test_object_description_no_typesetting_heading(app, directive, noindex, noindexentry, sig_f, sig_g, index_g):
99+
text = (f'.. {directive}:: {sig_f}\n'
100+
f' :no-typesetting:\n'
101+
f'.. {directive}:: {sig_g}\n'
102+
f' :no-typesetting:\n'
103+
f'\n'
104+
f'Heading\n'
105+
f'=======\n')
106+
doctree = restructuredtext.parse(app, text)
107+
# Note that all index nodes come before the targets and the heading is floated before those.
108+
assert_node(doctree, (nodes.title, addnodes.index, addnodes.index, nodes.target, nodes.target))

0 commit comments

Comments
 (0)