diff --git a/extensions/sphinx_inline_tabs/directive.py b/extensions/sphinx_inline_tabs/directive.py index def0daa9f7f..1853c95ca3b 100644 --- a/extensions/sphinx_inline_tabs/directive.py +++ b/extensions/sphinx_inline_tabs/directive.py @@ -1,8 +1,7 @@ """ Sphinx directive implementation. """ - -from typing import Final +from typing import Final, Optional from docutils import nodes from docutils.parsers.rst import directives @@ -19,7 +18,8 @@ INLINE_TAB_DOCNAMES: Final[str] = "inline_tab_docnames" """The key in the Sphinx environment to store a list of documents with inline tabs""" - +GENERATED_TAB_IDS: Final[str] = "generated_tab_ids" +"""The key in the Sphinx environment to store a list of generated tab IDs""" class TabDirective(SphinxDirective): """Tabbed content in Sphinx documentation.""" @@ -73,8 +73,17 @@ def run(self) -> list[nodes.Node]: Walk the parsed content and add new `id` attributes on section nodes that will be used for HTML page navigation. """ + if not hasattr(self.env, GENERATED_TAB_IDS): + setattr(self.env, GENERATED_TAB_IDS, dict()) + all_generated_tab_ids: dict[str, set[str]] = getattr(self.env, GENERATED_TAB_IDS) + if self.env.docname not in all_generated_tab_ids: + all_generated_tab_ids[self.env.docname] = set() + generated_tab_ids: set[str] = all_generated_tab_ids[self.env.docname] self.walk_parsed_nodes( - [container], tab_name=_make_id(self.arguments[0]), docname=self.env.docname + [container], + tab_name=_make_id(self.arguments[0]), + docname=self.env.docname, + generated_tab_ids=generated_tab_ids, ) """ @@ -96,6 +105,7 @@ def walk_parsed_nodes( tab_name: str = "", docname: str = "", tab_counter: int = 0, + generated_tab_ids: Optional[set[str]] = None, ): """ Recursively walk a docutils node tree, adding new `id` attributes to TabContainer and section nodes. @@ -106,10 +116,13 @@ def walk_parsed_nodes( :param tab_name: The name of the tab, encoded as a Sphinx `id` attribute. :param docname: The document name. :param tab_counter: The current tab counter. + :param generated_tab_ids: A set of generated tab IDs to ensure they are unique in the document. :return: """ + if generated_tab_ids is None: + generated_tab_ids = set() for parsed_node in parsed_nodes: - node_info = ( + node_info: str = ( f"type(parsed_node)={type(parsed_node)}; " f"has_ids={True if hasattr(parsed_node, 'attributes') and parsed_node.attributes["ids"] else False}; " f"parent={parsed_node.parent!r}" @@ -136,6 +149,7 @@ def walk_parsed_nodes( tab_count=tab_counter + 1, node_id=_make_id(tab_container_name), ) + tab_id.ensure_unique(generated_tab_ids) parsed_node.tab_counter = tab_counter + 1 parsed_node.is_parsed = True parsed_node.tab_id = tab_id @@ -143,6 +157,7 @@ def walk_parsed_nodes( f"{LOG_PREFIX} walk_parsed_nodes({docname}|{tab_name}): [{level}/{tab_counter}] {tab_container_name}; set ids to [{str(tab_id)}]" ) parsed_node.attributes["ids"] = [str(tab_id)] + generated_tab_ids.add(str(tab_id)) elif isinstance(parsed_node, nodes.section): section_title: str = parsed_node.next_node(nodes.title).astext() @@ -162,6 +177,7 @@ def walk_parsed_nodes( tab_count=tab_counter, node_id=node_id, ) + new_id.ensure_unique(generated_tab_ids) logger.debug( f"{LOG_PREFIX} walk_parsed_nodes({docname}|{tab_name}): [{level}/{tab_counter}] section={node_id}, new_id={str(new_id)}" ) @@ -174,6 +190,7 @@ def walk_parsed_nodes( f"{LOG_PREFIX} walk_parsed_nodes({docname}|{tab_name}): [{level}/{tab_counter}] set attributes['ids'] to {new_ids}" ) parsed_node.attributes["ids"] = new_ids + generated_tab_ids.add(str(new_id)) else: logger.warning( f"{LOG_PREFIX} walk_parsed_nodes({docname}|{tab_name}): [{level}/{tab_counter}] section has no IDs; this is unexpected" @@ -194,11 +211,12 @@ def walk_parsed_nodes( child_tab_name, docname, tab_counter + 1, + generated_tab_ids, ) else: logger.debug( f"{LOG_PREFIX} walk_parsed_nodes({docname}|{tab_name}): [{level}/{tab_counter}] {type(parsed_node)}(); parse {len(parsed_node.children)} children" ) self.walk_parsed_nodes( - parsed_node.children, level + 1, tab_name, docname, tab_counter + parsed_node.children, level + 1, tab_name, docname, tab_counter, generated_tab_ids ) diff --git a/extensions/sphinx_inline_tabs/static/tabs.js b/extensions/sphinx_inline_tabs/static/tabs.js index ba319c5e1c1..a7a3002e045 100644 --- a/extensions/sphinx_inline_tabs/static/tabs.js +++ b/extensions/sphinx_inline_tabs/static/tabs.js @@ -16,7 +16,7 @@ const labelsByName = {}; const labelsById = {}; // Tab ID pattern: "itab--{tab_name}--{level}_{tab_count}-{node_id}" -const inlinetabRE = new RegExp('itab--([a-zA-Z0-9- ]+)--([0-9]+)_([0-9]+)-(.*)'); +const inlinetabRE = new RegExp('itab--([a-zA-Z0-9-.:,\'\(\) ]+)--([0-9]+)_([0-9]+)-(.*)'); const SCROLL_CURRENT = "scroll-current"; /** @@ -114,6 +114,11 @@ function ready() { // Reset the active section in the ToC updateScrollCurrentForHash(""); + + // If there's a hash in the URL, handle it now + if (window.location.hash !== "") { + onHashchange(); + } // Register the hashchange handler window.addEventListener("hashchange", onHashchange); @@ -165,8 +170,6 @@ function onHashchange() { if (hash in labelsByName && labelsByName[hash].length > 0) { const labelElement = labelsByName[hash][0]; if (labelElement) { - console.debug(`sphinx_inline_tabs: labelsByName[${hash}][0].click()`); - labelElement.click(); // If the TabId contains the name of a parent tab, extract it and click its label let maybeParentId = tabId.tabName.replace(`-${tabId.nodeId}`, ""); console.debug(`sphinx_inline_tabs: maybeParentId=${maybeParentId}`); @@ -177,6 +180,10 @@ function onHashchange() { parentLabelElement.click(); } } + // Click the desired tab + console.debug(`sphinx_inline_tabs: labelsByName[${hash}][0].click()`); + labelElement.click(); + // Update the scroll position for the current hash console.debug(`sphinx_inline_tabs: update scroll-current for hash ${hash}`); updateScrollCurrentForHash(hash); return; @@ -188,8 +195,6 @@ function onHashchange() { if (tabId.tabName in labelsById && labelsById[tabId.tabName].length > 0) { const labelElement = labelsById[tabId.tabName][0]; if (labelElement) { - console.debug(`sphinx_inline_tabs: labelsById[${tabId.tabName}][0].click()`); - labelElement.click(); // If the TabId contains the name of a parent tab, extract it and click its label let maybeParentId = tabId.tabName.replace(`-${tabId.nodeId}`, ""); console.debug(`sphinx_inline_tabs: maybeParentId=${maybeParentId}`); @@ -200,6 +205,10 @@ function onHashchange() { parentLabelElement.click(); } } + // Click the desired tab + console.debug(`sphinx_inline_tabs: labelsById[${tabId.tabName}][0].click()`); + labelElement.click(); + // Re-run the current window hash console.debug(`sphinx_inline_tabs: re-run current window hash`); window.location.hash = `${window.location.hash}`; } diff --git a/extensions/sphinx_inline_tabs/tab_id.py b/extensions/sphinx_inline_tabs/tab_id.py index 71eee62afc1..4bacb7d46f6 100644 --- a/extensions/sphinx_inline_tabs/tab_id.py +++ b/extensions/sphinx_inline_tabs/tab_id.py @@ -1,9 +1,14 @@ import re from dataclasses import dataclass from typing import Final, Optional +from sphinx.util import logging from sphinx.util.nodes import _make_id -TAB_ID_PATTERN: Final[str] = "itab--([a-zA-Z0-9-]+)--([0-9]+)_([0-9]+)-(.*)" +TAB_ID_PATTERN: Final[str] = "itab--([a-zA-Z0-9-.:,'() ]+)--([0-9]+)_([0-9]+)-(.*)" +"""Regex pattern for validating tab identifiers.""" + +logger: logging.SphinxLoggerAdapter = logging.getLogger(__name__) +LOG_PREFIX: Final[str] = "[sphinx_inline_tabs]" @dataclass(slots=True) @@ -35,10 +40,22 @@ def __repr__(self): @classmethod def is_tab_id(cls, id_string: str) -> bool: + """ + Check if the given string is a valid tab identifier. + + :param id_string: The string to check. + :return: True if the string is a valid tab identifier, False otherwise. + """ return re.match(TAB_ID_PATTERN, id_string) is not None @classmethod def from_str(cls, id_string: str) -> Optional["TabId"]: + """ + Create a TabId instance from a string representation. + + :param id_string: The string representation of the tab identifier. + :return: A TabId instance if the string is valid, None otherwise. + """ match: Optional[re.Match[str]] = re.match(TAB_ID_PATTERN, id_string) if match is not None: return cls( @@ -48,3 +65,25 @@ def from_str(cls, id_string: str) -> Optional["TabId"]: node_id=match.group(4), ) return None + + def ensure_unique(self, existing_ids: Optional[set[str]]) -> None: + """ + Ensure that the tab identifier is unique within the given set of existing IDs. + + :param existing_ids: The set of existing tab IDs to check against. + :raises ValueError: If existing_ids is None. + """ + if existing_ids is None: + raise ValueError("existing_ids cannot be None") + if str(self) not in existing_ids: + return + logger.debug(f"{LOG_PREFIX} TabId.ensure_unique: {str(self)} is already in the set") + is_unique: bool = False + ctr: int = 1 + original_node_id: str = self.node_id + while not is_unique: + self.node_id = f"{original_node_id}_{ctr}" + if str(self) not in existing_ids: + logger.debug(f"{LOG_PREFIX} TabId.ensure_unique: {str(self)} is unique after appending {ctr}") + is_unique = True + ctr += 1