diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index bdb89b54..bb5469c7 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -146,22 +146,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): @@ -190,14 +193,24 @@ 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 := next( + ( + text + for _, sect_id, text in slug_to_section.values() + if sect_id == ref_id + ), + None, + ) + ) 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, @@ -214,10 +227,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: diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index bfb98f69..f037454e 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -142,3 +142,42 @@ 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"] + + +@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"), + # 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, 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. + """ + from docutils import nodes as docutils_nodes + + sphinx_doctree.set_conf({"extensions": ["myst_parser"], "myst_heading_anchors": 2}) + sphinx_doctree.srcdir.joinpath("other.md").write_text( + "# Other\n\n" + "(my-label)=\n## Labelled heading\n\n" + "(colon:label)=\n## Colon heading\n\n" + "(para-label)=\nA paragraph.\n", + encoding="utf8", + ) + result = sphinx_doctree(f"# Index\n\n{link}\n", "index.md") + assert not result.warnings + doctree = result.get_resolved_doctree("index") + ref = next(doctree.findall(docutils_nodes.reference)) + assert ref.astext() == expected, ref.pformat() diff --git a/tests/test_renderers/test_myst_refs/doc_with_target_id.xml b/tests/test_renderers/test_myst_refs/doc_with_target_id.xml index 94eda8c8..29cb1f94 100644 --- a/tests/test_renderers/test_myst_refs/doc_with_target_id.xml +++ b/tests/test_renderers/test_myst_refs/doc_with_target_id.xml @@ -6,3 +6,4 @@ + Title diff --git a/tests/test_renderers/test_myst_refs/doc_with_target_name.xml b/tests/test_renderers/test_myst_refs/doc_with_target_name.xml index 6470822b..398a0b43 100644 --- a/tests/test_renderers/test_myst_refs/doc_with_target_name.xml +++ b/tests/test_renderers/test_myst_refs/doc_with_target_name.xml @@ -6,3 +6,4 @@ + Title