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,raw,js,debug,log'.split(','),
40 VOID_ELEMENTS: 'area,base,br,col,embed,hr,img,input,keygen,link,menuitem,meta,param,source,track,wbr'.split(','),
42 exception: function(message, context) {
43 context = context || {};
45 if (context.template) {
46 prefix += " - template['" + context.template + "']";
48 throw new Error(prefix + ": " + message);
50 warning : function(message) {
51 if (typeof(window) !== 'undefined' && window.console) {
52 window.console.warn(message);
55 trim: function(s, mode) {
58 return s.replace(/^\s*/, "");
60 return s.replace(/\s*$/, "");
62 return s.replace(/^\s*|\s*$/g, "");
65 js_escape: function(s, noquotes) {
66 return (noquotes ? '' : "'") + s.replace(/\r?\n/g, "\\n").replace(/'/g, "\\'") + (noquotes ? '' : "'");
68 html_escape: function(s, attribute) {
72 s = String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
74 s = s.replace(/"/g, '"');
78 gen_attribute: function(o) {
79 if (o !== null && o !== undefined) {
80 if (o.constructor === Array) {
81 if (o[1] !== null && o[1] !== undefined) {
82 return this.format_attribute(o[0], o[1]);
84 } else if (typeof o === 'object') {
87 if (o.hasOwnProperty(k)) {
88 r += this.gen_attribute([k, o[k]]);
96 format_attribute: function(name, value) {
97 return ' ' + name + '="' + this.html_escape(value, true) + '"';
99 extend: function(dst, src, exclude) {
101 if (src.hasOwnProperty(p) && !(exclude && this.arrayIndexOf(exclude, p) !== -1)) {
107 arrayIndexOf : function(array, item) {
108 for (var i = 0, ilen = array.length; i < ilen; i++) {
109 if (array[i] === item) {
115 xml_node_to_string : function(node, childs_only) {
117 var childs = node.childNodes, r = [];
118 for (var i = 0, ilen = childs.length; i < ilen; i++) {
119 r.push(this.xml_node_to_string(childs[i]));
123 if (typeof XMLSerializer !== 'undefined') {
124 return (new XMLSerializer()).serializeToString(node);
126 switch(node.nodeType) {
127 case 1: return node.outerHTML;
128 case 3: return node.data;
129 case 4: return '<![CDATA[' + node.data + ']]>';
130 case 8: return '<!-- ' + node.data + '-->';
132 throw new Error('Unknown node type ' + node.nodeType);
136 call: function(context, template, old_dict, _import, callback) {
137 var new_dict = this.extend({}, old_dict);
138 new_dict['__caller__'] = old_dict['__template__'];
140 new_dict['__content__'] = callback(context, new_dict);
142 var r = context.engine._render(template, new_dict);
144 if (_import === '*') {
145 this.extend(old_dict, new_dict, ['__caller__', '__template__']);
147 _import = _import.split(',');
148 for (var i = 0, ilen = _import.length; i < ilen; i++) {
150 old_dict[v] = new_dict[v];
156 foreach: function(context, enu, as, old_dict, callback) {
158 var size, new_dict = this.extend({}, old_dict);
159 new_dict[as + "_all"] = enu;
160 var as_value = as + "_value",
161 as_index = as + "_index",
162 as_first = as + "_first",
163 as_last = as + "_last",
164 as_parity = as + "_parity";
165 if (size = enu.length) {
166 new_dict[as + "_size"] = size;
167 for (var j = 0, jlen = enu.length; j < jlen; j++) {
169 new_dict[as_value] = cur;
170 new_dict[as_index] = j;
171 new_dict[as_first] = j === 0;
172 new_dict[as_last] = j + 1 === size;
173 new_dict[as_parity] = (j % 2 == 1 ? 'odd' : 'even');
174 if (cur.constructor === Object) {
175 this.extend(new_dict, cur);
178 callback(context, new_dict);
180 } else if (enu.constructor == Number) {
182 for (var i = 0; i < enu; i++) {
185 this.foreach(context, _enu, as, old_dict, callback);
189 if (enu.hasOwnProperty(k)) {
191 new_dict[as_value] = v;
192 new_dict[as_index] = index;
193 new_dict[as_first] = index === 0;
194 new_dict[as_parity] = (j % 2 == 1 ? 'odd' : 'even');
196 callback(context, new_dict);
202 this.exception("No enumerator given to foreach", context);
208 QWeb2.Engine = (function() {
210 // TODO: handle prefix at template level : t-prefix="x", don't forget to lowercase it
213 this.templates_resources = []; // TODO: implement this.reload()
215 this.compiled_templates = {};
216 this.extend_templates = {};
217 this.default_dict = {};
218 this.tools = QWeb2.tools;
219 this.jQuery = window.jQuery;
220 this.reserved_words = QWeb2.RESERVED_WORDS.slice(0);
221 this.actions_precedence = QWeb2.ACTIONS_PRECEDENCE.slice(0);
222 this.void_elements = QWeb2.VOID_ELEMENTS.slice(0);
223 this.word_replacement = QWeb2.tools.extend({}, QWeb2.WORD_REPLACEMENT);
224 this.preprocess_node = null;
225 for (var i = 0; i < arguments.length; i++) {
226 this.add_template(arguments[i]);
230 QWeb2.tools.extend(Engine.prototype, {
232 * Add a template to the engine
234 * @param {String|Document} template Template as string or url or DOM Document
235 * @param {Function} [callback] Called when the template is loaded, force async request
237 add_template : function(template, callback) {
239 this.templates_resources.push(template);
240 if (template.constructor === String) {
241 return this.load_xml(template, function (err, xDoc) {
244 return callback(err);
249 self.add_template(xDoc, callback);
252 var ec = (template.documentElement && template.documentElement.childNodes) || template.childNodes || [];
253 for (var i = 0; i < ec.length; i++) {
255 if (node.nodeType === 1) {
256 if (node.nodeName == 'parsererror') {
257 return this.tools.exception(node.innerText);
259 var name = node.getAttribute(this.prefix + '-name');
260 var extend = node.getAttribute(this.prefix + '-extend');
261 if (name && extend) {
262 // Clone template and extend it
263 if (!this.templates[extend]) {
264 return this.tools.exception("Can't clone undefined template " + extend);
266 this.templates[name] = this.templates[extend].cloneNode(true);
271 this.templates[name] = node;
272 this.compiled_templates[name] = null;
274 delete(this.compiled_templates[extend]);
275 if (this.extend_templates[extend]) {
276 this.extend_templates[extend].push(node);
278 this.extend_templates[extend] = [node];
284 callback(null, template);
288 load_xml : function(s, callback) {
290 var async = !!callback;
291 s = this.tools.trim(s);
292 if (s.charAt(0) === '<') {
293 var tpl = this.load_xml_string(s);
299 var req = this.get_xhr();
301 s += '?debug=' + (new Date()).getTime(); // TODO fme: do it properly in case there's already url parameters
303 req.open('GET', s, async);
305 req.onreadystatechange = function() {
306 if (req.readyState == 4) {
307 if (req.status == 200) {
308 callback(null, self._parse_from_request(req));
310 callback(new Error("Can't load template, http status " + req.status));
317 return this._parse_from_request(req);
321 _parse_from_request: function(req) {
322 var xDoc = req.responseXML;
324 if (!xDoc.documentElement) {
325 throw new Error("QWeb2: This xml document has no root document : " + xDoc.responseText);
327 if (xDoc.documentElement.nodeName == "parsererror") {
328 throw new Error("QWeb2: Could not parse document :" + xDoc.documentElement.childNodes[0].nodeValue);
332 return this.load_xml_string(req.responseText);
335 load_xml_string : function(s) {
336 if (window.DOMParser) {
337 var dp = new DOMParser();
338 var r = dp.parseFromString(s, "text/xml");
339 if (r.body && r.body.firstChild && r.body.firstChild.nodeName == 'parsererror') {
340 throw new Error("QWeb2: Could not parse document :" + r.body.innerText);
346 xDoc = new ActiveXObject("MSXML2.DOMDocument");
348 throw new Error("Could not find a DOM Parser: " + e.message);
351 xDoc.preserveWhiteSpace = true;
355 has_template : function(template) {
356 return !!this.templates[template];
358 get_xhr : function() {
359 if (window.XMLHttpRequest) {
360 return new window.XMLHttpRequest();
363 return new ActiveXObject('MSXML2.XMLHTTP.3.0');
365 throw new Error("Could not get XHR");
368 compile : function(node) {
369 var e = new QWeb2.Element(this, node);
370 var template = node.getAttribute(this.prefix + '-name');
371 return " /* 'this' refers to Qweb2.Engine instance */\n" +
372 " var context = { engine : this, template : " + (this.tools.js_escape(template)) + " };\n" +
373 " dict = dict || {};\n" +
374 " dict['__template__'] = '" + template + "';\n" +
376 " /* START TEMPLATE */" +
377 (this.debug ? "" : " try {\n") +
378 (e.compile()) + "\n" +
379 " /* END OF TEMPLATE */" +
380 (this.debug ? "" : " } catch(error) {\n" +
381 " if (console && console.exception) console.exception(error);\n" +
382 " context.engine.tools.exception('Runtime Error: ' + error, context);\n") +
383 (this.debug ? "" : " }\n") +
384 " return r.join('');";
386 render : function(template, dict) {
388 QWeb2.tools.extend(dict, this.default_dict);
389 /*if (this.debug && window['console'] !== undefined) {
390 console.time("QWeb render template " + template);
392 var r = this._render(template, dict);
393 /*if (this.debug && window['console'] !== undefined) {
394 console.timeEnd("QWeb render template " + template);
398 _render : function(template, dict) {
399 if (this.compiled_templates[template]) {
400 return this.compiled_templates[template].apply(this, [dict || {}]);
401 } else if (this.templates[template]) {
403 if (ext = this.extend_templates[template]) {
405 while (extend_node = ext.shift()) {
406 this.extend(template, extend_node);
409 var code = this.compile(this.templates[template]), tcompiled;
411 tcompiled = new Function(['dict'], code);
413 if (this.debug && window.console) {
416 this.tools.exception("Error evaluating template: " + error, { template: name });
419 this.tools.exception("Error evaluating template: (IE?)" + error, { template: name });
421 this.compiled_templates[template] = tcompiled;
422 return this.render(template, dict);
424 return this.tools.exception("Template '" + template + "' not found");
427 extend : function(template, extend_node) {
429 return this.tools.exception("Can't extend template " + template + " without jQuery");
431 var template_dest = this.templates[template];
432 for (var i = 0, ilen = extend_node.childNodes.length; i < ilen; i++) {
433 var child = extend_node.childNodes[i];
434 if (child.nodeType === 1) {
435 var jquery = child.getAttribute(this.prefix + '-jquery'),
436 operation = child.getAttribute(this.prefix + '-operation'),
438 error_msg = "Error while extending template '" + template;
440 target = this.jQuery(jquery, template_dest);
442 this.tools.exception(error_msg + "No expression given");
444 error_msg += "' (expression='" + jquery + "') : ";
446 var allowed_operations = "append,prepend,before,after,replace,inner".split(',');
447 if (this.tools.arrayIndexOf(allowed_operations, operation) == -1) {
448 this.tools.exception(error_msg + "Invalid operation : '" + operation + "'");
450 operation = {'replace' : 'replaceWith', 'inner' : 'html'}[operation] || operation;
451 target[operation](child.cloneNode(true).childNodes);
454 var f = new Function(['$', 'document'], this.tools.xml_node_to_string(child, true));
456 return this.tools.exception("Parse " + error_msg + error);
459 f.apply(target, [this.jQuery, template_dest.ownerDocument]);
461 return this.tools.exception("Runtime " + error_msg + error);
471 QWeb2.Element = (function() {
472 function Element(engine, node) {
473 this.engine = engine;
475 this.tag = node.tagName;
477 this.actions_done = [];
478 this.attributes = {};
483 this.process_children = true;
484 this.is_void_element = ~QWeb2.tools.arrayIndexOf(this.engine.void_elements, this.tag);
485 var childs = this.node.childNodes;
487 for (var i = 0, ilen = childs.length; i < ilen; i++) {
488 this.children.push(new QWeb2.Element(this.engine, childs[i]));
491 var attrs = this.node.attributes;
493 for (var j = 0, jlen = attrs.length; j < jlen; j++) {
495 var name = attr.name;
496 var m = name.match(new RegExp("^" + this.engine.prefix + "-(.+)"));
499 if (name === 'name') {
502 this.actions[name] = attr.value;
504 this.attributes[name] = attr.value;
508 if (this.engine.preprocess_node) {
509 this.engine.preprocess_node.call(this);
513 QWeb2.tools.extend(Element.prototype, {
514 compile : function() {
517 lines = this._compile().split('\n');
518 for (var i = 0, ilen = lines.length; i < ilen; i++) {
519 var m, line = lines[i];
520 if (m = line.match(/^(\s*)\/\/@string=(.*)/)) {
522 if (this.engine.debug) {
523 // Split string lines in indented r.push arguments
524 r.push((m[2].indexOf("\\n") != -1 ? "',\n\t" + m[1] + "'" : '') + m[2]);
529 r.push(m[1] + "r.push('" + m[2]);
542 _compile : function() {
543 switch (this.node.nodeType) {
546 this.top_string(this.node.data);
549 this.compile_element();
551 var r = this._top.join('');
552 if (this.process_children) {
553 for (var i = 0, ilen = this.children.length; i < ilen; i++) {
554 var child = this.children[i];
555 child._indent = this._indent;
556 r += child._compile();
559 r += this._bottom.join('');
562 format_expression : function(e) {
563 /* Naive format expression builder. Replace reserved words and variables to dict[variable]
564 * Does not handle spaces before dot yet, and causes problems for anonymous functions. Use t-js="" for that */
565 if (QWeb2.expressions_cache[e]) {
566 return QWeb2.expressions_cache[e];
568 var chars = e.split(''),
574 for (var i = 0, ilen = chars.length; i < ilen; i++) {
576 if (instring.length) {
577 if (c === instring && chars[i - 1] !== "\\") {
580 } else if (c === '"' || c === "'") {
582 } else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
586 } else if (c.match(/\W/) && invar.length) {
587 // TODO: Should check for possible spaces before dot
588 if (chars[invar_pos - 1] !== '.' && QWeb2.tools.arrayIndexOf(this.engine.reserved_words, invar) < 0) {
589 invar = this.engine.word_replacement[invar] || ("dict['" + invar + "']");
593 } else if (invar.length) {
600 QWeb2.expressions_cache[e] = r;
603 string_interpolation : function(s) {
608 function append_literal(s) {
609 s && r.push(_this.engine.tools.js_escape(s));
612 var re = /#{(.*?)}/g, start = 0, r = [], m;
613 while (m = re.exec(s)) {
614 // extract literal string between previous and current match
615 append_literal(s.slice(start, re.lastIndex - m[0].length));
616 // extract matched expression
617 r.push('(' + this.format_expression(m[1]) + ')');
618 // update position of new matching
619 start = re.lastIndex;
621 // remaining text after last expression
622 append_literal(s.slice(start));
624 return r.join(' + ');
626 indent : function() {
627 return this._indent++;
629 dedent : function() {
630 if (this._indent !== 0) {
631 return this._indent--;
634 get_indent : function() {
635 return new Array(this._indent + 1).join("\t");
638 return this._top.push(this.get_indent() + s + '\n');
640 top_string : function(s) {
641 return this._top.push(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
643 bottom : function(s) {
644 return this._bottom.unshift(this.get_indent() + s + '\n');
646 bottom_string : function(s) {
647 return this._bottom.unshift(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
649 compile_element : function() {
650 for (var i = 0, ilen = this.engine.actions_precedence.length; i < ilen; i++) {
651 var a = this.engine.actions_precedence[i];
652 if (a in this.actions) {
653 var value = this.actions[a];
654 var key = 'compile_action_' + a;
657 } else if (this.engine[key]) {
658 this.engine[key].call(this, value);
660 this.engine.tools.exception("No handler method for action '" + a + "'");
664 if (this.tag.toLowerCase() !== this.engine.prefix) {
665 var tag = "<" + this.tag;
666 for (var a in this.attributes) {
667 tag += this.engine.tools.gen_attribute([a, this.attributes[a]]);
669 this.top_string(tag);
670 if (this.actions.att) {
671 this.top("r.push(context.engine.tools.gen_attribute(" + (this.format_expression(this.actions.att)) + "));");
673 for (var a in this.actions) {
674 var v = this.actions[a];
675 var m = a.match(/att-(.+)/);
677 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.format_expression(v)) + ")]));");
679 var m = a.match(/attf-(.+)/);
681 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.string_interpolation(v)) + ")]));");
684 if (this.actions.opentag === 'true' || (!this.children.length && this.is_void_element)) {
685 // We do not enforce empty content on void elements
686 // because QWeb rendering is not necessarily html.
687 this.top_string("/>");
689 this.top_string(">");
690 this.bottom_string("</" + this.tag + ">");
694 compile_action_if : function(value) {
695 this.top("if (" + (this.format_expression(value)) + ") {");
699 compile_action_foreach : function(value) {
700 var as = this.actions['as'] || value.replace(/[^a-zA-Z0-9]/g, '_');
701 //TODO: exception if t-as not valid
702 this.top("context.engine.tools.foreach(context, " + (this.format_expression(value)) + ", " + (this.engine.tools.js_escape(as)) + ", dict, function(context, dict) {");
706 compile_action_call : function(value) {
707 var _import = this.actions['import'] || '';
708 if (this.children.length === 0) {
709 return this.top("r.push(context.engine.tools.call(context, " + (this.engine.tools.js_escape(value)) + ", dict, " + (this.engine.tools.js_escape(_import)) + "));");
711 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) {");
714 this.top("var r = [];");
715 return this.bottom("return r.join('');");
718 compile_action_set : function(value) {
719 var variable = this.format_expression(value);
720 if (this.actions['value']) {
721 if (this.children.length) {
722 this.engine.tools.warning("@set with @value plus node chidren found. Children are ignored.");
724 this.top(variable + " = (" + (this.format_expression(this.actions['value'])) + ");");
725 this.process_children = false;
727 if (this.children.length === 0) {
728 this.top(variable + " = '';");
729 } else if (this.children.length === 1 && this.children[0].node.nodeType === 3) {
730 this.top(variable + " = " + (this.engine.tools.js_escape(this.children[0].node.data)) + ";");
731 this.process_children = false;
733 this.top(variable + " = (function(dict) {");
734 this.bottom("})(dict);");
736 this.top("var r = [];");
737 this.bottom("return r.join('');");
741 compile_action_esc : function(value) {
742 this.top("r.push(context.engine.tools.html_escape(" + (this.format_expression(value)) + "));");
744 compile_action_raw : function(value) {
745 this.top("r.push(" + (this.format_expression(value)) + ");");
747 compile_action_js : function(value) {
748 this.top("(function(" + value + ") {");
749 this.bottom("})(dict);");
751 var lines = this.engine.tools.xml_node_to_string(this.node, true).split(/\r?\n/);
752 for (var i = 0, ilen = lines.length; i < ilen; i++) {
755 this.process_children = false;
757 compile_action_debug : function(value) {
758 this.top("debugger;");
760 compile_action_log : function(value) {
761 this.top("console.log(" + this.format_expression(value) + ");");