diff sat/tools/common/template.py @ 2671:0fa217fafabf

tools (common/template), jp: refactoring to handle multiple sites: - site can now be specified in template header before theme, for instance: (some_site/some_theme)path/to/template.ext - absolute template paths are now implemented, but Renderer must be instanciated with trusted to True for security reason (it's the case for jp) - a new "front_url_filter" callable can be given to Renderer, which will convert template path to URL seen by end-user (default to real path). - the "front_url_filter" can be used in templates with… "front_url" filter - template_data is a new named tuple available in templates, which give site, theme and template relative URL - search order is site/theme, site/default_theme, and default/default_theme where default link to sat_pubsub templates - when loading CSS files, files with _noscript suffixes are now loaded, and used when javascript is not available - "styles_extra.css" is also loaded before "styles.css", useful when a theme want to reuse default style, and just override some rules - new site can be specified in sat.conf [DEFAULT] section, using sites_path_public_dict or sites_path_private_dict (where sites_path_private_dict won't be used in public frontends, like Libervia) - "private" argument of Renderer tells the renderer to load private sites or not - templates are now loaded from "templates" subdirectory, to differenciate them from other data like i18n - jp template output has been updated to handle those changes, and to manage absolute templates
author Goffi <goffi@goffi.org>
date Mon, 10 Sep 2018 08:58:18 +0200
parents 56f94936df1e
children 39d187f3698d
line wrap: on
line diff
--- a/sat/tools/common/template.py	Fri Aug 31 17:18:51 2018 +0200
+++ b/sat/tools/common/template.py	Mon Sep 10 08:58:18 2018 +0200
@@ -17,16 +17,16 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-""" template generation """
+"""Template generation"""
 
+import os.path
+from collections import namedtuple
 from sat.core.constants import Const as C
 from sat.core.i18n import _
 from sat.core import exceptions
+from sat.tools import config
 from sat.tools.common import date_utils
 from sat.core.log import getLogger
-
-log = getLogger(__name__)
-import os.path
 from xml.sax.saxutils import quoteattr
 import time
 import re
@@ -41,7 +41,8 @@
     import sat_templates
 except ImportError:
     raise exceptions.MissingModule(
-        u"sat_templates module is not available, please install it or check your path to use template engine"
+        u"sat_templates module is not available, please install it or check your path to "
+        u"use template engine"
     )
 else:
     sat_templates  # to avoid pyflakes warning
@@ -50,90 +51,206 @@
     import jinja2
 except:
     raise exceptions.MissingModule(
-        u"Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2"
+        u"Missing module jinja2, please install it from http://jinja.pocoo.org or with "
+        u"pip install jinja2"
     )
 
 from jinja2 import Markup as safe
 from jinja2 import is_undefined
+from jinja2 import utils
+from jinja2 import TemplateNotFound
+from jinja2.loaders import split_template_path
 from lxml import etree
 
+log = getLogger(__name__)
+
 HTML_EXT = ("html", "xhtml")
 RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
-#  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
+SITE_RESERVED_NAMES = (u"sat",)
+TPL_RESERVED_CHARS = ur"()/."
+RE_TPL_RESERVED_CHARS = re.compile(u"[" + TPL_RESERVED_CHARS + u"]")
+
+TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])
 
 
-class TemplateLoader(jinja2.FileSystemLoader):
-    def __init__(self):
-        searchpath = os.path.dirname(sat_templates.__file__)
-        super(TemplateLoader, self).__init__(searchpath, followlinks=True)
+class TemplateLoader(jinja2.BaseLoader):
+    """A template loader which handle site, theme and absolute paths"""
+    # TODO: list_templates should be implemented
 
-    def parse_template(self, template):
-        """parse template path and return theme and relative URL
+    def __init__(self, sites_paths, trusted=False):
+        """
+        @param trusted(bool): if True, absolue template paths will be allowed
+            be careful when using this option and sure that you can trust the template,
+            as this allow the template to open any file on the system that the
+            launching user can access.
+        """
+        if not sites_paths or not u"" in sites_paths:
+            raise exceptions.InternalError(u"Invalid sites_paths")
+        super(jinja2.BaseLoader, self).__init__()
+        self.sites_paths = sites_paths
+        self.trusted = trusted
+
+    @staticmethod
+    def parse_template(template):
+        """Parse template path and return site, theme and path
 
         @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
