chiark / gitweb /
documentation/python: initial support for documentation pages.
authorVladimír Vondruš <mosra@centrum.cz>
Sun, 5 May 2019 12:14:23 +0000 (14:14 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Tue, 21 May 2019 12:42:12 +0000 (14:42 +0200)
doc/documentation/python.rst
documentation/python.py
documentation/templates/python/page.html
documentation/test_python/page/another.html [new file with mode: 0644]
documentation/test_python/page/another.rst [new file with mode: 0644]
documentation/test_python/page/index.html [new file with mode: 0644]
documentation/test_python/page/index.rst [new file with mode: 0644]
documentation/test_python/page/pages.html [new file with mode: 0644]
documentation/test_python/test_page.py [new file with mode: 0644]

index 0c13aed92c7333960a62a1ddb827e0af661e8cae..ceed523b669d9a16def47f38428b2126fcbce82c 100644 (file)
@@ -145,7 +145,11 @@ Variable                            Description
                                     `shown here <{filename}/css/page-layout.rst#link-back-to-main-site-from-a-subsite>`_.
 :py:`INPUT_MODULES: List[Any]`      List of modules to generate the docs from.
                                     Values can be either strings or module
-                                    objects.
+                                    objects. See `Module inspection`_ for more
+                                    information.
+:py:`INPUT_PAGES: List[str]`        List of :abbr:`reST <reStructuredText>`
+                                    files for standalone pages. See `Pages`_
+                                    for more information.
 :py:`OUTPUT: str`                   Where to save the output. Relative paths
                                     relative are to the config file base dir;
                                     if not set, ``output/`` is used.
@@ -191,6 +195,9 @@ Variable                            Description
                                     put into the footer. If not set, a default
                                     generic text is used. If empty, no footer
                                     is rendered at all.
+:py:`FORMATTED_METADATA: List[str]` Which meatadata fields should be formatted
+                                    in documentation pages. By default only
+                                    the ``summary`` field is.
 :py:`CLASS_INDEX_EXPAND_LEVELS`     How many levels of the class index tree to
                                     expand. :py:`0` means only the top-level
                                     symbols are shown. If not set, :py:`1` is
@@ -352,8 +359,8 @@ search to a subdomain:
 
     SEARCH_EXTERNAL_URL = 'https://google.com/search?q=site:doc.magnum.graphics+{query}'
 
-`Content`_
-==========
+`Module inspection`_
+====================
 
 By default, if a module contains the :py:`__all__` attribute, all names listed
 there are exposed in the documentation. Otherwise, all module (and class)
@@ -523,6 +530,43 @@ non-obvious way to document enum values as well.
 The documentation output for enums includes enum value values and the class it
 was derived from, so it's possible to know whether it's an enum or a flag.
 
+`Pages`_
+========
+
+In addition to documentation generated by inspecting particular module, it's
+possible to add dedicated documentation pages. Content is written in
+:abbr:`reST <reStructuredText>` (see
+`Writing reST content <{filename}/themes/writing-rst-content.rst>`_ for a short
+introduction) and taken from files specified in :py:`INPUT_PAGES`. Filenames
+are interpreted relative to configuration file path, output filename is input
+basename with extension replaced to ``.html``. In particular, content of
+a ``index.rst`` file is used for the documentation main page. Example:
+
+.. code:: py
+
+    INPUT_PAGES = ['pages/index.rst']
+
+.. code:: rst
+
+    My Python library
+    =================
+
+    :summary: Welcome on the main page!
+
+    This is a documentation of the mypythonlib module. You can use it like
+    this:
+
+    .. code:: py
+
+        import mypythonlib
+        mypythonlib.foo()
+
+Apart from :py:`:summary:`, the page can have any number of metadata, with all
+of them exposed as properties of ``page`` in the `output templates`_. Fields
+listed in :py:`FORMATTED_METADATA` (the :py:`:summary:` is among them) are
+expected to be formatted as :abbr:`reST <reStructuredText>` and exposed as
+HTML, otherwise as a plain text.
+
 `pybind11 compatibility`_
 =========================
 
@@ -644,8 +688,8 @@ and ``m-dark.documentation.compiled.css`` files are produced like this:
     ./postprocess.py m-dark.css m-documentation.css -o m-dark+documentation.compiled.css
     ./postprocess.py m-dark.css m-documentation.css --no-import -o m-dark.documentation.compiled.css
 
-`Module documentation template`_
---------------------------------
+`Output templates`_
+-------------------
 
 Each output file is rendered with one of these templates:
 
@@ -654,10 +698,9 @@ Each output file is rendered with one of these templates:
 ======================= =======================================================
 Filename                Use
 ======================= =======================================================
-``module.html``         Module documentation. See `Module properties`_ for more
-                        information.
-``class.html``          Class documentation. See `Class properties`_ for more
-                        information.
+``module.html``         Module documentation
+``class.html``          Class documentation
+``page.html``           Explicit documentation pages, including the main page
 ======================= =======================================================
 
 Each template gets passed all configuration values from the `Configuration`_
@@ -685,9 +728,11 @@ Property                                Description
 :py:`page.url`                          File URL
 :py:`page.breadcrumb`                   List of :py:`(title, URL)` tuples for
                                         breadcrumb navigation.
+:py:`page.content`                      Detailed documentation, if any
 ======================================= =======================================
 
-Each module page has the following additional properties:
+Each module page, rendered with ``module.html``, has the following additional
+properties:
 
 .. class:: m-table m-fullwidth
 
@@ -711,7 +756,8 @@ Property                                Description
                                         description block [2]_
 ======================================= =======================================
 
-Each class page has the following additional properties:
+Each class page, rendered with ``class.html``, has the following additional
+properties:
 
 .. class:: m-table m-fullwidth
 
@@ -744,6 +790,11 @@ Property                                Description
                                         description block [2]_
 ======================================= =======================================
 
+Explicit documentation pages rendered with ``class.html`` have additional
+properties taken from input metadata. If given metadata is listed in
+:py:`FORMATTED_METADATA`, it's rendered into HTML, otherwise it's exposed as
+plain text.
+
 `Module properties`_
 ````````````````````
 
index 59a7aeccc86df85f010ba45dc9932fef9240dd9c..ec14d00701413e05b3c79fc5902f284a83163605 100755 (executable)
@@ -26,6 +26,7 @@
 
 import argparse
 import copy
+import docutils
 import enum
 import urllib.parse
 import html
@@ -56,6 +57,7 @@ default_config = {
     'PROJECT_SUBTITLE': None,
     'MAIN_PROJECT_URL': None,
     'INPUT_MODULES': [],
+    'INPUT_PAGES': [],
     'OUTPUT': 'output',
     'THEME_COLOR': '#22272e',
     'FAVICON': 'favicon-dark.png',
@@ -68,11 +70,16 @@ default_config = {
         ('Modules', 'modules', []),
         ('Classes', 'classes', [])],
     'LINKS_NAVBAR2': [],
+
     'PAGE_HEADER': None,
     'FINE_PRINT': '[default]',
+    'FORMATTED_METADATA': ['summary'],
+
     'CLASS_INDEX_EXPAND_LEVELS': 1,
     'CLASS_INDEX_EXPAND_INNER': False,
+
     'PYBIND11_COMPATIBILITY': False,
+
     'SEARCH_DISABLED': False,
     'SEARCH_DOWNLOAD_BINARY': False,
     'SEARCH_HELP': """.. raw:: html
@@ -830,6 +837,73 @@ def render_class(state: State, path, class_, env):
     render(state.config, 'class.html', page, env)
     return index_entry
 
+def publish_rst(source):
+    pub = docutils.core.Publisher(
+        writer=m.htmlsanity.SaneHtmlWriter(),
+        source_class=docutils.io.StringInput,
+        destination_class=docutils.io.StringOutput)
+    pub.set_components('standalone', 'restructuredtext', 'html')
+    #pub.writer.translator_class = m.htmlsanity.SaneHtmlTranslator
+    pub.process_programmatic_settings(None, m.htmlsanity.docutils_settings, None)
+    # Docutils uses a deprecated U mode for opening files, so instead of
+    # monkey-patching docutils.io.FileInput to not do that (like Pelican does),
+    # I just read the thing myself.
+    pub.set_source(source=source)
+    pub.publish()
+    return pub
+
+def render_page(state: State, path, filename, env):
+    logging.debug("generating %s.html", '.'.join(path))
+
+    # Render the file
+    with open(filename, 'r') as f: pub = publish_rst(f.read())
+
+    # Extract metadata from the page
+    metadata = {}
+    for docinfo in pub.document.traverse(docutils.nodes.docinfo):
+        for element in docinfo.children:
+            if element.tagname == 'field':
+                name_elem, body_elem = element.children
+                name = name_elem.astext()
+                if name in state.config['FORMATTED_METADATA']:
+                    # If the metadata are formatted, format them. Use a special
+                    # translator that doesn't add <dd> tags around the content,
+                    # also explicitly disable the <p> around as we not need it
+                    # always.
+                    # TODO: uncrapify this a bit
+                    visitor = m.htmlsanity._SaneFieldBodyTranslator(pub.document)
+                    visitor.compact_field_list = True
+                    body_elem.walkabout(visitor)
+                    value = visitor.astext()
+                else:
+                    value = body_elem.astext()
+                metadata[name.lower()] = value
+
+    # Breadcrumb, we don't do page hierarchy yet
+    assert len(path) == 1
+    breadcrumb = [(pub.writer.parts.get('title'), path[0] + '.html')]
+
+    page = Empty()
+    page.url = breadcrumb[-1][1]
+    page.breadcrumb = breadcrumb
+    page.prefix_wbr = path[0]
+
+    # Set page content and add extra metadata from there
+    page.content = pub.writer.parts.get('body').rstrip()
+    for key, value in metadata.items(): setattr(page, key, value)
+    if not hasattr(page, 'summary'): page.summary = ''
+
+    render(state.config, 'page.html', page, env)
+
+    # Index entry for this page, return only if it's not an index
+    if path == ['index']: return []
+    index_entry = IndexEntry()
+    index_entry.kind = 'page'
+    index_entry.name = breadcrumb[-1][0]
+    index_entry.url = page.url
+    index_entry.summary = page.summary
+    return [index_entry]
+
 def run(basedir, config, templates):
     # Prepare Jinja environment
     env = jinja2.Environment(
@@ -869,6 +943,9 @@ def run(basedir, config, templates):
 
         state.class_index += [render_module(state, [module_name], module, env)]
 
+    for page in config['INPUT_PAGES']:
+        state.page_index += render_page(state, [os.path.splitext(os.path.basename(page))[0]], os.path.join(basedir, page), env)
+
     # Recurse into the tree and mark every node that has nested modules with
     # has_nestaable_children.
     def mark_nested_modules(list: List[IndexEntry]):
@@ -895,12 +972,14 @@ def run(basedir, config, templates):
             # TODO could keep_trailing_newline fix this better?
             f.write(b'\n')
 
-    # Create index.html
-    # TODO: use actual reST source and have this just as a fallback
-    page = Empty()
-    page.breadcrumb = [(config['PROJECT_TITLE'], 'index.html')]
-    page.url = page.breadcrumb[-1][1]
-    render(config, 'page.html', page, env)
+    # Create index.html if it was not provided by the user
+    if 'index.rst' not in [os.path.basename(i) for i in config['INPUT_PAGES']]:
+        logging.debug("writing index.html for an empty main page")
+
+        page = Empty()
+        page.breadcrumb = [(config['PROJECT_TITLE'], 'index.html')]
+        page.url = page.breadcrumb[-1][1]
+        render(config, 'page.html', page, env)
 
     # Copy referenced files
     for i in config['STYLESHEETS'] + config['EXTRA_FILES'] + ([config['FAVICON'][0]] if config['FAVICON'] else []) + ([] if config['SEARCH_DISABLED'] else ['search.js']):
index 41bc697028550a632c860af85819d4ae31b64318..f3be8bad212bbd36f30c916dd1f97260f620158c 100644 (file)
@@ -6,4 +6,10 @@
         <h1>
           {%+ for name, target in page.breadcrumb[:-1] %}<span class="m-breadcrumb"><a href="{{ target }}">{{ name }}</a>.<wbr/></span>{% endfor %}{{ page.breadcrumb[-1][0] }}
         </h1>
+        {% if page.summary %}
+        <p>{{ page.summary }}</p>
+        {% endif %}
+        {% if page.content %}
+{{ page.content }}
+        {% endif %}
 {% endblock %}
diff --git a/documentation/test_python/page/another.html b/documentation/test_python/page/another.html
new file mode 100644 (file)
index 0000000..5501daf
--- /dev/null
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>Another page | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          Another page
+        </h1>
+        <p>Here's some summary. <strong>It's formated as well.</strong></p>
+<p>And the <em>text</em>.</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/page/another.rst b/documentation/test_python/page/another.rst
new file mode 100644 (file)
index 0000000..a6508a0
--- /dev/null
@@ -0,0 +1,6 @@
+Another page
+############
+
+:summary: Here's some summary. **It's formated as well.**
+
+And the *text*.
diff --git a/documentation/test_python/page/index.html b/documentation/test_python/page/index.html
new file mode 100644 (file)
index 0000000..845bead
--- /dev/null
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>Main Page | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          Main Page
+        </h1>
+<p>This page is not shown in the page tree.</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/page/index.rst b/documentation/test_python/page/index.rst
new file mode 100644 (file)
index 0000000..a2c051a
--- /dev/null
@@ -0,0 +1,4 @@
+Main Page
+#########
+
+This page is not shown in the page tree.
diff --git a/documentation/test_python/page/pages.html b/documentation/test_python/page/pages.html
new file mode 100644 (file)
index 0000000..6caa460
--- /dev/null
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>Pages</h2>
+        <ul class="m-doc">
+          <li><a href="another.html" class="m-doc">Another page</a> <span class="m-doc">Here's some summary. <strong>It's formated as well.</strong></span></li>
+        </ul>
+        <script>
+        function toggle(e) {
+            e.parentElement.className = e.parentElement.className == 'm-doc-collapsible' ?
+                'm-doc-expansible' : 'm-doc-collapsible';
+            return false;
+        }
+        /* Collapse all nodes marked as such. Doing it via JS instead of
+           directly in markup so disabling it doesn't harm usability. The list
+           is somehow regenerated on every iteration and shrinks as I change
+           the classes. It's not documented anywhere and I'm not sure if this
+           is the same across browsers, so I am going backwards in that list to
+           be sure. */
+        var collapsed = document.getElementsByClassName("collapsed");
+        for(var i = collapsed.length - 1; i >= 0; --i)
+            collapsed[i].className = 'm-doc-expansible';
+        </script>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/test_page.py b/documentation/test_python/test_page.py
new file mode 100644 (file)
index 0000000..b61415c
--- /dev/null
@@ -0,0 +1,37 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+from . import BaseTestCase
+
+class Page(BaseTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, '', *args, **kwargs)
+
+    def test(self):
+        self.run_python({
+            'INPUT_PAGES': ['index.rst', 'another.rst']
+        })
+        self.assertEqual(*self.actual_expected_contents('index.html'))
+        self.assertEqual(*self.actual_expected_contents('another.html'))
+        self.assertEqual(*self.actual_expected_contents('pages.html'))