diff options
Diffstat (limited to 'packages/gitbook')
281 files changed, 14452 insertions, 0 deletions
diff --git a/packages/gitbook/bin/gitbook.js b/packages/gitbook/bin/gitbook.js new file mode 100755 index 0000000..5cadbc9 --- /dev/null +++ b/packages/gitbook/bin/gitbook.js @@ -0,0 +1,8 @@ +#! /usr/bin/env node +/* eslint-disable no-console */ + +var color = require('bash-color'); + +console.log(color.red('You need to install "gitbook-cli" to have access to the gitbook command anywhere on your system.')); +console.log(color.red('If you\'ve installed this package globally, you need to uninstall it.')); +console.log(color.red('>> Run "npm uninstall -g gitbook" then "npm install -g gitbook-cli"')); diff --git a/packages/gitbook/lib/__tests__/gitbook.js b/packages/gitbook/lib/__tests__/gitbook.js new file mode 100644 index 0000000..c3669bb --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/__tests__/init.js b/packages/gitbook/lib/__tests__/init.js new file mode 100644 index 0000000..66188a3 --- /dev/null +++ b/packages/gitbook/lib/__tests__/init.js @@ -0,0 +1,16 @@ +var tmp = require('tmp'); +var initBook = require('../init'); + +describe('initBook', function() { + + it('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/packages/gitbook/lib/__tests__/module.js b/packages/gitbook/lib/__tests__/module.js new file mode 100644 index 0000000..d9220f5 --- /dev/null +++ b/packages/gitbook/lib/__tests__/module.js @@ -0,0 +1,6 @@ + +describe('GitBook', function() { + it('should correctly export', function() { + require('../'); + }); +}); diff --git a/packages/gitbook/lib/api/decodeConfig.js b/packages/gitbook/lib/api/decodeConfig.js new file mode 100644 index 0000000..5e00df5 --- /dev/null +++ b/packages/gitbook/lib/api/decodeConfig.js @@ -0,0 +1,17 @@ +/** + 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(values); +} + +module.exports = decodeGlobal; diff --git a/packages/gitbook/lib/api/decodeGlobal.js b/packages/gitbook/lib/api/decodeGlobal.js new file mode 100644 index 0000000..118afb2 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/decodePage.js b/packages/gitbook/lib/api/decodePage.js new file mode 100644 index 0000000..c85dd1b --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/deprecate.js b/packages/gitbook/lib/api/deprecate.js new file mode 100644 index 0000000..7a93a91 --- /dev/null +++ b/packages/gitbook/lib/api/deprecate.js @@ -0,0 +1,122 @@ +var is = require('is'); +var objectPath = require('object-path'); + +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; +} + +/** + Deprecate a method in favor of another one + + @param {Book} book + @param {String} key + @param {Object} instance + @param {String} oldName + @param {String} newName +*/ +function deprecateRenamedMethod(book, key, instance, oldName, newName, msg) { + msg = msg || ('"' + oldName + '" is deprecated, use "' + newName + '()" instead'); + var fn = objectPath.get(instance, newName); + + instance[oldName] = deprecateMethod(book, key, fn, msg); +} + +module.exports = { + method: deprecateMethod, + renamedMethod: deprecateRenamedMethod, + field: deprecateField, + enable: enableDeprecation, + disable: disableDeprecation +}; diff --git a/packages/gitbook/lib/api/encodeConfig.js b/packages/gitbook/lib/api/encodeConfig.js new file mode 100644 index 0000000..2a05528 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/encodeGlobal.js b/packages/gitbook/lib/api/encodeGlobal.js new file mode 100644 index 0000000..a366526 --- /dev/null +++ b/packages/gitbook/lib/api/encodeGlobal.js @@ -0,0 +1,257 @@ +var path = require('path'); +var Promise = require('../utils/promise'); +var PathUtils = require('../utils/path'); +var fs = require('../utils/fs'); + +var Plugins = require('../plugins'); +var deprecate = require('./deprecate'); +var fileToURL = require('../output/helper/fileToURL'); +var defaultBlocks = require('../constants/defaultBlocks'); +var gitbook = require('../gitbook'); +var parsers = require('../parsers'); + +var encodeConfig = require('./encodeConfig'); +var encodeSummary = require('./encodeSummary'); +var encodeNavigation = require('./encodeNavigation'); +var encodePage = require('./encodePage'); + +/** + 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 plugins = output.getPlugins(); + var blocks = Plugins.listBlocks(plugins); + + var result = { + log: logger, + config: encodeConfig(output, book.getConfig()), + summary: encodeSummary(output, book.getSummary()), + + /** + Check if the book is a multilingual book + + @return {Boolean} + */ + isMultilingual: function() { + return book.isMultilingual(); + }, + + /** + Check if the book is a language book for a multilingual book + + @return {Boolean} + */ + isLanguageBook: function() { + return book.isLanguageBook(); + }, + + /** + 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); + }, + + /** + Resolve a file from the book root + + @param {String} fileName + @return {String} + */ + resolve: function(fileName) { + return path.resolve(book.getContentRoot(), fileName); + }, + + /** + Resolve a page by it path + + @param {String} filePath + @return {String} + */ + getPageByPath: function(filePath) { + var page = output.getPage(filePath); + if (!page) return undefined; + + return encodePage(output, page); + }, + + /** + Render a block of text (markdown/asciidoc) + + @param {String} type + @param {String} text + @return {Promise<String>} + */ + renderBlock: function(type, text) { + var parser = parsers.get(type); + + return parser.parsePage(text) + .get('content'); + }, + + /** + Render an inline text (markdown/asciidoc) + + @param {String} type + @param {String} text + @return {Promise<String>} + */ + renderInline: function(type, text) { + var parser = parsers.get(type); + + return parser.parseInline(text) + .get('content'); + }, + + template: { + /** + Apply a templating block and returns its result + + @param {String} name + @param {Object} blockData + @return {Promise|Object} + */ + applyBlock: function(name, blockData) { + var block = blocks.get(name) || defaultBlocks.get(name); + return Promise(block.applyBlock(blockData, result)); + } + }, + + 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; + }, + + /** + Resolve a file from the output root + + @param {String} fileName + @return {String} + */ + resolve: function(fileName) { + return path.resolve(outputFolder, fileName); + }, + + /** + Convert a filepath into an url + @return {String} + */ + toURL: function(filePath) { + return fileToURL(output, filePath); + }, + + /** + Check that a file exists. + + @param {String} fileName + @return {Promise} + */ + hasFile: function(fileName, content) { + return Promise() + .then(function() { + var filePath = PathUtils.resolveInRoot(outputFolder, fileName); + + return fs.exists(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); + }); + }); + }, + + /** + Copy a file to the output folder + It creates the required folder. + + @param {String} inputFile + @param {String} outputFile + @param {Buffer} content + @return {Promise} + */ + copyFile: function(inputFile, outputFile, content) { + return Promise() + .then(function() { + var outputFilePath = PathUtils.resolveInRoot(outputFolder, outputFile); + + return fs.ensureFile(outputFilePath) + .then(function() { + return fs.copy(inputFile, outputFilePath); + }); + }); + } + }, + + gitbook: { + version: gitbook.version + } + }; + + // Deprecated properties + + deprecate.renamedMethod(output, 'this.isSubBook', result, 'isSubBook', 'isLanguageBook'); + deprecate.renamedMethod(output, 'this.contentLink', result, 'contentLink', 'output.toURL'); + + 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/packages/gitbook/lib/api/encodeNavigation.js b/packages/gitbook/lib/api/encodeNavigation.js new file mode 100644 index 0000000..8e329a1 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/encodePage.js b/packages/gitbook/lib/api/encodePage.js new file mode 100644 index 0000000..379d3d5 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/encodeProgress.js b/packages/gitbook/lib/api/encodeProgress.js new file mode 100644 index 0000000..afa0341 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/api/encodeSummary.js b/packages/gitbook/lib/api/encodeSummary.js new file mode 100644 index 0000000..0d66ded --- /dev/null +++ b/packages/gitbook/lib/api/encodeSummary.js @@ -0,0 +1,51 @@ +var encodeSummaryArticle = require('../json/encodeSummaryArticle'); + +/** + Encode summary to provide an API to plugin + + @param {Output} output + @param {Config} config + @return {Object} +*/ +function encodeSummary(output, summary) { + var result = { + /** + Iterate over the summary, it stops when the "iter" returns false + + @param {Function} iter + */ + walk: function (iter) { + summary.getArticle(function(article) { + var jsonArticle = encodeSummaryArticle(article, false); + + return iter(jsonArticle); + }); + }, + + /** + Get an article by its level + + @param {String} level + @return {Object} + */ + getArticleByLevel: function(level) { + var article = summary.getByLevel(level); + return (article? encodeSummaryArticle(article) : undefined); + }, + + /** + Get an article by its path + + @param {String} level + @return {Object} + */ + getArticleByPath: function(level) { + var article = summary.getByPath(level); + return (article? encodeSummaryArticle(article) : undefined); + } + }; + + return result; +} + +module.exports = encodeSummary; diff --git a/packages/gitbook/lib/api/index.js b/packages/gitbook/lib/api/index.js new file mode 100644 index 0000000..5e67525 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/browser.js b/packages/gitbook/lib/browser.js new file mode 100644 index 0000000..87a4dc4 --- /dev/null +++ b/packages/gitbook/lib/browser.js @@ -0,0 +1,26 @@ +var Modifiers = require('./modifiers'); + +module.exports = { + Parse: require('./parse'), + + // Models + Book: require('./models/book'), + FS: require('./models/fs'), + File: require('./models/file'), + Summary: require('./models/summary'), + Glossary: require('./models/glossary'), + Config: require('./models/config'), + Page: require('./models/page'), + PluginDependency: require('./models/pluginDependency'), + + // Modifiers + SummaryModifier: Modifiers.Summary, + ConfigModifier: Modifiers.Config, + + // Constants + CONFIG_FILES: require('./constants/configFiles.js'), + IGNORE_FILES: require('./constants/ignoreFiles.js'), + DEFAULT_PLUGINS: require('./constants/defaultPlugins'), + EXTENSIONS_MARKDOWN: require('./constants/extsMarkdown'), + EXTENSIONS_ASCIIDOC: require('./constants/extsAsciidoc') +}; diff --git a/packages/gitbook/lib/cli/build.js b/packages/gitbook/lib/cli/build.js new file mode 100644 index 0000000..023901e --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/cli/buildEbook.js b/packages/gitbook/lib/cli/buildEbook.js new file mode 100644 index 0000000..a87fac7 --- /dev/null +++ b/packages/gitbook/lib/cli/buildEbook.js @@ -0,0 +1,78 @@ +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) { + var extension = '.' + format; + + // Output file will be stored in + var outputFile = args[1] || ('book' + extension); + + // 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.getList(), function(lang) { + var langID = lang.getID(); + + var langOutputFile = path.join( + path.dirname(outputFile), + path.basename(outputFile, extension) + '_' + langID + extension + ); + + return fs.copy( + path.resolve(outputFolder, langID, 'index' + extension), + langOutputFile + ); + }) + .thenResolve(languages.getCount()); + } else { + return fs.copy( + path.resolve(outputFolder, 'index' + extension), + 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/packages/gitbook/lib/cli/getBook.js b/packages/gitbook/lib/cli/getBook.js new file mode 100644 index 0000000..ac82187 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/cli/getOutputFolder.js b/packages/gitbook/lib/cli/getOutputFolder.js new file mode 100644 index 0000000..272dff9 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/cli/index.js b/packages/gitbook/lib/cli/index.js new file mode 100644 index 0000000..f1fca1d --- /dev/null +++ b/packages/gitbook/lib/cli/index.js @@ -0,0 +1,12 @@ +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/packages/gitbook/lib/cli/init.js b/packages/gitbook/lib/cli/init.js new file mode 100644 index 0000000..55f1b15 --- /dev/null +++ b/packages/gitbook/lib/cli/init.js @@ -0,0 +1,17 @@ +var path = require('path'); + +var options = require('./options'); +var initBook = require('../init'); + +module.exports = { + name: 'init [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/packages/gitbook/lib/cli/install.js b/packages/gitbook/lib/cli/install.js new file mode 100644 index 0000000..c001711 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/cli/options.js b/packages/gitbook/lib/cli/options.js new file mode 100644 index 0000000..72961ab --- /dev/null +++ b/packages/gitbook/lib/cli/options.js @@ -0,0 +1,31 @@ +var Logger = require('../utils/logger'); + +var logOptions = { + name: 'log', + description: 'Minimum log level to display', + values: Logger.LEVELS + .keySeq() + .map(function(s) { + return s.toLowerCase(); + }).toJS(), + 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/packages/gitbook/lib/cli/parse.js b/packages/gitbook/lib/cli/parse.js new file mode 100644 index 0000000..0fa509a --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/cli/serve.js b/packages/gitbook/lib/cli/serve.js new file mode 100644 index 0000000..5340851 --- /dev/null +++ b/packages/gitbook/lib/cli/serve.js @@ -0,0 +1,159 @@ +/* eslint-disable no-console */ + +var tinylr = require('tiny-lr'); +var open = require('open'); + +var Parse = require('../parse'); +var Output = require('../output'); +var ConfigModifier = require('../modifiers').Config; + +var Promise = require('../utils/promise'); + +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 waitForCtrlC() { + var d = Promise.defer(); + + process.on('SIGINT', function() { + d.resolve(); + }); + + return d.promise; +} + + +function generateBook(args, kwargs) { + var port = kwargs.port; + var outputFolder = getOutputFolder(args); + var book = getBook(args, kwargs); + var Generator = Output.getGenerator(kwargs.format); + var browser = kwargs['browser']; + + var hasWatch = kwargs['watch']; + var hasLiveReloading = kwargs['live']; + var hasOpen = kwargs['open']; + + // Stop server if running + if (server.isRunning()) console.log('Stopping server'); + + return server.stop() + .then(function() { + return Parse.parseBook(book) + .then(function(resultBook) { + if (hasLiveReloading) { + // Enable livereload plugin + var config = resultBook.getConfig(); + config = ConfigModifier.addPlugin(config, 'livereload'); + resultBook = resultBook.set('config', config); + } + + 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 && hasLiveReloading) { + // trigger livereload + lrServer.changed({ + body: { + files: [lrPath] + } + }); + } + + if (hasOpen) { + open('http://localhost:'+port, browser); + } + }) + .then(function() { + if (!hasWatch) { + return waitForCtrlC(); + } + + 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 file watcher and live reloading', + defaults: true + }, + { + name: 'live', + description: 'Enable live reloading', + defaults: true + }, + { + name: 'open', + description: 'Enable opening book in browser', + defaults: false + }, + { + name: 'browser', + description: 'Specify browser for opening book', + defaults: '' + }, + options.log, + options.format + ], + exec: function(args, kwargs) { + server = new Server(); + var hasWatch = kwargs['watch']; + var hasLiveReloading = kwargs['live']; + + return Promise() + .then(function() { + if (!hasWatch || !hasLiveReloading) { + return; + } + + lrServer = tinylr({}); + 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(''); + + }); + }) + .then(function() { + return generateBook(args, kwargs); + }); + } +}; diff --git a/packages/gitbook/lib/cli/server.js b/packages/gitbook/lib/cli/server.js new file mode 100644 index 0000000..752f867 --- /dev/null +++ b/packages/gitbook/lib/cli/server.js @@ -0,0 +1,128 @@ +var events = require('events'); +var http = require('http'); +var send = require('send'); +var util = require('util'); +var url = require('url'); + +var Promise = require('../utils/promise'); + +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 {Boolean} +*/ +Server.prototype.isRunning = function() { + return !!this.running; +}; + +/** + Stop the server + + @return {Promise} +*/ +Server.prototype.stop = function() { + var that = this; + if (!this.isRunning()) return Promise(); + + var d = Promise.defer(); + this.running.close(function(err) { + that.running = null; + that.emit('state', false); + + if (err) d.reject(err); + else d.resolve(); + }); + + for (var i = 0; i < this.sockets.length; i++) { + this.sockets[i].destroy(); + } + + return d.promise; +}; + +/** + Start the server + + @return {Promise} +*/ +Server.prototype.start = function(dir, port) { + var that = this, pre = Promise(); + port = port || 8004; + + if (that.isRunning()) pre = this.stop(); + return pre + .then(function() { + var d = Promise.defer(); + + that.running = http.createServer(function(req, res){ + // Render error + function error(err) { + res.statusCode = err.status || 500; + res.end(err.message); + } + + // Redirect to directory's index.html + function redirect() { + var resultURL = urlTransform(req.url, function(parsed) { + parsed.pathname += '/'; + return parsed; + }); + + res.statusCode = 301; + res.setHeader('Location', resultURL); + res.end('Redirecting to ' + resultURL); + } + + res.setHeader('X-Current-Location', req.url); + + // Send file + send(req, url.parse(req.url).pathname, { + root: dir + }) + .on('error', error) + .on('directory', redirect) + .pipe(res); + }); + + that.running.on('connection', function (socket) { + that.sockets.push(socket); + socket.setTimeout(4000); + socket.on('close', function () { + that.sockets.splice(that.sockets.indexOf(socket), 1); + }); + }); + + that.running.listen(port, function(err) { + if (err) return d.reject(err); + + that.port = port; + that.dir = dir; + that.emit('state', true); + d.resolve(); + }); + + return d.promise; + }); +}; + +/** + urlTransform is a helper function that allows a function to transform + a url string in it's parsed form and returns the new url as a string + + @param {String} uri + @param {Function} fn + @return {String} +*/ +function urlTransform(uri, fn) { + return url.format(fn(url.parse(uri))); +} + +module.exports = Server; diff --git a/packages/gitbook/lib/cli/watch.js b/packages/gitbook/lib/cli/watch.js new file mode 100644 index 0000000..14434ab --- /dev/null +++ b/packages/gitbook/lib/cli/watch.js @@ -0,0 +1,46 @@ +var path = require('path'); +var chokidar = require('chokidar'); + +var Promise = require('../utils/promise'); +var parsers = require('../parsers'); + +/** + Watch a folder and resolve promise once a file is modified + + @param {String} dir + @return {Promise} +*/ +function watch(dir) { + var d = Promise.defer(); + dir = path.resolve(dir); + + var toWatch = [ + 'book.json', 'book.js', '_layouts/**' + ]; + + // Watch all parsable files + parsers.extensions.forEach(function(ext) { + toWatch.push('**/*'+ext); + }); + + var watcher = chokidar.watch(toWatch, { + cwd: dir, + ignored: '_book/**', + ignoreInitial: true + }); + + watcher.once('all', function(e, filepath) { + watcher.close(); + + d.resolve(filepath); + }); + watcher.once('error', function(err) { + watcher.close(); + + d.reject(err); + }); + + return d.promise; +} + +module.exports = watch; diff --git a/packages/gitbook/lib/constants/__tests__/configSchema.js b/packages/gitbook/lib/constants/__tests__/configSchema.js new file mode 100644 index 0000000..efc99b9 --- /dev/null +++ b/packages/gitbook/lib/constants/__tests__/configSchema.js @@ -0,0 +1,46 @@ +var jsonschema = require('jsonschema'); +var schema = require('../configSchema'); + +describe('configSchema', function() { + + function validate(cfg) { + var v = new jsonschema.Validator(); + return v.validate(cfg, schema, { + propertyName: 'config' + }); + } + + describe('structure', function() { + + it('should accept dot in filename', function() { + var result = validate({ + structure: { + readme: 'book-intro.adoc' + } + }); + + expect(result.errors.length).toBe(0); + }); + + it('should accept uppercase in filename', function() { + var result = validate({ + structure: { + readme: 'BOOK.adoc' + } + }); + + expect(result.errors.length).toBe(0); + }); + + it('should not accept filepath', function() { + var result = validate({ + structure: { + readme: 'folder/myFile.md' + } + }); + + expect(result.errors.length).toBe(1); + }); + + }); +}); diff --git a/packages/gitbook/lib/constants/configDefault.js b/packages/gitbook/lib/constants/configDefault.js new file mode 100644 index 0000000..0d95883 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/constants/configFiles.js b/packages/gitbook/lib/constants/configFiles.js new file mode 100644 index 0000000..a67fd74 --- /dev/null +++ b/packages/gitbook/lib/constants/configFiles.js @@ -0,0 +1,5 @@ +// Configuration files to test (sorted) +module.exports = [ + 'book.js', + 'book.json' +]; diff --git a/packages/gitbook/lib/constants/configSchema.js b/packages/gitbook/lib/constants/configSchema.js new file mode 100644 index 0000000..d2126c6 --- /dev/null +++ b/packages/gitbook/lib/constants/configSchema.js @@ -0,0 +1,194 @@ +var FILENAME_REGEX = '^[a-zA-Z-._\d,\s]+$'; + +module.exports = { + '$schema': 'http://json-schema.org/schema#', + 'id': 'https://gitbook.com/schemas/book.json', + 'title': 'GitBook Configuration', + 'type': 'object', + 'properties': { + 'root': { + 'type': 'string', + 'title': 'Path fro the root folder containing the book\'s content' + }, + 'title': { + 'type': 'string', + 'title': 'Title of the book, default is extracted from README' + }, + 'isbn': { + 'type': 'string', + 'title': 'ISBN for published book' + }, + 'language': { + 'type': 'string', + 'title': 'Language of the book' + }, + 'author': { + 'type': 'string', + 'title': 'Name of the author' + }, + 'gitbook': { + 'type': 'string', + 'default': '*', + 'title': 'GitBook version to match' + }, + 'direction': { + 'type': 'string', + 'enum': ['ltr', 'rtl'], + 'title': 'Direction of texts, default is detected in the pages' + }, + 'theme': { + 'type': 'string', + 'default': 'default', + 'title': 'Name of the theme plugin to use' + }, + 'variables': { + 'type': 'object', + 'title': 'Templating context variables' + }, + 'plugins': { + 'oneOf': [ + { '$ref': '#/definitions/pluginsArray' }, + { '$ref': '#/definitions/pluginsString' } + ], + 'default': [] + }, + 'pluginsConfig': { + 'type': 'object', + 'title': 'Configuration for plugins' + }, + 'structure': { + 'type': 'object', + 'properties': { + 'langs': { + 'default': 'LANGS.md', + 'type': 'string', + 'title': 'File to use as languages index', + 'pattern': FILENAME_REGEX + }, + 'readme': { + 'default': 'README.md', + 'type': 'string', + 'title': 'File to use as preface', + 'pattern': FILENAME_REGEX + }, + 'glossary': { + 'default': 'GLOSSARY.md', + 'type': 'string', + 'title': 'File to use as glossary index', + 'pattern': FILENAME_REGEX + }, + 'summary': { + 'default': 'SUMMARY.md', + 'type': 'string', + 'title': 'File to use as table of contents', + 'pattern': FILENAME_REGEX + } + }, + 'additionalProperties': false + }, + 'pdf': { + 'type': 'object', + 'title': 'PDF specific configurations', + 'properties': { + 'pageNumbers': { + 'type': 'boolean', + 'default': true, + 'title': 'Add page numbers to the bottom of every page' + }, + 'fontSize': { + 'type': 'integer', + 'minimum': 8, + 'maximum': 30, + 'default': 12, + 'title': 'Font size for the PDF output' + }, + 'fontFamily': { + 'type': 'string', + 'default': 'Arial', + 'title': 'Font family for the PDF output' + }, + 'paperSize': { + 'type': 'string', + 'enum': ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'], + 'default': 'a4', + 'title': 'Paper size for the PDF' + }, + 'chapterMark': { + 'type': 'string', + 'enum': ['pagebreak', 'rule', 'both', 'none'], + 'default': 'pagebreak', + 'title': 'How to mark detected chapters' + }, + 'pageBreaksBefore': { + 'type': 'string', + 'default': '/', + 'title': 'An XPath expression. Page breaks are inserted before the specified elements. To disable use the expression: "/"' + }, + 'margin': { + 'type': 'object', + 'properties': { + 'right': { + 'type': 'integer', + 'title': 'Right Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'left': { + 'type': 'integer', + 'title': 'Left Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 62 + }, + 'top': { + 'type': 'integer', + 'title': 'Top Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + }, + 'bottom': { + 'type': 'integer', + 'title': 'Bottom Margin', + 'minimum': 0, + 'maximum': 100, + 'default': 56 + } + } + } + } + } + }, + 'required': [], + 'definitions': { + 'pluginsArray': { + 'type': 'array', + 'items': { + 'oneOf': [ + { '$ref': '#/definitions/pluginObject' }, + { '$ref': '#/definitions/pluginString' } + ] + } + }, + 'pluginsString': { + 'type': 'string' + }, + 'pluginString': { + 'type': 'string' + }, + 'pluginObject': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string' + }, + 'version': { + 'type': 'string' + } + }, + 'additionalProperties': false, + 'required': ['name'] + } + } +}; diff --git a/packages/gitbook/lib/constants/defaultBlocks.js b/packages/gitbook/lib/constants/defaultBlocks.js new file mode 100644 index 0000000..74d1f1f --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/constants/defaultFilters.js b/packages/gitbook/lib/constants/defaultFilters.js new file mode 100644 index 0000000..35025cc --- /dev/null +++ b/packages/gitbook/lib/constants/defaultFilters.js @@ -0,0 +1,15 @@ +var Immutable = require('immutable'); +var moment = require('moment'); + +module.exports = Immutable.Map({ + // Format a date + // ex: 'MMMM Do YYYY, h:mm:ss a + date: function(time, format) { + return moment(time).format(format); + }, + + // Relative Time + dateFromNow: function(time) { + return moment(time).fromNow(); + } +}); diff --git a/packages/gitbook/lib/constants/defaultPlugins.js b/packages/gitbook/lib/constants/defaultPlugins.js new file mode 100644 index 0000000..6d15971 --- /dev/null +++ b/packages/gitbook/lib/constants/defaultPlugins.js @@ -0,0 +1,29 @@ +var Immutable = require('immutable'); +var PluginDependency = require('../models/pluginDependency'); + +var pkg = require('../../package.json'); + +/** + * Create a PluginDependency from a dependency of gitbook + * @param {String} pluginName + * @return {PluginDependency} + */ +function createFromDependency(pluginName) { + var npmID = PluginDependency.nameToNpmID(pluginName); + var version = pkg.dependencies[npmID]; + + return PluginDependency.create(pluginName, version); +} + +/* + * 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' +]).map(createFromDependency); diff --git a/packages/gitbook/lib/constants/extsAsciidoc.js b/packages/gitbook/lib/constants/extsAsciidoc.js new file mode 100644 index 0000000..b2f4ce4 --- /dev/null +++ b/packages/gitbook/lib/constants/extsAsciidoc.js @@ -0,0 +1,4 @@ +module.exports = [ + '.adoc', + '.asciidoc' +]; diff --git a/packages/gitbook/lib/constants/extsMarkdown.js b/packages/gitbook/lib/constants/extsMarkdown.js new file mode 100644 index 0000000..44bf36b --- /dev/null +++ b/packages/gitbook/lib/constants/extsMarkdown.js @@ -0,0 +1,5 @@ +module.exports = [ + '.md', + '.markdown', + '.mdown' +]; diff --git a/packages/gitbook/lib/constants/ignoreFiles.js b/packages/gitbook/lib/constants/ignoreFiles.js new file mode 100644 index 0000000..aac225e --- /dev/null +++ b/packages/gitbook/lib/constants/ignoreFiles.js @@ -0,0 +1,6 @@ +// Files containing ignore pattner (sorted by priority) +module.exports = [ + '.ignore', + '.gitignore', + '.bookignore' +]; diff --git a/packages/gitbook/lib/constants/pluginAssetsFolder.js b/packages/gitbook/lib/constants/pluginAssetsFolder.js new file mode 100644 index 0000000..cd44722 --- /dev/null +++ b/packages/gitbook/lib/constants/pluginAssetsFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_assets'; diff --git a/packages/gitbook/lib/constants/pluginHooks.js b/packages/gitbook/lib/constants/pluginHooks.js new file mode 100644 index 0000000..2d5dcaa --- /dev/null +++ b/packages/gitbook/lib/constants/pluginHooks.js @@ -0,0 +1,8 @@ +module.exports = [ + 'init', + 'finish', + 'finish:before', + 'config', + 'page', + 'page:before' +]; diff --git a/packages/gitbook/lib/constants/pluginPrefix.js b/packages/gitbook/lib/constants/pluginPrefix.js new file mode 100644 index 0000000..c7f2dd0 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/constants/pluginResources.js b/packages/gitbook/lib/constants/pluginResources.js new file mode 100644 index 0000000..ae283bf --- /dev/null +++ b/packages/gitbook/lib/constants/pluginResources.js @@ -0,0 +1,6 @@ +var Immutable = require('immutable'); + +module.exports = Immutable.List([ + 'js', + 'css' +]); diff --git a/packages/gitbook/lib/constants/templatesFolder.js b/packages/gitbook/lib/constants/templatesFolder.js new file mode 100644 index 0000000..aad6a72 --- /dev/null +++ b/packages/gitbook/lib/constants/templatesFolder.js @@ -0,0 +1,2 @@ + +module.exports = '_layouts'; diff --git a/packages/gitbook/lib/constants/themePrefix.js b/packages/gitbook/lib/constants/themePrefix.js new file mode 100644 index 0000000..99428de --- /dev/null +++ b/packages/gitbook/lib/constants/themePrefix.js @@ -0,0 +1,4 @@ +/* + All GitBook themes plugins name start with this prefix once shorted. +*/ +module.exports = 'theme-';
\ No newline at end of file diff --git a/packages/gitbook/lib/fs/__tests__/mock.js b/packages/gitbook/lib/fs/__tests__/mock.js new file mode 100644 index 0000000..04bd46a --- /dev/null +++ b/packages/gitbook/lib/fs/__tests__/mock.js @@ -0,0 +1,82 @@ +var createMockFS = require('../mock'); + +describe('MockFS', function() { + var fs = createMockFS({ + 'README.md': 'Hello World', + 'SUMMARY.md': '# Summary', + 'folder': { + 'test.md': 'Cool', + 'folder2': { + 'hello.md': 'Hello', + 'world.md': 'World' + } + } + }); + + describe('exists', function() { + it('must return true for a file', function() { + return fs.exists('README.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('must return false for a non existing file', function() { + return fs.exists('README_NOTEXISTS.md') + .then(function(result) { + expect(result).toBeFalsy(); + }); + }); + + it('must return true for a directory', function() { + return fs.exists('folder') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('must return true for a deep file', function() { + return fs.exists('folder/test.md') + .then(function(result) { + expect(result).toBeTruthy(); + }); + }); + + it('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() { + it('must return content for a file', function() { + return fs.readAsString('README.md') + .then(function(result) { + expect(result).toBe('Hello World'); + }); + }); + + it('must return content for a deep file', function() { + return fs.readAsString('folder/test.md') + .then(function(result) { + expect(result).toBe('Cool'); + }); + }); + }); + + describe('readDir', function() { + it('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/packages/gitbook/lib/fs/mock.js b/packages/gitbook/lib/fs/mock.js new file mode 100644 index 0000000..784c533 --- /dev/null +++ b/packages/gitbook/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(path.sep); + 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/packages/gitbook/lib/fs/node.js b/packages/gitbook/lib/fs/node.js new file mode 100644 index 0000000..dfe9fae --- /dev/null +++ b/packages/gitbook/lib/fs/node.js @@ -0,0 +1,42 @@ +var path = require('path'); +var Immutable = require('immutable'); +var fresh = require('fresh-require'); + +var fs = require('../utils/fs'); +var FS = require('../models/fs'); + +function fsReadDir(folder) { + return fs.readdir(folder) + .then(function(files) { + files = Immutable.List(files); + + return files + .map(function(file) { + if (file == '.' || file == '..') return; + + var stat = fs.statSync(path.join(folder, file)); + if (stat.isDirectory()) file = file + path.sep; + return file; + }) + .filter(function(file) { + return Boolean(file); + }); + }); +} + +function fsLoadObject(filename) { + return fresh(filename, require); +} + +module.exports = function createNodeFS(root) { + return FS.create({ + root: root, + + fsExists: fs.exists, + fsReadFile: fs.readFile, + fsStatFile: fs.stat, + fsReadDir: fsReadDir, + fsLoadObject: fsLoadObject, + fsReadAsStream: fs.readStream + }); +}; diff --git a/packages/gitbook/lib/gitbook.js b/packages/gitbook/lib/gitbook.js new file mode 100644 index 0000000..bafd3b8 --- /dev/null +++ b/packages/gitbook/lib/gitbook.js @@ -0,0 +1,28 @@ +var semver = require('semver'); +var pkg = require('../package.json'); + +var VERSION = pkg.version; +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) + + @param {String} condition + @return {Boolean} +*/ +function satisfies(condition) { + // Test with real version + if (semver.satisfies(VERSION, condition)) return true; + + // Test with future stable release + return semver.satisfies(VERSION_STABLE, condition); +} + +module.exports = { + version: pkg.version, + satisfies: satisfies, + START_TIME: START_TIME +}; diff --git a/packages/gitbook/lib/index.js b/packages/gitbook/lib/index.js new file mode 100644 index 0000000..1f683e2 --- /dev/null +++ b/packages/gitbook/lib/index.js @@ -0,0 +1,10 @@ +var extend = require('extend'); + +var common = require('./browser'); + +module.exports = extend({ + initBook: require('./init'), + createNodeFS: require('./fs/node'), + Output: require('./output'), + commands: require('./cli') +}, common); diff --git a/packages/gitbook/lib/init.js b/packages/gitbook/lib/init.js new file mode 100644 index 0000000..c112d4d --- /dev/null +++ b/packages/gitbook/lib/init.js @@ -0,0 +1,83 @@ +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 + + @param {Book} + @param {String} + @return {Promise} +*/ +function initBook(rootFolder) { + var extension = '.md'; + + return fs.mkdirp(rootFolder) + + // Parse the summary and readme + .then(function() { + var fs = createNodeFS(rootFolder); + var book = Book.createForFS(fs); + + return Parse.parseReadme(book) + + // 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) + + .then(function(book) { + var logger = book.getLogger(); + var summary = book.getSummary(); + var summaryFile = summary.getFile(); + var summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension); + + var articles = summary.getArticlesAsList(); + + // Write pages + return Promise.forEach(articles, function(article) { + var articlePath = article.getPath(); + var filePath = articlePath? path.join(rootFolder, articlePath) : null; + if (!filePath) { + return; + } + + return fs.assertFile(filePath, function() { + return fs.ensureFile(filePath) + .then(function() { + 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'); + }); + }); +} + +module.exports = initBook; diff --git a/packages/gitbook/lib/json/encodeBook.js b/packages/gitbook/lib/json/encodeBook.js new file mode 100644 index 0000000..9d7ec77 --- /dev/null +++ b/packages/gitbook/lib/json/encodeBook.js @@ -0,0 +1,39 @@ +var extend = require('extend'); + +var gitbook = require('../gitbook'); +var encodeSummary = require('./encodeSummary'); +var encodeGlossary = require('./encodeGlossary'); +var encodeReadme = require('./encodeReadme'); +var encodeLanguages = require('./encodeLanguages'); + +/** + 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(), + + languages: book.isMultilingual()? encodeLanguages(book.getLanguages()) : undefined, + + gitbook: { + version: gitbook.version, + time: gitbook.START_TIME + }, + book: extend({ + language: language? language : undefined + }, variables.toJS()) + }; +} + +module.exports = encodeBookToJson; diff --git a/packages/gitbook/lib/json/encodeBookWithPage.js b/packages/gitbook/lib/json/encodeBookWithPage.js new file mode 100644 index 0000000..1c5c7a3 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeFile.js b/packages/gitbook/lib/json/encodeFile.js new file mode 100644 index 0000000..d2c9e8a --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeGlossary.js b/packages/gitbook/lib/json/encodeGlossary.js new file mode 100644 index 0000000..e9bcfc9 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeGlossaryEntry.js b/packages/gitbook/lib/json/encodeGlossaryEntry.js new file mode 100644 index 0000000..d163f45 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeLanguages.js b/packages/gitbook/lib/json/encodeLanguages.js new file mode 100644 index 0000000..8447e80 --- /dev/null +++ b/packages/gitbook/lib/json/encodeLanguages.js @@ -0,0 +1,26 @@ +var encodeFile = require('./encodeFile'); + +/** + Encode a languages listing to JSON + + @param {Languages} + @return {Object} +*/ +function encodeLanguages(languages) { + var file = languages.getFile(); + var list = languages.getList(); + + return { + file: encodeFile(file), + list: list + .valueSeq() + .map(function(lang) { + return { + id: lang.getID(), + title: lang.getTitle() + }; + }).toJS() + }; +} + +module.exports = encodeLanguages; diff --git a/packages/gitbook/lib/json/encodeOutput.js b/packages/gitbook/lib/json/encodeOutput.js new file mode 100644 index 0000000..7347e57 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeOutputWithPage.js b/packages/gitbook/lib/json/encodeOutputWithPage.js new file mode 100644 index 0000000..8b21e3d --- /dev/null +++ b/packages/gitbook/lib/json/encodeOutputWithPage.js @@ -0,0 +1,23 @@ +var encodeOutput = require('./encodeOutput'); +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 encodeOutputWithPage(output, page) { + var file = page.getFile(); + var book = output.getBook(); + + var result = encodeOutput(output); + result.page = encodePage(page, book.getSummary()); + result.file = encodeFile(file); + + return result; +} + +module.exports = encodeOutputWithPage; diff --git a/packages/gitbook/lib/json/encodePage.js b/packages/gitbook/lib/json/encodePage.js new file mode 100644 index 0000000..be92117 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeReadme.js b/packages/gitbook/lib/json/encodeReadme.js new file mode 100644 index 0000000..96176a3 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeSummary.js b/packages/gitbook/lib/json/encodeSummary.js new file mode 100644 index 0000000..97db910 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/encodeSummaryArticle.js b/packages/gitbook/lib/json/encodeSummaryArticle.js new file mode 100644 index 0000000..2fc5144 --- /dev/null +++ b/packages/gitbook/lib/json/encodeSummaryArticle.js @@ -0,0 +1,28 @@ + +/** + 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(), + ref: article.getRef(), + articles: articles + }; +} + +module.exports = encodeSummaryArticle; diff --git a/packages/gitbook/lib/json/encodeSummaryPart.js b/packages/gitbook/lib/json/encodeSummaryPart.js new file mode 100644 index 0000000..a5e7218 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/json/index.js b/packages/gitbook/lib/json/index.js new file mode 100644 index 0000000..3b68f5e --- /dev/null +++ b/packages/gitbook/lib/json/index.js @@ -0,0 +1,13 @@ + +module.exports = { + encodeOutput: require('./encodeOutput'), + encodeBookWithPage: require('./encodeBookWithPage'), + encodeOutputWithPage: require('./encodeOutputWithPage'), + encodeBook: require('./encodeBook'), + encodeFile: require('./encodeFile'), + encodePage: require('./encodePage'), + encodeSummary: require('./encodeSummary'), + encodeSummaryArticle: require('./encodeSummaryArticle'), + encodeReadme: require('./encodeReadme'), + encodeLanguages: require('./encodeLanguages') +}; diff --git a/packages/gitbook/lib/models/__tests__/config.js b/packages/gitbook/lib/models/__tests__/config.js new file mode 100644 index 0000000..abad754 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/config.js @@ -0,0 +1,90 @@ +var Immutable = require('immutable'); +var Config = require('../config'); + +describe('Config', function() { + 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); + }); + }); + + describe('toReducedVersion', function() { + it('must only return diffs for simple values', function() { + var _config = Config.createWithValues({ + gitbook: '3.0.0' + }); + + var reducedVersion = _config.toReducedVersion(); + + expect(reducedVersion.toJS()).toEqual({ + gitbook: '3.0.0' + }); + }); + + it('must only return diffs for deep values', function() { + var _config = Config.createWithValues({ + structure: { + readme: 'intro.md' + } + }); + + var reducedVersion = _config.toReducedVersion(); + + expect(reducedVersion.toJS()).toEqual({ + structure: { + readme: 'intro.md' + } + }); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/glossary.js b/packages/gitbook/lib/models/__tests__/glossary.js new file mode 100644 index 0000000..5bf64dc --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/glossary.js @@ -0,0 +1,40 @@ +var File = require('../file'); +var Glossary = require('../glossary'); +var GlossaryEntry = require('../glossaryEntry'); + +describe('Glossary', function() { + 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() { + it('return as markdown', function() { + return glossary.toText('.md') + .then(function(text) { + expect(text).toContain('# Glossary'); + }); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/glossaryEntry.js b/packages/gitbook/lib/models/__tests__/glossaryEntry.js new file mode 100644 index 0000000..833115d --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/glossaryEntry.js @@ -0,0 +1,15 @@ +var GlossaryEntry = require('../glossaryEntry'); + +describe('GlossaryEntry', function() { + 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/packages/gitbook/lib/models/__tests__/page.js b/packages/gitbook/lib/models/__tests__/page.js new file mode 100644 index 0000000..479d276 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/page.js @@ -0,0 +1,28 @@ +var Immutable = require('immutable'); +var Page = require('../page'); + +describe('Page', function() { + + describe('toText', function() { + it('must not prepend frontmatter if no attributes', function() { + var page = Page().merge({ + content: 'Hello World' + }); + + expect(page.toText()).toBe('Hello World'); + }); + + it('must prepend frontmatter if attributes', function() { + var page = Page().merge({ + content: 'Hello World', + attributes: Immutable.fromJS({ + hello: 'world' + }) + }); + + expect(page.toText()).toBe('---\nhello: world\n---\n\nHello World'); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/plugin.js b/packages/gitbook/lib/models/__tests__/plugin.js new file mode 100644 index 0000000..b229664 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/plugin.js @@ -0,0 +1,27 @@ +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/packages/gitbook/lib/models/__tests__/pluginDependency.js b/packages/gitbook/lib/models/__tests__/pluginDependency.js new file mode 100644 index 0000000..cb04cf2 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/pluginDependency.js @@ -0,0 +1,80 @@ +var Immutable = require('immutable'); +var PluginDependency = require('../pluginDependency'); + +describe('PluginDependency', function() { + describe('createFromString', function() { + it('must parse name', function() { + var plugin = PluginDependency.createFromString('hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('*'); + }); + + it('must parse state', function() { + var plugin = PluginDependency.createFromString('-hello'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.isEnabled()).toBe(false); + }); + + describe('Version', function() { + it('must parse version', function() { + var plugin = PluginDependency.createFromString('hello@1.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('1.0.0'); + }); + + it('must parse semver', function() { + var plugin = PluginDependency.createFromString('hello@>=4.0.0'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('>=4.0.0'); + }); + }); + + describe('GIT Version', function() { + it('must handle HTTPS urls', function() { + var plugin = PluginDependency.createFromString('hello@git+https://github.com/GitbookIO/plugin-ga.git'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git'); + }); + + it('must handle SSH urls', function() { + var plugin = PluginDependency.createFromString('hello@git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + expect(plugin.getName()).toBe('hello'); + expect(plugin.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + + describe('listToArray', function() { + it('must create an array from a list of plugin dependencies', function() { + var list = PluginDependency.listToArray(Immutable.List([ + PluginDependency.createFromString('hello@1.0.0'), + PluginDependency.createFromString('noversion'), + PluginDependency.createFromString('-disabled') + ])); + + expect(list).toEqual([ + 'hello@1.0.0', + 'noversion', + '-disabled' + ]); + }); + }); + + describe('listFromArray', function() { + it('must create an array from a list of plugin dependencies', function() { + var arr = Immutable.fromJS([ + 'hello@1.0.0', + { + 'name': 'plugin-ga', + 'version': 'git+ssh://samy@github.com/GitbookIO/plugin-ga.git' + } + ]); + var list = PluginDependency.listFromArray(arr); + + expect(list.first().getName()).toBe('hello'); + expect(list.first().getVersion()).toBe('1.0.0'); + expect(list.last().getName()).toBe('plugin-ga'); + expect(list.last().getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + }); +}); diff --git a/packages/gitbook/lib/models/__tests__/summary.js b/packages/gitbook/lib/models/__tests__/summary.js new file mode 100644 index 0000000..29c9330 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/summary.js @@ -0,0 +1,94 @@ + +describe('Summary', function() { + var File = require('../file'); + var Summary = require('../summary'); + + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: 'My First Article', + ref: 'README.md' + }, + { + title: 'My Second Article', + ref: 'article.md' + }, + { + title: 'Article without ref' + }, + { + title: 'Article with absolute ref', + ref: 'https://google.fr' + } + ] + }, + { + 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(4); + }); + + 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'); + }); + + it('return undefined if not found', function() { + var article = summary.getByPath('NOT_EXISTING.md'); + + expect(article).toBeFalsy(); + }); + }); + + describe('toText', function() { + it('return as markdown', function() { + return summary.toText('.md') + .then(function(text) { + expect(text).toContain('# Summary'); + }); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/summaryArticle.js b/packages/gitbook/lib/models/__tests__/summaryArticle.js new file mode 100644 index 0000000..22a7a20 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/summaryArticle.js @@ -0,0 +1,53 @@ +var SummaryArticle = require('../summaryArticle'); +var File = require('../file'); + +describe('SummaryArticle', function() { + describe('createChildLevel', function() { + it('must create the right level', function() { + var article = SummaryArticle.create({}, '1.1'); + expect(article.createChildLevel()).toBe('1.1.1'); + }); + + it('must create the right level when has articles', function() { + var article = SummaryArticle.create({ + articles: [ + { + title: 'Test' + } + ] + }, '1.1'); + expect(article.createChildLevel()).toBe('1.1.2'); + }); + }); + + describe('isFile', function() { + it('must return true when exactly the file', function() { + var article = SummaryArticle.create({ + ref: 'hello.md' + }, '1.1'); + var file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(true); + }); + + it('must return true when path is not normalized', function() { + var article = SummaryArticle.create({ + ref: '/hello.md' + }, '1.1'); + var file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(true); + }); + + it('must return false when has anchor', function() { + var article = SummaryArticle.create({ + ref: 'hello.md#world' + }, '1.1'); + var file = File.createWithFilepath('hello.md'); + + expect(article.isFile(file)).toBe(false); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/summaryPart.js b/packages/gitbook/lib/models/__tests__/summaryPart.js new file mode 100644 index 0000000..8ee50b6 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/summaryPart.js @@ -0,0 +1,23 @@ +var SummaryPart = require('../summaryPart'); + +describe('SummaryPart', function() { + describe('createChildLevel', function() { + it('must create the right level', function() { + var article = SummaryPart.create({}, '1'); + expect(article.createChildLevel()).toBe('1.1'); + }); + + it('must create the right level when has articles', function() { + var article = SummaryPart.create({ + articles: [ + { + title: 'Test' + } + ] + }, '1'); + expect(article.createChildLevel()).toBe('1.2'); + }); + }); +}); + + diff --git a/packages/gitbook/lib/models/__tests__/templateBlock.js b/packages/gitbook/lib/models/__tests__/templateBlock.js new file mode 100644 index 0000000..e5f7666 --- /dev/null +++ b/packages/gitbook/lib/models/__tests__/templateBlock.js @@ -0,0 +1,205 @@ +var nunjucks = require('nunjucks'); +var Immutable = require('immutable'); +var Promise = require('../../utils/promise'); + +describe('TemplateBlock', function() { + var TemplateBlock = require('../templateBlock'); + + describe('create', function() { + it('must initialize a simple TemplateBlock from a function', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return { + body: '<p>Hello, World!</p>', + parse: true + }; + }); + + // Check basic templateBlock properties + expect(templateBlock.getName()).toBe('sayhello'); + expect(templateBlock.getEndTag()).toBe('endsayhello'); + expect(templateBlock.getBlocks().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('getShortcuts', function() { + it('must return undefined if no shortcuts', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return { + body: '<p>Hello, World!</p>', + parse: true + }; + }); + + expect(templateBlock.getShortcuts()).toNotExist(); + }); + + it('must return complete shortcut', function() { + var templateBlock = TemplateBlock.create('sayhello', { + process: function(block) { + return '<p>Hello, World!</p>'; + }, + shortcuts: { + parsers: ['markdown'], + start: '$', + end: '-' + } + }); + + var shortcut = templateBlock.getShortcuts(); + + expect(shortcut).toBeDefined(); + expect(shortcut.getStart()).toEqual('$'); + expect(shortcut.getEnd()).toEqual('-'); + expect(shortcut.getStartTag()).toEqual('sayhello'); + expect(shortcut.getEndTag()).toEqual('endsayhello'); + }); + }); + + describe('toNunjucksExt()', function() { + it('should replace by block anchor', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return 'Hello'; + }); + + var blocks = {}; + + // Create a fresh Nunjucks environment + var env = new nunjucks.Environment(null, { autoescape: false }); + + // Add template block to environement + var Ext = templateBlock.toNunjucksExt({}, blocks); + 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) { + blocks = Immutable.fromJS(blocks); + expect(blocks.size).toBe(1); + + var blockId = blocks.keySeq().get(0); + var block = blocks.get(blockId); + + expect(res).toBe('{{-%' + blockId + '%-}}'); + expect(block.get('body')).toBe('Hello'); + expect(block.get('name')).toBe('sayhello'); + }); + }); + + it('must create a valid nunjucks extension', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return { + body: '<p>Hello, World!</p>', + parse: true + }; + }); + + // 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>'); + }); + }); + + it('must apply block arguments correctly', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return { + body: '<'+block.kwargs.tag+'>Hello, '+block.kwargs.name+'!</'+block.kwargs.tag+'>', + parse: true + }; + }); + + // 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>'); + }); + }); + + it('must accept an async function', function() { + var templateBlock = TemplateBlock.create('sayhello', function(block) { + return Promise() + .then(function() { + return { + body: 'Hello ' + block.body, + parse: true + }; + }); + }); + + // 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 %}Samy{% endsayhello %}'; + return Promise.nfcall(env.renderString.bind(env), src) + .then(function(res) { + expect(res).toBe('Hello Samy'); + }); + }); + + it('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 { + body: '<p class="yoda">'+nested.end+' '+nested.start+'</p>', + parse: true + }; + } + }); + + // 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/packages/gitbook/lib/models/__tests__/templateEngine.js b/packages/gitbook/lib/models/__tests__/templateEngine.js new file mode 100644 index 0000000..6f18b18 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/models/book.js b/packages/gitbook/lib/models/book.js new file mode 100644 index 0000000..f774ee8 --- /dev/null +++ b/packages/gitbook/lib/models/book.js @@ -0,0 +1,364 @@ +var path = require('path'); +var Immutable = require('immutable'); + +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 Ignore = require('./ignore'); + +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.isFileIgnored(filename); +}; + +/** + 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()); +}; + +/** + Return a languages book + + @param {String} language + @return {Book} +*/ +Book.prototype.getLanguageBook = function(language) { + var books = this.getBooks(); + return books.get(language); +}; + +/** + 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); +}; + +/** + Set the configuration for this book + + @param {Config} + @return {Book} +*/ +Book.prototype.setConfig = function(config) { + return this.set('config', config); +}; + +/** + Set the ignore instance for this book + + @param {Ignore} + @return {Book} +*/ +Book.prototype.setIgnore = function(ignore) { + return this.set('ignore', ignore); +}; + +/** + 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 + }); +}; + +/** + Infers the default extension for files + @return {String} +*/ +Book.prototype.getDefaultExt = function() { + // Inferring sources + var clues = [ + this.getReadme(), + this.getSummary(), + this.getGlossary() + ]; + + // List their extensions + var exts = clues.map(function (clue) { + var file = clue.getFile(); + if (file.exists()) { + return file.getParser().getExtensions().first(); + } else { + return null; + } + }); + // Adds the general default extension + exts.push('.md'); + + // Choose the first non null + return exts.find(function (e) { return e !== null; }); +}; + +/** + Infer the default path for a Readme. + @param {Boolean} [absolute=false] False for a path relative to + this book's content root + @return {String} +*/ +Book.prototype.getDefaultReadmePath = function(absolute) { + var defaultPath = 'README'+this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } +}; + +/** + Infer the default path for a Summary. + @param {Boolean} [absolute=false] False for a path relative to + this book's content root + @return {String} +*/ +Book.prototype.getDefaultSummaryPath = function(absolute) { + var defaultPath = 'SUMMARY'+this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } +}; + +/** + Infer the default path for a Glossary. + @param {Boolean} [absolute=false] False for a path relative to + this book's content root + @return {String} +*/ +Book.prototype.getDefaultGlossaryPath = function(absolute) { + var defaultPath = 'GLOSSARY'+this.getDefaultExt(); + if (absolute) { + return path.join(this.getContentRoot(), defaultPath); + } else { + return defaultPath; + } +}; + +/** + 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(); + var config = parent.getConfig(); + + // Set language in configuration + config = config.setValue('language', language); + + return new Book({ + // Inherits config. logegr and list of ignored files + logger: parent.getLogger(), + config: config, + ignore: ignore, + + language: language, + fs: FS.reduceScope(parent.getContentFS(), language) + }); +}; + +module.exports = Book; diff --git a/packages/gitbook/lib/models/config.js b/packages/gitbook/lib/models/config.js new file mode 100644 index 0000000..6de52f9 --- /dev/null +++ b/packages/gitbook/lib/models/config.js @@ -0,0 +1,181 @@ +var is = require('is'); +var Immutable = require('immutable'); + +var File = require('./file'); +var PluginDependency = require('./pluginDependency'); +var configDefault = require('../constants/configDefault'); +var reducedObject = require('../utils/reducedObject'); + +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 minimum version of configuration, + * Basically it returns the current config minus the default one + * @return {Map} + */ +Config.prototype.toReducedVersion = function() { + return reducedObject(configDefault, this.getValues()); +}; + +/** + * Render config as text + * @return {Promise<String>} + */ +Config.prototype.toText = function() { + return JSON.stringify(this.toReducedVersion().toJS(), null, 4); +}; + +/** + * Change the file for the configuration + * @param {File} file + * @return {Config} + */ +Config.prototype.setFile = function(file) { + return this.set('file', file); +}; + +/** + * 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 {Config} + */ +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); +}; + +/** + * Return a list of plugin dependencies + * @return {List<PluginDependency>} + */ +Config.prototype.getPluginDependencies = function() { + var plugins = this.getValue('plugins'); + + if (is.string(plugins)) { + return PluginDependency.listFromString(plugins); + } else { + return PluginDependency.listFromArray(plugins); + } +}; + +/** + * Return a plugin dependency by its name + * @param {String} name + * @return {PluginDependency} + */ +Config.prototype.getPluginDependency = function(name) { + var plugins = this.getPluginDependencies(); + + return plugins.find(function(dep) { + return dep.getName() === name; + }); +}; + +/** + * Update the list of plugins dependencies + * @param {List<PluginDependency>} + * @return {Config} + */ +Config.prototype.setPluginDependencies = function(deps) { + var plugins = PluginDependency.listToArray(deps); + + return this.setValue('plugins', plugins); +}; + + +/** + * Update values for an existing configuration + * @param {Object} values + * @returns {Config} + */ +Config.prototype.updateValues = function(values) { + values = Immutable.fromJS(values); + + return this.set('values', values); +}; + +/** + * Update values for an existing configuration + * @param {Config} config + * @param {Object} values + * @returns {Config} + */ +Config.prototype.mergeValues = function(values) { + var currentValues = this.getValues(); + values = Immutable.fromJS(values); + + currentValues = currentValues.mergeDeep(values); + + return this.set('values', currentValues); +}; + +/** + * 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) + }); +}; + + +/** + * 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/packages/gitbook/lib/models/file.js b/packages/gitbook/lib/models/file.js new file mode 100644 index 0000000..8ddd4af --- /dev/null +++ b/packages/gitbook/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.getName(); + } 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/packages/gitbook/lib/models/fs.js b/packages/gitbook/lib/models/fs.js new file mode 100644 index 0000000..16bd4ea --- /dev/null +++ b/packages/gitbook/lib/models/fs.js @@ -0,0 +1,307 @@ +var path = require('path'); +var Immutable = require('immutable'); +var stream = require('stream'); + +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, + fsReadAsStream: 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 file as a stream + + @param {String} filename + @return {Promise<Stream>} +*/ +FS.prototype.readAsStream = function(filename) { + var that = this; + var filepath = that.resolve(filename); + var fsReadAsStream = this.get('fsReadAsStream'); + + if (fsReadAsStream) { + return Promise(fsReadAsStream(filepath)); + } + + return this.read(filename) + .then(function(buf) { + var bufferStream = new stream.PassThrough(); + bufferStream.end(buf); + + return bufferStream; + }); +}; + +/** + 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 + @param {Function(dirName)} filterFn: call it for each file/directory to test if it should stop iterating + @return {Promise<List<String>>} +*/ +FS.prototype.listAllFiles = function(dirName, filterFn) { + var that = this; + dirName = dirName || '.'; + + return this.readDir(dirName) + .then(function(files) { + return Promise.reduce(files, function(out, file) { + var isDirectory = pathIsFolder(file); + var newDirName = path.join(dirName, file); + + if (filterFn && filterFn(newDirName) === false) { + return out; + } + + if (!isDirectory) { + return out.push(newDirName); + } + + return that.listAllFiles(newDirName, filterFn) + .then(function(inner) { + return out.concat(inner); + }); + }, Immutable.List()); + }); +}; + +/** + Find a file in a folder (case insensitive) + 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/packages/gitbook/lib/models/glossary.js b/packages/gitbook/lib/models/glossary.js new file mode 100644 index 0000000..0033248 --- /dev/null +++ b/packages/gitbook/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.renderGlossary(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/packages/gitbook/lib/models/glossaryEntry.js b/packages/gitbook/lib/models/glossaryEntry.js new file mode 100644 index 0000000..10791db --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/models/ignore.js b/packages/gitbook/lib/models/ignore.js new file mode 100644 index 0000000..499195e --- /dev/null +++ b/packages/gitbook/lib/models/ignore.js @@ -0,0 +1,42 @@ +var Immutable = require('immutable'); +var IgnoreMutable = require('ignore'); + +/* + Immutable version of node-ignore +*/ +var Ignore = Immutable.Record({ + ignore: new IgnoreMutable() +}, 'Ignore'); + +Ignore.prototype.getIgnore = function() { + return this.get('ignore'); +}; + +/** + Test if a file is ignored by these rules + + @param {String} filePath + @return {Boolean} +*/ +Ignore.prototype.isFileIgnored = function(filename) { + var ignore = this.getIgnore(); + return ignore.filter([filename]).length == 0; +}; + +/** + Add rules + + @param {String} + @return {Ignore} +*/ +Ignore.prototype.add = function(rule) { + var ignore = this.getIgnore(); + var newIgnore = new IgnoreMutable(); + + newIgnore.add(ignore); + newIgnore.add(rule); + + return this.set('ignore', newIgnore); +}; + +module.exports = Ignore; diff --git a/packages/gitbook/lib/models/language.js b/packages/gitbook/lib/models/language.js new file mode 100644 index 0000000..dcefbf6 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/models/languages.js b/packages/gitbook/lib/models/languages.js new file mode 100644 index 0000000..42f05f9 --- /dev/null +++ b/packages/gitbook/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.ref + }); + list = list.set(lang.getID(), lang); + }); + + return Languages({ + file: file, + list: list + }); +}; + +module.exports = Languages; diff --git a/packages/gitbook/lib/models/output.js b/packages/gitbook/lib/models/output.js new file mode 100644 index 0000000..0f008ec --- /dev/null +++ b/packages/gitbook/lib/models/output.js @@ -0,0 +1,107 @@ +var Immutable = require('immutable'); + +var Book = require('./book'); +var LocationUtils = require('../utils/location'); + +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'); +}; + +/** + Return a page byt its file path + + @param {String} filePath + @return {Page|undefined} +*/ +Output.prototype.getPage = function(filePath) { + filePath = LocationUtils.normalize(filePath); + + var pages = this.getPages(); + return pages.get(filePath); +}; + +/** + 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/packages/gitbook/lib/models/page.js b/packages/gitbook/lib/models/page.js new file mode 100644 index 0000000..275a034 --- /dev/null +++ b/packages/gitbook/lib/models/page.js @@ -0,0 +1,70 @@ +var Immutable = require('immutable'); +var yaml = require('js-yaml'); + +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 page as text + * @return {String} +*/ +Page.prototype.toText = function() { + var attrs = this.getAttributes(); + var content = this.getContent(); + + if (attrs.size === 0) { + return content; + } + + var frontMatter = '---\n' + yaml.safeDump(attrs.toJS(), { skipInvalid: true }) + '---\n\n'; + return (frontMatter + content); +}; + +/** + * 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/packages/gitbook/lib/models/parser.js b/packages/gitbook/lib/models/parser.js new file mode 100644 index 0000000..d64542f --- /dev/null +++ b/packages/gitbook/lib/models/parser.js @@ -0,0 +1,122 @@ +var Immutable = require('immutable'); +var Promise = require('../utils/promise'); + +var Parser = Immutable.Record({ + name: String(), + + // List of extensions that can be processed using this parser + extensions: Immutable.List(), + + // Parsing functions + readme: Function(), + langs: Function(), + summary: Function(), + glossary: Function(), + page: Function(), + inline: Function() +}); + +Parser.prototype.getName = function() { + return this.get('name'); +}; + +Parser.prototype.getExtensions = function() { + return this.get('extensions'); +}; + +// PARSE + +Parser.prototype.parseReadme = function(content) { + var readme = this.get('readme'); + return Promise(readme(content)); +}; + +Parser.prototype.parseSummary = function(content) { + var summary = this.get('summary'); + return Promise(summary(content)); +}; + +Parser.prototype.parseGlossary = function(content) { + var glossary = this.get('glossary'); + return Promise(glossary(content)); +}; + +Parser.prototype.preparePage = function(content) { + var page = this.get('page'); + if (!page.prepare) { + return Promise(content); + } + + return Promise(page.prepare(content)); +}; + +Parser.prototype.parsePage = function(content) { + var page = this.get('page'); + return Promise(page(content)); +}; + +Parser.prototype.parseInline = function(content) { + var inline = this.get('inline'); + return Promise(inline(content)); +}; + +Parser.prototype.parseLanguages = function(content) { + var langs = this.get('langs'); + return Promise(langs(content)); +}; + +Parser.prototype.parseInline = function(content) { + var inline = this.get('inline'); + return Promise(inline(content)); +}; + +// TO TEXT + +Parser.prototype.renderLanguages = function(content) { + var langs = this.get('langs'); + return Promise(langs.toText(content)); +}; + +Parser.prototype.renderSummary = function(content) { + var summary = this.get('summary'); + return Promise(summary.toText(content)); +}; + +Parser.prototype.renderGlossary = function(content) { + var glossary = this.get('glossary'); + return Promise(glossary.toText(content)); +}; + +/** + Test if this parser matches an extension + + @param {String} ext + @return {Boolean} +*/ +Parser.prototype.matchExtension = function(ext) { + var exts = this.getExtensions(); + return exts.includes(ext.toLowerCase()); +}; + +/** + Create a new parser using a module (gitbook-markdown, etc) + + @param {String} name + @param {Array<String>} extensions + @param {Object} module + @return {Parser} +*/ +Parser.create = function(name, extensions, module) { + return new Parser({ + name: name, + extensions: Immutable.List(extensions), + readme: module.readme, + langs: module.langs, + summary: module.summary, + glossary: module.glossary, + page: module.page, + inline: module.inline + }); +}; + +module.exports = Parser; diff --git a/packages/gitbook/lib/models/plugin.js b/packages/gitbook/lib/models/plugin.js new file mode 100644 index 0000000..acabba9 --- /dev/null +++ b/packages/gitbook/lib/models/plugin.js @@ -0,0 +1,169 @@ +var Immutable = require('immutable'); + +var TemplateBlock = require('./templateBlock'); +var PluginDependency = require('./pluginDependency'); +var THEME_PREFIX = require('../constants/themePrefix'); + +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), + + // Parent depending on this plugin + parent: String(), + + // 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'); +}; + +Plugin.prototype.getParent = function() { + return this.get('parent'); +}; + +/** + * Return the ID on NPM for this plugin + * @return {String} + */ +Plugin.prototype.getNpmID = function() { + return PluginDependency.nameToNpmID(this.getName()); +}; + +/** + * Check if a plugin is loaded + * @return {Boolean} + */ +Plugin.prototype.isLoaded = function() { + return Boolean(this.getPackage().size > 0); +}; + +/** + * Check if a plugin is a theme given its name + * @return {Boolean} + */ +Plugin.prototype.isTheme = function() { + var name = this.getName(); + return (name && name.indexOf(THEME_PREFIX) === 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 + }); +}; + +/** + * Create a plugin from a dependency + * @param {PluginDependency} + * @return {Plugin} + */ +Plugin.createFromDep = function(dep) { + return new Plugin({ + name: dep.getName(), + version: dep.getVersion() + }); +}; + +Plugin.nameToNpmID = PluginDependency.nameToNpmID; + +module.exports = Plugin; diff --git a/packages/gitbook/lib/models/pluginDependency.js b/packages/gitbook/lib/models/pluginDependency.js new file mode 100644 index 0000000..8866294 --- /dev/null +++ b/packages/gitbook/lib/models/pluginDependency.js @@ -0,0 +1,168 @@ +var is = require('is'); +var semver = require('semver'); +var Immutable = require('immutable'); + +var PREFIX = require('../constants/pluginPrefix'); +var DEFAULT_VERSION = '*'; + +/* + * PluginDependency represents the informations about a plugin + * stored in config.plugins + */ +var PluginDependency = Immutable.Record({ + name: String(), + + // Requirement version (ex: ">1.0.0") + version: String(DEFAULT_VERSION), + + // Is this plugin enabled or disabled? + enabled: Boolean(true) +}, 'PluginDependency'); + +PluginDependency.prototype.getName = function() { + return this.get('name'); +}; + +PluginDependency.prototype.getVersion = function() { + return this.get('version'); +}; + +PluginDependency.prototype.isEnabled = function() { + return this.get('enabled'); +}; + +/** + * Toggle this plugin state + * @param {Boolean} + * @return {PluginDependency} + */ +PluginDependency.prototype.toggle = function(state) { + if (is.undef(state)) { + state = !this.isEnabled(); + } + + return this.set('enabled', state); +}; + +/** + * Return NPM ID for the dependency + * @return {String} + */ +PluginDependency.prototype.getNpmID = function() { + return PluginDependency.nameToNpmID(this.getName()); +}; + +/** + * Is the plugin using a git dependency + * @return {Boolean} + */ +PluginDependency.prototype.isGitDependency = function() { + return !semver.validRange(this.getVersion()); +}; + +/** + * Create a plugin with a name and a plugin + * @param {String} + * @return {Plugin|undefined} + */ +PluginDependency.create = function(name, version, enabled) { + if (is.undefined(enabled)) { + enabled = true; + } + + return new PluginDependency({ + name: name, + version: version || DEFAULT_VERSION, + enabled: Boolean(enabled) + }); +}; + +/** + * Create a plugin from a string + * @param {String} + * @return {Plugin|undefined} + */ +PluginDependency.createFromString = function(s) { + var parts = s.split('@'); + var name = parts[0]; + var version = parts.slice(1).join('@'); + var enabled = true; + + if (name[0] === '-') { + enabled = false; + name = name.slice(1); + } + + return new PluginDependency({ + name: name, + version: version || DEFAULT_VERSION, + enabled: enabled + }); +}; + +/** + * Create a PluginDependency from a string + * @param {String} + * @return {List<PluginDependency>} + */ +PluginDependency.listFromString = function(s) { + var parts = s.split(','); + return PluginDependency.listFromArray(parts); +}; + +/** + * Create a PluginDependency from an array + * @param {Array} + * @return {List<PluginDependency>} + */ +PluginDependency.listFromArray = function(arr) { + return Immutable.List(arr) + .map(function(entry) { + if (is.string(entry)) { + return PluginDependency.createFromString(entry); + } else { + return PluginDependency({ + name: entry.get('name'), + version: entry.get('version') + }); + } + }) + .filter(function(dep) { + return Boolean(dep.getName()); + }); +}; + +/** + * Export plugin dependencies as an array + * @param {List<PluginDependency>} list + * @return {Array<String>} + */ +PluginDependency.listToArray = function(list) { + return list + .map(function(dep) { + var result = ''; + + if (!dep.isEnabled()) { + result += '-'; + } + + result += dep.getName(); + if (dep.getVersion() !== DEFAULT_VERSION) { + result += '@' + dep.getVersion(); + } + + return result; + }) + .toJS(); +}; + +/** + * Return NPM id for a plugin name + * @param {String} + * @return {String} + */ +PluginDependency.nameToNpmID = function(s) { + return PREFIX + s; +}; + +module.exports = PluginDependency; diff --git a/packages/gitbook/lib/models/readme.js b/packages/gitbook/lib/models/readme.js new file mode 100644 index 0000000..c655c82 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/models/summary.js b/packages/gitbook/lib/models/summary.js new file mode 100644 index 0000000..70f0535 --- /dev/null +++ b/packages/gitbook/lib/models/summary.js @@ -0,0 +1,228 @@ +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|Part} +*/ +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) { + var articlePath = article.getPath(); + + return ( + articlePath && + LocationUtils.areIdenticalPaths(articlePath, 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; +}; + +/** + Return the parent article, or parent part of an article + + @param {String|Article} current + @return {Article|Part|Null} +*/ +Summary.prototype.getParent = function (level) { + // Coerce to level + level = is.string(level)? level : level.getLevel(); + + // Get parent level + var parentLevel = getParentLevel(level); + if (!parentLevel) { + return null; + } + + // Get parent of the position + var parentArticle = this.getByLevel(parentLevel); + return parentArticle || null; +}; + +/** + Render summary as text + + @param {String} parseExt Extension of the parser to use + @return {Promise<String>} +*/ +Summary.prototype.toText = function(parseExt) { + var file = this.getFile(); + var parts = this.getParts(); + + var parser = parseExt? parsers.getByExt(parseExt) : file.getParser(); + + if (!parser) { + throw error.FileNotParsableError({ + filename: file.getPath() + }); + } + + return parser.renderSummary({ + 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) + }); +}; + +/** + Returns parent level of a level + + @param {String} level + @return {String} +*/ +function getParentLevel(level) { + var parts = level.split('.'); + return parts.slice(0, -1).join('.'); +} + +module.exports = Summary; diff --git a/packages/gitbook/lib/models/summaryArticle.js b/packages/gitbook/lib/models/summaryArticle.js new file mode 100644 index 0000000..6da8d1d --- /dev/null +++ b/packages/gitbook/lib/models/summaryArticle.js @@ -0,0 +1,189 @@ +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. + * The README has a depth of 1 + * + * @return {Number} + */ +SummaryArticle.prototype.getDepth = function() { + return (this.getLevel().split('.').length - 1); +}; + +/** + * Get path (without anchor) to the pointing file. + * It also normalizes the file path. + * + * @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.flatten(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; +}; + +/** + * Create a new level for a new child article + * + * @return {String} + */ +SummaryArticle.prototype.createChildLevel = function() { + var level = this.getLevel(); + var subArticles = this.getArticles(); + var childLevel = level + '.' + (subArticles.size + 1); + + return childLevel; +}; + +/** + * Is article pointing to a page of an absolute url + * + * @return {Boolean} + */ +SummaryArticle.prototype.isPage = function() { + return !this.isExternal() && this.getRef(); +}; + +/** + * Check if this article is a file (exatcly) + * + * @param {File} file + * @return {Boolean} + */ +SummaryArticle.prototype.isFile = function(file) { + return ( + file.getPath() === this.getPath() + && this.getAnchor() === undefined + ); +}; + +/** + * Check if this article is the introduction of the book + * + * @param {Book|Readme} book + * @return {Boolean} + */ +SummaryArticle.prototype.isReadme = function(book) { + var readme = book.getFile? book : book.getReadme(); + var file = readme.getFile(); + + return this.isFile(file); +}; + +/** + * 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/packages/gitbook/lib/models/summaryPart.js b/packages/gitbook/lib/models/summaryPart.js new file mode 100644 index 0000000..f0e6f57 --- /dev/null +++ b/packages/gitbook/lib/models/summaryPart.js @@ -0,0 +1,61 @@ +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 new level for a new child article + * + * @return {String} + */ +SummaryPart.prototype.createChildLevel = function() { + var level = this.getLevel(); + var subArticles = this.getArticles(); + var childLevel = level + '.' + (subArticles.size + 1); + + return childLevel; +}; + +/** + * 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/packages/gitbook/lib/models/templateBlock.js b/packages/gitbook/lib/models/templateBlock.js new file mode 100644 index 0000000..458f084 --- /dev/null +++ b/packages/gitbook/lib/models/templateBlock.js @@ -0,0 +1,281 @@ +var is = require('is'); +var extend = require('extend'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var genKey = require('../utils/genKey'); +var TemplateShortcut = require('./templateShortcut'); + +var NODE_ENDARGS = '%%endargs%%'; + +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.Map() +}, 'TemplateBlock'); + +TemplateBlock.prototype.getName = function() { + return this.get('name'); +}; + +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'); +}; + + +/** + * Return shortcuts associated with this block or undefined + * @return {TemplateShortcut|undefined} + */ +TemplateBlock.prototype.getShortcuts = function() { + var shortcuts = this.get('shortcuts'); + if (shortcuts.size === 0) { + return undefined; + } + + return TemplateShortcut.createForBlock(this, 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, blocksOutput) { + blocksOutput = blocksOutput || {}; + + 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, blocksOutput); + }) + .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.normalizeBlockResult.bind(this)); + } else { + return this.normalizeBlockResult(r); + } +}; + +/** + * Normalize result from a block process function + * @param {Object|String} result + * @return {Object} + */ +TemplateBlock.prototype.normalizeBlockResult = function(result) { + if (is.string(result)) { + result = { body: result }; + } + result.name = this.getName(); + + return result; +}; + +/** + * Convert a block result to HTML + * @param {Object} result + * @param {Object} blocksOutput: stored post processing blocks in this object + * @return {String} + */ +TemplateBlock.prototype.blockResultToHtml = function(result, blocksOutput) { + var indexedKey; + var toIndex = (!result.parse) || (result.post !== undefined); + + if (toIndex) { + indexedKey = genKey(); + blocksOutput[indexedKey] = result; + } + + // Parsable block, just return it + if (result.parse) { + return result.body; + } + + // Return it as a position marker + return '{{-%' + indexedKey + '%-}}'; + +}; + +/** + * 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 = new TemplateBlock(block); + block = block.set('name', blockName); + return 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/packages/gitbook/lib/models/templateEngine.js b/packages/gitbook/lib/models/templateEngine.js new file mode 100644 index 0000000..5724d55 --- /dev/null +++ b/packages/gitbook/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(blocksOutput) { + 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, blocksOutput); + + 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/packages/gitbook/lib/models/templateOutput.js b/packages/gitbook/lib/models/templateOutput.js new file mode 100644 index 0000000..ae63c06 --- /dev/null +++ b/packages/gitbook/lib/models/templateOutput.js @@ -0,0 +1,42 @@ +var Immutable = require('immutable'); + +var TemplateOutput = Immutable.Record({ + // Text content of the template + content: String(), + + // Map of blocks to replace / post process + blocks: Immutable.Map() +}, 'TemplateOutput'); + +TemplateOutput.prototype.getContent = function() { + return this.get('content'); +}; + +TemplateOutput.prototype.getBlocks = function() { + return this.get('blocks'); +}; + +/** + * Update content of this output + * @param {String} content + * @return {TemplateContent} + */ +TemplateOutput.prototype.setContent = function(content) { + return this.set('content', content); +}; + +/** + * Create a TemplateOutput from a text content + * and an object containing block definition + * @param {String} content + * @param {Object} blocks + * @return {TemplateOutput} + */ +TemplateOutput.create = function(content, blocks) { + return new TemplateOutput({ + content: content, + blocks: Immutable.fromJS(blocks) + }); +}; + +module.exports = TemplateOutput; diff --git a/packages/gitbook/lib/models/templateShortcut.js b/packages/gitbook/lib/models/templateShortcut.js new file mode 100644 index 0000000..309fa6d --- /dev/null +++ b/packages/gitbook/lib/models/templateShortcut.js @@ -0,0 +1,73 @@ +var Immutable = require('immutable'); +var is = require('is'); + +/* + A TemplateShortcut is defined in plugin's template blocks + to replace content with a templating block using delimiters. +*/ +var TemplateShortcut = Immutable.Record({ + // List of parser names accepting this shortcut + parsers: Immutable.Map(), + + start: String(), + end: String(), + + startTag: String(), + endTag: String() +}, 'TemplateShortcut'); + +TemplateShortcut.prototype.getStart = function() { + return this.get('start'); +}; + +TemplateShortcut.prototype.getEnd = function() { + return this.get('end'); +}; + +TemplateShortcut.prototype.getStartTag = function() { + return this.get('startTag'); +}; + +TemplateShortcut.prototype.getEndTag = function() { + return this.get('endTag'); +}; + +TemplateShortcut.prototype.getParsers = function() { + return this.get('parsers'); +}; + +/** + Test if this shortcut accept a parser + + @param {Parser|String} parser + @return {Boolean} +*/ +TemplateShortcut.prototype.acceptParser = function(parser) { + if (!is.string(parser)) { + parser = parser.getName(); + } + + var parserNames = this.get('parsers'); + return parserNames.includes(parser); +}; + +/** + Create a shortcut for a block + + @param {TemplateBlock} block + @param {Map} details + @return {TemplateShortcut} +*/ +TemplateShortcut.createForBlock = function(block, details) { + details = Immutable.fromJS(details); + + return new TemplateShortcut({ + parsers: details.get('parsers'), + start: details.get('start'), + end: details.get('end'), + startTag: block.getName(), + endTag: block.getEndTag() + }); +}; + +module.exports = TemplateShortcut; diff --git a/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js new file mode 100644 index 0000000..61082c9 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js @@ -0,0 +1,32 @@ +var addPlugin = require('../addPlugin'); +var Config = require('../../../models/config'); + +describe('addPlugin', function() { + var config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should have correct state of dependencies', function() { + var disabledDep = config.getPluginDependency('disabled'); + + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); + + it('should add the plugin to the list', function() { + var newConfig = addPlugin(config, 'test'); + + var testDep = newConfig.getPluginDependency('test'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeTruthy(); + + var disabledDep = newConfig.getPluginDependency('disabled'); + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); +}); + + diff --git a/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js new file mode 100644 index 0000000..253cc39 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js @@ -0,0 +1,33 @@ +var removePlugin = require('../removePlugin'); +var Config = require('../../../models/config'); + +describe('removePlugin', function() { + var config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should remove the plugin from the list', function() { + var newConfig = removePlugin(config, 'hello'); + + var testDep = newConfig.getPluginDependency('hello'); + expect(testDep).toNotBeDefined(); + }); + + it('should remove the disabled plugin from the list', function() { + var newConfig = removePlugin(config, 'disabled'); + + var testDep = newConfig.getPluginDependency('disabled'); + expect(testDep).toNotBeDefined(); + }); + + it('should disable default plugin', function() { + var newConfig = removePlugin(config, 'search'); + + var disabledDep = newConfig.getPluginDependency('search'); + expect(disabledDep).toBeDefined(); + expect(disabledDep.getVersion()).toEqual('*'); + expect(disabledDep.isEnabled()).toBeFalsy(); + }); +}); + + diff --git a/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js new file mode 100644 index 0000000..4127853 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js @@ -0,0 +1,28 @@ +var togglePlugin = require('../togglePlugin'); +var Config = require('../../../models/config'); + +describe('togglePlugin', function() { + var config = Config.createWithValues({ + plugins: ['hello', 'world', '-disabled'] + }); + + it('should enable plugin', function() { + var newConfig = togglePlugin(config, 'disabled'); + + var testDep = newConfig.getPluginDependency('disabled'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeTruthy(); + }); + + it('should disable plugin', function() { + var newConfig = togglePlugin(config, 'world'); + + var testDep = newConfig.getPluginDependency('world'); + expect(testDep).toBeDefined(); + expect(testDep.getVersion()).toEqual('*'); + expect(testDep.isEnabled()).toBeFalsy(); + }); +}); + + diff --git a/packages/gitbook/lib/modifiers/config/addPlugin.js b/packages/gitbook/lib/modifiers/config/addPlugin.js new file mode 100644 index 0000000..b8d4ea1 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/addPlugin.js @@ -0,0 +1,25 @@ +var PluginDependency = require('../../models/pluginDependency'); +var togglePlugin = require('./togglePlugin'); +var isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Add a plugin to a book's configuration + * @param {Config} config + * @param {String} pluginName + * @param {String} version (optional) + * @return {Config} + */ +function addPlugin(config, pluginName, version) { + // For default plugin, we only ensure it is enabled + if (isDefaultPlugin(pluginName, version)) { + return togglePlugin(config, pluginName, true); + } + + var deps = config.getPluginDependencies(); + var dep = PluginDependency.create(pluginName, version); + + deps = deps.push(dep); + return config.setPluginDependencies(deps); +} + +module.exports = addPlugin; diff --git a/packages/gitbook/lib/modifiers/config/editPlugin.js b/packages/gitbook/lib/modifiers/config/editPlugin.js new file mode 100644 index 0000000..a792acd --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/editPlugin.js @@ -0,0 +1,13 @@ + +/** + * Edit configuration of a plugin + * @param {Config} config + * @param {String} plugin + * @param {Object} pluginConfig + * @return {Config} + */ +function editPlugin(config, pluginName, pluginConfig) { + return config.setValue('pluginsConfig.'+pluginName, pluginConfig); +} + +module.exports = editPlugin; diff --git a/packages/gitbook/lib/modifiers/config/getPluginConfig.js b/packages/gitbook/lib/modifiers/config/getPluginConfig.js new file mode 100644 index 0000000..ae76de8 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/getPluginConfig.js @@ -0,0 +1,20 @@ +/** + * Return the configuration for a plugin + * @param {Config} config + * @param {String} pluginName + * @return {Object} + */ +function getPluginConfig(config, pluginName) { + var pluginsConfig = config.getValues().get('pluginsConfig'); + if (pluginsConfig === undefined) { + return {}; + } + var pluginConf = pluginsConfig.get(pluginName); + if (pluginConf === undefined) { + return {}; + } else { + return pluginConf.toJS(); + } +} + +module.exports = getPluginConfig; diff --git a/packages/gitbook/lib/modifiers/config/hasPlugin.js b/packages/gitbook/lib/modifiers/config/hasPlugin.js new file mode 100644 index 0000000..9aab4f2 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/hasPlugin.js @@ -0,0 +1,15 @@ + +/** + * Test if a plugin is listed + * @param { {List<PluginDependency}} deps + * @param {String} plugin + * @param {String} version + * @return {Boolean} + */ +function hasPlugin(deps, pluginName, version) { + return !!deps.find(function(dep) { + return dep.getName() === pluginName && (!version || dep.getVersion() === version); + }); +} + +module.exports = hasPlugin; diff --git a/packages/gitbook/lib/modifiers/config/index.js b/packages/gitbook/lib/modifiers/config/index.js new file mode 100644 index 0000000..b3de0b0 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/index.js @@ -0,0 +1,10 @@ + +module.exports = { + addPlugin: require('./addPlugin'), + removePlugin: require('./removePlugin'), + togglePlugin: require('./togglePlugin'), + editPlugin: require('./editPlugin'), + hasPlugin: require('./hasPlugin'), + getPluginConfig: require('./getPluginConfig'), + isDefaultPlugin: require('./isDefaultPlugin') +}; diff --git a/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js b/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js new file mode 100644 index 0000000..63a141d --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js @@ -0,0 +1,14 @@ +var DEFAULT_PLUGINS = require('../../constants/defaultPlugins'); +var hasPlugin = require('./hasPlugin'); + +/** + * Test if a plugin is a default one + * @param {String} plugin + * @param {String} version + * @return {Boolean} + */ +function isDefaultPlugin(pluginName, version) { + return hasPlugin(DEFAULT_PLUGINS, pluginName, version); +} + +module.exports = isDefaultPlugin; diff --git a/packages/gitbook/lib/modifiers/config/removePlugin.js b/packages/gitbook/lib/modifiers/config/removePlugin.js new file mode 100644 index 0000000..ec06d1e --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/removePlugin.js @@ -0,0 +1,25 @@ +var togglePlugin = require('./togglePlugin'); +var isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Remove a plugin from a book's configuration + * @param {Config} config + * @param {String} plugin + * @return {Config} + */ +function removePlugin(config, pluginName) { + var deps = config.getPluginDependencies(); + + // For default plugin, we have to disable it instead of removing from the list + if (isDefaultPlugin(pluginName)) { + return togglePlugin(config, pluginName, false); + } + + // Remove the dependency from the list + deps = deps.filterNot(function(dep) { + return dep.getName() === pluginName; + }); + return config.setPluginDependencies(deps); +} + +module.exports = removePlugin; diff --git a/packages/gitbook/lib/modifiers/config/togglePlugin.js b/packages/gitbook/lib/modifiers/config/togglePlugin.js new file mode 100644 index 0000000..a49e3b9 --- /dev/null +++ b/packages/gitbook/lib/modifiers/config/togglePlugin.js @@ -0,0 +1,31 @@ +var PluginDependency = require('../../models/pluginDependency'); +var hasPlugin = require('./hasPlugin'); +var isDefaultPlugin = require('./isDefaultPlugin'); + +/** + * Enable/disable a plugin dependency + * @param {Config} config + * @param {String} pluginName + * @param {Boolean} state (optional) + * @return {Config} + */ +function togglePlugin(config, pluginName, state) { + var deps = config.getPluginDependencies(); + + // For default plugin, we should ensure it's listed first + if (isDefaultPlugin(pluginName) && !hasPlugin(deps, pluginName)) { + deps = deps.push(PluginDependency.create(pluginName)); + } + + deps = deps.map(function(dep) { + if (dep.getName() === pluginName) { + return dep.toggle(state); + } + + return dep; + }); + + return config.setPluginDependencies(deps); +} + +module.exports = togglePlugin; diff --git a/packages/gitbook/lib/modifiers/index.js b/packages/gitbook/lib/modifiers/index.js new file mode 100644 index 0000000..ad24604 --- /dev/null +++ b/packages/gitbook/lib/modifiers/index.js @@ -0,0 +1,5 @@ + +module.exports = { + Summary: require('./summary'), + Config: require('./config') +}; diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js b/packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js new file mode 100644 index 0000000..d1b916b --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js new file mode 100644 index 0000000..1ee1c8a --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js @@ -0,0 +1,78 @@ +var Summary = require('../../../models/summary'); +var SummaryArticle = require('../../../models/summaryArticle'); +var File = require('../../../models/file'); + +describe('insertArticle', function() { + var insertArticle = require('../insertArticle'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should insert an article at a given level', function() { + var article = SummaryArticle.create({ + title: 'Inserted' + }, 'fake.level'); + + var newSummary = insertArticle(summary, article, '2.1.1'); + + var inserted = newSummary.getByLevel('2.1.1'); + var nextOne = newSummary.getByLevel('2.1.2'); + + expect(inserted.getTitle()).toBe('Inserted'); + expect(inserted.getLevel()).toBe('2.1.1'); + + expect(nextOne.getTitle()).toBe('2.1.1'); + expect(nextOne.getLevel()).toBe('2.1.2'); + }); + + it('should insert an article in last position', function() { + var article = SummaryArticle.create({ + title: 'Inserted' + }, 'fake.level'); + + var newSummary = insertArticle(summary, article, '2.2'); + + var inserted = newSummary.getByLevel('2.2'); + var previousOne = newSummary.getByLevel('2.1'); + + expect(inserted.getTitle()).toBe('Inserted'); + expect(inserted.getLevel()).toBe('2.2'); + + expect(previousOne.getTitle()).toBe('2.1'); // Unchanged + expect(previousOne.getLevel()).toBe('2.1'); + }); +}); diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js b/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js new file mode 100644 index 0000000..11c2cbc --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js @@ -0,0 +1,60 @@ +var Summary = require('../../../models/summary'); +var SummaryPart = require('../../../models/summaryPart'); + +var File = require('../../../models/file'); + +describe('insertPart', function() { + var insertPart = require('../insertPart'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should insert an part at a given level', function() { + var part = SummaryPart.create({ + title: 'Inserted' + }, 'meaningless.level'); + + var newSummary = insertPart(summary, part, 1); + + var inserted = newSummary.getPart(1); + expect(inserted.getTitle()).toBe('Inserted'); + expect(newSummary.getParts().count()).toBe(3); + + var otherArticle = newSummary.getByLevel('3.1'); + expect(otherArticle.getTitle()).toBe('2.1'); + expect(otherArticle.getLevel()).toBe('3.1'); + }); + + it('should insert an part in last position', function() { + var part = SummaryPart.create({ + title: 'Inserted' + }, 'meaningless.level'); + + var newSummary = insertPart(summary, part, 2); + + var inserted = newSummary.getPart(2); + expect(inserted.getTitle()).toBe('Inserted'); + expect(newSummary.getParts().count()).toBe(3); + }); +}); diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js b/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js new file mode 100644 index 0000000..e2635ec --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js @@ -0,0 +1,45 @@ +var Immutable = require('immutable'); +var Summary = require('../../../models/summary'); +var File = require('../../../models/file'); + +describe('mergeAtLevel', function() { + var mergeAtLevel = require('../mergeAtLevel'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [] + } + ]); + + it('should edit a part', function() { + var beforeChildren = summary.getByLevel('1').getArticles(); + var newSummary = mergeAtLevel(summary, '1', {title: 'Part O'}); + var edited = newSummary.getByLevel('1'); + + expect(edited.getTitle()).toBe('Part O'); + // Same children + expect(Immutable.is(beforeChildren, edited.getArticles())).toBe(true); + }); + + it('should edit a part', function() { + var beforePath = summary.getByLevel('1.2').getPath(); + var newSummary = mergeAtLevel(summary, '1.2', {title: 'Renamed article'}); + var edited = newSummary.getByLevel('1.2'); + + expect(edited.getTitle()).toBe('Renamed article'); + // Same children + expect(Immutable.is(beforePath, edited.getPath())).toBe(true); + }); +}); diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js new file mode 100644 index 0000000..aed0b94 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js @@ -0,0 +1,68 @@ +var Immutable = require('immutable'); +var Summary = require('../../../models/summary'); +var File = require('../../../models/file'); + +describe('moveArticle', function() { + var moveArticle = require('../moveArticle'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should move an article to the same place', function() { + var newSummary = moveArticle(summary, '2.1', '2.1'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('should move an article to an previous level', function() { + var newSummary = moveArticle(summary, '2.2', '2.1'); + var moved = newSummary.getByLevel('2.1'); + var other = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.2'); + expect(other.getTitle()).toBe('2.1'); + }); + + it('should move an article to a next level', function() { + var newSummary = moveArticle(summary, '2.1', '2.2'); + var moved = newSummary.getByLevel('2.1'); + var other = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.2'); + expect(other.getTitle()).toBe('2.1'); + }); +}); diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js new file mode 100644 index 0000000..c380575 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js @@ -0,0 +1,82 @@ +var Immutable = require('immutable'); +var Summary = require('../../../models/summary'); +var File = require('../../../models/file'); + +describe('moveArticleAfter', function() { + var moveArticleAfter = require('../moveArticleAfter'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('moving right after itself should be invariant', function() { + var newSummary = moveArticleAfter(summary, '2.1', '2.1'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('moving after previous one should be invariant too', function() { + var newSummary = moveArticleAfter(summary, '2.1', '2.0'); + + expect(Immutable.is(summary, newSummary)).toBe(true); + }); + + it('should move an article after a previous level', function() { + var newSummary = moveArticleAfter(summary, '2.2', '2.0'); + var moved = newSummary.getByLevel('2.1'); + + expect(moved.getTitle()).toBe('2.2'); + expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1'); + }); + + it('should move an article after a previous and less deep level', function() { + var newSummary = moveArticleAfter(summary, '2.1.1', '2.0'); + var moved = newSummary.getByLevel('2.1'); + + expect(moved.getTitle()).toBe('2.1.1'); + expect(newSummary.getByLevel('2.2.1').getTitle()).toBe('2.1.2'); + expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1'); + }); + + it('should move an article after a next level', function() { + var newSummary = moveArticleAfter(summary, '2.1', '2.2'); + var moved = newSummary.getByLevel('2.2'); + + expect(moved.getTitle()).toBe('2.1'); + expect(newSummary.getByLevel('2.1').getTitle()).toBe('2.2'); + }); + +}); diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js new file mode 100644 index 0000000..b45fb49 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js @@ -0,0 +1,53 @@ +var Summary = require('../../../models/summary'); +var File = require('../../../models/file'); + +describe('removeArticle', function() { + var removeArticle = require('../removeArticle'); + var summary = Summary.createFromParts(File(), [ + { + articles: [ + { + title: '1.1', + path: '1.1' + }, + { + title: '1.2', + path: '1.2' + } + ] + }, + { + title: 'Part I', + articles: [ + { + title: '2.1', + path: '2.1', + articles: [ + { + title: '2.1.1', + path: '2.1.1' + }, + { + title: '2.1.2', + path: '2.1.2' + } + ] + }, + { + title: '2.2', + path: '2.2' + } + ] + } + ]); + + it('should remove an article at a given level', function() { + var newSummary = removeArticle(summary, '2.1.1'); + + var removed = newSummary.getByLevel('2.1.1'); + var nextOne = newSummary.getByLevel('2.1.2'); + + expect(removed.getTitle()).toBe('2.1.2'); + expect(nextOne).toBe(null); + }); +}); diff --git a/packages/gitbook/lib/modifiers/summary/editArticleRef.js b/packages/gitbook/lib/modifiers/summary/editArticleRef.js new file mode 100644 index 0000000..7106960 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/editArticleRef.js @@ -0,0 +1,17 @@ +var mergeAtLevel = require('./mergeAtLevel'); + +/** + Edit the ref of an article + + @param {Summary} summary + @param {String} level + @param {String} newRef + @return {Summary} +*/ +function editArticleRef(summary, level, newRef) { + return mergeAtLevel(summary, level, { + ref: newRef + }); +} + +module.exports = editArticleRef; diff --git a/packages/gitbook/lib/modifiers/summary/editArticleTitle.js b/packages/gitbook/lib/modifiers/summary/editArticleTitle.js new file mode 100644 index 0000000..4edee83 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/editArticleTitle.js @@ -0,0 +1,17 @@ +var mergeAtLevel = require('./mergeAtLevel'); + +/** + Edit title of an article + + @param {Summary} summary + @param {String} level + @param {String} newTitle + @return {Summary} +*/ +function editArticleTitle(summary, level, newTitle) { + return mergeAtLevel(summary, level, { + title: newTitle + }); +} + +module.exports = editArticleTitle; diff --git a/packages/gitbook/lib/modifiers/summary/editPartTitle.js b/packages/gitbook/lib/modifiers/summary/editPartTitle.js new file mode 100644 index 0000000..b79ac1e --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/editPartTitle.js @@ -0,0 +1,23 @@ +/** + 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/packages/gitbook/lib/modifiers/summary/index.js b/packages/gitbook/lib/modifiers/summary/index.js new file mode 100644 index 0000000..f91fdb6 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/index.js @@ -0,0 +1,13 @@ +module.exports = { + insertArticle: require('./insertArticle'), + moveArticle: require('./moveArticle'), + moveArticleAfter: require('./moveArticleAfter'), + removeArticle: require('./removeArticle'), + unshiftArticle: require('./unshiftArticle'), + editArticleTitle: require('./editArticleTitle'), + editArticleRef: require('./editArticleRef'), + + insertPart: require('./insertPart'), + removePart: require('./removePart'), + editPartTitle: require('./editPartTitle') +}; diff --git a/packages/gitbook/lib/modifiers/summary/indexArticleLevels.js b/packages/gitbook/lib/modifiers/summary/indexArticleLevels.js new file mode 100644 index 0000000..f311f74 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/modifiers/summary/indexLevels.js b/packages/gitbook/lib/modifiers/summary/indexLevels.js new file mode 100644 index 0000000..604e9ff --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/modifiers/summary/indexPartLevels.js b/packages/gitbook/lib/modifiers/summary/indexPartLevels.js new file mode 100644 index 0000000..d19c70a --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/modifiers/summary/insertArticle.js b/packages/gitbook/lib/modifiers/summary/insertArticle.js new file mode 100644 index 0000000..3a084b3 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/insertArticle.js @@ -0,0 +1,49 @@ +var is = require('is'); +var SummaryArticle = require('../../models/summaryArticle'); +var mergeAtLevel = require('./mergeAtLevel'); +var indexArticleLevels = require('./indexArticleLevels'); + +/** + Returns a new Summary with the article at the given level, with + subsequent article shifted. + + @param {Summary} summary + @param {Article} article + @param {String|Article} level: level to insert at + @return {Summary} +*/ +function insertArticle(summary, article, level) { + article = SummaryArticle(article); + level = is.string(level)? level : level.getLevel(); + + var parent = summary.getParent(level); + if (!parent) { + return summary; + } + + // Find the index to insert at + var articles = parent.getArticles(); + var index = getLeafIndex(level); + + // Insert the article at the right index + articles = articles.insert(index, article); + + // Reindex the level from here + parent = parent.set('articles', articles); + parent = indexArticleLevels(parent); + + return mergeAtLevel(summary, parent.getLevel(), parent); +} + +/** + @param {String} + @return {Number} The index of this level within its parent's children + */ +function getLeafIndex(level) { + var arr = level.split('.').map(function (char) { + return parseInt(char, 10); + }); + return arr[arr.length - 1] - 1; +} + +module.exports = insertArticle; diff --git a/packages/gitbook/lib/modifiers/summary/insertPart.js b/packages/gitbook/lib/modifiers/summary/insertPart.js new file mode 100644 index 0000000..199cba7 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/insertPart.js @@ -0,0 +1,19 @@ +var SummaryPart = require('../../models/summaryPart'); +var indexLevels = require('./indexLevels'); + +/** + Returns a new Summary with a part inserted at given index + + @param {Summary} summary + @param {Part} part + @param {Number} index + @return {Summary} +*/ +function insertPart(summary, part, index) { + part = SummaryPart(part); + + var parts = summary.getParts().insert(index, part); + return indexLevels(summary.set('parts', parts)); +} + +module.exports = insertPart; diff --git a/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js b/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js new file mode 100644 index 0000000..9a95ffc --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js @@ -0,0 +1,75 @@ + +/** + 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) { + // it is the article to edit + return article.merge(newArticle); + } else if (level.indexOf(articleLevel) === 0) { + // it is a parent + var articles = editArticleInList(article.getArticles(), level, newArticle); + return article.set('articles', articles); + } else { + // This is not the article you are looking for + 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, level, newArticle); + + return part.set('articles', articles); +} + + +/** + Edit an article, or a part, in a summary. Does a shallow merge. + + @param {Summary} summary + @param {String} level + @param {Article|Part} newValue + @return {Summary} +*/ +function mergeAtLevel(summary, level, newValue) { + var levelParts = level.split('.'); + var partIndex = Number(levelParts[0]) -1; + + var parts = summary.getParts(); + var part = parts.get(partIndex); + if (!part) { + return summary; + } + + var isEditingPart = levelParts.length < 2; + if (isEditingPart) { + part = part.merge(newValue); + } else { + part = editArticleInPart(part, level, newValue); + } + + parts = parts.set(partIndex, part); + return summary.set('parts', parts); +} + + +module.exports = mergeAtLevel; diff --git a/packages/gitbook/lib/modifiers/summary/moveArticle.js b/packages/gitbook/lib/modifiers/summary/moveArticle.js new file mode 100644 index 0000000..5cb1868 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/moveArticle.js @@ -0,0 +1,25 @@ +var is = require('is'); +var removeArticle = require('./removeArticle'); +var insertArticle = require('./insertArticle'); + +/** + Returns a new summary, with the given article removed from its + origin level, and placed at the given target level. + + @param {Summary} summary + @param {String|SummaryArticle} origin: level to remove + @param {String|SummaryArticle} target: the level where the article will be found + @return {Summary} +*/ +function moveArticle(summary, origin, target) { + // Coerce to level + var originLevel = is.string(origin)? origin : origin.getLevel(); + var targetLevel = is.string(target)? target : target.getLevel(); + var article = summary.getByLevel(originLevel); + + // Remove first + var removed = removeArticle(summary, originLevel); + return insertArticle(removed, article, targetLevel); +} + +module.exports = moveArticle; diff --git a/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js b/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js new file mode 100644 index 0000000..e268f73 --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js @@ -0,0 +1,60 @@ +var is = require('is'); +var removeArticle = require('./removeArticle'); +var insertArticle = require('./insertArticle'); + +/** + Returns a new summary, with the an article moved after another + article. Unlike `moveArticle`, does not ensure that the article + will be found at the target's level plus one. + + @param {Summary} summary + @param {String|SummaryArticle} origin + @param {String|SummaryArticle} afterTarget + @return {Summary} +*/ +function moveArticleAfter(summary, origin, afterTarget) { + // Coerce to level + var originLevel = is.string(origin)? origin : origin.getLevel(); + var afterTargetLevel = is.string(afterTarget)? afterTarget : afterTarget.getLevel(); + var article = summary.getByLevel(originLevel); + + var targetLevel = increment(afterTargetLevel); + + if (targetLevel < origin) { + // Remove first + var removed = removeArticle(summary, originLevel); + // Insert then + return insertArticle(removed, article, targetLevel); + } else { + // Insert right after first + var inserted = insertArticle(summary, article, targetLevel); + // Remove old one + return removeArticle(inserted, originLevel); + } +} + +/** + @param {String} + @return {Array<Number>} + */ +function levelToArray(l) { + return l.split('.').map(function (char) { + return parseInt(char, 10); + }); +} + +/** + @param {Array<Number>} + @return {String} + */ +function arrayToLevel(a) { + return a.join('.'); +} + +function increment(level) { + level = levelToArray(level); + level[level.length - 1]++; + return arrayToLevel(level); +} + +module.exports = moveArticleAfter; diff --git a/packages/gitbook/lib/modifiers/summary/removeArticle.js b/packages/gitbook/lib/modifiers/summary/removeArticle.js new file mode 100644 index 0000000..8a30d0a --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/removeArticle.js @@ -0,0 +1,37 @@ +var is = require('is'); +var mergeAtLevel = require('./mergeAtLevel'); +var indexArticleLevels = require('./indexArticleLevels'); + +/** + Remove an article from a level. + + @param {Summary} summary + @param {String|SummaryArticle} level: level to remove + @return {Summary} +*/ +function removeArticle(summary, level) { + // Coerce to level + level = is.string(level)? level : level.getLevel(); + + var parent = summary.getParent(level); + + var articles = parent.getArticles(); + // Find the index to remove + var index = articles.findIndex(function(art) { + return art.getLevel() === level; + }); + if (index === -1) { + return summary; + } + + // Remove from children + articles = articles.remove(index); + parent = parent.set('articles', articles); + + // Reindex the level from here + parent = indexArticleLevels(parent); + + return mergeAtLevel(summary, parent.getLevel(), parent); +} + +module.exports = removeArticle; diff --git a/packages/gitbook/lib/modifiers/summary/removePart.js b/packages/gitbook/lib/modifiers/summary/removePart.js new file mode 100644 index 0000000..2f8affc --- /dev/null +++ b/packages/gitbook/lib/modifiers/summary/removePart.js @@ -0,0 +1,15 @@ +var indexLevels = require('./indexLevels'); + +/** + Remove a part at given index + + @param {Summary} summary + @param {Number|} index + @return {Summary} +*/ +function removePart(summary, index) { + var parts = summary.getParts().remove(index); + return indexLevels(summary.set('parts', parts)); +} + +module.exports = removePart; diff --git a/packages/gitbook/lib/modifiers/summary/unshiftArticle.js b/packages/gitbook/lib/modifiers/summary/unshiftArticle.js new file mode 100644 index 0000000..d1ebc05 --- /dev/null +++ b/packages/gitbook/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 beginning of summary + + @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/packages/gitbook/lib/output/__tests__/createMock.js b/packages/gitbook/lib/output/__tests__/createMock.js new file mode 100644 index 0000000..f21c544 --- /dev/null +++ b/packages/gitbook/lib/output/__tests__/createMock.js @@ -0,0 +1,38 @@ +var Immutable = require('immutable'); + +var Output = require('../../models/output'); +var Book = require('../../models/book'); +var parseBook = require('../../parse/parseBook'); +var createMockFS = require('../../fs/mock'); +var preparePlugins = require('../preparePlugins'); + +/** + * Create an output using a generator + * + * FOR TESTING PURPOSE ONLY + * + * @param {Generator} generator + * @param {Map<String:String|Map>} files + * @return {Promise<Output>} + */ +function createMockOutput(generator, files, options) { + var fs = createMockFS(files); + var book = Book.createForFS(fs); + var state = generator.State? generator.State({}) : Immutable.Map(); + + book = book.setLogLevel('disabled'); + options = generator.Options(options); + + return parseBook(book) + .then(function(resultBook) { + return new Output({ + book: resultBook, + options: options, + state: state, + generator: generator.name + }); + }) + .then(preparePlugins); +} + +module.exports = createMockOutput; diff --git a/packages/gitbook/lib/output/__tests__/ebook.js b/packages/gitbook/lib/output/__tests__/ebook.js new file mode 100644 index 0000000..9266e9f --- /dev/null +++ b/packages/gitbook/lib/output/__tests__/ebook.js @@ -0,0 +1,16 @@ +var generateMock = require('./generateMock'); +var EbookGenerator = require('../ebook'); + +describe('EbookGenerator', function() { + + it('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/packages/gitbook/lib/output/__tests__/generateMock.js b/packages/gitbook/lib/output/__tests__/generateMock.js new file mode 100644 index 0000000..691ee2d --- /dev/null +++ b/packages/gitbook/lib/output/__tests__/generateMock.js @@ -0,0 +1,40 @@ +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 a 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; + + try { + dir = tmp.dirSync(); + } catch(err) { + throw err; + } + + 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/packages/gitbook/lib/output/__tests__/json.js b/packages/gitbook/lib/output/__tests__/json.js new file mode 100644 index 0000000..12ab567 --- /dev/null +++ b/packages/gitbook/lib/output/__tests__/json.js @@ -0,0 +1,46 @@ +var generateMock = require('./generateMock'); +var JSONGenerator = require('../json'); + +describe('JSONGenerator', function() { + + it('should generate a README.json', function() { + return generateMock(JSONGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('README.json'); + }); + }); + + it('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'); + }); + }); + + it('should generate a multilingual book', function() { + return generateMock(JSONGenerator, { + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'en': { + 'README.md': 'Hello' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('en/README.json'); + expect(folder).toHaveFile('fr/README.json'); + expect(folder).toHaveFile('README.json'); + }); + }); +}); + diff --git a/packages/gitbook/lib/output/__tests__/website.js b/packages/gitbook/lib/output/__tests__/website.js new file mode 100644 index 0000000..1f8c3c0 --- /dev/null +++ b/packages/gitbook/lib/output/__tests__/website.js @@ -0,0 +1,144 @@ +var fs = require('fs'); +var generateMock = require('./generateMock'); +var WebsiteGenerator = require('../website'); + +describe('WebsiteGenerator', function() { + + it('should generate an index.html', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + describe('Glossary', function() { + var folder; + + before(function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'SUMMARY.md': '* [Deep](folder/page.md)', + 'folder': { + 'page.md': 'Hello World' + }, + 'GLOSSARY.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then(function(_folder) { + folder = _folder; + }); + }); + + it('should generate a GLOSSARY.html', function() { + expect(folder).toHaveFile('GLOSSARY.html'); + }); + + it('should correctly resolve glossary links in README', function() { + var html = fs.readFileSync(folder + '/index.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="GLOSSARY.html#hello"]'); + }); + + it('should correctly resolve glossary links in directory', function() { + var html = fs.readFileSync(folder + '/folder/page.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="../GLOSSARY.html#hello"]'); + }); + + it('should accept a custom glossary file', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'book.json': '{ "structure": { "glossary": "custom.md" } }', + 'custom.md': '# Glossary\n\n## Hello\n\nHello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('custom.html'); + expect(folder).toNotHaveFile('GLOSSARY.html'); + + var html = fs.readFileSync(folder + '/index.html', 'utf8'); + expect(html).toHaveDOMElement('.page-inner a[href="custom.html#hello"]'); + }); + }); + }); + + + it('should copy asset files', function() { + return generateMock(WebsiteGenerator, { + 'README.md': 'Hello World', + 'myJsFile.js': 'var a = "test";', + 'folder': { + 'AnotherAssetFile.md': '# Even md' + } + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + expect(folder).toHaveFile('myJsFile.js'); + expect(folder).toHaveFile('folder/AnotherAssetFile.md'); + }); + }); + + it('should generate an index.html for AsciiDoc', function() { + return generateMock(WebsiteGenerator, { + 'README.adoc': 'Hello World' + }) + .then(function(folder) { + expect(folder).toHaveFile('index.html'); + }); + }); + + it('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'); + }); + }); + + it('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).toNotHaveFile('page.html'); + expect(folder).toHaveFile('test/page.html'); + }); + }); + + it('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).toNotHaveFile('en/README.md'); + expect(folder).toNotHaveFile('fr/README.md'); + + // Should copy assets only once + expect(folder).toHaveFile('gitbook/style.css'); + expect(folder).toNotHaveFile('en/gitbook/style.css'); + + expect(folder).toHaveFile('index.html'); + }); + }); +}); + diff --git a/packages/gitbook/lib/output/callHook.js b/packages/gitbook/lib/output/callHook.js new file mode 100644 index 0000000..4914e52 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/callPageHook.js b/packages/gitbook/lib/output/callPageHook.js new file mode 100644 index 0000000..c66cef0 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/createTemplateEngine.js b/packages/gitbook/lib/output/createTemplateEngine.js new file mode 100644 index 0000000..8cf320e --- /dev/null +++ b/packages/gitbook/lib/output/createTemplateEngine.js @@ -0,0 +1,45 @@ +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 transformFn = Templating.replaceShortcuts.bind(null, blocks); + var loader = new Templating.ConrefsLoader(rootFolder, transformFn, 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/packages/gitbook/lib/output/ebook/getConvertOptions.js b/packages/gitbook/lib/output/ebook/getConvertOptions.js new file mode 100644 index 0000000..bc80493 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/ebook/getCoverPath.js b/packages/gitbook/lib/output/ebook/getCoverPath.js new file mode 100644 index 0000000..ab6b579 --- /dev/null +++ b/packages/gitbook/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 coverName = config.getValue('cover', 'cover.jpg'); + + // Resolve to absolute + var cover = fs.pickFile(outputRoot, coverName); + if (cover) { + return cover; + } + + // Multilingual? try parent folder + if (book.isLanguageBook()) { + cover = fs.pickFile(path.join(outputRoot, '..'), coverName); + } + + return cover; +} + +module.exports = getCoverPath; diff --git a/packages/gitbook/lib/output/ebook/getPDFTemplate.js b/packages/gitbook/lib/output/ebook/getPDFTemplate.js new file mode 100644 index 0000000..b767daf --- /dev/null +++ b/packages/gitbook/lib/output/ebook/getPDFTemplate.js @@ -0,0 +1,41 @@ +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: '_SECTION_' + }; + + // Render the theme + return Templating.renderFile(engine, 'ebook/' + filePath, context) + + // Inline css and assets + .then(function(tplOut) { + return Promise.nfcall(juice.juiceResources, tplOut.getContent(), { + webResources: { + relativeTo: outputRoot + } + }); + }); +} + +module.exports = getPDFTemplate; diff --git a/packages/gitbook/lib/output/ebook/index.js b/packages/gitbook/lib/output/ebook/index.js new file mode 100644 index 0000000..786a10a --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/ebook/onFinish.js b/packages/gitbook/lib/output/ebook/onFinish.js new file mode 100644 index 0000000..7f21548 --- /dev/null +++ b/packages/gitbook/lib/output/ebook/onFinish.js @@ -0,0 +1,91 @@ +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'); +var SUMMARY_FILE = 'SUMMARY.html'; + +/** + Write the SUMMARY.html + + @param {Output} + @return {Output} +*/ +function writeSummary(output) { + var options = output.getOptions(); + var prefix = options.get('prefix'); + + var filePath = SUMMARY_FILE; + 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(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); +} + +/** + 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_FILE), + 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/packages/gitbook/lib/output/ebook/onPage.js b/packages/gitbook/lib/output/ebook/onPage.js new file mode 100644 index 0000000..b7b9b42 --- /dev/null +++ b/packages/gitbook/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'), page.getFile().getPath()) + ]) + + // Write page using website generator + .then(function(resultPage) { + return WebsiteGenerator.onPage(output, resultPage); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/lib/output/ebook/options.js b/packages/gitbook/lib/output/ebook/options.js new file mode 100644 index 0000000..ea7b8b4 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/generateAssets.js b/packages/gitbook/lib/output/generateAssets.js new file mode 100644 index 0000000..7a6e104 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/generateBook.js b/packages/gitbook/lib/output/generateBook.js new file mode 100644 index 0000000..46712bd --- /dev/null +++ b/packages/gitbook/lib/output/generateBook.js @@ -0,0 +1,193 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var Output = require('../models/output'); +var Promise = require('../utils/promise'); +var fs = require('../utils/fs'); + +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(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 logger = book.getLogger(); + 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 + }); + + logger.info.ln(''); + logger.info.ln('generating language "' + langBook.getLanguage() + '"'); + 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 + }) + ) + + // Cleanup output folder + .then(function(output) { + var logger = output.getLogger(); + var rootFolder = output.getRoot(); + + logger.debug.ln('cleanup folder "' + rootFolder + '"'); + return fs.ensureFolder(rootFolder) + .thenResolve(output); + }) + + .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/packages/gitbook/lib/output/generatePage.js b/packages/gitbook/lib/output/generatePage.js new file mode 100644 index 0000000..090a870 --- /dev/null +++ b/packages/gitbook/lib/output/generatePage.js @@ -0,0 +1,79 @@ +var path = require('path'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var timing = require('../utils/timing'); + +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', + Promise(page) + .then(function(resultPage) { + var file = resultPage.getFile(); + var filePath = file.getPath(); + var parser = file.getParser(); + var context = JSONUtils.encodeOutputWithPage(output, 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.preparePage(currentPage.getContent()); + }) + + // Render templating syntax + .then(function(content) { + var absoluteFilePath = path.join(book.getContentRoot(), filePath); + return Templating.render(engine, absoluteFilePath, content, context); + }) + + .then(function(output) { + var content = output.getContent(); + + return parser.parsePage(content) + .then(function(result) { + return output.setContent(result.content); + }); + }) + + // Post processing for templating syntax + .then(function(output) { + return Templating.postRender(engine, output); + }) + + // 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/packages/gitbook/lib/output/generatePages.js b/packages/gitbook/lib/output/generatePages.js new file mode 100644 index 0000000..73c5c09 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/getModifiers.js b/packages/gitbook/lib/output/getModifiers.js new file mode 100644 index 0000000..bb44e80 --- /dev/null +++ b/packages/gitbook/lib/output/getModifiers.js @@ -0,0 +1,73 @@ +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 fileToOutput = require('./helper/fileToOutput'); + +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 file = page.getFile(); + + // Glossary entries + var entries = glossary.getEntries(); + var glossaryFile = glossary.getFile(); + var glossaryFilename = fileToOutput(output, glossaryFile.getPath()); + + // 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, + + // Annotate text with glossary entries + Modifiers.annotateText.bind(null, entries, glossaryFilename), + + // Resolve images + Modifiers.resolveImages.bind(null, currentFilePath), + + // Resolve links (.md -> .html) + Modifiers.resolveLinks.bind(null, + currentFilePath, + resolveFileToURL.bind(null, output) + ), + + // 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/packages/gitbook/lib/output/helper/fileToOutput.js b/packages/gitbook/lib/output/helper/fileToOutput.js new file mode 100644 index 0000000..361c6eb --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/helper/fileToURL.js b/packages/gitbook/lib/output/helper/fileToURL.js new file mode 100644 index 0000000..44ad2d8 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/helper/index.js b/packages/gitbook/lib/output/helper/index.js new file mode 100644 index 0000000..f8bc109 --- /dev/null +++ b/packages/gitbook/lib/output/helper/index.js @@ -0,0 +1,2 @@ + +module.exports = {}; diff --git a/packages/gitbook/lib/output/helper/resolveFileToURL.js b/packages/gitbook/lib/output/helper/resolveFileToURL.js new file mode 100644 index 0000000..3f52713 --- /dev/null +++ b/packages/gitbook/lib/output/helper/resolveFileToURL.js @@ -0,0 +1,26 @@ +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 page = output.getPage(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/packages/gitbook/lib/output/helper/writeFile.js b/packages/gitbook/lib/output/helper/writeFile.js new file mode 100644 index 0000000..a6d4645 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/index.js b/packages/gitbook/lib/output/index.js new file mode 100644 index 0000000..9b8ec17 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/json/index.js b/packages/gitbook/lib/output/json/index.js new file mode 100644 index 0000000..361da06 --- /dev/null +++ b/packages/gitbook/lib/output/json/index.js @@ -0,0 +1,7 @@ + +module.exports = { + name: 'json', + Options: require('./options'), + onPage: require('./onPage'), + onFinish: require('./onFinish') +}; diff --git a/packages/gitbook/lib/output/json/onFinish.js b/packages/gitbook/lib/output/json/onFinish.js new file mode 100644 index 0000000..d41d778 --- /dev/null +++ b/packages/gitbook/lib/output/json/onFinish.js @@ -0,0 +1,47 @@ +var path = require('path'); + +var Promise = require('../../utils/promise'); +var fs = require('../../utils/fs'); +var JSONUtils = require('../../json'); + +/** + 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(); + + // Read the main JSON + return fs.readFile(path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), 'utf8') + + // Extend the JSON + .then(function(content) { + var json = JSON.parse(content); + + json.languages = JSONUtils.encodeLanguages(languages); + + return json; + }) + + .then(function(json) { + return fs.writeFile( + path.resolve(outputRoot, 'README.json'), + JSON.stringify(json, null, 4) + ); + }) + + .thenResolve(output); +} + +module.exports = onFinish; diff --git a/packages/gitbook/lib/output/json/onPage.js b/packages/gitbook/lib/output/json/onPage.js new file mode 100644 index 0000000..2315ba0 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/json/options.js b/packages/gitbook/lib/output/json/options.js new file mode 100644 index 0000000..79167b1 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js b/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js new file mode 100644 index 0000000..a3b1d81 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js @@ -0,0 +1,26 @@ +var cheerio = require('cheerio'); +var addHeadingId = require('../addHeadingId'); + +describe('addHeadingId', function() { + it('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>'); + }); + }); + + it('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/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js b/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js new file mode 100644 index 0000000..67e7a10 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js @@ -0,0 +1,46 @@ +var Immutable = require('immutable'); +var cheerio = require('cheerio'); +var GlossaryEntry = require('../../../models/glossaryEntry'); +var annotateText = require('../annotateText'); + +describe('annotateText', function() { + 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, 'GLOSSARY.md', $); + + 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, 'GLOSSARY.md', $); + 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, 'GLOSSARY.md', $); + expect($('a').length).toBe(0); + }); +}); + + diff --git a/packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js b/packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js new file mode 100644 index 0000000..bc1704d --- /dev/null +++ b/packages/gitbook/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(); + }); + + it('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); + }); + }); + + it('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/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js b/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js new file mode 100644 index 0000000..75d9902 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js @@ -0,0 +1,60 @@ +var cheerio = require('cheerio'); +var Promise = require('../../../utils/promise'); +var highlightCode = require('../highlightCode'); + +describe('highlightCode', function() { + function doHighlight(lang, code) { + return { + text: '' + (lang || '') + '$' + code + }; + } + + function doHighlightAsync(lang, code) { + return Promise() + .then(function() { + return doHighlight(lang, code); + }); + } + + it('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'); + }); + }); + + it('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'); + }); + }); + + it('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'); + }); + }); + + it('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/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js b/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js new file mode 100644 index 0000000..0073cff --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js @@ -0,0 +1,25 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var inlinePng = require('../inlinePng'); + +describe('inlinePng', function() { + var dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('should write an inline PNG using data URI as a file', function() { + var $ = cheerio.load('<img alt="GitBook Logo 20x20" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUEAYAAADdGcFOAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAAF+klEQVRIDY3Wf5CVVR3H8c9z791fyI9dQwdQ4TTI7wEWnQZZAa/mJE4Z0OaKUuN1KoaykZxUGGHay+iIVFMoEYrUPhDCKEKW2ChT8dA0RCSxWi6EW3sYYpcfxq5C+4O9957O+7m7O/qHQ9/XzH1+nHuec57z8wkWTsKw0y6N/LxXN6KzTnEUHi8eP/l3YStSU/MdsYvBbGh8six2YXcbcgc++QkfTQkWz/81KtqDA0hlUoWnsX+5uxe5X365BB9my2bjrHNHccLk16BpS9CExjcmXMDbD6wehdyEjxbjz1uK1zn9qga6dcfnMLXeXY/qjuQqTF4W1MKke8ZgeNhjMCxMPIWSd4OF78C55CFI/1kF6WwXpMqjkAZ/CKniNDrCsmU4lE1YbPlgR2x7R39FF23D4mq3A1+Z35PGTNs1E1XhxcGQOh6HNPwXkK56BVJhOaRg/pvoHXNxHFw410B25EYE2RMvI0i/twFJvXcrFObykEa+DmnQGLwYqR0l2a6JqItaj8C/4E2QxtZCofkC8tF1t8HZc/fAZaLnIF2xEsoEtW1w7vBSSFtfhDTnCki9cSi81Ain1uko2Ld+Dmf2rkUq0/5t+PYbFtPQdkjzNiAXTWtDEF49FgkzJInAVPwNyhzcDOmrdZCm/Rn+ebWtcPs+/U24hmg2XL0rRkPPELh9R8fDtXR2oC/VuZbGaci79Ajkb6lZgfyYtyzy/X9s6T/pO/ZfN/RdNxxIwTWM2wbX8KVmuIaEqmKm6zEondwGpd0SyOy5DrJ//TFkX9kMhd3XQHbEVCSsm4OECV5HIv2p15CwfWPSntoHRbv2Q1HzSvSlSqZwATIuBxk/zZBOBbdB+u9hSKU3Q7pwAjInZkFm6U8hu7MSMqe/Dqn8fUj5GVCmpxK+4N/F1LMa0p5eSOPqIPP7NGSunAI/+R6GnzQzIBt8A1LC/QZ+6HwLst1rITv0n5CtXgSZ78yFTNkR+FdeDZneJkip3fAtsQ5Scilkek7CH9dAmjIWvkK7IXXOh6/IzZDNPQdZXR1TQmdjKv0ZfEu0YKDpNflpyG5aDtnRv8VAuu3dBV+huyBbvgdS97tQNLQc0mfugKy5Cb4BipPIXvsUpK5N8Mvao/Bd3QDZRH9Rrtj3Cl6FHwPFMLmNkKrj8BnHoT+XX6f2wl+XxFS4Ab7C72Dgf7bi+5DpTkNm8kQMpCs/BzIlz8LfPxnzLdh3EjwMX4GX4Ju4GNb9A1L7k/D3J8b6kv2LFCtmCmcgUzoJsr2z4MfwFsh87xikZefg188fYaAhpPUxm3ge/vFnYkoED0HqeQiyJYcwkNGWnoNv6s9C1p1Bf/389VYoCjohW7UfMms3wXdpBv7+FEiPLIHs4DIMNERUNhbSpY3wk6QOsqlCDVx2xCrInMpBmfNPQOnzKxBkkrugdOl9GKigSZZCUWIm/GqwDtLUI5D+WAOlb9wKP0YvQLbjZSjsaYaL/n0/FA3fDtnCGihK5UYjCK+ZDr+TDIKLdm2Fs1UOzo76F5wO74XSZj0S6d7RCMLkCshcXALZxaWQRjXDZQ62oRAdCeG/Ju5HELX2QFH3C0hkRy6GovyfwF58AoVbguOxyB2H7/I34Gf11yANnQSp7Vr4MbQH0vg7kbNNp5AM3UrIVDchnz56B1Jm573wW9gZSFVPwO/hefg5FsIvN09CchtQCIOFw/F5U8ii3CZn4cqo7C8YlXEPYkx9cacZl00+iwnprrtwVdj1Q/gXmAs/pu6LZc9XQOGgSvh19n2cDZN341g2EcfxTEGwH/RewqlMsUfbbWIGLjUG+j/j9nokD1beiOvLS5dhjr30Gu6ZnivgdtM/6VJvY1+6pBHbH+h9CX84vfMxNJtisYVFlys+WNCIZJNmIsjohlhNSQC3f8R55H+y/hjkN8GPR9ndCLJxT4/3n0Px51ay8XQnNrYfDJHf//Fc0oMrEZSeeQGJ7+Z+gKCgLbHNWgXnB9FlYt5JaN38JIINC95EakjtAqQeuUx21c5B6tEFf0fSfbEFQf28Z6D6y+X/H0jf40QQJhYwAAAAAElFTkSuQmCC"/>'); + + return inlinePng(dir.name, 'index.html', $) + .then(function() { + var $img = $('img'); + var src = $img.attr('src'); + + expect(dir.name).toHaveFile(src); + }); + }); +}); + + diff --git a/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js b/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js new file mode 100644 index 0000000..8904c11 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js @@ -0,0 +1,104 @@ +var path = require('path'); +var cheerio = require('cheerio'); +var resolveLinks = require('../resolveLinks'); + +describe('resolveLinks', function() { + function resolveFileBasic(href) { + return 'fakeDir/' + 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>'; + + it('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('fakeDir/test/cool.md'); + }); + }); + + it('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('../fakeDir/test/cool.md'); + }); + }); + }); + + describe('Anchor', function() { + it('should prevent anchors in resolution', function() { + var TEST = '<p>This is a <a href="test/cool.md#an-anchor"></a></p>'; + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('test/cool.html#an-anchor'); + }); + }); + + it('should ignore pure anchor links', function() { + var TEST = '<p>This is a <a href="#an-anchor"></a></p>'; + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileCustom, $) + .then(function() { + var link = $('a'); + expect(link.attr('href')).toBe('#an-anchor'); + }); + }); + }); + + describe('Custom Resolver', function() { + var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>'; + + it('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'); + }); + }); + + it('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'); + }); + }); + }); + + describe('External link', function() { + var TEST = '<p>This is a <a href="http://www.github.com">external link</a></p>'; + + it('should have target="_blank" attribute', function() { + var $ = cheerio.load(TEST); + + return resolveLinks('hello.md', resolveFileBasic, $) + .then(function() { + var link = $('a'); + expect(link.attr('target')).toBe('_blank'); + }); + }); + }); + +}); diff --git a/packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js b/packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js new file mode 100644 index 0000000..5fe9796 --- /dev/null +++ b/packages/gitbook/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(); + }); + + it('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/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js b/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js new file mode 100644 index 0000000..dbb3502 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js @@ -0,0 +1,33 @@ +var cheerio = require('cheerio'); +var tmp = require('tmp'); +var path = require('path'); + +var svgToImg = require('../svgToImg'); +var svgToPng = require('../svgToPng'); + +describe('svgToPng', function() { + var dir; + + beforeEach(function() { + dir = tmp.dirSync(); + }); + + it('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/packages/gitbook/lib/output/modifiers/addHeadingId.js b/packages/gitbook/lib/output/modifiers/addHeadingId.js new file mode 100644 index 0000000..e2e2720 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/annotateText.js b/packages/gitbook/lib/output/modifiers/annotateText.js new file mode 100644 index 0000000..490c228 --- /dev/null +++ b/packages/gitbook/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 {String} glossaryFilePath + * @param {HTMLDom} $ + */ +function annotateText(entries, glossaryFilePath, $) { + 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="/' + glossaryFilePath + '#' + entryId + '" ' + + 'class="glossary-term" title="' + escape(description) + '">' + + match + + '</a>'; + }); + }); + + }); +} + +module.exports = annotateText; diff --git a/packages/gitbook/lib/output/modifiers/editHTMLElement.js b/packages/gitbook/lib/output/modifiers/editHTMLElement.js new file mode 100644 index 0000000..755598e --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/fetchRemoteImages.js b/packages/gitbook/lib/output/modifiers/fetchRemoteImages.js new file mode 100644 index 0000000..ef868b9 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/highlightCode.js b/packages/gitbook/lib/output/modifiers/highlightCode.js new file mode 100644 index 0000000..5d397bb --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/highlightCode.js @@ -0,0 +1,58 @@ +var is = require('is'); +var Immutable = require('immutable'); + +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 Immutable.List(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/packages/gitbook/lib/output/modifiers/index.js b/packages/gitbook/lib/output/modifiers/index.js new file mode 100644 index 0000000..f1daa2b --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/inlineAssets.js b/packages/gitbook/lib/output/modifiers/inlineAssets.js new file mode 100644 index 0000000..7cd874b --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/inlineAssets.js @@ -0,0 +1,29 @@ +var svgToImg = require('./svgToImg'); +var svgToPng = require('./svgToPng'); +var inlinePng = require('./inlinePng'); +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, $)) + .then(inlinePng.bind(null, rootFolder, currentFile, $)); + }; +} + +module.exports = inlineAssets; diff --git a/packages/gitbook/lib/output/modifiers/inlinePng.js b/packages/gitbook/lib/output/modifiers/inlinePng.js new file mode 100644 index 0000000..161f164 --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/inlinePng.js @@ -0,0 +1,47 @@ +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 inline PNG images to PNG file + + @param {String} rootFolder + @param {HTMLDom} $ + @return {Promise} +*/ +function inlinePng(rootFolder, currentFile, $) { + var currentDirectory = path.dirname(currentFile); + + return editHTMLElement($, 'img', function($img) { + var src = $img.attr('src'); + if (!LocationUtils.isDataURI(src)) { + return; + } + + // We avoid generating twice the same PNG + var hash = crc.crc32(src).toString(16); + var fileName = hash + '.png'; + + // Result file path + var filePath = path.join(rootFolder, fileName); + + return fs.assertFile(filePath, function() { + return imagesUtil.convertInlinePNG(src, filePath); + }) + .then(function() { + // Convert filename to a relative filename + fileName = LocationUtils.relative(currentDirectory, fileName); + + // Replace src + $img.attr('src', fileName); + }); + }); +} + + +module.exports = inlinePng; diff --git a/packages/gitbook/lib/output/modifiers/modifyHTML.js b/packages/gitbook/lib/output/modifiers/modifyHTML.js new file mode 100644 index 0000000..cd3d6e5 --- /dev/null +++ b/packages/gitbook/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) { + return op($); + }) + .then(function() { + var resultHTML = $.html(); + return page.set('content', resultHTML); + }); +} + +module.exports = modifyHTML; diff --git a/packages/gitbook/lib/output/modifiers/resolveImages.js b/packages/gitbook/lib/output/modifiers/resolveImages.js new file mode 100644 index 0000000..cc25cfa --- /dev/null +++ b/packages/gitbook/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) || LocationUtils.isDataURI(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/packages/gitbook/lib/output/modifiers/resolveLinks.js b/packages/gitbook/lib/output/modifiers/resolveLinks.js new file mode 100644 index 0000000..9d15e5e --- /dev/null +++ b/packages/gitbook/lib/output/modifiers/resolveLinks.js @@ -0,0 +1,53 @@ +var path = require('path'); +var url = require('url'); + +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'); + + // Don't change a tag without href + if (!href) { + return; + } + + if (LocationUtils.isExternal(href)) { + $a.attr('target', '_blank'); + return; + } + + // Split anchor + var parsed = url.parse(href); + href = parsed.pathname || ''; + + if (href) { + // Calcul absolute path for this + href = LocationUtils.toAbsolute(href, currentDirectory, '.'); + + // Resolve file + href = resolveFile(href); + + // Convert back to relative + href = LocationUtils.relative(currentDirectory, href); + } + + // Add back anchor + href = href + (parsed.hash || ''); + + $a.attr('href', href); + }); +} + +module.exports = resolveLinks; diff --git a/packages/gitbook/lib/output/modifiers/svgToImg.js b/packages/gitbook/lib/output/modifiers/svgToImg.js new file mode 100644 index 0000000..f31b06d --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/modifiers/svgToPng.js b/packages/gitbook/lib/output/modifiers/svgToPng.js new file mode 100644 index 0000000..1093106 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/prepareAssets.js b/packages/gitbook/lib/output/prepareAssets.js new file mode 100644 index 0000000..ae9b55a --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/preparePages.js b/packages/gitbook/lib/output/preparePages.js new file mode 100644 index 0000000..83944ed --- /dev/null +++ b/packages/gitbook/lib/output/preparePages.js @@ -0,0 +1,26 @@ +var Parse = require('../parse'); +var Promise = require('../utils/promise'); + +/** + List and prepare all pages + + @param {Output} + @return {Promise<Output>} +*/ +function preparePages(output) { + var book = output.getBook(); + var logger = book.getLogger(); + + if (book.isMultilingual()) { + return Promise(output); + } + + 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/packages/gitbook/lib/output/preparePlugins.js b/packages/gitbook/lib/output/preparePlugins.js new file mode 100644 index 0000000..5c4be93 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/website/__tests__/i18n.js b/packages/gitbook/lib/output/website/__tests__/i18n.js new file mode 100644 index 0000000..fd610fb --- /dev/null +++ b/packages/gitbook/lib/output/website/__tests__/i18n.js @@ -0,0 +1,38 @@ +var createMockOutput = require('../../__tests__/createMock'); +var prepareI18n = require('../prepareI18n'); +var createTemplateEngine = require('../createTemplateEngine'); + +var WebsiteGenerator = require('../'); + +describe('i18n', function() { + it('should correctly use english as default language', function() { + return createMockOutput(WebsiteGenerator, { + 'README.md': 'Hello World' + }) + .then(function(output) { + return prepareI18n(output); + }) + .then(function(output) { + var engine = createTemplateEngine(output, 'README.md'); + var t = engine.getFilters().get('t'); + + expect(t('SUMMARY_INTRODUCTION')).toEqual('Introduction'); + }); + }); + + it('should correctly use language from book.json', function() { + return createMockOutput(WebsiteGenerator, { + 'README.md': 'Hello World', + 'book.json': JSON.stringify({ language: 'fr' }) + }) + .then(function(output) { + return prepareI18n(output); + }) + .then(function(output) { + var engine = createTemplateEngine(output, 'README.md'); + var t = engine.getFilters().get('t'); + + expect(t('GITBOOK_LINK')).toEqual('Publié avec GitBook'); + }); + }); +}); diff --git a/packages/gitbook/lib/output/website/copyPluginAssets.js b/packages/gitbook/lib/output/website/copyPluginAssets.js new file mode 100644 index 0000000..9150636 --- /dev/null +++ b/packages/gitbook/lib/output/website/copyPluginAssets.js @@ -0,0 +1,117 @@ +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 outputRoot = options.get('root'); + + var state = output.getState(); + var resources = state.getResources(); + + var pluginRoot = plugin.getPath(); + var pluginResources = resources.get(plugin.getName()); + + var assetsFolder = pluginResources.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/packages/gitbook/lib/output/website/createTemplateEngine.js b/packages/gitbook/lib/output/website/createTemplateEngine.js new file mode 100644 index 0000000..02ec796 --- /dev/null +++ b/packages/gitbook/lib/output/website/createTemplateEngine.js @@ -0,0 +1,151 @@ +var path = require('path'); +var nunjucks = require('nunjucks'); +var DoExtension = require('nunjucks-do')(nunjucks); + +var Api = require('../../api'); +var deprecate = require('../../api/deprecate'); +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.getValue('language'); + + // Create API context + var context = Api.encodeGlobal(output); + + + /** + * Check if a file exists + * @param {String} fileName + * @return {Boolean} + */ + function fileExists(fileName) { + if (!fileName) { + return false; + } + + var filePath = PathUtils.resolveInRoot(outputFolder, fileName); + return fs.existsSync(filePath); + } + + /** + * Return an article by its path + * @param {String} filePath + * @return {Object|undefined} + */ + function getArticleByPath(filePath) { + var article = summary.getByPath(filePath); + if (!article) return undefined; + + return JSONUtils.encodeSummaryArticle(article); + } + + /** + * Return a page by its path + * @param {String} filePath + * @return {Object|undefined} + */ + function getPageByPath(filePath) { + var page = output.getPage(filePath); + if (!page) return undefined; + + return JSONUtils.encodePage(page, summary); + } + + return TemplateEngine.create({ + loader: loader, + + context: context, + + globals: { + getArticleByPath: getArticleByPath, + getPageByPath: getPageByPath, + fileExists: fileExists + }, + + 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); + }, + + + fileExists: deprecate.method(book, 'fileExists', fileExists, 'Filter "fileExists" is deprecated, use "fileExists(filename)" '), + getArticleByPath: deprecate.method(book, 'getArticleByPath', fileExists, 'Filter "getArticleByPath" is deprecated, use "getArticleByPath(filename)" '), + + contentURL: function(filePath) { + return fileToURL(output, filePath); + } + }), + + extensions: { + 'DoExtension': new DoExtension() + } + }); +} + +module.exports = createTemplateEngine; diff --git a/packages/gitbook/lib/output/website/index.js b/packages/gitbook/lib/output/website/index.js new file mode 100644 index 0000000..7818a28 --- /dev/null +++ b/packages/gitbook/lib/output/website/index.js @@ -0,0 +1,11 @@ + +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') +}; diff --git a/packages/gitbook/lib/output/website/listSearchPaths.js b/packages/gitbook/lib/output/website/listSearchPaths.js new file mode 100644 index 0000000..c45f39c --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/website/onAsset.js b/packages/gitbook/lib/output/website/onAsset.js new file mode 100644 index 0000000..69dfc4f --- /dev/null +++ b/packages/gitbook/lib/output/website/onAsset.js @@ -0,0 +1,28 @@ +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 bookFS = book.getContentFS(); + + var outputFolder = options.get('root'); + var outputPath = path.resolve(outputFolder, asset); + + return fs.ensureFile(outputPath) + .then(function() { + return bookFS.readAsStream(asset) + .then(function(stream) { + return fs.writeStream(outputPath, stream); + }); + }) + .thenResolve(output); +} + +module.exports = onAsset; diff --git a/packages/gitbook/lib/output/website/onFinish.js b/packages/gitbook/lib/output/website/onFinish.js new file mode 100644 index 0000000..5267458 --- /dev/null +++ b/packages/gitbook/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(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); +} + +module.exports = onFinish; diff --git a/packages/gitbook/lib/output/website/onInit.js b/packages/gitbook/lib/output/website/onInit.js new file mode 100644 index 0000000..3465eef --- /dev/null +++ b/packages/gitbook/lib/output/website/onInit.js @@ -0,0 +1,20 @@ +var Promise = require('../../utils/promise'); + +var copyPluginAssets = require('./copyPluginAssets'); +var prepareI18n = require('./prepareI18n'); +var prepareResources = require('./prepareResources'); + +/** + Initialize the generator + + @param {Output} + @return {Output} +*/ +function onInit(output) { + return Promise(output) + .then(prepareI18n) + .then(prepareResources) + .then(copyPluginAssets); +} + +module.exports = onInit; diff --git a/packages/gitbook/lib/output/website/onPage.js b/packages/gitbook/lib/output/website/onPage.js new file mode 100644 index 0000000..5fb40a7 --- /dev/null +++ b/packages/gitbook/lib/output/website/onPage.js @@ -0,0 +1,76 @@ +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 prefix = options.get('prefix'); + + var file = page.getFile(); + + var book = output.getBook(); + var plugins = output.getPlugins(); + var state = output.getState(); + var resources = state.getResources(); + + 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.encodeOutputWithPage(output, resultPage); + context.plugins = { + resources: Plugins.listResources(plugins, resources).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(tplOut) { + return writeFile(output, filePath, tplOut.getContent()); + }); + }); +} + +module.exports = onPage; diff --git a/packages/gitbook/lib/output/website/options.js b/packages/gitbook/lib/output/website/options.js new file mode 100644 index 0000000..ac9cdad --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/website/prepareI18n.js b/packages/gitbook/lib/output/website/prepareI18n.js new file mode 100644 index 0000000..cedd3b9 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/output/website/prepareResources.js b/packages/gitbook/lib/output/website/prepareResources.js new file mode 100644 index 0000000..4e6835d --- /dev/null +++ b/packages/gitbook/lib/output/website/prepareResources.js @@ -0,0 +1,54 @@ +var is = require('is'); +var Immutable = require('immutable'); +var Promise = require('../../utils/promise'); + +var Api = require('../../api'); + +/** + Prepare plugins resources, add all output corresponding type resources + + @param {Output} + @return {Promise<Output>} +*/ +function prepareResources(output) { + var plugins = output.getPlugins(); + var options = output.getOptions(); + var type = options.get('prefix'); + var state = output.getState(); + var context = Api.encodeGlobal(output); + + var result = Immutable.Map(); + + return Promise.forEach(plugins, function(plugin) { + var pluginResources = plugin.getResources(type); + + return Promise() + .then(function() { + // Apply resources if is a function + if (is.fn(pluginResources)) { + return Promise() + .then(pluginResources.bind(context)); + } + else { + return pluginResources; + } + }) + .then(function(resources) { + result = result.set(plugin.getName(), Immutable.Map(resources)); + }); + }) + .then(function() { + // Set output resources + state = state.merge({ + resources: result + }); + + output = output.merge({ + state: state + }); + + return output; + }); +} + +module.exports = prepareResources;
\ No newline at end of file diff --git a/packages/gitbook/lib/output/website/state.js b/packages/gitbook/lib/output/website/state.js new file mode 100644 index 0000000..cb8f750 --- /dev/null +++ b/packages/gitbook/lib/output/website/state.js @@ -0,0 +1,19 @@ +var I18n = require('i18n-t'); +var Immutable = require('immutable'); + +var GeneratorState = Immutable.Record({ + i18n: I18n(), + + // List of plugins' resources + resources: Immutable.Map() +}); + +GeneratorState.prototype.getI18n = function() { + return this.get('i18n'); +}; + +GeneratorState.prototype.getResources = function() { + return this.get('resources'); +}; + +module.exports = GeneratorState; diff --git a/packages/gitbook/lib/parse/__tests__/listAssets.js b/packages/gitbook/lib/parse/__tests__/listAssets.js new file mode 100644 index 0000000..4c5b0a0 --- /dev/null +++ b/packages/gitbook/lib/parse/__tests__/listAssets.js @@ -0,0 +1,29 @@ +var Immutable = require('immutable'); + +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); +var listAssets = require('../listAssets'); +var parseGlossary = require('../parseGlossary'); + +describe('listAssets', function() { + it('should not list glossary as asset', function() { + var fs = createMockFS({ + 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello', + 'assetFile.js': '', + 'assets': { + 'file.js': '' + } + }); + var book = Book.createForFS(fs); + + return parseGlossary(book) + .then(function(resultBook) { + return listAssets(resultBook, Immutable.Map()); + }) + .then(function(assets) { + expect(assets.size).toBe(2); + expect(assets.includes('assetFile.js')); + expect(assets.includes('assets/file.js')); + }); + }); +}); diff --git a/packages/gitbook/lib/parse/__tests__/parseBook.js b/packages/gitbook/lib/parse/__tests__/parseBook.js new file mode 100644 index 0000000..b1236c9 --- /dev/null +++ b/packages/gitbook/lib/parse/__tests__/parseBook.js @@ -0,0 +1,90 @@ +var Book = require('../../models/book'); +var createMockFS = require('../../fs/mock'); + +describe('parseBook', function() { + var parseBook = require('../parseBook'); + + it('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); + }); + }); + + it('should extend configuration for multilingual book', function() { + var fs = createMockFS({ + 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)', + 'book.json': '{ "title": "Test", "author": "GitBook" }', + 'en': { + 'README.md': 'Hello', + 'book.json': '{ "title": "Test EN" }' + }, + 'fr': { + 'README.md': 'Bonjour' + } + }); + var book = Book.createForFS(fs); + + return parseBook(book) + .then(function(resultBook) { + var books = resultBook.getBooks(); + + expect(resultBook.isMultilingual()).toBe(true); + expect(books.size).toBe(2); + + var en = books.get('en'); + var fr = books.get('fr'); + + var enConfig = en.getConfig(); + var frConfig = fr.getConfig(); + + expect(enConfig.getValue('title')).toBe('Test EN'); + expect(enConfig.getValue('author')).toBe('GitBook'); + + expect(frConfig.getValue('title')).toBe('Test'); + expect(frConfig.getValue('author')).toBe('GitBook'); + }); + }); + + it('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/packages/gitbook/lib/parse/__tests__/parseGlossary.js b/packages/gitbook/lib/parse/__tests__/parseGlossary.js new file mode 100644 index 0000000..9069af6 --- /dev/null +++ b/packages/gitbook/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'); + + it('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); + }); + }); + + it('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/packages/gitbook/lib/parse/__tests__/parseIgnore.js b/packages/gitbook/lib/parse/__tests__/parseIgnore.js new file mode 100644 index 0000000..54e7dae --- /dev/null +++ b/packages/gitbook/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); + } + + it('should load rules from .ignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-1.js')).toBeTruthy(); + }); + }); + + it('should load rules from .gitignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-2.js')).toBeTruthy(); + }); + }); + + it('should load rules from .bookignore', function() { + return getBook() + .then(function(book) { + expect(book.isFileIgnored('test-3.js')).toBeFalsy(); + }); + }); +}); diff --git a/packages/gitbook/lib/parse/__tests__/parsePageFromString.js b/packages/gitbook/lib/parse/__tests__/parsePageFromString.js new file mode 100644 index 0000000..2911fa3 --- /dev/null +++ b/packages/gitbook/lib/parse/__tests__/parsePageFromString.js @@ -0,0 +1,37 @@ +var parsePageFromString = require('../parsePageFromString'); +var Page = require('../../models/page'); + +describe('parsePageFromString', function() { + var page = new Page(); + + it('should parse YAML frontmatter', function() { + var CONTENT = '---\nhello: true\nworld: "cool"\n---\n# Hello World\n'; + var newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('ltr'); + expect(newPage.getContent()).toBe('# Hello World\n'); + + var attrs = newPage.getAttributes(); + expect(attrs.size).toBe(2); + expect(attrs.get('hello')).toBe(true); + expect(attrs.get('world')).toBe('cool'); + }); + + it('should parse text direction (english)', function() { + var CONTENT = 'Hello World'; + var newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('ltr'); + expect(newPage.getContent()).toBe('Hello World'); + expect(newPage.getAttributes().size).toBe(0); + }); + + it('should parse text direction (arab)', function() { + var CONTENT = 'Ù…Ø±ØØ¨Ø§ بالعالم'; + var newPage = parsePageFromString(page, CONTENT); + + expect(newPage.getDir()).toBe('rtl'); + expect(newPage.getContent()).toBe('Ù…Ø±ØØ¨Ø§ بالعالم'); + expect(newPage.getAttributes().size).toBe(0); + }); +}); diff --git a/packages/gitbook/lib/parse/__tests__/parseReadme.js b/packages/gitbook/lib/parse/__tests__/parseReadme.js new file mode 100644 index 0000000..4270ea3 --- /dev/null +++ b/packages/gitbook/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'); + + it('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.'); + }); + }); + + it('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/packages/gitbook/lib/parse/__tests__/parseSummary.js b/packages/gitbook/lib/parse/__tests__/parseSummary.js new file mode 100644 index 0000000..55a445e --- /dev/null +++ b/packages/gitbook/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'); + + it('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(); + }); + }); + + it('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/packages/gitbook/lib/parse/findParsableFile.js b/packages/gitbook/lib/parse/findParsableFile.js new file mode 100644 index 0000000..51e2dd0 --- /dev/null +++ b/packages/gitbook/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<File | Undefined>} +*/ +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/packages/gitbook/lib/parse/index.js b/packages/gitbook/lib/parse/index.js new file mode 100644 index 0000000..1f73946 --- /dev/null +++ b/packages/gitbook/lib/parse/index.js @@ -0,0 +1,15 @@ + +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'), + parsePageFromString: require('./parsePageFromString'), + lookupStructureFile: require('./lookupStructureFile') +}; diff --git a/packages/gitbook/lib/parse/listAssets.js b/packages/gitbook/lib/parse/listAssets.js new file mode 100644 index 0000000..d83d8fd --- /dev/null +++ b/packages/gitbook/lib/parse/listAssets.js @@ -0,0 +1,43 @@ +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(); + + var langs = book.getLanguages(); + var langsFile = langs.getFile().getPath(); + + var config = book.getConfig(); + var configFile = config.getFile().getPath(); + + function filterFile(file) { + return !( + file === summaryFile || + file === glossaryFile || + file === langsFile || + file === configFile || + book.isContentFileIgnored(file) || + pages.has(file) + ); + } + + return timing.measure( + 'parse.listAssets', + fs.listAllFiles('.', filterFile) + ); +} + +module.exports = listAssets; diff --git a/packages/gitbook/lib/parse/lookupStructureFile.js b/packages/gitbook/lib/parse/lookupStructureFile.js new file mode 100644 index 0000000..36b37f8 --- /dev/null +++ b/packages/gitbook/lib/parse/lookupStructureFile.js @@ -0,0 +1,20 @@ +var findParsableFile = require('./findParsableFile'); + +/** + Lookup a structure file (ex: SUMMARY.md, GLOSSARY.md) in a book. Uses + book's config to find it. + + @param {Book} book + @param {String} type: one of ["glossary", "readme", "summary", "langs"] + @return {Promise<File | Undefined>} The path of the file found, relative + to the book content root. +*/ +function lookupStructureFile(book, type) { + var config = book.getConfig(); + + var fileToSearch = config.getValue(['structure', type]); + + return findParsableFile(book, fileToSearch); +} + +module.exports = lookupStructureFile; diff --git a/packages/gitbook/lib/parse/parseBook.js b/packages/gitbook/lib/parse/parseBook.js new file mode 100644 index 0000000..a92f39e --- /dev/null +++ b/packages/gitbook/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 = 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/packages/gitbook/lib/parse/parseConfig.js b/packages/gitbook/lib/parse/parseConfig.js new file mode 100644 index 0000000..a411af8 --- /dev/null +++ b/packages/gitbook/lib/parse/parseConfig.js @@ -0,0 +1,55 @@ +var Promise = require('../utils/promise'); + +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(); + var config = book.getConfig(); + + return Promise.some(CONFIG_FILES, function(filename) { + // Is this file ignored? + if (book.isFileIgnored(filename)) { + return; + } + + // Try loading it + return fs.loadAsObject(filename) + .then(function(cfg) { + return fs.statFile(filename) + .then(function(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 values = result? result.values : {}; + values = validateConfig(values); + + // Set the file + if (result.file) { + config = config.setFile(result.file); + } + + // Merge with old values + config = config.mergeValues(values); + + return book.setConfig(config); + }); +} + +module.exports = parseConfig; diff --git a/packages/gitbook/lib/parse/parseGlossary.js b/packages/gitbook/lib/parse/parseGlossary.js new file mode 100644 index 0000000..a96e5fc --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/parse/parseIgnore.js b/packages/gitbook/lib/parse/parseIgnore.js new file mode 100644 index 0000000..84d8c33 --- /dev/null +++ b/packages/gitbook/lib/parse/parseIgnore.js @@ -0,0 +1,51 @@ +var Promise = require('../utils/promise'); +var IGNORE_FILES = require('../constants/ignoreFiles'); + +var DEFAULT_IGNORES = [ + // Skip Git stuff + '.git/', + + // Skip OS X meta data + '.DS_Store', + + // Skip stuff installed by plugins + 'node_modules', + + // Skip book outputs + '_book', + + // Ignore files in the templates folder + '_layouts' +]; + +/** + 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 = ignore.add(DEFAULT_IGNORES); + + return Promise.serie(IGNORE_FILES, function(filename) { + return fs.readAsString(filename) + .then(function(content) { + ignore = ignore.add(content.toString().split(/\r?\n/)); + }, function(err) { + return Promise(); + }); + }) + + .then(function() { + return book.setIgnore(ignore); + }); +} + +module.exports = parseIgnore; diff --git a/packages/gitbook/lib/parse/parseLanguages.js b/packages/gitbook/lib/parse/parseLanguages.js new file mode 100644 index 0000000..346f3a3 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/parse/parsePage.js b/packages/gitbook/lib/parse/parsePage.js new file mode 100644 index 0000000..fdc56a3 --- /dev/null +++ b/packages/gitbook/lib/parse/parsePage.js @@ -0,0 +1,21 @@ +var parsePageFromString = require('./parsePageFromString'); + +/** + * 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) { + return parsePageFromString(page, content); + }); +} + + +module.exports = parsePage; diff --git a/packages/gitbook/lib/parse/parsePageFromString.js b/packages/gitbook/lib/parse/parsePageFromString.js new file mode 100644 index 0000000..80c147b --- /dev/null +++ b/packages/gitbook/lib/parse/parsePageFromString.js @@ -0,0 +1,22 @@ +var Immutable = require('immutable'); +var fm = require('front-matter'); +var direction = require('direction'); + +/** + * Parse a page, its content and the YAMl header + * + * @param {Page} page + * @return {Page} + */ +function parsePageFromString(page, content) { + var parsed = fm(content); + + return page.merge({ + content: parsed.body, + attributes: Immutable.fromJS(parsed.attributes), + dir: direction(parsed.body) + }); +} + + +module.exports = parsePageFromString; diff --git a/packages/gitbook/lib/parse/parsePagesList.js b/packages/gitbook/lib/parse/parsePagesList.js new file mode 100644 index 0000000..1cf42f5 --- /dev/null +++ b/packages/gitbook/lib/parse/parsePagesList.js @@ -0,0 +1,78 @@ +var Immutable = require('immutable'); + +var timing = require('../utils/timing'); +var Page = require('../models/page'); +var walkSummary = require('./walkSummary'); +var parsePage = require('./parsePage'); + + +/** + Parse a page from a path + + @param {Book} book + @param {String} filePath + @return {Page} +*/ +function parseFilePage(book, filePath) { + var fs = book.getContentFS(); + + return fs.statFile(filePath) + .then(function(file) { + var page = Page.createForFile(file); + return parsePage(book, page); + }); +} + + +/** + Parse all pages from a book as an OrderedMap + + @param {Book} book + @return {Promise<OrderedMap<Page>>} +*/ +function parsePagesList(book) { + var summary = book.getSummary(); + var glossary = book.getGlossary(); + var map = Immutable.OrderedMap(); + + // Parse pages from summary + 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 parseFilePage(book, filepath) + .then(function(page) { + map = map.set(filepath, page); + }, function() { + // file doesn't exist + }); + }) + ) + + // Parse glossary + .then(function() { + var file = glossary.getFile(); + + if (!file.exists()) { + return; + } + + return parseFilePage(book, file.getPath()) + .then(function(page) { + map = map.set(file.getPath(), page); + }); + }) + + .then(function() { + return map; + }); +} + + +module.exports = parsePagesList; diff --git a/packages/gitbook/lib/parse/parseReadme.js b/packages/gitbook/lib/parse/parseReadme.js new file mode 100644 index 0000000..a2ede77 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/parse/parseStructureFile.js b/packages/gitbook/lib/parse/parseStructureFile.js new file mode 100644 index 0000000..718f731 --- /dev/null +++ b/packages/gitbook/lib/parse/parseStructureFile.js @@ -0,0 +1,67 @@ +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var lookupStructureFile = require('./lookupStructureFile'); + +/** + 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) { + if (type === 'readme') { + return parser.parseReadme(content); + } else if (type === 'glossary') { + return parser.parseGlossary(content); + } else if (type === 'summary') { + return parser.parseSummary(content); + } else if (type === 'langs') { + return parser.parseLanguages(content); + } else { + throw new Error('Parsing invalid type "' + type + '"'); + } + }) + .then(function(result) { + return [ + file, + result + ]; + }); +} + + +/** + 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(); + + return lookupStructureFile(book, type) + .then(function(file) { + if (!file) return [undefined, undefined]; + + return parseFile(fs, file, type); + }); +} + +module.exports = parseStructureFile; diff --git a/packages/gitbook/lib/parse/parseSummary.js b/packages/gitbook/lib/parse/parseSummary.js new file mode 100644 index 0000000..2c1e3b3 --- /dev/null +++ b/packages/gitbook/lib/parse/parseSummary.js @@ -0,0 +1,44 @@ +var parseStructureFile = require('./parseStructureFile'); +var Summary = require('../models/summary'); +var SummaryModifier = require('../modifiers').Summary; + +/** + 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 if not in SUMMARY.md + var readmeArticle = summary.getByPath(readmeFile.getPath()); + + if (readmeFile.exists() && !readmeArticle) { + summary = SummaryModifier.unshiftArticle(summary, { + title: 'Introduction', + ref: readmeFile.getPath() + }); + } + + // Set new summary + return book.setSummary(summary); + }); +} + +module.exports = parseSummary; diff --git a/packages/gitbook/lib/parse/validateConfig.js b/packages/gitbook/lib/parse/validateConfig.js new file mode 100644 index 0000000..21294ac --- /dev/null +++ b/packages/gitbook/lib/parse/validateConfig.js @@ -0,0 +1,31 @@ +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); + +var schema = require('../constants/configSchema'); +var error = require('../utils/error'); +var mergeDefaults = require('../utils/mergeDefaults'); + +/** + 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' + }); + + // Throw error + if (result.errors.length > 0) { + throw new error.ConfigurationError(new Error(result.errors[0].stack)); + } + + // Insert default values + var defaults = jsonSchemaDefaults(schema); + return mergeDefaults(bookJson, defaults); +} + +module.exports = validateConfig; diff --git a/packages/gitbook/lib/parse/walkSummary.js b/packages/gitbook/lib/parse/walkSummary.js new file mode 100644 index 0000000..0117752 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/parsers.js b/packages/gitbook/lib/parsers.js new file mode 100644 index 0000000..70e44f4 --- /dev/null +++ b/packages/gitbook/lib/parsers.js @@ -0,0 +1,63 @@ +var path = require('path'); +var Immutable = require('immutable'); + +var markdownParser = require('gitbook-markdown'); +var asciidocParser = require('gitbook-asciidoc'); + +var EXTENSIONS_MARKDOWN = require('./constants/extsMarkdown'); +var EXTENSIONS_ASCIIDOC = require('./constants/extsAsciidoc'); +var Parser = require('./models/parser'); + +// This list is ordered by priority of parsers to use +var parsers = Immutable.List([ + Parser.create('markdown', EXTENSIONS_MARKDOWN, markdownParser), + Parser.create('asciidoc', EXTENSIONS_ASCIIDOC, asciidocParser) +]); + +/** + * Return a specific parser by its name + * + * @param {String} name + * @return {Parser|undefined} + */ +function getParser(name) { + return parsers.find(function(parser) { + return parser.getName() === name; + }); +} + +/** + * Return a specific parser according to an extension + * + * @param {String} ext + * @return {Parser|undefined} + */ +function getParserByExt(ext) { + return parsers.find(function(parser) { + return parser.matchExtension(ext); + }); +} + +/** + * Return parser for a file + * + * @param {String} ext + * @return {Parser|undefined} + */ +function getParserForFile(filename) { + return getParserByExt(path.extname(filename)); +} + +// List all parsable extensions +var extensions = parsers + .map(function(parser) { + return parser.getExtensions(); + }) + .flatten(); + +module.exports = { + extensions: extensions, + get: getParser, + getByExt: getParserByExt, + getForFile: getParserForFile +}; diff --git a/packages/gitbook/lib/plugins/__tests__/findForBook.js b/packages/gitbook/lib/plugins/__tests__/findForBook.js new file mode 100644 index 0000000..d8af2e9 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/findForBook.js @@ -0,0 +1,19 @@ +var path = require('path'); + +var Book = require('../../models/book'); +var createNodeFS = require('../../fs/node'); +var findForBook = require('../findForBook'); + +describe('findForBook', function() { + var fs = createNodeFS( + path.resolve(__dirname, '../../..') + ); + var book = Book.createForFS(fs); + + it('should list default plugins', function() { + return findForBook(book) + .then(function(plugins) { + expect(plugins.has('fontsettings')).toBeTruthy(); + }); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/findInstalled.js b/packages/gitbook/lib/plugins/__tests__/findInstalled.js new file mode 100644 index 0000000..9377190 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/findInstalled.js @@ -0,0 +1,25 @@ +var path = require('path'); +var Immutable = require('immutable'); + +describe('findInstalled', function() { + var findInstalled = require('../findInstalled'); + + it('must list default plugins for gitbook directory', function() { + // Read gitbook-plugins from package.json + var pkg = require(path.resolve(__dirname, '../../../package.json')); + var gitbookPlugins = Immutable.Seq(pkg.dependencies) + .filter(function(v, k) { + return k.indexOf('gitbook-plugin') === 0; + }) + .cacheResult(); + + return findInstalled(path.resolve(__dirname, '../../../')) + .then(function(plugins) { + expect(plugins.size >= gitbookPlugins.size).toBeTruthy(); + + expect(plugins.has('fontsettings')).toBe(true); + expect(plugins.has('search')).toBe(true); + }); + }); + +}); diff --git a/packages/gitbook/lib/plugins/__tests__/installPlugin.js b/packages/gitbook/lib/plugins/__tests__/installPlugin.js new file mode 100644 index 0000000..0c1a346 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/installPlugin.js @@ -0,0 +1,29 @@ +var path = require('path'); + +var PluginDependency = require('../../models/pluginDependency'); +var Book = require('../../models/book'); +var NodeFS = require('../../fs/node'); +var installPlugin = require('../installPlugin'); + +var Parse = require('../../parse'); + +describe('installPlugin', function() { + var book; + + this.timeout(30000); + + before(function() { + var fs = NodeFS(path.resolve(__dirname, '../../../')); + var baseBook = Book.createForFS(fs); + + return Parse.parseConfig(baseBook) + .then(function(_book) { + book = _book; + }); + }); + + it('must install a plugin from NPM', function() { + var dep = PluginDependency.createFromString('ga'); + return installPlugin(book, dep); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/installPlugins.js b/packages/gitbook/lib/plugins/__tests__/installPlugins.js new file mode 100644 index 0000000..1a66f90 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/installPlugins.js @@ -0,0 +1,30 @@ +var path = require('path'); + +var Book = require('../../models/book'); +var NodeFS = require('../../fs/node'); +var installPlugins = require('../installPlugins'); + +var Parse = require('../../parse'); + +describe('installPlugins', function() { + var book; + + this.timeout(30000); + + before(function() { + var fs = NodeFS(path.resolve(__dirname, '../../../')); + var baseBook = Book.createForFS(fs); + + return Parse.parseConfig(baseBook) + .then(function(_book) { + book = _book; + }); + }); + + it('must install all plugins from NPM', function() { + return installPlugins(book) + .then(function(n) { + expect(n).toBe(2); + }); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/listDependencies.js b/packages/gitbook/lib/plugins/__tests__/listDependencies.js new file mode 100644 index 0000000..940faba --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/listDependencies.js @@ -0,0 +1,38 @@ +var PluginDependency = require('../../models/pluginDependency'); +var listDependencies = require('../listDependencies'); +var toNames = require('../toNames'); + +describe('listDependencies', function() { + it('must list default', function() { + var deps = PluginDependency.listFromString('ga,great'); + var plugins = listDependencies(deps); + var names = toNames(plugins); + + expect(names).toEqual([ + 'ga', 'great', + 'highlight', 'search', 'lunr', 'sharing', 'fontsettings', + 'theme-default' ]); + }); + + it('must list from array with -', function() { + var deps = PluginDependency.listFromString('ga,-great'); + var plugins = listDependencies(deps); + var names = toNames(plugins); + + expect(names).toEqual([ + 'ga', + 'highlight', 'search', 'lunr', 'sharing', 'fontsettings', + 'theme-default' ]); + }); + + it('must remove default plugins using -', function() { + var deps = PluginDependency.listFromString('ga,-search'); + var plugins = listDependencies(deps); + var names = toNames(plugins); + + expect(names).toEqual([ + 'ga', + 'highlight', 'lunr', 'sharing', 'fontsettings', + 'theme-default' ]); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js b/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js new file mode 100644 index 0000000..bb414a3 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js @@ -0,0 +1,10 @@ +var path = require('path'); +var locateRootFolder = require('../locateRootFolder'); + +describe('locateRootFolder', function() { + it('should correctly resolve the node_modules for gitbook', function() { + expect(locateRootFolder()).toBe( + path.resolve(__dirname, '../../../') + ); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/resolveVersion.js b/packages/gitbook/lib/plugins/__tests__/resolveVersion.js new file mode 100644 index 0000000..1877c9e --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/resolveVersion.js @@ -0,0 +1,22 @@ +var PluginDependency = require('../../models/pluginDependency'); +var resolveVersion = require('../resolveVersion'); + +describe('resolveVersion', function() { + it('must skip resolving and return non-semver versions', function() { + var plugin = PluginDependency.createFromString('ga@git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + + return resolveVersion(plugin) + .then(function(version) { + expect(version).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git'); + }); + }); + + it('must resolve a normal plugin dependency', function() { + var plugin = PluginDependency.createFromString('ga@>0.9.0 < 1.0.1'); + + return resolveVersion(plugin) + .then(function(version) { + expect(version).toBe('1.0.0'); + }); + }); +}); diff --git a/packages/gitbook/lib/plugins/__tests__/sortDependencies.js b/packages/gitbook/lib/plugins/__tests__/sortDependencies.js new file mode 100644 index 0000000..87df477 --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/sortDependencies.js @@ -0,0 +1,42 @@ +var PluginDependency = require('../../models/pluginDependency'); +var sortDependencies = require('../sortDependencies'); +var toNames = require('../toNames'); + +describe('sortDependencies', function() { + it('must load themes after plugins', function() { + var allPlugins = PluginDependency.listFromArray([ + 'hello', + 'theme-test', + 'world' + ]); + + var sorted = sortDependencies(allPlugins); + var names = toNames(sorted); + + expect(names).toEqual([ + 'hello', + 'world', + 'theme-test' + ]); + }); + + it('must keep order of themes', function() { + var allPlugins = PluginDependency.listFromArray([ + 'theme-test', + 'theme-test1', + 'hello', + 'theme-test2', + 'world' + ]); + var sorted = sortDependencies(allPlugins); + var names = toNames(sorted); + + expect(names).toEqual([ + 'hello', + 'world', + 'theme-test', + 'theme-test1', + 'theme-test2' + ]); + }); +});
\ No newline at end of file diff --git a/packages/gitbook/lib/plugins/__tests__/validatePlugin.js b/packages/gitbook/lib/plugins/__tests__/validatePlugin.js new file mode 100644 index 0000000..635423c --- /dev/null +++ b/packages/gitbook/lib/plugins/__tests__/validatePlugin.js @@ -0,0 +1,16 @@ +var Promise = require('../../utils/promise'); +var Plugin = require('../../models/plugin'); +var validatePlugin = require('../validatePlugin'); + +describe('validatePlugin', function() { + it('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/packages/gitbook/lib/plugins/findForBook.js b/packages/gitbook/lib/plugins/findForBook.js new file mode 100644 index 0000000..be2ad9f --- /dev/null +++ b/packages/gitbook/lib/plugins/findForBook.js @@ -0,0 +1,34 @@ +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var findInstalled = require('./findInstalled'); +var locateRootFolder = require('./locateRootFolder'); + +/** + * 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(locateRootFolder()), + 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/packages/gitbook/lib/plugins/findInstalled.js b/packages/gitbook/lib/plugins/findInstalled.js new file mode 100644 index 0000000..06cc6c4 --- /dev/null +++ b/packages/gitbook/lib/plugins/findInstalled.js @@ -0,0 +1,91 @@ +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, parent) { + 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 (parent) return; + } else { + results = results.set(pluginName, Plugin({ + name: pluginName, + version: version, + path: pkgPath, + depth: depth, + parent: parent + })); + } + + Immutable.Map(dependencies).forEach(function(dep) { + onPackage(dep, pluginName); + }); + } + + // 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) + .fail(function() { + return Promise([]); + }) + .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); + }); + }); + }) + .then(function() { + // Return installed plugins + return results; + }); +} + +module.exports = findInstalled; diff --git a/packages/gitbook/lib/plugins/index.js b/packages/gitbook/lib/plugins/index.js new file mode 100644 index 0000000..607a7f1 --- /dev/null +++ b/packages/gitbook/lib/plugins/index.js @@ -0,0 +1,10 @@ + +module.exports = { + loadForBook: require('./loadForBook'), + validateConfig: require('./validateConfig'), + installPlugins: require('./installPlugins'), + listResources: require('./listResources'), + listBlocks: require('./listBlocks'), + listFilters: require('./listFilters') +}; + diff --git a/packages/gitbook/lib/plugins/installPlugin.js b/packages/gitbook/lib/plugins/installPlugin.js new file mode 100644 index 0000000..37852df --- /dev/null +++ b/packages/gitbook/lib/plugins/installPlugin.js @@ -0,0 +1,47 @@ +var npmi = require('npmi'); + +var Promise = require('../utils/promise'); +var resolveVersion = require('./resolveVersion'); + +/** + Install a plugin for a book + + @param {Book} + @param {PluginDependency} + @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(''); + 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 +'" (' + requirement + ') from NPM with version', version); + 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'); + }); +} + +module.exports = installPlugin; diff --git a/packages/gitbook/lib/plugins/installPlugins.js b/packages/gitbook/lib/plugins/installPlugins.js new file mode 100644 index 0000000..307c41e --- /dev/null +++ b/packages/gitbook/lib/plugins/installPlugins.js @@ -0,0 +1,48 @@ +var npmi = require('npmi'); + +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +var Promise = require('../utils/promise'); +var installPlugin = require('./installPlugin'); + +/** + Install plugin requirements for a book + + @param {Book} + @return {Promise<Number>} +*/ +function installPlugins(book) { + var logger = book.getLogger(); + var config = book.getConfig(); + var plugins = config.getPluginDependencies(); + + // Remove default plugins + // (only if version is same as installed) + plugins = plugins.filterNot(function(plugin) { + var dependency = DEFAULT_PLUGINS.find(function(dep) { + return dep.getName() === plugin.getName(); + }); + + return ( + // Disabled plugin + !plugin.isEnabled() || + + // Or default one installed in GitBook itself + (dependency && + plugin.getVersion() === dependency.getVersion()) + ); + }); + + if (plugins.size == 0) { + logger.info.ln('nothing to install!'); + return Promise(); + } + + logger.info.ln('installing', plugins.size, 'plugins using npm@' + npmi.NPM_VERSION); + + return Promise.forEach(plugins, function(plugin) { + return installPlugin(book, plugin); + }) + .thenResolve(plugins.size); +} + +module.exports = installPlugins; diff --git a/packages/gitbook/lib/plugins/listBlocks.js b/packages/gitbook/lib/plugins/listBlocks.js new file mode 100644 index 0000000..3ac28af --- /dev/null +++ b/packages/gitbook/lib/plugins/listBlocks.js @@ -0,0 +1,18 @@ +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) { + var blocks = plugin.getBlocks(); + return result.merge(blocks); + }, Immutable.Map()); +} + +module.exports = listBlocks; diff --git a/packages/gitbook/lib/plugins/listDependencies.js b/packages/gitbook/lib/plugins/listDependencies.js new file mode 100644 index 0000000..d52eaa9 --- /dev/null +++ b/packages/gitbook/lib/plugins/listDependencies.js @@ -0,0 +1,33 @@ +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); +var sortDependencies = require('./sortDependencies'); + +/** + * List all dependencies for a book, including default plugins. + * It returns a concat with default plugins and remove disabled ones. + * + * @param {List<PluginDependency>} deps + * @return {List<PluginDependency>} + */ +function listDependencies(deps) { + // Extract list of plugins to disable (starting with -) + var toRemove = deps + .filter(function(plugin) { + return !plugin.isEnabled(); + }) + .map(function(plugin) { + return plugin.getName(); + }); + + // Concat with default plugins + deps = deps.concat(DEFAULT_PLUGINS); + + // Remove plugins + deps = deps.filterNot(function(plugin) { + return toRemove.includes(plugin.getName()); + }); + + // Sort + return sortDependencies(deps); +} + +module.exports = listDependencies; diff --git a/packages/gitbook/lib/plugins/listDepsForBook.js b/packages/gitbook/lib/plugins/listDepsForBook.js new file mode 100644 index 0000000..196e3aa --- /dev/null +++ b/packages/gitbook/lib/plugins/listDepsForBook.js @@ -0,0 +1,18 @@ +var listDependencies = require('./listDependencies'); + +/** + * 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 {List<PluginDependency>} + */ +function listDepsForBook(book) { + var config = book.getConfig(); + var plugins = config.getPluginDependencies(); + + return listDependencies(plugins); +} + +module.exports = listDepsForBook; diff --git a/packages/gitbook/lib/plugins/listFilters.js b/packages/gitbook/lib/plugins/listFilters.js new file mode 100644 index 0000000..4d8a471 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/plugins/listResources.js b/packages/gitbook/lib/plugins/listResources.js new file mode 100644 index 0000000..fe31b5a --- /dev/null +++ b/packages/gitbook/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, resources) { + return plugins.reduce(function(result, plugin) { + var npmId = plugin.getNpmID(); + var pluginResources = resources.get(plugin.getName()); + + PLUGIN_RESOURCES.forEach(function(resourceType) { + var assets = pluginResources.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/packages/gitbook/lib/plugins/loadForBook.js b/packages/gitbook/lib/plugins/loadForBook.js new file mode 100644 index 0000000..757677e --- /dev/null +++ b/packages/gitbook/lib/plugins/loadForBook.js @@ -0,0 +1,73 @@ +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var listDepsForBook = require('./listDepsForBook'); +var findForBook = require('./findForBook'); +var loadPlugin = require('./loadPlugin'); + + +/** + * Load all plugins in a book + * + * @param {Book} + * @return {Promise<Map<String:Plugin>} + */ +function loadForBook(book) { + var logger = book.getLogger(); + + // List the dependencies + var requirements = listDepsForBook(book); + + // List all plugins installed in the book + return findForBook(book) + .then(function(installedMap) { + var missing = []; + var plugins = requirements.reduce(function(result, dep) { + var name = dep.getName(); + var installed = installedMap.get(name); + + if (installed) { + var deps = installedMap + .filter(function(plugin) { + return plugin.getParent() === name; + }) + .toArray(); + + result = result.concat(deps); + result.push(installed); + } else { + missing.push(name); + } + + return result; + }, []); + + // Convert plugins list to a map + plugins = Immutable.List(plugins) + .map(function(plugin) { + return [ + plugin.getName(), + plugin + ]; + }); + plugins = Immutable.OrderedMap(plugins); + + // Log state + logger.info.ln(installedMap.size + ' plugins are installed'); + if (requirements.size != installedMap.size) { + logger.info.ln(requirements.size + ' explicitly listed'); + } + + // Verify that all plugins are present + if (missing.length > 0) { + throw new Error('Couldn\'t locate plugins "' + missing.join(', ') + '", Run \'gitbook install\' to install plugins from registry.'); + } + + return Promise.map(plugins, function(plugin) { + return loadPlugin(book, plugin); + }); + }); +} + + +module.exports = loadForBook; diff --git a/packages/gitbook/lib/plugins/loadPlugin.js b/packages/gitbook/lib/plugins/loadPlugin.js new file mode 100644 index 0000000..9ed83a1 --- /dev/null +++ b/packages/gitbook/lib/plugins/loadPlugin.js @@ -0,0 +1,89 @@ +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 packageMain; + 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; + } + + // Locate the main package + try { + var indexJs = path.normalize(packageContent.main || 'index.js'); + packageMain = resolve.sync('./' + indexJs, { basedir: pkgPath }); + } catch (err) { + if (!isModuleNotFound(err)) throw err; + packageMain = undefined; + } + + // Load plugin JS content + if (packageMain) { + try { + content = require(packageMain); + } catch(err) { + 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/packages/gitbook/lib/plugins/locateRootFolder.js b/packages/gitbook/lib/plugins/locateRootFolder.js new file mode 100644 index 0000000..1139510 --- /dev/null +++ b/packages/gitbook/lib/plugins/locateRootFolder.js @@ -0,0 +1,22 @@ +var path = require('path'); +var resolve = require('resolve'); + +var DEFAULT_PLUGINS = require('../constants/defaultPlugins'); + +/** + * Resolve the root folder containing for node_modules + * since gitbook can be used as a library and dependency can be flattened. + * + * @return {String} folderPath + */ +function locateRootFolder() { + var firstDefaultPlugin = DEFAULT_PLUGINS.first(); + var pluginPath = resolve.sync(firstDefaultPlugin.getNpmID() + '/package.json', { + basedir: __dirname + }); + var nodeModules = path.resolve(pluginPath, '../../..'); + + return nodeModules; +} + +module.exports = locateRootFolder; diff --git a/packages/gitbook/lib/plugins/resolveVersion.js b/packages/gitbook/lib/plugins/resolveVersion.js new file mode 100644 index 0000000..61aef8d --- /dev/null +++ b/packages/gitbook/lib/plugins/resolveVersion.js @@ -0,0 +1,71 @@ +var npm = require('npm'); +var semver = require('semver'); +var Immutable = require('immutable'); + +var Promise = require('../utils/promise'); +var Plugin = require('../models/plugin'); +var gitbook = require('../gitbook'); + +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 dependency to a version + + @param {PluginDependency} plugin + @return {Promise<String>} +*/ +function resolveVersion(plugin) { + var npmId = Plugin.nameToNpmID(plugin.getName()); + var requiredVersion = plugin.getVersion(); + + if (plugin.isGitDependency()) { + return Promise.resolve(requiredVersion); + } + + 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; + } + }); +} + +module.exports = resolveVersion; diff --git a/packages/gitbook/lib/plugins/sortDependencies.js b/packages/gitbook/lib/plugins/sortDependencies.js new file mode 100644 index 0000000..7f10095 --- /dev/null +++ b/packages/gitbook/lib/plugins/sortDependencies.js @@ -0,0 +1,34 @@ +var Immutable = require('immutable'); + +var THEME_PREFIX = require('../constants/themePrefix'); + +var TYPE_PLUGIN = 'plugin'; +var TYPE_THEME = 'theme'; + + +/** + * Returns the type of a plugin given its name + * @param {Plugin} plugin + * @return {String} + */ +function pluginType(plugin) { + var name = plugin.getName(); + return (name && name.indexOf(THEME_PREFIX) === 0) ? TYPE_THEME : TYPE_PLUGIN; +} + + +/** + * Sort the list of dependencies to match list in book.json + * The themes should always be loaded after the plugins + * + * @param {List<PluginDependency>} deps + * @return {List<PluginDependency>} + */ +function sortDependencies(plugins) { + var byTypes = plugins.groupBy(pluginType); + + return byTypes.get(TYPE_PLUGIN, Immutable.List()) + .concat(byTypes.get(TYPE_THEME, Immutable.List())); +} + +module.exports = sortDependencies;
\ No newline at end of file diff --git a/packages/gitbook/lib/plugins/toNames.js b/packages/gitbook/lib/plugins/toNames.js new file mode 100644 index 0000000..ad0dd8f --- /dev/null +++ b/packages/gitbook/lib/plugins/toNames.js @@ -0,0 +1,16 @@ + +/** + * Return list of plugin names. This method is nly used in unit tests. + * + * @param {OrderedMap<String:Plugin} plugins + * @return {Array<String>} + */ +function toNames(plugins) { + return plugins + .map(function(plugin) { + return plugin.getName(); + }) + .toArray(); +} + +module.exports = toNames; diff --git a/packages/gitbook/lib/plugins/validateConfig.js b/packages/gitbook/lib/plugins/validateConfig.js new file mode 100644 index 0000000..fab1fef --- /dev/null +++ b/packages/gitbook/lib/plugins/validateConfig.js @@ -0,0 +1,71 @@ +var Immutable = require('immutable'); +var jsonschema = require('jsonschema'); +var jsonSchemaDefaults = require('json-schema-defaults'); + +var Promise = require('../utils/promise'); +var error = require('../utils/error'); +var mergeDefaults = require('../utils/mergeDefaults'); + +/** + 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/packages/gitbook/lib/plugins/validatePlugin.js b/packages/gitbook/lib/plugins/validatePlugin.js new file mode 100644 index 0000000..4baa911 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/templating/__tests__/conrefsLoader.js b/packages/gitbook/lib/templating/__tests__/conrefsLoader.js new file mode 100644 index 0000000..196b513 --- /dev/null +++ b/packages/gitbook/lib/templating/__tests__/conrefsLoader.js @@ -0,0 +1,98 @@ +var path = require('path'); + +var TemplateEngine = require('../../models/templateEngine'); +var renderTemplate = require('../render'); +var ConrefsLoader = require('../conrefsLoader'); + +describe('ConrefsLoader', function() { + var dirName = __dirname + '/'; + var fileName = path.join(dirName, 'test.md'); + + describe('Git', function() { + var engine = TemplateEngine({ + loader: new ConrefsLoader(dirName) + }); + + it('should include content from git', function() { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('Hello from git'); + }); + }); + + it('should handle deep inclusion (1)', function() { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('First Hello. Hello from git'); + }); + }); + + it('should handle deep inclusion (2)', function() { + return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('First Hello. Hello from git'); + }); + }); + }); + + describe('Local', function() { + var engine = TemplateEngine({ + loader: new ConrefsLoader(dirName) + }); + + describe('Relative', function() { + it('should resolve basic relative filepath', function() { + return renderTemplate(engine, fileName, '{% include "include.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('Hello World'); + }); + }); + + it('should resolve basic parent filepath', function() { + return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "../include.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('Hello World'); + }); + }); + }); + + describe('Absolute', function() { + it('should resolve absolute filepath', function() { + return renderTemplate(engine, fileName, '{% include "/include.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('Hello World'); + }); + }); + + it('should resolve absolute filepath when in a directory', function() { + return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "/include.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('Hello World'); + }); + }); + }); + }); + + describe('transform', function() { + function transform(filePath, source) { + expect(filePath).toBeA('string'); + expect(source).toBeA('string'); + + expect(filePath).toBe(path.resolve(__dirname, 'include.md')); + expect(source).toBe('Hello World'); + + return 'test-' + source + '-endtest'; + } + var engine = TemplateEngine({ + loader: new ConrefsLoader(dirName, transform) + }); + + it('should transform included content', function() { + return renderTemplate(engine, fileName, '{% include "include.md" %}') + .then(function(out) { + expect(out.getContent()).toBe('test-Hello World-endtest'); + }); + }); + }); +}); + diff --git a/packages/gitbook/lib/templating/__tests__/include.md b/packages/gitbook/lib/templating/__tests__/include.md new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/packages/gitbook/lib/templating/__tests__/include.md @@ -0,0 +1 @@ +Hello World
\ No newline at end of file diff --git a/packages/gitbook/lib/templating/__tests__/postRender.js b/packages/gitbook/lib/templating/__tests__/postRender.js new file mode 100644 index 0000000..131e29b --- /dev/null +++ b/packages/gitbook/lib/templating/__tests__/postRender.js @@ -0,0 +1,51 @@ +var TemplateEngine = require('../../models/templateEngine'); +var TemplateBlock = require('../../models/templateBlock'); + +var renderTemplate = require('../render'); +var postRender = require('../postRender'); + +describe('postRender', function() { + var testPost; + var engine = TemplateEngine.create({ + blocks: [ + TemplateBlock.create('lower', function(blk) { + return blk.body.toLowerCase(); + }), + TemplateBlock.create('prefix', function(blk) { + return { + body: '_' + blk.body + '_', + post: function() { + testPost = true; + } + }; + }) + ] + }); + + it('should correctly replace block', function() { + return renderTemplate(engine, 'README.md', 'Hello {% lower %}Samy{% endlower %}') + .then(function(output) { + expect(output.getContent()).toMatch(/Hello \{\{\-([\S]+)\-\}\}/); + expect(output.getBlocks().size).toBe(1); + + return postRender(engine, output); + }) + .then(function(result) { + expect(result).toBe('Hello samy'); + }); + }); + + it('should correctly replace blocks', function() { + return renderTemplate(engine, 'README.md', 'Hello {% lower %}Samy{% endlower %}{% prefix %}Pesse{% endprefix %}') + .then(function(output) { + expect(output.getContent()).toMatch(/Hello \{\{\-([\S]+)\-\}\}\{\{\-([\S]+)\-\}\}/); + expect(output.getBlocks().size).toBe(2); + return postRender(engine, output); + }) + .then(function(result) { + expect(result).toBe('Hello samy_Pesse_'); + expect(testPost).toBe(true); + }); + }); + +}); diff --git a/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js b/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js new file mode 100644 index 0000000..216a1c8 --- /dev/null +++ b/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js @@ -0,0 +1,27 @@ +var Immutable = require('immutable'); + +var TemplateBlock = require('../../models/templateBlock'); +var replaceShortcuts = require('../replaceShortcuts'); + +describe('replaceShortcuts', function() { + var blocks = Immutable.List([ + TemplateBlock.create('math', { + shortcuts: { + start: '$$', + end: '$$', + parsers: ['markdown'] + } + }) + ]); + + it('should correctly replace inline matches by block', function() { + var content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$'); + expect(content).toBe('Hello {% math %}a = b{% endmath %}'); + }); + + it('should correctly replace block matches', function() { + var content = replaceShortcuts(blocks, 'test.md', 'Hello\n$$\na = b\n$$\n'); + expect(content).toBe('Hello\n{% math %}\na = b\n{% endmath %}\n'); + }); +}); + diff --git a/packages/gitbook/lib/templating/conrefsLoader.js b/packages/gitbook/lib/templating/conrefsLoader.js new file mode 100644 index 0000000..b3cdb3f --- /dev/null +++ b/packages/gitbook/lib/templating/conrefsLoader.js @@ -0,0 +1,93 @@ +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 ("") + * + * @param {String} rootFolder + * @param {Function(filePath, source)} transformFn (optional) + * @param {Logger} logger (optional) + */ +var ConrefsLoader = nunjucks.Loader.extend({ + async: true, + + init: function(rootFolder, transformFn, logger) { + this.rootFolder = rootFolder; + this.transformFn = transformFn; + 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) { + source = source.toString('utf8'); + + if (that.transformFn) { + return that.transformFn(filepath, source); + } + + return source; + }) + .then(function(source) { + return { + src: source, + 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)) { + + // Path of current template in the rootFolder (not absolute to fs) + var fromRelative = path.relative(this.rootFolder, from); + + // Resolve "to" to a filepath relative to rootFolder + var href = LocationUtils.toAbsolute(to, path.dirname(fromRelative), ''); + + // Return absolute path + 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/packages/gitbook/lib/templating/index.js b/packages/gitbook/lib/templating/index.js new file mode 100644 index 0000000..bd74aca --- /dev/null +++ b/packages/gitbook/lib/templating/index.js @@ -0,0 +1,10 @@ + +module.exports = { + render: require('./render'), + renderFile: require('./renderFile'), + postRender: require('./postRender'), + replaceShortcuts: require('./replaceShortcuts'), + + ConrefsLoader: require('./conrefsLoader'), + ThemesLoader: require('./themesLoader') +}; diff --git a/packages/gitbook/lib/templating/listShortcuts.js b/packages/gitbook/lib/templating/listShortcuts.js new file mode 100644 index 0000000..8d0a64a --- /dev/null +++ b/packages/gitbook/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 {List<TemplateBlock>} engine + * @param {String} filePath + * @return {List<TemplateShortcut>} + */ +function listShortcuts(blocks, filePath) { + var parser = parsers.getForFile(filePath); + + if (!parser) { + return Immutable.List(); + } + + return blocks + .map(function(block) { + return block.getShortcuts(); + }) + .filter(function(shortcuts) { + return ( + shortcuts && + shortcuts.acceptParser(parser.getName()) + ); + }); +} + +module.exports = listShortcuts; diff --git a/packages/gitbook/lib/templating/postRender.js b/packages/gitbook/lib/templating/postRender.js new file mode 100644 index 0000000..f464f86 --- /dev/null +++ b/packages/gitbook/lib/templating/postRender.js @@ -0,0 +1,53 @@ +var Promise = require('../utils/promise'); + + +/** + * 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, blocks) { + var newContent = content.replace(/\{\{\-\%([\s\S]+?)\%\-\}\}/g, function(match, key) { + var replacedWith = match; + + var block = blocks.get(key); + if (block) { + replacedWith = replaceBlocks(block.get('body'), blocks); + } + + return replacedWith; + }); + + return newContent; +} + +/** + * Post render a template: + * - Execute "post" for blocks + * - Replace block content + * + * @param {TemplateEngine} engine + * @param {TemplateOutput} content + * @return {Promise<String>} + */ +function postRender(engine, output) { + var content = output.getContent(); + var blocks = output.getBlocks(); + + var result = replaceBlocks(content, blocks); + + return Promise.forEach(blocks, function(block) { + var post = block.get('post'); + + if (!post) { + return; + } + + return post(); + }) + .thenResolve(result); +} + +module.exports = postRender; diff --git a/packages/gitbook/lib/templating/render.js b/packages/gitbook/lib/templating/render.js new file mode 100644 index 0000000..1a8b0cd --- /dev/null +++ b/packages/gitbook/lib/templating/render.js @@ -0,0 +1,44 @@ +var Promise = require('../utils/promise'); +var timing = require('../utils/timing'); +var TemplateOutput = require('../models/templateOutput'); +var replaceShortcuts = require('./replaceShortcuts'); + +/** + * Render a template + * + * @param {TemplateEngine} engine + * @param {String} filePath: absolute path for the loader + * @param {String} content + * @param {Object} context (optional) + * @return {Promise<TemplateOutput>} + */ +function renderTemplate(engine, filePath, content, context) { + context = context || {}; + + // Mutable objects to contains all blocks requiring post-processing + var blocks = {}; + + // Create nunjucks environment + var env = engine.toNunjucks(blocks); + + // Replace shortcuts from plugin's blocks + content = replaceShortcuts(engine.getBlocks(), filePath, content); + + return timing.measure( + 'template.render', + + Promise.nfcall( + env.renderString.bind(env), + content, + context, + { + path: filePath + } + ) + .then(function(content) { + return TemplateOutput.create(content, blocks); + }) + ); +} + +module.exports = renderTemplate; diff --git a/packages/gitbook/lib/templating/renderFile.js b/packages/gitbook/lib/templating/renderFile.js new file mode 100644 index 0000000..8672e8b --- /dev/null +++ b/packages/gitbook/lib/templating/renderFile.js @@ -0,0 +1,41 @@ +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<TemplateOutput>} + */ +function renderTemplateFile(engine, filePath, context) { + var loader = engine.getLoader(); + + // Resolve the filePath + var resolvedFilePath = loader.resolve(null, filePath); + + return Promise() + .then(function() { + if (!loader.async) { + return loader.getSource(resolvedFilePath); + } + + var deferred = Promise.defer(); + loader.getSource(resolvedFilePath, 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/packages/gitbook/lib/templating/replaceShortcuts.js b/packages/gitbook/lib/templating/replaceShortcuts.js new file mode 100644 index 0000000..1cfdbf0 --- /dev/null +++ b/packages/gitbook/lib/templating/replaceShortcuts.js @@ -0,0 +1,39 @@ +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 start = shortcut.getStart(); + var end = shortcut.getEnd(); + + var tagStart = shortcut.getStartTag(); + var tagEnd = shortcut.getEndTag(); + + var regex = new RegExp( + escapeStringRegexp(start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(end), + 'g' + ); + return content.replace(regex, function(all, match) { + return '{% ' + tagStart + ' %}' + match + '{% ' + tagEnd + ' %}'; + }); +} + +/** + * Replace shortcuts from blocks in a string + * + * @param {List<TemplateBlock>} engine + * @param {String} filePath + * @param {String} content + * @return {String} + */ +function replaceShortcuts(blocks, filePath, content) { + var shortcuts = listShortcuts(blocks, filePath); + return shortcuts.reduce(applyShortcut, content); +} + +module.exports = replaceShortcuts; diff --git a/packages/gitbook/lib/templating/themesLoader.js b/packages/gitbook/lib/templating/themesLoader.js new file mode 100644 index 0000000..bae4c12 --- /dev/null +++ b/packages/gitbook/lib/templating/themesLoader.js @@ -0,0 +1,115 @@ +var Immutable = require('immutable'); +var nunjucks = require('nunjucks'); +var fs = require('fs'); +var path = require('path'); + +var PathUtils = require('../utils/path'); + + +var ThemesLoader = nunjucks.Loader.extend({ + init: function(searchPaths) { + this.searchPaths = Immutable.List(searchPaths) + .map(path.normalize); + }, + + /** + * Read source of a resolved filepath + * @param {String} + * @return {Object} + */ + getSource: function(fullpath) { + if (!fullpath) return null; + + fullpath = this.resolve(null, fullpath); + var templateName = this.getTemplateName(fullpath); + + if(!fullpath) { + return null; + } + + var src = fs.readFileSync(fullpath, 'utf-8'); + + src = '{% do %}var template = template || {}; template.stack = template.stack || []; template.stack.push(template.self); template.self = ' + JSON.stringify(templateName) + '{% enddo %}\n' + + src + + '\n{% do %}template.self = template.stack.pop();{% enddo %}'; + + return { + src: src, + path: fullpath, + noCache: true + }; + }, + + /** + * Nunjucks calls "isRelative" to determine when to call "resolve". + * We handle absolute paths ourselves in ".resolve" so we always return true + */ + isRelative: function() { + return true; + }, + + /** + * Get original search path containing a template + * @param {String} filepath + * @return {String} searchPath + */ + getSearchPath: function(filepath) { + return this.searchPaths + .sortBy(function(s) { + return -s.length; + }) + .find(function(basePath) { + return (filepath && filepath.indexOf(basePath) === 0); + }); + }, + + /** + * Get template name from a filepath + * @param {String} filepath + * @return {String} name + */ + getTemplateName: function(filepath) { + var originalSearchPath = this.getSearchPath(filepath); + return originalSearchPath? path.relative(originalSearchPath, filepath) : null; + }, + + /** + * Resolve a template from a current template + * @param {String|null} from + * @param {String} to + * @return {String|null} + */ + resolve: function(from, to) { + var searchPaths = this.searchPaths; + + // Relative template like "./test.html" + if (PathUtils.isPureRelative(to) && from) { + return path.resolve(path.dirname(from), to); + } + + // Determine in which search folder we currently are + var originalSearchPath = this.getSearchPath(from); + var originalFilename = this.getTemplateName(from); + + // If we are including same file from a different search path + // Slice the search paths to avoid including from previous ones + if (originalFilename == to) { + var currentIndex = searchPaths.indexOf(originalSearchPath); + searchPaths = searchPaths.slice(currentIndex + 1); + } + + // Absolute template to resolve in root folder + var resultFolder = searchPaths.find(function(basePath) { + var p = path.resolve(basePath, to); + + return ( + p.indexOf(basePath) === 0 + && fs.existsSync(p) + ); + }); + if (!resultFolder) return null; + return path.resolve(resultFolder, to); + } +}); + +module.exports = ThemesLoader; diff --git a/packages/gitbook/lib/utils/__tests__/git.js b/packages/gitbook/lib/utils/__tests__/git.js new file mode 100644 index 0000000..abc1ea1 --- /dev/null +++ b/packages/gitbook/lib/utils/__tests__/git.js @@ -0,0 +1,57 @@ +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')).toBeFalsy(); + expect(Git.isUrl('README.md')).toBeFalsy(); + }); + + 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() { + it('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/packages/gitbook/lib/utils/__tests__/location.js b/packages/gitbook/lib/utils/__tests__/location.js new file mode 100644 index 0000000..822338e --- /dev/null +++ b/packages/gitbook/lib/utils/__tests__/location.js @@ -0,0 +1,100 @@ +var LocationUtils = require('../location'); + +describe('LocationUtils', function() { + 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); + expect(LocationUtils.isExternal('data:image/png')).toBe(false); + }); + + it('should correctly test data:uri location', function() { + expect(LocationUtils.isDataURI('data:image/png')).toBe(true); + expect(LocationUtils.isDataURI('http://google.fr')).toBe(false); + expect(LocationUtils.isDataURI('https://google.fr')).toBe(false); + expect(LocationUtils.isDataURI('test.md')).toBe(false); + expect(LocationUtils.isDataURI('data.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('.flatten', function() { + it('should remove leading slash', function() { + expect(LocationUtils.flatten('/test.md')).toBe('test.md'); + expect(LocationUtils.flatten('/hello/cool.md')).toBe('hello/cool.md'); + }); + + it('should remove leading slashes', function() { + expect(LocationUtils.flatten('///test.md')).toBe('test.md'); + }); + + it('should not break paths', function() { + expect(LocationUtils.flatten('hello/cool.md')).toBe('hello/cool.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/packages/gitbook/lib/utils/__tests__/path.js b/packages/gitbook/lib/utils/__tests__/path.js new file mode 100644 index 0000000..22bb016 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/utils/command.js b/packages/gitbook/lib/utils/command.js new file mode 100644 index 0000000..90a556e --- /dev/null +++ b/packages/gitbook/lib/utils/command.js @@ -0,0 +1,118 @@ +var is = require('is'); +var childProcess = require('child_process'); +var spawn = require('spawn-cmd').spawn; +var Promise = require('./promise'); + +/** + Execute a command + + @param {String} command + @param {Object} options + @return {Promise} +*/ +function exec(command, options) { + var d = Promise.defer(); + + var child = childProcess.exec(command, options, function(err, stdout, stderr) { + if (!err) { + return d.resolve(); + } + + err.message = stdout.toString('utf8') + stderr.toString('utf8'); + d.reject(err); + }); + + child.stdout.on('data', function (data) { + d.notify(data); + }); + + child.stderr.on('data', function (data) { + d.notify(data); + }); + + return d.promise; +} + +/** + Spawn an executable + + @param {String} command + @param {Array} args + @param {Object} options + @return {Promise} +*/ +function spawnCmd(command, args, options) { + var d = Promise.defer(); + var child = spawn(command, args, options); + + child.on('error', function(error) { + return d.reject(error); + }); + + child.stdout.on('data', function (data) { + d.notify(data); + }); + + child.stderr.on('data', function (data) { + d.notify(data); + }); + + child.on('close', function(code) { + if (code === 0) { + d.resolve(); + } else { + d.reject(new Error('Error with command "'+command+'"')); + } + }); + + return d.promise; +} + +/** + Transform an option object to a command line string + + @param {String|number} value + @param {String} +*/ +function escapeShellArg(value) { + if (is.number(value)) { + return value; + } + + value = String(value); + value = value.replace(/"/g, '\\"'); + + return '"' + value + '"'; +} + +/** + Transform a map of options into a command line arguments string + + @param {Object} options + @return {String} +*/ +function optionsToShellArgs(options) { + var result = []; + + for (var key in options) { + var value = options[key]; + + if (value === null || value === undefined || value === false) { + continue; + } + + if (is.bool(value)) { + result.push(key); + } else { + result.push(key + '=' + escapeShellArg(value)); + } + } + + return result.join(' '); +} + +module.exports = { + exec: exec, + spawn: spawnCmd, + optionsToShellArgs: optionsToShellArgs +}; diff --git a/packages/gitbook/lib/utils/error.js b/packages/gitbook/lib/utils/error.js new file mode 100644 index 0000000..7686779 --- /dev/null +++ b/packages/gitbook/lib/utils/error.js @@ -0,0 +1,99 @@ +var is = require('is'); + +var TypedError = require('error/typed'); +var WrappedError = require('error/wrapped'); + + +// Enforce as an Error object, and cleanup message +function enforce(err) { + if (is.string(err)) err = new Error(err); + err.message = err.message.replace(/^Error: /, ''); + + return err; +} + +// Random error wrappers during parsing/generation +var ParsingError = WrappedError({ + message: 'Parsing Error: {origMessage}', + type: 'parse' +}); +var OutputError = WrappedError({ + message: 'Output Error: {origMessage}', + type: 'generate' +}); + +// A file does not exists +var FileNotFoundError = TypedError({ + type: 'file.not-found', + message: 'No "{filename}" file (or is ignored)', + filename: null +}); + +// A file 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', + message: '"{filename}" not in "{root}"', + filename: null, + root: null, + code: 'EACCESS' +}); + +// A file is outside the scope +var RequireInstallError = TypedError({ + type: 'install.required', + message: '"{cmd}" is not installed.\n{install}', + cmd: null, + code: 'ENOENT', + install: '' +}); + +// Error for nunjucks templates +var TemplateError = WrappedError({ + message: 'Error compiling template "{filename}": {origMessage}', + type: 'template', + filename: null +}); + +// Error for nunjucks templates +var PluginError = WrappedError({ + message: 'Error with plugin "{plugin}": {origMessage}', + type: 'plugin', + plugin: null +}); + +// Error with the book's configuration +var ConfigurationError = WrappedError({ + message: 'Error with book\'s configuration: {origMessage}', + type: 'configuration' +}); + +// Error during ebook generation +var EbookError = WrappedError({ + message: 'Error during ebook generation: {origMessage}\n{stdout}', + type: 'ebook', + stdout: '' +}); + +module.exports = { + enforce: enforce, + + ParsingError: ParsingError, + OutputError: OutputError, + RequireInstallError: RequireInstallError, + + FileNotParsableError: FileNotParsableError, + FileNotFoundError: FileNotFoundError, + FileOutOfScopeError: FileOutOfScopeError, + + TemplateError: TemplateError, + PluginError: PluginError, + ConfigurationError: ConfigurationError, + EbookError: EbookError +}; diff --git a/packages/gitbook/lib/utils/fs.js b/packages/gitbook/lib/utils/fs.js new file mode 100644 index 0000000..35839a3 --- /dev/null +++ b/packages/gitbook/lib/utils/fs.js @@ -0,0 +1,170 @@ +var fs = require('graceful-fs'); +var mkdirp = require('mkdirp'); +var destroy = require('destroy'); +var rmdir = require('rmdir'); +var tmp = require('tmp'); +var request = require('request'); +var path = require('path'); +var cp = require('cp'); +var cpr = require('cpr'); + +var Promise = require('./promise'); + +// Write a stream to a file +function writeStream(filename, st) { + var d = Promise.defer(); + + var wstream = fs.createWriteStream(filename); + var cleanup = function() { + destroy(wstream); + wstream.removeAllListeners(); + }; + + wstream.on('finish', function () { + cleanup(); + d.resolve(); + }); + wstream.on('error', function (err) { + cleanup(); + d.reject(err); + }); + + st.on('error', function(err) { + cleanup(); + d.reject(err); + }); + + st.pipe(wstream); + + return d.promise; +} + +// Return a promise resolved with a boolean +function fileExists(filename) { + var d = Promise.defer(); + + fs.exists(filename, function(exists) { + d.resolve(exists); + }); + + return d.promise; +} + +// Generate temporary file +function genTmpFile(opts) { + return Promise.nfcall(tmp.file, opts) + .get(0); +} + +// Generate temporary dir +function genTmpDir(opts) { + return Promise.nfcall(tmp.dir, opts) + .get(0); +} + +// Download an image +function download(uri, dest) { + return writeStream(dest, request(uri)); +} + +// Find a filename available in a folder +function uniqueFilename(base, filename) { + var ext = path.extname(filename); + filename = path.resolve(base, filename); + filename = path.join(path.dirname(filename), path.basename(filename, ext)); + + var _filename = filename+ext; + + var i = 0; + while (fs.existsSync(filename)) { + _filename = filename + '_' + i + ext; + i = i + 1; + } + + return Promise(path.relative(base, _filename)); +} + +// Create all required folder to create a file +function ensureFile(filename) { + var base = path.dirname(filename); + return Promise.nfcall(mkdirp, base); +} + +// Remove a folder +function rmDir(base) { + return Promise.nfcall(rmdir, base, { + fs: fs + }); +} + +/** + 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; +} + +/** + Ensure that a directory exists and is empty + + @param {String} folder + @return {Promise} +*/ +function ensureFolder(rootFolder) { + return rmDir(rootFolder) + .fail(function() { + return Promise(); + }) + .then(function() { + return Promise.nfcall(mkdirp, rootFolder); + }); +} + +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), + writeStream: writeStream, + readStream: fs.createReadStream, + copy: Promise.nfbind(cp), + copyDir: Promise.nfbind(cpr), + tmpFile: genTmpFile, + tmpDir: genTmpDir, + download: download, + uniqueFilename: uniqueFilename, + ensureFile: ensureFile, + ensureFolder: ensureFolder, + rmDir: rmDir +}; diff --git a/packages/gitbook/lib/utils/genKey.js b/packages/gitbook/lib/utils/genKey.js new file mode 100644 index 0000000..0650011 --- /dev/null +++ b/packages/gitbook/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/packages/gitbook/lib/utils/git.js b/packages/gitbook/lib/utils/git.js new file mode 100644 index 0000000..6884b83 --- /dev/null +++ b/packages/gitbook/lib/utils/git.js @@ -0,0 +1,133 @@ +var is = require('is'); +var path = require('path'); +var crc = require('crc'); +var URI = require('urijs'); + +var pathUtil = require('./path'); +var Promise = require('./promise'); +var command = require('./command'); +var fs = require('./fs'); + +var GIT_PREFIX = 'git+'; + +function Git() { + this.tmpDir; + this.cloned = {}; +} + +// Return an unique ID for a combinaison host/ref +Git.prototype.repoID = function(host, ref) { + return crc.crc32(host+'#'+(ref || '')).toString(16); +}; + +// Allocate a temporary folder for cloning repos in it +Git.prototype.allocateDir = function() { + var that = this; + + if (this.tmpDir) return Promise(); + + return fs.tmpDir() + .then(function(dir) { + that.tmpDir = dir; + }); +}; + +// Clone a git repository if non existant +Git.prototype.clone = function(host, ref) { + var that = this; + + return this.allocateDir() + + // Return or clone the git repo + .then(function() { + // Unique ID for repo/ref combinaison + var repoId = that.repoID(host, ref); + + // Absolute path to the folder + var repoPath = path.join(that.tmpDir, repoId); + + if (that.cloned[repoId]) return repoPath; + + // Clone repo + return command.exec('git clone '+host+' '+repoPath) + + // Checkout reference if specified + .then(function() { + that.cloned[repoId] = true; + + if (!ref) return; + return command.exec('git checkout '+ref, { cwd: repoPath }); + }) + .thenResolve(repoPath); + }); +}; + +// Get file from a git repo +Git.prototype.resolve = function(giturl) { + // Path to a file in a git repo? + if (!Git.isUrl(giturl)) { + if (this.resolveRoot(giturl)) return Promise(giturl); + return Promise(null); + } + if (is.string(giturl)) giturl = Git.parseUrl(giturl); + if (!giturl) return Promise(null); + + // Clone or get from cache + return this.clone(giturl.host, giturl.ref) + .then(function(repo) { + return path.resolve(repo, giturl.filepath); + }); +}; + +// Return root of git repo from a filepath +Git.prototype.resolveRoot = function(filepath) { + var relativeToGit, repoId; + + // No git repo cloned, or file is not in a git repository + if (!this.tmpDir || !pathUtil.isInRoot(this.tmpDir, filepath)) return null; + + // Extract first directory (is the repo id) + relativeToGit = path.relative(this.tmpDir, filepath); + repoId = relativeToGit.split(path.sep)[0]; + if (!repoId) { + return; + } + + // Return an absolute file + return path.resolve(this.tmpDir, repoId); +}; + +// Check if an url is a git dependency url +Git.isUrl = function(giturl) { + return (giturl.indexOf(GIT_PREFIX) === 0); +}; + +// Parse and extract infos +Git.parseUrl = function(giturl) { + var ref, uri, fileParts, filepath; + + if (!Git.isUrl(giturl)) return null; + giturl = giturl.slice(GIT_PREFIX.length); + + uri = new URI(giturl); + ref = uri.fragment() || null; + uri.fragment(null); + + // Extract file inside the repo (after the .git) + fileParts = uri.path().split('.git'); + filepath = fileParts.length > 1? fileParts.slice(1).join('.git') : ''; + if (filepath[0] == '/') { + filepath = filepath.slice(1); + } + + // Recreate pathname without the real filename + uri.path(fileParts[0] + '.git'); + + return { + host: uri.toString(), + ref: ref, + filepath: filepath + }; +}; + +module.exports = Git; diff --git a/packages/gitbook/lib/utils/images.js b/packages/gitbook/lib/utils/images.js new file mode 100644 index 0000000..6d4b927 --- /dev/null +++ b/packages/gitbook/lib/utils/images.js @@ -0,0 +1,60 @@ +var Promise = require('./promise'); +var command = require('./command'); +var fs = require('./fs'); +var error = require('./error'); + +// Convert a svg file to a pmg +function convertSVGToPNG(source, dest, options) { + if (!fs.existsSync(source)) return Promise.reject(new error.FileNotFoundError({ filename: source })); + + return command.spawn('svgexport', [source, dest]) + .fail(function(err) { + if (err.code == 'ENOENT') { + err = error.RequireInstallError({ + cmd: 'svgexport', + install: 'Install it using: "npm install svgexport -g"' + }); + } + throw err; + }) + .then(function() { + if (fs.existsSync(dest)) return; + + throw new Error('Error converting '+source+' into '+dest); + }); +} + +// Convert a svg buffer to a png file +function convertSVGBufferToPNG(buf, dest) { + // Create a temporary SVG file to convert + return fs.tmpFile({ + postfix: '.svg' + }) + .then(function(tmpSvg) { + return fs.writeFile(tmpSvg, buf) + .then(function() { + return convertSVGToPNG(tmpSvg, dest); + }); + }); +} + +// Converts a inline data: to png file +function convertInlinePNG(source, dest) { + if (!/^data\:image\/png/.test(source)) return Promise.reject(new Error('Source is not a PNG data-uri')); + + var base64data = source.split('data:image/png;base64,')[1]; + var buf = new Buffer(base64data, 'base64'); + + return fs.writeFile(dest, buf) + .then(function() { + if (fs.existsSync(dest)) return; + + throw new Error('Error converting '+source+' into '+dest); + }); +} + +module.exports = { + convertSVGToPNG: convertSVGToPNG, + convertSVGBufferToPNG: convertSVGBufferToPNG, + convertInlinePNG: convertInlinePNG +};
\ No newline at end of file diff --git a/packages/gitbook/lib/utils/location.js b/packages/gitbook/lib/utils/location.js new file mode 100644 index 0000000..00d8004 --- /dev/null +++ b/packages/gitbook/lib/utils/location.js @@ -0,0 +1,139 @@ +var url = require('url'); +var path = require('path'); + +// Is the url an external url +function isExternal(href) { + try { + return Boolean(url.parse(href).protocol) && !isDataURI(href); + } catch(err) { + return false; + } +} + +// Is the url an iniline data-uri +function isDataURI(href) { + try { + return Boolean(url.parse(href).protocol) && (url.parse(href).protocol === 'data:'); + } catch(err) { + return false; + } +} + +// Inverse of isExternal +function isRelative(href) { + return !isExternal(href); +} + +// Return true if the link is an achor +function isAnchor(href) { + try { + var parsed = url.parse(href); + return !!(!parsed.protocol && !parsed.path && parsed.hash); + } catch(err) { + return false; + } +} + +// Normalize a path to be a link +function normalize(s) { + return path.normalize(s).replace(/\\/g, '/'); +} + +/** + * Flatten a path, it removes the leading "/" + * + * @param {String} href + * @return {String} + */ +function flatten(href) { + href = normalize(href); + if (href[0] == '/') { + href = normalize(href.slice(1)); + } + + return href; +} + +/** + * Convert a relative path to absolute + * + * @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) || isDataURI(_href)) { + return _href; + } + + outdir = outdir == undefined? dir : outdir; + + _href = normalize(_href); + dir = normalize(dir); + outdir = normalize(outdir); + + // Path "_href" inside the base folder + var hrefInRoot = normalize(path.join(dir, _href)); + if (_href[0] == '/') { + hrefInRoot = normalize(_href.slice(1)); + } + + // Make it relative to output + _href = path.relative(outdir, hrefInRoot); + + // Normalize windows paths + _href = normalize(_href); + + return _href; +} + +/** + * 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) { + var isDirectory = file.slice(-1) === '/'; + return normalize(path.relative(dir, file)) + (isDirectory? '/': ''); +} + +/** + * 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, + isDataURI: isDataURI, + isExternal: isExternal, + isRelative: isRelative, + isAnchor: isAnchor, + normalize: normalize, + toAbsolute: toAbsolute, + relative: relative, + relativeForFile: relativeForFile, + flatten: flatten +}; diff --git a/packages/gitbook/lib/utils/logger.js b/packages/gitbook/lib/utils/logger.js new file mode 100644 index 0000000..6fac92b --- /dev/null +++ b/packages/gitbook/lib/utils/logger.js @@ -0,0 +1,172 @@ +var is = require('is'); +var util = require('util'); +var color = require('bash-color'); +var Immutable = require('immutable'); + +var LEVELS = Immutable.Map({ + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + DISABLED: 10 +}); + +var COLORS = Immutable.Map({ + DEBUG: color.purple, + INFO: color.cyan, + WARN: color.yellow, + ERROR: color.red +}); + +function Logger(write, logLevel) { + if (!(this instanceof Logger)) return new Logger(write, logLevel); + + this._write = write || function(msg) { + if(process.stdout) { + process.stdout.write(msg); + } + }; + this.lastChar = '\n'; + + this.setLevel(logLevel || 'info'); + + // Create easy-to-use method like "logger.debug.ln('....')" + LEVELS.forEach(function(level, levelKey) { + if (levelKey === 'DISABLED') { + return; + } + levelKey = levelKey.toLowerCase(); + + this[levelKey] = this.log.bind(this, level); + this[levelKey].ln = this.logLn.bind(this, level); + this[levelKey].ok = this.ok.bind(this, level); + this[levelKey].fail = this.fail.bind(this, level); + this[levelKey].promise = this.promise.bind(this, level); + }, this); +} + +/** + Change minimum level + + @param {String} logLevel +*/ +Logger.prototype.setLevel = function(logLevel) { + if (is.string(logLevel)) { + logLevel = logLevel.toUpperCase(); + logLevel = LEVELS.get(logLevel); + } + + this.logLevel = logLevel; +}; + +/** + Return minimum logging level + + @return {Number} +*/ +Logger.prototype.getLevel = function(logLevel) { + return this.logLevel; +}; + +/** + Print a simple string + + @param {String} +*/ +Logger.prototype.write = function(msg) { + msg = msg.toString(); + this.lastChar = msg[msg.length - 1]; + return this._write(msg); +}; + +/** + Format a string using the first argument as a printf-like format. +*/ +Logger.prototype.format = function() { + return util.format.apply(util, arguments); +}; + +/** + Print a line + + @param {String} +*/ +Logger.prototype.writeLn = function(msg) { + return this.write((msg || '')+'\n'); +}; + +/** + Log/Print a message if level is allowed + + @param {Number} level +*/ +Logger.prototype.log = function(level) { + if (level < this.logLevel) return; + + var levelKey = LEVELS.findKey(function(v) { + return v === level; + }); + var args = Array.prototype.slice.apply(arguments, [1]); + var msg = this.format.apply(this, args); + + if (this.lastChar == '\n') { + msg = COLORS.get(levelKey)(levelKey.toLowerCase()+':')+' '+msg; + } + + return this.write(msg); +}; + +/** + Log/Print a line if level is allowed +*/ +Logger.prototype.logLn = function() { + if (this.lastChar != '\n') this.write('\n'); + + var args = Array.prototype.slice.apply(arguments); + args.push('\n'); + return this.log.apply(this, args); +}; + +/** + Log a confirmation [OK] +*/ +Logger.prototype.ok = function(level) { + var args = Array.prototype.slice.apply(arguments, [1]); + var msg = this.format.apply(this, args); + if (arguments.length > 1) { + this.logLn(level, color.green('>> ') + msg.trim().replace(/\n/g, color.green('\n>> '))); + } else { + this.log(level, color.green('OK'), '\n'); + } +}; + +/** + Log a "FAIL" +*/ +Logger.prototype.fail = function(level) { + return this.log(level, color.red('ERROR') + '\n'); +}; + +/** + Log state of a promise + + @param {Number} level + @param {Promise} + @return {Promise} +*/ +Logger.prototype.promise = function(level, p) { + var that = this; + + return p. + then(function(st) { + that.ok(level); + return st; + }, function(err) { + that.fail(level); + throw err; + }); +}; + +Logger.LEVELS = LEVELS; + +module.exports = Logger; diff --git a/packages/gitbook/lib/utils/mergeDefaults.js b/packages/gitbook/lib/utils/mergeDefaults.js new file mode 100644 index 0000000..47a374b --- /dev/null +++ b/packages/gitbook/lib/utils/mergeDefaults.js @@ -0,0 +1,16 @@ +var Immutable = require('immutable'); + +/** + * Merge + * @param {Object|Map} obj + * @param {Object|Map} src + * @return {Object} + */ +function mergeDefaults(obj, src) { + var objValue = Immutable.fromJS(obj); + var srcValue = Immutable.fromJS(src); + + return srcValue.mergeDeep(objValue).toJS(); +} + +module.exports = mergeDefaults; diff --git a/packages/gitbook/lib/utils/path.js b/packages/gitbook/lib/utils/path.js new file mode 100644 index 0000000..26b6005 --- /dev/null +++ b/packages/gitbook/lib/utils/path.js @@ -0,0 +1,74 @@ +var path = require('path'); +var error = require('./error'); + +// Normalize a filename +function normalizePath(filename) { + return path.normalize(filename); +} + +// Return true if file path is inside a folder +function isInRoot(root, filename) { + root = path.normalize(root); + filename = path.normalize(filename); + + if (root === '.') { + return true; + } + if (root[root.length - 1] != path.sep) { + root = root + path.sep; + } + + return (filename.substr(0, root.length) === root); +} + +// Resolve paths in a specific folder +// Throw error if file is outside this folder +function resolveInRoot(root) { + var input, result; + var args = Array.prototype.slice.call(arguments, 1); + + input = args + .reduce(function(current, p) { + // Handle path relative to book root ("/README.md") + if (p[0] == '/' || p[0] == '\\') return p.slice(1); + + return current? path.join(current, p) : path.normalize(p); + }, ''); + + result = path.resolve(root, input); + + if (!isInRoot(root, result)) { + throw new error.FileOutOfScopeError({ + filename: result, + root: root + }); + } + + return result; +} + +// Chnage extension of a file +function setExtension(filename, ext) { + return path.join( + path.dirname(filename), + path.basename(filename, path.extname(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, + isPureRelative: isPureRelative +}; diff --git a/packages/gitbook/lib/utils/promise.js b/packages/gitbook/lib/utils/promise.js new file mode 100644 index 0000000..b5cca4b --- /dev/null +++ b/packages/gitbook/lib/utils/promise.js @@ -0,0 +1,148 @@ +var Q = require('q'); +var Immutable = require('immutable'); + +// Debugging for long stack traces +if (process.env.DEBUG || process.env.CI) { + Q.longStackSupport = true; +} + +/** + * Reduce an array to a promise + * + * @param {Array|List} arr + * @param {Function(value, element, index)} + * @return {Promise<Mixed>} + */ +function reduce(arr, iter, base) { + 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, key); + }); + }, Q(base)); +} + +/** + * 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, key) { + return Q(iter(item, key)) + .then(function(r) { + before.push(r); + return before; + }); + }, []); +} + +/** + * 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) { + arr = Immutable.List(arr); + + return arr.reduce(function(prev, elem, i) { + return prev.then(function(val) { + if (val) return val; + + return iter(elem, i); + }); + }, Q()); +} + +/** + * Map an array using an async (promised) iterator + * + * @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) { + prev.push(out); + return prev; + }); + }, []); +} + +/** + * 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 function() { + var args = Array.prototype.slice.call(arguments, 0); + + return Q() + .then(function() { + return func.apply(null, args); + }); + }; +} + +module.exports = Q; +module.exports.forEach = forEach; +module.exports.reduce = reduce; +module.exports.map = map; +module.exports.serie = serie; +module.exports.some = some; +module.exports.wrapfn = wrap; diff --git a/packages/gitbook/lib/utils/reducedObject.js b/packages/gitbook/lib/utils/reducedObject.js new file mode 100644 index 0000000..7bcfd5b --- /dev/null +++ b/packages/gitbook/lib/utils/reducedObject.js @@ -0,0 +1,33 @@ +var Immutable = require('immutable'); + +/** + * Reduce the difference between a map and its default version + * @param {Map} defaultVersion + * @param {Map} currentVersion + * @return {Map} The properties of currentVersion that differs from defaultVersion + */ +function reducedObject(defaultVersion, currentVersion) { + if(defaultVersion === undefined) { + return currentVersion; + } + + return currentVersion.reduce(function(result, value, key) { + var defaultValue = defaultVersion.get(key); + + if (Immutable.Map.isMap(value)) { + var diffs = reducedObject(defaultValue, value); + + if (diffs.size > 0) { + return result.set(key, diffs); + } + } + + if (Immutable.is(defaultValue, value)) { + return result; + } + + return result.set(key, value); + }, Immutable.Map()); +} + +module.exports = reducedObject; diff --git a/packages/gitbook/lib/utils/timing.js b/packages/gitbook/lib/utils/timing.js new file mode 100644 index 0000000..e6b0323 --- /dev/null +++ b/packages/gitbook/lib/utils/timing.js @@ -0,0 +1,97 @@ +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; + + // Enable debug logging + var logLevel = logger.getLevel(); + logger.setLevel('debug'); + + Immutable.Map(timers) + .valueSeq() + .sortBy(function(timer) { + measured += timer.total; + return timer.total; + }) + .forEach(function(timer) { + var percent = (timer.total * 100) / totalDuration; + + + logger.debug.ln((percent.toFixed(1)) + '% of time spent in "' + timer.type + '" (' + timer.count + ' times) :'); + logger.debug.ln(prefix + 'Total: ' + time(timer.total)+ ' | Average: ' + time(timer.total / timer.count)); + logger.debug.ln(prefix + 'Min: ' + time(timer.min) + ' | Max: ' + time(timer.max)); + logger.debug.ln('---------------------------'); + }); + + + logger.debug.ln(time(totalDuration - measured) + ' spent in non-mesured sections'); + + // Rollback to previous level + logger.setLevel(logLevel); +} + +module.exports = { + measure: measure, + dump: dump +}; diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json new file mode 100644 index 0000000..b3f5f15 --- /dev/null +++ b/packages/gitbook/package.json @@ -0,0 +1,98 @@ +{ + "name": "gitbook", + "version": "3.2.0", + "homepage": "https://www.gitbook.com", + "description": "Library and cmd utility to generate GitBooks", + "main": "lib/index.js", + "browser": "./lib/browser.js", + "dependencies": { + "bash-color": "0.0.4", + "cheerio": "0.20.0", + "chokidar": "1.5.0", + "cp": "0.2.0", + "cpr": "1.1.1", + "crc": "3.4.0", + "destroy": "1.0.4", + "direction": "0.1.5", + "dom-serializer": "0.1.0", + "error": "7.0.2", + "escape-html": "^1.0.3", + "escape-string-regexp": "1.0.5", + "extend": "^3.0.0", + "fresh-require": "1.0.3", + "front-matter": "^2.1.0", + "gitbook-asciidoc": "1.2.2", + "gitbook-markdown": "2.0.1", + "gitbook-plugin-fontsettings": "2.0.0", + "gitbook-plugin-highlight": "2.0.2", + "gitbook-plugin-livereload": "0.0.1", + "gitbook-plugin-lunr": "1.2.0", + "gitbook-plugin-search": "2.2.1", + "gitbook-plugin-sharing": "1.0.2", + "gitbook-plugin-theme-default": "1.0.5", + "github-slugid": "1.0.1", + "graceful-fs": "4.1.4", + "i18n-t": "1.0.1", + "ignore": "3.1.2", + "immutable": "^3.8.1", + "is": "^3.1.0", + "js-yaml": "^3.6.1", + "json-schema-defaults": "0.1.1", + "jsonschema": "1.1.0", + "juice": "2.0.0", + "mkdirp": "0.5.1", + "moment": "2.13.0", + "npm": "3.9.2", + "npmi": "2.0.1", + "nunjucks": "2.4.2", + "nunjucks-do": "1.0.0", + "object-path": "^0.9.2", + "omit-keys": "^0.1.0", + "open": "0.0.5", + "q": "1.4.1", + "react": "^15.3.1", + "react-dom": "^15.3.1", + "react-redux": "^4.4.5", + "read-installed": "^4.0.3", + "redux": "^3.5.2", + "request": "2.72.0", + "resolve": "1.1.7", + "rmdir": "1.2.0", + "semver": "5.1.0", + "send": "0.13.2", + "spawn-cmd": "0.0.2", + "tiny-lr": "0.2.1", + "tmp": "0.0.28", + "urijs": "1.18.0" + }, + "scripts": { + "test": "./node_modules/.bin/mocha ./testing/setup.js \"./lib/**/*/__tests__/*.js\" --bail --reporter=list --timeout=10000" + }, + "repository": { + "type": "git", + "url": "https://github.com/GitbookIO/gitbook.git" + }, + "bin": { + "gitbook": "./bin/gitbook.js" + }, + "keywords": [ + "git", + "book", + "gitbook" + ], + "author": "GitBook Inc. <contact@gitbook.com>", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/GitbookIO/gitbook/issues" + }, + "contributors": [ + { + "name": "Aaron O'Mullan", + "email": "aaron@gitbook.com" + }, + { + "name": "Samy Pessé", + "email": "samy@gitbook.com" + } + ] +} diff --git a/packages/gitbook/testing/setup.js b/packages/gitbook/testing/setup.js new file mode 100644 index 0000000..1105002 --- /dev/null +++ b/packages/gitbook/testing/setup.js @@ -0,0 +1,72 @@ +var is = require('is'); +var path = require('path'); +var fs = require('fs'); +var expect = require('expect'); +var cheerio = require('cheerio'); + +expect.extend({ + /** + * Check that a file is created in a directory: + * expect('myFolder').toHaveFile('hello.md'); + */ + toHaveFile: function(fileName) { + var filePath = path.join(this.actual, fileName); + var exists = fs.existsSync(filePath); + + expect.assert( + exists, + 'expected %s to have file %s', + this.actual, + fileName + ); + return this; + }, + toNotHaveFile: function(fileName) { + var filePath = path.join(this.actual, fileName); + var exists = fs.existsSync(filePath); + + expect.assert( + !exists, + 'expected %s to not have file %s', + this.actual, + fileName + ); + return this; + }, + + /** + * Check that a value is defined (not null nor undefined) + */ + toBeDefined: function() { + expect.assert( + !(is.undefined(this.actual) || is.null(this.actual)), + 'expected to be defined' + ); + return this; + }, + + /** + * Check that a value is defined (not null nor undefined) + */ + toNotBeDefined: function() { + expect.assert( + (is.undefined(this.actual) || is.null(this.actual)), + 'expected %s to be not defined', + this.actual + ); + return this; + }, + + /** + * Check that a dom element exists in HTML + * @param {String} selector + */ + toHaveDOMElement: function(selector) { + var $ = cheerio.load(this.actual); + var $el = $(selector); + + expect.assert($el.length > 0, 'expected HTML to contains %s', selector); + } +}); + +global.expect = expect; |