Skip to content

Commit 8a96053

Browse files
committed
Fix: #682 toc navigation now works across doc projects
1 parent 2427821 commit 8a96053

4 files changed

Lines changed: 203 additions & 57 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGES
55

66
Unreleased
77
----------
8+
- Bugfix for TOC expand/collapse not working well across projects.
89

910
2026/01/08 0.46.0
1011
-----------------
@@ -15,6 +16,9 @@ Unreleased
1516
navigation enhancements. Originally introduced in 0.43.0, reverted in 0.44.0.
1617
- CrateDB Npgsql docs were removed.
1718

19+
This release was yanked, because the navigation didn't work across projects and
20+
couldn't be tested locally.
21+
1822
2025/12/19 0.45.0
1923
-----------------
2024
- Reverted silencing build warnings, because it made the navigation bar look odd.

docs/tests/index.rst

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,31 @@
44
Navigation bar test pages
55
#########################
66

7-
1. Clicking the title should expand the section and navigate to the section page
8-
2. Clicking just the icon should expand but not navigate to the section
9-
3. Clicking just the icon for an expanded section should collapse that section and leave other expanded sections expanded
10-
4. Hovering the mouse over an icon should show a fade background behind the icon
11-
5. Hovering the mouse over the title should show a fade background behind the title and the icon
12-
6. The current page should be highlighted in the navigation bar as the user navigates through the pages below.
7+
**Same-project entries (entries with actual TOC content):**
8+
9+
1. Clicking the title expands the section, collapses sibling sections at the
10+
same level, and navigates to the section page
11+
2. Clicking just the icon expands/collapses that section without navigating
12+
3. Clicking the icon for an expanded section collapses it, leaving other
13+
expanded sections unchanged
14+
15+
**Cross-project entries (entries linking to other projects):**
16+
17+
4. Clicking the title navigates to that project
18+
5. Clicking just the icon also navigates to that project (since the TOC
19+
content from another project isn't available to expand)
20+
21+
**Visual feedback:**
22+
23+
6. Hovering the mouse over an icon shows a fade background behind the icon
24+
7. Hovering the mouse over the title shows a fade background behind the title
25+
and the icon
26+
8. The current page is highlighted in the navigation bar
27+
28+
**Auto-expansion:**
29+
30+
9. The Database Drivers section auto-expands when viewing a driver project
31+
(only on first visit; user preference is respected thereafter)
1332

1433

1534
**Pages:**

src/crate/theme/rtd/crate/static/js/custom.js

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,25 +79,85 @@ document.addEventListener('DOMContentLoaded', () => {
7979
// Restore state on page load
8080
restoreNavState();
8181

82+
// Auto-expand sections marked with data-auto-expand="true"
83+
// Used for Database Drivers when viewing a driver project.
84+
// Only auto-expand if user hasn't explicitly set a preference for this checkbox.
85+
const savedStates = localStorage.getItem('navState');
86+
let userPreferences = {};
87+
if (savedStates) {
88+
try {
89+
userPreferences = JSON.parse(savedStates);
90+
} catch (e) {
91+
// Ignore parse errors, treat as no preferences
92+
}
93+
}
94+
let autoExpandStateChanged = false;
95+
96+
document.querySelectorAll('[data-auto-expand="true"]').forEach((li) => {
97+
const checkbox = li.querySelector('.toctree-checkbox');
98+
if (checkbox && checkbox.id) {
99+
// Only auto-expand if user has no saved preference for this checkbox
100+
if (!(checkbox.id in userPreferences)) {
101+
checkbox.checked = true;
102+
autoExpandStateChanged = true;
103+
}
104+
}
105+
});
106+
107+
// Save the auto-expanded state so it persists
108+
if (autoExpandStateChanged) {
109+
saveNavState();
110+
}
111+
82112
// Save state when checkboxes change
83113
document.querySelectorAll('.toctree-checkbox').forEach((checkbox) => {
84114
checkbox.addEventListener('change', saveNavState);
85115
});
86116

87-
// Make clicking the link text expand the section if collapsed, then navigate
88-
// Design: Click expands collapsed sections AND navigates to the page.
89-
// Already-expanded sections just navigate (no toggle). This allows users to
90-
// expand nested navigation while browsing, without collapsing sections they
91-
// want to keep visible.
117+
// Make clicking the link text expand the section and collapse siblings.
118+
// This provides consistent UX: clicking any title shows only that section's
119+
// children, matching what happens with cross-project navigation.
92120
document.querySelectorAll('.bs-docs-sidenav li.has-children > a, .bs-docs-sidenav li.has-children > .reference').forEach((link) => {
93121
link.addEventListener('click', () => {
94122
const li = link.parentElement;
95123
const checkbox = li.querySelector('.toctree-checkbox');
96-
if (checkbox && !checkbox.checked) {
97-
// Only expand if collapsed - navigation proceeds regardless
124+
125+
// Collapse sibling sections at the same level
126+
const parent = li.parentElement;
127+
if (parent) {
128+
parent.querySelectorAll(':scope > li.has-children > .toctree-checkbox').forEach((siblingCheckbox) => {
129+
if (siblingCheckbox !== checkbox && siblingCheckbox.checked) {
130+
siblingCheckbox.checked = false;
131+
}
132+
});
133+
}
134+
135+
// Expand this section
136+
if (checkbox) {
98137
checkbox.checked = true;
99-
saveNavState();
100138
}
139+
140+
saveNavState();
141+
});
142+
});
143+
144+
// Cross-project navigation: clicking expand icon on entries with empty <ul>
145+
// should navigate to that project instead of just toggling the checkbox.
146+
// It's ok UX, but also just plain needed as we can't expand the TOC of another project :-(
147+
document.querySelectorAll('.bs-docs-sidenav li.has-children > label').forEach((label) => {
148+
label.addEventListener('click', (e) => {
149+
const li = label.parentElement;
150+
const ul = li.querySelector(':scope > ul');
151+
// Check if <ul> is empty (cross-project entry)
152+
if (ul && ul.children.length === 0) {
153+
const link = li.querySelector(':scope > a');
154+
if (link && link.href) {
155+
e.preventDefault();
156+
e.stopPropagation();
157+
window.location.href = link.href;
158+
}
159+
}
160+
// If <ul> has children, default behavior (toggle checkbox) applies
101161
});
102162
});
103163
});

src/crate/theme/rtd/sidebartoc.py

Lines changed: 106 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,62 @@
2020
# software solely pursuant to the terms of the relevant commercial agreement.
2121

2222
from furo.navigation import get_navigation_tree
23+
from datetime import datetime
24+
import os
25+
import re
26+
27+
28+
def _slugify_id(text):
29+
"""Normalize text to a safe HTML ID: alphanumerics and hyphens only."""
30+
s = re.sub(r'[^a-z0-9-]', '-', text.lower())
31+
s = re.sub(r'-{2,}', '-', s) # collapse multiple hyphens
32+
return s.strip('-')
33+
34+
35+
class _NavBuilder:
36+
"""Helper to build navigation HTML."""
37+
38+
def __init__(self, parts, project, master_path, toctree_fn):
39+
self.parts = parts
40+
self.project = project
41+
self.master_path = master_path
42+
self.toctree = toctree_fn
43+
44+
def add_nav_link(self, entry_name, entry_url, li_base_class='toctree-l1', border_top=False):
45+
"""Add a cross-project navigation link with expand icon.
46+
47+
Includes an empty <ul> so Furo's get_navigation_tree() adds the
48+
checkbox/icon structure. Since cross-project TOC content isn't
49+
available, clicking the icon navigates to that project instead
50+
of expanding (handled by JS in custom.js).
51+
"""
52+
border = " border-top" if border_top else ""
53+
li_class = f'{li_base_class}{border}'
54+
self.parts.append(f'<li class="{li_class}">')
55+
self.parts.append(f'<a href="{entry_url}">{entry_name}</a>')
56+
# Empty <ul> triggers Furo to add has-children class and icon structure
57+
self.parts.append('<ul></ul>')
58+
self.parts.append('</li>')
59+
60+
def add_project_nav_item(
61+
self,
62+
project_name,
63+
display_name,
64+
url_if_not_current,
65+
border_top=False,
66+
public_docs=True
67+
):
68+
"""Add a navigation item in left navbar for a project."""
69+
border = " border-top" if border_top else ""
70+
if self.project == project_name:
71+
self.parts.append(f'<li class="current{border}">')
72+
self.parts.append(f'<a class="current-active" href="{self.master_path}">{display_name}</a>')
73+
self.parts.append(self.toctree())
74+
self.parts.append('</li>')
75+
return
76+
77+
if public_docs:
78+
self.add_nav_link(display_name, url_if_not_current, 'navleft-item', border_top)
2379

2480

2581
def _generate_crate_navigation_html(context):
@@ -32,7 +88,7 @@ def _generate_crate_navigation_html(context):
3288
return ""
3389

3490
theme_globaltoc_includehidden = context.get("theme_globaltoc_includehidden", True)
35-
def get_toctree(maxdepth=-1, titles_only=True, collapse=False):
91+
def _get_toctree(maxdepth=-1, titles_only=True, collapse=False):
3692
return toctree(
3793
maxdepth=maxdepth,
3894
titles_only=titles_only,
@@ -46,35 +102,15 @@ def get_toctree(maxdepth=-1, titles_only=True, collapse=False):
46102
master_path = context["pathto"](master_doc)
47103

48104
parts = ['<ul class="toctree nav nav-list">']
49-
50-
def _add_project_nav_item(
51-
project_name,
52-
display_name,
53-
url_if_not_current,
54-
border_top=False,
55-
include_toctree=True,
56-
only_if_current_project=False
57-
):
58-
"""Add a navigation item in left navbar for a specific project."""
59-
border = " border-top" if border_top else ""
60-
if project == project_name:
61-
parts.append(f'<li class="current{border}">')
62-
parts.append(f'<a class="current-active" href="{master_path}">{display_name}</a>')
63-
if include_toctree:
64-
parts.append(get_toctree())
65-
parts.append('</li>')
66-
else:
67-
if only_if_current_project:
68-
return
69-
parts.append(f'<li class="navleft-item{border}"><a href="{url_if_not_current}">{display_name}</a></li>')
105+
builder = _NavBuilder(parts, project, master_path, _get_toctree)
70106

71107

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

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

89-
# Add Overview and top level entries defined in the Guide's toctree
125+
# Add Overview and top level entries defined in the Guide's toctree.
126+
# The Guide project is the only one that has multiple top-level entries.
90127
if project == 'CrateDB: Guide':
91128
if pagename == 'index':
92129
parts.append('<li class="current">')
@@ -96,24 +133,25 @@ def _add_project_nav_item(
96133
parts.append('<li class="navleft-item">')
97134
parts.append(f'<a href="{master_path}">Overview</a>')
98135
parts.append('</li>')
99-
parts.append(get_toctree())
136+
parts.append(_get_toctree())
100137
else:
101-
parts.append('<li class="current"><a class="current-active" href="#">Overview</a></li>')
138+
# Show Overview link to Guide's index (no icon - it's just an index page)
139+
parts.append('<li class="navleft-item"><a href="/docs/guide/">Overview</a></li>')
140+
# Add Guide's level 1 entries with icons
141+
builder.add_nav_link('Getting Started','/docs/guide/start/')
142+
builder.add_nav_link('Handbook', '/docs/guide/handbook/')
102143

103144
# Add individual projects
104-
_add_project_nav_item('CrateDB Cloud', 'CrateDB Cloud', '/docs/cloud/')
105-
_add_project_nav_item('CrateDB: Reference', 'Reference Manual', '/docs/crate/reference/')
145+
builder.add_project_nav_item('CrateDB Cloud', 'CrateDB Cloud', '/docs/cloud/')
146+
builder.add_project_nav_item('CrateDB: Reference', 'Reference Manual', '/docs/crate/reference/')
106147

107148
# Start new section with a border
108-
_add_project_nav_item('CrateDB: Admin UI', 'Admin UI', '/docs/crate/admin-ui/', border_top=True)
109-
_add_project_nav_item('CrateDB: Crash CLI', 'CrateDB CLI', '/docs/crate/crash/')
110-
_add_project_nav_item('CrateDB Cloud: Croud CLI', 'Cloud CLI', '/docs/cloud/cli/')
149+
builder.add_project_nav_item('CrateDB: Admin UI', 'Admin UI', '/docs/crate/admin-ui/', border_top=True)
150+
builder.add_project_nav_item('CrateDB: Crash CLI', 'CrateDB CLI', '/docs/crate/crash/')
151+
builder.add_project_nav_item('CrateDB Cloud: Croud CLI', 'Cloud CLI', '/docs/cloud/cli/')
111152

112153
# Add all Driver projects
113-
parts.append('<li class="navleft-item">')
114-
parts.append('<a href="/docs/guide/connect/drivers.html">Database Drivers</a>')
115-
parts.append('</li>')
116-
154+
# The <ul> must be inside the same <li> for CSS sibling selectors to work
117155
_DRIVER_CONFIGS = [
118156
('CrateDB JDBC', 'JDBC', '/docs/jdbc/'),
119157
('CrateDB DBAL', 'PHP DBAL', '/docs/dbal/'),
@@ -122,22 +160,36 @@ def _add_project_nav_item(
122160
('SQLAlchemy Dialect', 'SQLAlchemy', '/docs/sqlalchemy-cratedb/'),
123161
]
124162
driver_projects = [config[0] for config in _DRIVER_CONFIGS]
125-
if project in driver_projects or (project == 'CrateDB: Guide' and pagename.startswith('connect')):
126-
parts.append('<li><ul>')
163+
show_drivers = project in driver_projects or (project == 'CrateDB: Guide' and pagename.startswith('connect'))
164+
165+
# Use data attribute to mark Database Drivers for auto-expansion
166+
driver_marker = ' data-auto-expand="true"' if show_drivers else ''
167+
parts.append(f'<li class="navleft-item"{driver_marker}>')
168+
parts.append('<a href="/docs/guide/connect/drivers.html">Database Drivers</a>')
169+
# Furo will add has-children class and icon structure when it detects the <ul>
170+
parts.append('<ul>')
171+
if show_drivers:
127172
for proj_name, display_name, url in _DRIVER_CONFIGS:
128-
_add_project_nav_item(proj_name, display_name, url)
129-
parts.append('</ul></li>')
173+
builder.add_project_nav_item(proj_name, display_name, url)
174+
parts.append('</ul>')
175+
parts.append('</li>')
176+
130177

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

135182

136183
# Other internal docs projects only included in special builds
137-
_add_project_nav_item('CrateDB documentation theme', 'Documentation theme', '',
138-
border_top=True, only_if_current_project=True)
139-
_add_project_nav_item('Doing Docs', 'Doing Docs at CrateDB', '',
140-
only_if_current_project=True)
184+
builder.add_project_nav_item('CrateDB documentation theme', 'Documentation theme', '', border_top=True, public_docs=False)
185+
builder.add_project_nav_item('Doing Docs', 'Doing Docs at CrateDB', '', public_docs=False)
186+
187+
# Show build timestamp for local development (not on Read the Docs)
188+
if not os.environ.get('READTHEDOCS'):
189+
build_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
190+
parts.append(f'<li class="navleft-item border-top" style="font-size: 0.75em; color: #888; padding-top: 1em;">')
191+
parts.append(f'Built: {build_time}')
192+
parts.append('</li>')
141193

142194
parts.append('</ul>')
143195
return ''.join(parts)
@@ -160,5 +212,16 @@ def add_crate_navigation(app, pagename, templatename, context, doctree):
160212
# Process through Furo's navigation enhancer
161213
enhanced_navigation = get_navigation_tree(navigation_html)
162214

215+
# Make checkbox IDs unique per project to prevent localStorage state collision.
216+
# Furo generates sequential IDs (toctree-checkbox-1, toctree-checkbox-2, etc.)
217+
# which collide across projects. Add project slug prefix to make them unique.
218+
project_slug = _slugify_id(context.get("project", ""))
219+
if project_slug:
220+
enhanced_navigation = re.sub(
221+
r'toctree-checkbox-(\d+)',
222+
f'toctree-checkbox-{project_slug}-\\1',
223+
enhanced_navigation
224+
)
225+
163226
# Add to context for use in templates
164227
context["crate_navigation_tree"] = enhanced_navigation

0 commit comments

Comments
 (0)