+            The site and/or theme can be specified in parenthesis just before the path
+            e.g.: (some_theme)path/to/template.html
+                  (/some_theme)path/to/template.html (equivalent to previous one)
+                  (other_site/other_theme)path/to/template.html
+                  (other_site/)path/to/template.html (defaut theme for other_site)
+                  /absolute/path/to/template.html (in trusted environment only)
+        @return (TemplateData):
+            site, theme and template_path.
+            if site is empty, SàT Templates are used
+            site and theme can be both None if absolute path is used
+            Relative path is the path from theme root dir e.g. blog/articles.html
         """
         if template.startswith(u"("):
+            # site and/or theme are specified
             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)
+                raise ValueError(u"incorrect site/theme in template")
+            theme_data = template[1:theme_end]
+            theme_splitted = theme_data.split(u'/')
+            if len(theme_splitted) == 1:
+                site, theme = u"", theme_splitted
+            elif len(theme_splitted) == 2:
+                site, theme = theme_splitted
+            else:
+                raise ValueError(u"incorrect site/theme in template")
+            template_path = template[theme_end+1:]
+            if not template_path or template_path.startswith(u"/"):
+                raise ValueError(u"incorrect template path")
         elif template.startswith(u"/"):
-            # absolute path means no template
+            # this is an absolute path, so we have no site and no theme
+            site = None
             theme = None
-            raise NotImplementedError(u"absolute path is not implemented yet")
+            template_path = template
         else:
+            # a default template
+            site = u""
             theme = C.TEMPLATE_THEME_DEFAULT
-            template = os.path.join(theme, template)
-        return theme, template
+            template_path = template
+
+        if site is not None:
+            site = site.strip()
+            if not site:
+                site = u""
+            elif site in SITE_RESERVED_NAMES:
+                raise ValueError(_(u"{site} can't be used as site name, "
+                                   u"it's reserved.").format(site=site))
+
+        if theme is not None:
+            theme = theme.strip()
+            if not theme:
+                theme = C.TEMPLATE_THEME_DEFAULT
+            if RE_TPL_RESERVED_CHARS.search(theme):
+                raise ValueError(_(u"{theme} contain forbidden char. Following chars "
+                                   u"are forbidden: {reserved}").format(
+                                   theme=theme, reserved=TPL_RESERVED_CHARS))
+
+        return TemplateData(site, theme, template_path)
 
-    def get_default_template(self, theme, template_path):
-        """return default template path
+    @staticmethod
+    def getSitesAndThemes(site, theme):
+        """Get sites and themes to check for template/file
 
-        @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
+        Will add default theme and default site in search list when suitable
+        @param site(unicode): site requested
+        @param theme(unicode): theme requested
+        @return (list[tuple[unicode, unicode]]): site and theme couples to check
         """
-        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
+        sites_and_themes = [[site, theme]]
         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:])
+            sites_and_themes.append([site, C.TEMPLATE_THEME_DEFAULT])
+        if site:
+            # the site is not the default one, so we add default at the end
+            sites_and_themes.append([u'', C.TEMPLATE_THEME_DEFAULT])
+        return sites_and_themes
+
+    def _get_template_f(self, site, theme, path_elts):
+        """Look for template and return opened file if found
+
+        @param site(unicode): names of site to check
+            (default site will also checked)
+        @param theme(unicode): theme to check (default theme will also be checked)
+        @param path_elts(iterable[str]): elements of template path
+        @return (tuple[(File, None), (str, None)]): a tuple with:
+            - opened template, or None if not found
+            - absolute file path, or None if not found
+        """
+        if site is None:
+            raise exceptions.InternalError(
+                u"_get_template_f must not be used with absolute path")
+        for site, theme in self.getSitesAndThemes(site, theme):
+            try:
+                base_path = self.sites_paths[site]
+            except KeyError:
+                log.warning(_(u"Unregistered site requested: {site}").format(
+                    site=site))
+            filepath = os.path.join(base_path, C.TEMPLATE_TPL_DIR, theme, *path_elts)
+            f = utils.open_if_exists(filepath)
+            if f is not None:
+                return f, filepath
+        return None, None
 
     def get_source(self, environment, template):
-        """relative path to template dir, with special theme handling
+        """Retrieve source handling site and themes
 
