From e7783f58b427ccc18d23a92f17ff02c030c40c1b Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 23 Sep 2017 01:22:35 +0530 Subject: [PATCH] feat(tags): allow multiline tags templates must be expressive and hence multiline tags makes it possible --- src/Ast/index.js | 95 +++++++++++++++++++++++++++- src/Tags/ElseIfTag.js | 10 ++- src/Tags/IfTag.js | 10 ++- src/Template/Compiler.js | 2 +- test/unit/ast.spec.js | 28 +++++++++ test/unit/template.spec.js | 125 +++++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 4 deletions(-) diff --git a/src/Ast/index.js b/src/Ast/index.js index 0df5dfc..b905735 100644 --- a/src/Ast/index.js +++ b/src/Ast/index.js @@ -54,6 +54,81 @@ class Ast { this._ast = [] this._insideBlockComment = false this._openedTags = [] + this._multilineOpened = null + } + + /** + * Process all lines as part of the recently opened + * tag, when tag is multiline + * + * @method _waitUntilTagFinishes + * + * @param {String} line + * + * @return {void} + * + * @private + */ + _waitUntilTagFinishes (line) { + /** + * Remove inline comments from the line + */ + line = line.replace(singleLineComment, '') + + /** + * Remove trailing spaces from the line, + * since they have no value + */ + line = line.trim() + + /** + * If line is ending with `)`. We will consider + * it the end of the multiline tag + */ + const ending = line.endsWith(')') + + /** + * Extract the content from the last + * line, since the line can be + * just `)` or it can be + * the content + `)` + */ + const content = ending ? line.replace(/\)$/, '') : line + + /** + * If there was some content next to `args` + * then use append to it, otherwise set + * the first content + */ + this._multilineOpened.args = this._multilineOpened.args + ? `${this._multilineOpened.args} ${content}` + : content + + /** + * Finally if we are ending, then stop tracking the + * tag and start processing new content + */ + if (ending) { + this._multilineOpened = null + } + } + + /** + * Returns a boolean telling if a line has more + * opening braces than closing braces + * + * @method _openingBracesAreMore + * + * @param {String} line + * + * @return {Boolean} + * + * @private + */ + _openingBracesAreMore (line) { + const openingBraces = line.match(/\(/g) + const closingBraces = line.match(/\)/g) + return (openingBraces ? openingBraces.length : 0) > (closingBraces ? closingBraces.length : 0) } /** @@ -74,10 +149,11 @@ class Ast { _tokenForTag (line, tag, args, index, selfClosing) { return { tag, - args, + args: args ? args.replace(/\)$/, '') : undefined, selfClosing, childs: [], body: line, + multiline: this._openingBracesAreMore(line), lineno: index + 1, end: { body: null, @@ -225,6 +301,15 @@ class Ast { return } + /** + * Wait until the multi line opened tag closes. Till + * then everything will be args for that tag. + */ + if (this._multilineOpened) { + this._waitUntilTagFinishes(line) + return + } + const lastTag = _.last(this._openedTags) /** @@ -262,6 +347,14 @@ class Ast { this._openedTags.push(token) } + /** + * Track the opening of multiline tag opening and push all + * upcoming lines as args, unless a closing `)` is found + */ + if (token.tag && token.multiline) { + this._multilineOpened = token + } + /** * Push to lastTag childs or the actual ast. */ diff --git a/src/Tags/ElseIfTag.js b/src/Tags/ElseIfTag.js index f9e138c..b06a855 100644 --- a/src/Tags/ElseIfTag.js +++ b/src/Tags/ElseIfTag.js @@ -51,7 +51,15 @@ class ElseIfTag extends BaseTag { * @return {Array} */ get allowedExpressions () { - return ['BinaryExpression', 'Literal', 'Identifier', 'CallExpression', 'MemberExpression'] + return [ + 'BinaryExpression', + 'Literal', + 'Identifier', + 'CallExpression', + 'MemberExpression', + 'UnaryExpression', + 'LogicalExpression' + ] } /** diff --git a/src/Tags/IfTag.js b/src/Tags/IfTag.js index d239591..90ab504 100644 --- a/src/Tags/IfTag.js +++ b/src/Tags/IfTag.js @@ -53,7 +53,15 @@ class IfTag extends BaseTag { * @return {Array} */ get allowedExpressions () { - return ['BinaryExpression', 'Literal', 'Identifier', 'CallExpression', 'MemberExpression', 'UnaryExpression'] + return [ + 'BinaryExpression', + 'Literal', + 'Identifier', + 'CallExpression', + 'MemberExpression', + 'UnaryExpression', + 'LogicalExpression' + ] } /** diff --git a/src/Template/Compiler.js b/src/Template/Compiler.js index 7ea53a1..5b0ccf1 100644 --- a/src/Template/Compiler.js +++ b/src/Template/Compiler.js @@ -34,7 +34,7 @@ const expressions = { class TemplateCompiler { constructor (tags, loader, asFunction = false) { this._tags = tags - this._blockRegExp = new RegExp(`^\\s*\\@(!?)(${_.keys(tags).join('|')})(?:\\((.*)\\))?`) + this._blockRegExp = new RegExp(`^\\s*\\@(!?)(${_.keys(tags).join('|')})(?:\\((.*)\\)?)?`) this._loader = loader this.buffer = new InternalBuffer(asFunction) this._runtimeVarIndex = 0 diff --git a/test/unit/ast.spec.js b/test/unit/ast.spec.js index 47d2012..5ea6027 100644 --- a/test/unit/ast.spec.js +++ b/test/unit/ast.spec.js @@ -273,4 +273,32 @@ test.group('Template Compiler', (group) => { assert.equal(ast[0].selfClosing, true) assert.equal(ast[0].body, `@!yield('foo')`) }) + + test('jump line numbers when multi-line tags are detected', (assert) => { + const statement = ` + @component( + 'user', + { username: 'virk' } + ) + {{ user.username }} + @endcomponent + ` + + const tags = { + component: { + name: 'component', + isBlock: true, + compile () {} + } + } + + const regExp = new RegExp(`^\\s*\\@(!?)(${_.keys(tags).join('|')})(?:\\((.*)\\))?`) + const ast = new Ast(tags, regExp).parse(statement) + + assert.lengthOf(ast, 1) + assert.equal(ast[0].lineno, 1) + assert.equal(ast[0].end.lineno, 6) + assert.lengthOf(ast[0].childs, 1) + assert.equal(ast[0].tag, 'component') + }) }) diff --git a/test/unit/template.spec.js b/test/unit/template.spec.js index 6b7e28d..64ee36b 100644 --- a/test/unit/template.spec.js +++ b/test/unit/template.spec.js @@ -128,6 +128,131 @@ test.group('Template Compiler', (group) => { assert.equal(error.stack.split('\n')[1].trim(), `at (${loader.getViewPath('includes/bad-partial.edge')}:2:0)`) } }) + + test('parse a template with multiline tags', (assert) => { + const statement = dedent` + @!component( + 'components.alert', + username = 'virk' + ) + ` + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + + assert.equal(output, dedent` + return (function templateFn () { + let out = new String() + this.isolate(function () { + out += \`\${this.renderWithContext('components.alert')}\` + }.bind(this.newContext({username: 'virk'},{$slot: { main: \`\` } }))) + return out + }).bind(this)() + `) + }) + + test('parse a template with multiline tags with closing bracket net to content', (assert) => { + const statement = dedent` + @!component( + 'components.alert', + username = 'virk') + ` + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + + assert.equal(output, dedent` + return (function templateFn () { + let out = new String() + this.isolate(function () { + out += \`\${this.renderWithContext('components.alert')}\` + }.bind(this.newContext({username: 'virk'},{$slot: { main: \`\` } }))) + return out + }).bind(this)() + `) + }) + + test('parse a template with multiline tags with first line having partial content', (assert) => { + const statement = dedent` + @!component('components.alert', + { username: 'virk' } + ) + ` + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + + assert.equal(output, dedent` + return (function templateFn () { + let out = new String() + this.isolate(function () { + out += \`\${this.renderWithContext('components.alert')}\` + }.bind(this.newContext({username: 'virk'},{$slot: { main: \`\` } }))) + return out + }).bind(this)() + `) + }) + + test('parse a template with multiline tags with comments in between', (assert) => { + const statement = dedent` + @!component('components.alert', + {{-- Data to be passed --}} + { username: 'virk' } + ) + ` + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + + assert.equal(output, dedent` + return (function templateFn () { + let out = new String() + this.isolate(function () { + out += \`\${this.renderWithContext('components.alert')}\` + }.bind(this.newContext({username: 'virk'},{$slot: { main: \`\` } }))) + return out + }).bind(this)() + `) + }) + + test('parse a template with multiline tags with comments next to content', (assert) => { + const statement = dedent` + @!component('components.alert', + { username: 'virk' } {{-- Data to be passed --}} + ) + ` + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + + assert.equal(output, dedent` + return (function templateFn () { + let out = new String() + this.isolate(function () { + out += \`\${this.renderWithContext('components.alert')}\` + }.bind(this.newContext({username: 'virk'},{$slot: { main: \`\` } }))) + return out + }).bind(this)() + `) + }) + + test('work fine with multiline if clause', (assert) => { + const statement = dedent` + @if( + username === 'virk' + && age === 22 + && isAdmin + ) +

You are super user

+ @endif + ` + + const inlineStatement = dedent` + @if(username === 'virk' && age === 22 && isAdmin) +

You are super user

+ @endif + ` + + const template = new Template(this.tags, {}) + const output = template.compileString(statement) + const inlineOutput = template.compileString(inlineStatement) + assert.equal(output, inlineOutput) + }) }) test.group('Template Runner', () => {