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[0] = callback(context, new_dict);
142 return context.engine._render(template, new_dict);
144 foreach: function(context, enu, as, old_dict, callback) {
146 var index, jlen, cur;
147 var size, new_dict = this.extend({}, old_dict);
148 new_dict[as + "_all"] = enu;
149 var as_value = as + "_value",
150 as_index = as + "_index",
151 as_first = as + "_first",
152 as_last = as + "_last",
153 as_parity = as + "_parity";
154 if (size = enu.length) {
155 new_dict[as + "_size"] = size;
156 for (index = 0, jlen = enu.length; index < jlen; index++) {
158 new_dict[as_value] = cur;
159 new_dict[as_index] = index;
160 new_dict[as_first] = index === 0;
161 new_dict[as_last] = index + 1 === size;
162 new_dict[as_parity] = (index % 2 == 1 ? 'odd' : 'even');
163 if (cur.constructor === Object) {
164 this.extend(new_dict, cur);
167 callback(context, new_dict);
169 } else if (enu.constructor == Number) {
171 for (var i = 0; i < enu; i++) {
174 this.foreach(context, _enu, as, old_dict, callback);
178 if (enu.hasOwnProperty(k)) {
180 new_dict[as_value] = cur;
181 new_dict[as_index] = index;
182 new_dict[as_first] = index === 0;
183 new_dict[as_parity] = (index % 2 == 1 ? 'odd' : 'even');
185 callback(context, new_dict);
191 this.exception("No enumerator given to foreach", context);
197 QWeb2.Engine = (function() {
199 // TODO: handle prefix at template level : t-prefix="x", don't forget to lowercase it
202 this.templates_resources = []; // TODO: implement this.reload()
204 this.compiled_templates = {};
205 this.extend_templates = {};
206 this.default_dict = {};
207 this.tools = QWeb2.tools;
208 this.jQuery = window.jQuery;
209 this.reserved_words = QWeb2.RESERVED_WORDS.slice(0);
210 this.actions_precedence = QWeb2.ACTIONS_PRECEDENCE.slice(0);
211 this.void_elements = QWeb2.VOID_ELEMENTS.slice(0);
212 this.word_replacement = QWeb2.tools.extend({}, QWeb2.WORD_REPLACEMENT);
213 this.preprocess_node = null;
214 for (var i = 0; i < arguments.length; i++) {
215 this.add_template(arguments[i]);
219 QWeb2.tools.extend(Engine.prototype, {
221 * Add a template to the engine
223 * @param {String|Document} template Template as string or url or DOM Document
224 * @param {Function} [callback] Called when the template is loaded, force async request
226 add_template : function(template, callback) {
228 this.templates_resources.push(template);
229 if (template.constructor === String) {
230 return this.load_xml(template, function (err, xDoc) {
233 return callback(err);
238 self.add_template(xDoc, callback);
241 var ec = (template.documentElement && template.documentElement.childNodes) || template.childNodes || [];
242 for (var i = 0; i < ec.length; i++) {
244 if (node.nodeType === 1) {
245 if (node.nodeName == 'parsererror') {
246 return this.tools.exception(node.innerText);
248 var name = node.getAttribute(this.prefix + '-name');
249 var extend = node.getAttribute(this.prefix + '-extend');
250 if (name && extend) {
251 // Clone template and extend it
252 if (!this.templates[extend]) {
253 return this.tools.exception("Can't clone undefined template " + extend);
255 this.templates[name] = this.templates[extend].cloneNode(true);
260 this.templates[name] = node;
261 this.compiled_templates[name] = null;
263 delete(this.compiled_templates[extend]);
264 if (this.extend_templates[extend]) {
265 this.extend_templates[extend].push(node);
267 this.extend_templates[extend] = [node];
273 callback(null, template);
277 load_xml : function(s, callback) {
279 var async = !!callback;
280 s = this.tools.trim(s);
281 if (s.charAt(0) === '<') {
282 var tpl = this.load_xml_string(s);
288 var req = this.get_xhr();
290 s += '?debug=' + (new Date()).getTime(); // TODO fme: do it properly in case there's already url parameters
292 req.open('GET', s, async);
294 req.onreadystatechange = function() {
295 if (req.readyState == 4) {
296 if (req.status == 200) {
297 callback(null, self._parse_from_request(req));
299 callback(new Error("Can't load template, http status " + req.status));
306 return this._parse_from_request(req);
310 _parse_from_request: function(req) {
311 var xDoc = req.responseXML;
313 if (!xDoc.documentElement) {
314 throw new Error("QWeb2: This xml document has no root document : " + xDoc.responseText);
316 if (xDoc.documentElement.nodeName == "parsererror") {
317 throw new Error("QWeb2: Could not parse document :" + xDoc.documentElement.childNodes[0].nodeValue);
321 return this.load_xml_string(req.responseText);
324 load_xml_string : function(s) {
325 if (window.DOMParser) {
326 var dp = new DOMParser();
327 var r = dp.parseFromString(s, "text/xml");
328 if (r.body && r.body.firstChild && r.body.firstChild.nodeName == 'parsererror') {
329 throw new Error("QWeb2: Could not parse document :" + r.body.innerText);
335 xDoc = new ActiveXObject("MSXML2.DOMDocument");
337 throw new Error("Could not find a DOM Parser: " + e.message);
340 xDoc.preserveWhiteSpace = true;
344 has_template : function(template) {
345 return !!this.templates[template];
347 get_xhr : function() {
348 if (window.XMLHttpRequest) {
349 return new window.XMLHttpRequest();
352 return new ActiveXObject('MSXML2.XMLHTTP.3.0');
354 throw new Error("Could not get XHR");
357 compile : function(node) {
358 var e = new QWeb2.Element(this, node);
359 var template = node.getAttribute(this.prefix + '-name');
360 return " /* 'this' refers to Qweb2.Engine instance */\n" +
361 " var context = { engine : this, template : " + (this.tools.js_escape(template)) + " };\n" +
362 " dict = dict || {};\n" +
363 " dict['__template__'] = '" + template + "';\n" +
365 " /* START TEMPLATE */" +
366 (this.debug ? "" : " try {\n") +
367 (e.compile()) + "\n" +
368 " /* END OF TEMPLATE */" +
369 (this.debug ? "" : " } catch(error) {\n" +
370 " if (console && console.exception) console.exception(error);\n" +
371 " context.engine.tools.exception('Runtime Error: ' + error, context);\n") +
372 (this.debug ? "" : " }\n") +
373 " return r.join('');";
375 render : function(template, dict) {
377 QWeb2.tools.extend(dict, this.default_dict);
378 /*if (this.debug && window['console'] !== undefined) {
379 console.time("QWeb render template " + template);
381 var r = this._render(template, dict);
382 /*if (this.debug && window['console'] !== undefined) {
383 console.timeEnd("QWeb render template " + template);
387 _render : function(template, dict) {
388 if (this.compiled_templates[template]) {
389 return this.compiled_templates[template].apply(this, [dict || {}]);
390 } else if (this.templates[template]) {
392 if (ext = this.extend_templates[template]) {
394 while (extend_node = ext.shift()) {
395 this.extend(template, extend_node);
398 var code = this.compile(this.templates[template]), tcompiled;
400 tcompiled = new Function(['dict'], code);
402 if (this.debug && window.console) {
405 this.tools.exception("Error evaluating template: " + error, { template: name });
408 this.tools.exception("Error evaluating template: (IE?)" + error, { template: name });
410 this.compiled_templates[template] = tcompiled;
411 return this.render(template, dict);
413 return this.tools.exception("Template '" + template + "' not found");
416 extend : function(template, extend_node) {
417 var jQuery = this.jQuery;
419 return this.tools.exception("Can't extend template " + template + " without jQuery");
421 var template_dest = this.templates[template];
422 for (var i = 0, ilen = extend_node.childNodes.length; i < ilen; i++) {
423 var child = extend_node.childNodes[i];
424 if (child.nodeType === 1) {
425 var jquery = child.getAttribute(this.prefix + '-jquery'),
426 operation = child.getAttribute(this.prefix + '-operation'),
428 error_msg = "Error while extending template '" + template;
430 target = jQuery(jquery, template_dest);
432 this.tools.exception(error_msg + "No expression given");
434 error_msg += "' (expression='" + jquery + "') : ";
436 var allowed_operations = "append,prepend,before,after,replace,inner,attributes".split(',');
437 if (this.tools.arrayIndexOf(allowed_operations, operation) == -1) {
438 this.tools.exception(error_msg + "Invalid operation : '" + operation + "'");
440 operation = {'replace' : 'replaceWith', 'inner' : 'html'}[operation] || operation;
441 if (operation === 'attributes') {
442 jQuery('attribute', child).each(function () {
443 var attrib = jQuery(this);
444 target.attr(attrib.attr('name'), attrib.text());
447 target[operation](child.cloneNode(true).childNodes);
451 var f = new Function(['$', 'document'], this.tools.xml_node_to_string(child, true));
453 return this.tools.exception("Parse " + error_msg + error);
456 f.apply(target, [jQuery, template_dest.ownerDocument]);
458 return this.tools.exception("Runtime " + error_msg + error);
468 QWeb2.Element = (function() {
469 function Element(engine, node) {
470 this.engine = engine;
472 this.tag = node.tagName;
474 this.actions_done = [];
475 this.attributes = {};
480 this.process_children = true;
481 this.is_void_element = ~QWeb2.tools.arrayIndexOf(this.engine.void_elements, this.tag);
482 var childs = this.node.childNodes;
484 for (var i = 0, ilen = childs.length; i < ilen; i++) {
485 this.children.push(new QWeb2.Element(this.engine, childs[i]));
488 var attrs = this.node.attributes;
490 for (var j = 0, jlen = attrs.length; j < jlen; j++) {
492 var name = attr.name;
493 var m = name.match(new RegExp("^" + this.engine.prefix + "-(.+)"));
496 if (name === 'name') {
499 this.actions[name] = attr.value;
501 this.attributes[name] = attr.value;
505 if (this.engine.preprocess_node) {
506 this.engine.preprocess_node.call(this);
510 QWeb2.tools.extend(Element.prototype, {
511 compile : function() {
514 lines = this._compile().split('\n');
515 for (var i = 0, ilen = lines.length; i < ilen; i++) {
516 var m, line = lines[i];
517 if (m = line.match(/^(\s*)\/\/@string=(.*)/)) {
519 if (this.engine.debug) {
520 // Split string lines in indented r.push arguments
521 r.push((m[2].indexOf("\\n") != -1 ? "',\n\t" + m[1] + "'" : '') + m[2]);
526 r.push(m[1] + "r.push('" + m[2]);
539 _compile : function() {
540 switch (this.node.nodeType) {
543 this.top_string(this.node.data);
546 this.compile_element();
548 var r = this._top.join('');
549 if (this.process_children) {
550 for (var i = 0, ilen = this.children.length; i < ilen; i++) {
551 var child = this.children[i];
552 child._indent = this._indent;
553 r += child._compile();
556 r += this._bottom.join('');
559 format_expression : function(e) {
560 /* Naive format expression builder. Replace reserved words and variables to dict[variable]
561 * Does not handle spaces before dot yet, and causes problems for anonymous functions. Use t-js="" for that */
562 if (QWeb2.expressions_cache[e]) {
563 return QWeb2.expressions_cache[e];
565 var chars = e.split(''),
571 for (var i = 0, ilen = chars.length; i < ilen; i++) {
573 if (instring.length) {
574 if (c === instring && chars[i - 1] !== "\\") {
577 } else if (c === '"' || c === "'") {
579 } else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
583 } else if (c.match(/\W/) && invar.length) {
584 // TODO: Should check for possible spaces before dot
585 if (chars[invar_pos - 1] !== '.' && QWeb2.tools.arrayIndexOf(this.engine.reserved_words, invar) < 0) {
586 invar = this.engine.word_replacement[invar] || ("dict['" + invar + "']");
590 } else if (invar.length) {
597 QWeb2.expressions_cache[e] = r;
600 format_str: function (e) {
604 return this.format_expression(e);
606 string_interpolation : function(s) {
611 function append_literal(s) {
612 s && r.push(_this.engine.tools.js_escape(s));
615 var re = /(?:#{(.+?)}|{{(.+?)}})/g, start = 0, r = [], m;
616 while (m = re.exec(s)) {
617 // extract literal string between previous and current match
618 append_literal(s.slice(start, re.lastIndex - m[0].length));
619 // extract matched expression
620 r.push('(' + this.format_str(m[2] || m[1]) + ')');
621 // update position of new matching
622 start = re.lastIndex;
624 // remaining text after last expression
625 append_literal(s.slice(start));
627 return r.join(' + ');
629 indent : function() {
630 return this._indent++;
632 dedent : function() {
633 if (this._indent !== 0) {
634 return this._indent--;
637 get_indent : function() {
638 return new Array(this._indent + 1).join("\t");
641 return this._top.push(this.get_indent() + s + '\n');
643 top_string : function(s) {
644 return this._top.push(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
646 bottom : function(s) {
647 return this._bottom.unshift(this.get_indent() + s + '\n');
649 bottom_string : function(s) {
650 return this._bottom.unshift(this.get_indent() + "//@string=" + this.engine.tools.js_escape(s, true) + '\n');
652 compile_element : function() {
653 for (var i = 0, ilen = this.engine.actions_precedence.length; i < ilen; i++) {
654 var a = this.engine.actions_precedence[i];
655 if (a in this.actions) {
656 var value = this.actions[a];
657 var key = 'compile_action_' + a;
660 } else if (this.engine[key]) {
661 this.engine[key].call(this, value);
663 this.engine.tools.exception("No handler method for action '" + a + "'");
667 if (this.tag.toLowerCase() !== this.engine.prefix) {
668 var tag = "<" + this.tag;
669 for (var a in this.attributes) {
670 tag += this.engine.tools.gen_attribute([a, this.attributes[a]]);
672 this.top_string(tag);
673 if (this.actions.att) {
674 this.top("r.push(context.engine.tools.gen_attribute(" + (this.format_expression(this.actions.att)) + "));");
676 for (var a in this.actions) {
677 var v = this.actions[a];
678 var m = a.match(/att-(.+)/);
680 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.format_expression(v)) + ")]));");
682 var m = a.match(/attf-(.+)/);
684 this.top("r.push(context.engine.tools.gen_attribute(['" + m[1] + "', (" + (this.string_interpolation(v)) + ")]));");
687 if (this.actions.opentag === 'true' || (!this.children.length && this.is_void_element)) {
688 // We do not enforce empty content on void elements
689 // because QWeb rendering is not necessarily html.
690 this.top_string("/>");
692 this.top_string(">");
693 this.bottom_string("</" + this.tag + ">");
697 compile_action_if : function(value) {
698 this.top("if (" + (this.format_expression(value)) + ") {");
702 compile_action_foreach : function(value) {
703 var as = this.actions['as'] || value.replace(/[^a-zA-Z0-9]/g, '_');
704 //TODO: exception if t-as not valid
705 this.top("context.engine.tools.foreach(context, " + (this.format_expression(value)) + ", " + (this.engine.tools.js_escape(as)) + ", dict, function(context, dict) {");
709 compile_action_call : function(value) {
710 if (this.children.length === 0) {
711 return this.top("r.push(context.engine.tools.call(context, " + (this.engine.tools.js_escape(value)) + ", dict));");
713 this.top("r.push(context.engine.tools.call(context, " + (this.engine.tools.js_escape(value)) + ", dict, null, function(context, dict) {");
716 this.top("var r = [];");
717 return this.bottom("return r.join('');");
720 compile_action_set : function(value) {
721 var variable = this.format_expression(value);
722 if (this.actions['value']) {
723 if (this.children.length) {
724 this.engine.tools.warning("@set with @value plus node chidren found. Children are ignored.");
726 this.top(variable + " = (" + (this.format_expression(this.actions['value'])) + ");");
727 this.process_children = false;
729 if (this.children.length === 0) {
730 this.top(variable + " = '';");
731 } else if (this.children.length === 1 && this.children[0].node.nodeType === 3) {
732 this.top(variable + " = " + (this.engine.tools.js_escape(this.children[0].node.data)) + ";");
733 this.process_children = false;
735 this.top(variable + " = (function(dict) {");
736 this.bottom("})(dict);");
738 this.top("var r = [];");
739 this.bottom("return r.join('');");
743 compile_action_esc : function(value) {
744 this.top("r.push(context.engine.tools.html_escape("
745 + this.format_expression(value)
748 compile_action_raw : function(value) {
749 this.top("r.push(" + (this.format_str(value)) + ");");
751 compile_action_js : function(value) {
752 this.top("(function(" + value + ") {");
753 this.bottom("})(dict);");
755 var lines = this.engine.tools.xml_node_to_string(this.node, true).split(/\r?\n/);
756 for (var i = 0, ilen = lines.length; i < ilen; i++) {
759 this.process_children = false;
761 compile_action_debug : function(value) {
762 this.top("debugger;");
764 compile_action_log : function(value) {
765 this.top("console.log(" + this.format_expression(value) + ");");