Complements rev. 8a7af44a07
The assets cache is only active while not in "xml dev" mode,
so the cache clearing must not be done while this development
mode is active (i.e. with `--dev=xml`)
642 lines
25 KiB
Python
642 lines
25 KiB
Python
# -*- coding: utf-8 -*-
|
|
import os
|
|
import re
|
|
import hashlib
|
|
import itertools
|
|
import json
|
|
import textwrap
|
|
import uuid
|
|
from datetime import datetime
|
|
from subprocess import Popen, PIPE
|
|
from odoo import fields, tools
|
|
from odoo.http import request
|
|
from odoo.modules.module import get_resource_path
|
|
import psycopg2
|
|
import werkzeug
|
|
from odoo.tools import func, misc
|
|
|
|
import logging
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
MAX_CSS_RULES = 4095
|
|
|
|
|
|
def rjsmin(script):
|
|
""" Minify js with a clever regex.
|
|
Taken from http://opensource.perlig.de/rjsmin
|
|
Apache License, Version 2.0 """
|
|
def subber(match):
|
|
""" Substitution callback """
|
|
groups = match.groups()
|
|
return (
|
|
groups[0] or
|
|
groups[1] or
|
|
groups[2] or
|
|
groups[3] or
|
|
(groups[4] and '\n') or
|
|
(groups[5] and ' ') or
|
|
(groups[6] and ' ') or
|
|
(groups[7] and ' ') or
|
|
''
|
|
)
|
|
|
|
result = re.sub(
|
|
r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
|
|
r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
|
|
r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
|
|
r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
|
|
r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
|
|
r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
|
|
r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
|
|
r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
|
|
r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
|
|
r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
|
|
r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
|
|
r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
|
|
r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
|
|
r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
|
|
r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
|
|
r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
|
|
r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
|
|
r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
|
|
r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
|
|
r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
|
|
r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
|
|
).strip()
|
|
return result
|
|
|
|
|
|
class AssetError(Exception):
|
|
pass
|
|
|
|
|
|
class AssetNotFound(AssetError):
|
|
pass
|
|
|
|
|
|
class AssetsBundle(object):
|
|
rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
|
|
rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
|
|
rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
|
|
|
|
def __init__(self, name, files, remains, env=None):
|
|
self.name = name
|
|
self.env = request.env if env is None else env
|
|
self.max_css_rules = self.env.context.get('max_css_rules', MAX_CSS_RULES)
|
|
self.javascripts = []
|
|
self.stylesheets = []
|
|
self.css_errors = []
|
|
self.remains = []
|
|
self._checksum = None
|
|
self.files = files
|
|
self.remains = remains
|
|
for f in files:
|
|
if f['atype'] == 'text/sass':
|
|
self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
|
|
elif f['atype'] == 'text/less':
|
|
self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
|
|
elif f['atype'] == 'text/css':
|
|
self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media']))
|
|
elif f['atype'] == 'text/javascript':
|
|
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
|
|
|
|
def to_html(self, sep=None, css=True, js=True, debug=False, async=False, url_for=(lambda url: url)):
|
|
if sep is None:
|
|
sep = '\n '
|
|
response = []
|
|
if debug == 'assets':
|
|
if css and self.stylesheets:
|
|
if not self.is_css_preprocessed():
|
|
self.preprocess_css(debug=debug)
|
|
if self.css_errors:
|
|
msg = '\n'.join(self.css_errors)
|
|
self.stylesheets.append(StylesheetAsset(self, inline=self.css_message(msg)))
|
|
for style in self.stylesheets:
|
|
response.append(style.to_html())
|
|
if js:
|
|
for jscript in self.javascripts:
|
|
response.append(jscript.to_html())
|
|
else:
|
|
if css and self.stylesheets:
|
|
css_attachments = self.css()
|
|
if not self.css_errors:
|
|
for attachment in css_attachments:
|
|
response.append('<link href="%s" rel="stylesheet"/>' % url_for(attachment.url))
|
|
else:
|
|
msg = '\n'.join(self.css_errors)
|
|
self.stylesheets.append(StylesheetAsset(self, inline=self.css_message(msg)))
|
|
for style in self.stylesheets:
|
|
response.append(style.to_html())
|
|
if js and self.javascripts:
|
|
response.append('<script %s type="text/javascript" src="%s"></script>' % (async and 'async="async"' or '', url_for(self.js().url)))
|
|
response.extend(self.remains)
|
|
|
|
return sep + sep.join(response)
|
|
|
|
@func.lazy_property
|
|
def last_modified(self):
|
|
"""Returns last modified date of linked files"""
|
|
return max(itertools.chain(
|
|
(asset.last_modified for asset in self.javascripts),
|
|
(asset.last_modified for asset in self.stylesheets),
|
|
))
|
|
|
|
@func.lazy_property
|
|
def version(self):
|
|
return self.checksum[0:7]
|
|
|
|
@func.lazy_property
|
|
def checksum(self):
|
|
"""
|
|
Not really a full checksum.
|
|
We compute a SHA1 on the rendered bundle + max linked files last_modified date
|
|
"""
|
|
check = json.dumps(self.files) + ",".join(self.remains) + str(self.last_modified)
|
|
return hashlib.sha1(check).hexdigest()
|
|
|
|
def clean_attachments(self, type):
|
|
""" Takes care of deleting any outdated ir.attachment records associated to a bundle before
|
|
saving a fresh one.
|
|
|
|
When `type` is css we need to check that we are deleting a different version (and not *any*
|
|
version) because css may be paginated and, therefore, may produce multiple attachments for
|
|
the same bundle's version.
|
|
|
|
When `type` is js we need to check that we are deleting a different version (and not *any*
|
|
version) because, as one of the creates in `save_attachment` can trigger a rollback, the
|
|
call to `clean_attachments ` is made at the end of the method in order to avoid the rollback
|
|
of an ir.attachment unlink (because we cannot rollback a removal on the filestore), thus we
|
|
must exclude the current bundle.
|
|
"""
|
|
ira = self.env['ir.attachment']
|
|
domain = [
|
|
('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.name, type)), # The wilcards are id, version and pagination number (if any)
|
|
'!', ('url', '=like', '/web/content/%-{}/%'.format(self.version))
|
|
]
|
|
|
|
# force bundle invalidation on other workers
|
|
if 'xml' not in tools.config['dev_mode']:
|
|
self.env['ir.qweb']._get_asset.clear_cache(self.env['ir.qweb'])
|
|
|
|
return ira.sudo().search(domain).unlink()
|
|
|
|
def get_attachments(self, type):
|
|
""" Return the ir.attachment records for a given bundle. This method takes care of mitigating
|
|
an issue happening when parallel transactions generate the same bundle: while the file is not
|
|
duplicated on the filestore (as it is stored according to its hash), there are multiple
|
|
ir.attachment records referencing the same version of a bundle. As we don't want to source
|
|
multiple time the same bundle in our `to_html` function, we group our ir.attachment records
|
|
by file name and only return the one with the max id for each group.
|
|
"""
|
|
url_pattern = '/web/content/%-{0}/{1}{2}.{3}'.format(self.version, self.name, '.%' if type == 'css' else '', type)
|
|
self.env.cr.execute("""
|
|
SELECT max(id)
|
|
FROM ir_attachment
|
|
WHERE url like %s
|
|
GROUP BY datas_fname
|
|
ORDER BY datas_fname
|
|
""", [url_pattern])
|
|
attachment_ids = [r[0] for r in self.env.cr.fetchall()]
|
|
return self.env['ir.attachment'].sudo().browse(attachment_ids)
|
|
|
|
def save_attachment(self, type, content, inc=None):
|
|
ira = self.env['ir.attachment']
|
|
|
|
fname = '%s%s.%s' % (self.name, ('' if inc is None else '.%s' % inc), type)
|
|
values = {
|
|
'name': "/web/content/%s" % type,
|
|
'datas_fname': fname,
|
|
'res_model': 'ir.ui.view',
|
|
'res_id': False,
|
|
'type': 'binary',
|
|
'public': True,
|
|
'datas': content.encode('utf8').encode('base64'),
|
|
}
|
|
attachment = ira.sudo().create(values)
|
|
|
|
url = '/web/content/%s-%s/%s' % (attachment.id, self.version, fname)
|
|
values = {
|
|
'name': url,
|
|
'url': url,
|
|
}
|
|
attachment.write(values)
|
|
|
|
if self.env.context.get('commit_assetsbundle') is True:
|
|
self.env.cr.commit()
|
|
|
|
self.clean_attachments(type)
|
|
|
|
return attachment
|
|
|
|
def js(self):
|
|
attachments = self.get_attachments('js')
|
|
if not attachments:
|
|
content = ';\n'.join(asset.minify() for asset in self.javascripts)
|
|
return self.save_attachment('js', content)
|
|
return attachments[0]
|
|
|
|
def css(self):
|
|
attachments = self.get_attachments('css')
|
|
if not attachments:
|
|
# get css content
|
|
css = self.preprocess_css()
|
|
if self.css_errors:
|
|
return
|
|
|
|
# move up all @import rules to the top
|
|
matches = []
|
|
css = re.sub(self.rx_css_import, lambda matchobj: matches.append(matchobj.group(0)) and '', css)
|
|
matches.append(css)
|
|
css = u'\n'.join(matches)
|
|
|
|
# split for browser max file size and browser max expression
|
|
re_rules = '([^{]+\{(?:[^{}]|\{[^{}]*\})*\})'
|
|
re_selectors = '()(?:\s*@media\s*[^{]*\{)?(?:\s*(?:[^,{]*(?:,|\{(?:[^}]*\}))))'
|
|
page = []
|
|
pages = [page]
|
|
page_selectors = 0
|
|
for rule in re.findall(re_rules, css):
|
|
selectors = len(re.findall(re_selectors, rule))
|
|
if page_selectors + selectors <= self.max_css_rules:
|
|
page_selectors += selectors
|
|
page.append(rule)
|
|
else:
|
|
pages.append([rule])
|
|
page = pages[-1]
|
|
page_selectors = selectors
|
|
for idx, page in enumerate(pages):
|
|
self.save_attachment("css", ' '.join(page), inc=idx)
|
|
attachments = self.get_attachments('css')
|
|
return attachments
|
|
|
|
def css_message(self, message):
|
|
# '\A' == css content carriage return
|
|
message = message.replace('\n', '\\A ').replace('"', '\\"')
|
|
return """
|
|
body:before {
|
|
background: #ffc;
|
|
width: 100%%;
|
|
font-size: 14px;
|
|
font-family: monospace;
|
|
white-space: pre;
|
|
content: "%s";
|
|
}
|
|
""" % message
|
|
|
|
def is_css_preprocessed(self):
|
|
preprocessed = True
|
|
for atype in (SassStylesheetAsset, LessStylesheetAsset):
|
|
outdated = False
|
|
assets = dict((asset.html_url, asset) for asset in self.stylesheets if isinstance(asset, atype))
|
|
if assets:
|
|
assets_domain = [('url', 'in', assets.keys())]
|
|
attachments = self.env['ir.attachment'].sudo().search(assets_domain)
|
|
for attachment in attachments:
|
|
asset = assets[attachment.url]
|
|
if asset.last_modified > fields.Datetime.from_string(attachment['__last_update']):
|
|
outdated = True
|
|
break
|
|
if asset._content is None:
|
|
asset._content = attachment.datas and attachment.datas.decode('base64').decode('utf8') or ''
|
|
if not asset._content and attachment.file_size > 0:
|
|
asset._content = None # file missing, force recompile
|
|
|
|
if any(asset._content is None for asset in assets.itervalues()):
|
|
outdated = True
|
|
|
|
if outdated:
|
|
if attachments:
|
|
attachments.unlink()
|
|
preprocessed = False
|
|
|
|
return preprocessed
|
|
|
|
def preprocess_css(self, debug=False):
|
|
"""
|
|
Checks if the bundle contains any sass/less content, then compiles it to css.
|
|
Returns the bundle's flat css.
|
|
"""
|
|
for atype in (SassStylesheetAsset, LessStylesheetAsset):
|
|
assets = [asset for asset in self.stylesheets if isinstance(asset, atype)]
|
|
if assets:
|
|
cmd = assets[0].get_command()
|
|
source = '\n'.join([asset.get_source() for asset in assets])
|
|
compiled = self.compile_css(cmd, source)
|
|
|
|
fragments = self.rx_css_split.split(compiled)
|
|
at_rules = fragments.pop(0)
|
|
if at_rules:
|
|
# Sass and less moves @at-rules to the top in order to stay css 2.1 compatible
|
|
self.stylesheets.insert(0, StylesheetAsset(self, inline=at_rules))
|
|
while fragments:
|
|
asset_id = fragments.pop(0)
|
|
asset = next(asset for asset in self.stylesheets if asset.id == asset_id)
|
|
asset._content = fragments.pop(0)
|
|
|
|
if debug:
|
|
try:
|
|
fname = os.path.basename(asset.url)
|
|
url = asset.html_url
|
|
with self.env.cr.savepoint():
|
|
self.env['ir.attachment'].sudo().create(dict(
|
|
datas=asset.content.encode('utf8').encode('base64'),
|
|
mimetype='text/css',
|
|
type='binary',
|
|
name=url,
|
|
url=url,
|
|
datas_fname=fname,
|
|
res_model=False,
|
|
res_id=False,
|
|
))
|
|
|
|
if self.env.context.get('commit_assetsbundle') is True:
|
|
self.env.cr.commit()
|
|
except psycopg2.Error:
|
|
pass
|
|
|
|
return '\n'.join(asset.minify() for asset in self.stylesheets)
|
|
|
|
def compile_css(self, cmd, source):
|
|
"""Sanitizes @import rules, remove duplicates @import rules, then compile"""
|
|
imports = []
|
|
|
|
def sanitize(matchobj):
|
|
ref = matchobj.group(2)
|
|
line = '@import "%s"%s' % (ref, matchobj.group(3))
|
|
if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
|
|
imports.append(line)
|
|
return line
|
|
msg = "Local import '%s' is forbidden for security reasons." % ref
|
|
_logger.warning(msg)
|
|
self.css_errors.append(msg)
|
|
return ''
|
|
source = re.sub(self.rx_preprocess_imports, sanitize, source)
|
|
|
|
try:
|
|
compiler = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
|
except Exception:
|
|
msg = "Could not execute command %r" % cmd[0]
|
|
_logger.error(msg)
|
|
self.css_errors.append(msg)
|
|
return ''
|
|
result = compiler.communicate(input=source.encode('utf-8'))
|
|
if compiler.returncode:
|
|
error = self.get_preprocessor_error(''.join(result), source=source)
|
|
_logger.warning(error)
|
|
self.css_errors.append(error)
|
|
return ''
|
|
compiled = result[0].strip().decode('utf8')
|
|
return compiled
|
|
|
|
def get_preprocessor_error(self, stderr, source=None):
|
|
"""Improve and remove sensitive information from sass/less compilator error messages"""
|
|
error = stderr.split('Load paths')[0].replace(' Use --trace for backtrace.', '')
|
|
if 'Cannot load compass' in error:
|
|
error += "Maybe you should install the compass gem using this extra argument:\n\n" \
|
|
" $ sudo gem install compass --pre\n"
|
|
error += "This error occured while compiling the bundle '%s' containing:" % self.name
|
|
for asset in self.stylesheets:
|
|
if isinstance(asset, PreprocessedCSS):
|
|
error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
|
|
return error
|
|
|
|
|
|
class WebAsset(object):
|
|
html_url_format = '%s'
|
|
_content = None
|
|
_filename = None
|
|
_ir_attach = None
|
|
_id = None
|
|
|
|
def __init__(self, bundle, inline=None, url=None, filename=None):
|
|
self.bundle = bundle
|
|
self.inline = inline
|
|
self._filename = filename
|
|
self.url = url
|
|
self.html_url_args = url
|
|
if not inline and not url:
|
|
raise Exception("An asset should either be inlined or url linked, defined in bundle '%s'" % bundle.name)
|
|
|
|
@func.lazy_property
|
|
def id(self):
|
|
if self._id is None: self._id = str(uuid.uuid4())
|
|
return self._id
|
|
|
|
@func.lazy_property
|
|
def name(self):
|
|
name = '<inline asset>' if self.inline else self.url
|
|
return "%s defined in bundle '%s'" % (name, self.bundle.name)
|
|
|
|
@property
|
|
def html_url(self):
|
|
return self.html_url_format % self.html_url_args
|
|
|
|
def stat(self):
|
|
if not (self.inline or self._filename or self._ir_attach):
|
|
path = filter(None, self.url.split('/'))
|
|
self._filename = get_resource_path(*path)
|
|
if self._filename:
|
|
return
|
|
try:
|
|
# Test url against ir.attachments
|
|
fields = ['__last_update', 'datas', 'mimetype']
|
|
domain = [('type', '=', 'binary'), ('url', '=', self.url)]
|
|
attach = self.bundle.env['ir.attachment'].sudo().search_read(domain, fields)
|
|
self._ir_attach = attach[0]
|
|
except Exception:
|
|
raise AssetNotFound("Could not find %s" % self.name)
|
|
|
|
def to_html(self):
|
|
raise NotImplementedError()
|
|
|
|
@func.lazy_property
|
|
def last_modified(self):
|
|
try:
|
|
self.stat()
|
|
if self._filename:
|
|
return datetime.fromtimestamp(os.path.getmtime(self._filename))
|
|
elif self._ir_attach:
|
|
server_format = tools.DEFAULT_SERVER_DATETIME_FORMAT
|
|
last_update = self._ir_attach['__last_update']
|
|
try:
|
|
return datetime.strptime(last_update, server_format + '.%f')
|
|
except ValueError:
|
|
return datetime.strptime(last_update, server_format)
|
|
except Exception:
|
|
pass
|
|
return datetime(1970, 1, 1)
|
|
|
|
@property
|
|
def content(self):
|
|
if self._content is None:
|
|
self._content = self.inline or self._fetch_content()
|
|
return self._content
|
|
|
|
def _fetch_content(self):
|
|
""" Fetch content from file or database"""
|
|
try:
|
|
self.stat()
|
|
if self._filename:
|
|
with open(self._filename, 'rb') as fp:
|
|
return fp.read().decode('utf-8')
|
|
else:
|
|
return self._ir_attach['datas'].decode('base64')
|
|
except UnicodeDecodeError:
|
|
raise AssetError('%s is not utf-8 encoded.' % self.name)
|
|
except IOError:
|
|
raise AssetNotFound('File %s does not exist.' % self.name)
|
|
except:
|
|
raise AssetError('Could not get content for %s.' % self.name)
|
|
|
|
def minify(self):
|
|
return self.content
|
|
|
|
def with_header(self, content=None):
|
|
if content is None:
|
|
content = self.content
|
|
return '\n/* %s */\n%s' % (self.name, content)
|
|
|
|
|
|
class JavascriptAsset(WebAsset):
|
|
def minify(self):
|
|
return self.with_header(rjsmin(self.content))
|
|
|
|
def _fetch_content(self):
|
|
try:
|
|
return super(JavascriptAsset, self)._fetch_content()
|
|
except AssetError, e:
|
|
return "console.error(%s);" % json.dumps(e.message)
|
|
|
|
def to_html(self):
|
|
if self.url:
|
|
return '<script type="text/javascript" src="%s"></script>' % (self.html_url)
|
|
else:
|
|
return '<script type="text/javascript" charset="utf-8">%s</script>' % self.with_header()
|
|
|
|
|
|
class StylesheetAsset(WebAsset):
|
|
rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
|
|
rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
|
|
rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
|
|
rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
|
|
|
|
def __init__(self, *args, **kw):
|
|
self.media = kw.pop('media', None)
|
|
super(StylesheetAsset, self).__init__(*args, **kw)
|
|
|
|
@property
|
|
def content(self):
|
|
content = super(StylesheetAsset, self).content
|
|
if self.media:
|
|
content = '@media %s { %s }' % (self.media, content)
|
|
return content
|
|
|
|
def _fetch_content(self):
|
|
try:
|
|
content = super(StylesheetAsset, self)._fetch_content()
|
|
web_dir = os.path.dirname(self.url)
|
|
|
|
if self.rx_import:
|
|
content = self.rx_import.sub(
|
|
r"""@import \1%s/""" % (web_dir,),
|
|
content,
|
|
)
|
|
|
|
if self.rx_url:
|
|
content = self.rx_url.sub(
|
|
r"url(\1%s/" % (web_dir,),
|
|
content,
|
|
)
|
|
|
|
if self.rx_charset:
|
|
# remove charset declarations, we only support utf-8
|
|
content = self.rx_charset.sub('', content)
|
|
|
|
return content
|
|
except AssetError, e:
|
|
self.bundle.css_errors.append(e.message)
|
|
return ''
|
|
|
|
def minify(self):
|
|
# remove existing sourcemaps, make no sense after re-mini
|
|
content = self.rx_sourceMap.sub('', self.content)
|
|
# comments
|
|
content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
|
|
# space
|
|
content = re.sub(r'\s+', ' ', content)
|
|
content = re.sub(r' *([{}]) *', r'\1', content)
|
|
return self.with_header(content)
|
|
|
|
def to_html(self):
|
|
media = (' media="%s"' % werkzeug.utils.escape(self.media)) if self.media else ''
|
|
if self.url:
|
|
href = self.html_url
|
|
return '<link rel="stylesheet" href="%s" type="text/css"%s/>' % (href, media)
|
|
else:
|
|
return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
|
|
|
|
|
|
class PreprocessedCSS(StylesheetAsset):
|
|
rx_import = None
|
|
|
|
def __init__(self, *args, **kw):
|
|
super(PreprocessedCSS, self).__init__(*args, **kw)
|
|
self.html_url_format = '%%s/%s/%%s.css' % self.bundle.name
|
|
self.html_url_args = tuple(self.url.rsplit('/', 1))
|
|
|
|
def get_source(self):
|
|
content = self.inline or self._fetch_content()
|
|
return "/*! %s */\n%s" % (self.id, content)
|
|
|
|
def get_command(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
class SassStylesheetAsset(PreprocessedCSS):
|
|
rx_indent = re.compile(r'^( +|\t+)', re.M)
|
|
indent = None
|
|
reindent = ' '
|
|
|
|
def minify(self):
|
|
return self.with_header()
|
|
|
|
def get_source(self):
|
|
content = textwrap.dedent(self.inline or self._fetch_content())
|
|
|
|
def fix_indent(m):
|
|
# Indentation normalization
|
|
ind = m.group()
|
|
if self.indent is None:
|
|
self.indent = ind
|
|
if self.indent == self.reindent:
|
|
# Don't reindent the file if identation is the final one (reindent)
|
|
raise StopIteration()
|
|
return ind.replace(self.indent, self.reindent)
|
|
|
|
try:
|
|
content = self.rx_indent.sub(fix_indent, content)
|
|
except StopIteration:
|
|
pass
|
|
return "/*! %s */\n%s" % (self.id, content)
|
|
|
|
def get_command(self):
|
|
try:
|
|
sass = misc.find_in_path('sass')
|
|
except IOError:
|
|
sass = 'sass'
|
|
return [sass, '--stdin', '-t', 'compressed', '--unix-newlines', '--compass',
|
|
'-r', 'bootstrap-sass']
|
|
|
|
|
|
class LessStylesheetAsset(PreprocessedCSS):
|
|
def get_command(self):
|
|
try:
|
|
if os.name == 'nt':
|
|
lessc = misc.find_in_path('lessc.cmd')
|
|
else:
|
|
lessc = misc.find_in_path('lessc')
|
|
except IOError:
|
|
lessc = 'lessc'
|
|
lesspath = get_resource_path('web', 'static', 'lib', 'bootstrap', 'less')
|
|
return [lessc, '-', '--no-js', '--no-color', '--include-path=%s' % lesspath]
|