Merge pull request #267 from jgable/pluginApi

Initial Plugin API Implementation
This commit is contained in:
Hannah Wolfe 2013-08-05 12:29:19 -07:00
commit 1815656778
10 changed files with 444 additions and 83 deletions

View File

@ -41,7 +41,7 @@ config.activeTheme = 'casper';
config.activePlugins = [
'fancyFirstChar.js'
'FancyFirstChar'
];
// Default Navigation Items

View File

@ -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 += '<span class="fancyChar">';
newContent += originalContent.substr(firstCharIndex, 1);
newContent += '</span>';
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;

View File

@ -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 += '<span class="fancyChar">';
newContent += originalContent.substr(firstCharIndex, 1);
newContent += '</span>';
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;

View File

@ -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) {

View File

@ -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);
}
};

View File

@ -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;

View File

@ -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);
});
});
}
};

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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);
});