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..54d605ec03a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -66,6 +66,12 @@ Features added Patch by Adam Turner. * #13805: LaTeX: add support for ``fontawesome7`` package. Patch by Jean-François B. +* #13739: singlehtml builder: append the docname to ids with format + ``//#``, to ensure uniqueness. For example, ``id3`` becomes + ``/path/to/doc/#id3``. This will break existing hyperlinks to ``singlehtml`` + HTML documents since it alters the format of the ids in both the content body + and the toctree. Fixes toctree refid format ``document-`` that did + not match the id in the body. Bugs fixed ---------- diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 1888f6679d1..9a377660b50 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -42,7 +42,7 @@ def get_outdated_docs(self) -> str | list[str]: # type: ignore[override] def get_target_uri(self, docname: str, typ: str | None = None) -> str: if docname in self.env.all_docs: # all references are on the same page... - return '#document-' + docname + return '#/' + docname + '/' else: # chances are this is a html_additional_page return docname + self.out_suffix @@ -88,6 +88,20 @@ def _get_local_toctree( ) return self.render_partial(toctree)['fragment'] + def ensure_fully_qualified_refids(self, tree: nodes.document) -> None: + """Append docname to refids and ids using format + /docname/#id. Compensates for loss of the pathname section + of the href, that ensures uniqueness in the html builder. + """ + for node in tree.findall(nodes.Element): + assert node.document is not None + if 'refid' in node or 'ids' in node: + docname = self.env.path2doc(node.document['source']) + if 'refid' in node: + node['refid'] = f'/{docname}/#{node["refid"]}' + if 'ids' in node: + node['ids'] = [f'/{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,6 +109,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.ensure_fully_qualified_refids(tree) return tree def assemble_toc_secnumbers(self) -> dict[str, dict[str, tuple[int, ...]]]: @@ -110,7 +125,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'/{docname}/{id}' new_secnumbers[alias] = secnum return {self.config.root_doc: new_secnumbers} @@ -131,9 +146,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'/{docname}/#{figtype}' new_fignumbers.setdefault(alias, {}) for id, fignum in fignums.items(): + id = f'/{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..8b63211038d 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -67,7 +67,7 @@ def __init__(self, document: nodes.document, builder: Builder) -> None: def visit_start_of_file(self, node: Element) -> None: # only occurs in the single-file builder self.docnames.append(node['docname']) - self.body.append('' % node['docname']) + self.body.append('' % node['docname']) def depart_start_of_file(self, node: Element) -> None: self.docnames.pop() @@ -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 = '/' + 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'/{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))