diff --git a/core/ghost.js b/core/ghost.js index 86735d25e5ec..1250e8bd8f31 100644 --- a/core/ghost.js +++ b/core/ghost.js @@ -30,6 +30,8 @@ var config = require('../config'), instance, defaults; +when.pipeline = require('when/pipeline'); + // ## Default values /** * A hash of default values to use instead of 'magic' numbers/strings. @@ -182,8 +184,6 @@ Ghost.prototype.init = function () { return when.join( // Check for or initialise a dbHash. initDbHashAndFirstRun(), - // Initialize plugins - self.initPlugins(), // Initialize the permissions actions and objects permissions.init() ); @@ -282,6 +282,19 @@ Ghost.prototype.registerThemeHelper = function (name, fn) { hbs.registerHelper(name, fn); }; +// Register an async handlebars helper for themes +Ghost.prototype.registerAsyncThemeHelper = function (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(options)).then(function (result) { + cb(result); + }).otherwise(function (err) { + errors.logAndThrowError(err, "registerAsyncThemeHelper: " + name); + }); + }); +}; + // Register a new filter callback function Ghost.prototype.registerFilter = function (name, priority, fn) { // Curry the priority optional parameter to a default of 5 @@ -312,43 +325,62 @@ Ghost.prototype.unregisterFilter = function (name, priority, fn) { }; // Execute filter functions in priority order -Ghost.prototype.doFilter = function (name, args, callback) { - var callbacks = this.filterCallbacks[name]; +Ghost.prototype.doFilter = function (name, args) { + var callbacks = this.filterCallbacks[name], + priorityCallbacks = []; // Bug out early if no callbacks by that name if (!callbacks) { - return callback(args); + return when.resolve(args); } + // For each priorityLevel _.times(defaults.maxPriority + 1, function (priority) { - // Bug out if no handlers on this priority - if (!_.isArray(callbacks[priority])) { - return; - } - - // Call each handler for this priority level - _.each(callbacks[priority], function (filterHandler) { - try { - args = filterHandler(args); - } catch (e) { - // If a filter causes an error, we log it so that it can be debugged, but do not throw the error - errors.logError(e); + // Add a function that runs its priority level callbacks in a pipeline + priorityCallbacks.push(function (currentArgs) { + // Bug out if no handlers on this priority + if (!_.isArray(callbacks[priority])) { + return when.resolve(currentArgs); } + + // Call each handler for this priority level, allowing for promises or values + return when.pipeline(callbacks[priority], currentArgs); }); }); - callback(args); + return when.pipeline(priorityCallbacks, args); }; // Initialise plugins. Will load from config.activePlugins by default Ghost.prototype.initPlugins = function (pluginsToLoad) { - pluginsToLoad = pluginsToLoad || models.Settings.activePlugins; + pluginsToLoad = pluginsToLoad || JSON.parse(this.settings('activePlugins')); + var self = this; - return plugins.init(this, pluginsToLoad).then(function (loadedPlugins) { - // Extend the loadedPlugins onto the available plugins - _.extend(self.availablePlugins, loadedPlugins); + // If no activePlugins defined in config settings, look in database settings. + if (!_.isArray(pluginsToLoad)) { + // The value will be resolved in the promise + pluginsToLoad = models.Settings.read("activePlugins").then(function (activePluginsSetting) { + var settingValue = activePluginsSetting.get('value') || '[]'; + + try { + // We have to parse the value because it's a string + settingValue = JSON.parse(settingValue) || []; + } catch (e) { + return when.reject(new Error("Failed to parse activePlugins setting value: " + e.message)); + } + + // Resolve with the array value + return when.resolve(settingValue); + }); + } + + return when(pluginsToLoad).then(function (pluginsToLoadValue) { + return plugins.init(self, pluginsToLoad).then(function (loadedPlugins) { + // Extend the loadedPlugins onto the available plugins + _.extend(self.availablePlugins, loadedPlugins); + }); }, errors.logAndThrowError); }; -module.exports = Ghost; \ No newline at end of file +module.exports = Ghost; diff --git a/core/server.js b/core/server.js index fd7712616c31..c88b1f1e4dd2 100644 --- a/core/server.js +++ b/core/server.js @@ -445,22 +445,30 @@ when(ghost.init()).then(function () { loading.resolve(); } - // ## Start Ghost App - if (getSocket()) { - // Make sure the socket is gone before trying to create another - fs.unlink(getSocket(), function (err) { + // Expose the express server on the ghost instance. + ghost.server = server; + + // Initialize plugins then start the server + ghost.initPlugins().then(function () { + + // ## Start Ghost App + if (getSocket()) { + // Make sure the socket is gone before trying to create another + fs.unlink(getSocket(), function (err) { + server.listen( + getSocket(), + startGhost + ); + fs.chmod(getSocket(), '0744'); + }); + + } else { server.listen( - getSocket(), + ghost.config().server.port, + ghost.config().server.host, startGhost ); - fs.chmod(getSocket(), '0744'); - }); + } - } else { - server.listen( - ghost.config().server.port, - ghost.config().server.host, - startGhost - ); - } + }); }, errors.logAndThrowError); diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index c78aad7a2714..fa49b7eaf782 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -41,7 +41,6 @@ frontendControllers = { } api.posts.browse(options).then(function (page) { - var maxPage = page.pages; // A bit of a hack for situations with no content. @@ -56,7 +55,7 @@ frontendControllers = { } // Render the page of posts - ghost.doFilter('prePostsRender', page.posts, function (posts) { + ghost.doFilter('prePostsRender', page.posts).then(function (posts) { res.render('index', {posts: posts, pagination: {page: page.page, prev: page.prev, next: page.next, limit: page.limit, total: page.total, pages: page.pages}}); }); }).otherwise(function (err) { @@ -66,7 +65,7 @@ frontendControllers = { 'single': function (req, res, next) { api.posts.read({'slug': req.params.slug}).then(function (post) { if (post) { - ghost.doFilter('prePostsRender', post, function (post) { + ghost.doFilter('prePostsRender', post).then(function (post) { res.render('post', {post: post}); }); } else { @@ -117,7 +116,7 @@ frontendControllers = { return res.redirect('/rss/' + maxPage + '/'); } - ghost.doFilter('prePostsRender', page.posts, function (posts) { + ghost.doFilter('prePostsRender', page.posts).then(function (posts) { posts.forEach(function (post) { var item = { title: _.escape(post.title), @@ -148,7 +147,6 @@ frontendControllers = { return next(new Error(err)); }); } - }; module.exports = frontendControllers; diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index 7a70ba228e63..18b9befe40de 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -1,480 +1,538 @@ -var _ = require('underscore'), - moment = require('moment'), - downsize = require('downsize'), - when = require('when'), - hbs = require('express-hbs'), - packageInfo = require('../../../package.json'), - errors = require('../errorHandling'), - models = require('../models'), - coreHelpers; - - -coreHelpers = function (ghost) { - var paginationHelper, - scriptTemplate = _.template(""), - isProduction = process.env.NODE_ENV === 'production', - version = encodeURIComponent(packageInfo.version); - - /** - * [ 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 - */ - ghost.registerThemeHelper('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; - } - } - - 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 - // - ghost.registerThemeHelper('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. - // - ghost.registerThemeHelper('pageUrl', function (context, block) { - 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 - ghost.registerThemeHelper('url', function (options) { - var output = ''; - - if (options && options.hash.absolute) { - output += ghost.config().url; - } - - if (models.isPost(this)) { - output += '/' + this.slug + '/'; +var _ = require('underscore'), + moment = require('moment'), + downsize = require('downsize'), + when = require('when'), + hbs = require('express-hbs'), + errors = require('../errorHandling'), + models = require('../models'), + packageInfo = require('../../../package.json'), + version = packageInfo.version, + scriptTemplate = _.template(""), + isProduction = process.env.NODE_ENV === 'production', + 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; } + } - return output; - }); + var f = options.hash.format || 'MMM Do, YYYY', + timeago = options.hash.timeago, + date; - // ### 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. - // - ghost.registerThemeHelper('author', function (context, options) { - 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. - ghost.registerThemeHelper('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; - } + if (timeago) { + date = moment(context).fromNow(); + } else { + date = moment(context).format(f); + } + return date; +}; - return output; - }); +// +// ### 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)); +}; - // ### 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. - // - ghost.registerThemeHelper('content', function (options) { - var truncateOptions = (options || {}).hash || {}; - truncateOptions = _.pick(truncateOptions, ['words', 'characters']); - - if (truncateOptions.words || truncateOptions.characters) { - return new hbs.handlebars.SafeString( - downsize(this.html, truncateOptions) - ); - } +// ### Page URL Helper +// +// *Usage example:* +// `{{pageUrl 2}}` +// +// Returns the URL for the page specified in the current object +// context. +// +coreHelpers.pageUrl = function (context, block) { + return context === 1 ? '/' : ('/page/' + context + '/'); +}; - return new hbs.handlebars.SafeString(this.html); - }); +// ### 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 = ''; + + if (options && options.hash.absolute) { + output += coreHelpers.ghost.config().url; + } + + if (models.isPost(this)) { + output += '/' + this.slug + '/'; + } + + return 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) { + return this.author ? this.author.name : ''; +}; - // ### 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. - // - ghost.registerThemeHelper('excerpt', function (options) { - var truncateOptions = (options || {}).hash || {}, - excerpt; - - truncateOptions = _.pick(truncateOptions, ['words', 'characters']); - - /*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; - } +// ### 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']); + + if (truncateOptions.words || truncateOptions.characters) { return new hbs.handlebars.SafeString( - downsize(excerpt, truncateOptions) + downsize(this.html, truncateOptions) ); - }); + } - // ### Filestorage helper - // - // *Usage example:* - // `{{fileStorage}}` - // - // Returns the config value for fileStorage. - ghost.registerThemeHelper('fileStorage', function (context, options) { - if (ghost.config().hasOwnProperty('fileStorage')) { - return ghost.config().fileStorage.toString(); - } - return "true"; - }); + return new hbs.handlebars.SafeString(this.html); +}; - ghost.registerThemeHelper('body_class', function (options) { - 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; +// ### 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']); + + /*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) + ); +}; - if (_.isString(this.path) && this.path.match(/\/page/)) { - classes.push('archive-template'); - } else if (!this.path || this.path === '/' || this.path === '') { - classes.push('home-template'); - } else { - classes.push('post-template'); - } +// ### Filestorage helper +// +// *Usage example:* +// `{{fileStorage}}` +// +// Returns the config value for fileStorage. +coreHelpers.fileStorage = function (context, options) { + if (coreHelpers.ghost.config().hasOwnProperty('fileStorage')) { + return coreHelpers.ghost.config().fileStorage.toString(); + } + return "true"; +}; - if (tags) { - classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); - } +coreHelpers.ghostScriptTags = function () { + var scriptFiles = []; + + 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({ + name: fileName, + version: version + }); + }); - if (page) { - classes.push('page'); - } + return scriptFiles.join(''); +}; - return ghost.doFilter('body_class', classes, function (classes) { - var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); - return new hbs.handlebars.SafeString(classString.trim()); - }); +/* + * Asynchronous Theme Helpers (Registered with ghost.registerAsyncThemeHelper) + */ + +coreHelpers.body_class = function (options) { + 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.path) && this.path.match(/\/page/)) { + classes.push('archive-template'); + } else if (!this.path || this.path === '/' || this.path === '') { + 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 coreHelpers.ghost.doFilter('body_class', classes).then(function (classes) { + var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classString.trim()); }); +}; - ghost.registerThemeHelper('post_class', function (options) { - 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; +coreHelpers.post_class = function (options) { + 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 (tags) { + classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); + } - if (featured) { - classes.push('featured'); - } + if (featured) { + classes.push('featured'); + } - if (page) { - classes.push('page'); - } + if (page) { + classes.push('page'); + } - return ghost.doFilter('post_class', classes, function (classes) { - var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); - return new hbs.handlebars.SafeString(classString.trim()); - }); + return coreHelpers.ghost.doFilter('post_class', classes).then(function (classes) { + var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classString.trim()); }); +}; - ghost.registerThemeHelper('ghost_head', function (options) { - var head = [], - majorMinor = /^(\d+\.)?(\d+)/, - trimmedVersion = this.version.match(majorMinor)[0]; +coreHelpers.ghost_head = function (options) { + var head = [], + majorMinor = /^(\d+\.)?(\d+)/, + trimmedVersion = this.version; - head.push(''); - head.push(''); + trimmedVersion = trimmedVersion ? trimmedVersion.match(majorMinor)[0] : '?'; - return ghost.doFilter('ghost_head', head, function (head) { - var headString = _.reduce(head, function (memo, item) { return memo + '\n' + item; }, ''); - return new hbs.handlebars.SafeString(headString.trim()); - }); + head.push(''); + head.push(''); + + return coreHelpers.ghost.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()); }); +}; - ghost.registerThemeHelper('meta_title', function (options) { - var title, blog; - blog = ghost.blogGlobals(); - if (_.isString(this.path)) { - if (!this.path || this.path === '/' || this.path === '' || this.path.match(/\/page/)) { - blog = ghost.blogGlobals(); - title = blog.title; - } else { - title = this.post ? this.post.title : ''; - } - } +coreHelpers.ghost_foot = function (options) { + var foot = []; + foot.push(''); - return ghost.doFilter('meta_title', title, function (title) { - return new hbs.handlebars.SafeString(title.trim()); - }); + return coreHelpers.ghost.doFilter('ghost_foot', foot).then(function (foot) { + var footString = _.reduce(foot, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(footString.trim()); }); +}; - ghost.registerThemeHelper('meta_description', function (options) { - var description, blog; - blog = ghost.blogGlobals(); - if (_.isString(this.path)) { - if (!this.path || this.path === '/' || this.path === '' || this.path.match(/\/page/)) { - blog = ghost.blogGlobals(); - description = blog.description; - } else { - description = ''; - } +coreHelpers.meta_title = function (options) { + var title, + blog; + if (_.isString(this.path)) { + if (!this.path || this.path === '/' || this.path === '' || this.path.match(/\/page/)) { + blog = coreHelpers.ghost.blogGlobals(); + title = blog.title; + } else { + title = this.post.title; } + } - return ghost.doFilter('meta_description', description, function (description) { - return new hbs.handlebars.SafeString(description.trim()); - }); + return coreHelpers.ghost.doFilter('meta_title', title).then(function (title) { + title = title || ""; + return new hbs.handlebars.SafeString(title.trim()); }); +}; - ghost.registerThemeHelper('ghost_foot', function (options) { - var foot = []; - foot.push(''); +coreHelpers.meta_description = function (options) { + var description, + blog; - return ghost.doFilter('ghost_foot', foot, function (foot) { - var footString = _.reduce(foot, function (memo, item) { return memo + ' ' + item; }, ''); - return new hbs.handlebars.SafeString(footString.trim()); - }); - }); - /** - * [ description] - * - * @param String key - * @param String default translation - * @param {Object} options - * @return String A correctly internationalised string - */ - ghost.registerThemeHelper('e', function (key, defaultString, options) { - var output; - - if (ghost.settings('defaultLang') === 'en' && _.isEmpty(options.hash) && !ghost.settings('forceI18n')) { - output = defaultString; + if (_.isString(this.path)) { + if (!this.path || this.path === '/' || this.path === '' || this.path.match(/\/page/)) { + blog = coreHelpers.ghost.blogGlobals(); + description = blog.description; } else { - output = ghost.polyglot().t(key, options.hash); + description = ''; } + } - return output; + return coreHelpers.ghost.doFilter('meta_description', description).then(function (description) { + description = description || ""; + return new hbs.handlebars.SafeString(description.trim()); }); +}; - ghost.registerThemeHelper('json', function (object, options) { - return JSON.stringify(object); - }); +/** + * 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; + + if (coreHelpers.ghost.settings('defaultLang') === 'en' && _.isEmpty(options.hash) && !coreHelpers.ghost.settings('forceI18n')) { + output = defaultString; + } else { + output = coreHelpers.ghost.polyglot().t(key, options.hash); + } + + return output; +}; - ghost.registerThemeHelper('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); - } +coreHelpers.json = function (object, options) { + return JSON.stringify(object); +}; - 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; +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 }); } - if (_i % _columns === 0) { - _data.rowStart = true; - } else if (_i % _columns === (_columns - 1)) { - _data.rowEnd = true; + } else { + for (key in context) { + if (context.hasOwnProperty(key)) { + j += 1; + } } - return _data; - } - if (context && typeof context === 'object') { - if (context instanceof Array) { - for (j = context.length; i < j; i += 1) { + for (key in context) { + if (context.hasOwnProperty(key)) { if (data) { - data.index = i; + 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[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; - } + ret = ret + fn(context[key], {data: data}); + i += 1; } } } + } - if (i === 0) { - ret = inverse(this); - } - return ret; - }); + if (i === 0) { + ret = inverse(this); + } + return ret; +}; - // A helper for inserting the javascript tags with version hashes - ghost.registerThemeHelper('ghostScriptTags', function () { - var scriptFiles = []; +// ## Template driven helpers +// Template driven helpers require that their template is loaded before they can be registered. +coreHelpers.paginationTemplate = null; + +// ### Pagination Helper +// `{{pagination}}` +// Outputs previous and next buttons, along with info about the current page +coreHelpers.pagination = function (options) { + 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 new hbs.handlebars.SafeString(coreHelpers.paginationTemplate(this.pagination)); +}; - if (isProduction) { - scriptFiles.push("ghost.min.js"); - } else { - scriptFiles = [ - 'vendor.js', - 'helpers.js', - 'templates.js', - 'models.js', - 'views.js' - ]; - } +coreHelpers.helperMissing = function (arg) { + if (arguments.length === 2) { + return undefined; + } + errors.logError('Missing helper: "' + arg + '"'); +}; - scriptFiles = _.map(scriptFiles, function (fileName) { - return scriptTemplate({ - name: fileName, - version: version - }); - }); +registerHelpers = function (ghost) { + var paginationHelper; - return scriptFiles.join(''); - }); + // Expose this so our helpers can use it in their code. + coreHelpers.ghost = ghost; + + ghost.registerThemeHelper('date', coreHelpers.date); + + ghost.registerThemeHelper('encode', coreHelpers.encode); + + ghost.registerThemeHelper('pageUrl', coreHelpers.pageUrl); + + ghost.registerThemeHelper('url', coreHelpers.url); + + ghost.registerThemeHelper('author', coreHelpers.author); + + ghost.registerThemeHelper('tags', coreHelpers.tags); + + ghost.registerThemeHelper('content', coreHelpers.content); + + ghost.registerThemeHelper('excerpt', coreHelpers.excerpt); + + ghost.registerThemeHelper('fileStorage', coreHelpers.fileStorage); + + ghost.registerThemeHelper('ghostScriptTags', coreHelpers.ghostScriptTags); + + ghost.registerThemeHelper('e', coreHelpers.e); + + ghost.registerThemeHelper('json', coreHelpers.json); + + ghost.registerThemeHelper('foreach', coreHelpers.foreach); - // ## Template driven helpers - // Template driven helpers require that their template is loaded before they can be registered. + ghost.registerThemeHelper('helperMissing', coreHelpers.helperMissing); + + ghost.registerAsyncThemeHelper('body_class', coreHelpers.body_class); + + ghost.registerAsyncThemeHelper('post_class', coreHelpers.post_class); + + ghost.registerAsyncThemeHelper('meta_title', coreHelpers.meta_title); + + ghost.registerAsyncThemeHelper('meta_description', coreHelpers.meta_description); + + ghost.registerAsyncThemeHelper('ghost_head', coreHelpers.ghost_head); + + ghost.registerAsyncThemeHelper('ghost_foot', coreHelpers.ghost_foot); - // ### Pagination Helper - // `{{pagination}}` - // Outputs previous and next buttons, along with info about the current page paginationHelper = ghost.loadTemplate('pagination').then(function (templateFn) { - ghost.registerThemeHelper('pagination', function (options) { - 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 new hbs.handlebars.SafeString(templateFn(this.pagination)); - }); - }); + coreHelpers.paginationTemplate = templateFn; - ghost.registerThemeHelper('helperMissing', function (arg) { - if (arguments.length === 2) { - return undefined; - } - errors.logError("Missing helper: '" + arg + "'"); + ghost.registerThemeHelper('pagination', coreHelpers.pagination); }); + // Return once the template-driven helpers have loaded return when.join( paginationHelper ); }; -module.exports.loadCoreHelpers = coreHelpers; +module.exports = coreHelpers; +module.exports.loadCoreHelpers = registerHelpers; diff --git a/core/test/unit/ghost_spec.js b/core/test/unit/ghost_spec.js index 13cb58fa4570..1e13872e4ad3 100644 --- a/core/test/unit/ghost_spec.js +++ b/core/test/unit/ghost_spec.js @@ -94,7 +94,7 @@ describe("Ghost API", function () { ghost.registerFilter(filterName, 2, testFilterHandler2); ghost.registerFilter(filterName, 9, testFilterHandler3); - ghost.doFilter(filterName, null, function () { + ghost.doFilter(filterName, null).then(function () { testFilterHandler1.calledBefore(testFilterHandler2).should.equal(true); testFilterHandler2.calledBefore(testFilterHandler3).should.equal(true); @@ -105,6 +105,51 @@ describe("Ghost API", function () { }); }); + it("executes filters that return a promise", function (done) { + var filterName = 'testprioritypromise', + testFilterHandler1 = sinon.spy(function (args) { + return when.promise(function (resolve) { + process.nextTick(function () { + args.filter1 = true; + + resolve(args); + }); + }); + }), + testFilterHandler2 = sinon.spy(function (args) { + args.filter2 = true; + + return args; + }), + testFilterHandler3 = sinon.spy(function (args) { + return when.promise(function (resolve) { + process.nextTick(function () { + args.filter3 = true; + + resolve(args); + }); + }); + }); + + ghost.registerFilter(filterName, 0, testFilterHandler1); + ghost.registerFilter(filterName, 2, testFilterHandler2); + ghost.registerFilter(filterName, 9, testFilterHandler3); + + ghost.doFilter(filterName, { test: true }).then(function (newArgs) { + + testFilterHandler1.calledBefore(testFilterHandler2).should.equal(true); + testFilterHandler2.calledBefore(testFilterHandler3).should.equal(true); + + testFilterHandler3.called.should.equal(true); + + newArgs.filter1.should.equal(true); + newArgs.filter2.should.equal(true); + newArgs.filter3.should.equal(true); + + done(); + }); + }); + it("can compile a template", function (done) { var template = path.join(process.cwd(), testTemplatePath, 'test.hbs'); diff --git a/core/test/unit/plugins_spec.js b/core/test/unit/plugins_spec.js index a8a8e06ebaad..4948c9321eef 100644 --- a/core/test/unit/plugins_spec.js +++ b/core/test/unit/plugins_spec.js @@ -4,13 +4,13 @@ var testUtils = require('./testUtils'), sinon = require('sinon'), _ = require("underscore"), when = require('when'), + knex = require('../../server/models/base').Knex, errors = require('../../server/errorHandling'), // Stuff we are testing plugins = require('../../server/plugins'), GhostPlugin = plugins.GhostPlugin, loader = require('../../server/plugins/loader'); - describe('Plugins', function () { var sandbox; diff --git a/core/test/unit/server_helpers_index_spec.js b/core/test/unit/server_helpers_index_spec.js index 502f6b5a9205..f529e84a543a 100644 --- a/core/test/unit/server_helpers_index_spec.js +++ b/core/test/unit/server_helpers_index_spec.js @@ -29,7 +29,7 @@ describe('Core Helpers', function () { it('can render content', function () { var html = "Hello World", - rendered = handlebars.helpers.content.call({html: html}); + rendered = helpers.content.call({html: html}); should.exist(rendered); rendered.string.should.equal(html); @@ -38,7 +38,7 @@ describe('Core Helpers', function () { it('can truncate html by word', function () { var html = "

