Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions myst_parser/sphinx_ext/myst_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@
LOGGER = logging.getLogger(__name__)


def _section_text_for_id(
slug_to_section: dict[str, tuple[int | None, str, str]], section_id: str
) -> str | None:
"""Find the text of the section whose docutils id is ``section_id``.

:param slug_to_section: The document's ``myst_slugs`` metadata,
mapping each heading slug to ``(line, section id, heading text)``.
:param section_id: The docutils id to look for.
:return: The heading text (possibly empty), or None if no section has that id.
"""
for _, sect_id, text in slug_to_section.values():
if sect_id == section_id:
return text
return None


class MystReferenceResolver(ReferencesResolver):
"""Resolves cross-references on doctrees.

Expand Down Expand Up @@ -146,22 +162,25 @@ def run(self, **kwargs: Any) -> None:

node.replace_self(newnode)

def _std_label_id_in_doc(self, docname: str, ref_id: str) -> str | None:
def _std_label_in_doc(self, docname: str, ref_id: str) -> tuple[str, str] | None:
"""Resolve ``ref_id`` against the std-domain labels of ``docname``.

Returns the label's id (the actual anchor) if ``ref_id`` is either
a label id or a label name in that document, else None.
:param docname: The document to search for the label.
:param ref_id: A label id or a label name in that document.
:return: The label's id (the actual anchor) and the title of the
section it names (empty for anonymous labels), or None.
"""
std = self.env.domaindata.get("std", {})
for store in ("labels", "anonlabels"):
for name, entry in std.get(store, {}).items():
if entry[0] != docname:
continue
title = entry[2] if len(entry) > 2 else ""
if entry[1] == ref_id:
return ref_id
return ref_id, title
if name == ref_id:
# referenced by label name: point at its actual anchor
return entry[1]
return entry[1], title
return None

def resolve_myst_ref_doc(self, node: pending_xref):
Expand Down Expand Up @@ -190,14 +209,17 @@ def resolve_myst_ref_doc(self, node: pending_xref):
slug_to_section = self.env.metadata[ref_docname].get("myst_slugs", {})
if ref_id in slug_to_section:
_, targetid, implicit_text = slug_to_section[ref_id]
elif any(sect_id == ref_id for _, sect_id, _ in slug_to_section.values()):
elif (
section_text := _section_text_for_id(slug_to_section, ref_id)
) is not None:
# the id demonstrably exists in the target document
# (a section's docutils id), so resolve silently
targetid = ref_id
elif (std_id := self._std_label_id_in_doc(ref_docname, ref_id)) is not None:
implicit_text = section_text
elif (std_label := self._std_label_in_doc(ref_docname, ref_id)) is not None:
# an explicit target in the document, referenced by its
# id or name: point at its actual anchor
targetid = std_id
targetid, implicit_text = std_label
else:
self.log_warning(
ref_id,
Expand All @@ -214,10 +236,16 @@ def resolve_myst_ref_doc(self, node: pending_xref):
caption = node.astext()
innernode = nodes.inline(caption, "", classes=inner_classes)
innernode.extend(node[0].children)
else:
elif implicit_text:
innernode = nodes.inline(
implicit_text, implicit_text, classes=inner_classes
)
else:
# no title to use (e.g. an anonymous label, or an unresolved id),
# so show the target rather than rendering an empty link
innernode = nodes.inline("", "", classes=inner_classes)
shown = ref_id or ref_docname
innernode += nodes.literal(shown, shown)

assert self.app.builder
try:
Expand Down
137 changes: 137 additions & 0 deletions tests/test_renderers/test_myst_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,140 @@ def test_slug_id_stays_secondary_under_sortids(sphinx_doctree: CreateDoctree):
for section in doctree.findall(docutils_nodes.section):
assert section["ids"][0].startswith("id"), section["ids"]
assert section["slug"] in section["ids"][1:], section["ids"]


_LINK_TEXT_OTHER_MD = """\
# Other

(my-label)=
## Labelled heading

(colon:label)=
## Colon heading

## Snake_case heading

### Third level

(deep-label)=
#### Deep heading

(para-label)=
A paragraph.

```{figure} img.png
:name: fig-label

Figure caption
```
"""

_LINK_TEXT_RST_DOC = """\
RST document
============

.. _rst-label:

RST section
-----------

