diff --git a/mustache.js b/mustache.js index 703e97a..e2b52c8 100644 --- a/mustache.js +++ b/mustache.js @@ -9,8 +9,24 @@ var Mustache = function() { this.message = message; } - var Renderer = function(send_func) { - this.send_func = send_func; + var Renderer = function(send_func, mode) { + this.user_send_func = send_func; + if (mode==='interpreter' || !mode) { + this.commandSet = this.interpreter; + + this.send_func = function(text) { + this.user_send_func(text); + } + } else if (mode==='compiler') { + this.commandSet = this.compiler; + + this.cached_output = []; + this.send_func = function(text) { + this.cached_output.push(text); + } + } else { + throw new ParserException('Unsupported mode.'); + } this.pragmas = {}; }; @@ -120,6 +136,8 @@ var Mustache = function() { // make sure the parser finished at an appropriate terminal state if (state!=='text') { this.stateMachine['endOfDoc'].call(this, parserContext, contextStack); + } else { + this.commandSet.text.call(this); } }, @@ -142,6 +160,8 @@ var Mustache = function() { text: function(parserContext, contextStack) { switch (parserContext.token()) { case parserContext.openTag: + this.commandSet.text.call(this); + return 'openMustache'; default: this.send_func(parserContext.token()); @@ -366,13 +386,13 @@ var Mustache = function() { parserContext.stack.push({sectionType:command.tagType, key:key, content:[], depth:1}); return 'endSectionScan'; case 'variable': - this.render_variable(key, contextStack); + this.commandSet.variable.call(this, key, contextStack); return 'text'; case 'unescapedVariable': - this.render_unescaped_variable(key, contextStack); + this.commandSet.unescaped_variable.call(this, key, contextStack); return 'text'; case 'partial': - this.render_partial(key, + this.commandSet.partial.call(this, key, contextStack, parserContext.partials, parserContext.openTag, @@ -383,7 +403,7 @@ var Mustache = function() { var section = parserContext.stack.pop(); if (--section.depth === 0) { if (section.key === key) { - this.render_section(section.sectionType, + this.commandSet.section.call(this, section.sectionType, section.content.join(''), key, contextStack, @@ -465,109 +485,279 @@ var Mustache = function() { return Object.prototype.toString.call(a) === '[object Array]'; }, - render_variable: function(key, contextStack) { - function escapeHTML(str) { - return ('' + str).replace(/&/g,'&') - .replace(//g,'>'); - } + interpreter: { + text: function() { + // in this implementation, rendering text is meaningless + // since the send_func method simply forwards to user_send_func + }, + variable: function(key, contextStack) { + function escapeHTML(str) { + return ('' + str).replace(/&/g,'&') + .replace(//g,'>'); + } - var result = this.find_in_stack(key, contextStack); - if (result!==undefined) { - this.send_func(escapeHTML(result)); - } - }, - render_unescaped_variable: function(key, contextStack) { - var result = this.find_in_stack(key, contextStack); - if (result!==undefined) { - this.send_func(result); - } - }, - render_partial: function(key, contextStack, partials, openTag, closeTag) { - if (!partials || partials[key] === undefined) { - throw new ParserException('Unknown partial \'' + key + '\''); - } - - var res = this.find_in_stack(key, contextStack); - if (this.is_object(res)) { - contextStack.push(res); - } - - var tokens = this.tokenize(partials[key], openTag, closeTag); + var result = this.find_in_stack(key, contextStack); + if (result!==undefined) { + this.user_send_func(escapeHTML(result)); + } + }, + unescaped_variable: function(key, contextStack) { + var result = this.find_in_stack(key, contextStack); + if (result!==undefined) { + this.user_send_func(result); + } + }, + partial: function(key, contextStack, partials, openTag, closeTag) { + if (!partials || partials[key] === undefined) { + throw new ParserException('Unknown partial \'' + key + '\''); + } + + var res = this.find_in_stack(key, contextStack); + if (this.is_object(res)) { + contextStack.push(res); + } + + var tokens = this.tokenize(partials[key], openTag, closeTag); - this.parse(this.createParserContext(tokens, partials, openTag, closeTag), contextStack); + this.parse(this.createParserContext(tokens, partials, openTag, closeTag), contextStack); + + if (this.is_object(res)) { + contextStack.pop(); + } + }, + section: function(sectionType, mustacheFragment, key, contextStack, partials, openTag, closeTag) { + // by @langalex, support for arrays of strings + var that = this; + function create_context(_context) { + if(that.is_object(_context)) { + return _context; + } else { + var iterator = '.'; + + if(that.pragmas["IMPLICIT-ITERATOR"] && + that.pragmas["IMPLICIT-ITERATOR"].iterator) { + iterator = that.pragmas["IMPLICIT-ITERATOR"].iterator; + } + var ctx = {}; + ctx[iterator] = _context; + return ctx; + } + } - if (this.is_object(res)) { - contextStack.pop(); - } - }, - render_section: function(sectionType, mustacheFragment, key, contextStack, partials, openTag, closeTag) { - // by @langalex, support for arrays of strings - var that = this; - function create_context(_context) { - if(that.is_object(_context)) { - return _context; - } else { - var iterator = '.'; - - if(that.pragmas["IMPLICIT-ITERATOR"] && - that.pragmas["IMPLICIT-ITERATOR"].iterator) { - iterator = that.pragmas["IMPLICIT-ITERATOR"].iterator; + var value = this.find_in_stack(key, contextStack); + + var tokens; + if (sectionType==='invertedSection') { + if (!value || this.is_array(value) && value.length === 0) { + // false or empty list, render it + tokens = this.tokenize(mustacheFragment, openTag, closeTag); + + this.parse(this.createParserContext(tokens, partials, openTag, closeTag), contextStack); } - var ctx = {}; - ctx[iterator] = _context; - return ctx; + } else if (sectionType==='section') { + if (this.is_array(value)) { // Enumerable, Let's loop! + tokens = this.tokenize(mustacheFragment, openTag, closeTag); + + for (var i=0, n=value.length; i/g,'>'); + } - var tokens; - if (sectionType==='invertedSection') { - if (!value || this.is_array(value) && value.length === 0) { - // false or empty list, render it - tokens = this.tokenize(mustacheFragment, openTag, closeTag); - - this.parse(this.createParserContext(tokens, partials, openTag, closeTag), contextStack); + var that = this; + this.user_send_func(function(contextStack, send_func) { + var result = that.find_in_stack(key, contextStack); + if (result!==undefined) { + send_func(escapeHTML(result)); + } + }); + }, + unescaped_variable: function(key/*, contextStack*/) { + var that = this; + this.user_send_func(function(contextStack, send_func) { + var result = that.find_in_stack(key, contextStack); + if (result!==undefined) { + send_func(result); + } + }); + }, + partial: function(key, reserved/*contextStack*/, partials, openTag, closeTag) { + if (!partials || partials[key] === undefined) { + throw new ParserException('Unknown partial \'' + key + '\''); } - } else if (sectionType==='section') { - if (this.is_array(value)) { // Enumerable, Let's loop! - tokens = this.tokenize(mustacheFragment, openTag, closeTag); + + if (!this.is_function(partials[key])) { + var old_user_send_func = this.user_send_func; + var commands = []; + this.user_send_func = function(command) { commands.push(command); }; + + var tokens = this.tokenize(partials[key], openTag, closeTag); + partials[key] = function() {}; // blank out the paritals so that infinite recursion doesn't happen + this.parse(this.createParserContext(tokens, partials, openTag, closeTag), reserved); + + this.user_send_func = old_user_send_func; - for (var i=0, n=value.length; ihi}}', undefined, {hi:'{{p}}'}), '', 'Partial but no view'); +}); + +test("Parser", function() { + expect(4); + + // matches whitespace_partial.html + equals( + Mustache.to_html( + '

{{ greeting }}

\n{{> partial }}\n

{{ farewell }}

', + { + greeting: function() { + return "Welcome"; + }, + + farewell: function() { + return "Fair enough, right?"; + }, + + partial: { + name: "Chris", + value: 10000, + taxed_value: function() { + return this.value - (this.value * 0.4); + }, + in_ca: true + } + }, + {partial:'Hello {{ name}}\nYou have just won ${{value }}!\n{{# in_ca }}\nWell, ${{ taxed_value }}, after taxes.\n{{/ in_ca }}\n'} + ), + '

Welcome

\nHello Chris\nYou have just won $10000!\n\nWell, $6000, after taxes.\n\n\n

Fair enough, right?

', + 'Whitespace in Tag names' + ); + + equals( + Mustache.to_html( + '{{tag1}}\n\n\n{{tag2}}\n', + { tag1: 'Hello', tag2: 'World' }, + {} + ), + 'Hello\n\n\nWorld\n', + 'Preservation of white space' + ); + + try { + Mustache.to_html( + '{{=tag1}}', + { tag1: 'Hello' }, + {} + ); + + ok(false); + } catch (e) { + equals(e.message, 'Unexpected end of document.'); + } + + var partials = { 'partial' : '{{key}}' }; + Mustache.compile('{{>partial}}', partials ); + + equals(partials['partial'], '{{key}}', 'Partials compiler must be non-destructive'); +}); + +test("Basic Variables", function() { + expect(3); + + // matches escaped.html + equals( + Mustache.to_html( + '

{{title}}

\nBut not {{entities}}.\n', + { + title: function() { + return "Bear > Shark"; + }, + entities: """ + }, + {} + ), + '

Bear > Shark

\nBut not ".\n', + 'HTML Escaping' + ); + + // matches null_string.html + equals( + Mustache.to_html( + 'Hello {{name}}\nglytch {{glytch}}\nbinary {{binary}}\nvalue {{value}}\nnumeric {{numeric}}', + { + name: "Elise", + glytch: true, + binary: false, + value: null, + numeric: function() { + return NaN; + } + }, + {} + ), + 'Hello Elise\nglytch true\nbinary false\nvalue \nnumeric NaN', + 'Different variable types' + ); + + // matches two_in_a_row.html + equals( + Mustache.to_html( + '{{greeting}}, {{name}}!', + { + name: "Joe", + greeting: "Welcome" + }, + {} + ), + 'Welcome, Joe!' + ); + +}); + +test("'{' or '&' (Unescaped Variable)", function() { + expect(2); + + // matches unescaped.html + equals( + Mustache.to_html( + '

{{{title}}}

', + { + title: function() { + return "Bear > Shark"; + } + }, + {} + ), + '

Bear > Shark

', + '{ character' + ); + + equals( + Mustache.to_html( + '

{{&title}}

', + { + title: function() { + return "Bear > Shark"; + } + }, + {} + ), + '

Bear > Shark

', + '& character' + ); +}); + +test("'#' (Sections)", function() { + expect(7); + + // matches array_of_partials_implicit_partial.html + equals( + Mustache.to_html( + 'Here is some stuff!\n{{#numbers}}\n{{>partial}}\n{{/numbers}}', + { numbers: ['1', '2', '3', '4'] }, + { partial: '{{.}}' } + ), + 'Here is some stuff!\n\n1\n\n2\n\n3\n\n4\n', + 'Array of Partials (Implicit)' + ); + + // matches array_of_partials_partial.html + equals( + Mustache.to_html( + 'Here is some stuff!\n{{#numbers}}\n{{>partial}}\n{{/numbers}}', + { numbers: [{i: '1'}, {i: '2'}, {i: '3'}, {i: '4'}] }, + { partial: '{{i}}' } + ), + 'Here is some stuff!\n\n1\n\n2\n\n3\n\n4\n', + 'Array of Partials (Explicit)' + ); + + // matches array_of_strings.html + equals( + Mustache.to_html( + '{{#array_of_strings}}{{.}} {{/array_of_strings}}', + {array_of_strings: ['hello', 'world']}, + {} + ), + 'hello world ', + 'Array of Strings' + ); + + // mathces higher_order_sections.html + equals( + Mustache.to_html( + '{{#bolder}}Hi {{name}}.{{/bolder}}\n', + { + "name": "Tater", + "helper": "To tinker?", + "bolder": function() { + return function(text, render) { + return "" + render(text) + ' ' + this.helper; + } + } + }, + {} + ), + 'Hi Tater. To tinker?\n' + ); + + // matches recursion_with_same_names.html + equals( + Mustache.to_html( + '{{ name }}\n{{ description }}\n\n{{#terms}}\n {{name}}\n {{index}}\n{{/terms}}\n', + { + name: 'name', + description: 'desc', + terms: [ + {name: 't1', index: 0}, + {name: 't2', index: 1}, + ] + }, + {} + ), + 'name\ndesc\n\n\n t1\n 0\n\n t2\n 1\n\n' + ); + + // matches reuse_of_enumerables.html + equals( + Mustache.to_html( + '{{#terms}}\n {{name}}\n {{index}}\n{{/terms}}\n{{#terms}}\n {{name}}\n {{index}}\n{{/terms}}\n', + { + terms: [ + {name: 't1', index: 0}, + {name: 't2', index: 1}, + ] + }, + {} + ), + '\n t1\n 0\n\n t2\n 1\n\n\n t1\n 0\n\n t2\n 1\n\n', + 'Lazy match of Section and Inverted Section' + ); + + // matches section_as_context.html + equals( + Mustache.to_html( + '{{#a_object}}\n

{{title}}

\n

{{description}}

\n \n{{/a_object}}\n', + { + a_object: { + title: 'this is an object', + description: 'one of its attributes is a list', + a_list: [{label: 'listitem1'}, {label: 'listitem2'}] + } + }, + {} + ), + '\n

this is an object

\n

one of its attributes is a list

\n \n\n', + 'Lazy match of Section and Inverted Section' + ); +}); + +test("'^' (Inverted Section)", function() { + expect(1); + + // matches inverted_section.html + equals( + Mustache.to_html( + '{{#repo}}{{name}}{{/repo}}\n{{^repo}}No repos :({{/repo}}\n', + { + "repo": [] + }, + {} + ), + '\nNo repos :(\n' + ); +}); + +test("'>' (Partials)", function() { + expect(5); + + // matches view_partial.html + equals( + Mustache.to_html( + '

{{greeting}}

\n{{>partial}}\n

{{farewell}}

', + { + greeting: function() { + return "Welcome"; + }, + + farewell: function() { + return "Fair enough, right?"; + }, + + partial: { + name: "Chris", + value: 10000, + taxed_value: function() { + return this.value - (this.value * 0.4); + }, + in_ca: true + } + }, + {partial: 'Hello {{name}}\nYou have just won ${{value}}!\n{{#in_ca}}\nWell, ${{ taxed_value }}, after taxes.\n{{/in_ca}}\n'} + ), + '

Welcome

\nHello Chris\nYou have just won $10000!\n\nWell, $6000, after taxes.\n\n\n

Fair enough, right?

' + ); + + // matches array_partial.html + equals( + Mustache.to_html( + '{{>partial}}', + { + partial: { + array: ['1', '2', '3', '4'] + } + }, + { partial: 'Here\'s a non-sense array of values\n{{#array}}\n {{.}}\n{{/array}}' } + ), + 'Here\'s a non-sense array of values\n\n 1\n\n 2\n\n 3\n\n 4\n' + ); + + // matches template_partial.html + equals( + Mustache.to_html( + '

{{title}}

\n{{>partial}}', + { + title: function() { + return "Welcome"; + }, + partial: { + again: "Goodbye" + } + }, + {partial:'Again, {{again}}!'} + ), + '

Welcome

\nAgain, Goodbye!' + ); + + // matches partial_recursion.html + equals( + Mustache.to_html( + '{{name}}\n{{#kids}}\n{{>partial}}\n{{/kids}}', + { + name: '1', + kids: [ + { + name: '1.1', + children: [ + {name: '1.1.1'} + ] + } + ] + }, + {partial:'{{name}}\n{{#children}}\n{{>partial}}\n{{/children}}'} + ), + '1\n\n1.1\n\n1.1.1\n\n\n' + ); + + try { + Mustache.to_html( + '{{>partial}}', + {}, + {partal: ''} + ); + ok(false); + } catch(e) { + equals(e.message, "Unknown partial 'partial'"); + } +}); + +test("'=' (Set Delimiter)", function() { + expect(1); + + // matches delimiter.html + equals( + Mustache.to_html( + '{{=<% %>=}}*\n<% first %>\n* <% second %>\n<%=| |=%>\n* | third |\n|={{ }}=|\n* {{ fourth }}', + { + first: "It worked the first time.", + second: "And it worked the second time.", + third: "Then, surprisingly, it worked the third time.", + fourth: "Fourth time also fine!." + }, + {} + ), + '*\nIt worked the first time.\n* And it worked the second time.\n\n* Then, surprisingly, it worked the third time.\n\n* Fourth time also fine!.', + 'Simple Set Delimiter' + ); +}); + +test("'!' (Comments)", function() { + expect(1); + + // matches comments.html + equals( + Mustache.to_html( + '

{{title}}{{! just something interesting... or not... }}

\n', + { + title: function() { + return "A Comedy of Errors"; + } + }, + {} + ), + '

A Comedy of Errors

\n' + ); +}); + +test("'%' (Pragmas)", function() { + expect(3); + + // matches array_of_strings_options.html + equals( + Mustache.to_html( + '{{%IMPLICIT-ITERATOR iterator=rob}}\n{{#array_of_strings_options}}{{rob}} {{/array_of_strings_options}}', + {array_of_strings_options: ['hello', 'world']}, + {} + ), + '\nhello world ', + 'IMPLICIT-ITERATOR pragma' + ); + + // matches unknown_pragma.txt + try { + equals( + Mustache.to_html( + '{{%I-HAVE-THE-GREATEST-MUSTACHE}}\n', + {}, + {} + ), + 'hello world ', + 'IMPLICIT-ITERATOR pragma' + ); + ok(false); + } catch (e) { + equals(e.message, 'This implementation of mustache doesn\'t understand the \'I-HAVE-THE-GREATEST-MUSTACHE\' pragma'); + } + + equals( + Mustache.to_html( + '{{%IMPLICIT-ITERATOR}}{{#dataSet}}{{.}}:{{/dataSet}}', + { dataSet: [ 'Object 1', 'Object 2', 'Object 3' ] }, + {} + ), + "Object 1:Object 2:Object 3:" + ); +}); + +test("Empty", function() { + expect(2); + + // matches empty_template.html + equals( + Mustache.to_html( + '

Test

', + {}, + {} + ), + '

Test

', + 'Empty Template' + ); + + // matches empty_partial.html + equals( + Mustache.to_html( + 'hey {{foo}}\n{{>partial}}\n', + { + foo: 1 + }, + {partial: 'yo'} + ), + 'hey 1\nyo\n', + 'Empty Partial' + ); +}); + +test("Demo", function() { + expect(2); + + // matches simple.html + equals( + Mustache.to_html( + 'Hello {{name}}\nYou have just won ${{value}}!\n{{#in_ca}}\nWell, ${{ taxed_value }}, after taxes.\n{{/in_ca}}', + { + name: "Chris", + value: 10000, + taxed_value: function() { + return this.value - (this.value * 0.4); + }, + in_ca: true + }, + {} + ), + 'Hello Chris\nYou have just won $10000!\n\nWell, $6000, after taxes.\n', + 'A simple template' + ); + + // matches complex.html + var template = [ + '

{{header}}

', + '{{#list}}', + ' ', + '{{/list}}', + '{{#empty}}', + '

The list is empty.

', + '{{/empty}}', + ].join('\n'); + + var view = { + header: function() { + return "Colors"; + }, + item: [ + {name: "red", current: true, url: "#Red"}, + {name: "green", current: false, url: "#Green"}, + {name: "blue", current: false, url: "#Blue"} + ], + link: function() { + return this["current"] !== true; + }, + list: function() { + return this.item.length !== 0; + }, + empty: function() { + return this.item.length === 0; + } + }; + + var expected_result = '

Colors

\n\n \n\n'; + + equals( + Mustache.to_html( + template, + view, + {} + ), + expected_result, + 'A complex template' + ); +}); + +test("Performance", function() { + expect(1); + + var start, end; + + var view = []; + for (var i=0;i<1000;++i) { + view.push({name:i}); + } + + var template = '{{#count}}{{name}}\n{{/count}}'; + + start = Date.now(); + for (var j=0;j<1000;++j) { + this._oldToHtml(template, view, {}); + } + end = Date.now(); + + var interpreter_time = end - start; + + start = Date.now(); + var compiler = Mustache.compile(template, {}); + for (var k=0;k<1000;++k) { + compiler(view); + } + end = Date.now(); + + var compiler_time = end - start; + + ok(compiler_timepartial}}\n{{/enumerate}}', + { enumerate: [ { text: 'A' }, { text: 'B' } ] }, + { partial: '{{=[[ ]]=}}\n{{text}}\n[[={{ }}=]]' } + ), + '\n\n{{text}}\n\n\n\n{{text}}\n\n', + 'Issue 44' + ); + + // matches bug_46_set_delimiter.html + equals( + Mustache.to_html( + '{{=[[ ]]=}}[[#IsMustacheAwesome]]mustache is awesome![[/IsMustacheAwesome]]', + {IsMustacheAwesome: true}, + {} + ), + 'mustache is awesome!', + 'Issue 46' + ); +}); diff --git a/test/unit.html b/test/unit.html index 5f373f2..64cbc28 100644 --- a/test/unit.html +++ b/test/unit.html @@ -5,7 +5,8 @@ - + +

mustache.js unit tests

diff --git a/test/unit.js b/test/unit.interpreter.js similarity index 96% rename from test/unit.js rename to test/unit.interpreter.js index c0da446..175b809 100644 --- a/test/unit.js +++ b/test/unit.interpreter.js @@ -1,3 +1,14 @@ +module('Interpreter'); + +test("Argument validation", function() { + expect(4); + + equals(Mustache.to_html(undefined), '', 'No parameters'); + equals(Mustache.to_html('{{hi}}'), '', ' No View or Partials'); + equals(Mustache.to_html('{{hi}}', {hi:'Hi.'}), 'Hi.', 'No Partials'); + equals(Mustache.to_html('{{>hi}}', undefined, {hi:'{{p}}'}), '', 'Partial but no view'); +}); + test("Parser", function() { expect(3);