Hello World! It's me!

", rendered = ( - handlebars.helpers.content + helpers.content .call( {html: html}, {"hash":{"words": 2}} @@ -52,7 +52,7 @@ describe('Core Helpers', function () { it('can truncate html by character', function () { var html = "

Hello World! It's me!

", rendered = ( - handlebars.helpers.content + helpers.content .call( {html: html}, {"hash":{"characters": 8}} @@ -72,14 +72,14 @@ describe('Core Helpers', function () { it("Returns the full name of the author from the context",function() { var data = {"author":{"name":"abc123"}}, - result = handlebars.helpers.author.call(data); + result = helpers.author.call(data); String(result).should.equal("abc123"); }); it("Returns a blank string where author data is missing",function() { var data = {"author": null}, - result = handlebars.helpers.author.call(data); + result = helpers.author.call(data); String(result).should.equal(""); }); @@ -110,7 +110,7 @@ describe('Core Helpers', function () { it('can render excerpt', function () { var html = "Hello World", - rendered = handlebars.helpers.excerpt.call({html: html}); + rendered = helpers.excerpt.call({html: html}); should.exist(rendered); rendered.string.should.equal(html); @@ -123,7 +123,7 @@ describe('Core Helpers', function () { + "< test > those<<< test >>> who mistake it <for> binary.", expected = "There are 10 types of people in the world: those who understand trinary, those who don't " + "and those>> who mistake it <for> binary.", - rendered = handlebars.helpers.excerpt.call({html: html}); + rendered = helpers.excerpt.call({html: html}); should.exist(rendered); rendered.string.should.equal(expected); @@ -134,7 +134,7 @@ describe('Core Helpers', function () { var html = "

