summaryrefslogtreecommitdiffstats
path: root/lib/plugins
diff options
context:
space:
mode:
authorSamy Pessé <samypesse@gmail.com>2016-02-26 09:41:26 +0100
committerSamy Pessé <samypesse@gmail.com>2016-02-26 09:41:26 +0100
commitd3d64f636c859f7f01a64f7774cf70bd8ccdc562 (patch)
tree4f7731f37c3a793d187b0ab1cd77680e69534c6c /lib/plugins
parent4cb9cbb5ae3aa8f9211ffa3ac5e3d34232c0ca4f (diff)
parenteef072693b17526347c37b66078a5059c71caa31 (diff)
downloadgitbook-d3d64f636c859f7f01a64f7774cf70bd8ccdc562.zip
gitbook-d3d64f636c859f7f01a64f7774cf70bd8ccdc562.tar.gz
gitbook-d3d64f636c859f7f01a64f7774cf70bd8ccdc562.tar.bz2
Merge pull request #1109 from GitbookIO/3.0.0
Version 3.0.0
Diffstat (limited to 'lib/plugins')
-rw-r--r--lib/plugins/compatibility.js57
-rw-r--r--lib/plugins/index.js155
-rw-r--r--lib/plugins/plugin.js300
-rw-r--r--lib/plugins/registry.js115
4 files changed, 627 insertions, 0 deletions
diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js
new file mode 100644
index 0000000..7ad35a9
--- /dev/null
+++ b/lib/plugins/compatibility.js
@@ -0,0 +1,57 @@
+var _ = require('lodash');
+var error = require('../utils/error');
+
+/*
+ Return the context for a plugin.
+ It tries to keep compatibilities with GitBook v2
+*/
+function pluginCtx(plugin) {
+ var book = plugin.book;
+ var ctx = {
+ config: book.config,
+ log: plugin.log,
+
+ // Paths
+ resolve: book.resolve
+ };
+
+ // Deprecation
+ error.deprecateField(ctx, 'options', book.config.dump(), '"options" property is deprecated, use config.get(key) instead');
+
+ // Loop for template filters/blocks
+ error.deprecateField(ctx, 'book', ctx, '"book" property is deprecated, use "this" directly instead');
+
+ return ctx;
+}
+
+// Call a function "fn" with a context of page similar to the one in GitBook v2
+function pageHook(page, fn) {
+ var ctx = {
+ type: page.type,
+ content: page.content,
+ path: page.path,
+ rawPath: page.rawPath
+ };
+
+ // Deprecate sections
+ error.deprecateField(ctx, 'sections', [
+ { content: ctx.content }
+ ], '"sections" property is deprecated, use page.content instead');
+
+ return fn(ctx)
+ .then(function(result) {
+ if (!result) return undefined;
+ if (result.content) {
+ return result.content;
+ }
+
+ if (result.sections) {
+ return _.pluck(result.sections, 'content').join('\n');
+ }
+ });
+}
+
+module.exports = {
+ pluginCtx: pluginCtx,
+ pageHook: pageHook
+};
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
new file mode 100644
index 0000000..8280542
--- /dev/null
+++ b/lib/plugins/index.js
@@ -0,0 +1,155 @@
+var _ = require('lodash');
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var BookPlugin = require('./plugin');
+var registry = require('./registry');
+var pluginsConfig = require('../config/plugins');
+
+/*
+PluginsManager is an interface to work with multiple plugins at once:
+- Extract assets from plugins
+- Call hooks for all plugins, etc
+*/
+
+function PluginsManager(book) {
+ this.book = book;
+ this.log = this.book.log;
+ this.plugins = [];
+
+ _.bindAll(this);
+}
+
+// Return count of plugins loaded
+PluginsManager.prototype.count = function() {
+ return _.size(this.plugins);
+};
+
+// Returns a plugin by its name
+PluginsManager.prototype.get = function(name) {
+ return _.find(this.plugins, {
+ id: name
+ });
+};
+
+// Load a plugin, or a list of plugins
+PluginsManager.prototype.load = function(name) {
+ var that = this;
+
+ if (_.isArray(name)) {
+ return Promise.serie(name, function(_name) {
+ return that.load(_name);
+ });
+ }
+
+ return Promise()
+
+ // Initiate and load the plugin
+ .then(function() {
+ var plugin;
+
+ if (!_.isString(name)) plugin = name;
+ else plugin = new BookPlugin(that.book, name);
+
+ if (that.get(plugin.id)) {
+ throw new Error('Plugin "'+plugin.id+'" is already loaded');
+ }
+
+
+ if (plugin.isLoaded()) return plugin;
+ else return plugin.load()
+ .thenResolve(plugin);
+ })
+
+ // Setup the plugin
+ .then(this._setup);
+};
+
+// Load all plugins from the book's configuration
+PluginsManager.prototype.loadAll = function() {
+ var plugins = _.pluck(this.book.config.get('plugins'), 'name');
+
+ this.log.info.ln('loading', plugins.length, 'plugins');
+ return this.load(plugins);
+};
+
+// Setup a plugin
+// Register its filter, blocks, etc
+PluginsManager.prototype._setup = function(plugin) {
+ this.plugins.push(plugin);
+};
+
+// Install all plugins for the book
+PluginsManager.prototype.install = function() {
+ var that = this;
+ var plugins = _.filter(this.book.config.get('plugins'), function(plugin) {
+ return !pluginsConfig.isDefaultPlugin(plugin.name);
+ });
+
+ if (plugins.length == 0) {
+ this.log.info.ln('nothing to install!');
+ return Promise(0);
+ }
+
+ this.log.info.ln('installing', plugins.length, 'plugins');
+
+ return Promise.serie(plugins, function(plugin) {
+ return registry.install(that.book, plugin.name, plugin.version);
+ })
+ .thenResolve(plugins.length);
+};
+
+// Call a hook on all plugins to transform an input
+PluginsManager.prototype.hook = function(name, input) {
+ return Promise.reduce(this.plugins, function(current, plugin) {
+ return plugin.hook(name, current);
+ }, input);
+};
+
+// Extract all resources for a namespace
+PluginsManager.prototype.getResources = function(namespace) {
+ return Promise.reduce(this.plugins, function(out, plugin) {
+ return plugin.getResources(namespace)
+ .then(function(pluginResources) {
+ _.each(BookPlugin.RESOURCES, function(resourceType) {
+ out[resourceType] = (out[resourceType] || []).concat(pluginResources[resourceType] || []);
+ });
+
+ return out;
+ });
+ }, {});
+};
+
+// Copy all resources for a plugin
+PluginsManager.prototype.copyResources = function(namespace, outputRoot) {
+ return Promise.serie(this.plugins, function(plugin) {
+ return plugin.getResources(namespace)
+ .then(function(resources) {
+ if (!resources.assets) return;
+
+ var input = path.resolve(plugin.root, resources.assets);
+ var output = path.resolve(outputRoot, plugin.npmId);
+
+ return fs.copyDir(input, output);
+ });
+ });
+};
+
+// Get all filters and blocks
+PluginsManager.prototype.getFilters = function() {
+ return _.reduce(this.plugins, function(out, plugin) {
+ var filters = plugin.getFilters();
+
+ return _.extend(out, filters);
+ }, {});
+};
+PluginsManager.prototype.getBlocks = function() {
+ return _.reduce(this.plugins, function(out, plugin) {
+ var blocks = plugin.getBlocks();
+
+ return _.extend(out, blocks);
+ }, {});
+};
+
+module.exports = PluginsManager;
diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js
new file mode 100644
index 0000000..f678111
--- /dev/null
+++ b/lib/plugins/plugin.js
@@ -0,0 +1,300 @@
+var _ = require('lodash');
+var path = require('path');
+var url = require('url');
+var resolve = require('resolve');
+var mergeDefaults = require('merge-defaults');
+var jsonschema = require('jsonschema');
+var jsonSchemaDefaults = require('json-schema-defaults');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var gitbook = require('../gitbook');
+var registry = require('./registry');
+var compatibility = require('./compatibility');
+
+var HOOKS = [
+ 'init', 'finish', 'finish:before', 'config', 'page', 'page:before'
+];
+
+var RESOURCES = ['js', 'css'];
+
+// Return true if an error is a "module not found"
+// Wait on https://github.com/substack/node-resolve/pull/81 to be merged
+function isModuleNotFound(err) {
+ return err.message.indexOf('Cannot find module') >= 0;
+}
+
+function BookPlugin(book, pluginId) {
+ this.book = book;
+ this.log = this.book.log.prefix(pluginId);
+
+ this.id = pluginId;
+ this.npmId = registry.npmId(pluginId);
+ this.root;
+
+ this.packageInfos = undefined;
+ this.content = undefined;
+
+ // Cache for resources
+ this._resources = {};
+
+ _.bindAll(this);
+}
+
+// Return true if plugin has been loaded correctly
+BookPlugin.prototype.isLoaded = function() {
+ return Boolean(this.packageInfos && this.content);
+};
+
+// Bind a function to the plugin's context
+BookPlugin.prototype.bind = function(fn) {
+ return fn.bind(compatibility.pluginCtx(this));
+};
+
+// Load this plugin
+// An optional folder to search in can be passed
+BookPlugin.prototype.load = function(folder) {
+ var that = this;
+
+ if (this.isLoaded()) {
+ return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded'));
+ }
+
+ // Fodlers to search plugins in
+ var searchPaths = _.compact([
+ folder,
+ this.book.resolve('node_modules'),
+ __dirname
+ ]);
+
+ // Try loading plugins from different location
+ var p = Promise.some(searchPaths, function(baseDir) {
+ // Locate plugin and load pacjage.json
+ try {
+ var res = resolve.sync(that.npmId + '/package.json', { basedir: baseDir });
+
+ that.root = path.dirname(res);
+ that.packageInfos = require(res);
+ } catch (err) {
+ if (!isModuleNotFound(err)) throw err;
+
+ that.packageInfos = undefined;
+ that.content = undefined;
+
+ return false;
+ }
+
+ // Load plugin JS content
+ try {
+ that.content = require(resolve.sync(that.npmId, { basedir: baseDir }));
+ } catch(err) {
+ // It's no big deal if the plugin doesn't have an "index.js"
+ // (For example: themes)
+ if (isModuleNotFound(err)) {
+ that.content = {};
+ } else {
+ throw new error.PluginError(err, {
+ plugin: that.id
+ });
+ }
+ }
+
+ return true;
+ })
+
+ .then(that.validate)
+
+ // Validate the configuration and update it
+ .then(function() {
+ var config = that.book.config.get(that.getConfigKey(), {});
+ return that.validateConfig(config);
+ })
+ .then(function(config) {
+ that.book.config.set(that.getConfigKey(), config);
+ });
+
+ this.log.info('loading plugin "' + this.id + '"... ');
+ return this.log.info.promise(p);
+};
+
+// Verify the definition of a plugin
+// Also verify that the plugin accepts the current gitbook version
+// This method throws erros if plugin is invalid
+BookPlugin.prototype.validate = function() {
+ var isValid = (
+ this.packageInfos &&
+ this.packageInfos.name &&
+ this.packageInfos.engines &&
+ this.packageInfos.engines.gitbook
+ );
+
+ if (!this.isLoaded()) {
+ throw new Error('Couldn\'t locate plugin "' + this.id + '", Run \'gitbook install\' to install plugins from registry.');
+ }
+
+ if (!isValid) {
+ throw new Error('Invalid plugin "' + this.id + '"');
+ }
+
+ if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) {
+ throw new Error('GitBook doesn\'t satisfy the requirements of this plugin: '+this.packageInfos.engines.gitbook);
+ }
+};
+
+// Normalize, validate configuration for this plugin using its schema
+// Throw an error when shcema is not respected
+BookPlugin.prototype.validateConfig = function(config) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var schema = that.packageInfos.gitbook || {};
+ if (!schema) return config;
+
+ // Normalize schema
+ schema.id = '/'+that.getConfigKey();
+ schema.type = 'object';
+
+ // Validate and throw if invalid
+ var v = new jsonschema.Validator();
+ var result = v.validate(config, schema, {
+ propertyName: that.getConfigKey()
+ });
+
+ // Throw error
+ if (result.errors.length > 0) {
+ throw new error.ConfigurationError(new Error(result.errors[0].stack));
+ }
+
+ // Insert default values
+ var defaults = jsonSchemaDefaults(schema);
+ return mergeDefaults(config, defaults);
+ });
+};
+
+// Return key for configuration
+BookPlugin.prototype.getConfigKey = function() {
+ return 'pluginsConfig.'+this.id;
+};
+
+// Call a hook and returns its result
+BookPlugin.prototype.hook = function(name, input) {
+ var that = this;
+ var hookFunc = this.content.hooks? this.content.hooks[name] : null;
+ input = input || {};
+
+ if (!hookFunc) return Promise(input);
+
+ this.book.log.debug.ln('call hook "' + name + '" for plugin "' + this.id + '"');
+ if (!_.contains(HOOKS, name)) {
+ this.book.log.warn.ln('hook "'+name+'" used by plugin "'+this.name+'" is deprecated, and will be removed in the coming versions');
+ }
+
+ return Promise()
+ .then(function() {
+ return that.bind(hookFunc)(input);
+ });
+};
+
+// Return resources without normalization
+BookPlugin.prototype._getResources = function(base) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ if (that._resources[base]) return that._resources[base];
+
+ base = base;
+ var book = that.content[base];
+
+ // Compatibility with version 1.x.x
+ if (base == 'website') book = book || that.content.book;
+
+ // Nothing specified, fallback to default
+ if (!book) {
+ return Promise({});
+ }
+
+ // Dynamic function
+ if(typeof book === 'function') {
+ // Call giving it the context of our book
+ return that.bind(book)();
+ }
+
+ // Plain data object
+ return book;
+ })
+
+ .then(function(resources) {
+ that._resources[base] = resources;
+ return _.cloneDeep(resources);
+ });
+};
+
+// Normalize a specific resource
+BookPlugin.prototype.normalizeResource = function(resource) {
+ // Parse the resource path
+ var parsed = url.parse(resource);
+
+ // This is a remote resource
+ // so we will simply link to using it's URL
+ if (parsed.protocol) {
+ return {
+ 'url': resource
+ };
+ }
+
+ // This will be copied over from disk
+ // and shipped with the book's build
+ return { 'path': this.npmId+'/'+resource };
+};
+
+
+// Normalize resources and return them
+BookPlugin.prototype.getResources = function(base) {
+ var that = this;
+
+ return this._getResources(base)
+ .then(function(resources) {
+ _.each(RESOURCES, function(resourceType) {
+ resources[resourceType] = _.map(resources[resourceType] || [], that.normalizeResource);
+ });
+
+ return resources;
+ });
+};
+
+// Normalize filters and return them
+BookPlugin.prototype.getFilters = function() {
+ var that = this;
+
+ return _.mapValues(this.content.filters || {}, function(fn, filter) {
+ return function() {
+ var ctx = _.extend(compatibility.pluginCtx(that), this);
+
+ return fn.apply(ctx, arguments);
+ };
+ });
+};
+
+// Normalize blocks and return them
+BookPlugin.prototype.getBlocks = function() {
+ var that = this;
+
+ return _.mapValues(this.content.blocks || {}, function(block, blockName) {
+ block = _.isFunction(block)? { process: block } : block;
+
+ var fn = block.process;
+ block.process = function() {
+ var ctx = _.extend(compatibility.pluginCtx(that), this);
+
+ return fn.apply(ctx, arguments);
+ };
+
+ return block;
+ });
+};
+
+module.exports = BookPlugin;
+module.exports.RESOURCES = RESOURCES;
+
diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js
new file mode 100644
index 0000000..837c9b5
--- /dev/null
+++ b/lib/plugins/registry.js
@@ -0,0 +1,115 @@
+var npm = require('npm');
+var npmi = require('npmi');
+var semver = require('semver');
+var _ = require('lodash');
+
+var Promise = require('../utils/promise');
+var gitbook = require('../gitbook');
+
+var PLUGIN_PREFIX = 'gitbook-plugin-';
+
+// Return an absolute name for the plugin (the one on NPM)
+function npmId(name) {
+ if (name.indexOf(PLUGIN_PREFIX) === 0) return name;
+ return [PLUGIN_PREFIX, name].join('');
+}
+
+// Return a plugin ID 9the one on GitBook
+function pluginId(name) {
+ return name.replace(PLUGIN_PREFIX, '');
+}
+
+// Validate an NPM plugin ID
+function validateId(name) {
+ return name.indexOf(PLUGIN_PREFIX) === 0;
+}
+
+// Initialize NPM for operations
+var initNPM = _.memoize(function() {
+ return Promise.nfcall(npm.load, {
+ silent: true,
+ loglevel: 'silent'
+ });
+});
+
+// Link a plugin for use in a specific book
+function linkPlugin(book, pluginPath) {
+ book.log('linking', pluginPath);
+}
+
+// Resolve the latest version for a plugin
+function resolveVersion(plugin) {
+ var npnName = npmId(plugin);
+
+ return initNPM()
+ .then(function() {
+ return Promise.nfcall(npm.commands.view, [npnName+'@*', 'engines'], true);
+ })
+ .then(function(versions) {
+ return _.chain(versions)
+ .pairs()
+ .map(function(v) {
+ return {
+ version: v[0],
+ gitbook: (v[1].engines || {}).gitbook
+ };
+ })
+ .filter(function(v) {
+ return v.gitbook && gitbook.satisfies(v.gitbook);
+ })
+ .sort(function(v1, v2) {
+ return semver.lt(v1.version, v2.version)? 1 : -1;
+ })
+ .pluck('version')
+ .first()
+ .value();
+ });
+}
+
+
+// Install a plugin in a book
+function installPlugin(book, plugin, version) {
+ book.log.info.ln('installing plugin', plugin);
+
+ var npnName = npmId(plugin);
+
+ return Promise()
+ .then(function() {
+ if (version) return version;
+
+ book.log.info.ln('No version specified, resolve plugin "' + plugin + '"');
+ return resolveVersion(plugin);
+ })
+
+ // Install the plugin with the resolved version
+ .then(function(version) {
+ if (!version) {
+ throw new Error('Found no satisfactory version for plugin "' + plugin + '"');
+ }
+
+ book.log.info.ln('install plugin' + plugin +'" from npm ('+npnName+') with version', version);
+ return Promise.nfcall(npmi, {
+ 'name': npnName,
+ 'version': version,
+ 'path': book.root,
+ 'npmLoad': {
+ 'loglevel': 'silent',
+ 'loaded': true,
+ 'prefix': book.root
+ }
+ });
+ })
+ .then(function() {
+ book.log.info.ok('plugin "' + plugin + '" installed with success');
+ });
+}
+
+module.exports = {
+ npmId: npmId,
+ pluginId: pluginId,
+ validateId: validateId,
+
+ resolve: resolveVersion,
+ link: linkPlugin,
+ install: installPlugin
+};