summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/backbone/file.js69
-rw-r--r--lib/backbone/glossary.js99
-rw-r--r--lib/backbone/index.js8
-rw-r--r--lib/backbone/langs.js81
-rw-r--r--lib/backbone/readme.js26
-rw-r--r--lib/backbone/summary.js339
-rw-r--r--lib/book.js929
-rw-r--r--lib/cli/helper.js139
-rw-r--r--lib/cli/index.js187
-rw-r--r--lib/cli/server.js (renamed from lib/utils/server.js)39
-rw-r--r--lib/cli/watch.js42
-rw-r--r--lib/config/index.js132
-rw-r--r--lib/config/plugins.js67
-rw-r--r--lib/config/schema.js188
-rw-r--r--lib/config/validator.js28
-rw-r--r--lib/config_default.js109
-rw-r--r--lib/configuration.js210
-rw-r--r--lib/conrefs_loader.js73
-rw-r--r--lib/fs/index.js106
-rw-r--r--lib/fs/node.js66
-rw-r--r--lib/generator.js76
-rw-r--r--lib/generators/ebook.js172
-rw-r--r--lib/generators/index.js11
-rw-r--r--lib/generators/json.js76
-rw-r--r--lib/generators/website.js268
-rw-r--r--lib/gitbook.js (renamed from lib/version.js)18
-rw-r--r--lib/index.js212
-rw-r--r--lib/init.js83
-rw-r--r--lib/output/assets-inliner.js140
-rw-r--r--lib/output/base.js274
-rw-r--r--lib/output/conrefs.js67
-rw-r--r--lib/output/ebook.js190
-rw-r--r--lib/output/folder.js152
-rw-r--r--lib/output/json.js47
-rw-r--r--lib/output/website.js270
-rw-r--r--lib/page/html.js280
-rw-r--r--lib/page/index.js250
-rw-r--r--lib/parsers.js60
-rw-r--r--lib/plugin.js241
-rw-r--r--lib/plugins/compatibility.js57
-rw-r--r--lib/plugins/index.js155
-rw-r--r--lib/plugins/plugin.js300
-rw-r--r--lib/plugins/registry.js115
-rw-r--r--lib/pluginslist.js230
-rw-r--r--lib/template/blocks.js (renamed from lib/blocks.js)9
-rw-r--r--lib/template/filters.js15
-rw-r--r--lib/template/index.js (renamed from lib/template.js)321
-rw-r--r--lib/template/loader.js42
-rw-r--r--lib/utils/batch.js52
-rw-r--r--lib/utils/command.js80
-rw-r--r--lib/utils/error.js105
-rw-r--r--lib/utils/fs.js225
-rw-r--r--lib/utils/git.js168
-rw-r--r--lib/utils/i18n.js80
-rw-r--r--lib/utils/images.js71
-rw-r--r--lib/utils/location.js (renamed from lib/utils/links.js)49
-rw-r--r--lib/utils/logger.js188
-rw-r--r--lib/utils/navigation.js79
-rw-r--r--lib/utils/page.js397
-rw-r--r--lib/utils/path.js36
-rw-r--r--lib/utils/progress.js55
-rw-r--r--lib/utils/promise.js62
-rw-r--r--lib/utils/string.js27
-rw-r--r--lib/utils/watch.js40
64 files changed, 5010 insertions, 3772 deletions
diff --git a/lib/backbone/file.js b/lib/backbone/file.js
new file mode 100644
index 0000000..209e261
--- /dev/null
+++ b/lib/backbone/file.js
@@ -0,0 +1,69 @@
+var _ = require('lodash');
+
+function BackboneFile(book) {
+ if (!(this instanceof BackboneFile)) return new BackboneFile(book);
+
+ this.book = book;
+ this.log = this.book.log;
+
+ // Filename in the book
+ this.path = '';
+ this.parser;
+
+ _.bindAll(this);
+}
+
+// Type of the backbone file
+BackboneFile.prototype.type = '';
+
+// Parse a backbone file
+BackboneFile.prototype.parse = function() {
+ // To be implemented by each child
+};
+
+// Handle case where file doesn't exists
+BackboneFile.prototype.parseNotFound = function() {
+
+};
+
+// Return true if backbone file exists
+BackboneFile.prototype.exists = function() {
+ return Boolean(this.path);
+};
+
+// Locate a backbone file, could be .md, .asciidoc, etc
+BackboneFile.prototype.locate = function() {
+ var that = this;
+ var filename = this.book.config.getStructure(this.type, true);
+ this.log.debug.ln('locating', this.type, ':', filename);
+
+ return this.book.findParsableFile(filename)
+ .then(function(result) {
+ if (!result) return;
+
+ that.path = result.path;
+ that.parser = result.parser;
+ });
+};
+
+// Read and parse the file
+BackboneFile.prototype.load = function() {
+ var that = this;
+ this.log.debug.ln('loading', this.type, ':', that.path);
+
+ return this.locate()
+ .then(function() {
+ if (!that.path) return that.parseNotFound();
+
+ that.log.debug.ln(that.type, 'located at', that.path);
+
+ return that.book.readFile(that.path)
+
+ // Parse it
+ .then(function(content) {
+ return that.parse(content);
+ });
+ });
+};
+
+module.exports = BackboneFile;
diff --git a/lib/backbone/glossary.js b/lib/backbone/glossary.js
new file mode 100644
index 0000000..cc0fdce
--- /dev/null
+++ b/lib/backbone/glossary.js
@@ -0,0 +1,99 @@
+var _ = require('lodash');
+var util = require('util');
+var BackboneFile = require('./file');
+
+// Normalize a glossary entry name into a unique id
+function nameToId(name) {
+ return name.toLowerCase()
+ .replace(/[\/\\\?\%\*\:\;\|\"\'\\<\\>\#\$\(\)\!\.\@]/g, '')
+ .replace(/ /g, '_')
+ .trim();
+}
+
+
+/*
+A glossary entry is represented by a name and a short description
+An unique id for the entry is generated using its name
+*/
+function GlossaryEntry(name, description) {
+ if (!(this instanceof GlossaryEntry)) return new GlossaryEntry(name, description);
+
+ this.name = name;
+ this.description = description;
+
+ Object.defineProperty(this, 'id', {
+ get: _.bind(this.getId, this)
+ });
+}
+
+// Normalizes a glossary entry's name to create an ID
+GlossaryEntry.prototype.getId = function() {
+ return nameToId(this.name);
+};
+
+
+/*
+A glossary is a list of entries stored in a GLOSSARY.md file
+*/
+function Glossary() {
+ BackboneFile.apply(this, arguments);
+
+ this.entries = [];
+}
+util.inherits(Glossary, BackboneFile);
+
+Glossary.prototype.type = 'glossary';
+
+// Get templating context
+Glossary.prototype.getContext = function() {
+ if (!this.path) return {};
+
+ return {
+ glossary: {
+ path: this.path
+ }
+ };
+};
+
+// Parse the readme content
+Glossary.prototype.parse = function(content) {
+ var that = this;
+
+ return this.parser.glossary(content)
+ .then(function(entries) {
+ that.entries = _.map(entries, function(entry) {
+ return new GlossaryEntry(entry.name, entry.description);
+ });
+ });
+};
+
+// Return an entry by its id
+Glossary.prototype.get = function(id) {
+ return _.find(this.entries, {
+ id: id
+ });
+};
+
+// Find an entry by its name
+Glossary.prototype.find = function(name) {
+ return this.get(nameToId(name));
+};
+
+// Return false if glossary has entries (and exists)
+Glossary.prototype.isEmpty = function(id) {
+ return _.size(this.entries) === 0;
+};
+
+// Convert the glossary to a list of annotations
+Glossary.prototype.annotations = function() {
+ return _.map(this.entries, function(entry) {
+ return {
+ id: entry.id,
+ name: entry.name,
+ description: entry.description,
+ href: '/' + this.path + '#' + entry.id
+ };
+ }, this);
+};
+
+module.exports = Glossary;
diff --git a/lib/backbone/index.js b/lib/backbone/index.js
new file mode 100644
index 0000000..4c3c3f3
--- /dev/null
+++ b/lib/backbone/index.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ Readme: require('./readme'),
+ Summary: require('./summary'),
+ Glossary: require('./glossary'),
+ Langs: require('./langs')
+};
+
diff --git a/lib/backbone/langs.js b/lib/backbone/langs.js
new file mode 100644
index 0000000..e339fa9
--- /dev/null
+++ b/lib/backbone/langs.js
@@ -0,0 +1,81 @@
+var _ = require('lodash');
+var path = require('path');
+var util = require('util');
+var BackboneFile = require('./file');
+
+function Language(title, folder) {
+ var that = this;
+
+ this.title = title;
+ this.folder = folder;
+
+ Object.defineProperty(this, 'id', {
+ get: function() {
+ return path.basename(that.folder);
+ }
+ });
+}
+
+/*
+A Langs is a list of languages stored in a LANGS.md file
+*/
+function Langs() {
+ BackboneFile.apply(this, arguments);
+
+ this.languages = [];
+}
+util.inherits(Langs, BackboneFile);
+
+Langs.prototype.type = 'langs';
+
+// Parse the readme content
+Langs.prototype.parse = function(content) {
+ var that = this;
+
+ return this.parser.langs(content)
+ .then(function(langs) {
+ that.languages = _.map(langs, function(entry) {
+ return new Language(entry.title, entry.path);
+ });
+ });
+};
+
+// Return the list of languages
+Langs.prototype.list = function() {
+ return this.languages;
+};
+
+// Return default/main language for the book
+Langs.prototype.getDefault = function() {
+ return _.first(this.languages);
+};
+
+// Return true if a language is the default one
+// "lang" cam be a string (id) or a Language entry
+Langs.prototype.isDefault = function(lang) {
+ lang = lang.id || lang;
+ return (this.cound() > 0 && this.getDefault().id == lang);
+};
+
+// Return the count of languages
+Langs.prototype.count = function() {
+ return _.size(this.languages);
+};
+
+// Return templating context for the languages list
+Langs.prototype.getContext = function() {
+ if (this.count() == 0) return {};
+
+ return {
+ languages: {
+ list: _.map(this.languages, function(lang) {
+ return {
+ id: lang.id,
+ title: lang.title
+ };
+ })
+ }
+ };
+};
+
+module.exports = Langs;
diff --git a/lib/backbone/readme.js b/lib/backbone/readme.js
new file mode 100644
index 0000000..a4cd9d8
--- /dev/null
+++ b/lib/backbone/readme.js
@@ -0,0 +1,26 @@
+var util = require('util');
+var BackboneFile = require('./file');
+
+function Readme() {
+ BackboneFile.apply(this, arguments);
+
+ this.title;
+ this.description;
+}
+util.inherits(Readme, BackboneFile);
+
+Readme.prototype.type = 'readme';
+
+// Parse the readme content
+Readme.prototype.parse = function(content) {
+ var that = this;
+
+ return this.parser.readme(content)
+ .then(function(out) {
+ that.title = out.title;
+ that.description = out.description;
+ });
+};
+
+
+module.exports = Readme;
diff --git a/lib/backbone/summary.js b/lib/backbone/summary.js
new file mode 100644
index 0000000..4ae3453
--- /dev/null
+++ b/lib/backbone/summary.js
@@ -0,0 +1,339 @@
+var _ = require('lodash');
+var util = require('util');
+var url = require('url');
+
+var location = require('../utils/location');
+var error = require('../utils/error');
+var BackboneFile = require('./file');
+
+
+/*
+ An article represent an entry in the Summary.
+ It's defined by a title, a reference, and children articles,
+ the reference (ref) can be a filename + anchor or an external file (optional)
+*/
+function TOCArticle(def, parent) {
+ // Title
+ this.title = def.title;
+
+ // Parent TOCPart or TOCArticle
+ this.parent = parent;
+
+ // As string indicating the overall position
+ // ex: '1.0.0'
+ this.level;
+ this._next;
+ this._prev;
+
+ // When README has been automatically added
+ this.isAutoIntro = def.isAutoIntro;
+ this.isIntroduction = def.isIntroduction;
+
+ this.validate();
+
+ // Path can be a relative path or an url, or nothing
+ this.ref = def.path;
+ if (this.ref) {
+ var parts = url.parse(this.ref);
+
+ if (!this.isExternal()) {
+ this.path = parts.pathname;
+ this.anchor = parts.hash;
+ }
+ }
+
+ this.articles = _.map(def.articles || [], function(article) {
+ if (article instanceof TOCArticle) return article;
+ return new TOCArticle(article, this);
+ }, this);
+}
+
+// Validate the article
+TOCArticle.prototype.validate = function() {
+ if (!this.title) {
+ throw error.ParsingError(new Error('SUMMARY entries should have an non-empty title'));
+ }
+};
+
+// Iterate over all articles in this articles
+TOCArticle.prototype.walk = function(iter, base) {
+ base = base || this.level;
+
+ _.each(this.articles, function(article, i) {
+ var level = levelId(base, i);
+
+ if (iter(article, level) === false) {
+ return false;
+ }
+ article.walk(iter, level);
+ });
+};
+
+// Return templating context for an article
+TOCArticle.prototype.getContext = function() {
+ return {
+ level: this.level,
+ title: this.title,
+ depth: this.depth(),
+ path: this.isExternal()? undefined : this.path,
+ anchor: this.isExternal()? undefined : this.anchor,
+ url: this.isExternal()? this.ref : undefined
+ };
+};
+
+// Return true if is pointing to a file
+TOCArticle.prototype.hasLocation = function() {
+ return Boolean(this.path);
+};
+
+// Return true if is pointing to an external location
+TOCArticle.prototype.isExternal = function() {
+ return location.isExternal(this.ref);
+};
+
+// Return true if this article is the introduction
+TOCArticle.prototype.isIntro = function() {
+ return Boolean(this.isIntroduction);
+};
+
+// Return true if has children
+TOCArticle.prototype.hasChildren = function() {
+ return this.articles.length > 0;
+};
+
+// Return true if has an article as parent
+TOCArticle.prototype.hasParent = function() {
+ return !(this.parent instanceof TOCPart);
+};
+
+// Return depth of this article
+TOCArticle.prototype.depth = function() {
+ return this.level.split('.').length;
+};
+
+// Return next article in the TOC
+TOCArticle.prototype.next = function() {
+ return this._next;
+};
+
+// Return previous article in the TOC
+TOCArticle.prototype.prev = function() {
+ return this._prev;
+};
+
+// Map over all articles
+TOCArticle.prototype.map = function(iter) {
+ return _.map(this.articles, iter);
+};
+
+
+/*
+ A part of a ToC is a composed of a tree of articles and an optiona title
+*/
+function TOCPart(part, parent) {
+ if (!(this instanceof TOCPart)) return new TOCPart(part, parent);
+
+ TOCArticle.apply(this, arguments);
+}
+util.inherits(TOCPart, TOCArticle);
+
+// Validate the part
+TOCPart.prototype.validate = function() { };
+
+// Return a sibling (next or prev) of this part
+TOCPart.prototype.sibling = function(direction) {
+ var parts = this.parent.parts;
+ var pos = _.findIndex(parts, this);
+
+ if (parts[pos + direction]) {
+ return parts[pos + direction];
+ }
+
+ return null;
+};
+
+// Iterate over all entries of the part
+TOCPart.prototype.walk = function(iter, base) {
+ var articles = this.articles;
+
+ if (articles.length == 0) return;
+
+ // Has introduction?
+ if (articles[0].isIntro()) {
+ if (iter(articles[0], '0') === false) {
+ return;
+ }
+
+ articles = articles.slice(1);
+ }
+
+
+ _.each(articles, function(article, i) {
+ var level = levelId(base, i);
+
+ if (iter(article, level) === false) {
+ return false;
+ }
+
+ article.walk(iter, level);
+ });
+};
+
+// Return templating context for a part
+TOCPart.prototype.getContext = function(onArticle) {
+ onArticle = onArticle || function(article) {
+ return article.getContext();
+ };
+
+ return {
+ title: this.title,
+ articles: this.map(onArticle)
+ };
+};
+
+/*
+A summary is composed of a list of parts, each composed wit a tree of articles.
+*/
+function Summary() {
+ BackboneFile.apply(this, arguments);
+
+ this.parts = [];
+ this._length = 0;
+}
+util.inherits(Summary, BackboneFile);
+
+Summary.prototype.type = 'summary';
+
+// Prepare summary when non existant
+Summary.prototype.parseNotFound = function() {
+ this.update([]);
+};
+
+// Parse the summary content
+Summary.prototype.parse = function(content) {
+ var that = this;
+
+ return this.parser.summary(content)
+
+ .then(function(summary) {
+ that.update(summary.parts);
+ });
+};
+
+// Return templating context for the summary
+Summary.prototype.getContext = function() {
+ function onArticle(article) {
+ var result = article.getContext();
+ if (article.hasChildren()) {
+ result.articles = article.map(onArticle);
+ }
+
+ return result;
+ }
+
+ return {
+ summary: {
+ parts: _.map(this.parts, function(part) {
+ return part.getContext(onArticle);
+ })
+ }
+ };
+};
+
+// Iterate over all entries of the summary
+// iter is called with an TOCArticle
+Summary.prototype.walk = function(iter) {
+ var hasMultipleParts = this.parts.length > 1;
+
+ _.each(this.parts, function(part, i) {
+ part.walk(iter, hasMultipleParts? levelId('', i) : null);
+ });
+};
+
+// Find a specific article using a filter
+Summary.prototype.find = function(filter) {
+ var result;
+
+ this.walk(function(article) {
+ if (filter(article)) {
+ result = article;
+ return false;
+ }
+ });
+
+ return result;
+};
+
+// Return the first TOCArticle for a specific page (or path)
+Summary.prototype.getArticle = function(page) {
+ if (!_.isString(page)) page = page.path;
+
+ return this.find(function(article) {
+ return article.path == page;
+ });
+};
+
+// Return the first TOCArticle for a specific level
+Summary.prototype.getArticleByLevel = function(lvl) {
+ return this.find(function(article) {
+ return article.level == lvl;
+ });
+};
+
+// Return the count of articles in the summary
+Summary.prototype.count = function() {
+ return this._length;
+};
+
+// Prepare the summary
+Summary.prototype.update = function(parts) {
+ var that = this;
+
+
+ that.parts = _.map(parts, function(part) {
+ return new TOCPart(part, that);
+ });
+
+ // Create first part if none
+ if (that.parts.length == 0) {
+ that.parts.push(new TOCPart({}, that));
+ }
+
+ // Add README as first entry
+ var firstArticle = that.parts[0].articles[0];
+ if (!firstArticle || firstArticle.path != that.book.readme.path) {
+ that.parts[0].articles.unshift(new TOCArticle({
+ title: 'Introduction',
+ path: that.book.readme.path,
+ isAutoIntro: true
+ }, that.parts[0]));
+ }
+ that.parts[0].articles[0].isIntroduction = true;
+
+
+ // Update the count and indexing of "level"
+ var prev = undefined;
+
+ that._length = 0;
+ that.walk(function(article, level) {
+ // Index level
+ article.level = level;
+
+ // Chain articles
+ article._prev = prev;
+ if (prev) prev._next = article;
+
+ prev = article;
+
+ that._length += 1;
+ });
+};
+
+
+// Return a level string from a base level and an index
+function levelId(base, i) {
+ i = i + 1;
+ return (base? [base || '', i] : [i]).join('.');
+}
+
+module.exports = Summary;
diff --git a/lib/book.js b/lib/book.js
index 9ba27f3..400296e 100644
--- a/lib/book.js
+++ b/lib/book.js
@@ -1,25 +1,34 @@
-var Q = require('q');
var _ = require('lodash');
var path = require('path');
-var parsers = require('gitbook-parsers');
-
-var fs = require('./utils/fs');
-var parseNavigation = require('./utils/navigation');
-var parseProgress = require('./utils/progress');
-var pageUtil = require('./utils/page');
+var Ignore = require('ignore');
+
+var Config = require('./config');
+var Readme = require('./backbone/readme');
+var Glossary = require('./backbone/glossary');
+var Summary = require('./backbone/summary');
+var Langs = require('./backbone/langs');
+var Page = require('./page');
var pathUtil = require('./utils/path');
-var links = require('./utils/links');
-var i18n = require('./utils/i18n');
-var logger = require('./utils/logger');
+var error = require('./utils/error');
+var Promise = require('./utils/promise');
+var Logger = require('./utils/logger');
+var parsers = require('./parsers');
+
+
+/*
+The Book class is an interface for parsing books content.
+It does not require to run on Node.js, isnce it only depends on the fs implementation
+*/
+
+function Book(opts) {
+ if (!(this instanceof Book)) return new Book(opts);
-var Configuration = require('./configuration');
-var TemplateEngine = require('./template');
-var PluginsList = require('./pluginslist');
+ this.opts = _.defaults(opts || {}, {
+ fs: null,
-var generators = require('./generators');
+ // Root path for the book
+ root: '',
-var Book = function(root, context, parent) {
- this.context = _.defaults(context || {}, {
// Extend book configuration
config: {},
@@ -32,572 +41,234 @@ var Book = function(root, context, parent) {
logLevel: 'info'
});
- // Log
- this.log = logger(this.context.log, this.context.logLevel);
+ if (!opts.fs) throw error.ParsingError(new Error('Book requires a fs instance'));
- // Root folder of the book
- this.root = path.resolve(root);
+ // Root path for the book
+ this.root = opts.root;
- // Parent book
- this.parent = parent;
+ // If multi-lingual, book can have a parent
+ this.parent = opts.parent;
+ if (this.parent) {
+ this.language = path.relative(this.parent.root, this.root);
+ }
- // Configuration
- this.config = new Configuration(this, this.context.config);
- Object.defineProperty(this, 'options', {
- get: function () {
- return this.config.options;
- }
- });
+ // A book is linked to an fs, to access its content
+ this.fs = opts.fs;
- // Template
- this.template = new TemplateEngine(this);
+ // Rules to ignore some files
+ this.ignore = Ignore();
+ this.ignore.addPattern([
+ // Skip Git stuff
+ '.git/',
- // Summary
- this.summary = {};
- this.navigation = [];
+ // Skip OS X meta data
+ '.DS_Store',
- // Glossary
- this.glossary = [];
+ // Skip stuff installed by plugins
+ 'node_modules',
- // Langs
- this.langs = [];
+ // Skip book outputs
+ '_book',
+ '*.pdf',
+ '*.epub',
+ '*.mobi'
+ ]);
- // Sub-books
- this.books = [];
+ // Create a logger for the book
+ this.log = new Logger(opts.log, opts.logLevel);
- // Files in the book
- this.files = [];
+ // Create an interface to access the configuration
+ this.config = new Config(this, opts.config);
- // List of plugins
- this.plugins = new PluginsList(this);
+ // Interfaces for the book structure
+ this.readme = new Readme(this);
+ this.summary = new Summary(this);
+ this.glossary = new Glossary(this);
- // Structure files
- this.summaryFile = null;
- this.glossaryFile = null;
- this.readmeFile = null;
- this.langsFile = null;
+ // Multilinguals book
+ this.langs = new Langs(this);
+ this.books = [];
- // Bind methods
- _.bindAll(this);
-};
+ // List of page in the book
+ this.pages = {};
-// Return string representation
-Book.prototype.toString = function() {
- return '[Book '+this.root+']';
-};
+ _.bindAll(this);
+}
-// Initialize and parse the book: config, summary, glossary
-Book.prototype.parse = function() {
- var that = this;
- var multilingual = false;
+// Return templating context for the book
+Book.prototype.getContext = function() {
+ var variables = this.config.get('variables', {});
- return this.parseConfig()
+ return {
+ book: _.extend({
+ language: this.language
+ }, variables)
+ };
+};
- .then(function() {
- return that.parsePlugins();
- })
+// Parse and prepare the configuration, fail if invalid
+Book.prototype.prepareConfig = function() {
+ return this.config.load();
+};
- .then(function() {
- return that.parseLangs()
- .then(function() {
- multilingual = that.langs.length > 0;
- if (multilingual) that.log.info.ln('Parsing multilingual book, with', that.langs.length, 'languages');
-
- // Sub-books that inherit from the current book configuration
- that.books = _.map(that.langs, function(lang) {
- that.log.info.ln('Preparing language book', lang.lang);
- return new Book(
- path.join(that.root, lang.path),
- _.merge({}, that.context, {
- config: _.extend({}, that.options, {
- 'output': path.join(that.options.output, lang.lang),
- 'language': lang.lang
- })
- }),
- that
- );
- });
+// Resolve a path in the book source
+// Enforce that the output path is in the scope
+Book.prototype.resolve = function() {
+ var filename = path.resolve.apply(path, [this.root].concat(_.toArray(arguments)));
+ if (!this.isFileInScope(filename)) {
+ throw error.FileOutOfScopeError({
+ filename: filename,
+ root: this.root
});
- })
+ }
- .then(function() {
- if (multilingual) return;
- return that.listAllFiles();
- })
- .then(function() {
- if (multilingual) return;
- return that.parseReadme();
- })
- .then(function() {
- if (multilingual) return;
- return that.parseSummary();
- })
- .then(function() {
- if (multilingual) return;
- return that.parseGlossary();
- })
+ return filename;
+};
- .then(function() {
- // Init sub-books
- return _.reduce(that.books, function(prev, book) {
- return prev.then(function() {
- return book.parse();
- });
- }, Q());
- })
+// Return false if a file is outside the book' scope
+Book.prototype.isFileInScope = function(filename) {
+ filename = path.resolve(this.root, filename);
- .thenResolve(this);
+ // Is the file in the scope of the parent?
+ if (this.parent && this.parent.isFileInScope(filename)) return true;
+
+ // Is file in the root folder?
+ return pathUtil.isInRoot(this.root, filename);
};
-// Generate the output
-Book.prototype.generate = function(generator) {
+// Parse .gitignore, etc to extract rules
+Book.prototype.parseIgnoreRules = function() {
var that = this;
- that.options.generator = generator || that.options.generator;
- that.log.info.ln('start generation with', that.options.generator, 'generator');
- return Q()
-
- // Clean output folder
- .then(function() {
- that.log.info('clean', that.options.generator, 'generator');
- return fs.clean(that.options.output)
- .progress(function(p) {
- that.log.debug.ln('remove', p.file, '('+p.i+'/'+p.count+')');
- })
- .then(function() {
- that.log.info.ok();
+ return Promise.serie([
+ '.ignore',
+ '.gitignore',
+ '.bookignore'
+ ], function(filename) {
+ return that.readFile(filename)
+ .then(function(content) {
+ that.ignore.addPattern(content.toString().split(/\r?\n/));
+ }, function() {
+ return Promise();
});
- })
+ });
+};
- // Create generator
- .then(function() {
- var Generator = generators[generator];
- if (!Generator) throw 'Generator \''+that.options.generator+'\' doesn\'t exist';
- generator = new Generator(that);
+// Parse the whole book
+Book.prototype.parse = function() {
+ var that = this;
- return generator.prepare();
- })
+ return Promise()
+ .then(this.prepareConfig)
+ .then(this.parseIgnoreRules)
- // Transform configuration
+ // Parse languages
.then(function() {
- return that.callHook('config', that.config.dump())
- .then(function(newConfig) {
- that.config.replace(newConfig);
- });
+ return that.langs.load();
})
- // Generate content
.then(function() {
if (that.isMultilingual()) {
- return that.generateMultiLingual(generator);
- } else {
- // Separate list of files into the different operations needed
- var ops = _.groupBy(that.files, function(file) {
- if (file[file.length -1] == '/') {
- return 'directories';
- } else if (_.contains(parsers.extensions, path.extname(file)) && that.navigation[file]) {
- return 'content';
- } else {
- return 'files';
- }
- });
-
-
- return Q()
+ if (that.isLanguageBook()) {
+ throw error.ParsingError(new Error('A multilingual book as a language book is forbidden'));
+ }
+
+ that.log.info.ln('Parsing multilingual book, with', that.langs.count(), 'languages');
+
+ // Create a new book for each language and parse it
+ return Promise.serie(that.langs.list(), function(lang) {
+ that.log.debug.ln('Preparing book for language', lang.id);
+ var langBook = new Book(_.extend({}, that.opts, {
+ parent: that,
+ config: that.config.dump(),
+ root: that.resolve(lang.id)
+ }));
- // First, let's create folder
- .then(function() {
- return _.reduce(ops.directories || [], function(prev, folder) {
- return prev.then(function() {
- that.log.debug.ln('transferring folder', folder);
- return Q(generator.transferFolder(folder));
- });
- }, Q());
- })
+ that.books.push(langBook);
- // Then, let's copy other files
- .then(function() {
- return Q.all(_.map(ops.files || [], function(file) {
- that.log.debug.ln('transferring file', file);
- return Q(generator.transferFile(file));
- }));
- })
-
- // Finally let's generate content
- .then(function() {
- var nFiles = (ops.content || []).length;
- return _.reduce(ops.content || [], function(prev, file, i) {
- return prev.then(function() {
- var p = ((i*100)/nFiles).toFixed(0)+'%';
- that.log.debug.ln('processing', file, p);
-
- return Q(generator.convertFile(file))
- .fail(function(err) {
- // Transform error message to signal file
- throw that.normError(err, {
- fileName: file
- });
- });
- });
- }, Q());
+ return langBook.parse();
});
}
- })
- // Finish generation
- .then(function() {
- return that.callHook('finish:before');
- })
- .then(function() {
- return generator.finish();
- })
- .then(function() {
- return that.callHook('finish');
- })
- .then(function() {
- that.log.info.ln('generation is finished');
- });
-};
+ return Promise()
-// Generate the output for a multilingual book
-Book.prototype.generateMultiLingual = function() {
- var that = this;
-
- return Q()
- .then(function() {
- // Generate sub-books
- return _.reduce(that.books, function(prev, book) {
- return prev.then(function() {
- return book.generate(that.options.generator);
- });
- }, Q());
- });
-};
+ // Parse the readme
+ .then(that.readme.load)
+ .then(function() {
+ if (!that.readme.exists()) {
+ throw new error.FileNotFoundError({ filename: 'README' });
+ }
-// Extract files from ebook generated
-Book.prototype.generateFile = function(output, options) {
- var book = this;
+ // Default configuration to infos extracted from readme
+ if (!that.config.get('title')) that.config.set('title', that.readme.title);
+ if (!that.config.get('description')) that.config.set('description', that.readme.description);
+ })
- options = _.defaults(options || {}, {
- ebookFormat: path.extname(output).slice(1)
- });
- output = output || path.resolve(book.root, 'book.'+options.ebookFormat);
+ // Parse the summary
+ .then(that.summary.load)
+ .then(function() {
+ if (!that.summary.exists()) {
+ that.log.warn.ln('no summary file in this book');
+ }
+
+ // Index summary's articles
+ that.summary.walk(function(article) {
+ if (!article.hasLocation() || article.isExternal()) return;
+ that.addPage(article.path);
+ });
+ })
- return fs.tmp.dir()
- .then(function(tmpDir) {
- book.setOutput(tmpDir);
+ // Parse the glossary
+ .then(that.glossary.load)
- return book.generate(options.ebookFormat)
+ // Add the glossary as a page
.then(function() {
- var copyFile = function(lang) {
- var _outputFile = output;
- var _tmpDir = tmpDir;
-
- if (lang) {
- _outputFile = _outputFile.slice(0, -path.extname(_outputFile).length)+'_'+lang+path.extname(_outputFile);
- _tmpDir = path.join(_tmpDir, lang);
- }
-
- book.log.debug.ln('copy ebook to', _outputFile);
- return fs.copy(
- path.join(_tmpDir, 'index.'+options.ebookFormat),
- _outputFile
- );
- };
-
- // Multi-langs book
- return Q()
- .then(function() {
- if (book.isMultilingual()) {
- return Q.all(
- _.map(book.langs, function(lang) {
- return copyFile(lang.lang);
- })
- )
- .thenResolve(book.langs.length);
- } else {
- return copyFile().thenResolve(1);
- }
- })
- .then(function(n) {
- book.log.info.ok(n+' file(s) generated');
-
- return fs.remove(tmpDir);
- });
+ if (!that.glossary.exists()) return;
+ that.addPage(that.glossary.path);
});
});
};
-// Parse configuration
-Book.prototype.parseConfig = function() {
- var that = this;
+// Mark a filename as being parsable
+Book.prototype.addPage = function(filename) {
+ if (this.hasPage(filename)) return this.getPage(filename);
- that.log.info('loading book configuration....');
- return that.config.load()
- .then(function() {
- that.log.info.ok();
- });
+ filename = pathUtil.normalize(filename);
+ this.pages[filename] = new Page(this, filename);
+ return this.pages[filename];
};
-// Parse list of plugins
-Book.prototype.parsePlugins = function() {
- var that = this;
-
- // Load plugins
- return that.plugins.load(that.options.plugins)
- .then(function() {
- if (_.size(that.plugins.failed) > 0) return Q.reject(new Error('Error loading plugins: '+that.plugins.failed.join(',')+'. Run \'gitbook install\' to install plugins from NPM.'));
-
- that.log.info.ok(that.plugins.count()+' plugins loaded');
- that.log.debug.ln('normalize plugins list');
- });
+// Return a page by its filename (or undefined)
+Book.prototype.getPage = function(filename) {
+ filename = pathUtil.normalize(filename);
+ return this.pages[filename];
};
-// Parse readme to extract defaults title and description
-Book.prototype.parseReadme = function() {
- var that = this;
- var filename = that.config.getStructure('readme', true);
- that.log.debug.ln('start parsing readme:', filename);
-
- return that.findFile(filename)
- .then(function(readme) {
- if (!readme) throw 'No README file';
- if (!_.contains(that.files, readme.path)) throw 'README file is ignored';
-
- that.readmeFile = readme.path;
- that._defaultsStructure(that.readmeFile);
- that.log.debug.ln('readme located at', that.readmeFile);
- return that.template.renderFile(that.readmeFile)
- .then(function(content) {
- return readme.parser.readme(content)
- .fail(function(err) {
- throw that.normError(err, {
- name: err.name || 'Readme Parse Error',
- fileName: that.readmeFile
- });
- });
- });
- })
- .then(function(readme) {
- that.options.title = that.options.title || readme.title;
- that.options.description = that.options.description || readme.description;
- });
+// Return true, if has a specific page
+Book.prototype.hasPage = function(filename) {
+ return Boolean(this.getPage(filename));
};
-
-// Parse langs to extract list of sub-books
-Book.prototype.parseLangs = function() {
- var that = this;
-
- var filename = that.config.getStructure('langs', true);
- that.log.debug.ln('start parsing languages index:', filename);
-
- return that.findFile(filename)
- .then(function(langs) {
- if (!langs) return [];
-
- that.langsFile = langs.path;
- that._defaultsStructure(that.langsFile);
-
- that.log.debug.ln('languages index located at', that.langsFile);
- return that.template.renderFile(that.langsFile)
- .then(function(content) {
- return langs.parser.langs(content)
- .fail(function(err) {
- throw that.normError(err, {
- name: err.name || 'Langs Parse Error',
- fileName: that.langsFile
- });
- });
- });
- })
- .then(function(langs) {
- that.langs = langs;
- });
+// Test if a file is ignored, return true if it is
+Book.prototype.isFileIgnored = function(filename) {
+ return this.ignore.filter([filename]).length == 0;
};
-// Parse summary to extract list of chapters
-Book.prototype.parseSummary = function() {
- var that = this;
-
- var filename = that.config.getStructure('summary', true);
- that.log.debug.ln('start parsing summary:', filename);
-
- return that.findFile(filename)
- .then(function(summary) {
- if (!summary) throw 'No SUMMARY file';
-
- // Remove the summary from the list of files to parse
- that.summaryFile = summary.path;
- that._defaultsStructure(that.summaryFile);
- that.files = _.without(that.files, that.summaryFile);
-
- that.log.debug.ln('summary located at', that.summaryFile);
- return that.template.renderFile(that.summaryFile)
- .then(function(content) {
- return summary.parser.summary(content, {
- entryPoint: that.readmeFile,
- entryPointTitle: that.i18n('SUMMARY_INTRODUCTION'),
- files: that.files
- })
- .fail(function(err) {
- throw that.normError(err, {
- name: err.name || 'Summary Parse Error',
- fileName: that.summaryFile
- });
- });
- });
- })
- .then(function(summary) {
- that.summary = summary;
- that.navigation = parseNavigation(that.summary, that.files);
- });
-};
-
-// Parse glossary to extract terms
-Book.prototype.parseGlossary = function() {
- var that = this;
-
- var filename = that.config.getStructure('glossary', true);
- that.log.debug.ln('start parsing glossary: ', filename);
-
- return that.findFile(filename)
- .then(function(glossary) {
- if (!glossary) return [];
-
- // Remove the glossary from the list of files to parse
- that.glossaryFile = glossary.path;
- that._defaultsStructure(that.glossaryFile);
- that.files = _.without(that.files, that.glossaryFile);
-
- that.log.debug.ln('glossary located at', that.glossaryFile);
- return that.template.renderFile(that.glossaryFile)
- .then(function(content) {
- return glossary.parser.glossary(content)
- .fail(function(err) {
- throw that.normError(err, {
- name: err.name || 'Glossary Parse Error',
- fileName: that.glossaryFile
- });
- });
- });
- })
- .then(function(glossary) {
- that.glossary = glossary;
- });
+// Read a file in the book, throw error if ignored
+Book.prototype.readFile = function(filename) {
+ if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename }));
+ return this.fs.readAsString(this.resolve(filename));
};
-// Parse a page
-Book.prototype.parsePage = function(filename, options) {
- var that = this, page = {};
- options = _.defaults(options || {}, {
- // Transform svg images
- convertImages: false,
-
- // Interpolate before templating
- interpolateTemplate: _.identity,
-
- // Interpolate after templating
- interpolateContent: _.identity
- });
-
- var interpolate = function(fn) {
- return Q(fn(page))
- .then(function(_page) {
- page = _page || page;
- });
- };
-
- that.log.debug.ln('start parsing file', filename);
-
- var extension = path.extname(filename);
- var filetype = parsers.get(extension);
-
- if (!filetype) return Q.reject(new Error('Can\'t parse file: '+filename));
-
- // Type of parser used
- page.type = filetype.name;
-
- // Path relative to book
- page.path = filename;
-
- // Path absolute in the system
- page.rawPath = path.resolve(that.root, filename);
-
- // Progress in the book
- page.progress = parseProgress(that.navigation, filename);
-
- that.log.debug.ln('render template', filename);
-
- // Read file content
- return that.readFile(page.path)
- .then(function(content) {
- page.content = content;
-
- return interpolate(options.interpolateTemplate);
- })
-
- // Prepare page markup
- .then(function() {
- return filetype.page.prepare(page.content)
- .then(function(content) {
- page.content = content;
- });
- })
-
- // Generate template
- .then(function() {
- return that.template.renderPage(page);
- })
-
- // Prepare and Parse markup
- .then(function(content) {
- page.content = content;
-
- that.log.debug.ln('use file parser', filetype.name, 'for', filename);
- return filetype.page(page.content);
- })
-
- // Post process sections
- .then(function(_page) {
- return _.reduce(_page.sections, function(prev, section) {
- return prev.then(function(_sections) {
- return that.template.postProcess(section.content || '')
- .then(function(content) {
- section.content = content;
- return _sections.concat([section]);
- });
- });
- }, Q([]));
- })
-
- // Prepare html
- .then(function(_sections) {
- return pageUtil.normalize(_sections, {
- book: that,
- convertImages: options.convertImages,
- input: filename,
- navigation: that.navigation,
- base: path.dirname(filename) || './',
- output: path.dirname(filename) || './',
- glossary: that.glossary
- });
- })
-
- // Interpolate output
- .then(function(_sections) {
- page.sections = _sections;
- return interpolate(options.interpolateContent);
- })
-
- .then(function() {
- return page;
- });
+// Get stat infos about a file
+Book.prototype.statFile = function(filename) {
+ if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename }));
+ return this.fs.stat(this.resolve(filename));
};
-// Find file that can be parsed with a specific filename
-Book.prototype.findFile = function(filename) {
+// Find a parsable file using a filename
+Book.prototype.findParsableFile = function(filename) {
var that = this;
var ext = path.extname(filename);
@@ -614,7 +285,7 @@ Book.prototype.findFile = function(filename) {
var filepath = basename+ext;
- return fs.findFile(that.root, filepath)
+ return that.fs.findFile(that.root, filepath)
.then(function(realFilepath) {
if (!realFilepath) return null;
@@ -624,200 +295,64 @@ Book.prototype.findFile = function(filename) {
};
});
});
- }, Q(null));
+ }, Promise(null));
};
-// Format a string using a specific markup language
-Book.prototype.formatString = function(extension, content) {
- return Q()
- .then(function() {
- var filetype = parsers.get(extension);
- if (!filetype) throw new Error('Filetype doesn\'t exist: '+filetype);
-
- return filetype.page(content);
- })
-
- // Merge sections
- .then(function(page) {
- return _.reduce(page.sections, function(content, section) {
- return content + section.content;
- }, '');
- });
-};
-
-// Check if a file exists in the book
-Book.prototype.fileExists = function(filename) {
- return fs.exists(
- this.resolve(filename)
- );
+// Return true if book is associated to a language
+Book.prototype.isLanguageBook = function() {
+ return Boolean(this.parent);
};
+Book.prototype.isSubBook = Book.prototype.isLanguageBook;
-// Check if a file path is inside the book
-Book.prototype.fileIsInBook = function(filename) {
- return pathUtil.isInRoot(this.root, filename);
+// Return true if the book is main instance of a multilingual book
+Book.prototype.isMultilingual = function() {
+ return this.langs.count() > 0;
};
-// Read a file
-Book.prototype.readFile = function(filename) {
- return fs.readFile(
- this.resolve(filename),
- { encoding: 'utf8' }
+// Return true if file is in the scope of this book
+Book.prototype.isInBook = function(filename) {
+ return pathUtil.isInRoot(
+ this.root,
+ filename
);
};
-// Return stat for a file
-Book.prototype.statFile = function(filename) {
- return fs.stat(this.resolve(filename));
-};
-
-// List all files in the book
-Book.prototype.listAllFiles = function() {
+// Return true if file is in the scope of a child book
+Book.prototype.isInLanguageBook = function(filename) {
var that = this;
- return fs.list(this.root, {
- ignoreFiles: ['.ignore', '.gitignore', '.bookignore'],
- ignoreRules: [
- // Skip Git stuff
- '.git/',
- '.gitignore',
-
- // Skip OS X meta data
- '.DS_Store',
-
- // Skip stuff installed by plugins
- 'node_modules',
-
- // Skip book outputs
- '_book',
- '*.pdf',
- '*.epub',
- '*.mobi',
-
- // Skip config files
- '.ignore',
- '.bookignore',
- 'book.json',
- ]
- })
- .then(function(_files) {
- that.files = _files;
+ return _.some(this.langs.list(), function(lang) {
+ return pathUtil.isInRoot(
+ that.resolve(lang.id),
+ that.resolve(filename)
+ );
});
};
-// Return true if the book is a multilingual book
-Book.prototype.isMultilingual = function() {
- return this.books.length > 0;
-};
-
-// Return root of the parent
-Book.prototype.parentRoot = function() {
- if (this.parent) return this.parent.parentRoot();
- return this.root;
-};
-
-// Return true if it's a sub-book
-Book.prototype.isSubBook = function() {
- return !!this.parent;
-};
-
-// Test if the file is the entry point
-Book.prototype.isEntryPoint = function(fp) {
- return fp == this.readmeFile;
-};
-
-// Alias to book.config.get
-Book.prototype.getConfig = function(key, def) {
- return this.config.get(key, def);
-};
-
-// Resolve a path in the book source
-// Enforce that the output path in the root folder
-Book.prototype.resolve = function() {
- return pathUtil.resolveInRoot.apply(null, [this.root].concat(_.toArray(arguments)));
-};
-
-// Resolve a path in the book output
-// Enforce that the output path in the output folder
-Book.prototype.resolveOutput = function() {
- return pathUtil.resolveInRoot.apply(null, [this.options.output].concat(_.toArray(arguments)));
-};
-
-// Convert an abslute path into a relative path to this
-Book.prototype.relative = function(p) {
- return path.relative(this.root, p);
-};
-
-// Normalize a path to .html and convert README -> index
-Book.prototype.contentPath = function(link) {
- if (
- path.basename(link, path.extname(link)) == 'README' ||
- link == this.readmeFile
- ) {
- link = path.join(path.dirname(link), 'index'+path.extname(link));
- }
-
- link = links.changeExtension(link, '.html');
- return link;
-};
-
-// Normalize a link to .html and convert README -> index
-Book.prototype.contentLink = function(link) {
- return links.normalize(this.contentPath(link));
-};
-
-// Default structure paths to an extension
-Book.prototype._defaultsStructure = function(filename) {
- var that = this;
- var extension = path.extname(filename);
-
- that.readmeFile = that.readmeFile || that.config.getStructure('readme')+extension;
- that.summaryFile = that.summaryFile || that.config.getStructure('summary')+extension;
- that.glossaryFile = that.glossaryFile || that.config.getStructure('glossary')+extension;
- that.langsFile = that.langsFile || that.config.getStructure('langs')+extension;
-};
-
-// Change output path
-Book.prototype.setOutput = function(p) {
- var that = this;
- this.options.output = path.resolve(p);
-
- _.each(this.books, function(book) {
- book.setOutput(path.join(that.options.output, book.options.language));
+// Locate a book in a folder
+// - Read the ".gitbook" is exists
+// - Try the folder itself
+// - Try a "docs" folder
+Book.locate = function(fs, root) {
+ return fs.readAsString(path.join(root, '.gitbook'))
+ .then(function(content) {
+ return path.join(root, content);
+ }, function() {
+ // .gitbook doesn't exists, fall back to the root folder
+ return Promise(root);
});
};
-// Translate a strign according to the book language
-Book.prototype.i18n = function() {
- var args = Array.prototype.slice.call(arguments);
- return i18n.__.apply({}, [this.config.normalizeLanguage()].concat(args));
-};
-
-// Normalize error
-Book.prototype.normError = function(err, opts, defs) {
- if (_.isString(err)) err = new Error(err);
-
- // Extend err
- _.extend(err, opts || {});
- _.defaults(err, defs || {});
-
- err.lineNumber = err.lineNumber || err.lineno;
- err.columnNumber = err.columnNumber || err.colno;
-
- err.toString = function() {
- var attributes = [];
-
- if (this.fileName) attributes.push('In file \''+this.fileName+'\'');
- if (this.lineNumber) attributes.push('Line '+this.lineNumber);
- if (this.columnNumber) attributes.push('Column '+this.columnNumber);
- return (this.name || 'Error')+': '+this.message+((attributes.length > 0)? ' ('+attributes.join(', ')+')' : '');
- };
-
- return err;
+// Locate and setup a book
+Book.setup = function(fs, root, opts) {
+ return Book.locate(fs, root)
+ .then(function(_root) {
+ return new Book(_.extend(opts || {}, {
+ root: _root,
+ fs: fs
+ }));
+ });
};
-// Call a hook in plugins
-Book.prototype.callHook = function(name, data) {
- return this.plugins.hook(name, data);
-};
-module.exports= Book;
+module.exports = Book;
diff --git a/lib/cli/helper.js b/lib/cli/helper.js
new file mode 100644
index 0000000..e4dc8da
--- /dev/null
+++ b/lib/cli/helper.js
@@ -0,0 +1,139 @@
+var _ = require('lodash');
+var path = require('path');
+
+var Book = require('../book');
+var NodeFS = require('../fs/node');
+var Logger = require('../utils/logger');
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var JSONOutput = require('../output/json');
+var WebsiteOutput = require('../output/website');
+var EBookOutput = require('../output/ebook');
+
+var nodeFS = new NodeFS();
+
+var LOG_OPTION = {
+ name: 'log',
+ description: 'Minimum log level to display',
+ values: _.chain(Logger.LEVELS)
+ .keys()
+ .map(function(s) {
+ return s.toLowerCase();
+ })
+ .value(),
+ defaults: 'info'
+};
+
+var FORMAT_OPTION = {
+ name: 'format',
+ description: 'Format to build to',
+ values: ['website', 'json', 'ebook'],
+ defaults: 'website'
+};
+
+var FORMATS = {
+ json: JSONOutput,
+ website: WebsiteOutput,
+ ebook: EBookOutput
+};
+
+// Commands which is processing a book
+// the root of the book is the first argument (or current directory)
+function bookCmd(fn) {
+ return function(args, kwargs) {
+ var input = path.resolve(args[0] || process.cwd());
+ return Book.setup(nodeFS, input, {
+ logLevel: kwargs.log
+ })
+ .then(function(book) {
+ return fn(book, args.slice(1), kwargs);
+ });
+ };
+}
+
+// Commands which is working on a Output instance
+function outputCmd(fn) {
+ return bookCmd(function(book, args, kwargs) {
+ var Out = FORMATS[kwargs.format];
+ var outputFolder = undefined;
+
+ // Set output folder
+ if (args[0]) {
+ outputFolder = path.resolve(process.cwd(), args[0]);
+ }
+
+ return fn(new Out(book, {
+ root: outputFolder
+ }), args);
+ });
+}
+
+// Command to generate an ebook
+function ebookCmd(format) {
+ return {
+ name: format + ' [book] [output] [file]',
+ description: 'generates ebook '+format,
+ options: [
+ LOG_OPTION
+ ],
+ exec: bookCmd(function(book, args, kwargs) {
+ return fs.tmpDir()
+ .then(function(dir) {
+ var ext = '.'+format;
+ var outputFile = path.resolve(process.cwd(), args[1] || ('book' + ext));
+ var output = new EBookOutput(book, {
+ root: dir,
+ format: format
+ });
+
+ return output.book.parse()
+ .then(function() {
+ return output.generate();
+ })
+
+ // Copy the ebook files
+ .then(function() {
+ if (output.book.isMultilingual()) {
+ return Promise.serie(output.book.langs.list(), function(lang) {
+ var _outputFile = path.join(
+ path.dirname(outputFile),
+ path.basename(outputFile, ext) + '_' + lang.id + ext
+ );
+
+ return fs.copy(
+ path.resolve(dir, lang.id, 'index' + ext),
+ _outputFile
+ );
+ })
+ .thenResolve(output.book.langs.count());
+ } else {
+ return fs.copy(
+ path.resolve(dir, 'index' + ext),
+ outputFile
+ ).thenResolve(1);
+ }
+ })
+ .then(function(n) {
+ output.book.log.info.ok(n+' file(s) generated');
+
+ output.book.log.info('cleaning up... ');
+ return output.book.log.info.promise(fs.rmDir(dir));
+ });
+ });
+ })
+ };
+}
+
+module.exports = {
+ nodeFS: nodeFS,
+ bookCmd: bookCmd,
+ outputCmd: outputCmd,
+ ebookCmd: ebookCmd,
+
+ options: {
+ log: LOG_OPTION,
+ format: FORMAT_OPTION
+ },
+
+ FORMATS: FORMATS
+};
diff --git a/lib/cli/index.js b/lib/cli/index.js
new file mode 100644
index 0000000..4d3d364
--- /dev/null
+++ b/lib/cli/index.js
@@ -0,0 +1,187 @@
+/* eslint-disable no-console */
+
+var _ = require('lodash');
+var path = require('path');
+var tinylr = require('tiny-lr');
+
+var Promise = require('../utils/promise');
+var PluginsManager = require('../plugins');
+var Book = require('../book');
+
+var helper = require('./helper');
+var Server = require('./server');
+var watch = require('./watch');
+
+module.exports = {
+ commands: [
+
+ {
+ name: 'parse [book]',
+ description: 'parse and returns debug information for a book',
+ options: [
+ helper.options.log
+ ],
+ exec: helper.bookCmd(function(book) {
+ return book.parse()
+ .then(function() {
+ book.log.info.ln('Book located in:', book.root);
+ book.log.info.ln('');
+
+ if (book.config.exists()) book.log.info.ln('Configuration:', book.config.path);
+
+ if (book.isMultilingual()) {
+ book.log.info.ln('Multilingual book detected:', book.langs.path);
+ } else {
+ book.log.info.ln('Readme:', book.readme.path);
+ book.log.info.ln('Summary:', book.summary.path);
+ if (book.glossary.exists()) book.log.info.ln('Glossary:', book.glossary.path);
+
+ book.log.info.ln('Pages:');
+ _.each(book.pages, function(page) {
+ book.log.info.ln('\t-', page.path);
+ });
+ }
+ });
+ })
+ },
+
+ {
+ name: 'install [book]',
+ description: 'install all plugins dependencies',
+ options: [
+ helper.options.log
+ ],
+ exec: helper.bookCmd(function(book, args) {
+ var plugins = new PluginsManager(book);
+
+ return book.config.load()
+ .then(function() {
+ return plugins.install();
+ });
+ })
+ },
+
+ {
+ name: 'build [book] [output]',
+ description: 'build a book',
+ options: [
+ helper.options.log,
+ helper.options.format
+ ],
+ exec: helper.outputCmd(function(output, args, kwargs) {
+ return output.book.parse()
+ .then(function() {
+ return output.generate();
+ });
+ })
+ },
+
+ helper.ebookCmd('pdf'),
+ helper.ebookCmd('epub'),
+ helper.ebookCmd('mobi'),
+
+ {
+ name: 'serve [book]',
+ description: 'Build then serve a book from a directory',
+ options: [
+ {
+ name: 'port',
+ description: 'Port for server to listen on',
+ defaults: 4000
+ },
+ {
+ name: 'lrport',
+ description: 'Port for livereload server to listen on',
+ defaults: 35729
+ },
+ {
+ name: 'watch',
+ description: 'Enable/disable file watcher',
+ defaults: true
+ },
+ helper.options.format,
+ helper.options.log
+ ],
+ exec: function(args, kwargs) {
+ var input = path.resolve(args[0] || process.cwd());
+ var server = new Server();
+
+ // Init livereload server
+ var lrServer = tinylr({});
+ var port = kwargs.port;
+ var lrPath;
+
+ var generate = function() {
+
+ // Stop server if running
+ if (server.isRunning()) console.log('Stopping server');
+ return server.stop()
+
+ // Generate the book
+ .then(function() {
+ return Book.setup(helper.nodeFS, input, {
+ 'logLevel': kwargs.log
+ })
+ .then(function(book) {
+ return book.parse()
+ .then(function() {
+ // Add livereload plugin
+ book.config.set('plugins',
+ book.config.get('plugins')
+ .concat([
+ { name: 'livereload' }
+ ])
+ );
+
+ var Out = helper.FORMATS[kwargs.format];
+ var output = new Out(book);
+
+ return output.generate()
+ .thenResolve(output);
+ });
+ });
+ })
+
+ // Start server and watch changes
+ .then(function(output) {
+ console.log();
+ console.log('Starting server ...');
+ return server.start(output.root(), port)
+ .then(function() {
+ console.log('Serving book on http://localhost:'+port);
+
+ if (lrPath) {
+ // trigger livereload
+ lrServer.changed({
+ body: {
+ files: [lrPath]
+ }
+ });
+ }
+
+ if (!kwargs.watch) return;
+
+ return watch(output.book.root)
+ .then(function(filepath) {
+ // set livereload path
+ lrPath = filepath;
+ console.log('Restart after change in file', filepath);
+ console.log('');
+ return generate();
+ });
+ });
+ });
+ };
+
+ return Promise.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport)
+ .then(function() {
+ console.log('Live reload server started on port:', kwargs.lrport);
+ console.log('Press CTRL+C to quit ...');
+ console.log('');
+ return generate();
+ });
+ }
+ }
+
+ ]
+};
diff --git a/lib/utils/server.js b/lib/cli/server.js
index 1d6822f..3bd5d18 100644
--- a/lib/utils/server.js
+++ b/lib/cli/server.js
@@ -1,9 +1,10 @@
-var Q = require("q");
-var events = require("events");
-var http = require("http");
-var send = require("send");
-var util = require("util");
-var url = require("url");
+var events = require('events');
+var http = require('http');
+var send = require('send');
+var util = require('util');
+var url = require('url');
+
+var Promise = require('../utils/promise');
var Server = function() {
this.running = null;
@@ -21,12 +22,12 @@ Server.prototype.isRunning = function() {
// Stop the server
Server.prototype.stop = function() {
var that = this;
- if (!this.isRunning()) return Q();
+ if (!this.isRunning()) return Promise();
- var d = Q.defer();
+ var d = Promise.defer();
this.running.close(function(err) {
that.running = null;
- that.emit("state", false);
+ that.emit('state', false);
if (err) d.reject(err);
else d.resolve();
@@ -40,13 +41,13 @@ Server.prototype.stop = function() {
};
Server.prototype.start = function(dir, port) {
- var that = this, pre = Q();
+ var that = this, pre = Promise();
port = port || 8004;
if (that.isRunning()) pre = this.stop();
return pre
.then(function() {
- var d = Q.defer();
+ var d = Promise.defer();
that.running = http.createServer(function(req, res){
// Render error
@@ -55,25 +56,25 @@ Server.prototype.start = function(dir, port) {
res.end(err.message);
}
- // Redirect to directory"s index.html
+ // Redirect to directory's index.html
function redirect() {
res.statusCode = 301;
- res.setHeader("Location", req.url + "/");
- res.end("Redirecting to " + req.url + "/");
+ res.setHeader('Location', req.url + '/');
+ res.end('Redirecting to ' + req.url + '/');
}
// Send file
send(req, url.parse(req.url).pathname)
.root(dir)
- .on("error", error)
- .on("directory", redirect)
+ .on('error', error)
+ .on('directory', redirect)
.pipe(res);
});
- that.running.on("connection", function (socket) {
+ that.running.on('connection', function (socket) {
that.sockets.push(socket);
socket.setTimeout(4000);
- socket.on("close", function () {
+ socket.on('close', function () {
that.sockets.splice(that.sockets.indexOf(socket), 1);
});
});
@@ -83,7 +84,7 @@ Server.prototype.start = function(dir, port) {
that.port = port;
that.dir = dir;
- that.emit("state", true);
+ that.emit('state', true);
d.resolve();
});
diff --git a/lib/cli/watch.js b/lib/cli/watch.js
new file mode 100644
index 0000000..b98faeb
--- /dev/null
+++ b/lib/cli/watch.js
@@ -0,0 +1,42 @@
+var _ = require('lodash');
+var path = require('path');
+var chokidar = require('chokidar');
+
+var Promise = require('../utils/promise');
+var parsers = require('../parsers');
+
+// Watch a folder and resolve promise once a file is modified
+function watch(dir) {
+ var d = Promise.defer();
+ dir = path.resolve(dir);
+
+ var toWatch = [
+ 'book.json', 'book.js'
+ ];
+
+ // Watch all parsable files
+ _.each(parsers.extensions, function(ext) {
+ toWatch.push('**/*'+ext);
+ });
+
+ var watcher = chokidar.watch(toWatch, {
+ cwd: dir,
+ ignored: '_book/**',
+ ignoreInitial: true
+ });
+
+ watcher.once('all', function(e, filepath) {
+ watcher.close();
+
+ d.resolve(filepath);
+ });
+ watcher.once('error', function(err) {
+ watcher.close();
+
+ d.reject(err);
+ });
+
+ return d.promise;
+}
+
+module.exports = watch;
diff --git a/lib/config/index.js b/lib/config/index.js
new file mode 100644
index 0000000..7f75733
--- /dev/null
+++ b/lib/config/index.js
@@ -0,0 +1,132 @@
+var _ = require('lodash');
+var semver = require('semver');
+
+var gitbook = require('../gitbook');
+var Promise = require('../utils/promise');
+var validator = require('./validator');
+var plugins = require('./plugins');
+
+// Config files to tested (sorted)
+var CONFIG_FILES = [
+ 'book.js',
+ 'book.json'
+];
+
+/*
+Config is an interface for the book's configuration stored in "book.json" (or "book.js")
+*/
+
+function Config(book, baseConfig) {
+ this.book = book;
+ this.fs = book.fs;
+ this.log = book.log;
+ this.path = '';
+
+ this.baseConfig = baseConfig || {};
+ this.replace({});
+}
+
+// Load configuration of the book
+// and verify that the configuration is satisfying
+Config.prototype.load = function() {
+ var that = this;
+ var isLanguageBook = this.book.isLanguageBook();
+
+ // Try all potential configuration file
+ return Promise.some(CONFIG_FILES, function(filename) {
+ that.log.debug.ln('try loading configuration from', filename);
+
+ return that.fs.loadAsObject(that.book.resolve(filename))
+ .then(function(_config) {
+ that.log.debug.ln('configuration loaded from', filename);
+
+ that.path = filename;
+ return that.replace(_config);
+ })
+ .fail(function(err) {
+ if (err.code != 'MODULE_NOT_FOUND') throw(err);
+ else return Promise(false);
+ });
+ })
+ .then(function() {
+ if (!isLanguageBook) {
+ if (!gitbook.satisfies(that.options.gitbook)) {
+ throw new Error('GitBook version doesn\'t satisfy version required by the book: '+that.options.gitbook);
+ }
+ if (that.options.gitbook != '*' && !semver.satisfies(semver.inc(gitbook.version, 'patch'), that.options.gitbook)) {
+ that.log.warn.ln('gitbook version specified in your book.json might be too strict for future patches, \''+(_.first(gitbook.version.split('.'))+'.x.x')+'\' is more adequate');
+ }
+
+ that.options.plugins = plugins.toList(that.options.plugins);
+ } else {
+ // Multilingual book should inherits the plugins list from parent
+ that.options.plugins = that.book.parent.config.get('plugins');
+ }
+
+ that.options.gitbook = gitbook.version;
+ });
+};
+
+// Replace the whole configuration
+Config.prototype.replace = function(options) {
+ var that = this;
+
+ // Extend base config
+ options = _.defaults(_.cloneDeep(options), this.baseConfig);
+
+ // Validate the config
+ this.options = validator.validate(options);
+
+ // options.input == book.root
+ Object.defineProperty(this.options, 'input', {
+ get: function () {
+ return that.book.root;
+ }
+ });
+
+ // options.originalInput == book.parent.root
+ Object.defineProperty(this.options, 'originalInput', {
+ get: function () {
+ return that.book.parent? that.book.parent.root : undefined;
+ }
+ });
+};
+
+// Return true if book has a configuration file
+Config.prototype.exists = function() {
+ return Boolean(this.path);
+};
+
+// Return path to a structure file
+// Strip the extension by default
+Config.prototype.getStructure = function(name, dontStripExt) {
+ var filename = this.options.structure[name];
+ if (dontStripExt) return filename;
+
+ filename = filename.split('.').slice(0, -1).join('.');
+ return filename;
+};
+
+// Return a configuration using a key and a default value
+Config.prototype.get = function(key, def) {
+ return _.get(this.options, key, def);
+};
+
+// Update a configuration
+Config.prototype.set = function(key, value) {
+ return _.set(this.options, key, value);
+};
+
+// Return a dump of the configuration
+Config.prototype.dump = function() {
+ return _.cloneDeep(this.options);
+};
+
+// Return templating context
+Config.prototype.getContext = function() {
+ return {
+ config: this.book.config.dump()
+ };
+};
+
+module.exports = Config;
diff --git a/lib/config/plugins.js b/lib/config/plugins.js
new file mode 100644
index 0000000..5d98736
--- /dev/null
+++ b/lib/config/plugins.js
@@ -0,0 +1,67 @@
+var _ = require('lodash');
+
+// Default plugins added to each books
+var DEFAULT_PLUGINS = ['highlight', 'search', 'sharing', 'fontsettings', 'theme-default'];
+
+// Return true if a plugin is a default plugin
+function isDefaultPlugin(name, version) {
+ return _.contains(DEFAULT_PLUGINS, name);
+}
+
+// Normalize a list of plugins to use
+function normalizePluginsList(plugins) {
+ // Normalize list to an array
+ plugins = _.isString(plugins) ? plugins.split(',') : (plugins || []);
+
+ // Remove empty parts
+ plugins = _.compact(plugins);
+
+ // Divide as {name, version} to handle format like 'myplugin@1.0.0'
+ plugins = _.map(plugins, function(plugin) {
+ if (plugin.name) return plugin;
+
+ var parts = plugin.split('@');
+ var name = parts[0];
+ var version = parts[1];
+ return {
+ 'name': name,
+ 'version': version // optional
+ };
+ });
+
+ // List plugins to remove
+ var toremove = _.chain(plugins)
+ .filter(function(plugin) {
+ return plugin.name.length > 0 && plugin.name[0] == '-';
+ })
+ .map(function(plugin) {
+ return plugin.name.slice(1);
+ })
+ .value();
+
+ // Merge with defaults
+ _.each(DEFAULT_PLUGINS, function(plugin) {
+ if (_.find(plugins, { name: plugin })) {
+ return;
+ }
+
+ plugins.push({
+ 'name': plugin
+ });
+ });
+ // Remove plugin that start with '-'
+ plugins = _.filter(plugins, function(plugin) {
+ return !_.contains(toremove, plugin.name) && !(plugin.name.length > 0 && plugin.name[0] == '-');
+ });
+
+ // Remove duplicates
+ plugins = _.uniq(plugins, 'name');
+
+ return plugins;
+}
+
+module.exports = {
+ isDefaultPlugin: isDefaultPlugin,
+ toList: normalizePluginsList
+};
+
diff --git a/lib/config/schema.js b/lib/config/schema.js
new file mode 100644
index 0000000..34a6c76
--- /dev/null
+++ b/lib/config/schema.js
@@ -0,0 +1,188 @@
+module.exports = {
+ '$schema': 'http://json-schema.org/schema#',
+ 'id': 'https://gitbook.com/schemas/book.json',
+ 'title': 'GitBook Configuration',
+ 'type': 'object',
+ 'properties': {
+ 'title': {
+ 'type': 'string',
+ 'title': 'Title of the book, default is extracted from README'
+ },
+ 'title': {
+ 'type': 'string',
+ 'title': 'Description of the book, default is extracted from README'
+ },
+ 'isbn': {
+ 'type': 'string',
+ 'title': 'ISBN for published book'
+ },
+ 'author': {
+ 'type': 'string',
+ 'title': 'Name of the author'
+ },
+ 'gitbook': {
+ 'type': 'string',
+ 'default': '*',
+ 'title': 'GitBook version to match'
+ },
+ 'direction': {
+ 'type': 'string',
+ 'enum': ['ltr', 'rtl'],
+ 'title': 'Direction of texts, default is detected in the pages'
+ },
+ 'theme': {
+ 'type': 'string',
+ 'default': 'default',
+ 'title': 'Name of the theme plugin to use'
+ },
+ 'variables': {
+ 'type': 'object',
+ 'title': 'Templating context variables'
+ },
+ 'plugins': {
+ 'oneOf': [
+ { '$ref': '#/definitions/pluginsArray' },
+ { '$ref': '#/definitions/pluginsString' }
+ ],
+ 'default': []
+ },
+ 'pluginsConfig': {
+ 'type': 'object',
+ 'title': 'Configuration for plugins'
+ },
+ 'structure': {
+ 'type': 'object',
+ 'properties': {
+ 'langs': {
+ 'default': 'LANGS.md',
+ 'type': 'string',
+ 'title': 'File to use as languages index',
+ 'pattern': '^[0-9a-zA-Z ... ]+$'
+ },
+ 'readme': {
+ 'default': 'README.md',
+ 'type': 'string',
+ 'title': 'File to use as preface',
+ 'pattern': '^[0-9a-zA-Z ... ]+$'
+ },
+ 'glossary': {
+ 'default': 'GLOSSARY.md',
+ 'type': 'string',
+ 'title': 'File to use as glossary index',
+ 'pattern': '^[0-9a-zA-Z ... ]+$'
+ },
+ 'summary': {
+ 'default': 'SUMMARY.md',
+ 'type': 'string',
+ 'title': 'File to use as table of contents',
+ 'pattern': '^[0-9a-zA-Z ... ]+$'
+ }
+ },
+ 'additionalProperties': false
+ },
+ 'pdf': {
+ 'type': 'object',
+ 'title': 'PDF specific configurations',
+ 'properties': {
+ 'pageNumbers': {
+ 'type': 'boolean',
+ 'default': true,
+ 'title': 'Add page numbers to the bottom of every page'
+ },
+ 'fontSize': {
+ 'type': 'integer',
+ 'minimum': 8,
+ 'maximum': 30,
+ 'default': 12,
+ 'title': 'Font size for the PDF output'
+ },
+ 'fontFamily': {
+ 'type': 'string',
+ 'default': 'Arial',
+ 'title': 'Font family for the PDF output'
+ },
+ 'paperSize': {
+ 'type': 'string',
+ 'enum': ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'],
+ 'default': 'a4',
+ 'title': 'Paper size for the PDF'
+ },
+ 'chapterMark': {
+ 'type': 'string',
+ 'enum': ['pagebreak', 'rule', 'both', 'none'],
+ 'default': 'pagebreak',
+ 'title': 'How to mark detected chapters'
+ },
+ 'pageBreaksBefore': {
+ 'type': 'string',
+ 'default': '/',
+ 'title': 'An XPath expression. Page breaks are inserted before the specified elements. To disable use the expression: "/"'
+ },
+ 'margin': {
+ 'type': 'object',
+ 'properties': {
+ 'right': {
+ 'type': 'integer',
+ 'title': 'Right Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 62
+ },
+ 'left': {
+ 'type': 'integer',
+ 'title': 'Left Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 62
+ },
+ 'top': {
+ 'type': 'integer',
+ 'title': 'Top Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 56
+ },
+ 'bottom': {
+ 'type': 'integer',
+ 'title': 'Bottom Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 56
+ }
+ }
+ }
+ }
+ }
+ },
+ 'required': [],
+ 'definitions': {
+ 'pluginsArray': {
+ 'type': 'array',
+ 'items': {
+ 'oneOf': [
+ { '$ref': '#/definitions/pluginObject' },
+ { '$ref': '#/definitions/pluginString' }
+ ]
+ }
+ },
+ 'pluginsString': {
+ 'type': 'string'
+ },
+ 'pluginString': {
+ 'type': 'string'
+ },
+ 'pluginObject': {
+ 'type': 'object',
+ 'properties': {
+ 'name': {
+ 'type': 'string'
+ },
+ 'version': {
+ 'type': 'string'
+ }
+ },
+ 'additionalProperties': false,
+ 'required': ['name']
+ }
+ }
+};
diff --git a/lib/config/validator.js b/lib/config/validator.js
new file mode 100644
index 0000000..764b19a
--- /dev/null
+++ b/lib/config/validator.js
@@ -0,0 +1,28 @@
+var jsonschema = require('jsonschema');
+var jsonSchemaDefaults = require('json-schema-defaults');
+var mergeDefaults = require('merge-defaults');
+
+var schema = require('./schema');
+var error = require('../utils/error');
+
+// Validate a book.json content
+// And return a mix with the default value
+function validate(bookJson) {
+ var v = new jsonschema.Validator();
+ var result = v.validate(bookJson, schema, {
+ propertyName: 'config'
+ });
+
+ // 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(bookJson, defaults);
+}
+
+module.exports = {
+ validate: validate
+};
diff --git a/lib/config_default.js b/lib/config_default.js
deleted file mode 100644
index 604003d..0000000
--- a/lib/config_default.js
+++ /dev/null
@@ -1,109 +0,0 @@
-var path = require('path');
-
-module.exports = {
- // Options that can't be extend
- 'configFile': 'book',
- 'generator': 'website',
- 'extension': null,
-
- // Book metadats (somes are extracted from the README by default)
- 'title': null,
- 'description': null,
- 'isbn': null,
- 'language': 'en',
- 'direction': null,
- 'author': null,
-
- // version of gitbook to use
- 'gitbook': '*',
-
- // Structure
- 'structure': {
- 'langs': 'LANGS.md',
- 'readme': 'README.md',
- 'glossary': 'GLOSSARY.md',
- 'summary': 'SUMMARY.md'
- },
-
- // CSS Styles
- 'styles': {
- 'website': 'styles/website.css',
- 'print': 'styles/print.css',
- 'ebook': 'styles/ebook.css',
- 'pdf': 'styles/pdf.css',
- 'mobi': 'styles/mobi.css',
- 'epub': 'styles/epub.css'
- },
-
- // Plugins list, can contain '-name' for removing default plugins
- 'plugins': [],
-
- // Global configuration for plugins
- 'pluginsConfig': {},
-
- // Variables for templating
- 'variables': {},
-
- // Set another theme with your own layout
- // It's recommended to use plugins or add more options for default theme, though
- // See https://github.com/GitbookIO/gitbook/issues/209
- 'theme': path.resolve(__dirname, '../theme'),
-
- // Links in template (null: default, false: remove, string: new value)
- 'links': {
- // Custom links at top of sidebar
- 'sidebar': {
- // 'Custom link name': 'https://customlink.com'
- },
-
- // Sharing links
- 'sharing': {
- 'google': null,
- 'facebook': null,
- 'twitter': null,
- 'weibo': null,
- 'all': null
- }
- },
-
-
- // Options for PDF generation
- 'pdf': {
- // Add toc at the end of the file
- 'toc': true,
-
- // Add page numbers to the bottom of every page
- 'pageNumbers': false,
-
- // Font for the file content
- 'fontSize': 12,
- 'fontFamily': 'Arial',
-
- // Paper size for the pdf
- // Choices are [u’a0’, u’a1’, u’a2’, u’a3’, u’a4’, u’a5’, u’a6’, u’b0’, u’b1’, u’b2’, u’b3’, u’b4’, u’b5’, u’b6’, u’legal’, u’letter’]
- 'paperSize': 'a4',
-
- // How to mark detected chapters.
- // Choices are “pagebreak”, “rule”, 'both' or “none”.
- 'chapterMark' : 'pagebreak',
-
- // An XPath expression. Page breaks are inserted before the specified elements.
- // To disable use the expression: '/'
- 'pageBreaksBefore': '/',
-
- // Margin (in pts)
- // Note: 72 pts equals 1 inch
- 'margin': {
- 'right': 62,
- 'left': 62,
- 'top': 56,
- 'bottom': 56
- },
-
- // Header HTML template. Available variables: _PAGENUM_, _TITLE_, _AUTHOR_ and _SECTION_.
- 'headerTemplate': null,
-
- // Footer HTML template. Available variables: _PAGENUM_, _TITLE_, _AUTHOR_ and _SECTION_.
- 'footerTemplate': null
- }
-};
diff --git a/lib/configuration.js b/lib/configuration.js
deleted file mode 100644
index dd95585..0000000
--- a/lib/configuration.js
+++ /dev/null
@@ -1,210 +0,0 @@
-var _ = require('lodash');
-var Q = require('q');
-var path = require('path');
-var semver = require('semver');
-
-var pkg = require('../package.json');
-var i18n = require('./utils/i18n');
-var version = require('./version');
-
-var DEFAULT_CONFIG = require('./config_default');
-
-// Default plugins added to each books
-var DEFAULT_PLUGINS = ['highlight', 'search', 'sharing', 'fontsettings'];
-
-// Check if a plugin is a default plugin
-// Plugin should be in the list
-// And version from book.json specified for this plugin should be satisfied
-function isDefaultPlugin(name, version) {
- if (!_.contains(DEFAULT_PLUGINS, name)) return false;
-
- try {
- var pluginPkg = require('gitbook-plugin-'+name+'/package.json');
- return semver.satisfies(pluginPkg.version, version || '*');
- } catch(e) {
- return false;
- }
-}
-
-// Normalize a list of plugins to use
-function normalizePluginsList(plugins, addDefaults) {
- // Normalize list to an array
- plugins = _.isString(plugins) ? plugins.split(',') : (plugins || []);
-
- // Remove empty parts
- plugins = _.compact(plugins);
-
- // Divide as {name, version} to handle format like 'myplugin@1.0.0'
- plugins = _.map(plugins, function(plugin) {
- if (plugin.name) return plugin;
-
- var parts = plugin.split('@');
- var name = parts[0];
- var version = parts[1];
- return {
- 'name': name,
- 'version': version, // optional
- 'isDefault': isDefaultPlugin(name, version)
- };
- });
-
- // List plugins to remove
- var toremove = _.chain(plugins)
- .filter(function(plugin) {
- return plugin.name.length > 0 && plugin.name[0] == '-';
- })
- .map(function(plugin) {
- return plugin.name.slice(1);
- })
- .value();
-
- // Merge with defaults
- if (addDefaults !== false) {
- _.each(DEFAULT_PLUGINS, function(plugin) {
- if (_.find(plugins, { name: plugin })) {
- return;
- }
-
- plugins.push({
- 'name': plugin,
- 'isDefault': true
- });
- });
- }
-
- // Remove plugin that start with '-'
- plugins = _.filter(plugins, function(plugin) {
- return !_.contains(toremove, plugin.name) && !(plugin.name.length > 0 && plugin.name[0] == '-');
- });
-
- // Remove duplicates
- plugins = _.uniq(plugins, 'name');
-
- return plugins;
-}
-
-var Configuration = function(book, options) {
- this.book = book;
- this.replace(options);
-};
-
-// Read and parse the configuration
-Configuration.prototype.load = function() {
- var that = this;
-
- return Q()
- .then(function() {
- var configPath, _config;
-
- try {
- configPath = require.resolve(
- that.book.resolve(that.options.configFile)
- );
-
- // Invalidate node.js cache for livreloading
- delete require.cache[configPath];
-
- _config = require(configPath);
- that.options = _.merge(
- that.options,
- _.omit(_config, 'configFile', 'defaultsPlugins', 'generator', 'extension')
- );
- }
- catch(err) {
- if (err instanceof SyntaxError) return Q.reject(err);
- return Q();
- }
- })
- .then(function() {
- if (!that.book.isSubBook()) {
- if (!version.satisfies(that.options.gitbook)) {
- throw new Error('GitBook version doesn\'t satisfy version required by the book: '+that.options.gitbook);
- }
- if (that.options.gitbook != '*' && !semver.satisfies(semver.inc(pkg.version, 'patch'), that.options.gitbook)) {
- that.book.log.warn.ln('gitbook version specified in your book.json might be too strict for future patches, \''+(_.first(pkg.version.split('.'))+'.x.x')+'\' is more adequate');
- }
- }
-
- that.options.output = path.resolve(that.options.output || that.book.resolve('_book'));
- that.options.plugins = normalizePluginsList(that.options.plugins);
- that.options.defaultsPlugins = normalizePluginsList(that.options.defaultsPlugins || '', false);
- that.options.plugins = _.union(that.options.plugins, that.options.defaultsPlugins);
- that.options.plugins = _.uniq(that.options.plugins, 'name');
-
- // Default value for text direction (from language)
- if (!that.options.direction) {
- var lang = i18n.getCatalog(that.options.language);
- if (lang) that.options.direction = lang.direction;
- }
-
- that.options.gitbook = pkg.version;
- });
-};
-
-// Extend the configuration
-Configuration.prototype.extend = function(options) {
- _.extend(this.options, options);
-};
-
-// Replace the whole configuration
-Configuration.prototype.replace = function(options) {
- var that = this;
-
- this.options = _.cloneDeep(DEFAULT_CONFIG);
- this.options = _.merge(this.options, options || {});
-
- // options.input == book.root
- Object.defineProperty(this.options, 'input', {
- get: function () {
- return that.book.root;
- }
- });
-
- // options.originalInput == book.parent.root
- Object.defineProperty(this.options, 'originalInput', {
- get: function () {
- return that.book.parent? that.book.parent.root : undefined;
- }
- });
-
- // options.originalOutput == book.parent.options.output
- Object.defineProperty(this.options, 'originalOutput', {
- get: function () {
- return that.book.parent? that.book.parent.options.output : undefined;
- }
- });
-};
-
-// Dump configuration as json object
-Configuration.prototype.dump = function() {
- return _.cloneDeep(this.options);
-};
-
-// Get structure file
-Configuration.prototype.getStructure = function(name, dontStripExt) {
- var filename = this.options.structure[name];
- if (dontStripExt) return filename;
-
- filename = filename.split('.').slice(0, -1).join('.');
- return filename;
-};
-
-// Return normalized language
-Configuration.prototype.normalizeLanguage = function() {
- return i18n.normalizeLanguage(this.options.language);
-};
-
-// Return a configuration
-Configuration.prototype.get = function(key, def) {
- return _.get(this.options, key, def);
-};
-
-// Update a configuration
-Configuration.prototype.set = function(key, value) {
- return _.set(this.options, key, value);
-};
-
-// Default configuration
-Configuration.DEFAULT = DEFAULT_CONFIG;
-
-module.exports= Configuration;
diff --git a/lib/conrefs_loader.js b/lib/conrefs_loader.js
deleted file mode 100644
index 255bf06..0000000
--- a/lib/conrefs_loader.js
+++ /dev/null
@@ -1,73 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var nunjucks = require('nunjucks');
-
-var git = require('./utils/git');
-var fs = require('./utils/fs');
-var pathUtil = require('./utils/path');
-
-// The loader should handle relative and git url
-var BookLoader = nunjucks.Loader.extend({
- async: true,
-
- init: function(book, opts) {
- this.opts = _.defaults(opts || {}, {
- interpolate: _.identity
- });
- this.book = book;
- },
-
- getSource: function(fileurl, callback) {
- var that = this;
-
- git.resolveFile(fileurl)
- .then(function(filepath) {
- // Is local file
- if (!filepath) filepath = path.resolve(fileurl);
- else that.book.log.debug.ln('resolve from git', fileurl, 'to', filepath);
-
- // Read file from absolute path
- return fs.readFile(filepath)
- .then(function(source) {
- return that.opts.interpolate(filepath, source.toString());
- })
- .then(function(source) {
- return {
- src: source,
- path: filepath,
-
- // We disable cache sincde content is modified (shortcuts, ...)
- noCache: true
- };
- });
- })
- .nodeify(callback);
- },
-
- resolve: function(from, to) {
- // If origin is in the book, we enforce result file to be in the book
- if (this.book.fileIsInBook(from)) {
- return this.book.resolve(
- this.book.relative(path.dirname(from)),
- to
- );
- }
-
- // If origin is in a git repository, we resolve file in the git repository
- var gitRoot = git.resolveRoot(from);
- if (gitRoot) {
- return pathUtil.resolveInRoot(gitRoot, to);
- }
-
- // If origin is not in the book (include from a git content ref)
- return path.resolve(path.dirname(from), to);
- },
-
- // Handle all files as relative, so that nunjucks pass responsability to 'resolve'
- // Only git urls are considered as absolute
- isRelative: function(filename) {
- return !git.checkUrl(filename);
- }
-});
-
-module.exports = BookLoader;
diff --git a/lib/fs/index.js b/lib/fs/index.js
new file mode 100644
index 0000000..8a3ca1e
--- /dev/null
+++ b/lib/fs/index.js
@@ -0,0 +1,106 @@
+var _ = require('lodash');
+var path = require('path');
+
+var Promise = require('../utils/promise');
+
+/*
+A filesystem is an interface to read files
+GitBook can works with a virtual filesystem, for example in the browser.
+*/
+
+// .readdir return files/folder as a list of string, folder ending with '/'
+function pathIsFolder(filename) {
+ return _.last(filename) == '/' || _.last(filename) == '\\';
+}
+
+
+function FS() {
+
+}
+
+// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
+FS.prototype.exists = function(filename) {
+ // To implement for each fs
+};
+
+// Read a file and returns a promise with the content as a buffer
+FS.prototype.read = function(filename) {
+ // To implement for each fs
+};
+
+// Read stat infos about a file
+FS.prototype.stat = function(filename) {
+ // To implement for each fs
+};
+
+// List files/directories in a directory
+FS.prototype.readdir = function(folder) {
+ // To implement for each fs
+};
+
+// These methods don't require to be redefined, by default it uses .exists, .read, .write, .list
+// For optmization, it can be redefined:
+
+// List files in a directory
+FS.prototype.listFiles = function(folder) {
+ return this.readdir(folder)
+ .then(function(files) {
+ return _.reject(files, pathIsFolder);
+ });
+};
+
+// List all files in the fs
+FS.prototype.listAllFiles = function(folder) {
+ var that = this;
+
+ return this.readdir(folder)
+ .then(function(files) {
+ return _.reduce(files, function(prev, file) {
+ return prev.then(function(output) {
+ var isDirectory = pathIsFolder(file);
+
+ if (!isDirectory) {
+ output.push(file);
+ return output;
+ } else {
+ return that.listAllFiles(path.join(folder, file))
+ .then(function(files) {
+ return output.concat(_.map(files, function(_file) {
+ return path.join(file, _file);
+ }));
+ });
+ }
+ });
+ }, Promise([]));
+ });
+};
+
+// Read a file as a string (utf-8)
+FS.prototype.readAsString = function(filename) {
+ return this.read(filename)
+ .then(function(buf) {
+ return buf.toString('utf-8');
+ });
+};
+
+// Find a file in a folder (case incensitive)
+// Return the real filename
+FS.prototype.findFile = function findFile(root, filename) {
+ return this.listFiles(root)
+ .then(function(files) {
+ return _.find(files, function(file) {
+ return (file.toLowerCase() == filename.toLowerCase());
+ });
+ });
+};
+
+// Load a JSON file
+// By default, fs only supports JSON
+FS.prototype.loadAsObject = function(filename) {
+ return this.readAsString(filename)
+ .then(function(str) {
+ return JSON.parse(str);
+ });
+};
+
+module.exports = FS;
diff --git a/lib/fs/node.js b/lib/fs/node.js
new file mode 100644
index 0000000..fc2517e
--- /dev/null
+++ b/lib/fs/node.js
@@ -0,0 +1,66 @@
+var _ = require('lodash');
+var util = require('util');
+var path = require('path');
+
+var fs = require('../utils/fs');
+var Promise = require('../utils/promise');
+var BaseFS = require('./');
+
+function NodeFS() {
+ BaseFS.call(this);
+}
+util.inherits(NodeFS, BaseFS);
+
+// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
+NodeFS.prototype.exists = function(filename) {
+ return fs.exists(filename);
+};
+
+// Read a file and returns a promise with the content as a buffer
+NodeFS.prototype.read = function(filename) {
+ return fs.readFile(filename);
+};
+
+// Read stat infos about a file
+NodeFS.prototype.stat = function(filename) {
+ return fs.stat(filename);
+};
+
+// List files in a directory
+NodeFS.prototype.readdir = function(folder) {
+ return fs.readdir(folder)
+ .then(function(files) {
+ return _.chain(files)
+ .map(function(file) {
+ if (file == '.' || file == '..') return;
+
+ var stat = fs.statSync(path.join(folder, file));
+ if (stat.isDirectory()) file = file + path.sep;
+ return file;
+ })
+ .compact()
+ .value();
+ });
+};
+
+// Load a JSON/JS file
+NodeFS.prototype.loadAsObject = function(filename) {
+ return Promise()
+ .then(function() {
+ var jsFile;
+
+ try {
+ jsFile = require.resolve(filename);
+
+ // Invalidate node.js cache for livreloading
+ delete require.cache[jsFile];
+
+ return require(jsFile);
+ }
+ catch(err) {
+ return Promise.reject(err);
+ }
+ });
+};
+
+module.exports = NodeFS;
diff --git a/lib/generator.js b/lib/generator.js
deleted file mode 100644
index 4e280d8..0000000
--- a/lib/generator.js
+++ /dev/null
@@ -1,76 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var Q = require('q');
-var fs = require('./utils/fs');
-
-var BaseGenerator = function(book) {
- this.book = book;
-
- Object.defineProperty(this, 'options', {
- get: function () {
- return this.book.options;
- }
- });
-
- _.bindAll(this);
-};
-
-BaseGenerator.prototype.callHook = function(name, data) {
- return this.book.callHook(name, data);
-};
-
-// Prepare the genertor
-BaseGenerator.prototype.prepare = function() {
- var that = this;
-
- return that.callHook('init');
-};
-
-// Write a parsed file to the output
-BaseGenerator.prototype.convertFile = function(input) {
- return Q.reject(new Error('Could not convert '+input));
-};
-
-// Copy file to the output (non parsable)
-BaseGenerator.prototype.transferFile = function(input) {
- return fs.copy(
- this.book.resolve(input),
- path.join(this.options.output, input)
- );
-};
-
-// Copy a folder to the output
-BaseGenerator.prototype.transferFolder = function(input) {
- return fs.mkdirp(
- path.join(this.book.options.output, input)
- );
-};
-
-// Copy the cover picture
-BaseGenerator.prototype.copyCover = function() {
- var that = this;
-
- return Q.all([
- fs.copy(that.book.resolve('cover.jpg'), path.join(that.options.output, 'cover.jpg')),
- fs.copy(that.book.resolve('cover_small.jpg'), path.join(that.options.output, 'cover_small.jpg'))
- ])
- .fail(function() {
- // If orignaly from multi-lang, try copy from parent
- if (!that.book.isSubBook()) return;
-
- return Q.all([
- fs.copy(path.join(that.book.parentRoot(), 'cover.jpg'), path.join(that.options.output, 'cover.jpg')),
- fs.copy(path.join(that.book.parentRoot(), 'cover_small.jpg'), path.join(that.options.output, 'cover_small.jpg'))
- ]);
- })
- .fail(function() {
- return Q();
- });
-};
-
-// At teh end of the generation
-BaseGenerator.prototype.finish = function() {
- return Q.reject(new Error('Could not finish generation'));
-};
-
-module.exports = BaseGenerator;
diff --git a/lib/generators/ebook.js b/lib/generators/ebook.js
deleted file mode 100644
index ff804c6..0000000
--- a/lib/generators/ebook.js
+++ /dev/null
@@ -1,172 +0,0 @@
-var util = require('util');
-var path = require('path');
-var Q = require('q');
-var _ = require('lodash');
-var juice = require('juice');
-var exec = require('child_process').exec;
-
-var fs = require('../utils/fs');
-var stringUtils = require('../utils/string');
-var BaseGenerator = require('./website');
-
-var Generator = function(book, format) {
- BaseGenerator.apply(this, arguments);
-
- // eBook format
- this.ebookFormat = format;
-
- // Resources namespace
- this.namespace = 'ebook';
-
- // Styles to use
- this.styles = _.compact(['print', 'ebook', this.ebookFormat]);
-
- // Convert images (svg -> png)
- this.convertImages = true;
-};
-util.inherits(Generator, BaseGenerator);
-
-Generator.prototype.prepareTemplates = function() {
- this.templates.page = this.book.plugins.template('ebook:page') || path.resolve(this.options.theme, 'templates/ebook/page.html');
- this.templates.summary = this.book.plugins.template('ebook:summary') || path.resolve(this.options.theme, 'templates/ebook/summary.html');
- this.templates.glossary = this.book.plugins.template('ebook:glossary') || path.resolve(this.options.theme, 'templates/ebook/glossary.html');
-
- return Q();
-};
-
-// Generate table of contents
-Generator.prototype.writeSummary = function() {
- var that = this;
-
- that.book.log.info.ln('write SUMMARY.html');
- return this._writeTemplate(this.templates.summary, {}, path.join(this.options.output, 'SUMMARY.html'));
-};
-
-// Return template for footer/header with inlined css
-Generator.prototype.getPDFTemplate = function(id) {
- var tpl = this.options.pdf[id+'Template'];
- var defaultTpl = path.resolve(this.options.theme, 'templates/ebook/'+id+'.html');
- var defaultCSS = path.resolve(this.options.theme, 'assets/ebook/pdf.css');
-
- // Default template from theme
- if (!tpl && fs.existsSync(defaultTpl)) {
- tpl = fs.readFileSync(defaultTpl, { encoding: 'utf-8' });
- }
-
- // Inline CSS using juice
- var stylesheets = [];
-
- // From theme
- if (fs.existsSync(defaultCSS)) {
- stylesheets.push(fs.readFileSync(defaultCSS, { encoding: 'utf-8' }));
- }
-
- // Custom PDF style
- if (this.styles.pdf) {
- stylesheets.push(fs.readFileSync(this.book.resolveOutput(this.styles.pdf), { encoding: 'utf-8' }));
- }
-
- tpl = juice(tpl, {
- extraCss: stylesheets.join('\n\n')
- });
-
- return tpl;
-};
-
-Generator.prototype.finish = function() {
- var that = this;
-
- return Q()
- .then(this.copyAssets)
- .then(this.copyCover)
- .then(this.writeGlossary)
- .then(this.writeSummary)
- .then(function() {
- if (!that.ebookFormat) return Q();
-
- if (!that.options.cover && fs.existsSync(path.join(that.options.output, 'cover.jpg'))) {
- that.options.cover = path.join(that.options.output, 'cover.jpg');
- }
-
- var d = Q.defer();
-
- var _options = {
- '--cover': that.options.cover,
- '--title': that.options.title,
- '--comments': that.options.description,
- '--isbn': that.options.isbn,
- '--authors': that.options.author,
- '--language': that.options.language,
- '--book-producer': 'GitBook',
- '--publisher': 'GitBook',
- '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
- '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
- '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
- '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
- '--no-chapters-in-toc': true,
- '--max-levels': '1',
- '--breadth-first': true
- };
-
- if (that.ebookFormat == 'pdf') {
- var pdfOptions = that.options.pdf;
-
- _.extend(_options, {
- '--chapter-mark': String(pdfOptions.chapterMark),
- '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
- '--margin-left': String(pdfOptions.margin.left),
- '--margin-right': String(pdfOptions.margin.right),
- '--margin-top': String(pdfOptions.margin.top),
- '--margin-bottom': String(pdfOptions.margin.bottom),
- '--pdf-default-font-size': String(pdfOptions.fontSize),
- '--pdf-mono-font-size': String(pdfOptions.fontSize),
- '--paper-size': String(pdfOptions.paperSize),
- '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
- '--pdf-header-template': that.getPDFTemplate('header'),
- '--pdf-footer-template': that.getPDFTemplate('footer'),
- '--pdf-sans-family': String(pdfOptions.fontFamily)
- });
- } else if (that.ebookFormat == 'epub') {
- _.extend(_options, {
- '--dont-split-on-page-breaks': true
- });
- }
-
- var command = [
- 'ebook-convert',
- path.join(that.options.output, 'SUMMARY.html'),
- path.join(that.options.output, 'index.'+that.ebookFormat),
- stringUtils.optionsToShellArgs(_options)
- ].join(' ');
-
- that.book.log.info('start conversion to', that.ebookFormat, '....');
-
- var child = exec(command, function (error, stdout) {
- if (error) {
- that.book.log.info.fail();
-
- if (error.code == 127) {
- error.message = 'Need to install ebook-convert from Calibre';
- } else {
- error.message = error.message + ' '+stdout;
- }
- return d.reject(error);
- }
-
- that.book.log.info.ok();
- d.resolve();
- });
-
- child.stdout.on('data', function (data) {
- that.book.log.debug(data);
- });
-
- child.stderr.on('data', function (data) {
- that.book.log.debug(data);
- });
-
- return d.promise;
- });
-};
-
-module.exports = Generator;
diff --git a/lib/generators/index.js b/lib/generators/index.js
deleted file mode 100644
index 068d0d9..0000000
--- a/lib/generators/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-var _ = require("lodash");
-var EbookGenerator = require("./ebook");
-
-module.exports = {
- json: require("./json"),
- website: require("./website"),
- ebook: EbookGenerator,
- pdf: _.partialRight(EbookGenerator, "pdf"),
- mobi: _.partialRight(EbookGenerator, "mobi"),
- epub: _.partialRight(EbookGenerator, "epub")
-};
diff --git a/lib/generators/json.js b/lib/generators/json.js
deleted file mode 100644
index 37ffa0b..0000000
--- a/lib/generators/json.js
+++ /dev/null
@@ -1,76 +0,0 @@
-var util = require('util');
-var path = require('path');
-var Q = require('q');
-var _ = require('lodash');
-
-var fs = require('../utils/fs');
-var BaseGenerator = require('../generator');
-var links = require('../utils/links');
-
-var Generator = function() {
- BaseGenerator.apply(this, arguments);
-};
-util.inherits(Generator, BaseGenerator);
-
-// Ignore some methods
-Generator.prototype.transferFile = function() { };
-
-// Convert an input file
-Generator.prototype.convertFile = function(input) {
- var that = this;
-
- return that.book.parsePage(input)
- .then(function(page) {
- var json = {
- progress: page.progress,
- sections: page.sections
- };
-
- var output = links.changeExtension(page.path, '.json');
- output = path.join(that.options.output, output);
-
- return fs.writeFile(
- output,
- JSON.stringify(json, null, 4)
- );
- });
-};
-
-// Finish generation
-Generator.prototype.finish = function() {
- return this.writeReadme();
-};
-
-// Write README.json
-Generator.prototype.writeReadme = function() {
- var that = this;
- var mainLang, langs, readme;
-
- return Q()
- .then(function() {
- langs = that.book.langs;
- mainLang = langs.length > 0? _.first(langs).lang : null;
-
- readme = links.changeExtension(that.book.readmeFile, '.json');
-
- // Read readme from main language
- return fs.readFile(
- mainLang? path.join(that.options.output, mainLang, readme) : path.join(that.options.output, readme)
- );
- })
- .then(function(content) {
- // Extend it with infos about the languages
- var json = JSON.parse(content);
- _.extend(json, {
- langs: langs
- });
-
- // Write it as README.json
- return fs.writeFile(
- path.join(that.options.output, 'README.json'),
- JSON.stringify(json, null, 4)
- );
- });
-};
-
-module.exports = Generator;
diff --git a/lib/generators/website.js b/lib/generators/website.js
deleted file mode 100644
index efb7c0f..0000000
--- a/lib/generators/website.js
+++ /dev/null
@@ -1,268 +0,0 @@
-var util = require('util');
-var path = require('path');
-var Q = require('q');
-var _ = require('lodash');
-
-var nunjucks = require('nunjucks');
-var AutoEscapeExtension = require('nunjucks-autoescape')(nunjucks);
-var FilterExtension = require('nunjucks-filter')(nunjucks);
-
-var fs = require('../utils/fs');
-var BaseGenerator = require('../generator');
-var links = require('../utils/links');
-var i18n = require('../utils/i18n');
-
-var pkg = require('../../package.json');
-
-var Generator = function() {
- BaseGenerator.apply(this, arguments);
-
- // Revision
- this.revision = new Date();
-
- // Resources namespace
- this.namespace = 'website';
-
- // Style to integrates in the output
- this.styles = ['website'];
-
- // Convert images (svg -> png)
- this.convertImages = false;
-
- // Templates
- this.templates = {};
-};
-util.inherits(Generator, BaseGenerator);
-
-// Prepare the genertor
-Generator.prototype.prepare = function() {
- return BaseGenerator.prototype.prepare.apply(this)
- .then(this.prepareStyles)
- .then(this.prepareTemplates)
- .then(this.prepareTemplateEngine);
-};
-
-// Prepare all styles
-Generator.prototype.prepareStyles = function() {
- var that = this;
-
- this.styles = _.chain(this.styles)
- .map(function(style) {
- var stylePath = that.options.styles[style];
- var styleExists = (
- fs.existsSync(that.book.resolveOutput(stylePath)) ||
- fs.existsSync(that.book.resolve(stylePath))
- );
-
- if (stylePath && styleExists) {
- return [style, stylePath];
- }
- return null;
- })
- .compact()
- .object()
- .value();
-
- return Q();
-};
-
-// Prepare templates
-Generator.prototype.prepareTemplates = function() {
- this.templates.page = this.book.plugins.template('site:page') || path.resolve(this.options.theme, 'templates/website/page.html');
- this.templates.langs = this.book.plugins.template('site:langs') || path.resolve(this.options.theme, 'templates/website/langs.html');
- this.templates.glossary = this.book.plugins.template('site:glossary') || path.resolve(this.options.theme, 'templates/website/glossary.html');
-
- return Q();
-};
-
-// Prepare template engine
-Generator.prototype.prepareTemplateEngine = function() {
- var that = this;
-
- return Q()
- .then(function() {
- var language = that.book.config.normalizeLanguage();
-
- if (!i18n.hasLocale(language)) {
- that.book.log.warn.ln('Language "'+language+'" is not available as a layout locales (en, '+i18n.getLocales().join(', ')+')');
- }
-
- var folders = _.chain(that.templates)
- .values()
- .map(path.dirname)
- .uniq()
- .value();
-
- that.env = new nunjucks.Environment(
- new nunjucks.FileSystemLoader(folders),
- {
- autoescape: true
- }
- );
-
- // Add filter
- that.env.addFilter('contentLink', that.book.contentLink.bind(that.book));
- that.env.addFilter('lvl', function(lvl) {
- return lvl.split('.').length;
- });
-
- // Add extension
- that.env.addExtension('AutoEscapeExtension', new AutoEscapeExtension(that.env));
- that.env.addExtension('FilterExtension', new FilterExtension(that.env));
- });
-};
-
-// Finis generation
-Generator.prototype.finish = function() {
- return this.copyAssets()
- .then(this.copyCover)
- .then(this.writeGlossary)
- .then(this.writeLangsIndex);
-};
-
-// Convert an input file
-Generator.prototype.convertFile = function(input) {
- var that = this;
-
- return that.book.parsePage(input, {
- convertImages: that.convertImages,
- interpolateTemplate: function(page) {
- return that.callHook('page:before', page);
- },
- interpolateContent: function(page) {
- return that.callHook('page', page);
- }
- })
- .then(function(page) {
- var relativeOutput = that.book.contentPath(page.path);
- var output = path.join(that.options.output, relativeOutput);
-
- var basePath = path.relative(path.dirname(output), that.options.output) || '.';
- if (process.platform === 'win32') basePath = basePath.replace(/\\/g, '/');
-
- that.book.log.debug.ln('write parsed file', page.path, 'to', relativeOutput);
-
- return that._writeTemplate(that.templates.page, {
- progress: page.progress,
-
- _input: page.path,
- content: page.sections,
-
- basePath: basePath,
- staticBase: links.join(basePath, 'gitbook')
- }, output);
- });
-};
-
-// Write the index for langs
-Generator.prototype.writeLangsIndex = function() {
- if (!this.book.langs.length) return Q();
-
- return this._writeTemplate(this.templates.langs, {
- langs: this.book.langs
- }, path.join(this.options.output, 'index.html'));
-};
-
-// Write glossary
-Generator.prototype.writeGlossary = function() {
- // No glossary
- if (this.book.glossary.length === 0) return Q();
-
- return this._writeTemplate(this.templates.glossary, {}, path.join(this.options.output, 'GLOSSARY.html'));
-};
-
-// Convert a page into a normalized data set
-Generator.prototype.normalizePage = function(page) {
- var that = this;
-
- var _callHook = function(name) {
- return that.callHook(name, page)
- .then(function(_page) {
- page = _page;
- return page;
- });
- };
-
- return Q()
- .then(function() {
- return _callHook('page');
- })
- .then(function() {
- return page;
- });
-};
-
-// Generate a template
-Generator.prototype._writeTemplate = function(tpl, options, output, interpolate) {
- var that = this;
-
- interpolate = interpolate || _.identity;
- return Q()
- .then(function() {
- return that.env.render(
- tpl,
- _.extend({
- gitbook: {
- version: pkg.version
- },
-
- styles: that.styles,
-
- revision: that.revision,
-
- title: that.options.title,
- description: that.options.description,
- language: that.book.config.normalizeLanguage(),
- innerlanguage: that.book.isSubBook()? that.book.config.get('language') : null,
-
- glossary: that.book.glossary,
-
- summary: that.book.summary,
- allNavigation: that.book.navigation,
-
- plugins: {
- resources: that.book.plugins.resources(that.namespace)
- },
- pluginsConfig: JSON.stringify(that.options.pluginsConfig),
- htmlSnippet: _.partial(_.partialRight(that.book.plugins.html, that, options), that.namespace),
-
- options: that.options,
-
- basePath: '.',
- staticBase: path.join('.', 'gitbook'),
-
- '__': that.book.i18n.bind(that.book)
- }, options)
- );
- })
- .then(interpolate)
- .then(function(html) {
- return fs.writeFile(
- output,
- html
- );
- });
-};
-
-// Copy assets
-Generator.prototype.copyAssets = function() {
- var that = this;
-
- // Copy gitbook assets
- return fs.copy(
- path.join(that.options.theme, 'assets/'+this.namespace),
- path.join(that.options.output, 'gitbook')
- )
-
- // Copy plugins assets
- .then(function() {
- return Q.all(
- _.map(that.book.plugins.list, function(plugin) {
- var pluginAssets = path.join(that.options.output, 'gitbook/plugins/', plugin.name);
- return plugin.copyAssets(pluginAssets, that.namespace);
- })
- );
- });
-};
-
-module.exports = Generator;
diff --git a/lib/version.js b/lib/gitbook.js
index f0ae187..54513c1 100644
--- a/lib/version.js
+++ b/lib/gitbook.js
@@ -4,7 +4,9 @@ var pkg = require('../package.json');
var VERSION = pkg.version;
var VERSION_STABLE = VERSION.replace(/\-(\S+)/g, '');
-// Test if current current gitbook version satisfies a condition
+var START_TIME = new Date();
+
+// Verify that this gitbook version satisfies a requirement
// We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha)
function satisfies(condition) {
// Test with real version
@@ -14,6 +16,18 @@ function satisfies(condition) {
return semver.satisfies(VERSION_STABLE, condition);
}
+// Return templating/json context for gitbook itself
+function getContext() {
+ return {
+ gitbook: {
+ version: pkg.version,
+ time: START_TIME
+ }
+ };
+}
+
module.exports = {
- satisfies: satisfies
+ version: pkg.version,
+ satisfies: satisfies,
+ getContext: getContext
};
diff --git a/lib/index.js b/lib/index.js
index a23ec3f..fdad6ee 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,215 +1,7 @@
-/* eslint no-console: 0 */
-
-var Q = require('q');
-var _ = require('lodash');
-var path = require('path');
-var tinylr = require('tiny-lr');
-var color = require('bash-color');
-
var Book = require('./book');
-var initBook = require('./init');
-var Server = require('./utils/server');
-var stringUtils = require('./utils/string');
-var watch = require('./utils/watch');
-var logger = require('./utils/logger');
-
-var LOG_OPTION = {
- name: 'log',
- description: 'Minimum log level to display',
- values: _.chain(logger.LEVELS).keys().map(stringUtils.toLowerCase).value(),
- defaults: 'info'
-};
-
-var FORMAT_OPTION = {
- name: 'format',
- description: 'Format to build to',
- values: ['website', 'json', 'ebook'],
- defaults: 'website'
-};
-
-// Export init to gitbook library
-Book.init = initBook;
+var cli = require('./cli');
module.exports = {
Book: Book,
- LOG_LEVELS: logger.LEVELS,
-
- commands: _.flatten([
- {
- name: 'build [book] [output]',
- description: 'build a book',
- options: [
- FORMAT_OPTION,
- LOG_OPTION
- ],
- exec: function(args, kwargs) {
- var input = args[0] || process.cwd();
- var output = args[1] || path.join(input, '_book');
-
- var book = new Book(input, _.extend({}, {
- 'config': {
- 'output': output
- },
- 'logLevel': kwargs.log
- }));
-
- return book.parse()
- .then(function() {
- return book.generate(kwargs.format);
- })
- .then(function(){
- console.log('');
- console.log(color.green('Done, without error'));
- });
- }
- },
-
- _.map(['pdf', 'epub', 'mobi'], function(ebookType) {
- return {
- name: ebookType+' [book] [output]',
- description: 'build a book to '+ebookType,
- options: [
- LOG_OPTION
- ],
- exec: function(args, kwargs) {
- var input = args[0] || process.cwd();
- var output = args[1];
-
- var book = new Book(input, _.extend({}, {
- 'logLevel': kwargs.log
- }));
-
- return book.parse()
- .then(function() {
- return book.generateFile(output, {
- ebookFormat: ebookType
- });
- })
- .then(function(){
- console.log('');
- console.log(color.green('Done, without error'));
- });
- }
- };
- }),
-
- {
- name: 'serve [book]',
- description: 'Build then serve a gitbook from a directory',
- options: [
- {
- name: 'port',
- description: 'Port for server to listen on',
- defaults: 4000
- },
- {
- name: 'lrport',
- description: 'Port for livereload server to listen on',
- defaults: 35729
- },
- {
- name: 'watch',
- description: 'Enable/disable file watcher',
- defaults: true
- },
- FORMAT_OPTION,
- LOG_OPTION
- ],
- exec: function(args, kwargs) {
- var input = args[0] || process.cwd();
- var server = new Server();
-
- // Init livereload server
- var lrServer = tinylr({});
- var lrPath;
-
- var generate = function() {
- if (server.isRunning()) console.log('Stopping server');
-
- return server.stop()
- .then(function() {
- var book = new Book(input, _.extend({}, {
- 'config': {
- 'defaultsPlugins': ['livereload']
- },
- 'logLevel': kwargs.log
- }));
-
- return book.parse()
- .then(function() {
- return book.generate(kwargs.format);
- })
- .thenResolve(book);
- })
- .then(function(book) {
- console.log();
- console.log('Starting server ...');
- return server.start(book.options.output, kwargs.port)
- .then(function() {
- console.log('Serving book on http://localhost:'+kwargs.port);
-
- if (lrPath) {
- // trigger livereload
- lrServer.changed({
- body: {
- files: [lrPath]
- }
- });
- }
-
- if (!kwargs.watch) return;
-
- return watch(book.root)
- .then(function(filepath) {
- // set livereload path
- lrPath = filepath;
- console.log('Restart after change in file', filepath);
- console.log('');
- return generate();
- });
- });
- });
- };
-
- return Q.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport)
- .then(function() {
- console.log('Live reload server started on port:', kwargs.lrport);
- console.log('Press CTRL+C to quit ...');
- console.log('');
- return generate();
- });
- }
- },
-
- {
- name: 'install [book]',
- description: 'install plugins dependencies',
- exec: function(args) {
- var input = args[0] || process.cwd();
-
- var book = new Book(input);
-
- return book.config.load()
- .then(function() {
- return book.plugins.install();
- })
- .then(function(){
- console.log('');
- console.log(color.green('Done, without error'));
- });
- }
- },
-
- {
- name: 'init [directory]',
- description: 'create files and folders based on contents of SUMMARY.md',
- exec: function(args) {
- return initBook(args[0] || process.cwd())
- .then(function(){
- console.log('');
- console.log(color.green('Done, without error'));
- });
- }
- }
- ])
+ commands: cli.commands
};
diff --git a/lib/init.js b/lib/init.js
deleted file mode 100644
index 2fc8016..0000000
--- a/lib/init.js
+++ /dev/null
@@ -1,83 +0,0 @@
-var _ = require('lodash');
-var Q = require('q');
-var path = require('path');
-
-var Book = require('./book');
-var fs = require('./utils/fs');
-
-// Initialize folder structure for a book
-// Read SUMMARY to created the right chapter
-function initBook(root, opts) {
- var book = new Book(root, opts);
- var extensionToUse = '.md';
-
- var chaptersPaths = function(chapters) {
- return _.reduce(chapters || [], function(accu, chapter) {
- var o = {
- title: chapter.title
- };
- if (chapter.path) o.path = chapter.path;
-
- return accu.concat(
- [o].concat(chaptersPaths(chapter.articles))
- );
- }, []);
- };
-
- book.log.info.ln('init book at', root);
- return fs.mkdirp(root)
- .then(function() {
- book.log.info.ln('detect structure from SUMMARY (if it exists)');
- return book.parseSummary();
- })
- .fail(function() {
- return Q();
- })
- .then(function() {
- var summary = book.summaryFile || 'SUMMARY.md';
- var chapters = book.summary.chapters || [];
- extensionToUse = path.extname(summary);
-
- if (chapters.length === 0) {
- chapters = [
- {
- title: 'Summary',
- path: 'SUMMARY'+extensionToUse
- },
- {
- title: 'Introduction',
- path: 'README'+extensionToUse
- }
- ];
- }
-
- return Q(chaptersPaths(chapters));
- })
- .then(function(chapters) {
- // Create files that don't exist
- return Q.all(_.map(chapters, function(chapter) {
- if (!chapter.path) return Q();
- var absolutePath = path.resolve(book.root, chapter.path);
-
- return fs.exists(absolutePath)
- .then(function(exists) {
- if(exists) {
- book.log.info.ln('found', chapter.path);
- return;
- } else {
- book.log.info.ln('create', chapter.path);
- }
-
- return fs.mkdirp(path.dirname(absolutePath))
- .then(function() {
- return fs.writeFile(absolutePath, '# '+chapter.title+'\n');
- });
- });
- }));
- })
- .then(function() {
- book.log.info.ln('initialization is finished');
- });
-}
-
-module.exports = initBook;
diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js
new file mode 100644
index 0000000..6f1f02d
--- /dev/null
+++ b/lib/output/assets-inliner.js
@@ -0,0 +1,140 @@
+var util = require('util');
+var path = require('path');
+var crc = require('crc');
+
+var FolderOutput = require('./folder')();
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var imagesUtil = require('../utils/images');
+var location = require('../utils/location');
+
+var DEFAULT_ASSETS_FOLDER = 'assets';
+
+/*
+Mixin to inline all the assets in a book:
+ - Outline <svg> tags
+ - Download remote images
+ - Convert .svg images as png
+*/
+
+module.exports = function assetsInliner(Base) {
+ Base = Base || FolderOutput;
+
+ function AssetsInliner() {
+ Base.apply(this, arguments);
+
+ // Map of svg already converted
+ this.svgs = {};
+ this.inlineSvgs = {};
+
+ // Map of images already downloaded
+ this.downloaded = {};
+ }
+ util.inherits(AssetsInliner, Base);
+
+ // Output a SVG buffer as a file
+ AssetsInliner.prototype.onOutputSVG = function(page, svg) {
+ this.log.debug.ln('output svg from', page.path);
+
+ // Convert svg buffer to a png file
+ return this.convertSVGBuffer(svg)
+
+ // Return relative path from the page
+ .then(function(filename) {
+ return page.relative('/' + filename);
+ });
+ };
+
+
+ // Output an image as a file
+ AssetsInliner.prototype.onOutputImage = function(page, src) {
+ var that = this;
+
+ return Promise()
+
+ // Download file if external
+ .then(function() {
+ if (!location.isExternal(src)) return;
+
+ return that.downloadAsset(src)
+ .then(function(_asset) {
+ src = '/' + _asset;
+ });
+
+ })
+ .then(function() {
+ // Resolve src to a relative filepath to the book's root
+ src = page.resolveLocal(src);
+
+ // Already a PNG/JPG/.. ?
+ if (path.extname(src).toLowerCase() != '.svg') {
+ return src;
+ }
+
+ // Convert SVG to PNG
+ return that.convertSVGFile(that.resolve(src));
+ })
+
+ // Return relative path from the page
+ .then(function(filename) {
+ return page.relative(filename);
+ });
+ };
+
+ // Download an asset if not already download; returns the output file
+ AssetsInliner.prototype.downloadAsset = function(src) {
+ if (this.downloaded[src]) return Promise(this.downloaded[src]);
+
+ var that = this;
+ var ext = path.extname(src);
+ var hash = crc.crc32(src).toString(16);
+
+ // Create new file
+ return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + ext)
+ .then(function(filename) {
+ that.downloaded[src] = filename;
+
+ that.log.debug.ln('downloading asset', src);
+ return fs.download(src, that.resolve(filename))
+ .thenResolve(filename);
+ });
+ };
+
+ // Convert a .svg into an .png
+ // Return the output filename for the .png
+ AssetsInliner.prototype.convertSVGFile = function(src) {
+ if (this.svgs[src]) return Promise(this.svgs[src]);
+
+ var that = this;
+ var hash = crc.crc32(src).toString(16);
+
+ // Create new file
+ return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
+ .then(function(filename) {
+ that.svgs[src] = filename;
+
+ return imagesUtil.convertSVGToPNG(src, that.resolve(filename))
+ .thenResolve(filename);
+ });
+ };
+
+ // Convert an inline svg into an .png
+ // Return the output filename for the .png
+ AssetsInliner.prototype.convertSVGBuffer = function(buf) {
+ var that = this;
+ var hash = crc.crc32(buf).toString(16);
+
+ // Already converted?
+ if (this.inlineSvgs[hash]) return Promise(this.inlineSvgs[hash]);
+
+ return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
+ .then(function(filename) {
+ that.inlineSvgs[hash] = filename;
+
+ return imagesUtil.convertSVGBufferToPNG(buf, that.resolve(filename))
+ .thenResolve(filename);
+ });
+ };
+
+ return AssetsInliner;
+};
diff --git a/lib/output/base.js b/lib/output/base.js
new file mode 100644
index 0000000..a1d8804
--- /dev/null
+++ b/lib/output/base.js
@@ -0,0 +1,274 @@
+var _ = require('lodash');
+var Ignore = require('ignore');
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var pathUtil = require('../utils/path');
+var location = require('../utils/location');
+var PluginsManager = require('../plugins');
+var TemplateEngine = require('../template');
+
+/*
+Output is like a stream interface for a parsed book
+to output "something".
+
+The process is mostly on the behavior of "onPage" and "onAsset"
+*/
+
+function Output(book, opts, parent) {
+ _.bindAll(this);
+ this.parent = parent;
+
+ this.opts = _.defaults({}, opts || {}, {
+ directoryIndex: true
+ });
+
+ this.book = book;
+ this.log = this.book.log;
+
+ // Create plugins manager
+ this.plugins = new PluginsManager(this.book);
+
+ // Create template engine
+ this.template = new TemplateEngine(this);
+
+ // Files to ignore in output
+ this.ignore = Ignore();
+}
+
+// Default extension for output
+Output.prototype.defaultExtension = '.html';
+
+// Start the generation, for a parsed book
+Output.prototype.generate = function() {
+ var that = this;
+ var isMultilingual = this.book.isMultilingual();
+
+ return Promise()
+
+ // Load all plugins
+ .then(function() {
+ return that.plugins.loadAll()
+ .then(function() {
+ that.template.addFilters(that.plugins.getFilters());
+ that.template.addBlocks(that.plugins.getBlocks());
+ });
+ })
+
+ // Transform the configuration
+ .then(function() {
+ return that.plugins.hook('config', that.book.config.dump())
+ .then(function(cfg) {
+ that.book.config.replace(cfg);
+ });
+ })
+
+ // Initialize the generation
+ .then(function() {
+ return that.plugins.hook('init');
+ })
+ .then(function() {
+ that.log.info.ln('preparing the generation');
+ return that.prepare();
+ })
+
+ // Process all files
+ .then(function() {
+ that.log.debug.ln('listing files');
+ return that.book.fs.listAllFiles(that.book.root);
+ })
+
+ // We want to process assets first, then pages
+ // Since pages can have logic based on existance of assets
+ .then(function(files) {
+ // Split into pages/assets
+ var byTypes = _.chain(files)
+ .filter(that.ignore.createFilter())
+
+ // Ignore file present in a language book
+ .filter(function(filename) {
+ return !(isMultilingual && that.book.isInLanguageBook(filename));
+ })
+
+ .groupBy(function(filename) {
+ return (that.book.hasPage(filename)? 'page' : 'asset');
+ })
+
+ .value();
+
+ return Promise.serie(byTypes.asset, function(filename) {
+ that.log.debug.ln('copy asset', filename);
+ return that.onAsset(filename);
+ })
+ .then(function() {
+ return Promise.serie(byTypes.page, function(filename) {
+ that.log.debug.ln('process page', filename);
+ return that.onPage(that.book.getPage(filename));
+ });
+ });
+ })
+
+ // Generate sub-books
+ .then(function() {
+ if (!that.book.isMultilingual()) return;
+
+ return Promise.serie(that.book.books, function(subbook) {
+ that.log.info.ln('');
+ that.log.info.ln('start generation of language "' + path.relative(that.book.root, subbook.root) + '"');
+
+ var out = that.onLanguageBook(subbook);
+ return out.generate();
+ });
+ })
+
+ // Finish the generation
+ .then(function() {
+ return that.plugins.hook('finish:before');
+ })
+ .then(function() {
+ that.log.debug.ln('finishing the generation');
+ return that.finish();
+ })
+ .then(function() {
+ return that.plugins.hook('finish');
+ })
+
+ .then(function() {
+ if (!that.book.isLanguageBook()) that.log.info.ln('');
+ that.log.info.ok('generation finished with success!');
+ });
+};
+
+// Prepare the generation
+Output.prototype.prepare = function() {
+ this.ignore.addPattern(_.compact([
+ '.gitignore',
+ '.ignore',
+ '.bookignore',
+ 'node_modules',
+
+ // The configuration file should not be copied in the output
+ this.book.config.path,
+
+ // Structure file to ignore
+ this.book.summary.path,
+ this.book.langs.path
+ ]));
+};
+
+// Write a page (parsable file), ex: markdown, etc
+Output.prototype.onPage = function(page) {
+ return page.toHTML(this);
+};
+
+// Copy an asset file (non-parsable), ex: images, etc
+Output.prototype.onAsset = function(filename) {
+
+};
+
+// Finish the generation
+Output.prototype.finish = function() {
+
+};
+
+// Resolve an HTML link
+Output.prototype.onRelativeLink = function(currentPage, href) {
+ var to = currentPage.followPage(href);
+
+ // Replace by an .html link
+ if (to) {
+ href = to.path;
+
+ // Recalcul as relative link
+ href = currentPage.relative(href);
+
+ // Replace .md by .html
+ href = this.outputUrl(href);
+ }
+
+ return href;
+};
+
+// Output a SVG buffer as a file
+Output.prototype.onOutputSVG = function(page, svg) {
+ return null;
+};
+
+// Output an image as a file
+// Normalize the relative link
+Output.prototype.onOutputImage = function(page, imgFile) {
+ imgFile = page.resolveLocal(imgFile);
+ return page.relative(imgFile);
+};
+
+// Read a template by its source URL
+Output.prototype.onGetTemplate = function(sourceUrl) {
+ throw new Error('template not found '+sourceUrl);
+};
+
+// Generate a source URL for a template
+Output.prototype.onResolveTemplate = function(from, to) {
+ return path.resolve(path.dirname(from), to);
+};
+
+// Prepare output for a language book
+Output.prototype.onLanguageBook = function(book) {
+ return new this.constructor(book, this.opts, this);
+};
+
+
+// ---- Utilities ----
+
+// Return a default context for templates
+Output.prototype.getContext = function() {
+ return _.extend(
+ {},
+ this.book.getContext(),
+ this.book.langs.getContext(),
+ this.book.summary.getContext(),
+ this.book.glossary.getContext(),
+ this.book.config.getContext()
+ );
+};
+
+// Resolve a file path in the context of a specific page
+// Result is an "absolute path relative to the output folder"
+Output.prototype.resolveForPage = function(page, href) {
+ if (_.isString(page)) page = this.book.getPage(page);
+
+ href = page.relative(href);
+ return this.onRelativeLink(page, href);
+};
+
+// Filename for output
+// READMEs are replaced by index.html
+// /test/README.md -> /test/index.html
+Output.prototype.outputPath = function(filename, ext) {
+ ext = ext || this.defaultExtension;
+ var output = filename;
+
+ if (
+ path.basename(filename, path.extname(filename)) == 'README' ||
+ output == this.book.readme.path
+ ) {
+ output = path.join(path.dirname(output), 'index'+ext);
+ } else {
+ output = pathUtil.setExtension(output, ext);
+ }
+
+ return output;
+};
+
+// Filename for output
+// /test/index.html -> /test/
+Output.prototype.outputUrl = function(filename, ext) {
+ var href = this.outputPath(filename, ext);
+
+ if (path.basename(href) == 'index.html' && this.opts.directoryIndex) {
+ href = path.dirname(href) + '/';
+ }
+
+ return location.normalize(href);
+};
+
+module.exports = Output;
diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js
new file mode 100644
index 0000000..e58f836
--- /dev/null
+++ b/lib/output/conrefs.js
@@ -0,0 +1,67 @@
+var path = require('path');
+var util = require('util');
+
+var folderOutput = require('./folder');
+var Git = require('../utils/git');
+var fs = require('../utils/fs');
+var pathUtil = require('../utils/path');
+var location = require('../utils/location');
+
+/*
+Mixin for output to resolve git conrefs
+*/
+
+module.exports = function conrefsLoader(Base) {
+ Base = folderOutput(Base);
+
+ function ConrefsLoader() {
+ Base.apply(this, arguments);
+
+ this.git = new Git();
+ }
+ util.inherits(ConrefsLoader, Base);
+
+ // Read a template by its source URL
+ ConrefsLoader.prototype.onGetTemplate = function(sourceURL) {
+ var that = this;
+
+ return this.git.resolve(sourceURL)
+ .then(function(filepath) {
+ // Is local file
+ if (!filepath) {
+ filepath = that.book.resolve(sourceURL);
+ } else {
+ that.book.log.debug.ln('resolve from git', sourceURL, 'to', filepath);
+ }
+
+ // Read file from absolute path
+ return fs.readFile(filepath)
+ .then(function(source) {
+ return {
+ src: source.toString('utf8'),
+ path: filepath
+ };
+ });
+ });
+ };
+
+ // Generate a source URL for a template
+ ConrefsLoader.prototype.onResolveTemplate = function(from, to) {
+ // If origin is in the book, we enforce result file to be in the book
+ if (this.book.isInBook(from)) {
+ var href = location.toAbsolute(to, path.dirname(from), '');
+ return this.book.resolve(href);
+ }
+
+ // If origin is in a git repository, we resolve file in the git repository
+ var gitRoot = this.git.resolveRoot(from);
+ if (gitRoot) {
+ return pathUtil.resolveInRoot(gitRoot, to);
+ }
+
+ // If origin is not in the book (include from a git content ref)
+ return path.resolve(path.dirname(from), to);
+ };
+
+ return ConrefsLoader;
+};
diff --git a/lib/output/ebook.js b/lib/output/ebook.js
new file mode 100644
index 0000000..228a025
--- /dev/null
+++ b/lib/output/ebook.js
@@ -0,0 +1,190 @@
+var _ = require('lodash');
+var util = require('util');
+var juice = require('juice');
+
+var command = require('../utils/command');
+var fs = require('../utils/fs');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var WebsiteOutput = require('./website');
+var assetsInliner = require('./assets-inliner');
+
+function _EbookOutput() {
+ WebsiteOutput.apply(this, arguments);
+
+ // ebook-convert does not support link like "./"
+ this.opts.directoryIndex = false;
+}
+util.inherits(_EbookOutput, WebsiteOutput);
+
+var EbookOutput = assetsInliner(_EbookOutput);
+
+EbookOutput.prototype.name = 'ebook';
+
+// Finish generation, create ebook using ebook-convert
+EbookOutput.prototype.finish = function() {
+ var that = this;
+ if (that.book.isMultilingual()) {
+ return EbookOutput.super_.prototype.finish.apply(that);
+ }
+
+ return Promise()
+ .then(function() {
+ return EbookOutput.super_.prototype.finish.apply(that);
+ })
+
+ // Generate SUMMARY.html
+ .then(function() {
+ return that.render('summary', that.getContext())
+ .then(function(html) {
+ return that.writeFile(
+ 'SUMMARY.html',
+ html
+ );
+ });
+ })
+
+ // Start ebook-convert
+ .then(function() {
+ return that.ebookConvertOption();
+ })
+
+ .then(function(options) {
+ if (!that.opts.format) return;
+
+ var cmd = [
+ 'ebook-convert',
+ that.resolve('SUMMARY.html'),
+ that.resolve('index.'+that.opts.format),
+ command.optionsToShellArgs(options)
+ ].join(' ');
+
+ return command.exec(cmd)
+ .progress(function(data) {
+ that.book.log.debug(data);
+ })
+ .fail(function(err) {
+ if (err.code == 127) {
+ throw error.RequireInstallError({
+ cmd: 'ebook-convert',
+ install: 'Install it from Calibre: https://calibre-ebook.com'
+ });
+ }
+
+ throw error.EbookError(err);
+ });
+ });
+};
+
+// Generate header/footer for PDF
+EbookOutput.prototype.getPDFTemplate = function(tpl) {
+ var that = this;
+ var context = _.extend(
+ {
+ // Nunjucks context mapping to ebook-convert templating
+ page: {
+ num: '_PAGENUM_',
+ title: '_TITLE_',
+ section: '_SECTION_'
+ }
+ },
+ this.getContext()
+ );
+
+ return this.render('pdf_'+tpl, context)
+
+ // Inline css, include css relative to the output folder
+ .then(function(output) {
+ return Promise.nfcall(juice.juiceResources, output, {
+ webResources: {
+ relativeTo: that.root()
+ }
+ });
+ });
+};
+
+// Locate the cover file to use
+// Use configuration or search a "cover.jpg" file
+// For multi-lingual book, it can use the one from the main book
+EbookOutput.prototype.locateCover = function() {
+ var cover = this.book.config.get('cover', 'cover.jpg');
+
+ // Resolve to absolute
+ cover = this.resolve(cover);
+
+ // Cover doesn't exist and multilingual?
+ if (!fs.existsSync(cover)) {
+ if (this.parent) return this.parent.locateCover()
+ else return undefined;
+ }
+
+ return cover;
+};
+
+// Generate options for ebook-convert
+EbookOutput.prototype.ebookConvertOption = function() {
+ var that = this;
+
+ var options = {
+ '--cover': this.locateCover(),
+ '--title': that.book.config.get('title'),
+ '--comments': that.book.config.get('description'),
+ '--isbn': that.book.config.get('isbn'),
+ '--authors': that.book.config.get('author'),
+ '--language': that.book.config.get('language'),
+ '--book-producer': 'GitBook',
+ '--publisher': 'GitBook',
+ '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
+ '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
+ '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
+ '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
+ '--no-chapters-in-toc': true,
+ '--max-levels': '1',
+ '--breadth-first': true
+ };
+
+ if (that.opts.format == 'epub') {
+ options = _.extend(options, {
+ '--dont-split-on-page-breaks': true
+ });
+ }
+
+ if (that.opts.format != 'pdf') return Promise(options);
+
+ var pdfOptions = that.book.config.get('pdf');
+
+ options = _.extend(options, {
+ '--chapter-mark': String(pdfOptions.chapterMark),
+ '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
+ '--margin-left': String(pdfOptions.margin.left),
+ '--margin-right': String(pdfOptions.margin.right),
+ '--margin-top': String(pdfOptions.margin.top),
+ '--margin-bottom': String(pdfOptions.margin.bottom),
+ '--pdf-default-font-size': String(pdfOptions.fontSize),
+ '--pdf-mono-font-size': String(pdfOptions.fontSize),
+ '--paper-size': String(pdfOptions.paperSize),
+ '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
+ '--pdf-header-template': that.getPDFTemplate('header'),
+ '--pdf-footer-template': that.getPDFTemplate('footer'),
+ '--pdf-sans-family': String(pdfOptions.fontFamily)
+ });
+
+ return that.getPDFTemplate('header')
+ .then(function(tpl) {
+ options['--pdf-header-template'] = tpl;
+
+ return that.getPDFTemplate('footer');
+ })
+ .then(function(tpl) {
+ options['--pdf-footer-template'] = tpl;
+
+ return options;
+ });
+};
+
+// Don't write multi-lingual index for wbook
+EbookOutput.prototype.outputMultilingualIndex = function() {
+
+};
+
+module.exports = EbookOutput;
diff --git a/lib/output/folder.js b/lib/output/folder.js
new file mode 100644
index 0000000..8303ed2
--- /dev/null
+++ b/lib/output/folder.js
@@ -0,0 +1,152 @@
+var _ = require('lodash');
+var util = require('util');
+var path = require('path');
+
+var Output = require('./base');
+var fs = require('../utils/fs');
+var pathUtil = require('../utils/path');
+var Promise = require('../utils/promise');
+
+/*
+This output requires the native fs module to output
+book as a directory (mapping assets and pages)
+*/
+
+module.exports = function folderOutput(Base) {
+ Base = Base || Output;
+
+ function FolderOutput() {
+ Base.apply(this, arguments);
+
+ this.opts.root = path.resolve(this.opts.root || this.book.resolve('_book'));
+ }
+ util.inherits(FolderOutput, Base);
+
+ // Copy an asset file (non-parsable), ex: images, etc
+ FolderOutput.prototype.onAsset = function(filename) {
+ return this.copyFile(
+ this.book.resolve(filename),
+ filename
+ );
+ };
+
+ // Prepare the generation by creating the output folder
+ FolderOutput.prototype.prepare = function() {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ return FolderOutput.super_.prototype.prepare.apply(that);
+ })
+
+ // Cleanup output folder
+ .then(function() {
+ that.log.debug.ln('removing previous output directory');
+ return fs.rmDir(that.root())
+ .fail(function() {
+ return Promise();
+ });
+ })
+
+ // Create output folder
+ .then(function() {
+ that.log.debug.ln('creating output directory');
+ return fs.mkdirp(that.root());
+ })
+
+ // Add output folder to ignored files
+ .then(function() {
+ that.ignore.addPattern([
+ path.relative(that.book.root, that.root())
+ ]);
+ });
+ };
+
+ // Prepare output for a language book
+ FolderOutput.prototype.onLanguageBook = function(book) {
+ return new this.constructor(book, _.extend({}, this.opts, {
+
+ // Language output should be output in sub-directory of output
+ root: path.resolve(this.root(), book.language)
+ }), this);
+ };
+
+ // ----- Utility methods -----
+
+ // Return path to the root folder
+ FolderOutput.prototype.root = function() {
+ return this.opts.root;
+ };
+
+ // Resolve a file in the output directory
+ FolderOutput.prototype.resolve = function(filename) {
+ return pathUtil.resolveInRoot.apply(null, [this.root()].concat(_.toArray(arguments)));
+ };
+
+ // Copy a file to the output
+ FolderOutput.prototype.copyFile = function(from, to) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ to = that.resolve(to);
+ var folder = path.dirname(to);
+
+ // Ensure folder exists
+ return fs.mkdirp(folder);
+ })
+ .then(function() {
+ return fs.copy(from, to);
+ });
+ };
+
+ // Write a file/buffer to the output folder
+ FolderOutput.prototype.writeFile = function(filename, buf) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var folder = path.dirname(filename);
+
+ // Ensure folder exists
+ return fs.mkdirp(folder);
+ })
+
+ // Write the file
+ .then(function() {
+ return fs.writeFile(filename, buf);
+ });
+ };
+
+ // Return true if a file exists in the output folder
+ FolderOutput.prototype.hasFile = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ return fs.exists(that.resolve(filename));
+ });
+ };
+
+ // Create a new unique file
+ // Returns its filename
+ FolderOutput.prototype.createNewFile = function(base, filename) {
+ var that = this;
+
+ if (!filename) {
+ filename = path.basename(filename);
+ base = path.dirname(base);
+ }
+
+ return fs.uniqueFilename(this.resolve(base), filename)
+ .then(function(out) {
+ out = path.join(base, out);
+
+ return fs.ensure(that.resolve(out))
+ .thenResolve(out);
+ });
+ };
+
+ return FolderOutput;
+};
diff --git a/lib/output/json.js b/lib/output/json.js
new file mode 100644
index 0000000..e8a71cc
--- /dev/null
+++ b/lib/output/json.js
@@ -0,0 +1,47 @@
+var conrefsLoader = require('./conrefs');
+
+var JSONOutput = conrefsLoader();
+
+JSONOutput.prototype.name = 'json';
+
+// Don't copy asset on JSON output
+JSONOutput.prototype.onAsset = function(filename) {};
+
+// Write a page (parsable file)
+JSONOutput.prototype.onPage = function(page) {
+ var that = this;
+
+ // Parse the page
+ return page.toHTML(this)
+
+ // Write as json
+ .then(function() {
+ var json = page.getContext();
+
+ // Delete some private properties
+ delete json.config;
+
+ // Specify JSON output version
+ json.version = '2';
+
+ return that.writeFile(
+ page.withExtension('.json'),
+ JSON.stringify(json, null, 4)
+ );
+ });
+};
+
+// At the end of generation, generate README.json for multilingual books
+JSONOutput.prototype.finish = function() {
+ if (!this.book.isMultilingual()) return;
+
+ // Copy README.json from main book
+ var mainLanguage = this.book.langs.getDefault().id;
+ return this.copyFile(
+ this.resolve(mainLanguage, 'README.json'),
+ 'README.json'
+ );
+};
+
+
+module.exports = JSONOutput;
diff --git a/lib/output/website.js b/lib/output/website.js
new file mode 100644
index 0000000..230af71
--- /dev/null
+++ b/lib/output/website.js
@@ -0,0 +1,270 @@
+var _ = require('lodash');
+var path = require('path');
+var util = require('util');
+var nunjucks = require('nunjucks');
+var I18n = require('i18n-t');
+
+var Promise = require('../utils/promise');
+var location = require('../utils/location');
+var fs = require('../utils/fs');
+var defaultFilters = require('../template/filters');
+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) {
+ return path.join(dir, '_layouts');
+}
+
+function _WebsiteOutput() {
+ Output.apply(this, arguments);
+
+ // Nunjucks environment
+ this.env;
+
+ // Plugin instance for the main theme
+ this.theme;
+
+ // Plugin instance for the default theme
+ this.defaultTheme;
+
+ // Resources loaded from plugins
+ this.resources;
+
+ // i18n for themes
+ this.i18n = new I18n();
+}
+util.inherits(_WebsiteOutput, Output);
+
+var WebsiteOutput = conrefsLoader(_WebsiteOutput);
+
+// Name of the generator
+// It's being used as a prefix for templates
+WebsiteOutput.prototype.name = 'website';
+
+// Load and setup the theme
+WebsiteOutput.prototype.prepare = function() {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ return WebsiteOutput.super_.prototype.prepare.apply(that);
+ })
+
+ .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,
+
+ // Is default theme still installed
+ that.themeDefault? that.themeDefault.root : null
+ ])
+ .compact()
+ .uniq()
+ .value();
+
+ // Load i18n
+ _.each(searchPaths.concat().reverse(), function(searchPath) {
+ var i18nRoot = path.resolve(searchPath, '_i18n');
+
+ if (!fs.existsSync(i18nRoot)) return;
+ that.i18n.load(i18nRoot);
+ });
+
+ that.env = new nunjucks.Environment(new nunjucks.FileSystemLoader(_.map(searchPaths, templatesPath)));
+
+ // Add GitBook default filters
+ _.each(defaultFilters, function(fn, filter) {
+ that.env.addFilter(filter, fn);
+ });
+
+ // Translate using _i18n locales
+ that.env.addFilter('t', function(s) {
+ return that.i18n.t(that.book.config.get('language'), s);
+ });
+
+ // Transform an absolute path into a relative path
+ // using this.ctx.page.path
+ that.env.addFilter('resolveFile', function(href) {
+ return location.normalize(that.resolveForPage(this.ctx.file.path, href));
+ });
+
+ // Test if a file exists
+ that.env.addFilter('fileExists', function(href) {
+ return fs.existsSync(that.resolve(href));
+ });
+
+ // Transform a '.md' into a '.html' (README -> index)
+ that.env.addFilter('contentURL', function(s) {
+ return location.normalize(that.outputUrl(s));
+ });
+
+ // Relase path to an asset
+ that.env.addFilter('resolveAsset', function(href) {
+ href = path.join('gitbook', href);
+
+ // Resolve for current file
+ if (this.ctx.file) {
+ href = that.resolveForPage(this.ctx.file.path, '/' + href);
+ }
+
+ // Use assets from parent
+ if (that.book.isLanguageBook()) {
+ href = path.join('../', href);
+ }
+
+ return location.normalize(href);
+ });
+ })
+
+ // Copy assets from themes before copying files from book
+ .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;
+
+ // Copy assets only if exists (don't fail otherwise)
+ var assetFolder = path.join(folder, '_assets', that.name);
+ if (!fs.existsSync(assetFolder)) return;
+
+ that.log.debug.ln('copy assets from theme', assetFolder);
+ return fs.copyDir(
+ assetFolder,
+ that.resolve('gitbook'),
+ {
+ deleteFirst: false, // Delete "to" before
+ overwrite: true,
+ confirm: true
+ }
+ );
+ });
+ })
+
+ // Load resources for plugins
+ .then(function() {
+ return that.plugins.getResources(that.name)
+ .then(function(resources) {
+ that.resources = resources;
+ });
+ });
+};
+
+// Write a page (parsable file)
+WebsiteOutput.prototype.onPage = function(page) {
+ var that = this;
+
+ // Parse the page
+ return page.toHTML(this)
+
+ // Render the page template with the same context as the json output
+ .then(function() {
+ return that.render('page', page.getContext());
+ })
+
+ // Write the HTML file
+ .then(function(html) {
+ return that.writeFile(
+ that.outputPath(page.path),
+ html
+ );
+ });
+};
+
+// Finish generation, create ebook using ebook-convert
+WebsiteOutput.prototype.finish = function() {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ return WebsiteOutput.super_.prototype.finish.apply(that);
+ })
+
+ // Copy assets from plugins
+ .then(function() {
+ if (that.book.isLanguageBook()) return;
+ return that.plugins.copyResources(that.name, that.resolve('gitbook'));
+ })
+
+ // Generate homepage to select languages
+ .then(function() {
+ if (!that.book.isMultilingual()) return;
+ return that.outputMultilingualIndex();
+ });
+};
+
+// ----- Utilities ----
+
+// Write multi-languages index
+WebsiteOutput.prototype.outputMultilingualIndex = function() {
+ var that = this;
+
+ return that.render('languages', that.getContext())
+ .then(function(html) {
+ return that.writeFile(
+ 'index.html',
+ html
+ );
+ });
+};
+
+// Render a template using nunjucks
+// 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)
+ },
+
+ plugins: {
+ resources: this.resources
+ },
+
+ options: this.opts
+ });
+
+ return Promise.nfcall(this.env.render.bind(this.env), filename, context);
+};
+
+// Return a complete name for a template
+WebsiteOutput.prototype.templateName = function(name) {
+ return path.join(this.name, name+'.html');
+};
+
+module.exports = WebsiteOutput;
diff --git a/lib/page/html.js b/lib/page/html.js
new file mode 100644
index 0000000..bce6cd2
--- /dev/null
+++ b/lib/page/html.js
@@ -0,0 +1,280 @@
+var _ = require('lodash');
+var url = require('url');
+var cheerio = require('cheerio');
+var domSerializer = require('dom-serializer');
+var slug = require('github-slugid');
+
+var Promise = require('../utils/promise');
+var location = require('../utils/location');
+
+// Selector to ignore
+var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6';
+
+function HTMLPipeline(htmlString, opts) {
+ _.bindAll(this);
+
+ this.opts = _.defaults(opts || {}, {
+ // Called once the description has been found
+ onDescription: function(description) { },
+
+ // Calcul new href for a relative link
+ onRelativeLink: _.identity,
+
+ // Output an image
+ onImage: _.identity,
+
+ // Syntax highlighting
+ onCodeBlock: _.identity,
+
+ // Output a svg, if returns null the svg is kept inlined
+ onOutputSVG: _.constant(null),
+
+ // Words to annotate
+ annotations: [],
+
+ // When an annotation is applied
+ onAnnotation: function () { }
+ });
+
+ this.$ = cheerio.load(htmlString, {
+ // We should parse html without trying to normalize too much
+ xmlMode: false,
+
+ // SVG need some attributes to use uppercases
+ lowerCaseAttributeNames: false,
+ lowerCaseTags: false
+ });
+}
+
+// Transform a query of elements in the page
+HTMLPipeline.prototype._transform = function(query, fn) {
+ var that = this;
+
+ var $elements = this.$(query);
+
+ return Promise.serie($elements, function(el) {
+ var $el = that.$(el);
+ return fn.call(that, $el);
+ });
+};
+
+// Normalize links
+HTMLPipeline.prototype.transformLinks = function() {
+ return this._transform('a', function($a) {
+ var href = $a.attr('href');
+ if (!href) return;
+
+ if (location.isAnchor(href)) {
+ // Don't "change" anchor links
+ } else if (location.isRelative(href)) {
+ // Preserve anchor
+ var parsed = url.parse(href);
+ var filename = this.opts.onRelativeLink(parsed.pathname);
+
+ $a.attr('href', filename + (parsed.hash || ''));
+ } else {
+ // External links
+ $a.attr('target', '_blank');
+ }
+ });
+};
+
+// Normalize images
+HTMLPipeline.prototype.transformImages = function() {
+ return this._transform('img', function($img) {
+ return Promise(this.opts.onImage($img.attr('src')))
+ .then(function(filename) {
+ $img.attr('src', filename);
+ });
+ });
+};
+
+// Normalize code blocks
+HTMLPipeline.prototype.transformCodeBlocks = function() {
+ return this._transform('code', function($code) {
+ // Extract language
+ var lang = _.chain(
+ ($code.attr('class') || '').split(' ')
+ )
+ .map(function(cl) {
+ // Markdown
+ if (cl.search('lang-') === 0) return cl.slice('lang-'.length);
+
+ // Asciidoc
+ if (cl.search('language-') === 0) return cl.slice('language-'.length);
+
+ return null;
+ })
+ .compact()
+ .first()
+ .value();
+
+ var source = $code.text();
+
+ return Promise(this.opts.onCodeBlock(source, lang))
+ .then(function(blk) {
+ if (blk.html === false) {
+ $code.text(blk.body);
+ } else {
+ $code.html(blk.body);
+ }
+ });
+ });
+};
+
+// Add ID to headings
+HTMLPipeline.prototype.transformHeadings = function() {
+ var that = this;
+
+ this.$('h1,h2,h3,h4,h5,h6').each(function() {
+ var $h = that.$(this);
+
+ // Already has an ID?
+ if ($h.attr('id')) return;
+ $h.attr('id', slug($h.text()));
+ });
+};
+
+// Outline SVG from the HML
+HTMLPipeline.prototype.transformSvgs = function() {
+ var that = this;
+
+ return this._transform('svg', function($svg) {
+ var content = [
+ '<?xml version="1.0" encoding="UTF-8"?>',
+ renderDOM(that.$, $svg)
+ ].join('\n');
+
+ return Promise(that.opts.onOutputSVG(content))
+ .then(function(filename) {
+ if (!filename) return;
+
+ $svg.replaceWith(that.$('<img>').attr('src', filename));
+ });
+ });
+};
+
+// Annotate the content
+HTMLPipeline.prototype.applyAnnotations = function() {
+ var that = this;
+
+ _.each(this.opts.annotations, function(annotation) {
+ var searchRegex = new RegExp( '\\b(' + pregQuote(annotation.name.toLowerCase()) + ')\\b' , 'gi' );
+
+ that.$('*').each(function() {
+ var $this = that.$(this);
+
+ if (
+ $this.is(ANNOTATION_IGNORE) ||
+ $this.parents(ANNOTATION_IGNORE).length > 0
+ ) return;
+
+ replaceText(that.$, this, searchRegex, function(match) {
+ that.opts.onAnnotation(annotation);
+
+ return '<a href="' + that.opts.onRelativeLink(annotation.href) + '" '
+ + 'class="glossary-term" title="'+_.escape(annotation.description)+'">'
+ + match
+ + '</a>';
+ });
+ });
+ });
+};
+
+// Extract page description from html
+// This can totally be improved
+HTMLPipeline.prototype.extractDescription = function() {
+ var $p = this.$('p').first();
+ var description = $p.text().trim().slice(0, 155);
+
+ this.opts.onDescription(description);
+};
+
+// Write content to the pipeline
+HTMLPipeline.prototype.output = function() {
+ var that = this;
+
+ return Promise()
+ .then(this.extractDescription)
+ .then(this.transformImages)
+ .then(this.transformHeadings)
+ .then(this.transformCodeBlocks)
+ .then(this.transformSvgs)
+ .then(this.applyAnnotations)
+
+ // Transform of links should be applied after annotations
+ // because annotations are created as links
+ .then(this.transformLinks)
+
+ .then(function() {
+ return renderDOM(that.$);
+ });
+};
+
+
+// Render a cheerio DOM as html
+function renderDOM($, dom, options) {
+ if (!dom && $._root && $._root.children) {
+ dom = $._root.children;
+ }
+ options = options|| dom.options || $._options;
+ return domSerializer(dom, options);
+}
+
+// Replace text in an element
+function replaceText($, el, search, replace, text_only ) {
+ return $(el).each(function(){
+ var node = this.firstChild,
+ val,
+ new_val,
+
+ // Elements to be removed at the end.
+ remove = [];
+
+ // Only continue if firstChild exists.
+ if ( node ) {
+
+ // Loop over all childNodes.
+ while (node) {
+
+ // Only process text nodes.
+ if ( node.nodeType === 3 ) {
+
+ // The original node value.
+ val = node.nodeValue;
+
+ // The new value.
+ new_val = val.replace( search, replace );
+
+ // Only replace text if the new value is actually different!
+ if ( new_val !== val ) {
+
+ if ( !text_only && /</.test( new_val ) ) {
+ // The new value contains HTML, set it in a slower but far more
+ // robust way.
+ $(node).before( new_val );
+
+ // Don't remove the node yet, or the loop will lose its place.
+ remove.push( node );
+ } else {
+ // The new value contains no HTML, so it can be set in this
+ // very fast, simple way.
+ node.nodeValue = new_val;
+ }
+ }
+ }
+
+ node = node.nextSibling;
+ }
+ }
+
+ // Time to remove those elements!
+ if (remove.length) $(remove).remove();
+ });
+}
+
+function pregQuote( str ) {
+ return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
+}
+
+module.exports = HTMLPipeline;
diff --git a/lib/page/index.js b/lib/page/index.js
new file mode 100644
index 0000000..f3a8f39
--- /dev/null
+++ b/lib/page/index.js
@@ -0,0 +1,250 @@
+var _ = require('lodash');
+var path = require('path');
+var direction = require('direction');
+var fm = require('front-matter');
+
+var error = require('../utils/error');
+var pathUtil = require('../utils/path');
+var location = require('../utils/location');
+var parsers = require('../parsers');
+var gitbook = require('../gitbook');
+var pluginCompatibility = require('../plugins/compatibility');
+var HTMLPipeline = require('./html');
+
+/*
+A page represent a parsable file in the book (Markdown, Asciidoc, etc)
+*/
+
+function Page(book, filename) {
+ if (!(this instanceof Page)) return new Page(book, filename);
+ var extension;
+ _.bindAll(this);
+
+ this.book = book;
+ this.log = this.book.log;
+
+ // Current content
+ this.content = '';
+
+ // Short description for the page
+ this.description = '';
+
+ // Relative path to the page
+ this.path = location.normalize(filename);
+
+ // Absolute path to the page
+ this.rawPath = this.book.resolve(filename);
+
+ // Last modification date
+ this.mtime = 0;
+
+ // Can we parse it?
+ extension = path.extname(this.path);
+ this.parser = parsers.get(extension);
+ if (!this.parser) throw error.ParsingError(new Error('Can\'t parse file "'+this.path+'"'));
+
+ this.type = this.parser.name;
+}
+
+// Return the filename of the page with another extension
+// "README.md" -> "README.html"
+Page.prototype.withExtension = function(ext) {
+ return pathUtil.setExtension(this.path, ext);
+};
+
+// Resolve a filename relative to this page
+// It returns a path relative to the book root folder
+Page.prototype.resolveLocal = function() {
+ var dir = path.dirname(this.path);
+ var file = path.join.apply(path, _.toArray(arguments));
+
+ return location.toAbsolute(file, dir, '');
+};
+
+// Resolve a filename relative to this page
+// It returns an absolute path for the FS
+Page.prototype.resolve = function() {
+ return this.book.resolve(this.resolveLocal.apply(this, arguments));
+};
+
+// Convert an absolute path (in the book) to a relative path from this page
+Page.prototype.relative = function(name) {
+ // Convert /test.png -> test.png
+ name = location.toAbsolute(name, '', '');
+
+ return location.relative(
+ this.resolve('.') + '/',
+ this.book.resolve(name)
+ );
+};
+
+// Return a page result of a relative page from this page
+Page.prototype.followPage = function(filename) {
+ var absPath = this.resolveLocal(filename);
+ return this.book.getPage(absPath);
+};
+
+// Update content of the page
+Page.prototype.update = function(content) {
+ this.content = content;
+};
+
+// Read the page as a string
+Page.prototype.read = function() {
+ var that = this;
+
+ return this.book.statFile(this.path)
+ .then(function(stat) {
+ that.mtime = stat.mtime;
+ return that.book.readFile(that.path);
+ })
+ .then(this.update);
+};
+
+// Return templating context for this page
+// This is used both for themes and page parsing
+Page.prototype.getContext = function() {
+ var article = this.book.summary.getArticle(this);
+ var next = article? article.next() : null;
+ var prev = article? article.prev() : null;
+
+ // Detect text direction in this page
+ var dir = this.book.config.get('direction');
+ if (!dir) {
+ dir = direction(this.content);
+ if (dir == 'neutral') dir = null;
+ }
+
+ return _.extend(
+ {
+ file: {
+ path: this.path,
+ mtime: this.mtime,
+ type: this.type
+ },
+ page: {
+ title: article? article.title : null,
+ description: this.description,
+ next: next? next.getContext() : null,
+ previous: prev? prev.getContext() : null,
+ level: article? article.level : null,
+ depth: article? article.depth : 0,
+ content: this.content,
+ dir: dir
+ }
+ },
+ gitbook.getContext(),
+ this.book.getContext(),
+ this.book.langs.getContext(),
+ this.book.summary.getContext(),
+ this.book.glossary.getContext(),
+ this.book.config.getContext()
+ );
+};
+
+// Parse the page and return its content
+Page.prototype.toHTML = function(output) {
+ var that = this;
+
+ this.log.debug.ln('start parsing file', this.path);
+
+ // Call a hook in the output
+ // using an utility to "keep" compatibility with gitbook 2
+ function hook(name) {
+ return pluginCompatibility.pageHook(that, function(ctx) {
+ return output.plugins.hook(name, ctx);
+ })
+ .then(function(result) {
+ if(_.isString(result)) that.update(result);
+ });
+ }
+
+ return this.read()
+
+ // Parse yaml front matter
+ .then(function() {
+ var parsed = fm(that.content);
+
+ // Extend page with the fontmatter attribute
+ that.description = parsed.attributes.description || '';
+
+ // Keep only the body
+ that.update(parsed.body);
+ })
+
+ .then(function() {
+ return hook('page:before');
+ })
+
+ // Pre-process page with parser
+ .then(function() {
+ return that.parser.page.prepare(that.content)
+ .then(that.update);
+ })
+
+ // Render template
+ .then(function() {
+ return output.template.render(that.content, that.getContext(), {
+ path: that.path
+ })
+ .then(that.update);
+ })
+
+ // Render markup using the parser
+ .then(function() {
+ return that.parser.page(that.content)
+ .then(function(out) {
+ that.update(out.content);
+ });
+ })
+
+ // Post process templating
+ .then(function() {
+ return output.template.postProcess(that.content)
+ .then(that.update);
+ })
+
+ // Normalize HTML output
+ .then(function() {
+ var pipelineOpts = {
+ onRelativeLink: _.partial(output.onRelativeLink, that),
+ onImage: _.partial(output.onOutputImage, that),
+ onOutputSVG: _.partial(output.onOutputSVG, that),
+
+ // Use 'code' template block
+ onCodeBlock: function(source, lang) {
+ return output.template.applyBlock('code', {
+ body: source,
+ kwargs: {
+ language: lang
+ }
+ });
+ },
+
+ // Extract description from page's content if no frontmatter
+ onDescription: function(description) {
+ if (that.description) return;
+ that.description = description;
+ },
+
+ // Convert glossary entries to annotations
+ annotations: that.book.glossary.annotations()
+ };
+ var pipeline = new HTMLPipeline(that.content, pipelineOpts);
+
+ return pipeline.output()
+ .then(that.update);
+ })
+
+ .then(function() {
+ return hook('page');
+ })
+
+ // Return content itself
+ .then(function() {
+ return that.content;
+ });
+};
+
+
+module.exports = Page;
diff --git a/lib/parsers.js b/lib/parsers.js
new file mode 100644
index 0000000..6899865
--- /dev/null
+++ b/lib/parsers.js
@@ -0,0 +1,60 @@
+var _ = require('lodash');
+var path = require('path');
+
+var markdownParser = require('gitbook-markdown');
+var asciidocParser = require('gitbook-asciidoc');
+
+var Promise = require('./utils/promise');
+
+// This list is ordered by priority of parsers to use
+var PARSERS = [
+ createParser(markdownParser, {
+ name: 'markdown',
+ extensions: ['.md', '.markdown', '.mdown']
+ }),
+ createParser(asciidocParser, {
+ name: 'asciidoc',
+ extensions: ['.adoc', '.asciidoc']
+ })
+];
+
+
+// Prepare and compose a parser
+function createParser(parser, base) {
+ var nparser = base;
+
+ nparser.glossary = Promise.wrapfn(parser.glossary);
+ nparser.glossary.toText = Promise.wrapfn(parser.glossary.toText);
+
+ nparser.summary = Promise.wrapfn(parser.summary);
+ nparser.summary.toText = Promise.wrapfn(parser.summary.toText);
+
+ nparser.langs = Promise.wrapfn(parser.langs);
+ nparser.langs.toText = Promise.wrapfn(parser.langs.toText);
+
+ nparser.readme = Promise.wrapfn(parser.readme);
+
+ nparser.page = Promise.wrapfn(parser.page);
+ nparser.page.prepare = Promise.wrapfn(parser.page.prepare || _.identity);
+
+ return nparser;
+}
+
+// Return a specific parser according to an extension
+function getParser(ext) {
+ return _.find(PARSERS, function(input) {
+ return input.name == ext || _.contains(input.extensions, ext);
+ });
+}
+
+// Return parser for a file
+function getParserForFile(filename) {
+ return getParser(path.extname(filename));
+}
+
+module.exports = {
+ all: PARSERS,
+ extensions: _.flatten(_.pluck(PARSERS, 'extensions')),
+ get: getParser,
+ getForFile: getParserForFile
+};
diff --git a/lib/plugin.js b/lib/plugin.js
deleted file mode 100644
index b7e8260..0000000
--- a/lib/plugin.js
+++ /dev/null
@@ -1,241 +0,0 @@
-var _ = require('lodash');
-var Q = require('q');
-var path = require('path');
-var url = require('url');
-var fs = require('./utils/fs');
-var resolve = require('resolve');
-var mergeDefaults = require('merge-defaults');
-var jsonschema = require('jsonschema');
-var jsonSchemaDefaults = require('json-schema-defaults');
-
-var version = require('./version');
-
-var PLUGIN_PREFIX = 'gitbook-plugin-';
-
-// Return an absolute name for the plugin (the one on NPM)
-function absoluteName(name) {
- if (name.indexOf(PLUGIN_PREFIX) === 0) return name;
- return [PLUGIN_PREFIX, name].join('');
-}
-
-
-var Plugin = function(book, name) {
- this.book = book;
- this.name = absoluteName(name);
- this.packageInfos = {};
- this.infos = {};
-
- // Bind methods
- _.bindAll(this);
-
- _.each([
- absoluteName(name),
- name
- ], function(_name) {
- // Load from the book
- if (this.load(_name, book.root)) return false;
-
- // Load from default plugins
- if (this.load(_name, __dirname)) return false;
- }, this);
-};
-
-// Type of plugins resources
-Plugin.RESOURCES = ['js', 'css'];
-Plugin.HOOKS = [
- 'init', 'finish', 'finish:before', 'config', 'page', 'page:before'
-];
-
-// Return the reduce name for the plugin
-// "gitbook-plugin-test" -> "test"
-// Return a relative name for the plugin (the one on GitBook)
-Plugin.prototype.reducedName = function() {
- return this.name.replace(PLUGIN_PREFIX, '');
-};
-
-// Load from a name
-Plugin.prototype.load = function(name, baseDir) {
- try {
- var res = resolve.sync(name+'/package.json', { basedir: baseDir });
-
- this.baseDir = path.dirname(res);
- this.packageInfos = require(res);
- this.infos = require(resolve.sync(name, { basedir: baseDir }));
- this.name = this.packageInfos.name;
-
- return true;
- } catch (e) {
- this.packageInfos = {};
- this.infos = {};
- return false;
- }
-};
-
-Plugin.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.name+'/'+resource };
-};
-
-// Return resources
-Plugin.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 Q({});
- }
-
- // Dynamic function
- if(typeof book === 'function') {
- // Call giving it the context of our book
- return Q().then(book.bind(this.book));
- }
-
- // Plain data object
- return Q(_.cloneDeep(book));
-};
-
-// Normalize resources and return them
-Plugin.prototype.getResources = function(base) {
- var that = this;
-
- return this._getResources(base)
- .then(function(resources) {
-
- _.each(Plugin.RESOURCES, function(resourceType) {
- resources[resourceType] = (resources[resourceType] || []).map(that.normalizeResource);
- });
-
- return resources;
- });
-};
-
-// Normalize filters and return them
-Plugin.prototype.getFilters = function() {
- return this.infos.filters || {};
-};
-
-// Normalize blocks and return them
-Plugin.prototype.getBlocks = function() {
- return this.infos.blocks || {};
-};
-
-// Test if it's a valid plugin
-Plugin.prototype.isValid = function() {
- var that = this;
- var isValid = (
- this.packageInfos &&
- this.packageInfos.name &&
- this.packageInfos.engines &&
- this.packageInfos.engines.gitbook &&
- version.satisfies(this.packageInfos.engines.gitbook)
- );
-
- // Valid hooks
- _.each(this.infos.hooks, function(hook, hookName) {
- if (_.contains(Plugin.HOOKS, hookName)) return;
- that.book.log.warn.ln('Hook "'+hookName+'"" used by plugin "'+that.packageInfos.name+'" has been removed or is deprecated');
- });
-
- return isValid;
-};
-
-// Normalize, validate configuration for this plugin using its schema
-// Throw an error when shcema is not respected
-Plugin.prototype.validateConfig = function(config) {
- var that = this;
-
- return Q()
- .then(function() {
- var schema = that.packageInfos.gitbook || {};
- if (!schema) return config;
-
- // Normalize schema
- schema.id = '/pluginsConfig.'+that.reducedName();
- schema.type = 'object';
-
- // Validate and throw if invalid
- var v = new jsonschema.Validator();
- var result = v.validate(config, schema, {
- propertyName: 'pluginsConfig.'+that.reducedName()
- });
-
- // Throw error
- if (result.errors.length > 0) {
- throw new Error('Configuration Error: '+result.errors[0].stack);
- }
-
- // Insert default values
- var defaults = jsonSchemaDefaults(schema);
- return mergeDefaults(config, defaults);
- });
-};
-
-// Resolve file path
-Plugin.prototype.resolveFile = function(filename) {
- return path.resolve(this.baseDir, filename);
-};
-
-// Resolve file path
-Plugin.prototype.callHook = function(name, data) {
- // Our book will be the context to apply
- var context = this.book;
-
- var hookFunc = this.infos.hooks? this.infos.hooks[name] : null;
- data = data || {};
-
- if (!hookFunc) return Q(data);
-
- this.book.log.debug.ln('call hook', name);
- if (!_.contains(Plugin.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 Q()
- .then(function() {
- return hookFunc.apply(context, [data]);
- });
-};
-
-// Copy plugin assets fodler
-Plugin.prototype.copyAssets = function(out, base) {
- var that = this;
-
- return this.getResources(base)
- .get('assets')
- .then(function(assets) {
- // Assets are undefined
- if(!assets) return false;
-
- return fs.copy(
- that.resolveFile(assets),
- out
- ).then(_.constant(true));
- }, _.constant(false));
-};
-
-// Get config from book
-Plugin.prototype.getConfig = function() {
- return this.book.config.get('pluginsConfig.'+this.reducedName(), {});
-};
-
-// Set configuration for this plugin
-Plugin.prototype.setConfig = function(values) {
- return this.book.config.set('pluginsConfig.'+this.reducedName(), values);
-};
-
-module.exports = Plugin;
diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js
new file mode 100644
index 0000000..7ad35a9
--- /dev/null
+++ b/lib/plugins/compatibility.js
@@ -0,0 +1,57 @@
+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 = {
+ config: book.config,
+ log: plugin.log,
+
+ // Paths
+ resolve: book.resolve
+ };
+
+ // Deprecation
+ error.deprecateField(ctx, 'options', book.config.dump(), '"options" property is deprecated, use config.get(key) instead');
+
+ // Loop for template filters/blocks
+ error.deprecateField(ctx, 'book', ctx, '"book" property is deprecated, use "this" directly instead');
+
+ return ctx;
+}
+
+// Call a function "fn" with a context of page similar to the one in GitBook v2
+function pageHook(page, fn) {
+ var ctx = {
+ type: page.type,
+ content: page.content,
+ path: page.path,
+ rawPath: page.rawPath
+ };
+
+ // Deprecate sections
+ error.deprecateField(ctx, 'sections', [
+ { content: ctx.content }
+ ], '"sections" property is deprecated, use page.content instead');
+
+ return fn(ctx)
+ .then(function(result) {
+ if (!result) return undefined;
+ if (result.content) {
+ return result.content;
+ }
+
+ if (result.sections) {
+ return _.pluck(result.sections, 'content').join('\n');
+ }
+ });
+}
+
+module.exports = {
+ pluginCtx: pluginCtx,
+ pageHook: pageHook
+};
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
new file mode 100644
index 0000000..8280542
--- /dev/null
+++ b/lib/plugins/index.js
@@ -0,0 +1,155 @@
+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);
+}
+
+// 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, or a list of plugins
+PluginsManager.prototype.load = function(name) {
+ var that = this;
+
+ if (_.isArray(name)) {
+ return Promise.serie(name, function(_name) {
+ return that.load(_name);
+ });
+ }
+
+ return Promise()
+
+ // Initiate and load the plugin
+ .then(function() {
+ var plugin;
+
+ if (!_.isString(name)) plugin = name;
+ else plugin = new BookPlugin(that.book, name);
+
+ 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 plugins = _.pluck(this.book.config.get('plugins'), 'name');
+
+ this.log.info.ln('loading', plugins.length, 'plugins');
+ return this.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/plugins/plugin.js b/lib/plugins/plugin.js
new file mode 100644
index 0000000..f678111
--- /dev/null
+++ b/lib/plugins/plugin.js
@@ -0,0 +1,300 @@
+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) {
+ this.book = book;
+ this.log = this.book.log.prefix(pluginId);
+
+ this.id = pluginId;
+ this.npmId = registry.npmId(pluginId);
+ this.root;
+
+ 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
+// 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) {
+ 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];
+
+ base = 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
new file mode 100644
index 0000000..837c9b5
--- /dev/null
+++ b/lib/plugins/registry.js
@@ -0,0 +1,115 @@
+var npm = require('npm');
+var npmi = require('npmi');
+var semver = require('semver');
+var _ = require('lodash');
+
+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.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');
+ });
+}
+
+module.exports = {
+ npmId: npmId,
+ pluginId: pluginId,
+ validateId: validateId,
+
+ resolve: resolveVersion,
+ link: linkPlugin,
+ install: installPlugin
+};
diff --git a/lib/pluginslist.js b/lib/pluginslist.js
deleted file mode 100644
index 290cd35..0000000
--- a/lib/pluginslist.js
+++ /dev/null
@@ -1,230 +0,0 @@
-var _ = require('lodash');
-var Q = require('q');
-var npmi = require('npmi');
-var npm = require('npm');
-var semver = require('semver');
-
-var Plugin = require('./plugin');
-var version = require('./version');
-
-var initNPM = _.memoize(function() {
- return Q.nfcall(npm.load, { silent: true, loglevel: 'silent' });
-});
-
-
-var PluginsList = function(book, plugins) {
- this.book = book;
- this.log = this.book.log;
-
- // List of Plugin objects
- this.list = [];
-
- // List of names of failed plugins
- this.failed = [];
-
- // Namespaces
- this.namespaces = _.chain(['website', 'ebook'])
- .map(function(namespace) {
- return [
- namespace,
- {
- html: {},
- resources: _.chain(Plugin.RESOURCES)
- .map(function(type) {
- return [type, []];
- })
- .object()
- .value()
- }
- ];
- })
- .object()
- .value();
-
- // Bind methods
- _.bindAll(this);
-
- if (plugins) this.load(plugins);
-};
-
-// return count of plugins
-PluginsList.prototype.count = function() {
- return this.list.length;
-};
-
-// Add and load a plugin
-PluginsList.prototype.load = function(plugin) {
- var that = this;
-
- if (_.isArray(plugin)) {
- return _.reduce(plugin, function(prev, p) {
- return prev.then(function() {
- return that.load(p);
- });
- }, Q());
- }
- if (_.isObject(plugin) && !(plugin instanceof Plugin)) plugin = plugin.name;
- if (_.isString(plugin)) plugin = new Plugin(this.book, plugin);
-
- that.log.info('load plugin', plugin.name, '....');
- if (!plugin.isValid()) {
- that.log.info.fail();
- that.failed.push(plugin.name);
- return Q();
- } else {
- that.log.info.ok();
-
- // Push in the list
- that.list.push(plugin);
- }
-
- return Q()
-
- // Validate and normalize configuration
- .then(function() {
- var config = plugin.getConfig();
- return plugin.validateConfig(config);
- })
- .then(function(config) {
- // Update configuration
- plugin.setConfig(config);
-
- // Extract filters
- that.book.template.addFilters(plugin.getFilters());
-
- // Extract blocks
- that.book.template.addBlocks(plugin.getBlocks());
-
- return _.reduce(_.keys(that.namespaces), function(prev, namespaceName) {
- return prev.then(function() {
- return plugin.getResources(namespaceName)
- .then(function(plResources) {
- var namespace = that.namespaces[namespaceName];
-
- // Extract js and css
- _.each(Plugin.RESOURCES, function(resourceType) {
- namespace.resources[resourceType] = (namespace.resources[resourceType] || []).concat(plResources[resourceType] || []);
- });
-
- // Map of html resources by name added by each plugin
- _.each(plResources.html || {}, function(value, tag) {
- // Turn into function if not one already
- if (!_.isFunction(value)) value = _.constant(value);
-
- namespace.html[tag] = namespace.html[tag] || [];
- namespace.html[tag].push(value);
- });
- });
- });
- }, Q());
- });
-};
-
-// Call a hook
-PluginsList.prototype.hook = function(name, data) {
- return _.reduce(this.list, function(prev, plugin) {
- return prev.then(function(ret) {
- return plugin.callHook(name, ret);
- });
- }, Q(data));
-};
-
-// Return a template from a plugin
-PluginsList.prototype.template = function(name) {
- var withTpl = _.find(this.list, function(plugin) {
- return (
- plugin.infos.templates &&
- plugin.infos.templates[name]
- );
- });
-
- if (!withTpl) return null;
- return withTpl.resolveFile(withTpl.infos.templates[name]);
-};
-
-// Return an html snippet
-PluginsList.prototype.html = function(namespace, tag, context, options) {
- var htmlSnippets = this.namespaces[namespace].html[tag];
- return _.map(htmlSnippets || [], function(code) {
- return code.call(context, options);
- }).join('\n');
-};
-
-// Return a resources map for a namespace
-PluginsList.prototype.resources = function(namespace) {
- return this.namespaces[namespace].resources;
-};
-
-// Install plugins from a book
-PluginsList.prototype.install = function() {
- var that = this;
-
- // Remove defaults (no need to install)
- var plugins = _.reject(that.book.options.plugins, {
- isDefault: true
- });
-
- // Install plugins one by one
- that.book.log.info.ln(plugins.length+' plugins to install');
- return _.reduce(plugins, function(prev, plugin) {
- return prev.then(function() {
- var fullname = 'gitbook-plugin-'+plugin.name;
-
- return Q()
-
- // Resolve version if needed
- .then(function() {
- if (plugin.version) return plugin.version;
-
- that.book.log.info.ln('No version specified, resolve plugin', plugin.name);
- return initNPM()
- .then(function() {
- return Q.nfcall(npm.commands.view, [fullname+'@*', '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 && version.satisfies(v.gitbook);
- })
- .sort(function(v1, v2) {
- return semver.lt(v1.version, v2.version)? 1 : -1;
- })
- .pluck('version')
- .first()
- .value();
- });
- })
-
- // Install the plugin with the resolved version
- .then(function(version) {
- if (!version) {
- throw 'Found no satisfactory version for plugin '+plugin.name;
- }
-
- that.book.log.info.ln('install plugin', plugin.name, 'from npm ('+fullname+') with version', version);
- return Q.nfcall(npmi, {
- 'name': fullname,
- 'version': version,
- 'path': that.book.root,
- 'npmLoad': {
- 'loglevel': 'silent',
- 'loaded': true,
- 'prefix': that.book.root
- }
- });
- })
- .then(function() {
- that.book.log.info.ok('plugin', plugin.name, 'installed with success');
- });
- });
- }, Q());
-};
-
-module.exports = PluginsList;
diff --git a/lib/blocks.js b/lib/template/blocks.js
index 92097a7..a079cde 100644
--- a/lib/blocks.js
+++ b/lib/template/blocks.js
@@ -6,6 +6,11 @@ module.exports = {
html: _.identity,
// Highlight a code block
- // This block can be extent by plugins
- code: _.identity
+ // This block can be replaced by plugins
+ code: function(blk) {
+ return {
+ html: false,
+ body: blk.body
+ };
+ }
};
diff --git a/lib/template/filters.js b/lib/template/filters.js
new file mode 100644
index 0000000..ac68b82
--- /dev/null
+++ b/lib/template/filters.js
@@ -0,0 +1,15 @@
+var moment = require('moment');
+
+
+module.exports = {
+ // Format a date
+ // ex: 'MMMM Do YYYY, h:mm:ss a
+ date: function(time, format) {
+ return moment(time).format(format);
+ },
+
+ // Relative Time
+ dateFromNow: function(time) {
+ return moment(time).fromNow();
+ }
+};
diff --git a/lib/template.js b/lib/template/index.js
index dac1201..fc7603d 100644
--- a/lib/template.js
+++ b/lib/template/index.js
@@ -1,47 +1,42 @@
var _ = require('lodash');
-var Q = require('q');
var path = require('path');
var nunjucks = require('nunjucks');
-var parsers = require('gitbook-parsers');
var escapeStringRegexp = require('escape-string-regexp');
-var batch = require('./utils/batch');
-var pkg = require('../package.json');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var parsers = require('../parsers');
var defaultBlocks = require('./blocks');
-var BookLoader = require('./conrefs_loader');
+var defaultFilters = require('./filters');
+var Loader = require('./loader');
-// Normalize result from a block
+// Return extension name for a specific block
+function blockExtName(name) {
+ return 'Block'+name+'Extension';
+}
+
+// Normalize the result of block process function
function normBlockResult(blk) {
if (_.isString(blk)) blk = { body: blk };
return blk;
}
-
-var TemplateEngine = function(book) {
- var that = this;
-
- this.book = book;
+function TemplateEngine(output) {
+ this.output = output;
+ this.book = output.book;
this.log = this.book.log;
- // Template loader
- this.loader = new BookLoader(book, {
- // Replace shortcuts in imported files
- interpolate: function(filepath, source) {
- var parser = parsers.get(path.extname(filepath));
- var type = parser? parser.name : null;
-
- return that.applyShortcuts(type, source);
- }
- });
+ // Create file loader
+ this.loader = new Loader(this);
- // Nunjucks env
+ // Create nunjucks instance
this.env = new nunjucks.Environment(
this.loader,
{
- // Escaping is done after by the markdown parser
+ // Escaping is done after by the asciidoc/markdown parser
autoescape: false,
- // Tags
+ // Syntax
tags: {
blockStart: '{%',
blockEnd: '%}',
@@ -65,79 +60,48 @@ var TemplateEngine = function(book) {
// Bind methods
_.bindAll(this);
- // Add default blocks
+ // Add default blocks and filters
this.addBlocks(defaultBlocks);
-};
-
-// Process the result of block in a context
-TemplateEngine.prototype.processBlock = function(blk) {
- blk = _.defaults(blk, {
- parse: false,
- post: undefined
- });
- blk.id = _.uniqueId('blk');
-
- var toAdd = (!blk.parse) || (blk.post !== undefined);
-
- // Add to global map
- if (toAdd) this.blockBodies[blk.id] = blk;
-
- // Parsable block, just return it
- if (blk.parse) {
- return blk.body;
- }
-
- // Return it as a position marker
- return '@%@'+blk.id+'@%@';
-};
-
-// Replace position markers of blocks by body after processing
-// This is done to avoid that markdown/asciidoc processer parse the block content
-TemplateEngine.prototype.replaceBlocks = function(content) {
- var that = this;
-
- return content.replace(/\@\%\@([\s\S]+?)\@\%\@/g, function(match, key) {
- var blk = that.blockBodies[key];
- if (!blk) return match;
-
- var body = blk.body;
-
- return body;
- });
-};
+ this.addFilters(defaultFilters);
+}
// Bind a function to a context
+// Filters and blocks are binded to this context
TemplateEngine.prototype.bindContext = function(func) {
- var that = this;
+ var ctx = {
+ ctx: this.ctx,
+ output: this.output,
+ generator: this.output.name
+ };
- return function() {
- var ctx = {
- ctx: this.ctx,
- book: that.book,
- generator: that.book.options.generator
- };
+ return _.bind(func, ctx);
+};
- return func.apply(ctx, arguments);
- };
+// Interpolate a string content to replace shortcuts according to the filetype
+TemplateEngine.prototype.interpolate = function(filepath, source) {
+ var parser = parsers.get(path.extname(filepath));
+ var type = parser? parser.name : null;
+
+ return this.applyShortcuts(type, source);
};
-// Add filter
+// Add a new custom filter
TemplateEngine.prototype.addFilter = function(filterName, func) {
try {
this.env.getFilter(filterName);
- this.log.warn.ln('conflict in filters, \''+filterName+'\' is already set');
+ this.log.error.ln('conflict in filters, "'+filterName+'" is already set');
return false;
} catch(e) {
// Filter doesn't exist
}
- this.log.debug.ln('add filter \''+filterName+'\'');
+ this.log.debug.ln('add filter "'+filterName+'"');
this.env.addFilter(filterName, this.bindContext(function() {
var ctx = this;
var args = Array.prototype.slice.apply(arguments);
var callback = _.last(args);
- Q()
+ Promise()
.then(function() {
return func.apply(ctx, args.slice(0, -1));
})
@@ -146,29 +110,24 @@ TemplateEngine.prototype.addFilter = function(filterName, func) {
return true;
};
-// Add multiple filters
+// Add multiple filters at once
TemplateEngine.prototype.addFilters = function(filters) {
_.each(filters, function(filter, name) {
this.addFilter(name, filter);
}, this);
};
-// Return nunjucks extension name of a block
-TemplateEngine.prototype.blockExtName = function(name) {
- return 'Block'+name+'Extension';
-};
-
-// Test if a block is defined
+// Return true if a block is defined
TemplateEngine.prototype.hasBlock = function(name) {
- return this.env.hasExtension(this.blockExtName(name));
+ return this.env.hasExtension(blockExtName(name));
};
-// Remove a block
+// Remove/Disable a block
TemplateEngine.prototype.removeBlock = function(name) {
if (!this.hasBlock(name)) return;
// Remove nunjucks extension
- this.env.removeExtension(this.blockExtName(name));
+ this.env.removeExtension(blockExtName(name));
// Cleanup shortcuts
this.shortcuts = _.reject(this.shortcuts, {
@@ -177,22 +136,27 @@ TemplateEngine.prototype.removeBlock = function(name) {
};
// Add a block
+// Using the extensions of nunjucks: https://mozilla.github.io/nunjucks/api.html#addextension
TemplateEngine.prototype.addBlock = function(name, block) {
var that = this, Ext, extName;
+ // Block can be a simple function
if (_.isFunction(block)) block = { process: block };
block = _.defaults(block || {}, {
shortcuts: [],
end: 'end'+name,
- process: _.identity,
blocks: []
});
- extName = this.blockExtName(name);
+ extName = blockExtName(name);
+
+ if (!block.process) {
+ throw new Error('Invalid block "' + name + '", it should have a "process" method');
+ }
if (this.hasBlock(name) && !defaultBlocks[name]) {
- this.log.warn.ln('conflict in blocks, \''+name+'\' is already defined');
+ this.log.warn.ln('conflict in blocks, "'+name+'" is already defined');
}
// Cleanup previous block
@@ -215,7 +179,7 @@ TemplateEngine.prototype.addBlock = function(name, block) {
var args = parser.parseSignature(null, true);
parser.advanceAfterBlockEnd(tok.value);
- while (1) {
+ do {
// Read body
var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks);
@@ -232,14 +196,14 @@ TemplateEngine.prototype.addBlock = function(name, block) {
// Read new block
lastBlockName = parser.peekToken().value;
- if (lastBlockName == block.end) {
- break;
- }
// Parse signature and move to the end of the block
- lastBlockArgs = parser.parseSignature(null, true);
- parser.advanceAfterBlockEnd(lastBlockName);
- }
+ if (lastBlockName != block.end) {
+ lastBlockArgs = parser.parseSignature(null, true);
+ parser.advanceAfterBlockEnd(lastBlockName);
+ }
+ } while (lastBlockName != block.end)
+
parser.advanceAfterBlockEnd();
var bodies = [body];
@@ -282,7 +246,7 @@ TemplateEngine.prototype.addBlock = function(name, block) {
};
});
- Q()
+ Promise()
.then(function() {
return that.applyBlock(name, {
body: body(),
@@ -292,7 +256,7 @@ TemplateEngine.prototype.addBlock = function(name, block) {
}, context);
})
- // process the block returned
+ // Process the block returned
.then(that.processBlock)
.nodeify(callback);
};
@@ -301,10 +265,13 @@ TemplateEngine.prototype.addBlock = function(name, block) {
// Add the Extension
this.env.addExtension(extName, new Ext());
- // Add shortcuts
- if (!_.isArray(block.shortcuts)) block.shortcuts = [block.shortcuts];
+ // Add shortcuts if any
+ if (!_.isArray(block.shortcuts)) {
+ block.shortcuts = [block.shortcuts];
+ }
+
_.each(block.shortcuts, function(shortcut) {
- this.log.debug.ln('add template shortcut from \''+shortcut.start+'\' to block \''+name+'\' for parsers ', shortcut.parsers);
+ this.log.debug.ln('add template shortcut from "'+shortcut.start+'" to block "'+name+'" for parsers ', shortcut.parsers);
this.shortcuts.push({
block: name,
parsers: shortcut.parsers,
@@ -318,7 +285,7 @@ TemplateEngine.prototype.addBlock = function(name, block) {
}, this);
};
-// Add multiple blocks
+// Add multiple blocks at once
TemplateEngine.prototype.addBlocks = function(blocks) {
_.each(blocks, function(block, name) {
this.addBlock(name, block);
@@ -331,7 +298,7 @@ TemplateEngine.prototype.applyBlock = function(name, blk, ctx) {
var func, block, r;
block = this.blocks[name];
- if (!block) throw new Error('Block not found \''+name+'\'');
+ if (!block) throw new Error('Block not found "'+name+'"');
if (_.isString(blk)) {
blk = {
body: blk
@@ -348,116 +315,114 @@ TemplateEngine.prototype.applyBlock = function(name, blk, ctx) {
func = this.bindContext(block.process);
r = func.call(ctx || {}, blk);
- if (Q.isPromise(r)) return r.then(normBlockResult);
+ if (Promise.isPromise(r)) return r.then(normBlockResult);
else return normBlockResult(r);
};
-// Apply a shortcut to a string
-TemplateEngine.prototype._applyShortcut = function(parser, content, shortcut) {
- if (!_.contains(shortcut.parsers, parser)) return content;
- var regex = new RegExp(
- escapeStringRegexp(shortcut.start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(shortcut.end),
- 'g'
- );
- return content.replace(regex, function(all, match) {
- return '{% '+shortcut.tag.start+' %}'+ match + '{% '+shortcut.tag.end+' %}';
+// Process the result of block in a context
+TemplateEngine.prototype.processBlock = function(blk) {
+ blk = _.defaults(blk, {
+ parse: false,
+ post: undefined
});
-};
+ blk.id = _.uniqueId('blk');
-// Apply all shortcuts to some template string
-TemplateEngine.prototype.applyShortcuts = function(type, content) {
- return _.reduce(this.shortcuts, _.partial(this._applyShortcut.bind(this), type), content);
-};
+ var toAdd = (!blk.parse) || (blk.post !== undefined);
-// Render a string from the book
-TemplateEngine.prototype.renderString = function(content, context, options) {
- context = _.extend({}, context, {
- // Variables from book.json
- book: this.book.options.variables,
+ // Add to global map
+ if (toAdd) this.blockBodies[blk.id] = blk;
- // Complete book.json
- config: this.book.options,
+ // Parsable block, just return it
+ if (blk.parse) {
+ return blk.body;
+ }
- // infos about gitbook
- gitbook: {
- version: pkg.version,
- generator: this.book.options.generator
- }
- });
+ // Return it as a position marker
+ return '@%@'+blk.id+'@%@';
+};
+
+// Render a string (without post processing)
+TemplateEngine.prototype.render = function(content, context, options) {
options = _.defaults(options || {}, {
- path: null,
- type: null
+ path: null
});
- if (options.path) options.path = this.book.resolve(options.path);
- if (!options.type && options.path) {
- var parser = parsers.get(path.extname(options.path));
- options.type = parser? parser.name : null;
+ var filename = options.path;
+
+ // Setup path and type
+ if (options.path) {
+ options.path = this.book.resolve(options.path);
}
// Replace shortcuts
content = this.applyShortcuts(options.type, content);
- return Q.nfcall(this.env.renderString.bind(this.env), content, context, options)
+ return Promise.nfcall(this.env.renderString.bind(this.env), content, context, options)
.fail(function(err) {
- if (_.isString(err)) err = new Error(err);
- err.message = err.message.replace(/^Error: /, '');
-
- throw err;
+ throw error.TemplateError(err, {
+ filename: filename || '<inline>'
+ });
});
};
-// Render a file from the book
-TemplateEngine.prototype.renderFile = function(filename) {
- var that = this;
+// Render a string with post-processing
+TemplateEngine.prototype.renderString = function(content, context, options) {
+ return this.render(content, context, options)
+ .then(this.postProcess);
+};
- return that.book.readFile(filename)
- .then(function(content) {
- return that.renderString(content, {}, {
- path: filename
- });
+// Apply a shortcut to a string
+TemplateEngine.prototype.applyShortcut = function(content, shortcut) {
+ var regex = new RegExp(
+ escapeStringRegexp(shortcut.start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(shortcut.end),
+ 'g'
+ );
+ return content.replace(regex, function(all, match) {
+ return '{% '+shortcut.tag.start+' %}'+ match + '{% '+shortcut.tag.end+' %}';
});
};
-// Render a page from the book
-TemplateEngine.prototype.renderPage = function(page) {
+// Replace position markers of blocks by body after processing
+// This is done to avoid that markdown/asciidoc processer parse the block content
+TemplateEngine.prototype.replaceBlocks = function(content) {
var that = this;
- return that.book.statFile(page.path)
- .then(function(stat) {
- var context = {
- // infos about the file
- file: {
- path: page.path,
- mtime: stat.mtime
- }
- };
+ return content.replace(/\@\%\@([\s\S]+?)\@\%\@/g, function(match, key) {
+ var blk = that.blockBodies[key];
+ if (!blk) return match;
- return that.renderString(page.content, context, {
- path: page.path,
- type: page.type
- });
+ var body = blk.body;
+
+ return body;
});
};
+// Apply all shortcuts to a template
+TemplateEngine.prototype.applyShortcuts = function(type, content) {
+ return _.chain(this.shortcuts)
+ .filter(function(shortcut) {
+ return _.contains(shortcut.parsers, type);
+ })
+ .reduce(this.applyShortcut, content)
+ .value();
+};
+
+
// Post process content
TemplateEngine.prototype.postProcess = function(content) {
var that = this;
- return Q(content)
+ return Promise(content)
.then(that.replaceBlocks)
.then(function(_content) {
- return batch.execEach(that.blockBodies, {
- max: 20,
- fn: function(blk, blkId) {
- return Q()
- .then(function() {
- if (!blk.post) return Q();
- return blk.post();
- })
- .then(function() {
- delete that.blockBodies[blkId];
- });
- }
+ return Promise.serie(that.blockBodies, function(blk, blkId) {
+ return Promise()
+ .then(function() {
+ if (!blk.post) return;
+ return blk.post();
+ })
+ .then(function() {
+ delete that.blockBodies[blkId];
+ });
})
.thenResolve(_content);
});
diff --git a/lib/template/loader.js b/lib/template/loader.js
new file mode 100644
index 0000000..23d179a
--- /dev/null
+++ b/lib/template/loader.js
@@ -0,0 +1,42 @@
+var nunjucks = require('nunjucks');
+var location = require('../utils/location');
+
+/*
+Simple nunjucks loader which is passing the reponsability to the Output
+*/
+
+var Loader = nunjucks.Loader.extend({
+ async: true,
+
+ init: function(engine, opts) {
+ this.engine = engine;
+ this.output = engine.output;
+ },
+
+ getSource: function(sourceURL, callback) {
+ var that = this;
+
+ this.output.onGetTemplate(sourceURL)
+ .then(function(out) {
+ // We disable cache since content is modified (shortcuts, ...)
+ out.noCache = true;
+
+ // Transform template before runnign it
+ out.source = that.engine.interpolate(out.path, out.source);
+
+ return out;
+ })
+ .nodeify(callback);
+ },
+
+ resolve: function(from, to) {
+ return this.output.onResolveTemplate(from, to);
+ },
+
+ // Handle all files as relative, so that nunjucks pass responsability to 'resolve'
+ isRelative: function(filename) {
+ return location.isRelative(filename);
+ }
+});
+
+module.exports = Loader;
diff --git a/lib/utils/batch.js b/lib/utils/batch.js
deleted file mode 100644
index 9069766..0000000
--- a/lib/utils/batch.js
+++ /dev/null
@@ -1,52 +0,0 @@
-var Q = require("q");
-var _ = require("lodash");
-
-// Execute a method for all element
-function execEach(items, options) {
- if (_.size(items) === 0) return Q();
- var concurrents = 0, d = Q.defer(), pending = [];
-
- options = _.defaults(options || {}, {
- max: 100,
- fn: function() {}
- });
-
-
- function startItem(item, i) {
- if (concurrents >= options.max) {
- pending.push([item, i]);
- return;
- }
-
- concurrents++;
- Q()
- .then(function() {
- return options.fn(item, i);
- })
- .then(function() {
- concurrents--;
-
- // Next pending
- var next = pending.shift();
-
- if (concurrents === 0 && !next) {
- d.resolve();
- } else if (next) {
- startItem.apply(null, next);
- }
- })
- .fail(function(err) {
- pending = [];
- d.reject(err);
- });
- }
-
- _.each(items, startItem);
-
- return d.promise;
-}
-
-module.exports = {
- execEach: execEach
-};
-
diff --git a/lib/utils/command.js b/lib/utils/command.js
new file mode 100644
index 0000000..de240df
--- /dev/null
+++ b/lib/utils/command.js
@@ -0,0 +1,80 @@
+var _ = require('lodash');
+var childProcess = require('child_process');
+var spawn = require("spawn-cmd").spawn;
+var Promise = require('./promise');
+
+// Execute a command
+function exec(command, options) {
+ var d = Promise.defer();
+
+ var child = childProcess.exec(command, options, function(err, stdout, stderr) {
+ if (!err) {
+ return d.resolve();
+ }
+
+ err.message = stdout.toString('utf8') + stderr.toString('utf8');
+ d.reject(err);
+ });
+
+ child.stdout.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.stderr.on('data', function (data) {
+ d.notify(data);
+ });
+
+ return d.promise;
+}
+
+// Spawn an executable
+function spawnCmd(command, args, options) {
+ var d = Promise.defer();
+ var child = spawn(command, args, options);
+
+ child.on('error', function(error) {
+ return d.reject(error);
+ });
+
+ child.stdout.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.stderr.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.on('close', function(code) {
+ if (code === 0) {
+ d.resolve();
+ } else {
+ d.reject(new Error('Error with command "'+command+'"'));
+ }
+ });
+
+ return d.promise;
+}
+
+// Transform an option object to a command line string
+function escapeShellArg(s) {
+ s = s.replace(/"/g, '\\"');
+ return '"' + s + '"';
+}
+
+function optionsToShellArgs(options) {
+ return _.chain(options)
+ .map(function(value, key) {
+ if (value === null || value === undefined || value === false) return null;
+ if (value === true) return key;
+ return key + '=' + escapeShellArg(value);
+ })
+ .compact()
+ .value()
+ .join(' ');
+}
+
+module.exports = {
+ exec: exec,
+ spawn: spawnCmd,
+ optionsToShellArgs: optionsToShellArgs
+};
diff --git a/lib/utils/error.js b/lib/utils/error.js
new file mode 100644
index 0000000..27fa59d
--- /dev/null
+++ b/lib/utils/error.js
@@ -0,0 +1,105 @@
+var _ = require('lodash');
+var TypedError = require('error/typed');
+var WrappedError = require('error/wrapped');
+var deprecated = require('deprecated');
+
+var Logger = require('./logger');
+
+var log = new Logger();
+
+// Enforce as an Error object, and cleanup message
+function enforce(err) {
+ if (_.isString(err)) err = new Error(err);
+ err.message = err.message.replace(/^Error: /, '');
+
+ return err;
+}
+
+// Random error wrappers during parsing/generation
+var ParsingError = WrappedError({
+ message: 'Parsing Error: {origMessage}',
+ type: 'parse'
+});
+var OutputError = WrappedError({
+ message: 'Output Error: {origMessage}',
+ type: 'generate'
+});
+
+// A file does not exists
+var FileNotFoundError = TypedError({
+ type: 'file.not-found',
+ message: 'No "{filename}" file (or is ignored)',
+ filename: null
+});
+
+// A file is outside the scope
+var FileOutOfScopeError = TypedError({
+ type: 'file.out-of-scope',
+ message: '"{filename}" not in "{root}"',
+ filename: null,
+ root: null,
+ code: 'EACCESS'
+});
+
+// A file is outside the scope
+var RequireInstallError = TypedError({
+ type: 'install.required',
+ message: '"{cmd}" is not installed.\n{install}',
+ cmd: null,
+ code: 'ENOENT',
+ install: ''
+});
+
+// Error for nunjucks templates
+var TemplateError = WrappedError({
+ message: 'Error compiling template "{filename}": {origMessage}',
+ type: 'template',
+ filename: null
+});
+
+// Error for nunjucks templates
+var PluginError = WrappedError({
+ message: 'Error with plugin "{plugin}": {origMessage}',
+ type: 'plugin',
+ plugin: null
+});
+
+// Error with the book's configuration
+var ConfigurationError = WrappedError({
+ message: 'Error with book\'s configuration: {origMessage}',
+ type: 'configuration'
+});
+
+// Error during ebook generation
+var EbookError = WrappedError({
+ message: 'Error during ebook generation: {origMessage}\n{stdout}',
+ type: 'ebook',
+ stdout: ''
+});
+
+// Deprecate methods/fields
+function deprecateMethod(fn, msg) {
+ return deprecated.method(msg, log.warn.ln, fn);
+}
+function deprecateField(obj, prop, value, msg) {
+ return deprecated.field(msg, log.warn.ln, obj, prop, value);
+}
+
+module.exports = {
+ enforce: enforce,
+
+ ParsingError: ParsingError,
+ OutputError: OutputError,
+ RequireInstallError: RequireInstallError,
+
+ FileNotFoundError: FileNotFoundError,
+ FileOutOfScopeError: FileOutOfScopeError,
+
+ TemplateError: TemplateError,
+ PluginError: PluginError,
+ ConfigurationError: ConfigurationError,
+ EbookError: EbookError,
+
+ deprecateMethod: deprecateMethod,
+ deprecateField: deprecateField
+};
diff --git a/lib/utils/fs.js b/lib/utils/fs.js
index b82701f..42fd3c6 100644
--- a/lib/utils/fs.js
+++ b/lib/utils/fs.js
@@ -1,71 +1,36 @@
-var _ = require('lodash');
-var Q = require('q');
+var fs = require('graceful-fs');
+var mkdirp = require('mkdirp');
+var destroy = require('destroy');
+var rmdir = require('rmdir');
var tmp = require('tmp');
+var request = require('request');
var path = require('path');
-var fs = require('graceful-fs');
-var fsExtra = require('fs-extra');
-var Ignore = require('fstream-ignore');
-
-var fsUtils = {
- tmp: {
- file: function(opt) {
- return Q.nfcall(tmp.file.bind(tmp), opt).get(0);
- },
- dir: function() {
- return Q.nfcall(tmp.dir.bind(tmp)).get(0);
- }
- },
- list: listFiles,
- stat: Q.denodeify(fs.stat),
- readdir: Q.denodeify(fs.readdir),
- readFile: Q.denodeify(fs.readFile),
- writeFile: writeFile,
- writeStream: writeStream,
- mkdirp: Q.denodeify(fsExtra.mkdirp),
- copy: Q.denodeify(fsExtra.copy),
- remove: Q.denodeify(fsExtra.remove),
- symlink: Q.denodeify(fsExtra.symlink),
- exists: function(path) {
- var d = Q.defer();
- fs.exists(path, d.resolve);
- return d.promise;
- },
- findFile: findFile,
- existsSync: fs.existsSync.bind(fs),
- readFileSync: fs.readFileSync.bind(fs),
- clean: cleanFolder,
- getUniqueFilename: getUniqueFilename
-};
-
-// Write a file
-function writeFile(filename, data, options) {
- var d = Q.defer();
-
- try {
- fs.writeFileSync(filename, data, options);
- } catch(err) {
- d.reject(err);
- }
- d.resolve();
-
+var cp = require('cp');
+var cpr = require('cpr');
- return d.promise;
-}
+var Promise = require('./promise');
// Write a stream to a file
function writeStream(filename, st) {
- var d = Q.defer();
+ var d = Promise.defer();
var wstream = fs.createWriteStream(filename);
+ var cleanup = function() {
+ destroy(wstream);
+ wstream.removeAllListeners();
+ };
wstream.on('finish', function () {
+ cleanup();
d.resolve();
});
wstream.on('error', function (err) {
+ cleanup();
d.reject(err);
});
st.on('error', function(err) {
+ cleanup();
d.reject(err);
});
@@ -74,120 +39,80 @@ function writeStream(filename, st) {
return d.promise;
}
-// Find a filename available
-function getUniqueFilename(base, filename) {
- if (!filename) {
- filename = base;
- base = '/';
- }
+// Return a promise resolved with a boolean
+function fileExists(filename) {
+ var d = Promise.defer();
- filename = path.resolve(base, filename);
+ fs.exists(filename, function(exists) {
+ d.resolve(exists);
+ });
+
+ return d.promise;
+}
+
+// Generate temporary file
+function genTmpFile(opts) {
+ return Promise.nfcall(tmp.file, opts)
+ .get(0);
+}
+
+// Generate temporary dir
+function genTmpDir(opts) {
+ return Promise.nfcall(tmp.dir, opts)
+ .get(0);
+}
+
+// Download an image
+function download(uri, dest) {
+ return writeStream(dest, request(uri));
+}
+
+// Find a filename available in a folder
+function uniqueFilename(base, filename) {
var ext = path.extname(filename);
+ filename = path.resolve(base, filename);
filename = path.join(path.dirname(filename), path.basename(filename, ext));
var _filename = filename+ext;
var i = 0;
while (fs.existsSync(filename)) {
- _filename = filename+'_'+i+ext;
+ _filename = filename + '_' + i + ext;
i = i + 1;
}
- return path.relative(base, _filename);
-}
-
-
-// List files in a directory
-function listFiles(root, options) {
- options = _.defaults(options || {}, {
- ignoreFiles: [],
- ignoreRules: []
- });
-
- var d = Q.defer();
-
- // Our list of files
- var files = [];
-
- var ig = Ignore({
- path: root,
- ignoreFiles: options.ignoreFiles
- });
-
- // Add extra rules to ignore common folders
- ig.addIgnoreRules(options.ignoreRules, '__custom_stuff');
-
- // Push each file to our list
- ig.on('child', function (c) {
- files.push(
- c.path.substr(c.root.path.length + 1) + (c.props.Directory === true ? '/' : '')
- );
- });
-
- ig.on('end', function() {
- // Normalize paths on Windows
- if(process.platform === 'win32') {
- return d.resolve(files.map(function(file) {
- return file.replace(/\\/g, '/');
- }));
- }
-
- // Simply return paths otherwise
- return d.resolve(files);
- });
-
- ig.on('error', d.reject);
-
- return d.promise;
+ return Promise(path.relative(base, _filename));
}
-// Clean a folder without removing .git and .svn
-// Creates it if non existant
-function cleanFolder(root) {
- if (!fs.existsSync(root)) return fsUtils.mkdirp(root);
-
- return listFiles(root, {
- ignoreFiles: [],
- ignoreRules: [
- // Skip Git and SVN stuff
- '.git/',
- '.svn/'
- ]
- })
- .then(function(files) {
- var d = Q.defer();
-
- _.reduce(files, function(prev, file, i) {
- return prev.then(function() {
- var _file = path.join(root, file);
-
- d.notify({
- i: i+1,
- count: files.length,
- file: _file
- });
- return fsUtils.remove(_file);
- });
- }, Q())
- .then(function() {
- d.resolve();
- }, function(err) {
- d.reject(err);
- });
-
- return d.promise;
- });
+// Create all required folder to create a file
+function ensureFile(filename) {
+ var base = path.dirname(filename);
+ return Promise.nfcall(mkdirp, base);
}
-// Find a file in a folder (case incensitive)
-// Return the real filename
-function findFile(root, filename) {
- return Q.nfcall(fs.readdir, root)
- .then(function(files) {
- return _.find(files, function(file) {
- return (file.toLowerCase() == filename.toLowerCase());
- });
+// Remove a folder
+function rmDir(base) {
+ return Promise.nfcall(rmdir, base, {
+ fs: fs
});
}
-module.exports = fsUtils;
+module.exports = {
+ exists: fileExists,
+ existsSync: fs.existsSync,
+ mkdirp: Promise.nfbind(mkdirp),
+ readFile: Promise.nfbind(fs.readFile),
+ writeFile: Promise.nfbind(fs.writeFile),
+ stat: Promise.nfbind(fs.stat),
+ statSync: fs.statSync,
+ readdir: Promise.nfbind(fs.readdir),
+ writeStream: writeStream,
+ copy: Promise.nfbind(cp),
+ copyDir: Promise.nfbind(cpr),
+ tmpFile: genTmpFile,
+ tmpDir: genTmpDir,
+ download: download,
+ uniqueFilename: uniqueFilename,
+ ensure: ensureFile,
+ rmDir: rmDir
+};
diff --git a/lib/utils/git.js b/lib/utils/git.js
index 72c8818..52b1096 100644
--- a/lib/utils/git.js
+++ b/lib/utils/git.js
@@ -1,127 +1,129 @@
-var Q = require('q');
var _ = require('lodash');
var path = require('path');
var crc = require('crc');
-var exec = Q.denodeify(require('child_process').exec);
var URI = require('urijs');
-var pathUtil = require('./path');
+var pathUtil = require('./path');
+var Promise = require('./promise');
+var command = require('./command');
var fs = require('./fs');
var GIT_PREFIX = 'git+';
-var GIT_TMP = null;
-
-
-// Check if an url is a git dependency url
-function checkGitUrl(giturl) {
- return (giturl.indexOf(GIT_PREFIX) === 0);
-}
-// Validates a SHA in hexadecimal
-function validateSha(str) {
- return (/[0-9a-f]{40}/).test(str);
+function Git() {
+ this.tmpDir;
+ this.cloned = {};
}
-// Parse and extract infos
-function parseGitUrl(giturl) {
- var ref, uri, fileParts, filepath;
-
- if (!checkGitUrl(giturl)) return null;
- giturl = giturl.slice(GIT_PREFIX.length);
-
- uri = new URI(giturl);
- ref = uri.fragment() || 'master';
- uri.fragment(null);
-
- // Extract file inside the repo (after the .git)
- fileParts =uri.path().split('.git');
- filepath = fileParts.length > 1? fileParts.slice(1).join('.git') : '';
- if (filepath[0] == '/') filepath = filepath.slice(1);
-
- // Recreate pathname without the real filename
- uri.path(_.first(fileParts)+'.git');
+// Return an unique ID for a combinaison host/ref
+Git.prototype.repoID = function(host, ref) {
+ return crc.crc32(host+'#'+(ref || '')).toString(16);
+};
- return {
- host: uri.toString(),
- ref: ref || 'master',
- filepath: filepath
- };
-}
+// Allocate a temporary folder for cloning repos in it
+Git.prototype.allocateDir = function() {
+ var that = this;
-// Clone a git repo from a specific ref
-function cloneGitRepo(host, ref) {
- var isBranch = false;
+ if (this.tmpDir) return Promise();
- ref = ref || 'master';
- if (!validateSha(ref)) isBranch = true;
+ return fs.tmpDir()
+ .then(function(dir) {
+ that.tmpDir = dir;
+ });
+};
- return Q()
+// Clone a git repository if non existant
+Git.prototype.clone = function(host, ref) {
+ var that = this;
- // Create temporary folder to store git repos
- .then(function() {
- if (GIT_TMP) return;
- return fs.tmp.dir()
- .then(function(_tmp) {
- GIT_TMP = _tmp;
- });
- })
+ return this.allocateDir()
// Return or clone the git repo
.then(function() {
// Unique ID for repo/ref combinaison
- var repoId = crc.crc32(host+'#'+ref).toString(16);
+ var repoId = that.repoID(host, ref);
// Absolute path to the folder
- var repoPath = path.resolve(GIT_TMP, repoId);
-
- return fs.exists(repoPath)
- .then(function(doExists) {
- if (doExists) return;
-
- // Clone repo
- return exec('git clone '+host+' '+repoPath)
- .then(function() {
- return exec('git checkout '+ref, { cwd: repoPath });
- });
- })
- .thenResolve(repoPath);
+ var repoPath = path.join(that.tmpDir, repoId);
+
+ if (that.cloned[repoId]) return repoPath;
+
+ // Clone repo
+ return command.exec('git clone '+host+' '+repoPath)
+
+ // Checkout reference if specified
+ .then(function() {
+ that.cloned[repoId] = true;
+
+ if (!ref) return;
+ return command.exec('git checkout '+ref, { cwd: repoPath });
+ })
+ .thenResolve(repoPath);
});
-}
+};
// Get file from a git repo
-function resolveFileFromGit(giturl) {
- if (_.isString(giturl)) giturl = parseGitUrl(giturl);
- if (!giturl) return Q(null);
+Git.prototype.resolve = function(giturl) {
+ // Path to a file in a git repo?
+ if (!Git.isUrl(giturl)) {
+ if (this.resolveRoot(giturl)) return Promise(giturl);
+ return Promise(null);
+ }
+ if (_.isString(giturl)) giturl = Git.parseUrl(giturl);
+ if (!giturl) return Promise(null);
// Clone or get from cache
- return cloneGitRepo(giturl.host, giturl.ref)
+ return this.clone(giturl.host, giturl.ref)
.then(function(repo) {
-
- // Resolve relative path
return path.resolve(repo, giturl.filepath);
});
-}
+};
// Return root of git repo from a filepath
-function resolveGitRoot(filepath) {
+Git.prototype.resolveRoot = function(filepath) {
var relativeToGit, repoId;
// No git repo cloned, or file is not in a git repository
- if (!GIT_TMP || !pathUtil.isInRoot(GIT_TMP, filepath)) return null;
+ if (!this.tmpDir || !pathUtil.isInRoot(this.tmpDir, filepath)) return null;
// Extract first directory (is the repo id)
- relativeToGit = path.relative(GIT_TMP, filepath);
+ relativeToGit = path.relative(this.tmpDir, filepath);
repoId = _.first(relativeToGit.split(path.sep));
if (!repoId) return;
// Return an absolute file
- return path.resolve(GIT_TMP, repoId);
-}
+ return path.resolve(this.tmpDir, repoId);
+};
+
+// Check if an url is a git dependency url
+Git.isUrl = function(giturl) {
+ return (giturl.indexOf(GIT_PREFIX) === 0);
+};
+
+// Parse and extract infos
+Git.parseUrl = function(giturl) {
+ var ref, uri, fileParts, filepath;
+ if (!Git.isUrl(giturl)) return null;
+ giturl = giturl.slice(GIT_PREFIX.length);
+
+ uri = new URI(giturl);
+ ref = uri.fragment() || null;
+ uri.fragment(null);
+
+ // Extract file inside the repo (after the .git)
+ fileParts = uri.path().split('.git');
+ filepath = fileParts.length > 1? fileParts.slice(1).join('.git') : '';
+ if (filepath[0] == '/') filepath = filepath.slice(1);
-module.exports = {
- checkUrl: checkGitUrl,
- parseUrl: parseGitUrl,
- resolveFile: resolveFileFromGit,
- resolveRoot: resolveGitRoot
+ // Recreate pathname without the real filename
+ uri.path(_.first(fileParts)+'.git');
+
+ return {
+ host: uri.toString(),
+ ref: ref,
+ filepath: filepath
+ };
};
+
+module.exports = Git;
diff --git a/lib/utils/i18n.js b/lib/utils/i18n.js
deleted file mode 100644
index de64b49..0000000
--- a/lib/utils/i18n.js
+++ /dev/null
@@ -1,80 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var fs = require('fs');
-
-var i18n = require('i18n');
-
-var I18N_PATH = path.resolve(__dirname, '../../theme/i18n/');
-var DEFAULT_LANGUAGE = 'en';
-var LOCALES = _.map(fs.readdirSync(I18N_PATH), function(lang) {
- return path.basename(lang, '.json');
-});
-
-i18n.configure({
- locales: LOCALES,
- directory: I18N_PATH,
- defaultLocale: DEFAULT_LANGUAGE,
- updateFiles: false
-});
-
-function compareLocales(lang, locale) {
- var langMain = _.first(lang.split('-'));
- var langSecond = _.last(lang.split('-'));
-
- var localeMain = _.first(locale.split('-'));
- var localeSecond = _.last(locale.split('-'));
-
- if (locale == lang) return 100;
- if (localeMain == langMain) return 50;
- if (localeSecond == langSecond) return 20;
- return 0;
-}
-
-var normalizeLanguage = _.memoize(function(lang) {
- var language = _.chain(LOCALES)
- .values()
- .map(function(locale) {
- return {
- locale: locale,
- score: compareLocales(lang, locale)
- };
- })
- .filter(function(lang) {
- return lang.score > 0;
- })
- .sortBy('score')
- .pluck('locale')
- .last()
- .value();
- return language || lang;
-});
-
-function translate(locale, phrase) {
- var args = Array.prototype.slice.call(arguments, 2);
-
- return i18n.__.apply({}, [{
- locale: locale,
- phrase: phrase
- }].concat(args));
-}
-
-function getCatalog(locale) {
- locale = normalizeLanguage(locale);
- return i18n.getCatalog(locale);
-}
-
-function getLocales() {
- return LOCALES;
-}
-
-function hasLocale(locale) {
- return _.contains(LOCALES, locale);
-}
-
-module.exports = {
- __: translate,
- normalizeLanguage: normalizeLanguage,
- getCatalog: getCatalog,
- getLocales: getLocales,
- hasLocale: hasLocale
-};
diff --git a/lib/utils/images.js b/lib/utils/images.js
index a82b0a1..e387d6b 100644
--- a/lib/utils/images.js
+++ b/lib/utils/images.js
@@ -1,37 +1,44 @@
-var _ = require("lodash");
-var Q = require("q");
-var fs = require("./fs");
-var spawn = require("spawn-cmd").spawn;
-
-// Convert a svg file
-var convertSVG = function(source, dest, options) {
- if (!fs.existsSync(source)) return Q.reject(new Error("File doesn't exist: "+source));
- var d = Q.defer();
-
- options = _.defaults(options || {}, {
-
- });
-
- //var command = shellescape(["svgexport", source, dest]);
- var child = spawn("svgexport", [source, dest]);
+var Promise = require('./promise');
+var command = require('./command');
+var fs = require('./fs');
+var error = require('./error');
+
+// Convert a svg file to a pmg
+function convertSVGToPNG(source, dest, options) {
+ if (!fs.existsSync(source)) return Promise.reject(new error.FileNotFoundError({ filename: source }));
+
+ return command.spawn('svgexport', [source, dest])
+ .fail(function(err) {
+ if (err.code == 'ENOENT') {
+ err = error.RequireInstallError({
+ cmd: 'svgexport',
+ install: 'Install it using: "npm install svgexport -g"'
+ });
+ }
+ throw err;
+ })
+ .then(function() {
+ if (fs.existsSync(dest)) return;
- child.on("error", function(error) {
- if (error.code == "ENOENT") error = new Error("Need to install \"svgexport\" using \"npm install svgexport -g\"");
- return d.reject(error);
+ throw new Error('Error converting '+source+' into '+dest);
});
-
- child.on("close", function(code) {
- if (code === 0 && fs.existsSync(dest)) {
- d.resolve();
- } else {
- d.reject(new Error("Error converting "+source+" into "+dest));
- }
+}
+
+// Convert a svg buffer to a png file
+function convertSVGBufferToPNG(buf, dest) {
+ // Create a temporary SVG file to convert
+ return fs.tmpFile({
+ postfix: '.svg'
+ })
+ .then(function(tmpSvg) {
+ return fs.writeFile(tmpSvg, buf)
+ .then(function() {
+ return convertSVGToPNG(tmpSvg, dest);
+ });
});
-
- return d.promise;
-};
+}
module.exports = {
- convertSVG: convertSVG,
- INVALID: [".svg"]
-};
+ convertSVGToPNG: convertSVGToPNG,
+ convertSVGBufferToPNG: convertSVGBufferToPNG
+}; \ No newline at end of file
diff --git a/lib/utils/links.js b/lib/utils/location.js
index 5122396..ba0c57d 100644
--- a/lib/utils/links.js
+++ b/lib/utils/location.js
@@ -1,7 +1,7 @@
var url = require('url');
var path = require('path');
-// Is the link an external link
+// Is the url an external url
function isExternal(href) {
try {
return Boolean(url.parse(href).protocol);
@@ -10,15 +10,9 @@ function isExternal(href) {
}
}
-// Return true if the link is relative
+// Inverse of isExternal
function isRelative(href) {
- try {
- var parsed = url.parse(href);
-
- return !!(!parsed.protocol && parsed.path);
- } catch(err) {
- return true;
- }
+ return !isExternal(href);
}
// Return true if the link is an achor
@@ -32,15 +26,20 @@ function isAnchor(href) {
}
// Normalize a path to be a link
-function normalizeLink(s) {
+function normalize(s) {
return s.replace(/\\/g, '/');
}
-// Relative to absolute path
+// Convert relative to absolute path
// dir: directory parent of the file currently in rendering process
// outdir: directory parent from the html output
function toAbsolute(_href, dir, outdir) {
if (isExternal(_href)) return _href;
+ outdir = outdir == undefined? dir : outdir;
+
+ _href = normalize(_href);
+ dir = normalize(dir);
+ outdir = normalize(outdir);
// Path "_href" inside the base folder
var hrefInRoot = path.normalize(path.join(dir, _href));
@@ -50,32 +49,22 @@ function toAbsolute(_href, dir, outdir) {
_href = path.relative(outdir, hrefInRoot);
// Normalize windows paths
- _href = normalizeLink(_href);
+ _href = normalize(_href);
return _href;
}
-// Join links
-function join() {
- var _href = path.join.apply(path, arguments);
-
- return normalizeLink(_href);
-};
-
-// Change extension
-function changeExtension(filename, newext) {
- return path.join(
- path.dirname(filename),
- path.basename(filename, path.extname(filename))+newext
- );
+// Convert an absolute path to a relative path for a specific folder (dir)
+// ('test/', 'hello.md') -> '../hello.md'
+function relative(dir, file) {
+ return normalize(path.relative(dir, file));
}
module.exports = {
- isAnchor: isAnchor,
- isRelative: isRelative,
isExternal: isExternal,
+ isRelative: isRelative,
+ isAnchor: isAnchor,
+ normalize: normalize,
toAbsolute: toAbsolute,
- join: join,
- changeExtension: changeExtension,
- normalize: normalizeLink
+ relative: relative
};
diff --git a/lib/utils/logger.js b/lib/utils/logger.js
index db3d90e..60215af 100644
--- a/lib/utils/logger.js
+++ b/lib/utils/logger.js
@@ -1,6 +1,6 @@
-var _ = require("lodash");
-var util = require("util");
-var color = require("bash-color");
+var _ = require('lodash');
+var util = require('util');
+var color = require('bash-color');
var LEVELS = {
DEBUG: 0,
@@ -17,86 +17,112 @@ var COLORS = {
ERROR: color.red
};
-module.exports = function(_write, logLevel) {
- var logger = {};
- var lastChar = "\n";
- if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()];
+function Logger(write, logLevel, prefix) {
+ if (!(this instanceof Logger)) return new Logger(write, logLevel);
+
+ this._write = write || function(msg) { process.stdout.write(msg); };
+ this.lastChar = '\n';
+
+ // Define log level
+ this.setLevel(logLevel);
+
+ _.bindAll(this);
- // Write a simple message
- logger.write = function(msg) {
- msg = msg.toString();
- lastChar = _.last(msg);
- return _write(msg);
- };
-
- // Format a message
- logger.format = function() {
- return util.format.apply(util, arguments);
- };
-
- // Write a line
- logger.writeLn = function(msg) {
- return this.write((msg || "")+"\n");
- };
-
- // Write a message with a certain level
- logger.log = function(level) {
- if (level < logLevel) return;
-
- var levelKey = _.findKey(LEVELS, function(v) { return v == level; });
- var args = Array.prototype.slice.apply(arguments, [1]);
- var msg = logger.format.apply(logger, args);
-
- if (lastChar == "\n") {
- msg = COLORS[levelKey](levelKey.toLowerCase()+":")+" "+msg;
- }
-
- return logger.write(msg);
- };
- logger.logLn = function() {
- if (lastChar != "\n") logger.write("\n");
-
- var args = Array.prototype.slice.apply(arguments);
- args.push("\n");
- logger.log.apply(logger, args);
- };
-
- // Write a OK
- logger.ok = function(level) {
- var args = Array.prototype.slice.apply(arguments, [1]);
- var msg = logger.format.apply(logger, args);
- if (arguments.length > 1) {
- logger.logLn(level, color.green(">> ") + msg.trim().replace(/\n/g, color.green("\n>> ")));
- } else {
- logger.log(level, color.green("OK"), "\n");
- }
- };
-
- // Write an "FAIL"
- logger.fail = function(level) {
- return logger.log(level, color.red("ERROR")+"\n");
- };
-
- _.each(_.omit(LEVELS, "DISABLED"), function(level, levelKey) {
+ // Create easy-to-use method like "logger.debug.ln('....')"
+ _.each(_.omit(LEVELS, 'DISABLED'), function(level, levelKey) {
levelKey = levelKey.toLowerCase();
- logger[levelKey] = _.partial(logger.log, level);
- logger[levelKey].ln = _.partial(logger.logLn, level);
- logger[levelKey].ok = _.partial(logger.ok, level);
- logger[levelKey].fail = _.partial(logger.fail, level);
- logger[levelKey].promise = function(p) {
- return p.
- then(function(st) {
- logger[levelKey].ok();
- return st;
- }, function(err) {
- logger[levelKey].fail();
- throw err;
- });
- };
- });
+ this[levelKey] = _.partial(this.log, level);
+ this[levelKey].ln = _.partial(this.logLn, level);
+ this[levelKey].ok = _.partial(this.ok, level);
+ this[levelKey].fail = _.partial(this.fail, level);
+ this[levelKey].promise = _.partial(this.promise, level);
+ }, this);
+}
+
+// Create a new logger prefixed from this logger
+Logger.prototype.prefix = function(prefix) {
+ return (new Logger(this._write, this.logLevel, prefix));
+};
+
+// Change minimum level
+Logger.prototype.setLevel = function(logLevel) {
+ if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()];
+ this.logLevel = logLevel;
+};
+
+// Print a simple string
+Logger.prototype.write = function(msg) {
+ msg = msg.toString();
+ this.lastChar = _.last(msg);
+ return this._write(msg);
+};
+
+// Format a string using the first argument as a printf-like format.
+Logger.prototype.format = function() {
+ return util.format.apply(util, arguments);
+};
+
+// Print a line
+Logger.prototype.writeLn = function(msg) {
+ return this.write((msg || '')+'\n');
+};
- return logger;
+// Log/Print a message if level is allowed
+Logger.prototype.log = function(level) {
+ if (level < this.logLevel) return;
+
+ var levelKey = _.findKey(LEVELS, function(v) { return v == level; });
+ var args = Array.prototype.slice.apply(arguments, [1]);
+ var msg = this.format.apply(this, args);
+
+ if (this.lastChar == '\n') {
+ msg = COLORS[levelKey](levelKey.toLowerCase()+':')+' '+msg;
+ }
+
+ return this.write(msg);
+};
+
+// Log/Print a line if level is allowed
+Logger.prototype.logLn = function() {
+ if (this.lastChar != '\n') this.write('\n');
+
+ var args = Array.prototype.slice.apply(arguments);
+ args.push('\n');
+ return this.log.apply(this, args);
+};
+
+// Log a confirmation [OK]
+Logger.prototype.ok = function(level) {
+ var args = Array.prototype.slice.apply(arguments, [1]);
+ var msg = this.format.apply(this, args);
+ if (arguments.length > 1) {
+ this.logLn(level, color.green('>> ') + msg.trim().replace(/\n/g, color.green('\n>> ')));
+ } else {
+ this.log(level, color.green('OK'), '\n');
+ }
};
-module.exports.LEVELS = LEVELS;
-module.exports.COLORS = COLORS;
+
+// Log a "FAIL"
+Logger.prototype.fail = function(level) {
+ return this.log(level, color.red('ERROR') + '\n');
+};
+
+// Log state of a promise
+Logger.prototype.promise = function(level, p) {
+ var that = this;
+
+ return p.
+ then(function(st) {
+ that.ok(level);
+ return st;
+ }, function(err) {
+ that.fail(level);
+ throw err;
+ });
+};
+
+Logger.LEVELS = LEVELS;
+Logger.COLORS = COLORS;
+
+module.exports = Logger;
diff --git a/lib/utils/navigation.js b/lib/utils/navigation.js
deleted file mode 100644
index d07eb35..0000000
--- a/lib/utils/navigation.js
+++ /dev/null
@@ -1,79 +0,0 @@
-var _ = require("lodash");
-
-// Cleans up an article/chapter object
-// remove "articles" attributes
-function clean(obj) {
- return obj && _.omit(obj, ["articles"]);
-}
-
-function flattenChapters(chapters) {
- return _.reduce(chapters, function(accu, chapter) {
- return accu.concat([clean(chapter)].concat(flattenChapters(chapter.articles)));
- }, []);
-}
-
-// Returns from a summary a map of
-/*
- {
- "file/path.md": {
- prev: ...,
- next: ...,
- },
- ...
- }
-*/
-function navigation(summary, files) {
- // Support single files as well as list
- files = _.isArray(files) ? files : (_.isString(files) ? [files] : null);
-
- // List of all navNodes
- // Flatten chapters
- var navNodes = flattenChapters(summary.chapters);
-
- // Mapping of prev/next for a give path
- var mapping = _.chain(navNodes)
- .map(function(current, i) {
- var prev = null, next = null;
-
- // Skip if no path
- if(!current.exists) return null;
-
- // Find prev
- prev = _.chain(navNodes.slice(0, i))
- .reverse()
- .find(function(node) {
- return node.exists && !node.external;
- })
- .value();
-
- // Find next
- next = _.chain(navNodes.slice(i+1))
- .find(function(node) {
- return node.exists && !node.external;
- })
- .value();
-
- return [current.path, {
- index: i,
- title: current.title,
- introduction: current.introduction,
- prev: prev,
- next: next,
- level: current.level,
- }];
- })
- .compact()
- .object()
- .value();
-
- // Filter for only files we want
- if(files) {
- return _.pick(mapping, files);
- }
-
- return mapping;
-}
-
-
-// Exports
-module.exports = navigation;
diff --git a/lib/utils/page.js b/lib/utils/page.js
deleted file mode 100644
index 010d703..0000000
--- a/lib/utils/page.js
+++ /dev/null
@@ -1,397 +0,0 @@
-var Q = require('q');
-var _ = require('lodash');
-var url = require('url');
-var path = require('path');
-var cheerio = require('cheerio');
-var domSerializer = require('dom-serializer');
-var request = require('request');
-var crc = require('crc');
-var slug = require('github-slugid');
-
-var links = require('./links');
-var imgUtils = require('./images');
-var fs = require('./fs');
-var batch = require('./batch');
-
-var parsableExtensions = require('gitbook-parsers').extensions;
-
-// Map of images that have been converted
-var imgConversionCache = {};
-
-// Render a cheerio dom as html
-function renderDom($, dom, options) {
- if (!dom && $._root && $._root.children) {
- dom = $._root.children;
- }
-
- options = options|| dom.options || $._options;
- return domSerializer(dom, options);
-}
-
-function replaceText($, el, search, replace, text_only ) {
- return $(el).each(function(){
- var node = this.firstChild,
- val,
- new_val,
-
- // Elements to be removed at the end.
- remove = [];
-
- // Only continue if firstChild exists.
- if ( node ) {
-
- // Loop over all childNodes.
- while (node) {
-
- // Only process text nodes.
- if ( node.nodeType === 3 ) {
-
- // The original node value.
- val = node.nodeValue;
-
- // The new value.
- new_val = val.replace( search, replace );
-
- // Only replace text if the new value is actually different!
- if ( new_val !== val ) {
-
- if ( !text_only && /</.test( new_val ) ) {
- // The new value contains HTML, set it in a slower but far more
- // robust way.
- $(node).before( new_val );
-
- // Don't remove the node yet, or the loop will lose its place.
- remove.push( node );
- } else {
- // The new value contains no HTML, so it can be set in this
- // very fast, simple way.
- node.nodeValue = new_val;
- }
- }
- }
-
- node = node.nextSibling;
- }
- }
-
- // Time to remove those elements!
- if (remove.length) $(remove).remove();
- });
-}
-
-function pregQuote( str ) {
- return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
-}
-
-
-// Adapt an html snippet to be relative to a base folder
-function normalizeHtml(src, options) {
- var $ = cheerio.load(src, {
- // We should parse html without trying to normalize too much
- xmlMode: false,
-
- // SVG need some attributes to use uppercases
- lowerCaseAttributeNames: false,
- lowerCaseTags: false
- });
- var toConvert = [];
- var svgContent = {};
- var outputRoot = options.book.options.output;
-
- imgConversionCache[outputRoot] = imgConversionCache[outputRoot] || {};
-
- // Find svg images to extract and process
- if (options.convertImages) {
- $('svg').each(function() {
- var content = renderDom($, $(this));
- var svgId = _.uniqueId('svg');
- var dest = svgId+'.svg';
-
- // Generate filename
- dest = '/'+fs.getUniqueFilename(outputRoot, dest);
-
- svgContent[dest] = '<?xml version="1.0" encoding="UTF-8"?>'+content;
- $(this).replaceWith($('<img>').attr('src', dest));
- });
- }
-
- // Generate ID for headings
- $('h1,h2,h3,h4,h5,h6').each(function() {
- if ($(this).attr('id')) return;
-
- $(this).attr('id', slug($(this).text()));
- });
-
- // Find images to normalize
- $('img').each(function() {
- var origin;
- var src = $(this).attr('src');
-
- if (!src) return;
- var isExternal = links.isExternal(src);
-
- // Transform as relative to the bases
- if (links.isRelative(src)) {
- src = links.toAbsolute(src, options.base, options.output);
- }
-
- // Convert if needed
- if (options.convertImages) {
- // If image is external and ebook, then downlaod the images
- if (isExternal) {
- origin = src;
- src = '/'+crc.crc32(origin).toString(16)+path.extname(url.parse(origin).pathname);
- src = links.toAbsolute(src, options.base, options.output);
- isExternal = false;
- }
-
- var ext = path.extname(src);
- var srcAbs = links.join('/', options.base, src);
-
- // Test image extension
- if (_.contains(imgUtils.INVALID, ext)) {
- if (imgConversionCache[outputRoot][srcAbs]) {
- // Already converted
- src = imgConversionCache[outputRoot][srcAbs];
- } else {
- // Not converted yet
- var dest = '';
-
- // Replace extension
- dest = links.join(path.dirname(srcAbs), path.basename(srcAbs, ext)+'.png');
- dest = dest[0] == '/'? dest.slice(1) : dest;
-
- // Get a name that doesn't exists
- dest = fs.getUniqueFilename(outputRoot, dest);
-
- options.book.log.debug.ln('detect invalid image (will be converted to png):', srcAbs);
-
- // Add to cache
- imgConversionCache[outputRoot][srcAbs] = '/'+dest;
-
- // Push to convert
- toConvert.push({
- origin: origin,
- content: svgContent[srcAbs],
- source: isExternal? srcAbs : path.join('./', srcAbs),
- dest: path.join('./', dest)
- });
-
- src = links.join('/', dest);
- }
-
- // Reset as relative to output
- src = links.toAbsolute(src, options.base, options.output);
- }
-
- else if (origin) {
- // Need to downlaod image
- toConvert.push({
- origin: origin,
- source: path.join('./', srcAbs)
- });
- }
- }
-
- $(this).attr('src', src);
- });
-
- // Normalize links
- $('a').each(function() {
- var href = $(this).attr('href');
- if (!href) return;
-
- if (links.isAnchor(href)) {
- // Keep it as it is
- } else if (links.isRelative(href)) {
- var parts = url.parse(href);
-
- var pathName = decodeURIComponent(parts.pathname);
- var anchor = parts.hash || '';
-
- // Calcul absolute path for this file (without the anchor)
- var absolutePath = links.join(options.base, pathName);
-
- // If is in navigation relative: transform as content
- if (options.navigation[absolutePath]) {
- absolutePath = options.book.contentLink(absolutePath);
- }
-
- // If md/adoc/rst files is not in summary
- // or for ebook, signal all files that are outside the summary
- else if (_.contains(parsableExtensions, path.extname(absolutePath)) ||
- _.contains(['epub', 'pdf', 'mobi'], options.book.options.generator)) {
- options.book.log.warn.ln('page', options.input, 'contains an hyperlink to resource outside spine \''+href+'\'');
- }
-
- // Transform as absolute
- href = links.toAbsolute('/'+absolutePath, options.base, options.output)+anchor;
- } else {
- // External links
- $(this).attr('target', '_blank');
- }
-
- // Transform extension
- $(this).attr('href', href);
- });
-
- // Highlight code blocks
- $('code').each(function() {
- // Normalize language
- var lang = _.chain(
- ($(this).attr('class') || '').split(' ')
- )
- .map(function(cl) {
- // Markdown
- if (cl.search('lang-') === 0) return cl.slice('lang-'.length);
-
- // Asciidoc
- if (cl.search('language-') === 0) return cl.slice('language-'.length);
-
- return null;
- })
- .compact()
- .first()
- .value();
-
- var source = $(this).text();
- var blk = options.book.template.applyBlock('code', {
- body: source,
- kwargs: {
- language: lang
- }
- });
-
- if (blk.html === false) $(this).text(blk.body);
- else $(this).html(blk.body);
- });
-
- // Replace glossary terms
- var glossary = _.sortBy(options.glossary, function(term) {
- return -term.name.length;
- });
-
- _.each(glossary, function(term) {
- var r = new RegExp( '\\b(' + pregQuote(term.name.toLowerCase()) + ')\\b' , 'gi' );
- var includedInFiles = false;
-
- $('*').each(function() {
- // Ignore codeblocks
- if (_.contains(['code', 'pre', 'a', 'script'], this.name.toLowerCase())) return;
-
- replaceText($, this, r, function(match) {
- // Add to files index in glossary
- if (!includedInFiles) {
- includedInFiles = true;
- term.files = term.files || [];
- term.files.push(options.navigation[options.input]);
- }
- return '<a href=\''+links.toAbsolute('/GLOSSARY.html', options.base, options.output) + '#' + term.id+'\' class=\'glossary-term\' title=\''+_.escape(term.description)+'\'>'+match+'</a>';
- });
- });
- });
-
- return {
- html: renderDom($),
- images: toConvert
- };
-}
-
-// Convert svg images to png
-function convertImages(images, options) {
- if (!options.convertImages) return Q();
-
- var downloaded = [];
- options.book.log.debug.ln('convert ', images.length, 'images to png');
-
- return batch.execEach(images, {
- max: 100,
- fn: function(image) {
- var imgin = path.resolve(options.book.options.output, image.source);
-
- return Q()
-
- // Write image if need to be download
- .then(function() {
- if (!image.origin && !_.contains(downloaded, image.origin)) return;
- options.book.log.debug('download image', image.origin, '...');
- downloaded.push(image.origin);
- return options.book.log.debug.promise(fs.writeStream(imgin, request(image.origin)))
- .fail(function(err) {
- if (!_.isError(err)) err = new Error(err);
-
- err.message = 'Fail downloading '+image.origin+': '+err.message;
- throw err;
- });
- })
-
- // Write svg if content
- .then(function() {
- if (!image.content) return;
- return fs.writeFile(imgin, image.content);
- })
-
- // Convert
- .then(function() {
- if (!image.dest) return;
- var imgout = path.resolve(options.book.options.output, image.dest);
- options.book.log.debug('convert image', image.source, 'to', image.dest, '...');
- return options.book.log.debug.promise(imgUtils.convertSVG(imgin, imgout));
- });
- }
- })
- .then(function() {
- options.book.log.debug.ok(images.length+' images converted with success');
- });
-}
-
-// Adapt page content to be relative to a base folder
-function normalizePage(sections, options) {
- options = _.defaults(options || {}, {
- // Current book
- book: null,
-
- // Do we need to convert svg?
- convertImages: false,
-
- // Current file path
- input: '.',
-
- // Navigation to use to transform path
- navigation: {},
-
- // Directory parent of the file currently in rendering process
- base: './',
-
- // Directory parent from the html output
- output: './',
-
- // Glossary terms
- glossary: []
- });
-
- // List of images to convert
- var toConvert = [];
-
- sections = _.map(sections, function(section) {
- if (section.type != 'normal') return section;
-
- var out = normalizeHtml(section.content, options);
-
- toConvert = toConvert.concat(out.images);
- section.content = out.html;
- return section;
- });
-
- return Q()
- .then(function() {
- toConvert = _.uniq(toConvert, 'source');
- return convertImages(toConvert, options);
- })
- .thenResolve(sections);
-}
-
-
-module.exports = {
- normalize: normalizePage
-};
diff --git a/lib/utils/path.js b/lib/utils/path.js
index 5285896..c233c92 100644
--- a/lib/utils/path.js
+++ b/lib/utils/path.js
@@ -1,5 +1,12 @@
-var _ = require("lodash");
-var path = require("path");
+var _ = require('lodash');
+var path = require('path');
+
+var error = require('./error');
+
+// Normalize a filename
+function normalizePath(filename) {
+ return path.normalize(filename);
+}
// Return true if file path is inside a folder
function isInRoot(root, filename) {
@@ -10,31 +17,42 @@ function isInRoot(root, filename) {
// Resolve paths in a specific folder
// Throw error if file is outside this folder
function resolveInRoot(root) {
- var input, result, err;
+ var input, result;
input = _.chain(arguments)
.toArray()
.slice(1)
.reduce(function(current, p) {
// Handle path relative to book root ("/README.md")
- if (p[0] == "/" || p[0] == "\\") return p.slice(1);
+ if (p[0] == '/' || p[0] == '\\') return p.slice(1);
return current? path.join(current, p) : path.normalize(p);
- }, "")
+ }, '')
.value();
result = path.resolve(root, input);
if (!isInRoot(root, result)) {
- err = new Error("EACCESS: \"" + result + "\" not in \"" + root + "\"");
- err.code = "EACCESS";
- throw err;
+ throw new error.FileOutOfScopeError({
+ filename: result,
+ root: root
+ });
}
return result;
}
+// Chnage extension
+function setExtension(filename, ext) {
+ return path.join(
+ path.dirname(filename),
+ path.basename(filename, path.extname(filename)) + ext
+ );
+}
+
module.exports = {
isInRoot: isInRoot,
- resolveInRoot: resolveInRoot
+ resolveInRoot: resolveInRoot,
+ normalize: normalizePath,
+ setExtension: setExtension
};
diff --git a/lib/utils/progress.js b/lib/utils/progress.js
deleted file mode 100644
index 8dda892..0000000
--- a/lib/utils/progress.js
+++ /dev/null
@@ -1,55 +0,0 @@
-var _ = require('lodash');
-
-// Returns from a navigation and a current file, a snapshot of current detailed state
-function calculProgress(navigation, current) {
- var n = _.size(navigation);
- var percent = 0, prevPercent = 0, currentChapter = null;
- var done = true;
-
- var chapters = _.chain(navigation)
-
- // Transform as array
- .map(function(nav, path) {
- nav.path = path;
- return nav;
- })
-
- // Sort entries
- .sortBy(function(nav) {
- return nav.index;
- })
-
- .map(function(nav, i) {
- // Calcul percent
- nav.percent = (i * 100) / Math.max((n - 1), 1);
-
- // Is it done
- nav.done = done;
- if (nav.path == current) {
- currentChapter = nav;
- percent = nav.percent;
- done = false;
- } else if (done) {
- prevPercent = nav.percent;
- }
-
- return nav;
- })
- .value();
-
- return {
- // Previous percent
- prevPercent: prevPercent,
-
- // Current percent
- percent: percent,
-
- // List of chapter with progress
- chapters: chapters,
-
- // Current chapter
- current: currentChapter
- };
-}
-
-module.exports = calculProgress;
diff --git a/lib/utils/promise.js b/lib/utils/promise.js
new file mode 100644
index 0000000..d49cf27
--- /dev/null
+++ b/lib/utils/promise.js
@@ -0,0 +1,62 @@
+var Q = require('q');
+var _ = require('lodash');
+
+// Reduce an array to a promise
+function reduce(arr, iter, base) {
+ return _.reduce(arr, function(prev, elem, i) {
+ return prev.then(function(val) {
+ return iter(val, elem, i);
+ });
+ }, Q(base));
+}
+
+// Transform an array
+function serie(arr, iter, base) {
+ return reduce(arr, function(before, item, i) {
+ return Q(iter(item, i))
+ .then(function(r) {
+ before.push(r);
+ return before;
+ });
+ }, []);
+}
+
+// Iter over an array and return first result (not null)
+function some(arr, iter) {
+ return _.reduce(arr, function(prev, elem, i) {
+ return prev.then(function(val) {
+ if (val) return val;
+
+ return iter(elem, i);
+ });
+ }, Q());
+}
+
+// Map an array using an async (promised) iterator
+function map(arr, iter) {
+ return reduce(arr, function(prev, entry, i) {
+ return Q(iter(entry, i))
+ .then(function(out) {
+ prev.push(out);
+ return prev;
+ });
+ }, []);
+}
+
+// Wrap a fucntion in a promise
+function wrap(func) {
+ return _.wrap(func, function(_func) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ return Q()
+ .then(function() {
+ return _func.apply(null, args);
+ });
+ });
+}
+
+module.exports = Q;
+module.exports.reduce = reduce;
+module.exports.map = map;
+module.exports.serie = serie;
+module.exports.some = some;
+module.exports.wrapfn = wrap;
diff --git a/lib/utils/string.js b/lib/utils/string.js
deleted file mode 100644
index caa2364..0000000
--- a/lib/utils/string.js
+++ /dev/null
@@ -1,27 +0,0 @@
-var _ = require("lodash");
-
-function escapeShellArg(arg) {
- var ret = "";
-
- ret = arg.replace(/"/g, '\\"');
-
- return "\"" + ret + "\"";
-}
-
-function optionsToShellArgs(options) {
- return _.chain(options)
- .map(function(value, key) {
- if (value === null || value === undefined || value === false) return null;
- if (value === true) return key;
- return key+"="+escapeShellArg(value);
- })
- .compact()
- .value()
- .join(" ");
-}
-
-module.exports = {
- escapeShellArg: escapeShellArg,
- optionsToShellArgs: optionsToShellArgs,
- toLowerCase: String.prototype.toLowerCase.call.bind(String.prototype.toLowerCase)
-};
diff --git a/lib/utils/watch.js b/lib/utils/watch.js
deleted file mode 100644
index 4d1a752..0000000
--- a/lib/utils/watch.js
+++ /dev/null
@@ -1,40 +0,0 @@
-var Q = require("q");
-var _ = require("lodash");
-var path = require("path");
-var chokidar = require("chokidar");
-
-var parsers = require("gitbook-parsers");
-
-function watch(dir) {
- var d = Q.defer();
- dir = path.resolve(dir);
-
- var toWatch = [
- "book.json", "book.js"
- ];
-
- _.each(parsers.extensions, function(ext) {
- toWatch.push("**/*"+ext);
- });
-
- var watcher = chokidar.watch(toWatch, {
- cwd: dir,
- ignored: "_book/**",
- ignoreInitial: true
- });
-
- watcher.once("all", function(e, filepath) {
- watcher.close();
-
- d.resolve(filepath);
- });
- watcher.once("error", function(err) {
- watcher.close();
-
- d.reject(err);
- });
-
- return d.promise;
-}
-
-module.exports = watch;