Skip to content

Commit de7e712

Browse files
authored
♻️ REFACTOR: Make content parsing format agnostic (#79)
Major refactor of tabs, group-tabs and code-tabs: * Removes hard-coded reStructuredText for group and code tabs, so that this works with other parsers (inc `MyST`) * group-tab directive now subclasses `TabDirective` to remove duplicated run code * likewise, `code-tab` now subclasses `group-tab` * `TabDirective` and `TabsDirective` now subclass `SphinxDirective` for easier access to directive `env` New features: * Can now pass code-tabs with a second argument (allowing whitespace) to provide an alternative tab label * code-tabs can now use custom lexers, which are added to the sphinx app in `conf.py`
1 parent 384fdf7 commit de7e712

10 files changed

Lines changed: 228 additions & 192 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ another area. For example:
112112

113113
## Code Tabs
114114

115-
Tabs containing code areas with syntax highlighting can be created as follows:
115+
Grouped tabs containing code areas with syntax highlighting can be created as follows:
116116

117117
```rst
118118
.. tabs::
@@ -152,6 +152,27 @@ Tabs containing code areas with syntax highlighting can be created as follows:
152152
END PROGRAM main
153153
```
154154

155+
Code tabs also support custom lexers (added via sphinx `conf.py`).
156+
157+
By default, code tabs are labelled with the language name, though can be provided with custom labels like so:
158+
159+
```rst
160+
.. tabs::
161+
162+
.. code-tab:: c I love C
163+
164+
int main(const int argc, const char **argv) {
165+
return 0;
166+
}
167+
168+
.. code-tab:: py I love Python more
169+
170+
def main():
171+
return
172+
173+
```
174+
175+
155176
![Code Tabs](/images/codeTabs.gif)
156177

157178
[github-ci]: https://github.com/executablebooks/sphinx-tabs/workflows/continuous-integration/badge.svg?branch=master

sphinx_tabs/tabs.py

Lines changed: 80 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
""" Tabbed views for Sphinx, with HTML builder """
22

33
import base64
4-
import json
54
from pathlib import Path
5+
from functools import partial
66

77
from docutils import nodes
8-
from docutils.parsers.rst import Directive, directives
8+
from docutils.parsers.rst import directives
99
from pkg_resources import resource_filename
1010
from pygments.lexers import get_all_lexers
11+
from sphinx.highlighting import lexer_classes
1112
from sphinx.util.osutil import copyfile
1213
from sphinx.util import logging
14+
from sphinx.util.docutils import SphinxDirective
15+
from sphinx.directives.code import CodeBlock
1316

1417

1518
FILES = [
@@ -43,44 +46,43 @@ def get_compatible_builders(app):
4346
return builders
4447

4548

46-
class TabsDirective(Directive):
49+
class TabsDirective(SphinxDirective):
4750
""" Top-level tabs directive """
4851

4952
has_content = True
5053

5154
def run(self):
5255
""" Parse a tabs directive """
5356
self.assert_has_content()
54-
env = self.state.document.settings.env
5557

5658
node = nodes.container()
5759
node["classes"] = ["sphinx-tabs"]
5860

59-
if "next_tabs_id" not in env.temp_data:
60-
env.temp_data["next_tabs_id"] = 0
61-
if "tabs_stack" not in env.temp_data:
62-
env.temp_data["tabs_stack"] = []
61+
if "next_tabs_id" not in self.env.temp_data:
62+
self.env.temp_data["next_tabs_id"] = 0
63+
if "tabs_stack" not in self.env.temp_data:
64+
self.env.temp_data["tabs_stack"] = []
6365

64-
tabs_id = env.temp_data["next_tabs_id"]
66+
tabs_id = self.env.temp_data["next_tabs_id"]
6567
tabs_key = "tabs_%d" % tabs_id
66-
env.temp_data["next_tabs_id"] += 1
67-
env.temp_data["tabs_stack"].append(tabs_id)
68+
self.env.temp_data["next_tabs_id"] += 1
69+
self.env.temp_data["tabs_stack"].append(tabs_id)
6870

69-
env.temp_data[tabs_key] = {}
70-
env.temp_data[tabs_key]["tab_ids"] = []
71-
env.temp_data[tabs_key]["tab_titles"] = []
72-
env.temp_data[tabs_key]["is_first_tab"] = True
71+
self.env.temp_data[tabs_key] = {}
72+
self.env.temp_data[tabs_key]["tab_ids"] = []
73+
self.env.temp_data[tabs_key]["tab_titles"] = []
74+
self.env.temp_data[tabs_key]["is_first_tab"] = True
7375

7476
self.state.nested_parse(self.content, self.content_offset, node)
7577

76-
if env.app.builder.name in get_compatible_builders(env.app):
78+
if self.env.app.builder.name in get_compatible_builders(self.env.app):
7779
tabs_node = nodes.container()
7880
tabs_node.tagname = "div"
7981

8082
classes = "ui top attached tabular menu sphinx-menu"
8183
tabs_node["classes"] = classes.split(" ")
8284

83-
tab_titles = env.temp_data[tabs_key]["tab_titles"]
85+
tab_titles = self.env.temp_data[tabs_key]["tab_titles"]
8486
for idx, [data_tab, tab_name] in enumerate(tab_titles):
8587
tab = nodes.container()
8688
tab.tagname = "a"
@@ -91,69 +93,65 @@ def run(self):
9193

9294
node.children.insert(0, tabs_node)
9395

94-
env.temp_data["tabs_stack"].pop()
96+
self.env.temp_data["tabs_stack"].pop()
9597
return [node]
9698

9799

98-
class TabDirective(Directive):
100+
class TabDirective(SphinxDirective):
99101
""" Tab directive, for adding a tab to a collection of tabs """
100102

101103
has_content = True
102104

105+
def __init__(self, *args, **kwargs):
106+
self.tab_id = None
107+
self.tab_classes = set()
108+
super().__init__(*args, **kwargs)
109+
103110
def run(self):
104111
""" Parse a tab directive """
105112
self.assert_has_content()
106-
env = self.state.document.settings.env
107113

108-
tabs_id = env.temp_data["tabs_stack"][-1]
114+
tabs_id = self.env.temp_data["tabs_stack"][-1]
109115
tabs_key = "tabs_%d" % tabs_id
110116

111-
args = self.content[0].strip()
112-
if args.startswith("{"):
113-
try:
114-
args = json.loads(args)
115-
self.content.trim_start(1)
116-
except ValueError:
117-
args = {}
117+
include_tabs_id_in_data_tab = False
118+
if self.tab_id is None:
119+
tab_id = self.env.new_serialno(tabs_key)
120+
include_tabs_id_in_data_tab = True
118121
else:
119-
args = {}
122+
tab_id = self.tab_id
120123

121124
tab_name = nodes.container()
122125
self.state.nested_parse(self.content[:1], self.content_offset, tab_name)
123-
args["tab_name"] = tab_name
124126

125-
include_tabs_id_in_data_tab = False
126-
if "tab_id" not in args:
127-
args["tab_id"] = env.new_serialno(tabs_key)
128-
include_tabs_id_in_data_tab = True
129127
i = 1
130-
while args["tab_id"] in env.temp_data[tabs_key]["tab_ids"]:
131-
args["tab_id"] = "%s-%d" % (args["tab_id"], i)
128+
while tab_id in self.env.temp_data[tabs_key]["tab_ids"]:
129+
tab_id = "%s-%d" % (tab_id, i)
132130
i += 1
133-
env.temp_data[tabs_key]["tab_ids"].append(args["tab_id"])
131+
self.env.temp_data[tabs_key]["tab_ids"].append(tab_id)
134132

135-
data_tab = str(args["tab_id"])
133+
data_tab = str(tab_id)
136134
if include_tabs_id_in_data_tab:
137135
data_tab = "%d-%s" % (tabs_id, data_tab)
138136
data_tab = "sphinx-data-tab-{}".format(data_tab)
139137

140-
env.temp_data[tabs_key]["tab_titles"].append((data_tab, args["tab_name"]))
138+
self.env.temp_data[tabs_key]["tab_titles"].append((data_tab, tab_name))
141139

142140
text = "\n".join(self.content)
143141
node = nodes.container(text)
144142

145143
classes = "ui bottom attached sphinx-tab tab segment"
146144
node["classes"] = classes.split(" ")
147-
node["classes"].extend(args.get("classes", []))
145+
node["classes"].extend(self.tab_classes)
148146
node["classes"].append(data_tab)
149147

150-
if env.temp_data[tabs_key]["is_first_tab"]:
148+
if self.env.temp_data[tabs_key]["is_first_tab"]:
151149
node["classes"].append("active")
152-
env.temp_data[tabs_key]["is_first_tab"] = False
150+
self.env.temp_data[tabs_key]["is_first_tab"] = False
153151

154152
self.state.nested_parse(self.content[2:], self.content_offset, node)
155153

156-
if env.app.builder.name not in get_compatible_builders(env.app):
154+
if self.env.app.builder.name not in get_compatible_builders(self.env.app):
157155
outer_node = nodes.container()
158156
tab = nodes.container()
159157
tab.tagname = "a"
@@ -167,84 +165,66 @@ def run(self):
167165
return [node]
168166

169167

170-
class GroupTabDirective(Directive):
168+
class GroupTabDirective(TabDirective):
171169
""" Tab directive that toggles with same tab names across page"""
172170

173171
has_content = True
174172

175173
def run(self):
176-
""" Parse a tab directive """
177174
self.assert_has_content()
178-
179175
group_name = self.content[0]
180-
self.content.trim_start(2)
181-
182-
for idx, line in enumerate(self.content.data):
183-
self.content.data[idx] = " " + line
184-
185-
tab_args = {
186-
"tab_id": base64.b64encode(group_name.encode("utf-8")).decode("utf-8"),
187-
"group_tab": True,
188-
}
176+
if self.tab_id is None:
177+
self.tab_id = base64.b64encode(group_name.encode("utf-8")).decode("utf-8")
178+
return super().run()
189179

190-
new_content = [
191-
".. tab:: {}".format(json.dumps(tab_args)),
192-
" {}".format(group_name),
193-
"",
194-
]
195180

196-
for idx, line in enumerate(new_content):
197-
self.content.data.insert(idx, line)
198-
self.content.items.insert(idx, (None, idx))
199-
200-
node = nodes.container()
201-
self.state.nested_parse(self.content, self.content_offset, node)
202-
return node.children
203-
204-
205-
class CodeTabDirective(Directive):
181+
class CodeTabDirective(GroupTabDirective):
206182
""" Tab directive with a codeblock as its content"""
207183

208184
has_content = True
209-
option_spec = {"linenos": directives.flag}
185+
required_arguments = 1 # Lexer name
186+
optional_arguments = 1 # Custom label
187+
final_argument_whitespace = True
188+
option_spec = { # From sphinx CodeBlock
189+
"force": directives.flag,
190+
"linenos": directives.flag,
191+
"dedent": int,
192+
"lineno-start": int,
193+
"emphasize-lines": directives.unchanged_required,
194+
"caption": directives.unchanged_required,
195+
"class": directives.class_option,
196+
"name": directives.unchanged,
197+
}
210198

211199
def run(self):
212-
""" Parse a tab directive """
200+
""" Parse a code-tab directive"""
213201
self.assert_has_content()
214202

215-
args = self.content[0].strip().split()
216-
self.content.trim_start(2)
217-
218-
lang = args[0]
219-
tab_name = " ".join(args[1:]) if len(args) > 1 else LEXER_MAP[lang]
220-
221-
for idx, line in enumerate(self.content.data):
222-
self.content.data[idx] = " " + line
223-
224-
tab_args = {
225-
"tab_id": base64.b64encode(tab_name.encode("utf-8")).decode("utf-8"),
226-
"classes": ["code-tab"],
227-
}
203+
if len(self.arguments) > 1:
204+
tab_name = self.arguments[1]
205+
elif self.arguments[0] in lexer_classes and not isinstance(
206+
lexer_classes[self.arguments[0]], partial
207+
):
208+
tab_name = lexer_classes[self.arguments[0]].name
209+
else:
210+
try:
211+
tab_name = LEXER_MAP[self.arguments[0]]
212+
except:
213+
raise ValueError("Lexer not implemented: {}".format(self.arguments[0]))
228214

229-
new_content = [
230-
".. tab:: {}".format(json.dumps(tab_args)),
231-
" {}".format(tab_name),
232-
"",
233-
" .. code-block:: {}".format(lang),
234-
]
215+
self.tab_classes.add("code-tab")
235216

236-
if "linenos" in self.options:
237-
new_content.append(" :linenos:")
217+
# All content should be parsed as code
218+
code_block = CodeBlock.run(self)
238219

239-
new_content.append("")
220+
# Reset to generate tab node
221+
self.content.data = [tab_name, ""]
222+
self.content.items = [(None, 0), (None, 1)]
240223

241-
for idx, line in enumerate(new_content):
242-
self.content.data.insert(idx, line)
243-
self.content.items.insert(idx, (None, idx))
224+
node = super().run()
225+
node[0].extend(code_block)
244226

245-
node = nodes.container()
246-
self.state.nested_parse(self.content, self.content_offset, node)
247-
return node.children
227+
return node
248228

249229

250230
class _FindTabsDirectiveVisitor(nodes.NodeVisitor):

tests/conftest.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,40 @@ def rootdir():
1515
return path(__file__).parent.abspath() / "roots"
1616

1717

18-
@pytest.fixture(scope="function", autouse=True)
19-
def build_and_check(
18+
@pytest.fixture(autouse=True)
19+
def auto_build_and_check(
2020
app,
2121
status,
2222
warning,
2323
check_build_success,
2424
get_sphinx_app_doctree,
2525
regress_sphinx_app_output,
26+
request,
2627
):
2728
"""
2829
Build and check build success and output regressions.
2930
Currently all tests start with this.
31+
Disable using a `noautobuild` mark.
32+
"""
33+
if "noautobuild" in request.keywords:
34+
return
35+
app.build()
36+
check_build_success(status, warning)
37+
get_sphinx_app_doctree(app, regress=True)
38+
regress_sphinx_app_output(app)
39+
40+
41+
@pytest.fixture()
42+
def manual_build_and_check(
43+
app,
44+
status,
45+
warning,
46+
check_build_success,
47+
get_sphinx_app_doctree,
48+
regress_sphinx_app_output,
49+
):
50+
"""
51+
For manually triggering app build and check.
3052
"""
3153
app.build()
3254
check_build_success(status, warning)

0 commit comments

Comments
 (0)