Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ 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
``/<docname>/#<id>``, to ensure uniqueness. For example, ``id3`` becomes
``/path/to/doc/#id3``. This is a breaking change since it alters the format
of the ids in both the content body and the toctree. Fixes toctree
refid format ``document-<docname>`` that did not match the id in the body.

Bugs fixed
----------
Expand Down
34 changes: 27 additions & 7 deletions sphinx/builders/singlehtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,29 +88,47 @@ 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-<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):
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'] = 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)
logger.info(darkgreen(master))
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
# HTMLTranslter#add_secnumber().
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}
Expand All @@ -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
Expand All @@ -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'/{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}
Expand Down
8 changes: 4 additions & 4 deletions sphinx/writers/html5.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<span id="document-%s"></span>' % node['docname'])
self.body.append('<span id="/%s/"></span>' % node['docname'])

def depart_start_of_file(self, node: Element) -> None:
self.docnames.pop()
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions tests/roots/test-tocdepth/bar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ Bar B1

should be 2.2.1

FooBar B1
---------

should be 2.2.2

5 changes: 5 additions & 0 deletions tests/roots/test-tocdepth/foo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ Foo B1

should be 1.2.1

FooBar B1
---------

should be 1.2.2

18 changes: 16 additions & 2 deletions tests/test_builders/test_build_html_tocdepth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

import pytest

Expand All @@ -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

Expand Down Expand Up @@ -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))
Loading