Skip to content

Commit 850f7c9

Browse files
authored
👌 Improve footnote def/ref warnings and translations (#931)
Footnotes are now parsed similar to the corresponding restructuredtext, in that resolution (between definitions and references) and ordering is now deferred to transforms on the doctree. This allows for the proper interaction with other docutils/sphinx transforms, including those that perform translations. In addition, an upstream improvement to unreferenced footnote definitions is also added here: sphinx-doc/sphinx#12730, so that unreferenced and duplicate definitions are correctly warned about, e.g.: ``` source/index.md:1: WARNING: Footnote [1] is not referenced. [ref.footnote] source/index.md:4: WARNING: Duplicate footnote definition found for label: 'a' [ref.footnote] ``` It is of note that warnings for references with no corresponding definitions are deferred to docutils to handle, e.g. for `[^a]` with no definition: ``` source/index.md:1: ERROR: Too many autonumbered footnote references: only 0 corresponding footnotes available. [docutils] source/index.md:1: ERROR: Unknown target name: "a". [docutils] ``` These warning messages are a little obscure, and it would be ideal that one clear warning was emitted for the issue. However, it is non-trivial in this extension; to both suppress the existing warnings, and then replace them with a better one, so for now we don't do it here, and ideally this would be improved upstream in docutils.
1 parent 401e08c commit 850f7c9

30 files changed

+714
-301
lines changed

Diff for: docs/configuration.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,13 @@ tasklist
195195
(myst-warnings)=
196196
## Build Warnings
197197

198-
Below lists the MyST specific warnings that may be emitted during the build process. These will be prepended to the end of the warning message, e.g.
198+
Below lists the MyST specific warnings that may be emitted during the build process. These will be prepended to the end of the warning message (see also <inv:sphinx#show_warning_types>), e.g.
199199

200200
```
201201
WARNING: Non-consecutive header level increase; H1 to H3 [myst.header]
202202
```
203203

204-
**In general, if your build logs any warnings, you should either fix them or [raise an Issue](https://github.com/executablebooks/MyST-Parser/issues/new/choose) if you think the warning is erroneous.**
204+
In general, if your build logs any warnings, you should either fix them or [raise an Issue](https://github.com/executablebooks/MyST-Parser/issues/new/choose) if you think the warning is erroneous.
205205

206206
However, in some circumstances if you wish to suppress the warning you can use the <inv:sphinx#suppress_warnings> configuration option, e.g.
207207

Diff for: docs/syntax/typography.md

+11-8
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,16 @@ that are not separated by a blank line
295295
This is not part of the footnote.
296296
:::
297297

298-
````{important}
299-
Although footnote references can be used just fine within directives, e.g.[^myref],
300-
it is recommended that footnote definitions are not set within directives,
301-
unless they will only be referenced within that same directive:
298+
By default, the footnotes will be collected, sorted and moved to the end of the document,
299+
with a transition line placed before any footnotes (that has a `footnotes` class).
302300

303-
This is because, they may not be available to reference in text outside that particular directive.
304-
````
301+
This behaviour can be modified using the [configuration options](#sphinx/config-options):
305302

306-
By default, a transition line (with a `footnotes` class) will be placed before any footnotes.
307-
This can be turned off by adding `myst_footnote_transition = False` to the config file.
303+
```python
304+
myst_footnote_sort = False
305+
myst_footnote_transition = False
306+
```
307+
308+
```{versionadded} 4.0.0
309+
``myst_footnote_sort`` configuration option
310+
```

Diff for: myst_parser/config/main.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,19 @@ def __repr__(self) -> str:
319319
},
320320
)
321321

322+
footnote_sort: bool = dc.field(
323+
default=True,
324+
metadata={
325+
"validator": instance_of(bool),
326+
"help": "Move all footnotes to the end of the document, and sort by reference order",
327+
},
328+
)
329+
322330
footnote_transition: bool = dc.field(
323331
default=True,
324332
metadata={
325333
"validator": instance_of(bool),
326-
"help": "Place a transition before any footnotes",
334+
"help": "Place a transition before sorted footnotes",
327335
},
328336
)
329337

Diff for: myst_parser/mdit_to_docutils/base.py

+37-52
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import os
88
import posixpath
99
import re
10-
from collections import OrderedDict
1110
from collections.abc import Callable, Iterable, Iterator, MutableMapping, Sequence
1211
from contextlib import contextmanager, suppress
1312
from datetime import date, datetime
@@ -159,8 +158,9 @@ def sphinx_env(self) -> BuildEnvironment | None:
159158
def create_warning(
160159
self,
161160
message: str,
162-
subtype: MystWarnings,
161+
subtype: MystWarnings | str,
163162
*,
163+
wtype: str | None = None,
164164
line: int | None = None,
165165
append_to: nodes.Element | None = None,
166166
) -> nodes.system_message | None:
@@ -173,6 +173,7 @@ def create_warning(
173173
self.document,
174174
message,
175175
subtype,
176+
wtype=wtype,
176177
line=line,
177178
append_to=append_to,
178179
)
@@ -190,20 +191,6 @@ def _render_tokens(self, tokens: list[Token]) -> None:
190191

191192
# nest tokens
192193
node_tree = SyntaxTreeNode(tokens)
193-
194-
# move footnote definitions to env
195-
self.md_env.setdefault("foot_refs", {})
196-
for node in node_tree.walk(include_self=True):
197-
new_children = []
198-
for child in node.children:
199-
if child.type == "footnote_reference":
200-
label = child.meta["label"]
201-
self.md_env["foot_refs"].setdefault(label, []).append(child)
202-
else:
203-
new_children.append(child)
204-
205-
node.children = new_children
206-
207194
# render
208195
for child in node_tree.children:
209196
# skip hidden?
@@ -254,6 +241,12 @@ def _render_finalise(self) -> None:
254241
self._heading_slugs
255242
)
256243

244+
# ensure these settings are set for later footnote transforms
245+
self.document.settings.myst_footnote_transition = (
246+
self.md_config.footnote_transition
247+
)
248+
self.document.settings.myst_footnote_sort = self.md_config.footnote_sort
249+
257250
# log warnings for duplicate reference definitions
258251
# "duplicate_refs": [{"href": "ijk", "label": "B", "map": [4, 5], "title": ""}],
259252
for dup_ref in self.md_env.get("duplicate_refs", []):
@@ -264,35 +257,6 @@ def _render_finalise(self) -> None:
264257
append_to=self.document,
265258
)
266259

267-
# we don't use the foot_references stored in the env
268-
# since references within directives/roles will have been added after
269-
# those from the initial markdown parse
270-
# instead we gather them from a walk of the created document
271-
foot_refs = OrderedDict()
272-
for refnode in findall(self.document)(nodes.footnote_reference):
273-
if refnode["refname"] not in foot_refs:
274-
foot_refs[refnode["refname"]] = True
275-
276-
if foot_refs and self.md_config.footnote_transition:
277-
self.current_node.append(nodes.transition(classes=["footnotes"]))
278-
for footref in foot_refs:
279-
foot_ref_tokens = self.md_env["foot_refs"].get(footref, [])
280-
if len(foot_ref_tokens) > 1:
281-
self.create_warning(
282-
f"Multiple footnote definitions found for label: '{footref}'",
283-
MystWarnings.MD_FOOTNOTE_DUPE,
284-
append_to=self.current_node,
285-
)
286-
287-
if len(foot_ref_tokens) < 1:
288-
self.create_warning(
289-
f"No footnote definitions found for label: '{footref}'",
290-
MystWarnings.MD_FOOTNOTE_MISSING,
291-
append_to=self.current_node,
292-
)
293-
else:
294-
self.render_footnote_reference(foot_ref_tokens[0])
295-
296260
# Add the wordcount, generated by the ``mdit_py_plugins.wordcount_plugin``.
297261
wordcount_metadata = self.md_env.get("wordcount", {})
298262
if wordcount_metadata:
@@ -1469,29 +1433,50 @@ def render_footnote_ref(self, token: SyntaxTreeNode) -> None:
14691433

14701434
refnode = nodes.footnote_reference(f"[^{target}]")
14711435
self.add_line_and_source_path(refnode, token)
1472-
if not target.isdigit():
1436+
if target.isdigit():
1437+
# a manually numbered footnote, similar to rST ``[1]_``
1438+
refnode += nodes.Text(target)
1439+
else:
1440+
# an auto-numbered footnote, similar to rST ``[#label]_``
14731441
refnode["auto"] = 1
14741442
self.document.note_autofootnote_ref(refnode)
1475-
else:
1476-
refnode += nodes.Text(target)
14771443

14781444
refnode["refname"] = target
14791445
self.document.note_footnote_ref(refnode)
14801446

14811447
self.current_node.append(refnode)
14821448

14831449
def render_footnote_reference(self, token: SyntaxTreeNode) -> None:
1450+
"""Despite the name, this is actually a footnote definition, e.g. `[^a]: ...`"""
14841451
target = token.meta["label"]
14851452

1453+
if target in self.document.nameids:
1454+
# note we chose to directly omit these footnotes in the parser,
1455+
# rather than let docutils/sphinx handle them, since otherwise you end up with a confusing warning:
1456+
# WARNING: Duplicate explicit target name: "x". [docutils]
1457+
# we use [ref.footnote] as the type/subtype, rather than a myst specific warning,
1458+
# to make it more aligned with sphinx warnings for unreferenced footnotes
1459+
self.create_warning(
1460+
f"Duplicate footnote definition found for label: '{target}'",
1461+
"footnote",
1462+
wtype="ref",
1463+
line=token_line(token),
1464+
append_to=self.current_node,
1465+
)
1466+
return
1467+
14861468
footnote = nodes.footnote()
14871469
self.add_line_and_source_path(footnote, token)
14881470
footnote["names"].append(target)
1489-
if not target.isdigit():
1490-
footnote["auto"] = 1
1491-
self.document.note_autofootnote(footnote)
1492-
else:
1471+
if target.isdigit():
1472+
# a manually numbered footnote, similar to rST ``.. [1]``
14931473
footnote += nodes.label("", target)
14941474
self.document.note_footnote(footnote)
1475+
else:
1476+
# an auto-numbered footnote, similar to rST ``.. [#label]``
1477+
footnote["auto"] = 1
1478+
self.document.note_autofootnote(footnote)
1479+
14951480
self.document.note_explicit_target(footnote, footnote)
14961481
with self.current_node_context(footnote, append=True):
14971482
self.render_children(token)

Diff for: myst_parser/mdit_to_docutils/transforms.py

+126-1
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,140 @@
66

77
from docutils import nodes
88
from docutils.transforms import Transform
9+
from docutils.transforms.references import Footnotes
910
from markdown_it.common.normalize_url import normalizeLink
1011

1112
from myst_parser._compat import findall
1213
from myst_parser.mdit_to_docutils.base import clean_astext
1314
from myst_parser.warnings_ import MystWarnings, create_warning
1415

1516

17+
class UnreferencedFootnotesDetector(Transform):
18+
"""Detect unreferenced footnotes and emit warnings.
19+
20+
Replicates https://github.com/sphinx-doc/sphinx/pull/12730,
21+
but also allows for use in docutils (without sphinx).
22+
"""
23+
24+
default_priority = Footnotes.default_priority + 2
25+
26+
# document: nodes.document
27+
28+
def apply(self, **kwargs: t.Any) -> None:
29+
"""Apply the transform."""
30+
31+
for node in self.document.footnotes:
32+
# note we do not warn on duplicate footnotes here
33+
# (i.e. where the name has been moved to dupnames)
34+
# since this is already reported by docutils
35+
if not node["backrefs"] and node["names"]:
36+
create_warning(
37+
self.document,
38+
"Footnote [{}] is not referenced.".format(node["names"][0])
39+
if node["names"]
40+
else node["dupnames"][0],
41+
wtype="ref",
42+
subtype="footnote",
43+
node=node,
44+
)
45+
for node in self.document.symbol_footnotes:
46+
if not node["backrefs"]:
47+
create_warning(
48+
self.document,
49+
"Footnote [*] is not referenced.",
50+
wtype="ref",
51+
subtype="footnote",
52+
node=node,
53+
)
54+
for node in self.document.autofootnotes:
55+
# note we do not warn on duplicate footnotes here
56+
# (i.e. where the name has been moved to dupnames)
57+
# since this is already reported by docutils
58+
if not node["backrefs"] and node["names"]:
59+
create_warning(
60+
self.document,
61+
"Footnote [#] is not referenced.",
62+
wtype="ref",
63+
subtype="footnote",
64+
node=node,
65+
)
66+
67+
68+
class SortFootnotes(Transform):
69+
"""Sort auto-numbered, labelled footnotes by the order they are referenced.
70+
71+
This is run before the docutils ``Footnote`` transform, where numbered labels are assigned.
72+
"""
73+
74+
default_priority = Footnotes.default_priority - 2
75+
76+
# document: nodes.document
77+
78+
def apply(self, **kwargs: t.Any) -> None:
79+
"""Apply the transform."""
80+
if not self.document.settings.myst_footnote_sort:
81+
return
82+
83+
ref_order: list[str] = [
84+
node["refname"]
85+
for node in self.document.autofootnote_refs
86+
if "refname" in node
87+
]
88+
89+
def _sort_key(node: nodes.footnote) -> int:
90+
if node["names"] and node["names"][0] in ref_order:
91+
return ref_order.index(node["names"][0])
92+
return 999
93+
94+
self.document.autofootnotes.sort(key=_sort_key)
95+
96+
97+
class CollectFootnotes(Transform):
98+
"""Transform to move footnotes to the end of the document, and sort by label."""
99+
100+
default_priority = Footnotes.default_priority + 3
101+
102+
# document: nodes.document
103+
104+
def apply(self, **kwargs: t.Any) -> None:
105+
"""Apply the transform."""
106+
if not self.document.settings.myst_footnote_sort:
107+
return
108+
109+
footnotes: list[tuple[str, nodes.footnote]] = []
110+
for footnote in (
111+
self.document.symbol_footnotes
112+
+ self.document.footnotes
113+
+ self.document.autofootnotes
114+
):
115+
label = footnote.children[0]
116+
footnotes.append((label.astext(), footnote))
117+
118+
if (
119+
footnotes
120+
and self.document.settings.myst_footnote_transition
121+
# avoid warning: Document or section may not begin with a transition
122+
and not all(isinstance(c, nodes.footnote) for c in self.document.children)
123+
):
124+
transition = nodes.transition(classes=["footnotes"])
125+
transition.source = self.document.source
126+
self.document += transition
127+
128+
def _sort_key(footnote: tuple[str, nodes.footnote]) -> int | str:
129+
label, _ = footnote
130+
try:
131+
# ensure e.g 10 comes after 2
132+
return int(label)
133+
except ValueError:
134+
return label
135+
136+
for _, footnote in sorted(footnotes, key=_sort_key):
137+
footnote.parent.remove(footnote)
138+
self.document += footnote
139+
140+
16141
class ResolveAnchorIds(Transform):
17-
"""Directive for resolving `[name](#id)` type links."""
142+
"""Transform for resolving `[name](#id)` type links."""
18143

19144
default_priority = 879 # this is the same as Sphinx's StandardDomain.process_doc
20145

Diff for: myst_parser/parsers/docutils_.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
read_topmatter,
2424
)
2525
from myst_parser.mdit_to_docutils.base import DocutilsRenderer
26-
from myst_parser.mdit_to_docutils.transforms import ResolveAnchorIds
26+
from myst_parser.mdit_to_docutils.transforms import (
27+
CollectFootnotes,
28+
ResolveAnchorIds,
29+
SortFootnotes,
30+
UnreferencedFootnotesDetector,
31+
)
2732
from myst_parser.parsers.mdit import create_md_parser
2833
from myst_parser.warnings_ import MystWarnings, create_warning
2934

@@ -246,7 +251,12 @@ class Parser(RstParser):
246251
translate_section_name = None
247252

248253
def get_transforms(self):
249-
return super().get_transforms() + [ResolveAnchorIds]
254+
return super().get_transforms() + [
255+
UnreferencedFootnotesDetector,
256+
SortFootnotes,
257+
CollectFootnotes,
258+
ResolveAnchorIds,
259+
]
250260

251261
def parse(self, inputstring: str, document: nodes.document) -> None:
252262
"""Parse source text.

Diff for: myst_parser/parsers/mdit.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,8 @@ def create_md_parser(
6161
.use(front_matter_plugin)
6262
.use(myst_block_plugin)
6363
.use(myst_role_plugin)
64-
.use(footnote_plugin)
64+
.use(footnote_plugin, inline=False, move_to_end=False, always_match_refs=True)
6565
.use(wordcount_plugin, per_minute=config.words_per_minute)
66-
.disable("footnote_inline")
67-
# disable this for now, because it need a new implementation in the renderer
68-
.disable("footnote_tail")
6966
)
7067

7168
typographer = False

0 commit comments

Comments
 (0)