changeset 2169:f472179305a1

tools(templates): workflow improvments: - template theme can be specified in parenthesis: (some_theme)path/to/template.html. Withtout parenthesis, "default" is used - static content are supposed to be in [theme]/static, error pages in [theme]/error/[err_code].html - default page are used in some case (2 for now): if error page with specified code doesn't exists, a base page is used, and if a page doesn't exist for a theme, the same one for default theme is looked for - CSS files are automatically found for HTML pages - CSS files can be split, the'll be added in the template according to the page requested. - theme CSS file is looked for, and if not found the default theme equivalent is looked for. - each element of a path can be associated to a CSS file, and styles.css is always there. For instance if blog/articles.html is requested, the following CSS can be included: "styles.css", "blog.css", "blog_article.css". They all must be in /static - if the automatic finding of CSS files is not wanted, css_files arguments can be used instead, with full relative path (i.e. including theme) - CSS files can be merged and included inline with css_inline argument - root_path can be specified, it will be used as a prefix for static files - requested theme (which may differ from actual theme, e.g. if the template is not found and default one is used instead) is available in template with "theme" variable - added getThemeAndRoot method to retrieve theme and theme root path from template
author Goffi <goffi@goffi.org>
date Sun, 05 Mar 2017 23:41:10 +0100
parents 255830fdb80b
children d246666ebe25
files frontends/src/jp/output_template.py src/core/constants.py src/tools/common/template.py
diffstat 3 files changed, 176 insertions(+), 19 deletions(-) [+]
line wrap: on
line diff
--- a/frontends/src/jp/output_template.py	Sun Mar 05 21:36:01 2017 +0100
+++ b/frontends/src/jp/output_template.py	Sun Mar 05 23:41:10 2017 +0100
@@ -102,10 +102,11 @@
             tmp_file = os.path.join(tmp_dir, template_name)
             with open(tmp_file, 'w') as f:
                 f.write(rendered.encode('utf-8'))
-            static_dir = self.renderer.getStaticDir(template_path)
+            theme, theme_root_path = self.renderer.getThemeAndRoot(template_path)
+            static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR)
             if os.path.exists(static_dir):
                 import shutil
-                shutil.copytree(static_dir, os.path.join(tmp_dir, u'static'))
+                shutil.copytree(static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR))
             webbrowser.open(tmp_file)
         else:
             self.host.disp(rendered)
--- a/src/core/constants.py	Sun Mar 05 21:36:01 2017 +0100
+++ b/src/core/constants.py	Sun Mar 05 23:41:10 2017 +0100
@@ -171,6 +171,10 @@
                             ['%s/' % path for path in list(BaseDirectory.load_config_paths(APP_NAME_FILE))]
                            ]
 
+    ## Templates ##
+    TEMPLATE_THEME_DEFAULT = u'default'
+    TEMPLATE_STATIC_DIR = u'static'
+
 
     ## Plugins ##
 
--- a/src/tools/common/template.py	Sun Mar 05 21:36:01 2017 +0100
+++ b/src/tools/common/template.py	Sun Mar 05 23:41:10 2017 +0100
@@ -36,15 +36,90 @@
 except:
     raise exceptions.MissingModule(u'Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2')
 
