""" Tabbed views for Sphinx, with HTML builder """ import base64 import json import posixpath import os from docutils import nodes from docutils.parsers.rst import Directive, directives from pkg_resources import resource_filename from pygments.lexers import get_all_lexers from sphinx.util.osutil import copyfile from sphinx.util import logging FILES = [ 'tabs.js', 'tabs.css', 'semantic-ui-2.2.10/segment.min.css', 'semantic-ui-2.2.10/menu.min.css', 'semantic-ui-2.2.10/tab.min.css', 'semantic-ui-2.2.10/tab.min.js', ] LEXER_MAP = {} for lexer in get_all_lexers(): for short_name in lexer[1]: LEXER_MAP[short_name] = lexer[0] def get_compatible_builders(app): builders = ['html', 'singlehtml', 'dirhtml', 'readthedocs', 'readthedocsdirhtml', 'readthedocssinglehtml', 'readthedocssinglehtmllocalmedia', 'spelling'] builders.extend(app.config['sphinx_tabs_valid_builders']) return builders class TabsDirective(Directive): """ Top-level tabs directive """ has_content = True def run(self): """ Parse a tabs directive """ self.assert_has_content() env = self.state.document.settings.env node = nodes.container() node['classes'] = ['sphinx-tabs'] if 'next_tabs_id' not in env.temp_data: env.temp_data['next_tabs_id'] = 0 if 'tabs_stack' not in env.temp_data: env.temp_data['tabs_stack'] = [] tabs_id = env.temp_data['next_tabs_id'] tabs_key = 'tabs_%d' % tabs_id env.temp_data['next_tabs_id'] += 1 env.temp_data['tabs_stack'].append(tabs_id) env.temp_data[tabs_key] = {} env.temp_data[tabs_key]['tab_ids'] = [] env.temp_data[tabs_key]['tab_titles'] = [] env.temp_data[tabs_key]['is_first_tab'] = True self.state.nested_parse(self.content, self.content_offset, node) if env.app.builder.name in get_compatible_builders(env.app): tabs_node = nodes.container() tabs_node.tagname = 'div' classes = 'ui top attached tabular menu sphinx-menu' tabs_node['classes'] = classes.split(' ') tab_titles = env.temp_data[tabs_key]['tab_titles'] for idx, [data_tab, tab_name] in enumerate(tab_titles): tab = nodes.container() tab.tagname = 'a' tab['classes'] = ['item'] if idx > 0 else ['active', 'item'] tab['classes'].append(data_tab) tab += tab_name tabs_node += tab node.children.insert(0, tabs_node) env.temp_data['tabs_stack'].pop() return [node] class TabDirective(Directive): """ Tab directive, for adding a tab to a collection of tabs """ has_content = True def run(self): """ Parse a tab directive """ self.assert_has_content() env = self.state.document.settings.env tabs_id = env.temp_data['tabs_stack'][-1] tabs_key = 'tabs_%d' % tabs_id args = self.content[0].strip() if args.startswith('{'): try: args = json.loads(args) self.content.trim_start(1) except ValueError: args = {} else: args = {} tab_name = nodes.container() self.state.nested_parse( self.content[:1], self.content_offset, tab_name) args['tab_name'] = tab_name include_tabs_id_in_data_tab = False if 'tab_id' not in args: args['tab_id'] = env.new_serialno(tabs_key) include_tabs_id_in_data_tab = True i = 1 while args['tab_id'] in env.temp_data[tabs_key]['tab_ids']: args['tab_id'] = '%s-%d' % (args['tab_id'], i) i += 1 env.temp_data[tabs_key]['tab_ids'].append(args['tab_id']) data_tab = str(args['tab_id']) if include_tabs_id_in_data_tab: data_tab = '%d-%s' % (tabs_id, data_tab) data_tab = "sphinx-data-tab-{}".format(data_tab) env.temp_data[tabs_key]['tab_titles'].append( (data_tab, args['tab_name'])) text = '\n'.join(self.content) node = nodes.container(text) classes = 'ui bottom attached sphinx-tab tab segment' node['classes'] = classes.split(' ') node['classes'].extend(args.get('classes', [])) node['classes'].append(data_tab) if env.temp_data[tabs_key]['is_first_tab']: node['classes'].append('active') env.temp_data[tabs_key]['is_first_tab'] = False self.state.nested_parse(self.content[2:], self.content_offset, node) if env.app.builder.name not in get_compatible_builders(env.app): outer_node = nodes.container() tab = nodes.container() tab.tagname = 'a' tab['classes'] = ['item'] tab += tab_name outer_node.append(tab) outer_node.append(node) return [outer_node] return [node] class GroupTabDirective(Directive): """ Tab directive that toggles with same tab names across page""" has_content = True def run(self): """ Parse a tab directive """ self.assert_has_content() group_name = self.content[0] self.content.trim_start(2) for idx, line in enumerate(self.content.data): self.content.data[idx] = ' ' + line tab_args = { 'tab_id': base64.b64encode( group_name.encode('utf-8')).decode('utf-8'), 'group_tab': True } new_content = [ '.. tab:: {}'.format(json.dumps(tab_args)), ' {}'.format(group_name), '', ] for idx, line in enumerate(new_content): self.content.data.insert(idx, line) self.content.items.insert(idx, (None, idx)) node = nodes.container() self.state.nested_parse(self.content, self.content_offset, node) return node.children class CodeTabDirective(Directive): """ Tab directive with a codeblock as its content""" has_content = True option_spec = { 'linenos': directives.flag } def run(self): """ Parse a tab directive """ self.assert_has_content() args = self.content[0].strip().split() self.content.trim_start(2) lang = args[0] tab_name = ' '.join(args[1:]) if len(args) > 1 else LEXER_MAP[lang] for idx, line in enumerate(self.content.data): self.content.data[idx] = ' ' + line tab_args = { 'tab_id': base64.b64encode( tab_name.encode('utf-8')).decode('utf-8'), 'classes': ['code-tab'], } new_content = [ '.. tab:: {}'.format(json.dumps(tab_args)), ' {}'.format(tab_name), '', ' .. code-block:: {}'.format(lang), ] if 'linenos' in self.options: new_content.append(' :linenos:') new_content.append('') for idx, line in enumerate(new_content): self.content.data.insert(idx, line) self.content.items.insert(idx, (None, idx)) node = nodes.container() self.state.nested_parse(self.content, self.content_offset, node) return node.children class _FindTabsDirectiveVisitor(nodes.NodeVisitor): """ Visitor pattern than looks for a sphinx tabs directive in a document """ def __init__(self, document): nodes.NodeVisitor.__init__(self, document) self._found = False def unknown_visit(self, node): if not self._found and isinstance(node, nodes.container) and \ 'classes' in node and isinstance(node['classes'], list): self._found = 'sphinx-tabs' in node['classes'] @property def found_tabs_directive(self): """ Return whether a sphinx tabs directive was found """ return self._found # pylint: disable=unused-argument def update_context(app, pagename, templatename, context, doctree): """ Remove sphinx-tabs CSS and JS asset files if not used in a page """ if doctree is None: return visitor = _FindTabsDirectiveVisitor(doctree) doctree.walk(visitor) if not visitor.found_tabs_directive: paths = [posixpath.join('_static', 'sphinx_tabs/' + f) for f in FILES] if 'css_files' in context: context['css_files'] = context['css_files'][:] for path in paths: if path.endswith('.css'): context['css_files'].remove(path) if 'script_files' in context: context['script_files'] = context['script_files'][:] for path in paths: if path.endswith('.js'): context['script_files'].remove(path) # pylint: enable=unused-argument def copy_assets(app, exception): """ Copy asset files to the output """ if 'getLogger' in dir(logging): log = logging.getLogger(__name__).info # pylint: disable=no-member else: log = app.info builders = get_compatible_builders(app) if exception: return if app.builder.name not in builders: if not app.config['sphinx_tabs_nowarn']: app.warn( 'Not copying tabs assets! Not compatible with %s builder' % app.builder.name) return log('Copying tabs assets') installdir = os.path.join(app.builder.outdir, '_static', 'sphinx_tabs') for path in FILES: source = resource_filename('sphinx_tabs', path) dest = os.path.join(installdir, path) destdir = os.path.dirname(dest) if not os.path.exists(destdir): os.makedirs(destdir) copyfile(source, dest) def setup(app): """ Set up the plugin """ app.add_config_value('sphinx_tabs_nowarn', False, '') app.add_config_value('sphinx_tabs_valid_builders', [], '') app.add_directive('tabs', TabsDirective) app.add_directive('tab', TabDirective) app.add_directive('group-tab', GroupTabDirective) app.add_directive('code-tab', CodeTabDirective) for path in ['sphinx_tabs/' + f for f in FILES]: if path.endswith('.css'): if 'add_css_file' in dir(app): app.add_css_file(path) else: app.add_stylesheet(path) if path.endswith('.js'): if 'add_script_file' in dir(app): app.add_script_file(path) else: app.add_javascript(path) app.connect('html-page-context', update_context) app.connect('build-finished', copy_assets)