assetsbundle.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306
  1. # -*- coding: utf-8 -*-
  2. from contextlib import closing
  3. from collections import OrderedDict
  4. from datetime import datetime
  5. from lxml import etree
  6. from subprocess import Popen, PIPE
  7. import base64
  8. import copy
  9. import hashlib
  10. import io
  11. import itertools
  12. import json
  13. import logging
  14. import os
  15. import re
  16. import textwrap
  17. import uuid
  18. import psycopg2
  19. try:
  20. import sass as libsass
  21. except ImportError:
  22. # If the `sass` python library isn't found, we fallback on the
  23. # `sassc` executable in the path.
  24. libsass = None
  25. from odoo import release, SUPERUSER_ID, _
  26. from odoo.http import request
  27. from odoo.modules.module import get_resource_path
  28. from odoo.tools import (func, misc, transpile_javascript,
  29. is_odoo_module, SourceMapGenerator, profiler,
  30. apply_inheritance_specs)
  31. from odoo.tools.misc import file_open, html_escape as escape
  32. from odoo.tools.pycompat import to_text
  33. _logger = logging.getLogger(__name__)
  34. EXTENSIONS = (".js", ".css", ".scss", ".sass", ".less", ".xml")
  35. class CompileError(RuntimeError): pass
  36. def rjsmin(script):
  37. """ Minify js with a clever regex.
  38. Taken from http://opensource.perlig.de/rjsmin (version 1.1.0)
  39. Apache License, Version 2.0 """
  40. def subber(match):
  41. """ Substitution callback """
  42. groups = match.groups()
  43. return (
  44. groups[0] or
  45. groups[1] or
  46. (groups[3] and (groups[2] + '\n')) or
  47. groups[2] or
  48. (groups[5] and "%s%s%s" % (
  49. groups[4] and '\n' or '',
  50. groups[5],
  51. groups[6] and '\n' or '',
  52. )) or
  53. (groups[7] and '\n') or
  54. (groups[8] and ' ') or
  55. (groups[9] and ' ') or
  56. (groups[10] and ' ') or
  57. ''
  58. )
  59. result = re.sub(
  60. r'([^\047"\140/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^'
  61. r'\r\n]|\r?\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^'
  62. r'\r\n]|\r?\n|\r)[^"\\\r\n]*)*")|(?:\140[^\140\\]*(?:\\(?:[^\r\n'
  63. r']|\r?\n|\r)[^\140\\]*)*\140))[^\047"\140/\000-\040]*)|(?<=[(,='
  64. r':\[!&|?{};\r\n+*-])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*'
  65. r'\*+(?:[^/*][^*]*\*+)*/))*(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-'
  66. r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)*('
  67. r'(?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
  68. r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/))((?:[\000-\011'
  69. r'\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*(?:(?:('
  70. r'?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
  71. r']*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040&)+,.:;=?\]|}-]))?|'
  72. r'(?<=[\000-#%-,./:-@\[-^\140{-~-]return)(?:[\000-\011\013\014\0'
  73. r'16-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*(?:((?:(?://[^\r'
  74. r'\n]*)?[\r\n]))(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?'
  75. r':[^/*][^*]*\*+)*/))*)*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^'
  76. r'\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r'
  77. r'\n]*)*/))((?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
  78. r'*][^*]*\*+)*/))*(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013'
  79. r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000'
  80. r'-\040&)+,.:;=?\]|}-]))?|(?<=[^\000-!#%&(*,./:-@\[\\^{|~])(?:['
  81. r'\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)'
  82. r')*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\014\016-\040'
  83. r']|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#%-\047'
  84. r')*,./:-@\\-^\140|-~])|(?<=[^\000-#%-,./:-@\[-^\140{-~-])((?:['
  85. r'\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)'
  86. r'))+(?=[^\000-#%-,./:-@\[-^\140{-~-])|(?<=\+)((?:[\000-\011\013'
  87. r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<'
  88. r'=-)((?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]'
  89. r'*\*+)*/)))+(?=-)|(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*'
  90. r'+(?:[^/*][^*]*\*+)*/))+|(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-'
  91. r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
  92. ).strip()
  93. return result
  94. class AssetError(Exception):
  95. pass
  96. class AssetNotFound(AssetError):
  97. pass
  98. class AssetsBundle(object):
  99. rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
  100. rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
  101. rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
  102. TRACKED_BUNDLES = ['web.assets_common', 'web.assets_backend']
  103. def __init__(self, name, files, env=None, css=True, js=True):
  104. """
  105. :param name: bundle name
  106. :param files: files to be added to the bundle
  107. :param css: if css is True, the stylesheets files are added to the bundle
  108. :param js: if js is True, the javascript files are added to the bundle
  109. """
  110. self.name = name
  111. self.env = request.env if env is None else env
  112. self.javascripts = []
  113. self.templates = []
  114. self.stylesheets = []
  115. self.css_errors = []
  116. self.files = files
  117. self.user_direction = self.env['res.lang']._lang_get(
  118. self.env.context.get('lang') or self.env.user.lang
  119. ).direction
  120. # asset-wide html "media" attribute
  121. for f in files:
  122. if css:
  123. if f['atype'] == 'text/sass':
  124. self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
  125. elif f['atype'] == 'text/scss':
  126. self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
  127. elif f['atype'] == 'text/less':
  128. self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
  129. elif f['atype'] == 'text/css':
  130. self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
  131. if js:
  132. if f['atype'] == 'text/javascript':
  133. self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
  134. elif f['atype'] == 'text/xml':
  135. self.templates.append(XMLAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
  136. def to_node(self, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False):
  137. """
  138. :returns [(tagName, attributes, content)] if the tag is auto close
  139. """
  140. response = []
  141. is_debug_assets = debug and 'assets' in debug
  142. if css and self.stylesheets:
  143. css_attachments = self.css(is_minified=not is_debug_assets) or []
  144. for attachment in css_attachments:
  145. if is_debug_assets:
  146. href = self.get_debug_asset_url(extra='rtl/' if self.user_direction == 'rtl' else '',
  147. name=css_attachments.name,
  148. extension='')
  149. else:
  150. href = attachment.url
  151. attr = dict([
  152. ["type", "text/css"],
  153. ["rel", "stylesheet"],
  154. ["href", href],
  155. ['data-asset-bundle', self.name],
  156. ['data-asset-version', self.version],
  157. ])
  158. response.append(("link", attr, None))
  159. if self.css_errors:
  160. msg = '\n'.join(self.css_errors)
  161. response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
  162. response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/dist/css/bootstrap.css").to_node())
  163. if js and self.javascripts:
  164. js_attachment = self.js(is_minified=not is_debug_assets)
  165. src = self.get_debug_asset_url(name=js_attachment.name, extension='') if is_debug_assets else js_attachment[0].url
  166. attr = dict([
  167. ["async", "async" if async_load else None],
  168. ["defer", "defer" if defer_load or lazy_load else None],
  169. ["type", "text/javascript"],
  170. ["data-src" if lazy_load else "src", src],
  171. ['data-asset-bundle', self.name],
  172. ['data-asset-version', self.version],
  173. ])
  174. response.append(("script", attr, None))
  175. return response
  176. @func.lazy_property
  177. def last_modified_combined(self):
  178. """Returns last modified date of linked files"""
  179. # WebAsset are recreate here when a better solution would be to use self.stylesheets and self.javascripts
  180. # We currently have no garanty that they are present since it will depends on js and css parameters
  181. # last_modified is actually only usefull for the checksum and checksum should be extension specific since
  182. # they are differents bundles. This will be a future work.
  183. # changing the logic from max date to combined date to fix bundle invalidation issues.
  184. assets = [WebAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])
  185. for f in self.files
  186. if f['atype'] in ['text/sass', "text/scss", "text/less", "text/css", "text/javascript", "text/xml"]]
  187. return ','.join(str(asset.last_modified) for asset in assets)
  188. @func.lazy_property
  189. def version(self):
  190. return self.checksum[0:7]
  191. @func.lazy_property
  192. def checksum(self):
  193. """
  194. Not really a full checksum.
  195. We compute a SHA512/256 on the rendered bundle + combined linked files last_modified date
  196. """
  197. check = u"%s%s" % (json.dumps(self.files, sort_keys=True), self.last_modified_combined)
  198. return hashlib.sha512(check.encode('utf-8')).hexdigest()[:64]
  199. def _get_asset_template_url(self):
  200. return "/web/assets/{id}-{unique}/{extra}{name}{sep}{extension}"
  201. def _get_asset_url_values(self, id, unique, extra, name, sep, extension): # extra can contain direction or/and website
  202. return {
  203. 'id': id,
  204. 'unique': unique,
  205. 'extra': extra,
  206. 'name': name,
  207. 'sep': sep,
  208. 'extension': extension,
  209. }
  210. def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", extension='%'):
  211. return self._get_asset_template_url().format(
  212. **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, extension=extension)
  213. )
  214. def get_debug_asset_url(self, extra='', name='%', extension='%'):
  215. return f"/web/assets/debug/{extra}{name}{extension}"
  216. def _unlink_attachments(self, attachments):
  217. """ Unlinks attachments without actually calling unlink, so that the ORM cache is not cleared.
  218. Specifically, if an attachment is generated while a view is rendered, clearing the ORM cache
  219. could unload fields loaded with a sudo(), and expected to be readable by the view.
  220. Such a view would be website.layout when main_object is an ir.ui.view.
  221. """
  222. to_delete = set(attach.store_fname for attach in attachments if attach.store_fname)
  223. self.env.cr.execute(f"""DELETE FROM {attachments._table} WHERE id IN (
  224. SELECT id FROM {attachments._table} WHERE id in %s FOR NO KEY UPDATE SKIP LOCKED
  225. )""", [tuple(attachments.ids)])
  226. for file_path in to_delete:
  227. attachments._file_delete(file_path)
  228. def clean_attachments(self, extension):
  229. """ Takes care of deleting any outdated ir.attachment records associated to a bundle before
  230. saving a fresh one.
  231. When `extension` is js we need to check that we are deleting a different version (and not *any*
  232. version) because, as one of the creates in `save_attachment` can trigger a rollback, the
  233. call to `clean_attachments ` is made at the end of the method in order to avoid the rollback
  234. of an ir.attachment unlink (because we cannot rollback a removal on the filestore), thus we
  235. must exclude the current bundle.
  236. """
  237. ira = self.env['ir.attachment']
  238. url = self.get_asset_url(
  239. extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
  240. name=self.name,
  241. sep='',
  242. extension='.%s' % extension
  243. )
  244. domain = [
  245. ('url', '=like', url),
  246. '!', ('url', '=like', self.get_asset_url(unique=self.version))
  247. ]
  248. attachments = ira.sudo().search(domain)
  249. # avoid to invalidate cache if it's already empty (mainly useful for test)
  250. if attachments:
  251. self._unlink_attachments(attachments)
  252. # force bundle invalidation on other workers
  253. self.env['ir.qweb'].clear_caches()
  254. return True
  255. def get_attachments(self, extension, ignore_version=False):
  256. """ Return the ir.attachment records for a given bundle. This method takes care of mitigating
  257. an issue happening when parallel transactions generate the same bundle: while the file is not
  258. duplicated on the filestore (as it is stored according to its hash), there are multiple
  259. ir.attachment records referencing the same version of a bundle. As we don't want to source
  260. multiple time the same bundle in our `to_html` function, we group our ir.attachment records
  261. by file name and only return the one with the max id for each group.
  262. :param extension: file extension (js, min.js, css)
  263. :param ignore_version: if ignore_version, the url contains a version => web/assets/%-%/name.extension
  264. (the second '%' corresponds to the version),
  265. else: the url contains a version equal to that of the self.version
  266. => web/assets/%-self.version/name.extension.
  267. """
  268. unique = "%" if ignore_version else self.version
  269. url_pattern = self.get_asset_url(
  270. unique=unique,
  271. extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
  272. name=self.name,
  273. sep='',
  274. extension='.%s' % extension
  275. )
  276. self.env.cr.execute("""
  277. SELECT max(id)
  278. FROM ir_attachment
  279. WHERE create_uid = %s
  280. AND url like %s
  281. GROUP BY name
  282. ORDER BY name
  283. """, [SUPERUSER_ID, url_pattern])
  284. attachment_ids = [r[0] for r in self.env.cr.fetchall()]
  285. return self.env['ir.attachment'].sudo().browse(attachment_ids)
  286. def add_post_rollback(self):
  287. """
  288. In some rare cases it is possible that an attachment is created
  289. during a transaction, added to the ormcache but the transaction
  290. is rolled back, leading to 404 when getting the attachments.
  291. This postrollback hook will help fix this issue by clearing the
  292. cache if it is not committed.
  293. """
  294. self.env.cr.postrollback.add(self.env.registry._Registry__cache.clear)
  295. def save_attachment(self, extension, content):
  296. """Record the given bundle in an ir.attachment and delete
  297. all other ir.attachments referring to this bundle (with the same name and extension).
  298. :param extension: extension of the bundle to be recorded
  299. :param content: bundle content to be recorded
  300. :return the ir.attachment records for a given bundle.
  301. """
  302. assert extension in ('js', 'min.js', 'js.map', 'css', 'min.css', 'css.map', 'xml', 'min.xml')
  303. ira = self.env['ir.attachment']
  304. # Set user direction in name to store two bundles
  305. # 1 for ltr and 1 for rtl, this will help during cleaning of assets bundle
  306. # and allow to only clear the current direction bundle
  307. # (this applies to css bundles only)
  308. fname = '%s.%s' % (self.name, extension)
  309. mimetype = (
  310. 'text/css' if extension in ['css', 'min.css'] else
  311. 'text/xml' if extension in ['xml', 'min.xml'] else
  312. 'application/json' if extension in ['js.map', 'css.map'] else
  313. 'application/javascript'
  314. )
  315. values = {
  316. 'name': fname,
  317. 'mimetype': mimetype,
  318. 'res_model': 'ir.ui.view',
  319. 'res_id': False,
  320. 'type': 'binary',
  321. 'public': True,
  322. 'raw': content.encode('utf8'),
  323. }
  324. self.add_post_rollback()
  325. attachment = ira.with_user(SUPERUSER_ID).create(values)
  326. url = self.get_asset_url(
  327. id=attachment.id,
  328. unique=self.version,
  329. extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
  330. name=fname,
  331. sep='', # included in fname
  332. extension=''
  333. )
  334. values = {
  335. 'url': url,
  336. }
  337. attachment.write(values)
  338. if self.env.context.get('commit_assetsbundle') is True:
  339. self.env.cr.commit()
  340. self.clean_attachments(extension)
  341. # For end-user assets (common and backend), send a message on the bus
  342. # to invite the user to refresh their browser
  343. if self.env and 'bus.bus' in self.env and self.name in self.TRACKED_BUNDLES:
  344. self.env['bus.bus']._sendone('broadcast', 'bundle_changed', {
  345. 'server_version': release.version # Needs to be dynamically imported
  346. })
  347. _logger.debug('Asset Changed: bundle: %s -- version: %s', self.name, self.version)
  348. return attachment
  349. def js(self, is_minified=True):
  350. extension = 'min.js' if is_minified else 'js'
  351. js_attachment = self.get_attachments(extension)
  352. if not js_attachment:
  353. template_bundle = ''
  354. if self.templates:
  355. content = ['<?xml version="1.0" encoding="UTF-8"?>']
  356. content.append('<templates xml:space="preserve">')
  357. content.append(self.xml(show_inherit_info=not is_minified))
  358. content.append('</templates>')
  359. templates = '\n'.join(content).replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${")
  360. template_bundle = textwrap.dedent(f"""
  361. /*******************************************
  362. * Templates *
  363. *******************************************/
  364. odoo.define('{self.name}.bundle.xml', function(require){{
  365. 'use strict';
  366. const {{ loadXML }} = require('@web/core/assets');
  367. const templates = `{templates}`;
  368. return loadXML(templates);
  369. }});""")
  370. if is_minified:
  371. content_bundle = ';\n'.join(asset.minify() for asset in self.javascripts)
  372. content_bundle += template_bundle
  373. js_attachment = self.save_attachment(extension, content_bundle)
  374. else:
  375. js_attachment = self.js_with_sourcemap(template_bundle=template_bundle)
  376. return js_attachment[0]
  377. def js_with_sourcemap(self, template_bundle=None):
  378. """Create the ir.attachment representing the not-minified content of the bundleJS
  379. and create/modify the ir.attachment representing the linked sourcemap.
  380. :return ir.attachment representing the un-minified content of the bundleJS
  381. """
  382. sourcemap_attachment = self.get_attachments('js.map') \
  383. or self.save_attachment('js.map', '')
  384. generator = SourceMapGenerator(
  385. source_root="/".join(
  386. [".." for i in range(0, len(self.get_debug_asset_url(name=self.name).split("/")) - 2)]
  387. ) + "/",
  388. )
  389. content_bundle_list = []
  390. content_line_count = 0
  391. line_header = 5 # number of lines added by with_header()
  392. for asset in self.javascripts:
  393. if asset.is_transpiled:
  394. # '+ 3' corresponds to the 3 lines added at the beginning of the file during transpilation.
  395. generator.add_source(
  396. asset.url, asset._content, content_line_count, start_offset=line_header + 3)
  397. else:
  398. generator.add_source(
  399. asset.url, asset.content, content_line_count, start_offset=line_header)
  400. content_bundle_list.append(asset.with_header(asset.content, minimal=False))
  401. content_line_count += len(asset.content.split("\n")) + line_header
  402. content_bundle = ';\n'.join(content_bundle_list)
  403. if template_bundle:
  404. content_bundle += template_bundle
  405. content_bundle += "\n\n//# sourceMappingURL=" + sourcemap_attachment.url
  406. js_attachment = self.save_attachment('js', content_bundle)
  407. generator._file = js_attachment.url
  408. sourcemap_attachment.write({
  409. "raw": generator.get_content()
  410. })
  411. return js_attachment
  412. def xml(self, show_inherit_info=False):
  413. """
  414. Create the ir.attachment representing the content of the bundle XML.
  415. The xml contents are loaded and parsed with etree. Inheritances are
  416. applied in the order of files and templates.
  417. Used parsed attributes:
  418. * `t-name`: template name
  419. * `t-inherit`: inherited template name. The template use the
  420. `apply_inheritance_specs` method from `ir.ui.view` to apply
  421. inheritance (with xpath and position).
  422. * 't-inherit-mode': 'primary' to create a new template with the
  423. update, or 'extension' to apply the update on the inherited
  424. template.
  425. * `t-extend` deprecated attribute, used by the JavaScript Qweb.
  426. :param show_inherit_info: if true add the file url and inherit
  427. information in the template.
  428. :return ir.attachment representing the content of the bundle XML
  429. """
  430. template_dict = OrderedDict()
  431. parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True)
  432. for asset in self.templates:
  433. # Load content.
  434. try:
  435. content = asset.content.strip()
  436. template = content if content.startswith('<odoo>') else f'<templates>{asset.content}</templates>'
  437. io_content = io.BytesIO(template.encode('utf-8'))
  438. content_templates_tree = etree.parse(io_content, parser=parser).getroot()
  439. except etree.ParseError as e:
  440. _logger.error("Could not parse file %s: %s", asset.url, e.msg)
  441. raise
  442. addon = asset.url.split('/')[1]
  443. template_dict.setdefault(addon, OrderedDict())
  444. # Process every templates.
  445. for template_tree in list(content_templates_tree):
  446. template_name = None
  447. if 't-name' in template_tree.attrib:
  448. template_name = template_tree.attrib['t-name']
  449. dotted_names = template_name.split('.', 1)
  450. if len(dotted_names) > 1 and dotted_names[0] == addon:
  451. template_name = dotted_names[1]
  452. if 't-inherit' in template_tree.attrib:
  453. inherit_mode = template_tree.attrib.get('t-inherit-mode', 'primary')
  454. if inherit_mode not in ['primary', 'extension']:
  455. raise ValueError(_("Invalid inherit mode. Module %r and template name %r", addon, template_name))
  456. # Get inherited template, the identifier can be "addon.name", just "name" or (silly) "just.name.with.dots"
  457. parent_dotted_name = template_tree.attrib['t-inherit']
  458. split_name_attempt = parent_dotted_name.split('.', 1)
  459. parent_addon, parent_name = split_name_attempt if len(split_name_attempt) == 2 else (addon, parent_dotted_name)
  460. if parent_addon not in template_dict:
  461. if parent_dotted_name in template_dict[addon]:
  462. parent_addon = addon
  463. parent_name = parent_dotted_name
  464. else:
  465. raise ValueError(_("Module %r not loaded or inexistent (try to inherit %r), or templates of addon being loaded %r are misordered (template %r)", parent_addon, parent_name, addon, template_name))
  466. if parent_name not in template_dict[parent_addon]:
  467. raise ValueError(_("Cannot create %r because the template to inherit %r is not found.") % (f'{addon}.{template_name}', f'{parent_addon}.{parent_name}'))
  468. # After several performance tests, we found out that deepcopy is the most efficient
  469. # solution in this case (compared with copy, xpath with '.' and stringifying).
  470. parent_tree, parent_urls = template_dict[parent_addon][parent_name]
  471. parent_tree = copy.deepcopy(parent_tree)
  472. if show_inherit_info:
  473. # Add inheritance information as xml comment for debugging.
  474. xpaths = []
  475. for item in template_tree:
  476. position = item.get('position')
  477. attrib = dict(**item.attrib)
  478. attrib.pop('position', None)
  479. comment = etree.Comment(f""" Filepath: {asset.url} ; position="{position}" ; {attrib} """)
  480. if position == "attributes":
  481. if item.get('expr'):
  482. comment_node = etree.Element('xpath', {'expr': item.get('expr'), 'position': 'before'})
  483. else:
  484. comment_node = etree.Element(item.tag, item.attrib)
  485. comment_node.attrib['position'] = 'before'
  486. comment_node.append(comment)
  487. xpaths.append(comment_node)
  488. else:
  489. if len(item) > 0:
  490. item[0].addprevious(comment)
  491. else:
  492. item.append(comment)
  493. xpaths.append(item)
  494. else:
  495. xpaths = list(template_tree)
  496. # Apply inheritance.
  497. if inherit_mode == 'primary':
  498. parent_tree.tag = template_tree.tag
  499. inherited_template = apply_inheritance_specs(parent_tree, xpaths)
  500. if inherit_mode == 'primary': # New template_tree: A' = B(A)
  501. for attr_name, attr_val in template_tree.attrib.items():
  502. if attr_name not in ('t-inherit', 't-inherit-mode'):
  503. inherited_template.set(attr_name, attr_val)
  504. if not template_name:
  505. raise ValueError(_("Template name is missing in file %r.", asset.url))
  506. template_dict[addon][template_name] = (inherited_template, parent_urls + [asset.url])
  507. else: # Modifies original: A = B(A)
  508. template_dict[parent_addon][parent_name] = (inherited_template, parent_urls + [asset.url])
  509. elif template_name:
  510. if template_name in template_dict[addon]:
  511. raise ValueError(_("Template %r already exists in module %r", template_name, addon))
  512. template_dict[addon][template_name] = (template_tree, [asset.url])
  513. elif template_tree.attrib.get('t-extend'):
  514. template_name = '%s__extend_%s' % (template_tree.attrib.get('t-extend'), len(template_dict[addon]))
  515. template_dict[addon][template_name] = (template_tree, [asset.url])
  516. else:
  517. raise ValueError(_("Template name is missing in file %r.", asset.url))
  518. # Concat and render inherited templates
  519. root = etree.Element('root')
  520. for addon in template_dict.values():
  521. for template, urls in addon.values():
  522. if show_inherit_info:
  523. tail = "\n"
  524. if len(root) > 0:
  525. tail = root[-1].tail
  526. root[-1].tail = "\n\n"
  527. comment = etree.Comment(f""" Filepath: {' => '.join(urls)} """)
  528. comment.tail = tail
  529. root.append(comment)
  530. root.append(template)
  531. # Returns the string by removing the <root> tag.
  532. return etree.tostring(root, encoding='unicode')[6:-7]
  533. def css(self, is_minified=True):
  534. extension = 'min.css' if is_minified else 'css'
  535. attachments = self.get_attachments(extension)
  536. if not attachments:
  537. # get css content
  538. css = self.preprocess_css()
  539. if self.css_errors:
  540. return self.get_attachments(extension, ignore_version=True)
  541. matches = []
  542. css = re.sub(self.rx_css_import, lambda matchobj: matches.append(matchobj.group(0)) and '', css)
  543. if is_minified:
  544. # move up all @import rules to the top
  545. matches.append(css)
  546. css = u'\n'.join(matches)
  547. self.save_attachment(extension, css)
  548. attachments = self.get_attachments(extension)
  549. else:
  550. return self.css_with_sourcemap(u'\n'.join(matches))
  551. return attachments
  552. def css_with_sourcemap(self, content_import_rules):
  553. """Create the ir.attachment representing the not-minified content of the bundleCSS
  554. and create/modify the ir.attachment representing the linked sourcemap.
  555. :param content_import_rules: string containing all the @import rules to put at the beginning of the bundle
  556. :return ir.attachment representing the un-minified content of the bundleCSS
  557. """
  558. sourcemap_attachment = self.get_attachments('css.map') \
  559. or self.save_attachment('css.map', '')
  560. debug_asset_url = self.get_debug_asset_url(name=self.name,
  561. extra='rtl/' if self.user_direction == 'rtl' else '')
  562. generator = SourceMapGenerator(
  563. source_root="/".join(
  564. [".." for i in range(0, len(debug_asset_url.split("/")) - 2)]
  565. ) + "/",
  566. )
  567. # adds the @import rules at the beginning of the bundle
  568. content_bundle_list = [content_import_rules]
  569. content_line_count = len(content_import_rules.split("\n"))
  570. for asset in self.stylesheets:
  571. if asset.content:
  572. content = asset.with_header(asset.content)
  573. if asset.url:
  574. generator.add_source(asset.url, content, content_line_count)
  575. # comments all @import rules that have been added at the beginning of the bundle
  576. content = re.sub(self.rx_css_import, lambda matchobj: f"/* {matchobj.group(0)} */", content)
  577. content_bundle_list.append(content)
  578. content_line_count += len(content.split("\n"))
  579. content_bundle = '\n'.join(content_bundle_list) + f"\n//*# sourceMappingURL={sourcemap_attachment.url} */"
  580. css_attachment = self.save_attachment('css', content_bundle)
  581. generator._file = css_attachment.url
  582. sourcemap_attachment.write({
  583. "raw": generator.get_content(),
  584. })
  585. return css_attachment
  586. def dialog_message(self, message):
  587. """
  588. Returns a JS script which shows a warning to the user on page load.
  589. TODO: should be refactored to be a base js file whose code is extended
  590. by related apps (web/website).
  591. """
  592. return """
  593. (function (message) {
  594. 'use strict';
  595. if (window.__assetsBundleErrorSeen) {
  596. return;
  597. }
  598. window.__assetsBundleErrorSeen = true;
  599. if (document.readyState !== 'loading') {
  600. onDOMContentLoaded();
  601. } else {
  602. window.addEventListener('DOMContentLoaded', () => onDOMContentLoaded());
  603. }
  604. async function onDOMContentLoaded() {
  605. var odoo = window.top.odoo;
  606. if (!odoo || !odoo.define) {
  607. useAlert();
  608. return;
  609. }
  610. // Wait for potential JS loading
  611. await new Promise(resolve => {
  612. const noLazyTimeout = setTimeout(() => resolve(), 10); // 10 since need to wait for promise resolutions of odoo.define
  613. odoo.define('AssetsBundle.PotentialLazyLoading', function (require) {
  614. 'use strict';
  615. const lazyloader = require('web.public.lazyloader');
  616. clearTimeout(noLazyTimeout);
  617. lazyloader.allScriptsLoaded.then(() => resolve());
  618. });
  619. });
  620. var alertTimeout = setTimeout(useAlert, 10); // 10 since need to wait for promise resolutions of odoo.define
  621. odoo.define('AssetsBundle.ErrorMessage', function (require) {
  622. 'use strict';
  623. require('web.dom_ready');
  624. var core = require('web.core');
  625. var Dialog = require('web.Dialog');
  626. var _t = core._t;
  627. clearTimeout(alertTimeout);
  628. new Dialog(null, {
  629. title: _t("Style error"),
  630. $content: $('<div/>')
  631. .append($('<p/>', {text: _t("The style compilation failed, see the error below. Your recent actions may be the cause, please try reverting the changes you made.")}))
  632. .append($('<pre/>', {html: message})),
  633. }).open();
  634. });
  635. }
  636. function useAlert() {
  637. window.alert(message);
  638. }
  639. })("%s");
  640. """ % message.replace('"', '\\"').replace('\n', '&NewLine;')
  641. def _get_assets_domain_for_already_processed_css(self, assets):
  642. """ Method to compute the attachments' domain to search the already process assets (css).
  643. This method was created to be overridden.
  644. """
  645. return [('url', 'in', list(assets.keys()))]
  646. def is_css_preprocessed(self):
  647. preprocessed = True
  648. old_attachments = self.env['ir.attachment'].sudo()
  649. asset_types = [SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset]
  650. if self.user_direction == 'rtl':
  651. asset_types.append(StylesheetAsset)
  652. for atype in asset_types:
  653. outdated = False
  654. assets = dict((asset.html_url, asset) for asset in self.stylesheets if isinstance(asset, atype))
  655. if assets:
  656. assets_domain = self._get_assets_domain_for_already_processed_css(assets)
  657. attachments = self.env['ir.attachment'].sudo().search(assets_domain)
  658. old_attachments += attachments
  659. for attachment in attachments:
  660. asset = assets[attachment.url]
  661. if asset.last_modified > attachment['__last_update']:
  662. outdated = True
  663. break
  664. if asset._content is None:
  665. asset._content = (attachment.raw or b'').decode('utf8')
  666. if not asset._content and attachment.file_size > 0:
  667. asset._content = None # file missing, force recompile
  668. if any(asset._content is None for asset in assets.values()):
  669. outdated = True
  670. if outdated:
  671. preprocessed = False
  672. return preprocessed, old_attachments
  673. def preprocess_css(self, debug=False, old_attachments=None):
  674. """
  675. Checks if the bundle contains any sass/less content, then compiles it to css.
  676. If user language direction is Right to Left then consider css files to call run_rtlcss,
  677. css files are also stored in ir.attachment after processing done by rtlcss.
  678. Returns the bundle's flat css.
  679. """
  680. if self.stylesheets:
  681. compiled = ""
  682. for atype in (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset):
  683. assets = [asset for asset in self.stylesheets if isinstance(asset, atype)]
  684. if assets:
  685. source = '\n'.join([asset.get_source() for asset in assets])
  686. compiled += self.compile_css(assets[0].compile, source)
  687. # We want to run rtlcss on normal css, so merge it in compiled
  688. if self.user_direction == 'rtl':
  689. stylesheet_assets = [asset for asset in self.stylesheets if not isinstance(asset, (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset))]
  690. compiled += '\n'.join([asset.get_source() for asset in stylesheet_assets])
  691. compiled = self.run_rtlcss(compiled)
  692. if not self.css_errors and old_attachments:
  693. self._unlink_attachments(old_attachments)
  694. old_attachments = None
  695. fragments = self.rx_css_split.split(compiled)
  696. at_rules = fragments.pop(0)
  697. if at_rules:
  698. # Sass and less moves @at-rules to the top in order to stay css 2.1 compatible
  699. self.stylesheets.insert(0, StylesheetAsset(self, inline=at_rules))
  700. while fragments:
  701. asset_id = fragments.pop(0)
  702. asset = next(asset for asset in self.stylesheets if asset.id == asset_id)
  703. asset._content = fragments.pop(0)
  704. return '\n'.join(asset.minify() for asset in self.stylesheets)
  705. def compile_css(self, compiler, source):
  706. """Sanitizes @import rules, remove duplicates @import rules, then compile"""
  707. imports = []
  708. def handle_compile_error(e, source):
  709. error = self.get_preprocessor_error(e, source=source)
  710. _logger.warning(error)
  711. self.css_errors.append(error)
  712. return ''
  713. def sanitize(matchobj):
  714. ref = matchobj.group(2)
  715. line = '@import "%s"%s' % (ref, matchobj.group(3))
  716. if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
  717. imports.append(line)
  718. return line
  719. msg = "Local import '%s' is forbidden for security reasons. Please remove all @import {your_file} imports in your custom files. In Odoo you have to import all files in the assets, and not through the @import statement." % ref
  720. _logger.warning(msg)
  721. self.css_errors.append(msg)
  722. return ''
  723. source = re.sub(self.rx_preprocess_imports, sanitize, source)
  724. compiled = ''
  725. try:
  726. compiled = compiler(source)
  727. except CompileError as e:
  728. return handle_compile_error(e, source=source)
  729. compiled = compiled.strip()
  730. # Post process the produced css to add required vendor prefixes here
  731. compiled = re.sub(r'(appearance: (\w+);)', r'-webkit-appearance: \2; -moz-appearance: \2; \1', compiled)
  732. # Most of those are only useful for wkhtmltopdf (some for old PhantomJS)
  733. compiled = re.sub(r'(display: ((?:inline-)?)flex((?: ?!important)?);)', r'display: -webkit-\2box\3; display: -webkit-\2flex\3; \1', compiled)
  734. compiled = re.sub(r'(justify-content: flex-(\w+)((?: ?!important)?);)', r'-webkit-box-pack: \2\3; \1', compiled)
  735. compiled = re.sub(r'(flex-flow: (\w+ \w+);)', r'-webkit-flex-flow: \2; \1', compiled)
  736. compiled = re.sub(r'(flex-direction: (column);)', r'-webkit-box-orient: vertical; -webkit-box-direction: normal; -webkit-flex-direction: \2; \1', compiled)
  737. compiled = re.sub(r'(flex-wrap: (\w+);)', r'-webkit-flex-wrap: \2; \1', compiled)
  738. compiled = re.sub(r'(flex: ((\d)+ \d+ (?:\d+|auto));)', r'-webkit-box-flex: \3; -webkit-flex: \2; \1', compiled)
  739. return compiled
  740. def run_rtlcss(self, source):
  741. rtlcss = 'rtlcss'
  742. if os.name == 'nt':
  743. try:
  744. rtlcss = misc.find_in_path('rtlcss.cmd')
  745. except IOError:
  746. rtlcss = 'rtlcss'
  747. cmd = [rtlcss, '-c', get_resource_path("base", "data/rtlcss.json"), '-']
  748. try:
  749. rtlcss = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
  750. except Exception:
  751. # Check the presence of rtlcss, if rtlcss not available then we should return normal less file
  752. try:
  753. process = Popen(
  754. ['rtlcss', '--version'], stdout=PIPE, stderr=PIPE
  755. )
  756. except (OSError, IOError):
  757. _logger.warning('You need https://rtlcss.com/ to convert css file to right to left compatiblity. Use: npm install -g rtlcss')
  758. return source
  759. msg = "Could not execute command %r" % cmd[0]
  760. _logger.error(msg)
  761. self.css_errors.append(msg)
  762. return ''
  763. result = rtlcss.communicate(input=source.encode('utf-8'))
  764. if rtlcss.returncode:
  765. cmd_output = ''.join(misc.ustr(result))
  766. if not cmd_output:
  767. cmd_output = "Process exited with return code %d\n" % rtlcss.returncode
  768. error = self.get_rtlcss_error(cmd_output, source=source)
  769. _logger.warning(error)
  770. self.css_errors.append(error)
  771. return ''
  772. rtlcss_result = result[0].strip().decode('utf8')
  773. return rtlcss_result
  774. def get_preprocessor_error(self, stderr, source=None):
  775. """Improve and remove sensitive information from sass/less compilator error messages"""
  776. error = misc.ustr(stderr).split('Load paths')[0].replace(' Use --trace for backtrace.', '')
  777. if 'Cannot load compass' in error:
  778. error += "Maybe you should install the compass gem using this extra argument:\n\n" \
  779. " $ sudo gem install compass --pre\n"
  780. error += "This error occurred while compiling the bundle '%s' containing:" % self.name
  781. for asset in self.stylesheets:
  782. if isinstance(asset, PreprocessedCSS):
  783. error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
  784. return error
  785. def get_rtlcss_error(self, stderr, source=None):
  786. """Improve and remove sensitive information from sass/less compilator error messages"""
  787. error = misc.ustr(stderr).split('Load paths')[0].replace(' Use --trace for backtrace.', '')
  788. error += "This error occurred while compiling the bundle '%s' containing:" % self.name
  789. return error
  790. class WebAsset(object):
  791. html_url_format = '%s'
  792. _content = None
  793. _filename = None
  794. _ir_attach = None
  795. _id = None
  796. def __init__(self, bundle, inline=None, url=None, filename=None):
  797. self.bundle = bundle
  798. self.inline = inline
  799. self._filename = filename
  800. self.url = url
  801. self.html_url_args = url
  802. if not inline and not url:
  803. raise Exception("An asset should either be inlined or url linked, defined in bundle '%s'" % bundle.name)
  804. @func.lazy_property
  805. def id(self):
  806. if self._id is None: self._id = str(uuid.uuid4())
  807. return self._id
  808. @func.lazy_property
  809. def name(self):
  810. return '<inline asset>' if self.inline else self.url
  811. @property
  812. def html_url(self):
  813. return self.html_url_format % self.html_url_args
  814. def stat(self):
  815. if not (self.inline or self._filename or self._ir_attach):
  816. path = (segment for segment in self.url.split('/') if segment)
  817. self._filename = get_resource_path(*path)
  818. if self._filename:
  819. return
  820. try:
  821. # Test url against ir.attachments
  822. self._ir_attach = self.bundle.env['ir.attachment'].sudo()._get_serve_attachment(self.url)
  823. self._ir_attach.ensure_one()
  824. except ValueError:
  825. raise AssetNotFound("Could not find %s" % self.name)
  826. def to_node(self):
  827. raise NotImplementedError()
  828. @func.lazy_property
  829. def last_modified(self):
  830. try:
  831. self.stat()
  832. if self._filename:
  833. return datetime.fromtimestamp(os.path.getmtime(self._filename))
  834. elif self._ir_attach:
  835. return self._ir_attach['__last_update']
  836. except Exception:
  837. pass
  838. return datetime(1970, 1, 1)
  839. @property
  840. def content(self):
  841. if self._content is None:
  842. self._content = self.inline or self._fetch_content()
  843. return self._content
  844. def _fetch_content(self):
  845. """ Fetch content from file or database"""
  846. try:
  847. self.stat()
  848. if self._filename:
  849. with closing(file_open(self._filename, 'rb', filter_ext=EXTENSIONS)) as fp:
  850. return fp.read().decode('utf-8')
  851. else:
  852. return self._ir_attach.raw.decode()
  853. except UnicodeDecodeError:
  854. raise AssetError('%s is not utf-8 encoded.' % self.name)
  855. except IOError:
  856. raise AssetNotFound('File %s does not exist.' % self.name)
  857. except:
  858. raise AssetError('Could not get content for %s.' % self.name)
  859. def minify(self):
  860. return self.content
  861. def with_header(self, content=None):
  862. if content is None:
  863. content = self.content
  864. return f'\n/* {self.name} */\n{content}'
  865. class JavascriptAsset(WebAsset):
  866. def __init__(self, bundle, inline=None, url=None, filename=None):
  867. super().__init__(bundle, inline, url, filename)
  868. self._is_transpiled = None
  869. self._converted_content = None
  870. @property
  871. def is_transpiled(self):
  872. if self._is_transpiled is None:
  873. self._is_transpiled = bool(is_odoo_module(super().content))
  874. return self._is_transpiled
  875. @property
  876. def content(self):
  877. content = super().content
  878. if self.is_transpiled:
  879. if not self._converted_content:
  880. self._converted_content = transpile_javascript(self.url, content)
  881. return self._converted_content
  882. return content
  883. def minify(self):
  884. return self.with_header(rjsmin(self.content))
  885. def _fetch_content(self):
  886. try:
  887. return super()._fetch_content()
  888. except AssetError as e:
  889. return u"console.error(%s);" % json.dumps(to_text(e))
  890. def to_node(self):
  891. if self.url:
  892. return ("script", dict([
  893. ["type", "text/javascript"],
  894. ["src", self.html_url],
  895. ['data-asset-bundle', self.bundle.name],
  896. ['data-asset-version', self.bundle.version],
  897. ]), None)
  898. else:
  899. return ("script", dict([
  900. ["type", "text/javascript"],
  901. ["charset", "utf-8"],
  902. ['data-asset-bundle', self.bundle.name],
  903. ['data-asset-version', self.bundle.version],
  904. ]), self.with_header())
  905. def with_header(self, content=None, minimal=True):
  906. if minimal:
  907. return super().with_header(content)
  908. # format the header like
  909. # /**************************
  910. # * Filepath: <asset_url> *
  911. # * Lines: 42 *
  912. # **************************/
  913. line_count = content.count('\n')
  914. lines = [
  915. f"Filepath: {self.url}",
  916. f"Lines: {line_count}",
  917. ]
  918. length = max(map(len, lines))
  919. return "\n".join([
  920. "",
  921. "/" + "*" * (length + 5),
  922. *(f"* {line:<{length}} *" for line in lines),
  923. "*" * (length + 5) + "/",
  924. content,
  925. ])
  926. class XMLAsset(WebAsset):
  927. def _fetch_content(self):
  928. try:
  929. content = super()._fetch_content()
  930. except AssetError as e:
  931. return f'<error data-asset-bundle={self.bundle.name!r} data-asset-version={self.bundle.version!r}>{json.dumps(to_text(e))}</error>'
  932. parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True)
  933. root = etree.parse(io.BytesIO(content.encode('utf-8')), parser=parser).getroot()
  934. if root.tag in ('templates', 'template'):
  935. return ''.join(etree.tostring(el, encoding='unicode') for el in root)
  936. return etree.tostring(root, encoding='unicode')
  937. def to_node(self):
  938. attributes = {
  939. 'async': 'async',
  940. 'defer': 'defer',
  941. 'type': 'text/xml',
  942. 'data-src': self.html_url,
  943. 'data-asset-bundle': self.bundle.name,
  944. 'data-asset-version': self.bundle.version,
  945. }
  946. return ("script", attributes, None)
  947. def with_header(self, content=None):
  948. if content is None:
  949. content = self.content
  950. # format the header like
  951. # <!--=========================-->
  952. # <!-- Filepath: <asset_url> -->
  953. # <!-- Bundle: <name> -->
  954. # <!-- Lines: 42 -->
  955. # <!--=========================-->
  956. line_count = content.count('\n')
  957. lines = [
  958. f"Filepath: {self.url}",
  959. f"Lines: {line_count}",
  960. ]
  961. length = max(map(len, lines))
  962. return "\n".join([
  963. "",
  964. "<!-- " + "=" * length + " -->",
  965. *(f"<!-- {line:<{length}} -->" for line in lines),
  966. "<!-- " + "=" * length + " -->",
  967. content,
  968. ])
  969. class StylesheetAsset(WebAsset):
  970. rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
  971. rx_url = re.compile(r"""(?<!")url\s*\(\s*('|"|)(?!'|"|/|https?://|data:|#{str)""", re.U)
  972. rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
  973. rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
  974. def __init__(self, *args, **kw):
  975. self.media = kw.pop('media', None)
  976. self.direction = kw.pop('direction', None)
  977. super().__init__(*args, **kw)
  978. if self.direction == 'rtl' and self.url:
  979. self.html_url_args = self.url.rsplit('.', 1)
  980. self.html_url_format = '%%s/%s/%s.%%s' % ('rtl', self.bundle.name)
  981. self.html_url_args = tuple(self.html_url_args)
  982. @property
  983. def content(self):
  984. content = super().content
  985. if self.media:
  986. content = '@media %s { %s }' % (self.media, content)
  987. return content
  988. def _fetch_content(self):
  989. try:
  990. content = super()._fetch_content()
  991. web_dir = os.path.dirname(self.url)
  992. if self.rx_import:
  993. content = self.rx_import.sub(
  994. r"""@import \1%s/""" % (web_dir,),
  995. content,
  996. )
  997. if self.rx_url:
  998. content = self.rx_url.sub(
  999. r"url(\1%s/" % (web_dir,),
  1000. content,
  1001. )
  1002. if self.rx_charset:
  1003. # remove charset declarations, we only support utf-8
  1004. content = self.rx_charset.sub('', content)
  1005. return content
  1006. except AssetError as e:
  1007. self.bundle.css_errors.append(str(e))
  1008. return ''
  1009. def get_source(self):
  1010. content = self.inline or self._fetch_content()
  1011. return "/*! %s */\n%s" % (self.id, content)
  1012. def minify(self):
  1013. # remove existing sourcemaps, make no sense after re-mini
  1014. content = self.rx_sourceMap.sub('', self.content)
  1015. # comments
  1016. content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
  1017. # space
  1018. content = re.sub(r'\s+', ' ', content)
  1019. content = re.sub(r' *([{}]) *', r'\1', content)
  1020. return self.with_header(content)
  1021. def to_node(self):
  1022. if self.url:
  1023. attr = dict([
  1024. ["type", "text/css"],
  1025. ["rel", "stylesheet"],
  1026. ["href", self.html_url],
  1027. ["media", escape(to_text(self.media)) if self.media else None],
  1028. ['data-asset-bundle', self.bundle.name],
  1029. ['data-asset-version', self.bundle.version],
  1030. ])
  1031. return ("link", attr, None)
  1032. else:
  1033. attr = dict([
  1034. ["type", "text/css"],
  1035. ["media", escape(to_text(self.media)) if self.media else None],
  1036. ['data-asset-bundle', self.bundle.name],
  1037. ['data-asset-version', self.bundle.version],
  1038. ])
  1039. return ("style", attr, self.with_header())
  1040. class PreprocessedCSS(StylesheetAsset):
  1041. rx_import = None
  1042. def __init__(self, *args, **kw):
  1043. super().__init__(*args, **kw)
  1044. self.html_url_args = tuple(self.url.rsplit('/', 1))
  1045. self.html_url_format = '%%s/%s%s/%%s.css' % ('rtl/' if self.direction == 'rtl' else '', self.bundle.name)
  1046. def get_command(self):
  1047. raise NotImplementedError
  1048. def compile(self, source):
  1049. command = self.get_command()
  1050. try:
  1051. compiler = Popen(command, stdin=PIPE, stdout=PIPE,
  1052. stderr=PIPE)
  1053. except Exception:
  1054. raise CompileError("Could not execute command %r" % command[0])
  1055. (out, err) = compiler.communicate(input=source.encode('utf-8'))
  1056. if compiler.returncode:
  1057. cmd_output = misc.ustr(out) + misc.ustr(err)
  1058. if not cmd_output:
  1059. cmd_output = u"Process exited with return code %d\n" % compiler.returncode
  1060. raise CompileError(cmd_output)
  1061. return out.decode('utf8')
  1062. class SassStylesheetAsset(PreprocessedCSS):
  1063. rx_indent = re.compile(r'^( +|\t+)', re.M)
  1064. indent = None
  1065. reindent = ' '
  1066. def minify(self):
  1067. return self.with_header()
  1068. def get_source(self):
  1069. content = textwrap.dedent(self.inline or self._fetch_content())
  1070. def fix_indent(m):
  1071. # Indentation normalization
  1072. ind = m.group()
  1073. if self.indent is None:
  1074. self.indent = ind
  1075. if self.indent == self.reindent:
  1076. # Don't reindent the file if identation is the final one (reindent)
  1077. raise StopIteration()
  1078. return ind.replace(self.indent, self.reindent)
  1079. try:
  1080. content = self.rx_indent.sub(fix_indent, content)
  1081. except StopIteration:
  1082. pass
  1083. return "/*! %s */\n%s" % (self.id, content)
  1084. def get_command(self):
  1085. try:
  1086. sass = misc.find_in_path('sass')
  1087. except IOError:
  1088. sass = 'sass'
  1089. return [sass, '--stdin', '-t', 'compressed', '--unix-newlines', '--compass',
  1090. '-r', 'bootstrap-sass']
  1091. class ScssStylesheetAsset(PreprocessedCSS):
  1092. @property
  1093. def bootstrap_path(self):
  1094. return get_resource_path('web', 'static', 'lib', 'bootstrap', 'scss')
  1095. precision = 8
  1096. output_style = 'expanded'
  1097. def compile(self, source):
  1098. if libsass is None:
  1099. return super().compile(source)
  1100. try:
  1101. profiler.force_hook()
  1102. return libsass.compile(
  1103. string=source,
  1104. include_paths=[
  1105. self.bootstrap_path,
  1106. ],
  1107. output_style=self.output_style,
  1108. precision=self.precision,
  1109. )
  1110. except libsass.CompileError as e:
  1111. raise CompileError(e.args[0])
  1112. def get_command(self):
  1113. try:
  1114. sassc = misc.find_in_path('sassc')
  1115. except IOError:
  1116. sassc = 'sassc'
  1117. return [sassc, '--stdin', '--precision', str(self.precision), '--load-path', self.bootstrap_path, '-t', self.output_style]
  1118. class LessStylesheetAsset(PreprocessedCSS):
  1119. def get_command(self):
  1120. try:
  1121. if os.name == 'nt':
  1122. lessc = misc.find_in_path('lessc.cmd')
  1123. else:
  1124. lessc = misc.find_in_path('lessc')
  1125. except IOError:
  1126. lessc = 'lessc'
  1127. return [lessc, '-', '--no-js', '--no-color']