+HTML_EXT = ('html', 'xhtml')
+# TODO: handle external path (an additional search path for templates should be settable by user
+# TODO: handle absolute URL (should be used for trusted use cases) only (e.g. jp) for security reason
+
+
+class TemplateLoader(jinja2.FileSystemLoader):
+
+    def __init__(self):
+        searchpath = os.path.dirname(sat_templates.__file__)
+        super(TemplateLoader, self).__init__(searchpath, followlinks=True)
+
+    def parse_template(self, template):
+        """parse template path and return theme and relative URL
+
+        @param template_path(unicode): path to template with parenthesis syntax
+        @return (tuple[(unicode,None),unicode]): theme and template_path
+            theme can be None if relative path is used
+            relative path is the path from search path with theme specified
+            e.g. default/blog/articles.html
+        """
+        if template.startswith(u'('):
+            try:
+                theme_end = template.index(u')')
+            except IndexError:
+                raise ValueError(u"incorrect theme in template")
+            theme = template[1:theme_end]
+            template = template[theme_end+1:]
+            if not template or template.startswith(u'/'):
+                raise ValueError(u"incorrect path after template name")
+            template = os.path.join(theme, template)
+        elif template.startswith(u'/'):
+            # absolute path means no template
+            theme = None
+            raise NotImplementedError(u'absolute path is not implemented yet')
+        else:
+            theme = C.TEMPLATE_THEME_DEFAULT
+            template = os.path.join(theme, template)
+        return theme, template
+
+    def get_default_template(self, theme, template_path):
+        """return default template path
+
+        @param theme(unicode): theme used
+        @param template_path(unicode): path to the not found template
+        @return (unicode, None): default path or None if there is not
+        """
+        ext = os.path.splitext(template_path)[1][1:]
+        path_elems = template_path.split(u'/')
+        if ext in HTML_EXT:
+            if path_elems[1] == u'error':
+                # if an inexisting error page is requested, we return base page
+                default_path = os.path.join(theme, u'error/base.html')
+                return default_path
+        if theme != C.TEMPLATE_THEME_DEFAULT:
+            # if template doesn't exists for this theme, we try with default
+            return os.path.join(C.TEMPLATE_THEME_DEFAULT, path_elems[1:])
+
+    def get_source(self, environment, template):
+        """relative path to template dir, with special theme handling
+
+        if the path is just relative, "default" theme is used.
+        The theme can be specified in parenthesis just before the path
+        e.g.: (some_theme)path/to/template.html
+        """
+        theme, template_path = self.parse_template(template)
+        try:
+            return super(TemplateLoader, self).get_source(environment, template_path)
+        except jinja2.exceptions.TemplateNotFound as e:
+            # in some special cases, a defaut template is returned if nothing is found
+            if theme is not None:
+                default_path = self.get_default_template(theme, template_path)
+                if default_path is not None:
+                    return super(TemplateLoader, self).get_source(environment, default_path)
+            # if no default template is found, we re-raise the error
+            raise e
+
 
 class Renderer(object):
 
-    def __init__(self, host): # , template_dir=None):
+    def __init__(self, host):
         self.host = host
