From 023890928137b4c2c1e3fbfd4debf9080c94a732 Mon Sep 17 00:00:00 2001 From: Jacob Gable Date: Mon, 5 Aug 2013 10:11:32 -0500 Subject: [PATCH] Initial Plugin API Implementation Implements basic functionality described in #227 for loading plugins from a specific directory and having a specific workflow with an init() method and a disable() method. --- config.js | 2 +- content/plugins/FancyFirstChar/index.js | 62 ++++++++++++++ content/plugins/fancyFirstChar.js | 41 --------- core/ghost.js | 66 +++++++++------ core/server/api.js | 27 +++++- core/server/plugins/GhostPlugin.js | 50 +++++++++++ core/server/plugins/index.js | 72 ++++++++++++++++ core/server/plugins/loader.js | 81 ++++++++++++++++++ core/test/ghost/plugins_spec.js | 108 ++++++++++++++++++++++++ core/test/unit/ghost_spec.js | 18 ++-- 10 files changed, 444 insertions(+), 83 deletions(-) create mode 100644 content/plugins/FancyFirstChar/index.js delete mode 100644 content/plugins/fancyFirstChar.js create mode 100644 core/server/plugins/GhostPlugin.js create mode 100644 core/server/plugins/index.js create mode 100644 core/server/plugins/loader.js create mode 100644 core/test/ghost/plugins_spec.js diff --git a/config.js b/config.js index 14245e42de..7d4340edc1 100644 --- a/config.js +++ b/config.js @@ -41,7 +41,7 @@ config.activeTheme = 'casper'; config.activePlugins = [ - 'fancyFirstChar.js' + 'FancyFirstChar' ]; // Default Navigation Items diff --git a/content/plugins/FancyFirstChar/index.js b/content/plugins/FancyFirstChar/index.js new file mode 100644 index 0000000000..b6a9a1dba3 --- /dev/null +++ b/content/plugins/FancyFirstChar/index.js @@ -0,0 +1,62 @@ +var util = require('util'), + _ = require('underscore'), + fancifyPlugin; + +fancifyPlugin = { + + // Fancify a single post body + fancify: function (originalContent) { + var newContent, + firstCharIndex = 0; + + if (originalContent.substr(0, 1) === '<') { + firstCharIndex = originalContent.indexOf('>') + 1; + } + + newContent = originalContent.substr(0, firstCharIndex); + newContent += ''; + newContent += originalContent.substr(firstCharIndex, 1); + newContent += ''; + newContent += originalContent.substr(firstCharIndex + 1, originalContent.length - firstCharIndex - 1); + + return newContent; + }, + + // Fancify a collection of posts + fancifyPosts: function (posts) { + var self = this; + + if (_.isArray(posts)) { + _.each(posts, function (post) { + post.content = self.fancify(post.content); + }); + } else if (posts.hasOwnProperty('content')) { + posts.content = this.fancify(posts.content); + } + + return posts; + }, + + install: function () { + + }, + + uninstall: function () { + + }, + + // Registers the prePostsRender filter to alter the content. + activate: function (ghost) { + ghost.registerFilter('prePostsRender', this.fancifyPosts); + }, + + // Unregister any filters. + deactivate: function (ghost) { + ghost.unregisterFilter("prePostsRender", this.fancifyPosts); + } +}; + +// Ensure our this context in the important methods +_.bindAll(fancifyPlugin, "fancifyPosts", "fancify", "activate", "deactivate"); + +module.exports = fancifyPlugin; \ No newline at end of file diff --git a/content/plugins/fancyFirstChar.js b/content/plugins/fancyFirstChar.js deleted file mode 100644 index d2b955a601..0000000000 --- a/content/plugins/fancyFirstChar.js +++ /dev/null @@ -1,41 +0,0 @@ -var _ = require('underscore'); - -var fancyFirstChar; - -function fancify(originalContent) { - var newContent, - firstCharIndex = 0; - - if (originalContent.substr(0, 1) === '<') { - firstCharIndex = originalContent.indexOf('>') + 1; - } - - newContent = originalContent.substr(0, firstCharIndex); - newContent += ''; - newContent += originalContent.substr(firstCharIndex, 1); - newContent += ''; - newContent += originalContent.substr(firstCharIndex + 1, originalContent.length - firstCharIndex - 1); - - return newContent; -} - - -fancyFirstChar = { - init: function (ghost) { - ghost.registerFilter('prePostsRender', function (posts) { - if (_.isArray(posts)) { - _.each(posts, function (post) { - post.content = fancify(post.content); - }); - } else if (posts.hasOwnProperty('content')) { - posts.content = fancify(posts.content); - } - - return posts; - }); - }, - activate: function () {}, - deactivate: function () {} -}; - -module.exports = fancyFirstChar; \ No newline at end of file diff --git a/core/ghost.js b/core/ghost.js index f19aa74ed3..5ca278effa 100644 --- a/core/ghost.js +++ b/core/ghost.js @@ -15,6 +15,8 @@ var config = require('./../config'), models = require('./server/models'), + plugins = require('./server/plugins'), + requireTree = require('./server/require-tree'), themePath = path.resolve(__dirname + '../../content/themes'), pluginPath = path.resolve(__dirname + '../../content/plugins'), @@ -77,6 +79,8 @@ Ghost = function () { // Holds the persistent notifications instance.notifications = []; + instance.availablePlugins = {}; + app = express(); polyglot = new Polyglot(); @@ -120,36 +124,12 @@ Ghost.prototype.init = function () { var self = this; return when.join(instance.dataProvider.init(), instance.getPaths()).then(function () { - return self.loadPlugins(); + return self.initPlugins(); }, errors.logAndThrowError).then(function () { return self.updateSettingsCache(); }, errors.logAndThrowError); }; -Ghost.prototype.loadPlugins = function () { - var self = this, - pluginPaths = _.values(self.paths().availablePlugins), - pluginPromises = []; - - _.each(self.config().activePlugins, function (plugin) { - var match = _.find(pluginPaths, function (path) { - return new RegExp(plugin + '$').test(path); - }); - - if (match) { - pluginPromises.push(require(path.join(pluginPath, plugin))); - } - }); - - return when.all(pluginPromises).then(function (plugins) { - _.each(plugins, function (plugin) { - if (_.isFunction(plugin.init)) { - plugin.init(self); - } - }); - }, errors.logAndThrowError); -}; - Ghost.prototype.updateSettingsCache = function (settings) { var self = this; @@ -246,6 +226,25 @@ Ghost.prototype.registerFilter = function (name, priority, fn) { this.filterCallbacks[name][priority].push(fn); }; +/** + * @param {string} name + * @param {integer} priority + * @param {Function} fn + */ +Ghost.prototype.unregisterFilter = function (name, priority, fn) { + // Curry the priority optional parameter to a default of 5 + if (_.isFunction(priority)) { + fn = priority; + priority = defaults.filterPriority; + } + + // Check if it even exists + if (this.filterCallbacks[name] && this.filterCallbacks[name][priority]) { + // Remove the function from the list of filter funcs + this.filterCallbacks[name][priority] = _.without(this.filterCallbacks[name][priority], fn); + } +}; + /** * @param {string} name [description] * @param {*} args @@ -280,10 +279,25 @@ Ghost.prototype.doFilter = function (name, args, callback) { callback(args); }; +/** + * Initialise plugins. Will load from config.activePlugins by default + * + * @param {Array} pluginsToLoad + */ +Ghost.prototype.initPlugins = function (pluginsToLoad) { + pluginsToLoad = pluginsToLoad || config.activePlugins; + + var self = this; + + return plugins.init(this, pluginsToLoad).then(function (loadedPlugins) { + // Extend the loadedPlugins onto the available plugins + _.extend(self.availablePlugins, loadedPlugins); + }, errors.logAndThrowError); +}; + /** * Initialise Theme * - * @todo Tod (?) Old comment * @param {Object} app */ Ghost.prototype.initTheme = function (app) { diff --git a/core/server/api.js b/core/server/api.js index 6963af4da7..c769aea421 100644 --- a/core/server/api.js +++ b/core/server/api.js @@ -95,6 +95,10 @@ settings = { return dataProvider.Settings.browse(options).then(settingsObject, errors.logAndThrowError); }, read: function read(options) { + if (_.isString(options)) { + options = { key: options }; + } + return dataProvider.Settings.read(options.key).then(function (setting) { if (!setting) { return when.reject("Unable to find setting: " + options.key); @@ -103,9 +107,26 @@ settings = { return _.pick(setting.toJSON(), 'key', 'value'); }, errors.logAndThrowError); }, - edit: function edit(settings) { - settings = settingsCollection(settings); - return dataProvider.Settings.edit(settings).then(settingsObject, errors.logAndThrowError); + edit: function edit(key, value) { + // Check for passing a collection of settings first + if (_.isObject(key)) { + key = settingsCollection(key); + return dataProvider.Settings.edit(key).then(settingsObject, errors.logAndThrowError); + } + + return dataProvider.Settings.read(key).then(function (setting) { + if (!setting) { + return when.reject("Unable to find setting: " + key); + } + + if (!_.isString(value)) { + value = JSON.stringify(value); + } + + setting.set('value', value); + + return dataProvider.Settings.edit(setting); + }, errors.logAndThrowError); } }; diff --git a/core/server/plugins/GhostPlugin.js b/core/server/plugins/GhostPlugin.js new file mode 100644 index 0000000000..00dce90206 --- /dev/null +++ b/core/server/plugins/GhostPlugin.js @@ -0,0 +1,50 @@ + +var GhostPlugin; + +/** + * GhostPlugin is the base class for a standard plugin. + * @class + * @parameter {Ghost} The current Ghost app instance + */ +GhostPlugin = function (ghost) { + this.app = ghost; +}; + +/** + * A method that will be called on installation. + * Can optionally return a promise if async. + * @parameter {Ghost} The current Ghost app instance + */ +GhostPlugin.prototype.install = function (ghost) { + return; +}; + +/** + * A method that will be called on uninstallation. + * Can optionally return a promise if async. + * @parameter {Ghost} The current Ghost app instance + */ +GhostPlugin.prototype.uninstall = function (ghost) { + return; +}; + +/** + * A method that will be called when the plugin is enabled. + * Can optionally return a promise if async. + * @parameter {Ghost} The current Ghost app instance + */ +GhostPlugin.prototype.activate = function (ghost) { + return; +}; + +/** + * A method that will be called when the plugin is disabled. + * Can optionally return a promise if async. + * @parameter {Ghost} The current Ghost app instance + */ +GhostPlugin.prototype.deactivate = function (ghost) { + return; +}; + +module.exports = GhostPlugin; + diff --git a/core/server/plugins/index.js b/core/server/plugins/index.js new file mode 100644 index 0000000000..85f7b6d224 --- /dev/null +++ b/core/server/plugins/index.js @@ -0,0 +1,72 @@ + +var _ = require("underscore"), + when = require('when'), + ghostApi, + loader = require("./loader"), + GhostPlugin = require("./GhostPlugin"); + +function getInstalledPlugins() { + if (!ghostApi) { + ghostApi = require('../api'); + } + + return ghostApi.settings.read("installedPlugins").then(function (installed) { + installed = installed || "[]"; + + try { + installed = JSON.parse(installed.value); + } catch (e) { + return when.reject(e); + } + + return installed; + }); +} + +function saveInstalledPlugins(installedPlugins) { + return getInstalledPlugins().then(function (currentInstalledPlugins) { + var updatedPluginsInstalled = _.uniq(installedPlugins.concat(currentInstalledPlugins)); + + return ghostApi.settings.edit("installedPlugins", updatedPluginsInstalled); + }); +} + +module.exports = { + GhostPlugin: GhostPlugin, + + init: function (ghost, pluginsToLoad) { + // Grab all installed plugins, install any not already installed that are in pluginsToLoad. + return getInstalledPlugins().then(function (installedPlugins) { + var loadedPlugins = {}, + recordLoadedPlugin = function (name, loadedPlugin) { + // After loading the plugin, add it to our hash of loaded plugins + loadedPlugins[name] = loadedPlugin; + + return when.resolve(loadedPlugin); + }, + loadPromises = _.map(pluginsToLoad, function (plugin) { + // If already installed, just activate the plugin + if (_.contains(installedPlugins, plugin)) { + return loader.activatePluginByName(plugin, ghost).then(function (loadedPlugin) { + return recordLoadedPlugin(plugin, loadedPlugin); + }); + } + + // Install, then activate the plugin + return loader.installPluginByName(plugin, ghost).then(function () { + return loader.activatePluginByName(plugin, ghost); + }).then(function (loadedPlugin) { + return recordLoadedPlugin(plugin, loadedPlugin); + }); + }); + + return when.all(loadPromises).then(function () { + // Save our installed plugins to settings + return saveInstalledPlugins(_.keys(loadedPlugins)); + }).then(function () { + // Return the hash of all loaded plugins + return when.resolve(loadedPlugins); + }); + }); + } +}; \ No newline at end of file diff --git a/core/server/plugins/loader.js b/core/server/plugins/loader.js new file mode 100644 index 0000000000..31f140f56e --- /dev/null +++ b/core/server/plugins/loader.js @@ -0,0 +1,81 @@ + +var path = require("path"), + _ = require("underscore"), + when = require("when"), + ghostInstance, + pluginRootDirectory = path.join(process.cwd(), "content/plugins"), + loader; + +function getGhostInstance() { + if (ghostInstance) { + return ghostInstance; + } + + var Ghost = require("../../ghost"); + + ghostInstance = new Ghost(); + + return ghostInstance; +} + +function getPluginByName(name, ghost) { + ghost = ghost || getGhostInstance(); + + // Grab the plugin class to instantiate + var PluginClass = require(loader.getPluginRelativePath(name)), + plugin; + + // Check for an actual class, otherwise just use whatever was returned + if (_.isFunction(PluginClass)) { + plugin = new PluginClass(ghost); + } else { + plugin = PluginClass; + } + + return plugin; +} + +// The loader is responsible for loading plugins +loader = { + // Get a relative path to the given plugins root, defaults + // to be relative to __dirname + getPluginRelativePath: function (name, relativeTo) { + relativeTo = relativeTo || __dirname; + + return path.relative(relativeTo, path.join(pluginRootDirectory, name)); + }, + + // Load a plugin and return the instantiated plugin + installPluginByName: function (name, ghost) { + var plugin = getPluginByName(name, ghost); + + // Check for an install() method on the plugin. + if (!_.isFunction(plugin.install)) { + return when.reject(new Error("Error loading plugin named " + name + "; no install() method defined.")); + } + + // Wrapping the install() with a when because it's possible + // to not return a promise from it. + return when(plugin.install(ghost)).then(function () { + return when.resolve(plugin); + }); + }, + + // Activate a plugin and return it + activatePluginByName: function (name, ghost) { + var plugin = getPluginByName(name, ghost); + + // Check for an activate() method on the plugin. + if (!_.isFunction(plugin.activate)) { + return when.reject(new Error("Error loading plugin named " + name + "; no activate() method defined.")); + } + + // Wrapping the activate() with a when because it's possible + // to not return a promise from it. + return when(plugin.activate(ghost)).then(function () { + return when.resolve(plugin); + }); + } +}; + +module.exports = loader; \ No newline at end of file diff --git a/core/test/ghost/plugins_spec.js b/core/test/ghost/plugins_spec.js new file mode 100644 index 0000000000..c9cac95206 --- /dev/null +++ b/core/test/ghost/plugins_spec.js @@ -0,0 +1,108 @@ +/*globals describe, beforeEach, it*/ +var _ = require("underscore"), + when = require('when'), + should = require('should'), + sinon = require('sinon'), + errors = require('../../server/errorHandling'), + helpers = require('./helpers'), + plugins = require('../../server/plugins'), + GhostPlugin = plugins.GhostPlugin, + loader = require('../../server/plugins/loader') + +describe('Plugins', function () { + + before(function (done) { + helpers.resetData().then(function () { + done(); + }, done); + }); + + describe('GhostPlugin Class', function () { + + should.exist(GhostPlugin); + + it('sets app instance', function () { + var fakeGhost = {fake: true}, + plugin = new GhostPlugin(fakeGhost); + + plugin.app.should.equal(fakeGhost); + }); + + it('has default install, uninstall, activate and deactivate methods', function () { + var fakeGhost = {fake: true}, + plugin = new GhostPlugin(fakeGhost); + + _.isFunction(plugin.install).should.equal(true); + _.isFunction(plugin.uninstall).should.equal(true); + _.isFunction(plugin.activate).should.equal(true); + _.isFunction(plugin.deactivate).should.equal(true); + }); + }); + + describe('loader', function () { + + // TODO: These depend heavily on the FancyFirstChar plugin being present. + + it('can load FancyFirstChar by name and unload', function (done) { + var fancyPlugin = require("../../../content/plugins/FancyFirstChar"), + fakeGhost = { + registerFilter: function () { return; }, + unregisterFilter: function () { return; } + }, + installMock = sinon.stub(fancyPlugin, "install"), + uninstallMock = sinon.stub(fancyPlugin, "uninstall"), + registerMock = sinon.stub(fakeGhost, "registerFilter"), + unregisterMock = sinon.stub(fakeGhost, "unregisterFilter"); + + loader.installPluginByName("FancyFirstChar", fakeGhost).then(function (loadedPlugin) { + + should.exist(loadedPlugin); + + installMock.called.should.equal(true); + + loadedPlugin.activate(fakeGhost); + + // Registers the filter + registerMock.called.should.equal(true); + + loadedPlugin.deactivate(fakeGhost); + + // Unregisters the filter + unregisterMock.called.should.equal(true); + + loadedPlugin.uninstall(fakeGhost); + + done(); + }, done); + }); + }); + + it("can initialize an array of plugins", function (done) { + + var fakeGhost = { + registerFilter: function () { return; }, + unregisterFilter: function () { return; } + }, + installSpy = sinon.spy(loader, "installPluginByName"), + activateSpy = sinon.spy(loader, "activatePluginByName"); + + plugins.init(fakeGhost, ["FancyFirstChar"]).then(function (loadedPlugins) { + should.exist(loadedPlugins); + should.exist(loadedPlugins["FancyFirstChar"]); + + installSpy.called.should.equal(true); + activateSpy.called.should.equal(true); + + var api = require("../../server/api"); + + return api.settings.read("installedPlugins").then(function (setting) { + should.exist(setting); + + setting.value.should.equal('["FancyFirstChar"]') + + done(); + }); + }, done); + }); + +}); \ No newline at end of file diff --git a/core/test/unit/ghost_spec.js b/core/test/unit/ghost_spec.js index ecde0ec5fa..9759b5b56a 100644 --- a/core/test/unit/ghost_spec.js +++ b/core/test/unit/ghost_spec.js @@ -25,15 +25,9 @@ describe("Ghost API", function () { }); it("uses init() to initialize", function (done) { - var fakeDataProvider = { - init: function () { - return when.resolve(); - } - }, - dataProviderInitSpy = sinon.spy(fakeDataProvider, "init"), - oldDataProvider = ghost.dataProvider; - - ghost.dataProvider = fakeDataProvider; + var dataProviderInitMock = sinon.stub(ghost.dataProvider, "init", function () { + return when.resolve(); + }); should.not.exist(ghost.settings()); @@ -41,12 +35,12 @@ describe("Ghost API", function () { should.exist(ghost.settings()); - dataProviderInitSpy.called.should.equal(true); + dataProviderInitMock.called.should.equal(true); - ghost.dataProvider = oldDataProvider; + dataProviderInitMock.restore(); done(); - }).then(null, done); + }, done); });