Skip to content

Commit d2211c3

Browse files
✨ Using sphinx toc functions (#219)
Co-authored-by: Joris Van den Bossche <[email protected]>
1 parent ab92898 commit d2211c3

13 files changed

+206
-181
lines changed

docs/index.rst

-1
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,3 @@ Other sites that are using this theme:
2929

3030
demo/index
3131
changelog
32-

docs/user_guide/index.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ User Guide
88

99
install
1010
configuring
11-
customizing
11+
customizing

pydata_sphinx_theme/__init__.py

+171-82
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,118 @@
44
import os
55

66
from sphinx.errors import ExtensionError
7+
from bs4 import BeautifulSoup as bs
78

89
from .bootstrap_html_translator import BootstrapHTML5Translator
9-
import docutils
1010

1111
__version__ = "0.4.2dev0"
1212

1313

1414
def add_toctree_functions(app, pagename, templatename, context, doctree):
15-
"""Add functions so Jinja templates can add toctree objects.
15+
"""Add functions so Jinja templates can add toctree objects."""
1616

17-
This converts the docutils nodes into a nested dictionary that Jinja can
18-
use in our templating.
19-
"""
20-
from sphinx.environment.adapters.toctree import TocTree
17+
def generate_nav_html(kind, **kwargs):
18+
"""
19+
Return the navigation link structure in HTML. Arguments are passed
20+
to Sphinx "toctree" function (context["toctree"] below).
21+
22+
We use beautifulsoup to add the right CSS classes / structure for bootstrap.
23+
24+
See https://www.sphinx-doc.org/en/master/templating.html#toctree.
2125
22-
def get_nav_object(maxdepth=None, collapse=True, **kwargs):
26+
Parameters
27+
----------
28+
kind : ["navbar", "sidebar", "raw"]
29+
The kind of UI element this toctree is generated for.
30+
kwargs: passed to the Sphinx `toctree` template function.
31+
32+
Returns
33+
-------
34+
HTML string (if kind in ["navbar", "sidebar"])
35+
or BeautifulSoup object (if kind == "raw")
36+
"""
37+
toc_sphinx = context["toctree"](**kwargs)
38+
soup = bs(toc_sphinx, "html.parser")
39+
40+
# pair "current" with "active" since that's what we use w/ bootstrap
41+
for li in soup("li", {"class": "current"}):
42+
li["class"].append("active")
43+
44+
if kind == "navbar":
45+
# Add CSS for bootstrap
46+
for li in soup("li"):
47+
li["class"].append("nav-item")
48+
li.find("a")["class"].append("nav-link")
49+
out = "\n".join([ii.prettify() for ii in soup.find_all("li")])
50+
51+
elif kind == "sidebar":
52+
# Remove sidebar links to sub-headers on the page
53+
for li in soup.select("li.current ul li"):
54+
# Remove
55+
if li.find("a"):
56+
href = li.find("a")["href"]
57+
if "#" in href and href != "#":
58+
li.decompose()
59+
60+
# Join all the top-level `li`s together for display
61+
current_lis = soup.select("li.current.toctree-l1 li.toctree-l2")
62+
out = "\n".join([ii.prettify() for ii in current_lis])
63+
64+
elif kind == "raw":
65+
out = soup
66+
67+
return out
68+
69+
def generate_toc_html(kind="html"):
70+
"""Return the within-page TOC links in HTML."""
71+
72+
if "toc" not in context:
73+
return ""
74+
75+
soup = bs(context["toc"], "html.parser")
76+
77+
# Add toc-hN + visible classes
78+
def add_header_level_recursive(ul, level):
79+
if level <= (context["theme_show_toc_level"] + 1):
80+
ul["class"] = ul.get("class", []) + ["visible"]
81+
for li in ul("li", recursive=False):
82+
li["class"] = li.get("class", []) + [f"toc-h{level}"]
83+
ul = li.find("ul", recursive=False)
84+
if ul:
85+
add_header_level_recursive(ul, level + 1)
86+
87+
add_header_level_recursive(soup.find("ul"), 1)
88+
89+
# Add in CSS classes for bootstrap
90+
for ul in soup("ul"):
91+
ul["class"] = ul.get("class", []) + ["nav", "section-nav", "flex-column"]
92+
93+
for li in soup("li"):
94+
li["class"] = li.get("class", []) + ["nav-item", "toc-entry"]
95+
if li.find("a"):
96+
a = li.find("a")
97+
a["class"] = a.get("class", []) + ["nav-link"]
98+
99+
# If we only have one h1 header, assume it's a title
100+
h1_headers = soup.select(".toc-h1")
101+
if len(h1_headers) == 1:
102+
title = h1_headers[0]
103+
# If we have no sub-headers of a title then we won't have a TOC
104+
if not title.select(".toc-h2"):
105+
out = ""
106+
else:
107+
out = title.find("ul").prettify()
108+
# Else treat the h1 headers as sections
109+
else:
110+
out = soup.prettify()
111+
112+
# Return the toctree object
113+
if kind == "html":
114+
return out
115+
else:
116+
return soup
117+
118+
def get_nav_object(maxdepth=None, collapse=True, includehidden=True, **kwargs):
23119
"""Return a list of nav links that can be accessed from Jinja.
24120
25121
Parameters
@@ -30,50 +126,39 @@ def get_nav_object(maxdepth=None, collapse=True, **kwargs):
30126
Whether to only include sub-pages of the currently-active page,
31127
instead of sub-pages of all top-level pages of the site.
32128
kwargs: key/val pairs
33-
Passed to the `TocTree.get_toctree_for` Sphinx method
129+
Passed to the `toctree` Sphinx method
34130
"""
35-
# The TocTree will contain the full site TocTree including sub-pages.
36-
# "collapse=True" collapses sub-pages of non-active TOC pages.
37-
# maxdepth controls how many TOC levels are returned
38-
toctree = TocTree(app.env).get_toctree_for(
39-
pagename, app.builder, collapse=collapse, maxdepth=maxdepth, **kwargs
131+
toc_sphinx = context["toctree"](
132+
maxdepth=maxdepth, collapse=collapse, includehidden=includehidden, **kwargs
40133
)
41-
# If no toctree is defined (AKA a single-page site), skip this
42-
if toctree is None:
43-
return []
44-
45-
# toctree has this structure
46-
# <caption>
47-
# <bullet_list>
48-
# <list_item classes="toctree-l1">
49-
# <list_item classes="toctree-l1">
50-
# `list_item`s are the actual TOC links and are the only thing we want
51-
toc_items = [
52-
item
53-
for child in toctree.children
54-
for item in child
55-
if isinstance(item, docutils.nodes.list_item)
56-
]
134+
soup = bs(toc_sphinx, "html.parser")
57135

58-
# Now convert our docutils nodes into dicts that Jinja can use
59-
nav = [docutils_node_to_jinja(child, only_pages=True) for child in toc_items]
136+
# # If no toctree is defined (AKA a single-page site), skip this
137+
# if toctree is None:
138+
# return []
60139

61-
return nav
140+
nav_object = soup_to_python(soup, only_pages=True)
141+
return nav_object
62142

63143
def get_page_toc_object():
64144
"""Return a list of within-page TOC links that can be accessed from Jinja."""
65-
self_toc = TocTree(app.env).get_toc_for(pagename, app.builder)
145+
146+
if "toc" not in context:
147+
return ""
148+
149+
soup = bs(context["toc"], "html.parser")
66150

67151
try:
68-
# If there's only one child, assume we have a single "title" as top header
69-
# so start the TOC at the first item's children (AKA, level 2 headers)
70-
if len(self_toc.children) == 1:
71-
nav = docutils_node_to_jinja(self_toc.children[0]).get("children", [])
72-
else:
73-
nav = [docutils_node_to_jinja(item) for item in self_toc.children]
74-
return nav
152+
toc_object = soup_to_python(soup, only_pages=False)
75153
except Exception:
76-
return {}
154+
return []
155+
156+
# If there's only one child, assume we have a single "title" as top header
157+
# so start the TOC at the first item's children (AKA, level 2 headers)
158+
if len(toc_object) == 1:
159+
return toc_object[0]["children"]
160+
else:
161+
return toc_object
77162

78163
def navbar_align_class():
79164
"""Return the class that aligns the navbar based on config."""
@@ -92,63 +177,67 @@ def navbar_align_class():
92177
)
93178
return align_options[align]
94179

180+
context["generate_nav_html"] = generate_nav_html
181+
context["generate_toc_html"] = generate_toc_html
95182
context["get_nav_object"] = get_nav_object
96183
context["get_page_toc_object"] = get_page_toc_object
97184
context["navbar_align_class"] = navbar_align_class
98185

99186

100-
def docutils_node_to_jinja(list_item, only_pages=False):
101-
"""Convert a docutils node to a structure that can be read by Jinja.
187+
def soup_to_python(soup, only_pages=False):
188+
"""
189+
Convert the toctree html structure to python objects which can be used in Jinja.
102190
103191
Parameters
104192
----------
105-
list_item : docutils list_item node
106-
A parent item, potentially with children, corresponding to the level
107-
of a TocTree.
193+
soup : BeautifulSoup object for the toctree
108194
only_pages : bool
109195
Only include items for full pages in the output dictionary. Exclude
110196
anchor links (TOC items with a URL that starts with #)
111197
112198
Returns
113199
-------
114-
nav : dict
115-
The TocTree, converted into a dictionary with key/values that work
200+
nav : list of dicts
201+
The toctree, converted into a dictionary with key/values that work
116202
within Jinja.
117203
"""
118-
if not list_item.children:
119-
return None
120-
121-
# We assume this structure of a list item:
122-
# <list_item>
123-
# <compact_paragraph >
124-
# <reference> <-- the thing we want
125-
reference = list_item.children[0].children[0]
126-
title = reference.astext()
127-
url = reference.attributes["refuri"]
128-
active = "current" in list_item.attributes["classes"]
129-
130-
# If we've got an anchor link, skip it if we wish
131-
if only_pages and "#" in url:
132-
return None
133-
134-
# Converting the docutils attributes into jinja-friendly objects
135-
nav = {}
136-
nav["title"] = title
137-
nav["url"] = url
138-
nav["active"] = active
139-
140-
# Recursively convert children as well
141-
# If there are sub-pages for this list_item, there should be two children:
142-
# a paragraph, and a bullet_list.
143-
nav["children"] = []
144-
if len(list_item.children) > 1:
145-
# The `.children` of the bullet_list has the nodes of the sub-pages.
146-
subpage_list = list_item.children[1].children
147-
for sub_page in subpage_list:
148-
child_nav = docutils_node_to_jinja(sub_page, only_pages=only_pages)
149-
if child_nav is not None:
150-
nav["children"].append(child_nav)
151-
return nav
204+
# toctree has this structure (caption only for toctree, not toc)
205+
# <p class="caption">...</span></p>
206+
# <ul>
207+
# <li class="toctree-l1"><a href="..">..</a></li>
208+
# <li class="toctree-l1"><a href="..">..</a></li>
209+
# ...
210+
211+
def extract_level_recursive(ul, navs_list):
212+
213+
for li in ul.find_all("li", recursive=False):
214+
ref = li.a
215+
url = ref["href"]
216+
title = "".join(map(str, ref.contents))
217+
active = "current" in li.get("class", [])
218+
219+
# If we've got an anchor link, skip it if we wish
220+
if only_pages and "#" in url and url != "#":
221+
continue
222+
223+
# Converting the docutils attributes into jinja-friendly objects
224+
nav = {}
225+
nav["title"] = title
226+
nav["url"] = url
227+
nav["active"] = active
228+
229+
navs_list.append(nav)
230+
231+
# Recursively convert children as well
232+
nav["children"] = []
233+
ul = li.find("ul", recursive=False)
234+
if ul:
235+
extract_level_recursive(ul, nav["children"])
236+
237+
navs = []
238+
for ul in soup.find_all("ul", recursive=False):
239+
extract_level_recursive(ul, navs)
240+
return navs
152241

153242

154243
# -----------------------------------------------------------------------------
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,7 @@
11
<nav class="bd-links" id="bd-docs-nav" aria-label="Main navigation">
2-
3-
<div class="bd-toc-item active">
4-
{% set nav = get_nav_object(maxdepth=3, collapse=True) %}
5-
2+
<div class="bd-toc-item active">
63
<ul class="nav bd-sidenav">
7-
{% for main_nav_item in nav %}
8-
{% if main_nav_item.active %}
9-
{% for nav_item in main_nav_item.children %}
10-
{% if nav_item.children %}
11-
12-
<li class="{% if nav_item.active%}active{% endif %}">
13-
<a href="{{ nav_item.url }}">{{ nav_item.title }}</a>
14-
<ul>
15-
{% for nav_item in nav_item.children %}
16-
<li class="{% if nav_item.active%}active{% endif %}">
17-
<a href="{{ nav_item.url }}">{{ nav_item.title }}</a>
18-
</li>
19-
{% endfor %}
20-
</ul>
21-
</li>
22-
{% else %}
23-
<li class="{% if nav_item.active%}active{% endif %}">
24-
<a href="{{ nav_item.url }}">{{ nav_item.title }}</a>
25-
</li>
26-
{% endif %}
27-
{% endfor %}
28-
{% endif %}
29-
{% endfor %}
30-
</ul>
31-
32-
</nav>
4+
{{ generate_nav_html("sidebar", maxdepth=4, collapse=True, includehidden=True, titles_only=True) }}
5+
</ul>
6+
</div>
7+
</nav>

pydata_sphinx_theme/docs-navbar.html

+2-6
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,11 @@
2525
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
2626
<span class="navbar-toggler-icon"></span>
2727
</button>
28+
2829
{% set navbar_class, navbar_align = navbar_align_class() %}
2930
<div id="navbar-menu" class="{{ navbar_class }} collapse navbar-collapse">
3031
<ul id="navbar-main-elements" class="navbar-nav {{ navbar_align }}">
31-
{% set nav = get_nav_object(maxdepth=1, collapse=True) %}
32-
{% for main_nav_item in nav %}
33-
<li class="nav-item {% if main_nav_item.active%}active{% endif %}">
34-
<a class="nav-link" href="{{ main_nav_item.url }}">{{ main_nav_item.title }}</a>
35-
</li>
36-
{% endfor %}
32+
{{ generate_nav_html("navbar", maxdepth=1, collapse=True, includehidden=True, titles_only=True) }}
3733
{% for external_link in theme_external_links %}
3834
<li class="nav-item">
3935
<a class="nav-link nav-external" href="{{ external_link.url }}">{{ external_link.name }}<i class="fas fa-external-link-alt"></i></a>

0 commit comments

Comments
 (0)