diff options
author | Samy Pesse <samypesse@gmail.com> | 2016-04-30 20:15:08 +0200 |
---|---|---|
committer | Samy Pesse <samypesse@gmail.com> | 2016-04-30 20:15:08 +0200 |
commit | 36b49c66c6b75515bc84dd678fd52121a313e8d2 (patch) | |
tree | bc7e0f703d4557869943ec7f9495cac7a5027d4f /lib | |
parent | 87db7cf1d412fa6fbd18e9a7e4f4755f2c0c5547 (diff) | |
parent | 80b8e340dadc54377ff40500f86b1de631395806 (diff) | |
download | gitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.zip gitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.tar.gz gitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.tar.bz2 |
Merge branch 'fixes'
Diffstat (limited to 'lib')
237 files changed, 9407 insertions, 5003 deletions
diff --git a/lib/__tests__/gitbook.js b/lib/__tests__/gitbook.js new file mode 100644 index 0000000..c3669bb --- /dev/null +++ b/lib/__tests__/gitbook.js @@ -0,0 +1,9 @@ +var gitbook = require('../gitbook'); + +describe('satisfies', function() { + + it('should return true for *', function() { + expect(gitbook.satisfies('*')).toBe(true); + }); + +}); diff --git a/lib/__tests__/init.js b/lib/__tests__/init.js new file mode 100644 index 0000000..5665cf1 --- /dev/null +++ b/lib/__tests__/init.js @@ -0,0 +1,16 @@ +var tmp = require('tmp'); +var initBook = require('../init'); + +describe('initBook', function() { + + pit('should create a README and SUMMARY for empty book', function() { + var dir = tmp.dirSync(); + + return initBook(dir.name) + .then(function() { + expect(dir.name).toHaveFile('README.md'); + expect(dir.name).toHaveFile('SUMMARY.md'); + }); + }); + +}); diff --git a/lib/__tests__/module.js b/lib/__tests__/module.js new file mode 100644 index 0000000..d9220f5 --- /dev/null +++ b/lib/__tests__/module.js @@ -0,0 +1,6 @@ + +describe('GitBook', function() { + it('should correctly export', function() { + require('../'); + }); +}); diff --git a/lib/api/decodeConfig.js b/lib/api/decodeConfig.js new file mode 100644 index 0000000..351ed05 --- /dev/null +++ b/lib/api/decodeConfig.js @@ -0,0 +1,19 @@ +var Config = require('../models/config'); + +/** + Decode changes from a JS API to a config object + + @param {Config} config + @param {Object} result: result from API + @return {Config} +*/ +function decodeGlobal(config, result) { + var values = result.values; + + delete values.generator; + delete values.output; + + return Config.updateValues(config, values); +} + +module.exports = decodeGlobal; diff --git a/lib/api/decodeGlobal.js b/lib/api/decodeGlobal.js new file mode 100644 index 0000000..118afb2 --- /dev/null +++ b/lib/api/decodeGlobal.js @@ -0,0 +1,22 @@ +var decodeConfig = require('./decodeConfig'); + +/** + Decode changes from a JS API to a output object. + Only the configuration can be edited by plugin's hooks + + @param {Output} output + @param {Object} result: result from API + @return {Output} +*/ +function decodeGlobal(output, result) { + var book = output.getBook(); + var config = book.getConfig(); + + // Update config + config = decodeConfig(config, result.config); + book = book.set('config', config); + + return output.set('book', book); +} + +module.exports = decodeGlobal; diff --git a/lib/api/decodePage.js b/lib/api/decodePage.js new file mode 100644 index 0000000..c85dd1b --- /dev/null +++ b/lib/api/decodePage.js @@ -0,0 +1,44 @@ +var deprecate = require('./deprecate'); + +/** + Decode changes from a JS API to a page object. + Only the content can be edited by plugin's hooks. + + @param {Output} output + @param {Page} page: page instance to edit + @param {Object} result: result from API + @return {Page} +*/ +function decodePage(output, page, result) { + var originalContent = page.getContent(); + + // No returned value + // Existing content will be used + if (!result) { + return page; + } + + deprecate.disable('page.sections'); + + // GitBook 3 + // Use returned page.content if different from original content + if (result.content != originalContent) { + page = page.set('content', result.content); + } + + // GitBook 2 compatibility + // Finally, use page.sections + else if (result.sections) { + page = page.set('content', + result.sections.map(function(section) { + return section.content; + }).join('\n') + ); + } + + deprecate.enable('page.sections'); + + return page; +} + +module.exports = decodePage; diff --git a/lib/api/deprecate.js b/lib/api/deprecate.js new file mode 100644 index 0000000..d8d6ac1 --- /dev/null +++ b/lib/api/deprecate.js @@ -0,0 +1,104 @@ +var is = require('is'); + +var logged = {}; +var disabled = {}; + +/** + Log a deprecated notice + + @param {Book|Output} book + @param {String} key + @param {String} message +*/ +function logNotice(book, key, message) { + if (logged[key] || disabled[key]) return; + + logged[key] = true; + + var logger = book.getLogger(); + logger.warn.ln(message); +} + +/** + Deprecate a function + + @param {Book|Output} book + @param {String} key: unique identitifer for the deprecated + @param {Function} fn + @param {String} msg: message to print when called + @return {Function} +*/ +function deprecateMethod(book, key, fn, msg) { + return function() { + logNotice(book, key, msg); + + return fn.apply(this, arguments); + }; +} + +/** + Deprecate a property of an object + + @param {Book|Output} book + @param {String} key: unique identitifer for the deprecated + @param {Object} instance + @param {String|Function} property + @param {String} msg: message to print when called + @return {Function} +*/ +function deprecateField(book, key, instance, property, value, msg) { + var store = undefined; + + var prepare = function() { + if (!is.undefined(store)) return; + + if (is.fn(value)) store = value(); + else store = value; + }; + + var getter = function(){ + prepare(); + + logNotice(book, key, msg); + return store; + }; + var setter = function(v) { + prepare(); + + logNotice(book, key, msg); + store = v; + return store; + }; + + Object.defineProperty(instance, property, { + get: getter, + set: setter, + enumerable: true, + configurable: true + }); +} + +/** + Enable a deprecation + + @param {String} key: unique identitifer +*/ +function enableDeprecation(key) { + disabled[key] = false; +} + +/** + Disable a deprecation + + @param {String} key: unique identitifer +*/ +function disableDeprecation(key) { + disabled[key] = true; +} + +module.exports = { + method: deprecateMethod, + field: deprecateField, + enable: enableDeprecation, + disable: disableDeprecation +}; diff --git a/lib/api/encodeConfig.js b/lib/api/encodeConfig.js new file mode 100644 index 0000000..2a05528 --- /dev/null +++ b/lib/api/encodeConfig.js @@ -0,0 +1,36 @@ +var objectPath = require('object-path'); +var deprecate = require('./deprecate'); + +/** + Encode a config object into a JS config api + + @param {Output} output + @param {Config} config + @return {Object} +*/ +function encodeConfig(output, config) { + var result = { + values: config.getValues().toJS(), + + get: function(key, defaultValue) { + return objectPath.get(result.values, key, defaultValue); + }, + + set: function(key, value) { + return objectPath.set(result.values, key, value); + } + }; + + deprecate.field(output, 'config.options', result, 'options', + result.values, '"config.options" property is deprecated, use "config.get(key)" instead'); + + deprecate.field(output, 'config.options.generator', result.values, 'generator', + output.getGenerator(), '"options.generator" property is deprecated, use "output.name" instead'); + + deprecate.field(output, 'config.options.generator', result.values, 'output', + output.getRoot(), '"options.output" property is deprecated, use "output.root()" instead'); + + return result; +} + +module.exports = encodeConfig; diff --git a/lib/api/encodeGlobal.js b/lib/api/encodeGlobal.js new file mode 100644 index 0000000..4688cca --- /dev/null +++ b/lib/api/encodeGlobal.js @@ -0,0 +1,125 @@ +var Promise = require('../utils/promise'); +var PathUtils = require('../utils/path'); +var fs = require('../utils/fs'); + +var deprecate = require('./deprecate'); +var encodeConfig = require('./encodeConfig'); +var encodeNavigation = require('./encodeNavigation'); +var fileToURL = require('../output/helper/fileToURL'); + +/** + Encode a global context into a JS object + It's the context for page's hook, etc + + @param {Output} output + @return {Object} +*/ +function encodeGlobal(output) { + var book = output.getBook(); + var bookFS = book.getContentFS(); + var logger = output.getLogger(); + var outputFolder = output.getRoot(); + + var result = { + log: logger, + config: encodeConfig(output, book.getConfig()), + + isMultilingual: function() { + return book.isMultilingual(); + }, + + isLanguageBook: function() { + return book.isLanguageBook(); + }, + + isSubBook: deprecate.method(output, 'this.isSubBook', function() { + return book.isLanguageBook(); + }, '"isSubBook" is deprecated, use "isLanguageBook()" instead'), + + /** + Read a file from the book + + @param {String} fileName + @return {Promise<Buffer>} + */ + readFile: function(fileName) { + return bookFS.read(fileName); + }, + + /** + Read a file from the book as a string + + @param {String} fileName + @return {Promise<String>} + */ + readFileAsString: function(fileName) { + return bookFS.readAsString(fileName); + }, + + output: { + /** + Name of the generator being used + {String} + */ + name: output.getGenerator(), + + /** + Return absolute path to the root folder of output + @return {String} + */ + root: function() { + return outputFolder; + }, + + /** + Convert a filepath into an url + @return {String} + */ + toURL: function(filePath) { + return fileToURL(output, filePath); + }, + + /** + Write a file to the output folder, + It creates the required folder + + @param {String} fileName + @param {Buffer} content + @return {Promise} + */ + writeFile: function(fileName, content) { + return Promise() + .then(function() { + var filePath = PathUtils.resolveInRoot(outputFolder, fileName); + + return fs.ensureFile(filePath) + .then(function() { + return fs.writeFile(filePath, content); + }); + }); + } + } + }; + + // todo + // template.applyBlock + + // Deprecated properties + + deprecate.field(output, 'this.generator', result, 'generator', + output.getGenerator(), '"this.generator" property is deprecated, use "this.output.name" instead'); + + deprecate.field(output, 'this.navigation', result, 'navigation', function() { + return encodeNavigation(output); + }, '"navigation" property is deprecated'); + + deprecate.field(output, 'this.book', result, 'book', + result, '"book" property is deprecated, use "this" directly instead'); + + deprecate.field(output, 'this.options', result, 'options', + result.config.values, '"options" property is deprecated, use config.get(key) instead'); + + return result; +} + +module.exports = encodeGlobal; diff --git a/lib/api/encodeNavigation.js b/lib/api/encodeNavigation.js new file mode 100644 index 0000000..8e329a1 --- /dev/null +++ b/lib/api/encodeNavigation.js @@ -0,0 +1,64 @@ +var Immutable = require('immutable'); + +/** + Encode an article for next/prev + + @param {Map<String:Page>} + @param {Article} + @return {Object} +*/ +function encodeArticle(pages, article) { + var articlePath = article.getPath(); + + return { + path: articlePath, + title: article.getTitle(), + level: article.getLevel(), + exists: (articlePath && pages.has(articlePath)), + external: article.isExternal() + }; +} + +/** + this.navigation is a deprecated property from GitBook v2 + + @param {Output} + @return {Object} +*/ +function encodeNavigation(output) { + var book = output.getBook(); + var pages = output.getPages(); + var summary = book.getSummary(); + var articles = summary.getArticlesAsList(); + + + var navigation = articles + .map(function(article, i) { + var ref = article.getRef(); + if (!ref) { + return undefined; + } + + var prev = articles.get(i - 1); + var next = articles.get(i + 1); + + return [ + ref, + { + index: i, + title: article.getTitle(), + introduction: (i === 0), + prev: prev? encodeArticle(pages, prev) : undefined, + next: next? encodeArticle(pages, next) : undefined, + level: article.getLevel() + } + ]; + }) + .filter(function(e) { + return Boolean(e); + }); + + return Immutable.Map(navigation).toJS(); +} + +module.exports = encodeNavigation; diff --git a/lib/api/encodePage.js b/lib/api/encodePage.js new file mode 100644 index 0000000..379d3d5 --- /dev/null +++ b/lib/api/encodePage.js @@ -0,0 +1,39 @@ +var JSONUtils = require('../json'); +var deprecate = require('./deprecate'); +var encodeProgress = require('./encodeProgress'); + +/** + Encode a page in a context to a JS API + + @param {Output} output + @param {Page} page + @return {Object} +*/ +function encodePage(output, page) { + var book = output.getBook(); + var summary = book.getSummary(); + var fs = book.getContentFS(); + var file = page.getFile(); + + // JS Page is based on the JSON output + var result = JSONUtils.encodePage(page, summary); + + result.type = file.getType(); + result.path = file.getPath(); + result.rawPath = fs.resolve(result.path); + + deprecate.field(output, 'page.progress', result, 'progress', function() { + return encodeProgress(output, page); + }, '"page.progress" property is deprecated'); + + deprecate.field(output, 'page.sections', result, 'sections', [ + { + content: result.content, + type: 'normal' + } + ], '"sections" property is deprecated, use page.content instead'); + + return result; +} + +module.exports = encodePage; diff --git a/lib/api/encodeProgress.js b/lib/api/encodeProgress.js new file mode 100644 index 0000000..afa0341 --- /dev/null +++ b/lib/api/encodeProgress.js @@ -0,0 +1,63 @@ +var Immutable = require('immutable'); +var encodeNavigation = require('./encodeNavigation'); + +/** + page.progress is a deprecated property from GitBook v2 + + @param {Output} + @param {Page} + @return {Object} +*/ +function encodeProgress(output, page) { + var current = page.getPath(); + var navigation = encodeNavigation(output); + navigation = Immutable.Map(navigation); + + var n = navigation.size; + var percent = 0, prevPercent = 0, currentChapter = null; + var done = true; + + var chapters = navigation + .map(function(nav, chapterPath) { + nav.path = chapterPath; + return nav; + }) + .valueSeq() + .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; + }) + .toJS(); + + return { + // Previous percent + prevPercent: prevPercent, + + // Current percent + percent: percent, + + // List of chapter with progress + chapters: chapters, + + // Current chapter + current: currentChapter + }; +} + +module.exports = encodeProgress; + diff --git a/lib/api/index.js b/lib/api/index.js new file mode 100644 index 0000000..5e67525 --- /dev/null +++ b/lib/api/index.js @@ -0,0 +1,8 @@ + +module.exports = { + encodePage: require('./encodePage'), + decodePage: require('./decodePage'), + + encodeGlobal: require('./encodeGlobal'), + decodeGlobal: require('./decodeGlobal') +}; diff --git a/lib/backbone/file.js b/lib/backbone/file.js deleted file mode 100644 index 209e261..0000000 --- a/lib/backbone/file.js +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index cc0fdce..0000000 --- a/lib/backbone/glossary.js +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 4c3c3f3..0000000 --- a/lib/backbone/index.js +++ /dev/null @@ -1,8 +0,0 @@ - -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 deleted file mode 100644 index e339fa9..0000000 --- a/lib/backbone/langs.js +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 088a942..0000000 --- a/lib/backbone/readme.js +++ /dev/null @@ -1,44 +0,0 @@ -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'; - -/* - Return and extension of context to define the readme - - @retrun {Object} -*/ -Readme.prototype.getContext = function() { - return { - readme: { - path: this.path - } - }; -}; - -/* - Parse the readme content - - @param {String} content - @retrun {Promise} -*/ -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 deleted file mode 100644 index 2dbcecb..0000000 --- a/lib/backbone/summary.js +++ /dev/null @@ -1,349 +0,0 @@ -var _ = require('lodash'); -var util = require('util'); - -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 && !this.isExternal()) { - var parts = this.ref.split('#'); - this.path = (parts.length > 1? parts.slice(0, -1).join('#') : this.ref); - this.anchor = (parts.length > 1? '#' + _.last(parts) : null); - - // Normalize path to remove ('./', etc) - this.path = location.normalize(this.path); - } - - 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: { - path: this.path, - 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; -}; - -// Flatten the list of articles -Summary.prototype.flatten = function() { - var result = []; - - this.walk(function(article) { - result.push(article); - }); - - 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 deleted file mode 100644 index 77e973a..0000000 --- a/lib/book.js +++ /dev/null @@ -1,396 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -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 error = require('./utils/error'); -var Promise = require('./utils/promise'); -var Logger = require('./utils/logger'); -var parsers = require('./parsers'); -var initBook = require('./init'); - - -/* -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); - - this.opts = _.defaults(opts || {}, { - fs: null, - - // Root path for the book - root: '', - - // Extend book configuration - config: {}, - - // Log function - log: function(msg) { - process.stdout.write(msg); - }, - - // Log level - logLevel: 'info' - }); - - if (!opts.fs) throw error.ParsingError(new Error('Book requires a fs instance')); - - // Root path for the book - this.root = opts.root; - - // If multi-lingual, book can have a parent - this.parent = opts.parent; - if (this.parent) { - this.language = path.relative(this.parent.root, this.root); - } - - // A book is linked to an fs, to access its content - this.fs = opts.fs; - - // Rules to ignore some files - this.ignore = Ignore(); - this.ignore.addPattern([ - // Skip Git stuff - '.git/', - - // Skip OS X meta data - '.DS_Store', - - // Skip stuff installed by plugins - 'node_modules', - - // Skip book outputs - '_book', - '*.pdf', - '*.epub', - '*.mobi' - ]); - - // Create a logger for the book - this.log = new Logger(opts.log, opts.logLevel); - - // Create an interface to access the configuration - this.config = new Config(this, opts.config); - - // Interfaces for the book structure - this.readme = new Readme(this); - this.summary = new Summary(this); - this.glossary = new Glossary(this); - - // Multilinguals book - this.langs = new Langs(this); - this.books = []; - - // List of page in the book - this.pages = {}; - - // Deprecation for templates - Object.defineProperty(this, 'options', { - get: function () { - this.log.warn.ln('"options" property is deprecated, use config.get(key) instead'); - return this.config.options; - } - }); - - _.bindAll(this); - - // Loop for template filters/blocks - error.deprecateField(this, 'book', this, '"book" property is deprecated, use "this" directly instead'); -} - -// Return templating context for the book -Book.prototype.getContext = function() { - var variables = this.config.get('variables', {}); - - return { - book: _.extend({ - language: this.language - }, variables) - }; -}; - -// Parse and prepare the configuration, fail if invalid -Book.prototype.prepareConfig = function() { - var that = this; - - return this.config.load() - .then(function() { - var rootFolder = that.config.get('root'); - if (!rootFolder) return; - - that.originalRoot = that.root; - that.root = path.resolve(that.root, rootFolder); - }); -}; - -// 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 - }); - } - - return filename; -}; - -// Return false if a file is outside the book' scope -Book.prototype.isFileInScope = function(filename) { - filename = path.resolve(this.root, filename); - - // 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); -}; - -// Parse .gitignore, etc to extract rules -Book.prototype.parseIgnoreRules = function() { - var that = this; - - 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(); - }); - }); -}; - -// Parse the whole book -Book.prototype.parse = function() { - var that = this; - - return Promise() - .then(this.prepareConfig) - .then(this.parseIgnoreRules) - - // Parse languages - .then(function() { - return that.langs.load(); - }) - - .then(function() { - if (that.isMultilingual()) { - 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) - })); - - that.books.push(langBook); - - return langBook.parse(); - }); - } - - return Promise() - - // Parse the readme - .then(that.readme.load) - .then(function() { - if (!that.readme.exists()) { - throw new error.FileNotFoundError({ filename: 'README' }); - } - - // 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); - }) - - // 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); - }); - }) - - // Parse the glossary - .then(that.glossary.load) - - // Add the glossary as a page - .then(function() { - if (!that.glossary.exists()) return; - that.addPage(that.glossary.path); - }); - }); -}; - -// Mark a filename as being parsable -Book.prototype.addPage = function(filename) { - if (this.hasPage(filename)) return this.getPage(filename); - - filename = pathUtil.normalize(filename); - this.pages[filename] = new Page(this, filename); - return this.pages[filename]; -}; - -// Return a page by its filename (or undefined) -Book.prototype.getPage = function(filename) { - filename = pathUtil.normalize(filename); - return this.pages[filename]; -}; - - -// Return true, if has a specific page -Book.prototype.hasPage = function(filename) { - return Boolean(this.getPage(filename)); -}; - -// Test if a file is ignored, return true if it is -Book.prototype.isFileIgnored = function(filename) { - return this.ignore.filter([filename]).length == 0; -}; - -// 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)); -}; - -// 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 a parsable file using a filename -Book.prototype.findParsableFile = function(filename) { - var that = this; - - var ext = path.extname(filename); - var basename = path.basename(filename, ext); - - // Ordered list of extensions to test - var exts = parsers.extensions; - if (ext) exts = _.uniq([ext].concat(exts)); - - return _.reduce(exts, function(prev, ext) { - return prev.then(function(output) { - // Stop if already find a parser - if (output) return output; - - var filepath = basename+ext; - - return that.fs.findFile(that.root, filepath) - .then(function(realFilepath) { - if (!realFilepath) return null; - - return { - parser: parsers.getByExt(ext), - path: realFilepath - }; - }); - }); - }, Promise(null)); -}; - -// Return true if book is associated to a language -Book.prototype.isLanguageBook = function() { - return Boolean(this.parent); -}; -Book.prototype.isSubBook = Book.prototype.isLanguageBook; - -// Return true if the book is main instance of a multilingual book -Book.prototype.isMultilingual = function() { - return this.langs.count() > 0; -}; - -// Return true if file is in the scope of this book -Book.prototype.isInBook = function(filename) { - return pathUtil.isInRoot( - this.root, - filename - ); -}; - -// Return true if file is in the scope of a child book -Book.prototype.isInLanguageBook = function(filename) { - var that = this; - - return _.some(this.langs.list(), function(lang) { - return pathUtil.isInRoot( - that.resolve(lang.id), - that.resolve(filename) - ); - }); -}; - -// ----- Parser Methods - -// Render a markup string in inline mode -Book.prototype.renderInline = function(type, src) { - var parser = parsers.get(type); - return parser.inline(src) - .get('content'); -}; - -// Render a markup string in block mode -Book.prototype.renderBlock = function(type, src) { - var parser = parsers.get(type); - return parser.page(src) - .get('content'); -}; - - -// ----- DEPRECATED METHODS - -Book.prototype.contentLink = error.deprecateMethod(function(s) { - return this.output.toURL(s); -}, '.contentLink() is deprecated, use ".output.toURL()" instead'); - -Book.prototype.contentPath = error.deprecateMethod(function(s) { - return this.output.toURL(s); -}, '.contentPath() is deprecated, use ".output.toURL()" instead'); - -Book.prototype.isSubBook = error.deprecateMethod(function() { - return this.isLanguageBook(); -}, '.isSubBook() is deprecated, use ".isLanguageBook()" instead'); - - -// Initialize a book -Book.init = function(fs, root, opts) { - var book = new Book(_.extend(opts || {}, { - root: root, - fs: fs - })); - - return initBook(book); -}; - - -module.exports = Book; diff --git a/lib/browser.js b/lib/browser.js new file mode 100644 index 0000000..745a544 --- /dev/null +++ b/lib/browser.js @@ -0,0 +1,14 @@ +var Modifiers = require('./modifiers'); + +module.exports = { + Parse: require('./parse'), + + // Models + Book: require('./models/book'), + FS: require('./models/fs'), + Summary: require('./models/summary'), + Glossary: require('./models/glossary'), + + // Modifiers + SummaryModifier: Modifiers.Summary +}; diff --git a/lib/cli/build.js b/lib/cli/build.js new file mode 100644 index 0000000..023901e --- /dev/null +++ b/lib/cli/build.js @@ -0,0 +1,34 @@ +var Parse = require('../parse'); +var Output = require('../output'); +var timing = require('../utils/timing'); + +var options = require('./options'); +var getBook = require('./getBook'); +var getOutputFolder = require('./getOutputFolder'); + + +module.exports = { + name: 'build [book] [output]', + description: 'build a book', + options: [ + options.log, + options.format, + options.timing + ], + exec: function(args, kwargs) { + var book = getBook(args, kwargs); + var outputFolder = getOutputFolder(args); + + var Generator = Output.getGenerator(kwargs.format); + + return Parse.parseBook(book) + .then(function(resultBook) { + return Output.generate(Generator, resultBook, { + root: outputFolder + }); + }) + .fin(function() { + if (kwargs.timing) timing.dump(book.getLogger()); + }); + } +}; diff --git a/lib/cli/buildEbook.js b/lib/cli/buildEbook.js new file mode 100644 index 0000000..405d838 --- /dev/null +++ b/lib/cli/buildEbook.js @@ -0,0 +1,76 @@ +var path = require('path'); +var tmp = require('tmp'); + +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var Parse = require('../parse'); +var Output = require('../output'); + +var options = require('./options'); +var getBook = require('./getBook'); + + +module.exports = function(format) { + return { + name: (format + ' [book] [output]'), + description: 'build a book into an ebook file', + options: [ + options.log + ], + exec: function(args, kwargs) { + // Output file will be stored in + var outputFile = args[1] || ('book.' + format); + + // Create temporary directory + var outputFolder = tmp.dirSync().name; + + var book = getBook(args, kwargs); + var logger = book.getLogger(); + var Generator = Output.getGenerator('ebook'); + + return Parse.parseBook(book) + .then(function(resultBook) { + return Output.generate(Generator, resultBook, { + root: outputFolder, + format: format + }); + }) + + // Extract ebook file + .then(function(output) { + var book = output.getBook(); + var languages = book.getLanguages(); + + if (book.isMultilingual()) { + return Promise.ForEach(languages, function(lang) { + var langID = lang.getID(); + + var langOutputFile = path.join( + path.dirname(outputFile), + path.basename(outputFile, format) + '_' + langID + '.' + format + ); + + return fs.copy( + path.resolve(outputFolder, langID, 'index.' + format), + langOutputFile + ); + }) + .thenResolve(languages.getCount()); + } else { + return fs.copy( + path.resolve(outputFolder, 'index.' + format), + outputFile + ).thenResolve(1); + } + }) + + // Log end + .then(function(count) { + logger.info.ok(count + ' file(s) generated'); + + logger.debug('cleaning up... '); + return logger.debug.promise(fs.rmDir(outputFolder)); + }); + } + }; +}; diff --git a/lib/cli/getBook.js b/lib/cli/getBook.js new file mode 100644 index 0000000..ac82187 --- /dev/null +++ b/lib/cli/getBook.js @@ -0,0 +1,23 @@ +var path = require('path'); +var Book = require('../models/book'); +var createNodeFS = require('../fs/node'); + +/** + Return a book instance to work on from + command line args/kwargs + + @param {Array} args + @param {Object} kwargs + @return {Book} +*/ +function getBook(args, kwargs) { + var input = path.resolve(args[0] || process.cwd()); + var logLevel = kwargs.log; + + var fs = createNodeFS(input); + var book = Book.createForFS(fs); + + return book.setLogLevel(logLevel); +} + +module.exports = getBook; diff --git a/lib/cli/getOutputFolder.js b/lib/cli/getOutputFolder.js new file mode 100644 index 0000000..272dff9 --- /dev/null +++ b/lib/cli/getOutputFolder.js @@ -0,0 +1,17 @@ +var path = require('path'); + +/** + Return path to output folder + + @param {Array} args + @return {String} +*/ +function getOutputFolder(args) { + var bookRoot = path.resolve(args[0] || process.cwd()); + var defaultOutputRoot = path.join(bookRoot, '_book'); + var outputFolder = args[1]? path.resolve(process.cwd(), args[1]) : defaultOutputRoot; + + return outputFolder; +} + +module.exports = getOutputFolder; diff --git a/lib/cli/helper.js b/lib/cli/helper.js deleted file mode 100644 index 02cede6..0000000 --- a/lib/cli/helper.js +++ /dev/null @@ -1,140 +0,0 @@ -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()); - var book = new Book({ - fs: nodeFS, - root: input, - logLevel: kwargs.log - }); - - 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[0] || ('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 index eea707f..f1fca1d 100644 --- a/lib/cli/index.js +++ b/lib/cli/index.js @@ -1,199 +1,12 @@ -/* 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: 'init [book]', - description: 'setup and create files for chapters', - options: [ - helper.options.log - ], - exec: function(args) { - var input = path.resolve(args[0] || process.cwd()); - return Book.init(helper.nodeFS, input); - } - }, - - { - 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() { - var book = new Book({ - fs: helper.nodeFS, - root: input, - logLevel: kwargs.log - }); - - 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(); - }); - } - } - - ] -}; +var buildEbook = require('./buildEbook'); + +module.exports = [ + require('./build'), + require('./serve'), + require('./install'), + require('./parse'), + require('./init'), + buildEbook('pdf'), + buildEbook('epub'), + buildEbook('mobi') +]; diff --git a/lib/cli/init.js b/lib/cli/init.js new file mode 100644 index 0000000..9a1bff8 --- /dev/null +++ b/lib/cli/init.js @@ -0,0 +1,17 @@ +var path = require('path'); + +var options = require('./options'); +var initBook = require('../init'); + +module.exports = { + name: 'install [book]', + description: 'setup and create files for chapters', + options: [ + options.log + ], + exec: function(args, kwargs) { + var bookRoot = path.resolve(process.cwd(), args[0] || './'); + + return initBook(bookRoot); + } +}; diff --git a/lib/cli/install.js b/lib/cli/install.js new file mode 100644 index 0000000..c001711 --- /dev/null +++ b/lib/cli/install.js @@ -0,0 +1,21 @@ +var options = require('./options'); +var getBook = require('./getBook'); + +var Parse = require('../parse'); +var Plugins = require('../plugins'); + +module.exports = { + name: 'install [book]', + description: 'install all plugins dependencies', + options: [ + options.log + ], + exec: function(args, kwargs) { + var book = getBook(args, kwargs); + + return Parse.parseConfig(book) + .then(function(resultBook) { + return Plugins.installPlugins(resultBook); + }); + } +}; diff --git a/lib/cli/options.js b/lib/cli/options.js new file mode 100644 index 0000000..ddcb5c5 --- /dev/null +++ b/lib/cli/options.js @@ -0,0 +1,30 @@ +var Logger = require('../utils/logger'); + +var logOptions = { + name: 'log', + description: 'Minimum log level to display', + values: Object.keys(Logger.LEVELS) + .map(function(s) { + return s.toLowerCase(); + }), + defaults: 'info' +}; + +var formatOption = { + name: 'format', + description: 'Format to build to', + values: ['website', 'json', 'ebook'], + defaults: 'website' +}; + +var timingOption = { + name: 'timing', + description: 'Print timing debug information', + defaults: false +}; + +module.exports = { + log: logOptions, + format: formatOption, + timing: timingOption +}; diff --git a/lib/cli/parse.js b/lib/cli/parse.js new file mode 100644 index 0000000..0fa509a --- /dev/null +++ b/lib/cli/parse.js @@ -0,0 +1,79 @@ +var options = require('./options'); +var getBook = require('./getBook'); + +var Parse = require('../parse'); + +function printBook(book) { + var logger = book.getLogger(); + + var config = book.getConfig(); + var configFile = config.getFile(); + + var summary = book.getSummary(); + var summaryFile = summary.getFile(); + + var readme = book.getReadme(); + var readmeFile = readme.getFile(); + + var glossary = book.getGlossary(); + var glossaryFile = glossary.getFile(); + + if (configFile.exists()) { + logger.info.ln('Configuration file is', configFile.getPath()); + } + + if (readmeFile.exists()) { + logger.info.ln('Introduction file is', readmeFile.getPath()); + } + + if (glossaryFile.exists()) { + logger.info.ln('Glossary file is', glossaryFile.getPath()); + } + + if (summaryFile.exists()) { + logger.info.ln('Table of Contents file is', summaryFile.getPath()); + } +} + +function printMultingualBook(book) { + var logger = book.getLogger(); + var languages = book.getLanguages(); + var books = book.getBooks(); + + logger.info.ln(languages.size + ' languages'); + + languages.forEach(function(lang) { + logger.info.ln('Language:', lang.getTitle()); + printBook(books.get(lang.getID())); + logger.info.ln(''); + }); +} + +module.exports = { + name: 'parse [book]', + description: 'parse and print debug information about a book', + options: [ + options.log + ], + exec: function(args, kwargs) { + var book = getBook(args, kwargs); + var logger = book.getLogger(); + + return Parse.parseBook(book) + .then(function(resultBook) { + var rootFolder = book.getRoot(); + var contentFolder = book.getContentRoot(); + + logger.info.ln('Book located in:', rootFolder); + if (contentFolder != rootFolder) { + logger.info.ln('Content located in:', contentFolder); + } + + if (resultBook.isMultilingual()) { + printMultingualBook(resultBook); + } else { + printBook(resultBook); + } + }); + } +}; diff --git a/lib/cli/serve.js b/lib/cli/serve.js new file mode 100644 index 0000000..628f591 --- /dev/null +++ b/lib/cli/serve.js @@ -0,0 +1,93 @@ +/* eslint-disable no-console */ + +var tinylr = require('tiny-lr'); + +var Parse = require('../parse'); +var Output = require('../output'); + +var options = require('./options'); +var getBook = require('./getBook'); +var getOutputFolder = require('./getOutputFolder'); +var Server = require('./server'); +var watch = require('./watch'); + +var server, lrServer, lrPath; + +function generateBook(args, kwargs) { + var port = kwargs.port; + var outputFolder = getOutputFolder(args); + var book = getBook(args, kwargs); + var Generator = Output.getGenerator(kwargs.format); + + // Stop server if running + if (server.isRunning()) console.log('Stopping server'); + + return server.stop() + .then(function() { + return Parse.parseBook(book) + .then(function(resultBook) { + return Output.generate(Generator, resultBook, { + root: outputFolder + }); + }); + }) + .then(function() { + console.log(); + console.log('Starting server ...'); + return server.start(outputFolder, port); + }) + .then(function() { + console.log('Serving book on http://localhost:'+port); + + if (lrPath) { + // trigger livereload + lrServer.changed({ + body: { + files: [lrPath] + } + }); + } + }) + .then(function() { + if (!kwargs.watch) return; + + return watch(book.getRoot()) + .then(function(filepath) { + // set livereload path + lrPath = filepath; + console.log('Restart after change in file', filepath); + console.log(''); + return generateBook(args, kwargs); + }); + }); +} + +module.exports = { + name: 'serve [book] [output]', + description: 'serve the book as a website for testing', + 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 + }, + options.log, + options.format + ], + exec: function(args, kwargs) { + server = new Server(); + lrServer = tinylr({}); + + return generateBook(args, kwargs); + } +}; diff --git a/lib/cli/server.js b/lib/cli/server.js index 8d3d7ce..555bbb7 100644 --- a/lib/cli/server.js +++ b/lib/cli/server.js @@ -6,20 +6,28 @@ var url = require('url'); var Promise = require('../utils/promise'); -var Server = function() { +function Server() { this.running = null; this.dir = null; this.port = 0; this.sockets = []; -}; +} util.inherits(Server, events.EventEmitter); -// Return true if the server is running +/** + Return true if the server is running + + @return {Boolean} +*/ Server.prototype.isRunning = function() { return !!this.running; }; -// Stop the server +/** + Stop the server + + @return {Promise} +*/ Server.prototype.stop = function() { var that = this; if (!this.isRunning()) return Promise(); @@ -40,6 +48,11 @@ Server.prototype.stop = function() { return d.promise; }; +/** + Start the server + + @return {Promise} +*/ Server.prototype.start = function(dir, port) { var that = this, pre = Promise(); port = port || 8004; diff --git a/lib/cli/watch.js b/lib/cli/watch.js index 130b0d4..0d1ab17 100644 --- a/lib/cli/watch.js +++ b/lib/cli/watch.js @@ -5,7 +5,12 @@ var chokidar = require('chokidar'); var Promise = require('../utils/promise'); var parsers = require('../parsers'); -// Watch a folder and resolve promise once a file is modified +/** + Watch a folder and resolve promise once a file is modified + + @param {String} dir + @return {Promise} +*/ function watch(dir) { var d = Promise.defer(); dir = path.resolve(dir); diff --git a/lib/config/index.js b/lib/config/index.js deleted file mode 100644 index 6887cc2..0000000 --- a/lib/config/index.js +++ /dev/null @@ -1,137 +0,0 @@ -var _ = require('lodash'); -var semver = require('semver'); - -var gitbook = require('../gitbook'); -var Promise = require('../utils/promise'); -var error = require('../utils/error'); -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; - } - }); - - error.deprecateField(this.options, 'generator', (this.book.output? this.book.output.name : null), '"options.generator" property is deprecated, use "output.name" instead'); - error.deprecateField(this.options, 'output', (this.book.output && this.book.output.root? this.book.output.root() : null), '"options.output" property is deprecated, use "output.root()" instead'); -}; - -// 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() { - var opts = _.omit(this.options, 'generator', 'output'); - return _.cloneDeep(opts); -}; - -// 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 deleted file mode 100644 index 24f0041..0000000 --- a/lib/config/plugins.js +++ /dev/null @@ -1,67 +0,0 @@ -var _ = require('lodash'); - -// Default plugins added to each books -var DEFAULT_PLUGINS = ['highlight', 'search', 'lunr', '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.slice(1).join('@'); - 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/constants/configDefault.js b/lib/constants/configDefault.js new file mode 100644 index 0000000..0d95883 --- /dev/null +++ b/lib/constants/configDefault.js @@ -0,0 +1,6 @@ +var Immutable = require('immutable'); +var jsonSchemaDefaults = require('json-schema-defaults'); + +var schema = require('./configSchema'); + +module.exports = Immutable.fromJS(jsonSchemaDefaults(schema)); diff --git a/lib/constants/configFiles.js b/lib/constants/configFiles.js new file mode 100644 index 0000000..a67fd74 --- /dev/null +++ b/lib/constants/configFiles.js @@ -0,0 +1,5 @@ +// Configuration files to test (sorted) +module.exports = [ + 'book.js', + 'book.json' +]; diff --git a/lib/config/schema.js b/lib/constants/configSchema.js index 3fb2050..3fb2050 100644 --- a/lib/config/schema.js +++ b/lib/constants/configSchema.js diff --git a/lib/constants/defaultBlocks.js b/lib/constants/defaultBlocks.js new file mode 100644 index 0000000..74d1f1f --- /dev/null +++ b/lib/constants/defaultBlocks.js @@ -0,0 +1,51 @@ +var Immutable = require('immutable'); +var TemplateBlock = require('../models/templateBlock'); + +module.exports = Immutable.Map({ + html: TemplateBlock({ + name: 'html', + process: function(blk) { + return blk; + } + }), + + code: TemplateBlock({ + name: 'code', + process: function(blk) { + return { + html: false, + body: blk.body + }; + } + }), + + markdown: TemplateBlock({ + name: 'markdown', + process: function(blk) { + return this.book.renderInline('markdown', blk.body) + .then(function(out) { + return { body: out }; + }); + } + }), + + asciidoc: TemplateBlock({ + name: 'asciidoc', + process: function(blk) { + return this.book.renderInline('asciidoc', blk.body) + .then(function(out) { + return { body: out }; + }); + } + }), + + markup: TemplateBlock({ + name: 'markup', + process: function(blk) { + return this.book.renderInline(this.ctx.file.type, blk.body) + .then(function(out) { + return { body: out }; + }); + } + }) +}); diff --git a/lib/template/filters.js b/lib/constants/defaultFilters.js index ac68b82..35025cc 100644 --- a/lib/template/filters.js +++ b/lib/constants/defaultFilters.js @@ -1,7 +1,7 @@ +var Immutable = require('immutable'); var moment = require('moment'); - -module.exports = { +module.exports = Immutable.Map({ // Format a date // ex: 'MMMM Do YYYY, h:mm:ss a date: function(time, format) { @@ -12,4 +12,4 @@ module.exports = { dateFromNow: function(time) { return moment(time).fromNow(); } -}; +}); diff --git a/lib/constants/defaultPlugins.js b/lib/constants/defaultPlugins.js new file mode 100644 index 0000000..e6ea2bb --- /dev/null +++ b/lib/constants/defaultPlugins.js @@ -0,0 +1,14 @@ +var Immutable = require('immutable'); + +/* + List of default plugins for all books, + default plugins should be installed in node dependencies of GitBook +*/ +module.exports = Immutable.List([ + 'highlight', + 'search', + 'lunr', + 'sharing', + 'fontsettings', + 'theme-default' +]); diff --git a/lib/constants/ignoreFiles.js b/lib/constants/ignoreFiles.js new file mode 100644 index 0000000..aac225e --- /dev/null +++ b/lib/constants/ignoreFiles.js @@ -0,0 +1,6 @@ +// Files containing ignore pattner (sorted by priority) +module.exports = [ + '.ignore', + '.gitignore', + '.bookignore' +]; diff --git a/lib/constants/pluginAssetsFolder.js b/lib/constants/pluginAssetsFolder.js new file mode 100644 index 0000000..cd44722 --- /dev/null +++ b/lib/constants/pluginAssetsFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_assets'; diff --git a/lib/constants/pluginHooks.js b/lib/constants/pluginHooks.js new file mode 100644 index 0000000..2d5dcaa --- /dev/null +++ b/lib/constants/pluginHooks.js @@ -0,0 +1,8 @@ +module.exports = [ + 'init', + 'finish', + 'finish:before', + 'config', + 'page', + 'page:before' +]; diff --git a/lib/constants/pluginPrefix.js b/lib/constants/pluginPrefix.js new file mode 100644 index 0000000..c7f2dd0 --- /dev/null +++ b/lib/constants/pluginPrefix.js @@ -0,0 +1,5 @@ + +/* + All GitBook plugins are NPM packages starting with this prefix. +*/ +module.exports = 'gitbook-plugin-'; diff --git a/lib/constants/pluginResources.js b/lib/constants/pluginResources.js new file mode 100644 index 0000000..ae283bf --- /dev/null +++ b/lib/constants/pluginResources.js @@ -0,0 +1,6 @@ +var Immutable = require('immutable'); + +module.exports = Immutable.List([ + 'js', + 'css' +]); diff --git a/lib/constants/templatesFolder.js b/lib/constants/templatesFolder.js new file mode 100644 index 0000000..aad6a72 --- /dev/null +++ b/lib/constants/templatesFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_layouts'; diff --git a/lib/fs/__tests__/mock.js b/lib/fs/__tests__/mock.js new file mode 100644 index 0000000..7842011 --- /dev/null +++ b/lib/fs/__tests__/mock.js @@ -0,0 +1,83 @@ +jest.autoMockOff(); + +describe('MockFS', function() { + var createMockFS = require('../mock'); + var fs = createMockFS({ + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary', + 'folder': { + 'test.md': 'Cool', + 'folder2': { + 'hello.md': 'Hello', + 'world.md': 'World' + } + } + }); + + describe('exists', function() { + pit('must return true for a file', function() { + return fs.exists('README.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + pit('must return false for a non existing file', function() { + return fs.exists('README_NOTEXISTS.md') + .then(function(result) { + expect(result).toBeFalsy(); + }); + }); + + pit('must return true for a directory', function() { + return fs.exists('folder') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + pit('must return true for a deep file', function() { + return fs.exists('folder/test.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + pit('must return true for a deep file (2)', function() { + return fs.exists('folder/folder2/hello.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + }); + + describe('readAsString', function() { + pit('must return content for a file', function() { + return fs.readAsString('README.md') + .then(function(result) { + expect(result).toBe('Hello World'); + }); + }); + + pit('must return content for a deep file', function() { + return fs.readAsString('folder/test.md') + .then(function(result) { + expect(result).toBe('Cool'); + }); + }); + }); + + describe('readDir', function() { + pit('must return content for a directory', function() { + return fs.readDir('./') + .then(function(files) { + expect(files.size).toBe(3); + expect(files.includes('README.md')).toBeTruthy(); + expect(files.includes('SUMMARY.md')).toBeTruthy(); + expect(files.includes('folder/')).toBeTruthy(); + }); + }); + }); +}); + + diff --git a/lib/fs/index.js b/lib/fs/index.js deleted file mode 100644 index 8a3ca1e..0000000 --- a/lib/fs/index.js +++ /dev/null @@ -1,106 +0,0 @@ -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/mock.js b/lib/fs/mock.js new file mode 100644 index 0000000..2149e1d --- /dev/null +++ b/lib/fs/mock.js @@ -0,0 +1,95 @@ +var path = require('path'); +var is = require('is'); +var Buffer = require('buffer').Buffer; +var Immutable = require('immutable'); + +var FS = require('../models/fs'); +var error = require('../utils/error'); + +/** + Create a fake filesystem for unit testing GitBook. + + @param {Map<String:String|Map>} +*/ +function createMockFS(files) { + files = Immutable.fromJS(files); + var mtime = new Date(); + + function getFile(filePath) { + var parts = path.normalize(filePath).split('/'); + return parts.reduce(function(list, part, i) { + if (!list) return null; + + var file; + + if (!part || part === '.') file = list; + else file = list.get(part); + + if (!file) return null; + + if (is.string(file)) { + if (i === (parts.length - 1)) return file; + else return null; + } + + return file; + }, files); + } + + function fsExists(filePath) { + return Boolean(getFile(filePath) !== null); + } + + function fsReadFile(filePath) { + var file = getFile(filePath); + if (!is.string(file)) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return new Buffer(file, 'utf8'); + } + + function fsStatFile(filePath) { + var file = getFile(filePath); + if (!file) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return { + mtime: mtime + }; + } + + function fsReadDir(filePath) { + var dir = getFile(filePath); + if (!dir || is.string(dir)) { + throw error.FileNotFoundError({ + filename: filePath + }); + } + + return dir + .map(function(content, name) { + if (!is.string(content)) { + name = name + '/'; + } + + return name; + }) + .valueSeq(); + } + + return FS.create({ + root: '', + fsExists: fsExists, + fsReadFile: fsReadFile, + fsStatFile: fsStatFile, + fsReadDir: fsReadDir + }); +} + +module.exports = createMockFS; diff --git a/lib/fs/node.js b/lib/fs/node.js index fc2517e..e05cb65 100644 --- a/lib/fs/node.js +++ b/lib/fs/node.js @@ -1,36 +1,15 @@ -var _ = require('lodash'); -var util = require('util'); var path = require('path'); +var Immutable = require('immutable'); var fs = require('../utils/fs'); -var Promise = require('../utils/promise'); -var BaseFS = require('./'); +var FS = require('../models/fs'); -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) { +function fsReadDir(folder) { return fs.readdir(folder) .then(function(files) { - return _.chain(files) + files = Immutable.List(files); + + return files .map(function(file) { if (file == '.' || file == '..') return; @@ -38,29 +17,24 @@ NodeFS.prototype.readdir = function(folder) { if (stat.isDirectory()) file = file + path.sep; return file; }) - .compact() - .value(); + .filter(function(file) { + return Boolean(file); + }); }); -}; - -// Load a JSON/JS file -NodeFS.prototype.loadAsObject = function(filename) { - return Promise() - .then(function() { - var jsFile; +} - try { - jsFile = require.resolve(filename); +function fsLoadObject(filename) { + return require(filename); +} - // Invalidate node.js cache for livreloading - delete require.cache[jsFile]; +module.exports = function createNodeFS(root) { + return FS.create({ + root: root, - return require(jsFile); - } - catch(err) { - return Promise.reject(err); - } + fsExists: fs.exists, + fsReadFile: fs.readFile, + fsStatFile: fs.stat, + fsReadDir: fsReadDir, + fsLoadObject: fsLoadObject }); }; - -module.exports = NodeFS; diff --git a/lib/gitbook.js b/lib/gitbook.js index 54513c1..bafd3b8 100644 --- a/lib/gitbook.js +++ b/lib/gitbook.js @@ -6,8 +6,13 @@ var VERSION_STABLE = VERSION.replace(/\-(\S+)/g, ''); 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) +/** + 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) + + @param {String} condition + @return {Boolean} +*/ function satisfies(condition) { // Test with real version if (semver.satisfies(VERSION, condition)) return true; @@ -16,18 +21,8 @@ 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 = { version: pkg.version, satisfies: satisfies, - getContext: getContext + START_TIME: START_TIME }; diff --git a/lib/index.js b/lib/index.js index fdad6ee..1f683e2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,10 @@ -var Book = require('./book'); -var cli = require('./cli'); +var extend = require('extend'); -module.exports = { - Book: Book, - commands: cli.commands -}; +var common = require('./browser'); + +module.exports = extend({ + initBook: require('./init'), + createNodeFS: require('./fs/node'), + Output: require('./output'), + commands: require('./cli') +}, common); diff --git a/lib/init.js b/lib/init.js index b7bb7f5..3e3cdca 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,66 +1,79 @@ var path = require('path'); +var createNodeFS = require('./fs/node'); var fs = require('./utils/fs'); var Promise = require('./utils/promise'); +var File = require('./models/file'); +var Readme = require('./models/readme'); +var Book = require('./models/book'); +var Parse = require('./parse'); -// Initialize folder structure for a book -// Read SUMMARY to created the right chapter -function initBook(book) { - var extensionToUse = '.md'; +/** + Initialize folder structure for a book + Read SUMMARY to created the right chapter - book.log.info.ln('init book at', book.root); - return fs.mkdirp(book.root) - .then(function() { - return book.config.load(); - }) - .then(function() { - book.log.info.ln('detect structure from SUMMARY (if it exists)'); - return book.summary.load(); - }) - .then(function() { - var summary = book.summary.path || 'SUMMARY.md'; - var articles = book.summary.flatten(); + @param {Book} + @param {String} + @return {Promise} +*/ +function initBook(rootFolder) { + var extension = '.md'; + + return fs.mkdirp(rootFolder) - // Use extension of summary - extensionToUse = path.extname(summary); + // Parse the summary and readme + .then(function() { + var fs = createNodeFS(rootFolder); + var book = Book.createForFS(fs); - // Readme doesn't have a path - if (!articles[0].path) { - articles[0].path = 'README' + extensionToUse; - } + return Parse.parseReadme(book) - // Summary doesn't exists? create one - if (!book.summary.path) { - articles.push({ - title: 'Summary', - path: 'SUMMARY'+extensionToUse - }); - } + // Setup default readme if doesn't found one + .fail(function() { + var readmeFile = File.createWithFilepath('README' + extension); + var readme = Readme.create(readmeFile); + return book.setReadme(readme); + }); + }) + .then(Parse.parseSummary) - // Create files that don't exist - return Promise.serie(articles, function(article) { - if (!article.path) return; + .then(function(book) { + var logger = book.getLogger(); + var summary = book.getSummary(); + var summaryFile = summary.getFile(); + var summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension); - var absolutePath = book.resolve(article.path); + var articles = summary.getArticlesAsList(); - return fs.exists(absolutePath) - .then(function(exists) { - if(exists) { - book.log.info.ln('found', article.path); - return; - } else { - book.log.info.ln('create', article.path); - } + // Write pages + return Promise.forEach(articles, function(article) { + var filePath = path.join(rootFolder, article.getPath()); + if (!filePath) return; - return fs.mkdirp(path.dirname(absolutePath)) + return fs.assertFile(filePath, function() { + return fs.ensureFile(filePath) .then(function() { - return fs.writeFile(absolutePath, '# '+article.title+'\n\n'); + logger.info.ln('create', article.getPath()); + return fs.writeFile(filePath, '# ' + article.getTitle() + '\n\n'); }); }); + }) + + // Write summary + .then(function() { + var filePath = path.join(rootFolder, summaryFilename); + + return fs.ensureFile(filePath) + .then(function() { + logger.info.ln('create ' + path.basename(filePath)); + return fs.writeFile(filePath, summary.toText(extension)); + }); + }) + + // Log end + .then(function() { + logger.info.ln('initialization is finished'); }); - }) - .then(function() { - book.log.info.ln('initialization is finished'); }); } diff --git a/lib/json/encodeBook.js b/lib/json/encodeBook.js new file mode 100644 index 0000000..9bcb6ee --- /dev/null +++ b/lib/json/encodeBook.js @@ -0,0 +1,35 @@ +var extend = require('extend'); + +var gitbook = require('../gitbook'); +var encodeSummary = require('./encodeSummary'); +var encodeGlossary = require('./encodeGlossary'); +var encodeReadme = require('./encodeReadme'); + +/** + Encode a book to JSON + + @param {Book} + @return {Object} +*/ +function encodeBookToJson(book) { + var config = book.getConfig(); + var language = book.getLanguage(); + + var variables = config.getValue('variables', {}); + + return { + summary: encodeSummary(book.getSummary()), + glossary: encodeGlossary(book.getGlossary()), + readme: encodeReadme(book.getReadme()), + config: book.getConfig().getValues().toJS(), + gitbook: { + version: gitbook.version, + time: gitbook.START_TIME + }, + book: extend({ + language: language? language : undefined + }, variables.toJS()) + }; +} + +module.exports = encodeBookToJson; diff --git a/lib/json/encodeBookWithPage.js b/lib/json/encodeBookWithPage.js new file mode 100644 index 0000000..5600a82 --- /dev/null +++ b/lib/json/encodeBookWithPage.js @@ -0,0 +1,22 @@ +var encodeBook = require('./encodeBook'); +var encodePage = require('./encodePage'); +var encodeFile = require('./encodeFile'); + +/** + Return a JSON representation of a book with a specific file + + @param {Book} output + @param {Page} page + @return {Object} +*/ +function encodeBookWithPage(book, page) { + var file = page.getFile(); + + var result = encodeBook(book); + result.page = encodePage(page, book.getSummary()); + result.file = encodeFile(file); + + return result; +} + +module.exports = encodeBookWithPage; diff --git a/lib/json/encodeFile.js b/lib/json/encodeFile.js new file mode 100644 index 0000000..d2c9e8a --- /dev/null +++ b/lib/json/encodeFile.js @@ -0,0 +1,21 @@ + +/** + Return a JSON representation of a file + + @param {File} file + @return {Object} +*/ +function encodeFileToJson(file) { + var filePath = file.getPath(); + if (!filePath) { + return undefined; + } + + return { + path: filePath, + mtime: file.getMTime(), + type: file.getType() + }; +} + +module.exports = encodeFileToJson; diff --git a/lib/json/encodeGlossary.js b/lib/json/encodeGlossary.js new file mode 100644 index 0000000..e9bcfc9 --- /dev/null +++ b/lib/json/encodeGlossary.js @@ -0,0 +1,21 @@ +var encodeFile = require('./encodeFile'); +var encodeGlossaryEntry = require('./encodeGlossaryEntry'); + +/** + Encode a glossary to JSON + + @param {Glossary} + @return {Object} +*/ +function encodeGlossary(glossary) { + var file = glossary.getFile(); + var entries = glossary.getEntries(); + + return { + file: encodeFile(file), + entries: entries + .map(encodeGlossaryEntry).toJS() + }; +} + +module.exports = encodeGlossary; diff --git a/lib/json/encodeGlossaryEntry.js b/lib/json/encodeGlossaryEntry.js new file mode 100644 index 0000000..d163f45 --- /dev/null +++ b/lib/json/encodeGlossaryEntry.js @@ -0,0 +1,16 @@ + +/** + Encode a SummaryArticle to JSON + + @param {GlossaryEntry} + @return {Object} +*/ +function encodeGlossaryEntry(entry) { + return { + id: entry.getID(), + name: entry.getName(), + description: entry.getDescription() + }; +} + +module.exports = encodeGlossaryEntry; diff --git a/lib/json/encodeOutput.js b/lib/json/encodeOutput.js new file mode 100644 index 0000000..9054124 --- /dev/null +++ b/lib/json/encodeOutput.js @@ -0,0 +1,25 @@ +var encodeBook = require('./encodeBook'); + +/** + Encode an output to JSON + + @param {Output} + @return {Object} +*/ +function encodeOutputToJson(output) { + var book = output.getBook(); + var generator = output.getGenerator(); + var options = output.getOptions(); + + var result = encodeBook(book); + + result.output = { + name: generator + }; + + result.options = options.toJS(); + + return result; +} + +module.exports = encodeOutputToJson; diff --git a/lib/json/encodePage.js b/lib/json/encodePage.js new file mode 100644 index 0000000..be92117 --- /dev/null +++ b/lib/json/encodePage.js @@ -0,0 +1,39 @@ +var encodeSummaryArticle = require('./encodeSummaryArticle'); + +/** + Return a JSON representation of a page + + @param {Page} page + @param {Summary} summary + @return {Object} +*/ +function encodePage(page, summary) { + var file = page.getFile(); + var attributes = page.getAttributes(); + var article = summary.getByPath(file.getPath()); + + var result = attributes.toJS(); + + if (article) { + result.title = article.getTitle(); + result.level = article.getLevel(); + result.depth = article.getDepth(); + + var nextArticle = summary.getNextArticle(article); + if (nextArticle) { + result.next = encodeSummaryArticle(nextArticle); + } + + var prevArticle = summary.getPrevArticle(article); + if (prevArticle) { + result.previous = encodeSummaryArticle(prevArticle); + } + } + + result.content = page.getContent(); + result.dir = page.getDir(); + + return result; +} + +module.exports = encodePage; diff --git a/lib/json/encodeReadme.js b/lib/json/encodeReadme.js new file mode 100644 index 0000000..96176a3 --- /dev/null +++ b/lib/json/encodeReadme.js @@ -0,0 +1,17 @@ +var encodeFile = require('./encodeFile'); + +/** + Encode a readme to JSON + + @param {Readme} + @return {Object} +*/ +function encodeReadme(readme) { + var file = readme.getFile(); + + return { + file: encodeFile(file) + }; +} + +module.exports = encodeReadme; diff --git a/lib/json/encodeSummary.js b/lib/json/encodeSummary.js new file mode 100644 index 0000000..97db910 --- /dev/null +++ b/lib/json/encodeSummary.js @@ -0,0 +1,20 @@ +var encodeFile = require('./encodeFile'); +var encodeSummaryPart = require('./encodeSummaryPart'); + +/** + Encode a summary to JSON + + @param {Summary} + @return {Object} +*/ +function encodeSummary(summary) { + var file = summary.getFile(); + var parts = summary.getParts(); + + return { + file: encodeFile(file), + parts: parts.map(encodeSummaryPart).toJS() + }; +} + +module.exports = encodeSummary; diff --git a/lib/json/encodeSummaryArticle.js b/lib/json/encodeSummaryArticle.js new file mode 100644 index 0000000..987e44a --- /dev/null +++ b/lib/json/encodeSummaryArticle.js @@ -0,0 +1,27 @@ + +/** + Encode a SummaryArticle to JSON + + @param {SummaryArticle} + @return {Object} +*/ +function encodeSummaryArticle(article, recursive) { + var articles = undefined; + if (recursive !== false) { + articles = article.getArticles() + .map(encodeSummaryArticle) + .toJS(); + } + + return { + title: article.getTitle(), + level: article.getLevel(), + depth: article.getDepth(), + anchor: article.getAnchor(), + url: article.getUrl(), + path: article.getPath(), + articles: articles + }; +} + +module.exports = encodeSummaryArticle; diff --git a/lib/json/encodeSummaryPart.js b/lib/json/encodeSummaryPart.js new file mode 100644 index 0000000..a5e7218 --- /dev/null +++ b/lib/json/encodeSummaryPart.js @@ -0,0 +1,17 @@ +var encodeSummaryArticle = require('./encodeSummaryArticle'); + +/** + Encode a SummaryPart to JSON + + @param {SummaryPart} + @return {Object} +*/ +function encodeSummaryPart(part) { + return { + title: part.getTitle(), + articles: part.getArticles() + .map(encodeSummaryArticle).toJS() + }; +} + +module.exports = encodeSummaryPart; diff --git a/lib/json/index.js b/lib/json/index.js new file mode 100644 index 0000000..39cac99 --- /dev/null +++ b/lib/json/index.js @@ -0,0 +1,11 @@ + +module.exports = { + encodeOutput: require('./encodeOutput'), + encodeBookWithPage: require('./encodeBookWithPage'), + encodeBook: require('./encodeBook'), + encodeFile: require('./encodeFile'), + encodePage: require('./encodePage'), + encodeSummary: require('./encodeSummary'), + encodeSummaryArticle: require('./encodeSummaryArticle'), + encodeReadme: require('./encodeReadme') +}; diff --git a/lib/models/__tests__/config.js b/lib/models/__tests__/config.js new file mode 100644 index 0000000..8445cef --- /dev/null +++ b/lib/models/__tests__/config.js @@ -0,0 +1,63 @@ +jest.autoMockOff(); + +var Immutable = require('immutable'); + +describe('Config', function() { + var Config = require('../config'); + + var config = Config.createWithValues({ + hello: { + world: 1, + test: 'Hello', + isFalse: false + } + }); + + describe('getValue', function() { + it('must return value as immutable', function() { + var value = config.getValue('hello'); + expect(Immutable.Map.isMap(value)).toBeTruthy(); + }); + + it('must return deep value', function() { + var value = config.getValue('hello.world'); + expect(value).toBe(1); + }); + + it('must return default value if non existant', function() { + var value = config.getValue('hello.nonExistant', 'defaultValue'); + expect(value).toBe('defaultValue'); + }); + + it('must not return default value for falsy values', function() { + var value = config.getValue('hello.isFalse', 'defaultValue'); + expect(value).toBe(false); + }); + }); + + describe('setValue', function() { + it('must set value as immutable', function() { + var testConfig = config.setValue('hello', { + 'cool': 1 + }); + var value = testConfig.getValue('hello'); + + expect(Immutable.Map.isMap(value)).toBeTruthy(); + expect(value.size).toBe(1); + expect(value.has('cool')).toBeTruthy(); + }); + + it('must set deep value', function() { + var testConfig = config.setValue('hello.world', 2); + var hello = testConfig.getValue('hello'); + var world = testConfig.getValue('hello.world'); + + expect(Immutable.Map.isMap(hello)).toBeTruthy(); + expect(hello.size).toBe(3); + + expect(world).toBe(2); + }); + }); +}); + + diff --git a/lib/models/__tests__/glossary.js b/lib/models/__tests__/glossary.js new file mode 100644 index 0000000..2ce224c --- /dev/null +++ b/lib/models/__tests__/glossary.js @@ -0,0 +1,42 @@ +jest.autoMockOff(); + +describe('Glossary', function() { + var File = require('../file'); + var Glossary = require('../glossary'); + var GlossaryEntry = require('../glossaryEntry'); + + var glossary = Glossary.createFromEntries(File(), [ + { + name: 'Hello World', + description: 'Awesome!' + }, + { + name: 'JavaScript', + description: 'This is a cool language' + } + ]); + + describe('createFromEntries', function() { + it('must add all entries', function() { + var entries = glossary.getEntries(); + expect(entries.size).toBe(2); + }); + + it('must add entries as GlossaryEntries', function() { + var entries = glossary.getEntries(); + var entry = entries.get('hello-world'); + expect(entry instanceof GlossaryEntry).toBeTruthy(); + }); + }); + + describe('toText', function() { + pit('return as markdown', function() { + return glossary.toText('.md') + .then(function(text) { + expect(text).toContain('# Glossary'); + }); + }); + }); +}); + + diff --git a/lib/models/__tests__/glossaryEntry.js b/lib/models/__tests__/glossaryEntry.js new file mode 100644 index 0000000..9eabc68 --- /dev/null +++ b/lib/models/__tests__/glossaryEntry.js @@ -0,0 +1,17 @@ +jest.autoMockOff(); + +describe('GlossaryEntry', function() { + var GlossaryEntry = require('../glossaryEntry'); + + describe('getID', function() { + it('must return a normalized ID', function() { + var entry = new GlossaryEntry({ + name: 'Hello World' + }); + + expect(entry.getID()).toBe('hello-world'); + }); + }); +}); + + diff --git a/lib/models/__tests__/plugin.js b/lib/models/__tests__/plugin.js new file mode 100644 index 0000000..81d9d51 --- /dev/null +++ b/lib/models/__tests__/plugin.js @@ -0,0 +1,29 @@ +jest.autoMockOff(); + +describe('Plugin', function() { + var Plugin = require('../plugin'); + + describe('createFromString', function() { + it('must parse name', function() { + var plugin = Plugin.createFromString('hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('*'); + }); + + it('must parse version', function() { + var plugin = Plugin.createFromString('hello@1.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('1.0.0'); + }); + }); + + describe('isLoaded', function() { + it('must return false for empty plugin', function() { + var plugin = Plugin.createFromString('hello'); + expect(plugin.isLoaded()).toBe(false); + }); + + }); +}); + + diff --git a/lib/models/__tests__/summary.js b/lib/models/__tests__/summary.js new file mode 100644 index 0000000..ad040cf --- /dev/null +++ b/lib/models/__tests__/summary.js @@ -0,0 +1,81 @@ + +describe('Summary', function() { + var File = require('../file'); + var Summary = require('../summary'); + + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: 'My First Article', + path: 'README.md' + }, + { + title: 'My Second Article', + path: 'article.md' + } + ] + }, + { + title: 'Test' + } + ]); + + describe('createFromEntries', function() { + it('must add all parts', function() { + var parts = summary.getParts(); + expect(parts.size).toBe(2); + }); + }); + + describe('getByLevel', function() { + it('can return a Part', function() { + var part = summary.getByLevel('1'); + + expect(part).toBeDefined(); + expect(part.getArticles().size).toBe(2); + }); + + it('can return a Part (2)', function() { + var part = summary.getByLevel('2'); + + expect(part).toBeDefined(); + expect(part.getTitle()).toBe('Test'); + expect(part.getArticles().size).toBe(0); + }); + + it('can return an Article', function() { + var article = summary.getByLevel('1.1'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My First Article'); + }); + }); + + describe('getByPath', function() { + it('return correct article', function() { + var article = summary.getByPath('README.md'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My First Article'); + }); + + it('return correct article', function() { + var article = summary.getByPath('article.md'); + + expect(article).toBeDefined(); + expect(article.getTitle()).toBe('My Second Article'); + }); + }); + + describe('toText', function() { + pit('return as markdown', function() { + return summary.toText('.md') + .then(function(text) { + expect(text).toContain('# Summary'); + }); + }); + }); +}); + + diff --git a/lib/models/__tests__/templateBlock.js b/lib/models/__tests__/templateBlock.js new file mode 100644 index 0000000..44d53de --- /dev/null +++ b/lib/models/__tests__/templateBlock.js @@ -0,0 +1,106 @@ +var nunjucks = require('nunjucks'); +var Immutable = require('immutable'); +var Promise = require('../../utils/promise'); + +describe('TemplateBlock', function() { + var TemplateBlock = require('../templateBlock'); + + describe('create', function() { + pit('must initialize a simple TemplateBlock from a function', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return '<p>Hello, World!</p>'; + }); + + // Check basic templateBlock properties + expect(templateBlock.getName()).toBe('sayhello'); + expect(templateBlock.getPost()).toBeNull(); + expect(templateBlock.getParse()).toBeTruthy(); + expect(templateBlock.getEndTag()).toBe('endsayhello'); + expect(templateBlock.getBlocks().size).toBe(0); + expect(templateBlock.getShortcuts().size).toBe(0); + expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension'); + + // Check result of applying block + return Promise() + .then(function() { + return templateBlock.applyBlock(); + }) + .then(function(result) { + expect(result.name).toBe('sayhello'); + expect(result.body).toBe('<p>Hello, World!</p>'); + }); + }); + }); + + describe('toNunjucksExt()', function() { + pit('must create a valid nunjucks extension', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return '<p>Hello, World!</p>'; + }); + + // Create a fresh Nunjucks environment + var env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + var Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + var src = '{% sayhello %}{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<p>Hello, World!</p>'); + }); + }); + + pit('must apply block arguments correctly', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return '<'+block.kwargs.tag+'>Hello, '+block.kwargs.name+'!</'+block.kwargs.tag+'>'; + }); + + // Create a fresh Nunjucks environment + var env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + var Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + var src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<p>Hello, Samy!</p>'); + }); + }); + + pit('must handle nested blocks', function() { + var templateBlock = new TemplateBlock({ + name: 'yoda', + blocks: Immutable.List(['start', 'end']), + process: function(block) { + var nested = {}; + + block.blocks.forEach(function(blk) { + nested[blk.name] = blk.body.trim(); + }); + + return '<p class="yoda">'+nested.end+' '+nested.start+'</p>'; + } + }); + + // Create a fresh Nunjucks environment + var env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + var Ext = templateBlock.toNunjucksExt(); + env.addExtension(templateBlock.getExtensionName(), new Ext()); + + // Render a template using the block + var src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('<p class="yoda">inverted this sentence should be</p>'); + }); + }); + }); +});
\ No newline at end of file diff --git a/lib/models/__tests__/templateEngine.js b/lib/models/__tests__/templateEngine.js new file mode 100644 index 0000000..6f18b18 --- /dev/null +++ b/lib/models/__tests__/templateEngine.js @@ -0,0 +1,51 @@ + +describe('TemplateBlock', function() { + var TemplateEngine = require('../templateEngine'); + + describe('create', function() { + it('must initialize with a list of filters', function() { + var engine = TemplateEngine.create({ + filters: { + hello: function(name) { + return 'Hello ' + name + '!'; + } + } + }); + var env = engine.toNunjucks(); + var res = env.renderString('{{ "Luke"|hello }}'); + + expect(res).toBe('Hello Luke!'); + }); + + it('must initialize with a list of globals', function() { + var engine = TemplateEngine.create({ + globals: { + hello: function(name) { + return 'Hello ' + name + '!'; + } + } + }); + var env = engine.toNunjucks(); + var res = env.renderString('{{ hello("Luke") }}'); + + expect(res).toBe('Hello Luke!'); + }); + + it('must pass context to filters and blocks', function() { + var engine = TemplateEngine.create({ + filters: { + hello: function(name) { + return 'Hello ' + name + ' ' + this.lastName + '!'; + } + }, + context: { + lastName: 'Skywalker' + } + }); + var env = engine.toNunjucks(); + var res = env.renderString('{{ "Luke"|hello }}'); + + expect(res).toBe('Hello Luke Skywalker!'); + }); + }); +});
\ No newline at end of file diff --git a/lib/models/book.js b/lib/models/book.js new file mode 100644 index 0000000..f960df1 --- /dev/null +++ b/lib/models/book.js @@ -0,0 +1,258 @@ +var path = require('path'); +var Immutable = require('immutable'); +var Ignore = require('ignore'); + +var Logger = require('../utils/logger'); + +var FS = require('./fs'); +var Config = require('./config'); +var Readme = require('./readme'); +var Summary = require('./summary'); +var Glossary = require('./glossary'); +var Languages = require('./languages'); + + +var Book = Immutable.Record({ + // Logger for outptu message + logger: Logger(), + + // Filesystem binded to the book scope to read files/directories + fs: FS(), + + // Ignore files parser + ignore: Ignore(), + + // Structure files + config: Config(), + readme: Readme(), + summary: Summary(), + glossary: Glossary(), + languages: Languages(), + + // ID of the language for language books + language: String(), + + // List of children, if multilingual (String -> Book) + books: Immutable.OrderedMap() +}); + +Book.prototype.getLogger = function() { + return this.get('logger'); +}; + +Book.prototype.getFS = function() { + return this.get('fs'); +}; + +Book.prototype.getIgnore = function() { + return this.get('ignore'); +}; + +Book.prototype.getConfig = function() { + return this.get('config'); +}; + +Book.prototype.getReadme = function() { + return this.get('readme'); +}; + +Book.prototype.getSummary = function() { + return this.get('summary'); +}; + +Book.prototype.getGlossary = function() { + return this.get('glossary'); +}; + +Book.prototype.getLanguages = function() { + return this.get('languages'); +}; + +Book.prototype.getBooks = function() { + return this.get('books'); +}; + +Book.prototype.getLanguage = function() { + return this.get('language'); +}; + +/** + Return FS instance to access the content + + @return {FS} +*/ +Book.prototype.getContentFS = function() { + var fs = this.getFS(); + var config = this.getConfig(); + var rootFolder = config.getValue('root'); + + if (rootFolder) { + return FS.reduceScope(fs, rootFolder); + } + + return fs; +}; + +/** + Return root of the book + + @return {String} +*/ +Book.prototype.getRoot = function() { + var fs = this.getFS(); + return fs.getRoot(); +}; + +/** + Return root for content of the book + + @return {String} +*/ +Book.prototype.getContentRoot = function() { + var fs = this.getContentFS(); + return fs.getRoot(); +}; + +/** + Check if a file is ignore (should not being parsed, etc) + + @param {String} ref + @return {Page|undefined} +*/ +Book.prototype.isFileIgnored = function(filename) { + var ignore = this.getIgnore(); + var language = this.getLanguage(); + + // Ignore is always relative to the root of the main book + if (language) { + filename = path.join(language, filename); + } + + + return ignore.filter([filename]).length == 0; +}; + +/** + Check if a content file is ignore (should not being parsed, etc) + + @param {String} ref + @return {Page|undefined} +*/ +Book.prototype.isContentFileIgnored = function(filename) { + var config = this.getConfig(); + var rootFolder = config.getValue('root'); + + if (rootFolder) { + filename = path.join(rootFolder, filename); + } + + return this.isFileIgnored(filename); +}; + +/** + Return a page from a book by its path + + @param {String} ref + @return {Page|undefined} +*/ +Book.prototype.getPage = function(ref) { + return this.getPages().get(ref); +}; + +/** + Is this book the parent of language's books + + @return {Boolean} +*/ +Book.prototype.isMultilingual = function() { + return (this.getLanguages().getCount() > 0); +}; + +/** + Return true if book is associated to a language + + @return {Boolean} +*/ +Book.prototype.isLanguageBook = function() { + return Boolean(this.getLanguage()); +}; + +/** + Add a new language book + + @param {String} language + @param {Book} book + @return {Book} +*/ +Book.prototype.addLanguageBook = function(language, book) { + var books = this.getBooks(); + books = books.set(language, book); + + return this.set('books', books); +}; + +/** + Set the summary for this book + + @param {Summary} + @return {Book} +*/ +Book.prototype.setSummary = function(summary) { + return this.set('summary', summary); +}; + +/** + Set the readme for this book + + @param {Readme} + @return {Book} +*/ +Book.prototype.setReadme = function(readme) { + return this.set('readme', readme); +}; + +/** + Change log level + + @param {String} level + @return {Book} +*/ +Book.prototype.setLogLevel = function(level) { + this.getLogger().setLevel(level); + return this; +}; + +/** + Create a book using a filesystem + + @param {FS} fs + @return {Book} +*/ +Book.createForFS = function createForFS(fs) { + return new Book({ + fs: fs + }); +}; + +/** + Create a language book from a parent + + @param {Book} parent + @param {String} language + @return {Book} +*/ +Book.createFromParent = function createFromParent(parent, language) { + var ignore = parent.getIgnore(); + + return new Book({ + // Inherits config. logegr and list of ignored files + logger: parent.getLogger(), + config: parent.getConfig(), + ignore: Ignore().add(ignore), + + language: language, + fs: FS.reduceScope(parent.getContentFS(), language) + }); +}; + +module.exports = Book; diff --git a/lib/models/config.js b/lib/models/config.js new file mode 100644 index 0000000..6ee03e4 --- /dev/null +++ b/lib/models/config.js @@ -0,0 +1,106 @@ +var is = require('is'); +var Immutable = require('immutable'); + +var File = require('./file'); +var configDefault = require('../constants/configDefault'); + +var Config = Immutable.Record({ + file: File(), + values: configDefault +}, 'Config'); + +Config.prototype.getFile = function() { + return this.get('file'); +}; + +Config.prototype.getValues = function() { + return this.get('values'); +}; + +/** + Return a configuration value by its key path + + @param {String} key + @return {Mixed} +*/ +Config.prototype.getValue = function(keyPath, def) { + var values = this.getValues(); + keyPath = Config.keyToKeyPath(keyPath); + + if (!values.hasIn(keyPath)) { + return Immutable.fromJS(def); + } + + return values.getIn(keyPath); +}; + +/** + Update a configuration value + + @param {String} key + @param {Mixed} value + @return {Mixed} +*/ +Config.prototype.setValue = function(keyPath, value) { + keyPath = Config.keyToKeyPath(keyPath); + + value = Immutable.fromJS(value); + + var values = this.getValues(); + values = values.setIn(keyPath, value); + + return this.set('values', values); +}; + +/** + Create a new config for a file + + @param {File} file + @param {Object} values + @returns {Config} +*/ +Config.create = function(file, values) { + return new Config({ + file: file, + values: Immutable.fromJS(values) + }); +}; + +/** + Create a new config + + @param {Object} values + @returns {Config} +*/ +Config.createWithValues = function(values) { + return new Config({ + values: Immutable.fromJS(values) + }); +}; + +/** + Update values for an existing configuration + + @param {Config} config + @param {Object} values + @returns {Config} +*/ +Config.updateValues = function(config, values) { + values = Immutable.fromJS(values); + + return config.set('values', values); +}; + + +/** + Convert a keyPath to an array of keys + + @param {String|Array} + @return {Array} +*/ +Config.keyToKeyPath = function(keyPath) { + if (is.string(keyPath)) keyPath = keyPath.split('.'); + return keyPath; +}; + +module.exports = Config; diff --git a/lib/models/file.js b/lib/models/file.js new file mode 100644 index 0000000..d1726a7 --- /dev/null +++ b/lib/models/file.js @@ -0,0 +1,89 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var parsers = require('../parsers'); + +var File = Immutable.Record({ + // Path of the file, relative to the FS + path: String(), + + // Time when file data last modified + mtime: Date() +}); + +File.prototype.getPath = function() { + return this.get('path'); +}; + +File.prototype.getMTime = function() { + return this.get('mtime'); +}; + +/** + Does the file exists / is set + + @return {Boolean} +*/ +File.prototype.exists = function() { + return Boolean(this.getPath()); +}; + +/** + Return type of file ('markdown' or 'asciidoc') + + @return {String} +*/ +File.prototype.getType = function() { + var parser = this.getParser(); + if (parser) { + return parser.name; + } else { + return undefined; + } +}; + +/** + Return extension of this file (lowercased) + + @return {String} +*/ +File.prototype.getExtension = function() { + return path.extname(this.getPath()).toLowerCase(); +}; + +/** + Return parser for this file + + @return {Parser} +*/ +File.prototype.getParser = function() { + return parsers.getByExt(this.getExtension()); +}; + +/** + Create a file from stats informations + + @param {String} filepath + @param {Object|fs.Stats} stat + @return {File} +*/ +File.createFromStat = function createFromStat(filepath, stat) { + return new File({ + path: filepath, + mtime: stat.mtime + }); +}; + +/** + Create a file with only a path + + @param {String} filepath + @return {File} +*/ +File.createWithFilepath = function createWithFilepath(filepath) { + return new File({ + path: filepath + }); +}; + +module.exports = File; diff --git a/lib/models/fs.js b/lib/models/fs.js new file mode 100644 index 0000000..ab65dd5 --- /dev/null +++ b/lib/models/fs.js @@ -0,0 +1,274 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var File = require('./file'); +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var PathUtil = require('../utils/path'); + +var FS = Immutable.Record({ + root: String(), + + fsExists: Function(), + fsReadFile: Function(), + fsStatFile: Function(), + fsReadDir: Function(), + fsLoadObject: null +}); + +/** + Return path to the root + + @return {String} +*/ +FS.prototype.getRoot = function() { + return this.get('root'); +}; + +/** + Verify that a file is in the fs scope + + @param {String} filename + @return {Boolean} +*/ +FS.prototype.isInScope = function(filename) { + var rootPath = this.getRoot(); + filename = path.join(rootPath, filename); + + return PathUtil.isInRoot(rootPath, filename); +}; + +/** + Resolve a file in this FS + + @param {String} + @return {String} +*/ +FS.prototype.resolve = function() { + var rootPath = this.getRoot(); + var args = Array.prototype.slice.call(arguments); + var filename = path.join.apply(path, [rootPath].concat(args)); + filename = path.normalize(filename); + + if (!this.isInScope(filename)) { + throw error.FileOutOfScopeError({ + filename: filename, + root: this.root + }); + } + + return filename; +}; + +/** + Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise + + @param {String} filename + @return {Promise<Boolean>} +*/ +FS.prototype.exists = function(filename) { + var that = this; + + return Promise() + .then(function() { + filename = that.resolve(filename); + var exists = that.get('fsExists'); + + return exists(filename); + }); +}; + +/** + Read a file and returns a promise with the content as a buffer + + @param {String} filename + @return {Promise<Buffer>} +*/ +FS.prototype.read = function(filename) { + var that = this; + + return Promise() + .then(function() { + filename = that.resolve(filename); + var read = that.get('fsReadFile'); + + return read(filename); + }); +}; + +/** + Read a file as a string (utf-8) + + @param {String} filename + @return {Promise<String>} +*/ +FS.prototype.readAsString = function(filename, encoding) { + encoding = encoding || 'utf8'; + + return this.read(filename) + .then(function(buf) { + return buf.toString(encoding); + }); +}; + +/** + Read stat infos about a file + + @param {String} filename + @return {Promise<File>} +*/ +FS.prototype.statFile = function(filename) { + var that = this; + + return Promise() + .then(function() { + var filepath = that.resolve(filename); + var stat = that.get('fsStatFile'); + + return stat(filepath); + }) + .then(function(stat) { + return File.createFromStat(filename, stat); + }); +}; + +/** + List files/directories in a directory. + Directories ends with '/' + + @param {String} dirname + @return {Promise<List<String>>} +*/ +FS.prototype.readDir = function(dirname) { + var that = this; + + return Promise() + .then(function() { + var dirpath = that.resolve(dirname); + var readDir = that.get('fsReadDir'); + + return readDir(dirpath); + }) + .then(function(files) { + return Immutable.List(files); + }); +}; + +/** + List only files in a diretcory + Directories ends with '/' + + @param {String} dirname + @return {Promise<List<String>>} +*/ +FS.prototype.listFiles = function(dirname) { + return this.readDir(dirname) + .then(function(files) { + return files.filterNot(pathIsFolder); + }); +}; + +/** + List all files in a directory + + @param {String} dirname + @return {Promise<List<String>>} +*/ +FS.prototype.listAllFiles = function(folder) { + var that = this; + folder = folder || '.'; + + return this.readDir(folder) + .then(function(files) { + return Promise.reduce(files, function(out, file) { + var isDirectory = pathIsFolder(file); + + if (!isDirectory) { + return out.push(path.join(folder, file)); + } + + return that.listAllFiles(path.join(folder, file)) + .then(function(inner) { + return out.concat(inner); + }); + }, Immutable.List()); + }); +}; + +/** + Find a file in a folder (case incensitive) + Return the found filename + + @param {String} dirname + @param {String} filename + @return {Promise<String>} +*/ +FS.prototype.findFile = function(dirname, filename) { + return this.listFiles(dirname) + .then(function(files) { + return files.find(function(file) { + return (file.toLowerCase() == filename.toLowerCase()); + }); + }); +}; + +/** + Load a JSON file + By default, fs only supports JSON + + @param {String} filename + @return {Promise<Object>} +*/ +FS.prototype.loadAsObject = function(filename) { + var that = this; + var fsLoadObject = this.get('fsLoadObject'); + + return this.exists(filename) + .then(function(exists) { + if (!exists) { + var err = new Error('Module doesn\'t exist'); + err.code = 'MODULE_NOT_FOUND'; + + throw err; + } + + if (fsLoadObject) { + return fsLoadObject(that.resolve(filename)); + } else { + return that.readAsString(filename) + .then(function(str) { + return JSON.parse(str); + }); + } + }); +}; + +/** + Create a FS instance + + @param {Object} def + @return {FS} +*/ +FS.create = function create(def) { + return new FS(def); +}; + +/** + Create a new FS instance with a reduced scope + + @param {FS} fs + @param {String} scope + @return {FS} +*/ +FS.reduceScope = function reduceScope(fs, scope) { + return fs.set('root', path.join(fs.getRoot(), scope)); +}; + + +// .readdir return files/folder as a list of string, folder ending with '/' +function pathIsFolder(filename) { + var lastChar = filename[filename.length - 1]; + return lastChar == '/' || lastChar == '\\'; +} + +module.exports = FS;
\ No newline at end of file diff --git a/lib/models/glossary.js b/lib/models/glossary.js new file mode 100644 index 0000000..bb4407d --- /dev/null +++ b/lib/models/glossary.js @@ -0,0 +1,109 @@ +var Immutable = require('immutable'); + +var error = require('../utils/error'); +var File = require('./file'); +var GlossaryEntry = require('./glossaryEntry'); +var parsers = require('../parsers'); + +var Glossary = Immutable.Record({ + file: File(), + entries: Immutable.OrderedMap() +}); + +Glossary.prototype.getFile = function() { + return this.get('file'); +}; + +Glossary.prototype.getEntries = function() { + return this.get('entries'); +}; + +/** + Return an entry by its name + + @param {String} name + @return {GlossaryEntry} +*/ +Glossary.prototype.getEntry = function(name) { + var entries = this.getEntries(); + var id = GlossaryEntry.nameToID(name); + + return entries.get(id); +}; + +/** + Render glossary as text + + @return {Promise<String>} +*/ +Glossary.prototype.toText = function(parser) { + var file = this.getFile(); + var entries = this.getEntries(); + + parser = parser? parsers.getByExt(parser) : file.getParser(); + + if (!parser) { + throw error.FileNotParsableError({ + filename: file.getPath() + }); + } + + return parser.glossary.toText(entries.toJS()); +}; + + +/** + Add/Replace an entry to a glossary + + @param {Glossary} glossary + @param {GlossaryEntry} entry + @return {Glossary} +*/ +Glossary.addEntry = function addEntry(glossary, entry) { + var id = entry.getID(); + var entries = glossary.getEntries(); + + entries = entries.set(id, entry); + return glossary.set('entries', entries); +}; + +/** + Add/Replace an entry to a glossary by name/description + + @param {Glossary} glossary + @param {GlossaryEntry} entry + @return {Glossary} +*/ +Glossary.addEntryByName = function addEntryByName(glossary, name, description) { + var entry = new GlossaryEntry({ + name: name, + description: description + }); + + return Glossary.addEntry(glossary, entry); +}; + +/** + Create a glossary from a list of entries + + @param {String} filename + @param {Array|List} entries + @return {Glossary} +*/ +Glossary.createFromEntries = function createFromEntries(file, entries) { + entries = entries.map(function(entry) { + if (!(entry instanceof GlossaryEntry)) { + entry = new GlossaryEntry(entry); + } + + return [entry.getID(), entry]; + }); + + return new Glossary({ + file: file, + entries: Immutable.OrderedMap(entries) + }); +}; + + +module.exports = Glossary; diff --git a/lib/models/glossaryEntry.js b/lib/models/glossaryEntry.js new file mode 100644 index 0000000..10791db --- /dev/null +++ b/lib/models/glossaryEntry.js @@ -0,0 +1,43 @@ +var Immutable = require('immutable'); +var slug = require('github-slugid'); + +/* + A definition represents an entry in the glossary +*/ + +var GlossaryEntry = Immutable.Record({ + name: String(), + description: String() +}); + +GlossaryEntry.prototype.getName = function() { + return this.get('name'); +}; + +GlossaryEntry.prototype.getDescription = function() { + return this.get('description'); +}; + + +/** + Get identifier for this entry + + @retrun {Boolean} +*/ +GlossaryEntry.prototype.getID = function() { + return GlossaryEntry.nameToID(this.getName()); +}; + + +/** + Normalize a glossary entry name into a unique id + + @param {String} + @return {String} +*/ +GlossaryEntry.nameToID = function nameToID(name) { + return slug(name); +}; + + +module.exports = GlossaryEntry; diff --git a/lib/models/language.js b/lib/models/language.js new file mode 100644 index 0000000..dcefbf6 --- /dev/null +++ b/lib/models/language.js @@ -0,0 +1,21 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Language = Immutable.Record({ + title: String(), + path: String() +}); + +Language.prototype.getTitle = function() { + return this.get('title'); +}; + +Language.prototype.getPath = function() { + return this.get('path'); +}; + +Language.prototype.getID = function() { + return path.basename(this.getPath()); +}; + +module.exports = Language; diff --git a/lib/models/languages.js b/lib/models/languages.js new file mode 100644 index 0000000..1e58d88 --- /dev/null +++ b/lib/models/languages.js @@ -0,0 +1,71 @@ +var Immutable = require('immutable'); + +var File = require('./file'); +var Language = require('./language'); + +var Languages = Immutable.Record({ + file: File(), + list: Immutable.OrderedMap() +}); + +Languages.prototype.getFile = function() { + return this.get('file'); +}; + +Languages.prototype.getList = function() { + return this.get('list'); +}; + +/** + Get default languages + + @return {Language} +*/ +Languages.prototype.getDefaultLanguage = function() { + return this.getList().first(); +}; + +/** + Get a language by its ID + + @param {String} lang + @return {Language} +*/ +Languages.prototype.getLanguage = function(lang) { + return this.getList().get(lang); +}; + +/** + Return count of langs + + @return {Number} +*/ +Languages.prototype.getCount = function() { + return this.getList().size; +}; + +/** + Create a languages list from a JS object + + @param {File} + @param {Array} + @return {Language} +*/ +Languages.createFromList = function(file, langs) { + var list = Immutable.OrderedMap(); + + langs.forEach(function(lang) { + lang = Language({ + title: lang.title, + path: lang.path + }); + list = list.set(lang.getID(), lang); + }); + + return Languages({ + file: file, + list: list + }); +}; + +module.exports = Languages; diff --git a/lib/models/output.js b/lib/models/output.js new file mode 100644 index 0000000..43e36f8 --- /dev/null +++ b/lib/models/output.js @@ -0,0 +1,93 @@ +var Immutable = require('immutable'); + +var Book = require('./book'); + +var Output = Immutable.Record({ + book: Book(), + + // Name of the generator being used + generator: String(), + + // Map of plugins to use (String -> Plugin) + plugins: Immutable.OrderedMap(), + + // Map pages to generation (String -> Page) + pages: Immutable.OrderedMap(), + + // List assets (String) + assets: Immutable.List(), + + // Option for the generation + options: Immutable.Map(), + + // Internal state for the generation + state: Immutable.Map() +}); + +Output.prototype.getBook = function() { + return this.get('book'); +}; + +Output.prototype.getGenerator = function() { + return this.get('generator'); +}; + +Output.prototype.getPlugins = function() { + return this.get('plugins'); +}; + +Output.prototype.getPages = function() { + return this.get('pages'); +}; + +Output.prototype.getOptions = function() { + return this.get('options'); +}; + +Output.prototype.getAssets = function() { + return this.get('assets'); +}; + +Output.prototype.getState = function() { + return this.get('state'); +}; + +/** + Get root folder for output + + @return {String} +*/ +Output.prototype.getRoot = function() { + return this.getOptions().get('root'); +}; + +/** + Update state of output + + @param {Map} newState + @return {Output} +*/ +Output.prototype.setState = function(newState) { + return this.set('state', newState); +}; + +/** + Update options + + @param {Map} newOptions + @return {Output} +*/ +Output.prototype.setOptions = function(newOptions) { + return this.set('options', newOptions); +}; + +/** + Return logegr for this output (same as book) + + @return {Logger} +*/ +Output.prototype.getLogger = function() { + return this.getBook().getLogger(); +}; + +module.exports = Output; diff --git a/lib/models/page.js b/lib/models/page.js new file mode 100644 index 0000000..ffb9601 --- /dev/null +++ b/lib/models/page.js @@ -0,0 +1,55 @@ +var Immutable = require('immutable'); + +var File = require('./file'); + +var Page = Immutable.Record({ + file: File(), + + // Attributes extracted from the YAML header + attributes: Immutable.Map(), + + // Content of the page + content: String(), + + // Direction of the text + dir: String('ltr') +}); + +Page.prototype.getFile = function() { + return this.get('file'); +}; + +Page.prototype.getAttributes = function() { + return this.get('attributes'); +}; + +Page.prototype.getContent = function() { + return this.get('content'); +}; + +Page.prototype.getDir = function() { + return this.get('dir'); +}; + +/** + Return path of the page + + @return {String} +*/ +Page.prototype.getPath = function() { + return this.getFile().getPath(); +}; + +/** + Create a page for a file + + @param {File} file + @return {Page} +*/ +Page.createForFile = function(file) { + return new Page({ + file: file + }); +}; + +module.exports = Page; diff --git a/lib/models/plugin.js b/lib/models/plugin.js new file mode 100644 index 0000000..dd7bc90 --- /dev/null +++ b/lib/models/plugin.js @@ -0,0 +1,152 @@ +var Immutable = require('immutable'); + +var TemplateBlock = require('./templateBlock'); +var PREFIX = require('../constants/pluginPrefix'); +var DEFAULT_VERSION = '*'; + +var Plugin = Immutable.Record({ + name: String(), + + // Requirement version (ex: ">1.0.0") + version: String(DEFAULT_VERSION), + + // Path to load this plugin + path: String(), + + // Depth of this plugin in the dependency tree + depth: Number(0), + + // Content of the "package.json" + package: Immutable.Map(), + + // Content of the package itself + content: Immutable.Map() +}, 'Plugin'); + +Plugin.prototype.getName = function() { + return this.get('name'); +}; + +Plugin.prototype.getPath = function() { + return this.get('path'); +}; + +Plugin.prototype.getVersion = function() { + return this.get('version'); +}; + +Plugin.prototype.getPackage = function() { + return this.get('package'); +}; + +Plugin.prototype.getContent = function() { + return this.get('content'); +}; + +Plugin.prototype.getDepth = function() { + return this.get('depth'); +}; + +/** + Return the ID on NPM for this plugin + + @return {String} +*/ +Plugin.prototype.getNpmID = function() { + return Plugin.nameToNpmID(this.getName()); +}; + +/** + Check if a plugin is loaded + + @return {Boolean} +*/ +Plugin.prototype.isLoaded = function() { + return Boolean(this.getPackage().size > 0); +}; + +/** + Return map of hooks + @return {Map<String:Function>} +*/ +Plugin.prototype.getHooks = function() { + return this.getContent().get('hooks') || Immutable.Map(); +}; + +/** + Return infos about resources for a specific type + + @param {String} type + @return {Map<String:Mixed>} +*/ +Plugin.prototype.getResources = function(type) { + if (type != 'website' && type != 'ebook') { + throw new Error('Invalid assets type ' + type); + } + + var content = this.getContent(); + return (content.get(type) + || (type == 'website'? content.get('book') : null) + || Immutable.Map()); +}; + +/** + Return map of filters + @return {Map<String:Function>} +*/ +Plugin.prototype.getFilters = function() { + return this.getContent().get('filters'); +}; + +/** + Return map of blocks + @return {Map<String:TemplateBlock>} +*/ +Plugin.prototype.getBlocks = function() { + var blocks = this.getContent().get('blocks'); + blocks = blocks || Immutable.Map(); + + return blocks + .map(function(block, blockName) { + return TemplateBlock.create(blockName, block); + }); +}; + +/** + Return a specific hook + + @param {String} name + @return {Function|undefined} +*/ +Plugin.prototype.getHook = function(name) { + return this.getHooks().get(name); +}; + +/** + Create a plugin from a string + + @param {String} + @return {Plugin} +*/ +Plugin.createFromString = function(s) { + var parts = s.split('@'); + var name = parts[0]; + var version = parts.slice(1).join('@'); + + return new Plugin({ + name: name, + version: version || DEFAULT_VERSION + }); +}; + +/** + Return NPM id for a plugin name + + @param {String} + @return {String} +*/ +Plugin.nameToNpmID = function(s) { + return PREFIX + s; +}; + +module.exports = Plugin; diff --git a/lib/models/readme.js b/lib/models/readme.js new file mode 100644 index 0000000..c655c82 --- /dev/null +++ b/lib/models/readme.js @@ -0,0 +1,40 @@ +var Immutable = require('immutable'); + +var File = require('./file'); + +var Readme = Immutable.Record({ + file: File(), + title: String(), + description: String() +}); + +Readme.prototype.getFile = function() { + return this.get('file'); +}; + +Readme.prototype.getTitle = function() { + return this.get('title'); +}; + +Readme.prototype.getDescription = function() { + return this.get('description'); +}; + +/** + Create a new readme + + @param {File} file + @param {Object} def + @return {Readme} +*/ +Readme.create = function(file, def) { + def = def || {}; + + return new Readme({ + file: file, + title: def.title || '', + description: def.description || '' + }); +}; + +module.exports = Readme; diff --git a/lib/models/summary.js b/lib/models/summary.js new file mode 100644 index 0000000..5314bb0 --- /dev/null +++ b/lib/models/summary.js @@ -0,0 +1,190 @@ +var is = require('is'); +var Immutable = require('immutable'); + +var error = require('../utils/error'); +var LocationUtils = require('../utils/location'); +var File = require('./file'); +var SummaryPart = require('./summaryPart'); +var SummaryArticle = require('./summaryArticle'); +var parsers = require('../parsers'); + +var Summary = Immutable.Record({ + file: File(), + parts: Immutable.List() +}, 'Summary'); + +Summary.prototype.getFile = function() { + return this.get('file'); +}; + +Summary.prototype.getParts = function() { + return this.get('parts'); +}; + +/** + Return a part by its index + + @param {Number} + @return {Part} +*/ +Summary.prototype.getPart = function(i) { + var parts = this.getParts(); + return parts.get(i); +}; + +/** + Return an article using an iterator to find it. + if "partIter" is set, it can also return a Part. + + @param {Function} iter + @param {Function} partIter + @return {Article|Part} +*/ +Summary.prototype.getArticle = function(iter, partIter) { + var parts = this.getParts(); + + return parts.reduce(function(result, part) { + if (result) return result; + + if (partIter && partIter(part)) return part; + return SummaryArticle.findArticle(part, iter); + }, null); +}; + + +/** + Return a part/article by its level + + @param {String} level + @return {Article} +*/ +Summary.prototype.getByLevel = function(level) { + function iterByLevel(article) { + return (article.getLevel() === level); + } + + return this.getArticle(iterByLevel, iterByLevel); +}; + +/** + Return an article by its path + + @param {String} filePath + @return {Article} +*/ +Summary.prototype.getByPath = function(filePath) { + return this.getArticle(function(article) { + return (LocationUtils.areIdenticalPaths(article.getPath(), filePath)); + }); +}; + +/** + Return the first article + + @return {Article} +*/ +Summary.prototype.getFirstArticle = function() { + return this.getArticle(function(article) { + return true; + }); +}; + +/** + Return next article of an article + + @param {Article} current + @return {Article} +*/ +Summary.prototype.getNextArticle = function(current) { + var level = is.string(current)? current : current.getLevel(); + var wasPrev = false; + + return this.getArticle(function(article) { + if (wasPrev) return true; + + wasPrev = article.getLevel() == level; + return false; + }); +}; + +/** + Return previous article of an article + + @param {Article} current + @return {Article} +*/ +Summary.prototype.getPrevArticle = function(current) { + var level = is.string(current)? current : current.getLevel(); + var prev = undefined; + + this.getArticle(function(article) { + if (article.getLevel() == level) { + return true; + } + + prev = article; + return false; + }); + + return prev; +}; + +/** + Render summary as text + + @return {Promise<String>} +*/ +Summary.prototype.toText = function(parser) { + var file = this.getFile(); + var parts = this.getParts(); + + parser = parser? parsers.getByExt(parser) : file.getParser(); + + if (!parser) { + throw error.FileNotParsableError({ + filename: file.getPath() + }); + } + + return parser.summary.toText({ + parts: parts.toJS() + }); +}; + +/** + Return all articles as a list + + @return {List<Article>} +*/ +Summary.prototype.getArticlesAsList = function() { + var accu = []; + + this.getArticle(function(article) { + accu.push(article); + }); + + return Immutable.List(accu); +}; + +/** + Create a new summary for a list of parts + + @param {Lust|Array} parts + @return {Summary} +*/ +Summary.createFromParts = function createFromParts(file, parts) { + parts = parts.map(function(part, i) { + if (part instanceof SummaryPart) { + return part; + } + + return SummaryPart.create(part, i + 1); + }); + + return new Summary({ + file: file, + parts: new Immutable.List(parts) + }); +}; + +module.exports = Summary; diff --git a/lib/models/summaryArticle.js b/lib/models/summaryArticle.js new file mode 100644 index 0000000..da82790 --- /dev/null +++ b/lib/models/summaryArticle.js @@ -0,0 +1,150 @@ +var Immutable = require('immutable'); + +var location = require('../utils/location'); + +/* + An article represents an entry in the Summary / table of Contents +*/ + +var SummaryArticle = Immutable.Record({ + level: String(), + title: String(), + ref: String(), + articles: Immutable.List() +}, 'SummaryArticle'); + +SummaryArticle.prototype.getLevel = function() { + return this.get('level'); +}; + +SummaryArticle.prototype.getTitle = function() { + return this.get('title'); +}; + +SummaryArticle.prototype.getRef = function() { + return this.get('ref'); +}; + +SummaryArticle.prototype.getArticles = function() { + return this.get('articles'); +}; + +/** + Return how deep the article is + + @return {Number} +*/ +SummaryArticle.prototype.getDepth = function() { + return this.getLevel().split('.').length; +}; + +/** + Get path (without anchor) to the pointing file + + @return {String} +*/ +SummaryArticle.prototype.getPath = function() { + if (this.isExternal()) { + return undefined; + } + + var ref = this.getRef(); + if (!ref) { + return undefined; + } + + + var parts = ref.split('#'); + + var pathname = (parts.length > 1? parts.slice(0, -1).join('#') : ref); + + // Normalize path to remove ('./', etc) + return location.normalize(pathname); +}; + +/** + Return url if article is external + + @return {String} +*/ +SummaryArticle.prototype.getUrl = function() { + return this.isExternal()? this.getRef() : undefined; +}; + +/** + Get anchor for this article (or undefined) + + @return {String} +*/ +SummaryArticle.prototype.getAnchor = function() { + var ref = this.getRef(); + var parts = ref.split('#'); + + var anchor = (parts.length > 1? '#' + parts[parts.length - 1] : undefined); + return anchor; +}; + +/** + Is article pointing to a page of an absolute url + + @return {Boolean} +*/ +SummaryArticle.prototype.isPage = function() { + return !this.isExternal() && this.getRef(); +}; + +/** + Is article pointing to aan absolute url + + @return {Boolean} +*/ +SummaryArticle.prototype.isExternal = function() { + return location.isExternal(this.getRef()); +}; + +/** + Create a SummaryArticle + + @param {Object} def + @return {SummaryArticle} +*/ +SummaryArticle.create = function(def, level) { + var articles = (def.articles || []).map(function(article, i) { + if (article instanceof SummaryArticle) { + return article; + } + return SummaryArticle.create(article, [level, i + 1].join('.')); + }); + + return new SummaryArticle({ + level: level, + title: def.title, + ref: def.ref || def.path || '', + articles: Immutable.List(articles) + }); +}; + + +/** + Find an article from a base one + + @param {Article|Part} base + @param {Function(article)} iter + @return {Article} +*/ +SummaryArticle.findArticle = function(base, iter) { + var articles = base.getArticles(); + + return articles.reduce(function(result, article) { + if (result) return result; + + if (iter(article)) { + return article; + } + + return SummaryArticle.findArticle(article, iter); + }, null); +}; + + +module.exports = SummaryArticle; diff --git a/lib/models/summaryPart.js b/lib/models/summaryPart.js new file mode 100644 index 0000000..f7a82ce --- /dev/null +++ b/lib/models/summaryPart.js @@ -0,0 +1,48 @@ +var Immutable = require('immutable'); + +var SummaryArticle = require('./summaryArticle'); + +/* + A part represents a section in the Summary / table of Contents +*/ + +var SummaryPart = Immutable.Record({ + level: String(), + title: String(), + articles: Immutable.List() +}); + +SummaryPart.prototype.getLevel = function() { + return this.get('level'); +}; + +SummaryPart.prototype.getTitle = function() { + return this.get('title'); +}; + +SummaryPart.prototype.getArticles = function() { + return this.get('articles'); +}; + +/** + Create a SummaryPart + + @param {Object} def + @return {SummaryPart} +*/ +SummaryPart.create = function(def, level) { + var articles = (def.articles || []).map(function(article, i) { + if (article instanceof SummaryArticle) { + return article; + } + return SummaryArticle.create(article, [level, i + 1].join('.')); + }); + + return new SummaryPart({ + level: String(level), + title: def.title, + articles: Immutable.List(articles) + }); +}; + +module.exports = SummaryPart; diff --git a/lib/models/templateBlock.js b/lib/models/templateBlock.js new file mode 100644 index 0000000..4e47da7 --- /dev/null +++ b/lib/models/templateBlock.js @@ -0,0 +1,310 @@ +var is = require('is'); +var extend = require('extend'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var genKey = require('../utils/genKey'); + +var NODE_ENDARGS = '%%endargs%%'; + +var blockBodies = {}; + +var TemplateBlock = Immutable.Record({ + // Name of block, also the start tag + name: String(), + + // End tag, default to "end<name>" + end: String(), + + // Function to process the block content + process: Function(), + + // List of String, for inner block tags + blocks: Immutable.List(), + + // List of shortcuts to replace with this block + shortcuts: Immutable.List(), + + // Function to execute in post processing + post: null, + + parse: true +}, 'TemplateBlock'); + +TemplateBlock.prototype.getName = function() { + return this.get('name'); +}; + +TemplateBlock.prototype.getPost = function() { + return this.get('post'); +}; + +TemplateBlock.prototype.getParse = function() { + return this.get('parse'); +}; + +TemplateBlock.prototype.getEndTag = function() { + return this.get('end') || ('end' + this.getName()); +}; + +TemplateBlock.prototype.getProcess = function() { + return this.get('process'); +}; + +TemplateBlock.prototype.getBlocks = function() { + return this.get('blocks'); +}; + +TemplateBlock.prototype.getShortcuts = function() { + return this.get('shortcuts'); +}; + +/** + Return name for the nunjucks extension + + @return {String} +*/ +TemplateBlock.prototype.getExtensionName = function() { + return 'Block' + this.getName() + 'Extension'; +}; + +/** + Return a nunjucks extension to represents this block + + @return {Nunjucks.Extension} +*/ +TemplateBlock.prototype.toNunjucksExt = function(mainContext) { + var that = this; + var name = this.getName(); + var endTag = this.getEndTag(); + var blocks = this.getBlocks().toJS(); + + function Ext() { + this.tags = [name]; + + this.parse = function(parser, nodes) { + var lastBlockName = null; + var lastBlockArgs = null; + var allBlocks = blocks.concat([endTag]); + + // Parse first block + var tok = parser.nextToken(); + lastBlockArgs = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(tok.value); + + var args = new nodes.NodeList(); + var bodies = []; + var blockNamesNode = new nodes.Array(tok.lineno, tok.colno); + var blockArgCounts = new nodes.Array(tok.lineno, tok.colno); + + // Parse while we found "end<block>" + do { + // Read body + var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks); + + // Handle body with previous block name and args + blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName)); + blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length)); + bodies.push(currentBody); + + // Append arguments of this block as arguments of the run function + lastBlockArgs.children.forEach(function(child) { + args.addChild(child); + }); + + // Read new block + lastBlockName = parser.nextToken().value; + + // Parse signature and move to the end of the block + if (lastBlockName != endTag) { + lastBlockArgs = parser.parseSignature(null, true); + } + + parser.advanceAfterBlockEnd(lastBlockName); + } while (lastBlockName != endTag); + + args.addChild(blockNamesNode); + args.addChild(blockArgCounts); + args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS)); + + return new nodes.CallExtensionAsync(this, 'run', args, bodies); + }; + + this.run = function(context) { + var fnArgs = Array.prototype.slice.call(arguments, 1); + + var args; + var blocks = []; + var bodies = []; + var blockNames; + var blockArgCounts; + var callback; + + // Extract callback + callback = fnArgs.pop(); + + // Detect end of arguments + var endArgIndex = fnArgs.indexOf(NODE_ENDARGS); + + // Extract arguments and bodies + args = fnArgs.slice(0, endArgIndex); + bodies = fnArgs.slice(endArgIndex + 1); + + // Extract block counts + blockArgCounts = args.pop(); + blockNames = args.pop(); + + // Recreate list of blocks + blockNames.forEach(function(name, i) { + var countArgs = blockArgCounts[i]; + var blockBody = bodies.shift(); + + var blockArgs = countArgs > 0? args.slice(0, countArgs) : []; + args = args.slice(countArgs); + var blockKwargs = extractKwargs(blockArgs); + + blocks.push({ + name: name, + body: blockBody(), + args: blockArgs, + kwargs: blockKwargs + }); + }); + + var mainBlock = blocks.shift(); + mainBlock.blocks = blocks; + + Promise() + .then(function() { + var ctx = extend({ + ctx: context + }, mainContext || {}); + + return that.applyBlock(mainBlock, ctx); + }) + .then(function(result) { + return that.blockResultToHtml(result); + }) + .nodeify(callback); + }; + }; + + return Ext; +}; + +/** + Apply a block to a content + @param {Object} inner + @param {Object} context + @return {Promise<String>|String} +*/ +TemplateBlock.prototype.applyBlock = function(inner, context) { + var processFn = this.getProcess(); + + inner = inner || {}; + inner.args = inner.args || []; + inner.kwargs = inner.kwargs || {}; + inner.blocks = inner.blocks || []; + + var r = processFn.call(context, inner); + + if (Promise.isPromiseAlike(r)) { + return r.then(this.handleBlockResult); + } else { + return this.handleBlockResult(r); + } +}; + +/** + Handle result from a block process function + + @param {Object} result + @return {Object} +*/ +TemplateBlock.prototype.handleBlockResult = function(result) { + if (is.string(result)) { + result = { body: result }; + } + result.name = this.getName(); + + return result; +}; + +/** + Convert a block result to HTML + + @param {Object} result + @return {String} +*/ +TemplateBlock.prototype.blockResultToHtml = function(result) { + var parse = this.getParse(); + var indexedKey; + var toIndex = (!parse) || (this.getPost() !== undefined); + + if (toIndex) { + indexedKey = TemplateBlock.indexBlockResult(result); + } + + // Parsable block, just return it + if (parse) { + return result.body; + } + + // Return it as a position marker + return '{{-%' + indexedKey + '%-}}'; + +}; + +/** + Index a block result, and return the indexed key + + @param {Object} blk + @return {String} +*/ +TemplateBlock.indexBlockResult = function(blk) { + var key = genKey(); + blockBodies[key] = blk; + + return key; +}; + +/** + Get a block results indexed for a specific key + + @param {String} key + @return {Object|undefined} +*/ +TemplateBlock.getBlockResultByKey = function(key) { + return blockBodies[key]; +}; + +/** + Create a template block from a function or an object + + @param {String} blockName + @param {Object} block + @return {TemplateBlock} +*/ +TemplateBlock.create = function(blockName, block) { + if (is.fn(block)) { + block = new Immutable.Map({ + process: block + }); + } + + block = block.set('name', blockName); + return new TemplateBlock(block); +}; + +/** + Extract kwargs from an arguments array + + @param {Array} args + @return {Object} +*/ +function extractKwargs(args) { + var last = args[args.length - 1]; + return (is.object(last) && last.__keywords)? args.pop() : {}; +} + +module.exports = TemplateBlock; diff --git a/lib/models/templateEngine.js b/lib/models/templateEngine.js new file mode 100644 index 0000000..243bfc6 --- /dev/null +++ b/lib/models/templateEngine.js @@ -0,0 +1,139 @@ +var nunjucks = require('nunjucks'); +var Immutable = require('immutable'); + +var TemplateEngine = Immutable.Record({ + // Map of {TemplateBlock} + blocks: Immutable.Map(), + + // Map of Extension + extensions: Immutable.Map(), + + // Map of filters: {String} name -> {Function} fn + filters: Immutable.Map(), + + // Map of globals: {String} name -> {Mixed} + globals: Immutable.Map(), + + // Context for filters / blocks + context: Object(), + + // Nunjucks loader + loader: nunjucks.FileSystemLoader('views') +}, 'TemplateEngine'); + +TemplateEngine.prototype.getBlocks = function() { + return this.get('blocks'); +}; + +TemplateEngine.prototype.getGlobals = function() { + return this.get('globals'); +}; + +TemplateEngine.prototype.getFilters = function() { + return this.get('filters'); +}; + +TemplateEngine.prototype.getShortcuts = function() { + return this.get('shortcuts'); +}; + +TemplateEngine.prototype.getLoader = function() { + return this.get('loader'); +}; + +TemplateEngine.prototype.getContext = function() { + return this.get('context'); +}; + +TemplateEngine.prototype.getExtensions = function() { + return this.get('extensions'); +}; + +/** + Return a block by its name (or undefined) + + @param {String} name + @return {TemplateBlock} +*/ +TemplateEngine.prototype.getBlock = function(name) { + var blocks = this.getBlocks(); + return blocks.find(function(block) { + return block.getName() === name; + }); +}; + +/** + Return a nunjucks environment from this configuration + + @return {Nunjucks.Environment} +*/ +TemplateEngine.prototype.toNunjucks = function() { + var loader = this.getLoader(); + var blocks = this.getBlocks(); + var filters = this.getFilters(); + var globals = this.getGlobals(); + var extensions = this.getExtensions(); + var context = this.getContext(); + + var env = new nunjucks.Environment( + loader, + { + // Escaping is done after by the asciidoc/markdown parser + autoescape: false, + + // Syntax + tags: { + blockStart: '{%', + blockEnd: '%}', + variableStart: '{{', + variableEnd: '}}', + commentStart: '{###', + commentEnd: '###}' + } + } + ); + + // Add filters + filters.forEach(function(filterFn, filterName) { + env.addFilter(filterName, filterFn.bind(context)); + }); + + // Add blocks + blocks.forEach(function(block) { + var extName = block.getExtensionName(); + var Ext = block.toNunjucksExt(context); + + env.addExtension(extName, new Ext()); + }); + + // Add globals + globals.forEach(function(globalValue, globalName) { + env.addGlobal(globalName, globalValue); + }); + + // Add other extensions + extensions.forEach(function(ext, extName) { + env.addExtension(extName, ext); + }); + + return env; +}; + +/** + Create a template engine + + @param {Object} def + @return {TemplateEngine} +*/ +TemplateEngine.create = function(def) { + return new TemplateEngine({ + blocks: Immutable.List(def.blocks || []), + extensions: Immutable.Map(def.extensions || {}), + filters: Immutable.Map(def.filters || {}), + globals: Immutable.Map(def.globals || {}), + context: def.context, + loader: def.loader + }); +}; + +module.exports = TemplateEngine; diff --git a/lib/modifiers/index.js b/lib/modifiers/index.js new file mode 100644 index 0000000..ed09e31 --- /dev/null +++ b/lib/modifiers/index.js @@ -0,0 +1,4 @@ + +module.exports = { + Summary: require('./summary') +}; diff --git a/lib/modifiers/summary/__tests__/editArticle.js b/lib/modifiers/summary/__tests__/editArticle.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/modifiers/summary/__tests__/editArticle.js diff --git a/lib/modifiers/summary/__tests__/editPartTitle.js b/lib/modifiers/summary/__tests__/editPartTitle.js new file mode 100644 index 0000000..d1b916b --- /dev/null +++ b/lib/modifiers/summary/__tests__/editPartTitle.js @@ -0,0 +1,44 @@ +var Summary = require('../../../models/summary'); +var File = require('../../../models/file'); + +describe('editPartTitle', function() { + var editPartTitle = require('../editPartTitle'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: 'My First Article', + path: 'README.md' + }, + { + title: 'My Second Article', + path: 'article.md' + } + ] + }, + { + title: 'Test' + } + ]); + + it('should correctly set title of first part', function() { + var newSummary = editPartTitle(summary, 0, 'Hello World'); + var part = newSummary.getPart(0); + + expect(part.getTitle()).toBe('Hello World'); + }); + + it('should correctly set title of second part', function() { + var newSummary = editPartTitle(summary, 1, 'Hello'); + var part = newSummary.getPart(1); + + expect(part.getTitle()).toBe('Hello'); + }); + + it('should not fail if part doesn\'t exist', function() { + var newSummary = editPartTitle(summary, 3, 'Hello'); + expect(newSummary.getParts().size).toBe(2); + }); +}); + + diff --git a/lib/modifiers/summary/editArticle.js b/lib/modifiers/summary/editArticle.js new file mode 100644 index 0000000..1625398 --- /dev/null +++ b/lib/modifiers/summary/editArticle.js @@ -0,0 +1,70 @@ + +/** + Edit a list of articles + + @param {List<Article>} articles + @param {String} level + @param {Article} newArticle + @return {List<Article>} +*/ +function editArticleInList(articles, level, newArticle) { + return articles.map(function(article) { + var articleLevel = article.getLevel(); + + if (articleLevel == level) { + return article.merge(newArticle); + } + + if (level.indexOf(articleLevel) === 0) { + var articles = editArticleInList(article.getArticles(), level, newArticle); + return article.set('articles', articles); + } + + return article; + }); +} + + +/** + Edit an article in a part + + @param {Part} part + @param {String} level + @param {Article} newArticle + @return {Part} +*/ +function editArticleInPart(part, level, newArticle) { + var articles = part.getArticles(); + articles = editArticleInList(articles); + + return part.set('articles', articles); +} + + +/** + Edit an article in a summary + + @param {Summary} summary + @param {String} level + @param {Article} newArticle + @return {Summary} +*/ +function editArticle(summary, level, newArticle) { + var parts = summary.getParts(); + + var levelParts = level.split('.'); + var partIndex = Number(levelParts[0]); + + var part = parts.get(partIndex); + if (!part) { + return summary; + } + + part = editArticleInPart(part, level, newArticle); + parts = parts.set(partIndex, part); + + return summary.set('parts', parts); +} + + +module.exports = editArticle; diff --git a/lib/modifiers/summary/editArticleTitle.js b/lib/modifiers/summary/editArticleTitle.js new file mode 100644 index 0000000..bd9b6f2 --- /dev/null +++ b/lib/modifiers/summary/editArticleTitle.js @@ -0,0 +1,17 @@ +var editArticle = require('./editArticle'); + +/** + Edit title of an article + + @param {Summary} summary + @param {String} level + @param {String} newTitle + @return {Summary} +*/ +function editArticleTitle(summary, level, newTitle) { + return editArticle(summary, level, { + title: newTitle + }); +} + +module.exports = editArticleTitle; diff --git a/lib/modifiers/summary/editPartTitle.js b/lib/modifiers/summary/editPartTitle.js new file mode 100644 index 0000000..472399b --- /dev/null +++ b/lib/modifiers/summary/editPartTitle.js @@ -0,0 +1,24 @@ + +/** + Edit title of a part in the summary + + @param {Summary} summary + @param {Number} index + @param {String} newTitle + @return {Summary} +*/ +function editPartTitle(summary, index, newTitle) { + var parts = summary.getParts(); + + var part = parts.get(index); + if (!part) { + return summary; + } + + part = part.set('title', newTitle); + parts = parts.set(index, part); + + return summary.set('parts', parts); +} + +module.exports = editPartTitle; diff --git a/lib/modifiers/summary/index.js b/lib/modifiers/summary/index.js new file mode 100644 index 0000000..855d7cc --- /dev/null +++ b/lib/modifiers/summary/index.js @@ -0,0 +1,8 @@ + +module.exports = { + insertArticle: require('./insertArticle'), + unshiftArticle: require('./unshiftArticle'), + + editPartTitle: require('./editPartTitle'), + editArticleTitle: require('./editArticleTitle') +}; diff --git a/lib/modifiers/summary/indexArticleLevels.js b/lib/modifiers/summary/indexArticleLevels.js new file mode 100644 index 0000000..f311f74 --- /dev/null +++ b/lib/modifiers/summary/indexArticleLevels.js @@ -0,0 +1,23 @@ + +/** + Index levels in an article tree + + @param {Article} + @param {String} baseLevel + @return {Article} +*/ +function indexArticleLevels(article, baseLevel) { + baseLevel = baseLevel || article.getLevel(); + var articles = article.getArticles(); + + articles = articles.map(function(inner, i) { + return indexArticleLevels(inner, baseLevel + '.' + (i + 1)); + }); + + return article.merge({ + level: baseLevel, + articles: articles + }); +} + +module.exports = indexArticleLevels; diff --git a/lib/modifiers/summary/indexLevels.js b/lib/modifiers/summary/indexLevels.js new file mode 100644 index 0000000..604e9ff --- /dev/null +++ b/lib/modifiers/summary/indexLevels.js @@ -0,0 +1,17 @@ +var indexPartLevels = require('./indexPartLevels'); + +/** + Index all levels in the summary + + @param {Summary} + @return {Summary} +*/ +function indexLevels(summary) { + var parts = summary.getParts(); + parts = parts.map(indexPartLevels); + + return summary.set('parts', parts); +} + + +module.exports = indexLevels; diff --git a/lib/modifiers/summary/indexPartLevels.js b/lib/modifiers/summary/indexPartLevels.js new file mode 100644 index 0000000..d19c70a --- /dev/null +++ b/lib/modifiers/summary/indexPartLevels.js @@ -0,0 +1,24 @@ +var indexArticleLevels = require('./indexArticleLevels'); + +/** + Index levels in a part + + @param {Part} + @param {Number} index + @return {Part} +*/ +function indexPartLevels(part, index) { + var baseLevel = String(index + 1); + var articles = part.getArticles(); + + articles = articles.map(function(inner, i) { + return indexArticleLevels(inner, baseLevel + '.' + (i + 1)); + }); + + return part.merge({ + level: baseLevel, + articles: articles + }); +} + +module.exports = indexPartLevels; diff --git a/lib/modifiers/summary/insertArticle.js b/lib/modifiers/summary/insertArticle.js new file mode 100644 index 0000000..ae920c2 --- /dev/null +++ b/lib/modifiers/summary/insertArticle.js @@ -0,0 +1,63 @@ +var is = require('is'); +var SummaryArticle = require('../../models/summaryArticle'); +var editArticle = require('./editArticle'); +var indexArticleLevels = require('./indexArticleLevels'); + + +/** + Get level of parent of an article + + @param {String} level + @return {String} +*/ +function getParentLevel(level) { + var parts = level.split('.'); + return parts.slice(0, -1).join('.'); +} + +/** + Insert an article in a summary at a specific position + + @param {Summary} summary + @param {String|Article} level: level to insert after + @param {Article} article + @return {Summary} +*/ +function insertArticle(summary, level, article) { + article = SummaryArticle(article); + level = is.string(level)? level : level.getLevel(); + + var parentLevel = getParentLevel(level); + + if (!parentLevel) { + // todo: insert new part + return summary; + } + + // Get parent of the position + var parentArticle = summary.getByLevel(parentLevel); + if (!parentLevel) { + return summary; + } + + // Find the index to insert at + var articles = parentArticle.getArticles(); + var index = articles.findIndex(function(art) { + return art.getLevel() === level; + }); + if (!index) { + return summary; + } + + // Insert the article at the right index + articles = articles.insert(index, article); + + // Reindex the level from here + parentArticle = parentArticle.set('articles', articles); + parentArticle = indexArticleLevels(parentArticle); + + return editArticle(summary, parentLevel, parentArticle); + +} + +module.exports = insertArticle; diff --git a/lib/modifiers/summary/unshiftArticle.js b/lib/modifiers/summary/unshiftArticle.js new file mode 100644 index 0000000..3f2ae4d --- /dev/null +++ b/lib/modifiers/summary/unshiftArticle.js @@ -0,0 +1,29 @@ +var SummaryArticle = require('../../models/summaryArticle'); +var SummaryPart = require('../../models/summaryPart'); + +var indexLevels = require('./indexLevels'); + +/** + Insert an article at the + + @param {Summary} summary + @param {Article} article + @return {Summary} +*/ +function unshiftArticle(summary, article) { + article = SummaryArticle(article); + + var parts = summary.getParts(); + var part = parts.get(0) || SummaryPart(); + + var articles = part.getArticles(); + articles = articles.unshift(article); + part = part.set('articles', articles); + + parts = parts.set(0, part); + summary = summary.set('parts', parts); + + return indexLevels(summary); +} + +module.exports = unshiftArticle; diff --git a/lib/output/__tests__/ebook.js b/lib/output/__tests__/ebook.js new file mode 100644 index 0000000..dabf360 --- /dev/null +++ b/lib/output/__tests__/ebook.js @@ -0,0 +1,16 @@ +var generateMock = require('../generateMock'); +var EbookGenerator = require('../ebook'); + +describe('EbookGenerator', function() { + + pit('should generate a SUMMARY.html', function() { + return generateMock(EbookGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('SUMMARY.html'); + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/lib/output/__tests__/json.js b/lib/output/__tests__/json.js new file mode 100644 index 0000000..94a0362 --- /dev/null +++ b/lib/output/__tests__/json.js @@ -0,0 +1,29 @@ +var generateMock = require('../generateMock'); +var JSONGenerator = require('../json'); + +describe('JSONGenerator', function() { + + pit('should generate a README.json', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + }); + }); + + pit('should generate a json file for each articles', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + expect(folder).toHaveFile('test/page.json'); + }); + }); +}); + diff --git a/lib/output/__tests__/website.js b/lib/output/__tests__/website.js new file mode 100644 index 0000000..6b949a4 --- /dev/null +++ b/lib/output/__tests__/website.js @@ -0,0 +1,71 @@ +var generateMock = require('../generateMock'); +var WebsiteGenerator = require('../website'); + +describe('WebsiteGenerator', function() { + + pit('should generate an index.html', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + pit('should generate an HTML file for each articles', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + pit('should not generate file if entry file doesn\'t exist', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)', + 'test': { + 'page.md': 'Hello 2' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).not.toHaveFile('page.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + pit('should generate a multilingual book', function() { + return generateMock(WebsiteGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then(function(folder) { + // It should generate languages + expect(folder).toHaveFile('en/index.html'); + expect(folder).toHaveFile('fr/index.html'); + + // Should not copy languages as assets + expect(folder).not.toHaveFile('en/README.md'); + expect(folder).not.toHaveFile('fr/README.md'); + + // Should copy assets only once + expect(folder).toHaveFile('gitbook/style.css'); + expect(folder).not.toHaveFile('en/gitbook/style.css'); + + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js deleted file mode 100644 index 6f1f02d..0000000 --- a/lib/output/assets-inliner.js +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index 868b85b..0000000 --- a/lib/output/base.js +++ /dev/null @@ -1,309 +0,0 @@ -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 error = require('../utils/error'); -var PluginsManager = require('../plugins'); -var TemplateEngine = require('../template'); -var gitbook = require('../gitbook'); - -/* -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; - book.output = this; - 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(); - - // Hack to inherits from rules of the book - this.ignore.add(this.book.ignore); -} - -// Default name for generator -Output.prototype.name = 'base'; - -// 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', - '_layouts', - - // 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; - - // Change README path to be "index.html" - if (href == this.book.readme.path) { - href = 'index.html'; - } - - // Recalcul as relative link - href = currentPage.relative(href); - - // Replace .md by .html - href = this.toURL(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) { - if (location.isExternal(imgFile)) { - return 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 conetxt for the output itself -Output.prototype.getSelfContext = function() { - return { - name: this.name - }; -}; - -// Return a default context for templates -Output.prototype.getContext = function() { - var ctx = _.extend( - { - output: this.getSelfContext() - }, - this.book.getContext(), - (this.book.isLanguageBook()? this.book.parent: this.book).langs.getContext(), - this.book.readme.getContext(), - this.book.summary.getContext(), - this.book.glossary.getContext(), - this.book.config.getContext(), - gitbook.getContext() - ); - - // Deprecated fields - error.deprecateField(ctx.gitbook, 'generator', this.name, '"gitbook.generator" property is deprecated, use "output.name" instead'); - - return ctx; -}; - -// 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.toURL = 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/callHook.js b/lib/output/callHook.js new file mode 100644 index 0000000..4914e52 --- /dev/null +++ b/lib/output/callHook.js @@ -0,0 +1,60 @@ +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var Api = require('../api'); + +function defaultGetArgument() { + return undefined; +} + +function defaultHandleResult(output, result) { + return output; +} + +/** + Call a "global" hook for an output + + @param {String} name + @param {Function(Output) -> Mixed} getArgument + @param {Function(Output, result) -> Output} handleResult + @param {Output} output + @return {Promise<Output>} +*/ +function callHook(name, getArgument, handleResult, output) { + getArgument = getArgument || defaultGetArgument; + handleResult = handleResult || defaultHandleResult; + + var logger = output.getLogger(); + var plugins = output.getPlugins(); + + logger.debug.ln('calling hook "' + name + '"'); + + // Create the JS context for plugins + var context = Api.encodeGlobal(output); + + return timing.measure( + 'call.hook.' + name, + + // Get the arguments + Promise(getArgument(output)) + + // Call the hooks in serie + .then(function(arg) { + return Promise.reduce(plugins, function(prev, plugin) { + var hook = plugin.getHook(name); + if (!hook) { + return prev; + } + + return hook.call(context, prev); + }, arg); + }) + + // Handle final result + .then(function(result) { + output = Api.decodeGlobal(output, context); + return handleResult(output, result); + }) + ); +} + +module.exports = callHook; diff --git a/lib/output/callPageHook.js b/lib/output/callPageHook.js new file mode 100644 index 0000000..c66cef0 --- /dev/null +++ b/lib/output/callPageHook.js @@ -0,0 +1,28 @@ +var Api = require('../api'); +var callHook = require('./callHook'); + +/** + Call a hook for a specific page + + @param {String} name + @param {Output} output + @param {Page} page + @return {Promise<Page>} +*/ +function callPageHook(name, output, page) { + return callHook( + name, + + function(out) { + return Api.encodePage(out, page); + }, + + function(out, result) { + return Api.decodePage(out, page, result); + }, + + output + ); +} + +module.exports = callPageHook; diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js deleted file mode 100644 index e58f836..0000000 --- a/lib/output/conrefs.js +++ /dev/null @@ -1,67 +0,0 @@ -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/createTemplateEngine.js b/lib/output/createTemplateEngine.js new file mode 100644 index 0000000..37b3c27 --- /dev/null +++ b/lib/output/createTemplateEngine.js @@ -0,0 +1,44 @@ +var Templating = require('../templating'); +var TemplateEngine = require('../models/templateEngine'); + +var Api = require('../api'); +var Plugins = require('../plugins'); + +var defaultBlocks = require('../constants/defaultBlocks'); +var defaultFilters = require('../constants/defaultFilters'); + +/** + Create template engine for an output. + It adds default filters/blocks, then add the ones from plugins + + @param {Output} output + @return {TemplateEngine} +*/ +function createTemplateEngine(output) { + var plugins = output.getPlugins(); + var book = output.getBook(); + var rootFolder = book.getContentRoot(); + var logger = book.getLogger(); + + var filters = Plugins.listFilters(plugins); + var blocks = Plugins.listBlocks(plugins); + + // Extend with default + blocks = defaultBlocks.merge(blocks); + filters = defaultFilters.merge(filters); + + // Create loader + var loader = new Templating.ConrefsLoader(rootFolder, logger); + + // Create API context + var context = Api.encodeGlobal(output); + + return new TemplateEngine({ + filters: filters, + blocks: blocks, + loader: loader, + context: context + }); +} + +module.exports = createTemplateEngine; diff --git a/lib/output/ebook.js b/lib/output/ebook.js deleted file mode 100644 index 2b8fac9..0000000 --- a/lib/output/ebook.js +++ /dev/null @@ -1,193 +0,0 @@ -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'; - -// Return context for templating -// Incldue type of ebbook generated -EbookOutput.prototype.getSelfContext = function() { - var ctx = EbookOutput.super_.prototype.getSelfContext.apply(this); - ctx.format = this.opts.format; - - return ctx; -}; - -// 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', 'SUMMARY.html', that.getContext()); - }) - - // 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.renderAsString('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/ebook/getConvertOptions.js b/lib/output/ebook/getConvertOptions.js new file mode 100644 index 0000000..bc80493 --- /dev/null +++ b/lib/output/ebook/getConvertOptions.js @@ -0,0 +1,73 @@ +var extend = require('extend'); + +var Promise = require('../../utils/promise'); +var getPDFTemplate = require('./getPDFTemplate'); +var getCoverPath = require('./getCoverPath'); + +/** + Generate options for ebook-convert + + @param {Output} + @return {Promise<Object>} +*/ +function getConvertOptions(output) { + var options = output.getOptions(); + var format = options.get('format'); + + var book = output.getBook(); + var config = book.getConfig(); + + return Promise() + .then(function() { + var coverPath = getCoverPath(output); + var options = { + '--cover': coverPath, + '--title': config.getValue('title'), + '--comments': config.getValue('description'), + '--isbn': config.getValue('isbn'), + '--authors': config.getValue('author'), + '--language': book.getLanguage() || config.getValue('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 \')]', + '--max-levels': '1', + '--no-chapters-in-toc': true, + '--breadth-first': true, + '--dont-split-on-page-breaks': format === 'epub'? true : undefined + }; + + if (format !== 'pdf') { + return options; + } + + return Promise.all([ + getPDFTemplate(output, 'header'), + getPDFTemplate(output, 'footer') + ]) + .spread(function(headerTpl, footerTpl) { + var pdfOptions = config.getValue('pdf').toJS(); + + return 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-sans-family': String(pdfOptions.fontFamily), + '--pdf-header-template': headerTpl, + '--pdf-footer-template': footerTpl + }); + }); + }); +} + + +module.exports = getConvertOptions; diff --git a/lib/output/ebook/getCoverPath.js b/lib/output/ebook/getCoverPath.js new file mode 100644 index 0000000..c2192d4 --- /dev/null +++ b/lib/output/ebook/getCoverPath.js @@ -0,0 +1,30 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Resolve path to cover file to use + + @param {Output} + @return {String} +*/ +function getCoverPath(output) { + var outputRoot = output.getRoot(); + var book = output.getBook(); + var config = book.getConfig(); + var cover = config.getValue('cover', 'cover.jpg'); + + // Resolve to absolute + cover = fs.pickFile(outputRoot, cover); + if (cover) { + return cover; + } + + // Multilingual? try parent folder + if (book.isLanguageBook()) { + cover = fs.pickFile(path.join(outputRoot, '..'), cover); + } + + return cover; +} + +module.exports = getCoverPath; diff --git a/lib/output/ebook/getPDFTemplate.js b/lib/output/ebook/getPDFTemplate.js new file mode 100644 index 0000000..f7a450d --- /dev/null +++ b/lib/output/ebook/getPDFTemplate.js @@ -0,0 +1,42 @@ +var juice = require('juice'); + +var WebsiteGenerator = require('../website'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var Promise = require('../../utils/promise'); + + +/** + Generate PDF header/footer templates + + @param {Output} output + @param {String} type + @return {String} +*/ +function getPDFTemplate(output, type) { + var filePath = 'pdf_' + type + '.html'; + var outputRoot = output.getRoot(); + var engine = WebsiteGenerator.createTemplateEngine(output, filePath); + + // Generate context + var context = JSONUtils.encodeOutput(output); + context.page = { + num: '_PAGENUM_', + title: '_TITLE_', + section: '_SECTION_' + }; + + // Render the theme + return Templating.renderFile(engine, 'ebook/' + filePath, context) + + // Inline css and assets + .then(function(html) { + return Promise.nfcall(juice.juiceResources, html, { + webResources: { + relativeTo: outputRoot + } + }); + }); +} + +module.exports = getPDFTemplate; diff --git a/lib/output/ebook/index.js b/lib/output/ebook/index.js new file mode 100644 index 0000000..786a10a --- /dev/null +++ b/lib/output/ebook/index.js @@ -0,0 +1,9 @@ +var extend = require('extend'); +var WebsiteGenerator = require('../website'); + +module.exports = extend({}, WebsiteGenerator, { + name: 'ebook', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}); diff --git a/lib/output/ebook/onFinish.js b/lib/output/ebook/onFinish.js new file mode 100644 index 0000000..17a8e5e --- /dev/null +++ b/lib/output/ebook/onFinish.js @@ -0,0 +1,90 @@ +var path = require('path'); + +var WebsiteGenerator = require('../website'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var Promise = require('../../utils/promise'); +var error = require('../../utils/error'); +var command = require('../../utils/command'); +var writeFile = require('../helper/writeFile'); + +var getConvertOptions = require('./getConvertOptions'); + +/** + Write the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function writeSummary(output) { + var options = output.getOptions(); + var prefix = options.get('prefix'); + + var filePath = 'SUMMARY.html'; + var engine = WebsiteGenerator.createTemplateEngine(output, filePath); + var context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/SUMMARY.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); +} + +/** + Generate the ebook file as "index.pdf" + + @param {Output} + @return {Output} +*/ +function runEbookConvert(output) { + var logger = output.getLogger(); + var options = output.getOptions(); + var format = options.get('format'); + var outputFolder = output.getRoot(); + + if (!format) { + return Promise(output); + } + + return getConvertOptions(output) + .then(function(options) { + var cmd = [ + 'ebook-convert', + path.resolve(outputFolder, 'SUMMARY.html'), + path.resolve(outputFolder, 'index.' + format), + command.optionsToShellArgs(options) + ].join(' '); + + return command.exec(cmd) + .progress(function(data) { + logger.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); + }); + }) + .thenResolve(output); +} + +/** + Finish the generation, generates the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + return writeSummary(output) + .then(runEbookConvert); +} + +module.exports = onFinish; diff --git a/lib/output/ebook/onPage.js b/lib/output/ebook/onPage.js new file mode 100644 index 0000000..21fd34c --- /dev/null +++ b/lib/output/ebook/onPage.js @@ -0,0 +1,24 @@ +var WebsiteGenerator = require('../website'); +var Modifiers = require('../modifiers'); + +/** + Write a page for ebook output + + @param {Output} output + @param {Output} +*/ +function onPage(output, page) { + var options = output.getOptions(); + + // Inline assets + return Modifiers.modifyHTML(page, [ + Modifiers.inlineAssets(options.get('root')) + ]) + + // Write page using website generator + .then(function(resultPage) { + return WebsiteGenerator.onPage(output, resultPage); + }); +} + +module.exports = onPage; diff --git a/lib/output/ebook/options.js b/lib/output/ebook/options.js new file mode 100644 index 0000000..ea7b8b4 --- /dev/null +++ b/lib/output/ebook/options.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String(), + + // Prefix for generation + prefix: String('ebook'), + + // Format to generate using ebook-convert + format: String(), + + // Force use of absolute urls ("index.html" instead of "/") + directoryIndex: Boolean(false) +}); + +module.exports = Options; diff --git a/lib/output/folder.js b/lib/output/folder.js deleted file mode 100644 index 8303ed2..0000000 --- a/lib/output/folder.js +++ /dev/null @@ -1,152 +0,0 @@ -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/generateAssets.js b/lib/output/generateAssets.js new file mode 100644 index 0000000..7a6e104 --- /dev/null +++ b/lib/output/generateAssets.js @@ -0,0 +1,26 @@ +var Promise = require('../utils/promise'); + +/** + Output all assets using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generateAssets(generator, output) { + var assets = output.getAssets(); + var logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onAsset) { + return Promise(output); + } + + return Promise.reduce(assets, function(out, assetFile) { + logger.debug.ln('copy asset "' + assetFile + '"'); + + return generator.onAsset(out, assetFile); + }, output); +} + +module.exports = generateAssets; diff --git a/lib/output/generateBook.js b/lib/output/generateBook.js new file mode 100644 index 0000000..6fcade0 --- /dev/null +++ b/lib/output/generateBook.js @@ -0,0 +1,181 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Output = require('../models/output'); +var Config = require('../models/config'); +var Promise = require('../utils/promise'); + +var callHook = require('./callHook'); +var preparePlugins = require('./preparePlugins'); +var preparePages = require('./preparePages'); +var prepareAssets = require('./prepareAssets'); +var generateAssets = require('./generateAssets'); +var generatePages = require('./generatePages'); + +/** + Process an output to generate the book + + @param {Generator} generator + @param {Output} output + + @return {Promise<Output>} +*/ +function processOutput(generator, startOutput) { + return Promise(startOutput) + .then(preparePlugins) + .then(preparePages) + .then(prepareAssets) + + .then( + callHook.bind(null, + 'config', + function(output) { + var book = output.getBook(); + var config = book.getConfig(); + var values = config.getValues(); + + return values.toJS(); + }, + function(output, result) { + var book = output.getBook(); + var config = book.getConfig(); + + config = Config.updateValues(config, result); + book = book.set('config', config); + return output.set('book', book); + } + ) + ) + + .then( + callHook.bind(null, + 'init', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then(function(output) { + if (!generator.onInit) { + return output; + } + + return generator.onInit(output); + }) + + .then(generateAssets.bind(null, generator)) + .then(generatePages.bind(null, generator)) + + .tap(function(output) { + var book = output.getBook(); + + if (!book.isMultilingual()) { + return; + } + + var books = book.getBooks(); + var outputRoot = output.getRoot(); + var plugins = output.getPlugins(); + var state = output.getState(); + var options = output.getOptions(); + + return Promise.forEach(books, function(langBook) { + // Inherits plugins list, options and state + var langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage())); + var langOutput = new Output({ + book: langBook, + options: langOptions, + state: state, + generator: generator.name, + plugins: plugins + }); + + return processOutput(generator, langOutput); + }); + }) + + .then(callHook.bind(null, + 'finish:before', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ) + + .then(function(output) { + if (!generator.onFinish) { + return output; + } + + return generator.onFinish(output); + }) + + .then(callHook.bind(null, + 'finish', + function(output) { + return {}; + }, + function(output) { + return output; + } + ) + ); +} + +/** + Generate a book using a generator. + + The overall process is: + 1. List and load plugins for this book + 2. Call hook "config" + 3. Call hook "init" + 4. Initialize generator + 5. List all assets and pages + 6. Copy all assets to output + 7. Generate all pages + 8. Call hook "finish:before" + 9. Finish generation + 10. Call hook "finish" + + + @param {Generator} generator + @param {Book} book + @param {Object} options + + @return {Promise<Output>} +*/ +function generateBook(generator, book, options) { + options = generator.Options(options); + var state = generator.State? generator.State({}) : Immutable.Map(); + var start = Date.now(); + + return Promise( + new Output({ + book: book, + options: options, + state: state, + generator: generator.name + }) + ) + .then(processOutput.bind(null, generator)) + + // Log duration and end message + .then(function(output) { + var logger = output.getLogger(); + var end = Date.now(); + var duration = (end - start)/1000; + + logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !'); + + return output; + }); +} + +module.exports = generateBook; diff --git a/lib/output/generateMock.js b/lib/output/generateMock.js new file mode 100644 index 0000000..47d29dc --- /dev/null +++ b/lib/output/generateMock.js @@ -0,0 +1,35 @@ +var tmp = require('tmp'); + +var Book = require('../models/book'); +var createMockFS = require('../fs/mock'); +var parseBook = require('../parse/parseBook'); +var generateBook = require('./generateBook'); + + +/** + Generate a book using JSON generator + And returns the path to the output dir. + + FOR TESTING PURPOSE ONLY + + @param {Generator} + @param {Map<String:String|Map>} files + @return {Promise<String>} +*/ +function generateMock(Generator, files) { + var fs = createMockFS(files); + var book = Book.createForFS(fs); + var dir = tmp.dirSync(); + + book = book.setLogLevel('disabled'); + + return parseBook(book) + .then(function(resultBook) { + return generateBook(Generator, resultBook, { + root: dir.name + }); + }) + .thenResolve(dir.name); +} + +module.exports = generateMock; diff --git a/lib/output/generatePage.js b/lib/output/generatePage.js new file mode 100644 index 0000000..a93d4b0 --- /dev/null +++ b/lib/output/generatePage.js @@ -0,0 +1,71 @@ +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var timing = require('../utils/timing'); + +var Parse = require('../parse'); +var Templating = require('../templating'); +var JSONUtils = require('../json'); +var createTemplateEngine = require('./createTemplateEngine'); +var callPageHook = require('./callPageHook'); + +/** + Prepare and generate HTML for a page + + @param {Output} output + @param {Page} page + @return {Promise<Page>} +*/ +function generatePage(output, page) { + var book = output.getBook(); + var engine = createTemplateEngine(output); + + return timing.measure( + 'page.generate', + Parse.parsePage(book, page) + .then(function(resultPage) { + var file = resultPage.getFile(); + var filePath = file.getPath(); + var parser = file.getParser(); + var context = JSONUtils.encodeBookWithPage(book, resultPage); + + if (!parser) { + return Promise.reject(error.FileNotParsableError({ + filename: filePath + })); + } + + // Call hook "page:before" + return callPageHook('page:before', output, resultPage) + + // Escape code blocks with raw tags + .then(function(currentPage) { + return parser.page.prepare(currentPage.getContent()); + }) + + // Render templating syntax + .then(function(content) { + return Templating.render(engine, filePath, content, context); + }) + + // Render page using parser (markdown -> HTML) + .then(parser.page).get('content') + + // Post processing for templating syntax + .then(function(content) { + return Templating.postRender(engine, content); + }) + + // Return new page + .then(function(content) { + return resultPage.set('content', content); + }) + + // Call final hook + .then(function(currentPage) { + return callPageHook('page', output, currentPage); + }); + }) + ); +} + +module.exports = generatePage; diff --git a/lib/output/generatePages.js b/lib/output/generatePages.js new file mode 100644 index 0000000..73c5c09 --- /dev/null +++ b/lib/output/generatePages.js @@ -0,0 +1,36 @@ +var Promise = require('../utils/promise'); +var generatePage = require('./generatePage'); + +/** + Output all pages using a generator + + @param {Generator} generator + @param {Output} output + @return {Promise<Output>} +*/ +function generatePages(generator, output) { + var pages = output.getPages(); + var logger = output.getLogger(); + + // Is generator ignoring assets? + if (!generator.onPage) { + return Promise(output); + } + + return Promise.reduce(pages, function(out, page) { + var file = page.getFile(); + + logger.debug.ln('generate page "' + file.getPath() + '"'); + + return generatePage(out, page) + .then(function(resultPage) { + return generator.onPage(out, resultPage); + }) + .fail(function(err) { + logger.error.ln('error while generating page "' + file.getPath() + '":'); + throw err; + }); + }, output); +} + +module.exports = generatePages; diff --git a/lib/output/getModifiers.js b/lib/output/getModifiers.js new file mode 100644 index 0000000..e649df6 --- /dev/null +++ b/lib/output/getModifiers.js @@ -0,0 +1,68 @@ +var Modifiers = require('./modifiers'); +var resolveFileToURL = require('./helper/resolveFileToURL'); +var Api = require('../api'); +var Plugins = require('../plugins'); +var Promise = require('../utils/promise'); +var defaultBlocks = require('../constants/defaultBlocks'); + +var CODEBLOCK = 'code'; + +/** + Return default modifier to prepare a page for + rendering. + + @return {Array<Modifier>} +*/ +function getModifiers(output, page) { + var book = output.getBook(); + var plugins = output.getPlugins(); + var glossary = book.getGlossary(); + var entries = glossary.getEntries(); + var file = page.getFile(); + + // Current file path + var currentFilePath = file.getPath(); + + // Get TemplateBlock for highlighting + var blocks = Plugins.listBlocks(plugins); + var code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK); + + // Current context + var context = Api.encodeGlobal(output); + + return [ + // Normalize IDs on headings + Modifiers.addHeadingId, + + // Resolve links (.md -> .html) + Modifiers.resolveLinks.bind(null, + currentFilePath, + resolveFileToURL.bind(null, output) + ), + + // Resolve images + Modifiers.resolveImages.bind(null, currentFilePath), + + // Annotate text with glossary entries + Modifiers.annotateText.bind(null, entries), + + // Highlight code blocks using "code" block + Modifiers.highlightCode.bind(null, function(lang, source) { + return Promise(code.applyBlock({ + body: source, + kwargs: { + language: lang + } + }, context)) + .then(function(result) { + if (result.html === false) { + return { text: result.body }; + } else { + return { html: result.body }; + } + }); + }) + ]; +} + +module.exports = getModifiers; diff --git a/lib/output/helper/fileToOutput.js b/lib/output/helper/fileToOutput.js new file mode 100644 index 0000000..9673162 --- /dev/null +++ b/lib/output/helper/fileToOutput.js @@ -0,0 +1,32 @@ +var path = require('path'); + +var PathUtils = require('../../utils/path'); +var LocationUtils = require('../../utils/location'); + +var OUTPUT_EXTENSION = '.html'; + +/** + Convert a filePath (absolute) to a filename for output + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function fileToOutput(output, filePath) { + var book = output.getBook(); + var readme = book.getReadme(); + var fileReadme = readme.getFile(); + + if ( + path.basename(filePath, path.extname(filePath)) == 'README' || + (fileReadme.exists() && filePath == fileReadme.getPath()) + ) { + filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION); + } else { + filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION); + } + + return LocationUtils.normalize(filePath); +} + +module.exports = fileToOutput; diff --git a/lib/output/helper/fileToURL.js b/lib/output/helper/fileToURL.js new file mode 100644 index 0000000..44ad2d8 --- /dev/null +++ b/lib/output/helper/fileToURL.js @@ -0,0 +1,31 @@ +var path = require('path'); +var LocationUtils = require('../../utils/location'); + +var fileToOutput = require('./fileToOutput'); + +/** + Convert a filePath (absolute) to an url (without hostname). + It returns an absolute path. + + "README.md" -> "/" + "test/hello.md" -> "test/hello.html" + "test/README.md" -> "test/" + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function fileToURL(output, filePath) { + var options = output.getOptions(); + var directoryIndex = options.get('directoryIndex'); + + filePath = fileToOutput(output, filePath); + + if (directoryIndex && path.basename(filePath) == 'index.html') { + filePath = path.dirname(filePath) + '/'; + } + + return LocationUtils.normalize(filePath); +} + +module.exports = fileToURL; diff --git a/lib/output/helper/index.js b/lib/output/helper/index.js new file mode 100644 index 0000000..f8bc109 --- /dev/null +++ b/lib/output/helper/index.js @@ -0,0 +1,2 @@ + +module.exports = {}; diff --git a/lib/output/helper/resolveFileToUrl.js b/lib/output/helper/resolveFileToUrl.js new file mode 100644 index 0000000..3dba8f7 --- /dev/null +++ b/lib/output/helper/resolveFileToUrl.js @@ -0,0 +1,27 @@ +var LocationUtils = require('../../utils/location'); + +var fileToURL = require('./fileToURL'); + +/** + Resolve an absolute path (extracted from a link) + + @param {Output} output + @param {String} filePath + @return {String} +*/ +function resolveFileToURL(output, filePath) { + // Convert /test.png -> test.png + filePath = LocationUtils.toAbsolute(filePath, '', ''); + + var pages = output.getPages(); + var page = pages.get(filePath); + + // if file is a page, return correct .html url + if (page) { + filePath = fileToURL(output, filePath); + } + + return LocationUtils.normalize(filePath); +} + +module.exports = resolveFileToURL; diff --git a/lib/output/helper/writeFile.js b/lib/output/helper/writeFile.js new file mode 100644 index 0000000..a6d4645 --- /dev/null +++ b/lib/output/helper/writeFile.js @@ -0,0 +1,23 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Write a file to the output folder + + @param {Output} output + @param {String} filePath + @param {Buffer|String} content + @return {Promise} +*/ +function writeFile(output, filePath, content) { + var rootFolder = output.getRoot(); + filePath = path.join(rootFolder, filePath); + + return fs.ensureFile(filePath) + .then(function() { + return fs.writeFile(filePath, content); + }) + .thenResolve(output); +} + +module.exports = writeFile; diff --git a/lib/output/index.js b/lib/output/index.js new file mode 100644 index 0000000..9b8ec17 --- /dev/null +++ b/lib/output/index.js @@ -0,0 +1,24 @@ +var Immutable = require('immutable'); + +var generators = Immutable.List([ + require('./json'), + require('./website'), + require('./ebook') +]); + +/** + Return a specific generator by its name + + @param {String} + @return {Generator} +*/ +function getGenerator(name) { + return generators.find(function(generator) { + return generator.name == name; + }); +} + +module.exports = { + generate: require('./generateBook'), + getGenerator: getGenerator +}; diff --git a/lib/output/json.js b/lib/output/json.js deleted file mode 100644 index 7061141..0000000 --- a/lib/output/json.js +++ /dev/null @@ -1,47 +0,0 @@ -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.getOutputContext(that); - - // Delete some private properties - delete json.config; - - // Specify JSON output version - json.version = '3'; - - 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/json/index.js b/lib/output/json/index.js new file mode 100644 index 0000000..e24c127 --- /dev/null +++ b/lib/output/json/index.js @@ -0,0 +1,6 @@ + +module.exports = { + name: 'json', + Options: require('./options'), + onPage: require('./onPage') +}; diff --git a/lib/output/json/onFinish.js b/lib/output/json/onFinish.js new file mode 100644 index 0000000..ff336a2 --- /dev/null +++ b/lib/output/json/onFinish.js @@ -0,0 +1,32 @@ +var path = require('path'); + +var Promise = require('../../utils/promise'); +var fs = require('../../utils/fs'); + +/** + Finish the generation + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + var book = output.getBook(); + var outputRoot = output.getRoot(); + + if (!book.isMultilingual()) { + return Promise(output); + } + + // Get main language + var languages = book.getLanguages(); + var mainLanguage = languages.getDefaultLanguage(); + + // Copy README.json from it + return fs.copy( + path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), + path.resolve(outputRoot, 'README.json') + ) + .thenResolve(output); +} + +module.exports = onFinish; diff --git a/lib/output/json/onPage.js b/lib/output/json/onPage.js new file mode 100644 index 0000000..fece540 --- /dev/null +++ b/lib/output/json/onPage.js @@ -0,0 +1,43 @@ +var JSONUtils = require('../../json'); +var PathUtils = require('../../utils/path'); +var Modifiers = require('../modifiers'); +var writeFile = require('../helper/writeFile'); +var getModifiers = require('../getModifiers'); + +var JSON_VERSION = '3'; + +/** + Write a page as a json file + + @param {Output} output + @param {Page} page +*/ +function onPage(output, page) { + var file = page.getFile(); + var readme = output.getBook().getReadme().getFile(); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the JSON + var json = JSONUtils.encodeBookWithPage(output.getBook(), resultPage); + + // Delete some private properties + delete json.config; + + // Specify JSON output version + json.version = JSON_VERSION; + + // File path in the output folder + var filePath = file.getPath() == readme.getPath()? 'README.json' : file.getPath(); + filePath = PathUtils.setExtension(filePath, '.json'); + + // Write it to the disk + return writeFile( + output, + filePath, + JSON.stringify(json, null, 4) + ); + }); +} + +module.exports = onPage; diff --git a/lib/output/json/options.js b/lib/output/json/options.js new file mode 100644 index 0000000..79167b1 --- /dev/null +++ b/lib/output/json/options.js @@ -0,0 +1,8 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String() +}); + +module.exports = Options; diff --git a/lib/output/modifiers/__tests__/addHeadingId.js b/lib/output/modifiers/__tests__/addHeadingId.js new file mode 100644 index 0000000..7277440 --- /dev/null +++ b/lib/output/modifiers/__tests__/addHeadingId.js @@ -0,0 +1,29 @@ +jest.autoMockOff(); + +var cheerio = require('cheerio'); + +describe('addHeadingId', function() { + var addHeadingId = require('../addHeadingId'); + + pit('should add an ID if none', function() { + var $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>'); + + return addHeadingId($) + .then(function() { + var html = $.html(); + expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>'); + }); + }); + + pit('should not change existing IDs', function() { + var $ = cheerio.load('<h1 id="awesome">Hello World</h1>'); + + return addHeadingId($) + .then(function() { + var html = $.html(); + expect(html).toBe('<h1 id="awesome">Hello World</h1>'); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/annotateText.js b/lib/output/modifiers/__tests__/annotateText.js new file mode 100644 index 0000000..15d4c30 --- /dev/null +++ b/lib/output/modifiers/__tests__/annotateText.js @@ -0,0 +1,49 @@ +jest.autoMockOff(); + +var Immutable = require('immutable'); +var cheerio = require('cheerio'); +var GlossaryEntry = require('../../../models/glossaryEntry'); + +describe('annotateText', function() { + var annotateText = require('../annotateText'); + + var entries = Immutable.List([ + GlossaryEntry({ name: 'Word' }), + GlossaryEntry({ name: 'Multiple Words' }) + ]); + + it('should annotate text', function() { + var $ = cheerio.load('<p>This is a word, and multiple words</p>'); + + annotateText(entries, $); + + var links = $('a'); + expect(links.length).toBe(2); + + var word = $(links.get(0)); + expect(word.attr('href')).toBe('/GLOSSARY.md#word'); + expect(word.text()).toBe('word'); + expect(word.hasClass('glossary-term')).toBeTruthy(); + + var words = $(links.get(1)); + expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words'); + expect(words.text()).toBe('multiple words'); + expect(words.hasClass('glossary-term')).toBeTruthy(); + }); + + it('should not annotate scripts', function() { + var $ = cheerio.load('<script>This is a word, and multiple words</script>'); + + annotateText(entries, $); + expect($('a').length).toBe(0); + }); + + it('should not annotate when has class "no-glossary"', function() { + var $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>'); + + annotateText(entries, $); + expect($('a').length).toBe(0); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/fetchRemoteImages.js b/lib/output/modifiers/__tests__/fetchRemoteImages.js new file mode 100644 index 0000000..f5610a2 --- /dev/null +++ b/lib/output/modifiers/__tests__/fetchRemoteImages.js @@ -0,0 +1,40 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var path = require('path'); + +var URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png'; + +describe('fetchRemoteImages', function() { + var dir; + var fetchRemoteImages = require('../fetchRemoteImages'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should download image file', function() { + var $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); + + pit('should download image file and replace with relative path', function() { + var $ = cheerio.load('<img src="' + URL + '" />'); + + return fetchRemoteImages(dir.name, 'test/index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(path.join('test', src)); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/highlightCode.js b/lib/output/modifiers/__tests__/highlightCode.js new file mode 100644 index 0000000..bd7d422 --- /dev/null +++ b/lib/output/modifiers/__tests__/highlightCode.js @@ -0,0 +1,63 @@ +jest.autoMockOff(); + +var cheerio = require('cheerio'); +var Promise = require('../../../utils/promise'); + +describe('highlightCode', function() { + var highlightCode = require('../highlightCode'); + + function doHighlight(lang, code) { + return { + text: '' + (lang || '') + '$' + code + }; + } + + function doHighlightAsync(lang, code) { + return Promise() + .then(function() { + return doHighlight(lang, code); + }); + } + + pit('should call it for normal code element', function() { + var $ = cheerio.load('<p>This is a <code>test</code></p>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('$test'); + }); + }); + + pit('should call it for markdown code block', function() { + var $ = cheerio.load('<pre><code class="lang-js">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('js$test'); + }); + }); + + pit('should call it for asciidoc code block', function() { + var $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlight, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); + + pit('should accept async highlighter', function() { + var $ = cheerio.load('<pre><code class="language-python">test</code></pre>'); + + return highlightCode(doHighlightAsync, $) + .then(function() { + var $code = $('code'); + expect($code.text()).toBe('python$test'); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/resolveLinks.js b/lib/output/modifiers/__tests__/resolveLinks.js new file mode 100644 index 0000000..3d50d80 --- /dev/null +++ b/lib/output/modifiers/__tests__/resolveLinks.js @@ -0,0 +1,71 @@ +jest.autoMockOff(); + +var path = require('path'); +var cheerio = require('cheerio'); + +describe('resolveLinks', function() { + var resolveLinks = require('../resolveLinks'); + + function resolveFileBasic(href) { + return href; + } + + function resolveFileCustom(href) { + if (path.extname(href) == '.md') { + return href.slice(0, -3) + '.html'; + } + + return href; + } + + describe('Absolute path', function() { + var TEST = '<p>This is a <a href="/test/cool.md"></a></p>'; + + pit('should resolve path starting by "/" in root directory', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileBasic, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('test/cool.md'); + }); + }); + + pit('should resolve path starting by "/" in child directory', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('afolder/hello.md', resolveFileBasic, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('../test/cool.md'); + }); + }); + }); + + describe('Custom Resolver', function() { + var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>'; + + pit('should resolve path correctly for absolute path', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a').first(); + expect(link.attr('href')).toBe('test/cool.html'); + }); + }); + + pit('should resolve path correctly for absolute path (2)', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('afodler/hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a').first(); + expect(link.attr('href')).toBe('../test/cool.html'); + }); + }); + }); + +}); + + diff --git a/lib/output/modifiers/__tests__/svgToImg.js b/lib/output/modifiers/__tests__/svgToImg.js new file mode 100644 index 0000000..793395e --- /dev/null +++ b/lib/output/modifiers/__tests__/svgToImg.js @@ -0,0 +1,25 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); + +describe('svgToImg', function() { + var dir; + var svgToImg = require('../svgToImg'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should write svg as a file', function() { + var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + + return svgToImg(dir.name, 'index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + + diff --git a/lib/output/modifiers/__tests__/svgToPng.js b/lib/output/modifiers/__tests__/svgToPng.js new file mode 100644 index 0000000..163d72e --- /dev/null +++ b/lib/output/modifiers/__tests__/svgToPng.js @@ -0,0 +1,32 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var path = require('path'); + +describe('svgToPng', function() { + var dir; + var svgToImg = require('../svgToImg'); + var svgToPng = require('../svgToPng'); + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + pit('should write svg as png file', function() { + var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>'); + var fileName = 'index.html'; + + return svgToImg(dir.name, fileName, $) + .then(function() { + return svgToPng(dir.name, fileName, $); + }) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + expect(path.extname(src)).toBe('.png'); + }); + }); +}); + + diff --git a/lib/output/modifiers/addHeadingId.js b/lib/output/modifiers/addHeadingId.js new file mode 100644 index 0000000..e2e2720 --- /dev/null +++ b/lib/output/modifiers/addHeadingId.js @@ -0,0 +1,23 @@ +var slug = require('github-slugid'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Add ID to an heading + + @param {HTMLElement} heading +*/ +function addId(heading) { + if (heading.attr('id')) return; + heading.attr('id', slug(heading.text())); +} + +/** + Add ID to all headings + + @param {HTMLDom} $ +*/ +function addHeadingId($) { + return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId); +} + +module.exports = addHeadingId; diff --git a/lib/output/modifiers/annotateText.js b/lib/output/modifiers/annotateText.js new file mode 100644 index 0000000..d8443cf --- /dev/null +++ b/lib/output/modifiers/annotateText.js @@ -0,0 +1,94 @@ +var escape = require('escape-html'); + +// Selector to ignore +var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6'; + +function pregQuote( str ) { + return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); +} + +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(); + }); +} + +/** + Annotate text using a list of GlossaryEntry + + @param {List<GlossaryEntry>} + @param {HTMLDom} $ +*/ +function annotateText(entries, $) { + entries.forEach(function(entry) { + var entryId = entry.getID(); + var name = entry.getName(); + var description = entry.getDescription(); + + var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' ); + + $('*').each(function() { + var $this = $(this); + + if ( + $this.is(ANNOTATION_IGNORE) || + $this.parents(ANNOTATION_IGNORE).length > 0 + ) return; + + replaceText($, this, searchRegex, function(match) { + return '<a href="/GLOSSARY.md#' + entryId + '" ' + + 'class="glossary-term" title="' + escape(description) + '">' + + match + + '</a>'; + }); + }); + + }); +} + +module.exports = annotateText; diff --git a/lib/output/modifiers/editHTMLElement.js b/lib/output/modifiers/editHTMLElement.js new file mode 100644 index 0000000..755598e --- /dev/null +++ b/lib/output/modifiers/editHTMLElement.js @@ -0,0 +1,15 @@ +var Promise = require('../../utils/promise'); + +/** + Edit all elements matching a selector +*/ +function editHTMLElement($, selector, fn) { + var $elements = $(selector); + + return Promise.forEach($elements, function(el) { + var $el = $(el); + return fn($el); + }); +} + +module.exports = editHTMLElement; diff --git a/lib/output/modifiers/fetchRemoteImages.js b/lib/output/modifiers/fetchRemoteImages.js new file mode 100644 index 0000000..ef868b9 --- /dev/null +++ b/lib/output/modifiers/fetchRemoteImages.js @@ -0,0 +1,44 @@ +var path = require('path'); +var crc = require('crc'); + +var editHTMLElement = require('./editHTMLElement'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +/** + Fetch all remote images + + @param {String} rootFolder + @param {String} currentFile + @param {HTMLDom} $ + @return {Promise} +*/ +function fetchRemoteImages(rootFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + var extension = path.extname(src); + + if (!LocationUtils.isExternal(src)) { + return; + } + + // We avoid generating twice the same PNG + var hash = crc.crc32(src).toString(16); + var fileName = hash + extension; + var filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return fs.download(src, filePath); + }) + .then(function() { + // Convert to relative + src = LocationUtils.relative(currentDirectory, fileName); + + $img.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = fetchRemoteImages; diff --git a/lib/output/modifiers/highlightCode.js b/lib/output/modifiers/highlightCode.js new file mode 100644 index 0000000..dcd9d24 --- /dev/null +++ b/lib/output/modifiers/highlightCode.js @@ -0,0 +1,56 @@ +var is = require('is'); +var Promise = require('../../utils/promise'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Return language for a code blocks from a list of class names + + @param {Array<String>} + @return {String} +*/ +function getLanguageForClass(classNames) { + return classNames + .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; + }) + .find(function(cl) { + return Boolean(cl); + }); +} + + +/** + Highlight all code elements + + @param {Function(lang, body) -> String} highlight + @param {HTMLDom} $ + @return {Promise} +*/ +function highlightCode(highlight, $) { + return editHTMLElement($, 'code', function($code) { + var classNames = ($code.attr('class') || '').split(' '); + var lang = getLanguageForClass(classNames); + var source = $code.text(); + + return Promise(highlight(lang, source)) + .then(function(r) { + if (is.string(r.html)) { + $code.html(r.html); + } else { + $code.text(r.text); + } + }); + }); +} + +module.exports = highlightCode; diff --git a/lib/output/modifiers/index.js b/lib/output/modifiers/index.js new file mode 100644 index 0000000..f1daa2b --- /dev/null +++ b/lib/output/modifiers/index.js @@ -0,0 +1,15 @@ + +module.exports = { + modifyHTML: require('./modifyHTML'), + inlineAssets: require('./inlineAssets'), + + // HTML transformations + addHeadingId: require('./addHeadingId'), + svgToImg: require('./svgToImg'), + fetchRemoteImages: require('./fetchRemoteImages'), + svgToPng: require('./svgToPng'), + resolveLinks: require('./resolveLinks'), + resolveImages: require('./resolveImages'), + annotateText: require('./annotateText'), + highlightCode: require('./highlightCode') +}; diff --git a/lib/output/modifiers/inlineAssets.js b/lib/output/modifiers/inlineAssets.js new file mode 100644 index 0000000..9f19fd7 --- /dev/null +++ b/lib/output/modifiers/inlineAssets.js @@ -0,0 +1,27 @@ +var svgToImg = require('./svgToImg'); +var svgToPng = require('./svgToPng'); +var resolveImages = require('./resolveImages'); +var fetchRemoteImages = require('./fetchRemoteImages'); + +var Promise = require('../../utils/promise'); + +/** + Inline all assets in a page + + @param {String} rootFolder +*/ +function inlineAssets(rootFolder, currentFile) { + return function($) { + return Promise() + + // Resolving images and fetching external images should be + // done before svg conversion + .then(resolveImages.bind(null, currentFile)) + .then(fetchRemoteImages.bind(null, rootFolder, currentFile)) + + .then(svgToImg.bind(null, rootFolder, currentFile)) + .then(svgToPng.bind(null, rootFolder, currentFile)); + }; +} + +module.exports = inlineAssets; diff --git a/lib/output/modifiers/modifyHTML.js b/lib/output/modifiers/modifyHTML.js new file mode 100644 index 0000000..0fcf994 --- /dev/null +++ b/lib/output/modifiers/modifyHTML.js @@ -0,0 +1,25 @@ +var cheerio = require('cheerio'); +var Promise = require('../../utils/promise'); + +/** + Apply a list of operations to a page and + output the new page. + + @param {Page} + @param {List|Array<Transformation>} + @return {Promise<Page>} +*/ +function modifyHTML(page, operations) { + var html = page.getContent(); + var $ = cheerio.load(html); + + return Promise.forEach(operations, function(op) { + op($); + }) + .then(function() { + var resultHTML = $.html(); + return page.set('content', resultHTML); + }); +} + +module.exports = modifyHTML; diff --git a/lib/output/modifiers/resolveImages.js b/lib/output/modifiers/resolveImages.js new file mode 100644 index 0000000..e401cf5 --- /dev/null +++ b/lib/output/modifiers/resolveImages.js @@ -0,0 +1,33 @@ +var path = require('path'); + +var LocationUtils = require('../../utils/location'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Resolve all HTML images: + - /test.png in hello -> ../test.html + + @param {String} currentFile + @param {HTMLDom} $ +*/ +function resolveImages(currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + + if (LocationUtils.isExternal(src)) { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // Convert back to relative + src = LocationUtils.relative(currentDirectory, src); + + $img.attr('src', src); + }); +} + +module.exports = resolveImages; diff --git a/lib/output/modifiers/resolveLinks.js b/lib/output/modifiers/resolveLinks.js new file mode 100644 index 0000000..bf3fd10 --- /dev/null +++ b/lib/output/modifiers/resolveLinks.js @@ -0,0 +1,38 @@ +var path = require('path'); + +var LocationUtils = require('../../utils/location'); +var editHTMLElement = require('./editHTMLElement'); + +/** + Resolve all HTML links: + - /test.md in hello -> ../test.html + + @param {String} currentFile + @param {Function(String) -> String} resolveFile + @param {HTMLDom} $ +*/ +function resolveLinks(currentFile, resolveFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'a', function($a) { + var href = $a.attr('href'); + + if (LocationUtils.isExternal(href)) { + $a.attr('_target', 'blank'); + return; + } + + // Calcul absolute path for this + href = LocationUtils.toAbsolute(href, currentDirectory, '.'); + + // Resolve file + href = resolveFile(href); + + // Convert back to relative + href = LocationUtils.relative(currentDirectory, href); + + $a.attr('href', href); + }); +} + +module.exports = resolveLinks; diff --git a/lib/output/modifiers/svgToImg.js b/lib/output/modifiers/svgToImg.js new file mode 100644 index 0000000..f31b06d --- /dev/null +++ b/lib/output/modifiers/svgToImg.js @@ -0,0 +1,56 @@ +var path = require('path'); +var crc = require('crc'); +var domSerializer = require('dom-serializer'); + +var editHTMLElement = require('./editHTMLElement'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +/** + Render a cheerio DOM as html + + @param {HTMLDom} $ + @param {HTMLElement} dom + @param {Object} + @return {String} +*/ +function renderDOM($, dom, options) { + if (!dom && $._root && $._root.children) { + dom = $._root.children; + } + options = options|| dom.options || $._options; + return domSerializer(dom, options); +} + +/** + Replace SVG tag by IMG + + @param {String} baseFolder + @param {HTMLDom} $ +*/ +function svgToImg(baseFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'svg', function($svg) { + var content = '<?xml version="1.0" encoding="UTF-8"?>' + + renderDOM($, $svg); + + // We avoid generating twice the same PNG + var hash = crc.crc32(content).toString(16); + var fileName = hash + '.svg'; + var filePath = path.join(baseFolder, fileName); + + // Write the svg to the file + return fs.assertFile(filePath, function() { + return fs.writeFile(filePath, content, 'utf8'); + }) + + // Return as image + .then(function() { + var src = LocationUtils.relative(currentDirectory, fileName); + $svg.replaceWith('<img src="' + src + '" />'); + }); + }); +} + +module.exports = svgToImg; diff --git a/lib/output/modifiers/svgToPng.js b/lib/output/modifiers/svgToPng.js new file mode 100644 index 0000000..1093106 --- /dev/null +++ b/lib/output/modifiers/svgToPng.js @@ -0,0 +1,53 @@ +var crc = require('crc'); +var path = require('path'); + +var imagesUtil = require('../../utils/images'); +var fs = require('../../utils/fs'); +var LocationUtils = require('../../utils/location'); + +var editHTMLElement = require('./editHTMLElement'); + +/** + Convert all SVG images to PNG + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function svgToPng(rootFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + if (path.extname(src) !== '.svg') { + return; + } + + // Calcul absolute path for this + src = LocationUtils.toAbsolute(src, currentDirectory, '.'); + + // We avoid generating twice the same PNG + var hash = crc.crc32(src).toString(16); + var fileName = hash + '.png'; + + // Input file path + var inputPath = path.join(rootFolder, src); + + // Result file path + var filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertSVGToPNG(inputPath, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + + +module.exports = svgToPng; diff --git a/lib/output/prepareAssets.js b/lib/output/prepareAssets.js new file mode 100644 index 0000000..ae9b55a --- /dev/null +++ b/lib/output/prepareAssets.js @@ -0,0 +1,22 @@ +var Parse = require('../parse'); + +/** + List all assets in the book + + @param {Output} + @return {Promise<Output>} +*/ +function prepareAssets(output) { + var book = output.getBook(); + var pages = output.getPages(); + var logger = output.getLogger(); + + return Parse.listAssets(book, pages) + .then(function(assets) { + logger.info.ln('found', assets.size, 'asset files'); + + return output.set('assets', assets); + }); +} + +module.exports = prepareAssets; diff --git a/lib/output/preparePages.js b/lib/output/preparePages.js new file mode 100644 index 0000000..8ad5f8c --- /dev/null +++ b/lib/output/preparePages.js @@ -0,0 +1,21 @@ +var Parse = require('../parse'); + +/** + List and prepare all pages + + @param {Output} + @return {Promise<Output>} +*/ +function preparePages(output) { + var book = output.getBook(); + var logger = book.getLogger(); + + return Parse.parsePagesList(book) + .then(function(pages) { + logger.info.ln('found', pages.size, 'pages'); + + return output.set('pages', pages); + }); +} + +module.exports = preparePages; diff --git a/lib/output/preparePlugins.js b/lib/output/preparePlugins.js new file mode 100644 index 0000000..54837ed --- /dev/null +++ b/lib/output/preparePlugins.js @@ -0,0 +1,36 @@ +var Plugins = require('../plugins'); +var Promise = require('../utils/promise'); + +/** + Load and setup plugins + + @param {Output} + @return {Promise<Output>} +*/ +function preparePlugins(output) { + var book = output.getBook(); + + return Promise() + + // Only load plugins for main book + .then(function() { + if (book.isLanguageBook()) { + return output.getPlugins(); + } else { + return Plugins.loadForBook(book); + } + }) + + // Update book's configuration using the plugins + .then(function(plugins) { + return Plugins.validateConfig(book, plugins) + .then(function(newBook) { + return output.merge({ + book: newBook, + plugins: plugins + }); + }); + }); +} + +module.exports = preparePlugins; diff --git a/lib/output/website/copyPluginAssets.js b/lib/output/website/copyPluginAssets.js new file mode 100644 index 0000000..9dc876f --- /dev/null +++ b/lib/output/website/copyPluginAssets.js @@ -0,0 +1,115 @@ +var path = require('path'); + +var ASSET_FOLDER = require('../../constants/pluginAssetsFolder'); +var Promise = require('../../utils/promise'); +var fs = require('../../utils/fs'); + +/** + Copy all assets from plugins. + Assets are files stored in "_assets" + nd resources declared in the plugin itself. + + @param {Output} + @return {Promise} +*/ +function copyPluginAssets(output) { + var book = output.getBook(); + + // Don't copy plugins assets for language book + // It'll be resolved to the parent folder + if (book.isLanguageBook()) { + return Promise(output); + } + + var plugins = output.getPlugins() + + // We reverse the order of plugins to copy + // so that first plugins can replace assets from other plugins. + .reverse(); + + return Promise.forEach(plugins, function(plugin) { + return copyAssets(output, plugin) + .then(function() { + return copyResources(output, plugin); + }); + }) + .thenResolve(output); +} + +/** + Copy assets from a plugin + + @param {Plugin} + @return {Promise} +*/ +function copyAssets(output, plugin) { + var logger = output.getLogger(); + var pluginRoot = plugin.getPath(); + var options = output.getOptions(); + + var outputRoot = options.get('root'); + var assetOutputFolder = path.join(outputRoot, 'gitbook'); + var prefix = options.get('prefix'); + + var assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix); + + if (!fs.existsSync(assetFolder)) { + return Promise(); + } + + logger.debug.ln('copy assets from theme', assetFolder); + return fs.copyDir( + assetFolder, + assetOutputFolder, + { + deleteFirst: false, + overwrite: true, + confirm: true + } + ); +} + +/** + Copy resources from a plugin + + @param {Plugin} + @return {Promise} +*/ +function copyResources(output, plugin) { + var logger = output.getLogger(); + + var options = output.getOptions(); + var prefix = options.get('prefix'); + var outputRoot = options.get('root'); + + var pluginRoot = plugin.getPath(); + var resources = plugin.getResources(prefix); + + var assetsFolder = resources.get('assets'); + var assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getNpmID()); + + if (!assetsFolder) { + return Promise(); + } + + // Resolve assets folder + assetsFolder = path.resolve(pluginRoot, assetsFolder); + if (!fs.existsSync(assetsFolder)) { + logger.warn.ln('assets folder for plugin "' + plugin.getName() + '" doesn\'t exist'); + return Promise(); + } + + logger.debug.ln('copy resources from plugin', assetsFolder); + + return fs.copyDir( + assetsFolder, + assetOutputFolder, + { + deleteFirst: false, + overwrite: true, + confirm: true + } + ); +} + +module.exports = copyPluginAssets; diff --git a/lib/output/website/createTemplateEngine.js b/lib/output/website/createTemplateEngine.js new file mode 100644 index 0000000..334ec13 --- /dev/null +++ b/lib/output/website/createTemplateEngine.js @@ -0,0 +1,118 @@ +var path = require('path'); +var nunjucks = require('nunjucks'); +var DoExtension = require('nunjucks-do')(nunjucks); + +var Api = require('../../api'); +var JSONUtils = require('../../json'); +var LocationUtils = require('../../utils/location'); +var fs = require('../../utils/fs'); +var PathUtils = require('../../utils/path'); +var TemplateEngine = require('../../models/templateEngine'); +var templatesFolder = require('../../constants/templatesFolder'); +var defaultFilters = require('../../constants/defaultFilters'); +var Templating = require('../../templating'); +var listSearchPaths = require('./listSearchPaths'); + +var fileToURL = require('../helper/fileToURL'); +var resolveFileToURL = require('../helper/resolveFileToURL'); + +/** + Directory for a theme with the templates +*/ +function templateFolder(dir) { + return path.join(dir, templatesFolder); +} + +/** + Create templating engine to render themes + + @param {Output} output + @param {String} currentFile + @return {TemplateEngine} +*/ +function createTemplateEngine(output, currentFile) { + var book = output.getBook(); + var state = output.getState(); + var i18n = state.getI18n(); + var config = book.getConfig(); + var summary = book.getSummary(); + var outputFolder = output.getRoot(); + + // Search paths for templates + var searchPaths = listSearchPaths(output); + var tplSearchPaths = searchPaths.map(templateFolder); + + // Create loader + var loader = new Templating.ThemesLoader(tplSearchPaths); + + // Get languages + var language = config.get('language'); + + // Create API context + var context = Api.encodeGlobal(output); + + return TemplateEngine.create({ + loader: loader, + + context: context, + + filters: defaultFilters.merge({ + /** + Translate a sentence + */ + t: function t(s) { + return i18n.t(language, s); + }, + + /** + Resolve an absolute file path into a + relative path. + it also resolve pages + */ + resolveFile: function(filePath) { + filePath = resolveFileToURL(output, filePath); + return LocationUtils.relativeForFile(currentFile, filePath); + }, + + resolveAsset: function(filePath) { + filePath = LocationUtils.toAbsolute(filePath, '', ''); + filePath = path.join('gitbook', filePath); + filePath = LocationUtils.relativeForFile(currentFile, filePath); + + // Use assets from parent if language book + if (book.isLanguageBook()) { + filePath = path.join('../', filePath); + } + + return LocationUtils.normalize(filePath); + }, + + /** + Check if a file exists + */ + fileExists: function(fileName) { + var filePath = PathUtils.resolveInRoot(outputFolder, fileName); + return fs.existsSync(filePath); + }, + + contentURL: function(filePath) { + return fileToURL(output, filePath); + }, + + /** + Return an article by its path + */ + getArticleByPath: function(s) { + var article = summary.getByPath(s); + if (!article) return undefined; + return JSONUtils.encodeSummaryArticle(article); + } + }), + + extensions: { + 'DoExtension': new DoExtension() + } + }); +} + +module.exports = createTemplateEngine; diff --git a/lib/output/website/index.js b/lib/output/website/index.js index 0a8618c..7818a28 100644 --- a/lib/output/website/index.js +++ b/lib/output/website/index.js @@ -1,225 +1,11 @@ -var _ = require('lodash'); -var path = require('path'); -var util = require('util'); -var I18n = require('i18n-t'); -var Promise = require('../../utils/promise'); -var location = require('../../utils/location'); -var fs = require('../../utils/fs'); -var conrefsLoader = require('../conrefs'); -var Output = require('../base'); -var setupTemplateEnv = require('./templateEnv'); - -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() { - // This list is ordered to give priority to templates in the book - var searchPaths = _.pluck(that.plugins.list(), 'root'); - - // The book itself can contains a "_layouts" folder - searchPaths.unshift(that.book.root); - - // Load i18n - _.each(searchPaths.concat().reverse(), function(searchPath) { - var i18nRoot = path.resolve(searchPath, '_i18n'); - - if (!fs.existsSync(i18nRoot)) return; - that.i18n.load(i18nRoot); - }); - - that.searchPaths = searchPaths; - }) - - // Copy assets from themes before copying files from book - .then(function() { - if (that.book.isLanguageBook()) return; - - // Assets from the book are already copied - // Copy assets from plugins (start with default plugins) - return Promise.serie(that.plugins.list().reverse(), function(plugin) { - // Copy assets only if exists (don't fail otherwise) - var assetFolder = path.join(plugin.root, '_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, - 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', that.outputPath(page.path), page.getOutputContext(that)); - }); -}; - -// 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', 'index.html', that.getContext()); -}; - -/* - Render a template as an HTML string - Templates are stored in `_layouts` folders - - - @param {String} tpl: template name (ex: "page") - @param {String} outputFile: filename to write, relative to the output - @param {Object} context: context for the page - @return {Promise} -*/ -WebsiteOutput.prototype.renderAsString = function(tpl, context) { - // Calcul template name - var filename = this.templateName(tpl); - - context = _.extend(context, { - plugins: { - resources: this.resources - }, - - options: this.opts - }); - - // Create environment - var env = setupTemplateEnv(this, context); - - return Promise.nfcall(env.render.bind(env), filename, context); +module.exports = { + name: 'website', + State: require('./state'), + Options: require('./options'), + onInit: require('./onInit'), + onFinish: require('./onFinish'), + onPage: require('./onPage'), + onAsset: require('./onAsset'), + createTemplateEngine: require('./createTemplateEngine') }; - -/* - Render a template using nunjucks - Templates are stored in `_layouts` folders - - - @param {String} tpl: template name (ex: "page") - @param {String} outputFile: filename to write, relative to the output - @param {Object} context: context for the page - @return {Promise} -*/ -WebsiteOutput.prototype.render = function(tpl, outputFile, context) { - var that = this; - - // Calcul relative path to the root - var outputDirName = path.dirname(outputFile); - var basePath = location.normalize(path.relative(outputDirName, './')); - - // Setup complete context - context = _.extend(context, { - basePath: basePath, - - template: { - getJSContext: function() { - return { - page: _.omit(context.page, 'content'), - config: context.config, - file: context.file, - gitbook: context.gitbook, - basePath: basePath, - book: { - language: context.book.language - } - }; - } - } - }); - - return this.renderAsString(tpl, context) - .then(function(html) { - return that.writeFile( - outputFile, - html - ); - }); -}; - -// 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/output/website/listSearchPaths.js b/lib/output/website/listSearchPaths.js new file mode 100644 index 0000000..c45f39c --- /dev/null +++ b/lib/output/website/listSearchPaths.js @@ -0,0 +1,23 @@ + +/** + List search paths for templates / i18n, etc + + @param {Output} output + @return {List<String>} +*/ +function listSearchPaths(output) { + var book = output.getBook(); + var plugins = output.getPlugins(); + + var searchPaths = plugins + .valueSeq() + .map(function(plugin) { + return plugin.getPath(); + }) + .toList(); + + return searchPaths.unshift(book.getContentRoot()); +} + + +module.exports = listSearchPaths; diff --git a/lib/output/website/onAsset.js b/lib/output/website/onAsset.js new file mode 100644 index 0000000..17b6ba7 --- /dev/null +++ b/lib/output/website/onAsset.js @@ -0,0 +1,27 @@ +var path = require('path'); +var fs = require('../../utils/fs'); + +/** + Copy an asset to the output folder + + @param {Output} output + @param {Page} page +*/ +function onAsset(output, asset) { + var book = output.getBook(); + var options = output.getOptions(); + + var rootFolder = book.getContentRoot(); + var outputFolder = options.get('root'); + + var filePath = path.resolve(rootFolder, asset); + var outputPath = path.resolve(outputFolder, asset); + + return fs.ensureFile(outputPath) + .then(function() { + return fs.copy(filePath, outputPath); + }) + .thenResolve(output); +} + +module.exports = onAsset; diff --git a/lib/output/website/onFinish.js b/lib/output/website/onFinish.js new file mode 100644 index 0000000..e3560e2 --- /dev/null +++ b/lib/output/website/onFinish.js @@ -0,0 +1,35 @@ +var Promise = require('../../utils/promise'); +var JSONUtils = require('../../json'); +var Templating = require('../../templating'); +var writeFile = require('../helper/writeFile'); +var createTemplateEngine = require('./createTemplateEngine'); + +/** + Finish the generation, write the languages index + + @param {Output} + @return {Output} +*/ +function onFinish(output) { + var book = output.getBook(); + var options = output.getOptions(); + var prefix = options.get('prefix'); + + if (!book.isMultilingual()) { + return Promise(output); + } + + var filePath = 'index.html'; + var engine = createTemplateEngine(output, filePath); + var context = JSONUtils.encodeOutput(output); + + // Render the theme + return Templating.renderFile(engine, prefix + '/languages.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); +} + +module.exports = onFinish; diff --git a/lib/output/website/onInit.js b/lib/output/website/onInit.js new file mode 100644 index 0000000..979a90d --- /dev/null +++ b/lib/output/website/onInit.js @@ -0,0 +1,18 @@ +var Promise = require('../../utils/promise'); + +var copyPluginAssets = require('./copyPluginAssets'); +var prepareI18n = require('./prepareI18n'); + +/** + Initialize the generator + + @param {Output} + @return {Output} +*/ +function onInit(output) { + return Promise(output) + .then(prepareI18n) + .then(copyPluginAssets); +} + +module.exports = onInit; diff --git a/lib/output/website/onPage.js b/lib/output/website/onPage.js new file mode 100644 index 0000000..64b4e04 --- /dev/null +++ b/lib/output/website/onPage.js @@ -0,0 +1,72 @@ +var path = require('path'); +var omit = require('omit-keys'); + +var Templating = require('../../templating'); +var Plugins = require('../../plugins'); +var JSONUtils = require('../../json'); +var LocationUtils = require('../../utils/location'); +var Modifiers = require('../modifiers'); +var writeFile = require('../helper/writeFile'); +var getModifiers = require('../getModifiers'); +var createTemplateEngine = require('./createTemplateEngine'); +var fileToOutput = require('../helper/fileToOutput'); + +/** + Write a page as a json file + + @param {Output} output + @param {Page} page +*/ +function onPage(output, page) { + var options = output.getOptions(); + var file = page.getFile(); + var prefix = options.get('prefix'); + var book = output.getBook(); + var plugins = output.getPlugins(); + + var engine = createTemplateEngine(output, page.getPath()); + + // Output file path + var filePath = fileToOutput(output, file.getPath()); + + // Calcul relative path to the root + var outputDirName = path.dirname(filePath); + var basePath = LocationUtils.normalize(path.relative(outputDirName, './')); + + return Modifiers.modifyHTML(page, getModifiers(output, page)) + .then(function(resultPage) { + // Generate the context + var context = JSONUtils.encodeBookWithPage(output.getBook(), resultPage); + context.plugins = { + resources: Plugins.listResources(plugins, prefix).toJS() + }; + + context.template = { + getJSContext: function() { + return { + page: omit(context.page, 'content'), + config: context.config, + file: context.file, + gitbook: context.gitbook, + basePath: basePath, + book: { + language: book.getLanguage() + } + }; + } + }; + + // We should probabbly move it to "template" or a "site" namespace + context.basePath = basePath; + + // Render the theme + return Templating.renderFile(engine, prefix + '/page.html', context) + + // Write it to the disk + .then(function(html) { + return writeFile(output, filePath, html); + }); + }); +} + +module.exports = onPage; diff --git a/lib/output/website/options.js b/lib/output/website/options.js new file mode 100644 index 0000000..ac9cdad --- /dev/null +++ b/lib/output/website/options.js @@ -0,0 +1,14 @@ +var Immutable = require('immutable'); + +var Options = Immutable.Record({ + // Root folder for the output + root: String(), + + // Prefix for generation + prefix: String('website'), + + // Use directory index url instead of "index.html" + directoryIndex: Boolean(true) +}); + +module.exports = Options; diff --git a/lib/output/website/prepareI18n.js b/lib/output/website/prepareI18n.js new file mode 100644 index 0000000..b57d178 --- /dev/null +++ b/lib/output/website/prepareI18n.js @@ -0,0 +1,30 @@ +var path = require('path'); + +var fs = require('../../utils/fs'); +var Promise = require('../../utils/promise'); +var listSearchPaths = require('./listSearchPaths'); + +/** + Prepare i18n, load translations from plugins and book + + @param {Output} + @return {Promise<Output>} +*/ +function prepareI18n(output) { + var state = output.getState(); + var i18n = state.getI18n(); + var searchPaths = listSearchPaths(output); + + searchPaths + .reverse() + .forEach(function(searchPath) { + var i18nRoot = path.resolve(searchPath, '_i18n'); + + if (!fs.existsSync(i18nRoot)) return; + i18n.load(i18nRoot); + }); + + return Promise(output); +} + +module.exports = prepareI18n; diff --git a/lib/output/website/state.js b/lib/output/website/state.js new file mode 100644 index 0000000..99e7f04 --- /dev/null +++ b/lib/output/website/state.js @@ -0,0 +1,12 @@ +var I18n = require('i18n-t'); +var Immutable = require('immutable'); + +var GeneratorState = Immutable.Record({ + i18n: I18n() +}); + +GeneratorState.prototype.getI18n = function() { + return this.get('i18n'); +}; + +module.exports = GeneratorState; diff --git a/lib/output/website/templateEnv.js b/lib/output/website/templateEnv.js deleted file mode 100644 index d385108..0000000 --- a/lib/output/website/templateEnv.js +++ /dev/null @@ -1,95 +0,0 @@ -var _ = require('lodash'); -var nunjucks = require('nunjucks'); -var path = require('path'); -var fs = require('fs'); -var DoExtension = require('nunjucks-do')(nunjucks); - - -var location = require('../../utils/location'); -var defaultFilters = require('../../template/filters'); - -var ThemeLoader = require('./themeLoader'); - -// Directory for a theme with the templates -function templatesPath(dir) { - return path.join(dir, '_layouts'); -} - -/* - Create and setup at Nunjucks template environment - - @return {Nunjucks.Environment} -*/ -function setupTemplateEnv(output, context) { - context = _.defaults(context || {}, { - // Required by ThemeLoader - template: {} - }); - - var loader = new ThemeLoader( - _.map(output.searchPaths, templatesPath) - ); - var env = new nunjucks.Environment(loader); - - env.addExtension('DoExtension', new DoExtension()); - - // Add context as global - _.each(context, function(value, key) { - env.addGlobal(key, value); - }); - - // Add GitBook default filters - _.each(defaultFilters, function(fn, filter) { - env.addFilter(filter, fn); - }); - - // Translate using _i18n locales - env.addFilter('t', function t(s) { - return output.i18n.t(output.book.config.get('language'), s); - }); - - // Transform an absolute path into a relative path - // using this.ctx.page.path - env.addFilter('resolveFile', function resolveFile(href) { - return location.normalize(output.resolveForPage(context.file.path, href)); - }); - - // Test if a file exists - env.addFilter('fileExists', function fileExists(href) { - return fs.existsSync(output.resolve(href)); - }); - - // Transform a '.md' into a '.html' (README -> index) - env.addFilter('contentURL', function contentURL(s) { - return output.toURL(s); - }); - - // Get an article using its path - env.addFilter('getArticleByPath', function getArticleByPath(s) { - var article = output.book.summary.getArticle(s); - if (!article) return undefined; - - return article.getContext(); - }); - - // Relase path to an asset - env.addFilter('resolveAsset', function resolveAsset(href) { - href = path.join('gitbook', href); - - // Resolve for current file - if (context.file) { - href = output.resolveForPage(context.file.path, '/' + href); - } - - // Use assets from parent - if (output.book.isLanguageBook()) { - href = path.join('../', href); - } - - return location.normalize(href); - }); - - return env; -} - -module.exports = setupTemplateEnv; diff --git a/lib/page/html.js b/lib/page/html.js deleted file mode 100644 index e8d3a85..0000000 --- a/lib/page/html.js +++ /dev/null @@ -1,290 +0,0 @@ -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 $ = this.$; - var $p = $('p').first(); - var $next = $p.nextUntil('h1,h2,h3,h4,h5,h6,pre,blockquote,ul,ol,div'); - - var description = $p.text().trim(); - - $next.each(function() { - description += ' ' + $(this).text().trim(); - }); - - // Truncate description - description = _.trunc(description, 300); - - 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 deleted file mode 100644 index f0d7f57..0000000 --- a/lib/page/index.js +++ /dev/null @@ -1,246 +0,0 @@ -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 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; - - // Map of attributes from YAML frontmatter - // Description is also extracted by default from content - this.attributes = {}; - - // Current content - this.content = ''; - - // 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.getByExt(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 { - file: { - path: this.path, - mtime: this.mtime, - type: this.type - }, - page: _.extend({}, this.attributes, { - title: article? article.title : null, - 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 - }) - }; -}; - -// Return complete context for templating (page + book + summary + ...) -Page.prototype.getOutputContext = function(output) { - return _.extend({}, this.getContext(), output.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); - - // Extract attributes - that.attributes = parsed.attributes; - - // 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.getOutputContext(output), { - 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.attributes.description) return; - that.attributes.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/parse/__tests__/parseBook.js b/lib/parse/__tests__/parseBook.js new file mode 100644 index 0000000..25d1802 --- /dev/null +++ b/lib/parse/__tests__/parseBook.js @@ -0,0 +1,55 @@ +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseBook', function() { + var parseBook = require('../parseBook'); + + pit('should parse multilingual book', function() { + var fs = createMockFS({ + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }); + var book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + var languages = resultBook.getLanguages(); + var books = resultBook.getBooks(); + + expect(resultBook.isMultilingual()).toBe(true); + expect(languages.getList().size).toBe(2); + expect(books.size).toBe(2); + }); + }); + + pit('should parse book in a directory', function() { + var fs = createMockFS({ + 'book.json': JSON.stringify({ + root: './test' + }), + 'test': { + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary\n\n* [Page](page.md)\n', + 'page.md': 'Page' + } + }); + var book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + var readme = resultBook.getReadme(); + var summary = resultBook.getSummary(); + var articles = summary.getArticlesAsList(); + + expect(summary.getFile().exists()).toBe(true); + expect(readme.getFile().exists()).toBe(true); + expect(articles.size).toBe(2); + }); + }); + +}); diff --git a/lib/parse/__tests__/parseGlossary.js b/lib/parse/__tests__/parseGlossary.js new file mode 100644 index 0000000..53805fe --- /dev/null +++ b/lib/parse/__tests__/parseGlossary.js @@ -0,0 +1,36 @@ +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseGlossary', function() { + var parseGlossary = require('../parseGlossary'); + + pit('should parse glossary if exists', function() { + var fs = createMockFS({ + 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello' + }); + var book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + var glossary = resultBook.getGlossary(); + var file = glossary.getFile(); + var entries = glossary.getEntries(); + + expect(file.exists()).toBeTruthy(); + expect(entries.size).toBe(1); + }); + }); + + pit('should not fail if doesn\'t exist', function() { + var fs = createMockFS({}); + var book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + var glossary = resultBook.getGlossary(); + var file = glossary.getFile(); + + expect(file.exists()).toBeFalsy(); + }); + }); +}); diff --git a/lib/parse/__tests__/parseIgnore.js b/lib/parse/__tests__/parseIgnore.js new file mode 100644 index 0000000..bee4236 --- /dev/null +++ b/lib/parse/__tests__/parseIgnore.js @@ -0,0 +1,40 @@ +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseIgnore', function() { + var parseIgnore = require('../parseIgnore'); + var fs = createMockFS({ + '.ignore': 'test-1.js', + '.gitignore': 'test-2.js\ntest-3.js', + '.bookignore': '!test-3.js', + 'test-1.js': '1', + 'test-2.js': '2', + 'test-3.js': '3' + }); + + function getBook() { + var book = Book.createForFS(fs); + return parseIgnore(book); + } + + pit('should load rules from .ignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-1.js')).toBeTruthy(); + }); + }); + + pit('should load rules from .gitignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-2.js')).toBeTruthy(); + }); + }); + + pit('should load rules from .bookignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-3.js')).toBeFalsy(); + }); + }); +}); diff --git a/lib/parse/__tests__/parseReadme.js b/lib/parse/__tests__/parseReadme.js new file mode 100644 index 0000000..1b1567b --- /dev/null +++ b/lib/parse/__tests__/parseReadme.js @@ -0,0 +1,36 @@ +var Promise = require('../../utils/promise'); +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseReadme', function() { + var parseReadme = require('../parseReadme'); + + pit('should parse summary if exists', function() { + var fs = createMockFS({ + 'README.md': '# Hello\n\nAnd here is the description.' + }); + var book = Book.createForFS(fs); + + return parseReadme(book) + .then(function(resultBook) { + var readme = resultBook.getReadme(); + var file = readme.getFile(); + + expect(file.exists()).toBeTruthy(); + expect(readme.getTitle()).toBe('Hello'); + expect(readme.getDescription()).toBe('And here is the description.'); + }); + }); + + pit('should fail if doesn\'t exist', function() { + var fs = createMockFS({}); + var book = Book.createForFS(fs); + + return parseReadme(book) + .then(function(resultBook) { + throw new Error('It should have fail'); + }, function() { + return Promise(); + }); + }); +}); diff --git a/lib/parse/__tests__/parseSummary.js b/lib/parse/__tests__/parseSummary.js new file mode 100644 index 0000000..4b4650d --- /dev/null +++ b/lib/parse/__tests__/parseSummary.js @@ -0,0 +1,34 @@ +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseSummary', function() { + var parseSummary = require('../parseSummary'); + + pit('should parse summary if exists', function() { + var fs = createMockFS({ + 'SUMMARY.md': '# Summary\n\n* [Hello](hello.md)' + }); + var book = Book.createForFS(fs); + + return parseSummary(book) + .then(function(resultBook) { + var summary = resultBook.getSummary(); + var file = summary.getFile(); + + expect(file.exists()).toBeTruthy(); + }); + }); + + pit('should not fail if doesn\'t exist', function() { + var fs = createMockFS({}); + var book = Book.createForFS(fs); + + return parseSummary(book) + .then(function(resultBook) { + var summary = resultBook.getSummary(); + var file = summary.getFile(); + + expect(file.exists()).toBeFalsy(); + }); + }); +}); diff --git a/lib/parse/findParsableFile.js b/lib/parse/findParsableFile.js new file mode 100644 index 0000000..4434d64 --- /dev/null +++ b/lib/parse/findParsableFile.js @@ -0,0 +1,36 @@ +var path = require('path'); + +var Promise = require('../utils/promise'); +var parsers = require('../parsers'); + +/** + Find a file parsable (Markdown or AsciiDoc) in a book + + @param {Book} book + @param {String} filename + @return {Promise<>} +*/ +function findParsableFile(book, filename) { + var fs = book.getContentFS(); + var ext = path.extname(filename); + var basename = path.basename(filename, ext); + var basedir = path.dirname(filename); + + // Ordered list of extensions to test + var exts = parsers.extensions; + + return Promise.some(exts, function(ext) { + var filepath = basename + ext; + + return fs.findFile(basedir, filepath) + .then(function(found) { + if (!found || book.isContentFileIgnored(found)) { + return undefined; + } + + return fs.statFile(found); + }); + }); +} + +module.exports = findParsableFile; diff --git a/lib/parse/index.js b/lib/parse/index.js new file mode 100644 index 0000000..ac27fcf --- /dev/null +++ b/lib/parse/index.js @@ -0,0 +1,13 @@ + +module.exports = { + parseBook: require('./parseBook'), + parseSummary: require('./parseSummary'), + parseGlossary: require('./parseGlossary'), + parseReadme: require('./parseReadme'), + parseConfig: require('./parseConfig'), + parsePagesList: require('./parsePagesList'), + parseIgnore: require('./parseIgnore'), + listAssets: require('./listAssets'), + parseLanguages: require('./parseLanguages'), + parsePage: require('./parsePage') +}; diff --git a/lib/parse/listAssets.js b/lib/parse/listAssets.js new file mode 100644 index 0000000..c43b054 --- /dev/null +++ b/lib/parse/listAssets.js @@ -0,0 +1,36 @@ +var timing = require('../utils/timing'); + +/** + List all assets in a book + Assets are file not ignored and not a page + + @param {Book} book + @param {List<String>} pages + @param +*/ +function listAssets(book, pages) { + var fs = book.getContentFS(); + + var summary = book.getSummary(); + var summaryFile = summary.getFile().getPath(); + + var glossary = book.getGlossary(); + var glossaryFile = glossary.getFile().getPath(); + + return timing.measure( + 'parse.listAssets', + fs.listAllFiles() + .then(function(files) { + return files.filterNot(function(file) { + return ( + book.isContentFileIgnored(file) || + pages.has(file) || + file !== summaryFile || + file !== glossaryFile + ); + }); + }) + ); +} + +module.exports = listAssets; diff --git a/lib/parse/parseBook.js b/lib/parse/parseBook.js new file mode 100644 index 0000000..84a4038 --- /dev/null +++ b/lib/parse/parseBook.js @@ -0,0 +1,77 @@ +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var Book = require('../models/book'); + +var parseIgnore = require('./parseIgnore'); +var parseConfig = require('./parseConfig'); +var parseGlossary = require('./parseGlossary'); +var parseSummary = require('./parseSummary'); +var parseReadme = require('./parseReadme'); +var parseLanguages = require('./parseLanguages'); + +/** + Parse content of a book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseBookContent(book) { + return Promise(book) + .then(parseReadme) + .then(parseSummary) + .then(parseGlossary); +} + +/** + Parse a multilingual book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseMultilingualBook(book) { + var languages = book.getLanguages(); + var langList = languages.getList(); + + return Promise.reduce(langList, function(currentBook, lang) { + var langID = lang.getID(); + var child = Book.createFromParent(currentBook, langID); + var ignore = currentBook.getIgnore(); + + return Promise(child) + .then(parseConfig) + .then(parseBookContent) + .then(function(result) { + // Ignore content of this book when generating parent book + ignore.add(langID + '/**'); + currentBook = currentBook.set('ignore', ignore); + + return currentBook.addLanguageBook(langID, result); + }); + }, book); +} + + +/** + Parse a whole book from a filesystem + + @param {Book} book + @return {Promise<Book>} +*/ +function parseBook(book) { + return timing.measure( + 'parse.book', + Promise(book) + .then(parseIgnore) + .then(parseConfig) + .then(parseLanguages) + .then(function(resultBook) { + if (resultBook.isMultilingual()) { + return parseMultilingualBook(resultBook); + } else { + return parseBookContent(resultBook); + } + }) + ); +} + +module.exports = parseBook; diff --git a/lib/parse/parseConfig.js b/lib/parse/parseConfig.js new file mode 100644 index 0000000..5200de2 --- /dev/null +++ b/lib/parse/parseConfig.js @@ -0,0 +1,51 @@ +var Promise = require('../utils/promise'); +var Config = require('../models/config'); + +var File = require('../models/file'); +var validateConfig = require('./validateConfig'); +var CONFIG_FILES = require('../constants/configFiles'); + +/** + Parse configuration from "book.json" or "book.js" + + @param {Book} book + @return {Promise<Book>} +*/ +function parseConfig(book) { + var fs = book.getFS(); + + return Promise.some(CONFIG_FILES, function(filename) { + // Is this file ignored? + if (book.isFileIgnored(filename)) { + return; + } + + // Try loading it + return Promise.all([ + fs.loadAsObject(filename), + fs.statFile(filename) + ]) + .spread(function(cfg, file) { + return { + file: file, + values: cfg + }; + }) + .fail(function(err) { + if (err.code != 'MODULE_NOT_FOUND') throw(err); + else return Promise(false); + }); + }) + + .then(function(result) { + var file = result? result.file : File(); + var values = result? result.values : {}; + + values = validateConfig(values); + + var config = Config.create(file, values); + return book.set('config', config); + }); +} + +module.exports = parseConfig; diff --git a/lib/parse/parseGlossary.js b/lib/parse/parseGlossary.js new file mode 100644 index 0000000..a96e5fc --- /dev/null +++ b/lib/parse/parseGlossary.js @@ -0,0 +1,26 @@ +var parseStructureFile = require('./parseStructureFile'); +var Glossary = require('../models/glossary'); + +/** + Parse glossary + + @param {Book} book + @return {Promise<Book>} +*/ +function parseGlossary(book) { + var logger = book.getLogger(); + + return parseStructureFile(book, 'glossary') + .spread(function(file, entries) { + if (!file) { + return book; + } + + logger.debug.ln('glossary index file found at', file.getPath()); + + var glossary = Glossary.createFromEntries(file, entries); + return book.set('glossary', glossary); + }); +} + +module.exports = parseGlossary; diff --git a/lib/parse/parseIgnore.js b/lib/parse/parseIgnore.js new file mode 100644 index 0000000..b23bfd8 --- /dev/null +++ b/lib/parse/parseIgnore.js @@ -0,0 +1,50 @@ +var Promise = require('../utils/promise'); +var IGNORE_FILES = require('../constants/ignoreFiles'); + +/** + Parse ignore files + + @param {Book} + @return {Book} +*/ +function parseIgnore(book) { + if (book.isLanguageBook()) { + return Promise.reject(new Error('Ignore files could be parsed for language books')); + } + + var fs = book.getFS(); + var ignore = book.getIgnore(); + + ignore.addPattern([ + // Skip Git stuff + '.git/', + + // Skip OS X meta data + '.DS_Store', + + // Skip stuff installed by plugins + 'node_modules', + + // Skip book outputs + '_book', + '*.pdf', + '*.epub', + '*.mobi', + + // Ignore files in the templates folder + '_layouts' + ]); + + return Promise.serie(IGNORE_FILES, function(filename) { + return fs.readAsString(filename) + .then(function(content) { + ignore.addPattern(content.toString().split(/\r?\n/)); + }, function(err) { + return Promise(); + }); + }) + + .thenResolve(book); +} + +module.exports = parseIgnore; diff --git a/lib/parse/parseLanguages.js b/lib/parse/parseLanguages.js new file mode 100644 index 0000000..346f3a3 --- /dev/null +++ b/lib/parse/parseLanguages.js @@ -0,0 +1,28 @@ +var parseStructureFile = require('./parseStructureFile'); +var Languages = require('../models/languages'); + +/** + Parse languages list from book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseLanguages(book) { + var logger = book.getLogger(); + + return parseStructureFile(book, 'langs') + .spread(function(file, result) { + if (!file) { + return book; + } + + var languages = Languages.createFromList(file, result); + + logger.debug.ln('languages index file found at', file.getPath()); + logger.info.ln('parsing multilingual book, with', languages.getList().size, 'languages'); + + return book.set('languages', languages); + }); +} + +module.exports = parseLanguages; diff --git a/lib/parse/parsePage.js b/lib/parse/parsePage.js new file mode 100644 index 0000000..1d515d6 --- /dev/null +++ b/lib/parse/parsePage.js @@ -0,0 +1,29 @@ +var Immutable = require('immutable'); +var fm = require('front-matter'); +var direction = require('direction'); + +/** + Parse a page, read its content and parse the YAMl header + + @param {Book} book + @param {Page} page + @return {Promise<Page>} +*/ +function parsePage(book, page) { + var fs = book.getContentFS(); + var file = page.getFile(); + + return fs.readAsString(file.getPath()) + .then(function(content) { + var parsed = fm(content); + + return page.merge({ + content: parsed.body, + attributes: Immutable.fromJS(parsed.attributes), + dir: direction(parsed.body) + }); + }); +} + + +module.exports = parsePage; diff --git a/lib/parse/parsePagesList.js b/lib/parse/parsePagesList.js new file mode 100644 index 0000000..a3a52f8 --- /dev/null +++ b/lib/parse/parsePagesList.js @@ -0,0 +1,45 @@ +var Immutable = require('immutable'); + +var timing = require('../utils/timing'); +var Page = require('../models/page'); +var walkSummary = require('./walkSummary'); + +/** + Parse all pages from a book as an OrderedMap + + @param {Book} book + @return {Promise<OrderedMap<Page>>} +*/ +function parsePagesList(book) { + var fs = book.getContentFS(); + var summary = book.getSummary(); + var map = Immutable.OrderedMap(); + + return timing.measure( + 'parse.listPages', + walkSummary(summary, function(article) { + if (!article.isPage()) return; + + var filepath = article.getPath(); + + // Is the page ignored? + if (book.isContentFileIgnored(filepath)) return; + + return fs.statFile(filepath) + .then(function(file) { + map = map.set( + filepath, + Page.createForFile(file) + ); + }, function() { + // file doesn't exist + }); + }) + .then(function() { + return map; + }) + ); +} + + +module.exports = parsePagesList; diff --git a/lib/parse/parseReadme.js b/lib/parse/parseReadme.js new file mode 100644 index 0000000..a2ede77 --- /dev/null +++ b/lib/parse/parseReadme.js @@ -0,0 +1,28 @@ +var parseStructureFile = require('./parseStructureFile'); +var Readme = require('../models/readme'); + +var error = require('../utils/error'); + +/** + Parse readme from book + + @param {Book} book + @return {Promise<Book>} +*/ +function parseReadme(book) { + var logger = book.getLogger(); + + return parseStructureFile(book, 'readme') + .spread(function(file, result) { + if (!file) { + throw new error.FileNotFoundError({ filename: 'README' }); + } + + logger.debug.ln('readme found at', file.getPath()); + + var readme = Readme.create(file, result); + return book.set('readme', readme); + }); +} + +module.exports = parseReadme; diff --git a/lib/parse/parseStructureFile.js b/lib/parse/parseStructureFile.js new file mode 100644 index 0000000..bdb97db --- /dev/null +++ b/lib/parse/parseStructureFile.js @@ -0,0 +1,57 @@ +var findParsableFile = require('./findParsableFile'); +var Promise = require('../utils/promise'); +var error = require('../utils/error'); + +/** + Parse a ParsableFile using a specific method + + @param {FS} fs + @param {ParsableFile} file + @param {String} type + @return {Promise<Array<String, List|Map>>} +*/ +function parseFile(fs, file, type) { + var filepath = file.getPath(); + var parser = file.getParser(); + + if (!parser) { + return Promise.reject( + error.FileNotParsableError({ + filename: filepath + }) + ); + } + + return fs.readAsString(filepath) + .then(function(content) { + return [ + file, + parser[type](content) + ]; + }); +} + + +/** + Parse a structure file (ex: SUMMARY.md, GLOSSARY.md). + It uses the configuration to find the specified file. + + @param {Book} book + @param {String} type: one of ["glossary", "readme", "summary"] + @return {Promise<List|Map>} +*/ +function parseStructureFile(book, type) { + var fs = book.getContentFS(); + var config = book.getConfig(); + + var fileToSearch = config.getValue(['structure', type]); + + return findParsableFile(book, fileToSearch) + .then(function(file) { + if (!file) return [undefined, undefined]; + + return parseFile(fs, file, type); + }); +} + +module.exports = parseStructureFile; diff --git a/lib/parse/parseSummary.js b/lib/parse/parseSummary.js new file mode 100644 index 0000000..72bf224 --- /dev/null +++ b/lib/parse/parseSummary.js @@ -0,0 +1,46 @@ +var parseStructureFile = require('./parseStructureFile'); +var Summary = require('../models/summary'); +var SummaryModifier = require('../modifiers').Summary; +var location = require('../utils/location'); + +/** + Parse summary in a book, the summary can only be parsed + if the readme as be detected before. + + @param {Book} book + @return {Promise<Book>} +*/ +function parseSummary(book) { + var readme = book.getReadme(); + var logger = book.getLogger(); + var readmeFile = readme.getFile(); + + return parseStructureFile(book, 'summary') + .spread(function(file, result) { + var summary; + + if (!file) { + logger.warn.ln('no summary file in this book'); + summary = Summary(); + } else { + logger.debug.ln('summary file found at', file.getPath()); + summary = Summary.createFromParts(file, result.parts); + } + + // Insert readme as first entry + var firstArticle = summary.getFirstArticle(); + + if (readmeFile.exists() && + (!firstArticle || !location.areIdenticalPaths(firstArticle.getRef(), readmeFile.getPath()))) { + summary = SummaryModifier.unshiftArticle(summary, { + title: 'Introduction', + ref: readmeFile.getPath() + }); + } + + // Set new summary + return book.setSummary(summary); + }); +} + +module.exports = parseSummary; diff --git a/lib/config/validator.js b/lib/parse/validateConfig.js index 764b19a..855edc3 100644 --- a/lib/config/validator.js +++ b/lib/parse/validateConfig.js @@ -2,12 +2,17 @@ var jsonschema = require('jsonschema'); var jsonSchemaDefaults = require('json-schema-defaults'); var mergeDefaults = require('merge-defaults'); -var schema = require('./schema'); +var schema = require('../constants/configSchema'); var error = require('../utils/error'); -// Validate a book.json content -// And return a mix with the default value -function validate(bookJson) { +/** + Validate a book.json content + And return a mix with the default value + + @param {Object} bookJson + @return {Object} +*/ +function validateConfig(bookJson) { var v = new jsonschema.Validator(); var result = v.validate(bookJson, schema, { propertyName: 'config' @@ -23,6 +28,4 @@ function validate(bookJson) { return mergeDefaults(bookJson, defaults); } -module.exports = { - validate: validate -}; +module.exports = validateConfig; diff --git a/lib/parse/walkSummary.js b/lib/parse/walkSummary.js new file mode 100644 index 0000000..0117752 --- /dev/null +++ b/lib/parse/walkSummary.js @@ -0,0 +1,34 @@ +var Promise = require('../utils/promise'); + +/** + Walk over a list of articles + + @param {List<Article>} articles + @param {Function(article)} + @return {Promise} +*/ +function walkArticles(articles, fn) { + return Promise.forEach(articles, function(article) { + return Promise(fn(article)) + .then(function() { + return walkArticles(article.getArticles(), fn); + }); + }); +} + +/** + Walk over summary and execute "fn" on each article + + @param {Summary} summary + @param {Function(article)} + @return {Promise} +*/ +function walkSummary(summary, fn) { + var parts = summary.getParts(); + + return Promise.forEach(parts, function(part) { + return walkArticles(part.getArticles(), fn); + }); +} + +module.exports = walkSummary; diff --git a/lib/plugins/__tests__/findInstalled.js b/lib/plugins/__tests__/findInstalled.js new file mode 100644 index 0000000..93912d3 --- /dev/null +++ b/lib/plugins/__tests__/findInstalled.js @@ -0,0 +1,16 @@ +var path = require('path'); + +describe('findInstalled', function() { + var findInstalled = require('../findInstalled'); + + pit('must list default plugins for gitbook directory', function() { + return findInstalled(path.resolve(__dirname, '../../../')) + .then(function(plugins) { + expect(plugins.size > 7).toBeTruthy(); + + expect(plugins.has('fontsettings')).toBe(true); + expect(plugins.has('search')).toBe(true); + }); + }); + +}); diff --git a/lib/plugins/__tests__/listAll.js b/lib/plugins/__tests__/listAll.js new file mode 100644 index 0000000..71483a7 --- /dev/null +++ b/lib/plugins/__tests__/listAll.js @@ -0,0 +1,71 @@ +jest.autoMockOff(); + +describe('listAll', function() { + var listAll = require('../listAll'); + + it('must list from string', function() { + var plugins = listAll('ga,great'); + + expect(plugins.size).toBe(8); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + expect(plugins.has('search')).toBe(true); + }); + + it('must list from array', function() { + var plugins = listAll(['ga', 'great']); + + expect(plugins.size).toBe(8); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + expect(plugins.has('search')).toBe(true); + }); + + it('must parse version (semver)', function() { + var plugins = listAll(['ga@1.0.0', 'great@>=4.0.0']); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + var ga = plugins.get('ga'); + expect(ga.getVersion()).toBe('1.0.0'); + + var great = plugins.get('great'); + expect(great.getVersion()).toBe('>=4.0.0'); + }); + + it('must parse version (git)', function() { + var plugins = listAll(['ga@git+https://github.com/GitbookIO/plugin-ga.git', 'great@git+ssh://samy@github.com/GitbookIO/plugin-ga.git']); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(true); + + var ga = plugins.get('ga'); + expect(ga.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git'); + + var great = plugins.get('great'); + expect(great.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + + it('must list from array with -', function() { + var plugins = listAll(['ga', '-great']); + + expect(plugins.size).toBe(7); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('great')).toBe(false); + }); + + it('must remove default plugins using -', function() { + var plugins = listAll(['ga', '-search']); + + expect(plugins.size).toBe(6); + + expect(plugins.has('ga')).toBe(true); + expect(plugins.has('search')).toBe(false); + }); +}); diff --git a/lib/plugins/__tests__/validatePlugin.js b/lib/plugins/__tests__/validatePlugin.js new file mode 100644 index 0000000..3d50839 --- /dev/null +++ b/lib/plugins/__tests__/validatePlugin.js @@ -0,0 +1,21 @@ +jest.autoMockOff(); + +var Promise = require('../../utils/promise'); +var Plugin = require('../../models/plugin'); + + +describe('validatePlugin', function() { + var validatePlugin = require('../validatePlugin'); + + pit('must not validate a not loaded plugin', function() { + var plugin = Plugin.createFromString('test'); + + return validatePlugin(plugin) + .then(function() { + throw new Error('Should not be validate'); + }, function(err) { + return Promise(); + }); + }); + +}); diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js deleted file mode 100644 index 77f4be2..0000000 --- a/lib/plugins/compatibility.js +++ /dev/null @@ -1,61 +0,0 @@ -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 = book; - - return ctx; -} - -/* - Call a function "fn" with a context of page similar to the one in GitBook v2 - - @params {Page} - @returns {String|undefined} new content of the page -*/ -function pageHook(page, fn) { - // Get page context - var ctx = page.getContext().page; - - // Add other informations - ctx.type = page.type; - ctx.rawPath = page.rawPath; - ctx.path = page.path; - - // Deprecate sections - error.deprecateField(ctx, 'sections', [ - { content: ctx.content, type: 'normal' } - ], '"sections" property is deprecated, use page.content instead'); - - // Keep reference of original content for compatibility - var originalContent = ctx.content; - - return fn(ctx) - .then(function(result) { - // No returned value - // Existing content will be used - if (!result) return undefined; - - // GitBook 3 - // Use returned page.content if different from original content - if (result.content != originalContent) { - return result.content; - } - - // GitBook 2 compatibility - // Finally, use page.sections - if (result.sections) { - return _.pluck(result.sections, 'content').join('\n'); - } - }); -} - -module.exports = { - pluginCtx: pluginCtx, - pageHook: pageHook -}; diff --git a/lib/plugins/findForBook.js b/lib/plugins/findForBook.js new file mode 100644 index 0000000..14ccc05 --- /dev/null +++ b/lib/plugins/findForBook.js @@ -0,0 +1,34 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var findInstalled = require('./findInstalled'); + +/** + List all plugins installed in a book + + @param {Book} + @return {Promise<OrderedMap<String:Plugin>>} +*/ +function findForBook(book) { + return timing.measure( + 'plugins.findForBook', + + Promise.all([ + findInstalled(path.resolve(__dirname, '../..')), + findInstalled(book.getRoot()) + ]) + + // Merge all plugins + .then(function(results) { + return Immutable.List(results) + .reduce(function(out, result) { + return out.merge(result); + }, Immutable.OrderedMap()); + }) + ); +} + + +module.exports = findForBook; diff --git a/lib/plugins/findInstalled.js b/lib/plugins/findInstalled.js new file mode 100644 index 0000000..2259230 --- /dev/null +++ b/lib/plugins/findInstalled.js @@ -0,0 +1,87 @@ +var readInstalled = require('read-installed'); +var Immutable = require('immutable'); +var path = require('path'); + +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); +var Plugin = require('../models/plugin'); +var PREFIX = require('../constants/pluginPrefix'); + +/** + Validate if a package name is a GitBook plugin + + @return {Boolean} +*/ +function validateId(name) { + return name && name.indexOf(PREFIX) === 0; +} + + +/** + List all packages installed inside a folder + + @param {String} folder + @return {OrderedMap<String:Plugin>} +*/ +function findInstalled(folder) { + var options = { + dev: false, + log: function() {}, + depth: 4 + }; + var results = Immutable.OrderedMap(); + + function onPackage(pkg, isRoot) { + if (!pkg.name) return; + + var name = pkg.name; + var version = pkg.version; + var pkgPath = pkg.realPath; + var depth = pkg.depth; + var dependencies = pkg.dependencies; + + var pluginName = name.slice(PREFIX.length); + + if (!validateId(name)){ + if (!isRoot) return; + } else { + results = results.set(pluginName, Plugin({ + name: pluginName, + version: version, + path: pkgPath, + depth: depth + })); + } + + Immutable.Map(dependencies).forEach(function(dep) { + onPackage(dep); + }); + } + + // Search for gitbook-plugins in node_modules folder + var node_modules = path.join(folder, 'node_modules'); + + // List all folders in node_modules + return fs.readdir(node_modules) + .then(function(modules) { + return Promise.serie(modules, function(module) { + // Not a gitbook-plugin + if (!validateId(module)) { + return Promise(); + } + + // Read gitbook-plugin package details + var module_folder = path.join(node_modules, module); + return Promise.nfcall(readInstalled, module_folder, options) + .then(function(data) { + onPackage(data, true); + }); + }); + }) + .then(function() { + // Return installed plugins + return results; + }); +} + +module.exports = findInstalled; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index c6f1686..607a7f1 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,188 +1,10 @@ -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); -} - -// Returns the list of plugins -PluginsManager.prototype.list = function() { - return this.plugins; -}; - -// Return count of plugins loaded -PluginsManager.prototype.count = function() { - return _.size(this.plugins); -}; - -// Returns a plugin by its name -PluginsManager.prototype.get = function(name) { - return _.find(this.plugins, { - id: name - }); -}; - -// Load a plugin (could be a BookPlugin or {name,path}) -PluginsManager.prototype.load = function(plugin) { - var that = this; - - if (_.isArray(plugin)) { - return Promise.serie(plugin, that.load); - } - - return Promise() - - // Initiate and load the plugin - .then(function() { - if (!(plugin instanceof BookPlugin)) { - plugin = new BookPlugin(that.book, plugin.name, plugin.path); - } - - if (that.get(plugin.id)) { - throw new Error('Plugin "'+plugin.id+'" is already loaded'); - } - - - 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 that = this; - var pluginNames = _.pluck(this.book.config.get('plugins'), 'name'); - - return registry.list(this.book) - .then(function(plugins) { - // Filter out plugins not listed of first level - // (aka pre-installed plugins) - plugins = _.filter(plugins, function(plugin) { - return ( - plugin.depth > 1 || - _.contains(pluginNames, plugin.name) - ); - }); - - // Sort plugins to match list in book.json - plugins.sort(function(a, b){ - return pluginNames.indexOf(a.name) < pluginNames.indexOf(b.name) ? -1 : 1; - }); - - // Log state - that.log.info.ln(_.size(plugins) + ' are installed'); - if (_.size(pluginNames) != _.size(plugins)) that.log.info.ln(_.size(pluginNames) + ' explicitly listed'); - - // Verify that all plugins are present - var notInstalled = _.filter(pluginNames, function(name) { - return !_.find(plugins, { name: name }); - }); - - if (_.size(notInstalled) > 0) { - throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.'); - } - - // Load plugins - return that.load(plugins); - }); -}; - -// Setup a plugin -// 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 = { + loadForBook: require('./loadForBook'), + validateConfig: require('./validateConfig'), + installPlugins: require('./installPlugins'), + listResources: require('./listResources'), + listBlocks: require('./listBlocks'), + listFilters: require('./listFilters') }; -module.exports = PluginsManager; diff --git a/lib/plugins/installPlugins.js b/lib/plugins/installPlugins.js new file mode 100644 index 0000000..05a5316 --- /dev/null +++ b/lib/plugins/installPlugins.js @@ -0,0 +1,146 @@ +var npm = require('npm'); +var npmi = require('npmi'); +var semver = require('semver'); +var Immutable = require('immutable'); + +var pkg = require('../../package.json'); +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +var Promise = require('../utils/promise'); +var Plugin = require('../models/plugin'); +var gitbook = require('../gitbook'); +var listForBook = require('./listForBook'); + +var npmIsReady; + +/** + Initialize and prepare NPM + + @return {Promise} +*/ +function initNPM() { + if (npmIsReady) return npmIsReady; + + npmIsReady = Promise.nfcall(npm.load, { + silent: true, + loglevel: 'silent' + }); + + return npmIsReady; +} + + + +/** + Resolve a plugin to a version + + @param {Plugin} + @return {Promise<String>} +*/ +function resolveVersion(plugin) { + var npmId = Plugin.nameToNpmID(plugin.getName()); + var requiredVersion = plugin.getVersion(); + + return initNPM() + .then(function() { + return Promise.nfcall(npm.commands.view, [npmId + '@' + requiredVersion, 'engines'], true); + }) + .then(function(versions) { + versions = Immutable.Map(versions).entrySeq(); + + var result = versions + .map(function(entry) { + return { + version: entry[0], + gitbook: (entry[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; + }) + .get(0); + + if (!result) { + return undefined; + } else { + return result.version; + } + }); +} + + +/** + Install a plugin for a book + + @param {Book} + @param {Plugin} + @return {Promise} +*/ +function installPlugin(book, plugin) { + var logger = book.getLogger(); + + var installFolder = book.getRoot(); + var name = plugin.getName(); + var requirement = plugin.getVersion(); + + logger.info.ln('installing plugin "' + name + '"'); + + // Find a version to install + return resolveVersion(plugin) + .then(function(version) { + if (!version) { + throw new Error('Found no satisfactory version for plugin "' + name + '" with requirement "' + requirement + '"'); + } + + logger.info.ln('install plugin "' + name +'" from NPM with version', requirement); + return Promise.nfcall(npmi, { + 'name': plugin.getNpmID(), + 'version': version, + 'path': installFolder, + 'npmLoad': { + 'loglevel': 'silent', + 'loaded': true, + 'prefix': installFolder + } + }); + }) + .then(function() { + logger.info.ok('plugin "' + name + '" installed with success'); + }); +} + + +/** + Install plugin requirements for a book + + @param {Book} + @return {Promise} +*/ +function installPlugins(book) { + var logger = book.getLogger(); + var plugins = listForBook(book); + + // Remove default plugins + // (only if version is same as installed) + plugins = plugins.filterNot(function(plugin) { + return ( + DEFAULT_PLUGINS.includes(plugin.getName()) && + plugin.getVersion() === pkg.dependencies[plugin.getNpmID()] + ); + }); + + if (plugins.size == 0) { + logger.info.ln('nothing to install!'); + return Promise(); + } + + logger.info.ln('installing', plugins.size, 'plugins'); + + return Promise.forEach(plugins, function(plugin) { + return installPlugin(book, plugin); + }); +} + +module.exports = installPlugins; diff --git a/lib/plugins/listAll.js b/lib/plugins/listAll.js new file mode 100644 index 0000000..65b8d7f --- /dev/null +++ b/lib/plugins/listAll.js @@ -0,0 +1,67 @@ +var is = require('is'); +var Immutable = require('immutable'); +var Plugin = require('../models/plugin'); + +var pkg = require('../../package.json'); +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); + +/** + List all plugins for a book + + @param {List<Plugin|String>} + @return {OrderedMap<Plugin>} +*/ +function listAll(plugins) { + if (is.string(plugins)) { + plugins = new Immutable.List(plugins.split(',')); + } + + // Convert to an ordered map + plugins = plugins.map(function(plugin) { + if (is.string(plugin)) { + plugin = Plugin.createFromString(plugin); + } else { + plugin = new Plugin(plugin); + } + + return [plugin.getName(), plugin]; + }); + plugins = Immutable.OrderedMap(plugins); + + // Extract list of plugins to disable (starting with -) + var toRemove = plugins.toList() + .filter(function(plugin) { + return plugin.getName()[0] === '-'; + }) + .map(function(plugin) { + return plugin.getName().slice(1); + }); + + // Remove the '-' + plugins = plugins.mapKeys(function(name) { + if (name[0] === '-') { + return name.slice(1); + } else { + return name; + } + }); + + // Append default plugins + DEFAULT_PLUGINS.forEach(function(pluginName) { + if (plugins.has(pluginName)) return; + + plugins = plugins.set(pluginName, new Plugin({ + name: pluginName, + version: pkg.dependencies[Plugin.nameToNpmID(pluginName)] + })); + }); + + // Remove plugins + plugins = plugins.filterNot(function(plugin, name) { + return toRemove.includes(name); + }); + + return plugins; +} + +module.exports = listAll; diff --git a/lib/plugins/listBlocks.js b/lib/plugins/listBlocks.js new file mode 100644 index 0000000..f738937 --- /dev/null +++ b/lib/plugins/listBlocks.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +/** + List blocks from a list of plugins + + @param {OrderedMap<String:Plugin>} + @return {Map<String:TemplateBlock>} +*/ +function listBlocks(plugins) { + return plugins + .reverse() + .reduce(function(result, plugin) { + return result.merge(plugin.getBlocks()); + }, Immutable.Map()); +} + +module.exports = listBlocks; diff --git a/lib/plugins/listFilters.js b/lib/plugins/listFilters.js new file mode 100644 index 0000000..4d8a471 --- /dev/null +++ b/lib/plugins/listFilters.js @@ -0,0 +1,17 @@ +var Immutable = require('immutable'); + +/** + List filters from a list of plugins + + @param {OrderedMap<String:Plugin>} + @return {Map<String:Function>} +*/ +function listFilters(plugins) { + return plugins + .reverse() + .reduce(function(result, plugin) { + return result.merge(plugin.getFilters()); + }, Immutable.Map()); +} + +module.exports = listFilters; diff --git a/lib/plugins/listForBook.js b/lib/plugins/listForBook.js new file mode 100644 index 0000000..ce94678 --- /dev/null +++ b/lib/plugins/listForBook.js @@ -0,0 +1,18 @@ +var listAll = require('./listAll'); + +/** + List all plugin requirements for a book. + It can be different from the final list of plugins, + since plugins can have their own dependencies + + @param {Book} + @return {OrderedMap<Plugin>} +*/ +function listForBook(book) { + var config = book.getConfig(); + var plugins = config.getValue('plugins'); + + return listAll(plugins); +} + +module.exports = listForBook; diff --git a/lib/plugins/listResources.js b/lib/plugins/listResources.js new file mode 100644 index 0000000..4a73a2c --- /dev/null +++ b/lib/plugins/listResources.js @@ -0,0 +1,45 @@ +var Immutable = require('immutable'); +var path = require('path'); + +var LocationUtils = require('../utils/location'); +var PLUGIN_RESOURCES = require('../constants/pluginResources'); + +/** + List all resources from a list of plugins + + @param {OrderedMap<String:Plugin>} + @param {String} type + @return {Map<String:List<{url, path}>} +*/ +function listResources(plugins, type) { + return plugins.reduce(function(result, plugin) { + var npmId = plugin.getNpmID(); + var resources = plugin.getResources(type); + + PLUGIN_RESOURCES.forEach(function(resourceType) { + var assets = resources.get(resourceType); + if (!assets) return; + + var list = result.get(resourceType) || Immutable.List(); + + assets = assets.map(function(assetFile) { + if (LocationUtils.isExternal(assetFile)) { + return { + url: assetFile + }; + } else { + return { + path: LocationUtils.normalize(path.join(npmId, assetFile)) + }; + } + }); + + list = list.concat(assets); + result = result.set(resourceType, list); + }); + + return result; + }, Immutable.Map()); +} + +module.exports = listResources; diff --git a/lib/plugins/loadForBook.js b/lib/plugins/loadForBook.js new file mode 100644 index 0000000..c4acb5f --- /dev/null +++ b/lib/plugins/loadForBook.js @@ -0,0 +1,57 @@ +var Promise = require('../utils/promise'); + +var listForBook = require('./listForBook'); +var findForBook = require('./findForBook'); +var loadPlugin = require('./loadPlugin'); + + +/** + Load a list of plugins in a book + + @param {Book} + @return {Promise<Map<String:Plugin>} +*/ +function loadForBook(book) { + var logger = book.getLogger(); + var requirements = listForBook(book); + var requirementsKeys = requirements.keySeq().toList(); + + return findForBook(book) + .then(function(installed) { + // Filter out plugins not listed of first level + // (aka pre-installed plugins) + installed = installed.filter(function(plugin) { + return ( + plugin.getDepth() > 0 || + requirements.has(plugin.getName()) + ); + }); + + // Sort plugins to match list in book.json + installed = installed.sort(function(a, b){ + return requirementsKeys.indexOf(a.getName()) < requirementsKeys.indexOf(b.getName()) ? -1 : 1; + }); + + // Log state + logger.info.ln(installed.size + ' plugins are installed'); + if (requirements.size != installed.size) { + logger.info.ln(requirements.size + ' explicitly listed'); + } + + // Verify that all plugins are present + var notInstalled = requirementsKeys.filter(function(name) { + return !installed.has(name); + }); + + if (notInstalled.size > 0) { + throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.'); + } + + return Promise.map(installed, function(plugin) { + return loadPlugin(book, plugin); + }); + }); +} + + +module.exports = loadForBook; diff --git a/lib/plugins/loadPlugin.js b/lib/plugins/loadPlugin.js new file mode 100644 index 0000000..400146e --- /dev/null +++ b/lib/plugins/loadPlugin.js @@ -0,0 +1,83 @@ +var path = require('path'); +var resolve = require('resolve'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var timing = require('../utils/timing'); + +var validatePlugin = require('./validatePlugin'); + +// 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.code == 'MODULE_NOT_FOUND' || err.message.indexOf('Cannot find module') >= 0; +} + +/** + Load a plugin in a book + + @param {Book} book + @param {Plugin} plugin + @param {String} pkgPath (optional) + @return {Promise<Plugin>} +*/ +function loadPlugin(book, plugin) { + var logger = book.getLogger(); + + var name = plugin.getName(); + var pkgPath = plugin.getPath(); + + // Try loading plugins from different location + var p = Promise() + .then(function() { + var packageContent; + var content; + + // Locate plugin and load package.json + try { + var res = resolve.sync('./package.json', { basedir: pkgPath }); + + pkgPath = path.dirname(res); + packageContent = require(res); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + + packageContent = undefined; + content = undefined; + + return; + } + + // Load plugin JS content + try { + content = require(pkgPath); + } catch(err) { + // It's no big deal if the plugin doesn't have an "index.js" + // (For example: themes) + if (isModuleNotFound(err)) { + content = {}; + } else { + throw new error.PluginError(err, { + plugin: name + }); + } + } + + // Update plugin + return plugin.merge({ + 'package': Immutable.fromJS(packageContent), + 'content': Immutable.fromJS(content) + }); + }) + + .then(validatePlugin); + + p = timing.measure('plugin.load', p); + + logger.info('loading plugin "' + name + '"... '); + return logger.info.promise(p); +} + + +module.exports = loadPlugin; diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js deleted file mode 100644 index d1c00d8..0000000 --- a/lib/plugins/plugin.js +++ /dev/null @@ -1,288 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -var url = require('url'); -var resolve = require('resolve'); -var mergeDefaults = require('merge-defaults'); -var jsonschema = require('jsonschema'); -var jsonSchemaDefaults = require('json-schema-defaults'); - -var Promise = require('../utils/promise'); -var error = require('../utils/error'); -var gitbook = require('../gitbook'); -var registry = require('./registry'); -var compatibility = require('./compatibility'); - -var HOOKS = [ - 'init', 'finish', 'finish:before', 'config', 'page', 'page:before' -]; - -var RESOURCES = ['js', 'css']; - -// Return true if an error is a "module not found" -// Wait on https://github.com/substack/node-resolve/pull/81 to be merged -function isModuleNotFound(err) { - return err.message.indexOf('Cannot find module') >= 0; -} - -function BookPlugin(book, pluginId, pluginFolder) { - this.book = book; - this.log = this.book.log.prefix(pluginId); - - - this.id = pluginId; - this.npmId = registry.npmId(pluginId); - this.root = pluginFolder; - - this.packageInfos = undefined; - this.content = undefined; - - // Cache for resources - this._resources = {}; - - _.bindAll(this); -} - -// Return true if plugin has been loaded correctly -BookPlugin.prototype.isLoaded = function() { - return Boolean(this.packageInfos && this.content); -}; - -// Bind a function to the plugin's context -BookPlugin.prototype.bind = function(fn) { - return fn.bind(compatibility.pluginCtx(this)); -}; - -// Load this plugin from its root folder -BookPlugin.prototype.load = function(folder) { - var that = this; - - if (this.isLoaded()) { - return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded')); - } - - // Try loading plugins from different location - var p = Promise() - .then(function() { - // Locate plugin and load pacjage.json - try { - var res = resolve.sync('./package.json', { basedir: that.root }); - - that.root = path.dirname(res); - that.packageInfos = require(res); - } catch (err) { - if (!isModuleNotFound(err)) throw err; - - that.packageInfos = undefined; - that.content = undefined; - - return; - } - - // Load plugin JS content - try { - that.content = require(that.root); - } catch(err) { - // It's no big deal if the plugin doesn't have an "index.js" - // (For example: themes) - if (isModuleNotFound(err)) { - that.content = {}; - } else { - throw new error.PluginError(err, { - plugin: that.id - }); - } - } - }) - - .then(that.validate) - - // Validate the configuration and update it - .then(function() { - var config = that.book.config.get(that.getConfigKey(), {}); - return that.validateConfig(config); - }) - .then(function(config) { - that.book.config.set(that.getConfigKey(), config); - }); - - this.log.info('loading plugin "' + this.id + '"... '); - return this.log.info.promise(p); -}; - -// Verify the definition of a plugin -// Also verify that the plugin accepts the current gitbook version -// This method throws erros if plugin is invalid -BookPlugin.prototype.validate = function() { - var isValid = ( - this.isLoaded() && - this.packageInfos && - this.packageInfos.name && - this.packageInfos.engines && - this.packageInfos.engines.gitbook - ); - - if (!isValid) { - throw new Error('Error loading plugin "' + this.id + '" at "' + this.root + '"'); - } - - if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) { - throw new Error('GitBook doesn\'t satisfy the requirements of this plugin: '+this.packageInfos.engines.gitbook); - } -}; - -// Normalize, validate configuration for this plugin using its schema -// Throw an error when shcema is not respected -BookPlugin.prototype.validateConfig = function(config) { - var that = this; - - return Promise() - .then(function() { - var schema = that.packageInfos.gitbook || {}; - if (!schema) return config; - - // Normalize schema - schema.id = '/'+that.getConfigKey(); - schema.type = 'object'; - - // Validate and throw if invalid - var v = new jsonschema.Validator(); - var result = v.validate(config, schema, { - propertyName: that.getConfigKey() - }); - - // Throw error - if (result.errors.length > 0) { - throw new error.ConfigurationError(new Error(result.errors[0].stack)); - } - - // Insert default values - var defaults = jsonSchemaDefaults(schema); - return mergeDefaults(config, defaults); - }); -}; - -// Return key for configuration -BookPlugin.prototype.getConfigKey = function() { - return 'pluginsConfig.'+this.id; -}; - -// Call a hook and returns its result -BookPlugin.prototype.hook = function(name, input) { - var that = this; - var hookFunc = this.content.hooks? this.content.hooks[name] : null; - input = input || {}; - - if (!hookFunc) return Promise(input); - - this.book.log.debug.ln('call hook "' + name + '" for plugin "' + this.id + '"'); - if (!_.contains(HOOKS, name)) { - this.book.log.warn.ln('hook "'+name+'" used by plugin "'+this.name+'" is deprecated, and will be removed in the coming versions'); - } - - return Promise() - .then(function() { - return that.bind(hookFunc)(input); - }); -}; - -// Return resources without normalization -BookPlugin.prototype._getResources = function(base) { - var that = this; - - return Promise() - .then(function() { - if (that._resources[base]) return that._resources[base]; - - var book = that.content[base]; - - // Compatibility with version 1.x.x - if (base == 'website') book = book || that.content.book; - - // Nothing specified, fallback to default - if (!book) { - return Promise({}); - } - - // Dynamic function - if(typeof book === 'function') { - // Call giving it the context of our book - return that.bind(book)(); - } - - // Plain data object - return book; - }) - - .then(function(resources) { - that._resources[base] = resources; - return _.cloneDeep(resources); - }); -}; - -// Normalize a specific resource -BookPlugin.prototype.normalizeResource = function(resource) { - // Parse the resource path - var parsed = url.parse(resource); - - // This is a remote resource - // so we will simply link to using it's URL - if (parsed.protocol) { - return { - 'url': resource - }; - } - - // This will be copied over from disk - // and shipped with the book's build - return { 'path': this.npmId+'/'+resource }; -}; - - -// Normalize resources and return them -BookPlugin.prototype.getResources = function(base) { - var that = this; - - return this._getResources(base) - .then(function(resources) { - _.each(RESOURCES, function(resourceType) { - resources[resourceType] = _.map(resources[resourceType] || [], that.normalizeResource); - }); - - return resources; - }); -}; - -// Normalize filters and return them -BookPlugin.prototype.getFilters = function() { - var that = this; - - return _.mapValues(this.content.filters || {}, function(fn, filter) { - return function() { - var ctx = _.extend(compatibility.pluginCtx(that), this); - - return fn.apply(ctx, arguments); - }; - }); -}; - -// Normalize blocks and return them -BookPlugin.prototype.getBlocks = function() { - var that = this; - - return _.mapValues(this.content.blocks || {}, function(block, blockName) { - block = _.isFunction(block)? { process: block } : block; - - var fn = block.process; - block.process = function() { - var ctx = _.extend(compatibility.pluginCtx(that), this); - - return fn.apply(ctx, arguments); - }; - - return block; - }); -}; - -module.exports = BookPlugin; -module.exports.RESOURCES = RESOURCES; - diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js deleted file mode 100644 index fe9406d..0000000 --- a/lib/plugins/registry.js +++ /dev/null @@ -1,172 +0,0 @@ -var npm = require('npm'); -var npmi = require('npmi'); -var path = require('path'); -var semver = require('semver'); -var _ = require('lodash'); -var readInstalled = require('read-installed'); - -var Promise = require('../utils/promise'); -var gitbook = require('../gitbook'); - -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 && 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'); - }); -} - -// List all packages installed inside a folder -// Returns an ordered list of plugins -function listInstalled(folder) { - var options = { - dev: false, - log: function() {}, - depth: 4 - }; - var results = []; - - function onPackage(pkg, isRoot) { - if (!validateId(pkg.name)){ - if (!isRoot) return; - } else { - results.push({ - name: pluginId(pkg.name), - version: pkg.version, - path: pkg.realPath, - depth: pkg.depth - }); - } - - _.each(pkg.dependencies, function(dep) { - onPackage(dep); - }); - } - - return Promise.nfcall(readInstalled, folder, options) - .then(function(data) { - onPackage(data, true); - return _.uniq(results, 'name'); - }); -} - -// List installed plugins for a book (defaults and installed) -function listPlugins(book) { - return Promise.all([ - listInstalled(path.resolve(__dirname, '../..')), - listInstalled(book.root), - book.originalRoot? listInstalled(book.originalRoot) : Promise([]), - book.isLanguageBook()? listInstalled(book.parent.root) : Promise([]) - ]) - .spread(function() { - var args = _.toArray(arguments); - - var results = _.reduce(args, function(out, a) { - return out.concat(a); - }, []); - - return _.uniq(results, 'name'); - }); -} - -module.exports = { - npmId: npmId, - pluginId: pluginId, - validateId: validateId, - - resolve: resolveVersion, - link: linkPlugin, - install: installPlugin, - list: listPlugins, - listInstalled: listInstalled -}; diff --git a/lib/plugins/validateConfig.js b/lib/plugins/validateConfig.js new file mode 100644 index 0000000..37f3c96 --- /dev/null +++ b/lib/plugins/validateConfig.js @@ -0,0 +1,71 @@ +var Immutable = require('immutable'); +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); +var mergeDefaults = require('merge-defaults'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); + +/** + Validate one plugin for a book and update book's confiration + + @param {Book} + @param {Plugin} + @return {Book} +*/ +function validatePluginConfig(book, plugin) { + var config = book.getConfig(); + var packageInfos = plugin.getPackage(); + + var configKey = [ + 'pluginsConfig', + plugin.getName() + ].join('.'); + + var pluginConfig = config.getValue(configKey, {}).toJS(); + + var schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS(); + if (!schema) return book; + + // Normalize schema + schema.id = '/' + configKey; + schema.type = 'object'; + + // Validate and throw if invalid + var v = new jsonschema.Validator(); + var result = v.validate(pluginConfig, schema, { + propertyName: configKey + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + var defaults = jsonSchemaDefaults(schema); + pluginConfig = mergeDefaults(pluginConfig, defaults); + + + // Update configuration + config = config.setValue(configKey, pluginConfig); + + // Return new book + return book.set('config', config); +} + +/** + Validate a book configuration for plugins and + returns an update configuration with default values. + + @param {Book} + @param {OrderedMap<String:Plugin>} + @return {Promise<Book>} +*/ +function validateConfig(book, plugins) { + return Promise.reduce(plugins, function(newBook, plugin) { + return validatePluginConfig(newBook, plugin); + }, book); +} + +module.exports = validateConfig; diff --git a/lib/plugins/validatePlugin.js b/lib/plugins/validatePlugin.js new file mode 100644 index 0000000..4baa911 --- /dev/null +++ b/lib/plugins/validatePlugin.js @@ -0,0 +1,34 @@ +var gitbook = require('../gitbook'); + +var Promise = require('../utils/promise'); + +/** + Validate a plugin + + @param {Plugin} + @return {Promise<Plugin>} +*/ +function validatePlugin(plugin) { + var packageInfos = plugin.getPackage(); + + var isValid = ( + plugin.isLoaded() && + packageInfos && + packageInfos.get('name') && + packageInfos.get('engines') && + packageInfos.get('engines').get('gitbook') + ); + + if (!isValid) { + return Promise.reject(new Error('Error loading plugin "' + plugin.getName() + '" at "' + plugin.getPath() + '"')); + } + + var engine = packageInfos.get('engines').get('gitbook'); + if (!gitbook.satisfies(engine)) { + return Promise.reject(new Error('GitBook doesn\'t satisfy the requirements of this plugin: ' + engine)); + } + + return Promise(plugin); +} + +module.exports = validatePlugin; diff --git a/lib/template/blocks.js b/lib/template/blocks.js deleted file mode 100644 index 5dfb0c8..0000000 --- a/lib/template/blocks.js +++ /dev/null @@ -1,36 +0,0 @@ -var _ = require('lodash'); - -module.exports = { - // Return non-parsed html - // since blocks are by default non-parsable, a simple identity method works fine - html: _.identity, - - // Highlight a code block - // This block can be replaced by plugins - code: function(blk) { - return { - html: false, - body: blk.body - }; - }, - - // Render some markdown to HTML - markdown: function(blk) { - return this.book.renderInline('markdown', blk.body) - .then(function(out) { - return { body: out }; - }); - }, - asciidoc: function(blk) { - return this.book.renderInline('asciidoc', blk.body) - .then(function(out) { - return { body: out }; - }); - }, - markup: function(blk) { - return this.book.renderInline(this.ctx.file.type, blk.body) - .then(function(out) { - return { body: out }; - }); - } -}; diff --git a/lib/template/index.js b/lib/template/index.js deleted file mode 100644 index ae11bc9..0000000 --- a/lib/template/index.js +++ /dev/null @@ -1,552 +0,0 @@ -var _ = require('lodash'); -var path = require('path'); -var nunjucks = require('nunjucks'); -var escapeStringRegexp = require('escape-string-regexp'); - -var Promise = require('../utils/promise'); -var error = require('../utils/error'); -var parsers = require('../parsers'); -var defaultBlocks = require('./blocks'); -var defaultFilters = require('./filters'); -var Loader = require('./loader'); - -var NODE_ENDARGS = '%%endargs%%'; - -// 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; -} - -// Extract kwargs from an arguments array -function extractKwargs(args) { - var last = _.last(args); - return (_.isObject(last) && last.__keywords)? args.pop() : {}; -} - -function TemplateEngine(output) { - this.output = output; - this.book = output.book; - this.log = this.book.log; - - // Create file loader - this.loader = new Loader(this); - - // Create nunjucks instance - this.env = new nunjucks.Environment( - this.loader, - { - // Escaping is done after by the asciidoc/markdown parser - autoescape: false, - - // Syntax - tags: { - blockStart: '{%', - blockEnd: '%}', - variableStart: '{{', - variableEnd: '}}', - commentStart: '{###', - commentEnd: '###}' - } - } - ); - - // List of tags shortcuts - this.shortcuts = []; - - // Map of blocks bodies (that requires post-processing) - this.blockBodies = {}; - - // Map of added blocks - this.blocks = {}; - - // Bind methods - _.bindAll(this); - - // Add default blocks and filters - this.addBlocks(defaultBlocks); - this.addFilters(defaultFilters); - - // Build context for this book with depreacted fields - this.ctx = { - template: this, - book: this.book, - output: this.output - }; - error.deprecateField(this.ctx, 'generator', this.output.name, '"generator" property is deprecated, use "output.generator" instead'); -} - -/* - Bind a function to a context - Filters and blocks are binded to this context. - - @param {Function} - @param {Function} -*/ -TemplateEngine.prototype.bindContext = function(func) { - var that = this; - - return function() { - var ctx = _.extend({ - ctx: this.ctx - }, that.ctx); - - return func.apply(ctx, arguments); - }; -}; - -/* - Interpolate a string content to replace shortcuts according to the filetype. - - @param {String} filepath - @param {String} source - @param {String} -*/ -TemplateEngine.prototype.interpolate = function(filepath, source) { - var parser = parsers.getByExt(path.extname(filepath)); - var type = parser? parser.name : null; - - return this.applyShortcuts(type, source); -}; - -/* - Add a new custom filter, it bind to the right context - - @param {String} - @param {Function} -*/ -TemplateEngine.prototype.addFilter = function(filterName, func) { - try { - this.env.getFilter(filterName); - 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.env.addFilter(filterName, this.bindContext(function() { - var ctx = this; - var args = Array.prototype.slice.apply(arguments); - var callback = _.last(args); - - Promise() - .then(function() { - return func.apply(ctx, args.slice(0, -1)); - }) - .nodeify(callback); - }), true); - return true; -}; - -/* - Add multiple filters at once - - @param {Map<String:Function>} -*/ -TemplateEngine.prototype.addFilters = function(filters) { - _.each(filters, function(filter, name) { - this.addFilter(name, filter); - }, this); -}; - -/* - Return true if a block is defined - - @param {String} -*/ -TemplateEngine.prototype.hasBlock = function(name) { - return this.env.hasExtension(blockExtName(name)); -}; - -/* - Remove/Disable a block - - @param {String} -*/ -TemplateEngine.prototype.removeBlock = function(name) { - if (!this.hasBlock(name)) return; - - // Remove nunjucks extension - this.env.removeExtension(blockExtName(name)); - - // Cleanup shortcuts - this.shortcuts = _.reject(this.shortcuts, { - block: name - }); -}; - -/* - Add a block. - Using the extensions of nunjucks: https://mozilla.github.io/nunjucks/api.html#addextension - - @param {String} name - @param {BlockDescriptor|Function} block - @param {Function} block.process: function to be called to render the block - @param {String} block.end: name of the end tag of this block (default to "end<name>") - @param {Array<String>} block.blocks: list of inner blocks to parse - @param {Array<Shortcut>} block.shortcuts: list of shortcuts to parse this block -*/ -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, - blocks: [] - }); - - 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'); - } - - // Cleanup previous block - this.removeBlock(name); - - this.log.debug.ln('add block \''+name+'\''); - this.blocks[name] = block; - - Ext = function () { - this.tags = [name]; - - this.parse = function(parser, nodes) { - var lastBlockName = null; - var lastBlockArgs = null; - var allBlocks = block.blocks.concat([block.end]); - - // Parse first block - var tok = parser.nextToken(); - lastBlockArgs = parser.parseSignature(null, true); - parser.advanceAfterBlockEnd(tok.value); - - var args = new nodes.NodeList(); - var bodies = []; - var blockNamesNode = new nodes.Array(tok.lineno, tok.colno); - var blockArgCounts = new nodes.Array(tok.lineno, tok.colno); - - // Parse while we found "end<block>" - do { - // Read body - var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks); - - // Handle body with previous block name and args - blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName)); - blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length)); - bodies.push(currentBody); - - // Append arguments of this block as arguments of the run function - _.each(lastBlockArgs.children, function(child) { - args.addChild(child); - }); - - // Read new block - lastBlockName = parser.nextToken().value; - - // Parse signature and move to the end of the block - if (lastBlockName != block.end) { - lastBlockArgs = parser.parseSignature(null, true); - } - - parser.advanceAfterBlockEnd(lastBlockName); - } while (lastBlockName != block.end); - - args.addChild(blockNamesNode); - args.addChild(blockArgCounts); - args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS)); - - return new nodes.CallExtensionAsync(this, 'run', args, bodies); - }; - - this.run = function(context) { - var fnArgs = Array.prototype.slice.call(arguments, 1); - - var args; - var blocks = []; - var bodies = []; - var blockNames; - var blockArgCounts; - var callback; - - // Extract callback - callback = fnArgs.pop(); - - // Detect end of arguments - var endArgIndex = fnArgs.indexOf(NODE_ENDARGS); - - // Extract arguments and bodies - args = fnArgs.slice(0, endArgIndex); - bodies = fnArgs.slice(endArgIndex + 1); - - // Extract block counts - blockArgCounts = args.pop(); - blockNames = args.pop(); - - // Recreate list of blocks - _.each(blockNames, function(name, i) { - var countArgs = blockArgCounts[i]; - var blockBody = bodies.shift(); - - var blockArgs = countArgs > 0? args.slice(0, countArgs) : []; - args = args.slice(countArgs); - var blockKwargs = extractKwargs(blockArgs); - - blocks.push({ - name: name, - body: blockBody(), - args: blockArgs, - kwargs: blockKwargs - }); - }); - - var mainBlock = blocks.shift(); - mainBlock.blocks = blocks; - - Promise() - .then(function() { - return that.applyBlock(name, mainBlock, context); - }) - - // Process the block returned - .then(that.processBlock) - .nodeify(callback); - }; - }; - - // Add the Extension - this.env.addExtension(extName, new Ext()); - - // 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.shortcuts.push({ - block: name, - parsers: shortcut.parsers, - start: shortcut.start, - end: shortcut.end, - tag: { - start: name, - end: block.end - } - }); - }, this); -}; - -/* - Add multiple blocks at once - - @param {Array<BlockDescriptor>} -*/ -TemplateEngine.prototype.addBlocks = function(blocks) { - _.each(blocks, function(block, name) { - this.addBlock(name, block); - }, this); -}; - -/* - Apply a block to some content - This method result depends on the type of block (async or sync) - - - @param {String} name: name of the block type to apply - @param {Block} blk: content of the block - @param {Object} ctx: context of execution of the block - @return {Block|Promise<Block>} -*/ -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 (_.isString(blk)) { - blk = { - body: blk - }; - } - - blk = _.defaults(blk, { - args: [], - kwargs: {}, - blocks: [] - }); - - // Bind and call block processor - func = this.bindContext(block.process); - r = func.call(ctx || {}, blk); - - if (Promise.isPromiseAlike(r)) return Promise(r).then(normBlockResult); - else return normBlockResult(r); -}; - -/* - Process the result of block in a context. It returns the content to append to the output. - It can return an "anchor" that will be replaced by "replaceBlocks" in "postProcess" - - @param {Block} - @return {String} -*/ -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+'%-}}'; -}; - -/* - Render a string (without post processing) - - @param {String} content: template's content to render - @param {Object} context - @param {Object} options - @param {String} options.path: pathname to the template - @return {Promise<String>} -*/ -TemplateEngine.prototype.render = function(content, context, options) { - options = _.defaults(options || {}, { - path: 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 Promise.nfcall(this.env.renderString.bind(this.env), content, context, options) - .fail(function(err) { - throw error.TemplateError(err, { - filename: filename || '<inline>' - }); - }); -}; - -/* - Render a string (with post processing) - - @param {String} content: template's content to render - @param {Object} context - @param {Object} options - @return {Promise<String>} -*/ -TemplateEngine.prototype.renderString = function(content, context, options) { - return this.render(content, context, options) - .then(this.postProcess); -}; - -/* - Apply a shortcut of block to a template - - @param {String} content - @param {Shortcut} shortcut - @return {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+' %}'; - }); -}; - - -/* - Apply all shortcut of blocks to a template - - @param {String} type: type of template ("markdown", "asciidoc") - @param {String} content - @return {String} -*/ -TemplateEngine.prototype.applyShortcuts = function(type, content) { - return _.chain(this.shortcuts) - .filter(function(shortcut) { - return _.contains(shortcut.parsers, type); - }) - .reduce(this.applyShortcut, content) - .value(); -}; - -/* - Replace position markers of blocks by body after processing - This is done to avoid that markdown/asciidoc processer parse the block content - - @param {String} content - @return {String} -*/ -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; - }); -}; - - - -/* - Post process templating result: remplace block's anchors and apply "post" - - @param {String} content - @return {Promise<String>} -*/ -TemplateEngine.prototype.postProcess = function(content) { - var that = this; - - return Promise(content) - .then(that.replaceBlocks) - .then(function(_content) { - 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); - }); -}; - -module.exports = TemplateEngine; diff --git a/lib/template/loader.js b/lib/template/loader.js deleted file mode 100644 index 23d179a..0000000 --- a/lib/template/loader.js +++ /dev/null @@ -1,42 +0,0 @@ -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/templating/__tests__/conrefsLoader.js b/lib/templating/__tests__/conrefsLoader.js new file mode 100644 index 0000000..3480a48 --- /dev/null +++ b/lib/templating/__tests__/conrefsLoader.js @@ -0,0 +1,34 @@ +var TemplateEngine = require('../../models/templateEngine'); +var renderTemplate = require('../render'); + +describe('ConrefsLoader', function() { + var ConrefsLoader = require('../conrefsLoader'); + + var engine = TemplateEngine({ + loader: new ConrefsLoader(__dirname) + }); + + describe('Git', function() { + pit('should include content from git', function() { + return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}') + .then(function(str) { + expect(str).toBe('Hello from git'); + }); + }); + + pit('should handle deep inclusion (1)', function() { + return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}') + .then(function(str) { + expect(str).toBe('First Hello. Hello from git'); + }); + }); + + pit('should handle deep inclusion (2)', function() { + return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}') + .then(function(str) { + expect(str).toBe('First Hello. Hello from git'); + }); + }); + }); +}); + diff --git a/lib/templating/conrefsLoader.js b/lib/templating/conrefsLoader.js new file mode 100644 index 0000000..c3e5048 --- /dev/null +++ b/lib/templating/conrefsLoader.js @@ -0,0 +1,72 @@ +var path = require('path'); +var nunjucks = require('nunjucks'); + +var fs = require('../utils/fs'); +var Git = require('../utils/git'); +var LocationUtils = require('../utils/location'); +var PathUtils = require('../utils/path'); + + +/** + Template loader resolving both: + - relative url ("./test.md") + - absolute url ("/test.md") + - git url ("") +*/ +var ConrefsLoader = nunjucks.Loader.extend({ + async: true, + + init: function(rootFolder, logger) { + this.rootFolder = rootFolder; + this.logger = logger; + this.git = new Git(); + }, + + getSource: function(sourceURL, callback) { + var that = this; + + this.git.resolve(sourceURL) + .then(function(filepath) { + // Is local file + if (!filepath) { + filepath = path.resolve(sourceURL); + } else { + if (that.logger) that.logger.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 + }; + }); + }) + .nodeify(callback); + }, + + resolve: function(from, to) { + // If origin is in the book, we enforce result file to be in the book + if (PathUtils.isInRoot(this.rootFolder, from)) { + var href = LocationUtils.toAbsolute(to, path.dirname(from), ''); + return PathUtils.resolveInRoot(this.rootFolder, href); + } + + // If origin is in a git repository, we resolve file in the git repository + var gitRoot = this.git.resolveRoot(from); + if (gitRoot) { + return PathUtils.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' + isRelative: function(filename) { + return LocationUtils.isRelative(filename); + } +}); + +module.exports = ConrefsLoader; diff --git a/lib/templating/index.js b/lib/templating/index.js new file mode 100644 index 0000000..a33965d --- /dev/null +++ b/lib/templating/index.js @@ -0,0 +1,9 @@ + +module.exports = { + render: require('./render'), + renderFile: require('./renderFile'), + postRender: require('./postRender'), + + ConrefsLoader: require('./conrefsLoader'), + ThemesLoader: require('./themesLoader') +}; diff --git a/lib/templating/listShortcuts.js b/lib/templating/listShortcuts.js new file mode 100644 index 0000000..8f2388b --- /dev/null +++ b/lib/templating/listShortcuts.js @@ -0,0 +1,31 @@ +var Immutable = require('immutable'); +var parsers = require('../parsers'); + +/** + Return a list of all shortcuts that can apply + to a file for a TemplatEngine + + @param {TemplateEngine} engine + @param {String} filePath + @return {List<Shortcut>} +*/ +function listShortcuts(engine, filePath) { + var blocks = engine.getBlocks(); + var parser = parsers.getForFile(filePath); + if (!parser) { + return Immutable.List(); + } + + return blocks + .map(function(block) { + var shortcuts = block.getShortcuts(); + + return shortcuts.filter(function(shortcut) { + var parsers = shortcut.get('parsers'); + return parsers.includes(parser.name); + }); + }) + .flatten(1); +} + +module.exports = listShortcuts; diff --git a/lib/templating/postRender.js b/lib/templating/postRender.js new file mode 100644 index 0000000..6928e82 --- /dev/null +++ b/lib/templating/postRender.js @@ -0,0 +1,28 @@ +var Promise = require('../utils/promise'); +var replaceBlocks = require('./replaceBlocks'); + +/** + Post render a template: + - Execute "post" for blocks + - Replace block content + + @param {TemplateEngine} engine + @param {String} content + @return {Promise<String>} +*/ +function postRender(engine, content) { + var result = replaceBlocks(content); + + return Promise.forEach(result.blocks, function(blockType) { + var block = engine.getBlock(); + var post = block.getPost(); + if (!post) { + return; + } + + return post(); + }) + .thenResolve(result.content); +} + +module.exports = postRender; diff --git a/lib/templating/render.js b/lib/templating/render.js new file mode 100644 index 0000000..bf21cfe --- /dev/null +++ b/lib/templating/render.js @@ -0,0 +1,30 @@ +var Promise = require('../utils/promise'); + +var replaceShortcuts = require('./replaceShortcuts'); + +/** + Render a template + + @param {TemplateEngine} engine + @param {String} filePath + @param {String} content + @param {Object} context + @return {Promise<String>} +*/ +function renderTemplate(engine, filePath, content, context) { + context = context || {}; + var env = engine.toNunjucks(); + + content = replaceShortcuts(engine, filePath, content); + + return Promise.nfcall( + env.renderString.bind(env), + content, + context, + { + path: filePath + } + ); +} + +module.exports = renderTemplate; diff --git a/lib/templating/renderFile.js b/lib/templating/renderFile.js new file mode 100644 index 0000000..9b74e5b --- /dev/null +++ b/lib/templating/renderFile.js @@ -0,0 +1,38 @@ +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var render = require('./render'); + +/** + Render a template + + @param {TemplateEngine} engine + @param {String} filePath + @param {Object} context + @return {Promise<String>} +*/ +function renderTemplateFile(engine, filePath, context) { + var loader = engine.getLoader(); + + return Promise() + .then(function() { + if (!loader.async) { + return loader.getSource(filePath); + } + + var deferred = Promise.defer(); + loader.getSource(filePath, deferred.makeNodeResolver()); + return deferred.promise; + }) + .then(function(result) { + if (!result) { + throw error.TemplateError(new Error('Not found'), { + filename: filePath + }); + } + + return render(engine, result.path, result.src, context); + }); + +} + +module.exports = renderTemplateFile; diff --git a/lib/templating/replaceBlocks.js b/lib/templating/replaceBlocks.js new file mode 100644 index 0000000..4b1c37f --- /dev/null +++ b/lib/templating/replaceBlocks.js @@ -0,0 +1,34 @@ +var Immutable = require('immutable'); +var TemplateBlock = require('../models/templateBlock'); + +/** + Replace position markers of blocks by body after processing + This is done to avoid that markdown/asciidoc processer parse the block content + + @param {String} content + @return {Object} {blocks: Set, content: String} +*/ +function replaceBlocks(content) { + var blockTypes = new Immutable.Set(); + var newContent = content.replace(/\{\{\-\%([\s\S]+?)\%\-\}\}/g, function(match, key) { + var replacedWith = match; + + var block = TemplateBlock.getBlockResultByKey(key); + if (block) { + var result = replaceBlocks(block.body); + + blockTypes = blockTypes.add(block.name); + blockTypes = blockTypes.concat(result.blocks); + replacedWith = result.content; + } + + return replacedWith; + }); + + return { + content: newContent, + blocks: blockTypes + }; +} + +module.exports = replaceBlocks; diff --git a/lib/templating/replaceShortcuts.js b/lib/templating/replaceShortcuts.js new file mode 100644 index 0000000..f6a51cb --- /dev/null +++ b/lib/templating/replaceShortcuts.js @@ -0,0 +1,37 @@ +var escapeStringRegexp = require('escape-string-regexp'); +var listShortcuts = require('./listShortcuts'); + +/* + Apply a shortcut of block to a template + @param {String} content + @param {Shortcut} shortcut + @return {String} +*/ +function applyShortcut(content, shortcut) { + var tags = shortcut.get('tag'); + var start = shortcut.get('start'); + var end = shortcut.get('end'); + + var regex = new RegExp( + escapeStringRegexp(start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(end), + 'g' + ); + return content.replace(regex, function(all, match) { + return '{% ' + tags.start + ' %}' + match + '{% ' + tags.end + ' %}'; + }); +} + +/** + Replace shortcuts from blocks in a string + + @param {TemplateEngine} engine + @param {String} filePath + @param {String} content + @return {String} +*/ +function replaceShortcuts(engine, filePath, content) { + var shortcuts = listShortcuts(engine, filePath); + return shortcuts.reduce(applyShortcut, content); +} + +module.exports = replaceShortcuts; diff --git a/lib/output/website/themeLoader.js b/lib/templating/themesLoader.js index 774a39e..69c3879 100644 --- a/lib/output/website/themeLoader.js +++ b/lib/templating/themesLoader.js @@ -1,27 +1,19 @@ -var _ = require('lodash'); +var Immutable = require('immutable'); +var nunjucks = require('nunjucks'); var fs = require('fs'); var path = require('path'); -var nunjucks = require('nunjucks'); -/* - Nunjucks loader similar to FileSystemLoader, but avoid infinite looping -*/ +var PathUtils = require('../utils/path'); -/* - Return true if a filename is relative. -*/ -function isRelative(filename) { - return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0); -} -var ThemeLoader = nunjucks.Loader.extend({ +var ThemesLoader = nunjucks.Loader.extend({ init: function(searchPaths) { - this.searchPaths = _.map(searchPaths, path.normalize); + this.searchPaths = Immutable.List(searchPaths) + .map(path.normalize); }, /* Read source of a resolved filepath - @param {String} @return {Object} */ @@ -58,24 +50,21 @@ var ThemeLoader = nunjucks.Loader.extend({ /* Get original search path containing a template - @param {String} filepath @return {String} searchPath */ getSearchPath: function(filepath) { - return _.chain(this.searchPaths) + return this.searchPaths .sortBy(function(s) { return -s.length; }) .find(function(basePath) { return (filepath && filepath.indexOf(basePath) === 0); - }) - .value(); + }); }, /* Get template name from a filepath - @param {String} filepath @return {String} name */ @@ -86,7 +75,6 @@ var ThemeLoader = nunjucks.Loader.extend({ /* Resolve a template from a current template - @param {String|null} from @param {String} to @return {String|null} @@ -95,7 +83,7 @@ var ThemeLoader = nunjucks.Loader.extend({ var searchPaths = this.searchPaths; // Relative template like "./test.html" - if (isRelative(to) && from) { + if (PathUtils.isPureRelative(to) && from) { return path.resolve(path.dirname(from), to); } @@ -111,7 +99,7 @@ var ThemeLoader = nunjucks.Loader.extend({ } // Absolute template to resolve in root folder - var resultFolder = _.find(searchPaths, function(basePath) { + var resultFolder = searchPaths.find(function(basePath) { var p = path.resolve(basePath, to); return ( @@ -124,4 +112,4 @@ var ThemeLoader = nunjucks.Loader.extend({ } }); -module.exports = ThemeLoader; +module.exports = ThemesLoader; diff --git a/lib/utils/__tests__/git.js b/lib/utils/__tests__/git.js new file mode 100644 index 0000000..6eed81e --- /dev/null +++ b/lib/utils/__tests__/git.js @@ -0,0 +1,58 @@ +var should = require('should'); +var path = require('path'); +var os = require('os'); + +var Git = require('../git'); + +describe('Git', function() { + + describe('URL parsing', function() { + + it('should correctly validate git urls', function() { + // HTTPS + expect(Git.isUrl('git+https://github.com/Hello/world.git')).toBeTruthy(); + + // SSH + expect(Git.isUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1')).toBeTruthy(); + + // Non valid + expect(Git.isUrl('https://github.com/Hello/world.git')).not.toBeTruthy(); + expect(Git.isUrl('README.md')).not.toBeTruthy(); + }); + + it('should parse HTTPS urls', function() { + var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md'); + + expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + expect(parts.ref).toBe(null); + expect(parts.filepath).toBe('test.md'); + }); + + it('should parse HTTPS urls with a reference', function() { + var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#1.0.0'); + + expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git'); + expect(parts.ref).toBe('1.0.0'); + expect(parts.filepath).toBe('test.md'); + }); + + it('should parse SSH urls', function() { + var parts = Git.parseUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + + expect(parts.host).toBe('git@github.com:GitbookIO/gitbook.git'); + expect(parts.ref).toBe('e1594cde2c32e4ff48f6c4eff3d3d461743d74e1'); + expect(parts.filepath).toBe('directory/README.md'); + }); + }); + + describe('Cloning and resolving', function() { + pit('should clone an HTTPS url', function() { + var git = new Git(path.join(os.tmpdir(), 'test-git-'+Date.now())); + return git.resolve('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md') + .then(function(filename) { + expect(path.extname(filename)).toBe('.md'); + }); + }); + }); + +}); diff --git a/lib/utils/__tests__/location.js b/lib/utils/__tests__/location.js new file mode 100644 index 0000000..f2037ff --- /dev/null +++ b/lib/utils/__tests__/location.js @@ -0,0 +1,78 @@ +jest.autoMockOff(); + +describe('LocationUtils', function() { + var LocationUtils = require('../location'); + + it('should correctly test external location', function() { + expect(LocationUtils.isExternal('http://google.fr')).toBe(true); + expect(LocationUtils.isExternal('https://google.fr')).toBe(true); + expect(LocationUtils.isExternal('test.md')).toBe(false); + expect(LocationUtils.isExternal('folder/test.md')).toBe(false); + expect(LocationUtils.isExternal('/folder/test.md')).toBe(false); + }); + + it('should correctly detect anchor location', function() { + expect(LocationUtils.isAnchor('#test')).toBe(true); + expect(LocationUtils.isAnchor(' #test')).toBe(true); + expect(LocationUtils.isAnchor('https://google.fr#test')).toBe(false); + expect(LocationUtils.isAnchor('test.md#test')).toBe(false); + }); + + describe('.relative', function() { + it('should resolve to a relative path (same folder)', function() { + expect(LocationUtils.relative('links/', 'links/test.md')).toBe('test.md'); + }); + + it('should resolve to a relative path (parent folder)', function() { + expect(LocationUtils.relative('links/', 'test.md')).toBe('../test.md'); + }); + + it('should resolve to a relative path (child folder)', function() { + expect(LocationUtils.relative('links/', 'links/hello/test.md')).toBe('hello/test.md'); + }); + }); + + describe('.toAbsolute', function() { + it('should correctly transform as absolute', function() { + expect(LocationUtils.toAbsolute('http://google.fr')).toBe('http://google.fr'); + expect(LocationUtils.toAbsolute('test.md', './', './')).toBe('test.md'); + expect(LocationUtils.toAbsolute('folder/test.md', './', './')).toBe('folder/test.md'); + }); + + it('should correctly handle windows path', function() { + expect(LocationUtils.toAbsolute('folder\\test.md', './', './')).toBe('folder/test.md'); + }); + + it('should correctly handle absolute path', function() { + expect(LocationUtils.toAbsolute('/test.md', './', './')).toBe('test.md'); + expect(LocationUtils.toAbsolute('/test.md', 'test', 'test')).toBe('../test.md'); + expect(LocationUtils.toAbsolute('/sub/test.md', 'test', 'test')).toBe('../sub/test.md'); + expect(LocationUtils.toAbsolute('/test.png', 'folder', '')).toBe('test.png'); + }); + + it('should correctly handle absolute path (windows)', function() { + expect(LocationUtils.toAbsolute('\\test.png', 'folder', '')).toBe('test.png'); + }); + + it('should resolve path starting by "/" in root directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './', './') + ).toBe('test/hello.md'); + }); + + it('should resolve path starting by "/" in child directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './hello', './') + ).toBe('test/hello.md'); + }); + + it('should resolve path starting by "/" in child directory, with same output directory', function() { + expect( + LocationUtils.toAbsolute('/test/hello.md', './hello', './hello') + ).toBe('../test/hello.md'); + }); + }); + +}); + + diff --git a/lib/utils/__tests__/path.js b/lib/utils/__tests__/path.js new file mode 100644 index 0000000..22bb016 --- /dev/null +++ b/lib/utils/__tests__/path.js @@ -0,0 +1,17 @@ +var path = require('path'); + +describe('Paths', function() { + var PathUtils = require('..//path'); + + describe('setExtension', function() { + it('should correctly change extension of filename', function() { + expect(PathUtils.setExtension('test.md', '.html')).toBe('test.html'); + expect(PathUtils.setExtension('test.md', '.json')).toBe('test.json'); + }); + + it('should correctly change extension of path', function() { + expect(PathUtils.setExtension('hello/test.md', '.html')).toBe(path.normalize('hello/test.html')); + expect(PathUtils.setExtension('hello/test.md', '.json')).toBe(path.normalize('hello/test.json')); + }); + }); +}); diff --git a/lib/utils/error.js b/lib/utils/error.js index 27fa59d..7686779 100644 --- a/lib/utils/error.js +++ b/lib/utils/error.js @@ -1,15 +1,12 @@ -var _ = require('lodash'); +var is = require('is'); + 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); + if (is.string(err)) err = new Error(err); err.message = err.message.replace(/^Error: /, ''); return err; @@ -32,6 +29,13 @@ var FileNotFoundError = TypedError({ filename: null }); +// A file cannot be parsed +var FileNotParsableError = TypedError({ + type: 'file.not-parsable', + message: '"{filename}" file cannot be parsed', + filename: null +}); + // A file is outside the scope var FileOutOfScopeError = TypedError({ type: 'file.out-of-scope', @@ -77,14 +81,6 @@ var EbookError = WrappedError({ 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, @@ -92,14 +88,12 @@ module.exports = { OutputError: OutputError, RequireInstallError: RequireInstallError, + FileNotParsableError: FileNotParsableError, FileNotFoundError: FileNotFoundError, FileOutOfScopeError: FileOutOfScopeError, TemplateError: TemplateError, PluginError: PluginError, ConfigurationError: ConfigurationError, - EbookError: EbookError, - - deprecateMethod: deprecateMethod, - deprecateField: deprecateField + EbookError: EbookError }; diff --git a/lib/utils/fs.js b/lib/utils/fs.js index 42fd3c6..3f97096 100644 --- a/lib/utils/fs.js +++ b/lib/utils/fs.js @@ -97,12 +97,46 @@ function rmDir(base) { }); } +/** + Assert a file, if it doesn't exist, call "generator" + + @param {String} filePath + @param {Function} generator + @return {Promise} +*/ +function assertFile(filePath, generator) { + return fileExists(filePath) + .then(function(exists) { + if (exists) return; + + return generator(); + }); +} + +/** + Pick a file, returns the absolute path if exists, undefined otherwise + + @param {String} rootFolder + @param {String} fileName + @return {String} +*/ +function pickFile(rootFolder, fileName) { + var result = path.join(rootFolder, fileName); + if (fs.existsSync(result)) { + return result; + } + + return undefined; +} + module.exports = { exists: fileExists, existsSync: fs.existsSync, mkdirp: Promise.nfbind(mkdirp), readFile: Promise.nfbind(fs.readFile), writeFile: Promise.nfbind(fs.writeFile), + assertFile: assertFile, + pickFile: pickFile, stat: Promise.nfbind(fs.stat), statSync: fs.statSync, readdir: Promise.nfbind(fs.readdir), @@ -113,6 +147,6 @@ module.exports = { tmpDir: genTmpDir, download: download, uniqueFilename: uniqueFilename, - ensure: ensureFile, + ensureFile: ensureFile, rmDir: rmDir }; diff --git a/lib/utils/genKey.js b/lib/utils/genKey.js new file mode 100644 index 0000000..0650011 --- /dev/null +++ b/lib/utils/genKey.js @@ -0,0 +1,13 @@ +var lastKey = 0; + +/* + Generate a random key + @return {String} +*/ +function generateKey() { + lastKey += 1; + var str = lastKey.toString(16); + return '00000'.slice(str.length) + str; +} + +module.exports = generateKey; diff --git a/lib/utils/location.js b/lib/utils/location.js index ba43644..84a71ad 100644 --- a/lib/utils/location.js +++ b/lib/utils/location.js @@ -30,9 +30,14 @@ function normalize(s) { return path.normalize(s).replace(/\\/g, '/'); } -// Convert relative to absolute path -// dir: directory parent of the file currently in rendering process -// outdir: directory parent from the html output +/** + Convert relative to absolute path + + @param {String} href + @param {String} dir: directory parent of the file currently in rendering process + @param {String} outdir: directory parent from the html output + @return {String} +*/ function toAbsolute(_href, dir, outdir) { if (isExternal(_href)) return _href; outdir = outdir == undefined? dir : outdir; @@ -54,17 +59,49 @@ function toAbsolute(_href, dir, outdir) { return _href; } -// Convert an absolute path to a relative path for a specific folder (dir) -// ('test/', 'hello.md') -> '../hello.md' +/** + Convert an absolute path to a relative path for a specific folder (dir) + ('test/', 'hello.md') -> '../hello.md' + + @param {String} dir: current directory + @param {String} file: absolute path of file + @return {String} +*/ function relative(dir, file) { return normalize(path.relative(dir, file)); } +/** + Convert an absolute path to a relative path for a specific folder (dir) + ('test/test.md', 'hello.md') -> '../hello.md' + + @param {String} baseFile: current file + @param {String} file: absolute path of file + @return {String} +*/ +function relativeForFile(baseFile, file) { + return relative(path.dirname(baseFile), file); +} + +/** + Compare two paths, return true if they are identical + ('README.md', './README.md') -> true + + @param {String} p1: first path + @param {String} p2: second path + @return {Boolean} +*/ +function areIdenticalPaths(p1, p2) { + return normalize(p1) === normalize(p2); +} + module.exports = { + areIdenticalPaths: areIdenticalPaths, isExternal: isExternal, isRelative: isRelative, isAnchor: isAnchor, normalize: normalize, toAbsolute: toAbsolute, - relative: relative + relative: relative, + relativeForFile: relativeForFile }; diff --git a/lib/utils/logger.js b/lib/utils/logger.js index 60215af..fc9c394 100644 --- a/lib/utils/logger.js +++ b/lib/utils/logger.js @@ -17,14 +17,17 @@ var COLORS = { ERROR: color.red }; -function Logger(write, logLevel, prefix) { +function Logger(write, logLevel) { if (!(this instanceof Logger)) return new Logger(write, logLevel); - this._write = write || function(msg) { process.stdout.write(msg); }; + this._write = write || function(msg) { + if(process.stdout) { + process.stdout.write(msg); + } + }; this.lastChar = '\n'; - // Define log level - this.setLevel(logLevel); + this.setLevel(logLevel || 'info'); _.bindAll(this); @@ -40,35 +43,48 @@ function Logger(write, logLevel, prefix) { }, 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 -// Change minimum level + @param {String} logLevel +*/ Logger.prototype.setLevel = function(logLevel) { if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()]; this.logLevel = logLevel; }; -// Print a simple string +/** + Print a simple string + + @param {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. +/** + 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 +/** + Print a line + + @param {String} +*/ Logger.prototype.writeLn = function(msg) { return this.write((msg || '')+'\n'); }; -// Log/Print a message if level is allowed +/** + Log/Print a message if level is allowed + + @param {Number} level +*/ Logger.prototype.log = function(level) { if (level < this.logLevel) return; @@ -83,7 +99,9 @@ Logger.prototype.log = function(level) { return this.write(msg); }; -// Log/Print a line if level is allowed +/** + Log/Print a line if level is allowed +*/ Logger.prototype.logLn = function() { if (this.lastChar != '\n') this.write('\n'); @@ -92,7 +110,9 @@ Logger.prototype.logLn = function() { return this.log.apply(this, args); }; -// Log a confirmation [OK] +/** + Log a confirmation [OK] +*/ Logger.prototype.ok = function(level) { var args = Array.prototype.slice.apply(arguments, [1]); var msg = this.format.apply(this, args); @@ -103,12 +123,20 @@ Logger.prototype.ok = function(level) { } }; -// Log a "FAIL" +/** + Log a "FAIL" +*/ Logger.prototype.fail = function(level) { return this.log(level, color.red('ERROR') + '\n'); }; -// Log state of a promise +/** + Log state of a promise + + @param {Number} level + @param {Promise} + @return {Promise} +*/ Logger.prototype.promise = function(level, p) { var that = this; diff --git a/lib/utils/path.js b/lib/utils/path.js index c233c92..a4968c8 100644 --- a/lib/utils/path.js +++ b/lib/utils/path.js @@ -42,7 +42,7 @@ function resolveInRoot(root) { return result; } -// Chnage extension +// Chnage extension of a file function setExtension(filename, ext) { return path.join( path.dirname(filename), @@ -50,9 +50,20 @@ function setExtension(filename, ext) { ); } +/* + Return true if a filename is relative. + + @param {String} + @return {Boolean} +*/ +function isPureRelative(filename) { + return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0); +} + module.exports = { isInRoot: isInRoot, resolveInRoot: resolveInRoot, normalize: normalizePath, - setExtension: setExtension + setExtension: setExtension, + isPureRelative: isPureRelative }; diff --git a/lib/utils/promise.js b/lib/utils/promise.js index d49cf27..19d7554 100644 --- a/lib/utils/promise.js +++ b/lib/utils/promise.js @@ -1,19 +1,46 @@ var Q = require('q'); -var _ = require('lodash'); +var Immutable = require('immutable'); -// Reduce an array to a promise +/** + Reduce an array to a promise + + @param {Array|List} arr + @param {Function(value, element, index)} + @return {Promise<Mixed>} +*/ function reduce(arr, iter, base) { - return _.reduce(arr, function(prev, elem, i) { + arr = Immutable.Iterable.isIterable(arr)? arr : Immutable.List(arr); + + return arr.reduce(function(prev, elem, key) { return prev.then(function(val) { - return iter(val, elem, i); + return iter(val, elem, key); }); }, Q(base)); } -// Transform an array +/** + Iterate over an array using an async iter + + @param {Array|List} arr + @param {Function(value, element, index)} + @return {Promise} +*/ +function forEach(arr, iter) { + return reduce(arr, function(val, el, key) { + return iter(el, key); + }); +} + +/** + Transform an array + + @param {Array|List} arr + @param {Function(value, element, index)} + @return {Promise} +*/ function serie(arr, iter, base) { - return reduce(arr, function(before, item, i) { - return Q(iter(item, i)) + return reduce(arr, function(before, item, key) { + return Q(iter(item, key)) .then(function(r) { before.push(r); return before; @@ -21,9 +48,17 @@ function serie(arr, iter, base) { }, []); } -// Iter over an array and return first result (not null) +/** + Iter over an array and return first result (not null) + + @param {Array|List} arr + @param {Function(element, index)} + @return {Promise<Mixed>} +*/ function some(arr, iter) { - return _.reduce(arr, function(prev, elem, i) { + arr = Immutable.List(arr); + + return arr.reduce(function(prev, elem, i) { return prev.then(function(val) { if (val) return val; @@ -32,8 +67,14 @@ function some(arr, iter) { }, Q()); } -// Map an array using an async (promised) iterator -function map(arr, iter) { +/** + Map an array using an async (promised) iterator + + @param {Array|List} arr + @param {Function(element, index)} + @return {Promise<List>} +*/ +function mapAsList(arr, iter) { return reduce(arr, function(prev, entry, i) { return Q(iter(entry, i)) .then(function(out) { @@ -43,18 +84,57 @@ function map(arr, iter) { }, []); } -// Wrap a fucntion in a promise +/** + Map an array or map + + @param {Array|List|Map|OrderedMap} arr + @param {Function(element, key)} + @return {Promise<List|Map|OrderedMap>} +*/ +function map(arr, iter) { + if (Immutable.Map.isMap(arr)) { + var type = 'Map'; + if (Immutable.OrderedMap.isOrderedMap(arr)) { + type = 'OrderedMap'; + } + + return mapAsList(arr, function(value, key) { + return Q(iter(value, key)) + .then(function(result) { + return [key, result]; + }); + }) + .then(function(result) { + return Immutable[type](result); + }); + } else { + return mapAsList(arr, iter) + .then(function(result) { + return Immutable.List(result); + }); + } +} + + +/** + Wrap a function in a promise + + @param {Function} func + @return {Funciton} +*/ function wrap(func) { - return _.wrap(func, function(_func) { - var args = Array.prototype.slice.call(arguments, 1); + return function() { + var args = Array.prototype.slice.call(arguments, 0); + return Q() .then(function() { - return _func.apply(null, args); + return func.apply(null, args); }); - }); + }; } module.exports = Q; +module.exports.forEach = forEach; module.exports.reduce = reduce; module.exports.map = map; module.exports.serie = serie; diff --git a/lib/utils/timing.js b/lib/utils/timing.js new file mode 100644 index 0000000..21a4b91 --- /dev/null +++ b/lib/utils/timing.js @@ -0,0 +1,89 @@ +var Immutable = require('immutable'); +var is = require('is'); + +var timers = {}; +var startDate = Date.now(); + +/** + Mesure an operation + + @parqm {String} type + @param {Promise} p + @return {Promise} +*/ +function measure(type, p) { + timers[type] = timers[type] || { + type: type, + count: 0, + total: 0, + min: undefined, + max: 0 + }; + + var start = Date.now(); + + return p + .fin(function() { + var end = Date.now(); + var duration = (end - start); + + timers[type].count ++; + timers[type].total += duration; + + if (is.undefined(timers[type].min)) { + timers[type].min = duration; + } else { + timers[type].min = Math.min(timers[type].min, duration); + } + + timers[type].max = Math.max(timers[type].max, duration); + }); +} + +/** + Return a milliseconds number as a second string + + @param {Number} ms + @return {String} +*/ +function time(ms) { + if (ms < 1000) { + return (ms.toFixed(0)) + 'ms'; + } + + return (ms/1000).toFixed(2) + 's'; +} + +/** + Dump all timers to a logger + + @param {Logger} logger +*/ +function dump(logger) { + var prefix = ' > '; + var measured = 0; + var totalDuration = Date.now() - startDate; + + Immutable.Map(timers) + .valueSeq() + .sortBy(function(timer) { + measured += timer.total; + return timer.total; + }) + .forEach(function(timer) { + logger.debug.ln('Timer "' + timer.type + '" (' + timer.count + ' times) :'); + logger.debug.ln(prefix + 'Total: ' + time(timer.total)); + logger.debug.ln(prefix + 'Average: ' + time(timer.total / timer.count)); + logger.debug.ln(prefix + 'Min: ' + time(timer.min)); + logger.debug.ln(prefix + 'Max: ' + time(timer.max)); + logger.debug.ln('---------------------------'); + }); + + + logger.debug.ln(time(totalDuration - measured) + ' spent in non-mesured sections'); +} + +module.exports = { + measure: measure, + dump: dump +}; |