@financial-times/dotcom-server-handlebars 7.3.1 → 7.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +3 -2
  2. package/src/PageKitHandlebars.ts +142 -0
  3. package/src/__test__/HandlebarsRenderer.spec.ts +99 -0
  4. package/src/__test__/__fixtures__/components/DefaultExportComponentCJS.js +5 -0
  5. package/src/__test__/__fixtures__/components/DefaultExportComponentES.tsx +5 -0
  6. package/src/__test__/__fixtures__/components/NamedExportComponentCJS.js +5 -0
  7. package/src/__test__/__fixtures__/components/NamedExportComponentES.tsx +5 -0
  8. package/src/__test__/__fixtures__/views/partials/content.hbs +1 -0
  9. package/src/__test__/__fixtures__/views/partials/partial.hbs +1 -0
  10. package/src/__test__/__fixtures__/views/partials/wrapper.hbs +3 -0
  11. package/src/__test__/__fixtures__/views/view.hbs +4 -0
  12. package/src/__test__/helpers.spec.ts +341 -0
  13. package/src/findPartialFiles.ts +25 -0
  14. package/src/helpers/README.md +213 -0
  15. package/src/helpers/array.ts +8 -0
  16. package/src/helpers/capture.ts +9 -0
  17. package/src/helpers/concat.ts +8 -0
  18. package/src/helpers/dateformat.ts +13 -0
  19. package/src/helpers/encode.ts +15 -0
  20. package/src/helpers/ifAll.ts +12 -0
  21. package/src/helpers/ifEquals.ts +12 -0
  22. package/src/helpers/ifEqualsSome.ts +13 -0
  23. package/src/helpers/ifSome.ts +12 -0
  24. package/src/helpers/json.ts +16 -0
  25. package/src/helpers/renderReactComponent.ts +34 -0
  26. package/src/helpers/resize.ts +20 -0
  27. package/src/helpers/slice.ts +22 -0
  28. package/src/helpers/unlessAll.ts +12 -0
  29. package/src/helpers/unlessEquals.ts +12 -0
  30. package/src/helpers/unlessSome.ts +12 -0
  31. package/src/helpers.ts +16 -0
  32. package/src/index.ts +3 -0
  33. package/src/loadFileContents.ts +5 -0
  34. package/src/types.d.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/dotcom-server-handlebars",
3
- "version": "7.3.1",
3
+ "version": "7.3.2",
4
4
  "description": "",
5
5
  "main": "dist/node/index.js",
6
6
  "types": "src/index.ts",
@@ -31,7 +31,8 @@
31
31
  "npm": "7.x || 8.x"
32
32
  },
33
33
  "files": [
34
- "dist/"
34
+ "dist/",
35
+ "src/"
35
36
  ],
