view libervia/backend/tools/common/template.py @ 4108:238e305f2306

plugin JID Search: JID search plugin, first draft
author Goffi <goffi@goffi.org>
date Thu, 06 Jul 2023 11:34:51 +0200
parents 810921c33a47
children 81faa85c9cfa
line wrap: on
line source

#!/usr/bin/env python3

# SAT: an XMPP client
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# 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"""

from collections import namedtuple
from datetime import datetime
import html
import json
import os.path
from pathlib import Path
import re
import time
from typing import List, Optional, Tuple, Union
from xml.sax.saxutils import quoteattr

from babel import support
from babel import Locale
from babel.core import UnknownLocaleError
from jinja2 import is_undefined
from jinja2 import utils
from jinja2 import TemplateNotFound
from jinja2 import pass_context
from jinja2.loaders import split_template_path
from lxml import etree
from markupsafe import Markup as safe
import pygments
from pygments import lexers
from pygments import formatters

from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.tools import config
from libervia.backend.tools.common import date_utils

log = getLogger(__name__)

try:
    import sat_templates
except ImportError:
    raise exceptions.MissingModule(
        "sat_templates module is not available, please install it or check your path to "
        "use template engine"
    )
else:
    sat_templates  # to avoid pyflakes warning

try:
    import jinja2
except:
    raise exceptions.MissingModule(
        "Missing module jinja2, please install it from http://jinja.pocoo.org or with "
        "pip install jinja2"
    )



HTML_EXT = ("html", "xhtml")
RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
SITE_RESERVED_NAMES = ("sat",)
TPL_RESERVED_CHARS = r"()/."
RE_TPL_RESERVED_CHARS = re.compile("[" + TPL_RESERVED_CHARS + "]")
BROWSER_DIR = "_browser"
BROWSER_META_FILE = "browser_meta.json"

TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])


class TemplateLoader(jinja2.BaseLoader):
    """A template loader which handle site, theme and absolute paths"""
    # TODO: list_templates should be implemented

    def __init__(self, sites_paths, sites_themes, 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 "" in sites_paths:
            raise exceptions.InternalError("Invalid sites_paths")
        super(jinja2.BaseLoader, self).__init__()
        self.sites_paths = sites_paths
        self.sites_themes = sites_themes
        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
            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("("):
            # site and/or theme are specified
            try:
                theme_end = template.index(")")
            except IndexError:
                raise ValueError("incorrect site/theme in template")
            theme_data = template[1:theme_end]
            theme_splitted = theme_data.split('/')
            if len(theme_splitted) == 1:
                site, theme = "", theme_splitted[0]
            elif len(theme_splitted) == 2:
                site, theme = theme_splitted
            else:
                raise ValueError("incorrect site/theme in template")
            template_path = template[theme_end+1:]
            if not template_path or template_path.startswith("/"):
                raise ValueError("incorrect template path")
        elif template.startswith("/"):
            # this is an absolute path, so we have no site and no theme
            site = None
            theme = None
            template_path = template
        else:
            # a default template
            site = ""
            theme = C.TEMPLATE_THEME_DEFAULT
            template_path = template

        if site is not None:
            site = site.strip()
            if not site:
                site = ""
            elif site in SITE_RESERVED_NAMES:
                raise ValueError(_("{site} can't be used as site name, "
                                   "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(_("{theme} contain forbidden char. Following chars "
                                   "are forbidden: {reserved}").format(
                                   theme=theme, reserved=TPL_RESERVED_CHARS))

        return TemplateData(site, theme, template_path)

    @staticmethod
    def get_sites_and_themes(
            site: str,
            theme: str,
            settings: Optional[dict] = None,
        ) -> List[Tuple[str, str]]:
        """Get sites and themes to check for template/file

        Will add default theme and default site in search list when suitable. Settings'
        `fallback` can be used to modify behaviour: themes in this list will then be used
        instead of default (it can also be empty list or None, in which case no fallback
        is used).

        @param site: site requested
        @param theme: theme requested
        @return: site and theme couples to check
        """
        if settings is None:
            settings = {}
        sites_and_themes = [[site, theme]]
        fallback = settings.get("fallback", [C.TEMPLATE_THEME_DEFAULT])
        for fb_theme in fallback:
            if theme != fb_theme:
                sites_and_themes.append([site, fb_theme])
        if site:
            for fb_theme in fallback:
                sites_and_themes.append(["", fb_theme])
        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(
                "_get_template_f must not be used with absolute path")
        settings = self.sites_themes[site][theme]['settings']
        for site_to_check, theme_to_check in self.get_sites_and_themes(
                site, theme, settings):
            try:
                base_path = self.sites_paths[site_to_check]
            except KeyError:
                log.warning(_("Unregistered site requested: {site_to_check}").format(
                    site_to_check=site_to_check))
            filepath = os.path.join(
                base_path,
                C.TEMPLATE_TPL_DIR,
                theme_to_check,
                *path_elts
            )
            f = utils.open_if_exists(filepath, 'r')
            if f is not None:
                return f, filepath
        return None, None

    def get_source(self, environment, template):
        """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.
        @raise PermissionError: absolute path used in untrusted environment
        """
        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("We can't have a theme with absolute "
                                               "template.")
            if not self.trusted:
                log.error(_("Absolute template used while unsecure is disabled, hack "
                            "attempt? Template: {template}").format(template=template))
                raise exceptions.PermissionError("absolute template is not allowed")
            filepath = template_path
            f = utils.open_if_exists(filepath, 'r')
        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] == "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("error/base.html should exist")
            else:
                raise TemplateNotFound(template)

        try:
            contents = f.read()
        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):
    """Index global to a page"""

    def __init__(self):
        self._indexes = {}

    def next(self, value):
        if value not in self._indexes:
            self._indexes[value] = 0
            return 0
        self._indexes[value] += 1
        return self._indexes[value]

    def current(self, value):
        return self._indexes.get(value)


