diff --git a/myst_parser/mdit_to_docutils/sphinx_.py b/myst_parser/mdit_to_docutils/sphinx_.py index 1569131f..aab4c2e7 100644 --- a/myst_parser/mdit_to_docutils/sphinx_.py +++ b/myst_parser/mdit_to_docutils/sphinx_.py @@ -43,7 +43,9 @@ def _process_wrap_node( ): """Process a wrap node, which is a node that wraps a link.""" self.add_line_and_source_path(wrap_node, token) - self.copy_attributes(token, wrap_node, ("class", "id", "title")) + self.copy_attributes( + token, wrap_node, ("class", "id", "reftitle"), aliases={"title": "reftitle"} + ) self.current_node.append(wrap_node) if explicit: diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index a7f9427d..6479f7e3 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -340,6 +340,8 @@ def apply(self, **kwargs: t.Any) -> None: ) if labelid: pending["reflocalid"] = labelid + if "reftitle" in refnode: + pending["reftitle"] = refnode["reftitle"] inner_node = nodes.inline( "", "", classes=["xref", "myst"] + refnode["classes"] ) diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index bdb89b54..54f7f703 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -144,6 +144,11 @@ def run(self, **kwargs: Any) -> None: elif not newnode.children: newnode.append(nodes.literal(target, target)) + if "reftitle" in node and "reftitle" not in newnode: + # carry the link title, e.g. [text](#target "title"), + # through whichever branch resolved the reference + newnode["reftitle"] = node["reftitle"] + node.replace_self(newnode) def _std_label_id_in_doc(self, docname: str, ref_id: str) -> str | None: @@ -226,6 +231,8 @@ def resolve_myst_ref_doc(self, node: pending_xref): ) except NoUri: ref_node = innernode + if "reftitle" in node: + ref_node["reftitle"] = node["reftitle"] node.replace_self(ref_node) def resolve_myst_ref_any( diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index bfb98f69..e5949b76 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -1,6 +1,7 @@ import sys import pytest +from docutils import nodes from sphinx.util.console import strip_colors from sphinx_pytest.plugin import CreateDoctree @@ -142,3 +143,32 @@ 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( + "test_name,text", + [ + ("doc_root_anchor", '# Title\n\n[a](#index "TT")'), + ("heading_anchor", '# Title\n\n## Section\n\n[a](#section "TT")'), + ("explicit_target", '(target)=\n# Title\n\n[a](#target "TT")'), + ("doc", '# Title\n\n[a](index.md "TT")'), + ("doc_with_target_id", '(ref)=\n# Title\n\n[a](index.md#ref "TT")'), + ("unresolved", '# Title\n\n[a](#nope "TT")'), + ], +) +def test_link_title_is_kept( + test_name: str, text: str, sphinx_doctree: CreateDoctree +) -> None: + """A CommonMark link title must survive cross-reference resolution. + + Only two of these branches used to keep it; the rest built a fresh node + and dropped the title on the way. + """ + sphinx_doctree.set_conf({"extensions": ["myst_parser"]}) + doctree = sphinx_doctree(text, "index.md").get_resolved_doctree("index") + titles = [ + node["reftitle"] + for node in doctree.findall(nodes.reference) + if "reftitle" in node + ] + assert titles == ["TT"], doctree.pformat()