# HG changeset patch # User Goffi # Date 1488753670 -3600 # Node ID f472179305a1f0ba191a4eb6598ddac70c8d0cd7 # Parent 255830fdb80b5ac8640fff9a27bad6b47d5b5df7 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 diff -r 255830fdb80b -r f472179305a1 frontends/src/jp/output_template.py --- 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) diff -r 255830fdb80b -r f472179305a1 src/core/constants.py --- 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 ## diff -r 255830fdb80b -r f472179305a1 src/tools/common/template.py --- 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)