+        If the path is absolute it is used directly if in trusted environment
+        else and exception is raised.
         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
+        @raise PermissionError: absolute path used in untrusted environment
         """
-        theme, template_path = self.parse_template(template)
+        site, theme, template_path = self.parse_template(template)
+
+        if site is None:
+            # we have an abolute template
+            if theme is not None:
+                raise exceptions.InternalError(u"We can't have a theme with absolute "
+                                               u"template.")
+            if not self.trusted:
+                log.error(_(u"Absolute template used while unsecure is disabled, hack "
+                            u"attempt? Template: {template}").format(template=template))
+                raise exceptions.PermissionError(u"absolute template is not allowed")
+            filepath = template_path
+            f = utils.open_if_exists(filepath)
+        else:
+            # relative path, we have to deal with site and theme
+            assert theme and template_path
+            path_elts = split_template_path(template_path)
+            # if we have non default site, we check it first, else we only check default
+            f, filepath = self._get_template_f(site, theme, path_elts)
+
+        if f is None:
+            if (site is not None and path_elts[0] == u"error"
+                and os.path.splitext(template_path)[1][1:] in HTML_EXT):
+                # if an HTML error is requested but doesn't exist, we try again
+                # with base error.
+                f, filepath = self._get_template_f(
+                    site, theme, ("error", "base.html"))
+                if f is None:
+                    raise exceptions.InternalError(u"error/base.html should exist")
+            else:
+                raise TemplateNotFound(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
+            contents = f.read().decode('utf-8')
+        finally:
+            f.close()
+
+        mtime = os.path.getmtime(filepath)
+
+        def uptodate():
+            try:
+                return os.path.getmtime(filepath) == mtime
+            except OSError:
+                return False
+
+        return contents, filepath, uptodate
 
 
 class Indexer(object):
@@ -154,12 +271,10 @@
 
 
 class ScriptsHandler(object):
-    def __init__(self, renderer, template_path, template_root_dir, root_path):
+    def __init__(self, renderer, template_data):
         self.renderer = renderer
-        self.template_root_dir = template_root_dir
-        self.root_path = root_path
+        self.template_data = template_data
         self.scripts = []  #  we don't use a set because order may be important
-        dummy, self.theme, self.is_default_theme = renderer.getThemeData(template_path)
 
     def include(self, library_name, attribute="defer"):
         """Mark that a script need to be imported.
