Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGES

Unreleased
----------
- Bugfix for TOC expand/collapse not working well across projects.

2026/01/08 0.46.0
-----------------
Expand All @@ -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.
Expand Down
31 changes: 25 additions & 6 deletions docs/tests/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
76 changes: 68 additions & 8 deletions src/crate/theme/rtd/crate/static/js/custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ul>
// 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 <ul> 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 <ul> has children, default behavior (toggle checkbox) applies
});
});
});
152 changes: 109 additions & 43 deletions src/crate/theme/rtd/sidebartoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ul> 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'<li class="{li_class}">')
self.parts.append(f'<a href="{entry_url}">{entry_name}</a>')
# Empty <ul> triggers Furo to add has-children class and icon structure
self.parts.append('<ul></ul>')
self.parts.append('</li>')

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'<li class="current{border}">')
self.parts.append(f'<a class="current-active" href="{self.master_path}">{display_name}</a>')
self.parts.append(self.toctree())
self.parts.append('</li>')
return

if public_docs:
self.add_nav_link(display_name, url_if_not_current, 'navleft-item', border_top)


def _generate_crate_navigation_html(context):
Expand All @@ -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,
Expand All @@ -46,35 +102,15 @@ def get_toctree(maxdepth=-1, titles_only=True, collapse=False):
master_path = context["pathto"](master_doc)

parts = ['<ul class="toctree nav nav-list">']

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'<li class="current{border}">')
parts.append(f'<a class="current-active" href="{master_path}">{display_name}</a>')
if include_toctree:
parts.append(get_toctree())
parts.append('</li>')
else:
if only_if_current_project:
return
parts.append(f'<li class="navleft-item{border}"><a href="{url_if_not_current}">{display_name}</a></li>')
builder = _NavBuilder(parts, project, master_path, _get_toctree)


# Special project used standalone
if project == 'SQL 99':
current_class = ' class="current"' if pagename == master_doc else ''
parts.append(f'<li{current_class}>')
parts.append(f'<a class="current-active" href="{master_path}">SQL-99 Complete, Really</a>')
parts.append(get_toctree(maxdepth=2))
parts.append(_get_toctree(maxdepth=2))
parts.append('</li>')
return ''.join(parts)

Expand All @@ -86,7 +122,8 @@ def _add_project_nav_item(
parts.append('</div>')
parts.append('</li>')

# 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('<li class="current">')
Expand All @@ -96,24 +133,25 @@ def _add_project_nav_item(
parts.append('<li class="navleft-item">')
parts.append(f'<a href="{master_path}">Overview</a>')
parts.append('</li>')
parts.append(get_toctree())
parts.append(_get_toctree())
else:
parts.append('<li class="current"><a class="current-active" href="#">Overview</a></li>')
# Show Overview link to Guide's index (no icon - it's just an index page)
parts.append('<li class="navleft-item"><a href="/docs/guide/">Overview</a></li>')
# 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('<li class="navleft-item">')
parts.append('<a href="/docs/guide/connect/drivers.html">Database Drivers</a>')
parts.append('</li>')

# The <ul> must be inside the same <li> for CSS sibling selectors to work
_DRIVER_CONFIGS = [
('CrateDB JDBC', 'JDBC', '/docs/jdbc/'),
('CrateDB DBAL', 'PHP DBAL', '/docs/dbal/'),
Expand All @@ -122,22 +160,39 @@ 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('<li><ul>')
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'<li class="{driver_class}"{driver_marker}>')
parts.append(f'<a{driver_link_class} href="/docs/guide/connect/drivers.html">Database Drivers</a>')
# Furo will add has-children class and icon structure when it detects the <ul>
parts.append('<ul>')
if show_drivers:
for proj_name, display_name, url in _DRIVER_CONFIGS:
_add_project_nav_item(proj_name, display_name, url)
parts.append('</ul></li>')
builder.add_project_nav_item(proj_name, display_name, url)
parts.append('</ul>')
parts.append('</li>')


# Add Support and Community links section after a border
parts.append('<li class="navleft-item border-top"><a target="_blank" href="/support/">Support</a></li>')
parts.append('<li class="navleft-item"><a target="_blank" href="https://community.cratedb.com/">Community</a></li>')


# 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'<li class="navleft-item border-top" style="font-size: 0.75em; color: #888; padding-top: 1em;">')
parts.append(f'Built: {build_time}')
parts.append('</li>')

parts.append('</ul>')
return ''.join(parts)
Expand All @@ -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