diff --git a/packages/spacebars-compiler/package.js b/packages/spacebars-compiler/package.js index 7ad9e1b548..c219d82aa6 100644 --- a/packages/spacebars-compiler/package.js +++ b/packages/spacebars-compiler/package.js @@ -15,7 +15,8 @@ Package.on_use(function (api) { api.use('underscore'); api.use('ui'); api.use('minifiers', ['server']); - api.add_files(['tokens.js', 'tojs.js', 'spacebars-compiler.js']); + api.add_files(['tokens.js', 'tojs.js', 'templatetag.js', + 'spacebars-compiler.js']); }); Package.on_test(function (api) { diff --git a/packages/spacebars-compiler/spacebars-compiler.js b/packages/spacebars-compiler/spacebars-compiler.js index 535134fd45..9f78088ed0 100644 --- a/packages/spacebars-compiler/spacebars-compiler.js +++ b/packages/spacebars-compiler/spacebars-compiler.js @@ -1,247 +1,6 @@ -var makeStacheTagStartRegex = function (r) { - return new RegExp(r.source + /(?![{>!#/])/.source, - r.ignoreCase ? 'i' : ''); -}; - -var starts = { - ELSE: makeStacheTagStartRegex(/^\{\{\s*else(?=[\s}])/i), - DOUBLE: makeStacheTagStartRegex(/^\{\{\s*(?!\s)/), - TRIPLE: makeStacheTagStartRegex(/^\{\{\{\s*(?!\s)/), - COMMENT: makeStacheTagStartRegex(/^\{\{\s*!/), - INCLUSION: makeStacheTagStartRegex(/^\{\{\s*>\s*(?!\s)/), - BLOCKOPEN: makeStacheTagStartRegex(/^\{\{\s*#\s*(?!\s)/), - BLOCKCLOSE: makeStacheTagStartRegex(/^\{\{\s*\/\s*(?!\s)/) -}; - -var ends = { - DOUBLE: /^\s*\}\}/, - TRIPLE: /^\s*\}\}\}/ -}; - -// Parse a tag at `pos` in `inputString`. Succeeds or errors. -Spacebars.parseStacheTag = function (scannerOrString, options) { - var scanner = scannerOrString; - if (typeof scanner === 'string') - scanner = new HTML.Scanner(scannerOrString); - - var run = function (regex) { - // regex is assumed to start with `^` - var result = regex.exec(scanner.rest()); - if (! result) - return null; - var ret = result[0]; - scanner.pos += ret.length; - return ret; - }; - - var advance = function (amount) { - scanner.pos += amount; - }; - - var scanIdentifier = function (isFirstInPath) { - var id = parseIdentifierName(scanner); - if (! id) - expected('IDENTIFIER'); - if (isFirstInPath && - (id === 'null' || id === 'true' || id === 'false')) - scanner.fatal("Can't use null, true, or false, as an identifier at start of path"); - - return id; - }; - - var scanPath = function () { - var segments = []; - - // handle initial `.`, `..`, `./`, `../`, `../..`, `../../`, etc - var dots; - if ((dots = run(/^[\.\/]+/))) { - var ancestorStr = '.'; // eg `../../..` maps to `....` - var endsWithSlash = /\/$/.test(dots); - - if (endsWithSlash) - dots = dots.slice(0, -1); - - _.each(dots.split('/'), function(dotClause, index) { - if (index === 0) { - if (dotClause !== '.' && dotClause !== '..') - expected("`.`, `..`, `./` or `../`"); - } else { - if (dotClause !== '..') - expected("`..` or `../`"); - } - - if (dotClause === '..') - ancestorStr += '.'; - }); - - segments.push(ancestorStr); - - if (!endsWithSlash) - return segments; - } - - while (true) { - // scan a path segment - - if (run(/^\[/)) { - var seg = run(/^[\s\S]*?\]/); - if (! seg) - error("Unterminated path segment"); - seg = seg.slice(0, -1); - if (! seg && ! segments.length) - error("Path can't start with empty string"); - segments.push(seg); - } else { - var id = scanIdentifier(! segments.length); - if (id === 'this' && ! segments.length) { - // initial `this` - segments.push('.'); - } else { - segments.push(id); - } - } - - var sep = run(/^[\.\/]/); - if (! sep) - break; - } - - return segments; - }; - - // scan an argument; succeeds or errors - var scanArg = function (notKeyword) { - var startPos = scanner.pos; - var result; - if ((result = parseNumber(scanner))) { - return ['NUMBER', result.value]; - } else if ((result = parseStringLiteral(scanner))) { - return ['STRING', result.value]; - } else if (/^[\.\[]/.test(scanner.peek())) { - return ['PATH', scanPath()]; - } else if ((result = parseIdentifierName(scanner))) { - var id = result; - if (id === 'null') { - return ['NULL', null]; - } else if (id === 'true' || id === 'false') { - return ['BOOLEAN', id === 'true']; - } else { - if ((! notKeyword) && - /^\s*=/.test(scanner.rest())) { - // it's a keyword argument! - run(/^\s*=\s*/); - // recurse to scan value, disallowing a second `=`. - var arg = scanArg(true); - arg.push(id); // add third element for key - return arg; - } else { - scanner.pos = startPos; // unconsume `id` - return ['PATH', scanPath()]; - } - } - } else { - expected('identifier, number, string, boolean, or null'); - } - }; - - var type; - - var error = function (msg) { - scanner.fatal(msg); - }; - - var expected = function (what) { - error('Expected ' + what); - }; - - // must do ELSE first; order of others doesn't matter - - if (run(starts.ELSE)) type = 'ELSE'; - else if (run(starts.DOUBLE)) type = 'DOUBLE'; - else if (run(starts.TRIPLE)) type = 'TRIPLE'; - else if (run(starts.COMMENT)) type = 'COMMENT'; - else if (run(starts.INCLUSION)) type = 'INCLUSION'; - else if (run(starts.BLOCKOPEN)) type = 'BLOCKOPEN'; - else if (run(starts.BLOCKCLOSE)) type = 'BLOCKCLOSE'; - else - error('Unknown stache tag'); - - var tag = { type: type }; - - if (type === 'COMMENT') { - var result = run(/^[\s\S]*?\}\}/); - if (! result) - error("Unclosed comment"); - tag.value = result.slice(0, -2); - } else if (type === 'BLOCKCLOSE') { - tag.path = scanPath(); - if (! run(ends.DOUBLE)) - expected('`}}`'); - } else if (type === 'ELSE') { - if (! run(ends.DOUBLE)) - expected('`}}`'); - } else { - // DOUBLE, TRIPLE, BLOCKOPEN, INCLUSION - tag.path = scanPath(); - tag.args = []; - while (true) { - run(/^\s*/); - if (type === 'TRIPLE') { - if (run(ends.TRIPLE)) - break; - else if (scanner.peek() === '}') - expected('`}}}`'); - } else { - if (run(ends.DOUBLE)) - break; - else if (scanner.peek() === '}') - expected('`}}`'); - } - tag.args.push(scanArg()); - if (run(/^(?=[\s}])/) !== '') - expected('space'); - } - } - - var checkTag = function (tag) { - if (tag.type === 'INCLUSION') { - // throw error on >1 positional arguments - var numPosArgs = 0; - var args = tag.args; - for (var i = 0; i < args.length; i++) - if (args[i].length === 2) - numPosArgs++; - if (numPosArgs > 1) - error("Only one positional argument is allowed in {{> ... }}"); - } - }; - - checkTag(tag); - - return tag; -}; - -Spacebars.peekStacheTag = function (scanner, options) { - var startPos = scanner.pos; - var result = Spacebars.parseStacheTag(scanner, options); - scanner.pos = startPos; - return result; -}; - -var makeObjectLiteral = function (obj) { - var parts = []; - for (var k in obj) - parts.push(toObjectLiteralKey(k) + ': ' + obj[k]); - return '{' + parts.join(', ') + '}'; -}; - - - -////////////////////////////////////////////////// - Spacebars.parse = function (input) { // This implementation of `getSpecialTag` looks for "{{" and if it // finds it, it will parse a stache tag or fail fatally trying. @@ -254,9 +13,9 @@ Spacebars.parse = function (input) { scanner.rest().slice(0, 2) === '{{')) return null; - // `parseStacheTag` will succeed or die trying. + // `TemplateTag.parse` will succeed or die trying. var lastPos = scanner.pos; - var stache = Spacebars.parseStacheTag(scanner); + var stache = TemplateTag.parse(scanner); // kill any `args: []` cluttering up the object if (stache.args && ! stache.args.length) delete stache.args; @@ -316,7 +75,7 @@ Spacebars.parse = function (input) { scanner.fatal("Expected {{else}} or block close for " + blockName); lastPos = scanner.pos; - var stache2 = Spacebars.parseStacheTag(scanner); + var stache2 = TemplateTag.parse(scanner); if (stache2.type === 'ELSE') { stache.elseContent = HTML.parseFragment(scanner, parserOptions); @@ -325,7 +84,7 @@ Spacebars.parse = function (input) { scanner.fatal("Expected block close for " + blockName); lastPos = scanner.pos; - stache2 = Spacebars.parseStacheTag(scanner); + stache2 = TemplateTag.parse(scanner); } if (stache2.type === 'BLOCKCLOSE') { @@ -346,13 +105,15 @@ Spacebars.parse = function (input) { }; var isAtBlockCloseOrElse = function (scanner) { - // we could just call parseStacheTag, but this function is called - // for every token in the input stream, so we add some shortcuts. + // Because this function may be called for every token in the input + // stream, we try to make it reasonably efficient in the false case. + // We also have to screen for `{{` before calling TemplateTag.peek + // to avoid throwing an error. var rest, type; return (scanner.peek() === '{' && (rest = scanner.rest()).slice(0, 2) === '{{' && /^\{\{\s*(\/|else\b)/.test(rest) && - (type = Spacebars.peekStacheTag(scanner).type) && + (type = TemplateTag.peek(scanner).type) && (type === 'BLOCKCLOSE' || type === 'ELSE')); }; @@ -560,6 +321,14 @@ var replaceSpecials = function (node) { } }; +var makeObjectLiteral = function (obj) { + var parts = []; + for (var k in obj) + parts.push(toObjectLiteralKey(k) + ': ' + obj[k]); + return '{' + parts.join(', ') + '}'; +}; + + var codeGenInclusionArgs = function (tag) { var args = null; var posArgs = []; diff --git a/packages/spacebars-compiler/spacebars_tests.js b/packages/spacebars-compiler/spacebars_tests.js index 61e8279d3c..2b558ef9b9 100644 --- a/packages/spacebars-compiler/spacebars_tests.js +++ b/packages/spacebars-compiler/spacebars_tests.js @@ -6,7 +6,7 @@ Tinytest.add("spacebars - stache tags", function (test) { var msg = ''; test.throws(function () { try { - Spacebars.parseStacheTag(input); + Spacebars.TemplateTag.parse(input); } catch (e) { msg = e.message; throw e; @@ -14,7 +14,7 @@ Tinytest.add("spacebars - stache tags", function (test) { }); test.equal(msg.slice(0, expected.length), expected); } else { - var result = Spacebars.parseStacheTag(input); + var result = Spacebars.TemplateTag.parse(input); test.equal(result, expected); } }; diff --git a/packages/spacebars-compiler/templatetag.js b/packages/spacebars-compiler/templatetag.js new file mode 100644 index 0000000000..8d5b3161ae --- /dev/null +++ b/packages/spacebars-compiler/templatetag.js @@ -0,0 +1,240 @@ + +TemplateTag = Spacebars.TemplateTag = function () {}; + +var makeStacheTagStartRegex = function (r) { + return new RegExp(r.source + /(?![{>!#/])/.source, + r.ignoreCase ? 'i' : ''); +}; + +var starts = { + ELSE: makeStacheTagStartRegex(/^\{\{\s*else(?=[\s}])/i), + DOUBLE: makeStacheTagStartRegex(/^\{\{\s*(?!\s)/), + TRIPLE: makeStacheTagStartRegex(/^\{\{\{\s*(?!\s)/), + COMMENT: makeStacheTagStartRegex(/^\{\{\s*!/), + INCLUSION: makeStacheTagStartRegex(/^\{\{\s*>\s*(?!\s)/), + BLOCKOPEN: makeStacheTagStartRegex(/^\{\{\s*#\s*(?!\s)/), + BLOCKCLOSE: makeStacheTagStartRegex(/^\{\{\s*\/\s*(?!\s)/) +}; + +var ends = { + DOUBLE: /^\s*\}\}/, + TRIPLE: /^\s*\}\}\}/ +}; + +// Parse a tag from the provided scanner or string. Either succeeds +// and returns a Spacebars.TemplateTag, or throws an error (using +// `scanner.fatal` if a scanner is provided). +TemplateTag.parse = function (scannerOrString) { + var scanner = scannerOrString; + if (typeof scanner === 'string') + scanner = new HTML.Scanner(scannerOrString); + + var run = function (regex) { + // regex is assumed to start with `^` + var result = regex.exec(scanner.rest()); + if (! result) + return null; + var ret = result[0]; + scanner.pos += ret.length; + return ret; + }; + + var advance = function (amount) { + scanner.pos += amount; + }; + + var scanIdentifier = function (isFirstInPath) { + var id = parseIdentifierName(scanner); + if (! id) + expected('IDENTIFIER'); + if (isFirstInPath && + (id === 'null' || id === 'true' || id === 'false')) + scanner.fatal("Can't use null, true, or false, as an identifier at start of path"); + + return id; + }; + + var scanPath = function () { + var segments = []; + + // handle initial `.`, `..`, `./`, `../`, `../..`, `../../`, etc + var dots; + if ((dots = run(/^[\.\/]+/))) { + var ancestorStr = '.'; // eg `../../..` maps to `....` + var endsWithSlash = /\/$/.test(dots); + + if (endsWithSlash) + dots = dots.slice(0, -1); + + _.each(dots.split('/'), function(dotClause, index) { + if (index === 0) { + if (dotClause !== '.' && dotClause !== '..') + expected("`.`, `..`, `./` or `../`"); + } else { + if (dotClause !== '..') + expected("`..` or `../`"); + } + + if (dotClause === '..') + ancestorStr += '.'; + }); + + segments.push(ancestorStr); + + if (!endsWithSlash) + return segments; + } + + while (true) { + // scan a path segment + + if (run(/^\[/)) { + var seg = run(/^[\s\S]*?\]/); + if (! seg) + error("Unterminated path segment"); + seg = seg.slice(0, -1); + if (! seg && ! segments.length) + error("Path can't start with empty string"); + segments.push(seg); + } else { + var id = scanIdentifier(! segments.length); + if (id === 'this' && ! segments.length) { + // initial `this` + segments.push('.'); + } else { + segments.push(id); + } + } + + var sep = run(/^[\.\/]/); + if (! sep) + break; + } + + return segments; + }; + + // scan an argument; succeeds or errors + var scanArg = function (notKeyword) { + var startPos = scanner.pos; + var result; + if ((result = parseNumber(scanner))) { + return ['NUMBER', result.value]; + } else if ((result = parseStringLiteral(scanner))) { + return ['STRING', result.value]; + } else if (/^[\.\[]/.test(scanner.peek())) { + return ['PATH', scanPath()]; + } else if ((result = parseIdentifierName(scanner))) { + var id = result; + if (id === 'null') { + return ['NULL', null]; + } else if (id === 'true' || id === 'false') { + return ['BOOLEAN', id === 'true']; + } else { + if ((! notKeyword) && + /^\s*=/.test(scanner.rest())) { + // it's a keyword argument! + run(/^\s*=\s*/); + // recurse to scan value, disallowing a second `=`. + var arg = scanArg(true); + arg.push(id); // add third element for key + return arg; + } else { + scanner.pos = startPos; // unconsume `id` + return ['PATH', scanPath()]; + } + } + } else { + expected('identifier, number, string, boolean, or null'); + } + }; + + var type; + + var error = function (msg) { + scanner.fatal(msg); + }; + + var expected = function (what) { + error('Expected ' + what); + }; + + // must do ELSE first; order of others doesn't matter + + if (run(starts.ELSE)) type = 'ELSE'; + else if (run(starts.DOUBLE)) type = 'DOUBLE'; + else if (run(starts.TRIPLE)) type = 'TRIPLE'; + else if (run(starts.COMMENT)) type = 'COMMENT'; + else if (run(starts.INCLUSION)) type = 'INCLUSION'; + else if (run(starts.BLOCKOPEN)) type = 'BLOCKOPEN'; + else if (run(starts.BLOCKCLOSE)) type = 'BLOCKCLOSE'; + else + error('Unknown stache tag'); + + var tag = new TemplateTag; + tag.type = type; + + if (type === 'COMMENT') { + var result = run(/^[\s\S]*?\}\}/); + if (! result) + error("Unclosed comment"); + tag.value = result.slice(0, -2); + } else if (type === 'BLOCKCLOSE') { + tag.path = scanPath(); + if (! run(ends.DOUBLE)) + expected('`}}`'); + } else if (type === 'ELSE') { + if (! run(ends.DOUBLE)) + expected('`}}`'); + } else { + // DOUBLE, TRIPLE, BLOCKOPEN, INCLUSION + tag.path = scanPath(); + tag.args = []; + while (true) { + run(/^\s*/); + if (type === 'TRIPLE') { + if (run(ends.TRIPLE)) + break; + else if (scanner.peek() === '}') + expected('`}}}`'); + } else { + if (run(ends.DOUBLE)) + break; + else if (scanner.peek() === '}') + expected('`}}`'); + } + tag.args.push(scanArg()); + if (run(/^(?=[\s}])/) !== '') + expected('space'); + } + } + + var checkTag = function (tag) { + if (tag.type === 'INCLUSION') { + // throw error on >1 positional arguments + var numPosArgs = 0; + var args = tag.args; + for (var i = 0; i < args.length; i++) + if (args[i].length === 2) + numPosArgs++; + if (numPosArgs > 1) + error("Only one positional argument is allowed in {{> ... }}"); + } + }; + + checkTag(tag); + + return tag; +}; + +// Returns a Spacebars.TemplateTag parsed from `scanner`, leaving scanner +// at its original position. +// +// An error will still be thrown if there is not a valid template tag at +// the current position. +TemplateTag.peek = function (scanner) { + var startPos = scanner.pos; + var result = TemplateTag.parse(scanner); + scanner.pos = startPos; + return result; +};