@@ -169,13 +284,13 @@
         @param library_name(unicode): name of the library to import
         @param loading:
         """
-        if attribute not in ("defer", "async", ""):
+        if attribute not in (u"defer", u"async", u""):
             raise exceptions.DataError(
                 _(u'Invalid attribute, please use one of "defer", "async" or ""')
             )
-        if library_name.endswith(".js"):
-            library_name = library_name[:-3]
-        if library_name not in self.scripts:
+        if not library_name.endswith(u".js"):
+            library_name = library_name + u".js"
+        if (library_name, attribute) not in self.scripts:
             self.scripts.append((library_name, attribute))
         return u""
 
@@ -187,25 +302,61 @@
         scripts = []
         tpl = u"<script src={src} {attribute}></script>"
         for library, attribute in self.scripts:
-            path = self.renderer.getStaticPath(
-                library, self.template_root_dir, self.theme, self.is_default_theme, ".js"
-            )
-            if path is None:
-                log.warning(_(u"Can't find {}.js javascript library").format(library))
+            library_path = self.renderer.getStaticPath(self.template_data, library)
+            if library_path is None:
+                log.warning(_(u"Can't find {libary} javascript library").format(
+                    library=library))
                 continue
-            path = os.path.join(self.root_path, path)
+            path = self.renderer.getFrontURL(library_path)
             scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
         return safe(u"\n".join(scripts))
 
 
 class Renderer(object):
-    def __init__(self, host):
+
+    def __init__(self, host, front_url_filter=None, trusted=False, private=False):
+        """
+        @param front_url_filter(callable): filter to retrieve real url of a directory/file
+            The callable will get a two arguments:
+                - a dict with a "template_data" key containing TemplateData instance of
+                  current template. Only site and theme should be necessary.
+                - the relative URL of the file to retrieve, relative from theme root
+            None to use default filter which return real path on file
+            Need to be specified for web rendering, to reflect URL seen by end user
+        @param trusted(bool): if True, allow to access absolute path
+            Only set to True if environment is safe (e.g. command line tool)
+        @param private(bool): if True, also load sites from sites_path_private_dict
+        """
+        # TODO:
         self.host = host
-        self.base_dir = os.path.dirname(
-            sat_templates.__file__
-        )  # FIXME: should be modified if we handle use extra dirs
+        self.trusted = trusted
+        self.sites_paths = {
+            u"": os.path.dirname(sat_templates.__file__),
+        }
+        conf = config.parseMainConf()
+        public_sites = config.getConfig(conf, None, u"sites_path_public_dict", {})
+        sites_data = [public_sites]
+        if private:
+            private_sites = config.getConfig(conf, None, u"sites_path_private_dict", {})
+            sites_data.append(private_sites)
+        for sites in sites_data:
+            normalised = {}
+            for name, path in sites.iteritems():
+                if RE_TPL_RESERVED_CHARS.search(name):
+                    log.warning(_(u"Can't add \"{name}\" site, it contains forbidden "
+                                  u"characters. Forbidden characters are {forbidden}.")
+                                .format(name=name, forbidden=TPL_RESERVED_CHARS))
+                    continue
+                path = os.path.expanduser(os.path.normpath(path))
+                if not path or not path.startswith(u"/"):
+                    log.warning(_(u"Can't add \"{name}\" site, it should map to an "
+                                  u"absolute path").format(name=name))
+                    continue
+                normalised[name] = path
+            self.sites_paths.update(normalised)
+
         self.env = jinja2.Environment(
-            loader=TemplateLoader(),
+            loader=TemplateLoader(sites_paths=self.sites_paths, trusted=trusted),
             autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
             trim_blocks=True,
             lstrip_blocks=True,
@@ -217,41 +368,64 @@
         # we want to have access to SàT constants in templates
         self.env.globals[u"C"] = C
         # custom filters
-        self.env.filters["next_gidx"] = self._next_gidx
-        self.env.filters["cur_gidx"] = self._cur_gidx
-        self.env.filters["date_fmt"] = self._date_fmt
-        self.env.filters["xmlui_class"] = self._xmlui_class
-        self.env.filters["attr_escape"] = self.attr_escape
-        self.env.filters["item_filter"] = self._item_filter
-        self.env.filters["adv_format"] = self._adv_format
-        self.env.filters["dict_ext"] = self._dict_ext
-        self.env.filters["highlight"] = self.highlight
+        self.env.filters[u"next_gidx"] = self._next_gidx
+        self.env.filters[u"cur_gidx"] = self._cur_gidx
+        self.env.filters[u"date_fmt"] = self._date_fmt
+        self.env.filters[u"xmlui_class"] = self._xmlui_class
+        self.env.filters[u"attr_escape"] = self.attr_escape
+        self.env.filters[u"item_filter"] = self._item_filter
+        self.env.filters[u"adv_format"] = self._adv_format
+        self.env.filters[u"dict_ext"] = self._dict_ext
+        self.env.filters[u"highlight"] = self.highlight
+        self.env.filters[u"front_url"] = (self._front_url if front_url_filter is None
+                                         else front_url_filter)
         # custom tests
-        self.env.tests["in_the_past"] = self._in_the_past
+        self.env.tests[u"in_the_past"] = self._in_the_past
         self.icons_path = os.path.join(host.media_dir, u"fonts/fontello/svg")
 
+    def getFrontURL(self, template_data, path=None):
+        """Give front URL (i.e. URL seen by end-user) of a path
+
+        @param template_data[TemplateData]: data of current template
+        @param path(unicode, None): relative path of file to get,
+            if set, will remplate template_data.path
+        """
+        return self.env.filters[u"front_url"]({u"template_data": template_data},
+                                path or template_data.path)
+
     def installTranslations(self):
-        i18n_dir = os.path.join(self.base_dir, "i18n")
-        self.translations = {}
-        for lang_dir in os.listdir(i18n_dir):
-            lang_path = os.path.join(i18n_dir, lang_dir)
-            if not os.path.isdir(lang_path):
-                continue
-            po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
-            try:
-                with open(po_path, "rb") as f:
-                    self.translations[Locale.parse(lang_dir)] = support.Translations(
-                        f, "sat"
-                    )
-            except EnvironmentError:
-                log.error(
-                    _(u"Can't find template translation at {path}").format(path=po_path)
-                )
-            except UnknownLocaleError as e:
-                log.error(_(u"Invalid locale name: {msg}").format(msg=e))
-            else:
-                log.info(_(u"loaded {lang} templates translations").format(lang=lang_dir))
-        self.env.install_null_translations(True)
+        # TODO: support multi translation
+        #       for now, only translations in sat_templates are handled
+        for site_key, site_path in self.sites_paths.iteritems():
+            site_prefix = u"[{}] ".format(site_key) if site_key else u''
+            i18n_dir = os.path.join(site_path, "i18n")
+            self.translations = {}
+            for lang_dir in os.listdir(i18n_dir):
+                lang_path = os.path.join(i18n_dir, lang_dir)
+                if not os.path.isdir(lang_path):
+                    continue
+                po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
+                try:
+                    locale = Locale.parse(lang_dir)
+                    with open(po_path, "rb") as f:
+                        try:
+                            translations = self.translations[locale]
+                        except KeyError:
+                            self.translations[locale] = support.Translations(f, "sat")
+                        else:
+                            translations.merge(support.Translations(f, "sat"))
+                except EnvironmentError:
+                    log.error(
+                        _(u"Can't find template translation at {path}").format(
+                            path=po_path))
+                except UnknownLocaleError as e:
+                    log.error(_(u"{site}Invalid locale name: {msg}").format(
+                        site=site_prefix, msg=e))
+                else:
+                    log.info(_(u"{site}loaded {lang} templates translations").format(
+                        site = site_prefix,
+                        lang=lang_dir))
+            self.env.install_null_translations(True)
 
     def setLocale(self, locale_str):
         """set current locale