36
37
  "repository": {
37
38
  "type": "git",
@@ -0,0 +1,142 @@
1
+ import path from 'path'
2
+ import mixinDeep from 'mixin-deep'
3
+ import Handlebars, { HelperDelegate, TemplateDelegate } from 'handlebars'
4
+ import findPartialFiles from './findPartialFiles'
5
+ import loadFileContents from './loadFileContents'
6
+ import { TRenderCallback, TPartialTemplates, TFilePaths } from './types'
7
+
8
+ export type TPageKitHandlebarsOptions = {
9
+ /**
10
+ * An instance of Handlebars to use.
11
+ * @default require('handlebars')
12
+ */
13
+ handlebars?: typeof Handlebars
14
+
15
+ /**
16
+ * Current working directory.
17
+ * @default process.cwd()
18
+ */
19
+ rootDirectory?: string
20
+
21
+ /**
22
+ * Additional helper functions to register with Handlebars.
23
+ * @default {}
24
+ */
25
+ helpers?: {
26
+ [key: string]: HelperDelegate
27
+ }
28
+
29
+ /**
30
+ * Partial templates to register with Handlebars.
31
+ * @default {}
32
+ */
33
+ partials?: TPartialTemplates
34
+
35
+ /**
36
+ * A list of directories and patterns used to dynamically find and load partial template files.
37
+ * @default { './views/partials': '**\/*.{hbs,html}' }
38
+ */
39
+ partialPaths?: TFilePaths
40
+
41
+ /**
42
+ * Enable caching of template files to reduce filesystem I/O
43
+ * @default process.env.NODE_ENV !== 'development
44
+ */
45
+ cache?: boolean
46
+ }
47
+
48
+ // By default NODE_ENV will be undefined so be explicit
49
+ const nodeEnv = process.env.NODE_ENV || 'development'
50
+
51
+ const defaultOptions: TPageKitHandlebarsOptions = {
52
+ cache: nodeEnv !== 'development',
53
+ rootDirectory: process.cwd(),
54
+ helpers: {},
55
+ partials: {},
56
+ partialPaths: {
57
+ './views/partials': '**/*.{hbs,html}',
58
+ './bower_components': '*/{templates,server/templates,components,partials,views}/**/*.{hbs,html}',
59
+ './node_modules/@financial-times':
60
+ '*/{templates,server/templates,components,partials,views}/**/*.{hbs,html}'
61
+ }
62
+ }
63
+
64
+ export class PageKitHandlebars {
65
+ public options: TPageKitHandlebarsOptions
66
+ public engine: this['renderView']
67
+
68
+ private cache: Map<string, any> = new Map()
69
+
70
+ constructor(userOptions: TPageKitHandlebarsOptions = {}) {
71
+ this.options = mixinDeep({}, defaultOptions, userOptions)
72
+
73
+ // Create a point for Express to mount as a view engine
74
+ this.engine = this.renderView.bind(this)
75
+ }
76
+
77
+ loadPartials(): TPartialTemplates {
78
+ let partials: TFilePaths = this.cache.get('__partials__')
79
+
80
+ if (partials === undefined) {
81
+ partials = findPartialFiles(this.options.rootDirectory, this.options.partialPaths)
82
+
83
+ if (this.options.cache) {
84
+ this.cache.set('__partials__', partials)
85
+ }
86
+ }
87
+
88
+ const templates = {}
89
+
90
+ Object.keys(partials).forEach((name) => {
91
+ const filePath = partials[name]
92
+ templates[name] = this.loadTemplate(filePath)
93
+ })
94
+
95
+ return templates
96
+ }
97
+
98
+ loadTemplate(filePath: string): TemplateDelegate {
99
+ if (path.isAbsolute(filePath) === false) {
100
+ filePath = path.resolve(this.options.rootDirectory, filePath)
101
+ }
102
+
103
+ let template: TemplateDelegate = this.cache.get(filePath)
104
+
105
+ if (template === undefined) {
106
+ const contents = loadFileContents(filePath)
107
+ const hbs = this.options.handlebars || Handlebars
108
+
109
+ template = hbs.compile(contents)
110
+
111
+ if (this.options.cache) {
112
+ this.cache.set(filePath, template)
113
+ }
114
+ }
115
+
116
+ return template
117
+ }
118
+
119
+ render(template: string | TemplateDelegate, templateContext: any): string {
120
+ if (typeof template === 'string') {
121
+ template = this.loadTemplate(template)
122
+ }
123
+
124
+ const html = template(templateContext, {
125
+ helpers: this.options.helpers,
126
+ partials: { ...this.options.partials, ...this.loadPartials() }
127
+ })
128
+
129
+ return html.trim()
130
+ }
131
+
132
+ // This method is intended to be mounted by Express and used as a view engine.
133
+ // <https://expressjs.com/en/guide/using-template-engines.html>
134
+ renderView(templatePath: string, templateContext: any, callback: TRenderCallback): void {
135
+ try {
136
+ const html = this.render(templatePath, templateContext)
137
+ callback(null, html)
138
+ } catch (error) {
139
+ callback(error)
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,99 @@
1
+ import path from 'path'
2
+ import { PageKitHandlebars as Subject } from '../PageKitHandlebars'
3
+
4
+ // NOTE: Tests are run from the repository root directory so we need to set the CWD
5
+ const root = path.join(__dirname, '__fixtures__')
6
+ const view = path.resolve(root, 'views/view.hbs')
7
+
8
+ describe('dotcom-server-handlebars/src/PageKitHandlebars', () => {
9
+ let instance
10
+
11
+ beforeEach(() => {
12
+ instance = new Subject({
13
+ rootDirectory: root,
14
+ cache: true
15
+ })
16
+ })
17
+
18
+ describe('.loadPartials()', () => {
19
+ let result
20
+
21
+ beforeEach(() => {
22
+ result = instance.loadPartials()
23
+ })
24
+
25
+ it('loads and compiles partial templates from disk', () => {
26
+ const values = Object.values(result)
27
+
28
+ expect(values.length).toBe(3)
29
+
30
+ values.forEach((template) => {
31
+ expect(template).toEqual(expect.any(Function))
32
+ })
33
+ })
34
+
35
+ it('caches each compiled partial template', () => {
36
+ const keys = Object.keys(result)
37
+
38
+ keys.forEach((key) => {
39
+ const filename = path.join(root, `views/partials/${key}.hbs`)
40
+ expect(instance.cache.has(filename)).toBe(true)
41
+ })
42
+ })
43
+ })
44
+
45
+ describe('.loadTemplate()', () => {
46
+ let result
47
+
48
+ beforeEach(() => {
49
+ result = instance.loadTemplate(view)
50
+ })
51
+
52
+ it('loads and compiles a template from disk', () => {
53
+ expect(result).toEqual(expect.any(Function))
54
+ })
55
+
56
+ it('caches the compiled template', () => {
57
+ expect(instance.cache.has(view)).toBe(true)
58
+ })
59
+ })
60
+
61
+ describe('.render()', () => {
62
+ const templateContext = { title: 'Hello World', aside: 'Lorem ipsum' }
63
+
64
+ it('can render a template from disk', () => {
65
+ const result = instance.render(view, templateContext)
66
+
67
+ expect(result).toContain('<h1>Hello World</h1>')
68
+ expect(result).toContain('<aside>Lorem ipsum</aside>')
69
+ expect(result).toMatch(/<main>.+<\/main>/s)
70
+ })
71
+
72
+ it('can render a given template function', () => {
73
+ const template = instance.loadTemplate(view)
74
+ const result = instance.render(template, templateContext)
75
+
76
+ expect(result).toContain('<h1>Hello World</h1>')
77
+ expect(result).toContain('<aside>Lorem ipsum</aside>')
78
+ expect(result).toMatch(/<main>.+<\/main>/s)
79
+ })
80
+ })
81
+
82
+ describe('.renderView()', () => {
83
+ it('can render a template and fire a callback with the result', () => {
84
+ return new Promise((done) => {
85
+ const templateContext = { title: 'Hello World', aside: 'Lorem ipsum' }
86
+
87
+ instance.renderView(view, templateContext, (error, result) => {
88
+ expect(error).toBeNull()
89
+
90
+ expect(result).toContain('<h1>Hello World</h1>')
91
+ expect(result).toContain('<aside>Lorem ipsum</aside>')
92
+ expect(result).toMatch(/<main>.+<\/main>/s)
93
+
94
+ done()
95
+ })
96
+ })
97
+ })
98
+ })
99
+ })
@@ -0,0 +1,5 @@
1
+ const React = require('react')
2
+
3
+ module.exports = function ({ text }) {
4
+ return React.createElement('div', null, text)
5
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react'
2
+
3
+ export default function ({ text }) {
4
+ return <div>{text}</div>
5
+ }
@@ -0,0 +1,5 @@
1
+ const React = require('react')
2
+
3
+ exports.Component = function ({ text }) {
4
+ return React.createElement('div', null, text)
5
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react'
2
+
3
+ export function Component({ text }) {
4
+ return <div>{text}</div>
5
+ }
@@ -0,0 +1 @@
1
+ <h1>{{title}}</h1>
@@ -0,0 +1 @@
1
+ <aside>{{aside}}</aside>
@@ -0,0 +1,3 @@
1
+ <main>
2
+ {{> @partial-block }}
3
+ </main>
@@ -0,0 +1,4 @@
1
+ {{#> wrapper }}
2
+ {{>content}}
3
+ {{>partial}}
4
+ {{/wrapper}}
@@ -0,0 +1,341 @@
1
+ import { compile } from 'handlebars'
2
+ import * as helpers from '../helpers'
3
+
4
+ describe('dotcom-server-handlebars/src/helpers', () => {
5
+ describe('block helpers', () => {
6
+ describe('#capture', () => {
7
+ it('captures the string and assigns it to a variable', () => {
8
+ const template = compile('{{#capture "myOutput"}}Hello, World!{{/capture}}')
9
+ const templateData = {} as { [key: string]: any }
10
+ const result = template(templateData, { helpers })
11
+
12
+ expect(templateData.myOutput).toBe('Hello, World!')
13
+ expect(result).toBe('')
14
+ })
15
+ })
16
+
17
+ describe('#dateformat', () => {
18
+ const date = new Date('2019-04-10 13:40:21 GMT+0100 (BST)')
19
+
20
+ it('formats as ISO date time by default', () => {
21
+ const template = compile('{{#dateformat}}{{date}}{{/dateformat}}')
22
+ const result = template({ date }, { helpers })
23
+
24
+ expect(result).toBe('2019-04-10T12:40:21Z')
25
+ })
26
+
27
+ it('formats using the given format', () => {
28
+ const template = compile('{{#dateformat "dddd, mmmm d, yyyy"}}{{date}}{{/dateformat}}')
29
+ const result = template({ date }, { helpers })
30
+
31
+ expect(result).toBe('Wednesday, April 10, 2019')
32
+ })
33
+
34
+ it('throws if the incorrect number of parameters are provided', () => {
35
+ const template = compile('{{#dateformat "" ""}}{{date}}{{/dateformat}}')
36
+ expect(() => template({}, { helpers })).toThrow()
37
+ })
38
+ })
39
+
40
+ describe('#ifAll', () => {
41
+ const template = compile('{{#ifAll foo bar baz}}yes{{else}}no{{/ifAll}}')
42
+
43
+ it('outputs the contents when all parameters are truthy', () => {
44
+ const result = template({ foo: 123, bar: 'abc', baz: true }, { helpers })
45
+ expect(result).toBe('yes')
46
+ })
47
+
48
+ it('does not output the contents if any parameters are falsy', () => {
49
+ const result = template({ foo: 123, bar: 'abc', baz: false }, { helpers })
50
+ expect(result).toBe('no')
51
+ })
52
+
53
+ it('throws if the incorrect number of parameters are provided', () => {
54
+ const template = compile('{{#ifSome}}yes{{/ifSome}}')
55
+ expect(() => template({}, { helpers })).toThrow()
56
+ })
57
+ })
58
+
59
+ describe('#ifEquals', () => {
60
+ const template = compile('{{#ifEquals foo bar}}yes{{else}}no{{/ifEquals}}')
61
+
62
+ it('outputs the contents parameters are strictly equal', () => {
63
+ const result = template({ foo: true, bar: true }, { helpers })
64
+ expect(result).toBe('yes')
65
+ })
66
+
67
+ it('does not output the contents if any parameter is not strictly equal', () => {
68
+ const result = template({ foo: true, bar: null }, { helpers })
69
+ expect(result).toBe('no')
70
+ })
71
+
72
+ it('throws if the incorrect number of parameters are provided', () => {
73
+ const template = compile('{{#ifEquals}}yes{{/ifEquals}}')
74
+ expect(() => template({}, { helpers })).toThrow()
75
+ })
76
+ })
77
+
78
+ describe('#ifEqualsSome', () => {
79
+ const template = compile('{{#ifEqualsSome foo bar baz}}yes{{else}}no{{/ifEqualsSome}}')
80
+
81
+ it('outputs the contents if parameters are strictly equal', () => {
82
+ const result = template({ foo: true, bar: true, baz: true }, { helpers })
83
+ expect(result).toBe('yes')
84
+ })
85
+
86
+ it('outputs true if first parameter is equal', () => {
87
+ const result = template({ foo: true, bar: false, baz: true }, { helpers })
88
+ expect(result).toBe('yes')
89
+ })
90
+
91
+ it('outputs true if second parameter is equal', () => {
92
+ const result = template({ foo: true, bar: true, baz: false }, { helpers })
93
+ expect(result).toBe('yes')
94
+ })
95
+
96
+ it('outputs false if neither parameter is equal', () => {
97
+ const result = template({ foo: true, bar: false, baz: false }, { helpers })
98
+ expect(result).toBe('no')
99
+ })
100
+
101
+ it('throws if the incorrect number of parameters are provided', () => {
102
+ const template = compile('{{#ifEqualsSome}}yes{{/ifEqualsSome}}')
103
+ expect(() => template({}, { helpers })).toThrow()
104
+ })
105
+ })
106
+
107
+ describe('#ifSome', () => {
108
+ const template = compile('{{#ifSome foo bar baz}}yes{{else}}no{{/ifSome}}')
109
+
110
+ it('outputs the contents if at least one parameter is truthy', () => {
111
+ const result = template({ foo: 123, bar: '', baz: false }, { helpers })
112
+ expect(result).toBe('yes')
113
+ })
114
+
115
+ it('does not output the contents if all parameters are falsy', () => {
116
+ const result = template({ foo: 0, bar: '', baz: false }, { helpers })
117
+ expect(result).toBe('no')
118
+ })
119
+
120
+ it('throws if the incorrect number of parameters are provided', () => {
121
+ const template = compile('{{#ifSome}}yes{{/ifSome}}')
122
+ expect(() => template({}, { helpers })).toThrow()
123
+ })
124
+ })
125
+
126
+ describe('#renderReactComponent', () => {
127
+ it('renders the specified component (exported as a default CJS Module export) from a local path', () => {
128
+ const template = compile(`{{{renderReactComponent
129
+ localPath="packages/dotcom-server-handlebars/src/__test__/__fixtures__/components/DefaultExportComponentCJS"
130
+ text="foo"
131
+ }}}`)
132
+ const result = template({}, { helpers })
133
+
134
+ expect(result).toBe('<div>foo</div>')
135
+ })
136
+
137
+ it('renders the specified component (exported as a default ES Module export) from a local path', () => {
138
+ const template = compile(`{{{renderReactComponent
139
+ localPath="packages/dotcom-server-handlebars/src/__test__/__fixtures__/components/DefaultExportComponentES"
140
+ text="bar"
141
+ }}}`)
142
+ const result = template({}, { helpers })
143
+
144
+ expect(result).toBe('<div>bar</div>')
145
+ })
146
+
147
+ it('renders the specified component (exported as a named CJS Module export) from a local path', () => {
148
+ const template = compile(`{{{renderReactComponent
149
+ localPath="packages/dotcom-server-handlebars/src/__test__/__fixtures__/components/NamedExportComponentCJS"
150
+ namedExport="Component"
151
+ text="baz"
152
+ }}}`)
153
+ const result = template({}, { helpers })
154
+
155
+ expect(result).toBe('<div>baz</div>')
156
+ })
157
+
158
+ it('renders the specified component (exported as a named ES Module export) from a local path', () => {
159
+ const template = compile(`{{{renderReactComponent
160
+ localPath="packages/dotcom-server-handlebars/src/__test__/__fixtures__/components/NamedExportComponentES"
161
+ namedExport="Component"
162
+ text="qux"
163
+ }}}`)
164
+ const result = template({}, { helpers })
165
+
166
+ expect(result).toBe('<div>qux</div>')
167
+ })
168
+
169
+ it('throws if mandatory parameters are not provided', () => {
170
+ const template = compile('{{{renderReactComponent}}}')
171
+ expect(() => template({}, { helpers })).toThrow()
172
+ })
173
+ })
174
+
175
+ describe('#resize', () => {
176
+ it('formats the image as an image service URL', () => {
177
+ const template = compile('{{#resize 640}}http://website.com/picture.jpg{{/resize}}')
178
+ const result = template({}, { helpers })
179
+
180
+ expect(result).toBe(
181
+ 'https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fwebsite.com%2Fpicture.jpg?width=640&source=next&fit=scale-down'
182
+ )
183
+ })
184
+
185
+ it('accepts named parameters', () => {
186
+ const template = compile('{{#resize 640 fit="contain"}}http://website.com/picture.jpg{{/resize}}')
187
+ const result = template({}, { helpers })
188
+
189
+ expect(result).toBe(
190
+ 'https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fwebsite.com%2Fpicture.jpg?width=640&source=next&fit=contain'
191
+ )
192
+ })
193
+
194
+ it('throws if the incorrect number of parameters are provided', () => {
195
+ const template = compile('{{#resize}}http://website.com/picture.jpg{{/resize}}')
196
+ expect(() => template({}, { helpers })).toThrow()
197
+ })
198
+ })
199
+
200
+ describe('#slice', () => {
201
+ it('slices a given array and iterates over the new array', () => {
202
+ const template = compile('{{#slice items offset="2" limit="3"}}{{this}}{{/slice}}')
203
+ const result = template({ items: [1, 2, 3, 4, 5, 6] }, { helpers })
204
+
205
+ expect(result).toBe('345')
206
+ })
207
+
208
+ it('throws if the incorrect number of parameters are provided', () => {
209
+ const template = compile('{{#slice offset="2" limit="3"}}{{this}}{{/slice}}')
210
+ expect(() => template({}, { helpers })).toThrow()
211
+ })
212
+ })
213
+
214
+ describe('#unlessAll', () => {
215
+ const template = compile('{{#unlessAll foo bar baz}}yes{{else}}no{{/unlessAll}}')
216
+
217
+ it('outputs the contents if all parameters are falsy', () => {
218
+ const result = template({ foo: 0, bar: '', baz: false }, { helpers })
219
+ expect(result).toBe('yes')
220
+ })
221
+
222
+ it('does not output the contents if any parameters are truthy', () => {
223
+ const result = template({ foo: 0, bar: '', baz: true }, { helpers })
224
+ expect(result).toBe('no')
225
+ })
226
+
227
+ it('throws if the incorrect number of parameters are provided', () => {
228
+ const template = compile('{{#unlessAll}}yes{{/unlessAll}}')
229
+ expect(() => template({}, { helpers })).toThrow()
230
+ })
231
+ })
232
+
233
+ describe('#unlessEquals', () => {
234
+ const template = compile('{{#unlessEquals foo bar}}yes{{else}}no{{/unlessEquals}}')
235
+
236
+ it('outputs the contents if all parameters are falsy', () => {
237
+ const result = template({ foo: true, bar: false }, { helpers })
238
+ expect(result).toBe('yes')
239
+ })
240
+
241
+ it('does not output the contents if any parameters are truthy', () => {
242
+ const result = template({ foo: true, bar: true }, { helpers })
243
+ expect(result).toBe('no')
244
+ })
245
+
246
+ it('throws if the incorrect number of parameters are provided', () => {
247
+ const template = compile('{{#unlessEquals}}yes{{/unlessEquals}}')
248
+ expect(() => template({}, { helpers })).toThrow()
249
+ })
250
+ })
251
+
252
+ describe('#unlessSome', () => {
253
+ const template = compile('{{#unlessSome foo bar baz}}yes{{else}}no{{/unlessSome}}')
254
+
255
+ it('outputs the contents if any parameters are falsy', () => {
256
+ const result = template({ foo: 1, bar: 'abc', baz: false }, { helpers })
257
+ expect(result).toBe('yes')
258
+ })
259
+
260
+ it('does not output the contents if all parameters are truthy', () => {
261
+ const result = template({ foo: 1, bar: 'abc', baz: true }, { helpers })
262
+ expect(result).toBe('no')
263
+ })
264
+
265
+ it('throws if the incorrect number of parameters are provided', () => {
266
+ const template = compile('{{#unlessSome}}yes{{/unlessSome}}')
267
+ expect(() => template({}, { helpers })).toThrow()
268
+ })
269
+ })
270
+ })
271
+
272
+ describe('inline helpers', () => {
273
+ describe('array', () => {
274
+ it('converts all parameters into one array', () => {
275
+ const template = compile('{{array foo bar baz}}')
276
+ const result = template({ foo: 1, bar: 2, baz: 3 }, { helpers })
277
+
278
+ expect(result).toBe('1,2,3')
279
+ })
280
+
281
+ it('throws if the incorrect number of parameters are provided', () => {
282
+ const template = compile('{{array}}')
283
+ expect(() => template({}, { helpers })).toThrow()
284
+ })
285
+ })
286
+
287
+ describe('concat', () => {
288
+ it('concatenates all parameters into one string', () => {
289
+ const template = compile('{{concat "Welcome to " place ", " name}}')
290
+ const result = template({ place: 'Hell', name: 'human' }, { helpers })
291
+
292
+ expect(result).toBe('Welcome to Hell, human')
293
+ })
294
+
295
+ it('throws if the incorrect number of parameters are provided', () => {
296
+ const template = compile('{{concat}}')
297
+ expect(() => template({}, { helpers })).toThrow()
298
+ })
299
+ })
300
+
301
+ describe('encode', () => {
302
+ it('encodes the parameter as a URI component', () => {
303
+ const template = compile('{{{encode title}}}')
304
+ const result = template({ title: 'http://www.foo.com?bar=baz&qux=«»' }, { helpers })
305
+
306
+ expect(result).toBe('http%3A%2F%2Fwww.foo.com%3Fbar%3Dbaz%26qux%3D%C2%AB%C2%BB')
307
+ })
308
+
309
+ it('encodes the parameter as a complete URI', () => {
310
+ const template = compile('{{{encode title mode="uri"}}}')
311
+ const result = template({ title: 'http://www.foo.com?bar=baz&qux=«»' }, { helpers })
312
+
313
+ expect(result).toBe('http://www.foo.com?bar=baz&qux=%C2%AB%C2%BB')
314
+ })
315
+
316
+ it('throws if the incorrect number of parameters are provided', () => {
317
+ const template = compile('{{encode}}')
318
+ expect(() => template({}, { helpers })).toThrow()
319
+ })
320
+ })
321
+
322
+ describe('json', () => {
323
+ it('stringifies the parameter', () => {
324
+ const template = compile('{{{json data}}}')
325
+ const result = template({ data: { foo: 'bar', baz: 123, qux: true } }, { helpers })
326
+
327
+ expect(result).toBe('{"foo":"bar","baz":123,"qux":true}')
328
+ })
329
+
330
+ it('throws if the incorrect number of parameters are provided', () => {
331
+ const template = compile('{{json}}')
332
+ expect(() => template({}, { helpers })).toThrow()
333
+ })
334
+
335
+ it('throws if the user tries to output the @root context', () => {
336
+ const template = compile('{{json data}}')
337
+ expect(() => template({ data: { _locals: true } }, { helpers })).toThrow()
338
+ })
339
+ })
340
+ })
341
+ })
@@ -0,0 +1,25 @@
1
+ import path from 'path'
2
+ import glob from 'glob'
3
+ import { TFilePaths } from './types'
4
+
5
+ export default function loadPartialFiles(cwd: string, partialPaths: TFilePaths): TFilePaths {
6
+ const partialFiles = {}
7
+
8
+ Object.keys(partialPaths).forEach((partialPath) => {
9
+ const globPattern = partialPaths[partialPath]
10
+ const baseDirectory = path.resolve(cwd, partialPath)
11
+
12
+ const matchingFiles = glob.sync(globPattern, {
13
+ cwd: baseDirectory
14
+ })
15
+
16
+ matchingFiles.forEach((file) => {
17
+ const extension = path.extname(file)
18
+ const name = file.replace(extension, '')
19
+
20
+ partialFiles[name] = path.join(baseDirectory, file)
21
+ })
22
+ })
23
+
24
+ return partialFiles
25
+ }
@@ -0,0 +1,213 @@
1
+ # Handlebars Helpers
2
+
3
+ This package contains a suite of helpers to enable the migration of applications from [n-handlebars] to Page Kit. Not every helper provided by n-handlebars has been ported over; any helpers we could not find usage of, are used only within [n-ui], or have dependencies on other Handlebars components have not been included.
4
+
5
+ [n-ui]: https://github.com/Financial-Times/n-ui/
6
+ [n-handlebars]: https://github.com/Financial-Times/n-handlebars
7
+
8
+
9
+ ## Usage
10
+
11
+ When using this package you can import the `helpers` property and provide it as an option when creating a new `PageKitHandlebars` instance. Please note that helpers will not be appended to the global Handlebars instance.
12
+
13
+ ```js
14
+ const { PageKitHandlebars, helpers } = require('@financial-times/dotcom-server-handlebars')
15
+ const renderer = new PageKitHandlebars({ helpers })
16
+ ```
17
+
18
+ Alternatively if you want to use the helpers with an existing Handlebars instance:
19
+
20
+ ```js
21
+ const Handlebars = require('handlebars')
22
+ const { helpers } = require('@financial-times/dotcom-server-handlebars')
23
+
24
+ Handlebars.registerHelper(helpers)
25
+ ```
26
+
27
+
28
+ ## Block helpers
29
+
30
+ ### capture
31
+
32
+ Captures the string inside of the opening and closing tags and assigns it to a variable.
33
+
34
+ Example:
35
+
36
+ ```hbs
37
+ {{#capture "myOutput"}}I am being captured.{{/capture}}
38
+
39
+ {{myOutput}}
40
+ ```
41
+
42
+ ### dateformat
43
+
44
+ Formats a [date object] using the [dateformat] library. If no format is specified it will default to the `isoUtcDateTime` format.
45
+
46
+ Example:
47
+
48
+ ```hbs
49
+ {{#dateformat}}{{date}}{{/dateformat}}
50
+ {{#dateformat "fullDate"}}{{ date }}{{/dateformat}}
51
+ {{#dateformat "dddd, d mmmm, yyyy"}}{{ date }}{{/dateformat}}
52
+ ```
53
+
54
+ [date object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
55
+ [dateformat]: https://www.npmjs.com/package/dateformat
56
+
57
+ ### ifAll
58
+
59
+ Outputs the content if all of the parameters are [truthy].
60
+
61
+ Example:
62
+
63
+ ```hbs
64
+ {{#ifAll foo bar baz}}All parameters are truthy{{else}}A parameter is falsy{{/ifAll}}
65
+ ```
66
+
67
+ [truthy]: https://developer.mozilla.org/en-US/docs/Glossary/Truthy
68
+
69
+ ### ifEquals
70
+
71
+ Outputs the content if all of the parameters are [strictly equal]. The first parameter is used as the control and all parameters are tested against it.
72
+
73
+ Example:
74
+
75
+ ```hbs
76
+ {{#ifEquals foo bar}}Parameters are all equal{{else}}A parameter does not match{{/ifEquals}}
77
+ ```
78
+
79
+ [strictly equal]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness
80
+
81
+ ### ifEqualsSome
82
+
83
+ Outputs the content if some of the parameters are [strictly equal]. The first parameter is used as the control and all parameters are tested against it.
84
+
85
+ Example:
86
+
87
+ ```hbs
88
+ {{#ifEqualsSome foo bar baz}}Some parameters are all equal{{else}}No parameters match{{/ifEqualsSome}}
89
+ ```
90
+
91
+ ### ifSome
92
+
93
+ Outputs the content if at least one of the parameters is [truthy].
94
+
95
+ Example:
96
+
97
+ ```hbs
98
+ {{#ifSome foo bar baz}}A parameter is truthy{{else}}All parameters are falsy{{/ifSome}}
99
+ ```
100
+
101
+ ### renderReactComponent
102
+
103
+ Outputs a JSX component specified by its local path (relative to the root of the app consuming `dotcom-server-handlebars`) or the package name of the node module, with specification of a named export if required, as well as any other props.
104
+
105
+ Example:
106
+
107
+ ```hbs
108
+ {{{renderReactComponent localPath="views/components/ComponentWithHbsOutput" title="This is a React component"}}}
109
+
110
+ {{{renderReactComponent package="@financial-times/dotcom-ui-header" namedExport="LogoOnly" variant="large-logo"}}}
111
+ ```
112
+
113
+ ### resize
114
+
115
+ Deliver an image via the [Origami Image Service] and resize it to the specified width. Additional named parameters will be appended to the URL query string.
116
+
117
+ Example:
118
+
119
+ ```hbs
120
+ <img src="{{#resize 640}}{{image}}{{/resize}}" />
121
+ <img src="{{#resize 640 fit="contain"}}{{image}}{{/resize}}" />
122
+ ```
123
+
124
+ [Origami Image Service]: https://www.ft.com/__origami/service/image/v2/
125
+
126
+ ### slice
127
+
128
+ Iterate over a subset of items. Accepts an `offset` and `limit` parameter.
129
+
130
+ Example:
131
+
132
+ ```hbs
133
+ {{#slice iterable offset="2"}}{{this}}{{/slice}}
134
+ {{#slice iterable offset="4" limit="2"}}{{this}}{{/slice}}
135
+ ```
136
+
137
+ ### unlessAll
138
+
139
+ Outputs the content if all of the parameters are [falsy].
140
+
141
+ Example:
142
+
143
+ ```hbs
144
+ {{#unlessAll foo bar baz}}All parameters are falsy{{else}}A parameter is truthy{{/unlessAll}}
145
+ ```
146
+
147
+ [falsy]: https://developer.mozilla.org/en-US/docs/Glossary/Falsy
148
+
149
+ ### unlessEquals
150
+
151
+ Outputs the content if any parameters are _not_ [strictly equal].
152
+
153
+ Example:
154
+
155
+ ```hbs
156
+ {{#unlessEquals foo bar}}Parameters are not equal{{else}}All parameters match{{/unlessEquals}}
157
+ ```
158
+
159
+ ### unlessSome
160
+
161
+ Outputs the content if any of the parameters are [falsy].
162
+
163
+ Example:
164
+
165
+ ```hbs
166
+ {{#unlessSome foo bar baz}}A parameter is falsy{{else}}All parameters are truthy{{/unlessSome}}
167
+ ```
168
+
169
+
170
+ ## Inline helpers
171
+
172
+ ### array
173
+
174
+ Converts the given parameters into a single array.
175
+
176
+ Example:
177
+
178
+ ```hbs
179
+ {{array foo bar baz}}
180
+ {{>partial parameter=(array foo bar baz)}}
181
+ ```
182
+
183
+ ### concat
184
+
185
+ Concatenates multiple parameters into a single string.
186
+
187
+ Example:
188
+
189
+ ```hbs
190
+ {{concat "Welcome to " name}}
191
+ {{>partial parameter=(concat "Welcome to " name)}}
192
+ ```
193
+
194
+ ### encode
195
+
196
+ Encodes a uniform resource identifer (URI) using [`encodeURIComponent()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) or [`encodeURI()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI).
197
+
198
+ Example:
199
+
200
+ ```hbs
201
+ {{encode text}}
202
+ {{encode text mode="uri"}}
203
+ ```
204
+
205
+ ### json
206
+
207
+ JSON stringifies the given parameter. Please note that this will error if you try to output the `@root` context when used in an Express application as this may contain secret information.
208
+
209
+ Example:
210
+
211
+ ```hbs
212
+ {{{json data}}}
213
+ ```
@@ -0,0 +1,8 @@
1
+ export default function array(...args) {
2
+ if (args.length < 2) {
3
+ throw Error('At least one parameter must be provided')
4
+ }
5
+
6
+ // The final parameter will always be an instance of HelperOptions
7
+ return args.slice(0, -1)
8
+ }
@@ -0,0 +1,9 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function capture(name: string, options: HelperOptions) {
4
+ if (this.hasOwnProperty(name)) {
5
+ throw Error(`Template data property "${name}" has already been declared.`)
6
+ } else {
7
+ this[name] = options.fn(this)
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ export default function concat(...args) {
2
+ if (args.length < 3) {
3
+ throw Error('At least two parameters must be provided')
4
+ }
5
+
6
+ // The final parameter will always be an intance of HelperOptions
7
+ return args.slice(0, -1).join('')
8
+ }
@@ -0,0 +1,13 @@
1
+ import formatter from 'dateformat'
2
+ import { HelperOptions } from 'handlebars'
3
+
4
+ export default function dateformat(...args) {
5
+ if (args.length > 2) {
6
+ throw Error('Incorrect number of parameters provided')
7
+ }
8
+
9
+ const options = args.pop() as HelperOptions
10
+ const format = args[0] || 'isoUtcDateTime'
11
+
12
+ return formatter(options.fn(this), format)
13
+ }
@@ -0,0 +1,15 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function encode(...args) {
4
+ if (args.length !== 2) {
5
+ throw Error('Incorrect number of parameters provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+
10
+ if (options.hash.mode === 'uri') {
11
+ return encodeURI(args[0])
12
+ } else {
13
+ return encodeURIComponent(args[0])
14
+ }
15
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function ifAll(...args) {
4
+ if (args.length < 2) {
5
+ throw Error('At least one parameter must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.every(Boolean)
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function ifEquals(...args) {
4
+ if (args.length < 3) {
5
+ throw Error('At least two parameters must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.every((item) => item === args[0])
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
@@ -0,0 +1,13 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function ifEqualsSome(...args) {
4
+ if (args.length < 4) {
5
+ throw Error('At least three parameters must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const control = args.shift()
10
+ const condition = args.some((item) => item === control)
11
+
12
+ return condition ? options.fn(this) : options.inverse(this)
13
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function ifSome(...args) {
4
+ if (args.length < 2) {
5
+ throw Error('At least one parameter must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.some(Boolean)
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
@@ -0,0 +1,16 @@
1
+ export default function json(...args) {
2
+ if (args.length !== 2) {
3
+ throw Error('Incorrect number of parameters provided')
4
+ }
5
+
6
+ // The second parameter will always be an instance of HelperOptions
7
+ const target = args[0]
8
+
9
+ // Do not allow users to output the whole @root context
10
+ // <https://github.com/Financial-Times/n-handlebars/pull/65>
11
+ if (target && target.hasOwnProperty('_locals')) {
12
+ throw Error('For security reasons you may not use the JSON helper to output the entire view context')
13
+ }
14
+
15
+ return JSON.stringify(target)
16
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'path'
2
+ import React from 'react'
3
+ import ReactDOMServer from 'react-dom/server'
4
+
5
+ export default function renderReactComponent({ hash }) {
6
+ let modulePath
7
+
8
+ if (hash.hasOwnProperty('package')) {
9
+ // `paths` argument provided to ensure package lookup is made
10
+ // within context of consuming app rather than dependency.
11
+ modulePath = require.resolve(hash.package, { paths: [process.cwd()] })
12
+ }
13
+
14
+ if (hash.hasOwnProperty('localPath')) {
15
+ // localPath is relative to root of app consuming dotcom-server-handlebars.
16
+ modulePath = path.resolve(hash.localPath)
17
+ }
18
+
19
+ if (!modulePath) {
20
+ throw new Error('You must specify a "package" or "localPath" argument to load a module')
21
+ }
22
+
23
+ const importedModule = require(modulePath)
24
+
25
+ const Component = hash.hasOwnProperty('namedExport')
26
+ ? importedModule[hash.namedExport]
27
+ : importedModule.__esModule
28
+ ? importedModule.default
29
+ : importedModule
30
+
31
+ const props = { ...this, ...hash }
32
+
33
+ return ReactDOMServer.renderToStaticMarkup(React.createElement(Component, props))
34
+ }
@@ -0,0 +1,20 @@
1
+ import { HelperOptions } from 'handlebars'
2
+ import querystring from 'querystring'
3
+
4
+ const host = 'https://www.ft.com/__origami/service/image/v2/images/raw'
5
+
6
+ const defaults = { source: 'next', fit: 'scale-down' }
7
+
8
+ export default function resize(...args) {
9
+ if (args.length !== 2) {
10
+ throw Error('Incorrect number of parameters provided')
11
+ }
12
+
13
+ const options = args.pop() as HelperOptions
14
+ const image = options.fn(this)
15
+ const width = args[0]
16
+
17
+ const query = querystring.stringify({ width, ...defaults, ...options.hash })
18
+
19
+ return `${host}/${encodeURIComponent(image)}?${query}`
20
+ }
@@ -0,0 +1,22 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function slice(...args) {
4
+ if (args.length !== 2) {
5
+ throw Error('Incorrect number of parameters provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+
10
+ const offset = parseInt(options.hash.offset, 10) || 0
11
+ const limit = parseInt(options.hash.limit, 10) || 1
12
+
13
+ const slicedItems = Array.from(args[0]).slice(offset, offset + limit)
14
+
15
+ let contents = ''
16
+
17
+ slicedItems.forEach((item) => {
18
+ contents += options.fn(item)
19
+ })
20
+
21
+ return contents
22
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function unlessAll(...args) {
4
+ if (args.length < 2) {
5
+ throw Error('At least one parameter must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.some(Boolean) === false
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function unlessEquals(...args) {
4
+ if (args.length < 3) {
5
+ throw Error('At least two parameters must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.every((item) => item === args[0]) === false
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
@@ -0,0 +1,12 @@
1
+ import { HelperOptions } from 'handlebars'
2
+
3
+ export default function unlessSome(...args) {
4
+ if (args.length < 2) {
5
+ throw Error('At least one parameter must be provided')
6
+ }
7
+
8
+ const options = args.pop() as HelperOptions
9
+ const condition = args.every(Boolean) === false
10
+
11
+ return condition ? options.fn(this) : options.inverse(this)
12
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { default as array } from './helpers/array'
2
+ export { default as capture } from './helpers/capture'
3
+ export { default as concat } from './helpers/concat'
4
+ export { default as dateformat } from './helpers/dateformat'
5
+ export { default as encode } from './helpers/encode'
6
+ export { default as ifAll } from './helpers/ifAll'
7
+ export { default as ifEquals } from './helpers/ifEquals'
8
+ export { default as ifEqualsSome } from './helpers/ifEqualsSome'
9
+ export { default as ifSome } from './helpers/ifSome'
10
+ export { default as json } from './helpers/json'
11
+ export { default as renderReactComponent } from './helpers/renderReactComponent'
12
+ export { default as resize } from './helpers/resize'
13
+ export { default as slice } from './helpers/slice'
14
+ export { default as unlessAll } from './helpers/unlessAll'
15
+ export { default as unlessEquals } from './helpers/unlessEquals'
16
+ export { default as unlessSome } from './helpers/unlessSome'
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import * as helpers from './helpers'
2
+ export * from './PageKitHandlebars'
3
+ export { helpers }
@@ -0,0 +1,5 @@
1
+ import fs from 'fs'
2
+
3
+ export default function loadFileContents(filePath: string): string {
4
+ return fs.readFileSync(filePath).toString()
5
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { TemplateDelegate } from 'handlebars'
2
+
3
+ export type TRenderCallback = (error?: Error, output?: string) => any
4
+
5
+ export type TFilePaths = {
6
+ [key: string]: string
7
+ }
8
+
9
+ export type TPartialTemplates = {
10
+ [key: string]: TemplateDelegate
11
+ }