Repository: getnikola/nikola

Issue:
Meta files for themes
**To do:**

- [x] Add rudimentary support on themes.getnikola.com so at least it builds
- [x] Update documentation (document format; themes repo readme; creating-a-theme + theming docs)
- [ ] Do useful (?) stuff with meta info on themes.getnikola.com:
  - [x] index: conflate 
  - [x] theme page: show new meta
  - [x] theme page: variants
  - [ ] tag pages for stories?
- [x] Write .theme files for existing themes (and make those dependencies in themes site)
- [ ] Perhaps do useful stuff (re compatibility) in Nikola core?

---

Our current way of specifying theme settings is by using two simple text files, engine and parent. This is not flexible enough, so I propose an extensible meta file format for themes.

Some meta fields that could be included:
* parent
* engine
* author, author_url
* website
* license
* tags
* based_on (eg. Bootstrap, Casper, Lanyon for Jekyll)
* family (eg. bootstrap3, zen)
* mako_version, jinja_version
* variants (eg. bootstrap3-gradients, zen-ipython)

Use cases (sample):
* Merging Mako and Jinja2 themes on the site (via @ralsina on IRC: *I think we should collapse the mako themes that have a jinja variant together in the listing*)
* Listing available theme variants on the site
* Adding tags so themes can be browsed by features they support, frameworks they use, etc.
* Attribution for authors, frameworks, inspirations, licenses
	
File format: For consistency with plugins, and simplicity, perhaps INI? (The old files have to stay for backwards compatibility, at least until v8.)

Comments and more ideas welcome.


Discussion:
I like this.
Me too. I'd like the file format to be similar as the one for plugins, so INI sounds good.

Resolving patch:
diff --git a/nikola/nikola.py b/nikola/nikola.py
--- a/nikola/nikola.py
+++ b/nikola/nikola.py
@@ -2267,6 +2267,9 @@ def generic_page_renderer(self, lang, post, filters, context=None):
         deps = post.deps(lang)
         uptodate_deps = post.deps_uptodate(lang)
         deps.extend(utils.get_asset_path(x, self.THEMES) for x in ('bundles', 'parent', 'engine'))
+        _theme_ini = utils.get_asset_path(self.config['THEME'] + '.theme', self.THEMES)
+        if _theme_ini:
+            deps.append(_theme_ini)
 
         context = copy(context) if context else {}
         context['post'] = post
diff --git a/nikola/plugins/command/theme.py b/nikola/plugins/command/theme.py
--- a/nikola/plugins/command/theme.py
+++ b/nikola/plugins/command/theme.py
@@ -32,6 +32,7 @@
 import shutil
 import time
 import requests
+import configparser
 
 import pygments
 from pygments.lexers import PythonLexer
@@ -131,6 +132,13 @@ class CommandTheme(Command):
             'default': 'base',
             'help': 'Parent to use for new theme (default: base)',
         },
+        {
+            'name': 'new_legacy_meta',
+            'long': 'legacy-meta',
+            'type': bool,
+            'default': False,
+            'help': 'Create legacy meta files for new theme',
+        },
     ]
 
     def _execute(self, options, args):
@@ -147,6 +155,7 @@ def _execute(self, options, args):
         new = options.get('new')
         new_engine = options.get('new_engine')
         new_parent = options.get('new_parent')
