UNPKG

20.4 kBJavaScriptView Raw
1'use strict';
2
3var fs = require('fs');
4var path = require('path');
5var readdirp = require('readdirp');
6var handlebars = require('handlebars');
7var resolver = require('./resolver');
8var _ = require('lodash');
9
10/**
11 * Regex pattern for layout directive. {{!< layout }}
12 */
13var layoutPattern = /{{!<\s+([A-Za-z0-9\._\-\/]+)\s*}}/;
14
15/**
16 * Constructor
17 */
18var ExpressHbs = function() {
19 this.handlebars = handlebars.create();
20 this.SafeString = this.handlebars.SafeString;
21 this.Utils = this.handlebars.Utils;
22 this.beautify = null;
23 this.beautifyrc = null;
24 this.cwd = process.cwd();
25};
26
27/**
28 * Defines content for a named block declared in layout.
29 *
30 * @example
31 *
32 * {{#contentFor "pageStylesheets"}}
33 * <link rel="stylesheet" href='{{{URL "css/style.css"}}}' />
34 * {{/contentFor}}
35 */
36ExpressHbs.prototype.content = function(name, options, context) {
37 var block = options.data.root.blockCache[name] || (options.data.root.blockCache[name] = []);
38 block.push(options.fn(context));
39};
40
41/**
42 * Returns the layout filepath given the template filename and layout used.
43 * Backward compatible with specifying layouts in locals like 'layouts/foo',
44 * but if you have specified a layoutsDir you can specify layouts in locals with just the layout name.
45 *
46 * @param {String} filename Path to template file.
47 * @param {String} layout Layout path.
48 */
49ExpressHbs.prototype.layoutPath = function(filename, layout) {
50 var dirs,
51 layoutPath;
52 if (layout[0] === '.') {
53 dirs = path.dirname(filename);
54 } else if (this.layoutsDir) {
55 dirs = this.layoutsDir;
56 } else {
57 dirs = this.viewsDir;
58 }
59 [].concat(dirs).forEach(function (dir) {
60 if (!layoutPath) {
61 layoutPath = path.resolve(dir, layout);
62 }
63 });
64 return layoutPath;
65};
66
67/**
68 * Find the path of the declared layout in `str`, if any
69 *
70 * @param {String} str The template string to parse
71 * @param {String} filename Path to template
72 * @returns {String|undefined} Returns the path to layout.
73 */
74ExpressHbs.prototype.declaredLayoutFile = function(str, filename) {
75 var matches = str.match(layoutPattern);
76 if (matches) {
77 var layout = matches[1];
78 // behave like `require`, if '.' then relative, else look in
79 // usual location (layoutsDir)
80 if (this.layoutsDir && layout[0] !== '.') {
81 layout = path.resolve(this.layoutsDir, layout);
82 }
83 return path.resolve(path.dirname(filename), layout);
84 }
85};
86
87/**
88 * Compiles a layout file.
89 *
90 * The function checks whether the layout file declares a parent layout.
91 * If it does, the parent layout is loaded recursively and checked as well
92 * for a parent layout, and so on, until the top layout is reached.
93 * All layouts are then returned as a stack to the caller via the callback.
94 *
95 * @param {String} layoutFile The path to the layout file to compile
96 * @param {Boolean} useCache Cache the compiled layout?
97 * @param {Function} cb Callback called with layouts stack
98 */
99ExpressHbs.prototype.cacheLayout = function(layoutFile, useCache, cb) {
100 var self = this;
101
102 // assume hbs extension
103 if (path.extname(layoutFile) === '') layoutFile += this._options.extname;
104
105 // path is relative in directive, make it absolute
106 var layoutTemplates = this.cache[layoutFile];
107 if (layoutTemplates) return cb(null, layoutTemplates);
108
109 fs.readFile(layoutFile, 'utf8', function(err, str) {
110 if (err) return cb(err);
111
112 // File path of eventual declared parent layout
113 var parentLayoutFile = self.declaredLayoutFile(str, layoutFile);
114
115 // This function returns the current layout stack to the caller
116 var _returnLayouts = function(layouts) {
117 var currentLayout;
118 layouts = layouts.slice(0);
119 currentLayout = self.compile(str, layoutFile);
120 layouts.push(currentLayout);
121 if (useCache) {
122 self.cache[layoutFile] = layouts.slice(0);
123 }
124 cb(null, layouts);
125 };
126
127 if (parentLayoutFile) {
128 // Recursively compile/cache parent layouts
129 self.cacheLayout(parentLayoutFile, useCache, function(err, parentLayouts) {
130 if (err) return cb(err);
131 _returnLayouts(parentLayouts);
132 });
133 } else {
134 // No parent layout: return current layout with an empty stack
135 _returnLayouts([]);
136 }
137 });
138};
139
140/**
141 * Cache partial templates found under directories configure in partialsDir.
142 */
143ExpressHbs.prototype.cachePartials = function(cb) {
144 var self = this;
145
146 if (!(this.partialsDir instanceof Array)) {
147 this.partialsDir = [this.partialsDir];
148 }
149
150 // Use to iterate all folder in series
151 var count = 0;
152
153 function readNext() {
154 readdirp(self.partialsDir[count], {fileFilter: '*' + self._options.extname})
155 .on('warn', function(err) {
156 console.warn('Non-fatal error in express-hbs cachePartials.', err);
157 })
158 .on('error', function(err) {
159 console.error('Fatal error in express-hbs cachePartials', err);
160 return cb(err);
161 })
162 .on('data', function(entry) {
163 if (!entry) return;
164 var source = fs.readFileSync(entry.fullPath, 'utf8');
165 var dirname = path.dirname(entry.path);
166 dirname = dirname === '.' ? '' : dirname + '/';
167
168 var name = dirname + path.basename(entry.basename, self._options.extname);
169 // fix the path in windows
170 name = name.split('\\').join('/');
171 self.registerPartial(name, source, entry.fullPath);
172 })
173 .on('end', function() {
174 count += 1;
175
176 // If all directories aren't read, read the next directory
177 if (count < self.partialsDir.length) {
178 readNext();
179 } else {
180 self.isPartialCachingComplete = true;
181 if (cb) cb(null, true);
182 }
183 });
184 }
185
186 readNext();
187};
188
189/**
190 * Express 3.x template engine compliance.
191 *
192 * @param {Object} options = {
193 * handlebars: "override handlebars",
194 * defaultLayout: "path to default layout",
195 * partialsDir: "absolute path to partials (one path or an array of paths)",
196 * layoutsDir: "absolute path to the layouts",
197 * extname: "extension to use",
198 * contentHelperName: "contentFor",
199 * blockHelperName: "block",
200 * beautify: "{Boolean} whether to pretty print HTML",
201 * onCompile: function(self, source, filename) {
202 * return self.handlebars.compile(source);
203 * }
204 * }
205 */
206ExpressHbs.prototype.express3 = function(options) {
207 var self = this;
208
209 // Set defaults
210 if (!options) options = {};
211 if (!options.extname) options.extname = '.hbs';
212 if (!options.contentHelperName) options.contentHelperName = 'contentFor';
213 if (!options.blockHelperName) options.blockHelperName = 'block';
214 if (!options.templateOptions) options.templateOptions = {};
215 if (options.handlebars) this.handlebars = options.handlebars;
216 if (options.onCompile) this.onCompile = options.onCompile;
217
218 this._options = options;
219 if (this._options.handlebars) this.handlebars = this._options.handlebars;
220
221 if (options.i18n) {
222 var i18n = options.i18n;
223 this.handlebars.registerHelper('__', function() {
224 var args = Array.prototype.slice.call(arguments);
225 var options = args.pop();
226 return i18n.__.apply(options.data.root, args);
227 });
228 this.handlebars.registerHelper('__n', function() {
229 var args = Array.prototype.slice.call(arguments);
230 var options = args.pop();
231 return i18n.__n.apply(options.data.root, args);
232 });
233 }
234
235 this.handlebars.registerHelper(this._options.blockHelperName, function(name, options) {
236 var val = options.data.root.blockCache[name];
237 if (val === undefined && typeof options.fn === 'function') {
238 val = options.fn(this);
239 }
240 if (Array.isArray(val)) {
241 val = val.join('\n');
242 }
243 return val;
244 });
245
246 // Pass 'this' as context of helper function to don't lose context call of helpers.
247 this.handlebars.registerHelper(this._options.contentHelperName, function(name, options) {
248 return self.content(name, options, this);
249 });
250
251 // Absolute path to partials directory.
252 this.partialsDir = this._options.partialsDir;
253
254 // Absolute path to the layouts directory
255 this.layoutsDir = this._options.layoutsDir;
256
257 // express passes this through ___express func, gulp pass in an option
258 this.viewsDir = null;
259 this.viewsDirOpt = this._options.viewsDir;
260
261 // Cache for templates, express 3.x doesn't do this for us
262 this.cache = {};
263
264 // Holds the default compiled layout if specified in options configuration.
265 this.defaultLayoutTemplates = null;
266
267 // Keep track of if partials have been cached already or not.
268 this.isPartialCachingComplete = false;
269
270 return this.___express.bind(this);
271};
272
273/**
274 * Express 4.x template engine compliance.
275 *
276 * @param {Object} options = {
277 * handlebars: "override handlebars",
278 * defaultLayout: "path to default layout",
279 * partialsDir: "absolute path to partials (one path or an array of paths)",
280 * layoutsDir: "absolute path to the layouts",
281 * extname: "extension to use",
282 * contentHelperName: "contentFor",
283 * blockHelperName: "block",
284 * beautify: "{Boolean} whether to pretty print HTML"
285 * }
286 */
287ExpressHbs.prototype.express4 = ExpressHbs.prototype.express3;
288
289/**
290 * Tries to load the default layout.
291 *
292 * @param {Boolean} useCache Whether to cache.
293 */
294ExpressHbs.prototype.loadDefaultLayout = function(useCache, cb) {
295 var self = this;
296 if (!this._options.defaultLayout) return cb();
297 if (useCache && this.defaultLayoutTemplates) return cb(null, this.defaultLayoutTemplates);
298
299 this.cacheLayout(this._options.defaultLayout, useCache, function(err, templates) {
300 if (err) return cb(err);
301 self.defaultLayoutTemplates = templates.slice(0);
302 return cb(null, templates);
303 });
304};
305
306/**
307 * Expose useful methods.
308 */
309ExpressHbs.prototype.registerHelper = function(name, fn) {
310 this.handlebars.registerHelper(name, fn);
311};
312
313/**
314 * Registers a partial.
315 *
316 * @param {String} name The name of the partial as used in a template.
317 * @param {String} source String source of the partial.
318 */
319ExpressHbs.prototype.registerPartial = function(name, source, filename) {
320 this.handlebars.registerPartial(name, this.compile(source, filename));
321};
322
323/**
324 * Compiles a string.
325 *
326 * @param {String} source The source to compile.
327 * @param {String} filename The path used to embed into __filename for errors.
328 */
329ExpressHbs.prototype.compile = function(source, filename) {
330 // Handlebars has a bug with comment only partial causes errors. This must
331 // be a string so the block below can add a space.
332 if (typeof source !== 'string') {
333 throw new Error('registerPartial must be a string for empty comment workaround');
334 }
335 if (source.indexOf('}}') === source.length - 2) {
336 source += ' ';
337 }
338
339 var compiled;
340 if (this.onCompile) {
341 compiled = this.onCompile(this, source, filename);
342 } else {
343 compiled = this.handlebars.compile(source);
344 }
345
346 if (filename) {
347 if (Array.isArray(this.viewsDir) && this.viewsDir.length > 0) {
348 compiled.__filename = path.relative(this.cwd, filename).replace(path.sep, '/');
349 } else {
350 compiled.__filename = path.relative(this.viewsDir || '', filename).replace(path.sep, '/');
351 }
352 }
353 return compiled;
354};
355
356/**
357 * Registers an asynchronous helper.
358 *
359 * @param {String} name The name of the partial as used in a template.
360 * @param {String} fn The `function(options, cb)`
361 */
362ExpressHbs.prototype.registerAsyncHelper = function(name, fn) {
363 this.handlebars.registerHelper(name, function(context, options) {
364 var resolverCache = this.resolverCache ||
365 _.get(context, 'data.root.resolverCache') ||
366 _.get(options, 'data.root.resolverCache');
367 if (!resolverCache) {
368 throw new Error('Could not find resolver cache in async helper ' + name + '.');
369 }
370 if (options && fn.length > 2) {
371 var resolveFunc = function(arr, cb) {
372 return fn.call(this, arr[0], arr[1], cb);
373 };
374
375 return resolver.resolve(
376 resolverCache,
377 resolveFunc.bind(this),
378 [context, options]
379 );
380 }
381 return resolver.resolve(
382 resolverCache,
383 fn.bind(this),
384 context
385 );
386 });
387};
388
389ExpressHbs.prototype.getTemplateOptions = function() {
390 return this._options.templateOptions;
391};
392
393ExpressHbs.prototype.updateTemplateOptions = function(templateOptions) {
394 this._options.templateOptions = templateOptions;
395};
396
397ExpressHbs.prototype.getLocalTemplateOptions = function(locals) {
398 return locals._templateOptions || {};
399};
400
401ExpressHbs.prototype.updateLocalTemplateOptions = function(locals, localTemplateOptions) {
402 return locals._templateOptions = localTemplateOptions;
403};
404
405/**
406 * Creates a new instance of ExpressHbs.
407 */
408ExpressHbs.prototype.create = function() {
409 return new ExpressHbs();
410};
411
412/**
413 * express 3.x, 4.x template engine compliance
414 *
415 * @param {String} filename Full path to template.
416 * @param {Object} options Is the context or locals for templates. {
417 * {Object} settings - subset of Express settings, `settings.views` is
418 * the views directory
419 * }
420 * @param {Function} cb The callback expecting the rendered template as a string.
421 *
422 * @example
423 *
424 * Example options from express
425 *
426 * {
427 * settings: {
428 * 'x-powered-by': true,
429 * env: 'production',
430 * views: '/home/coder/barc/code/express-hbs/example/views',
431 * 'jsonp callback name': 'callback',
432 * 'view cache': true,
433 * 'view engine': 'hbs'
434 * },
435 * cache: true,
436 *
437 * // the rest are app-defined locals
438 * title: 'My favorite veggies',
439 * layout: 'layout/veggie'
440 * }
441 */
442ExpressHbs.prototype.___express = function ___express(filename, source, options, cb) {
443 // support running as a gulp/grunt filter outside of express
444 if (arguments.length === 3) {
445 cb = options;
446 options = source;
447 source = null;
448 }
449
450 options.blockCache = {};
451 options.resolverCache = {};
452
453 this.viewsDir = options.settings.views || this.viewsDirOpt;
454 var self = this;
455
456 /**
457 * Allow a layout to be declared as a handlebars comment to remain spec
458 * compatible with handlebars.
459 *
460 * Valid directives
461 *
462 * {{!< foo}} # foo.hbs in same directory as template
463 * {{!< ../layouts/default}} # default.hbs in parent layout directory
464 * {{!< ../layouts/default.html}} # default.html in parent layout directory
465 */
466 function parseLayout(str, filename, cb) {
467 var layoutFile = self.declaredLayoutFile(str, filename);
468 if (layoutFile) {
469 self.cacheLayout(layoutFile, options.cache, cb);
470 } else {
471 cb(null, null);
472 }
473 }
474
475 /**
476 * Renders `template` with given `locals` and calls `cb` with the
477 * resulting HTML string.
478 *
479 * @param template
480 * @param locals
481 * @param cb
482 */
483 function renderTemplate(template, locals, cb) {
484 var res;
485
486 try {
487 var localTemplateOptions = self.getLocalTemplateOptions(locals);
488 var localsClone = _.extend({}, locals);
489 self.updateLocalTemplateOptions(localsClone, undefined);
490 res = template(localsClone, _.merge({}, self._options.templateOptions, localTemplateOptions));
491 } catch (err) {
492 if (err.message) {
493 err.message = '[' + template.__filename + '] ' + err.message;
494 } else if (typeof err === 'string') {
495 return cb('[' + template.__filename + '] ' + err, null);
496 }
497 return cb(err, null);
498 }
499 cb(null, res);
500 }
501
502
503 /**
504 * Renders `template` with an optional set of nested `layoutTemplates` using
505 * data in `locals`.
506 */
507 function render(template, locals, layoutTemplates, cb) {
508 if (!layoutTemplates) layoutTemplates = [];
509
510 // We'll render templates from bottom to top of the stack, each template
511 // being passed the rendered string of the previous ones as `body`
512 var i = layoutTemplates.length - 1;
513
514 var _stackRenderer = function(err, htmlStr) {
515 if (err) return cb(err);
516
517 if (i >= 0) {
518 locals.body = htmlStr;
519 renderTemplate(layoutTemplates[i--], locals, _stackRenderer);
520 } else {
521 cb(null, htmlStr);
522 }
523 };
524
525 // Start the rendering with the innermost page template
526 renderTemplate(template, locals, _stackRenderer);
527 }
528
529
530 /**
531 * Lazy loads js-beautify, which should not be used in production env.
532 */
533 function loadBeautify() {
534 if (!self.beautify) {
535 self.beautify = require('js-beautify').html;
536 var rc = path.join(process.cwd(), '.jsbeautifyrc');
537 if (fs.existsSync(rc)) {
538 self.beautifyrc = JSON.parse(fs.readFileSync(rc, 'utf8'));
539 }
540 }
541 }
542
543 /**
544 * Gets the source and compiled template for filename either from the cache
545 * or compiling it on the fly.
546 */
547 function getSourceTemplate(cb) {
548 if (options.cache) {
549 var info = self.cache[filename];
550 if (info) {
551 return cb(null, info.source, info.template);
552 }
553 }
554
555 fs.readFile(filename, 'utf8', function(err, source) {
556 if (err) return cb(err);
557
558 var template = self.compile(source, filename);
559 if (options.cache) {
560 self.cache[filename] = {
561 source: source,
562 template: template
563 };
564 }
565 return cb(null, source, template);
566 });
567 }
568
569 /**
570 * Compiles a file into a template and a layoutTemplate, then renders it above.
571 */
572 function compileFile(locals, cb) {
573 getSourceTemplate(function(err, source, template) {
574 if (err) return cb(err);
575
576 // Try to get the layout
577 parseLayout(source, filename, function(err, layoutTemplates) {
578 if (err) return cb(err);
579
580 function renderIt(layoutTemplates) {
581 if (self._options.beautify) {
582 return render(template, locals, layoutTemplates, function(err, html) {
583 if (err) return cb(err);
584 loadBeautify();
585 return cb(null, self.beautify(html, self.beautifyrc));
586 });
587 }
588 return render(template, locals, layoutTemplates, cb);
589 }
590
591 // Determine which layout to use
592
593 if (typeof options.layout !== 'undefined' && !options.layout) {
594 // If options.layout is falsy, behave as if no layout should be used - suppress defaults
595 renderIt(null);
596 } else if (layoutTemplates) {
597 // 1. Layout specified in template
598 renderIt(layoutTemplates);
599 } else if (typeof options.layout !== 'undefined' && options.layout) {
600 // 2. Layout specified by options from render
601 var layoutFile = self.layoutPath(filename, options.layout);
602 self.cacheLayout(layoutFile, options.cache, function(err, layoutTemplates) {
603 if (err) return cb(err);
604 renderIt(layoutTemplates);
605 });
606 } else if (self.defaultLayoutTemplates) {
607 // 3. Default layout specified when middleware was configured.
608 renderIt(self.defaultLayoutTemplates);
609 } else {
610 // render without a template
611 renderIt(null);
612 }
613 });
614 });
615 }
616
617 function replaceValue(values, text) {
618 if (typeof text === 'string') {
619 Object.keys(values).forEach(function(id) {
620 text = text.replace(id, function() {
621 return values[id];
622 });
623 text = text.replace(self.Utils.escapeExpression(id), function() {
624 return self.Utils.escapeExpression(values[id]);
625 });
626 });
627 }
628 return text;
629 }
630
631 // Handles waiting for async helpers
632 function handleAsync(err, res) {
633 if (err) return cb(err);
634 resolver.done(options.resolverCache, function(err, values) {
635 if (err) return cb(err);
636 Object.keys(values).forEach(function(key) {
637 values[key] = replaceValue(values, values[key]);
638 });
639 res = replaceValue(values, res);
640 if (resolver.hasResolvers(res)) {
641 return handleAsync(null, res);
642 }
643 cb(null, res);
644 });
645 }
646
647 // kick it off by loading default template (if any)
648 this.loadDefaultLayout(options.cache, function(err) {
649 if (err) return cb(err);
650
651 // Force reloading of all partials if caching is not used. Inefficient but there
652 // is no loading partial event.
653 if (self.partialsDir && (!options.cache || !self.isPartialCachingComplete)) {
654 return self.cachePartials(function(err) {
655 if (err) return cb(err);
656 return compileFile(options, handleAsync);
657 });
658 }
659
660 return compileFile(options, handleAsync);
661 });
662};
663
664module.exports = new ExpressHbs();