diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index 49af6a36..4ab58f27 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -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. @@ -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): @@ -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, @@ -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: diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index bfb98f69..ab892518 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -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 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