diff --git a/CHANGES.rst b/CHANGES.rst index ad6d698341a..1e986521c80 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,11 @@ Bugs fixed English stemmer) and Dutch (which uses the Dutch Porter stemmer). Patch by Hugo van Kemenade +* #14221: Fix a regression from Sphinx v8.2.0 onwards that could cause links + produced by ``singlehtml`` builds to contain multiple fragment delimiters + (``#``), and withdraw the deprecation of + ``SingleFileHTMLBuilder.fix_refuris`` because the builder still requires it. + Patch by James Addison Release 9.1.0 (released Dec 31, 2025) ===================================== diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index b7583b0e37d..79323233e6d 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -52,11 +52,6 @@ The following is a list of deprecated interfaces. - 11.0 - N/A - * - ``sphinx.builders.singlehtml.SingleFileHTMLBuilder.fix_refuris`` - - 8.2 - - 10.0 - - N/A - * - ``sphinx.util.FilenameUniqDict`` - 8.1 - 10.0 diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 1888f6679d1..cd55261537d 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -2,14 +2,12 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING from docutils import nodes from sphinx._cli.util.colour import darkgreen from sphinx.builders.html import StandaloneHTMLBuilder -from sphinx.deprecation import RemovedInSphinx10Warning from sphinx.environment.adapters.toctree import global_toctree_for_doc from sphinx.locale import __ from sphinx.util import logging @@ -52,14 +50,6 @@ def get_relative_uri(self, from_: str, to: str, typ: str | None = None) -> str: return self.get_target_uri(to, typ) def fix_refuris(self, tree: Node) -> None: - deprecation_msg = ( - "The 'SingleFileHTMLBuilder.fix_refuris' method is no longer used " - 'within the builder and is planned for removal in Sphinx 10. ' - 'Please report malformed URIs generated by the Sphinx singlehtml ' - 'builder as bugreports.' - ) - warnings.warn(deprecation_msg, RemovedInSphinx10Warning, stacklevel=2) - # fix refuris with double anchor for refnode in tree.findall(nodes.reference): if 'refuri' not in refnode: @@ -86,6 +76,8 @@ def _get_local_toctree( toctree = global_toctree_for_doc( self.env, docname, self, tags=self.tags, collapse=collapse, **kwargs ) + if toctree is not None: + self.fix_refuris(toctree) return self.render_partial(toctree)['fragment'] def assemble_doctree(self) -> nodes.document: @@ -95,6 +87,7 @@ def assemble_doctree(self) -> nodes.document: tree = inline_all_toctrees(self, set(), master, tree, darkgreen, [master]) tree['docname'] = master self.env.resolve_references(tree, master, self) + self.fix_refuris(tree) return tree def assemble_toc_secnumbers(self) -> dict[str, dict[str, tuple[int, ...]]]: @@ -145,6 +138,7 @@ def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, A ) # if there is no toctree, toc is None if toctree: + self.fix_refuris(toctree) toc = self.render_partial(toctree)['fragment'] display_toc = True else: diff --git a/tests/roots/test-refuris/conf.py b/tests/roots/test-refuris/conf.py new file mode 100644 index 00000000000..b490ae6b4da --- /dev/null +++ b/tests/roots/test-refuris/conf.py @@ -0,0 +1,2 @@ +project = 'Glossary Test' +extensions = [] diff --git a/tests/roots/test-refuris/glossary/term1.rst b/tests/roots/test-refuris/glossary/term1.rst new file mode 100644 index 00000000000..4c7cbfdd0dc --- /dev/null +++ b/tests/roots/test-refuris/glossary/term1.rst @@ -0,0 +1,7 @@ +Glossary +======== + +.. glossary:: + + API + Lorem Ipsum. diff --git a/tests/roots/test-refuris/index.rst b/tests/roots/test-refuris/index.rst new file mode 100644 index 00000000000..c044ca8a538 --- /dev/null +++ b/tests/roots/test-refuris/index.rst @@ -0,0 +1,12 @@ +Main Documentation +================== + +This documentation discusses :term:`API` design. + +We use :term:`API` throughout our application to communicate between +different services. + +.. toctree:: + :maxdepth: 2 + + glossary/term1 diff --git a/tests/test_builders/test_build_html_refuris.py b/tests/test_builders/test_build_html_refuris.py new file mode 100644 index 00000000000..99ebaf26a68 --- /dev/null +++ b/tests/test_builders/test_build_html_refuris.py @@ -0,0 +1,58 @@ +"""Test output of reference URIs when building single-page HTML output.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.test_builders.xpath_util import check_xpath + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from pathlib import Path + from xml.etree.ElementTree import Element, ElementTree + + from sphinx.testing.util import SphinxTestApp + + +def _internal_reference_fragment_check(nodes: Sequence[Element]) -> None: + """Confirm that internal references do not contain duplicate fragment symbols""" + assert nodes, 'Expected at least one node to check' + for node in nodes: + assert node.tag == 'a', 'Attempted to check hyperlink on a non-anchor element' + href = node.attrib.get('href') + if not href: + continue + assert href.count('#') < 2, 'Hyperlink contains duplicate fragments' + + +@pytest.mark.sphinx('singlehtml', testroot='refuris') +def test_singlehtml_refuris_check_fragments( + app: SphinxTestApp, + cached_etree_parse: Callable[[Path], ElementTree], +) -> None: + app.build() + check_xpath( + cached_etree_parse(app.outdir / 'index.html'), + 'index.html', + ".//a[@class='reference internal']", + _internal_reference_fragment_check, + ) + + +@pytest.mark.sphinx('singlehtml', testroot='refuris') +def test_singlehtml_refuris_check_term_anchor( + app: SphinxTestApp, + cached_etree_parse: Callable[[Path], ElementTree], +) -> None: + app.build() + etree = cached_etree_parse(app.outdir / 'index.html') + api_refs = [ + node + for node in etree.findall(".//a[@class='reference internal']") + if ''.join(node.itertext()) == 'API' + ] + assert api_refs # the API term is referenced twice from the document text + assert all(ref.get('href') == '#term-API' for ref in api_refs) + assert etree.find(".//*[@id='term-API']") is not None