From 22e02cd65b92295950dbff85e7ddcafbf4a750fb Mon Sep 17 00:00:00 2001 From: Jorge Marques Date: Sat, 30 Aug 2025 19:10:31 +0200 Subject: [PATCH 1/5] Add section with same title for test-tocdepth/[foo/bar] To assert unique ids in singlehtml builder. Signed-off-by: Jorge Marques --- tests/roots/test-tocdepth/bar.rst | 5 +++++ tests/roots/test-tocdepth/foo.rst | 5 +++++ 2 files changed, 10 insertions(+) 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 + From 4399a2856cea46418c41ea7f0894e8e0cad0e94c Mon Sep 17 00:00:00 2001 From: Jorge Marques Date: Sat, 30 Aug 2025 18:59:02 +0200 Subject: [PATCH 2/5] Add test to assert unique ids in singlehtml output Since the singlehtml aggregates all doc files into a single html page during the write step, and the ids must be unique for proper link anchoring, add test that collects all ids in the page and checks if all ids are unique, by asserting the length of the list against it as a set. --- .../test_builders/test_build_html_tocdepth.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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)) From 91bbe106a0e5c2f00bd1660b2a4f9cb0b43251f0 Mon Sep 17 00:00:00 2001 From: Jorge Marques Date: Wed, 3 Sep 2025 00:08:31 +0200 Subject: [PATCH 3/5] Update AUTHORS.rst and CHANGES.rst --- AUTHORS.rst | 1 + CHANGES.rst | 6 ++++++ 2 files changed, 7 insertions(+) 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 ---------- From 04e45397cbf60bef03fd42a3d7493b05650f9911 Mon Sep 17 00:00:00 2001 From: Jorge Marques Date: Wed, 3 Sep 2025 00:02:28 +0200 Subject: [PATCH 4/5] [singlehtml] Append docname to refid and ids Use doc path to make ids unique with format ``/docname/#id``. Compensates for the loss of the pathname in the href. This will break existing hyperlinks to `singlehtml` HTML documents since it alters the format --- sphinx/builders/singlehtml.py | 17 ++++++++++++++++- sphinx/writers/html5.py | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 1888f6679d1..8d424448f24 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, ...]]]: diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index bbcd247e33c..45fe3d9b7e6 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() From 3de21545d575157046f90744ecc66909d614f4aa Mon Sep 17 00:00:00 2001 From: Jorge Marques Date: Sat, 6 Sep 2025 18:12:45 +0200 Subject: [PATCH 5/5] [singlehtml] Reformat fignum and secnum tuple Format as ``/docname/#id`` to match other parts. --- sphinx/builders/singlehtml.py | 5 +++-- sphinx/writers/html5.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 8d424448f24..9a377660b50 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -125,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} @@ -146,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 45fe3d9b7e6..8b63211038d 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 = '/' + 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