diff --git a/CHANGES.rst b/CHANGES.rst
index 1adacce4..0d87feeb 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,7 @@ CHANGES
Unreleased
----------
+- Bugfix for TOC expand/collapse not working well across projects.
2026/01/08 0.46.0
-----------------
@@ -15,6 +16,9 @@ Unreleased
navigation enhancements. Originally introduced in 0.43.0, reverted in 0.44.0.
- CrateDB Npgsql docs were removed.
+This release was yanked, because the navigation didn't work across projects and
+couldn't be tested locally.
+
2025/12/19 0.45.0
-----------------
- Reverted silencing build warnings, because it made the navigation bar look odd.
diff --git a/docs/tests/index.rst b/docs/tests/index.rst
index 099165ab..ff940e83 100644
--- a/docs/tests/index.rst
+++ b/docs/tests/index.rst
@@ -4,12 +4,31 @@
Navigation bar test pages
#########################
-1. Clicking the title should expand the section and navigate to the section page
-2. Clicking just the icon should expand but not navigate to the section
-3. Clicking just the icon for an expanded section should collapse that section and leave other expanded sections expanded
-4. Hovering the mouse over an icon should show a fade background behind the icon
-5. Hovering the mouse over the title should show a fade background behind the title and the icon
-6. The current page should be highlighted in the navigation bar as the user navigates through the pages below.
+**Same-project entries (entries with actual TOC content):**
+
+1. Clicking the title expands the section, collapses sibling sections at the
+ same level, and navigates to the section page
+2. Clicking just the icon expands/collapses that section without navigating
+3. Clicking the icon for an expanded section collapses it, leaving other
+ expanded sections unchanged
+
+**Cross-project entries (entries linking to other projects):**
+
+4. Clicking the title navigates to that project
+5. Clicking just the icon also navigates to that project (since the TOC
+ content from another project isn't available to expand)
+
+**Visual feedback:**
+
+6. Hovering the mouse over an icon shows a fade background behind the icon
+7. Hovering the mouse over the title shows a fade background behind the title
+ and the icon
+8. The current page is highlighted in the navigation bar
+
+**Auto-expansion:**
+
+9. The Database Drivers section auto-expands when viewing a driver project
+ (only on first visit; user preference is respected thereafter)
**Pages:**
diff --git a/src/crate/theme/rtd/crate/static/js/custom.js b/src/crate/theme/rtd/crate/static/js/custom.js
index 4d2a829e..ed542ff7 100644
--- a/src/crate/theme/rtd/crate/static/js/custom.js
+++ b/src/crate/theme/rtd/crate/static/js/custom.js
@@ -79,25 +79,85 @@ document.addEventListener('DOMContentLoaded', () => {
// Restore state on page load
restoreNavState();
+ // Auto-expand sections marked with data-auto-expand="true"
+ // Used for Database Drivers when viewing a driver project.
+ // Only auto-expand if user hasn't explicitly set a preference for this checkbox.
+ const savedStates = localStorage.getItem('navState');
+ let userPreferences = {};
+ if (savedStates) {
+ try {
+ userPreferences = JSON.parse(savedStates);
+ } catch (e) {
+ // Ignore parse errors, treat as no preferences
+ }
+ }
+ let autoExpandStateChanged = false;
+
+ document.querySelectorAll('[data-auto-expand="true"]').forEach((li) => {
+ const checkbox = li.querySelector('.toctree-checkbox');
+ if (checkbox && checkbox.id) {
+ // Only auto-expand if user has no saved preference for this checkbox
+ if (!(checkbox.id in userPreferences)) {
+ checkbox.checked = true;
+ autoExpandStateChanged = true;
+ }
+ }
+ });
+
+ // Save the auto-expanded state so it persists
+ if (autoExpandStateChanged) {
+ saveNavState();
+ }
+
// Save state when checkboxes change
document.querySelectorAll('.toctree-checkbox').forEach((checkbox) => {
checkbox.addEventListener('change', saveNavState);
});
- // Make clicking the link text expand the section if collapsed, then navigate
- // Design: Click expands collapsed sections AND navigates to the page.
- // Already-expanded sections just navigate (no toggle). This allows users to
- // expand nested navigation while browsing, without collapsing sections they
- // want to keep visible.
+ // Make clicking the link text expand the section and collapse siblings.
+ // This provides consistent UX: clicking any title shows only that section's
+ // children, matching what happens with cross-project navigation.
document.querySelectorAll('.bs-docs-sidenav li.has-children > a, .bs-docs-sidenav li.has-children > .reference').forEach((link) => {
link.addEventListener('click', () => {
const li = link.parentElement;
const checkbox = li.querySelector('.toctree-checkbox');
- if (checkbox && !checkbox.checked) {
- // Only expand if collapsed - navigation proceeds regardless
+
+ // Collapse sibling sections at the same level
+ const parent = li.parentElement;
+ if (parent) {
+ parent.querySelectorAll(':scope > li.has-children > .toctree-checkbox').forEach((siblingCheckbox) => {
+ if (siblingCheckbox !== checkbox && siblingCheckbox.checked) {
+ siblingCheckbox.checked = false;
+ }
+ });
+ }
+
+ // Expand this section
+ if (checkbox) {
checkbox.checked = true;
- saveNavState();
}
+
+ saveNavState();
+ });
+ });
+
+ // Cross-project navigation: clicking expand icon on entries with empty
+ // should navigate to that project instead of just toggling the checkbox.
+ // It's ok UX, but also just plain needed as we can't expand the TOC of another project :-(
+ document.querySelectorAll('.bs-docs-sidenav li.has-children > label').forEach((label) => {
+ label.addEventListener('click', (e) => {
+ const li = label.parentElement;
+ const ul = li.querySelector(':scope > ul');
+ // Check if is empty (cross-project entry)
+ if (ul && ul.children.length === 0) {
+ const link = li.querySelector(':scope > a');
+ if (link && link.href) {
+ e.preventDefault();
+ e.stopPropagation();
+ window.location.href = link.href;
+ }
+ }
+ // If has children, default behavior (toggle checkbox) applies
});
});
});
diff --git a/src/crate/theme/rtd/sidebartoc.py b/src/crate/theme/rtd/sidebartoc.py
index d9a2f7e2..29218984 100644
--- a/src/crate/theme/rtd/sidebartoc.py
+++ b/src/crate/theme/rtd/sidebartoc.py
@@ -20,6 +20,62 @@
# software solely pursuant to the terms of the relevant commercial agreement.
from furo.navigation import get_navigation_tree
+from datetime import datetime
+import os
+import re
+
+
+def _slugify_id(text):
+ """Normalize text to a safe HTML ID: alphanumerics and hyphens only."""
+ s = re.sub(r'[^a-z0-9-]', '-', text.lower())
+ s = re.sub(r'-{2,}', '-', s) # collapse multiple hyphens
+ return s.strip('-')
+
+
+class _NavBuilder:
+ """Helper to build navigation HTML."""
+
+ def __init__(self, parts, project, master_path, toctree_fn):
+ self.parts = parts
+ self.project = project
+ self.master_path = master_path
+ self.toctree = toctree_fn
+
+ def add_nav_link(self, entry_name, entry_url, li_base_class='toctree-l1', border_top=False):
+ """Add a cross-project navigation link with expand icon.
+
+ Includes an empty so Furo's get_navigation_tree() adds the
+ checkbox/icon structure. Since cross-project TOC content isn't
+ available, clicking the icon navigates to that project instead
+ of expanding (handled by JS in custom.js).
+ """
+ border = " border-top" if border_top else ""
+ li_class = f'{li_base_class}{border}'
+ self.parts.append(f'- ')
+ self.parts.append(f'{entry_name}')
+ # Empty
triggers Furo to add has-children class and icon structure
+ self.parts.append('')
+ self.parts.append('
')
+
+ def add_project_nav_item(
+ self,
+ project_name,
+ display_name,
+ url_if_not_current,
+ border_top=False,
+ public_docs=True
+ ):
+ """Add a navigation item in left navbar for a project."""
+ border = " border-top" if border_top else ""
+ if self.project == project_name:
+ self.parts.append(f'- ')
+ self.parts.append(f'{display_name}')
+ self.parts.append(self.toctree())
+ self.parts.append('
')
+ return
+
+ if public_docs:
+ self.add_nav_link(display_name, url_if_not_current, 'navleft-item', border_top)
def _generate_crate_navigation_html(context):
@@ -32,7 +88,7 @@ def _generate_crate_navigation_html(context):
return ""
theme_globaltoc_includehidden = context.get("theme_globaltoc_includehidden", True)
- def get_toctree(maxdepth=-1, titles_only=True, collapse=False):
+ def _get_toctree(maxdepth=-1, titles_only=True, collapse=False):
return toctree(
maxdepth=maxdepth,
titles_only=titles_only,
@@ -46,27 +102,7 @@ def get_toctree(maxdepth=-1, titles_only=True, collapse=False):
master_path = context["pathto"](master_doc)
parts = ['']
-
- def _add_project_nav_item(
- project_name,
- display_name,
- url_if_not_current,
- border_top=False,
- include_toctree=True,
- only_if_current_project=False
- ):
- """Add a navigation item in left navbar for a specific project."""
- border = " border-top" if border_top else ""
- if project == project_name:
- parts.append(f'- ')
- parts.append(f'{display_name}')
- if include_toctree:
- parts.append(get_toctree())
- parts.append('
')
- else:
- if only_if_current_project:
- return
- parts.append(f'- {display_name}
')
+ builder = _NavBuilder(parts, project, master_path, _get_toctree)
# Special project used standalone
@@ -74,7 +110,7 @@ def _add_project_nav_item(
current_class = ' class="current"' if pagename == master_doc else ''
parts.append(f'- ')
parts.append(f'SQL-99 Complete, Really')
- parts.append(get_toctree(maxdepth=2))
+ parts.append(_get_toctree(maxdepth=2))
parts.append('
')
return ''.join(parts)
@@ -86,7 +122,8 @@ def _add_project_nav_item(
parts.append('')
parts.append('')
- # Add Overview and top level entries defined in the Guide's toctree
+ # Add Overview and top level entries defined in the Guide's toctree.
+ # The Guide project is the only one that has multiple top-level entries.
if project == 'CrateDB: Guide':
if pagename == 'index':
parts.append('- ')
@@ -96,24 +133,25 @@ def _add_project_nav_item(
parts.append('
- ')
parts.append(f'Overview')
parts.append('
')
- parts.append(get_toctree())
+ parts.append(_get_toctree())
else:
- parts.append('- Overview
')
+ # Show Overview link to Guide's index (no icon - it's just an index page)
+ parts.append('- Overview
')
+ # Add Guide's level 1 entries with icons
+ builder.add_nav_link('Getting Started','/docs/guide/start/')
+ builder.add_nav_link('Handbook', '/docs/guide/handbook/')
# Add individual projects
- _add_project_nav_item('CrateDB Cloud', 'CrateDB Cloud', '/docs/cloud/')
- _add_project_nav_item('CrateDB: Reference', 'Reference Manual', '/docs/crate/reference/')
+ builder.add_project_nav_item('CrateDB Cloud', 'CrateDB Cloud', '/docs/cloud/')
+ builder.add_project_nav_item('CrateDB: Reference', 'Reference Manual', '/docs/crate/reference/')
# Start new section with a border
- _add_project_nav_item('CrateDB: Admin UI', 'Admin UI', '/docs/crate/admin-ui/', border_top=True)
- _add_project_nav_item('CrateDB: Crash CLI', 'CrateDB CLI', '/docs/crate/crash/')
- _add_project_nav_item('CrateDB Cloud: Croud CLI', 'Cloud CLI', '/docs/cloud/cli/')
+ builder.add_project_nav_item('CrateDB: Admin UI', 'Admin UI', '/docs/crate/admin-ui/', border_top=True)
+ builder.add_project_nav_item('CrateDB: Crash CLI', 'CrateDB CLI', '/docs/crate/crash/')
+ builder.add_project_nav_item('CrateDB Cloud: Croud CLI', 'Cloud CLI', '/docs/cloud/cli/')
# Add all Driver projects
- parts.append('- ')
- parts.append('Database Drivers')
- parts.append('
')
-
+ # The must be inside the same - for CSS sibling selectors to work
_DRIVER_CONFIGS = [
('CrateDB JDBC', 'JDBC', '/docs/jdbc/'),
('CrateDB DBAL', 'PHP DBAL', '/docs/dbal/'),
@@ -122,11 +160,23 @@ def _add_project_nav_item(
('SQLAlchemy Dialect', 'SQLAlchemy', '/docs/sqlalchemy-cratedb/'),
]
driver_projects = [config[0] for config in _DRIVER_CONFIGS]
- if project in driver_projects or (project == 'CrateDB: Guide' and pagename.startswith('connect')):
- parts.append('
')
+ show_drivers = project in driver_projects or (project == 'CrateDB: Guide' and pagename.startswith('connect'))
+
+ # Use data attribute to mark Database Drivers for auto-expansion
+ driver_marker = ' data-auto-expand="true"' if show_drivers else ''
+ # Add current class when viewing a driver page to make it bold
+ driver_class = 'current' if show_drivers else 'navleft-item'
+ driver_link_class = ' class="current-active"' if show_drivers else ''
+ parts.append(f'- ')
+ parts.append(f'Database Drivers')
+ # Furo will add has-children class and icon structure when it detects the
+ parts.append('')
+ if show_drivers:
for proj_name, display_name, url in _DRIVER_CONFIGS:
- _add_project_nav_item(proj_name, display_name, url)
- parts.append('
')
+ builder.add_project_nav_item(proj_name, display_name, url)
+ parts.append('
')
+ parts.append(' ')
+
# Add Support and Community links section after a border
parts.append('- Support
')
@@ -134,10 +184,15 @@ def _add_project_nav_item(
# Other internal docs projects only included in special builds
- _add_project_nav_item('CrateDB documentation theme', 'Documentation theme', '',
- border_top=True, only_if_current_project=True)
- _add_project_nav_item('Doing Docs', 'Doing Docs at CrateDB', '',
- only_if_current_project=True)
+ builder.add_project_nav_item('CrateDB documentation theme', 'Documentation theme', '', border_top=True, public_docs=False)
+ builder.add_project_nav_item('Doing Docs', 'Doing Docs at CrateDB', '', public_docs=False)
+
+ # Show build timestamp for local development (not on Read the Docs)
+ if not os.environ.get('READTHEDOCS'):
+ build_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ parts.append(f'- ')
+ parts.append(f'Built: {build_time}')
+ parts.append('
')
parts.append('
')
return ''.join(parts)
@@ -160,5 +215,16 @@ def add_crate_navigation(app, pagename, templatename, context, doctree):
# Process through Furo's navigation enhancer
enhanced_navigation = get_navigation_tree(navigation_html)
+ # Make checkbox IDs unique per project to prevent localStorage state collision.
+ # Furo generates sequential IDs (toctree-checkbox-1, toctree-checkbox-2, etc.)
+ # which collide across projects. Add project slug prefix to make them unique.
+ project_slug = _slugify_id(context.get("project", ""))
+ if project_slug:
+ enhanced_navigation = re.sub(
+ r'toctree-checkbox-(\d+)',
+ f'toctree-checkbox-{project_slug}-\\1',
+ enhanced_navigation
+ )
+
# Add to context for use in templates
context["crate_navigation_tree"] = enhanced_navigation