summaryrefslogtreecommitdiffstats
path: root/lib/plugins/plugin.js
blob: df79184809030e3953cc92f513e90a0006ef9756 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
var _ = require('lodash');
var path = require('path');
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 HOOKS = [
    'init', 'finish', 'finish:before', 'config', 'page', 'page:before'
];

var RESOURCES = ['js', 'css'];

// Return true if an error is a "module not found"
// Wait on https://github.com/substack/node-resolve/pull/81 to be merged
function isModuleNotFound(err) {
    return err.message.indexOf('Cannot find module') >= 0;
}

function BookPlugin(book, pluginId) {
    this.book = book;
    this.log = this.book.log;

    this.id = pluginId;
    this.npmId = registry.npmId(pluginId);
    this.root;

    this.packageInfos = undefined;
    this.content = undefined;

    _.bindAll(this);
}

// Return true if plugin has been loaded correctly
BookPlugin.prototype.isLoaded = function() {
    return Boolean(this.packageInfos && this.content);
};

// Load this plugin
// An optional folder to search in can be passed
BookPlugin.prototype.load = function(folder) {
    var that = this;

    if (this.isLoaded()) {
        return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded'));
    }

    // Fodlers to search plugins in
    var searchPaths = _.compact([
        folder,
        this.book.resolve('node_modules'),
        __dirname
    ]);

    // Try loading plugins from different location
    var p = Promise.some(searchPaths, function(baseDir) {
        // Locate plugin and load pacjage.json
        try {
            var res = resolve.sync(that.npmId + '/package.json', { basedir: baseDir });

            that.root = path.dirname(res);
            that.packageInfos = require(res);
        } catch (err) {
            if (!isModuleNotFound(err)) throw err;

            that.packageInfos = undefined;
            that.content = undefined;

            return false;
        }

        // Load plugin JS content
        try {
            that.content = require(resolve.sync(that.npmId, { basedir: baseDir }));
        } catch(err) {
            // It's no big deal if the plugin doesn't have an "index.js"
            // (For example: themes)
            if (isModuleNotFound(err)) {
                that.content = {};
            } else {
                throw new error.PluginError(err, {
                    plugin: that.id
                });
            }
        }

        return true;
    })

    .then(that.validate)

    // Validate the configuration and update it
    .then(function() {
        var config = that.book.config.get(that.getConfigKey(), {});
        return that.validateConfig(config);
    })
    .then(function(config) {
        that.book.config.set(that.getConfigKey(), config);
    });

    this.log.info('Loading plugin "' + this.id + '" ...');
    return this.log.info.promise(p);
};

// Verify the definition of a plugin
// Also verify that the plugin accepts the current gitbook version
// This method throws erros if plugin is invalid
BookPlugin.prototype.validate = function() {
    var isValid = (
        this.packageInfos &&
        this.packageInfos.name &&
        this.packageInfos.engines &&
        this.packageInfos.engines.gitbook
    );

    if (!this.isLoaded()) {
        throw new Error('Couldn\'t locate plugin "' + this.id + '", Run \'gitbook install\' to install plugins from registry.');
    }

    if (!isValid) {
        throw new Error('Invalid plugin "' + this.id + '"');
    }

    if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) {
        throw new Error('GitBook doesn\'t satisfy the requirements of this plugin: '+this.packageInfos.engines.gitbook);
    }
};

// Normalize, validate configuration for this plugin using its schema
// Throw an error when shcema is not respected
BookPlugin.prototype.validateConfig = function(config) {
    var that = this;

    return Promise()
    .then(function() {
        var schema = that.packageInfos.gitbook || {};
        if (!schema) return config;

        // Normalize schema
        schema.id = '/'+that.getConfigKey();
        schema.type = 'object';

        // Validate and throw if invalid
        var v = new jsonschema.Validator();
        var result = v.validate(config, schema, {
            propertyName: that.getConfigKey()
        });

        // Throw error
        if (result.errors.length > 0) {
            throw new error.ConfigurationError(new Error(result.errors[0].stack));
        }

        // Insert default values
        var defaults = jsonSchemaDefaults(schema);
        return mergeDefaults(config, defaults);
    });
};

// Return key for configuration
BookPlugin.prototype.getConfigKey = function() {
    return 'pluginsConfig.'+this.id;
};

// Call a hook and returns its result
BookPlugin.prototype.hook = function(name, input) {
    // Our book will be the context to apply
    var context = this.book;

    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 hookFunc.apply(context, [input]);
    });
};

// Return resources without normalization
BookPlugin.prototype._getResources = function(base) {
    base = base;
    var book = this.infos[base];

    // Compatibility with version 1.x.x
    if (base == 'website') book = book || this.infos.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 Promise().then(book.bind(this.book));
    }

    // Plain data object
    return Promise(_.cloneDeep(book));
};

// 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] = (resources[resourceType] || []).map(that.normalizeResource);
        });

        return resources;
    });
};

// Normalize filters and return them
BookPlugin.prototype.getFilters = function() {
    return this.content.filters || {};
};

// Normalize blocks and return them
BookPlugin.prototype.getBlocks = function() {
    return this.content.blocks || {};
};

module.exports = BookPlugin;