diff --git a/CHANGES b/CHANGES index 99806e953..3ba44e84c 100644 --- a/CHANGES +++ b/CHANGES @@ -77,6 +77,8 @@ Features added * #3476: setuptools: Support multiple builders * latex: merged cells in LaTeX tables allow code-blocks, lists, blockquotes... as do normal cells (refs: #3435) +* HTML buildre uses experimental HTML5 writer if ``html_experimental_html5_builder`` is True + and docutils 0.13 and newer is installed. Bugs fixed ---------- diff --git a/doc/config.rst b/doc/config.rst index dba953207..2a13848fc 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -1088,6 +1088,11 @@ that use Sphinx's HTMLWriter class. Output file base name for HTML help builder. Default is ``'pydoc'``. +.. confval:: html_experimental_html5_writer + + Output is processed with HTML5 writer. This feature needs docutils 0.13 or newer. Default is ``False``. + + .. versionadded:: 1.6 .. _applehelp-options: diff --git a/sphinx/application.py b/sphinx/application.py index 782dccb9a..1f88c1291 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -46,6 +46,7 @@ from sphinx.util import status_iterator, old_status_iterator, display_chunk from sphinx.util.tags import Tags from sphinx.util.osutil import ENOENT from sphinx.util.console import bold, darkgreen # type: ignore +from sphinx.util.docutils import is_html5_writer_available from sphinx.util.i18n import find_catalog_source_files if False: @@ -623,24 +624,32 @@ class Sphinx(object): raise ExtensionError('Value for key %r must be a ' '(visit, depart) function tuple' % key) translator = self._translators.get(key) + translators = [] if translator is not None: - pass + translators.append(translator) elif key == 'html': - from sphinx.writers.html import HTMLTranslator as translator # type: ignore + from sphinx.writers.html import HTMLTranslator + translators.append(HTMLTranslator) + if is_html5_writer_available(): + from sphinx.writers.html5 import HTML5Translator + translators.append(HTML5Translator) elif key == 'latex': - from sphinx.writers.latex import LaTeXTranslator as translator # type: ignore + from sphinx.writers.latex import LaTeXTranslator + translators.append(LaTeXTranslator) elif key == 'text': - from sphinx.writers.text import TextTranslator as translator # type: ignore + from sphinx.writers.text import TextTranslator + translators.append(TextTranslator) elif key == 'man': - from sphinx.writers.manpage import ManualPageTranslator as translator # type: ignore # NOQA + from sphinx.writers.manpage import ManualPageTranslator + translators.append(ManualPageTranslator) elif key == 'texinfo': - from sphinx.writers.texinfo import TexinfoTranslator as translator # type: ignore # NOQA - else: - # ignore invalid keys for compatibility - continue - setattr(translator, 'visit_' + node.__name__, visit) - if depart: - setattr(translator, 'depart_' + node.__name__, depart) + from sphinx.writers.texinfo import TexinfoTranslator + translators.append(TexinfoTranslator) + + for translator in translators: + setattr(translator, 'visit_' + node.__name__, visit) + if depart: + setattr(translator, 'depart_' + node.__name__, depart) def add_enumerable_node(self, node, figtype, title_getter=None, **kwds): # type: (nodes.Node, unicode, Callable, Any) -> None diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py index f777ecacc..acad9bed1 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html.py @@ -34,6 +34,7 @@ from sphinx.util.i18n import format_date from sphinx.util.osutil import SEP, os_path, relative_uri, ensuredir, \ movefile, copyfile from sphinx.util.nodes import inline_all_toctrees +from sphinx.util.docutils import is_html5_writer_available, __version_info__ from sphinx.util.fileutil import copy_asset from sphinx.util.matching import patmatch, Matcher, DOTFILES from sphinx.config import string_classes @@ -55,6 +56,13 @@ if False: from sphinx.domains import Domain, Index # NOQA from sphinx.application import Sphinx # NOQA +# Experimental HTML5 Writer +if is_html5_writer_available(): + from sphinx.writers.html5 import HTML5Translator, SmartyPantsHTML5Translator + html5_ready = True +else: + html5_ready = False + #: the filename for the inventory of objects INVENTORY_FILENAME = 'objects.inv' #: the filename for the "last build" file (for serializing builders) @@ -145,6 +153,12 @@ class StandaloneHTMLBuilder(Builder): self.script_files.append('_static/translations.js') self.use_index = self.get_builder_config('use_index', 'html') + if self.config.html_experimental_html5_writer and not html5_ready: + self.app.warn(' '.join(( + 'html_experimental_html5_writer is set, but current version is old.', + 'Docutils\' version should be or newer than 0.13, but %s.', + )) % '.'.join(map(str, __version_info__))) + def _get_translations_js(self): # type: () -> unicode candidates = [path.join(dir, self.config.language, @@ -188,10 +202,16 @@ class StandaloneHTMLBuilder(Builder): def init_translator_class(self): # type: () -> None if self.translator_class is None: - if self.config.html_use_smartypants: - self.translator_class = SmartyPantsHTMLTranslator + if self.config.html_experimental_html5_writer and html5_ready: + if self.config.html_use_smartypants: + self.translator_class = SmartyPantsHTML5Translator + else: + self.translator_class = HTML5Translator else: - self.translator_class = HTMLTranslator + if self.config.html_use_smartypants: + self.translator_class = SmartyPantsHTMLTranslator + else: + self.translator_class = HTMLTranslator def get_outdated_docs(self): # type: () -> Iterator[unicode] @@ -374,6 +394,7 @@ class StandaloneHTMLBuilder(Builder): parents = [], logo = logo, favicon = favicon, + html5_doctype = self.config.html_experimental_html5_writer and html5_ready, ) # type: Dict[unicode, Any] if self.theme: self.globalcontext.update( @@ -1320,6 +1341,7 @@ def setup(app): app.add_config_value('html_search_options', {}, 'html') app.add_config_value('html_search_scorer', '', None) app.add_config_value('html_scaled_image_link', True, 'html') + app.add_config_value('html_experimental_html5_writer', False, 'html') return { 'version': 'builtin', diff --git a/sphinx/themes/basic/layout.html b/sphinx/themes/basic/layout.html index 2d37d7134..c47299dae 100644 --- a/sphinx/themes/basic/layout.html +++ b/sphinx/themes/basic/layout.html @@ -7,10 +7,12 @@ :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. #} -{%- block doctype -%} +{%- block doctype -%}{%- if html5_doctype %} + +{%- else %} -{%- endblock %} +{%- endif %}{%- endblock %} {%- set reldelim1 = reldelim1 is not defined and ' »' or reldelim1 %} {%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %} {%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and diff --git a/sphinx/util/docutils.py b/sphinx/util/docutils.py index c50364fd9..d9bc64bf3 100644 --- a/sphinx/util/docutils.py +++ b/sphinx/util/docutils.py @@ -150,3 +150,8 @@ class LoggingReporter(Reporter): def set_conditions(self, category, report_level, halt_level, debug=False): # type: (unicode, int, int, bool) -> None Reporter.set_conditions(self, category, report_level, halt_level, debug=debug) + + +def is_html5_writer_available(): + # type: () -> bool + return __version_info__ > (0, 13, 0) diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py new file mode 100644 index 000000000..c935b1940 --- /dev/null +++ b/sphinx/writers/html5.py @@ -0,0 +1,929 @@ +# -*- coding: utf-8 -*- +""" + sphinx.writers.html5 + ~~~~~~~~~~~~~~~~~~~~ + + Experimental docutils writers for HTML5 handling Sphinx' custom nodes. + + :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import sys +import posixpath +import os + +from six import string_types +from docutils import nodes +from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator + +from sphinx import addnodes +from sphinx.locale import admonitionlabels, _ +from sphinx.util import logging +from sphinx.util.images import get_image_size +from sphinx.util.smartypants import sphinx_smarty_pants + +if False: + # For type annotation + from typing import Any # NOQA + from sphinx.builders.html import StandaloneHTMLBuilder # NOQA + + +logger = logging.getLogger(__name__) + +# A good overview of the purpose behind these classes can be found here: +# http://www.arnebrodowski.de/blog/write-your-own-restructuredtext-writer.html + + +class HTML5Translator(BaseTranslator): + """ + Our custom HTML translator. + """ + + def __init__(self, builder, *args, **kwds): + # type: (StandaloneHTMLBuilder, Any, Any) -> None + BaseTranslator.__init__(self, *args, **kwds) + self.highlighter = builder.highlighter + self.no_smarty = 0 + self.builder = builder + self.highlightlang = self.highlightlang_base = \ + builder.config.highlight_language + self.highlightopts = builder.config.highlight_options + self.highlightlinenothreshold = sys.maxsize + self.docnames = [builder.current_docname] # for singlehtml builder + self.protect_literal_text = 0 + self.permalink_text = builder.config.html_add_permalinks + # support backwards-compatible setting to a bool + if not isinstance(self.permalink_text, string_types): + self.permalink_text = self.permalink_text and u'\u00B6' or '' + self.permalink_text = self.encode(self.permalink_text) + self.secnumber_suffix = builder.config.html_secnumber_suffix + self.param_separator = '' + self.optional_param_level = 0 + self._table_row_index = 0 + self.required_params_left = 0 + + def visit_start_of_file(self, node): + # type: (nodes.Node) -> None + # only occurs in the single-file builder + self.docnames.append(node['docname']) + self.body.append('' % node['docname']) + + def depart_start_of_file(self, node): + # type: (nodes.Node) -> None + self.docnames.pop() + + def visit_desc(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'dl', CLASS=node['objtype'])) + + def depart_desc(self, node): + # type: (nodes.Node) -> None + self.body.append('\n\n') + + def visit_desc_signature(self, node): + # type: (nodes.Node) -> None + # the id is set automatically + self.body.append(self.starttag(node, 'dt')) + # anchor for per-desc interactive data + if node.parent['objtype'] != 'describe' \ + and node['ids'] and node['first']: + self.body.append('' % node['ids'][0]) + + def depart_desc_signature(self, node): + # type: (nodes.Node) -> None + if not node.get('is_multiline'): + self.add_permalink_ref(node, _('Permalink to this definition')) + self.body.append('\n') + + def visit_desc_signature_line(self, node): + # type: (nodes.Node) -> None + pass + + def depart_desc_signature_line(self, node): + # type: (nodes.Node) -> None + if node.get('add_permalink'): + # the permalink info is on the parent desc_signature node + self.add_permalink_ref(node.parent, _('Permalink to this definition')) + self.body.append('
') + + def visit_desc_addname(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'code', '', CLASS='descclassname')) + + def depart_desc_addname(self, node): + # type: (nodes.Node) -> None + self.body.append('') + + def visit_desc_type(self, node): + # type: (nodes.Node) -> None + pass + + def depart_desc_type(self, node): + # type: (nodes.Node) -> None + pass + + def visit_desc_returns(self, node): + # type: (nodes.Node) -> None + self.body.append(' → ') + + def depart_desc_returns(self, node): + # type: (nodes.Node) -> None + pass + + def visit_desc_name(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'code', '', CLASS='descname')) + + def depart_desc_name(self, node): + # type: (nodes.Node) -> None + self.body.append('') + + def visit_desc_parameterlist(self, node): + # type: (nodes.Node) -> None + self.body.append('(') + self.first_param = 1 + self.optional_param_level = 0 + # How many required parameters are left. + self.required_params_left = sum([isinstance(c, addnodes.desc_parameter) + for c in node.children]) + self.param_separator = node.child_text_separator + + def depart_desc_parameterlist(self, node): + # type: (nodes.Node) -> None + self.body.append(')') + + # If required parameters are still to come, then put the comma after + # the parameter. Otherwise, put the comma before. This ensures that + # signatures like the following render correctly (see issue #1001): + # + # foo([a, ]b, c[, d]) + # + def visit_desc_parameter(self, node): + # type: (nodes.Node) -> None + if self.first_param: + self.first_param = 0 + elif not self.required_params_left: + self.body.append(self.param_separator) + if self.optional_param_level == 0: + self.required_params_left -= 1 + if not node.hasattr('noemph'): + self.body.append('') + + def depart_desc_parameter(self, node): + # type: (nodes.Node) -> None + if not node.hasattr('noemph'): + self.body.append('') + if self.required_params_left: + self.body.append(self.param_separator) + + def visit_desc_optional(self, node): + # type: (nodes.Node) -> None + self.optional_param_level += 1 + self.body.append('[') + + def depart_desc_optional(self, node): + # type: (nodes.Node) -> None + self.optional_param_level -= 1 + self.body.append(']') + + def visit_desc_annotation(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'em', '', CLASS='property')) + + def depart_desc_annotation(self, node): + # type: (nodes.Node) -> None + self.body.append('') + + def visit_desc_content(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'dd', '')) + + def depart_desc_content(self, node): + # type: (nodes.Node) -> None + self.body.append('') + + def visit_versionmodified(self, node): + # type: (nodes.Node) -> None + self.body.append(self.starttag(node, 'div', CLASS=node['type'])) + + def depart_versionmodified(self, node): + # type: (nodes.Node) -> None + self.body.append('\n') + + # overwritten + def visit_reference(self, node): + # type: (nodes.Node) -> None + atts = {'class': 'reference'} + if node.get('internal') or 'refuri' not in node: + atts['class'] += ' internal' + else: + atts['class'] += ' external' + if 'refuri' in node: + atts['href'] = node['refuri'] or '#' + if self.settings.cloak_email_addresses and \ + atts['href'].startswith('mailto:'): + atts['href'] = self.cloak_mailto(atts['href']) + self.in_mailto = 1 + else: + assert 'refid' in node, \ + 'References must have "refuri" or "refid" attribute.' + atts['href'] = '#' + node['refid'] + if not isinstance(node.parent, nodes.TextElement): + assert len(node) == 1 and isinstance(node[0], nodes.image) + atts['class'] += ' image-reference' + if 'reftitle' in node: + atts['title'] = node['reftitle'] + if 'target' in node: + atts['target'] = node['target'] + self.body.append(self.starttag(node, 'a', '', **atts)) + + if node.get('secnumber'): + self.body.append(('%s' + self.secnumber_suffix) % + '.'.join(map(str, node['secnumber']))) + + def visit_number_reference(self, node): + # type: (nodes.Node) -> None + self.visit_reference(node) + + def depart_number_reference(self, node): + # type: (nodes.Node) -> None + self.depart_reference(node) + + # overwritten -- we don't want source comments to show up in the HTML + def visit_comment(self, node): + # type: (nodes.Node) -> None + raise nodes.SkipNode + + # overwritten + def visit_admonition(self, node, name=''): + # type: (nodes.Node, unicode) -> None + self.body.append(self.starttag( + node, 'div', CLASS=('admonition ' + name))) + if name: + node.insert(0, nodes.title(name, admonitionlabels[name])) + + def visit_seealso(self, node): + # type: (nodes.Node) -> None + self.visit_admonition(node, 'seealso') + + def depart_seealso(self, node): + # type: (nodes.Node) -> None + self.depart_admonition(node) + + def add_secnumber(self, node): + # type: (nodes.Node) -> None + if node.get('secnumber'): + self.body.append('.'.join(map(str, node['secnumber'])) + + self.secnumber_suffix) + elif isinstance(node.parent, nodes.section): + if self.builder.name == 'singlehtml': + docname = self.docnames[-1] + anchorname = "%s/#%s" % (docname, node.parent['ids'][0]) + if anchorname not in self.builder.secnumbers: + anchorname = "%s/" % docname # try first heading which has no anchor + else: + anchorname = '#' + node.parent['ids'][0] + if anchorname not in self.builder.secnumbers: + anchorname = '' # try first heading which has no anchor + if self.builder.secnumbers.get(anchorname): + numbers = self.builder.secnumbers[anchorname] + self.body.append('.'.join(map(str, numbers)) + + self.secnumber_suffix) + + def add_fignumber(self, node): + # type: (nodes.Node) -> None + def append_fignumber(figtype, figure_id): + # type: (unicode, unicode) -> None + if self.builder.name == 'singlehtml': + key = u"%s/%s" % (self.docnames[-1], figtype) + else: + key = figtype + + if figure_id in self.builder.fignumbers.get(key, {}): + self.body.append('') + prefix = self.builder.config.numfig_format.get(figtype) + if prefix is None: + msg = 'numfig_format is not defined for %s' % figtype + logger.warning(msg) + else: + numbers = self.builder.fignumbers[key][figure_id] + self.body.append(prefix % '.'.join(map(str, numbers)) + ' ') + self.body.append('') + + figtype = self.builder.env.domains['std'].get_figtype(node) # type: ignore + if figtype: + if len(node['ids']) == 0: + msg = 'Any IDs not assigned for %s node' % node.tagname + logger.warning(msg, location=node) + else: + append_fignumber(figtype, node['ids'][0]) + + def add_permalink_ref(self, node, title): + # type: (nodes.Node, unicode) -> None + if node['ids'] and self.permalink_text and self.builder.add_permalinks: + format = u'%s' + self.body.append(format % (node['ids'][0], title, self.permalink_text)) + + def generate_targets_for_listing(self, node): + # type: (nodes.Node) -> None + """Generate hyperlink targets for listings. + + Original visit_bullet_list(), visit_definition_list() and visit_enumerated_list() + generates hyperlink targets inside listing tags (