Skip to content

Commit af6fc7d

Browse files
authored
Generate static TOC to use with ReadTheDocs (#116)
* Replace furo sidebar with custom generated TOC * Generate static toctree from commented trees to avoid O(n^2) processing * Adapt search to work with static TOC * Highlight current page in TOC * Use TOC highlight script * Expand and scroll to current page in TOC * Expand only parents of current page * Link to SlangPy docs instead of relying on redirect * Fix some formatting issues
1 parent fa977a4 commit af6fc7d

File tree

11 files changed

+451
-132
lines changed

11 files changed

+451
-132
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ Gemfile
22
Gemfile.lock
33
_site/
44
upgrade-site.sh
5+
docs/_build
6+
*.pyc

docs/_ext/fix_toc.py

Lines changed: 0 additions & 22 deletions
This file was deleted.

docs/_ext/generate_toc_html.py

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import re
2+
from pathlib import Path
3+
from sphinx.util import logging
4+
from sphinx import addnodes
5+
from docutils import nodes
6+
import os
7+
8+
def extract_commented_toctree(content):
9+
"""Extract the toctree content from a commented block."""
10+
pattern = re.compile(r'<!-- RTD-TOC-START\s*(.*?)\s*RTD-TOC-END -->', re.DOTALL)
11+
match = pattern.search(content)
12+
if match:
13+
# Extract the content and parse the toctree directive
14+
toctree_content = match.group(1)
15+
# Find the toctree directive
16+
toctree_pattern = re.compile(r'```{toctree}\s*(.*?)\s*```', re.DOTALL)
17+
toctree_match = toctree_pattern.search(toctree_content)
18+
if toctree_match:
19+
return toctree_match.group(1)
20+
return None
21+
22+
def parse_toctree_options(content):
23+
"""Parse toctree options from the content."""
24+
options = {}
25+
lines = content.strip().split('\n')
26+
for line in lines:
27+
if line.startswith(':'):
28+
parts = line[1:].split(':', 1)
29+
if len(parts) == 2:
30+
key, value = parts
31+
options[key.strip()] = value.strip()
32+
return options
33+
34+
def parse_toctree_entries(content):
35+
"""Parse entries from a toctree directive."""
36+
entries = []
37+
lines = content.strip().split('\n')
38+
for line in lines:
39+
if line.strip() and not line.startswith(':'):
40+
# Handle both formats:
41+
# 1. "Title <link>"
42+
# 2. Just the link
43+
if '<' in line:
44+
parts = line.strip().split('<')
45+
if len(parts) == 2:
46+
title = parts[0].strip()
47+
link = parts[1].strip().rstrip('>')
48+
entries.append((title, link))
49+
else:
50+
link = line.strip()
51+
entries.append((None, link))
52+
return entries
53+
54+
def get_title(env, docname):
55+
"""Get the title of a document from its doctree."""
56+
doctree = env.get_doctree(docname)
57+
for node in doctree.traverse(nodes.title):
58+
return node.astext()
59+
return docname
60+
61+
def get_docname_from_link(env, current_doc, link):
62+
"""Get the full docname from a relative link."""
63+
if link.startswith(('http://', 'https://', 'mailto:')):
64+
return link
65+
66+
# Get the directory of the current document
67+
current_dir = os.path.dirname(env.doc2path(current_doc))
68+
if not current_dir:
69+
return link
70+
71+
# Resolve the relative path
72+
full_path = os.path.normpath(os.path.join(current_dir, link))
73+
# Convert back to docname format
74+
docname = os.path.relpath(full_path, env.srcdir).replace('\\', '/')
75+
if docname.endswith('.rst') or docname.endswith('.md'):
76+
docname = docname.rsplit('.', 1)[0]
77+
return docname
78+
79+
def process_document(env, docname, parent_maxdepth=1, processed_docs=None):
80+
"""Process a single document for both commented and uncommented toctrees."""
81+
if processed_docs is None:
82+
processed_docs = set()
83+
84+
if docname in processed_docs:
85+
return []
86+
87+
processed_docs.add(docname)
88+
logger = logging.getLogger(__name__)
89+
sections = []
90+
91+
# Get the document's doctree
92+
doctree = env.get_doctree(docname)
93+
94+
# First check for commented toctree
95+
doc_path = env.doc2path(docname)
96+
logger.info(f"Checking for commented toctree in {doc_path}")
97+
with open(doc_path, 'r', encoding='utf-8') as f:
98+
content = f.read()
99+
100+
toctree_content = extract_commented_toctree(content)
101+
if toctree_content:
102+
logger.info(f"Found commented toctree in {docname}")
103+
# Parse toctree options and entries
104+
options = parse_toctree_options(toctree_content)
105+
entries = parse_toctree_entries(toctree_content)
106+
logger.info(f"Parsed {len(entries)} entries from commented toctree")
107+
108+
# Process the entries
109+
processed_entries = []
110+
for title, link in entries:
111+
if link.startswith(('http://', 'https://', 'mailto:')):
112+
# External link
113+
processed_entries.append({
114+
'title': title or link,
115+
'link': link,
116+
'children': []
117+
})
118+
else:
119+
# Internal link - resolve the full docname
120+
ref = get_docname_from_link(env, docname, link)
121+
if ref in env.found_docs:
122+
# Recursively process the referenced document
123+
sub_sections = process_document(env, ref, parent_maxdepth, processed_docs)
124+
processed_entries.append({
125+
'title': title or get_title(env, ref),
126+
'link': '../' + env.app.builder.get_target_uri(ref).lstrip('/'),
127+
'children': sub_sections
128+
})
129+
else:
130+
# Link not found
131+
processed_entries.append({
132+
'title': title or link,
133+
'link': '../' + env.app.builder.get_target_uri(link).lstrip('/'), # Use original link for not found
134+
'children': []
135+
})
136+
sections.append((None, processed_entries))
137+
138+
# Then process uncommented toctrees
139+
uncommented_toctrees = list(doctree.traverse(addnodes.toctree))
140+
logger.info(f"Found {len(uncommented_toctrees)} uncommented toctrees in {docname}")
141+
for node in uncommented_toctrees:
142+
caption = node.get('caption')
143+
maxdepth = node.get('maxdepth', parent_maxdepth)
144+
entries = []
145+
for (title, link) in node['entries']:
146+
if link.startswith(('http://', 'https://', 'mailto:')):
147+
# External link
148+
entries.append({
149+
'title': title or link,
150+
'link': link,
151+
'children': []
152+
})
153+
else:
154+
# Internal link - resolve the full docname
155+
ref = get_docname_from_link(env, docname, link)
156+
if ref in env.found_docs:
157+
# Recursively process the referenced document
158+
sub_sections = process_document(env, ref, maxdepth, processed_docs)
159+
entries.append({
160+
'title': title or get_title(env, ref),
161+
'link': '../' + env.app.builder.get_target_uri(ref).lstrip('/'),
162+
'children': sub_sections
163+
})
164+
else:
165+
# Link not found
166+
entries.append({
167+
'title': title or link,
168+
'link': '../' + env.app.builder.get_target_uri(link).lstrip('/'), # Use original link for not found
169+
'children': []
170+
})
171+
sections.append((caption, entries))
172+
173+
return sections
174+
175+
def render_toc_html_from_doctree(sections):
176+
"""Render the TOC as HTML using Sphinx's native toctree structure."""
177+
html = ['<div class="sidebar-tree">']
178+
checkbox_counter = {'value': 1} # Use a mutable container to track counter
179+
180+
for caption, entries in sections:
181+
if caption:
182+
html.append(f' <p class="caption" role="heading"><span class="caption-text">{caption}</span></p>')
183+
html.append('<ul>')
184+
for entry in entries:
185+
html.extend(render_entry(entry, level=1, indent=0, checkbox_counter=checkbox_counter))
186+
html.append('</ul>')
187+
else:
188+
html.append('<ul>')
189+
for entry in entries:
190+
html.extend(render_entry(entry, level=1, indent=0, checkbox_counter=checkbox_counter))
191+
html.append('</ul>')
192+
html.append('</div>')
193+
return '\n'.join(html)
194+
195+
def render_entry(entry, level=1, indent=0, checkbox_counter=None):
196+
"""Render a single TOC entry with Sphinx's native CSS classes and structure."""
197+
# Determine if this entry has children
198+
has_children = bool(entry['children'])
199+
200+
# Build CSS classes
201+
classes = [f'toctree-l{level}']
202+
if has_children:
203+
classes.append('has-children')
204+
205+
html = []
206+
207+
if has_children:
208+
# For entries with children, use single-line compact format like example.html
209+
checkbox_id = f'toctree-checkbox-{checkbox_counter["value"]}'
210+
checkbox_counter['value'] += 1
211+
212+
# Build the complete line in one go
213+
if entry['link'].startswith(('http://', 'https://', 'mailto:')):
214+
# External link
215+
line = f'<li class="{" ".join(classes)}"><a class="reference external" href="{entry["link"]}" target="_parent">{entry["title"]}</a><input class="toctree-checkbox" id="{checkbox_id}" name="{checkbox_id}" role="switch" type="checkbox"/><label for="{checkbox_id}"><div class="visually-hidden">Toggle navigation of {entry["title"]}</div><i class="icon"><svg><use href="#svg-arrow-right"></use></svg></i></label><ul>'
216+
else:
217+
# Internal link
218+
line = f'<li class="{" ".join(classes)}"><a class="reference internal" href="{entry["link"]}" target="_parent">{entry["title"]}</a><input class="toctree-checkbox" id="{checkbox_id}" name="{checkbox_id}" role="switch" type="checkbox"/><label for="{checkbox_id}"><div class="visually-hidden">Toggle navigation of {entry["title"]}</div><i class="icon"><svg><use href="#svg-arrow-right"></use></svg></i></label><ul>'
219+
220+
html.append(line)
221+
222+
# Add children
223+
for child_caption, child_entries in entry['children']:
224+
if child_caption:
225+
html.append(f'<p class="caption" role="heading"><span class="caption-text">{child_caption}</span></p>')
226+
for child in child_entries:
227+
html.extend(render_entry(child, level=level+1, indent=0, checkbox_counter=checkbox_counter))
228+
else:
229+
for child in child_entries:
230+
html.extend(render_entry(child, level=level+1, indent=0, checkbox_counter=checkbox_counter))
231+
html.append('</ul>')
232+
html.append('</li>')
233+
else:
234+
# For simple entries without children, use single-line format like example.html
235+
if entry['link'].startswith(('http://', 'https://', 'mailto:')):
236+
# External link
237+
html.append(f'<li class="{" ".join(classes)}"><a class="reference external" href="{entry["link"]}" target="_parent">{entry["title"]}</a></li>')
238+
else:
239+
# Internal link
240+
html.append(f'<li class="{" ".join(classes)}"><a class="reference internal" href="{entry["link"]}" target="_parent">{entry["title"]}</a></li>')
241+
242+
return html
243+
244+
def generate_toc_html(app, exception):
245+
logger = logging.getLogger(__name__)
246+
env = app.builder.env
247+
master_doc = app.config.master_doc if hasattr(app.config, 'master_doc') else 'index'
248+
if master_doc not in env.found_docs:
249+
logger.warning(f"Master doc '{master_doc}' not found in env.found_docs")
250+
return
251+
252+
logger.info(f"Starting TOC generation from master doc: {master_doc}")
253+
# Process all documents recursively
254+
sections = process_document(env, master_doc)
255+
logger.info(f"Found {len(sections)} sections in total")
256+
html = render_toc_html_from_doctree(sections)
257+
258+
# Write the TOC to _static/toc.html with sphinx toctree styling
259+
out_path = os.path.join(app.outdir, '_static', 'toc.html')
260+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
261+
262+
# Create a complete HTML document with sphinx toctree styling
263+
full_html = f"""<!DOCTYPE html>
264+
<html lang="en">
265+
<head>
266+
<meta charset="utf-8">
267+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
268+
<title>Table of Contents</title>
269+
<link rel="stylesheet" href="styles/furo.css">
270+
<link rel="stylesheet" href="styles/furo-extensions.css">
271+
<link rel="stylesheet" href="theme_overrides.css">
272+
<style>
273+
/* Make iframe body fill height and be scrollable */
274+
html, body {{
275+
height: 100%;
276+
margin: 0;
277+
padding: 0;
278+
overflow: hidden;
279+
background: var(--color-sidebar-background);
280+
}}
281+
282+
/* Keep search panel fixed at top */
283+
#tocSearchPanel {{
284+
position: sticky;
285+
top: 0;
286+
z-index: 10;
287+
background: var(--color-sidebar-background, #f8f9fb);
288+
}}
289+
290+
/* Use flexbox for proper layout */
291+
.content-container {{
292+
height: 100vh;
293+
display: flex;
294+
flex-direction: column;
295+
background: var(--color-sidebar-background);
296+
}}
297+
298+
/* Search panel takes its natural height */
299+
#tocSearchPanel {{
300+
flex-shrink: 0;
301+
}}
302+
303+
/* TOC content fills remaining space and scrolls */
304+
.toc-content {{
305+
flex: 1;
306+
overflow-y: auto;
307+
overflow-x: hidden;
308+
background: var(--color-sidebar-background);
309+
}}
310+
311+
/* Style for current page */
312+
.sidebar-tree .current-page > .reference {{
313+
font-weight: bold;
314+
}}
315+
</style>
316+
317+
<!-- SVG symbol definitions for navigation arrows (matching Sphinx/Furo) -->
318+
<svg style="display: none;">
319+
<symbol id="svg-arrow-right" viewBox="0 0 24 24">
320+
<title>Expand</title>
321+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
322+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather-chevron-right">
323+
<polyline points="9 18 15 12 9 6"></polyline>
324+
</svg>
325+
</symbol>
326+
</svg>
327+
</head>
328+
<body>
329+
<div class="content-container">
330+
<div id="tocSearchPanel">
331+
<div id="tocSearchPanelInner">
332+
<input type="text" id="txtSearch" placeholder="Search..." autocomplete="off" />
333+
</div>
334+
<div id="tocSearchResult" style="display: none;"></div>
335+
</div>
336+
<div class="toc-content">
337+
{html}
338+
</div>
339+
</div>
340+
<script src="toc-highlight.js"></script>
341+
<script src="search.js"></script>
342+
</body>
343+
</html>"""
344+
345+
with open(out_path, 'w', encoding='utf-8') as f:
346+
f.write(full_html)
347+
logger.info(f"Generated {out_path}")
348+
349+
def setup(app):
350+
app.connect('build-finished', generate_toc_html)

docs/_static/custom_body_classes.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
document.addEventListener('DOMContentLoaded', function() {
2+
const currentPath = window.location.pathname;
3+
if (currentPath.includes('/external/core-module-reference/')) {
4+
document.body.classList.add('core-module-reference-page');
5+
}
6+
});

0 commit comments

Comments
 (0)