Text.
"""


def _write_link_text_docs(sphinx_doctree: CreateDoctree, heading_anchors: int | None):
conf: dict = {"extensions": ["myst_parser"], "show_warning_types": True}
if heading_anchors is not None:
conf["myst_heading_anchors"] = heading_anchors
sphinx_doctree.set_conf(conf)
sphinx_doctree.srcdir.joinpath("other.md").write_text(
_LINK_TEXT_OTHER_MD, encoding="utf8"
)
sphinx_doctree.srcdir.joinpath("rstdoc.rst").write_text(
_LINK_TEXT_RST_DOC, encoding="utf8"
)
sphinx_doctree.srcdir.joinpath("img.png").write_bytes(b"")


@pytest.mark.parametrize(
"heading_anchors", [2, None], ids=["heading_anchors", "no_heading_anchors"]
)
@pytest.mark.parametrize(
"link,expected",
[
# an explicit label on a heading, which becomes the section's id
("[](other.md#my-label)", "Labelled heading"),
# a label referenced by name, rather than by its id
("[](other.md#colon:label)", "Colon heading"),
("[](other.md#colon-label)", "Colon heading"),
# a label on a heading beyond the `myst_heading_anchors` depth
("[](other.md#deep-label)", "Deep heading"),
# a label on a figure uses the figure's caption
("[](other.md#fig-label)", "Figure caption"),
# a label in an rST document uses its section title
("[](rstdoc.rst#rst-label)", "RST section"),
# explicit link text is kept
("[custom](other.md#my-label)", "custom"),
# an anonymous label has no title, so the target is shown
("[](other.md#para-label)", "para-label"),
],
)
def test_doc_with_target_link_text(
link: str,
expected: str,
heading_anchors: int | None,
sphinx_doctree: CreateDoctree,
):
"""`[](doc.md#target)` to an explicit target uses the target's title.

Regression: links that resolved through a label or a section id, rather
than a heading slug, rendered with no link text at all.
The title must come from the label itself, not from heading slugs,
so the same holds with `myst_heading_anchors` unset.
"""
from docutils import nodes as docutils_nodes

_write_link_text_docs(sphinx_doctree, heading_anchors)
result = sphinx_doctree(f"# Index\n\n{link}\n", "index.md")
doctree = result.get_resolved_doctree("index")
# checked after resolution, which is where reference warnings are emitted
assert not result.warnings, result.warnings
ref = next(doctree.findall(docutils_nodes.reference))
assert ref.astext() == expected, ref.pformat()


def test_doc_with_section_id_link_text(sphinx_doctree: CreateDoctree):
"""`[](doc.md#id)` to a section's docutils id, not its slug, uses its title.

The heading's slug (``snake_case-heading``) and its docutils id
(``snake-case-heading``) differ, so this resolves through the section id.
"""
from docutils import nodes as docutils_nodes

_write_link_text_docs(sphinx_doctree, 2)
result = sphinx_doctree("# Index\n\n[](other.md#snake-case-heading)\n", "index.md")
doctree = result.get_resolved_doctree("index")
assert not result.warnings, result.warnings
ref = next(doctree.findall(docutils_nodes.reference))
assert ref.astext() == "Snake_case heading", ref.pformat()
assert ref["refuri"].endswith("#snake-case-heading"), ref.attributes


def test_doc_with_missing_target_link_text(sphinx_doctree: CreateDoctree):
"""`[](doc.md#missing)` warns once, and shows the id rather than nothing.

The link still points at the requested id, as it did before.
"""
from docutils import nodes as docutils_nodes

_write_link_text_docs(sphinx_doctree, 2)
result = sphinx_doctree("# Index\n\n[](other.md#missing-id)\n", "index.md")
doctree = result.get_resolved_doctree("index")
warnings = [line for line in result.warnings.splitlines() if line.strip()]
assert len(warnings) == 1, result.warnings
assert "local id not found in doc 'other': 'missing-id'" in warnings[0]
assert "[myst.xref_missing]" in warnings[0]
ref = next(doctree.findall(docutils_nodes.reference))
assert ref.astext() == "missing-id", ref.pformat()
# (the test builder gives documents an empty uri, so only the fragment shows)
assert ref["refuri"].endswith("#missing-id"), ref.attributes
1 change: 1 addition & 0 deletions tests/test_renderers/test_myst_refs/doc_with_target_id.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
<paragraph>
<reference internal="True" refid="ref">
<inline classes="std std-ref">
Title
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
<paragraph>
<reference internal="True" refid="ref-colon">
<inline classes="std std-ref">
Title
Loading