diff --git a/addons/web/static/lib/qweb/qweb2.js b/addons/web/static/lib/qweb/qweb2.js
index d2a367f1c33..60a7c0beea5 100644
--- a/addons/web/static/lib/qweb/qweb2.js
+++ b/addons/web/static/lib/qweb/qweb2.js
@@ -28,7 +28,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
var QWeb2 = {
expressions_cache: { },
RESERVED_WORDS: 'true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date'.split(','),
- ACTIONS_PRECEDENCE: 'foreach,if,elif,else,call,set,tag,esc,raw,js,debug,log'.split(','),
+ ACTIONS_PRECEDENCE: 'foreach,if,elif,else,call,set,tag,out,esc,raw,js,debug,log'.split(','),
WORD_REPLACEMENT: {
'and': '&&',
'or': '||',
@@ -65,15 +65,14 @@ var QWeb2 = {
js_escape: function(s, noquotes) {
return (noquotes ? '' : "'") + s.replace(/\r?\n/g, "\\n").replace(/'/g, "\\'") + (noquotes ? '' : "'");
},
- html_escape: function(s, attribute) {
+ html_escape: function(s) {
if (s == null) {
return '';
}
- s = String(s).replace(/&/g, '&').replace(//g, '>');
- if (attribute) {
- s = s.replace(/"/g, '"');
- }
- return s;
+ return _.escape(s);
+ },
+ markup(s) {
+ return new _Markup(s);
},
gen_attribute: function(o) {
if (o !== null && o !== undefined) {
@@ -94,7 +93,10 @@ var QWeb2 = {
return '';
},
format_attribute: function(name, value) {
- return ' ' + name + '="' + this.html_escape(value, true) + '"';
+ // we want to ensure `value` is *not* a `Markup`, because markup-safe
+ // strings are not necessarily attributes-safe.
+ const attrvalue = value == null ? '' : this.html_escape(String(value));
+ return ` ${name}="${attrvalue}"`;
},
extend: function(dst, src, exclude) {
for (var p in src) {
@@ -149,9 +151,9 @@ var QWeb2 = {
var new_dict = this.extend({}, old_dict);
new_dict['__caller__'] = old_dict['__template__'];
if (callback) {
- new_dict[0] = callback(context, new_dict);
+ new_dict[0] = this.markup(callback(context, new_dict));
}
- return context.engine._render(template, new_dict);
+ return this.markup(context.engine._render(template, new_dict));
},
foreach: function(context, enu, as, old_dict, callback) {
if (enu != null) {
@@ -689,7 +691,7 @@ QWeb2.Element = (function() {
s && r.push(_this.engine.tools.js_escape(s));
}
- var re = /(?:#{(.+?)}|{{(.+?)}})/g, start = 0, r = [], m;
+ var re = /#{(.+?)}|{{(.+?)}}/g, start = 0, r = [], m;
while (m = re.exec(s)) {
// extract literal string between previous and current match
append_literal(s.slice(start, re.lastIndex - m[0].length));
@@ -800,7 +802,7 @@ QWeb2.Element = (function() {
this.bottom("}));");
this.indent();
this.top("var r = [];");
- return this.bottom("return r.join('');");
+ return this.bottom("return context.engine.tools.markup(r.join(''));");
}
},
compile_action_set : function(value) {
@@ -822,11 +824,14 @@ QWeb2.Element = (function() {
this.bottom("})(dict);");
this.indent();
this.top("var r = [];");
- this.bottom("return r.join('');");
+ this.bottom("return context.engine.tools.markup(r.join(''));");
}
}
},
compile_action_esc : function(value) {
+ return this.compile_action_out(value);
+ },
+ compile_action_out(value) {
this.top("var t = " + this.format_str(value) + ";");
this.top("if (t != null) r.push(context.engine.tools.html_escape(t));");
this.top("else {");
@@ -834,6 +839,14 @@ QWeb2.Element = (function() {
this.indent();
},
compile_action_raw : function(value) {
+ let e = this.node;
+ while (e.parentElement && !e.getAttribute('t-name')) {
+ e = e.parentElement;
+ }
+ console.warn(
+ "Found deprecated directive '@t-raw=\"%s\"' in "
+ + "template '%s'. Replace by @t-out, and explicitely wrap content in "
+ + "utils.Markup if necessary.", value, e.getAttribute('t-name'));
this.top("var t = " + this.format_str(value) + ";");
this.top("if (t != null) r.push(t);");
this.top("else {");
diff --git a/addons/web/static/src/legacy/js/core/utils.js b/addons/web/static/src/legacy/js/core/utils.js
index 05d2f14b358..95ad897c9a1 100644
--- a/addons/web/static/src/legacy/js/core/utils.js
+++ b/addons/web/static/src/legacy/js/core/utils.js
@@ -240,9 +240,81 @@ class UnknownPatchError extends Error {
}
}
+const __escape = _.escape;
+_.escapeMethod = Symbol('html')
+_.escape = function escape(s) {
+ return s[_.escapeMethod] ? s[_.escapeMethod]() : __escape(s);
+}
+
+// notable issues:
+// * objects can't be negative in JS, so !!"" -> false but
+// !!(new String) -> true, likewise markup
+// TODO (?)
+// * Markup.join / Markup#join => escapes items and returns a Markup
+// * Markup#replace => automatically escapes the replacements (difficult impl)
+class _Markup extends String {
+ [_.escapeMethod]() {
+ return this;
+ }
+}
+// exposed for qweb2.js
+window._Markup = _Markup;
+
+/**
+ * Returns a markup object, which acts like a String but is considered safe by
+ * `_.escape`, and will therefore be injected as-is (without additional
+ * escaping) in templates. Can be used to inject dynamic HTML in templates
+ * (where the template itself can't), see first example.
+ *
+ * Can also be used as a *template tag*, in which case the literal content
+ * won't be escaped but the substitutions which are not already markup objects
+ * will be.
+ *
+ * ## WARNINGS:
+ * * A markup object is a `String` (boxed) but not a `string` (primitive), they
+ * typecheck differently which can be relevant.
+ * * To strip out the "markupness", just call `String(markup)`.
+ * * Most string operations (e.g. concatenation, `String#replace`, ...) will
+ * also strip out markupness
+ * * If the input is empty, returns a regular string (that way boolean tests
+ * work as expected).
+ *
+ * @returns a markup object
+ *
+ * @example regular function
+ * let h;
+ * if (someTest) {
+ * h = Markup(_t("This is a success"));
+ * } else {
+ * h = Markup(_t("Things did not work out"));
+ * }
+ * qweb.render("some_template", { message: h });
+ *
+ * @example template tag
+ * const escaped = " text";
+ * const asis = Markup`some text`;
+ * const h = Markup`Regular strings get ${escaped} but markup is injected ${asis}`;
+ */
+function Markup(v, ...exprs) {
+ if (!(v instanceof Array)) {
+ return v ? new _Markup(v) : '';
+ }
+ const elements = [];
+ let i = 0;
+ for(; i < exprs.length; ++i) {
+ elements.push(v[i], _.escape(exprs[i]));
+ }
+ elements.push(v[i]);
+
+ const s = elements.join('');
+ if (!s) { return '' }
+ return new _Markup(s);
+}
+
var utils = {
AlreadyDefinedPatchError,
UnknownPatchError,
+ Markup,
/**
* Throws an error if the given condition is not true
@@ -818,25 +890,40 @@ var utils = {
},
/**
* Returns a string formatted using given values.
+ *
* If the value is an object, its keys will replace `%(key)s` expressions.
* If the values are a set of strings, they will replace `%s` expressions.
* If no value is given, the string will not be formatted.
*
- * @param {string} string
- * @param {(Object|...string)} values
+ * `Markup`-aware: if a `Markup` object is provided as format string,
+ * automatically escapes injected values and returns a new `Markup`
+ * object.
+ *
+ * @param {string|Markup} string format string
+ * @param values values injected into the format string, can be either a
+ * sequence of positional parameters (for positional
+ * placeholders) or a single Object acting a as map (for named
+ * placeholders).
+ * @returns {string|Markup}
*/
- sprintf: function (string, ...values) {
+ sprintf(string, ...values) {
+ let finalizer, mapper;
+ finalizer = mapper = a => a;
+ if (string instanceof _Markup) {
+ string = String(string);
+ finalizer = Markup;
+ mapper = _.escape;
+ }
if (values.length === 1 && typeof values[0] === 'object') {
const valuesDict = values[0];
- for (const value in valuesDict) {
- string = string.replace(`%(${value})s`, valuesDict[value]);
- }
+ string = string.replace(
+ /%\((\w+)\)s/g,
+ (_, group) => mapper(valuesDict[group]));
} else {
- for (const value of values) {
- string = string.replace(/%s/, value);
- }
+ let i = 0;
+ string = string.replace(/%s/g, () => mapper(values[i++]));
}
- return string;
+ return finalizer(string);
},
/**
* Sort an array in place, keeping the initial order for identical values.
diff --git a/addons/web/static/tests/legacy/qweb_tests.js b/addons/web/static/tests/legacy/qweb_tests.js
index dd2b0c527ec..2880988ad4a 100644
--- a/addons/web/static/tests/legacy/qweb_tests.js
+++ b/addons/web/static/tests/legacy/qweb_tests.js
@@ -1,6 +1,8 @@
odoo.define('web.qweb_tests', function (require) {
"use strict";
+const {Markup} = require('web.utils');
+
var qwebPath = '/web/static/lib/qweb/';
const {hushConsole} = require('@web/../tests/helpers/utils');
@@ -9,99 +11,182 @@ function trim(s) {
}
/**
- * Loads the template file, and executes all the test template in a
- * qunit module $title
+ * Promise-based wrapper for QWeb2.Engine#add_template
+ *
+ * The base version is callbacks-based which is a bit shit, and it also has
+ * variable asynchronicity: it'll be async if passed a URL but not if passed
+ * either a Document or an XML string.
+ *
+ * Either way this converts the `(error, doc)` callback to a `Promise`
+ * as in it literally returns the parsed DOM Document.
+ *
+ * @param qweb the qweb instance to load the template into
+ * @param {String|Document} template qweb template (Document, template file, or template URL)
*/
-function loadTest(assert, template, context) {
- var done = assert.async();
- assert.expect(1);
-
- var qweb = new window.QWeb2.Engine();
- var prom = new Promise(function (resolve, reject) {
- qweb.add_template(qwebPath + template, function (error, doc) {
- if (error) {
- return reject(error);
- }
- resolve({
- qweb: qweb,
- doc: doc
- });
- });
+function add_template(qweb, template) {
+ return new Promise(function (resolve, reject) {
+ qweb.add_template(template, (error, doc) => error ? reject(error) : resolve(doc));
});
-
- prom.then(function (r) {
- var qweb = r.qweb;
- var doc = r.doc;
- assert.expect(doc.querySelectorAll('result').length);
-
- var templates = qweb.templates;
- for (var template in templates) {
- try {
- if (!templates.hasOwnProperty(template)) {
- continue;
- }
- // ignore templates whose name starts with _, they're
- // helpers/internal
- if (/^_/.test(template)) {
- continue;
- }
-
- var params = doc.querySelector('params#' + template);
- var args = params ? JSON.parse(params.textContent) : (context ? _.clone(context) : {});
-
- var results = doc.querySelector('result#' + template);
-
- assert.equal(
- trim(qweb.render(template, args)),
- trim(results.textContent.replace(new RegExp(String.fromCharCode(13), 'g'), '')),
- template);
- } catch (error) {
- assert.notOk(error.stack || error, 'Rendering error');
- }
- }
- done();
- });
- return prom;
}
-QUnit.module('QWeb', {
- before() {
- this.oldConsole = window.console;
- window.console = hushConsole;
+/**
+ * Loads the template file, and executes all the test template in a
+ * qunit module $title
+ *
+ * @param assert QUnit assertion module
+ * @param {String|Document} templateFile template container to load
+ * @param {Object} [context] additional rendering context
+ */
+async function loadTest(assert, templateFile, context) {
+ const qweb = new window.QWeb2.Engine();
+ const doc = await add_template(qweb, qwebPath + templateFile);
+
+ assert.expect(doc.querySelectorAll('result').length);
+
+ const templates = qweb.templates;
+ for (const template in templates) {
+ if (!templates.hasOwnProperty(template)) {
+ continue;
+ }
+ // ignore templates whose name starts with _, they're
+ // helpers/internal
+ if (/^_/.test(template)) {
+ continue;
+ }
+
+ const results = doc.querySelector(`result#${template}`).textContent.replace(/\r/g, '');
+ const params = doc.querySelector(`params#${template}`) || {textContent: 'null'};
+ const args = {...JSON.parse(params.textContent), ...context};
+
+ try {
+ assert.equal(trim(qweb.render(template, args)), trim(results), template);
+ } catch (error) {
+ assert.notOk(error.stack || error, `Rendering error for ${template} (in ${templateFile} with context ${JSON.stringify(args)}).`);
+ }
+ }
+}
+
+const table = [
+ {name: 'Output', file: 'qweb-test-output.xml'},
+ {name: 'Context-setting', file: 'qweb-test-set.xml'},
+ {name: 'Conditionals', file: 'qweb-test-conditionals.xml'},
+ {name: 'Attributes manipulation', file: 'qweb-test-attributes.xml'},
+ {name: 'Templates calling (to the faraway pages)', file: 'qweb-test-call.xml', context: {True: true}},
+ {name: 'Foreach', file: 'qweb-test-foreach.xml'},
+ {
+ name: 'Global', file: 'qweb-test-global.xml',
+ // test uses python syntax
+ context: {bool: (v) => !!v ? 'True' : 'False'},
+ fixture: {
+ before() {
+ this.WORD_REPLACEMENT = window.QWeb2.WORD_REPLACEMENT;
+ window.QWeb2.WORD_REPLACEMENT = _.extend(
+ {not: '!', None: 'undefined'},
+ this.WORD_REPLACEMENT
+ )
+ },
+ after() {
+ window.QWeb2.WORD_REPLACEMENT = this.WORD_REPLACEMENT;
+ }
+ }
},
- after() {window.console = this.oldConsole;}
-}, function () {
- QUnit.test('Output', function (assert) {
- loadTest(assert, 'qweb-test-output.xml');
- });
- QUnit.test('Context-setting', function (assert) {
- loadTest(assert, 'qweb-test-set.xml');
- });
- QUnit.test('Conditionals', function (assert) {
- loadTest(assert, 'qweb-test-conditionals.xml');
- });
- QUnit.test('Attributes manipulation', function (assert) {
- loadTest(assert, 'qweb-test-attributes.xml');
- });
- QUnit.test('Template calling (to the faraway pages)', function (assert) {
- loadTest(assert, 'qweb-test-call.xml', {True: true});
- });
- QUnit.test('Foreach', function (assert) {
- loadTest(assert, 'qweb-test-foreach.xml');
- });
- QUnit.test('Global', function (assert) {
- // test use python syntax
- var WORD_REPLACEMENT = window.QWeb2.WORD_REPLACEMENT;
- window.QWeb2.WORD_REPLACEMENT = _.extend({not: '!', None: 'undefined'}, WORD_REPLACEMENT);
- loadTest(assert, 'qweb-test-global.xml', {bool: function (v) { return !!v ? 'True' : 'False';}})
- .then(function () {
- window.QWeb2.WORD_REPLACEMENT = WORD_REPLACEMENT;
- }, function () {
- window.QWeb2.WORD_REPLACEMENT = WORD_REPLACEMENT;
- });
- });
- QUnit.test('Template inheritance', function (assert) {
- loadTest(assert, 'qweb-test-extend.xml');
+ {name: 'Template Inheritance', file: 'qweb-test-extend.xml'},
+];
+QUnit.module('QWeb', {
+ beforeEach() {
+ this.oldConsole = window.console;
+ window.console = hushConsole;
+ },
+ afterEach() {window.console = this.oldConsole;}
+}, () => {
+ for(const {name, file, context, fixture} of table) {
+ QUnit.test(name, async assert => {
+ // fake expect to avoid qunit being a pain in the ass, loadTest will
+ // update it
+ assert.expect(1);
+ if (fixture && 'before' in fixture) { await fixture.before(); }
+ try {
+ await loadTest(assert, file, context);
+ } finally {
+ if (fixture && 'after' in fixture) { await fixture.after(); }
+ }
+ });
+ }
+ QUnit.test('escape', assert => {
+ // not strictly about qweb...
+ assert.expect(8);
+ assert.equal(_.escape('a'), 'a');
+ assert.equal(_.escape(''), '<a>');
+ assert.equal(_.escape({[_.escapeMethod]() { return 'a'; }}), 'a');
+ assert.equal(_.escape({[_.escapeMethod]() { return ''; }}), '');
+ assert.equal(_.escape(Markup('a')), 'a');
+ assert.equal(_.escape(Markup('')), '');
+ assert.equal(_.escape(Markup`a`), 'a');
+ assert.equal(_.escape(Markup``), '');
});
+ QUnit.module('t-out', {}, () => {
+ QUnit.test("basics", async assert => {
+ assert.expect(5);
+ const qweb = new QWeb2.Engine;
+ await add_template(qweb, `
+
+ `);
+
+ assert.equal(
+ qweb.render('t-out', {value: 'test'}),
+ '<i>test</i>
',
+ "regular t-out should just escape the contents"
+ );
+ assert.equal(
+ qweb.render('t-out', {value: Markup('test')}),
+ 'test
',
+ "Markup contents should not be escaped"
+ );
+ assert.equal(
+ qweb.render('t-out', {value: Markup`test ${1}`}),
+ 'test 1
',
+ "markup template string should not be escaped"
+ );
+ const teststr = 'test';
+ assert.equal(
+ qweb.render('t-out', {value: Markup`${teststr}`}),
+ '<i>test</i>
',
+ "the markup template should not be escaped but what it uses should be"
+ );
+ const testMarkup = Markup(teststr);
+ assert.equal(
+ qweb.render('t-out', {value: Markup`${testMarkup}`}),
+ 'test
',
+ "markupception"
+ );
+ });
+ QUnit.test("Set", async assert => {
+ assert.expect(4);
+ const qweb = new QWeb2.Engine;
+ await add_template(qweb, `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ assert.equal(trim(qweb.render('litval')), '<a/>');
+ assert.equal(trim(qweb.render('body')), '');
+ assert.equal(trim(qweb.render('value', {val: ''})), '<a/>');
+ assert.equal(trim(qweb.render('bodyout', {val: ''})), '<a/>');
+ });
+ })
});
});