diff --git a/examples/index.js b/examples/index.js index e2166cf..02b3305 100644 --- a/examples/index.js +++ b/examples/index.js @@ -9,7 +9,7 @@ http.createServer((req, res) => { edge.mount(join(__dirname, './views')) res.writeHead(200, { 'content-type': 'text/html' }) try { - res.end(edge.render('user', { title: 'Hello' })) + res.end(edge.render('user', { title: 'Hello', username: 'virk' })) } catch (error) { new Youch(error, req).toHTML().then((html) => { res.writeHead(500, { 'content-type': 'text/html' }) diff --git a/examples/views/partial.edge b/examples/views/partial.edge index 462e5e2..a583421 100644 --- a/examples/views/partial.edge +++ b/examples/views/partial.edge @@ -1,2 +1,3 @@ {{ inspect($state, 1) }} -
Access to local state {{ username }}
\ No newline at end of file diff --git a/examples/views/user.edge b/examples/views/user.edge index 3a59040..a4822a2 100644 --- a/examples/views/user.edge +++ b/examples/views/user.edge @@ -18,6 +18,13 @@ {{ inspect($state, 1) }} + @set('users', [{}]) + + {{ users.map( + (user) => { + return user.getUsername() + } + ) }} @component('alert', { title: 'hello world' }) @slot('title', props) diff --git a/examples/views/user.presenter.js b/examples/views/user.presenter.js index 1745123..e7c4228 100644 --- a/examples/views/user.presenter.js +++ b/examples/views/user.presenter.js @@ -1,9 +1,6 @@ module.exports = class User { - constructor (state) { + constructor (state, sharedState) { this.state = state - } - - slotTitle (ctx) { - return ctx.resolve('props').title.toUpperCase() + this.sharedState = sharedState } } diff --git a/index.ts b/index.ts index 2b90662..989a615 100644 --- a/index.ts +++ b/index.ts @@ -7,12 +7,13 @@ * file that was distributed with this source code. */ +export * from './src/Contracts' import { Edge } from './src/Edge' import globals from './src/Edge/globals' -export * from './src/Contracts' const edge = new Edge() globals(edge) -export default edge export { Edge } +export default edge +export { safeValue, withCtx } from './src/Context' diff --git a/package-lock.json b/package-lock.json index d398dfd..ba3e79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -535,6 +535,12 @@ "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", "dev": true }, + "@types/lodash": { + "version": "4.14.149", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", + "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", + "dev": true + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", diff --git a/package.json b/package.json index a736cc4..0920a98 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@adonisjs/mrm-preset": "^2.2.4", "@poppinss/dev-utils": "^1.0.4", + "@types/lodash": "^4.14.149", "@types/node": "^13.9.0", "commitizen": "^4.0.3", "cz-conventional-changelog": "^3.1.0", diff --git a/src/Context/index.ts b/src/Context/index.ts index 27cb7ff..97f5468 100644 --- a/src/Context/index.ts +++ b/src/Context/index.ts @@ -19,10 +19,27 @@ import { ContextContract } from '../Contracts' * method ensures that underlying value is never * escaped. */ -export class SafeValue { +class SafeValue { constructor (public value: any) {} } +/** + * A class to wrap callbacks that can access the `ctx` + */ +class WithCtx { + constructor (private callback: (ctx: ContextContract, ...args: any[]) => any) { + } + + /** + * Invoke the callback + */ + public invoke (ctx: ContextContract, bindState: any) { + return (...args: any[]) => { + return this.callback.bind(bindState)(ctx, ...args) + } + } +} + /** * Context is used at runtime to resolve values for a given * template. @@ -39,12 +56,6 @@ export class Context extends Macroable implements ContextContract { */ private frames: any[] = [] - /** - * We keep a reference to the last resolved key and use it inside - * the `reThrow` method. - */ - private lastResolvedKey = '' - /** * Required by Macroable */ @@ -151,6 +162,22 @@ export class Context extends Macroable implements ContextContract { : (input instanceof SafeValue ? input.value : input) } + /** + * Transform the resolved value before returning it + * back + */ + private transformValue (value: any, bindState: any) { + if (value instanceof WithCtx) { + return value.invoke(this, bindState) + } + + if (typeof (value) === 'function') { + return value.bind(bindState) + } + + return value + } + /** * Resolves value for a given key. It will look for the value in different * locations and continues till the end if `undefined` is returned at @@ -169,8 +196,6 @@ export class Context extends Macroable implements ContextContract { * ``` */ public resolve (key: string): any { - this.lastResolvedKey = key - /** * A special key to return the template current state */ @@ -201,7 +226,7 @@ export class Context extends Macroable implements ContextContract { */ value = this.getFromFrame(key) if (value !== undefined) { - return typeof (value) === 'function' ? value.bind(this) : value + return this.transformValue(value, this) } /** @@ -210,7 +235,7 @@ export class Context extends Macroable implements ContextContract { */ value = this.presenter[key] if (value !== undefined) { - return typeof (value) === 'function' ? value.bind(this.presenter) : value + return this.transformValue(value, this.presenter) } /** @@ -218,14 +243,14 @@ export class Context extends Macroable implements ContextContract { */ value = this.presenter.state[key] if (value !== undefined) { - return typeof (value) === 'function' ? value.bind(this.presenter.state) : value + return this.transformValue(value, this.presenter.state) } /** * Finally fallback to shared globals */ value = this.presenter.sharedState[key] - return typeof (value) === 'function' ? value.bind(this.presenter.sharedState) : value + return this.transformValue(value, this.presenter.sharedState) } /** @@ -272,11 +297,26 @@ export class Context extends Macroable implements ContextContract { throw error } - const message = error.message.replace(/ctx\.resolve\(\.\.\.\)/, this.lastResolvedKey) - throw new EdgeError(message, 'E_RUNTIME_EXCEPTION', { + // const message = error.message.replace(/ctx\.resolve\(\.\.\.\)/, this.lastResolvedKey) + throw new EdgeError(error.message, 'E_RUNTIME_EXCEPTION', { filename: this.$filename, line: this.$lineNumber, col: 0, }) } } + +/** + * Mark value as safe and not to be escaped + */ +export function safeValue (value: string) { + return new SafeValue(value) +} + +/** + * Wrap a function that receives the template engine current + * ctx when invoked. + */ +export function withCtx (callback: (ctx: ContextContract, ...args: any[]) => any) { + return new WithCtx(callback) +} diff --git a/src/Edge/globals/index.ts b/src/Edge/globals/index.ts index 4f156d0..059e9bc 100644 --- a/src/Edge/globals/index.ts +++ b/src/Edge/globals/index.ts @@ -1,7 +1,3 @@ -/** - * @module edge - */ - /* * edge * @@ -12,13 +8,26 @@ */ import { inspect as utilInspect } from 'util' -import { range } from 'lodash' +import { + size, + last, + first, + range, + groupBy, + truncate, +} from 'lodash' + +import { safeValue, withCtx } from '../../Context' import { EdgeContract, ContextContract } from '../../Contracts' /** * Inspect value. */ -function inspect (ctx: ContextContract, valueToInspect: any, depth: number = 1) { +function inspect ( + ctx: ContextContract, + valueToInspect: any, + depth: number = 1, +) { const inspectedString = `${utilInspect(valueToInspect, { showHidden: true, compact: false, @@ -29,7 +38,9 @@ function inspect (ctx: ContextContract, valueToInspect: any, depth: number = 1) ${ctx.resolve('$filename')} ` - return ctx.safe(`${inspectedString}${filename}`) + return safeValue( + `${inspectedString}${filename}`, + ) } /** @@ -39,7 +50,21 @@ inspect[Symbol.for('nodejs.util.inspect.custom')] = function customInspect () { return '[inspect]' } +/** + * A list of default globals + */ export default function globals (edge: EdgeContract) { - edge.global('inspect', inspect) - edge.global('range', (_, start: number, end?: number, step?: number) => range(start, end, step)) + edge.global('inspect', withCtx(inspect)) + edge.global('range', (start: number, end?: number, step?: number) => range(start, end, step)) + edge.global('first', first) + edge.global('last', last) + edge.global('groupBy', groupBy) + edge.global('size', size) + edge.global('truncate', truncate) + edge.global('toAnchor', (url: string, title: string = url) => { + return safeValue(` ${title} `) + }) + edge.global('style', (url: string, title: string = url) => { + return safeValue(` ${title} `) + }) } diff --git a/src/Presenter/index.ts b/src/Presenter/index.ts index 3079ab8..e0a3f80 100644 --- a/src/Presenter/index.ts +++ b/src/Presenter/index.ts @@ -18,5 +18,7 @@ import { PresenterContract } from '../Contracts' */ export class Presenter implements PresenterContract { constructor (public state: any, public sharedState: any) { + this.state = this.state || {} + this.sharedState = this.sharedState || {} } } diff --git a/test/compiler.spec.ts b/test/compiler.spec.ts index 27905ba..921c3ea 100644 --- a/test/compiler.spec.ts +++ b/test/compiler.spec.ts @@ -538,7 +538,7 @@ test.group('Compiler | Compile', (group) => { new Context({ state: {}, sharedState: {} }), ) } catch (error) { - assert.equal(error.message, 'getUserName is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.filename, join(fs.basePath, 'master.edge')) assert.equal(error.line, 1) assert.equal(error.col, 0) @@ -572,7 +572,7 @@ test.group('Compiler | Compile', (group) => { const fn = new Function('template', 'ctx', compiler.compile('index.edge', false).template) fn({}, new Context({ state: {}, sharedState: {} })) } catch (error) { - assert.equal(error.message, 'getContent is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.filename, join(fs.basePath, 'index.edge')) assert.equal(error.line, 3) assert.equal(error.col, 0) diff --git a/test/component.spec.ts b/test/component.spec.ts index b94b1e5..32702bf 100644 --- a/test/component.spec.ts +++ b/test/component.spec.ts @@ -144,7 +144,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getComponentName is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.line, 3) assert.equal(error.col, 0) assert.equal(error.filename, join(fs.basePath, 'eval.edge')) @@ -167,7 +167,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getColor is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.line, 3) assert.equal(error.col, 0) assert.equal(error.filename, join(fs.basePath, 'eval.edge')) @@ -192,7 +192,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getColor is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') /** * Expected to be on line 4. But okay for now */ @@ -218,7 +218,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getColor is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.line, 3) assert.equal(error.col, 0) assert.equal(error.filename, join(fs.basePath, 'eval.edge')) @@ -244,7 +244,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getColor is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') /** * Expected to be on line 5. But okay for now */ @@ -299,7 +299,7 @@ test.group('Component | render | errors', (group) => { try { template.render('eval.edge', {}) } catch (error) { - assert.equal(error.message, 'getColor is not a function') + assert.equal(error.message, 'ctx.resolve(...) is not a function') assert.equal(error.line, 2) assert.equal(error.col, 0) assert.equal(error.filename, join(fs.basePath, 'button.edge')) diff --git a/test/context.spec.ts b/test/context.spec.ts index fe39152..e49b539 100644 --- a/test/context.spec.ts +++ b/test/context.spec.ts @@ -8,8 +8,8 @@ */ import test from 'japa' -import { Context } from '../src/Context' import { Presenter } from '../src/Presenter' +import { Context, withCtx } from '../src/Context' test.group('Context', (group) => { group.afterEach(() => { @@ -487,8 +487,43 @@ test.group('Context', (group) => { try { ctx.reThrow(error) } catch (newError) { - assert.equal(newError.message, 'getUser is not a function') + assert.equal(newError.message, 'ctx.resolve(...) is not a function') } } }) + + test('withCtx wrapped functions should be able to access ctx', (assert) => { + const sharedState = {} + const data = { + username: 'virk', + } + + class MyPresenter extends Presenter { + public getUsername = withCtx(function (ctx) { + return `${this.state.username} ${ctx.presenter.state.username}` + }) + } + + const presenter = new MyPresenter(data, sharedState) + const context = new Context(presenter) + + assert.equal(context.resolve('getUsername')(), 'virk virk') + }) + + test('withCtx wrapped global functions should be able to access ctx', (assert) => { + const sharedState = { + getUsername: withCtx(function (ctx) { + assert.deepEqual(this, sharedState) + return ctx.presenter.state.username + }), + } + const data = { + username: 'virk', + } + + const presenter = new Presenter(data, sharedState) + const context = new Context(presenter) + + assert.equal(context.resolve('getUsername')(), 'virk') + }) }) diff --git a/test/edge.spec.ts b/test/edge.spec.ts index 84f020f..ec8ff2a 100644 --- a/test/edge.spec.ts +++ b/test/edge.spec.ts @@ -12,6 +12,7 @@ import { join } from 'path' import { Filesystem } from '@poppinss/dev-utils' import { Edge } from '../src/Edge' +import applyGlobals from '../src/Edge/globals' const fs = new Filesystem(join(__dirname, 'views')) @@ -241,3 +242,75 @@ test.group('Edge', (group) => { } }) }) + +test.group('Edge | globals', () => { + test('return first item from an array', (assert) => { + const edge = new Edge() + edge.registerTemplate('welcome', { + template: 'Hello {{ first(users) }}', + }) + applyGlobals(edge) + assert.equal(edge.render('welcome', { users: ['virk', 'romain'] }), 'Hello virk') + }) + + test('return last item from an array', (assert) => { + const edge = new Edge() + edge.registerTemplate('welcome', { + template: 'Hello {{ last(users) }}', + }) + applyGlobals(edge) + assert.equal(edge.render('welcome', { users: ['virk', 'romain'] }), 'Hello romain') + }) + + test('group array values by key', (assert) => { + const edge = new Edge() + edge.registerTemplate('welcome', { + template: 'Total of {{ groupBy(users, \'age\')[\'28\'].length }} users', + }) + applyGlobals(edge) + + const users = [ + { + username: 'virk', + age: 28, + }, + { + username: 'romain', + age: 28, + }, + { + username: 'nikk', + age: 26, + }, + ] + + assert.equal(edge.render('welcome', { users }), 'Total of 2 users') + }) + + test('group array values by closure', (assert) => { + const edge = new Edge() + edge.registerTemplate('welcome', { + template: `Total of {{ + groupBy(users, ({ age }) => age)[\'28\'].length + }} users`, + }) + applyGlobals(edge) + + const users = [ + { + username: 'virk', + age: 28, + }, + { + username: 'romain', + age: 28, + }, + { + username: 'nikk', + age: 26, + }, + ] + + assert.equal(edge.render('welcome', { users }), 'Total of 2 users') + }) +})