diff options
Diffstat (limited to 'lib')
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; |