diff --git a/src/Compiler/index.ts b/src/Compiler/index.ts index ce37c4c..1988f59 100644 --- a/src/Compiler/index.ts +++ b/src/Compiler/index.ts @@ -1,7 +1,3 @@ -/** - * @module edge - */ - /* * edge * @@ -12,10 +8,10 @@ */ import { EdgeError } from 'edge-error' -import { Parser, EdgeBuffer, ParserToken, ParserTagToken } from 'edge-parser' +import { Parser, EdgeBuffer } from 'edge-parser' +import { Token, TagToken, utils as lexerUtils } from 'edge-lexer' import { CacheManager } from '../CacheManager' -import { isBlockToken, getLineAndColumnForToken } from '../utils' import { LoaderContract, TagsContract, LoaderTemplate, CompilerContract } from '../Contracts' /** @@ -27,33 +23,28 @@ import { LoaderContract, TagsContract, LoaderTemplate, CompilerContract } from ' * the parsing process, it will recursively merge the layout sections. */ export class Compiler implements CompilerContract { - private _cacheManager = new CacheManager(this._cache) + public cacheManager = new CacheManager(this.cache) constructor ( - private _loader: LoaderContract, - private _tags: TagsContract, - private _cache: boolean = true, + private loader: LoaderContract, + private tags: TagsContract, + private cache: boolean = true, ) {} /** * Merges sections of base template and parent template tokens */ - private _mergeSections ( - base: ParserToken[], - extended: ParserToken[], - filename: string, - layoutPath: string, - ): ParserToken[] { + private mergeSections (base: Token[], extended: Token[]): Token[] { /** * Collection of all sections from the extended tokens */ - const extendedSections: { [key: string]: ParserTagToken } = {} + const extendedSections: { [key: string]: TagToken } = {} /** - * Collection of extended set calls as top level nodes. Now since they are hanging - * up in the air, they will be hoisted like `var` statements in Javascript + * Collection of extended set calls as top level nodes. The set + * calls are hoisted just like `var` statements in Javascript. */ - const extendedSetCalls: ParserTagToken[] = [] + const extendedSetCalls: TagToken[] = [] extended .forEach((node) => { @@ -62,7 +53,7 @@ export class Compiler implements CompilerContract { * template */ if ( - isBlockToken(node, 'layout') || + lexerUtils.isTag(node, 'layout') || node.type === 'newline' || (node.type === 'raw' && !node.value.trim()) ) { @@ -72,7 +63,7 @@ export class Compiler implements CompilerContract { /** * Collect parent template sections */ - if (isBlockToken(node, 'section')) { + if (lexerUtils.isTag(node, 'section')) { extendedSections[node.properties.jsArg.trim()] = node return } @@ -80,19 +71,20 @@ export class Compiler implements CompilerContract { /** * Collect set calls inside parent templates */ - if (isBlockToken(node, 'set')) { + if (lexerUtils.isTag(node, 'set')) { extendedSetCalls.push(node) return } /** - * Everything else if not allowed as top level nodes + * Everything else is not allowed as top level nodes */ - const [line, col] = getLineAndColumnForToken(node) + const [line, col] = lexerUtils.getLineAndColumn(node) + throw new EdgeError( - `Template extending the layout can only define @sections as top level nodes`, + 'Template extending a layout can only use "@section" or "@set" tags as top level nodes', 'E_UNALLOWED_EXPRESSION', - { line, col, filename }, + { line, col, filename: node.filename }, ) }) @@ -101,25 +93,21 @@ export class Compiler implements CompilerContract { */ const finalNodes = base .map((node) => { - if (!isBlockToken(node, 'section')) { - node.filename = layoutPath + if (!lexerUtils.isTag(node, 'section')) { return node } - const extendedNode = extendedSections[node.properties.jsArg.trim()] + const sectionName = node.properties.jsArg.trim() + const extendedNode = extendedSections[sectionName] if (!extendedNode) { - node.filename = layoutPath return node } /** * Concat children when super was called */ - if (extendedNode.children.length && isBlockToken(extendedNode.children[0], 'super')) { - extendedNode.children = node.children.map((child) => { - (child as ParserToken).filename = layoutPath - return child - }).concat(extendedNode.children) + if (extendedNode.children.length && lexerUtils.isTag(extendedNode.children[0], 'super')) { + extendedNode.children = node.children.concat(extendedNode.children) } return extendedNode @@ -128,7 +116,7 @@ export class Compiler implements CompilerContract { /** * Set calls are hoisted to the top */ - return ([] as ParserToken[]).concat(extendedSetCalls).concat(finalNodes) + return ([] as Token[]).concat(extendedSetCalls).concat(finalNodes) } /** @@ -136,34 +124,17 @@ export class Compiler implements CompilerContract { * are checked for layouts and if layouts are used, their sections will be * merged together. */ - private _templateContentToTokens ( - content: string, - parser: Parser, - filename: string, - ): ParserToken[] { - let templateTokens = parser.generateLexerTokens(content) + private templateContentToTokens (content: string, parser: Parser): Token[] { + let templateTokens = parser.tokenize(content) const firstToken = templateTokens[0] /** * The `layout` is inbuilt feature from core, where we merge the layout * and parent template sections together */ - if (isBlockToken(firstToken, 'layout')) { + if (lexerUtils.isTag(firstToken, 'layout')) { const layoutName = firstToken.properties.jsArg.replace(/'/g, '') - - /** - * Making absolute path, so that lexer errors must point to the - * absolute file path - */ - const absPath = this._loader.makePath(layoutName) - const layoutTokens = this.generateLexerTokens(absPath) - - templateTokens = this._mergeSections( - layoutTokens, - templateTokens, - filename, - absPath, - ) + templateTokens = this.mergeSections(this.tokenize(layoutName), templateTokens) } return templateTokens @@ -171,18 +142,18 @@ export class Compiler implements CompilerContract { /** * Converts the template content to an [array of lexer tokens]. The method is - * same as the `parser.generateLexerTokens`, plus it will handle the layouts - * and it's sections. + * same as the `parser.tokenize`, but it also handles layouts natively. * * ``` - * compiler.generateLexerTokens('<template-path>') + * compiler.tokenize('<template-path>') * ``` */ - public generateLexerTokens (templatePath: string): ParserToken[] { - const { template } = this._loader.resolve(templatePath, false) + public tokenize (templatePath: string): Token[] { + const absPath = this.loader.makePath(templatePath) + const { template } = this.loader.resolve(absPath, false) - const parser = new Parser(this._tags, { filename: templatePath }) - return this._templateContentToTokens(template, parser, templatePath) + const parser = new Parser(this.tags, { filename: absPath }) + return this.templateContentToTokens(template, parser) } /** @@ -207,19 +178,19 @@ export class Compiler implements CompilerContract { * ``` */ public compile (templatePath: string, inline: boolean): LoaderTemplate { - const absPath = this._loader.makePath(templatePath) + const absPath = this.loader.makePath(templatePath) /** * If template is in the cache, then return it without * further processing */ - const cachedResponse = this._cacheManager.get(absPath) + const cachedResponse = this.cacheManager.get(absPath) if (cachedResponse) { return cachedResponse } /** - * Do not load presenter in inline mode + * Do not return presenter in inline mode */ const loadPresenter = !inline @@ -232,32 +203,32 @@ export class Compiler implements CompilerContract { /** * Get a new instance of the parser. */ - const parser = new Parser(this._tags, { filename: absPath }) + const parser = new Parser(this.tags, { filename: absPath }) /** - * Resolve the template and Presenter using the given loader + * Resolve the template and Presenter using the given loader. We always + * load the presenter but don't return it when `loadPresenter = false`. */ - const { template, Presenter } = this._loader.resolve(absPath, loadPresenter) + const { template, Presenter } = this.loader.resolve(absPath, true) /** * Convert template to AST. The AST will have the layout actions merged (if layout) * is used. */ - const templateTokens = this._templateContentToTokens(template, parser, absPath) + const templateTokens = this.templateContentToTokens(template, parser) /** * Finally process the ast */ - const buffer = new EdgeBuffer() - buffer.writeStatement(`ctx.set('$filename', '${templatePath.replace(/\.edge$/, '')}.edge');`) - templateTokens.forEach((token) => parser.processLexerToken(token, buffer)) + const buffer = new EdgeBuffer(absPath, wrapAsFunction) + templateTokens.forEach((token) => parser.processToken(token, buffer)) const payload = { - template: buffer.flush(wrapAsFunction), + template: buffer.flush(), Presenter, } - this._cacheManager.set(absPath, payload) - return payload + this.cacheManager.set(absPath, payload) + return loadPresenter ? payload : { template: payload.template } } } diff --git a/test/compiler.spec.ts b/test/compiler.spec.ts index 688fbc6..5bd949f 100644 --- a/test/compiler.spec.ts +++ b/test/compiler.spec.ts @@ -9,16 +9,23 @@ import test from 'japa' import { join } from 'path' +import dedent from 'dedent-js' import { Filesystem } from '@poppinss/dev-utils' +import { TagTypes, MustacheTypes } from 'edge-lexer' import { Loader } from '../src/Loader' +import { Context } from '../src/Context' import { Compiler } from '../src/Compiler' +import { layoutTag } from '../src/Tags/Layout' +import { sectionTag } from '../src/Tags/Section' +import { setTag } from '../src/Tags/Set' +import './assert-extend' const tags = {} const fs = new Filesystem(join(__dirname, 'views')) -test.group('Compiler', (group) => { +test.group('Compiler | Cache', (group) => { group.afterEach(async () => { await fs.cleanup() }) @@ -30,16 +37,23 @@ test.group('Compiler', (group) => { loader.mount('default', fs.basePath) const compiler = new Compiler(loader, tags) - assert.equal(compiler.compile('foo', false).template, `(function (template, ctx) { - let out = ''; - ctx.set('$filename', 'foo.edge'); - out += 'Hello '; - out += \`\${ctx.escape(ctx.resolve('username'))}\`; - return out; -})(template, ctx)`) + const { template } = compiler.compile('foo', false) + + assert.stringEqual(template, dedent`return (function (template, ctx) { + let out = ''; + ctx.$lineNumber = 1; + ctx.$filename = '${join(fs.basePath, 'foo.edge')}'; + try { + out += 'Hello '; + out += \`\${ctx.escape(ctx.resolve('username'))}\`; + } catch (error) { + ctx.reThrow(error); + } + return out; + })(template, ctx)`) }) - test('save template to cache when is turned on', async (assert) => { + test('save template to cache when caching is turned on', async (assert) => { await fs.add('foo.edge', 'Hello {{ username }}') const loader = new Loader() @@ -48,7 +62,7 @@ test.group('Compiler', (group) => { const compiler = new Compiler(loader, tags, true) assert.equal( compiler.compile('foo', false), - compiler['_cacheManager'].get(join(fs.basePath, 'foo.edge')), + compiler.cacheManager.get(join(fs.basePath, 'foo.edge')), ) }) @@ -62,7 +76,7 @@ test.group('Compiler', (group) => { const compiler = new Compiler(loader, tags, true) compiler.compile('foo', false) assert.equal( - compiler['_cacheManager'].get(join(fs.basePath, 'foo.edge'))!.Presenter!['name'], + compiler.cacheManager.get(join(fs.basePath, 'foo.edge'))!.Presenter!['name'], 'Foo', ) }) @@ -75,31 +89,493 @@ test.group('Compiler', (group) => { const compiler = new Compiler(loader, tags, false) compiler.compile('foo', false) - assert.isUndefined(compiler['_cacheManager'].get(join(fs.basePath, 'foo.edge'))) + assert.isUndefined(compiler.cacheManager.get(join(fs.basePath, 'foo.edge'))) }) - test('do not wrap inline templates to a function', async (assert) => { + test('do not load presenter for inline templates', async (assert) => { await fs.add('foo.edge', 'Hello {{ username }}') + await fs.add('foo.presenter.js', '') + const loader = new Loader() loader.mount('default', fs.basePath) const compiler = new Compiler(loader, tags) - assert.equal(compiler.compile('foo', true).template, ` - let out = ''; - ctx.set('$filename', 'foo.edge'); - out += 'Hello '; - out += \`\${ctx.escape(ctx.resolve('username'))}\`; - return out;`) + assert.isUndefined(compiler.compile('foo', true).Presenter) + assert.isDefined(compiler.compile('foo', false).Presenter) }) +}) - test('do not load presenter for inline templates', async (assert) => { - await fs.add('foo.edge', 'Hello {{ username }}') - await fs.add('foo.presenter.js', '') +test.group('Compiler | Tokenize', (group) => { + group.afterEach(async () => { + await fs.cleanup() + }) + + test('during tokenize, merge @section tags of a given layout', async (assert) => { + await fs.add('master.edge', dedent` + Master page + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + Hello world + @endsection + `) const loader = new Loader() loader.mount('default', fs.basePath) - const compiler = new Compiler(loader, tags) - assert.isUndefined(compiler.compile('foo', true).Presenter) + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + assert.deepEqual(compiler.tokenize('index.edge'), [ + { + type: 'raw' as const, + value: 'Master page', + line: 1, + filename: join(fs.basePath, 'master.edge'), + }, + { + type: 'newline' as const, + line: 1, + filename: join(fs.basePath, 'master.edge'), + }, + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'index.edge'), + properties: { name: 'section', jsArg: `'content'`, selfclosed: false }, + loc: { + start: { line: 2, col: 9 }, + end: { line: 2, col: 19 }, + }, + children: [ + { + type: 'raw' as const, + value: ' Hello world', + line: 3, + filename: join(fs.basePath, 'index.edge'), + }, + ], + }, + ]) + }) + + test('during tokenize, merge @set tags of a given layout', async (assert) => { + await fs.add('master.edge', dedent` + Master page + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @set('username', 'virk') + @section('content') + Hello world + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + set: setTag, + }) + + assert.deepEqual(compiler.tokenize('index.edge'), [ + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'index.edge'), + properties: { name: 'set', jsArg: `'username', 'virk'`, selfclosed: false }, + loc: { + start: { line: 2, col: 5 }, + end: { line: 2, col: 24 }, + }, + children: [], + }, + { + type: 'raw' as const, + value: 'Master page', + line: 1, + filename: join(fs.basePath, 'master.edge'), + }, + { + type: 'newline' as const, + line: 1, + filename: join(fs.basePath, 'master.edge'), + }, + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'index.edge'), + properties: { name: 'section', jsArg: `'content'`, selfclosed: false }, + loc: { + start: { line: 3, col: 9 }, + end: { line: 3, col: 19 }, + }, + children: [ + { + type: 'raw' as const, + value: ' Hello world', + line: 4, + filename: join(fs.basePath, 'index.edge'), + }, + ], + }, + ]) + }) + + test('ensure template extending layout can only use section or set tags', async (assert) => { + assert.plan(4) + + await fs.add('master.edge', dedent` + Master page + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + Hello world + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + try { + compiler.tokenize('index.edge') + } catch (error) { + assert.equal( + error.message, + 'Template extending a layout can only use "@section" or "@set" tags as top level nodes', + ) + assert.equal(error.filename, join(fs.basePath, 'index.edge')) + assert.equal(error.line, 2) + assert.equal(error.col, 0) + } + }) + + test('during tokenize, merge @section tags of a nested layouts', async (assert) => { + await fs.add('super-master.edge', dedent` + Master page + @!section('header') + @!section('content') + `) + + await fs.add('master.edge', dedent` + @layout('super-master') + @section('header') + This is header + @endsection + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + This is content + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + assert.deepEqual(compiler.tokenize('index.edge'), [ + { + type: 'raw' as const, + value: 'Master page', + line: 1, + filename: join(fs.basePath, 'super-master.edge'), + }, + { + type: 'newline' as const, + line: 1, + filename: join(fs.basePath, 'super-master.edge'), + }, + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'master.edge'), + properties: { name: 'section', jsArg: `'header'`, selfclosed: false }, + loc: { + start: { line: 2, col: 9 }, + end: { line: 2, col: 18 }, + }, + children: [ + { + type: 'raw' as const, + value: ' This is header', + line: 3, + filename: join(fs.basePath, 'master.edge'), + }, + ], + }, + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'index.edge'), + properties: { name: 'section', jsArg: `'content'`, selfclosed: false }, + loc: { + start: { line: 2, col: 9 }, + end: { line: 2, col: 19 }, + }, + children: [ + { + type: 'raw' as const, + value: ' This is content', + line: 3, + filename: join(fs.basePath, 'index.edge'), + }, + ], + }, + ]) + }) + + test('layout tokens must point to its own filename', async (assert) => { + await fs.add('master.edge', dedent` + {{ username }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + Hello world + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + assert.deepEqual(compiler.tokenize('index.edge'), [ + { + type: MustacheTypes.MUSTACHE, + filename: join(fs.basePath, 'master.edge'), + loc: { + start: { line: 1, col: 2 }, + end: { line: 1, col: 14 }, + }, + properties: { jsArg: ` username ` }, + }, + { + type: 'newline' as const, + line: 1, + filename: join(fs.basePath, 'master.edge'), + }, + { + type: TagTypes.TAG, + filename: join(fs.basePath, 'index.edge'), + properties: { name: 'section', jsArg: `'content'`, selfclosed: false }, + loc: { + start: { line: 2, col: 9 }, + end: { line: 2, col: 19 }, + }, + children: [ + { + type: 'raw' as const, + value: ' Hello world', + line: 3, + filename: join(fs.basePath, 'index.edge'), + }, + ], + }, + ]) + }) +}) + +test.group('Compiler | Compile', (group) => { + group.afterEach(async () => { + await fs.cleanup() + }) + + test('compile template with layouts', async (assert) => { + await fs.add('master.edge', dedent` + {{ username }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + {{ content }} + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + assert.stringEqual(compiler.compile('index.edge', false).template, dedent` + return (function (template, ctx) { + let out = ''; + ctx.$lineNumber = 1; + ctx.$filename = '${join(fs.basePath, 'index.edge')}'; + try { + ctx.$filename = '${join(fs.basePath, 'master.edge')}'; + out += \`\${ctx.escape(ctx.resolve('username'))}\`; + out += '\\n'; + out += ' '; + ctx.$filename = '${join(fs.basePath, 'index.edge')}'; + ctx.$lineNumber = 3; + out += \`\${ctx.escape(ctx.resolve('content'))}\`; + } catch (error) { + ctx.reThrow(error); + } + return out; + })(template, ctx) + `) + }) + + test('compile errors inside layout must point to the right file', async (assert) => { + assert.plan(3) + + await fs.add('master.edge', dedent` + {{ user name }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + {{ content }} + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + try { + compiler.compile('index.edge', false) + } catch (error) { + assert.equal(error.filename, join(fs.basePath, 'master.edge')) + assert.equal(error.line, 1) + assert.equal(error.col, 8) + } + }) + + test('compile errors parent template must point to the right file', async (assert) => { + assert.plan(3) + + await fs.add('master.edge', dedent` + {{ username }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + {{ con tent }} + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + try { + compiler.compile('index.edge', false) + } catch (error) { + assert.equal(error.filename, join(fs.basePath, 'index.edge')) + assert.equal(error.line, 3) + assert.equal(error.col, 9) + } + }) + + test('runtime errors inside layout must point to the right file', async (assert) => { + assert.plan(4) + + await fs.add('master.edge', dedent` + {{ getUserName() }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + {{ content }} + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + try { + new Function('template', 'ctx', compiler.compile('index.edge', false).template)( + {}, + new Context({ state: {} }, {}), + ) + } catch (error) { + assert.equal(error.message, 'getUserName is not a function') + assert.equal(error.filename, join(fs.basePath, 'master.edge')) + assert.equal(error.line, 1) + assert.equal(error.col, 0) + } + }) + + test('runtime errors inside parent template must point to the right file', async (assert) => { + assert.plan(4) + + await fs.add('master.edge', dedent` + {{ username }} + @!section('content') + `) + + await fs.add('index.edge', dedent` + @layout('master') + @section('content') + {{ getContent() }} + @endsection + `) + + const loader = new Loader() + loader.mount('default', fs.basePath) + + const compiler = new Compiler(loader, { + section: sectionTag, + layout: layoutTag, + }) + + try { + const fn = new Function('template', 'ctx', compiler.compile('index.edge', false).template) + fn({}, new Context({ state: {} }, {})) + } catch (error) { + assert.equal(error.message, 'getContent is not a function') + assert.equal(error.filename, join(fs.basePath, 'index.edge')) + assert.equal(error.line, 3) + assert.equal(error.col, 0) + } }) })