@@ -294,79 +468,137 @@
 
         @param template(unicode): template to parse
         @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
+        @raise NotFound: requested site has not been found
         """
-        theme, dummy = self.env.loader.parse_template(template)
-        return theme, os.path.join(self.base_dir, theme)
-
-    def getStaticPath(self, name, template_root_dir, theme, is_default, ext=".css"):
-        """retrieve path of a static file if it exists with current theme or default
+        # FIXME: check use in jp, and include site
+        site, theme, __ = self.env.loader.parse_template(template)
+        if site is None:
+            # absolute template
+            return  u"", os.path.dirname(template)
+        try:
+            site_root_dir = self.sites_paths[site]
+        except KeyError:
+            raise exceptions.NotFound
+        return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme)
 
-        File will be looked at [theme]/static/[name][ext], and then default
-        if not found.
-        @param name(unicode): name of the file to look for
-        @param template_root_dir(unicode): absolute path to template root used
-        @param theme(unicode): name of the template theme used
-        @param is_default(bool): True if theme is the default theme
-        @return (unicode, None): relative path if found, else None
+    def getStaticPath(self, template_data, filename):
+        """Retrieve path of a static file if it exists with current theme or default
+
+        File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename,
+        then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally
+        <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates).
+        In case of absolue URL, base dir of template is used as base. For instance if
+        template is an absolute template to "/some/path/template.html", file will be
+        checked at "/some/path/<filename>"
+        @param template_data(TemplateData): data of current template
+        @return (TemplateData, None): built template data instance where .path is
+            the relative path to the file, from theme root dir.
+            None if not found.
         """
-        file_ = None
-        path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + ext)
-        if os.path.exists(os.path.join(template_root_dir, path)):
-            file_ = path
-        elif not is_default:
-            path = os.path.join(
-                C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + ext
-            )
-            if os.path.exists(os.path.join(template_root_dir, path)):
-                file_.append(path)
-        return file_
+        if template_data.site is None:
+            # we have and absolue path
+            if (not template_data.theme is None
+                or not template_data.path.startswith(u'/')):
+                raise exceptions.InternalError(
+                    u"invalid template data, was expecting absolute URL")
+            static_dir = os.path.dirname(template_data.path)
+            file_path = os.path.join(static_dir, filename)
+            if os.path.exists(file_path):
+                return TemplateData(site=None, theme=None, path=file_path)
+            else:
+                return None
 
