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 92322e51f1..0c00782c23 100644
--- a/core/server/api.js
+++ b/core/server/api.js
@@ -104,6 +104,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);
@@ -112,9 +116,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);
});