Mercurial > libervia-backend
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 ) |