class ScriptsHandler(object):
    def __init__(self, renderer, template_data):
        self.renderer = renderer
        self.template_data = template_data
        self.scripts = []  #  we don't use a set because order may be important

    def include(self, library_name, attribute="defer"):
        """Mark that a script need to be imported.

        Must be used before base.html is extended, as <script> are generated there.
        If called several time with the same library, it will be imported once.
        @param library_name(unicode): name of the library to import
        @param loading:
        """
        if attribute not in ("defer", "async", ""):
            raise exceptions.DataError(
                _('Invalid attribute, please use one of "defer", "async" or ""')
            )
        if not library_name.endswith(".js"):
            library_name = library_name + ".js"
        if (library_name, attribute) not in self.scripts:
            self.scripts.append((library_name, attribute))
        return ""

    def generate_scripts(self):
        """Generate the <script> elements

        @return (unicode): <scripts> HTML tags
        """
        scripts = []
        tpl = "<script src={src} {attribute}></script>"
        for library, attribute in self.scripts:
            library_path = self.renderer.get_static_path(self.template_data, library)
            if library_path is None:
                log.warning(_("Can't find {libary} javascript library").format(
                    library=library))
                continue
            path = self.renderer.get_front_url(library_path)
            scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
        return safe("\n".join(scripts))


class Environment(jinja2.Environment):

    def get_template(self, name, parent=None, globals=None):
        if name[0] not in ('/', '('):
            # if name is not an absolute path or a full template name (this happen on
            # extend or import during rendering), we convert it to a full template name.
            # This is needed to handle cache correctly when a base template is overriden.
            # Without that, we could not distinguish something like base/base.html if
            # it's launched from some_site/some_theme or from [default]/default
            name = "({site}/{theme}){template}".format(
                site=self._template_data.site,
                theme=self._template_data.theme,
                template=name)

        return super(Environment, self).get_template(name, parent, globals)


