summaryrefslogtreecommitdiffstats
path: root/lib/plugins2/plugin.js
blob: d1c00d8228e97a8e9356a3280513a52965fb1ed1 (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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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;