summaryrefslogtreecommitdiffstats
path: root/lib/plugins2
diff options
context:
space:
mode:
authorSamy Pessé <samypesse@gmail.com>2016-04-22 11:00:21 +0200
committerSamy Pessé <samypesse@gmail.com>2016-04-22 11:00:21 +0200
commit4336fdb2414d460ffee68a0cc87c0cb0c85cf56e (patch)
tree279f711ab98666c892c19a7b9e4073a094f03f98 /lib/plugins2
parent87db7cf1d412fa6fbd18e9a7e4f4755f2c0c5547 (diff)
downloadgitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.zip
gitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.tar.gz
gitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.tar.bz2
Base
Diffstat (limited to 'lib/plugins2')
-rw-r--r--lib/plugins2/compatibility.js61
-rw-r--r--lib/plugins2/index.js188
-rw-r--r--lib/plugins2/plugin.js288
-rw-r--r--lib/plugins2/registry.js172
4 files changed, 709 insertions, 0 deletions
diff --git a/lib/plugins2/compatibility.js b/lib/plugins2/compatibility.js
new file mode 100644
index 0000000..77f4be2
--- /dev/null
+++ b/lib/plugins2/compatibility.js
@@ -0,0 +1,61 @@
+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 = book;
+
+ return ctx;
+}
+
+/*
+ Call a function "fn" with a context of page similar to the one in GitBook v2
+
+ @params {Page}
+ @returns {String|undefined} new content of the page
+*/
+function pageHook(page, fn) {
+ // Get page context
+ var ctx = page.getContext().page;
+
+ // Add other informations
+ ctx.type = page.type;
+ ctx.rawPath = page.rawPath;
+ ctx.path = page.path;
+
+ // Deprecate sections
+ error.deprecateField(ctx, 'sections', [
+ { content: ctx.content, type: 'normal' }
+ ], '"sections" property is deprecated, use page.content instead');
+
+ // Keep reference of original content for compatibility
+ var originalContent = ctx.content;
+
+ return fn(ctx)
+ .then(function(result) {
+ // No returned value
+ // Existing content will be used
+ if (!result) return undefined;
+
+ // GitBook 3
+ // Use returned page.content if different from original content
+ if (result.content != originalContent) {
+ return result.content;
+ }
+
+ // GitBook 2 compatibility
+ // Finally, use page.sections
+ if (result.sections) {
+ return _.pluck(result.sections, 'content').join('\n');
+ }
+ });
+}
+
+module.exports = {
+ pluginCtx: pluginCtx,
+ pageHook: pageHook
+};
diff --git a/lib/plugins2/index.js b/lib/plugins2/index.js
new file mode 100644
index 0000000..c6f1686
--- /dev/null
+++ b/lib/plugins2/index.js
@@ -0,0 +1,188 @@
+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);
+}
+
+// Returns the list of plugins
+PluginsManager.prototype.list = function() {
+ return this.plugins;
+};
+
+// 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 (could be a BookPlugin or {name,path})
+PluginsManager.prototype.load = function(plugin) {
+ var that = this;
+
+ if (_.isArray(plugin)) {
+ return Promise.serie(plugin, that.load);
+ }
+
+ return Promise()
+
+ // Initiate and load the plugin
+ .then(function() {
+ if (!(plugin instanceof BookPlugin)) {
+ plugin = new BookPlugin(that.book, plugin.name, plugin.path);
+ }
+
+ 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 that = this;
+ var pluginNames = _.pluck(this.book.config.get('plugins'), 'name');
+
+ return registry.list(this.book)
+ .then(function(plugins) {
+ // Filter out plugins not listed of first level
+ // (aka pre-installed plugins)
+ plugins = _.filter(plugins, function(plugin) {
+ return (
+ plugin.depth > 1 ||
+ _.contains(pluginNames, plugin.name)
+ );
+ });
+
+ // Sort plugins to match list in book.json
+ plugins.sort(function(a, b){
+ return pluginNames.indexOf(a.name) < pluginNames.indexOf(b.name) ? -1 : 1;
+ });
+
+ // Log state
+ that.log.info.ln(_.size(plugins) + ' are installed');
+ if (_.size(pluginNames) != _.size(plugins)) that.log.info.ln(_.size(pluginNames) + ' explicitly listed');
+
+ // Verify that all plugins are present
+ var notInstalled = _.filter(pluginNames, function(name) {
+ return !_.find(plugins, { name: name });
+ });
+
+ if (_.size(notInstalled) > 0) {
+ throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.');
+ }
+
+ // Load plugins
+ return that.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/plugins2/plugin.js b/lib/plugins2/plugin.js
new file mode 100644
index 0000000..d1c00d8
--- /dev/null
+++ b/lib/plugins2/plugin.js
@@ -0,0 +1,288 @@
+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, pluginFolder) {
+ this.book = book;
+ this.log = this.book.log.prefix(pluginId);
+
+
+ this.id = pluginId;
+ this.npmId = registry.npmId(pluginId);
+ this.root = pluginFolder;
+
+ 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 from its root folder
+BookPlugin.prototype.load = function(folder) {
+ var that = this;
+
+ if (this.isLoaded()) {
+ return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded'));
+ }
+
+ // Try loading plugins from different location
+ var p = Promise()
+ .then(function() {
+ // Locate plugin and load pacjage.json
+ try {
+ var res = resolve.sync('./package.json', { basedir: that.root });
+
+ that.root = path.dirname(res);
+ that.packageInfos = require(res);
+ } catch (err) {
+ if (!isModuleNotFound(err)) throw err;
+
+ that.packageInfos = undefined;
+ that.content = undefined;
+
+ return;
+ }
+
+ // Load plugin JS content
+ try {
+ that.content = require(that.root);
+ } 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
+ });
+ }
+ }
+ })
+
+ .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.isLoaded() &&
+ this.packageInfos &&
+ this.packageInfos.name &&
+ this.packageInfos.engines &&
+ this.packageInfos.engines.gitbook
+ );
+
+ if (!isValid) {
+ throw new Error('Error loading plugin "' + this.id + '" at "' + this.root + '"');
+ }
+
+ 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];
+
+ 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/plugins2/registry.js b/lib/plugins2/registry.js
new file mode 100644
index 0000000..fe9406d
--- /dev/null
+++ b/lib/plugins2/registry.js
@@ -0,0 +1,172 @@
+var npm = require('npm');
+var npmi = require('npmi');
+var path = require('path');
+var semver = require('semver');
+var _ = require('lodash');
+var readInstalled = require('read-installed');
+
+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 && 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');
+ });
+}
+
+// List all packages installed inside a folder
+// Returns an ordered list of plugins
+function listInstalled(folder) {
+ var options = {
+ dev: false,
+ log: function() {},
+ depth: 4
+ };
+ var results = [];
+
+ function onPackage(pkg, isRoot) {
+ if (!validateId(pkg.name)){
+ if (!isRoot) return;
+ } else {
+ results.push({
+ name: pluginId(pkg.name),
+ version: pkg.version,
+ path: pkg.realPath,
+ depth: pkg.depth
+ });
+ }
+
+ _.each(pkg.dependencies, function(dep) {
+ onPackage(dep);
+ });
+ }
+
+ return Promise.nfcall(readInstalled, folder, options)
+ .then(function(data) {
+ onPackage(data, true);
+ return _.uniq(results, 'name');
+ });
+}
+
+// List installed plugins for a book (defaults and installed)
+function listPlugins(book) {
+ return Promise.all([
+ listInstalled(path.resolve(__dirname, '../..')),
+ listInstalled(book.root),
+ book.originalRoot? listInstalled(book.originalRoot) : Promise([]),
+ book.isLanguageBook()? listInstalled(book.parent.root) : Promise([])
+ ])
+ .spread(function() {
+ var args = _.toArray(arguments);
+
+ var results = _.reduce(args, function(out, a) {
+ return out.concat(a);
+ }, []);
+
+ return _.uniq(results, 'name');
+ });
+}
+
+module.exports = {
+ npmId: npmId,
+ pluginId: pluginId,
+ validateId: validateId,
+
+ resolve: resolveVersion,
+ link: linkPlugin,
+ install: installPlugin,
+ list: listPlugins,
+ listInstalled: listInstalled
+};