diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index b0ea24c669b..c7924c732c1 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -15,11 +15,11 @@ re_background_image = re.compile(r"(background-image\s*:\s*url\(\s*['\"]?\s*)([^ class AssetsBundleMultiWebsite(AssetsBundle): - def _get_asset_url_values(self, id, unique, extra, name, sep, type): + def _get_asset_url_values(self, id, unique, extra, name, sep, extension): website_id = self.env.context.get('website_id') website_id_path = website_id and ('%s/' % website_id) or '' extra = website_id_path + extra - res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, sep, type) + res = super(AssetsBundleMultiWebsite, self)._get_asset_url_values(id, unique, extra, name, sep, extension) return res def _get_assets_domain_for_already_processed_css(self, assets): @@ -28,6 +28,12 @@ class AssetsBundleMultiWebsite(AssetsBundle): res = expression.AND([res, current_website.website_domain()]) return res + def get_debug_asset_url(self, extra='', name='%', extension='%'): + website_id = self.env.context.get('website_id') + website_id_path = website_id and ('%s/' % website_id) or '' + extra = website_id_path + extra + return super(AssetsBundleMultiWebsite, self).get_debug_asset_url(extra, name, extension) + class QWeb(models.AbstractModel): """ QWeb object for rendering stuff in the website context """ @@ -41,7 +47,7 @@ class QWeb(models.AbstractModel): 'img': 'src', } - def get_asset_bundle(self, xmlid, files, env=None): + def get_asset_bundle(self, xmlid, files, env=None, css=True, js=True): return AssetsBundleMultiWebsite(xmlid, files, env=env) def _post_processing_att(self, tagName, atts, options): diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index 1111e6b626f..70a58d1b798 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -120,7 +120,7 @@ Here is what happens when a template is rendered by the server with these direct be replaced by a list of stylesheet tags pointing to the css files - the *t-call-assets* directive with the *t-css* attribute set to false will - be replaced by a list of script tags pointing to the js files + link to the non minified bundle file (which uses sourcemaps) - if we are not in *debug=assets* mode @@ -213,15 +213,56 @@ are a few things you can try to solve the issue: the proper files (if that does not work, the files may be cached). -Javascript Module System +Loading Javascript Code ======================== -Once we are able to load our javascript files into the browser, we need to make -sure they are loaded in the correct order. In order to do that, Odoo has defined -a small module system (located in the file *addons/web/static/src/js/boot.js*, -which needs to be loaded first). +Large applications are usually broken up into smaller files, that need to be +connected together. Some file may need to use some part of code defined in +another file. There are two ways of sharing code between files: -The Odoo module system, inspired by AMD, works by defining the function *define* +- use the global scope (the *window* object) to write/read references to some + objects or functions, + +- use a module system that will provide a way for each modules to export or import + values, and will make sure that they are loaded in a proper order. + +While it's possible to work in the global scope, this has a number of issues: + +It is difficult to ensure that implementation details are not exposed by work done in the global scope directly. + +- Dependencies are implicit, leading to fragile and unreliable load ordering. + +- The lack of insight into execution means it's impossible to use various optimisations (e.g. deferred and asynchronous + loading). + +- Module systems help resolve these issues: because modules specify their dependencies the module system can ensure the + necessary order of loading is respected, and because modules can precisely specify their exports it is less likely + that they will leak implementation details. + +For most Odoo code, we want to use a module system. Because of the way assets +work in Odoo (and in particular, the fact that each installed odoo addon can +modify the list of files contained in a bundle), Odoo has to resolve modules +browser side. To do that, Odoo provides a small module system described just +below (see :ref:`reference/javascript_reference/odoo_module`). + +However, Odoo also provides support for native javascript modules (see +:ref:`reference/javascript_reference/js_module`). These modules +will simply be translated by the server into odoo modules. It is encouraged to +write all javascript code as a native module, for a better IDE integration. In +the future, the Odoo module system should be considered an implementation detail, +not the primary way to write javascript code. + +.. note:: + Native javascript modules are the primary way to define javascript code. + +.. _reference/javascript_reference/odoo_module: + +Odoo Module System +=================== + +Odoo has defined a small module system (located in the file +*addons/web/static/src/js/boot.js*, which needs to be loaded first). The Odoo +module system, inspired by AMD, works by defining the function ``define`` on the global odoo object. We then define each javascript module by calling that function. In the Odoo framework, a module is a piece of code that will be executed as soon as possible. It has a name and potentially some dependencies. When its @@ -282,10 +323,10 @@ The *odoo.define* method is given three arguments: - *moduleName*: the name of the javascript module. It should be a unique string. The convention is to have the name of the odoo addon followed by a specific - description. For example, 'web.Widget' describes a module defined in the *web* - addon, which exports a *Widget* class (because the first letter is capitalized) + description. For example, ``web.Widget`` describes a module defined in the *web* + addon, which exports a ``Widget`` class (because the first letter is capitalized) - If the name is not unique, an exception will be thrown and displayed in the + If the name is not unique, an exception will be thrown and` displayed in the console. - *dependencies*: the second argument is optional. If given, it should be a list @@ -293,7 +334,7 @@ The *odoo.define* method is given three arguments: dependencies that are required to be loaded before the module is executed. If the dependencies are not explicitly given here, then the module system will extract them from the function by calling toString on it, then using a regexp - to find all *require* statements. + to find all ``require`` statements. .. code-block:: javascript @@ -369,6 +410,192 @@ Best practices in the short term, but this is actually harder to maintain. +.. _reference/javascript_reference/js_module: + +Native Javascript Modules +========================= + +Most new Odoo javascript code should use the native javascript module system. This +is simpler, and brings the benefits of a better developer experience with a better +integration with IDE. + +There is a very important point to know: since Odoo needs to know which files +should be translated into odoo modules and which files should not be translated, +this is an opt-in system: Odoo will look at the first line of a JS file. If it +contains the string *@odoo-module*, then it will be automatically converted to an +Odoo module. + +For example, let us consider the following module, located in *web/static/src/file_a.js*: + +.. code-block:: javascript + + /** @odoo-module **/ + import { someFunction } from './file_b'; + + export function otherFunction(val) { + return someFunction(val + 3); + } + +Note the comment in the first line: it describes that this file should be converted. +Any file without that comment will be kept as-is (which will most likely be an +error). This file will then be translated into an Odoo module that look like this: + +.. code-block:: javascript + + odoo.define('@web/file_a', function (require) { + 'use strict'; + let __exports = {}; + + const { someFunction } = require("@web/file_b"); + + __exports.otherFunction = function otherFunction(val) { + return someFunction(val + 3); + }; + + return __exports; + )}; + +So, as you can see, the transformation is basically adding ``odoo.define`` on top, +and updating the import/export statements. + +Another important point is that the translated module has an official name: +*@web/file_a*. This is the actual name of the module. Every relative imports +will be converted as well. Every file located in an Odoo addon +*some_addon/static/src/path/to/file.js* will be assigned a name prefixed by the +addon name like this: *@some_addon/path/to/file*. + + +Relative imports work, but only inside an odoo addon. So, imagine that we have +the following file structure: + +.. code-block:: javascript + + addons/ + web/ + static/ + src/ + file_a.js + file_b.js + stock/ + static/ + src/ + file_c.js + +The file ``file_b`` can import ``file_a`` like this: + +.. code-block:: javascript + + /** @odoo-module **/ + import {something} from `./file_a` + +But ``file_c`` need to use the full name: + +.. code-block:: javascript + + /** @odoo-module **/ + import {something} from `@web/file_a` + + +Aliased modules +--------------- + +Because Odoo modules follow a different module naming pattern, a system exists to allow a smoother transition towards +the new system. Currently, if a file is converted to a module (and therefore follow the new naming convention), +other files not yet converted to ES6-like syntax in the project won't be able to require the module. Aliases are +here to map old names with new ones by creating a small proxy function. The module can then be called by its new +*and* old name. + +To add such alias, the comment tag on top of the file should look like this: + +.. code-block:: javascript + + /** @odoo-module alias=web.someName**/ + import { someFunction } from './file_b'; + + export default function otherFunction(val) { + return someFunction(val + 3); + } + +Then the translated module will also create an alias with the requested name: + +.. code-block:: javascript + + odoo.define(`web.someName`, function(require) { + return require('@web/file_a')[Symbol.for("default")]; + }); + +The default behaviour of aliases is to re-export the ``default`` value of the +module they alias. This is because "classic" modules generally export a single +value which would be used directly, roughly matching the semantics of default +exports. +However it is also possible to delegate more directly, and follow the exact +behaviour of the aliased module: + +.. code-block:: javascript + + /** @odoo-module alias=web.someName default=0**/ + import { someFunction } from './file_b'; + + export function otherFunction(val) { + return someFunction(val + 3); + } + +In that case, this will define an alias with exactly the values exported by the +original module: + +.. code-block:: javascript + + odoo.define(`web.someName`, function(require) { + return require('@web/file_a'); + }); + +.. note:: + Only one alias can be defined using this method. If you were to need another one to have, by example, three + names to call the same module, you will have to manually add yourself a proxy. This is not good practice and + should be avoided unless there is no other options. + +Limitations +----------- + +For performance reasons, Odoo does not use a full javascript +parser to transform native modules. There are, therefore, a number of limitations including but not limited to: + +- an ``import`` or ``export`` keyword cannot be preceded by a non space character, +- a multiline comment or string cannot have a line starting by ``import`` or ``export`` + + .. code-block:: javascript + + // supported + import X from "xxx"; + export X; + export default X; + import X from "xxx"; + + /* + * import X ... + */ + + /* + * export X + */ + + + // not supported + + var a= 1;import X from "xxx"; + /* + import X ... + */ + +- Odoo needs a way to determine if a module is described by a path (like ``./views/form_view``) or a name (like + ``web.FormView``). It has to use a heuristic to do just that: if there is a ``/`` in the name, it is considered + a path. This means that Odoo does not really support anymore module names with a ``/``. + +As "classic" modules are not deprecated and there is currently no plan to remove them, you can and should keep using +them if you encounter issues with or are constrained by the limitations of native modules. Both styles can coexist +within the same Odoo addon. + + Class System ============ diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 17fdc299a21..fc963d518ad 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -1,30 +1,32 @@ # -*- coding: utf-8 -*- +from collections import OrderedDict +from datetime import datetime +from subprocess import Popen, PIPE import base64 -import os -import re import hashlib import itertools import json +import logging +import os +import re import textwrap import uuid + +import psycopg2 try: import sass as libsass except ImportError: # If the `sass` python library isn't found, we fallback on the # `sassc` executable in the path. libsass = None -from datetime import datetime -from subprocess import Popen, PIPE -from collections import OrderedDict -from odoo import fields, tools, SUPERUSER_ID -from odoo.tools.pycompat import to_text + +from .qweb import escape +from odoo import SUPERUSER_ID from odoo.http import request from odoo.modules.module import get_resource_path -from .qweb import escape -import psycopg2 -from odoo.tools import func, misc +from odoo.tools import func, misc, transpile_javascript, is_odoo_module, SourceMapGenerator +from odoo.tools.pycompat import to_text -import logging _logger = logging.getLogger(__name__) @@ -104,27 +106,33 @@ class AssetsBundle(object): TRACKED_BUNDLES = ['web.assets_common', 'web.assets_backend'] - def __init__(self, name, files, env=None): + def __init__(self, name, files, env=None, css=True, js=True): + """ + :param name: bundle name + :param files: files to be added to the bundle + :param css: if css is True, the stylesheets files are added to the bundle + :param js: if js is True, the javascript files are added to the bundle + """ self.name = name self.env = request.env if env is None else env self.javascripts = [] self.stylesheets = [] self.css_errors = [] - self._checksum = None self.files = files self.user_direction = self.env['res.lang']._lang_get( self.env.context.get('lang') or self.env.user.lang ).direction 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'], direction=self.user_direction)) - elif f['atype'] == 'text/scss': - self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) - elif f['atype'] == 'text/less': - self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) - elif f['atype'] == 'text/css': - self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) - elif f['atype'] == 'text/javascript': + if css: + if f['atype'] == 'text/sass': + self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + elif f['atype'] == 'text/scss': + self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + elif f['atype'] == 'text/less': + self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + elif f['atype'] == 'text/css': + self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction)) + if js and f['atype'] == 'text/javascript': self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])) def to_node(self, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False): @@ -145,9 +153,17 @@ class AssetsBundle(object): for style in self.stylesheets: response.append(style.to_node()) - if js: - for jscript in self.javascripts: - response.append(jscript.to_node()) + if js and self.javascripts: + self.js(is_minified=False) + attr = OrderedDict([ + ["async", "async" if async_load else None], + ["defer", "defer" if defer_load or lazy_load else None], + ["type", "text/javascript"], + ["data-src" if lazy_load else "src", self.get_debug_asset_url(name=self.name, extension=".js")], + ['data-asset-xmlid', self.name], + ['data-asset-version', self.version], + ]) + response.append(("script", attr, None)) else: if css and self.stylesheets: css_attachments = self.css() or [] @@ -163,12 +179,13 @@ class AssetsBundle(object): if self.css_errors: msg = '\n'.join(self.css_errors) response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node()) + if js and self.javascripts: attr = OrderedDict([ ["async", "async" if async_load else None], ["defer", "defer" if defer_load or lazy_load else None], ["type", "text/javascript"], - ["data-src" if lazy_load else "src", self.js().url], + ["data-src" if lazy_load else "src", self.js(is_minified=True)[0].url], ['data-asset-xmlid', self.name], ['data-asset-version', self.version], ]) @@ -198,28 +215,34 @@ class AssetsBundle(object): return hashlib.sha512(check.encode('utf-8')).hexdigest()[:64] def _get_asset_template_url(self): - return "/web/content/{id}-{unique}/{extra}{name}{sep}{type}" + return "/web/assets/{id}-{unique}/{extra}{name}{sep}{extension}" - def _get_asset_url_values(self, id, unique, extra, name, sep, type): # extra can contain direction or/and website + def _get_asset_url_values(self, id, unique, extra, name, sep, extension): # extra can contain direction or/and website return { 'id': id, 'unique': unique, 'extra': extra, 'name': name, 'sep': sep, - 'type': type, + 'extension': extension, } - def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", type='%'): + def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", extension='%'): return self._get_asset_template_url().format( - **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, type=type) + **self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, extension=extension) ) - def clean_attachments(self, type): + def _get_debug_asset_template_url(self): + return "/web/assets/{id}-{unique}/{extra}{name}{sep}{extension}" + + def get_debug_asset_url(self, extra='', name='%', extension='%'): + return f"/web/assets/debug/{extra}{name}{extension}" + + def clean_attachments(self, extension): """ Takes care of deleting any outdated ir.attachment records associated to a bundle before saving a fresh one. - When `type` is js we need to check that we are deleting a different version (and not *any* + When `extension` 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 @@ -227,11 +250,12 @@ class AssetsBundle(object): """ ira = self.env['ir.attachment'] url = self.get_asset_url( - extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if extension == 'css' and self.user_direction == 'rtl' else ''), name=self.name, sep='', - type='.%s' % type + extension='.%s' % extension ) + domain = [ ('url', '=like', url), '!', ('url', '=like', self.get_asset_url(unique=self.version)) @@ -246,21 +270,28 @@ class AssetsBundle(object): return True - def get_attachments(self, type, ignore_version=False): + def get_attachments(self, extension, ignore_version=False): """ 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. + + :param extension: file extension (js, min.js, css) + :param ignore_version: if ignore_version, the url contains a version => web/assets/%-%/name.extension + (the second '%' corresponds to the version), + else: the url contains a version equal to that of the self.version + => web/assets/%-self.version/name.extension. """ unique = "%" if ignore_version else self.version + url_pattern = self.get_asset_url( unique=unique, - extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if extension == 'css' and self.user_direction == 'rtl' else ''), name=self.name, sep='', - type='.%s' % type + extension='.%s' % extension ) self.env.cr.execute(""" SELECT max(id) @@ -270,19 +301,32 @@ class AssetsBundle(object): GROUP BY name ORDER BY name """, [SUPERUSER_ID, 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): - assert type in ('js', 'css') + def save_attachment(self, extension, content): + """Record the given bundle in an ir.attachment and delete + all other ir.attachments referring to this bundle (with the same name and extension). + + :param extension: extension of the bundle to be recorded + :param content: bundle content to be recorded + + :return the ir.attachment records for a given bundle. + """ + assert extension in ('js', 'min.js', 'js.map', 'css') ira = self.env['ir.attachment'] # Set user direction in name to store two bundles # 1 for ltr and 1 for rtl, this will help during cleaning of assets bundle # and allow to only clear the current direction bundle # (this applies to css bundles only) - fname = '%s.%s' % (self.name, type) - mimetype = 'application/javascript' if type == 'js' else 'text/css' + fname = '%s.%s' % (self.name, extension) + mimetype = ( + 'text/css' if extension == 'css' else + 'application/json' if extension == 'js.map' else + 'application/javascript' + ) values = { 'name': fname, 'mimetype': mimetype, @@ -293,14 +337,13 @@ class AssetsBundle(object): 'raw': content.encode('utf8'), } attachment = ira.with_user(SUPERUSER_ID).create(values) - url = self.get_asset_url( id=attachment.id, unique=self.version, - extra='%s' % ('rtl/' if type == 'css' and self.user_direction == 'rtl' else ''), + extra='%s' % ('rtl/' if extension == 'css' and self.user_direction == 'rtl' else ''), name=fname, sep='', # included in fname - type='' + extension='' ) values = { 'url': url, @@ -310,7 +353,7 @@ class AssetsBundle(object): if self.env.context.get('commit_assetsbundle') is True: self.env.cr.commit() - self.clean_attachments(type) + self.clean_attachments(extension) # For end-user assets (common and backend), send a message on the bus # to invite the user to refresh their browser @@ -322,13 +365,72 @@ class AssetsBundle(object): return attachment - def js(self): - attachments = self.get_attachments('js') + def js(self, is_minified=True): + extension = 'min.js' if is_minified else 'js' + attachments = self.get_attachments(extension) + if not attachments: - content = ';\n'.join(asset.minify() for asset in self.javascripts) - return self.save_attachment('js', content) + if is_minified: + content = ';\n'.join(asset.minify() for asset in self.javascripts) + return self.save_attachment(extension, content) + else: + return self.js_with_sourcemap() + return attachments[0] + def js_with_sourcemap(self): + """Create/modify the ir.attachment representing the not-minified content of the bundleJS + and the ir.attachment representing the linked sourcemap. + To avoid the loss of break points in the development tool when the bundleJS + has to be rebuilt, the bundleJS url must not change so we will not include + 'self.version' in the ir.attachment url. + If any ir.attachment exists for the bundleJS, we create a new one (bundleJS and sourcemap), + else, we modify the 'raw' in the existing ir.attachments. + + :return ir.attachment representing the un-minified content of the bundleJS + """ + sourcemap_attachment = self.get_attachments('js.map') \ + or self.save_attachment('js.map', '') + + generator = SourceMapGenerator( + source_root="/".join( + [".." for i in range(0, len(self.get_debug_asset_url(name=self.name).split("/")) - 2)] + ) + "/", + ) + + content_bundle_list = [] + content_line_count = 0 + line_header = 6 # number of lines added by with_header() + for asset in self.javascripts: + if asset.is_transpiled: + # '+ 3' corresponds to the 3 lines added at the beginning of the file during transpilation. + generator.add_source( + asset.url, asset._content, content_line_count, start_offset=line_header + 3) + else: + generator.add_source( + asset.url, asset.content, content_line_count, start_offset=line_header) + + content_bundle_list.append(asset.with_header(asset.content, minimal=False)) + content_line_count += len(asset.content.split("\n")) + line_header + + content_bundle = ';\n'.join(content_bundle_list) + "\n//# sourceMappingURL=" + sourcemap_attachment.url + + js_attachment = self.get_attachments('js') + if js_attachment: + js_attachment.write({ + "raw": content_bundle.encode('utf8'), + }) + else: + js_attachment = self.save_attachment('js', content_bundle) + + generator._file = js_attachment.url + sourcemap_attachment.write({ + # Store with XSSI-prevention prefix + "raw": b")]}'\n" + json.dumps(generator.to_json()).encode('utf8'), + }) + + return js_attachment + def css(self): attachments = self.get_attachments('css') if not attachments: @@ -693,10 +795,25 @@ class WebAsset(object): def with_header(self, content=None): if content is None: content = self.content - return '\n/* %s */\n%s' % (self.name, content) + return f'\n/* {self.name} */\n{content}' class JavascriptAsset(WebAsset): + + def __init__(self, bundle, inline=None, url=None, filename=None): + super().__init__(bundle, inline, url, filename) + self.is_transpiled = is_odoo_module(super().content) + self._converted_content = None + + @property + def content(self): + content = super().content + if self.is_transpiled: + if not self._converted_content: + self._converted_content = transpile_javascript(self.url, content) + return self._converted_content + return content + def minify(self): return self.with_header(rjsmin(self.content)) @@ -722,6 +839,30 @@ class JavascriptAsset(WebAsset): ['data-asset-version', self.bundle.version], ]), self.with_header()) + def with_header(self, content=None, minimal=True): + if minimal: + return super().with_header(content) + + # format the header like + # /************************** + # * Filepath: * + # * Bundle: * + # * Lines: 42 * + # **************************/ + lines = [ + f"Filepath: {self.url}", + f"Bundle: {self.bundle.name}", + f"Lines: {len(content.splitlines())}", + ] + length = max(map(len, lines)) + return "\n".join([ + "", + "/" + "*" * (length + 5), + *(f"* {line:<{length}} *" for line in lines), + "*" * (length + 5) + "/", + content, + ]) + class StylesheetAsset(WebAsset): rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U) diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py index b02b9e5938d..c69be53cc39 100644 --- a/odoo/addons/base/models/ir_qweb.py +++ b/odoo/addons/base/models/ir_qweb.py @@ -281,8 +281,8 @@ class IrQWeb(models.AbstractModel, QWeb): # method called by computing code - def get_asset_bundle(self, xmlid, files, env=None): - return AssetsBundle(xmlid, files, env=env) + def get_asset_bundle(self, xmlid, files, env=None, css=True, js=True): + return AssetsBundle(xmlid, files, env=env, css=css, js=js) @tools.conditional( # in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear @@ -292,7 +292,7 @@ class IrQWeb(models.AbstractModel, QWeb): ) def _get_asset_nodes(self, xmlid, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, values=None): files, remains = self._get_asset_content(xmlid, options) - asset = self.get_asset_bundle(xmlid, files, env=self.env) + asset = self.get_asset_bundle(xmlid, files, env=self.env, css=css, js=js) remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] != 'link')] return remains + asset.to_node(css=css, js=js, debug=debug, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load) diff --git a/odoo/addons/test_assetsbundle/tests/__init__.py b/odoo/addons/test_assetsbundle/tests/__init__.py index a7408928296..32b69a7f41d 100644 --- a/odoo/addons/test_assetsbundle/tests/__init__.py +++ b/odoo/addons/test_assetsbundle/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_assetsbundle +from . import test_js_transpiler diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index b2df9118730..b2f462d25c6 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -42,12 +42,13 @@ class TestJavascriptAssetsBundle(FileTouchable): files, remains = env['ir.qweb']._get_asset_content(xmlid, env.context) return AssetsBundle(xmlid, files, env=env) - def _any_ira_for_bundle(self, type, lang=None): + def _any_ira_for_bundle(self, extension, lang=None): """ Returns all ir.attachments associated to a bundle, regardless of the verion. """ user_direction = self.env['res.lang']._lang_get(lang or self.env.user.lang).direction - bundle = self.jsbundle_xmlid if type == 'js' else self.cssbundle_xmlid - url = '/web/content/%-%/{0}{1}.{2}'.format(('rtl/' if type == 'css' and user_direction == 'rtl' else ''), bundle, type) + bundle = self.jsbundle_xmlid if extension in ['js', 'min.js'] else self.cssbundle_xmlid + rtl = 'rtl/' if extension == 'css' and user_direction == 'rtl' else '' + url = f'/web/assets/%-%/{rtl}{bundle}.{extension}' domain = [('url', '=like', url)] return self.env['ir.attachment'].search(domain) @@ -60,20 +61,39 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_01_generation(self): """ Checks that a bundle creates an ir.attachment record when its `js` method is called - for the first time. + for the first time and this ir.attachment is different depending on `is_minified` param. """ self.bundle = self._get_asset(self.jsbundle_xmlid, env=self.env) - # there shouldn't be any attachment associated to this bundle - self.assertEqual(len(self._any_ira_for_bundle('js')), 0) - self.assertEqual(len(self.bundle.get_attachments('js')), 0) + # there shouldn't be any minified attachment associated to this bundle + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 0, + "there shouldn't be any minified attachment associated to this bundle") + self.assertEqual(len(self.bundle.get_attachments('min.js')), 0, + "there shouldn't be any minified attachment associated to this bundle") # trigger the first generation and, thus, the first save in database self.bundle.js() - # there should be one attachment associated to this bundle - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) - self.assertEqual(len(self.bundle.get_attachments('js')), 1) + # there should be one minified attachment associated to this bundle + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") + self.assertEqual(len(self.bundle.get_attachments('min.js')), 1, + "there should be one minified attachment associated to this bundle") + + # there shouldn't be any non-minified attachment associated to this bundle + self.assertEqual(len(self._any_ira_for_bundle('js')), 0, + "there shouldn't be any non-minified attachment associated to this bundle") + self.assertEqual(len(self.bundle.get_attachments('js')), 0, + "there shouldn't be any non-minified attachment associated to this bundle") + + # trigger the first generation and, thus, the first save in database for the non-minified version. + self.bundle.js(is_minified=False) + + # there should be one non-minified attachment associated to this bundle + self.assertEqual(len(self._any_ira_for_bundle('js')), 1, + "there should be one non-minified attachment associated to this bundle") + self.assertEqual(len(self.bundle.get_attachments('js')), 1, + "there should be one non-minified attachment associated to this bundle") def test_02_access(self): """ Checks that the bundle's cache is working, i.e. that the bundle creates only one @@ -82,23 +102,27 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle0 = self._get_asset(self.jsbundle_xmlid) bundle0.js() - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") version0 = bundle0.version - ira0 = self._any_ira_for_bundle('js') + ira0 = self._any_ira_for_bundle('min.js') date0 = ira0.create_date bundle1 = self._get_asset(self.jsbundle_xmlid) bundle1.js() - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") version1 = bundle1.version - ira1 = self._any_ira_for_bundle('js') + ira1 = self._any_ira_for_bundle('min.js') date1 = ira1.create_date - self.assertEqual(version0, version1) - self.assertEqual(date0, date1) + self.assertEqual(version0, version1, + "the version should not be changed because the bundle hasn't changed") + self.assertEqual(date0, date1, + "the date of creation of the ir.attachment should not change because the bundle is unchanged") def test_03_date_invalidation(self): """ Checks that a bundle is invalidated when one of its assets' modification date is changed. @@ -115,11 +139,14 @@ class TestJavascriptAssetsBundle(FileTouchable): bundle1.js() last_modified1 = bundle1.last_modified version1 = bundle1.version - self.assertNotEqual(last_modified0, last_modified1) - self.assertNotEqual(version0, version1) + self.assertNotEqual(last_modified0, last_modified1, + "the creation date of the ir.attachment should change because the bundle has changed.") + self.assertNotEqual(version0, version1, + "the version must should because the bundle has changed.") # check if the previous attachment is correctly cleaned - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") def test_04_content_invalidation(self): """ Checks that a bundle is invalidated when its content is modified by adding a file to @@ -130,7 +157,8 @@ class TestJavascriptAssetsBundle(FileTouchable): files0 = bundle0.files version0 = bundle0.version - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") view_arch = """ @@ -152,23 +180,70 @@ class TestJavascriptAssetsBundle(FileTouchable): files1 = bundle1.files version1 = bundle1.version - self.assertNotEqual(files0, files1) - self.assertNotEqual(version0, version1) + self.assertNotEqual(files0, files1, + "the list of files should be different because a file has been added to the bundle") + self.assertNotEqual(version0, version1, + "the version should be different because a file has been added to the bundle") # check if the previous attachment are correctly cleaned - self.assertEqual(len(self._any_ira_for_bundle('js')), 1) + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified attachment associated to this bundle") - def test_05_debug(self): - """ Checks that a bundle rendered in debug mode outputs non-minified assets. + def test_05_normal_mode(self): + """ Checks that a bundle rendered in normal mode outputs minified assets + and create a minified ir.attachment. + """ + debug_bundle = self._get_asset(self.jsbundle_xmlid) + nodes = debug_bundle.to_node() + content = self._node_to_list(nodes) + # there should be a minified file + self.assertEqual(content[3].count('test_assetsbundle.bundle1.min.js'), 1) + + # there should be one minified assets created in normal mode + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified assets created in normal mode") + + # there shouldn't be any non-minified assets created in normal mode + self.assertEqual(len(self._any_ira_for_bundle('js')), 0, + "there shouldn't be any non-minified assets created in normal mode") + + def test_06_debug(self): + """ Checks that a bundle rendered in debug 1 mode outputs non-minified assets + and create an non-minified ir.attachment. + """ + debug_bundle = self._get_asset(self.jsbundle_xmlid) + nodes = debug_bundle.to_node(debug='1') + content = self._node_to_list(nodes) + # there should be a minified file + self.assertEqual(content[3].count('test_assetsbundle.bundle1.min.js'), 1, + "there should be one minified assets created in debug mode") + + # there should be one minified assets created in debug mode + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 1, + "there should be one minified assets created in debug mode") + + # there shouldn't be any non-minified assets created in debug mode + self.assertEqual(len(self._any_ira_for_bundle('js')), 0, + "there shouldn't be any non-minified assets created in debug mode") + + def test_07_debug_assets(self): + """ Checks that a bundle rendered in debug assets mode outputs non-minified assets + and create an non-minified ir.attachment at the . """ debug_bundle = self._get_asset(self.jsbundle_xmlid) nodes = debug_bundle.to_node(debug='assets') content = self._node_to_list(nodes) - # find back one of the original asset file - self.assertIn('/test_assetsbundle/static/src/js/test_jsfile1.js', content) + # there should be a non-minified file (not .min.js) + self.assertEqual(content[3].count('test_assetsbundle.bundle1.js'), 1, + "there should be one non-minified assets created in debug assets mode") - # there shouldn't be any assets created in debug mode - self.assertEqual(len(self._any_ira_for_bundle('js')), 0) + # there shouldn't be any minified assets created in debug mode + self.assertEqual(len(self._any_ira_for_bundle('min.js')), 0, + "there shouldn't be any minified assets created in debug assets mode") + + # there should be one non-minified assets created in debug mode + self.assertEqual(len(self._any_ira_for_bundle('js')), 1, + "there should be one non-minified assets without a version in its url created in debug assets mode") def test_08_css_generation3(self): # self.cssbundle_xlmid contains 3 rules @@ -343,7 +418,7 @@ class TestJavascriptAssetsBundle(FileTouchable): # Check two bundles are available, one for ltr and one for rtl css_bundles = self.env['ir.attachment'].search([ - ('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) + ('url', '=like', '/web/assets/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) ]) self.assertEqual(len(css_bundles), 2) @@ -389,7 +464,7 @@ class TestJavascriptAssetsBundle(FileTouchable): # check if the previous attachment is correctly cleaned css_bundles = self.env['ir.attachment'].search([ - ('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) + ('url', '=like', '/web/assets/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) ]) self.assertEqual(len(css_bundles), 2) @@ -409,7 +484,7 @@ class TestJavascriptAssetsBundle(FileTouchable): rtl_version0 = rtl_bundle0.version css_bundles = self.env['ir.attachment'].search([ - ('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) + ('url', '=like', '/web/assets/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) ]) self.assertEqual(len(css_bundles), 2) @@ -451,7 +526,7 @@ class TestJavascriptAssetsBundle(FileTouchable): # check if the previous attachment are correctly cleaned css_bundles = self.env['ir.attachment'].search([ - ('url', '=like', '/web/content/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) + ('url', '=like', '/web/assets/%-%/{0}%.{1}'.format(self.cssbundle_xmlid, 'css')) ]) self.assertEqual(len(css_bundles), 2) @@ -473,18 +548,20 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_20_exteral_lib_assets(self): html = self.env['ir.ui.view']._render_template('test_assetsbundle.template2') - attachments = self.env['ir.attachment'].search([('url', '=like', '/web/content/%-%/test_assetsbundle.bundle4.%')]) + attachments = self.env['ir.attachment'].search([('url', '=like', '/web/assets/%-%/test_assetsbundle.bundle4.%')]) self.assertEqual(len(attachments), 2) - asset_data = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0] - asset_xmlid = asset_data.attrib.get('data-asset-xmlid') - asset_version = asset_data.attrib.get('data-asset-version') + asset_data_css = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0] + asset_data_js = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[1] format_data = { "js": attachments[0].url, "css": attachments[1].url, - "asset_xmlid": asset_xmlid, - "asset_version": asset_version, + "asset_xmlid_css": asset_data_css.attrib.get('data-asset-xmlid'), + "asset_version_css": asset_data_css.attrib.get('data-asset-version'), + "asset_xmlid_js": asset_data_js.attrib.get('data-asset-xmlid'), + "asset_version_js": asset_data_js.attrib.get('data-asset-version'), + } self.assertEqual(html.strip(), (""" @@ -492,11 +569,11 @@ class TestJavascriptAssetsBundle(FileTouchable): - + - + @@ -504,31 +581,30 @@ class TestJavascriptAssetsBundle(FileTouchable): def test_21_exteral_lib_assets_debug_mode(self): html = self.env['ir.ui.view']._render_template('test_assetsbundle.template2', {"debug": "assets"}) - attachments = self.env['ir.attachment'].search([('url', '=like', '/web/content/%-%/test_assetsbundle.bundle4.%')]) - self.assertEqual(len(attachments), 0) + attachments = self.env['ir.attachment'].search([('url', '=like', '%/test_assetsbundle.bundle4.js')]) + self.assertEqual(len(attachments), 1) - asset_data = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0] - asset_xmlid = asset_data.attrib.get('data-asset-xmlid') - asset_version = asset_data.attrib.get('data-asset-version') + asset_data_css = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[0] + asset_data_js = etree.HTML(html).xpath('//*[@data-asset-xmlid]')[2] format_data = { - "asset_xmlid": asset_xmlid, - "asset_version": asset_version, + "asset_xmlid_css": asset_data_css.attrib.get('data-asset-xmlid'), + "asset_version_css": asset_data_css.attrib.get('data-asset-version'), + "asset_xmlid_js": asset_data_js.attrib.get('data-asset-xmlid'), + "asset_version_js": asset_data_js.attrib.get('data-asset-version'), + "js": '/web/assets/debug/test_assetsbundle.bundle4.js', } - self.assertEqual(html.strip(), (""" - - + + - - - + diff --git a/odoo/addons/test_assetsbundle/tests/test_js_transpiler.py b/odoo/addons/test_assetsbundle/tests/test_js_transpiler.py new file mode 100644 index 00000000000..80e0882f92a --- /dev/null +++ b/odoo/addons/test_assetsbundle/tests/test_js_transpiler.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.tools import transpile_javascript + + +@tagged('post_install', '-at_install') +class TestJsTranspiler(TransactionCase): + maxDiff = None + + def test_01_alias(self): + input_content = """/** @odoo-module alias=test_assetsbundle.Alias **/""" + result = transpile_javascript("/test_assetsbundle/static/src/alias.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/alias', function (require) { +'use strict'; +let __exports = {}; +/** @odoo-module alias=test_assetsbundle.Alias **/ +return __exports; +}); + +odoo.define(`test_assetsbundle.Alias`, function(require) { + return require('@test_assetsbundle/alias')[Symbol.for("default")]; + }); +""" + + self.assertEqual(result, expected_result) + + def test_02_default(self): + input_content = """/** @odoo-module alias=test_assetsbundle.Alias default=False **/""" + result = transpile_javascript("/test_assetsbundle/static/src/alias.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/alias', function (require) { +'use strict'; +let __exports = {}; +/** @odoo-module alias=test_assetsbundle.Alias default=False **/ +return __exports; +}); + +odoo.define(`test_assetsbundle.Alias`, function(require) { + return require('@test_assetsbundle/alias'); + }); +""" + + self.assertEqual(result, expected_result) + + input_content = """/** @odoo-module alias=test_assetsbundle.Alias default=0 **/""" + result = transpile_javascript("/test_assetsbundle/static/src/alias.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/alias', function (require) { +'use strict'; +let __exports = {}; +/** @odoo-module alias=test_assetsbundle.Alias default=0 **/ +return __exports; +}); + +odoo.define(`test_assetsbundle.Alias`, function(require) { + return require('@test_assetsbundle/alias'); + }); +""" + + self.assertEqual(result, expected_result) + + input_content = """/** @odoo-module alias=test_assetsbundle.Alias default=false **/""" + result = transpile_javascript("/test_assetsbundle/static/src/alias.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/alias', function (require) { +'use strict'; +let __exports = {}; +/** @odoo-module alias=test_assetsbundle.Alias default=false **/ +return __exports; +}); + +odoo.define(`test_assetsbundle.Alias`, function(require) { + return require('@test_assetsbundle/alias'); + }); +""" + + self.assertEqual(result, expected_result) + + def test_03_classes(self): + input_content = """export default class Nice {} + +class Vehicule {} + +export class Car extends Vehicule {} + +export class Boat extends Vehicule {} + +export const Ferrari = class Ferrari extends Car {}; +""" + result = transpile_javascript("/test_assetsbundle/static/src/classes.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/classes', function (require) { +'use strict'; +let __exports = {}; +const Nice = __exports[Symbol.for("default")] = class Nice {} + +class Vehicule {} + +const Car = __exports.Car = class Car extends Vehicule {} + +const Boat = __exports.Boat = class Boat extends Vehicule {} + +const Ferrari = __exports.Ferrari = class Ferrari extends Car {}; + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + def test_04_comments(self): + input_content = """/** + * This is a comment + */ + +/** + * This isn't a string + */ +export class Test { + // This is a comment in a class +} + +/* cool comment */ const a = 5; /* another cool comment */ + +const b = 5; // hello + +// another one + +const y = "this is a /* nice string and should be kept */"; +const z = "this is a /* nice string and should be kept"; +export const x = "this is a // nice string and should be kept"; +const w = "this is a */ nice string and should be kept"; + +// This isn't a string +/* + comments + */ +const aaa = "keep!"; +/* + comments + */ +""" + result = transpile_javascript("/test_assetsbundle/static/src/comments.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/comments', function (require) { +'use strict'; +let __exports = {}; +/** + * This is a comment + */ + +/** + * This isn't a string + */ +const Test = __exports.Test = class Test { + // This is a comment in a class +} + +/* cool comment */ const a = 5; /* another cool comment */ + +const b = 5; // hello + +// another one + +const y = "this is a /* nice string and should be kept */"; +const z = "this is a /* nice string and should be kept"; +const x = __exports.x = "this is a // nice string and should be kept"; +const w = "this is a */ nice string and should be kept"; + +// This isn't a string +/* + comments + */ +const aaa = "keep!"; +/* + comments + */ + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + def test_05_functions(self): + input_content = """export function sayHello() { + console.log("Hello"); +} + +export function sayHelloWorld() { + console.log("Hello world"); +} + +export async function sayAsyncHello() { + console.log("Hello Async"); +} + + +export default function sayHelloDefault() { + console.log("Hello Default"); +} +""" + result = transpile_javascript("/test_assetsbundle/static/src/functions.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/functions', function (require) { +'use strict'; +let __exports = {}; +const sayHello = __exports.sayHello = function sayHello() { + console.log("Hello"); +} + +const sayHelloWorld = __exports.sayHelloWorld = function sayHelloWorld() { + console.log("Hello world"); +} + +const sayAsyncHello = __exports.sayAsyncHello = async function sayAsyncHello() { + console.log("Hello Async"); +} + + +const sayHelloDefault = __exports[Symbol.for("default")] = function sayHelloDefault() { + console.log("Hello Default"); +} + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + def test_06_import(self): + input_content = """/** + * import { Dialog, Notification } from "../src/Dialog"; + */ +import { Line1 } from "../src/Dialog"; +import { Line2, Notification } from "../src/Dialog"; +import { Line3, Notification } from "Dialog"; +import { Line4, Notification } from "@tests/Dialog"; +import { Line5, Notification } from "./Dialog"; +import { Line6, Notification } from '../src/Dialog' +import Line7 from "../src/Dialog"; +import Line8 from '../src/Dialog'; + +import Line9 from "test.Dialog"; +import { Line10, Notification } from 'test.Dialog2'; + +import * as Line11 from "test.Dialog"; +import "test.Dialog"; + +import Line12 from "@test.Dialog"; //HELLO +import {Line13} from "@test.Dialog" //HELLO + + +const test = `import { Line13, Notification } from "../src/Dialog";` +""" + result = transpile_javascript("/test_assetsbundle/static/src/import.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/import', function (require) { +'use strict'; +let __exports = {}; +/** + * import { Dialog, Notification } from "../src/Dialog"; + */ +const { Line1 } = require("@test_assetsbundle/Dialog"); +const { Line2, Notification } = require("@test_assetsbundle/Dialog"); +const { Line3, Notification } = require("Dialog"); +const { Line4, Notification } = require("@tests/Dialog"); +const { Line5, Notification } = require("@test_assetsbundle/Dialog"); +const { Line6, Notification } = require("@test_assetsbundle/Dialog") +const Line7 = require("@test_assetsbundle/Dialog")[Symbol.for("default")]; +const Line8 = require("@test_assetsbundle/Dialog")[Symbol.for("default")]; + +const Line9 = require("test.Dialog"); +const { Line10, Notification } = require('test.Dialog2'); + +const Line11 = require("test.Dialog"); +require("test.Dialog"); + +const Line12 = require("@test.Dialog")[Symbol.for("default")]; //HELLO +const {Line13} = require("@test.Dialog") //HELLO + + +const test = `import { Line13, Notification } from "../src/Dialog";` + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + def test_07_index(self): + input_content = """export const a = 5; + +import * as b from "@tests/dir"; + +import c from "@tests/dir/index/"; + +import d from "@tests";""" + result = transpile_javascript("/test_assetsbundle/static/src/index.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle', function (require) { +'use strict'; +let __exports = {}; +const a = __exports.a = 5; + +const b = require("@tests/dir"); + +const c = require("@tests/dir")[Symbol.for("default")]; + +const d = require("@tests")[Symbol.for("default")]; +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + def test_08_list(self): + input_content = """export {a, b}; + +export {a as aa, b, c as cc}; + +export {c, d} from "@tests/Dialog"; +export {e} from "../src/Dialog"; + +export {c as cc, d, e as ee} from "@tests/Dialog"; + +export * from "@tests/Dialog"; +""" + result = transpile_javascript("/test_assetsbundle/static/src/list.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/list', function (require) { +'use strict'; +let __exports = {}; +Object.assign(__exports, {a, b}); + +Object.assign(__exports, {aa: a, b, cc: c}); + +{const {c, d} = require("@tests/Dialog");Object.assign(__exports, {c, d})}; +{const {e} = require("@test_assetsbundle/Dialog");Object.assign(__exports, {e})}; + +{const {c, d, e} = require("@tests/Dialog");Object.assign(__exports, {cc: c, d, ee: e})}; + +Object.assign(__exports, require("@tests/Dialog")); + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) + + + def test_09_variables(self): + input_content = """export const v = 5; + +const a = 12; +const b = 15; + +export { a, b }; + +export default 100; + +export default a; +""" + result = transpile_javascript("/test_assetsbundle/static/src/variables.js", input_content) + + expected_result = """odoo.define('@test_assetsbundle/variables', function (require) { +'use strict'; +let __exports = {}; +const v = __exports.v = 5; + +const a = 12; +const b = 15; + +Object.assign(__exports, { a, b }); + +__exports[Symbol.for("default")] = 100; + +__exports[Symbol.for("default")] = a; + +return __exports; +}); +""" + + self.assertEqual(result, expected_result) diff --git a/odoo/addons/test_lint/tests/test_ecmascript.py b/odoo/addons/test_lint/tests/test_ecmascript.py index 9c0374139af..1a706175ebc 100644 --- a/odoo/addons/test_lint/tests/test_ecmascript.py +++ b/odoo/addons/test_lint/tests/test_ecmascript.py @@ -33,13 +33,16 @@ class TestECMAScriptVersion(lint_case.LintCase): files_to_check = [ p for p in self.iter_module_files('*.js') - if not 'static/test' in p - if not 'static/src/tests' in p + if 'static/test' not in p + if 'static/src/tests' not in p + if 'static/lib/qweb/qweb.js' not in p # because this file is not bundled at all + if 'py.js/lib/py.js' not in p # because it is not "strict" compliant + if 'static/lib/epos-2.12.0.js' not in p # same if not black_re.search(p) ] _logger.info('Testing %s js files', len(files_to_check)) - cmd = [es_check, MAX_ES_VERSION] + files_to_check + cmd = [es_check, MAX_ES_VERSION, '--module'] + files_to_check process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = process.communicate() self.assertEqual(process.returncode, 0, msg=out.decode()) diff --git a/odoo/http.py b/odoo/http.py index 312a455834b..d4475b2936c 100644 --- a/odoo/http.py +++ b/odoo/http.py @@ -1251,7 +1251,11 @@ class DisableCacheMiddleware(object): req = werkzeug.wrappers.Request(environ) root.setup_session(req) if req.session and req.session.debug and not 'wkhtmltopdf' in req.headers.get('User-Agent'): - new_headers = [('Cache-Control', 'no-cache')] + + if "assets" in req.session.debug and ".js" in req.base_url: + new_headers = [('Cache-Control', 'no-store')] + else: + new_headers = [('Cache-Control', 'no-cache')] for k, v in headers: if k.lower() != 'cache-control': diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index 546ec7f0431..c80d35c8950 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -21,3 +21,5 @@ from .date_utils import * from .convert import * from .template_inheritance import * from . import osutil +from .js_transpiler import transpile_javascript, is_odoo_module +from .js_sourcemap import SourceMapGenerator diff --git a/odoo/tools/js_transpiler.py b/odoo/tools/js_transpiler.py new file mode 100644 index 00000000000..6630de704e2 --- /dev/null +++ b/odoo/tools/js_transpiler.py @@ -0,0 +1,615 @@ +""" +This code is what let us use ES6-style modules in odoo. +Classic Odoo modules are composed of a top-level :samp:`odoo.define({name},{body_function})` call. +This processor will take files starting with an `@odoo-module` annotation (in a comment) and convert them to classic modules. +If any file has the /** odoo-module */ on top of it, it will get processed by this class. +It performs several operations to get from ES6 syntax to the usual odoo one with minimal changes. +This is done on the fly, this not a pre-processing tool. + +Caveat: This is done without a full parser, only using regex. One can only expect to cover as much edge cases +as possible with reasonable limitations. Also, this only changes imports and exports, so all JS features used in +the original source need to be supported by the browsers. +""" + +import re +from functools import partial + + +def transpile_javascript(url, content): + """ + Transpile the code from native JS modules to custom odoo modules. + + :param content: The original source code + :param url: The url of the file in the project + :return: The transpiled source code + """ + module_path = url_to_module_path(url) + legacy_odoo_define = get_aliased_odoo_define_content(module_path, content) + + # The order of the operations does sometimes matter. + steps = [ + convert_legacy_default_import, + convert_basic_import, + convert_default_import, + convert_star_import, + convert_unnamed_relative_import, + convert_from_export, + convert_star_from_export, + partial(convert_relative_require, url), + remove_index, + convert_export_function_or_class, + convert_variable_export, + convert_object_export, + convert_default_export, + partial(wrap_with_odoo_define, module_path), + ] + for s in steps: + content = s(content) + if legacy_odoo_define: + content += legacy_odoo_define + return content + + +URL_RE = re.compile(r""" + /?(?P\w+) # /module name + /([\w/]*/)?static/ # ... /static/ + (?Psrc|tests) # src or test file + (?P/[\w/]*) # URL (/...) + """, re.VERBOSE) + + +def url_to_module_path(url): + """ + Odoo modules each have a name. (odoo.define("", function (require) {...}); + It is used in to be required later. (const { something } = require(""). + The transpiler transforms the url of the file in the project to this name. + It takes the module name and add a @ on the start of it, and map it to be the source of the static/src (or + static/tests) folder in that module. + + in: web/static/src/one/two/three.js + out: @web/one/two/three.js + The module would therefore be defined and required by this path. + + :param url: an url in the project + :return: a special path starting with @. + """ + match = URL_RE.match(url) + if match: + url = match["url"] + if url.endswith(('/index.js', '/index')): + url, _ = url.rsplit('/', 1) + if match["type"] == "src": + return "@%s%s" % (match['module'], url) + else: + return "@%s/../tests%s" % (match['module'], url) + else: + raise ValueError("The js file %r must be in the folder '/static/src' or '/static/test'" % url) + + +def wrap_with_odoo_define(module_path, content): + """ + Wraps the current content (source code) with the odoo.define call. + Should logically be called once all other operations have been performed. + """ + return f"""odoo.define({module_path!r}, function (require) {{ +'use strict'; +let __exports = {{}}; +{content} +return __exports; +}}); +""" + + +EXPORT_FCT_OR_CLASS_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s+ # export + (?P(async\s+)?function|class)\s+ # async function or function or class + (?P\w+) # name of the class or the function + """, re.MULTILINE | re.VERBOSE) + + +def convert_export_function_or_class(content): + """ + Transpile functions and classes that are being exported. + + .. code-block:: javascript + + // before + export function name + // after + const name = __exports.name = function name + + // before + export async function name + // after + const name = __exports.name = async function name + + // before + export class name + // after + const name = __exports.name = class name + + """ + repl = r"\gconst \g = __exports.\g = \g \g" + return EXPORT_FCT_OR_CLASS_RE.sub(repl, content) + + +EXPORT_FCT_OR_CLASS_DEFAULT_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s+default\s+ # export default + (?P(async\s+)?function|class)\s+ # async function or function or class + (?P\w+) # name of the class or the function + """, re.MULTILINE | re.VERBOSE) + + +def convert_export_function_or_class_default(content): + """ + Transpile functions and classes that are being exported as default value. + + .. code-block:: javascript + + // before + export default function name + // after + const name = __exports[Symbol.for("default")] = function name + + // before + export default async function name + // after + const name = __exports[Symbol.for("default")] = async function name + + // before + export default class name + // after + const name = __exports[Symbol.for("default")] = class name + + """ + repl = r"""\gconst \g = __exports[Symbol.for("default")] = \g \g""" + return EXPORT_FCT_OR_CLASS_DEFAULT_RE.sub(repl, content) + + +EXPORT_VAR_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s+ # export + (?Plet|const|var)\s+ # let or cont or var + (?P\w+) # variable name + """, re.MULTILINE | re.VERBOSE) + + +def convert_variable_export(content): + """ + Transpile variables that are being exported. + + .. code-block:: javascript + + // before + export let name + // after + let name = __exports.name + // (same with var and const) + + """ + repl = r"\g\g \g = __exports.\g" + return EXPORT_VAR_RE.sub(repl, content) + + +EXPORT_DEFAULT_VAR_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s+default\s+ # export default + (?Plet|const|var)\s+ # let or const or var + (?P\w+)\s* # variable name + """, re.MULTILINE | re.VERBOSE) + + +def convert_variable_export_default(content): + """ + Transpile the variables that are exported as default values. + + .. code-block:: javascript + + // before + export default let name + // after + let name = __exports[Symbol.for("default")] + + """ + repl = r"""\g\g \g = __exports[Symbol.for("default")]""" + return EXPORT_DEFAULT_VAR_RE.sub(repl, content) + + +EXPORT_OBJECT_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s* # export + (?P{(\s*\w+\s*,?\s*)*}\s*) # { a, b, c as x, ... } + """, re.MULTILINE | re.VERBOSE) + + +def convert_object_export(content): + """ + Transpile exports of multiple elements + + .. code-block:: javascript + + // before + export { a, b, c as x } + // after + Object.assign(__exports, { a, b, x: c }) + """ + def repl(matchobj): + object_process = "{" + ", ".join([convert_as(val) for val in matchobj["object"][1:-1].split(",")]) + "}" + space = matchobj["space"] + return f"{space}Object.assign(__exports, {object_process})" + return EXPORT_OBJECT_RE.sub(repl, content) + + +EXPORT_FROM_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s* # export + (?P{(\s*\w+\s*,?\s*)*})\s* # { a, b, c as x, ... } + from\s* # from + (?P(\".*\")|('.*')|(`.*`)) # "file path" ("some/path.js") + """, re.MULTILINE | re.VERBOSE) + + +def convert_from_export(content): + """ + Transpile exports coming from another source + + .. code-block:: javascript + + // before + export { a, b, c as x } from "some/path.js" + // after + { a, b, c } = {require("some/path.js"); Object.assign(__exports, { a, b, x: c });} + """ + def repl(matchobj): + object_clean = "{" + ", ".join([remove_as(val) for val in matchobj["object"][1:-1].split(",")]) + "}" + object_process = "{" + ", ".join([convert_as(val) for val in matchobj["object"][1:-1].split(",")]) + "}" + return "%(space)s{const %(object_clean)s = require(%(path)s);Object.assign(__exports, %(object_process)s)}" % { + 'object_clean': object_clean, + 'object_process': object_process, + 'space': matchobj['space'], + 'path': matchobj['path'], + } + return EXPORT_FROM_RE.sub(repl, content) + + +EXPORT_STAR_FROM_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s*\*\s*from\s* # export * from + (?P(\".*\")|('.*')|(`.*`)) # "file path" ("some/path.js") + """, re.MULTILINE | re.VERBOSE) + + +def convert_star_from_export(content): + """ + Transpile exports star coming from another source + + .. code-block:: javascript + + // before + export * from "some/path.js" + // after + Object.assign(__exports, require("some/path.js")) + """ + repl = r"\gObject.assign(__exports, require(\g))" + return EXPORT_STAR_FROM_RE.sub(repl, content) + + +EXPORT_DEFAULT_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + export\s+default # export default + (\s+\w+\s*=)? # something (optional) + """, re.MULTILINE | re.VERBOSE) + + +def convert_default_export(content): + """ + This function handles the default exports. + Either by calling another operation with a TRUE flag, and if any default is left, doing a simple replacement. + + (see convert_export_function_or_class_default and convert_variable_export_default). + + + .. code-block:: javascript + + // before + export default + // after + __exports[Symbol.for("default")] = + + .. code-block:: javascript + + // before + export default something = + // after + __exports[Symbol.for("default")] = + """ + new_content = convert_export_function_or_class_default(content) + new_content = convert_variable_export_default(new_content) + repl = r"""\g__exports[Symbol.for("default")] =""" + return EXPORT_DEFAULT_RE.sub(repl, new_content) + + +IMPORT_BASIC_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + import\s+ # import + (?P{(\s*\w+\s*,?\s*)+})\s* # { a, b, c as x, ... } + from\s* # from + (?P(\".*\")|('.*')|(`.*`)) # "file path" ("some/path") + """, re.MULTILINE | re.VERBOSE) + + +def convert_basic_import(content): + """ + Transpile the simpler import call. + + .. code-block:: javascript + + // before + import { a, b, c as x } from "some/path" + // after + const {a, b, c: x} = require("some/path") + """ + def repl(matchobj): + new_object = matchobj["object"].replace(" as ", ": ") + return f"{matchobj['space']}const {new_object} = require({matchobj['path']})" + return IMPORT_BASIC_RE.sub(repl, content) + + +IMPORT_LEGACY_DEFAULT_RE = re.compile(r""" + ^ + (?P\s*) # space and empty line + import\s+ # import + (?P\w+)\s* # default variable name + from\s* # from + (?P(\"\w+\.\w+\")|('\w+\.\w+')|(`\w+\.\w+`)) # legacy alias file ("addon_name.module_name") + """, re.MULTILINE | re.VERBOSE) + + +def convert_legacy_default_import(content): + """ + Transpile legacy imports (that were used as they were default import). + Legacy imports means that their name is not a path but a .. + It requires slightly different processing. + + .. code-block:: javascript + + // before + import module_name from "addon.module_name" + // after + const module_name = require("addon.module_name") + """ + repl = r"""\gconst \g = require(\g)""" + return IMPORT_LEGACY_DEFAULT_RE.sub(repl, content) + + +IMPORT_DEFAULT = re.compile(r""" + ^ + (?P\s*) # space and empty line + import\s+ # import + (?P\w+)\s* # default variable name + from\s* # from + (?P(\".*\")|('.*')|(`.*`)) # "file path" ("some/path") + """, re.MULTILINE | re.VERBOSE) + + +def convert_default_import(content): + """ + Transpile the default import call. + + .. code-block:: javascript + + // before + import something from "some/path" + // after + const something = require("some/path")[Symbol.for("default")] + """ + repl = r"""\gconst \g = require(\g)[Symbol.for("default")]""" + return IMPORT_DEFAULT.sub(repl, content) + + +RELATIVE_REQUIRE_RE = re.compile(r""" + require\((["'`])([^@"'`]+)(\1)\) # require("some/path") + """, re.VERBOSE) + + +def convert_relative_require(url, content): + """ + Convert the relative path contained in a 'require()' + to the new path system (@module/path) + .. code-block:: javascript + + // Relative path: + // before + require("./path") + // after + require("@module/path") + + // Not a relative path: + // before + require("other_alias") + // after + require("other_alias") + """ + new_content = content + for open_quote, path, close_quote in RELATIVE_REQUIRE_RE.findall(new_content): + if path.startswith(".") and "/" in path: + pattern = rf"require\({open_quote}{path}{close_quote}\)" + repl = f'require("{relative_path_to_module_path(url, path)}")' + new_content = re.sub(pattern, repl, new_content) + return new_content + + +IMPORT_STAR = re.compile(r""" + ^(?P\s*) # indentation + import\s+\*\s+as\s+ # import * as + (?P\w+) # alias + \s*from\s* # from + (?P[^;\n]+) # path +""", re.MULTILINE | re.VERBOSE) + + +def convert_star_import(content): + """ + Transpile import star. + + .. code-block:: javascript + + // before + import * as name from "some/path" + // after + const name = require("some/path") + """ + repl = r"\gconst \g = require(\g)" + return IMPORT_STAR.sub(repl, content) + + +IMPORT_UNNAMED_RELATIVE_RE = re.compile(r""" + ^(?P\s*) # indentation + import\s+ # import + (?P[^;\n]+) # relative path +""", re.MULTILINE | re.VERBOSE) + + +def convert_unnamed_relative_import(content): + """ + Transpile relative "direct" imports. Direct meaning they are not store in a variable. + + .. code-block:: javascript + + // before + import "some/path" + // after + require("some/path") + """ + repl = r"require(\g)" + return IMPORT_UNNAMED_RELATIVE_RE.sub(repl, content) + + +URL_INDEX_RE = re.compile(r""" + require\s* # require + \(\s* # ( + (?P # path (three possibles syntax for the string) + (\".*/index/?\")| + ('.*/index/?')| + (`.*/index/?`) + ) + \s*\) # ) +""", re.MULTILINE | re.VERBOSE) + + +def remove_index(content): + """ + Remove in the paths the /index.js. + We want to be able to import a module just trough its directory name if it contains an index.js. + So we no longer need to specify the index.js in the paths. + """ + def repl(matchobj): + path = matchobj["path"] + new_path = path[: path.rfind("/index")] + path[0] + return f"require({new_path})" + return URL_INDEX_RE.sub(repl, content) + + +def relative_path_to_module_path(url, path_rel): + """ + Convert the relative path into a module path, which is more generic and fancy. + + :param path_rel: a relative path to the current url. + :return: module path (@module/...) + """ + url_split = url.split("/") + path_rel_split = path_rel.split("/") + nb_back = len([v for v in path_rel_split if v == ".."]) + 1 + result = "/".join(url_split[:-nb_back] + [v for v in path_rel_split if not v in ["..", "."]]) + return url_to_module_path(result) + + +ODOO_MODULE_RE = re.compile(r""" + \/(\*|\/).*\s+ # // or /* + @odoo-module\s+ # @odoo-module + (alias=(?P\S+))?\s+ # alias=web.AbstractAction (optional) + (default=(?PFalse|false|0))? # default=False or false or 0 (optional) + """, re.VERBOSE) + + +def is_odoo_module(content): + """ + Detect if the file is a native odoo module. + We look for a comment containing @odoo-module. + + :param content: source code + :return: is this a odoo module that need transpilation ? + """ + result = ODOO_MODULE_RE.match(content) + return bool(result) + + +def get_aliased_odoo_define_content(module_path, content): + """ + To allow smooth transition between the new system and the legacy one, we have the possibility to + defined an alternative module name (an alias) that will act as proxy between legacy require calls and + new modules. + + Example: + If we have a require call somewhere in the odoo source base being: + > vat AbstractAction require("web.AbstractAction") + we have a problem when we will have converted to module to ES6: its new name will be more like + "web/chrome/abstract_action". So the require would fail ! + So we add a second small modules, an alias, as such: + > odoo.define("web/chrome/abstract_action", function(require) { + > return require('web.AbstractAction')[Symbol.for("default")]; + > }); + + To generate this, change your comment on the top of the file. + + .. code-block:: javascript + + // before + /** @odoo-module */ + // after + /** @odoo-module alias=web.AbstractAction */ + + Notice that often, the legacy system acted like they it did defaukt imports. That's why we have the + "[Symbol.for("default")];" bit. If your use case does not need this default import, just do: + + .. code-block:: javascript + + // before + /** @odoo-module */ + // after + /** @odoo-module alias=web.AbstractAction default=false */ + + :return: the alias content to append to the source code. + """ + matchobj = ODOO_MODULE_RE.match(content) + if matchobj: + alias = matchobj['alias'] + if alias: + if matchobj['default']: + return """\nodoo.define(`%s`, function(require) { + return require('%s'); + });\n""" % (alias, module_path) + else: + return """\nodoo.define(`%s`, function(require) { + return require('%s')[Symbol.for("default")]; + });\n""" % (alias, module_path) + + +def convert_as(val): + parts = val.split(" as ") + return val if len(parts) < 2 else "%s: %s" % tuple(reversed(parts)) + + +def remove_as(val): + parts = val.split(" as ") + return val if len(parts) < 2 else parts[0]