diff options
Diffstat (limited to 'lib/plugins')
-rw-r--r-- | lib/plugins/__tests__/findInstalled.js | 16 | ||||
-rw-r--r-- | lib/plugins/__tests__/listAll.js | 71 | ||||
-rw-r--r-- | lib/plugins/__tests__/validatePlugin.js | 21 | ||||
-rw-r--r-- | lib/plugins/compatibility.js | 61 | ||||
-rw-r--r-- | lib/plugins/findForBook.js | 34 | ||||
-rw-r--r-- | lib/plugins/findInstalled.js | 87 | ||||
-rw-r--r-- | lib/plugins/index.js | 192 | ||||
-rw-r--r-- | lib/plugins/installPlugins.js | 146 | ||||
-rw-r--r-- | lib/plugins/listAll.js | 67 | ||||
-rw-r--r-- | lib/plugins/listBlocks.js | 17 | ||||
-rw-r--r-- | lib/plugins/listFilters.js | 17 | ||||
-rw-r--r-- | lib/plugins/listForBook.js | 18 | ||||
-rw-r--r-- | lib/plugins/listResources.js | 45 | ||||
-rw-r--r-- | lib/plugins/loadForBook.js | 57 | ||||
-rw-r--r-- | lib/plugins/loadPlugin.js | 83 | ||||
-rw-r--r-- | lib/plugins/plugin.js | 288 | ||||
-rw-r--r-- | lib/plugins/registry.js | 172 | ||||
-rw-r--r-- | lib/plugins/validateConfig.js | 71 | ||||
-rw-r--r-- | lib/plugins/validatePlugin.js | 34 |
19 files changed, 791 insertions, 706 deletions
diff --git a/lib/plugins/__tests__/findInstalled.js b/lib/plugins/__tests__/findInstalled.js new file mode 100644 index 0000000..93912d3 --- /dev/null +++ b/lib/plugins/__tests__/findInstalled.js @@ -0,0 +1,16 @@ +var path = require('path'); + +describe('findInstalled', function() { + var findInstalled = require('../findInstalled'); + + pit('must list default plugins for gitbook directory', function() { + return findInstalled(path.resolve(__dirname, '../../../')) + .then(function(plugins) { + expect(plugins.size > 7).toBeTruthy(); + + expect(plugins.has('fontsettings')).toBe(true); + expect(plugins.has('search')).toBe(true); + }); + }); + +}); diff --git a/lib/plugins/__tests__/listAll.js b/lib/plugins/__tests__/listAll.js new file mode 100644 index 0000000..71483a7 --- /dev/null +++ b/lib/plugins/__tests__/listAll.js @@ -0,0 +1,71 @@ +jest.autoMockOff(); + +describe('listAll', function() { + var listAll = require('../listAll'); + + it('must list from string', function() { + var plugins = listAll('ga,great'); + + expect(plugins.size).toBe(8); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + expect(plugins.has('search')).toBe(true); + }); + + it('must list from array', function() { + var plugins = listAll(['ga', 'great']); + + expect(plugins.size).toBe(8); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + expect(plugins.has('search')).toBe(true); + }); + + it('must parse version (semver)', function() { + var plugins = listAll(['ga@1.0.0', 'great@>=4.0.0']); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + var ga = plugins.get('ga'); + expect(ga.getVersion()).toBe('1.0.0'); + + var great = plugins.get('great'); + expect(great.getVersion()).toBe('>=4.0.0'); + }); + + it('must parse version (git)', function() { + var plugins = listAll(['ga@git+https://github.com/GitbookIO/plugin-ga.git', 'great@git+ssh://samy@github.com/GitbookIO/plugin-ga.git']); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + var ga = plugins.get('ga'); + expect(ga.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git'); + + var great = plugins.get('great'); + expect(great.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + + it('must list from array with -', function() { + var plugins = listAll(['ga', '-great']); + + expect(plugins.size).toBe(7); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(false); + }); + + it('must remove default plugins using -', function() { + var plugins = listAll(['ga', '-search']); + + expect(plugins.size).toBe(6); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('search')).toBe(false); + }); +}); diff --git a/lib/plugins/__tests__/validatePlugin.js b/lib/plugins/__tests__/validatePlugin.js new file mode 100644 index 0000000..3d50839 --- /dev/null +++ b/lib/plugins/__tests__/validatePlugin.js @@ -0,0 +1,21 @@ +jest.autoMockOff(); + +var Promise = require('../../utils/promise'); +var Plugin = require('../../models/plugin'); + + +describe('validatePlugin', function() { + var validatePlugin = require('../validatePlugin'); + + pit('must not validate a not loaded plugin', function() { + var plugin = Plugin.createFromString('test'); + + return validatePlugin(plugin) + .then(function() { + throw new Error('Should not be validate'); + }, function(err) { + return Promise(); + }); + }); + +}); diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js deleted file mode 100644 index 77f4be2..0000000 --- a/lib/plugins/compatibility.js +++ /dev/null @@ -1,61 +0,0 @@ -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/plugins/findForBook.js b/lib/plugins/findForBook.js new file mode 100644 index 0000000..14ccc05 --- /dev/null +++ b/lib/plugins/findForBook.js @@ -0,0 +1,34 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var findInstalled = require('./findInstalled'); + +/** + List all plugins installed in a book + + @param {Book} + @return {Promise<OrderedMap<String:Plugin>>} +*/ +function findForBook(book) { + return timing.measure( + 'plugins.findForBook', + + Promise.all([ + findInstalled(path.resolve(__dirname, '../..')), + findInstalled(book.getRoot()) + ]) + + // Merge all plugins + .then(function(results) { + return Immutable.List(results) + .reduce(function(out, result) { + return out.merge(result); + }, Immutable.OrderedMap()); + }) + ); +} + + +module.exports = findForBook; diff --git a/lib/plugins/findInstalled.js b/lib/plugins/findInstalled.js new file mode 100644 index 0000000..2259230 --- /dev/null +++ b/lib/plugins/findInstalled.js @@ -0,0 +1,87 @@ +var readInstalled = require('read-installed'); +var Immutable = require('immutable'); +var path = require('path'); + +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var Plugin = require('../models/plugin'); +var PREFIX = require('../constants/pluginPrefix'); + +/** + Validate if a package name is a GitBook plugin + + @return {Boolean} +*/ +function validateId(name) { + return name && name.indexOf(PREFIX) === 0; +} + + +/** + List all packages installed inside a folder + + @param {String} folder + @return {OrderedMap<String:Plugin>} +*/ +function findInstalled(folder) { + var options = { + dev: false, + log: function() {}, + depth: 4 + }; + var results = Immutable.OrderedMap(); + + function onPackage(pkg, isRoot) { + if (!pkg.name) return; + + var name = pkg.name; + var version = pkg.version; + var pkgPath = pkg.realPath; + var depth = pkg.depth; + var dependencies = pkg.dependencies; + + var pluginName = name.slice(PREFIX.length); + + if (!validateId(name)){ + if (!isRoot) return; + } else { + results = results.set(pluginName, Plugin({ + name: pluginName, + version: version, + path: pkgPath, + depth: depth + })); + } + + Immutable.Map(dependencies).forEach(function(dep) { + onPackage(dep); + }); + } + + // Search for gitbook-plugins in node_modules folder + var node_modules = path.join(folder, 'node_modules'); + + // List all folders in node_modules + return fs.readdir(node_modules) + .then(function(modules) { + return Promise.serie(modules, function(module) { + // Not a gitbook-plugin + if (!validateId(module)) { + return Promise(); + } + + // Read gitbook-plugin package details + var module_folder = path.join(node_modules, module); + return Promise.nfcall(readInstalled, module_folder, options) + .then(function(data) { + onPackage(data, true); + }); + }); + }) + .then(function() { + // Return installed plugins + return results; + }); +} + +module.exports = findInstalled; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index c6f1686..607a7f1 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,188 +1,10 @@ -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 = { + loadForBook: require('./loadForBook'), + validateConfig: require('./validateConfig'), + installPlugins: require('./installPlugins'), + listResources: require('./listResources'), + listBlocks: require('./listBlocks'), + listFilters: require('./listFilters') }; -module.exports = PluginsManager; diff --git a/lib/plugins/installPlugins.js b/lib/plugins/installPlugins.js new file mode 100644 index 0000000..05a5316 --- /dev/null +++ b/lib/plugins/installPlugins.js @@ -0,0 +1,146 @@ +var npm = require('npm'); +var npmi = require('npmi'); +var semver = require('semver'); +var Immutable = require('immutable'); + +var pkg = require('../../package.json'); +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +var Promise = require('../utils/promise'); +var Plugin = require('../models/plugin'); +var gitbook = require('../gitbook'); +var listForBook = require('./listForBook'); + +var npmIsReady; + +/** + Initialize and prepare NPM + + @return {Promise} +*/ +function initNPM() { + if (npmIsReady) return npmIsReady; + + npmIsReady = Promise.nfcall(npm.load, { + silent: true, + loglevel: 'silent' + }); + + return npmIsReady; +} + + + +/** + Resolve a plugin to a version + + @param {Plugin} + @return {Promise<String>} +*/ +function resolveVersion(plugin) { + var npmId = Plugin.nameToNpmID(plugin.getName()); + var requiredVersion = plugin.getVersion(); + + return initNPM() + .then(function() { + return Promise.nfcall(npm.commands.view, [npmId + '@' + requiredVersion, 'engines'], true); + }) + .then(function(versions) { + versions = Immutable.Map(versions).entrySeq(); + + var result = versions + .map(function(entry) { + return { + version: entry[0], + gitbook: (entry[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; + }) + .get(0); + + if (!result) { + return undefined; + } else { + return result.version; + } + }); +} + + +/** + Install a plugin for a book + + @param {Book} + @param {Plugin} + @return {Promise} +*/ +function installPlugin(book, plugin) { + var logger = book.getLogger(); + + var installFolder = book.getRoot(); + var name = plugin.getName(); + var requirement = plugin.getVersion(); + + logger.info.ln('installing plugin "' + name + '"'); + + // Find a version to install + return resolveVersion(plugin) + .then(function(version) { + if (!version) { + throw new Error('Found no satisfactory version for plugin "' + name + '" with requirement "' + requirement + '"'); + } + + logger.info.ln('install plugin "' + name +'" from NPM with version', requirement); + return Promise.nfcall(npmi, { + 'name': plugin.getNpmID(), + 'version': version, + 'path': installFolder, + 'npmLoad': { + 'loglevel': 'silent', + 'loaded': true, + 'prefix': installFolder + } + }); + }) + .then(function() { + logger.info.ok('plugin "' + name + '" installed with success'); + }); +} + + +/** + Install plugin requirements for a book + + @param {Book} + @return {Promise} +*/ +function installPlugins(book) { + var logger = book.getLogger(); + var plugins = listForBook(book); + + // Remove default plugins + // (only if version is same as installed) + plugins = plugins.filterNot(function(plugin) { + return ( + DEFAULT_PLUGINS.includes(plugin.getName()) && + plugin.getVersion() === pkg.dependencies[plugin.getNpmID()] + ); + }); + + if (plugins.size == 0) { + logger.info.ln('nothing to install!'); + return Promise(); + } + + logger.info.ln('installing', plugins.size, 'plugins'); + + return Promise.forEach(plugins, function(plugin) { + return installPlugin(book, plugin); + }); +} + +module.exports = installPlugins; diff --git a/lib/plugins/listAll.js b/lib/plugins/listAll.js new file mode 100644 index 0000000..65b8d7f --- /dev/null +++ b/lib/plugins/listAll.js @@ -0,0 +1,67 @@ +var is = require('is'); +var Immutable = require('immutable'); +var Plugin = require('../models/plugin'); + +var pkg = require('../../package.json'); +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); + +/** + List all plugins for a book + + @param {List<Plugin|String>} + @return {OrderedMap<Plugin>} +*/ +function listAll(plugins) { + if (is.string(plugins)) { + plugins = new Immutable.List(plugins.split(',')); + } + + // Convert to an ordered map + plugins = plugins.map(function(plugin) { + if (is.string(plugin)) { + plugin = Plugin.createFromString(plugin); + } else { + plugin = new Plugin(plugin); + } + + return [plugin.getName(), plugin]; + }); + plugins = Immutable.OrderedMap(plugins); + + // Extract list of plugins to disable (starting with -) + var toRemove = plugins.toList() + .filter(function(plugin) { + return plugin.getName()[0] === '-'; + }) + .map(function(plugin) { + return plugin.getName().slice(1); + }); + + // Remove the '-' + plugins = plugins.mapKeys(function(name) { + if (name[0] === '-') { + return name.slice(1); + } else { + return name; + } + }); + + // Append default plugins + DEFAULT_PLUGINS.forEach(function(pluginName) { + if (plugins.has(pluginName)) return; + + plugins = plugins.set(pluginName, new Plugin({ + name: pluginName, + version: pkg.dependencies[Plugin.nameToNpmID(pluginName)] + })); + }); + + // Remove plugins + plugins = plugins.filterNot(function(plugin, name) { + return toRemove.includes(name); + }); + + return plugins; +} + +module.exports = listAll; diff --git a/lib/plugins/listBlocks.js b/lib/plugins/listBlocks.js new file mode 100644 index 0000000..f738937 --- /dev/null +++ b/lib/plugins/listBlocks.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +/** + List blocks from a list of plugins + + @param {OrderedMap<String:Plugin>} + @return {Map<String:TemplateBlock>} +*/ +function listBlocks(plugins) { + return plugins + .reverse() + .reduce(function(result, plugin) { + return result.merge(plugin.getBlocks()); + }, Immutable.Map()); +} + +module.exports = listBlocks; diff --git a/lib/plugins/listFilters.js b/lib/plugins/listFilters.js new file mode 100644 index 0000000..4d8a471 --- /dev/null +++ b/lib/plugins/listFilters.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +/** + List filters from a list of plugins + + @param {OrderedMap<String:Plugin>} + @return {Map<String:Function>} +*/ +function listFilters(plugins) { + return plugins + .reverse() + .reduce(function(result, plugin) { + return result.merge(plugin.getFilters()); + }, Immutable.Map()); +} + +module.exports = listFilters; diff --git a/lib/plugins/listForBook.js b/lib/plugins/listForBook.js new file mode 100644 index 0000000..ce94678 --- /dev/null +++ b/lib/plugins/listForBook.js @@ -0,0 +1,18 @@ +var listAll = require('./listAll'); + +/** + List all plugin requirements for a book. + It can be different from the final list of plugins, + since plugins can have their own dependencies + + @param {Book} + @return {OrderedMap<Plugin>} +*/ +function listForBook(book) { + var config = book.getConfig(); + var plugins = config.getValue('plugins'); + + return listAll(plugins); +} + +module.exports = listForBook; diff --git a/lib/plugins/listResources.js b/lib/plugins/listResources.js new file mode 100644 index 0000000..4a73a2c --- /dev/null +++ b/lib/plugins/listResources.js @@ -0,0 +1,45 @@ +var Immutable = require('immutable'); +var path = require('path'); + +var LocationUtils = require('../utils/location'); +var PLUGIN_RESOURCES = require('../constants/pluginResources'); + +/** + List all resources from a list of plugins + + @param {OrderedMap<String:Plugin>} + @param {String} type + @return {Map<String:List<{url, path}>} +*/ +function listResources(plugins, type) { + return plugins.reduce(function(result, plugin) { + var npmId = plugin.getNpmID(); + var resources = plugin.getResources(type); + + PLUGIN_RESOURCES.forEach(function(resourceType) { + var assets = resources.get(resourceType); + if (!assets) return; + + var list = result.get(resourceType) || Immutable.List(); + + assets = assets.map(function(assetFile) { + if (LocationUtils.isExternal(assetFile)) { + return { + url: assetFile + }; + } else { + return { + path: LocationUtils.normalize(path.join(npmId, assetFile)) + }; + } + }); + + list = list.concat(assets); + result = result.set(resourceType, list); + }); + + return result; + }, Immutable.Map()); +} + +module.exports = listResources; diff --git a/lib/plugins/loadForBook.js b/lib/plugins/loadForBook.js new file mode 100644 index 0000000..c4acb5f --- /dev/null +++ b/lib/plugins/loadForBook.js @@ -0,0 +1,57 @@ +var Promise = require('../utils/promise'); + +var listForBook = require('./listForBook'); +var findForBook = require('./findForBook'); +var loadPlugin = require('./loadPlugin'); + + +/** + Load a list of plugins in a book + + @param {Book} + @return {Promise<Map<String:Plugin>} +*/ +function loadForBook(book) { + var logger = book.getLogger(); + var requirements = listForBook(book); + var requirementsKeys = requirements.keySeq().toList(); + + return findForBook(book) + .then(function(installed) { + // Filter out plugins not listed of first level + // (aka pre-installed plugins) + installed = installed.filter(function(plugin) { + return ( + plugin.getDepth() > 0 || + requirements.has(plugin.getName()) + ); + }); + + // Sort plugins to match list in book.json + installed = installed.sort(function(a, b){ + return requirementsKeys.indexOf(a.getName()) < requirementsKeys.indexOf(b.getName()) ? -1 : 1; + }); + + // Log state + logger.info.ln(installed.size + ' plugins are installed'); + if (requirements.size != installed.size) { + logger.info.ln(requirements.size + ' explicitly listed'); + } + + // Verify that all plugins are present + var notInstalled = requirementsKeys.filter(function(name) { + return !installed.has(name); + }); + + if (notInstalled.size > 0) { + throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.'); + } + + return Promise.map(installed, function(plugin) { + return loadPlugin(book, plugin); + }); + }); +} + + +module.exports = loadForBook; diff --git a/lib/plugins/loadPlugin.js b/lib/plugins/loadPlugin.js new file mode 100644 index 0000000..400146e --- /dev/null +++ b/lib/plugins/loadPlugin.js @@ -0,0 +1,83 @@ +var path = require('path'); +var resolve = require('resolve'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var timing = require('../utils/timing'); + +var validatePlugin = require('./validatePlugin'); + +// 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.code == 'MODULE_NOT_FOUND' || err.message.indexOf('Cannot find module') >= 0; +} + +/** + Load a plugin in a book + + @param {Book} book + @param {Plugin} plugin + @param {String} pkgPath (optional) + @return {Promise<Plugin>} +*/ +function loadPlugin(book, plugin) { + var logger = book.getLogger(); + + var name = plugin.getName(); + var pkgPath = plugin.getPath(); + + // Try loading plugins from different location + var p = Promise() + .then(function() { + var packageContent; + var content; + + // Locate plugin and load package.json + try { + var res = resolve.sync('./package.json', { basedir: pkgPath }); + + pkgPath = path.dirname(res); + packageContent = require(res); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + + packageContent = undefined; + content = undefined; + + return; + } + + // Load plugin JS content + try { + content = require(pkgPath); + } catch(err) { + // It's no big deal if the plugin doesn't have an "index.js" + // (For example: themes) + if (isModuleNotFound(err)) { + content = {}; + } else { + throw new error.PluginError(err, { + plugin: name + }); + } + } + + // Update plugin + return plugin.merge({ + 'package': Immutable.fromJS(packageContent), + 'content': Immutable.fromJS(content) + }); + }) + + .then(validatePlugin); + + p = timing.measure('plugin.load', p); + + logger.info('loading plugin "' + name + '"... '); + return logger.info.promise(p); +} + + +module.exports = loadPlugin; diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js deleted file mode 100644 index d1c00d8..0000000 --- a/lib/plugins/plugin.js +++ /dev/null @@ -1,288 +0,0 @@ -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/plugins/registry.js b/lib/plugins/registry.js deleted file mode 100644 index fe9406d..0000000 --- a/lib/plugins/registry.js +++ /dev/null @@ -1,172 +0,0 @@ -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 -}; diff --git a/lib/plugins/validateConfig.js b/lib/plugins/validateConfig.js new file mode 100644 index 0000000..37f3c96 --- /dev/null +++ b/lib/plugins/validateConfig.js @@ -0,0 +1,71 @@ +var Immutable = require('immutable'); +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); +var mergeDefaults = require('merge-defaults'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); + +/** + Validate one plugin for a book and update book's confiration + + @param {Book} + @param {Plugin} + @return {Book} +*/ +function validatePluginConfig(book, plugin) { + var config = book.getConfig(); + var packageInfos = plugin.getPackage(); + + var configKey = [ + 'pluginsConfig', + plugin.getName() + ].join('.'); + + var pluginConfig = config.getValue(configKey, {}).toJS(); + + var schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS(); + if (!schema) return book; + + // Normalize schema + schema.id = '/' + configKey; + schema.type = 'object'; + + // Validate and throw if invalid + var v = new jsonschema.Validator(); + var result = v.validate(pluginConfig, schema, { + propertyName: configKey + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + var defaults = jsonSchemaDefaults(schema); + pluginConfig = mergeDefaults(pluginConfig, defaults); + + + // Update configuration + config = config.setValue(configKey, pluginConfig); + + // Return new book + return book.set('config', config); +} + +/** + Validate a book configuration for plugins and + returns an update configuration with default values. + + @param {Book} + @param {OrderedMap<String:Plugin>} + @return {Promise<Book>} +*/ +function validateConfig(book, plugins) { + return Promise.reduce(plugins, function(newBook, plugin) { + return validatePluginConfig(newBook, plugin); + }, book); +} + +module.exports = validateConfig; diff --git a/lib/plugins/validatePlugin.js b/lib/plugins/validatePlugin.js new file mode 100644 index 0000000..4baa911 --- /dev/null +++ b/lib/plugins/validatePlugin.js @@ -0,0 +1,34 @@ +var gitbook = require('../gitbook'); + +var Promise = require('../utils/promise'); + +/** + Validate a plugin + + @param {Plugin} + @return {Promise<Plugin>} +*/ +function validatePlugin(plugin) { + var packageInfos = plugin.getPackage(); + + var isValid = ( + plugin.isLoaded() && + packageInfos && + packageInfos.get('name') && + packageInfos.get('engines') && + packageInfos.get('engines').get('gitbook') + ); + + if (!isValid) { + return Promise.reject(new Error('Error loading plugin "' + plugin.getName() + '" at "' + plugin.getPath() + '"')); + } + + var engine = packageInfos.get('engines').get('gitbook'); + if (!gitbook.satisfies(engine)) { + return Promise.reject(new Error('GitBook doesn\'t satisfy the requirements of this plugin: ' + engine)); + } + + return Promise(plugin); +} + +module.exports = validatePlugin; |