Hello World! It's me!

", expected = "Hello World", rendered = ( - handlebars.helpers.excerpt.call( + helpers.excerpt.call( {html: html}, {"hash": {"words": 2}} ) @@ -148,7 +148,7 @@ describe('Core Helpers', function () { var html = "

Hello World! It's me!

", expected = "Hello Wo", rendered = ( - handlebars.helpers.excerpt.call( + helpers.excerpt.call( {html: html}, {"hash": {"characters": 8}} ) @@ -164,35 +164,48 @@ describe('Core Helpers', function () { should.exist(handlebars.helpers.body_class); }); - it('can render class string', function () { - var rendered = handlebars.helpers.body_class.call({}); - should.exist(rendered); + it('can render class string', function (done) { + helpers.body_class.call({}).then(function (rendered) { + should.exist(rendered); + + rendered.string.should.equal('home-template'); - rendered.string.should.equal('home-template'); + done(); + }, done); }); - it('can render class string for context', function () { - var rendered1 = handlebars.helpers.body_class.call({path: '/'}), - rendered2 = handlebars.helpers.body_class.call({path: '/a-post-title'}), - rendered3 = handlebars.helpers.body_class.call({path: '/page/4'}); + it('can render class string for context', function (done) { + when.all([ + helpers.body_class.call({path: '/'}), + helpers.body_class.call({path: '/a-post-title'}), + helpers.body_class.call({path: '/page/4'}) + ]).then(function (rendered) { + rendered.length.should.equal(3); + + should.exist(rendered[0]); + should.exist(rendered[1]); + should.exist(rendered[2]); - should.exist(rendered1); - should.exist(rendered2); - should.exist(rendered3); + rendered[0].string.should.equal('home-template'); + rendered[1].string.should.equal('post-template'); + rendered[2].string.should.equal('archive-template'); - rendered1.string.should.equal('home-template'); - rendered2.string.should.equal('post-template'); - rendered3.string.should.equal('archive-template'); + done(); + }); }); - it('can render class for static page', function () { - var rendered = handlebars.helpers.body_class.call( - {post: {page: true}}, - {path: '/'} - ); + it('can render class for static page', function (done) { + helpers.body_class.call({ + path: '/', + post: { + page: true + } + }).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('home-template page'); - should.exist(rendered); - rendered.string.should.equal('home-template page'); + done(); + }, done); }); }); @@ -201,19 +214,23 @@ describe('Core Helpers', function () { should.exist(handlebars.helpers.post_class); }); - it('can render class string', function () { - var rendered = handlebars.helpers.post_class.call({}); - should.exist(rendered); - - rendered.string.should.equal('post'); + it('can render class string', function (done) { + helpers.post_class.call({}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('post'); + done(); + }); }); - it('can render featured class', function () { - var post = { featured: true }, - rendered = handlebars.helpers.post_class.call(post); + it('can render featured class', function (done) { + var post = { featured: true }; - should.exist(rendered); - rendered.string.should.equal('post featured'); + helpers.post_class.call(post).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('post featured'); + + done(); + }, done); }); }); @@ -222,16 +239,20 @@ describe('Core Helpers', function () { should.exist(handlebars.helpers.ghost_head); }); - it('returns meta tag string', function () { - var rendered = handlebars.helpers.ghost_head.call({version: "0.3.0"}); - should.exist(rendered); - rendered.string.should.equal('\n'); + it('returns meta tag string', function (done) { + helpers.ghost_head.call({version: "0.3.0"}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('\n'); + + done(); + }); }); it('returns meta tag string even if version is invalid', function () { - var rendered = handlebars.helpers.ghost_head.call({version: "0.9"}); - should.exist(rendered); - rendered.string.should.equal('\n'); + var rendered = helpers.ghost_head.call({version: "0.9"}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('\n'); + }); }); }); @@ -240,10 +261,14 @@ describe('Core Helpers', function () { should.exist(handlebars.helpers.ghost_foot); }); - it('returns meta tag string', function () { - var rendered = handlebars.helpers.ghost_foot.call(); - should.exist(rendered); - rendered.string.should.equal(''); + it('returns meta tag string', function (done) { + helpers.ghost_foot.call().then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal(''); + + done(); + }); + }); }); @@ -253,7 +278,7 @@ describe('Core Helpers', function () { }); it('should return a the slug with a prefix slash if the context is a post', function () { - var rendered = handlebars.helpers.url.call({html: 'content', markdown: "ff", title: "title", slug: "slug"}); + var rendered = helpers.url.call({html: 'content', markdown: "ff", title: "title", slug: "slug"}); should.exist(rendered); rendered.should.equal('/slug/'); }); @@ -263,7 +288,7 @@ describe('Core Helpers', function () { return { url: 'http://testurl.com' }; }), - rendered = handlebars.helpers.url.call( + rendered = helpers.url.call( {html: 'content', markdown: "ff", title: "title", slug: "slug"}, {hash: { absolute: 'true'}} ); @@ -275,10 +300,10 @@ describe('Core Helpers', function () { }); it('should return empty string if not a post', function () { - handlebars.helpers.url.call({markdown: "ff", title: "title", slug: "slug"}).should.equal(''); - handlebars.helpers.url.call({html: 'content', title: "title", slug: "slug"}).should.equal(''); - handlebars.helpers.url.call({html: 'content', markdown: "ff", slug: "slug"}).should.equal(''); - handlebars.helpers.url.call({html: 'content', markdown: "ff", title: "title"}).should.equal(''); + helpers.url.call({markdown: "ff", title: "title", slug: "slug"}).should.equal(''); + helpers.url.call({html: 'content', title: "title", slug: "slug"}).should.equal(''); + helpers.url.call({html: 'content', markdown: "ff", slug: "slug"}).should.equal(''); + helpers.url.call({html: 'content', markdown: "ff", title: "title"}).should.equal(''); }); }); @@ -288,9 +313,9 @@ describe('Core Helpers', function () { }); it('can return a valid url', function () { - handlebars.helpers.pageUrl(1).should.equal('/'); - handlebars.helpers.pageUrl(2).should.equal('/page/2/'); - handlebars.helpers.pageUrl(50).should.equal('/page/50/'); + helpers.pageUrl(1).should.equal('/'); + helpers.pageUrl(2).should.equal('/page/2/'); + helpers.pageUrl(50).should.equal('/page/50/'); }); }); @@ -307,7 +332,7 @@ describe('Core Helpers', function () { it('can render single page with no pagination necessary', function (done) { var rendered; helpers.loadCoreHelpers(ghost).then(function () { - rendered = handlebars.helpers.pagination.call({pagination: {page: 1, prev: undefined, next: undefined, limit: 15, total: 8, pages: 1}}); + rendered = helpers.pagination.call({pagination: {page: 1, prev: undefined, next: undefined, limit: 15, total: 8, pages: 1}}); should.exist(rendered); // strip out carriage returns and compare. rendered.string.should.match(paginationRegex); @@ -322,7 +347,7 @@ describe('Core Helpers', function () { it('can render first page of many with older posts link', function (done) { var rendered; helpers.loadCoreHelpers(ghost).then(function () { - rendered = handlebars.helpers.pagination.call({pagination: {page: 1, prev: undefined, next: 2, limit: 15, total: 8, pages: 3}}); + rendered = helpers.pagination.call({pagination: {page: 1, prev: undefined, next: 2, limit: 15, total: 8, pages: 3}}); should.exist(rendered); rendered.string.should.match(paginationRegex); @@ -337,7 +362,7 @@ describe('Core Helpers', function () { it('can render middle pages of many with older and newer posts link', function (done) { var rendered; helpers.loadCoreHelpers(ghost).then(function () { - rendered = handlebars.helpers.pagination.call({pagination: {page: 2, prev: 1, next: 3, limit: 15, total: 8, pages: 3}}); + rendered = helpers.pagination.call({pagination: {page: 2, prev: 1, next: 3, limit: 15, total: 8, pages: 3}}); should.exist(rendered); rendered.string.should.match(paginationRegex); @@ -353,7 +378,7 @@ describe('Core Helpers', function () { it('can render last page of many with newer posts link', function (done) { var rendered; helpers.loadCoreHelpers(ghost).then(function () { - rendered = handlebars.helpers.pagination.call({pagination: {page: 3, prev: 2, next: undefined, limit: 15, total: 8, pages: 3}}); + rendered = helpers.pagination.call({pagination: {page: 3, prev: 2, next: undefined, limit: 15, total: 8, pages: 3}}); should.exist(rendered); rendered.string.should.match(paginationRegex); @@ -370,7 +395,7 @@ describe('Core Helpers', function () { helpers.loadCoreHelpers(ghost).then(function () { var runErrorTest = function (data) { return function () { - handlebars.helpers.pagination.call(data); + helpers.pagination.call(data); }; }; @@ -485,43 +510,49 @@ describe('Core Helpers', function () { should.exist(handlebars.helpers.meta_title); }); - it('can return blog title', function () { - var rendered = handlebars.helpers.meta_title.call({path: '/'}); + it('can return blog title', function (done) { + helpers.meta_title.call({path: '/'}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('Ghost'); - should.exist(rendered); - String(rendered).should.equal('Ghost'); + done(); + }, done); }); - it('can return title of a post', function () { - var rendered = handlebars.helpers.meta_title.call( - {path: '/nice-post', post: {title: 'Post Title'}} - ); + it('can return title of a post', function (done) { + var post = {path: '/nice-post', post: {title: 'Post Title'}}; + helpers.meta_title.call(post).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('Post Title'); - should.exist(rendered); - String(rendered).should.equal('Post Title'); + done(); + }, done); }); }); - describe("meta_description helper", function () { + describe("meta_description helper", function (done) { it('has loaded meta_description helper', function () { should.exist(handlebars.helpers.meta_description); }); it('can return blog description', function () { - var rendered = handlebars.helpers.meta_description.call({path: '/'}); + helpers.meta_description.call({path: '/'}).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('Just a blogging platform.'); - should.exist(rendered); - String(rendered).should.equal('Just a blogging platform.'); + done(); + }, done); }); - it('can return empty description on post', function () { - var rendered = handlebars.helpers.meta_description.call( - {path: '/nice-post', post: {title: 'Post Title'}} - ); + it('can return empty description on post', function (done) { + var post = {path: '/nice-post', post: {title: 'Post Title'}}; + helpers.meta_description.call(post).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal(''); - should.exist(rendered); - String(rendered).should.equal(''); + done(); + }, done); }); });