-    def getThemeData(self, template_path):
-        """return template data got from template_path
+        sites_and_themes = TemplateLoader.getSitesAndThemes(template_data.site,
+                                                            template_data.theme)
+        for site, theme in sites_and_themes:
+            site_root_dir = self.sites_paths[site]
+            relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename)
+            absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR,
+                                         theme, relative_path)
+            if os.path.exists(absolute_path):
+                return TemplateData(site=site, theme=theme, path=relative_path)
+
+        return None
+
+    def _appendCSSPaths(self, template_data, css_files, css_files_noscript, name_root):
+        """Append found css to css_files and css_files_noscript
 
-        @return tuple(unicode, unicode, bool):
-            path_elems: elements of the path
-            theme: theme of the page
-            is_default: True if the theme is the default theme
+        @param css_files(list): list to fill of relative path to found css file
+        @param css_files_noscript(list): list to fill of relative path to found css file
+            with "_noscript" suffix
         """
-        path_elems = [os.path.splitext(p)[0] for p in template_path.split(u"/")]
-        theme = path_elems.pop(0)
-        is_default = theme == C.TEMPLATE_THEME_DEFAULT
-        return (path_elems, theme, is_default)
+        name = name_root + u".css"
+        css_path = self.getStaticPath(template_data, name)
+        if css_path is not None:
+            css_files.append(self.getFrontURL(css_path))
+            noscript_name = name_root + u"_noscript.css"
+            noscript_path = self.getStaticPath(template_data, noscript_name)
+            if noscript_path is not None:
+                css_files_noscript.append(self.getFrontURL(noscript_path))
+
+    def getCSSFiles(self, template_data):
+        """Retrieve CSS files to use according template_data
 
-    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
+        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 '_'.
+        styles_extra.css, styles.css and fonts.css are always used if they exist.
+        For each found file, if a file with the same name and "_noscript" suffix exists,
+        it will be return is second part of resulting tuple.
+        For instance, if template_data is (some_site, some_theme, blog/articles.html),
+        following files are returned, earch time trying [some_site root] first,
+        then default site (i.e. sat_templates) root:
+            - 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)
+        and for each found files, if same file with _noscript suffix exists, it is put
+        in noscript list (for instance (some_theme/static/styles_noscript.css)).
+        @param template_data(TemplateData): data of the current template
+        @return (tuple[list[unicode], list[unicode]]): a tuple with:
+            - front URLs of CSS files to use
+            - front URLs of CSS files to use when scripts are not enabled
         """
         # TODO: some caching would be nice
         css_files = []
-        path_elems, theme, is_default = self.getThemeData(template_path)
-        for css in (u"fonts", u"styles"):
-            css_path = self.getStaticPath(css, template_root_dir, theme, is_default)
-            if css_path is not None:
-                css_files.append(css_path)
+        css_files_noscript = []
+        path_elems = template_data.path.split(u'/')
+        path_elems[-1] = os.path.splitext(path_elems[-1])[0]
+
+        for name_root in (u'styles_extra', u'styles'):
+            self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root)
 
-        for idx, path in enumerate(path_elems):
-            css_path = self.getStaticPath(
-                u"_".join(path_elems[: idx + 1]), template_root_dir, theme, is_default
-            )
-            if css_path is not None:
-                css_files.append(css_path)
+        css_path = self.getStaticPath(template_data, u'fonts.css')
+        if css_path is not None:
+            css_files.append(self.getFrontURL(css_path))
 
-        return css_files
+        for idx in xrange(len(path_elems)):
+            name_root = u"_".join(path_elems[:idx+1])
+            self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root)
+
+        return css_files, css_files_noscript
 
     ## custom filters ##
 
     @jinja2.contextfilter
+    def _front_url(self, ctx, relative_url):
+        """Get front URL (URL seen by end-user) from a relative URL
+
+        This default method return absolute full path
+        """
+        template_data = ctx[u'template_data']
+        if template_data.site is None:
+            assert template_data.theme is None
+            assert template_data.path.startswith(u"/")
+            return os.path.join(os.path.dirname(template_data.path, relative_url))
+
+        site_root_dir = self.sites_paths[template_data.site]
+        return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme,
+                            relative_url)
+
+    @jinja2.contextfilter
     def _next_gidx(self, ctx, value):
         """Use next current global index as suffix"""
         next_ = ctx["gidx"].next(value)
@@ -433,7 +665,8 @@
             if filter is a callable, apply it
             if filter is a dict, it can have following keys:
                 - filters: iterable of filters to apply
-                - filters_args: kwargs of filters in the same order as filters (use empty dict if needed)
+                - filters_args: kwargs of filters in the same order as filters (use empty
+                                dict if needed)
                 - template: template to format where {value} is the filtered value
         """
         value = item.value
