var _ = require('underscore'), moment = require('moment'), downsize = require('downsize'), path = require('path'), when = require('when'), hbs = require('express-hbs'), polyglot = require('node-polyglot').instance, template = require('./template'), errors = require('../errorHandling'), models = require('../models'), filters = require('../filters'), packageInfo = require('../../../package.json'), version = packageInfo.version, scriptTemplate = _.template(""), isProduction = process.env.NODE_ENV === 'production', api = require('../api'), config = require('../config'), coreHelpers = {}, registerHelpers; /** * [ description] * @todo ghost core helpers + a way for themes to register them * @param {Object} context date object * @param {*} options * @return {Object} A Moment time / date object */ coreHelpers.date = function (context, options) { if (!options && context.hasOwnProperty('hash')) { options = context; context = undefined; // set to published_at by default, if it's available // otherwise, this will print the current date if (this.published_at) { context = this.published_at; } } // ensure that context is undefined, not null, as that can cause errors context = context === null ? undefined : context; var f = options.hash.format || 'MMM Do, YYYY', timeago = options.hash.timeago, date; if (timeago) { date = moment(context).fromNow(); } else { date = moment(context).format(f); } return date; }; // // ### URI Encoding helper // // *Usage example:* // `{{encode uri}}` // // Returns URI encoded string // coreHelpers.encode = function (context, str) { var uri = context || str; return new hbs.handlebars.SafeString(encodeURIComponent(uri)); }; // ### Page URL Helper // // *Usage example:* // `{{pageUrl 2}}` // // Returns the URL for the page specified in the current object // context. // coreHelpers.pageUrl = function (context, block) { /*jslint unparam:true*/ return context === 1 ? '/' : ('/page/' + context + '/'); }; // ### URL helper // // *Usage example:* // `{{url}}` // `{{url absolute}}` // // Returns the URL for the current object context // i.e. If inside a post context will return post permalink // absolute flag outputs absolute URL, else URL is relative coreHelpers.url = function (options) { var output = '', self = this, tags = { year: function () { return self.created_at.getFullYear(); }, month: function () { return self.created_at.getMonth() + 1; }, day: function () { return self.created_at.getDate(); }, slug: function () { return self.slug; }, id: function () { return self.id; } }, path = config.paths().path, isAbsolute = options && options.hash.absolute; return api.settings.read('permalinks').then(function (permalinks) { if (isAbsolute) { output += config().url; } if (path && path !== '/') { output += path; } if (models.isPost(self)) { output += permalinks.value; output = output.replace(/(:[a-z]+)/g, function (match) { if (_.has(tags, match.substr(1))) { return tags[match.substr(1)](); } }); } return output; }); }; // ### Asset helper // // *Usage example:* // `{{asset "css/screen.css"}}` // `{{asset "css/screen.css" ghost="true"}}` // // Returns the path to the specified asset. The ghost // flag outputs the asset path for the Ghost admin coreHelpers.asset = function (context, options) { var output = '', subDir = config.paths().path, isAdmin = options && options.hash && options.hash.ghost; if (subDir === '/') { output += '/'; } else { output += subDir + '/'; } if (isAdmin) { output += 'ghost/'; } else { output += 'assets/'; } output += context; return new hbs.handlebars.SafeString(output); }; // ### Author Helper // // *Usage example:* // `{{author}}` // // Returns the full name of the author of a given post, or a blank string // if the author could not be determined. // coreHelpers.author = function (context, options) { /*jslint unparam:true*/ return this.author ? this.author.name : ''; }; // ### Tags Helper // // *Usage example:* // `{{tags}}` // `{{tags separator=' - '}}` // // Returns a string of the tags on the post. // By default, tags are separated by commas. // // Note that the standard {{#each tags}} implementation is unaffected by this helper // and can be used for more complex templates. coreHelpers.tags = function (options) { var separator = _.isString(options.hash.separator) ? options.hash.separator : ', ', prefix = _.isString(options.hash.prefix) ? options.hash.prefix : '', suffix = _.isString(options.hash.suffix) ? options.hash.suffix : '', output = '', tagNames = _.pluck(this.tags, 'name'); if (tagNames.length) { output = prefix + tagNames.join(separator) + suffix; } return output; }; // ### Content Helper // // *Usage example:* // `{{content}}` // `{{content words="20"}}` // `{{content characters="256"}}` // // Turns content html into a safestring so that the user doesn't have to // escape it or tell handlebars to leave it alone with a triple-brace. // // Enables tag-safe truncation of content by characters or words. // // **returns** SafeString content html, complete or truncated. // coreHelpers.content = function (options) { var truncateOptions = (options || {}).hash || {}; truncateOptions = _.pick(truncateOptions, ['words', 'characters']); _.keys(truncateOptions).map(function (key) { truncateOptions[key] = parseInt(truncateOptions[key], 10); }); if (truncateOptions.words || truncateOptions.characters) { return new hbs.handlebars.SafeString( downsize(this.html, truncateOptions) ); } return new hbs.handlebars.SafeString(this.html); }; // ### Excerpt Helper // // *Usage example:* // `{{excerpt}}` // `{{excerpt words="50"}}` // `{{excerpt characters="256"}}` // // Attempts to remove all HTML from the string, and then shortens the result according to the provided option. // // Defaults to words="50" // // **returns** SafeString truncated, HTML-free content. // coreHelpers.excerpt = function (options) { var truncateOptions = (options || {}).hash || {}, excerpt; truncateOptions = _.pick(truncateOptions, ['words', 'characters']); _.keys(truncateOptions).map(function (key) { truncateOptions[key] = parseInt(truncateOptions[key], 10); }); /*jslint regexp:true */ excerpt = String(this.html).replace(/<\/?[^>]+>/gi, ''); excerpt = excerpt.replace(/(\r\n|\n|\r)+/gm, ' '); /*jslint regexp:false */ if (!truncateOptions.words && !truncateOptions.characters) { truncateOptions.words = 50; } return new hbs.handlebars.SafeString( downsize(excerpt, truncateOptions) ); }; // ### Filestorage helper // // *Usage example:* // `{{fileStorage}}` // // Returns the config value for fileStorage. coreHelpers.fileStorage = function (context, options) { /*jslint unparam:true*/ if (config().hasOwnProperty('fileStorage')) { return config().fileStorage.toString(); } return "true"; }; coreHelpers.ghostScriptTags = function () { var scriptFiles = [], webroot = config.paths().webroot; if (isProduction) { scriptFiles.push("ghost.min.js"); } else { scriptFiles = [ 'vendor.js', 'helpers.js', 'templates.js', 'models.js', 'views.js' ]; } scriptFiles = _.map(scriptFiles, function (fileName) { return scriptTemplate({ source: webroot + '/built/scripts/' + fileName, version: version }); }); return scriptFiles.join(''); }; /* * Asynchronous Theme Helpers (Registered with registerAsyncThemeHelper) */ coreHelpers.body_class = function (options) { /*jslint unparam:true*/ var classes = [], tags = this.post && this.post.tags ? this.post.tags : this.tags || [], page = this.post && this.post.page ? this.post.page : this.page || false; if (_.isString(this.ghostRoot) && this.ghostRoot.match(/\/page/)) { classes.push('archive-template'); } else if (!this.ghostRoot || this.ghostRoot === '/' || this.ghostRoot === '') { classes.push('home-template'); } else { classes.push('post-template'); } if (tags) { classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); } if (page) { classes.push('page'); } return filters.doFilter('body_class', classes).then(function (classes) { var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); return new hbs.handlebars.SafeString(classString.trim()); }); }; coreHelpers.post_class = function (options) { /*jslint unparam:true*/ var classes = ['post'], tags = this.post && this.post.tags ? this.post.tags : this.tags || [], featured = this.post && this.post.featured ? this.post.featured : this.featured || false, page = this.post && this.post.page ? this.post.page : this.page || false; if (tags) { classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); } if (featured) { classes.push('featured'); } if (page) { classes.push('page'); } return filters.doFilter('post_class', classes).then(function (classes) { var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); return new hbs.handlebars.SafeString(classString.trim()); }); }; coreHelpers.ghost_head = function (options) { /*jslint unparam:true*/ var blog = config.theme(), root = config.paths().webroot, head = [], majorMinor = /^(\d+\.)?(\d+)/, trimmedVersion = this.version; trimmedVersion = trimmedVersion ? trimmedVersion.match(majorMinor)[0] : '?'; head.push(''); head.push(''); if (this.ghostRoot) { head.push(''); } return filters.doFilter('ghost_head', head).then(function (head) { var headString = _.reduce(head, function (memo, item) { return memo + '\n' + item; }, ''); return new hbs.handlebars.SafeString(headString.trim()); }); }; coreHelpers.ghost_foot = function (options) { /*jslint unparam:true*/ var foot = []; foot.push(''); return filters.doFilter('ghost_foot', foot).then(function (foot) { var footString = _.reduce(foot, function (memo, item) { return memo + ' ' + item; }, ''); return new hbs.handlebars.SafeString(footString.trim()); }); }; coreHelpers.meta_title = function (options) { /*jslint unparam:true*/ var title, blog; if (_.isString(this.ghostRoot)) { if (!this.ghostRoot || this.ghostRoot === '/' || this.ghostRoot === '' || this.ghostRoot.match(/\/page/)) { blog = config.theme(); title = blog.title; } else { title = this.post.title; } } return filters.doFilter('meta_title', title).then(function (title) { title = title || ""; return new hbs.handlebars.SafeString(title.trim()); }); }; coreHelpers.meta_description = function (options) { /*jslint unparam:true*/ var description, blog; if (_.isString(this.ghostRoot)) { if (!this.ghostRoot || this.ghostRoot === '/' || this.ghostRoot === '' || this.ghostRoot.match(/\/page/)) { blog = config.theme(); description = blog.description; } else { description = ''; } } return filters.doFilter('meta_description', description).then(function (description) { description = description || ""; return new hbs.handlebars.SafeString(description.trim()); }); }; /** * Localised string helpers * * @param String key * @param String default translation * @param {Object} options * @return String A correctly internationalised string */ coreHelpers.e = function (key, defaultString, options) { var output; when.all([ api.settings.read('defaultLang'), api.settings.read('forceI18n') ]).then(function (values) { if (values[0].value === 'en' && _.isEmpty(options.hash) && _.isEmpty(values[1].value)) { output = defaultString; } else { output = polyglot().t(key, options.hash); } return output; }); }; coreHelpers.foreach = function (context, options) { var fn = options.fn, inverse = options.inverse, i = 0, j = 0, columns = options.hash.columns, key, ret = "", data; if (options.data) { data = hbs.handlebars.createFrame(options.data); } function setKeys(_data, _i, _j, _columns) { if (_i === 0) { _data.first = true; } if (_i === _j - 1) { _data.last = true; } // first post is index zero but still needs to be odd if (_i % 2 === 1) { _data.even = true; } else { _data.odd = true; } if (_i % _columns === 0) { _data.rowStart = true; } else if (_i % _columns === (_columns - 1)) { _data.rowEnd = true; } return _data; } if (context && typeof context === 'object') { if (context instanceof Array) { for (j = context.length; i < j; i += 1) { if (data) { data.index = i; data.first = data.rowEnd = data.rowStart = data.last = data.even = data.odd = false; data = setKeys(data, i, j, columns); } ret = ret + fn(context[i], { data: data }); } } else { for (key in context) { if (context.hasOwnProperty(key)) { j += 1; } } for (key in context) { if (context.hasOwnProperty(key)) { if (data) { data.key = key; data.first = data.rowEnd = data.rowStart = data.last = data.even = data.odd = false; data = setKeys(data, i, j, columns); } ret = ret + fn(context[key], {data: data}); i += 1; } } } } if (i === 0) { ret = inverse(this); } return ret; }; // ### Pagination Helper // `{{pagination}}` // Outputs previous and next buttons, along with info about the current page coreHelpers.pagination = function (options) { /*jslint unparam:true*/ if (!_.isObject(this.pagination) || _.isFunction(this.pagination)) { errors.logAndThrowError('pagination data is not an object or is a function'); return; } if (_.isUndefined(this.pagination.page) || _.isUndefined(this.pagination.pages) || _.isUndefined(this.pagination.total) || _.isUndefined(this.pagination.limit)) { errors.logAndThrowError('All values must be defined for page, pages, limit and total'); return; } if ((!_.isUndefined(this.pagination.next) && !_.isNumber(this.pagination.next)) || (!_.isUndefined(this.pagination.prev) && !_.isNumber(this.pagination.prev))) { errors.logAndThrowError('Invalid value, Next/Prev must be a number'); return; } if (!_.isNumber(this.pagination.page) || !_.isNumber(this.pagination.pages) || !_.isNumber(this.pagination.total) || !_.isNumber(this.pagination.limit)) { errors.logAndThrowError('Invalid value, check page, pages, limit and total are numbers'); return; } return template.execute('pagination', this.pagination); }; coreHelpers.helperMissing = function (arg) { if (arguments.length === 2) { return undefined; } errors.logError('Missing helper: "' + arg + '"'); }; // Register an async handlebars helper for a given handlebars instance function registerAsyncHelper(hbs, name, fn) { hbs.registerAsyncHelper(name, function (options, cb) { // Wrap the function passed in with a when.resolve so it can // return either a promise or a value when.resolve(fn.call(this, options)).then(function (result) { cb(result); }).otherwise(function (err) { errors.logAndThrowError(err, "registerAsyncThemeHelper: " + name); }); }); } // Register a handlebars helper for themes function registerThemeHelper(name, fn) { hbs.registerHelper(name, fn); } // Register an async handlebars helper for themes function registerAsyncThemeHelper(name, fn) { registerAsyncHelper(hbs, name, fn); } // Register a handlebars helper for admin function registerAdminHelper(name, fn) { coreHelpers.adminHbs.registerHelper(name, fn); } // Register an async handlebars helper for admin function registerAsyncAdminHelper(name, fn) { registerAsyncHelper(coreHelpers.adminHbs, name, fn); } registerHelpers = function (adminHbs) { // Expose hbs instance for admin coreHelpers.adminHbs = adminHbs; // Register theme helpers registerThemeHelper('asset', coreHelpers.asset); registerThemeHelper('author', coreHelpers.author); registerThemeHelper('content', coreHelpers.content); registerThemeHelper('date', coreHelpers.date); registerThemeHelper('encode', coreHelpers.encode); registerThemeHelper('excerpt', coreHelpers.excerpt); registerThemeHelper('foreach', coreHelpers.foreach); registerThemeHelper('pageUrl', coreHelpers.pageUrl); registerThemeHelper('pagination', coreHelpers.pagination); registerThemeHelper('tags', coreHelpers.tags); registerAsyncThemeHelper('body_class', coreHelpers.body_class); registerAsyncThemeHelper('e', coreHelpers.e); registerAsyncThemeHelper('ghost_foot', coreHelpers.ghost_foot); registerAsyncThemeHelper('ghost_head', coreHelpers.ghost_head); registerAsyncThemeHelper('meta_description', coreHelpers.meta_description); registerAsyncThemeHelper('meta_title', coreHelpers.meta_title); registerAsyncThemeHelper('post_class', coreHelpers.post_class); registerAsyncThemeHelper('url', coreHelpers.url); // Register admin helpers registerAdminHelper('asset', coreHelpers.asset); registerAdminHelper('ghostScriptTags', coreHelpers.ghostScriptTags); registerAdminHelper('fileStorage', coreHelpers.fileStorage); registerAsyncAdminHelper('url', coreHelpers.url); }; module.exports = coreHelpers; module.exports.loadCoreHelpers = registerHelpers; module.exports.registerThemeHelper = registerThemeHelper; module.exports.registerAsyncThemeHelper = registerAsyncThemeHelper;