+        new_legacy_meta = options.get('new_legacy_meta')
         command_count = [bool(x) for x in (
             install,
             uninstall,
@@ -172,7 +181,7 @@ def _execute(self, options, args):
         elif copy_template:
             return self.copy_template(copy_template)
         elif new:
-            return self.new_theme(new, new_engine, new_parent)
+            return self.new_theme(new, new_engine, new_parent, new_legacy_meta)
 
     def do_install_deps(self, url, name):
         """Install themes and their dependencies."""
@@ -316,7 +325,7 @@ def copy_template(self, template):
             LOGGER.error("This file already exists in your templates directory ({0}).".format(base))
             return 3
 
-    def new_theme(self, name, engine, parent):
+    def new_theme(self, name, engine, parent, create_legacy_meta=False):
         """Create a new theme."""
         base = 'themes'
         themedir = os.path.join(base, name)
@@ -326,9 +335,7 @@ def new_theme(self, name, engine, parent):
             LOGGER.info("Created directory {0}".format(base))
 
         # Check if engine and parent match
-        engine_file = utils.get_asset_path('engine', utils.get_theme_chain(parent, self.site.themes_dirs))
-        with io.open(engine_file, 'r', encoding='utf-8') as fh:
-            parent_engine = fh.read().strip()
+        parent_engine = utils.get_template_engine(utils.get_theme_chain(parent, self.site.themes_dirs))
 
         if parent_engine != engine:
             LOGGER.error("Cannot use engine {0} because parent theme '{1}' uses {2}".format(engine, parent, parent_engine))
@@ -342,12 +349,24 @@ def new_theme(self, name, engine, parent):
             LOGGER.error("Theme already exists")
             return 2
 
-        with io.open(os.path.join(themedir, 'parent'), 'w', encoding='utf-8') as fh:
-            fh.write(parent + '\n')
-            LOGGER.info("Created file {0}".format(os.path.join(themedir, 'parent')))
-        with io.open(os.path.join(themedir, 'engine'), 'w', encoding='utf-8') as fh:
-            fh.write(engine + '\n')
-            LOGGER.info("Created file {0}".format(os.path.join(themedir, 'engine')))
+        cp = configparser.ConfigParser()
+        cp['Theme'] = {
+            'engine': engine,
+            'parent': parent
+        }
+
+        theme_meta_path = os.path.join(themedir, name + '.theme')
+        with io.open(theme_meta_path, 'w', encoding='utf-8') as fh:
+            cp.write(fh)
+            LOGGER.info("Created file {0}".format(theme_meta_path))
+
+        if create_legacy_meta:
+            with io.open(os.path.join(themedir, 'parent'), 'w', encoding='utf-8') as fh:
+                fh.write(parent + '\n')
+                LOGGER.info("Created file {0}".format(os.path.join(themedir, 'parent')))
+            with io.open(os.path.join(themedir, 'engine'), 'w', encoding='utf-8') as fh:
+                fh.write(engine + '\n')
+                LOGGER.info("Created file {0}".format(os.path.join(themedir, 'engine')))
 
         LOGGER.info("Theme {0} created successfully.".format(themedir))
         LOGGER.notice('Remember to set THEME="{0}" in conf.py to use this theme.'.format(name))
diff --git a/nikola/utils.py b/nikola/utils.py
--- a/nikola/utils.py
+++ b/nikola/utils.py
@@ -28,6 +28,7 @@
 
 from __future__ import print_function, unicode_literals, absolute_import
 import calendar
+import configparser
 import datetime
 import dateutil.tz
 import hashlib
@@ -607,27 +608,52 @@ def get_theme_path(theme):
     return theme
 
 
+def parse_theme_meta(theme_dir):
+    """Parse a .theme meta file."""
+    cp = configparser.ConfigParser()
+    # The `or` case is in case theme_dir ends with a trailing slash
+    theme_name = os.path.basename(theme_dir) or os.path.basename(os.path.dirname(theme_dir))
+    theme_meta_path = os.path.join(theme_dir, theme_name + '.theme')
+    cp.read(theme_meta_path)
+    return cp if cp.has_section('Theme') else None
+
+
 def get_template_engine(themes):
     """Get template engine used by a given theme."""
     for theme_name in themes:
-        engine_path = os.path.join(theme_name, 'engine')
-        if os.path.isfile(engine_path):
-            with open(engine_path) as fd:
-                return fd.readlines()[0].strip()
-    # default
-    return 'mako'
+        meta = parse_theme_meta(theme_name)
+        if meta:
+            e = meta.get('Theme', 'engine', fallback=None)
+            if e:
+                return e
+        else:
+            # Theme still uses old-style parent/engine files
+            engine_path = os.path.join(theme_name, 'engine')
+            if os.path.isfile(engine_path):
+                with open(engine_path) as fd:
+                    return fd.readlines()[0].strip()
+        # default
+        return 'mako'
 
 
 def get_parent_theme_name(theme_name, themes_dirs=None):
     """Get name of parent theme."""
-    parent_path = os.path.join(theme_name, 'parent')
-    if os.path.isfile(parent_path):
-        with open(parent_path) as fd:
-            parent = fd.readlines()[0].strip()
-        if themes_dirs:
+    meta = parse_theme_meta(theme_name)
+    if meta:
+        parent = meta.get('Theme', 'parent', fallback=None)
+        if themes_dirs and parent:
             return get_theme_path_real(parent, themes_dirs)
         return parent
-    return None
+    else:
+        # Theme still uses old-style parent/engine files
+        parent_path = os.path.join(theme_name, 'parent')
+        if os.path.isfile(parent_path):
+            with open(parent_path) as fd:
+                parent = fd.readlines()[0].strip()
+            if themes_dirs:
+                return get_theme_path_real(parent, themes_dirs)
+            return parent
+        return None
 
 
 def get_theme_chain(theme, themes_dirs):


Test patch: