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