2 Copyright (c) 2013, Fabien Meghazi
4 Released under the MIT license
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal in
8 the Software without restriction, including without limitation the rights to use,
9 copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
10 Software, and to permit persons to whom the Software is furnished to do so,
11 subject to the following conditions:
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18 FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19 COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
20 AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25 // TODO: line number -> https://bugzilla.mozilla.org/show_bug.cgi?id=618650
26 // TODO: templates orverwritten could be called by t-call="__super__" ?
27 // TODO: t-set + t-value + children node == scoped variable ?
29 expressions_cache: {},
30 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(','),
31 ACTIONS_PRECEDENCE: 'foreach,if,call,set,esc,escf,raw,rawf,js,debug,log'.split(','),
41 exception: function(message, context) {
42 context = context || {};
44 if (context.template) {
45 prefix += " - template['" + context.template + "']";
47 throw new Error(prefix + ": " + message);
49 warning : function(message) {
50 if (typeof(window) !== 'undefined' && window.console) {
51 window.console.warn(message);
54 trim: function(s, mode) {
57 return s.replace(/^\s*/, "");
59 return s.replace(/\s*$/, "");
61 return s.replace(/^\s*|\s*$/g, "");
64 js_escape: function(s, noquotes) {
65 return (noquotes ? '' : "'") + s.replace(/\r?\n/g, "\\n").replace(/'/g, "\\'") + (noquotes ? '' : "'");
67 html_escape: function(s, attribute) {
71 s = String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
73 s = s.replace(/"/g, '"');
77 gen_attribute: function(o) {
78 if (o !== null && o !== undefined) {
79 if (o.constructor === Array) {
80 if (o[1] !== null && o[1] !== undefined) {
81 return this.format_attribute(o[0], o[1]);
83 } else if (typeof o === 'object') {
86 if (o.hasOwnProperty(k)) {
87 r += this.gen_attribute([k, o[k]]);
95 format_attribute: function(name, value) {
96 return ' ' + name + '="' + this.html_escape(value, true) + '"';
98 extend: function(dst, src, exclude) {
100 if (src.hasOwnProperty(p) && !(exclude && this.arrayIndexOf(exclude, p) !== -1)) {
106 arrayIndexOf : function(array, item) {
107 for (var i = 0, ilen = array.length; i < ilen; i++) {
108 if (array[i] === item) {
114 xml_node_to_string : function(node, childs_only) {
116 var childs = node.childNodes, r = [];
117 for (var i = 0, ilen = childs.length; i < ilen; i++) {
118 r.push(this.xml_node_to_string(childs[i]));
122 if (typeof XMLSerializer !== 'undefined') {
123 return (new XMLSerializer()).serializeToString(node);
125 switch(node.nodeType) {
126 case 1: return node.outerHTML;
127 case 3: return node.data;
128 case 4: return '<![CDATA[' + node.data + ']]>';
129 case 8: return '<!-- ' + node.data + '-->';
131 throw new Error('Unknown node type ' + node.nodeType);
135 call: function(context, template, old_dict, _import, callback) {
136 var new_dict = this.extend({}, old_dict);
137 new_dict['__caller__'] = old_dict['__template__'];
139 new_dict['__content__'] = callback(context, new_dict);
141 var r = context.engine._render(template, new_dict);
143 if (_import === '*') {
144 this.extend(old_dict, new_dict, ['__caller__', '__template__']);
146 _import = _import.split(',');
147 for (var i = 0, ilen = _import.length; i < ilen; i++) {
149 old_dict[v] = new_dict[v];
155 foreach: function(context, enu, as, old_dict, callback) {
157 var size, new_dict = this.extend({}, old_dict);
158 new_dict[as + "_all"] = enu;
159 var as_value = as + "_value",
160 as_index = as + "_index",
161 as_first = as + "_first",
162 as_last = as + "_last",
163 as_parity = as + "_parity";
164 if (size = enu.length) {
165 new_dict[as + "_size"] = size;
166 for (var j = 0, jlen = enu.length; j < jlen; j++) {
168 new_dict[as_value] = cur;
169 new_dict[as_index] = j;
170 new_dict[as_first] = j === 0;
171 new_dict[as_last] = j + 1 === size;
172 new_dict[as_parity] = (j % 2 == 1 ? 'odd' : 'even');
173 if (cur.constructor === Object) {
174 this.extend(new_dict, cur);
177 callback(context, new_dict);
179 } else if (enu.constructor == Number) {
181 for (var i = 0; i < enu; i++) {
184 this.foreach(context, _enu, as, old_dict, callback);
188 if (enu.hasOwnProperty(k)) {
190 new_dict[as_value] = v;
191 new_dict[as_index] = index;
192 new_dict[as_first] = index === 0;
193 new_dict[as_parity] = (j % 2 == 1 ? 'odd' : 'even');
195 callback(context, new_dict);
201 this.exception("No enumerator given to foreach", context);
207 QWeb2.Engine = (function() {
209 // TODO: handle prefix at template level : t-prefix="x", don't forget to lowercase it
212 this.templates_resources = []; // TODO: implement this.reload()
214 this.compiled_templates = {};
215 this.extend_templates = {};
216 this.default_dict = {};
217 this.tools = QWeb2.tools;
218 this.jQuery = window.jQuery;
219 this.reserved_words = QWeb2.RESERVED_WORDS.slice(0);
220 this.actions_precedence = QWeb2.ACTIONS_PRECEDENCE.slice(0);
221 this.word_replacement = QWeb2.tools.extend({}, QWeb2.WORD_REPLACEMENT);
222 this.preprocess_node = null;
223 for (var i = 0; i < arguments.length; i++) {
224 this.add_template(arguments[i]);
228 QWeb2.tools.extend(Engine.prototype, {
229 add_template : function(template) {
230 this.templates_resources.push(template);
231 if (template.constructor === String) {
232 template = this.load_xml(template);
234 var ec = (template.documentElement && template.documentElement.childNodes) || template.childNodes || [];
235 for (var i = 0; i < ec.length; i++) {
237 if (node.nodeType === 1) {
238 if (node.nodeName == 'parsererror') {
239 return this.tools.exception(node.innerText);
241 var name = node.getAttribute(this.prefix + '-name');
242 var extend = node.getAttribute(this.prefix + '-extend');
243 if (name && extend) {
244 // Clone template and extend it
245 if (!this.templates[extend]) {
246 return this.tools.exception("Can't clone undefined template " + extend);
248 this.templates[name] = this.templates[extend].cloneNode(true);
253 this.templates[name] = node;
254 this.compiled_templates[name] = null;
256 delete(this.compiled_templates[extend]);
257 if (this.extend_templates[extend]) {
258 this.extend_templates[extend].push(node);
260 this.extend_templates[extend] = [node];
267 load_xml : function(s) {
268 s = this.tools.trim(s);
269 if (s.charAt(0) === '<') {
270 return this.load_xml_string(s);
272 var req = this.get_xhr();
274 // TODO: third parameter is async : https://developer.mozilla.org/en/XMLHttpRequest#open()
275 // do an on_ready in QWeb2{} that could be passed to add_template
277 s += '?debug=' + (new Date()).getTime(); // TODO fme: do it properly in case there's already url parameters
279 req.open('GET', s, false);
281 var xDoc = req.responseXML;
283 if (!xDoc.documentElement) {
284 throw new Error("QWeb2: This xml document has no root document : " + xDoc.responseText);
286 if (xDoc.documentElement.nodeName == "parsererror") {
287 return this.tools.exception(xDoc.documentElement.childNodes[0].nodeValue);
291 return this.load_xml_string(req.responseText);
296 load_xml_string : function(s) {
297 if (window.DOMParser) {
298 var dp = new DOMParser();
299 var r = dp.parseFromString(s, "text/xml");
300 if (r.body && r.body.firstChild && r.body.firstChild.nodeName == 'parsererror') {
301 return this.tools.exception(r.body.innerText);
307 // new ActiveXObject("Msxml2.DOMDocument.4.0");
308 xDoc = new ActiveXObject("MSXML2.DOMDocument");
310 return this.tools.exception(
311 "Could not find a DOM Parser: " + e.message);
314 xDoc.preserveWhiteSpace = true;
318 has_template : function(template) {
319 return !!this.templates[template];
321 get_xhr : function() {
322 if (window.XMLHttpRequest) {
323 return new window.XMLHttpRequest();
326 return new ActiveXObject('MSXML2.XMLHTTP.3.0');
331 compile : function(node) {
332 var e = new QWeb2.Element(this, node);
333 var template = node.getAttribute(this.prefix + '-name');
334 return " /* 'this' refers to Qweb2.Engine instance */\n" +
335 " var context = { engine : this, template : " + (this.tools.js_escape(template)) + " };\n" +
336 " dict = dict || {};\n" +
337 " dict['__template__'] = '" + template + "';\n" +
339 " /* START TEMPLATE */" +
340 (this.debug ? "" : " try {\n") +
341 (e.compile()) + "\n" +
342 " /* END OF TEMPLATE */" +
343 (this.debug ? "" : " } catch(error) {\n" +
344 " if (console && console.exception) console.exception(error);\n" +
345 " context.engine.tools.exception('Runtime Error: ' + error, context);\n") +
346 (this.debug ? "" : " }\n") +
347 " return r.join('');";
349 render : function(template, dict) {
351 QWeb2.tools.extend(dict, this.default_dict);
352 /*if (this.debug && window['console'] !== undefined) {
353 console.time("QWeb render template " + template);
355 var r = this._render(template, dict);
356 /*if (this.debug && window['console'] !== undefined) {
357 console.timeEnd("QWeb render template " + template);
361 _render : function(template, dict) {
362 if (this.compiled_templates[template]) {
363 return this.compiled_templates[template].apply(this, [dict || {}]);
364 } else if (this.templates[template]) {
366 if (ext = this.extend_templates[template]) {
368 while (extend_node = ext.shift()) {
369 this.extend(template, extend_node);
372 var code = this.compile(this.templates[template]), tcompiled;
374 tcompiled = new Function(['dict'], code);
376 if (this.debug && window.console) {
379 this.tools.exception("Error evaluating template: " + error, { template: name });
382 this.tools.exception("Error evaluating template: (IE?)" + error, { template: name });
384 this.compiled_templates[template] = tcompiled;
385 return this.render(template, dict);
387 return this.tools.exception("Template '" + template + "' not found");
390 extend : function(template, extend_node) {
392 return this.tools.exception("Can't extend template " + template + " without jQuery");
394 var template_dest = this.templates[template];
395 for (var i = 0, ilen = extend_node.childNodes.length; i < ilen; i++) {
396 var child = extend_node.childNodes[i];
397 if (child.nodeType === 1) {
398 var jquery = child.getAttribute(this.prefix + '-jquery'),
399 operation = child.getAttribute(this.prefix + '-operation'),
401 error_msg = "Error while extending template '" + template;
403 target = this.jQuery(jquery, template_dest);
405 this.tools.exception(error_msg + "No expression given");
407 error_msg += "' (expression='" + jquery + "') : ";
409 var allowed_operations = "append,prepend,before,after,replace,inner".split(',');
410 if (this.tools.arrayIndexOf(allowed_operations, operation) == -1) {
411 this.tools.exception(error_msg + "Invalid operation : '" + operation + "'");
413 operation = {'replace' : 'replaceWith', 'inner' : 'html'}[operation] || operation;
414 target[operation](child.cloneNode(true).childNodes);
417 var f = new Function(['$', 'document'], this.tools.xml_node_to_string(child, true));
419 return this.tools.exception("Parse " + error_msg + error);
422 f.apply(target, [this.jQuery, template_dest.ownerDocument]);
424 return this.tools.exception("Runtime " + error_msg + error);
434 QWeb2.Element = (function() {
435 function Element(engine, node) {
436 this.engine = engine;
438 this.tag = node.tagName;
440 this.actions_done = [];
441 this.attributes = {};
446 this.process_children = true;
447 var childs = this.node.childNodes;
449 for (var i = 0, ilen = childs.length; i < ilen; i++) {
450 this.children.push(new QWeb2.Element(this.engine, childs[i]));
453 var attrs = this.node.attributes;
455 for (var j = 0, jlen = attrs.length; j < jlen; j++) {
457 var name = attr.name;
458 var m = name.match(new RegExp("^" + this.engine.prefix + "-(.+)"));
461 if (name === 'name') {
464 this.actions[name] = attr.value;
466 this.attributes[name] = attr.value;
470 if (this.engine.preprocess_node) {
471 this.engine.preprocess_node.call(this);
475 QWeb2.tools.extend(Element.prototype, {
476 compile : function() {
479 lines = this._compile().split('\n');
480 for (var i = 0, ilen = lines.length; i < ilen; i++) {
481 var m, line = lines[i];
482 if (m = line.match(/^(\s*)\/\/@string=(.*)/)) {
484 if (this.engine.debug) {
485 // Split string lines in indented r.push arguments
486 r.push((m[2].indexOf("\\n") != -1 ? "',\n\t" + m[1] + "'" : '') + m[2]);
491 r.push(m[1] + "r.push('" + m[2]);
504 _compile : function() {
505 switch (this.node.nodeType) {
508 this.top_string(this.node.data);
511 this.compile_element();
513 var r = this._top.join('');
514 if (this.process_children) {
515 for (var i = 0, ilen = this.children.length; i < ilen; i++) {
516 var child = this.children[i];
517 child._indent = this._indent;
518 r += child._compile();
521 r += this._bottom.join('');
524 format_expression : function(e) {
525 /* Naive format expression builder. Replace reserved words and variables to dict[variable]
526 * Does not handle spaces before dot yet, and causes problems for anonymous functions. Use t-js="" for that */
527 if (QWeb2.expressions_cache[e]) {
528 return QWeb2.expressions_cache[e];
530 var chars = e.split(''),
536 for (var i = 0, ilen = chars.length; i < ilen; i++) {
538 if (instring.length) {
539 if (c === instring && chars[i - 1] !== "\\") {
542 } else if (c === '"' || c === "'") {
544 } else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
548 } else if (c.match(/\W/) && invar.length) {
549 // TODO: Should check for possible spaces before dot
550 if (chars[invar_pos - 1] !== '.' && QWeb2.tools.arrayIndexOf(this.engine.reserved_words, invar) < 0) {
551 invar = this.engine.word_replacement[invar] || ("dict['" + invar + "']");
555 } else if (invar.length) {
562 QWeb2.expressions_cache[e] = r;
565 string_interpolation : function(s) {
569 var regex = /^{(.*)}(.*)/,
572 for (var i = 0, ilen = src.length; i < ilen; i++) {
574 m = val.match(regex);
576 r.push("(" + this.format_expression(m[1]) + ")");
578 r.push(this.engine.tools.js_escape(m[2]));
580 } else if (!(i === 0 && val === '')) {
581 r.push(this.engine.tools.js_escape((i === 0 ? '' : '#') + val));
584 return r.join(' + ');
586 indent : function() {
587 return this._indent++;
589 dedent : function() {
590 if (this._indent !== 0) {
591 return this._indent--;
594 get_indent : function() {
595 return new Array(this._indent + 1).join("\t");
598 return this._top.push(this.get_indent() + s + '\n');
600 top_string : function(s) {
601 return this._top.push(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
603 bottom : function(s) {
604 return this._bottom.unshift(this.get_indent() + s + '\n');
606 bottom_string : function(s) {
607 return this._bottom.unshift(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
609 compile_element : function() {
610 for (var i = 0, ilen = this.engine.actions_precedence.length; i < ilen; i++) {
611 var a = this.engine.actions_precedence[i];
612 if (a in this.actions) {
613 var value = this.actions[a];
614 var key = 'compile_action_' + a;
617 } else if (this.engine[key]) {
618 this.engine[key].call(this, value);
620 this.engine.tools.exception("No handler method for action '" + a + "'");
624 if (this.tag.toLowerCase() !== this.engine.prefix) {
625 var tag = "<" + this.tag;
626 for (var a in this.attributes) {
627 tag += this.engine.tools.gen_attribute([a, this.attributes[a]]);
629 this.top_string(tag);
630 if (this.actions.att) {
631 this.top("r.push(context.engine.tools.gen_attribute(" + (this.format_expression(this.actions.att)) + "));");
633 for (var a in this.actions) {
634 var v = this.actions[a];
635 var m = a.match(/att-(.+)/);
637 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.format_expression(v)) + ")]));");
639 var m = a.match(/attf-(.+)/);
641 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.string_interpolation(v)) + ")]));");
644 if (this.children.length || this.actions.opentag === 'true') {
645 this.top_string(">");
646 this.bottom_string("</" + this.tag + ">");
648 this.top_string("/>");
652 compile_action_if : function(value) {
653 this.top("if (" + (this.format_expression(value)) + ") {");
657 compile_action_foreach : function(value) {
658 var as = this.actions['as'] || value.replace(/[^a-zA-Z0-9]/g, '_');
659 //TODO: exception if t-as not valid
660 this.top("context.engine.tools.foreach(context, " + (this.format_expression(value)) + ", " + (this.engine.tools.js_escape(as)) + ", dict, function(context, dict) {");
664 compile_action_call : function(value) {
665 var _import = this.actions['import'] || '';
666 if (this.children.length === 0) {
667 return this.top("r.push(context.engine.tools.call(context, " + (this.engine.tools.js_escape(value)) + ", dict, " + (this.engine.tools.js_escape(_import)) + "));");
669 this.top("r.push(context.engine.tools.call(context, " + (this.engine.tools.js_escape(value)) + ", dict, " + (this.engine.tools.js_escape(_import)) + ", function(context, dict) {");
672 this.top("var r = [];");
673 return this.bottom("return r.join('');");
676 compile_action_set : function(value) {
677 var variable = this.format_expression(value);
678 if (this.actions['value']) {
679 if (this.children.length) {
680 this.engine.tools.warning("@set with @value plus node chidren found. Children are ignored.");
682 this.top(variable + " = (" + (this.format_expression(this.actions['value'])) + ");");
683 this.process_children = false;
685 if (this.children.length === 0) {
686 this.top(variable + " = '';");
687 } else if (this.children.length === 1 && this.children[0].node.nodeType === 3) {
688 this.top(variable + " = " + (this.engine.tools.js_escape(this.children[0].node.data)) + ";");
689 this.process_children = false;
691 this.top(variable + " = (function(dict) {");
692 this.bottom("})(dict);");
694 this.top("var r = [];");
695 this.bottom("return r.join('');");
699 compile_action_esc : function(value) {
700 this.top("r.push(context.engine.tools.html_escape(" + (this.format_expression(value)) + "));");
702 compile_action_escf : function(value) {
703 this.top("r.push(context.engine.tools.html_escape(" + (this.string_interpolation(value)) + "));");
705 compile_action_raw : function(value) {
706 this.top("r.push(" + (this.format_expression(value)) + ");");
708 compile_action_rawf : function(value) {
709 this.top("r.push(" + (this.string_interpolation(value)) + ");");
711 compile_action_js : function(value) {
712 this.top("(function(" + value + ") {");
713 this.bottom("})(dict);");
715 var lines = this.engine.tools.xml_node_to_string(this.node, true).split(/\r?\n/);
716 for (var i = 0, ilen = lines.length; i < ilen; i++) {
719 this.process_children = false;
721 compile_action_debug : function(value) {
722 this.top("debugger;");
724 compile_action_log : function(value) {
725 this.top("console.log(" + this.format_expression(value) + ");");