diff --git a/myst_parser/mdit_to_docutils/sphinx_.py b/myst_parser/mdit_to_docutils/sphinx_.py index 58d3992c..900450b5 100644 --- a/myst_parser/mdit_to_docutils/sphinx_.py +++ b/myst_parser/mdit_to_docutils/sphinx_.py @@ -76,6 +76,25 @@ def _handle_relative_docs(self, destination: str) -> str: ) return destination + def _link_target_path(self, path: str) -> str: + """Return the absolute path of a file that a link points to. + + Like ``BuildEnvironment.relfn2path``, a path starting with ``/`` is + relative to the source directory, any other is relative to the + directory of the current document. + Unlike ``relfn2path``, symlinks are not followed: + a document is registered under the path of the symlink, + so the path of its target would not be found as a document. + """ + env = self.sphinx_env + file_path = Path(path) + if file_path.parts[:1] in {("/",), ("\\",)}: + base = env.srcdir.joinpath(*file_path.parts[1:]) + else: + doc_dir = env.doc2path(env.docname, base=False).parent + base = env.srcdir.joinpath(doc_dir, file_path) + return os.path.normpath(base) + def render_link_project(self, token: SyntaxTreeNode) -> None: destination = cast(str, token.attrGet("href") or "") destination = destination.removeprefix("project:") @@ -90,7 +109,7 @@ def render_link_project(self, token: SyntaxTreeNode) -> None: path_dest, *_path_ids = destination.split("#", maxsplit=1) path_id = _path_ids[0] if _path_ids else None explicit = (token.info != "auto") and (len(token.children or []) > 0) - _, abs_path = self.sphinx_env.relfn2path(path_dest, self.sphinx_env.docname) + abs_path = self._link_target_path(path_dest) docname = self.sphinx_env.path2doc(abs_path) if not docname: self.create_warning( @@ -144,8 +163,7 @@ def render_link_unknown(self, token: SyntaxTreeNode) -> None: potential_path: Path | None = None if self.sphinx_env.srcdir: # not set in some test situations - _, path_str = self.sphinx_env.relfn2path(path_dest, self.sphinx_env.docname) - potential_path = Path(path_str) + potential_path = Path(self._link_target_path(path_dest)) if potential_path and potential_path.is_file(): docname = self.sphinx_env.path2doc(str(potential_path)) diff --git a/tests/test_sphinx/test_symlinked_docs.py b/tests/test_sphinx/test_symlinked_docs.py new file mode 100644 index 00000000..01e471c2 --- /dev/null +++ b/tests/test_sphinx/test_symlinked_docs.py @@ -0,0 +1,62 @@ +"""Links to a document that is a symlink to a file outside the source directory.""" + +from __future__ import annotations + +import os +from io import StringIO +from pathlib import Path + +import pytest +from sphinx.application import Sphinx + + +def _build(srcdir: Path, outdir: Path) -> tuple[str, str]: + """Build the project with the html builder, return (warnings, index.html).""" + status, warning = StringIO(), StringIO() + app = Sphinx( + str(srcdir), + str(srcdir), + str(outdir), + str(outdir / ".doctrees"), + "html", + status=status, + warning=warning, + freshenv=True, + ) + app.build() + return warning.getvalue(), (outdir / "index.html").read_text(encoding="utf-8") + + +@pytest.mark.parametrize( + "link", + [ + pytest.param("linked.md#target-section", id="relative-path"), + pytest.param("project:linked.md#target-section", id="project-scheme"), + ], +) +def test_link_to_symlinked_doc_outside_srcdir(tmp_path: Path, link: str): + """A link to a document that is a symlink to a file outside the source + directory resolves to the document, rather than to the symlink target. + """ + docs = tmp_path / "project" / "docs" + docs.mkdir(parents=True) + (tmp_path / "project" / "README.md").write_text( + "# Readme\n\n## Target section\n\ntext\n", encoding="utf-8" + ) + try: + os.symlink(os.path.join("..", "README.md"), docs / "linked.md") + except (OSError, NotImplementedError): # pragma: no cover + pytest.skip("symlinks are not available") + (docs / "conf.py").write_text( + 'extensions = ["myst_parser"]\nmyst_heading_anchors = 3\n', encoding="utf-8" + ) + (docs / "index.md").write_text( + f"# Index\n\n[Section title]({link})\n\n```{{toctree}}\nlinked\n```\n", + encoding="utf-8", + ) + + warnings, index_html = _build(docs, tmp_path / "_build") + + assert "Unknown source document" not in warnings + assert "Could not find document" not in warnings + assert 'href="linked.html#target-section"' in index_html