diff --git a/AUTHORS.rst b/AUTHORS.rst index 5bcd74c943b..2fe75be08e1 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -72,6 +72,7 @@ Contributors * Joel Wurtz -- cellspanning support in LaTeX * John Waltman -- Texinfo builder * Jon Dufresne -- modernisation +* Jorge Marques -- unique ids in singlehtml * Josip Dzolonga -- coverage builder * Juan Luis Cano Rodríguez -- new tutorial (2021) * Julien Palard -- Colspan and rowspan in text builder diff --git a/CHANGES.rst b/CHANGES.rst index 64f94e14ec3..7093ff9ef40 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,6 +66,9 @@ Features added Patch by Adam Turner. * #13805: LaTeX: add support for ``fontawesome7`` package. Patch by Jean-François B. +* #13739: singlehtml builder: make all ids unique by appending the docname, + matching ``sphinx/environment/adapters/toctree.py``'s ``_resolve_toctree()`` + format. E.g., ``id3`` becomes ``document-path/to/doc#id3``. Bugs fixed ---------- diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 1888f6679d1..9f66d8db318 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -88,6 +88,22 @@ def _get_local_toctree( ) return self.render_partial(toctree)['fragment'] + def prefix_ids_with_docname(self, tree: nodes.document) -> None: + # Append docname to refids and ids using format document-#. + # Compensates for loss of the pathname section of the href, that + # ensures uniqueness in the html builder. + for node in tree.findall(nodes.Element): + doc = node.document + if doc is None: + continue + env = doc.settings.env + if 'refid' in node or 'ids' in node: + docname = env.path2doc(doc['source']) + if 'refid' in node: + node['refid'] = 'document-' + docname + '#' + node['refid'] + if 'ids' in node: + node['ids'] = ['document-' + docname + '#' + id for id in node['ids']] + def assemble_doctree(self) -> nodes.document: master = self.config.root_doc tree = self.env.get_doctree(master) @@ -95,14 +111,16 @@ 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.prefix_ids_with_docname(tree) return tree def assemble_toc_secnumbers(self) -> dict[str, dict[str, tuple[int, ...]]]: # Assemble toc_secnumbers to resolve section numbers on SingleHTML. # Merge all secnumbers to single secnumber. # - # Note: current Sphinx has refid confliction in singlehtml mode. - # To avoid the problem, it replaces key of secnumbers to + # Note: current Sphinx patches refid with docname to avoid confliction + # in singlehtml mode. + # To match the patch, it replaces key of secnumbers to # tuple of docname and refid. # # There are related codes in inline_all_toctres() and @@ -110,7 +128,7 @@ def assemble_toc_secnumbers(self) -> dict[str, dict[str, tuple[int, ...]]]: new_secnumbers: dict[str, tuple[int, ...]] = {} for docname, secnums in self.env.toc_secnumbers.items(): for id, secnum in secnums.items(): - alias = f'{docname}/{id}' + alias = f'document-{docname}{id}' new_secnumbers[alias] = secnum return {self.config.root_doc: new_secnumbers} @@ -121,8 +139,9 @@ def assemble_toc_fignumbers( # Assemble toc_fignumbers to resolve figure numbers on SingleHTML. # Merge all fignumbers to single fignumber. # - # Note: current Sphinx has refid confliction in singlehtml mode. - # To avoid the problem, it replaces key of secnumbers to + # Note: current Sphinx patches refid with docname to avoid confliction + # in singlehtml mode. + # To match the patch, it replaces key of secnumbers to # tuple of docname and refid. # # There are related codes in inline_all_toctres() and @@ -131,9 +150,10 @@ def assemble_toc_fignumbers( # {'foo': {'figure': {'id2': (2,), 'id1': (1,)}}, 'bar': {'figure': {'id1': (3,)}}} for docname, fignumlist in self.env.toc_fignumbers.items(): for figtype, fignums in fignumlist.items(): - alias = f'{docname}/{figtype}' + alias = f'document-{docname}#{figtype}' new_fignumbers.setdefault(alias, {}) for id, fignum in fignums.items(): + id = f'document-{docname}#{id}' new_fignumbers[alias][id] = fignum return {self.config.root_doc: new_fignumbers} diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index bbcd247e33c..7d717c68ff6 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -395,10 +395,10 @@ def get_secnumber(self, node: Element) -> tuple[int, ...] | None: if isinstance(node.parent, nodes.section): if self.builder.name == 'singlehtml': docname = self.docnames[-1] - anchorname = f'{docname}/#{node.parent["ids"][0]}' + anchorname = node.parent['ids'][0] if anchorname not in self.builder.secnumbers: # try first heading which has no anchor - anchorname = f'{docname}/' + anchorname = f'document-{docname}' else: anchorname = '#' + node.parent['ids'][0] if anchorname not in self.builder.secnumbers: @@ -420,7 +420,7 @@ def add_secnumber(self, node: Element) -> None: def add_fignumber(self, node: Element) -> None: def append_fignumber(figtype: str, figure_id: str) -> None: if self.builder.name == 'singlehtml': - key = f'{self.docnames[-1]}/{figtype}' + key = f'document-{self.docnames[-1]}#{figtype}' else: key = figtype diff --git a/tests/roots/test-tocdepth/bar.rst b/tests/roots/test-tocdepth/bar.rst index d70dec90dd3..2bb869e93c5 100644 --- a/tests/roots/test-tocdepth/bar.rst +++ b/tests/roots/test-tocdepth/bar.rst @@ -25,3 +25,8 @@ Bar B1 should be 2.2.1 +FooBar B1 +--------- + +should be 2.2.2 + diff --git a/tests/roots/test-tocdepth/foo.rst b/tests/roots/test-tocdepth/foo.rst index 61fd539ffea..4834cb6eb14 100644 --- a/tests/roots/test-tocdepth/foo.rst +++ b/tests/roots/test-tocdepth/foo.rst @@ -24,3 +24,8 @@ Foo B1 should be 1.2.1 +FooBar B1 +--------- + +should be 1.2.2 + diff --git a/tests/test_builders/test_build_html_tocdepth.py b/tests/test_builders/test_build_html_tocdepth.py index 0fe83e0ff34..1c02b0da415 100644 --- a/tests/test_builders/test_build_html_tocdepth.py +++ b/tests/test_builders/test_build_html_tocdepth.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest @@ -12,7 +12,7 @@ if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path - from xml.etree.ElementTree import ElementTree + from xml.etree.ElementTree import Element, ElementTree from sphinx.testing.util import SphinxTestApp @@ -134,3 +134,17 @@ def test_tocdepth_singlehtml( ) -> None: app.build() check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) + + +@pytest.mark.sphinx('singlehtml', testroot='tocdepth') +@pytest.mark.test_params(shared_result='test_build_html_tocdepth') +def test_unique_ids_singlehtml( + app: SphinxTestApp, + cached_etree_parse: Callable[[Path], ElementTree], +) -> None: + app.build() + tree = cached_etree_parse(app.outdir / 'index.html') + root = cast('Element', tree.getroot()) + + ids = [el.attrib['id'] for el in root.findall('.//*[@id]')] + assert len(ids) == len(set(ids))