comparison 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
comparison
equal deleted inserted replaced
2670:ef93fcbaa749 2671:0fa217fafabf
15 # GNU Affero General Public License for more details. 15 # GNU Affero General Public License for more details.
16 16
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 """ template generation """ 20 """Template generation"""
21 21
22 import os.path
23 from collections import namedtuple
22 from sat.core.constants import Const as C 24 from sat.core.constants import Const as C
23 from sat.core.i18n import _ 25 from sat.core.i18n import _
24 from sat.core import exceptions 26 from sat.core import exceptions
27 from sat.tools import config
25 from sat.tools.common import date_utils 28 from sat.tools.common import date_utils
26 from sat.core.log import getLogger 29 from sat.core.log import getLogger
27
28 log = getLogger(__name__)
29 import os.path
30 from xml.sax.saxutils import quoteattr 30 from xml.sax.saxutils import quoteattr
31 import time 31 import time
32 import re 32 import re
33 from babel import support 33 from babel import support
34 from babel import Locale 34 from babel import Locale
39 39
40 try: 40 try:
41 import sat_templates 41 import sat_templates
42 except ImportError: 42 except ImportError:
43 raise exceptions.MissingModule( 43 raise exceptions.MissingModule(
44 u"sat_templates module is not available, please install it or check your path to use template engine" 44 u"sat_templates module is not available, please install it or check your path to "
45 u"use template engine"
45 ) 46 )
46 else: 47 else:
47 sat_templates # to avoid pyflakes warning 48 sat_templates # to avoid pyflakes warning
48 49
49 try: 50 try:
50 import jinja2 51 import jinja2
51 except: 52 except:
52 raise exceptions.MissingModule( 53 raise exceptions.MissingModule(
53 u"Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2" 54 u"Missing module jinja2, please install it from http://jinja.pocoo.org or with "
55 u"pip install jinja2"
54 ) 56 )
55 57
56 from jinja2 import Markup as safe 58 from jinja2 import Markup as safe
57 from jinja2 import is_undefined 59 from jinja2 import is_undefined
60 from jinja2 import utils
61 from jinja2 import TemplateNotFound
62 from jinja2.loaders import split_template_path
58 from lxml import etree 63 from lxml import etree
64
65 log = getLogger(__name__)
59 66
60 HTML_EXT = ("html", "xhtml") 67 HTML_EXT = ("html", "xhtml")
61 RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]") 68 RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
62 #  TODO: handle external path (an additional search path for templates should be settable by user 69 SITE_RESERVED_NAMES = (u"sat",)
63 # TODO: handle absolute URL (should be used for trusted use cases) only (e.g. jp) for security reason 70 TPL_RESERVED_CHARS = ur"()/."
64 71 RE_TPL_RESERVED_CHARS = re.compile(u"[" + TPL_RESERVED_CHARS + u"]")
65 72
66 class TemplateLoader(jinja2.FileSystemLoader): 73 TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])
67 def __init__(self): 74
68 searchpath = os.path.dirname(sat_templates.__file__) 75
69 super(TemplateLoader, self).__init__(searchpath, followlinks=True) 76 class TemplateLoader(jinja2.BaseLoader):
70 77 """A template loader which handle site, theme and absolute paths"""
71 def parse_template(self, template): 78 # TODO: list_templates should be implemented
72 """parse template path and return theme and relative URL 79
80 def __init__(self, sites_paths, trusted=False):
81 """
82 @param trusted(bool): if True, absolue template paths will be allowed
83 be careful when using this option and sure that you can trust the template,
84 as this allow the template to open any file on the system that the
85 launching user can access.
86 """
87 if not sites_paths or not u"" in sites_paths:
88 raise exceptions.InternalError(u"Invalid sites_paths")
89 super(jinja2.BaseLoader, self).__init__()
90 self.sites_paths = sites_paths
91 self.trusted = trusted
92
93 @staticmethod
94 def parse_template(template):
95 """Parse template path and return site, theme and path
73 96
74 @param template_path(unicode): path to template with parenthesis syntax 97 @param template_path(unicode): path to template with parenthesis syntax
75 @return (tuple[(unicode,None),unicode]): theme and template_path 98 The site and/or theme can be specified in parenthesis just before the path
76 theme can be None if relative path is used 99 e.g.: (some_theme)path/to/template.html
77 relative path is the path from search path with theme specified 100 (/some_theme)path/to/template.html (equivalent to previous one)
78 e.g. default/blog/articles.html 101 (other_site/other_theme)path/to/template.html
102 (other_site/)path/to/template.html (defaut theme for other_site)
103 /absolute/path/to/template.html (in trusted environment only)
104 @return (TemplateData):
105 site, theme and template_path.
106 if site is empty, SàT Templates are used
107 site and theme can be both None if absolute path is used
108 Relative path is the path from theme root dir e.g. blog/articles.html
79 """ 109 """
80 if template.startswith(u"("): 110 if template.startswith(u"("):
111 # site and/or theme are specified
81 try: 112 try:
82 theme_end = template.index(u")") 113 theme_end = template.index(u")")
83 except IndexError: 114 except IndexError:
84 raise ValueError(u"incorrect theme in template") 115 raise ValueError(u"incorrect site/theme in template")
85 theme = template[1:theme_end] 116 theme_data = template[1:theme_end]
86 template = template[theme_end + 1 :] 117 theme_splitted = theme_data.split(u'/')
87 if not template or template.startswith(u"/"): 118 if len(theme_splitted) == 1:
88 raise ValueError(u"incorrect path after template name") 119 site, theme = u"", theme_splitted
89 template = os.path.join(theme, template) 120 elif len(theme_splitted) == 2:
121 site, theme = theme_splitted
122 else:
123 raise ValueError(u"incorrect site/theme in template")
124 template_path = template[theme_end+1:]
125 if not template_path or template_path.startswith(u"/"):
126 raise ValueError(u"incorrect template path")
90 elif template.startswith(u"/"): 127 elif template.startswith(u"/"):
91 # absolute path means no template 128 # this is an absolute path, so we have no site and no theme
129 site = None
92 theme = None 130 theme = None
93 raise NotImplementedError(u"absolute path is not implemented yet") 131 template_path = template
94 else: 132 else:
133 # a default template
134 site = u""
95 theme = C.TEMPLATE_THEME_DEFAULT 135 theme = C.TEMPLATE_THEME_DEFAULT
96 template = os.path.join(theme, template) 136 template_path = template
97 return theme, template 137
98 138 if site is not None:
99 def get_default_template(self, theme, template_path): 139 site = site.strip()
100 """return default template path 140 if not site:
101 141 site = u""
102 @param theme(unicode): theme used 142 elif site in SITE_RESERVED_NAMES:
103 @param template_path(unicode): path to the not found template 143 raise ValueError(_(u"{site} can't be used as site name, "
104 @return (unicode, None): default path or None if there is not 144 u"it's reserved.").format(site=site))
105 """ 145
106 ext = os.path.splitext(template_path)[1][1:] 146 if theme is not None:
107 path_elems = template_path.split(u"/") 147 theme = theme.strip()
108 if ext in HTML_EXT: 148 if not theme:
109 if path_elems[1] == u"error": 149 theme = C.TEMPLATE_THEME_DEFAULT
110 # if an inexisting error page is requested, we return base page 150 if RE_TPL_RESERVED_CHARS.search(theme):
111 default_path = os.path.join(theme, u"error/base.html") 151 raise ValueError(_(u"{theme} contain forbidden char. Following chars "
112 return default_path 152 u"are forbidden: {reserved}").format(
153 theme=theme, reserved=TPL_RESERVED_CHARS))
154
155 return TemplateData(site, theme, template_path)
156
157 @staticmethod
158 def getSitesAndThemes(site, theme):
159 """Get sites and themes to check for template/file
160
161 Will add default theme and default site in search list when suitable
162 @param site(unicode): site requested
163 @param theme(unicode): theme requested
164 @return (list[tuple[unicode, unicode]]): site and theme couples to check
165 """
166 sites_and_themes = [[site, theme]]
113 if theme != C.TEMPLATE_THEME_DEFAULT: 167 if theme != C.TEMPLATE_THEME_DEFAULT:
114 # if template doesn't exists for this theme, we try with default 168 sites_and_themes.append([site, C.TEMPLATE_THEME_DEFAULT])
115 return os.path.join(C.TEMPLATE_THEME_DEFAULT, path_elems[1:]) 169 if site:
170 # the site is not the default one, so we add default at the end
171 sites_and_themes.append([u'', C.TEMPLATE_THEME_DEFAULT])
172 return sites_and_themes
173
174 def _get_template_f(self, site, theme, path_elts):
175 """Look for template and return opened file if found
176
177 @param site(unicode): names of site to check
178 (default site will also checked)
179 @param theme(unicode): theme to check (default theme will also be checked)
180 @param path_elts(iterable[str]): elements of template path
181 @return (tuple[(File, None), (str, None)]): a tuple with:
182 - opened template, or None if not found
183 - absolute file path, or None if not found
184 """
185 if site is None:
186 raise exceptions.InternalError(
187 u"_get_template_f must not be used with absolute path")
188 for site, theme in self.getSitesAndThemes(site, theme):
189 try:
190 base_path = self.sites_paths[site]
191 except KeyError:
192 log.warning(_(u"Unregistered site requested: {site}").format(
193 site=site))
194 filepath = os.path.join(base_path, C.TEMPLATE_TPL_DIR, theme, *path_elts)
195 f = utils.open_if_exists(filepath)
196 if f is not None:
197 return f, filepath
198 return None, None
116 199
117 def get_source(self, environment, template): 200 def get_source(self, environment, template):
118 """relative path to template dir, with special theme handling 201 """Retrieve source handling site and themes
119 202
203 If the path is absolute it is used directly if in trusted environment
204 else and exception is raised.
120 if the path is just relative, "default" theme is used. 205 if the path is just relative, "default" theme is used.
121 The theme can be specified in parenthesis just before the path 206 @raise PermissionError: absolute path used in untrusted environment
122 e.g.: (some_theme)path/to/template.html 207 """
123 """ 208 site, theme, template_path = self.parse_template(template)
124 theme, template_path = self.parse_template(template) 209
210 if site is None:
211 # we have an abolute template
212 if theme is not None:
213 raise exceptions.InternalError(u"We can't have a theme with absolute "
214 u"template.")
215 if not self.trusted:
216 log.error(_(u"Absolute template used while unsecure is disabled, hack "
217 u"attempt? Template: {template}").format(template=template))
218 raise exceptions.PermissionError(u"absolute template is not allowed")
219 filepath = template_path
220 f = utils.open_if_exists(filepath)
221 else:
222 # relative path, we have to deal with site and theme
223 assert theme and template_path
224 path_elts = split_template_path(template_path)
225 # if we have non default site, we check it first, else we only check default
226 f, filepath = self._get_template_f(site, theme, path_elts)
227
228 if f is None:
229 if (site is not None and path_elts[0] == u"error"
230 and os.path.splitext(template_path)[1][1:] in HTML_EXT):
231 # if an HTML error is requested but doesn't exist, we try again
232 # with base error.
233 f, filepath = self._get_template_f(
234 site, theme, ("error", "base.html"))
235 if f is None:
236 raise exceptions.InternalError(u"error/base.html should exist")
237 else:
238 raise TemplateNotFound(template)
239
125 try: 240 try:
126 return super(TemplateLoader, self).get_source(environment, template_path) 241 contents = f.read().decode('utf-8')
127 except jinja2.exceptions.TemplateNotFound as e: 242 finally:
128 # in some special cases, a defaut template is returned if nothing is found 243 f.close()
129 if theme is not None: 244
130 default_path = self.get_default_template(theme, template_path) 245 mtime = os.path.getmtime(filepath)
131 if default_path is not None: 246
132 return super(TemplateLoader, self).get_source( 247 def uptodate():
133 environment, default_path 248 try:
134 ) 249 return os.path.getmtime(filepath) == mtime
135 # if no default template is found, we re-raise the error 250 except OSError:
136 raise e 251 return False
252
253 return contents, filepath, uptodate
137 254
138 255
139 class Indexer(object): 256 class Indexer(object):
140 """Index global to a page""" 257 """Index global to a page"""
141 258
152 def current(self, value): 269 def current(self, value):
153 return self._indexes.get(value) 270 return self._indexes.get(value)
154 271
155 272
156 class ScriptsHandler(object): 273 class ScriptsHandler(object):
157 def __init__(self, renderer, template_path, template_root_dir, root_path): 274 def __init__(self, renderer, template_data):
158 self.renderer = renderer 275 self.renderer = renderer
159 self.template_root_dir = template_root_dir 276 self.template_data = template_data
160 self.root_path = root_path
161 self.scripts = [] #  we don't use a set because order may be important 277 self.scripts = [] #  we don't use a set because order may be important
162 dummy, self.theme, self.is_default_theme = renderer.getThemeData(template_path)
163 278
164 def include(self, library_name, attribute="defer"): 279 def include(self, library_name, attribute="defer"):
165 """Mark that a script need to be imported. 280 """Mark that a script need to be imported.
166 281
167 Must be used before base.html is extended, as <script> are generated there. 282 Must be used before base.html is extended, as <script> are generated there.
168 If called several time with the same library, it will be imported once. 283 If called several time with the same library, it will be imported once.
169 @param library_name(unicode): name of the library to import 284 @param library_name(unicode): name of the library to import
170 @param loading: 285 @param loading:
171 """ 286 """
172 if attribute not in ("defer", "async", ""): 287 if attribute not in (u"defer", u"async", u""):
173 raise exceptions.DataError( 288 raise exceptions.DataError(
174 _(u'Invalid attribute, please use one of "defer", "async" or ""') 289 _(u'Invalid attribute, please use one of "defer", "async" or ""')
175 ) 290 )
176 if library_name.endswith(".js"): 291 if not library_name.endswith(u".js"):
177 library_name = library_name[:-3] 292 library_name = library_name + u".js"
178 if library_name not in self.scripts: 293 if (library_name, attribute) not in self.scripts:
179 self.scripts.append((library_name, attribute)) 294 self.scripts.append((library_name, attribute))
180 return u"" 295 return u""
181 296
182 def generate_scripts(self): 297 def generate_scripts(self):
183 """Generate the <script> elements 298 """Generate the <script> elements
185 @return (unicode): <scripts> HTML tags 300 @return (unicode): <scripts> HTML tags
186 """ 301 """
187 scripts = [] 302 scripts = []
188 tpl = u"<script src={src} {attribute}></script>" 303 tpl = u"<script src={src} {attribute}></script>"
189 for library, attribute in self.scripts: 304 for library, attribute in self.scripts:
190 path = self.renderer.getStaticPath( 305 library_path = self.renderer.getStaticPath(self.template_data, library)
191 library, self.template_root_dir, self.theme, self.is_default_theme, ".js" 306 if library_path is None:
192 ) 307 log.warning(_(u"Can't find {libary} javascript library").format(
193 if path is None: 308 library=library))
194 log.warning(_(u"Can't find {}.js javascript library").format(library))
195 continue 309 continue
196 path = os.path.join(self.root_path, path) 310 path = self.renderer.getFrontURL(library_path)
197 scripts.append(tpl.format(src=quoteattr(path), attribute=attribute)) 311 scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
198 return safe(u"\n".join(scripts)) 312 return safe(u"\n".join(scripts))
199 313
200 314
201 class Renderer(object): 315 class Renderer(object):
202 def __init__(self, host): 316
317 def __init__(self, host, front_url_filter=None, trusted=False, private=False):
318 """
319 @param front_url_filter(callable): filter to retrieve real url of a directory/file
320 The callable will get a two arguments:
321 - a dict with a "template_data" key containing TemplateData instance of
322 current template. Only site and theme should be necessary.
323 - the relative URL of the file to retrieve, relative from theme root
324 None to use default filter which return real path on file
325 Need to be specified for web rendering, to reflect URL seen by end user
326 @param trusted(bool): if True, allow to access absolute path
327 Only set to True if environment is safe (e.g. command line tool)
328 @param private(bool): if True, also load sites from sites_path_private_dict
329 """
330 # TODO:
203 self.host = host 331 self.host = host
204 self.base_dir = os.path.dirname( 332 self.trusted = trusted
205 sat_templates.__file__ 333 self.sites_paths = {
206 ) # FIXME: should be modified if we handle use extra dirs 334 u"": os.path.dirname(sat_templates.__file__),
335 }
336 conf = config.parseMainConf()
337 public_sites = config.getConfig(conf, None, u"sites_path_public_dict", {})
338 sites_data = [public_sites]
339 if private:
340 private_sites = config.getConfig(conf, None, u"sites_path_private_dict", {})
341 sites_data.append(private_sites)
342 for sites in sites_data:
343 normalised = {}
344 for name, path in sites.iteritems():
345 if RE_TPL_RESERVED_CHARS.search(name):
346 log.warning(_(u"Can't add \"{name}\" site, it contains forbidden "
347 u"characters. Forbidden characters are {forbidden}.")
348 .format(name=name, forbidden=TPL_RESERVED_CHARS))
349 continue
350 path = os.path.expanduser(os.path.normpath(path))
351 if not path or not path.startswith(u"/"):
352 log.warning(_(u"Can't add \"{name}\" site, it should map to an "
353 u"absolute path").format(name=name))
354 continue
355 normalised[name] = path
356 self.sites_paths.update(normalised)
357
207 self.env = jinja2.Environment( 358 self.env = jinja2.Environment(
208 loader=TemplateLoader(), 359 loader=TemplateLoader(sites_paths=self.sites_paths, trusted=trusted),
209 autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]), 360 autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
210 trim_blocks=True, 361 trim_blocks=True,
211 lstrip_blocks=True, 362 lstrip_blocks=True,
212 extensions=["jinja2.ext.i18n"], 363 extensions=["jinja2.ext.i18n"],
213 ) 364 )
215 self._locale = Locale.parse(self._locale_str) 366 self._locale = Locale.parse(self._locale_str)
216 self.installTranslations() 367 self.installTranslations()
217 # we want to have access to SàT constants in templates 368 # we want to have access to SàT constants in templates
218 self.env.globals[u"C"] = C 369 self.env.globals[u"C"] = C
219 # custom filters 370 # custom filters
220 self.env.filters["next_gidx"] = self._next_gidx 371 self.env.filters[u"next_gidx"] = self._next_gidx
221 self.env.filters["cur_gidx"] = self._cur_gidx 372 self.env.filters[u"cur_gidx"] = self._cur_gidx
222 self.env.filters["date_fmt"] = self._date_fmt 373 self.env.filters[u"date_fmt"] = self._date_fmt
223 self.env.filters["xmlui_class"] = self._xmlui_class 374 self.env.filters[u"xmlui_class"] = self._xmlui_class
224 self.env.filters["attr_escape"] = self.attr_escape 375 self.env.filters[u"attr_escape"] = self.attr_escape
225 self.env.filters["item_filter"] = self._item_filter 376 self.env.filters[u"item_filter"] = self._item_filter
226 self.env.filters["adv_format"] = self._adv_format 377 self.env.filters[u"adv_format"] = self._adv_format
227 self.env.filters["dict_ext"] = self._dict_ext 378 self.env.filters[u"dict_ext"] = self._dict_ext
228 self.env.filters["highlight"] = self.highlight 379 self.env.filters[u"highlight"] = self.highlight
380 self.env.filters[u"front_url"] = (self._front_url if front_url_filter is None
381 else front_url_filter)
229 # custom tests 382 # custom tests
230 self.env.tests["in_the_past"] = self._in_the_past 383 self.env.tests[u"in_the_past"] = self._in_the_past
231 self.icons_path = os.path.join(host.media_dir, u"fonts/fontello/svg") 384 self.icons_path = os.path.join(host.media_dir, u"fonts/fontello/svg")
232 385
386 def getFrontURL(self, template_data, path=None):
387 """Give front URL (i.e. URL seen by end-user) of a path
388
389 @param template_data[TemplateData]: data of current template
390 @param path(unicode, None): relative path of file to get,
391 if set, will remplate template_data.path
392 """
393 return self.env.filters[u"front_url"]({u"template_data": template_data},
394 path or template_data.path)
395
233 def installTranslations(self): 396 def installTranslations(self):
234 i18n_dir = os.path.join(self.base_dir, "i18n") 397 # TODO: support multi translation
235 self.translations = {} 398 # for now, only translations in sat_templates are handled
236 for lang_dir in os.listdir(i18n_dir): 399 for site_key, site_path in self.sites_paths.iteritems():
237 lang_path = os.path.join(i18n_dir, lang_dir) 400 site_prefix = u"[{}] ".format(site_key) if site_key else u''
238 if not os.path.isdir(lang_path): 401 i18n_dir = os.path.join(site_path, "i18n")
239 continue 402 self.translations = {}
240 po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo") 403 for lang_dir in os.listdir(i18n_dir):
241 try: 404 lang_path = os.path.join(i18n_dir, lang_dir)
242 with open(po_path, "rb") as f: 405 if not os.path.isdir(lang_path):
243 self.translations[Locale.parse(lang_dir)] = support.Translations( 406 continue
244 f, "sat" 407 po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
245 ) 408 try:
246 except EnvironmentError: 409 locale = Locale.parse(lang_dir)
247 log.error( 410 with open(po_path, "rb") as f:
248 _(u"Can't find template translation at {path}").format(path=po_path) 411 try:
249 ) 412 translations = self.translations[locale]
250 except UnknownLocaleError as e: 413 except KeyError:
251 log.error(_(u"Invalid locale name: {msg}").format(msg=e)) 414 self.translations[locale] = support.Translations(f, "sat")
252 else: 415 else:
253 log.info(_(u"loaded {lang} templates translations").format(lang=lang_dir)) 416 translations.merge(support.Translations(f, "sat"))
254 self.env.install_null_translations(True) 417 except EnvironmentError:
418 log.error(
419 _(u"Can't find template translation at {path}").format(
420 path=po_path))
421 except UnknownLocaleError as e:
422 log.error(_(u"{site}Invalid locale name: {msg}").format(
423 site=site_prefix, msg=e))
424 else:
425 log.info(_(u"{site}loaded {lang} templates translations").format(
426 site = site_prefix,
427 lang=lang_dir))
428 self.env.install_null_translations(True)
255 429
256 def setLocale(self, locale_str): 430 def setLocale(self, locale_str):
257 """set current locale 431 """set current locale
258 432
259 change current translation locale and self self._locale and self._locale_str 433 change current translation locale and self self._locale and self._locale_str
292 def getThemeAndRoot(self, template): 466 def getThemeAndRoot(self, template):
293 """retrieve theme and root dir of a given tempalte 467 """retrieve theme and root dir of a given tempalte
294 468
295 @param template(unicode): template to parse 469 @param template(unicode): template to parse
296 @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir 470 @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
297 """ 471 @raise NotFound: requested site has not been found
298 theme, dummy = self.env.loader.parse_template(template) 472 """
299 return theme, os.path.join(self.base_dir, theme) 473 # FIXME: check use in jp, and include site
300 474 site, theme, __ = self.env.loader.parse_template(template)
301 def getStaticPath(self, name, template_root_dir, theme, is_default, ext=".css"): 475 if site is None:
302 """retrieve path of a static file if it exists with current theme or default 476 # absolute template
303 477 return u"", os.path.dirname(template)
304 File will be looked at [theme]/static/[name][ext], and then default 478 try:
305 if not found. 479 site_root_dir = self.sites_paths[site]
306 @param name(unicode): name of the file to look for 480 except KeyError:
307 @param template_root_dir(unicode): absolute path to template root used 481 raise exceptions.NotFound
308 @param theme(unicode): name of the template theme used 482 return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme)
309 @param is_default(bool): True if theme is the default theme 483
310 @return (unicode, None): relative path if found, else None 484 def getStaticPath(self, template_data, filename):
311 """ 485 """Retrieve path of a static file if it exists with current theme or default
312 file_ = None 486
313 path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + ext) 487 File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename,
314 if os.path.exists(os.path.join(template_root_dir, path)): 488 then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally
315 file_ = path 489 <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates).
316 elif not is_default: 490 In case of absolue URL, base dir of template is used as base. For instance if
317 path = os.path.join( 491 template is an absolute template to "/some/path/template.html", file will be
318 C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + ext 492 checked at "/some/path/<filename>"
319 ) 493 @param template_data(TemplateData): data of current template
320 if os.path.exists(os.path.join(template_root_dir, path)): 494 @return (TemplateData, None): built template data instance where .path is
321 file_.append(path) 495 the relative path to the file, from theme root dir.
322 return file_ 496 None if not found.
323 497 """
324 def getThemeData(self, template_path): 498 if template_data.site is None:
325 """return template data got from template_path 499 # we have and absolue path
326 500 if (not template_data.theme is None
327 @return tuple(unicode, unicode, bool): 501 or not template_data.path.startswith(u'/')):
328 path_elems: elements of the path 502 raise exceptions.InternalError(
329 theme: theme of the page 503 u"invalid template data, was expecting absolute URL")
330 is_default: True if the theme is the default theme 504 static_dir = os.path.dirname(template_data.path)
331 """ 505 file_path = os.path.join(static_dir, filename)
332 path_elems = [os.path.splitext(p)[0] for p in template_path.split(u"/")] 506 if os.path.exists(file_path):
333 theme = path_elems.pop(0) 507 return TemplateData(site=None, theme=None, path=file_path)
334 is_default = theme == C.TEMPLATE_THEME_DEFAULT 508 else:
335 return (path_elems, theme, is_default) 509 return None
336 510
337 def getCSSFiles(self, template_path, template_root_dir): 511 sites_and_themes = TemplateLoader.getSitesAndThemes(template_data.site,
338 """retrieve CSS files to use according to theme and template path 512 template_data.theme)
339 513 for site, theme in sites_and_themes:
340 for each element of the path, a .css file is looked for in /static, and returned if it exists. 514 site_root_dir = self.sites_paths[site]
341 previous element are kept by replacing '/' with '_', and styles.css is always returned. 515 relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename)
342 For instance, if template_path is some_theme/blog/articles.html: 516 absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR,
343 some_theme/static/styles.css is returned if it exists else default/static/styles.css 517 theme, relative_path)
344 some_theme/static/blog.css is returned if it exists else default/static/blog.css (if it exists too) 518 if os.path.exists(absolute_path):
345 some_theme/static/blog_articles.css is returned if it exists else default/static/blog_articles.css (if it exists too) 519 return TemplateData(site=site, theme=theme, path=relative_path)
346 @param template_path(unicode): relative path to template file (e.g. some_theme/blog/articles.html) 520
347 @param template_root_dir(unicode): absolute path of the theme root dir used 521 return None
348 @return list[unicode]: relative path to CSS files to use 522
523 def _appendCSSPaths(self, template_data, css_files, css_files_noscript, name_root):
524 """Append found css to css_files and css_files_noscript
525
526 @param css_files(list): list to fill of relative path to found css file
527 @param css_files_noscript(list): list to fill of relative path to found css file
528 with "_noscript" suffix
529 """
530 name = name_root + u".css"
531 css_path = self.getStaticPath(template_data, name)
532 if css_path is not None:
533 css_files.append(self.getFrontURL(css_path))
534 noscript_name = name_root + u"_noscript.css"
535 noscript_path = self.getStaticPath(template_data, noscript_name)
536 if noscript_path is not None:
537 css_files_noscript.append(self.getFrontURL(noscript_path))
538
539 def getCSSFiles(self, template_data):
540 """Retrieve CSS files to use according template_data
541
542 For each element of the path, a .css file is looked for in /static, and returned
543 if it exists.
544 Previous element are kept by replacing '/' with '_'.
545 styles_extra.css, styles.css and fonts.css are always used if they exist.
546 For each found file, if a file with the same name and "_noscript" suffix exists,
547 it will be return is second part of resulting tuple.
548 For instance, if template_data is (some_site, some_theme, blog/articles.html),
549 following files are returned, earch time trying [some_site root] first,
550 then default site (i.e. sat_templates) root:
551 - some_theme/static/styles.css is returned if it exists
552 else default/static/styles.css
553 - some_theme/static/blog.css is returned if it exists
554 else default/static/blog.css (if it exists too)
555 - some_theme/static/blog_articles.css is returned if it exists
556 else default/static/blog_articles.css (if it exists too)
557 and for each found files, if same file with _noscript suffix exists, it is put
558 in noscript list (for instance (some_theme/static/styles_noscript.css)).
559 @param template_data(TemplateData): data of the current template
560 @return (tuple[list[unicode], list[unicode]]): a tuple with:
561 - front URLs of CSS files to use
562 - front URLs of CSS files to use when scripts are not enabled
349 """ 563 """
350 # TODO: some caching would be nice 564 # TODO: some caching would be nice
351 css_files = [] 565 css_files = []
352 path_elems, theme, is_default = self.getThemeData(template_path) 566 css_files_noscript = []
353 for css in (u"fonts", u"styles"): 567 path_elems = template_data.path.split(u'/')
354 css_path = self.getStaticPath(css, template_root_dir, theme, is_default) 568 path_elems[-1] = os.path.splitext(path_elems[-1])[0]
355 if css_path is not None: 569
356 css_files.append(css_path) 570 for name_root in (u'styles_extra', u'styles'):
357 571 self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root)
358 for idx, path in enumerate(path_elems): 572
359 css_path = self.getStaticPath( 573 css_path = self.getStaticPath(template_data, u'fonts.css')
360 u"_".join(path_elems[: idx + 1]), template_root_dir, theme, is_default 574 if css_path is not None:
361 ) 575 css_files.append(self.getFrontURL(css_path))
362 if css_path is not None: 576
363 css_files.append(css_path) 577 for idx in xrange(len(path_elems)):
364 578 name_root = u"_".join(path_elems[:idx+1])
365 return css_files 579 self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root)
580
581 return css_files, css_files_noscript
366 582
367 ## custom filters ## 583 ## custom filters ##
584
585 @jinja2.contextfilter
586 def _front_url(self, ctx, relative_url):
587 """Get front URL (URL seen by end-user) from a relative URL
588
589 This default method return absolute full path
590 """
591 template_data = ctx[u'template_data']
592 if template_data.site is None:
593 assert template_data.theme is None
594 assert template_data.path.startswith(u"/")
595 return os.path.join(os.path.dirname(template_data.path, relative_url))
596
597 site_root_dir = self.sites_paths[template_data.site]
598 return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme,
599 relative_url)
368 600
369 @jinja2.contextfilter 601 @jinja2.contextfilter
370 def _next_gidx(self, ctx, value): 602 def _next_gidx(self, ctx, value):
371 """Use next current global index as suffix""" 603 """Use next current global index as suffix"""
372 next_ = ctx["gidx"].next(value) 604 next_ = ctx["gidx"].next(value)
431 @param filters(dict[unicode, (callable, dict, None)]): map of name => filter 663 @param filters(dict[unicode, (callable, dict, None)]): map of name => filter
432 if filter is None, return the value unchanged 664 if filter is None, return the value unchanged
433 if filter is a callable, apply it 665 if filter is a callable, apply it
434 if filter is a dict, it can have following keys: 666 if filter is a dict, it can have following keys:
435 - filters: iterable of filters to apply 667 - filters: iterable of filters to apply
436 - filters_args: kwargs of filters in the same order as filters (use empty dict if needed) 668 - filters_args: kwargs of filters in the same order as filters (use empty
669 dict if needed)
437 - template: template to format where {value} is the filtered value 670 - template: template to format where {value} is the filtered value
438 """ 671 """
439 value = item.value 672 value = item.value
440 filter_ = filters.get(item.name, None) 673 filter_ = filters.get(item.name, None)
441 if filter_ is None: 674 if filter_ is None:
498 return ret 731 return ret
499 732
500 def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None): 733 def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None):
501 """Do syntax highlighting on code 734 """Do syntax highlighting on code
502 735
503 under the hood, pygments is used, check its documentation for options possible values 736 Under the hood, pygments is used, check its documentation for options possible
737 values.
504 @param code(unicode): code or markup to highlight 738 @param code(unicode): code or markup to highlight
505 @param lexer_name(unicode, None): name of the lexer to use 739 @param lexer_name(unicode, None): name of the lexer to use
506 None to autodetect it 740 None to autodetect it
507 @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter 741 @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter
508 @return (unicode): HTML markup with highlight classes 742 @return (unicode): HTML markup with highlight classes
529 return time.time() > int(timestamp) 763 return time.time() > int(timestamp)
530 764
531 ## template methods ## 765 ## template methods ##
532 766
533 def _icon_defs(self, *names): 767 def _icon_defs(self, *names):
534 """Define svg icons which will be used in the template, and use their name as id""" 768 """Define svg icons which will be used in the template.
769
770 Their name is used as id
771 """
535 svg_elt = etree.Element( 772 svg_elt = etree.Element(
536 "svg", 773 "svg",
537 nsmap={None: "http://www.w3.org/2000/svg"}, 774 nsmap={None: "http://www.w3.org/2000/svg"},
538 width="0", 775 width="0",
539 height="0", 776 height="0",
549 raise exceptions.DataError(u"invalid SVG element") 786 raise exceptions.DataError(u"invalid SVG element")
550 defs_elt.append(icon_svg_elt) 787 defs_elt.append(icon_svg_elt)
551 return safe(etree.tostring(svg_elt, encoding="unicode")) 788 return safe(etree.tostring(svg_elt, encoding="unicode"))
552 789
553 def _icon_use(self, name, cls=""): 790 def _icon_use(self, name, cls=""):
554 return safe( 791 return safe(u'<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
555 u"""<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> 792 u'viewBox="0 0 100 100">\n'
556 <use href="#{name}"/> 793 u' <use href="#{name}"/>'
557 </svg> 794 u'</svg>\n'.format(name=name, cls=(" " + cls) if cls else ""))
558 """.format( 795
559 name=name, cls=(" " + cls) if cls else "" 796 def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
560 ) 797 media_path=u"", css_files=None, css_inline=False, **kwargs):
561 ) 798 """Render a template
562 799
563 def render(
564 self,
565 template,
566 theme=None,
567 locale=C.DEFAULT_LOCALE,
568 root_path=u"",
569 media_path=u"",
570 css_files=None,
571 css_inline=False,
572 **kwargs
573 ):
574 """render a template
575 .
576 @param template(unicode): template to render (e.g. blog/articles.html) 800 @param template(unicode): template to render (e.g. blog/articles.html)
801 @param site(unicide): site name
802 None or empty string for defaut site (i.e. SàT templates)
577 @param theme(unicode): template theme 803 @param theme(unicode): template theme
578 @param root_path(unicode): prefix of the path/URL to use for template root 804 @param media_path(unicode): prefix of the SàT media path/URL to use for
579 must end with a u'/' 805 template root. Must end with a u'/'
580 @param media_path(unicode): prefix of the SàT media path/URL to use for template root
581 must end with a u'/'
582 @param css_files(list[unicode],None): CSS files to used 806 @param css_files(list[unicode],None): CSS files to used
583 CSS files must be in static dir of the template 807 CSS files must be in static dir of the template
584 use None for automatic selection of CSS files based on template category 808 use None for automatic selection of CSS files based on template category
585 None is recommended. General static/style.css and theme file name will be used. 809 None is recommended. General static/style.css and theme file name will be
810 used.
586 @param css_inline(bool): if True, CSS will be embedded in the HTML page 811 @param css_inline(bool): if True, CSS will be embedded in the HTML page
587 @param **kwargs: variable to transmit to the template 812 @param **kwargs: variable to transmit to the template
588 """ 813 """
589 if not template: 814 if not template:
590 raise ValueError(u"template can't be empty") 815 raise ValueError(u"template can't be empty")
591 if theme is not None: 816 if site is not None or theme is not None:
592 # use want to set a theme, we add it to the template path 817 # user wants to set site and/or theme, so we add it to the template path
818 if site is None:
819 site = u''
820 if theme is None:
821 theme = C.TEMPLATE_THEME_DEFAULT
593 if template[0] == u"(": 822 if template[0] == u"(":
594 raise ValueError( 823 raise ValueError(
595 u"you can't specify theme in template path and in argument at the same time" 824 u"you can't specify site or theme in template path and in argument "
825 u"at the same time"
596 ) 826 )
597 elif template[0] == u"/": 827
598 raise ValueError(u"you can't specify theme with absolute paths") 828 template_data = TemplateData(site, theme, template)
599 template = u"(" + theme + u")" + template 829 template = u"({site}/{theme}){template}".format(
830 site=site, theme=theme, template=template)
600 else: 831 else:
601 theme, dummy = self.env.loader.parse_template(template) 832 template_data = self.env.loader.parse_template(template)
602 833
603 template_source = self.env.get_template(template) 834 template_source = self.env.get_template(template)
604 template_root_dir = os.path.normpath(
605 self.base_dir
606 ) # FIXME: should be modified if we handle use extra dirs
607 #  XXX: template_path may have a different theme as first element than theme if a default page is used
608 template_path = template_source.filename[len(template_root_dir) + 1 :]
609 835
610 if css_files is None: 836 if css_files is None:
611 css_files = self.getCSSFiles(template_path, template_root_dir) 837 css_files, css_files_noscript = self.getCSSFiles(template_data)
612 838
613 kwargs["icon_defs"] = self._icon_defs 839 kwargs["icon_defs"] = self._icon_defs
614 kwargs["icon"] = self._icon_use 840 kwargs["icon"] = self._icon_use
615 841
616 if css_inline: 842 if css_inline:
617 css_contents = [] 843 css_contents = []
618 for css_file in css_files: 844 for files, suffix in ((css_files, u""),
619 css_file_path = os.path.join(template_root_dir, css_file) 845 (css_files_noscript, u"_noscript")):
620 with open(css_file_path) as f: 846 site_root_dir = self.sites_paths[template_data.site]
621 css_contents.append(f.read()) 847 for css_file in files:
622 if css_contents: 848 css_file_path = os.path.join(site_root_dir, css_file)
623 kwargs["css_content"] = "\n".join(css_contents) 849 with open(css_file_path) as f:
624 850 css_contents.append(f.read())
625 scripts_handler = ScriptsHandler( 851 if css_contents:
626 self, template_path, template_root_dir, root_path 852 kwargs[u"css_content" + suffix] = u"\n".join(css_contents)
627 ) 853
854 scripts_handler = ScriptsHandler(self, template_data)
628 self.setLocale(locale) 855 self.setLocale(locale)
629 # XXX: theme used in template arguments is the requested theme, which may differ from actual theme 856 # XXX: theme used in template arguments is the requested theme, which may differ
630 # if the template doesn't exist in the requested theme. 857 # from actual theme if the template doesn't exist in the requested theme.
631 return template_source.render( 858 return template_source.render(
632 theme=theme, 859 template_data=template_data,
633 root_path=root_path,
634 media_path=media_path, 860 media_path=media_path,
635 css_files=css_files, 861 css_files=css_files,
862 css_files_noscript=css_files,
636 locale=self._locale, 863 locale=self._locale,
637 gidx=Indexer(), 864 gidx=Indexer(),
638 script=scripts_handler, 865 script=scripts_handler,
639 **kwargs 866 **kwargs
640 ) 867 )