-        self.base_dir = os.path.dirname(sat_templates.__file__)
-        self.theme = u'default'  # FIXME: temporary, template should be selected in render()
+        self.base_dir = os.path.dirname(sat_templates.__file__)  # FIXME: should be modified if we handle use extra dirs
         self.env = jinja2.Environment(
-            loader=jinja2.PackageLoader('sat_templates', self.theme),
+            loader=TemplateLoader(),
             autoescape=jinja2.select_autoescape(['html', 'xhtml', 'xml']),
             trim_blocks=True,
             lstrip_blocks=True,
@@ -52,24 +127,101 @@
         # we want to have access to SàT constants in templates
         self.env.globals[u'C'] = C
 
-    def render(self, template_path, theme=u"default", css_file=u"style.css", css_inline=False, **kwargs):
+    def getThemeAndRoot(self, template):
+        """retrieve theme and root dir of a given tempalte
+
+        @param template(unicode): template to parse
+        @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
+        """
+        theme, dummy = self.env.loader.parse_template(template)
+        return theme, os.path.join(self.base_dir, theme)
+
+    def _appendCSSIfExists(self, css_files, template_root_dir, theme, name, is_default):
+        """append CSS file to list if it exists, else try with default theme
+
+        CSS file will be looked at [theme]/static/[name].css, and then default
+        if not found.
+        @param css_files(list): list of CSS file to be completed
+        @param template_root_dir(unicode): absolute path to template root used
+        @param theme(unicode): name of the template theme used
+        @param name(unicode): name of the CSS file to look for
+        @param is_default(bool): True if theme is the default theme
+        """
+        css_path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + '.css')
+        if os.path.exists(os.path.join(template_root_dir, css_path)):
+            css_files.append(css_path)
+        elif not is_default:
+            css_path = os.path.join(C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + '.css')
+            if os.path.exists(os.path.join(template_root_dir, css_path)):
+                css_files.append(css_path)
+
+    def getCSSFiles(self, template_path, template_root_dir):
+        """retrieve CSS files to use according to theme and template path
+
+        for each element of the path, a .css file is looked for in /static, and returned if it exists.
+        previous element are kept by replacing '/' with '_', and styles.css is always returned.
+        For instance, if template_path is some_theme/blog/articles.html:
+            some_theme/static/styles.css is returned if it exists else default/static/styles.css
+            some_theme/static/blog.css is returned if it exists else default/static/blog.css (if it exists too)
+            some_theme/static/blog_articles.css is returned if it exists else default/static/blog_articles.css (if it exists too)
+        @param template_path(unicode): relative path to template file (e.g. some_theme/blog/articles.html)
+        @param template_root_dir(unicode): absolute path of the theme root dir used
+        @return list[unicode]: relative path to CSS files to use
+        """
+        # TODO: some caching would be nice
+        css_files = []
+        path_elems = template_path.split(u'/')
+        theme = path_elems.pop(0)
+        is_default = theme == C.TEMPLATE_THEME_DEFAULT
+        self._appendCSSIfExists(css_files, template_root_dir, theme, u'styles', is_default)
+
+        for idx, path in enumerate(path_elems):
+            self._appendCSSIfExists(css_files, template_root_dir, theme, u'_'.join(path_elems[:idx+1]), is_default)
+
+        return css_files
+
+    def render(self, template, theme=None, root_path=u'', css_files=None, css_inline=False, **kwargs):
         """render a template
 
-        @param template_path(unicode): path of the template to render (e.g. blog/articles.html)
+        @param template(unicode): template to render (e.g. blog/articles.html)
         @param theme(unicode): template theme
-        @param css_file(unicode): path to CSS file (relative to template dir, or absolute)
+        @param root_path(unicode): prefix of the path/URL to use for template root
+            must end with a u'/'
+        @param css_files(list[unicode],None): CSS files to used
+            CSS files must be in static dir of the template
+            use None for automatic selection of CSS files based on template category
+            None is recommended. General static/style.css and theme file name will be used.
         @param css_inline(bool): if True, CSS will be embedded in the HTML page
         @param **kwargs: variable to transmit to the template
         """
+        if not template:
+            raise ValueError(u"template can't be empty")
+        if theme is not None:
+            # use want to set a theme, we add it to the template path
+            if template[0] == u'(':
+                raise ValueError(u"you can't specify theme in template path and in argument at the same time")
+            elif template[0] == u'/':
+                raise ValueError(u"you can't specify theme with absolute paths")
+            template= u'(' + theme + u')' + template
+        else:
+            theme, dummy = self.env.loader.parse_template(template)
 
-        # TODO: handle theme
-        template = self.env.get_template(template_path)
+        template_source = self.env.get_template(template)
+        template_root_dir = os.path.normpath(self.base_dir)  # FIXME: should be modified if we handle use extra dirs
+        # XXX: template_path may have a different theme as first element than theme if a default page is used
+        template_path = template_source.filename[len(template_root_dir)+1:]
+
+        if css_files is None:
+            css_files = self.getCSSFiles(template_path, template_root_dir)
+
         if css_inline:
-            css_file_path = os.path.join(self.getStaticDir(template_path), css_file)
-            with open(css_file_path, 'r') as f:
-                kwargs[u"css_content"] = f.read()
-        return template.render(theme=theme, css_file=css_file, css_inline=css_inline, **kwargs)
-
-    def getStaticDir(self, template_path):
-        template_base = template_path.split(u'/')[0]
-        return os.path.join(self.base_dir, self.theme, template_base, "static")
+            css_contents = []
+            for css_file in css_files:
+                css_file_path = os.path.join(template_root_dir, css_file)
+                with open(css_file_path) as f:
+                    css_contents.append(f.read())
+            if css_contents:
+                kwargs['css_content'] = '\n'.join(css_contents)
+        # XXX: theme used in template arguments is the requested theme, which may differ from actual theme
+        #      if the template doesn't exist in the requested theme.
+        return template_source.render(theme=theme, root_path=root_path, css_files=css_files, **kwargs)