@@ -500,7 +733,8 @@
     def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None):
         """Do syntax highlighting on code
 
-        under the hood, pygments is used, check its documentation for options possible values
+        Under the hood, pygments is used, check its documentation for options possible
+        values.
         @param code(unicode): code or markup to highlight
         @param lexer_name(unicode, None): name of the lexer to use
             None to autodetect it
@@ -531,7 +765,10 @@
     ## template methods ##
 
     def _icon_defs(self, *names):
-        """Define svg icons which will be used in the template, and use their name as id"""
+        """Define svg icons which will be used in the template.
+
+        Their name is used as id
+        """
         svg_elt = etree.Element(
             "svg",
             nsmap={None: "http://www.w3.org/2000/svg"},
@@ -551,88 +788,78 @@
         return safe(etree.tostring(svg_elt, encoding="unicode"))
 
     def _icon_use(self, name, cls=""):
-        return safe(
-            u"""<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
-          <use href="#{name}"/>
-        </svg>
-        """.format(
-                name=name, cls=(" " + cls) if cls else ""
-            )
-        )
+        return safe(u'<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
+                    u'viewBox="0 0 100 100">\n'
+                    u'    <use href="#{name}"/>'
+                    u'</svg>\n'.format(name=name, cls=(" " + cls) if cls else ""))
 
-    def render(
-        self,
-        template,
-        theme=None,
-        locale=C.DEFAULT_LOCALE,
-        root_path=u"",
-        media_path=u"",
-        css_files=None,
-        css_inline=False,
-        **kwargs
-    ):
-        """render a template
-.
+    def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
+               media_path=u"", css_files=None, css_inline=False, **kwargs):
+        """Render a template
+
         @param template(unicode): template to render (e.g. blog/articles.html)
+        @param site(unicide): site name
+            None or empty string for defaut site (i.e. SàT templates)
         @param theme(unicode): template theme
-        @param root_path(unicode): prefix of the path/URL to use for template root
-            must end with a u'/'
-        @param media_path(unicode): prefix of the SàT media path/URL to use for template root
-            must end with a u'/'
+        @param media_path(unicode): prefix of the SàT media 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.
+            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 site is not None or theme is not None:
+            # user wants to set site and/or theme, so we add it to the template path
+            if site is None:
+                site = u''
+            if theme is None:
+                theme = C.TEMPLATE_THEME_DEFAULT
             if template[0] == u"(":
                 raise ValueError(
-                    u"you can't specify theme in template path and in argument at the same time"
+                    u"you can't specify site or theme in template path and in argument "
+                    u"at the same time"
                 )
-            elif template[0] == u"/":
-                raise ValueError(u"you can't specify theme with absolute paths")
-            template = u"(" + theme + u")" + template
+
+            template_data = TemplateData(site, theme, template)
+            template = u"({site}/{theme}){template}".format(
+                site=site, theme=theme, template=template)
         else:
-            theme, dummy = self.env.loader.parse_template(template)
+            template_data = self.env.loader.parse_template(template)
 
         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)
+            css_files, css_files_noscript = self.getCSSFiles(template_data)
 
         kwargs["icon_defs"] = self._icon_defs
         kwargs["icon"] = self._icon_use
 
         if css_inline:
             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)
+            for files, suffix in ((css_files, u""),
+                                  (css_files_noscript, u"_noscript")):
+                site_root_dir = self.sites_paths[template_data.site]
+                for css_file in files:
+                    css_file_path = os.path.join(site_root_dir, css_file)
+                    with open(css_file_path) as f:
+                        css_contents.append(f.read())
+                if css_contents:
+                    kwargs[u"css_content" + suffix] = u"\n".join(css_contents)
 
-        scripts_handler = ScriptsHandler(
-            self, template_path, template_root_dir, root_path
-        )
+        scripts_handler = ScriptsHandler(self, template_data)
         self.setLocale(locale)
-        # 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.
+        # 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,
+            template_data=template_data,
             media_path=media_path,
             css_files=css_files,
+            css_files_noscript=css_files,
             locale=self._locale,
             gidx=Indexer(),
             script=scripts_handler,