diff options
-rw-r--r-- | docs/themes/README.md | 4 | ||||
-rw-r--r-- | lib/output/json.js | 1 | ||||
-rw-r--r-- | lib/output/website.js | 61 | ||||
-rw-r--r-- | lib/plugins/index.js | 54 | ||||
-rw-r--r-- | lib/plugins/plugin.js | 33 | ||||
-rw-r--r-- | lib/plugins/registry.js | 54 | ||||
-rw-r--r-- | lib/template/fs-loader.js | 61 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | test/output-website.js | 25 | ||||
-rw-r--r-- | test/plugins.js | 16 |
10 files changed, 215 insertions, 97 deletions
diff --git a/docs/themes/README.md b/docs/themes/README.md index c013b57..59e684c 100644 --- a/docs/themes/README.md +++ b/docs/themes/README.md @@ -2,13 +2,11 @@ Since version 3.0.0, GitBook can be easily themed. Books are using by default the [theme-default](https://github.com/GitbookIO/theme-default). -The theme to use is specified in the [book's configuration](../config.md) using key `theme`. - > **Caution**: Custom theming can block some plugins from working correctly. ### Structure of a theme -A theme is a folder containing templates and assets. All the templates are optionnal, since theme are always extending the default theme. +A theme is a plugin containing templates and assets. All the templates are optionnal, since theme are always extending the default theme. | Folder | Description | | -------- | ----------- | diff --git a/lib/output/json.js b/lib/output/json.js index b66e593..7061141 100644 --- a/lib/output/json.js +++ b/lib/output/json.js @@ -1,4 +1,3 @@ -var _ = require('lodash'); var conrefsLoader = require('./conrefs'); var JSONOutput = conrefsLoader(); diff --git a/lib/output/website.js b/lib/output/website.js index e298b69..43f732c 100644 --- a/lib/output/website.js +++ b/lib/output/website.js @@ -8,13 +8,10 @@ var Promise = require('../utils/promise'); var location = require('../utils/location'); var fs = require('../utils/fs'); var defaultFilters = require('../template/filters'); +var FSLoader = require('../template/fs-loader'); var conrefsLoader = require('./conrefs'); var Output = require('./base'); -// Tranform a theme ID into a plugin -function themeID(plugin) { - return 'theme-' + plugin; -} // Directory for a theme with the templates function templatesPath(dir) { @@ -57,32 +54,11 @@ WebsiteOutput.prototype.prepare = function() { }) .then(function() { - var themeName = that.book.config.get('theme'); - that.theme = that.plugins.get(themeID(themeName)); - that.themeDefault = that.plugins.get(themeID('default')); - - if (!that.theme) { - throw new Error('Theme "' + themeName + '" is not installed, add "' + themeID(themeName) + '" to your "book.json"'); - } - - if (that.themeDefault.root != that.theme.root) { - that.log.info.ln('build using theme "' + themeName + '"'); - } - // This list is ordered to give priority to templates in the book - var searchPaths = _.chain([ - // The book itself can contains a "_layouts" folder - that.book.root, - - // Installed plugin (it can be identical to themeDefault.root) - that.theme.root, + var searchPaths = _.pluck(that.plugins.list(), 'root'); - // Is default theme still installed - that.themeDefault? that.themeDefault.root : null - ]) - .compact() - .uniq() - .value(); + // The book itself can contains a "_layouts" folder + searchPaths.unshift(that.book.root); // Load i18n _.each(searchPaths.concat().reverse(), function(searchPath) { @@ -92,7 +68,7 @@ WebsiteOutput.prototype.prepare = function() { that.i18n.load(i18nRoot); }); - that.env = new nunjucks.Environment(new nunjucks.FileSystemLoader(_.map(searchPaths, templatesPath))); + that.env = new nunjucks.Environment(new FSLoader(_.map(searchPaths, templatesPath))); // Add GitBook default filters _.each(defaultFilters, function(fn, filter) { @@ -142,21 +118,11 @@ WebsiteOutput.prototype.prepare = function() { .then(function() { if (that.book.isLanguageBook()) return; - return Promise.serie([ - // Assets from the book are already copied - // The order is reversed from the template's one - - // Is default theme still installed - that.themeDefault && that.themeDefault.root != that.theme.root? - that.themeDefault.root : null, - - // Installed plugin (it can be identical to themeDefault.root) - that.theme.root - ], function(folder) { - if (!folder) return; - + // Assets from the book are already copied + // Copy assets from plugins + return Promise.serie(that.plugins.list(), function(plugin) { // Copy assets only if exists (don't fail otherwise) - var assetFolder = path.join(folder, '_assets', that.name); + var assetFolder = path.join(plugin.root, '_assets', that.name); if (!fs.existsSync(assetFolder)) return; that.log.debug.ln('copy assets from theme', assetFolder); @@ -164,7 +130,7 @@ WebsiteOutput.prototype.prepare = function() { assetFolder, that.resolve('gitbook'), { - deleteFirst: false, // Delete "to" before + deleteFirst: false, overwrite: true, confirm: true } @@ -243,13 +209,10 @@ WebsiteOutput.prototype.outputMultilingualIndex = function() { // Templates are stored in `_layouts` folders WebsiteOutput.prototype.render = function(tpl, context) { var filename = this.templateName(tpl); + context = _.extend(context, { template: { - // Same template but in the default theme - default: this.themeDefault? path.resolve(templatesPath(this.themeDefault.root), filename) : null, - - // Same template but in the theme - theme: path.resolve(templatesPath(this.theme.root), filename) + self: filename }, plugins: { diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 8280542..f897d9c 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -21,6 +21,11 @@ function PluginsManager(book) { _.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); @@ -33,24 +38,21 @@ PluginsManager.prototype.get = function(name) { }); }; -// Load a plugin, or a list of plugins -PluginsManager.prototype.load = function(name) { +// Load a plugin (could be a BookPlugin or {name,path}) +PluginsManager.prototype.load = function(plugin) { var that = this; - if (_.isArray(name)) { - return Promise.serie(name, function(_name) { - return that.load(_name); - }); + if (_.isArray(plugin)) { + return Promise.serie(plugin, that.load); } return Promise() // Initiate and load the plugin .then(function() { - var plugin; - - if (!_.isString(name)) plugin = name; - else plugin = new BookPlugin(that.book, name); + 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'); @@ -68,10 +70,36 @@ PluginsManager.prototype.load = function(name) { // Load all plugins from the book's configuration PluginsManager.prototype.loadAll = function() { - var plugins = _.pluck(this.book.config.get('plugins'), 'name'); + 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) + ); + }); - this.log.info.ln('loading', plugins.length, 'plugins'); - return this.load(plugins); + // 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 diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js index d707e5c..d1c00d8 100644 --- a/lib/plugins/plugin.js +++ b/lib/plugins/plugin.js @@ -24,13 +24,14 @@ function isModuleNotFound(err) { return err.message.indexOf('Cannot find module') >= 0; } -function BookPlugin(book, pluginId) { +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; + this.root = pluginFolder; this.packageInfos = undefined; this.content = undefined; @@ -51,8 +52,7 @@ BookPlugin.prototype.bind = function(fn) { return fn.bind(compatibility.pluginCtx(this)); }; -// Load this plugin -// An optional folder to search in can be passed +// Load this plugin from its root folder BookPlugin.prototype.load = function(folder) { var that = this; @@ -60,18 +60,12 @@ BookPlugin.prototype.load = function(folder) { 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) { + var p = Promise() + .then(function() { // Locate plugin and load pacjage.json try { - var res = resolve.sync(that.npmId + '/package.json', { basedir: baseDir }); + var res = resolve.sync('./package.json', { basedir: that.root }); that.root = path.dirname(res); that.packageInfos = require(res); @@ -81,12 +75,12 @@ BookPlugin.prototype.load = function(folder) { that.packageInfos = undefined; that.content = undefined; - return false; + return; } // Load plugin JS content try { - that.content = require(resolve.sync(that.npmId, { basedir: baseDir })); + that.content = require(that.root); } catch(err) { // It's no big deal if the plugin doesn't have an "index.js" // (For example: themes) @@ -98,8 +92,6 @@ BookPlugin.prototype.load = function(folder) { }); } } - - return true; }) .then(that.validate) @@ -122,18 +114,15 @@ BookPlugin.prototype.load = function(folder) { // 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 (!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 + '"'); + throw new Error('Error loading plugin "' + this.id + '" at "' + this.root + '"'); } if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) { diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js index abb8215..ea172c4 100644 --- a/lib/plugins/registry.js +++ b/lib/plugins/registry.js @@ -1,7 +1,9 @@ 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'); @@ -21,7 +23,7 @@ function pluginId(name) { // Validate an NPM plugin ID function validateId(name) { - return name.indexOf(PLUGIN_PREFIX) === 0; + return name && name.indexOf(PLUGIN_PREFIX) === 0; } // Initialize NPM for operations @@ -104,6 +106,52 @@ function installPlugin(book, plugin, version) { }); } +// 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) + ]) + .spread(function(defaultPlugins, plugins) { + var results = plugins.concat(defaultPlugins); + return _.uniq(results, 'name'); + }); +} + module.exports = { npmId: npmId, pluginId: pluginId, @@ -111,5 +159,7 @@ module.exports = { resolve: resolveVersion, link: linkPlugin, - install: installPlugin + install: installPlugin, + list: listPlugins, + listInstalled: listInstalled }; diff --git a/lib/template/fs-loader.js b/lib/template/fs-loader.js new file mode 100644 index 0000000..00c4743 --- /dev/null +++ b/lib/template/fs-loader.js @@ -0,0 +1,61 @@ +var _ = require('lodash'); +var fs = require('fs'); +var path = require('path'); +var nunjucks = require('nunjucks'); + +/* + Nunjucks loader similar to FileSystemLoader, but avoid infinite looping +*/ + +function isRelative(filename) { + return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0); +} + +var Loader = nunjucks.Loader.extend({ + init: function(searchPaths) { + this.searchPaths = searchPaths.map(path.normalize); + }, + + getSource: function(fullpath) { + if (!fullpath) return null; + + fullpath = this.resolve(null, fullpath); + + if(!fullpath) { + return null; + } + + return { + src: fs.readFileSync(fullpath, 'utf-8'), + path: fullpath, + noCache: true + }; + }, + + // We handle absolute paths ourselves in ".resolve" + isRelative: function() { + return true; + }, + + resolve: function(from, to) { + // Relative template like "./test.html" + if (isRelative(to) && from) { + return path.resolve(path.dirname(from), to); + } + + // Absolute template to resolve in root folder + var resultFolder = _.find(this.searchPaths, function(basePath) { + var p = path.resolve(basePath, to); + + return ( + p.indexOf(basePath) === 0 + && p != from + && fs.existsSync(p) + ); + }); + if (!resultFolder) return null; + return path.resolve(resultFolder, to); + } +}); + +module.exports = Loader; diff --git a/package.json b/package.json index bf24b55..2f11e51 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,10 @@ "moment": "2.11.2", "npm": "3.7.5", "npmi": "1.0.1", - "nunjucks": "2.3.0", + "nunjucks": "2.4.0", "nunjucks-autoescape": "1.0.1", "q": "1.4.1", + "read-installed": "^4.0.3", "request": "2.69.0", "resolve": "0.6.3", "rmdir": "1.2.0", diff --git a/test/output-website.js b/test/output-website.js index 19459b3..2d936be 100644 --- a/test/output-website.js +++ b/test/output-website.js @@ -1,3 +1,5 @@ +var fs = require('fs'); + var mock = require('./mock'); var WebsiteOutput = require('../lib/output/website'); @@ -95,5 +97,28 @@ describe('Website Output', function() { }); }); + describe('Theming', function() { + var output; + + before(function() { + return mock.outputDefaultBook(WebsiteOutput, { + '_layouts/website/page.html': '{% extends "website/page.html" %}{% block body %}{{ super() }}<div id="theming-added"></div>{% endblock %}' + + }) + .then(function(_output) { + output = _output; + }); + }); + + it('should extend default theme', function() { + var readme = fs.readFileSync(output.resolve('index.html'), 'utf-8'); + + readme.should.be.html({ + '#theming-added': { + count: 1 + } + }); + }); + }); }); diff --git a/test/plugins.js b/test/plugins.js index 5d10031..399cdc5 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -9,6 +9,10 @@ var BookPlugin = require('../lib/plugins/plugin'); var PLUGINS_ROOT = path.resolve(__dirname, 'node_modules'); +function TestPlugin(book, name) { + return new BookPlugin(book, name, path.resolve(PLUGINS_ROOT, 'gitbook-plugin-'+name)); +} + describe('Plugins', function() { var book; @@ -90,7 +94,7 @@ describe('Plugins', function() { describe('Configuration', function() { it('should fail loading a plugin with an invalid configuration', function() { - var plugin = new BookPlugin(book, 'test-config'); + var plugin = TestPlugin(book, 'test-config'); return plugin.load(PLUGINS_ROOT) .should.be.rejectedWith('Error with book\'s configuration: pluginsConfig.test-config.myProperty is required'); }); @@ -108,7 +112,7 @@ describe('Plugins', function() { .then(function(book2) { return book2.prepareConfig() .then(function() { - var plugin = new BookPlugin(book2, 'test-config'); + var plugin = TestPlugin(book2, 'test-config'); return plugin.load(PLUGINS_ROOT); }) .then(function() { @@ -122,7 +126,7 @@ describe('Plugins', function() { var plugin; before(function() { - plugin = new BookPlugin(book, 'test-resources'); + plugin = TestPlugin(book, 'test-resources'); return plugin.load(PLUGINS_ROOT); }); @@ -146,7 +150,7 @@ describe('Plugins', function() { var plugin, filters; before(function() { - plugin = new BookPlugin(book, 'test-filters'); + plugin = TestPlugin(book, 'test-filters'); return plugin.load(PLUGINS_ROOT) .then(function() { @@ -171,7 +175,7 @@ describe('Plugins', function() { var plugin, blocks; before(function() { - plugin = new BookPlugin(book, 'test-blocks'); + plugin = TestPlugin(book, 'test-blocks'); return plugin.load(PLUGINS_ROOT) .then(function() { @@ -196,7 +200,7 @@ describe('Plugins', function() { var plugin; before(function() { - plugin = new BookPlugin(book, 'test-hooks'); + plugin = TestPlugin(book, 'test-hooks'); return plugin.load(PLUGINS_ROOT); }); |