diff options
author | Samy Pessé <samypesse@gmail.com> | 2016-04-22 11:00:21 +0200 |
---|---|---|
committer | Samy Pessé <samypesse@gmail.com> | 2016-04-22 11:00:21 +0200 |
commit | 4336fdb2414d460ffee68a0cc87c0cb0c85cf56e (patch) | |
tree | 279f711ab98666c892c19a7b9e4073a094f03f98 /lib/plugins2 | |
parent | 87db7cf1d412fa6fbd18e9a7e4f4755f2c0c5547 (diff) | |
download | gitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.zip gitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.tar.gz gitbook-4336fdb2414d460ffee68a0cc87c0cb0c85cf56e.tar.bz2 |
Base
Diffstat (limited to 'lib/plugins2')
-rw-r--r-- | lib/plugins2/compatibility.js | 61 | ||||
-rw-r--r-- | lib/plugins2/index.js | 188 | ||||
-rw-r--r-- | lib/plugins2/plugin.js | 288 | ||||
-rw-r--r-- | lib/plugins2/registry.js | 172 |
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 +}; |