From b1141207419f69068ba29064f02f9aea28842c21 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 6 Nov 2013 13:11:37 -0800 Subject: [PATCH] Major reorganization and cleanup --- CHANGES | 12 + mustache.js | 625 ++++++++++++++++++++++++------------------- test/context-test.js | 7 - test/parse-test.js | 2 +- test/writer-test.js | 47 ++-- 5 files changed, 378 insertions(+), 315 deletions(-) diff --git a/CHANGES b/CHANGES index fd72af8..6b7702c 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,15 @@ += HEAD + + * Remove compile* writer functions and replace them with cache* equivalents. This + reflects more clearly what the functions are actually doing, and also provides + a better basis for understanding what writer.clearCache() does. + * Do not cache dynamically loaded partials. + * Throw an error when rendering a template that contains higher-order sections and + the original template is not provided. + * Remove partials argument from low-level writer.render. + * Remove low-level Context.make function. + * Better code readability and inline documentation. + = 0.7.3 / 5 Nov 2013 * Don't require the original template to be passed to the rendering function diff --git a/mustache.js b/mustache.js index 4c65a95..ce39f8f 100644 --- a/mustache.js +++ b/mustache.js @@ -65,6 +65,216 @@ }); } + function escapeTags(tags) { + if (!isArray(tags) || tags.length !== 2) { + throw new Error('Invalid tags: ' + tags); + } + + return [ + new RegExp(escapeRegExp(tags[0]) + "\\s*"), + new RegExp("\\s*" + escapeRegExp(tags[1])) + ]; + } + + /** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all template text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices + * in the original template of the token, respectively. + * + * Tokens that are the root node of a subtree contain two more elements: an + * array of tokens in the subtree and the index in the original template at which + * the closing tag for that section begins. + */ + function parseTemplate(template, tags) { + tags = tags || mustache.tags; + template = template || ''; + + if (typeof tags === 'string') { + tags = tags.split(spaceRe); + } + + var tagRes = escapeTags(tags); + var scanner = new Scanner(template); + + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace() { + if (hasTag && !nonSpace) { + while (spaces.length) { + delete tokens[spaces.pop()]; + } + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(tagRes[0]); + if (value) { + for (var i = 0, len = value.length; i < len; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + } else { + nonSpace = true; + } + + tokens.push(['text', chr, start, start + 1]); + start += 1; + + // Check for whitespace on the current line. + if (chr == '\n') stripSpace(); + } + } + + // Match the opening tag. + if (!scanner.scan(tagRes[0])) break; + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(eqRe); + scanner.scan(eqRe); + scanner.scanUntil(tagRes[1]); + } else if (type === '{') { + value = scanner.scanUntil(new RegExp('\\s*' + escapeRegExp('}' + tags[1]))); + scanner.scan(curlyRe); + scanner.scanUntil(tagRes[1]); + type = '&'; + } else { + value = scanner.scanUntil(tagRes[1]); + } + + // Match the closing tag. + if (!scanner.scan(tagRes[1])) throw new Error('Unclosed tag at ' + scanner.pos); + + token = [type, value, start, scanner.pos]; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) { + throw new Error('Unopened section "' + value + '" at ' + start); + } + if (openSection[1] !== value) { + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + tagRes = escapeTags(tags = value.split(spaceRe)); + } + } + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + if (openSection) { + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + } + + return nestTokens(squashTokens(tokens)); + } + + /** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ + function squashTokens(tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, len = tokens.length; i < len; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + lastToken = token; + squashedTokens.push(token); + } + } + } + + return squashedTokens; + } + + /** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ + function nestTokens(tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token; + for (var i = 0, len = tokens.length; i < len; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + sections.push(token); + collector.push(token); + collector = token[4] = []; + break; + case '/': + var section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; + } + + /** + * A simple string scanner that is used by the mustache.parse to find + * tokens in template strings. + */ function Scanner(string) { this.string = string; this.tail = string; @@ -120,20 +330,28 @@ return match; }; + /** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ function Context(view, parent) { this.view = view == null ? {} : view; this.parent = parent; this._cache = { '.': this.view }; } - Context.make = function (view) { - return (view instanceof Context) ? view : new Context(view); - }; - + /** + * Creates a new context using the given view with this context + * as the parent. + */ Context.prototype.push = function (view) { return new Context(view, this); }; + /** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ Context.prototype.lookup = function (name) { var value; if (name in this._cache) { @@ -168,334 +386,169 @@ return value; }; - function Writer() { + /** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates and + * partials to avoid the need to parse the same template twice. + * + * The `partialLoader` argument may be a function that is used to load + * partials on the fly as they are needed if they are not found in cache. + * Partials loaded in this manner are not cached. + */ + function Writer(partialLoader) { this.clearCache(); + + if (isFunction(partialLoader)) { + this._loadPartial = partialLoader; + } } + /** + * Clears all cached templates and partials in this writer. + */ Writer.prototype.clearCache = function () { this._cache = {}; this._partialCache = {}; }; - Writer.prototype.compile = function (template, tags) { - var fn = this._cache[template]; - - if (!fn) { - var tokens = mustache.parse(template, tags); - fn = this._cache[template] = this.compileTokens(tokens, template); - } - - return fn; - }; - - Writer.prototype.compilePartial = function (name, template, tags) { - var fn = this.compile(template, tags); - this._partialCache[name] = fn; - return fn; - }; - + /** + * Gets an array of tokens for the partial with the given `name`, either + * from cache or from this writer's partial loading function. + */ Writer.prototype.getPartial = function (name) { if (!(name in this._partialCache) && this._loadPartial) { - this.compilePartial(name, this._loadPartial(name)); + var template = this._loadPartial(name); + if (template) { + // Intentionally do not cache dynamically-loaded templates. + return mustache.parse(template); + } } return this._partialCache[name]; }; - Writer.prototype.compileTokens = function (tokens, template) { - var self = this; - return function (view, partials) { - if (partials) { - if (isFunction(partials)) { - self._loadPartial = partials; - } else { - for (var name in partials) { - self.compilePartial(name, partials[name]); - } - } - } + /** + * Parses and caches the given `template` with the given `name` as a + * partial. This writer may then render other templates that reference + * that partial using the ">" tag and that partial's name. Returns the + * array of tokens that is generated from the parse. + */ + Writer.prototype.cachePartial = function (name, template, tags) { + return (this._partialCache[name] = this.cache(template, tags)); + }; + + /** + * Parses and caches the given `template` and returns the array of tokens + * that is generated from the parse. + */ + Writer.prototype.cache = function (template, tags) { + if (!(template in this._cache)) { + this._cache[template] = mustache.parse(template, tags); + } - return renderTokens(tokens, self, Context.make(view), template); - }; + return this._cache[template]; }; - Writer.prototype.render = function (template, view, partials) { - return this.compile(template)(view, partials); + /** + * High-level method that is used to render the given `template` with + * the given `view`. + */ + Writer.prototype.render = function (template, view) { + var tokens = this.cache(template); + var context = (view instanceof Context) ? view : new Context(view); + return this.renderTokens(tokens, context, template); }; /** - * Low-level function that renders the given `tokens` using the given `writer` - * and `context`. The `template` string is only needed for templates that use - * higher-order sections to extract the portion of the original template that - * was contained in that section. + * Low-level method that renders the given array of `tokens` using + * the given `context`. + * + * Note: The `template` string is only needed for templates that use + * higher-order sections to extract the portion of the original template + * that was contained in that section. */ - function renderTokens(tokens, writer, context, template) { + Writer.prototype.renderTokens = function (tokens, context, template) { var buffer = ''; - // This function is used to render an artbitrary template + // This function is used to render an arbitrary template // in the current context by higher-order functions. + var self = this; function subRender(template) { - return writer.render(template, context); + return self.render(template, context); } - var token, tokenValue, value; + var token, value; for (var i = 0, len = tokens.length; i < len; ++i) { token = tokens[i]; - tokenValue = token[1]; switch (token[0]) { case '#': - value = context.lookup(tokenValue); - - if (typeof value === 'object' || typeof value === 'string') { - if (isArray(value)) { - for (var j = 0, jlen = value.length; j < jlen; ++j) { - buffer += renderTokens(token[4], writer, context.push(value[j]), template); - } - } else if (value) { - buffer += renderTokens(token[4], writer, context.push(value), template); + value = context.lookup(token[1]); + if (!value) continue; + + if (isArray(value)) { + for (var j = 0, jlen = value.length; j < jlen; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), template); } + } else if (typeof value === 'object' || typeof value === 'string') { + buffer += this.renderTokens(token[4], context.push(value), template); } else if (isFunction(value)) { - var text = template == null ? null : template.slice(token[3], token[5]); - value = value.call(context.view, text, subRender); + if (typeof template !== 'string') { + throw new Error('Cannot use higher-order sections without the original template'); + } + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, template.slice(token[3], token[5]), subRender); + if (value != null) buffer += value; - } else if (value) { - buffer += renderTokens(token[4], writer, context, template); + } else { + buffer += this.renderTokens(token[4], context, template); } break; case '^': - value = context.lookup(tokenValue); + value = context.lookup(token[1]); // Use JavaScript's definition of falsy. Include empty arrays. // See https://github.com/janl/mustache.js/issues/186 if (!value || (isArray(value) && value.length === 0)) { - buffer += renderTokens(token[4], writer, context, template); + buffer += this.renderTokens(token[4], context, template); } break; case '>': - value = writer.getPartial(tokenValue); - if (isFunction(value)) buffer += value(context); + value = this.getPartial(token[1]); + if (value != null) buffer += this.renderTokens(value, context, template); break; case '&': - value = context.lookup(tokenValue); + value = context.lookup(token[1]); if (value != null) buffer += value; break; case 'name': - value = context.lookup(tokenValue); + value = context.lookup(token[1]); if (value != null) buffer += mustache.escape(value); break; case 'text': - buffer += tokenValue; + buffer += token[1]; break; } } return buffer; - } - - /** - * Forms the given array of `tokens` into a nested tree structure where - * tokens that represent a section have two additional items: 1) an array of - * all tokens that appear in that section and 2) the index in the original - * template that represents the end of that section. - */ - function nestTokens(tokens) { - var tree = []; - var collector = tree; - var sections = []; - - var token; - for (var i = 0, len = tokens.length; i < len; ++i) { - token = tokens[i]; - switch (token[0]) { - case '#': - case '^': - sections.push(token); - collector.push(token); - collector = token[4] = []; - break; - case '/': - var section = sections.pop(); - section[5] = token[2]; - collector = sections.length > 0 ? sections[sections.length - 1][4] : tree; - break; - default: - collector.push(token); - } - } - - return tree; - } - - /** - * Combines the values of consecutive text tokens in the given `tokens` array - * to a single token. - */ - function squashTokens(tokens) { - var squashedTokens = []; - - var token, lastToken; - for (var i = 0, len = tokens.length; i < len; ++i) { - token = tokens[i]; - if (token) { - if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { - lastToken[1] += token[1]; - lastToken[3] = token[3]; - } else { - lastToken = token; - squashedTokens.push(token); - } - } - } - - return squashedTokens; - } - - function escapeTags(tags) { - return [ - new RegExp(escapeRegExp(tags[0]) + "\\s*"), - new RegExp("\\s*" + escapeRegExp(tags[1])) - ]; - } - - /** - * Breaks up the given `template` string into a tree of token objects. If - * `tags` is given here it must be an array with two string values: the - * opening and closing tags used in the template (e.g. ["<%", "%>"]). Of - * course, the default is to use mustaches (i.e. Mustache.tags). - */ - function parseTemplate(template, tags) { - template = template || ''; - tags = tags || mustache.tags; - - if (typeof tags === 'string') tags = tags.split(spaceRe); - if (tags.length !== 2) throw new Error('Invalid tags: ' + tags.join(', ')); - - var tagRes = escapeTags(tags); - var scanner = new Scanner(template); - - var sections = []; // Stack to hold section tokens - var tokens = []; // Buffer to hold the tokens - var spaces = []; // Indices of whitespace tokens on the current line - var hasTag = false; // Is there a {{tag}} on the current line? - var nonSpace = false; // Is there a non-space char on the current line? - - // Strips all whitespace tokens array for the current line - // if there was a {{#tag}} on it and otherwise only space. - function stripSpace() { - if (hasTag && !nonSpace) { - while (spaces.length) { - delete tokens[spaces.pop()]; - } - } else { - spaces = []; - } - - hasTag = false; - nonSpace = false; - } - - var start, type, value, chr, token, openSection; - while (!scanner.eos()) { - start = scanner.pos; - - // Match any text between tags. - value = scanner.scanUntil(tagRes[0]); - if (value) { - for (var i = 0, len = value.length; i < len; ++i) { - chr = value.charAt(i); - - if (isWhitespace(chr)) { - spaces.push(tokens.length); - } else { - nonSpace = true; - } - - tokens.push(['text', chr, start, start + 1]); - start += 1; - - // Check for whitespace on the current line. - if (chr == '\n') stripSpace(); - } - } - - // Match the opening tag. - if (!scanner.scan(tagRes[0])) break; - hasTag = true; - - // Get the tag type. - type = scanner.scan(tagRe) || 'name'; - scanner.scan(whiteRe); - - // Get the tag value. - if (type === '=') { - value = scanner.scanUntil(eqRe); - scanner.scan(eqRe); - scanner.scanUntil(tagRes[1]); - } else if (type === '{') { - value = scanner.scanUntil(new RegExp('\\s*' + escapeRegExp('}' + tags[1]))); - scanner.scan(curlyRe); - scanner.scanUntil(tagRes[1]); - type = '&'; - } else { - value = scanner.scanUntil(tagRes[1]); - } - - // Match the closing tag. - if (!scanner.scan(tagRes[1])) throw new Error('Unclosed tag at ' + scanner.pos); - - token = [type, value, start, scanner.pos]; - tokens.push(token); - - if (type === '#' || type === '^') { - sections.push(token); - } else if (type === '/') { - // Check section nesting. - openSection = sections.pop(); - if (!openSection) { - throw new Error('Unopened section "' + value + '" at ' + start); - } - if (openSection[1] !== value) { - throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); - } - } else if (type === 'name' || type === '{' || type === '&') { - nonSpace = true; - } else if (type === '=') { - // Set the tags for the next time around. - tags = value.split(spaceRe); - if (tags.length !== 2) { - throw new Error('Invalid tags at ' + start + ': ' + tags.join(', ')); - } - tagRes = escapeTags(tags); - } - } - - // Make sure there are no open sections when we're done. - openSection = sections.pop(); - if (openSection) { - throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); - } - - return nestTokens(squashTokens(tokens)); - } + }; mustache.name = "mustache.js"; mustache.version = "0.7.3"; - mustache.tags = ["{{", "}}"]; - - mustache.Scanner = Scanner; - mustache.Context = Context; - mustache.Writer = Writer; + mustache.tags = [ "{{", "}}" ]; + // Export the parsing function. mustache.parse = parseTemplate; // Export the escaping function so that the user may override it. // See https://github.com/janl/mustache.js/issues/244 mustache.escape = escapeHtml; - // All Mustache.* functions use this writer. + // All high-level mustache.* functions use this writer. var defaultWriter = new Writer(); /** @@ -506,35 +559,42 @@ }; /** - * Compiles the given `template` to a reusable function using the default - * writer. + * Caches the given partial in the default writer and returns the + * array of tokens it contains. */ - mustache.compile = function (template, tags) { - return defaultWriter.compile(template, tags); + mustache.cachePartial = function (name, template, tags) { + return defaultWriter.cachePartial(name, template, tags); }; /** - * Compiles the partial with the given `name` and `template` to a reusable - * function using the default writer. + * Caches the given template in the default writer and returns the + * array of tokens it contains. */ - mustache.compilePartial = function (name, template, tags) { - return defaultWriter.compilePartial(name, template, tags); + mustache.cache = function (template, tags) { + return defaultWriter.cache(template, tags); }; /** - * Compiles the given array of tokens (the output of a parse) to a reusable - * function using the default writer. - */ - mustache.compileTokens = function (tokens, template) { - return defaultWriter.compileTokens(tokens, template); - }; - - /** - * Renders the `template` with the given `view` and `partials` using the - * default writer. + * Renders the `template` with the given `view` using the default writer. + * + * The optionals `partials` argument may either be a function that is used to load + * partials on the fly or an object containing names and templates of partials. If + * it is an object, the partials will be cached in the default writer. */ mustache.render = function (template, view, partials) { - return defaultWriter.render(template, view, partials); + if (partials) { + if (isFunction(partials)) { + defaultWriter._loadPartial = partials; + } else { + for (var name in partials) { + if (partials.hasOwnProperty(name)) { + defaultWriter.cachePartial(name, partials[name]); + } + } + } + } + + return defaultWriter.render(template, view); }; // This is here for backwards compatibility with 0.4.x. @@ -548,4 +608,9 @@ } }; + // Export these mainly for testing, but also for advanced usage. + mustache.Scanner = Scanner; + mustache.Context = Context; + mustache.Writer = Writer; + })); diff --git a/test/context-test.js b/test/context-test.js index 808b253..9b9cbbe 100644 --- a/test/context-test.js +++ b/test/context-test.js @@ -59,10 +59,3 @@ describe('A Mustache.Context', function () { }); }); }); - -describe('Mustache.Context.make', function () { - it('returns the same object when given a Context', function () { - var context = new Context; - assert.strictEqual(Context.make(context), context); - }); -}); diff --git a/test/parse-test.js b/test/parse-test.js index 40d23a4..97c26db 100644 --- a/test/parse-test.js +++ b/test/parse-test.js @@ -99,7 +99,7 @@ describe('Mustache.parse', function () { it('throws an error', function () { assert.throws(function () { Mustache.parse('A template {{=<%=}}'); - }, /invalid tags at 11/i); + }, /invalid tags/i); }); }); diff --git a/test/writer-test.js b/test/writer-test.js index db2813a..fc552db 100644 --- a/test/writer-test.js +++ b/test/writer-test.js @@ -3,41 +3,34 @@ var Writer = Mustache.Writer; describe('A new Mustache.Writer', function () { var writer; - beforeEach(function () { - writer = new Writer; - }); - it('loads partials correctly', function () { - var partial = 'The content of the partial.'; - var result = writer.render('{{>partial}}', {}, function (name) { - assert.equal(name, 'partial'); - return partial; + describe('with a cached partial', function () { + beforeEach(function () { + writer = new Writer; }); - assert.equal(result, partial); - }); + it('caches partials by content, not name', function () { + writer.cachePartial('partial', 'partial one'); + assert.equal(writer.render('{{>partial}}'), 'partial one'); - it('caches partials by content, not name', function () { - var result = writer.render('{{>partial}}', {}, { - partial: 'partial one' + writer.cachePartial('partial', 'partial two'); + assert.equal(writer.render('{{>partial}}'), 'partial two'); }); + }); - assert.equal(result, 'partial one'); - - result = writer.render('{{>partial}}', {}, { - partial: 'partial two' + describe('with a partial loader', function () { + var partial; + beforeEach(function () { + partial = 'The content of the partial.'; + writer = new Writer(function (name) { + assert.equal(name, 'partial'); + return partial; + }); }); - assert.equal(result, 'partial two'); + it('loads partials correctly', function () { + assert.equal(writer.render('{{>partial}}'), partial); + }); }); - it('can compile an array of tokens', function () { - var template = 'Hello {{name}}!'; - var tokens = Mustache.parse(template); - var render = writer.compileTokens(tokens, template); - - var result = render({ name: 'Michael' }); - - assert.equal(result, 'Hello Michael!'); - }); });