class Renderer(object):

    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
        """
        self.host = host
        self.trusted = trusted
        self.sites_paths = {
            "": os.path.dirname(sat_templates.__file__),
        }
        self.sites_themes = {
        }
        conf = config.parse_main_conf()
        public_sites = config.config_get(conf, None, "sites_path_public_dict", {})
        sites_data = [public_sites]
        if private:
            private_sites = config.config_get(conf, None, "sites_path_private_dict", {})
            sites_data.append(private_sites)
        for sites in sites_data:
            normalised = {}
            for name, path in sites.items():
                if RE_TPL_RESERVED_CHARS.search(name):
                    log.warning(_("Can't add \"{name}\" site, it contains forbidden "
                                  "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("/"):
                    log.warning(_("Can't add \"{name}\" site, it should map to an "
                                  "absolute path").format(name=name))
                    continue
                normalised[name] = path
            self.sites_paths.update(normalised)

        for site, site_path in self.sites_paths.items():
            tpl_path = Path(site_path) / C.TEMPLATE_TPL_DIR
            for p in tpl_path.iterdir():
                if not p.is_dir():
                    continue
                log.debug(f"theme found for {site or 'default site'}: {p.name}")
                theme_data = self.sites_themes.setdefault(site, {})[p.name] = {
                    'path': p,
                    'settings': {}}
                theme_settings = p / "settings.json"
                if theme_settings.is_file:
                    try:
                        with theme_settings.open() as f:
                            settings = json.load(f)
                    except Exception as e:
                        log.warning(_(
                            "Can't load theme settings at {path}: {e}").format(
                            path=theme_settings, e=e))
                    else:
                        log.debug(
                            f"found settings for theme {p.name!r} at {theme_settings}")
                        fallback = settings.get("fallback")
                        if fallback is None:
                            settings["fallback"] = []
                        elif isinstance(fallback, str):
                            settings["fallback"] = [fallback]
                        elif not isinstance(fallback, list):
                            raise ValueError(
                                'incorrect type for "fallback" in settings '
                                f'({type(fallback)}) at {theme_settings}: {fallback}'
                            )
                        theme_data['settings'] = settings
                browser_path = p / BROWSER_DIR
                if browser_path.is_dir():
                    theme_data['browser_path'] = browser_path
                browser_meta_path = browser_path / BROWSER_META_FILE
                if browser_meta_path.is_file():
                    try:
                        with browser_meta_path.open() as f:
                            theme_data['browser_meta'] = json.load(f)
                    except Exception as e:
                        log.error(
                            f"Can't parse browser metadata at {browser_meta_path}: {e}"
                        )
                        continue

        self.env = Environment(
            loader=TemplateLoader(
                sites_paths=self.sites_paths,
                sites_themes=self.sites_themes,
                trusted=trusted
            ),
            autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
            trim_blocks=True,
            lstrip_blocks=True,
            extensions=["jinja2.ext.i18n"],
        )
        self.env._template_data = None
        self._locale_str = C.DEFAULT_LOCALE
        self._locale = Locale.parse(self._locale_str)
        self.install_translations()

        # we want to have access to SàT constants in templates
        self.env.globals["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["timestamp_to_hour"] = self._timestamp_to_hour
        self.env.filters["delta_to_human"] = date_utils.delta2human
        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["front_url"] = (self._front_url if front_url_filter is None
                                         else front_url_filter)
        self.env.filters["media_type_main"] = self.media_type_main
        self.env.filters["media_type_sub"] = self.media_type_sub
        # custom tests
        self.env.tests["in_the_past"] = self._in_the_past
        self.icons_path = os.path.join(host.media_dir, "fonts/fontello/svg")

        # policies
        self.env.policies["ext.i18n.trimmed"] = True
        self.env.policies["json.dumps_kwargs"] = {
            "sort_keys": True,
            # if object can't be serialised, we use None
            "default": lambda o: o.to_json() if hasattr(o, "to_json") else None
        }

    def get_front_url(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["front_url"]({"template_data": template_data},
                                path or template_data.path)

    def install_translations(self):
        # TODO: support multi translation
        #       for now, only translations in sat_templates are handled
        self.translations = {}
        for site_key, site_path in self.sites_paths.items():
            site_prefix = "[{}] ".format(site_key) if site_key else ''
            i18n_dir = os.path.join(site_path, "i18n")
            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(
                        _("Can't find template translation at {path}").format(
                            path=po_path))
                except UnknownLocaleError as e:
                    log.error(_("{site}Invalid locale name: {msg}").format(
                        site=site_prefix, msg=e))
                else:
                    log.info(_("{site}loaded {lang} templates translations").format(
                        site = site_prefix,
                        lang=lang_dir))

        default_locale = Locale.parse(self._locale_str)
        if default_locale not in self.translations:
            # default locale disable gettext,
            # so we can use None instead of a Translations instance
            self.translations[default_locale] = None

        self.env.install_null_translations(True)
        # we generate a tuple of locales ordered by display name that templates can access
        # through the "locales" variable
        self.locales = tuple(sorted(list(self.translations.keys()),
                                    key=lambda l: l.language_name.lower()))


    def set_locale(self, locale_str):
        """set current locale

        change current translation locale and self self._locale and self._locale_str
        """
        if locale_str == self._locale_str:
            return
        if locale_str == "en":
            # we default to GB English when it's not specified
            # one of the main reason is to avoid the nonsense U.S. short date format
            locale_str = "en_GB"
        try:
            locale = Locale.parse(locale_str)
        except ValueError as e:
            log.warning(_("invalid locale value: {msg}").format(msg=e))
            locale_str = self._locale_str = C.DEFAULT_LOCALE
            locale = Locale.parse(locale_str)

        locale_str = str(locale)
        if locale_str != C.DEFAULT_LOCALE:
            try:
                translations = self.translations[locale]
            except KeyError:
                log.warning(_("Can't find locale {locale}".format(locale=locale)))
                locale_str = C.DEFAULT_LOCALE
                locale = Locale.parse(self._locale_str)
            else:
                self.env.install_gettext_translations(translations, True)
                log.debug(_("Switched to {lang}").format(lang=locale.english_name))

        if locale_str == C.DEFAULT_LOCALE:
            self.env.install_null_translations(True)

        self._locale = locale
        self._locale_str = locale_str

    def get_theme_and_root(self, template):
        """retrieve theme and root dir of a given template

        @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
        """
        # FIXME: check use in CLI frontend, and include site
        site, theme, __ = self.env.loader.parse_template(template)
        if site is None:
            # absolute template
            return  "", 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)

    def get_themes_data(self, site_name):
        try:
            return self.sites_themes[site_name]
        except KeyError:
            raise exceptions.NotFound(f"no theme found for {site_name}")

    def get_static_path(
            self,
            template_data: TemplateData,
            filename: str,
            settings: Optional[dict]=None
        ) -> Optional[TemplateData]:
        """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 absolute 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: data of current template
        @param filename: name of the file to retrieve
        @param settings: theme settings, can be used to modify behaviour
        @return: built template data instance where .path is
            the relative path to the file, from theme root dir.
            None if not found.
        """
        if template_data.site is None:
            # we have an absolue path
            if (not template_data.theme is None
                or not template_data.path.startswith('/')):
                raise exceptions.InternalError(
                    "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

        sites_and_themes = TemplateLoader.get_sites_and_themes(template_data.site,
                                                            template_data.theme,
                                                            settings)
        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 _append_css_paths(
            self,
            template_data: TemplateData,
            css_files: list,
            css_files_noscript: list,
            name_root: str,
            settings: dict

        ) -> None:
        """Append found css to css_files and css_files_noscript

        @param css_files: list to fill of relative path to found css file
        @param css_files_noscript: list to fill of relative path to found css file
            with "_noscript" suffix
        """
        name = name_root + ".css"
        css_path = self.get_static_path(template_data, name, settings)
        if css_path is not None:
            css_files.append(self.get_front_url(css_path))
            noscript_name = name_root + "_noscript.css"
            noscript_path = self.get_static_path(
                template_data, noscript_name, settings)
            if noscript_path is not None:
                css_files_noscript.append(self.get_front_url(noscript_path))

    def get_css_files(self, template_data):
        """Retrieve CSS files to use according template_data

        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, highlight.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 returned is second part of resulting tuple.
        For instance, if template_data is (some_site, some_theme, blog/articles.html),
        following files are returned, each 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)).
        The behaviour may be changed with theme settings: if "fallback" is set, specified
        themes will be checked instead of default. The theme will be checked in given
        order, and "fallback" may be None or empty list to not check anything.
        @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 = []
        css_files_noscript = []
        path_elems = template_data.path.split('/')
        path_elems[-1] = os.path.splitext(path_elems[-1])[0]
        site = template_data.site
        if site is None:
            # absolute path
            settings = {}
        else:
            settings = self.sites_themes[site][template_data.theme]['settings']

        css_path = self.get_static_path(template_data, 'fonts.css', settings)
        if css_path is not None:
            css_files.append(self.get_front_url(css_path))

        for name_root in ('styles', 'styles_extra', 'highlight'):
            self._append_css_paths(
                template_data, css_files, css_files_noscript, name_root, settings)

        for idx in range(len(path_elems)):
            name_root = "_".join(path_elems[:idx+1])
            self._append_css_paths(
                template_data, css_files, css_files_noscript, name_root, settings)

        return css_files, css_files_noscript

    ## custom filters ##

    @pass_context
    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['template_data']
        if template_data.site is None:
            assert template_data.theme is None
            assert template_data.path.startswith("/")
            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)

    @pass_context
    def _next_gidx(self, ctx, value):
        """Use next current global index as suffix"""
        next_ = ctx["gidx"].next(value)
        return value if next_ == 0 else "{}_{}".format(value, next_)

    @pass_context
    def _cur_gidx(self, ctx, value):
        """Use current current global index as suffix"""
        current = ctx["gidx"].current(value)
        return value if not current else "{}_{}".format(value, current)

    def _date_fmt(
        self,
        timestamp: Union[int, float],
        fmt: str = "short",
        date_only: bool = False,
        auto_limit: int = 7,
        auto_old_fmt: str = "short",
        auto_new_fmt: str = "relative",
        tz_name: Optional[str] = None
    ) -> str:
        if is_undefined(fmt):
            fmt = "short"

        try:
            return date_utils.date_fmt(
                timestamp, fmt, date_only, auto_limit, auto_old_fmt,
                auto_new_fmt, locale_str = self._locale_str,
                tz_info=tz_name or date_utils.TZ_UTC
            )
        except Exception as e:
            log.warning(_("Can't parse date: {msg}").format(msg=e))
            return str(timestamp)

    def _timestamp_to_hour(self, timestamp: float) -> int:
        """Get hour of day corresponding to a timestamp"""
        dt = datetime.fromtimestamp(timestamp)
        return dt.hour

    def attr_escape(self, text):
        """escape a text to a value usable as an attribute

        remove spaces, and put in lower case
        """
        return RE_ATTR_ESCAPE.sub("_", text.strip().lower())[:50]

    def _xmlui_class(self, xmlui_item, fields):
        """return classes computed from XMLUI fields name

        will return a string with a series of escaped {name}_{value} separated by spaces.
        @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use
        @param fields(iterable(unicode)): names of the widgets to use
        @return (unicode, None): computer string to use as class attribute value
            None if no field was specified
        """
        classes = []
        for name in fields:
            escaped_name = self.attr_escape(name)
            try:
                for value in xmlui_item.widgets[name].values:
                    classes.append(escaped_name + "_" + self.attr_escape(value))
            except KeyError:
                log.debug(
                    _('ignoring field "{name}": it doesn\'t exists').format(name=name)
                )
                continue
        return " ".join(classes) or None

    @pass_context
    def _item_filter(self, ctx, item, filters):
        """return item's value, filtered if suitable

        @param item(object): item to filter
            value must have name and value attributes,
            mostly used for XMLUI items
        @param filters(dict[unicode, (callable, dict, None)]): map of name => filter
            if filter is None, return the value unchanged
            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)
                - template: template to format where {value} is the filtered value
        """
        value = item.value
        filter_ = filters.get(item.name, None)
        if filter_ is None:
            return value
        elif isinstance(filter_, dict):
            filters_args = filter_.get("filters_args")
            for idx, f_name in enumerate(filter_.get("filters", [])):
                kwargs = filters_args[idx] if filters_args is not None else {}
                filter_func = self.env.filters[f_name]
                try:
                    eval_context_filter = filter_func.evalpass_context
                except AttributeError:
                    eval_context_filter = False

                if eval_context_filter:
                    value = filter_func(ctx.eval_ctx, value, **kwargs)
                else:
                    value = filter_func(value, **kwargs)
            template = filter_.get("template")
            if template:
                # format will return a string, so we need to check first
                # if the value is safe or not, and re-mark it after formatting
                is_safe = isinstance(value, safe)
                value = template.format(value=value)
                if is_safe:
                    value = safe(value)
            return value

    def _adv_format(self, value, template, **kwargs):
        """Advancer formatter

        like format() method, but take care or special values like None
        @param value(unicode): value to format
        @param template(None, unicode): template to use with format() method.
            It will be formatted using value=value and **kwargs
            None to return value unchanged
        @return (unicode): formatted value
        """
        if template is None:
            return value
        #  jinja use string when no special char is used, so we have to convert to unicode
        return str(template).format(value=value, **kwargs)

    def _dict_ext(self, source_dict, extra_dict, key=None):
        """extend source_dict with extra dict and return the result

        @param source_dict(dict): dictionary to extend
        @param extra_dict(dict, None): dictionary to use to extend first one
            None to return source_dict unmodified
        @param key(unicode, None): if specified extra_dict[key] will be used
            if it doesn't exists, a copy of unmodified source_dict is returned
        @return (dict): resulting dictionary
        """
        if extra_dict is None:
            return source_dict
        if key is not None:
            extra_dict = extra_dict.get(key, {})
        ret = source_dict.copy()
        ret.update(extra_dict)
        return ret

    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.
        @param code(unicode): code or markup to highlight
        @param lexer_name(unicode, None): name of the lexer to use
            None to autodetect it
        @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter
        @return (unicode): HTML markup with highlight classes
        """
        if lexer_opts is None:
            lexer_opts = {}
        if html_fmt_opts is None:
            html_fmt_opts = {}
        if lexer_name is None:
            lexer = lexers.guess_lexer(code, **lexer_opts)
        else:
            lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts)
        formatter = formatters.HtmlFormatter(**html_fmt_opts)
        return safe(pygments.highlight(code, lexer, formatter))

    def media_type_main(self, value: Optional[str]) -> Optional[str]:
        """Return main type of a media type"""
        if not value:
            return None
        return value.partition("/")[0]

    def media_type_sub(self, value: Optional[str]) -> Optional[str]:
        """Return main type of a media type"""
        if not value:
            return None
        return value.partition("/")[1]

    ## custom tests ##

    def _in_the_past(self, timestamp):
        """check if a date is in the past

        @param timestamp(unicode, int): unix time
        @return (bool): True if date is in the past
        """
        return time.time() > int(timestamp)

    ## template methods ##

    def _icon_defs(self, *names):
        """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"},
            width="0",
            height="0",
            style="display: block",
        )
        defs_elt = etree.SubElement(svg_elt, "defs")
        for name in names:
            path = os.path.join(self.icons_path, name + ".svg")
            icon_svg_elt = etree.parse(path).getroot()
            # we use icon name as id, so we can retrieve them easily
            icon_svg_elt.set("id", name)
            if not icon_svg_elt.tag == "{http://www.w3.org/2000/svg}svg":
                raise exceptions.DataError("invalid SVG element")
            defs_elt.append(icon_svg_elt)
        return safe(etree.tostring(svg_elt, encoding="unicode"))

    def _icon_use(self, name: str, cls: str = "", **kwargs: str) -> safe:
        """Insert a icon previously defined with [_icon_defs]"""
        extra_attrs = " ".join(f'{k}="{html.escape(str(v))}"' for k, v in kwargs.items())
        return safe(
            '<svg class="svg-icon{cls}"{extra_attrs} xmlns="http://www.w3.org/2000/svg" '
            'viewBox="0 0 100 100">\n'
            '    <use href="#{name}"/>'
            '</svg>\n'.format(
                name=name,
                cls=(" " + cls) if cls else "",
                extra_attrs=" " + extra_attrs if extra_attrs else ""
            )
        )

    def _icon_from_client(self, client):
        """Get icon name to represent a disco client"""
        if client is None:
            return 'desktop'
        elif 'pc' in client:
            return 'desktop'
        elif 'phone' in client:
            return 'mobile'
        elif 'web' in client:
            return 'globe'
        elif 'console' in client:
            return 'terminal'
        else:
            return 'desktop'

    def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
               media_path="", css_files=None, css_inline=False, **kwargs):
        """Render a template

        @param template(unicode): template to render (e.g. blog/articles.html)
        @param site(unicode): site name
            None or empty string for defaut site (i.e. SàT templates)
        @param theme(unicode): template theme
        @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 use
            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("template can't be empty")
        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 = ''
            if theme is None:
                theme = C.TEMPLATE_THEME_DEFAULT
            if template[0] == "(":
                raise ValueError(
                    "you can't specify site or theme in template path and in argument "
                    "at the same time"
                )

            template_data = TemplateData(site, theme, template)
            template = "({site}/{theme}){template}".format(
                site=site, theme=theme, template=template)
        else:
            template_data = self.env.loader.parse_template(template)

        # we need to save template_data in environment, to load right templates when they
        # are referenced from other templates (e.g. import)
        # FIXME: this trick will not work anymore if we use async templates (it works
        #        here because we know that the rendering will be blocking until we unset
        #        _template_data)
        self.env._template_data = template_data

        template_source = self.env.get_template(template)

        if css_files is None:
            css_files, css_files_noscript = self.get_css_files(template_data)
        else:
            css_files_noscript = []

        kwargs["icon_defs"] = self._icon_defs
        kwargs["icon"] = self._icon_use
        kwargs["icon_from_client"] = self._icon_from_client

        if css_inline:
            css_contents = []
            for files, suffix in ((css_files, ""),
                                  (css_files_noscript, "_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["css_content" + suffix] = "\n".join(css_contents)

        scripts_handler = ScriptsHandler(self, template_data)
        self.set_locale(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.
        rendered = template_source.render(
            template_data=template_data,
            media_path=media_path,
            css_files=css_files,
            css_files_noscript=css_files_noscript,
            locale=self._locale,
            locales=self.locales,
            gidx=Indexer(),
            script=scripts_handler,
            **kwargs
        )
        self.env._template_data = None
        return rendered