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 )
0 commit comments