summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorSamy Pesse <samypesse@gmail.com>2016-04-30 20:15:08 +0200
committerSamy Pesse <samypesse@gmail.com>2016-04-30 20:15:08 +0200
commit36b49c66c6b75515bc84dd678fd52121a313e8d2 (patch)
treebc7e0f703d4557869943ec7f9495cac7a5027d4f /lib
parent87db7cf1d412fa6fbd18e9a7e4f4755f2c0c5547 (diff)
parent80b8e340dadc54377ff40500f86b1de631395806 (diff)
downloadgitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.zip
gitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.tar.gz
gitbook-36b49c66c6b75515bc84dd678fd52121a313e8d2.tar.bz2
Merge branch 'fixes'
Diffstat (limited to 'lib')
-rw-r--r--lib/__tests__/gitbook.js9
-rw-r--r--lib/__tests__/init.js16
-rw-r--r--lib/__tests__/module.js6
-rw-r--r--lib/api/decodeConfig.js19
-rw-r--r--lib/api/decodeGlobal.js22
-rw-r--r--lib/api/decodePage.js44
-rw-r--r--lib/api/deprecate.js104
-rw-r--r--lib/api/encodeConfig.js36
-rw-r--r--lib/api/encodeGlobal.js125
-rw-r--r--lib/api/encodeNavigation.js64
-rw-r--r--lib/api/encodePage.js39
-rw-r--r--lib/api/encodeProgress.js63
-rw-r--r--lib/api/index.js8
-rw-r--r--lib/backbone/file.js69
-rw-r--r--lib/backbone/glossary.js99
-rw-r--r--lib/backbone/index.js8
-rw-r--r--lib/backbone/langs.js81
-rw-r--r--lib/backbone/readme.js44
-rw-r--r--lib/backbone/summary.js349
-rw-r--r--lib/book.js396
-rw-r--r--lib/browser.js14
-rw-r--r--lib/cli/build.js34
-rw-r--r--lib/cli/buildEbook.js76
-rw-r--r--lib/cli/getBook.js23
-rw-r--r--lib/cli/getOutputFolder.js17
-rw-r--r--lib/cli/helper.js140
-rw-r--r--lib/cli/index.js211
-rw-r--r--lib/cli/init.js17
-rw-r--r--lib/cli/install.js21
-rw-r--r--lib/cli/options.js30
-rw-r--r--lib/cli/parse.js79
-rw-r--r--lib/cli/serve.js93
-rw-r--r--lib/cli/server.js21
-rw-r--r--lib/cli/watch.js7
-rw-r--r--lib/config/index.js137
-rw-r--r--lib/config/plugins.js67
-rw-r--r--lib/constants/configDefault.js6
-rw-r--r--lib/constants/configFiles.js5
-rw-r--r--lib/constants/configSchema.js (renamed from lib/config/schema.js)0
-rw-r--r--lib/constants/defaultBlocks.js51
-rw-r--r--lib/constants/defaultFilters.js (renamed from lib/template/filters.js)6
-rw-r--r--lib/constants/defaultPlugins.js14
-rw-r--r--lib/constants/ignoreFiles.js6
-rw-r--r--lib/constants/pluginAssetsFolder.js2
-rw-r--r--lib/constants/pluginHooks.js8
-rw-r--r--lib/constants/pluginPrefix.js5
-rw-r--r--lib/constants/pluginResources.js6
-rw-r--r--lib/constants/templatesFolder.js2
-rw-r--r--lib/fs/__tests__/mock.js83
-rw-r--r--lib/fs/index.js106
-rw-r--r--lib/fs/mock.js95
-rw-r--r--lib/fs/node.js68
-rw-r--r--lib/gitbook.js21
-rw-r--r--lib/index.js15
-rw-r--r--lib/init.js105
-rw-r--r--lib/json/encodeBook.js35
-rw-r--r--lib/json/encodeBookWithPage.js22
-rw-r--r--lib/json/encodeFile.js21
-rw-r--r--lib/json/encodeGlossary.js21
-rw-r--r--lib/json/encodeGlossaryEntry.js16
-rw-r--r--lib/json/encodeOutput.js25
-rw-r--r--lib/json/encodePage.js39
-rw-r--r--lib/json/encodeReadme.js17
-rw-r--r--lib/json/encodeSummary.js20
-rw-r--r--lib/json/encodeSummaryArticle.js27
-rw-r--r--lib/json/encodeSummaryPart.js17
-rw-r--r--lib/json/index.js11
-rw-r--r--lib/models/__tests__/config.js63
-rw-r--r--lib/models/__tests__/glossary.js42
-rw-r--r--lib/models/__tests__/glossaryEntry.js17
-rw-r--r--lib/models/__tests__/plugin.js29
-rw-r--r--lib/models/__tests__/summary.js81
-rw-r--r--lib/models/__tests__/templateBlock.js106
-rw-r--r--lib/models/__tests__/templateEngine.js51
-rw-r--r--lib/models/book.js258
-rw-r--r--lib/models/config.js106
-rw-r--r--lib/models/file.js89
-rw-r--r--lib/models/fs.js274
-rw-r--r--lib/models/glossary.js109
-rw-r--r--lib/models/glossaryEntry.js43
-rw-r--r--lib/models/language.js21
-rw-r--r--lib/models/languages.js71
-rw-r--r--lib/models/output.js93
-rw-r--r--lib/models/page.js55
-rw-r--r--lib/models/plugin.js152
-rw-r--r--lib/models/readme.js40
-rw-r--r--lib/models/summary.js190
-rw-r--r--lib/models/summaryArticle.js150
-rw-r--r--lib/models/summaryPart.js48
-rw-r--r--lib/models/templateBlock.js310
-rw-r--r--lib/models/templateEngine.js139
-rw-r--r--lib/modifiers/index.js4
-rw-r--r--lib/modifiers/summary/__tests__/editArticle.js0
-rw-r--r--lib/modifiers/summary/__tests__/editPartTitle.js44
-rw-r--r--lib/modifiers/summary/editArticle.js70
-rw-r--r--lib/modifiers/summary/editArticleTitle.js17
-rw-r--r--lib/modifiers/summary/editPartTitle.js24
-rw-r--r--lib/modifiers/summary/index.js8
-rw-r--r--lib/modifiers/summary/indexArticleLevels.js23
-rw-r--r--lib/modifiers/summary/indexLevels.js17
-rw-r--r--lib/modifiers/summary/indexPartLevels.js24
-rw-r--r--lib/modifiers/summary/insertArticle.js63
-rw-r--r--lib/modifiers/summary/unshiftArticle.js29
-rw-r--r--lib/output/__tests__/ebook.js16
-rw-r--r--lib/output/__tests__/json.js29
-rw-r--r--lib/output/__tests__/website.js71
-rw-r--r--lib/output/assets-inliner.js140
-rw-r--r--lib/output/base.js309
-rw-r--r--lib/output/callHook.js60
-rw-r--r--lib/output/callPageHook.js28
-rw-r--r--lib/output/conrefs.js67
-rw-r--r--lib/output/createTemplateEngine.js44
-rw-r--r--lib/output/ebook.js193
-rw-r--r--lib/output/ebook/getConvertOptions.js73
-rw-r--r--lib/output/ebook/getCoverPath.js30
-rw-r--r--lib/output/ebook/getPDFTemplate.js42
-rw-r--r--lib/output/ebook/index.js9
-rw-r--r--lib/output/ebook/onFinish.js90
-rw-r--r--lib/output/ebook/onPage.js24
-rw-r--r--lib/output/ebook/options.js17
-rw-r--r--lib/output/folder.js152
-rw-r--r--lib/output/generateAssets.js26
-rw-r--r--lib/output/generateBook.js181
-rw-r--r--lib/output/generateMock.js35
-rw-r--r--lib/output/generatePage.js71
-rw-r--r--lib/output/generatePages.js36
-rw-r--r--lib/output/getModifiers.js68
-rw-r--r--lib/output/helper/fileToOutput.js32
-rw-r--r--lib/output/helper/fileToURL.js31
-rw-r--r--lib/output/helper/index.js2
-rw-r--r--lib/output/helper/resolveFileToUrl.js27
-rw-r--r--lib/output/helper/writeFile.js23
-rw-r--r--lib/output/index.js24
-rw-r--r--lib/output/json.js47
-rw-r--r--lib/output/json/index.js6
-rw-r--r--lib/output/json/onFinish.js32
-rw-r--r--lib/output/json/onPage.js43
-rw-r--r--lib/output/json/options.js8
-rw-r--r--lib/output/modifiers/__tests__/addHeadingId.js29
-rw-r--r--lib/output/modifiers/__tests__/annotateText.js49
-rw-r--r--lib/output/modifiers/__tests__/fetchRemoteImages.js40
-rw-r--r--lib/output/modifiers/__tests__/highlightCode.js63
-rw-r--r--lib/output/modifiers/__tests__/resolveLinks.js71
-rw-r--r--lib/output/modifiers/__tests__/svgToImg.js25
-rw-r--r--lib/output/modifiers/__tests__/svgToPng.js32
-rw-r--r--lib/output/modifiers/addHeadingId.js23
-rw-r--r--lib/output/modifiers/annotateText.js94
-rw-r--r--lib/output/modifiers/editHTMLElement.js15
-rw-r--r--lib/output/modifiers/fetchRemoteImages.js44
-rw-r--r--lib/output/modifiers/highlightCode.js56
-rw-r--r--lib/output/modifiers/index.js15
-rw-r--r--lib/output/modifiers/inlineAssets.js27
-rw-r--r--lib/output/modifiers/modifyHTML.js25
-rw-r--r--lib/output/modifiers/resolveImages.js33
-rw-r--r--lib/output/modifiers/resolveLinks.js38
-rw-r--r--lib/output/modifiers/svgToImg.js56
-rw-r--r--lib/output/modifiers/svgToPng.js53
-rw-r--r--lib/output/prepareAssets.js22
-rw-r--r--lib/output/preparePages.js21
-rw-r--r--lib/output/preparePlugins.js36
-rw-r--r--lib/output/website/copyPluginAssets.js115
-rw-r--r--lib/output/website/createTemplateEngine.js118
-rw-r--r--lib/output/website/index.js232
-rw-r--r--lib/output/website/listSearchPaths.js23
-rw-r--r--lib/output/website/onAsset.js27
-rw-r--r--lib/output/website/onFinish.js35
-rw-r--r--lib/output/website/onInit.js18
-rw-r--r--lib/output/website/onPage.js72
-rw-r--r--lib/output/website/options.js14
-rw-r--r--lib/output/website/prepareI18n.js30
-rw-r--r--lib/output/website/state.js12
-rw-r--r--lib/output/website/templateEnv.js95
-rw-r--r--lib/page/html.js290
-rw-r--r--lib/page/index.js246
-rw-r--r--lib/parse/__tests__/parseBook.js55
-rw-r--r--lib/parse/__tests__/parseGlossary.js36
-rw-r--r--lib/parse/__tests__/parseIgnore.js40
-rw-r--r--lib/parse/__tests__/parseReadme.js36
-rw-r--r--lib/parse/__tests__/parseSummary.js34
-rw-r--r--lib/parse/findParsableFile.js36
-rw-r--r--lib/parse/index.js13
-rw-r--r--lib/parse/listAssets.js36
-rw-r--r--lib/parse/parseBook.js77
-rw-r--r--lib/parse/parseConfig.js51
-rw-r--r--lib/parse/parseGlossary.js26
-rw-r--r--lib/parse/parseIgnore.js50
-rw-r--r--lib/parse/parseLanguages.js28
-rw-r--r--lib/parse/parsePage.js29
-rw-r--r--lib/parse/parsePagesList.js45
-rw-r--r--lib/parse/parseReadme.js28
-rw-r--r--lib/parse/parseStructureFile.js57
-rw-r--r--lib/parse/parseSummary.js46
-rw-r--r--lib/parse/validateConfig.js (renamed from lib/config/validator.js)17
-rw-r--r--lib/parse/walkSummary.js34
-rw-r--r--lib/plugins/__tests__/findInstalled.js16
-rw-r--r--lib/plugins/__tests__/listAll.js71
-rw-r--r--lib/plugins/__tests__/validatePlugin.js21
-rw-r--r--lib/plugins/compatibility.js61
-rw-r--r--lib/plugins/findForBook.js34
-rw-r--r--lib/plugins/findInstalled.js87
-rw-r--r--lib/plugins/index.js192
-rw-r--r--lib/plugins/installPlugins.js146
-rw-r--r--lib/plugins/listAll.js67
-rw-r--r--lib/plugins/listBlocks.js17
-rw-r--r--lib/plugins/listFilters.js17
-rw-r--r--lib/plugins/listForBook.js18
-rw-r--r--lib/plugins/listResources.js45
-rw-r--r--lib/plugins/loadForBook.js57
-rw-r--r--lib/plugins/loadPlugin.js83
-rw-r--r--lib/plugins/plugin.js288
-rw-r--r--lib/plugins/registry.js172
-rw-r--r--lib/plugins/validateConfig.js71
-rw-r--r--lib/plugins/validatePlugin.js34
-rw-r--r--lib/template/blocks.js36
-rw-r--r--lib/template/index.js552
-rw-r--r--lib/template/loader.js42
-rw-r--r--lib/templating/__tests__/conrefsLoader.js34
-rw-r--r--lib/templating/conrefsLoader.js72
-rw-r--r--lib/templating/index.js9
-rw-r--r--lib/templating/listShortcuts.js31
-rw-r--r--lib/templating/postRender.js28
-rw-r--r--lib/templating/render.js30
-rw-r--r--lib/templating/renderFile.js38
-rw-r--r--lib/templating/replaceBlocks.js34
-rw-r--r--lib/templating/replaceShortcuts.js37
-rw-r--r--lib/templating/themesLoader.js (renamed from lib/output/website/themeLoader.js)34
-rw-r--r--lib/utils/__tests__/git.js58
-rw-r--r--lib/utils/__tests__/location.js78
-rw-r--r--lib/utils/__tests__/path.js17
-rw-r--r--lib/utils/error.js30
-rw-r--r--lib/utils/fs.js36
-rw-r--r--lib/utils/genKey.js13
-rw-r--r--lib/utils/location.js49
-rw-r--r--lib/utils/logger.js62
-rw-r--r--lib/utils/path.js15
-rw-r--r--lib/utils/promise.js112
-rw-r--r--lib/utils/timing.js89
237 files changed, 9407 insertions, 5003 deletions
diff --git a/lib/__tests__/gitbook.js b/lib/__tests__/gitbook.js
new file mode 100644
index 0000000..c3669bb
--- /dev/null
+++ b/lib/__tests__/gitbook.js
@@ -0,0 +1,9 @@
+var gitbook = require('../gitbook');
+
+describe('satisfies', function() {
+
+ it('should return true for *', function() {
+ expect(gitbook.satisfies('*')).toBe(true);
+ });
+
+});
diff --git a/lib/__tests__/init.js b/lib/__tests__/init.js
new file mode 100644
index 0000000..5665cf1
--- /dev/null
+++ b/lib/__tests__/init.js
@@ -0,0 +1,16 @@
+var tmp = require('tmp');
+var initBook = require('../init');
+
+describe('initBook', function() {
+
+ pit('should create a README and SUMMARY for empty book', function() {
+ var dir = tmp.dirSync();
+
+ return initBook(dir.name)
+ .then(function() {
+ expect(dir.name).toHaveFile('README.md');
+ expect(dir.name).toHaveFile('SUMMARY.md');
+ });
+ });
+
+});
diff --git a/lib/__tests__/module.js b/lib/__tests__/module.js
new file mode 100644
index 0000000..d9220f5
--- /dev/null
+++ b/lib/__tests__/module.js
@@ -0,0 +1,6 @@
+
+describe('GitBook', function() {
+ it('should correctly export', function() {
+ require('../');
+ });
+});
diff --git a/lib/api/decodeConfig.js b/lib/api/decodeConfig.js
new file mode 100644
index 0000000..351ed05
--- /dev/null
+++ b/lib/api/decodeConfig.js
@@ -0,0 +1,19 @@
+var Config = require('../models/config');
+
+/**
+ Decode changes from a JS API to a config object
+
+ @param {Config} config
+ @param {Object} result: result from API
+ @return {Config}
+*/
+function decodeGlobal(config, result) {
+ var values = result.values;
+
+ delete values.generator;
+ delete values.output;
+
+ return Config.updateValues(config, values);
+}
+
+module.exports = decodeGlobal;
diff --git a/lib/api/decodeGlobal.js b/lib/api/decodeGlobal.js
new file mode 100644
index 0000000..118afb2
--- /dev/null
+++ b/lib/api/decodeGlobal.js
@@ -0,0 +1,22 @@
+var decodeConfig = require('./decodeConfig');
+
+/**
+ Decode changes from a JS API to a output object.
+ Only the configuration can be edited by plugin's hooks
+
+ @param {Output} output
+ @param {Object} result: result from API
+ @return {Output}
+*/
+function decodeGlobal(output, result) {
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ // Update config
+ config = decodeConfig(config, result.config);
+ book = book.set('config', config);
+
+ return output.set('book', book);
+}
+
+module.exports = decodeGlobal;
diff --git a/lib/api/decodePage.js b/lib/api/decodePage.js
new file mode 100644
index 0000000..c85dd1b
--- /dev/null
+++ b/lib/api/decodePage.js
@@ -0,0 +1,44 @@
+var deprecate = require('./deprecate');
+
+/**
+ Decode changes from a JS API to a page object.
+ Only the content can be edited by plugin's hooks.
+
+ @param {Output} output
+ @param {Page} page: page instance to edit
+ @param {Object} result: result from API
+ @return {Page}
+*/
+function decodePage(output, page, result) {
+ var originalContent = page.getContent();
+
+ // No returned value
+ // Existing content will be used
+ if (!result) {
+ return page;
+ }
+
+ deprecate.disable('page.sections');
+
+ // GitBook 3
+ // Use returned page.content if different from original content
+ if (result.content != originalContent) {
+ page = page.set('content', result.content);
+ }
+
+ // GitBook 2 compatibility
+ // Finally, use page.sections
+ else if (result.sections) {
+ page = page.set('content',
+ result.sections.map(function(section) {
+ return section.content;
+ }).join('\n')
+ );
+ }
+
+ deprecate.enable('page.sections');
+
+ return page;
+}
+
+module.exports = decodePage;
diff --git a/lib/api/deprecate.js b/lib/api/deprecate.js
new file mode 100644
index 0000000..d8d6ac1
--- /dev/null
+++ b/lib/api/deprecate.js
@@ -0,0 +1,104 @@
+var is = require('is');
+
+var logged = {};
+var disabled = {};
+
+/**
+ Log a deprecated notice
+
+ @param {Book|Output} book
+ @param {String} key
+ @param {String} message
+*/
+function logNotice(book, key, message) {
+ if (logged[key] || disabled[key]) return;
+
+ logged[key] = true;
+
+ var logger = book.getLogger();
+ logger.warn.ln(message);
+}
+
+/**
+ Deprecate a function
+
+ @param {Book|Output} book
+ @param {String} key: unique identitifer for the deprecated
+ @param {Function} fn
+ @param {String} msg: message to print when called
+ @return {Function}
+*/
+function deprecateMethod(book, key, fn, msg) {
+ return function() {
+ logNotice(book, key, msg);
+
+ return fn.apply(this, arguments);
+ };
+}
+
+/**
+ Deprecate a property of an object
+
+ @param {Book|Output} book
+ @param {String} key: unique identitifer for the deprecated
+ @param {Object} instance
+ @param {String|Function} property
+ @param {String} msg: message to print when called
+ @return {Function}
+*/
+function deprecateField(book, key, instance, property, value, msg) {
+ var store = undefined;
+
+ var prepare = function() {
+ if (!is.undefined(store)) return;
+
+ if (is.fn(value)) store = value();
+ else store = value;
+ };
+
+ var getter = function(){
+ prepare();
+
+ logNotice(book, key, msg);
+ return store;
+ };
+ var setter = function(v) {
+ prepare();
+
+ logNotice(book, key, msg);
+ store = v;
+ return store;
+ };
+
+ Object.defineProperty(instance, property, {
+ get: getter,
+ set: setter,
+ enumerable: true,
+ configurable: true
+ });
+}
+
+/**
+ Enable a deprecation
+
+ @param {String} key: unique identitifer
+*/
+function enableDeprecation(key) {
+ disabled[key] = false;
+}
+
+/**
+ Disable a deprecation
+
+ @param {String} key: unique identitifer
+*/
+function disableDeprecation(key) {
+ disabled[key] = true;
+}
+
+module.exports = {
+ method: deprecateMethod,
+ field: deprecateField,
+ enable: enableDeprecation,
+ disable: disableDeprecation
+};
diff --git a/lib/api/encodeConfig.js b/lib/api/encodeConfig.js
new file mode 100644
index 0000000..2a05528
--- /dev/null
+++ b/lib/api/encodeConfig.js
@@ -0,0 +1,36 @@
+var objectPath = require('object-path');
+var deprecate = require('./deprecate');
+
+/**
+ Encode a config object into a JS config api
+
+ @param {Output} output
+ @param {Config} config
+ @return {Object}
+*/
+function encodeConfig(output, config) {
+ var result = {
+ values: config.getValues().toJS(),
+
+ get: function(key, defaultValue) {
+ return objectPath.get(result.values, key, defaultValue);
+ },
+
+ set: function(key, value) {
+ return objectPath.set(result.values, key, value);
+ }
+ };
+
+ deprecate.field(output, 'config.options', result, 'options',
+ result.values, '"config.options" property is deprecated, use "config.get(key)" instead');
+
+ deprecate.field(output, 'config.options.generator', result.values, 'generator',
+ output.getGenerator(), '"options.generator" property is deprecated, use "output.name" instead');
+
+ deprecate.field(output, 'config.options.generator', result.values, 'output',
+ output.getRoot(), '"options.output" property is deprecated, use "output.root()" instead');
+
+ return result;
+}
+
+module.exports = encodeConfig;
diff --git a/lib/api/encodeGlobal.js b/lib/api/encodeGlobal.js
new file mode 100644
index 0000000..4688cca
--- /dev/null
+++ b/lib/api/encodeGlobal.js
@@ -0,0 +1,125 @@
+var Promise = require('../utils/promise');
+var PathUtils = require('../utils/path');
+var fs = require('../utils/fs');
+
+var deprecate = require('./deprecate');
+var encodeConfig = require('./encodeConfig');
+var encodeNavigation = require('./encodeNavigation');
+var fileToURL = require('../output/helper/fileToURL');
+
+/**
+ Encode a global context into a JS object
+ It's the context for page's hook, etc
+
+ @param {Output} output
+ @return {Object}
+*/
+function encodeGlobal(output) {
+ var book = output.getBook();
+ var bookFS = book.getContentFS();
+ var logger = output.getLogger();
+ var outputFolder = output.getRoot();
+
+ var result = {
+ log: logger,
+ config: encodeConfig(output, book.getConfig()),
+
+ isMultilingual: function() {
+ return book.isMultilingual();
+ },
+
+ isLanguageBook: function() {
+ return book.isLanguageBook();
+ },
+
+ isSubBook: deprecate.method(output, 'this.isSubBook', function() {
+ return book.isLanguageBook();
+ }, '"isSubBook" is deprecated, use "isLanguageBook()" instead'),
+
+ /**
+ Read a file from the book
+
+ @param {String} fileName
+ @return {Promise<Buffer>}
+ */
+ readFile: function(fileName) {
+ return bookFS.read(fileName);
+ },
+
+ /**
+ Read a file from the book as a string
+
+ @param {String} fileName
+ @return {Promise<String>}
+ */
+ readFileAsString: function(fileName) {
+ return bookFS.readAsString(fileName);
+ },
+
+ output: {
+ /**
+ Name of the generator being used
+ {String}
+ */
+ name: output.getGenerator(),
+
+ /**
+ Return absolute path to the root folder of output
+ @return {String}
+ */
+ root: function() {
+ return outputFolder;
+ },
+
+ /**
+ Convert a filepath into an url
+ @return {String}
+ */
+ toURL: function(filePath) {
+ return fileToURL(output, filePath);
+ },
+
+ /**
+ Write a file to the output folder,
+ It creates the required folder
+
+ @param {String} fileName
+ @param {Buffer} content
+ @return {Promise}
+ */
+ writeFile: function(fileName, content) {
+ return Promise()
+ .then(function() {
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ return fs.writeFile(filePath, content);
+ });
+ });
+ }
+ }
+ };
+
+ // todo
+ // template.applyBlock
+
+ // Deprecated properties
+
+ deprecate.field(output, 'this.generator', result, 'generator',
+ output.getGenerator(), '"this.generator" property is deprecated, use "this.output.name" instead');
+
+ deprecate.field(output, 'this.navigation', result, 'navigation', function() {
+ return encodeNavigation(output);
+ }, '"navigation" property is deprecated');
+
+ deprecate.field(output, 'this.book', result, 'book',
+ result, '"book" property is deprecated, use "this" directly instead');
+
+ deprecate.field(output, 'this.options', result, 'options',
+ result.config.values, '"options" property is deprecated, use config.get(key) instead');
+
+ return result;
+}
+
+module.exports = encodeGlobal;
diff --git a/lib/api/encodeNavigation.js b/lib/api/encodeNavigation.js
new file mode 100644
index 0000000..8e329a1
--- /dev/null
+++ b/lib/api/encodeNavigation.js
@@ -0,0 +1,64 @@
+var Immutable = require('immutable');
+
+/**
+ Encode an article for next/prev
+
+ @param {Map<String:Page>}
+ @param {Article}
+ @return {Object}
+*/
+function encodeArticle(pages, article) {
+ var articlePath = article.getPath();
+
+ return {
+ path: articlePath,
+ title: article.getTitle(),
+ level: article.getLevel(),
+ exists: (articlePath && pages.has(articlePath)),
+ external: article.isExternal()
+ };
+}
+
+/**
+ this.navigation is a deprecated property from GitBook v2
+
+ @param {Output}
+ @return {Object}
+*/
+function encodeNavigation(output) {
+ var book = output.getBook();
+ var pages = output.getPages();
+ var summary = book.getSummary();
+ var articles = summary.getArticlesAsList();
+
+
+ var navigation = articles
+ .map(function(article, i) {
+ var ref = article.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+ var prev = articles.get(i - 1);
+ var next = articles.get(i + 1);
+
+ return [
+ ref,
+ {
+ index: i,
+ title: article.getTitle(),
+ introduction: (i === 0),
+ prev: prev? encodeArticle(pages, prev) : undefined,
+ next: next? encodeArticle(pages, next) : undefined,
+ level: article.getLevel()
+ }
+ ];
+ })
+ .filter(function(e) {
+ return Boolean(e);
+ });
+
+ return Immutable.Map(navigation).toJS();
+}
+
+module.exports = encodeNavigation;
diff --git a/lib/api/encodePage.js b/lib/api/encodePage.js
new file mode 100644
index 0000000..379d3d5
--- /dev/null
+++ b/lib/api/encodePage.js
@@ -0,0 +1,39 @@
+var JSONUtils = require('../json');
+var deprecate = require('./deprecate');
+var encodeProgress = require('./encodeProgress');
+
+/**
+ Encode a page in a context to a JS API
+
+ @param {Output} output
+ @param {Page} page
+ @return {Object}
+*/
+function encodePage(output, page) {
+ var book = output.getBook();
+ var summary = book.getSummary();
+ var fs = book.getContentFS();
+ var file = page.getFile();
+
+ // JS Page is based on the JSON output
+ var result = JSONUtils.encodePage(page, summary);
+
+ result.type = file.getType();
+ result.path = file.getPath();
+ result.rawPath = fs.resolve(result.path);
+
+ deprecate.field(output, 'page.progress', result, 'progress', function() {
+ return encodeProgress(output, page);
+ }, '"page.progress" property is deprecated');
+
+ deprecate.field(output, 'page.sections', result, 'sections', [
+ {
+ content: result.content,
+ type: 'normal'
+ }
+ ], '"sections" property is deprecated, use page.content instead');
+
+ return result;
+}
+
+module.exports = encodePage;
diff --git a/lib/api/encodeProgress.js b/lib/api/encodeProgress.js
new file mode 100644
index 0000000..afa0341
--- /dev/null
+++ b/lib/api/encodeProgress.js
@@ -0,0 +1,63 @@
+var Immutable = require('immutable');
+var encodeNavigation = require('./encodeNavigation');
+
+/**
+ page.progress is a deprecated property from GitBook v2
+
+ @param {Output}
+ @param {Page}
+ @return {Object}
+*/
+function encodeProgress(output, page) {
+ var current = page.getPath();
+ var navigation = encodeNavigation(output);
+ navigation = Immutable.Map(navigation);
+
+ var n = navigation.size;
+ var percent = 0, prevPercent = 0, currentChapter = null;
+ var done = true;
+
+ var chapters = navigation
+ .map(function(nav, chapterPath) {
+ nav.path = chapterPath;
+ return nav;
+ })
+ .valueSeq()
+ .sortBy(function(nav) {
+ return nav.index;
+ })
+ .map(function(nav, i) {
+ // Calcul percent
+ nav.percent = (i * 100) / Math.max((n - 1), 1);
+
+ // Is it done
+ nav.done = done;
+ if (nav.path == current) {
+ currentChapter = nav;
+ percent = nav.percent;
+ done = false;
+ } else if (done) {
+ prevPercent = nav.percent;
+ }
+
+ return nav;
+ })
+ .toJS();
+
+ return {
+ // Previous percent
+ prevPercent: prevPercent,
+
+ // Current percent
+ percent: percent,
+
+ // List of chapter with progress
+ chapters: chapters,
+
+ // Current chapter
+ current: currentChapter
+ };
+}
+
+module.exports = encodeProgress;
+
diff --git a/lib/api/index.js b/lib/api/index.js
new file mode 100644
index 0000000..5e67525
--- /dev/null
+++ b/lib/api/index.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ encodePage: require('./encodePage'),
+ decodePage: require('./decodePage'),
+
+ encodeGlobal: require('./encodeGlobal'),
+ decodeGlobal: require('./decodeGlobal')
+};
diff --git a/lib/backbone/file.js b/lib/backbone/file.js
deleted file mode 100644
index 209e261..0000000
--- a/lib/backbone/file.js
+++ /dev/null
@@ -1,69 +0,0 @@
-var _ = require('lodash');
-
-function BackboneFile(book) {
- if (!(this instanceof BackboneFile)) return new BackboneFile(book);
-
- this.book = book;
- this.log = this.book.log;
-
- // Filename in the book
- this.path = '';
- this.parser;
-
- _.bindAll(this);
-}
-
-// Type of the backbone file
-BackboneFile.prototype.type = '';
-
-// Parse a backbone file
-BackboneFile.prototype.parse = function() {
- // To be implemented by each child
-};
-
-// Handle case where file doesn't exists
-BackboneFile.prototype.parseNotFound = function() {
-
-};
-
-// Return true if backbone file exists
-BackboneFile.prototype.exists = function() {
- return Boolean(this.path);
-};
-
-// Locate a backbone file, could be .md, .asciidoc, etc
-BackboneFile.prototype.locate = function() {
- var that = this;
- var filename = this.book.config.getStructure(this.type, true);
- this.log.debug.ln('locating', this.type, ':', filename);
-
- return this.book.findParsableFile(filename)
- .then(function(result) {
- if (!result) return;
-
- that.path = result.path;
- that.parser = result.parser;
- });
-};
-
-// Read and parse the file
-BackboneFile.prototype.load = function() {
- var that = this;
- this.log.debug.ln('loading', this.type, ':', that.path);
-
- return this.locate()
- .then(function() {
- if (!that.path) return that.parseNotFound();
-
- that.log.debug.ln(that.type, 'located at', that.path);
-
- return that.book.readFile(that.path)
-
- // Parse it
- .then(function(content) {
- return that.parse(content);
- });
- });
-};
-
-module.exports = BackboneFile;
diff --git a/lib/backbone/glossary.js b/lib/backbone/glossary.js
deleted file mode 100644
index cc0fdce..0000000
--- a/lib/backbone/glossary.js
+++ /dev/null
@@ -1,99 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-var BackboneFile = require('./file');
-
-// Normalize a glossary entry name into a unique id
-function nameToId(name) {
- return name.toLowerCase()
- .replace(/[\/\\\?\%\*\:\;\|\"\'\\<\\>\#\$\(\)\!\.\@]/g, '')
- .replace(/ /g, '_')
- .trim();
-}
-
-
-/*
-A glossary entry is represented by a name and a short description
-An unique id for the entry is generated using its name
-*/
-function GlossaryEntry(name, description) {
- if (!(this instanceof GlossaryEntry)) return new GlossaryEntry(name, description);
-
- this.name = name;
- this.description = description;
-
- Object.defineProperty(this, 'id', {
- get: _.bind(this.getId, this)
- });
-}
-
-// Normalizes a glossary entry's name to create an ID
-GlossaryEntry.prototype.getId = function() {
- return nameToId(this.name);
-};
-
-
-/*
-A glossary is a list of entries stored in a GLOSSARY.md file
-*/
-function Glossary() {
- BackboneFile.apply(this, arguments);
-
- this.entries = [];
-}
-util.inherits(Glossary, BackboneFile);
-
-Glossary.prototype.type = 'glossary';
-
-// Get templating context
-Glossary.prototype.getContext = function() {
- if (!this.path) return {};
-
- return {
- glossary: {
- path: this.path
- }
- };
-};
-
-// Parse the readme content
-Glossary.prototype.parse = function(content) {
- var that = this;
-
- return this.parser.glossary(content)
- .then(function(entries) {
- that.entries = _.map(entries, function(entry) {
- return new GlossaryEntry(entry.name, entry.description);
- });
- });
-};
-
-// Return an entry by its id
-Glossary.prototype.get = function(id) {
- return _.find(this.entries, {
- id: id
- });
-};
-
-// Find an entry by its name
-Glossary.prototype.find = function(name) {
- return this.get(nameToId(name));
-};
-
-// Return false if glossary has entries (and exists)
-Glossary.prototype.isEmpty = function(id) {
- return _.size(this.entries) === 0;
-};
-
-// Convert the glossary to a list of annotations
-Glossary.prototype.annotations = function() {
- return _.map(this.entries, function(entry) {
- return {
- id: entry.id,
- name: entry.name,
- description: entry.description,
- href: '/' + this.path + '#' + entry.id
- };
- }, this);
-};
-
-module.exports = Glossary;
diff --git a/lib/backbone/index.js b/lib/backbone/index.js
deleted file mode 100644
index 4c3c3f3..0000000
--- a/lib/backbone/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-
-module.exports = {
- Readme: require('./readme'),
- Summary: require('./summary'),
- Glossary: require('./glossary'),
- Langs: require('./langs')
-};
-
diff --git a/lib/backbone/langs.js b/lib/backbone/langs.js
deleted file mode 100644
index e339fa9..0000000
--- a/lib/backbone/langs.js
+++ /dev/null
@@ -1,81 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var util = require('util');
-var BackboneFile = require('./file');
-
-function Language(title, folder) {
- var that = this;
-
- this.title = title;
- this.folder = folder;
-
- Object.defineProperty(this, 'id', {
- get: function() {
- return path.basename(that.folder);
- }
- });
-}
-
-/*
-A Langs is a list of languages stored in a LANGS.md file
-*/
-function Langs() {
- BackboneFile.apply(this, arguments);
-
- this.languages = [];
-}
-util.inherits(Langs, BackboneFile);
-
-Langs.prototype.type = 'langs';
-
-// Parse the readme content
-Langs.prototype.parse = function(content) {
- var that = this;
-
- return this.parser.langs(content)
- .then(function(langs) {
- that.languages = _.map(langs, function(entry) {
- return new Language(entry.title, entry.path);
- });
- });
-};
-
-// Return the list of languages
-Langs.prototype.list = function() {
- return this.languages;
-};
-
-// Return default/main language for the book
-Langs.prototype.getDefault = function() {
- return _.first(this.languages);
-};
-
-// Return true if a language is the default one
-// "lang" cam be a string (id) or a Language entry
-Langs.prototype.isDefault = function(lang) {
- lang = lang.id || lang;
- return (this.cound() > 0 && this.getDefault().id == lang);
-};
-
-// Return the count of languages
-Langs.prototype.count = function() {
- return _.size(this.languages);
-};
-
-// Return templating context for the languages list
-Langs.prototype.getContext = function() {
- if (this.count() == 0) return {};
-
- return {
- languages: {
- list: _.map(this.languages, function(lang) {
- return {
- id: lang.id,
- title: lang.title
- };
- })
- }
- };
-};
-
-module.exports = Langs;
diff --git a/lib/backbone/readme.js b/lib/backbone/readme.js
deleted file mode 100644
index 088a942..0000000
--- a/lib/backbone/readme.js
+++ /dev/null
@@ -1,44 +0,0 @@
-var util = require('util');
-var BackboneFile = require('./file');
-
-function Readme() {
- BackboneFile.apply(this, arguments);
-
- this.title;
- this.description;
-}
-util.inherits(Readme, BackboneFile);
-
-Readme.prototype.type = 'readme';
-
-/*
- Return and extension of context to define the readme
-
- @retrun {Object}
-*/
-Readme.prototype.getContext = function() {
- return {
- readme: {
- path: this.path
- }
- };
-};
-
-/*
- Parse the readme content
-
- @param {String} content
- @retrun {Promise}
-*/
-Readme.prototype.parse = function(content) {
- var that = this;
-
- return this.parser.readme(content)
- .then(function(out) {
- that.title = out.title;
- that.description = out.description;
- });
-};
-
-
-module.exports = Readme;
diff --git a/lib/backbone/summary.js b/lib/backbone/summary.js
deleted file mode 100644
index 2dbcecb..0000000
--- a/lib/backbone/summary.js
+++ /dev/null
@@ -1,349 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-
-var location = require('../utils/location');
-var error = require('../utils/error');
-var BackboneFile = require('./file');
-
-/*
- An article represent an entry in the Summary.
- It's defined by a title, a reference, and children articles,
- the reference (ref) can be a filename + anchor or an external file (optional)
-*/
-function TOCArticle(def, parent) {
- // Title
- this.title = def.title;
-
- // Parent TOCPart or TOCArticle
- this.parent = parent;
-
- // As string indicating the overall position
- // ex: '1.0.0'
- this.level;
- this._next;
- this._prev;
-
- // When README has been automatically added
- this.isAutoIntro = def.isAutoIntro;
- this.isIntroduction = def.isIntroduction;
-
- this.validate();
-
- // Path can be a relative path or an url, or nothing
- this.ref = def.path;
- if (this.ref && !this.isExternal()) {
- var parts = this.ref.split('#');
- this.path = (parts.length > 1? parts.slice(0, -1).join('#') : this.ref);
- this.anchor = (parts.length > 1? '#' + _.last(parts) : null);
-
- // Normalize path to remove ('./', etc)
- this.path = location.normalize(this.path);
- }
-
- this.articles = _.map(def.articles || [], function(article) {
- if (article instanceof TOCArticle) return article;
- return new TOCArticle(article, this);
- }, this);
-}
-
-// Validate the article
-TOCArticle.prototype.validate = function() {
- if (!this.title) {
- throw error.ParsingError(new Error('SUMMARY entries should have an non-empty title'));
- }
-};
-
-// Iterate over all articles in this articles
-TOCArticle.prototype.walk = function(iter, base) {
- base = base || this.level;
-
- _.each(this.articles, function(article, i) {
- var level = levelId(base, i);
-
- if (iter(article, level) === false) {
- return false;
- }
- article.walk(iter, level);
- });
-};
-
-// Return templating context for an article
-TOCArticle.prototype.getContext = function() {
- return {
- level: this.level,
- title: this.title,
- depth: this.depth(),
- path: this.isExternal()? undefined : this.path,
- anchor: this.isExternal()? undefined : this.anchor,
- url: this.isExternal()? this.ref : undefined
- };
-};
-
-// Return true if is pointing to a file
-TOCArticle.prototype.hasLocation = function() {
- return Boolean(this.path);
-};
-
-// Return true if is pointing to an external location
-TOCArticle.prototype.isExternal = function() {
- return location.isExternal(this.ref);
-};
-
-// Return true if this article is the introduction
-TOCArticle.prototype.isIntro = function() {
- return Boolean(this.isIntroduction);
-};
-
-// Return true if has children
-TOCArticle.prototype.hasChildren = function() {
- return this.articles.length > 0;
-};
-
-// Return true if has an article as parent
-TOCArticle.prototype.hasParent = function() {
- return !(this.parent instanceof TOCPart);
-};
-
-// Return depth of this article
-TOCArticle.prototype.depth = function() {
- return this.level.split('.').length;
-};
-
-// Return next article in the TOC
-TOCArticle.prototype.next = function() {
- return this._next;
-};
-
-// Return previous article in the TOC
-TOCArticle.prototype.prev = function() {
- return this._prev;
-};
-
-// Map over all articles
-TOCArticle.prototype.map = function(iter) {
- return _.map(this.articles, iter);
-};
-
-
-/*
- A part of a ToC is a composed of a tree of articles and an optiona title
-*/
-function TOCPart(part, parent) {
- if (!(this instanceof TOCPart)) return new TOCPart(part, parent);
-
- TOCArticle.apply(this, arguments);
-}
-util.inherits(TOCPart, TOCArticle);
-
-// Validate the part
-TOCPart.prototype.validate = function() { };
-
-// Return a sibling (next or prev) of this part
-TOCPart.prototype.sibling = function(direction) {
- var parts = this.parent.parts;
- var pos = _.findIndex(parts, this);
-
- if (parts[pos + direction]) {
- return parts[pos + direction];
- }
-
- return null;
-};
-
-// Iterate over all entries of the part
-TOCPart.prototype.walk = function(iter, base) {
- var articles = this.articles;
-
- if (articles.length == 0) return;
-
- // Has introduction?
- if (articles[0].isIntro()) {
- if (iter(articles[0], '0') === false) {
- return;
- }
-
- articles = articles.slice(1);
- }
-
-
- _.each(articles, function(article, i) {
- var level = levelId(base, i);
-
- if (iter(article, level) === false) {
- return false;
- }
-
- article.walk(iter, level);
- });
-};
-
-// Return templating context for a part
-TOCPart.prototype.getContext = function(onArticle) {
- onArticle = onArticle || function(article) {
- return article.getContext();
- };
-
- return {
- title: this.title,
- articles: this.map(onArticle)
- };
-};
-
-/*
-A summary is composed of a list of parts, each composed wit a tree of articles.
-*/
-function Summary() {
- BackboneFile.apply(this, arguments);
-
- this.parts = [];
- this._length = 0;
-}
-util.inherits(Summary, BackboneFile);
-
-Summary.prototype.type = 'summary';
-
-// Prepare summary when non existant
-Summary.prototype.parseNotFound = function() {
- this.update([]);
-};
-
-// Parse the summary content
-Summary.prototype.parse = function(content) {
- var that = this;
-
- return this.parser.summary(content)
-
- .then(function(summary) {
- that.update(summary.parts);
- });
-};
-
-// Return templating context for the summary
-Summary.prototype.getContext = function() {
- function onArticle(article) {
- var result = article.getContext();
- if (article.hasChildren()) {
- result.articles = article.map(onArticle);
- }
-
- return result;
- }
-
- return {
- summary: {
- path: this.path,
- parts: _.map(this.parts, function(part) {
- return part.getContext(onArticle);
- })
- }
- };
-};
-
-// Iterate over all entries of the summary
-// iter is called with an TOCArticle
-Summary.prototype.walk = function(iter) {
- var hasMultipleParts = this.parts.length > 1;
-
- _.each(this.parts, function(part, i) {
- part.walk(iter, hasMultipleParts? levelId('', i) : null);
- });
-};
-
-// Find a specific article using a filter
-Summary.prototype.find = function(filter) {
- var result;
-
- this.walk(function(article) {
- if (filter(article)) {
- result = article;
- return false;
- }
- });
-
- return result;
-};
-
-// Flatten the list of articles
-Summary.prototype.flatten = function() {
- var result = [];
-
- this.walk(function(article) {
- result.push(article);
- });
-
- return result;
-};
-
-// Return the first TOCArticle for a specific page (or path)
-Summary.prototype.getArticle = function(page) {
- if (!_.isString(page)) page = page.path;
-
- return this.find(function(article) {
- return article.path == page;
- });
-};
-
-// Return the first TOCArticle for a specific level
-Summary.prototype.getArticleByLevel = function(lvl) {
- return this.find(function(article) {
- return article.level == lvl;
- });
-};
-
-// Return the count of articles in the summary
-Summary.prototype.count = function() {
- return this._length;
-};
-
-// Prepare the summary
-Summary.prototype.update = function(parts) {
- var that = this;
-
-
- that.parts = _.map(parts, function(part) {
- return new TOCPart(part, that);
- });
-
- // Create first part if none
- if (that.parts.length == 0) {
- that.parts.push(new TOCPart({}, that));
- }
-
- // Add README as first entry
- var firstArticle = that.parts[0].articles[0];
- if (!firstArticle || firstArticle.path != that.book.readme.path) {
- that.parts[0].articles.unshift(new TOCArticle({
- title: 'Introduction',
- path: that.book.readme.path,
- isAutoIntro: true
- }, that.parts[0]));
- }
- that.parts[0].articles[0].isIntroduction = true;
-
-
- // Update the count and indexing of "level"
- var prev = undefined;
-
- that._length = 0;
- that.walk(function(article, level) {
- // Index level
- article.level = level;
-
- // Chain articles
- article._prev = prev;
- if (prev) prev._next = article;
-
- prev = article;
-
- that._length += 1;
- });
-};
-
-
-// Return a level string from a base level and an index
-function levelId(base, i) {
- i = i + 1;
- return (base? [base || '', i] : [i]).join('.');
-}
-
-module.exports = Summary;
diff --git a/lib/book.js b/lib/book.js
deleted file mode 100644
index 77e973a..0000000
--- a/lib/book.js
+++ /dev/null
@@ -1,396 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var Ignore = require('ignore');
-
-var Config = require('./config');
-var Readme = require('./backbone/readme');
-var Glossary = require('./backbone/glossary');
-var Summary = require('./backbone/summary');
-var Langs = require('./backbone/langs');
-var Page = require('./page');
-var pathUtil = require('./utils/path');
-var error = require('./utils/error');
-var Promise = require('./utils/promise');
-var Logger = require('./utils/logger');
-var parsers = require('./parsers');
-var initBook = require('./init');
-
-
-/*
-The Book class is an interface for parsing books content.
-It does not require to run on Node.js, isnce it only depends on the fs implementation
-*/
-
-function Book(opts) {
- if (!(this instanceof Book)) return new Book(opts);
-
- this.opts = _.defaults(opts || {}, {
- fs: null,
-
- // Root path for the book
- root: '',
-
- // Extend book configuration
- config: {},
-
- // Log function
- log: function(msg) {
- process.stdout.write(msg);
- },
-
- // Log level
- logLevel: 'info'
- });
-
- if (!opts.fs) throw error.ParsingError(new Error('Book requires a fs instance'));
-
- // Root path for the book
- this.root = opts.root;
-
- // If multi-lingual, book can have a parent
- this.parent = opts.parent;
- if (this.parent) {
- this.language = path.relative(this.parent.root, this.root);
- }
-
- // A book is linked to an fs, to access its content
- this.fs = opts.fs;
-
- // Rules to ignore some files
- this.ignore = Ignore();
- this.ignore.addPattern([
- // Skip Git stuff
- '.git/',
-
- // Skip OS X meta data
- '.DS_Store',
-
- // Skip stuff installed by plugins
- 'node_modules',
-
- // Skip book outputs
- '_book',
- '*.pdf',
- '*.epub',
- '*.mobi'
- ]);
-
- // Create a logger for the book
- this.log = new Logger(opts.log, opts.logLevel);
-
- // Create an interface to access the configuration
- this.config = new Config(this, opts.config);
-
- // Interfaces for the book structure
- this.readme = new Readme(this);
- this.summary = new Summary(this);
- this.glossary = new Glossary(this);
-
- // Multilinguals book
- this.langs = new Langs(this);
- this.books = [];
-
- // List of page in the book
- this.pages = {};
-
- // Deprecation for templates
- Object.defineProperty(this, 'options', {
- get: function () {
- this.log.warn.ln('"options" property is deprecated, use config.get(key) instead');
- return this.config.options;
- }
- });
-
- _.bindAll(this);
-
- // Loop for template filters/blocks
- error.deprecateField(this, 'book', this, '"book" property is deprecated, use "this" directly instead');
-}
-
-// Return templating context for the book
-Book.prototype.getContext = function() {
- var variables = this.config.get('variables', {});
-
- return {
- book: _.extend({
- language: this.language
- }, variables)
- };
-};
-
-// Parse and prepare the configuration, fail if invalid
-Book.prototype.prepareConfig = function() {
- var that = this;
-
- return this.config.load()
- .then(function() {
- var rootFolder = that.config.get('root');
- if (!rootFolder) return;
-
- that.originalRoot = that.root;
- that.root = path.resolve(that.root, rootFolder);
- });
-};
-
-// Resolve a path in the book source
-// Enforce that the output path is in the scope
-Book.prototype.resolve = function() {
- var filename = path.resolve.apply(path, [this.root].concat(_.toArray(arguments)));
- if (!this.isFileInScope(filename)) {
- throw error.FileOutOfScopeError({
- filename: filename,
- root: this.root
- });
- }
-
- return filename;
-};
-
-// Return false if a file is outside the book' scope
-Book.prototype.isFileInScope = function(filename) {
- filename = path.resolve(this.root, filename);
-
- // Is the file in the scope of the parent?
- if (this.parent && this.parent.isFileInScope(filename)) return true;
-
- // Is file in the root folder?
- return pathUtil.isInRoot(this.root, filename);
-};
-
-// Parse .gitignore, etc to extract rules
-Book.prototype.parseIgnoreRules = function() {
- var that = this;
-
- return Promise.serie([
- '.ignore',
- '.gitignore',
- '.bookignore'
- ], function(filename) {
- return that.readFile(filename)
- .then(function(content) {
- that.ignore.addPattern(content.toString().split(/\r?\n/));
- }, function() {
- return Promise();
- });
- });
-};
-
-// Parse the whole book
-Book.prototype.parse = function() {
- var that = this;
-
- return Promise()
- .then(this.prepareConfig)
- .then(this.parseIgnoreRules)
-
- // Parse languages
- .then(function() {
- return that.langs.load();
- })
-
- .then(function() {
- if (that.isMultilingual()) {
- if (that.isLanguageBook()) {
- throw error.ParsingError(new Error('A multilingual book as a language book is forbidden'));
- }
-
- that.log.info.ln('Parsing multilingual book, with', that.langs.count(), 'languages');
-
- // Create a new book for each language and parse it
- return Promise.serie(that.langs.list(), function(lang) {
- that.log.debug.ln('Preparing book for language', lang.id);
- var langBook = new Book(_.extend({}, that.opts, {
- parent: that,
- config: that.config.dump(),
- root: that.resolve(lang.id)
- }));
-
- that.books.push(langBook);
-
- return langBook.parse();
- });
- }
-
- return Promise()
-
- // Parse the readme
- .then(that.readme.load)
- .then(function() {
- if (!that.readme.exists()) {
- throw new error.FileNotFoundError({ filename: 'README' });
- }
-
- // Default configuration to infos extracted from readme
- if (!that.config.get('title')) that.config.set('title', that.readme.title);
- if (!that.config.get('description')) that.config.set('description', that.readme.description);
- })
-
- // Parse the summary
- .then(that.summary.load)
- .then(function() {
- if (!that.summary.exists()) {
- that.log.warn.ln('no summary file in this book');
- }
-
- // Index summary's articles
- that.summary.walk(function(article) {
- if (!article.hasLocation() || article.isExternal()) return;
- that.addPage(article.path);
- });
- })
-
- // Parse the glossary
- .then(that.glossary.load)
-
- // Add the glossary as a page
- .then(function() {
- if (!that.glossary.exists()) return;
- that.addPage(that.glossary.path);
- });
- });
-};
-
-// Mark a filename as being parsable
-Book.prototype.addPage = function(filename) {
- if (this.hasPage(filename)) return this.getPage(filename);
-
- filename = pathUtil.normalize(filename);
- this.pages[filename] = new Page(this, filename);
- return this.pages[filename];
-};
-
-// Return a page by its filename (or undefined)
-Book.prototype.getPage = function(filename) {
- filename = pathUtil.normalize(filename);
- return this.pages[filename];
-};
-
-
-// Return true, if has a specific page
-Book.prototype.hasPage = function(filename) {
- return Boolean(this.getPage(filename));
-};
-
-// Test if a file is ignored, return true if it is
-Book.prototype.isFileIgnored = function(filename) {
- return this.ignore.filter([filename]).length == 0;
-};
-
-// Read a file in the book, throw error if ignored
-Book.prototype.readFile = function(filename) {
- if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename }));
- return this.fs.readAsString(this.resolve(filename));
-};
-
-// Get stat infos about a file
-Book.prototype.statFile = function(filename) {
- if (this.isFileIgnored(filename)) return Promise.reject(new error.FileNotFoundError({ filename: filename }));
- return this.fs.stat(this.resolve(filename));
-};
-
-// Find a parsable file using a filename
-Book.prototype.findParsableFile = function(filename) {
- var that = this;
-
- var ext = path.extname(filename);
- var basename = path.basename(filename, ext);
-
- // Ordered list of extensions to test
- var exts = parsers.extensions;
- if (ext) exts = _.uniq([ext].concat(exts));
-
- return _.reduce(exts, function(prev, ext) {
- return prev.then(function(output) {
- // Stop if already find a parser
- if (output) return output;
-
- var filepath = basename+ext;
-
- return that.fs.findFile(that.root, filepath)
- .then(function(realFilepath) {
- if (!realFilepath) return null;
-
- return {
- parser: parsers.getByExt(ext),
- path: realFilepath
- };
- });
- });
- }, Promise(null));
-};
-
-// Return true if book is associated to a language
-Book.prototype.isLanguageBook = function() {
- return Boolean(this.parent);
-};
-Book.prototype.isSubBook = Book.prototype.isLanguageBook;
-
-// Return true if the book is main instance of a multilingual book
-Book.prototype.isMultilingual = function() {
- return this.langs.count() > 0;
-};
-
-// Return true if file is in the scope of this book
-Book.prototype.isInBook = function(filename) {
- return pathUtil.isInRoot(
- this.root,
- filename
- );
-};
-
-// Return true if file is in the scope of a child book
-Book.prototype.isInLanguageBook = function(filename) {
- var that = this;
-
- return _.some(this.langs.list(), function(lang) {
- return pathUtil.isInRoot(
- that.resolve(lang.id),
- that.resolve(filename)
- );
- });
-};
-
-// ----- Parser Methods
-
-// Render a markup string in inline mode
-Book.prototype.renderInline = function(type, src) {
- var parser = parsers.get(type);
- return parser.inline(src)
- .get('content');
-};
-
-// Render a markup string in block mode
-Book.prototype.renderBlock = function(type, src) {
- var parser = parsers.get(type);
- return parser.page(src)
- .get('content');
-};
-
-
-// ----- DEPRECATED METHODS
-
-Book.prototype.contentLink = error.deprecateMethod(function(s) {
- return this.output.toURL(s);
-}, '.contentLink() is deprecated, use ".output.toURL()" instead');
-
-Book.prototype.contentPath = error.deprecateMethod(function(s) {
- return this.output.toURL(s);
-}, '.contentPath() is deprecated, use ".output.toURL()" instead');
-
-Book.prototype.isSubBook = error.deprecateMethod(function() {
- return this.isLanguageBook();
-}, '.isSubBook() is deprecated, use ".isLanguageBook()" instead');
-
-
-// Initialize a book
-Book.init = function(fs, root, opts) {
- var book = new Book(_.extend(opts || {}, {
- root: root,
- fs: fs
- }));
-
- return initBook(book);
-};
-
-
-module.exports = Book;
diff --git a/lib/browser.js b/lib/browser.js
new file mode 100644
index 0000000..745a544
--- /dev/null
+++ b/lib/browser.js
@@ -0,0 +1,14 @@
+var Modifiers = require('./modifiers');
+
+module.exports = {
+ Parse: require('./parse'),
+
+ // Models
+ Book: require('./models/book'),
+ FS: require('./models/fs'),
+ Summary: require('./models/summary'),
+ Glossary: require('./models/glossary'),
+
+ // Modifiers
+ SummaryModifier: Modifiers.Summary
+};
diff --git a/lib/cli/build.js b/lib/cli/build.js
new file mode 100644
index 0000000..023901e
--- /dev/null
+++ b/lib/cli/build.js
@@ -0,0 +1,34 @@
+var Parse = require('../parse');
+var Output = require('../output');
+var timing = require('../utils/timing');
+
+var options = require('./options');
+var getBook = require('./getBook');
+var getOutputFolder = require('./getOutputFolder');
+
+
+module.exports = {
+ name: 'build [book] [output]',
+ description: 'build a book',
+ options: [
+ options.log,
+ options.format,
+ options.timing
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+ var outputFolder = getOutputFolder(args);
+
+ var Generator = Output.getGenerator(kwargs.format);
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder
+ });
+ })
+ .fin(function() {
+ if (kwargs.timing) timing.dump(book.getLogger());
+ });
+ }
+};
diff --git a/lib/cli/buildEbook.js b/lib/cli/buildEbook.js
new file mode 100644
index 0000000..405d838
--- /dev/null
+++ b/lib/cli/buildEbook.js
@@ -0,0 +1,76 @@
+var path = require('path');
+var tmp = require('tmp');
+
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var Parse = require('../parse');
+var Output = require('../output');
+
+var options = require('./options');
+var getBook = require('./getBook');
+
+
+module.exports = function(format) {
+ return {
+ name: (format + ' [book] [output]'),
+ description: 'build a book into an ebook file',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ // Output file will be stored in
+ var outputFile = args[1] || ('book.' + format);
+
+ // Create temporary directory
+ var outputFolder = tmp.dirSync().name;
+
+ var book = getBook(args, kwargs);
+ var logger = book.getLogger();
+ var Generator = Output.getGenerator('ebook');
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder,
+ format: format
+ });
+ })
+
+ // Extract ebook file
+ .then(function(output) {
+ var book = output.getBook();
+ var languages = book.getLanguages();
+
+ if (book.isMultilingual()) {
+ return Promise.ForEach(languages, function(lang) {
+ var langID = lang.getID();
+
+ var langOutputFile = path.join(
+ path.dirname(outputFile),
+ path.basename(outputFile, format) + '_' + langID + '.' + format
+ );
+
+ return fs.copy(
+ path.resolve(outputFolder, langID, 'index.' + format),
+ langOutputFile
+ );
+ })
+ .thenResolve(languages.getCount());
+ } else {
+ return fs.copy(
+ path.resolve(outputFolder, 'index.' + format),
+ outputFile
+ ).thenResolve(1);
+ }
+ })
+
+ // Log end
+ .then(function(count) {
+ logger.info.ok(count + ' file(s) generated');
+
+ logger.debug('cleaning up... ');
+ return logger.debug.promise(fs.rmDir(outputFolder));
+ });
+ }
+ };
+};
diff --git a/lib/cli/getBook.js b/lib/cli/getBook.js
new file mode 100644
index 0000000..ac82187
--- /dev/null
+++ b/lib/cli/getBook.js
@@ -0,0 +1,23 @@
+var path = require('path');
+var Book = require('../models/book');
+var createNodeFS = require('../fs/node');
+
+/**
+ Return a book instance to work on from
+ command line args/kwargs
+
+ @param {Array} args
+ @param {Object} kwargs
+ @return {Book}
+*/
+function getBook(args, kwargs) {
+ var input = path.resolve(args[0] || process.cwd());
+ var logLevel = kwargs.log;
+
+ var fs = createNodeFS(input);
+ var book = Book.createForFS(fs);
+
+ return book.setLogLevel(logLevel);
+}
+
+module.exports = getBook;
diff --git a/lib/cli/getOutputFolder.js b/lib/cli/getOutputFolder.js
new file mode 100644
index 0000000..272dff9
--- /dev/null
+++ b/lib/cli/getOutputFolder.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+/**
+ Return path to output folder
+
+ @param {Array} args
+ @return {String}
+*/
+function getOutputFolder(args) {
+ var bookRoot = path.resolve(args[0] || process.cwd());
+ var defaultOutputRoot = path.join(bookRoot, '_book');
+ var outputFolder = args[1]? path.resolve(process.cwd(), args[1]) : defaultOutputRoot;
+
+ return outputFolder;
+}
+
+module.exports = getOutputFolder;
diff --git a/lib/cli/helper.js b/lib/cli/helper.js
deleted file mode 100644
index 02cede6..0000000
--- a/lib/cli/helper.js
+++ /dev/null
@@ -1,140 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-
-var Book = require('../book');
-var NodeFS = require('../fs/node');
-var Logger = require('../utils/logger');
-var Promise = require('../utils/promise');
-var fs = require('../utils/fs');
-var JSONOutput = require('../output/json');
-var WebsiteOutput = require('../output/website');
-var EBookOutput = require('../output/ebook');
-
-var nodeFS = new NodeFS();
-
-var LOG_OPTION = {
- name: 'log',
- description: 'Minimum log level to display',
- values: _.chain(Logger.LEVELS)
- .keys()
- .map(function(s) {
- return s.toLowerCase();
- })
- .value(),
- defaults: 'info'
-};
-
-var FORMAT_OPTION = {
- name: 'format',
- description: 'Format to build to',
- values: ['website', 'json', 'ebook'],
- defaults: 'website'
-};
-
-var FORMATS = {
- json: JSONOutput,
- website: WebsiteOutput,
- ebook: EBookOutput
-};
-
-// Commands which is processing a book
-// the root of the book is the first argument (or current directory)
-function bookCmd(fn) {
- return function(args, kwargs) {
- var input = path.resolve(args[0] || process.cwd());
- var book = new Book({
- fs: nodeFS,
- root: input,
- logLevel: kwargs.log
- });
-
- return fn(book, args.slice(1), kwargs);
- };
-}
-
-// Commands which is working on a Output instance
-function outputCmd(fn) {
- return bookCmd(function(book, args, kwargs) {
- var Out = FORMATS[kwargs.format];
- var outputFolder = undefined;
-
- // Set output folder
- if (args[0]) {
- outputFolder = path.resolve(process.cwd(), args[0]);
- }
-
- return fn(new Out(book, {
- root: outputFolder
- }), args);
- });
-}
-
-// Command to generate an ebook
-function ebookCmd(format) {
- return {
- name: format + ' [book] [output] [file]',
- description: 'generates ebook '+format,
- options: [
- LOG_OPTION
- ],
- exec: bookCmd(function(book, args, kwargs) {
- return fs.tmpDir()
- .then(function(dir) {
- var ext = '.'+format;
- var outputFile = path.resolve(process.cwd(), args[0] || ('book' + ext));
- var output = new EBookOutput(book, {
- root: dir,
- format: format
- });
-
- return output.book.parse()
- .then(function() {
- return output.generate();
- })
-
- // Copy the ebook files
- .then(function() {
- if (output.book.isMultilingual()) {
- return Promise.serie(output.book.langs.list(), function(lang) {
- var _outputFile = path.join(
- path.dirname(outputFile),
- path.basename(outputFile, ext) + '_' + lang.id + ext
- );
-
- return fs.copy(
- path.resolve(dir, lang.id, 'index' + ext),
- _outputFile
- );
- })
- .thenResolve(output.book.langs.count());
- } else {
- return fs.copy(
- path.resolve(dir, 'index' + ext),
- outputFile
- ).thenResolve(1);
- }
- })
- .then(function(n) {
- output.book.log.info.ok(n+' file(s) generated');
-
- output.book.log.info('cleaning up... ');
- return output.book.log.info.promise(fs.rmDir(dir));
- });
- });
- })
- };
-}
-
-module.exports = {
- nodeFS: nodeFS,
- bookCmd: bookCmd,
- outputCmd: outputCmd,
- ebookCmd: ebookCmd,
-
- options: {
- log: LOG_OPTION,
- format: FORMAT_OPTION
- },
-
- FORMATS: FORMATS
-};
diff --git a/lib/cli/index.js b/lib/cli/index.js
index eea707f..f1fca1d 100644
--- a/lib/cli/index.js
+++ b/lib/cli/index.js
@@ -1,199 +1,12 @@
-/* eslint-disable no-console */
-
-var _ = require('lodash');
-var path = require('path');
-var tinylr = require('tiny-lr');
-
-var Promise = require('../utils/promise');
-var PluginsManager = require('../plugins');
-var Book = require('../book');
-
-var helper = require('./helper');
-var Server = require('./server');
-var watch = require('./watch');
-
-module.exports = {
- commands: [
- {
- name: 'init [book]',
- description: 'setup and create files for chapters',
- options: [
- helper.options.log
- ],
- exec: function(args) {
- var input = path.resolve(args[0] || process.cwd());
- return Book.init(helper.nodeFS, input);
- }
- },
-
- {
- name: 'parse [book]',
- description: 'parse and returns debug information for a book',
- options: [
- helper.options.log
- ],
- exec: helper.bookCmd(function(book) {
- return book.parse()
- .then(function() {
- book.log.info.ln('Book located in:', book.root);
- book.log.info.ln('');
-
- if (book.config.exists()) book.log.info.ln('Configuration:', book.config.path);
-
- if (book.isMultilingual()) {
- book.log.info.ln('Multilingual book detected:', book.langs.path);
- } else {
- book.log.info.ln('Readme:', book.readme.path);
- book.log.info.ln('Summary:', book.summary.path);
- if (book.glossary.exists()) book.log.info.ln('Glossary:', book.glossary.path);
-
- book.log.info.ln('Pages:');
- _.each(book.pages, function(page) {
- book.log.info.ln('\t-', page.path);
- });
- }
- });
- })
- },
-
- {
- name: 'install [book]',
- description: 'install all plugins dependencies',
- options: [
- helper.options.log
- ],
- exec: helper.bookCmd(function(book, args) {
- var plugins = new PluginsManager(book);
-
- return book.config.load()
- .then(function() {
- return plugins.install();
- });
- })
- },
-
- {
- name: 'build [book] [output]',
- description: 'build a book',
- options: [
- helper.options.log,
- helper.options.format
- ],
- exec: helper.outputCmd(function(output, args, kwargs) {
- return output.book.parse()
- .then(function() {
- return output.generate();
- });
- })
- },
-
- helper.ebookCmd('pdf'),
- helper.ebookCmd('epub'),
- helper.ebookCmd('mobi'),
-
- {
- name: 'serve [book]',
- description: 'Build then serve a book from a directory',
- options: [
- {
- name: 'port',
- description: 'Port for server to listen on',
- defaults: 4000
- },
- {
- name: 'lrport',
- description: 'Port for livereload server to listen on',
- defaults: 35729
- },
- {
- name: 'watch',
- description: 'Enable/disable file watcher',
- defaults: true
- },
- helper.options.format,
- helper.options.log
- ],
- exec: function(args, kwargs) {
- var input = path.resolve(args[0] || process.cwd());
- var server = new Server();
-
- // Init livereload server
- var lrServer = tinylr({});
- var port = kwargs.port;
- var lrPath;
-
- var generate = function() {
-
- // Stop server if running
- if (server.isRunning()) console.log('Stopping server');
- return server.stop()
-
- // Generate the book
- .then(function() {
- var book = new Book({
- fs: helper.nodeFS,
- root: input,
- logLevel: kwargs.log
- });
-
- return book.parse()
- .then(function() {
- // Add livereload plugin
- book.config.set('plugins',
- book.config.get('plugins')
- .concat([
- { name: 'livereload' }
- ])
- );
-
- var Out = helper.FORMATS[kwargs.format];
- var output = new Out(book);
-
- return output.generate()
- .thenResolve(output);
- });
- })
-
- // Start server and watch changes
- .then(function(output) {
- console.log();
- console.log('Starting server ...');
- return server.start(output.root(), port)
- .then(function() {
- console.log('Serving book on http://localhost:'+port);
-
- if (lrPath) {
- // trigger livereload
- lrServer.changed({
- body: {
- files: [lrPath]
- }
- });
- }
-
- if (!kwargs.watch) return;
-
- return watch(output.book.root)
- .then(function(filepath) {
- // set livereload path
- lrPath = filepath;
- console.log('Restart after change in file', filepath);
- console.log('');
- return generate();
- });
- });
- });
- };
-
- return Promise.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport)
- .then(function() {
- console.log('Live reload server started on port:', kwargs.lrport);
- console.log('Press CTRL+C to quit ...');
- console.log('');
- return generate();
- });
- }
- }
-
- ]
-};
+var buildEbook = require('./buildEbook');
+
+module.exports = [
+ require('./build'),
+ require('./serve'),
+ require('./install'),
+ require('./parse'),
+ require('./init'),
+ buildEbook('pdf'),
+ buildEbook('epub'),
+ buildEbook('mobi')
+];
diff --git a/lib/cli/init.js b/lib/cli/init.js
new file mode 100644
index 0000000..9a1bff8
--- /dev/null
+++ b/lib/cli/init.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+var options = require('./options');
+var initBook = require('../init');
+
+module.exports = {
+ name: 'install [book]',
+ description: 'setup and create files for chapters',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var bookRoot = path.resolve(process.cwd(), args[0] || './');
+
+ return initBook(bookRoot);
+ }
+};
diff --git a/lib/cli/install.js b/lib/cli/install.js
new file mode 100644
index 0000000..c001711
--- /dev/null
+++ b/lib/cli/install.js
@@ -0,0 +1,21 @@
+var options = require('./options');
+var getBook = require('./getBook');
+
+var Parse = require('../parse');
+var Plugins = require('../plugins');
+
+module.exports = {
+ name: 'install [book]',
+ description: 'install all plugins dependencies',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+
+ return Parse.parseConfig(book)
+ .then(function(resultBook) {
+ return Plugins.installPlugins(resultBook);
+ });
+ }
+};
diff --git a/lib/cli/options.js b/lib/cli/options.js
new file mode 100644
index 0000000..ddcb5c5
--- /dev/null
+++ b/lib/cli/options.js
@@ -0,0 +1,30 @@
+var Logger = require('../utils/logger');
+
+var logOptions = {
+ name: 'log',
+ description: 'Minimum log level to display',
+ values: Object.keys(Logger.LEVELS)
+ .map(function(s) {
+ return s.toLowerCase();
+ }),
+ defaults: 'info'
+};
+
+var formatOption = {
+ name: 'format',
+ description: 'Format to build to',
+ values: ['website', 'json', 'ebook'],
+ defaults: 'website'
+};
+
+var timingOption = {
+ name: 'timing',
+ description: 'Print timing debug information',
+ defaults: false
+};
+
+module.exports = {
+ log: logOptions,
+ format: formatOption,
+ timing: timingOption
+};
diff --git a/lib/cli/parse.js b/lib/cli/parse.js
new file mode 100644
index 0000000..0fa509a
--- /dev/null
+++ b/lib/cli/parse.js
@@ -0,0 +1,79 @@
+var options = require('./options');
+var getBook = require('./getBook');
+
+var Parse = require('../parse');
+
+function printBook(book) {
+ var logger = book.getLogger();
+
+ var config = book.getConfig();
+ var configFile = config.getFile();
+
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile();
+
+ var readme = book.getReadme();
+ var readmeFile = readme.getFile();
+
+ var glossary = book.getGlossary();
+ var glossaryFile = glossary.getFile();
+
+ if (configFile.exists()) {
+ logger.info.ln('Configuration file is', configFile.getPath());
+ }
+
+ if (readmeFile.exists()) {
+ logger.info.ln('Introduction file is', readmeFile.getPath());
+ }
+
+ if (glossaryFile.exists()) {
+ logger.info.ln('Glossary file is', glossaryFile.getPath());
+ }
+
+ if (summaryFile.exists()) {
+ logger.info.ln('Table of Contents file is', summaryFile.getPath());
+ }
+}
+
+function printMultingualBook(book) {
+ var logger = book.getLogger();
+ var languages = book.getLanguages();
+ var books = book.getBooks();
+
+ logger.info.ln(languages.size + ' languages');
+
+ languages.forEach(function(lang) {
+ logger.info.ln('Language:', lang.getTitle());
+ printBook(books.get(lang.getID()));
+ logger.info.ln('');
+ });
+}
+
+module.exports = {
+ name: 'parse [book]',
+ description: 'parse and print debug information about a book',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+ var logger = book.getLogger();
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ var rootFolder = book.getRoot();
+ var contentFolder = book.getContentRoot();
+
+ logger.info.ln('Book located in:', rootFolder);
+ if (contentFolder != rootFolder) {
+ logger.info.ln('Content located in:', contentFolder);
+ }
+
+ if (resultBook.isMultilingual()) {
+ printMultingualBook(resultBook);
+ } else {
+ printBook(resultBook);
+ }
+ });
+ }
+};
diff --git a/lib/cli/serve.js b/lib/cli/serve.js
new file mode 100644
index 0000000..628f591
--- /dev/null
+++ b/lib/cli/serve.js
@@ -0,0 +1,93 @@
+/* eslint-disable no-console */
+
+var tinylr = require('tiny-lr');
+
+var Parse = require('../parse');
+var Output = require('../output');
+
+var options = require('./options');
+var getBook = require('./getBook');
+var getOutputFolder = require('./getOutputFolder');
+var Server = require('./server');
+var watch = require('./watch');
+
+var server, lrServer, lrPath;
+
+function generateBook(args, kwargs) {
+ var port = kwargs.port;
+ var outputFolder = getOutputFolder(args);
+ var book = getBook(args, kwargs);
+ var Generator = Output.getGenerator(kwargs.format);
+
+ // Stop server if running
+ if (server.isRunning()) console.log('Stopping server');
+
+ return server.stop()
+ .then(function() {
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder
+ });
+ });
+ })
+ .then(function() {
+ console.log();
+ console.log('Starting server ...');
+ return server.start(outputFolder, port);
+ })
+ .then(function() {
+ console.log('Serving book on http://localhost:'+port);
+
+ if (lrPath) {
+ // trigger livereload
+ lrServer.changed({
+ body: {
+ files: [lrPath]
+ }
+ });
+ }
+ })
+ .then(function() {
+ if (!kwargs.watch) return;
+
+ return watch(book.getRoot())
+ .then(function(filepath) {
+ // set livereload path
+ lrPath = filepath;
+ console.log('Restart after change in file', filepath);
+ console.log('');
+ return generateBook(args, kwargs);
+ });
+ });
+}
+
+module.exports = {
+ name: 'serve [book] [output]',
+ description: 'serve the book as a website for testing',
+ options: [
+ {
+ name: 'port',
+ description: 'Port for server to listen on',
+ defaults: 4000
+ },
+ {
+ name: 'lrport',
+ description: 'Port for livereload server to listen on',
+ defaults: 35729
+ },
+ {
+ name: 'watch',
+ description: 'Enable/disable file watcher',
+ defaults: true
+ },
+ options.log,
+ options.format
+ ],
+ exec: function(args, kwargs) {
+ server = new Server();
+ lrServer = tinylr({});
+
+ return generateBook(args, kwargs);
+ }
+};
diff --git a/lib/cli/server.js b/lib/cli/server.js
index 8d3d7ce..555bbb7 100644
--- a/lib/cli/server.js
+++ b/lib/cli/server.js
@@ -6,20 +6,28 @@ var url = require('url');
var Promise = require('../utils/promise');
-var Server = function() {
+function Server() {
this.running = null;
this.dir = null;
this.port = 0;
this.sockets = [];
-};
+}
util.inherits(Server, events.EventEmitter);
-// Return true if the server is running
+/**
+ Return true if the server is running
+
+ @return {Boolean}
+*/
Server.prototype.isRunning = function() {
return !!this.running;
};
-// Stop the server
+/**
+ Stop the server
+
+ @return {Promise}
+*/
Server.prototype.stop = function() {
var that = this;
if (!this.isRunning()) return Promise();
@@ -40,6 +48,11 @@ Server.prototype.stop = function() {
return d.promise;
};
+/**
+ Start the server
+
+ @return {Promise}
+*/
Server.prototype.start = function(dir, port) {
var that = this, pre = Promise();
port = port || 8004;
diff --git a/lib/cli/watch.js b/lib/cli/watch.js
index 130b0d4..0d1ab17 100644
--- a/lib/cli/watch.js
+++ b/lib/cli/watch.js
@@ -5,7 +5,12 @@ var chokidar = require('chokidar');
var Promise = require('../utils/promise');
var parsers = require('../parsers');
-// Watch a folder and resolve promise once a file is modified
+/**
+ Watch a folder and resolve promise once a file is modified
+
+ @param {String} dir
+ @return {Promise}
+*/
function watch(dir) {
var d = Promise.defer();
dir = path.resolve(dir);
diff --git a/lib/config/index.js b/lib/config/index.js
deleted file mode 100644
index 6887cc2..0000000
--- a/lib/config/index.js
+++ /dev/null
@@ -1,137 +0,0 @@
-var _ = require('lodash');
-var semver = require('semver');
-
-var gitbook = require('../gitbook');
-var Promise = require('../utils/promise');
-var error = require('../utils/error');
-var validator = require('./validator');
-var plugins = require('./plugins');
-
-// Config files to tested (sorted)
-var CONFIG_FILES = [
- 'book.js',
- 'book.json'
-];
-
-/*
-Config is an interface for the book's configuration stored in "book.json" (or "book.js")
-*/
-
-function Config(book, baseConfig) {
- this.book = book;
- this.fs = book.fs;
- this.log = book.log;
- this.path = '';
-
- this.baseConfig = baseConfig || {};
- this.replace({});
-}
-
-// Load configuration of the book
-// and verify that the configuration is satisfying
-Config.prototype.load = function() {
- var that = this;
- var isLanguageBook = this.book.isLanguageBook();
-
- // Try all potential configuration file
- return Promise.some(CONFIG_FILES, function(filename) {
- that.log.debug.ln('try loading configuration from', filename);
-
- return that.fs.loadAsObject(that.book.resolve(filename))
- .then(function(_config) {
- that.log.debug.ln('configuration loaded from', filename);
-
- that.path = filename;
- return that.replace(_config);
- })
- .fail(function(err) {
- if (err.code != 'MODULE_NOT_FOUND') throw(err);
- else return Promise(false);
- });
- })
- .then(function() {
- if (!isLanguageBook) {
- if (!gitbook.satisfies(that.options.gitbook)) {
- throw new Error('GitBook version doesn\'t satisfy version required by the book: '+that.options.gitbook);
- }
- if (that.options.gitbook != '*' && !semver.satisfies(semver.inc(gitbook.version, 'patch'), that.options.gitbook)) {
- that.log.warn.ln('gitbook version specified in your book.json might be too strict for future patches, \'>='+(_.first(gitbook.version.split('.'))+'.x.x')+'\' is more adequate');
- }
-
- that.options.plugins = plugins.toList(that.options.plugins);
- } else {
- // Multilingual book should inherits the plugins list from parent
- that.options.plugins = that.book.parent.config.get('plugins');
- }
-
- that.options.gitbook = gitbook.version;
- });
-};
-
-// Replace the whole configuration
-Config.prototype.replace = function(options) {
- var that = this;
-
- // Extend base config
- options = _.defaults(_.cloneDeep(options), this.baseConfig);
-
- // Validate the config
- this.options = validator.validate(options);
-
- // options.input == book.root
- Object.defineProperty(this.options, 'input', {
- get: function () {
- return that.book.root;
- }
- });
-
- // options.originalInput == book.parent.root
- Object.defineProperty(this.options, 'originalInput', {
- get: function () {
- return that.book.parent? that.book.parent.root : undefined;
- }
- });
-
- error.deprecateField(this.options, 'generator', (this.book.output? this.book.output.name : null), '"options.generator" property is deprecated, use "output.name" instead');
- error.deprecateField(this.options, 'output', (this.book.output && this.book.output.root? this.book.output.root() : null), '"options.output" property is deprecated, use "output.root()" instead');
-};
-
-// Return true if book has a configuration file
-Config.prototype.exists = function() {
- return Boolean(this.path);
-};
-
-// Return path to a structure file
-// Strip the extension by default
-Config.prototype.getStructure = function(name, dontStripExt) {
- var filename = this.options.structure[name];
- if (dontStripExt) return filename;
-
- filename = filename.split('.').slice(0, -1).join('.');
- return filename;
-};
-
-// Return a configuration using a key and a default value
-Config.prototype.get = function(key, def) {
- return _.get(this.options, key, def);
-};
-
-// Update a configuration
-Config.prototype.set = function(key, value) {
- return _.set(this.options, key, value);
-};
-
-// Return a dump of the configuration
-Config.prototype.dump = function() {
- var opts = _.omit(this.options, 'generator', 'output');
- return _.cloneDeep(opts);
-};
-
-// Return templating context
-Config.prototype.getContext = function() {
- return {
- config: this.book.config.dump()
- };
-};
-
-module.exports = Config;
diff --git a/lib/config/plugins.js b/lib/config/plugins.js
deleted file mode 100644
index 24f0041..0000000
--- a/lib/config/plugins.js
+++ /dev/null
@@ -1,67 +0,0 @@
-var _ = require('lodash');
-
-// Default plugins added to each books
-var DEFAULT_PLUGINS = ['highlight', 'search', 'lunr', 'sharing', 'fontsettings', 'theme-default'];
-
-// Return true if a plugin is a default plugin
-function isDefaultPlugin(name, version) {
- return _.contains(DEFAULT_PLUGINS, name);
-}
-
-// Normalize a list of plugins to use
-function normalizePluginsList(plugins) {
- // Normalize list to an array
- plugins = _.isString(plugins) ? plugins.split(',') : (plugins || []);
-
- // Remove empty parts
- plugins = _.compact(plugins);
-
- // Divide as {name, version} to handle format like 'myplugin@1.0.0'
- plugins = _.map(plugins, function(plugin) {
- if (plugin.name) return plugin;
-
- var parts = plugin.split('@');
- var name = parts[0];
- var version = parts.slice(1).join('@');
- return {
- 'name': name,
- 'version': version // optional
- };
- });
-
- // List plugins to remove
- var toremove = _.chain(plugins)
- .filter(function(plugin) {
- return plugin.name.length > 0 && plugin.name[0] == '-';
- })
- .map(function(plugin) {
- return plugin.name.slice(1);
- })
- .value();
-
- // Merge with defaults
- _.each(DEFAULT_PLUGINS, function(plugin) {
- if (_.find(plugins, { name: plugin })) {
- return;
- }
-
- plugins.push({
- 'name': plugin
- });
- });
- // Remove plugin that start with '-'
- plugins = _.filter(plugins, function(plugin) {
- return !_.contains(toremove, plugin.name) && !(plugin.name.length > 0 && plugin.name[0] == '-');
- });
-
- // Remove duplicates
- plugins = _.uniq(plugins, 'name');
-
- return plugins;
-}
-
-module.exports = {
- isDefaultPlugin: isDefaultPlugin,
- toList: normalizePluginsList
-};
-
diff --git a/lib/constants/configDefault.js b/lib/constants/configDefault.js
new file mode 100644
index 0000000..0d95883
--- /dev/null
+++ b/lib/constants/configDefault.js
@@ -0,0 +1,6 @@
+var Immutable = require('immutable');
+var jsonSchemaDefaults = require('json-schema-defaults');
+
+var schema = require('./configSchema');
+
+module.exports = Immutable.fromJS(jsonSchemaDefaults(schema));
diff --git a/lib/constants/configFiles.js b/lib/constants/configFiles.js
new file mode 100644
index 0000000..a67fd74
--- /dev/null
+++ b/lib/constants/configFiles.js
@@ -0,0 +1,5 @@
+// Configuration files to test (sorted)
+module.exports = [
+ 'book.js',
+ 'book.json'
+];
diff --git a/lib/config/schema.js b/lib/constants/configSchema.js
index 3fb2050..3fb2050 100644
--- a/lib/config/schema.js
+++ b/lib/constants/configSchema.js
diff --git a/lib/constants/defaultBlocks.js b/lib/constants/defaultBlocks.js
new file mode 100644
index 0000000..74d1f1f
--- /dev/null
+++ b/lib/constants/defaultBlocks.js
@@ -0,0 +1,51 @@
+var Immutable = require('immutable');
+var TemplateBlock = require('../models/templateBlock');
+
+module.exports = Immutable.Map({
+ html: TemplateBlock({
+ name: 'html',
+ process: function(blk) {
+ return blk;
+ }
+ }),
+
+ code: TemplateBlock({
+ name: 'code',
+ process: function(blk) {
+ return {
+ html: false,
+ body: blk.body
+ };
+ }
+ }),
+
+ markdown: TemplateBlock({
+ name: 'markdown',
+ process: function(blk) {
+ return this.book.renderInline('markdown', blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ }),
+
+ asciidoc: TemplateBlock({
+ name: 'asciidoc',
+ process: function(blk) {
+ return this.book.renderInline('asciidoc', blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ }),
+
+ markup: TemplateBlock({
+ name: 'markup',
+ process: function(blk) {
+ return this.book.renderInline(this.ctx.file.type, blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ })
+});
diff --git a/lib/template/filters.js b/lib/constants/defaultFilters.js
index ac68b82..35025cc 100644
--- a/lib/template/filters.js
+++ b/lib/constants/defaultFilters.js
@@ -1,7 +1,7 @@
+var Immutable = require('immutable');
var moment = require('moment');
-
-module.exports = {
+module.exports = Immutable.Map({
// Format a date
// ex: 'MMMM Do YYYY, h:mm:ss a
date: function(time, format) {
@@ -12,4 +12,4 @@ module.exports = {
dateFromNow: function(time) {
return moment(time).fromNow();
}
-};
+});
diff --git a/lib/constants/defaultPlugins.js b/lib/constants/defaultPlugins.js
new file mode 100644
index 0000000..e6ea2bb
--- /dev/null
+++ b/lib/constants/defaultPlugins.js
@@ -0,0 +1,14 @@
+var Immutable = require('immutable');
+
+/*
+ List of default plugins for all books,
+ default plugins should be installed in node dependencies of GitBook
+*/
+module.exports = Immutable.List([
+ 'highlight',
+ 'search',
+ 'lunr',
+ 'sharing',
+ 'fontsettings',
+ 'theme-default'
+]);
diff --git a/lib/constants/ignoreFiles.js b/lib/constants/ignoreFiles.js
new file mode 100644
index 0000000..aac225e
--- /dev/null
+++ b/lib/constants/ignoreFiles.js
@@ -0,0 +1,6 @@
+// Files containing ignore pattner (sorted by priority)
+module.exports = [
+ '.ignore',
+ '.gitignore',
+ '.bookignore'
+];
diff --git a/lib/constants/pluginAssetsFolder.js b/lib/constants/pluginAssetsFolder.js
new file mode 100644
index 0000000..cd44722
--- /dev/null
+++ b/lib/constants/pluginAssetsFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_assets';
diff --git a/lib/constants/pluginHooks.js b/lib/constants/pluginHooks.js
new file mode 100644
index 0000000..2d5dcaa
--- /dev/null
+++ b/lib/constants/pluginHooks.js
@@ -0,0 +1,8 @@
+module.exports = [
+ 'init',
+ 'finish',
+ 'finish:before',
+ 'config',
+ 'page',
+ 'page:before'
+];
diff --git a/lib/constants/pluginPrefix.js b/lib/constants/pluginPrefix.js
new file mode 100644
index 0000000..c7f2dd0
--- /dev/null
+++ b/lib/constants/pluginPrefix.js
@@ -0,0 +1,5 @@
+
+/*
+ All GitBook plugins are NPM packages starting with this prefix.
+*/
+module.exports = 'gitbook-plugin-';
diff --git a/lib/constants/pluginResources.js b/lib/constants/pluginResources.js
new file mode 100644
index 0000000..ae283bf
--- /dev/null
+++ b/lib/constants/pluginResources.js
@@ -0,0 +1,6 @@
+var Immutable = require('immutable');
+
+module.exports = Immutable.List([
+ 'js',
+ 'css'
+]);
diff --git a/lib/constants/templatesFolder.js b/lib/constants/templatesFolder.js
new file mode 100644
index 0000000..aad6a72
--- /dev/null
+++ b/lib/constants/templatesFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_layouts';
diff --git a/lib/fs/__tests__/mock.js b/lib/fs/__tests__/mock.js
new file mode 100644
index 0000000..7842011
--- /dev/null
+++ b/lib/fs/__tests__/mock.js
@@ -0,0 +1,83 @@
+jest.autoMockOff();
+
+describe('MockFS', function() {
+ var createMockFS = require('../mock');
+ var fs = createMockFS({
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary',
+ 'folder': {
+ 'test.md': 'Cool',
+ 'folder2': {
+ 'hello.md': 'Hello',
+ 'world.md': 'World'
+ }
+ }
+ });
+
+ describe('exists', function() {
+ pit('must return true for a file', function() {
+ return fs.exists('README.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ pit('must return false for a non existing file', function() {
+ return fs.exists('README_NOTEXISTS.md')
+ .then(function(result) {
+ expect(result).toBeFalsy();
+ });
+ });
+
+ pit('must return true for a directory', function() {
+ return fs.exists('folder')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ pit('must return true for a deep file', function() {
+ return fs.exists('folder/test.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ pit('must return true for a deep file (2)', function() {
+ return fs.exists('folder/folder2/hello.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+ });
+
+ describe('readAsString', function() {
+ pit('must return content for a file', function() {
+ return fs.readAsString('README.md')
+ .then(function(result) {
+ expect(result).toBe('Hello World');
+ });
+ });
+
+ pit('must return content for a deep file', function() {
+ return fs.readAsString('folder/test.md')
+ .then(function(result) {
+ expect(result).toBe('Cool');
+ });
+ });
+ });
+
+ describe('readDir', function() {
+ pit('must return content for a directory', function() {
+ return fs.readDir('./')
+ .then(function(files) {
+ expect(files.size).toBe(3);
+ expect(files.includes('README.md')).toBeTruthy();
+ expect(files.includes('SUMMARY.md')).toBeTruthy();
+ expect(files.includes('folder/')).toBeTruthy();
+ });
+ });
+ });
+});
+
+
diff --git a/lib/fs/index.js b/lib/fs/index.js
deleted file mode 100644
index 8a3ca1e..0000000
--- a/lib/fs/index.js
+++ /dev/null
@@ -1,106 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-
-var Promise = require('../utils/promise');
-
-/*
-A filesystem is an interface to read files
-GitBook can works with a virtual filesystem, for example in the browser.
-*/
-
-// .readdir return files/folder as a list of string, folder ending with '/'
-function pathIsFolder(filename) {
- return _.last(filename) == '/' || _.last(filename) == '\\';
-}
-
-
-function FS() {
-
-}
-
-// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
-FS.prototype.exists = function(filename) {
- // To implement for each fs
-};
-
-// Read a file and returns a promise with the content as a buffer
-FS.prototype.read = function(filename) {
- // To implement for each fs
-};
-
-// Read stat infos about a file
-FS.prototype.stat = function(filename) {
- // To implement for each fs
-};
-
-// List files/directories in a directory
-FS.prototype.readdir = function(folder) {
- // To implement for each fs
-};
-
-// These methods don't require to be redefined, by default it uses .exists, .read, .write, .list
-// For optmization, it can be redefined:
-
-// List files in a directory
-FS.prototype.listFiles = function(folder) {
- return this.readdir(folder)
- .then(function(files) {
- return _.reject(files, pathIsFolder);
- });
-};
-
-// List all files in the fs
-FS.prototype.listAllFiles = function(folder) {
- var that = this;
-
- return this.readdir(folder)
- .then(function(files) {
- return _.reduce(files, function(prev, file) {
- return prev.then(function(output) {
- var isDirectory = pathIsFolder(file);
-
- if (!isDirectory) {
- output.push(file);
- return output;
- } else {
- return that.listAllFiles(path.join(folder, file))
- .then(function(files) {
- return output.concat(_.map(files, function(_file) {
- return path.join(file, _file);
- }));
- });
- }
- });
- }, Promise([]));
- });
-};
-
-// Read a file as a string (utf-8)
-FS.prototype.readAsString = function(filename) {
- return this.read(filename)
- .then(function(buf) {
- return buf.toString('utf-8');
- });
-};
-
-// Find a file in a folder (case incensitive)
-// Return the real filename
-FS.prototype.findFile = function findFile(root, filename) {
- return this.listFiles(root)
- .then(function(files) {
- return _.find(files, function(file) {
- return (file.toLowerCase() == filename.toLowerCase());
- });
- });
-};
-
-// Load a JSON file
-// By default, fs only supports JSON
-FS.prototype.loadAsObject = function(filename) {
- return this.readAsString(filename)
- .then(function(str) {
- return JSON.parse(str);
- });
-};
-
-module.exports = FS;
diff --git a/lib/fs/mock.js b/lib/fs/mock.js
new file mode 100644
index 0000000..2149e1d
--- /dev/null
+++ b/lib/fs/mock.js
@@ -0,0 +1,95 @@
+var path = require('path');
+var is = require('is');
+var Buffer = require('buffer').Buffer;
+var Immutable = require('immutable');
+
+var FS = require('../models/fs');
+var error = require('../utils/error');
+
+/**
+ Create a fake filesystem for unit testing GitBook.
+
+ @param {Map<String:String|Map>}
+*/
+function createMockFS(files) {
+ files = Immutable.fromJS(files);
+ var mtime = new Date();
+
+ function getFile(filePath) {
+ var parts = path.normalize(filePath).split('/');
+ return parts.reduce(function(list, part, i) {
+ if (!list) return null;
+
+ var file;
+
+ if (!part || part === '.') file = list;
+ else file = list.get(part);
+
+ if (!file) return null;
+
+ if (is.string(file)) {
+ if (i === (parts.length - 1)) return file;
+ else return null;
+ }
+
+ return file;
+ }, files);
+ }
+
+ function fsExists(filePath) {
+ return Boolean(getFile(filePath) !== null);
+ }
+
+ function fsReadFile(filePath) {
+ var file = getFile(filePath);
+ if (!is.string(file)) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return new Buffer(file, 'utf8');
+ }
+
+ function fsStatFile(filePath) {
+ var file = getFile(filePath);
+ if (!file) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return {
+ mtime: mtime
+ };
+ }
+
+ function fsReadDir(filePath) {
+ var dir = getFile(filePath);
+ if (!dir || is.string(dir)) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return dir
+ .map(function(content, name) {
+ if (!is.string(content)) {
+ name = name + '/';
+ }
+
+ return name;
+ })
+ .valueSeq();
+ }
+
+ return FS.create({
+ root: '',
+ fsExists: fsExists,
+ fsReadFile: fsReadFile,
+ fsStatFile: fsStatFile,
+ fsReadDir: fsReadDir
+ });
+}
+
+module.exports = createMockFS;
diff --git a/lib/fs/node.js b/lib/fs/node.js
index fc2517e..e05cb65 100644
--- a/lib/fs/node.js
+++ b/lib/fs/node.js
@@ -1,36 +1,15 @@
-var _ = require('lodash');
-var util = require('util');
var path = require('path');
+var Immutable = require('immutable');
var fs = require('../utils/fs');
-var Promise = require('../utils/promise');
-var BaseFS = require('./');
+var FS = require('../models/fs');
-function NodeFS() {
- BaseFS.call(this);
-}
-util.inherits(NodeFS, BaseFS);
-
-// Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
-NodeFS.prototype.exists = function(filename) {
- return fs.exists(filename);
-};
-
-// Read a file and returns a promise with the content as a buffer
-NodeFS.prototype.read = function(filename) {
- return fs.readFile(filename);
-};
-
-// Read stat infos about a file
-NodeFS.prototype.stat = function(filename) {
- return fs.stat(filename);
-};
-
-// List files in a directory
-NodeFS.prototype.readdir = function(folder) {
+function fsReadDir(folder) {
return fs.readdir(folder)
.then(function(files) {
- return _.chain(files)
+ files = Immutable.List(files);
+
+ return files
.map(function(file) {
if (file == '.' || file == '..') return;
@@ -38,29 +17,24 @@ NodeFS.prototype.readdir = function(folder) {
if (stat.isDirectory()) file = file + path.sep;
return file;
})
- .compact()
- .value();
+ .filter(function(file) {
+ return Boolean(file);
+ });
});
-};
-
-// Load a JSON/JS file
-NodeFS.prototype.loadAsObject = function(filename) {
- return Promise()
- .then(function() {
- var jsFile;
+}
- try {
- jsFile = require.resolve(filename);
+function fsLoadObject(filename) {
+ return require(filename);
+}
- // Invalidate node.js cache for livreloading
- delete require.cache[jsFile];
+module.exports = function createNodeFS(root) {
+ return FS.create({
+ root: root,
- return require(jsFile);
- }
- catch(err) {
- return Promise.reject(err);
- }
+ fsExists: fs.exists,
+ fsReadFile: fs.readFile,
+ fsStatFile: fs.stat,
+ fsReadDir: fsReadDir,
+ fsLoadObject: fsLoadObject
});
};
-
-module.exports = NodeFS;
diff --git a/lib/gitbook.js b/lib/gitbook.js
index 54513c1..bafd3b8 100644
--- a/lib/gitbook.js
+++ b/lib/gitbook.js
@@ -6,8 +6,13 @@ var VERSION_STABLE = VERSION.replace(/\-(\S+)/g, '');
var START_TIME = new Date();
-// Verify that this gitbook version satisfies a requirement
-// We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha)
+/**
+ Verify that this gitbook version satisfies a requirement
+ We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha)
+
+ @param {String} condition
+ @return {Boolean}
+*/
function satisfies(condition) {
// Test with real version
if (semver.satisfies(VERSION, condition)) return true;
@@ -16,18 +21,8 @@ function satisfies(condition) {
return semver.satisfies(VERSION_STABLE, condition);
}
-// Return templating/json context for gitbook itself
-function getContext() {
- return {
- gitbook: {
- version: pkg.version,
- time: START_TIME
- }
- };
-}
-
module.exports = {
version: pkg.version,
satisfies: satisfies,
- getContext: getContext
+ START_TIME: START_TIME
};
diff --git a/lib/index.js b/lib/index.js
index fdad6ee..1f683e2 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,7 +1,10 @@
-var Book = require('./book');
-var cli = require('./cli');
+var extend = require('extend');
-module.exports = {
- Book: Book,
- commands: cli.commands
-};
+var common = require('./browser');
+
+module.exports = extend({
+ initBook: require('./init'),
+ createNodeFS: require('./fs/node'),
+ Output: require('./output'),
+ commands: require('./cli')
+}, common);
diff --git a/lib/init.js b/lib/init.js
index b7bb7f5..3e3cdca 100644
--- a/lib/init.js
+++ b/lib/init.js
@@ -1,66 +1,79 @@
var path = require('path');
+var createNodeFS = require('./fs/node');
var fs = require('./utils/fs');
var Promise = require('./utils/promise');
+var File = require('./models/file');
+var Readme = require('./models/readme');
+var Book = require('./models/book');
+var Parse = require('./parse');
-// Initialize folder structure for a book
-// Read SUMMARY to created the right chapter
-function initBook(book) {
- var extensionToUse = '.md';
+/**
+ Initialize folder structure for a book
+ Read SUMMARY to created the right chapter
- book.log.info.ln('init book at', book.root);
- return fs.mkdirp(book.root)
- .then(function() {
- return book.config.load();
- })
- .then(function() {
- book.log.info.ln('detect structure from SUMMARY (if it exists)');
- return book.summary.load();
- })
- .then(function() {
- var summary = book.summary.path || 'SUMMARY.md';
- var articles = book.summary.flatten();
+ @param {Book}
+ @param {String}
+ @return {Promise}
+*/
+function initBook(rootFolder) {
+ var extension = '.md';
+
+ return fs.mkdirp(rootFolder)
- // Use extension of summary
- extensionToUse = path.extname(summary);
+ // Parse the summary and readme
+ .then(function() {
+ var fs = createNodeFS(rootFolder);
+ var book = Book.createForFS(fs);
- // Readme doesn't have a path
- if (!articles[0].path) {
- articles[0].path = 'README' + extensionToUse;
- }
+ return Parse.parseReadme(book)
- // Summary doesn't exists? create one
- if (!book.summary.path) {
- articles.push({
- title: 'Summary',
- path: 'SUMMARY'+extensionToUse
- });
- }
+ // Setup default readme if doesn't found one
+ .fail(function() {
+ var readmeFile = File.createWithFilepath('README' + extension);
+ var readme = Readme.create(readmeFile);
+ return book.setReadme(readme);
+ });
+ })
+ .then(Parse.parseSummary)
- // Create files that don't exist
- return Promise.serie(articles, function(article) {
- if (!article.path) return;
+ .then(function(book) {
+ var logger = book.getLogger();
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile();
+ var summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension);
- var absolutePath = book.resolve(article.path);
+ var articles = summary.getArticlesAsList();
- return fs.exists(absolutePath)
- .then(function(exists) {
- if(exists) {
- book.log.info.ln('found', article.path);
- return;
- } else {
- book.log.info.ln('create', article.path);
- }
+ // Write pages
+ return Promise.forEach(articles, function(article) {
+ var filePath = path.join(rootFolder, article.getPath());
+ if (!filePath) return;
- return fs.mkdirp(path.dirname(absolutePath))
+ return fs.assertFile(filePath, function() {
+ return fs.ensureFile(filePath)
.then(function() {
- return fs.writeFile(absolutePath, '# '+article.title+'\n\n');
+ logger.info.ln('create', article.getPath());
+ return fs.writeFile(filePath, '# ' + article.getTitle() + '\n\n');
});
});
+ })
+
+ // Write summary
+ .then(function() {
+ var filePath = path.join(rootFolder, summaryFilename);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ logger.info.ln('create ' + path.basename(filePath));
+ return fs.writeFile(filePath, summary.toText(extension));
+ });
+ })
+
+ // Log end
+ .then(function() {
+ logger.info.ln('initialization is finished');
});
- })
- .then(function() {
- book.log.info.ln('initialization is finished');
});
}
diff --git a/lib/json/encodeBook.js b/lib/json/encodeBook.js
new file mode 100644
index 0000000..9bcb6ee
--- /dev/null
+++ b/lib/json/encodeBook.js
@@ -0,0 +1,35 @@
+var extend = require('extend');
+
+var gitbook = require('../gitbook');
+var encodeSummary = require('./encodeSummary');
+var encodeGlossary = require('./encodeGlossary');
+var encodeReadme = require('./encodeReadme');
+
+/**
+ Encode a book to JSON
+
+ @param {Book}
+ @return {Object}
+*/
+function encodeBookToJson(book) {
+ var config = book.getConfig();
+ var language = book.getLanguage();
+
+ var variables = config.getValue('variables', {});
+
+ return {
+ summary: encodeSummary(book.getSummary()),
+ glossary: encodeGlossary(book.getGlossary()),
+ readme: encodeReadme(book.getReadme()),
+ config: book.getConfig().getValues().toJS(),
+ gitbook: {
+ version: gitbook.version,
+ time: gitbook.START_TIME
+ },
+ book: extend({
+ language: language? language : undefined
+ }, variables.toJS())
+ };
+}
+
+module.exports = encodeBookToJson;
diff --git a/lib/json/encodeBookWithPage.js b/lib/json/encodeBookWithPage.js
new file mode 100644
index 0000000..5600a82
--- /dev/null
+++ b/lib/json/encodeBookWithPage.js
@@ -0,0 +1,22 @@
+var encodeBook = require('./encodeBook');
+var encodePage = require('./encodePage');
+var encodeFile = require('./encodeFile');
+
+/**
+ Return a JSON representation of a book with a specific file
+
+ @param {Book} output
+ @param {Page} page
+ @return {Object}
+*/
+function encodeBookWithPage(book, page) {
+ var file = page.getFile();
+
+ var result = encodeBook(book);
+ result.page = encodePage(page, book.getSummary());
+ result.file = encodeFile(file);
+
+ return result;
+}
+
+module.exports = encodeBookWithPage;
diff --git a/lib/json/encodeFile.js b/lib/json/encodeFile.js
new file mode 100644
index 0000000..d2c9e8a
--- /dev/null
+++ b/lib/json/encodeFile.js
@@ -0,0 +1,21 @@
+
+/**
+ Return a JSON representation of a file
+
+ @param {File} file
+ @return {Object}
+*/
+function encodeFileToJson(file) {
+ var filePath = file.getPath();
+ if (!filePath) {
+ return undefined;
+ }
+
+ return {
+ path: filePath,
+ mtime: file.getMTime(),
+ type: file.getType()
+ };
+}
+
+module.exports = encodeFileToJson;
diff --git a/lib/json/encodeGlossary.js b/lib/json/encodeGlossary.js
new file mode 100644
index 0000000..e9bcfc9
--- /dev/null
+++ b/lib/json/encodeGlossary.js
@@ -0,0 +1,21 @@
+var encodeFile = require('./encodeFile');
+var encodeGlossaryEntry = require('./encodeGlossaryEntry');
+
+/**
+ Encode a glossary to JSON
+
+ @param {Glossary}
+ @return {Object}
+*/
+function encodeGlossary(glossary) {
+ var file = glossary.getFile();
+ var entries = glossary.getEntries();
+
+ return {
+ file: encodeFile(file),
+ entries: entries
+ .map(encodeGlossaryEntry).toJS()
+ };
+}
+
+module.exports = encodeGlossary;
diff --git a/lib/json/encodeGlossaryEntry.js b/lib/json/encodeGlossaryEntry.js
new file mode 100644
index 0000000..d163f45
--- /dev/null
+++ b/lib/json/encodeGlossaryEntry.js
@@ -0,0 +1,16 @@
+
+/**
+ Encode a SummaryArticle to JSON
+
+ @param {GlossaryEntry}
+ @return {Object}
+*/
+function encodeGlossaryEntry(entry) {
+ return {
+ id: entry.getID(),
+ name: entry.getName(),
+ description: entry.getDescription()
+ };
+}
+
+module.exports = encodeGlossaryEntry;
diff --git a/lib/json/encodeOutput.js b/lib/json/encodeOutput.js
new file mode 100644
index 0000000..9054124
--- /dev/null
+++ b/lib/json/encodeOutput.js
@@ -0,0 +1,25 @@
+var encodeBook = require('./encodeBook');
+
+/**
+ Encode an output to JSON
+
+ @param {Output}
+ @return {Object}
+*/
+function encodeOutputToJson(output) {
+ var book = output.getBook();
+ var generator = output.getGenerator();
+ var options = output.getOptions();
+
+ var result = encodeBook(book);
+
+ result.output = {
+ name: generator
+ };
+
+ result.options = options.toJS();
+
+ return result;
+}
+
+module.exports = encodeOutputToJson;
diff --git a/lib/json/encodePage.js b/lib/json/encodePage.js
new file mode 100644
index 0000000..be92117
--- /dev/null
+++ b/lib/json/encodePage.js
@@ -0,0 +1,39 @@
+var encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ Return a JSON representation of a page
+
+ @param {Page} page
+ @param {Summary} summary
+ @return {Object}
+*/
+function encodePage(page, summary) {
+ var file = page.getFile();
+ var attributes = page.getAttributes();
+ var article = summary.getByPath(file.getPath());
+
+ var result = attributes.toJS();
+
+ if (article) {
+ result.title = article.getTitle();
+ result.level = article.getLevel();
+ result.depth = article.getDepth();
+
+ var nextArticle = summary.getNextArticle(article);
+ if (nextArticle) {
+ result.next = encodeSummaryArticle(nextArticle);
+ }
+
+ var prevArticle = summary.getPrevArticle(article);
+ if (prevArticle) {
+ result.previous = encodeSummaryArticle(prevArticle);
+ }
+ }
+
+ result.content = page.getContent();
+ result.dir = page.getDir();
+
+ return result;
+}
+
+module.exports = encodePage;
diff --git a/lib/json/encodeReadme.js b/lib/json/encodeReadme.js
new file mode 100644
index 0000000..96176a3
--- /dev/null
+++ b/lib/json/encodeReadme.js
@@ -0,0 +1,17 @@
+var encodeFile = require('./encodeFile');
+
+/**
+ Encode a readme to JSON
+
+ @param {Readme}
+ @return {Object}
+*/
+function encodeReadme(readme) {
+ var file = readme.getFile();
+
+ return {
+ file: encodeFile(file)
+ };
+}
+
+module.exports = encodeReadme;
diff --git a/lib/json/encodeSummary.js b/lib/json/encodeSummary.js
new file mode 100644
index 0000000..97db910
--- /dev/null
+++ b/lib/json/encodeSummary.js
@@ -0,0 +1,20 @@
+var encodeFile = require('./encodeFile');
+var encodeSummaryPart = require('./encodeSummaryPart');
+
+/**
+ Encode a summary to JSON
+
+ @param {Summary}
+ @return {Object}
+*/
+function encodeSummary(summary) {
+ var file = summary.getFile();
+ var parts = summary.getParts();
+
+ return {
+ file: encodeFile(file),
+ parts: parts.map(encodeSummaryPart).toJS()
+ };
+}
+
+module.exports = encodeSummary;
diff --git a/lib/json/encodeSummaryArticle.js b/lib/json/encodeSummaryArticle.js
new file mode 100644
index 0000000..987e44a
--- /dev/null
+++ b/lib/json/encodeSummaryArticle.js
@@ -0,0 +1,27 @@
+
+/**
+ Encode a SummaryArticle to JSON
+
+ @param {SummaryArticle}
+ @return {Object}
+*/
+function encodeSummaryArticle(article, recursive) {
+ var articles = undefined;
+ if (recursive !== false) {
+ articles = article.getArticles()
+ .map(encodeSummaryArticle)
+ .toJS();
+ }
+
+ return {
+ title: article.getTitle(),
+ level: article.getLevel(),
+ depth: article.getDepth(),
+ anchor: article.getAnchor(),
+ url: article.getUrl(),
+ path: article.getPath(),
+ articles: articles
+ };
+}
+
+module.exports = encodeSummaryArticle;
diff --git a/lib/json/encodeSummaryPart.js b/lib/json/encodeSummaryPart.js
new file mode 100644
index 0000000..a5e7218
--- /dev/null
+++ b/lib/json/encodeSummaryPart.js
@@ -0,0 +1,17 @@
+var encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ Encode a SummaryPart to JSON
+
+ @param {SummaryPart}
+ @return {Object}
+*/
+function encodeSummaryPart(part) {
+ return {
+ title: part.getTitle(),
+ articles: part.getArticles()
+ .map(encodeSummaryArticle).toJS()
+ };
+}
+
+module.exports = encodeSummaryPart;
diff --git a/lib/json/index.js b/lib/json/index.js
new file mode 100644
index 0000000..39cac99
--- /dev/null
+++ b/lib/json/index.js
@@ -0,0 +1,11 @@
+
+module.exports = {
+ encodeOutput: require('./encodeOutput'),
+ encodeBookWithPage: require('./encodeBookWithPage'),
+ encodeBook: require('./encodeBook'),
+ encodeFile: require('./encodeFile'),
+ encodePage: require('./encodePage'),
+ encodeSummary: require('./encodeSummary'),
+ encodeSummaryArticle: require('./encodeSummaryArticle'),
+ encodeReadme: require('./encodeReadme')
+};
diff --git a/lib/models/__tests__/config.js b/lib/models/__tests__/config.js
new file mode 100644
index 0000000..8445cef
--- /dev/null
+++ b/lib/models/__tests__/config.js
@@ -0,0 +1,63 @@
+jest.autoMockOff();
+
+var Immutable = require('immutable');
+
+describe('Config', function() {
+ var Config = require('../config');
+
+ var config = Config.createWithValues({
+ hello: {
+ world: 1,
+ test: 'Hello',
+ isFalse: false
+ }
+ });
+
+ describe('getValue', function() {
+ it('must return value as immutable', function() {
+ var value = config.getValue('hello');
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ });
+
+ it('must return deep value', function() {
+ var value = config.getValue('hello.world');
+ expect(value).toBe(1);
+ });
+
+ it('must return default value if non existant', function() {
+ var value = config.getValue('hello.nonExistant', 'defaultValue');
+ expect(value).toBe('defaultValue');
+ });
+
+ it('must not return default value for falsy values', function() {
+ var value = config.getValue('hello.isFalse', 'defaultValue');
+ expect(value).toBe(false);
+ });
+ });
+
+ describe('setValue', function() {
+ it('must set value as immutable', function() {
+ var testConfig = config.setValue('hello', {
+ 'cool': 1
+ });
+ var value = testConfig.getValue('hello');
+
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ expect(value.size).toBe(1);
+ expect(value.has('cool')).toBeTruthy();
+ });
+
+ it('must set deep value', function() {
+ var testConfig = config.setValue('hello.world', 2);
+ var hello = testConfig.getValue('hello');
+ var world = testConfig.getValue('hello.world');
+
+ expect(Immutable.Map.isMap(hello)).toBeTruthy();
+ expect(hello.size).toBe(3);
+
+ expect(world).toBe(2);
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/glossary.js b/lib/models/__tests__/glossary.js
new file mode 100644
index 0000000..2ce224c
--- /dev/null
+++ b/lib/models/__tests__/glossary.js
@@ -0,0 +1,42 @@
+jest.autoMockOff();
+
+describe('Glossary', function() {
+ var File = require('../file');
+ var Glossary = require('../glossary');
+ var GlossaryEntry = require('../glossaryEntry');
+
+ var glossary = Glossary.createFromEntries(File(), [
+ {
+ name: 'Hello World',
+ description: 'Awesome!'
+ },
+ {
+ name: 'JavaScript',
+ description: 'This is a cool language'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all entries', function() {
+ var entries = glossary.getEntries();
+ expect(entries.size).toBe(2);
+ });
+
+ it('must add entries as GlossaryEntries', function() {
+ var entries = glossary.getEntries();
+ var entry = entries.get('hello-world');
+ expect(entry instanceof GlossaryEntry).toBeTruthy();
+ });
+ });
+
+ describe('toText', function() {
+ pit('return as markdown', function() {
+ return glossary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Glossary');
+ });
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/glossaryEntry.js b/lib/models/__tests__/glossaryEntry.js
new file mode 100644
index 0000000..9eabc68
--- /dev/null
+++ b/lib/models/__tests__/glossaryEntry.js
@@ -0,0 +1,17 @@
+jest.autoMockOff();
+
+describe('GlossaryEntry', function() {
+ var GlossaryEntry = require('../glossaryEntry');
+
+ describe('getID', function() {
+ it('must return a normalized ID', function() {
+ var entry = new GlossaryEntry({
+ name: 'Hello World'
+ });
+
+ expect(entry.getID()).toBe('hello-world');
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/plugin.js b/lib/models/__tests__/plugin.js
new file mode 100644
index 0000000..81d9d51
--- /dev/null
+++ b/lib/models/__tests__/plugin.js
@@ -0,0 +1,29 @@
+jest.autoMockOff();
+
+describe('Plugin', function() {
+ var Plugin = require('../plugin');
+
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse version', function() {
+ var plugin = Plugin.createFromString('hello@1.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('1.0.0');
+ });
+ });
+
+ describe('isLoaded', function() {
+ it('must return false for empty plugin', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.isLoaded()).toBe(false);
+ });
+
+ });
+});
+
+
diff --git a/lib/models/__tests__/summary.js b/lib/models/__tests__/summary.js
new file mode 100644
index 0000000..ad040cf
--- /dev/null
+++ b/lib/models/__tests__/summary.js
@@ -0,0 +1,81 @@
+
+describe('Summary', function() {
+ var File = require('../file');
+ var Summary = require('../summary');
+
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: 'My First Article',
+ path: 'README.md'
+ },
+ {
+ title: 'My Second Article',
+ path: 'article.md'
+ }
+ ]
+ },
+ {
+ title: 'Test'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all parts', function() {
+ var parts = summary.getParts();
+ expect(parts.size).toBe(2);
+ });
+ });
+
+ describe('getByLevel', function() {
+ it('can return a Part', function() {
+ var part = summary.getByLevel('1');
+
+ expect(part).toBeDefined();
+ expect(part.getArticles().size).toBe(2);
+ });
+
+ it('can return a Part (2)', function() {
+ var part = summary.getByLevel('2');
+
+ expect(part).toBeDefined();
+ expect(part.getTitle()).toBe('Test');
+ expect(part.getArticles().size).toBe(0);
+ });
+
+ it('can return an Article', function() {
+ var article = summary.getByLevel('1.1');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+ });
+
+ describe('getByPath', function() {
+ it('return correct article', function() {
+ var article = summary.getByPath('README.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+
+ it('return correct article', function() {
+ var article = summary.getByPath('article.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My Second Article');
+ });
+ });
+
+ describe('toText', function() {
+ pit('return as markdown', function() {
+ return summary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Summary');
+ });
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/templateBlock.js b/lib/models/__tests__/templateBlock.js
new file mode 100644
index 0000000..44d53de
--- /dev/null
+++ b/lib/models/__tests__/templateBlock.js
@@ -0,0 +1,106 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+var Promise = require('../../utils/promise');
+
+describe('TemplateBlock', function() {
+ var TemplateBlock = require('../templateBlock');
+
+ describe('create', function() {
+ pit('must initialize a simple TemplateBlock from a function', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<p>Hello, World!</p>';
+ });
+
+ // Check basic templateBlock properties
+ expect(templateBlock.getName()).toBe('sayhello');
+ expect(templateBlock.getPost()).toBeNull();
+ expect(templateBlock.getParse()).toBeTruthy();
+ expect(templateBlock.getEndTag()).toBe('endsayhello');
+ expect(templateBlock.getBlocks().size).toBe(0);
+ expect(templateBlock.getShortcuts().size).toBe(0);
+ expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension');
+
+ // Check result of applying block
+ return Promise()
+ .then(function() {
+ return templateBlock.applyBlock();
+ })
+ .then(function(result) {
+ expect(result.name).toBe('sayhello');
+ expect(result.body).toBe('<p>Hello, World!</p>');
+ });
+ });
+ });
+
+ describe('toNunjucksExt()', function() {
+ pit('must create a valid nunjucks extension', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<p>Hello, World!</p>';
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, World!</p>');
+ });
+ });
+
+ pit('must apply block arguments correctly', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<'+block.kwargs.tag+'>Hello, '+block.kwargs.name+'!</'+block.kwargs.tag+'>';
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, Samy!</p>');
+ });
+ });
+
+ pit('must handle nested blocks', function() {
+ var templateBlock = new TemplateBlock({
+ name: 'yoda',
+ blocks: Immutable.List(['start', 'end']),
+ process: function(block) {
+ var nested = {};
+
+ block.blocks.forEach(function(blk) {
+ nested[blk.name] = blk.body.trim();
+ });
+
+ return '<p class="yoda">'+nested.end+' '+nested.start+'</p>';
+ }
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p class="yoda">inverted this sentence should be</p>');
+ });
+ });
+ });
+}); \ No newline at end of file
diff --git a/lib/models/__tests__/templateEngine.js b/lib/models/__tests__/templateEngine.js
new file mode 100644
index 0000000..6f18b18
--- /dev/null
+++ b/lib/models/__tests__/templateEngine.js
@@ -0,0 +1,51 @@
+
+describe('TemplateBlock', function() {
+ var TemplateEngine = require('../templateEngine');
+
+ describe('create', function() {
+ it('must initialize with a list of filters', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must initialize with a list of globals', function() {
+ var engine = TemplateEngine.create({
+ globals: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ hello("Luke") }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must pass context to filters and blocks', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + ' ' + this.lastName + '!';
+ }
+ },
+ context: {
+ lastName: 'Skywalker'
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke Skywalker!');
+ });
+ });
+}); \ No newline at end of file
diff --git a/lib/models/book.js b/lib/models/book.js
new file mode 100644
index 0000000..f960df1
--- /dev/null
+++ b/lib/models/book.js
@@ -0,0 +1,258 @@
+var path = require('path');
+var Immutable = require('immutable');
+var Ignore = require('ignore');
+
+var Logger = require('../utils/logger');
+
+var FS = require('./fs');
+var Config = require('./config');
+var Readme = require('./readme');
+var Summary = require('./summary');
+var Glossary = require('./glossary');
+var Languages = require('./languages');
+
+
+var Book = Immutable.Record({
+ // Logger for outptu message
+ logger: Logger(),
+
+ // Filesystem binded to the book scope to read files/directories
+ fs: FS(),
+
+ // Ignore files parser
+ ignore: Ignore(),
+
+ // Structure files
+ config: Config(),
+ readme: Readme(),
+ summary: Summary(),
+ glossary: Glossary(),
+ languages: Languages(),
+
+ // ID of the language for language books
+ language: String(),
+
+ // List of children, if multilingual (String -> Book)
+ books: Immutable.OrderedMap()
+});
+
+Book.prototype.getLogger = function() {
+ return this.get('logger');
+};
+
+Book.prototype.getFS = function() {
+ return this.get('fs');
+};
+
+Book.prototype.getIgnore = function() {
+ return this.get('ignore');
+};
+
+Book.prototype.getConfig = function() {
+ return this.get('config');
+};
+
+Book.prototype.getReadme = function() {
+ return this.get('readme');
+};
+
+Book.prototype.getSummary = function() {
+ return this.get('summary');
+};
+
+Book.prototype.getGlossary = function() {
+ return this.get('glossary');
+};
+
+Book.prototype.getLanguages = function() {
+ return this.get('languages');
+};
+
+Book.prototype.getBooks = function() {
+ return this.get('books');
+};
+
+Book.prototype.getLanguage = function() {
+ return this.get('language');
+};
+
+/**
+ Return FS instance to access the content
+
+ @return {FS}
+*/
+Book.prototype.getContentFS = function() {
+ var fs = this.getFS();
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ return FS.reduceScope(fs, rootFolder);
+ }
+
+ return fs;
+};
+
+/**
+ Return root of the book
+
+ @return {String}
+*/
+Book.prototype.getRoot = function() {
+ var fs = this.getFS();
+ return fs.getRoot();
+};
+
+/**
+ Return root for content of the book
+
+ @return {String}
+*/
+Book.prototype.getContentRoot = function() {
+ var fs = this.getContentFS();
+ return fs.getRoot();
+};
+
+/**
+ Check if a file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isFileIgnored = function(filename) {
+ var ignore = this.getIgnore();
+ var language = this.getLanguage();
+
+ // Ignore is always relative to the root of the main book
+ if (language) {
+ filename = path.join(language, filename);
+ }
+
+
+ return ignore.filter([filename]).length == 0;
+};
+
+/**
+ Check if a content file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isContentFileIgnored = function(filename) {
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ filename = path.join(rootFolder, filename);
+ }
+
+ return this.isFileIgnored(filename);
+};
+
+/**
+ Return a page from a book by its path
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.getPage = function(ref) {
+ return this.getPages().get(ref);
+};
+
+/**
+ Is this book the parent of language's books
+
+ @return {Boolean}
+*/
+Book.prototype.isMultilingual = function() {
+ return (this.getLanguages().getCount() > 0);
+};
+
+/**
+ Return true if book is associated to a language
+
+ @return {Boolean}
+*/
+Book.prototype.isLanguageBook = function() {
+ return Boolean(this.getLanguage());
+};
+
+/**
+ Add a new language book
+
+ @param {String} language
+ @param {Book} book
+ @return {Book}
+*/
+Book.prototype.addLanguageBook = function(language, book) {
+ var books = this.getBooks();
+ books = books.set(language, book);
+
+ return this.set('books', books);
+};
+
+/**
+ Set the summary for this book
+
+ @param {Summary}
+ @return {Book}
+*/
+Book.prototype.setSummary = function(summary) {
+ return this.set('summary', summary);
+};
+
+/**
+ Set the readme for this book
+
+ @param {Readme}
+ @return {Book}
+*/
+Book.prototype.setReadme = function(readme) {
+ return this.set('readme', readme);
+};
+
+/**
+ Change log level
+
+ @param {String} level
+ @return {Book}
+*/
+Book.prototype.setLogLevel = function(level) {
+ this.getLogger().setLevel(level);
+ return this;
+};
+
+/**
+ Create a book using a filesystem
+
+ @param {FS} fs
+ @return {Book}
+*/
+Book.createForFS = function createForFS(fs) {
+ return new Book({
+ fs: fs
+ });
+};
+
+/**
+ Create a language book from a parent
+
+ @param {Book} parent
+ @param {String} language
+ @return {Book}
+*/
+Book.createFromParent = function createFromParent(parent, language) {
+ var ignore = parent.getIgnore();
+
+ return new Book({
+ // Inherits config. logegr and list of ignored files
+ logger: parent.getLogger(),
+ config: parent.getConfig(),
+ ignore: Ignore().add(ignore),
+
+ language: language,
+ fs: FS.reduceScope(parent.getContentFS(), language)
+ });
+};
+
+module.exports = Book;
diff --git a/lib/models/config.js b/lib/models/config.js
new file mode 100644
index 0000000..6ee03e4
--- /dev/null
+++ b/lib/models/config.js
@@ -0,0 +1,106 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var File = require('./file');
+var configDefault = require('../constants/configDefault');
+
+var Config = Immutable.Record({
+ file: File(),
+ values: configDefault
+}, 'Config');
+
+Config.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Config.prototype.getValues = function() {
+ return this.get('values');
+};
+
+/**
+ Return a configuration value by its key path
+
+ @param {String} key
+ @return {Mixed}
+*/
+Config.prototype.getValue = function(keyPath, def) {
+ var values = this.getValues();
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ if (!values.hasIn(keyPath)) {
+ return Immutable.fromJS(def);
+ }
+
+ return values.getIn(keyPath);
+};
+
+/**
+ Update a configuration value
+
+ @param {String} key
+ @param {Mixed} value
+ @return {Mixed}
+*/
+Config.prototype.setValue = function(keyPath, value) {
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ value = Immutable.fromJS(value);
+
+ var values = this.getValues();
+ values = values.setIn(keyPath, value);
+
+ return this.set('values', values);
+};
+
+/**
+ Create a new config for a file
+
+ @param {File} file
+ @param {Object} values
+ @returns {Config}
+*/
+Config.create = function(file, values) {
+ return new Config({
+ file: file,
+ values: Immutable.fromJS(values)
+ });
+};
+
+/**
+ Create a new config
+
+ @param {Object} values
+ @returns {Config}
+*/
+Config.createWithValues = function(values) {
+ return new Config({
+ values: Immutable.fromJS(values)
+ });
+};
+
+/**
+ Update values for an existing configuration
+
+ @param {Config} config
+ @param {Object} values
+ @returns {Config}
+*/
+Config.updateValues = function(config, values) {
+ values = Immutable.fromJS(values);
+
+ return config.set('values', values);
+};
+
+
+/**
+ Convert a keyPath to an array of keys
+
+ @param {String|Array}
+ @return {Array}
+*/
+Config.keyToKeyPath = function(keyPath) {
+ if (is.string(keyPath)) keyPath = keyPath.split('.');
+ return keyPath;
+};
+
+module.exports = Config;
diff --git a/lib/models/file.js b/lib/models/file.js
new file mode 100644
index 0000000..d1726a7
--- /dev/null
+++ b/lib/models/file.js
@@ -0,0 +1,89 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var parsers = require('../parsers');
+
+var File = Immutable.Record({
+ // Path of the file, relative to the FS
+ path: String(),
+
+ // Time when file data last modified
+ mtime: Date()
+});
+
+File.prototype.getPath = function() {
+ return this.get('path');
+};
+
+File.prototype.getMTime = function() {
+ return this.get('mtime');
+};
+
+/**
+ Does the file exists / is set
+
+ @return {Boolean}
+*/
+File.prototype.exists = function() {
+ return Boolean(this.getPath());
+};
+
+/**
+ Return type of file ('markdown' or 'asciidoc')
+
+ @return {String}
+*/
+File.prototype.getType = function() {
+ var parser = this.getParser();
+ if (parser) {
+ return parser.name;
+ } else {
+ return undefined;
+ }
+};
+
+/**
+ Return extension of this file (lowercased)
+
+ @return {String}
+*/
+File.prototype.getExtension = function() {
+ return path.extname(this.getPath()).toLowerCase();
+};
+
+/**
+ Return parser for this file
+
+ @return {Parser}
+*/
+File.prototype.getParser = function() {
+ return parsers.getByExt(this.getExtension());
+};
+
+/**
+ Create a file from stats informations
+
+ @param {String} filepath
+ @param {Object|fs.Stats} stat
+ @return {File}
+*/
+File.createFromStat = function createFromStat(filepath, stat) {
+ return new File({
+ path: filepath,
+ mtime: stat.mtime
+ });
+};
+
+/**
+ Create a file with only a path
+
+ @param {String} filepath
+ @return {File}
+*/
+File.createWithFilepath = function createWithFilepath(filepath) {
+ return new File({
+ path: filepath
+ });
+};
+
+module.exports = File;
diff --git a/lib/models/fs.js b/lib/models/fs.js
new file mode 100644
index 0000000..ab65dd5
--- /dev/null
+++ b/lib/models/fs.js
@@ -0,0 +1,274 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var File = require('./file');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var PathUtil = require('../utils/path');
+
+var FS = Immutable.Record({
+ root: String(),
+
+ fsExists: Function(),
+ fsReadFile: Function(),
+ fsStatFile: Function(),
+ fsReadDir: Function(),
+ fsLoadObject: null
+});
+
+/**
+ Return path to the root
+
+ @return {String}
+*/
+FS.prototype.getRoot = function() {
+ return this.get('root');
+};
+
+/**
+ Verify that a file is in the fs scope
+
+ @param {String} filename
+ @return {Boolean}
+*/
+FS.prototype.isInScope = function(filename) {
+ var rootPath = this.getRoot();
+ filename = path.join(rootPath, filename);
+
+ return PathUtil.isInRoot(rootPath, filename);
+};
+
+/**
+ Resolve a file in this FS
+
+ @param {String}
+ @return {String}
+*/
+FS.prototype.resolve = function() {
+ var rootPath = this.getRoot();
+ var args = Array.prototype.slice.call(arguments);
+ var filename = path.join.apply(path, [rootPath].concat(args));
+ filename = path.normalize(filename);
+
+ if (!this.isInScope(filename)) {
+ throw error.FileOutOfScopeError({
+ filename: filename,
+ root: this.root
+ });
+ }
+
+ return filename;
+};
+
+/**
+ Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
+
+ @param {String} filename
+ @return {Promise<Boolean>}
+*/
+FS.prototype.exists = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var exists = that.get('fsExists');
+
+ return exists(filename);
+ });
+};
+
+/**
+ Read a file and returns a promise with the content as a buffer
+
+ @param {String} filename
+ @return {Promise<Buffer>}
+*/
+FS.prototype.read = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var read = that.get('fsReadFile');
+
+ return read(filename);
+ });
+};
+
+/**
+ Read a file as a string (utf-8)
+
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.readAsString = function(filename, encoding) {
+ encoding = encoding || 'utf8';
+
+ return this.read(filename)
+ .then(function(buf) {
+ return buf.toString(encoding);
+ });
+};
+
+/**
+ Read stat infos about a file
+
+ @param {String} filename
+ @return {Promise<File>}
+*/
+FS.prototype.statFile = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var filepath = that.resolve(filename);
+ var stat = that.get('fsStatFile');
+
+ return stat(filepath);
+ })
+ .then(function(stat) {
+ return File.createFromStat(filename, stat);
+ });
+};
+
+/**
+ List files/directories in a directory.
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.readDir = function(dirname) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var dirpath = that.resolve(dirname);
+ var readDir = that.get('fsReadDir');
+
+ return readDir(dirpath);
+ })
+ .then(function(files) {
+ return Immutable.List(files);
+ });
+};
+
+/**
+ List only files in a diretcory
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listFiles = function(dirname) {
+ return this.readDir(dirname)
+ .then(function(files) {
+ return files.filterNot(pathIsFolder);
+ });
+};
+
+/**
+ List all files in a directory
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listAllFiles = function(folder) {
+ var that = this;
+ folder = folder || '.';
+
+ return this.readDir(folder)
+ .then(function(files) {
+ return Promise.reduce(files, function(out, file) {
+ var isDirectory = pathIsFolder(file);
+
+ if (!isDirectory) {
+ return out.push(path.join(folder, file));
+ }
+
+ return that.listAllFiles(path.join(folder, file))
+ .then(function(inner) {
+ return out.concat(inner);
+ });
+ }, Immutable.List());
+ });
+};
+
+/**
+ Find a file in a folder (case incensitive)
+ Return the found filename
+
+ @param {String} dirname
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.findFile = function(dirname, filename) {
+ return this.listFiles(dirname)
+ .then(function(files) {
+ return files.find(function(file) {
+ return (file.toLowerCase() == filename.toLowerCase());
+ });
+ });
+};
+
+/**
+ Load a JSON file
+ By default, fs only supports JSON
+
+ @param {String} filename
+ @return {Promise<Object>}
+*/
+FS.prototype.loadAsObject = function(filename) {
+ var that = this;
+ var fsLoadObject = this.get('fsLoadObject');
+
+ return this.exists(filename)
+ .then(function(exists) {
+ if (!exists) {
+ var err = new Error('Module doesn\'t exist');
+ err.code = 'MODULE_NOT_FOUND';
+
+ throw err;
+ }
+
+ if (fsLoadObject) {
+ return fsLoadObject(that.resolve(filename));
+ } else {
+ return that.readAsString(filename)
+ .then(function(str) {
+ return JSON.parse(str);
+ });
+ }
+ });
+};
+
+/**
+ Create a FS instance
+
+ @param {Object} def
+ @return {FS}
+*/
+FS.create = function create(def) {
+ return new FS(def);
+};
+
+/**
+ Create a new FS instance with a reduced scope
+
+ @param {FS} fs
+ @param {String} scope
+ @return {FS}
+*/
+FS.reduceScope = function reduceScope(fs, scope) {
+ return fs.set('root', path.join(fs.getRoot(), scope));
+};
+
+
+// .readdir return files/folder as a list of string, folder ending with '/'
+function pathIsFolder(filename) {
+ var lastChar = filename[filename.length - 1];
+ return lastChar == '/' || lastChar == '\\';
+}
+
+module.exports = FS; \ No newline at end of file
diff --git a/lib/models/glossary.js b/lib/models/glossary.js
new file mode 100644
index 0000000..bb4407d
--- /dev/null
+++ b/lib/models/glossary.js
@@ -0,0 +1,109 @@
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var File = require('./file');
+var GlossaryEntry = require('./glossaryEntry');
+var parsers = require('../parsers');
+
+var Glossary = Immutable.Record({
+ file: File(),
+ entries: Immutable.OrderedMap()
+});
+
+Glossary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Glossary.prototype.getEntries = function() {
+ return this.get('entries');
+};
+
+/**
+ Return an entry by its name
+
+ @param {String} name
+ @return {GlossaryEntry}
+*/
+Glossary.prototype.getEntry = function(name) {
+ var entries = this.getEntries();
+ var id = GlossaryEntry.nameToID(name);
+
+ return entries.get(id);
+};
+
+/**
+ Render glossary as text
+
+ @return {Promise<String>}
+*/
+Glossary.prototype.toText = function(parser) {
+ var file = this.getFile();
+ var entries = this.getEntries();
+
+ parser = parser? parsers.getByExt(parser) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.glossary.toText(entries.toJS());
+};
+
+
+/**
+ Add/Replace an entry to a glossary
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntry = function addEntry(glossary, entry) {
+ var id = entry.getID();
+ var entries = glossary.getEntries();
+
+ entries = entries.set(id, entry);
+ return glossary.set('entries', entries);
+};
+
+/**
+ Add/Replace an entry to a glossary by name/description
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntryByName = function addEntryByName(glossary, name, description) {
+ var entry = new GlossaryEntry({
+ name: name,
+ description: description
+ });
+
+ return Glossary.addEntry(glossary, entry);
+};
+
+/**
+ Create a glossary from a list of entries
+
+ @param {String} filename
+ @param {Array|List} entries
+ @return {Glossary}
+*/
+Glossary.createFromEntries = function createFromEntries(file, entries) {
+ entries = entries.map(function(entry) {
+ if (!(entry instanceof GlossaryEntry)) {
+ entry = new GlossaryEntry(entry);
+ }
+
+ return [entry.getID(), entry];
+ });
+
+ return new Glossary({
+ file: file,
+ entries: Immutable.OrderedMap(entries)
+ });
+};
+
+
+module.exports = Glossary;
diff --git a/lib/models/glossaryEntry.js b/lib/models/glossaryEntry.js
new file mode 100644
index 0000000..10791db
--- /dev/null
+++ b/lib/models/glossaryEntry.js
@@ -0,0 +1,43 @@
+var Immutable = require('immutable');
+var slug = require('github-slugid');
+
+/*
+ A definition represents an entry in the glossary
+*/
+
+var GlossaryEntry = Immutable.Record({
+ name: String(),
+ description: String()
+});
+
+GlossaryEntry.prototype.getName = function() {
+ return this.get('name');
+};
+
+GlossaryEntry.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+
+/**
+ Get identifier for this entry
+
+ @retrun {Boolean}
+*/
+GlossaryEntry.prototype.getID = function() {
+ return GlossaryEntry.nameToID(this.getName());
+};
+
+
+/**
+ Normalize a glossary entry name into a unique id
+
+ @param {String}
+ @return {String}
+*/
+GlossaryEntry.nameToID = function nameToID(name) {
+ return slug(name);
+};
+
+
+module.exports = GlossaryEntry;
diff --git a/lib/models/language.js b/lib/models/language.js
new file mode 100644
index 0000000..dcefbf6
--- /dev/null
+++ b/lib/models/language.js
@@ -0,0 +1,21 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Language = Immutable.Record({
+ title: String(),
+ path: String()
+});
+
+Language.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Language.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Language.prototype.getID = function() {
+ return path.basename(this.getPath());
+};
+
+module.exports = Language;
diff --git a/lib/models/languages.js b/lib/models/languages.js
new file mode 100644
index 0000000..1e58d88
--- /dev/null
+++ b/lib/models/languages.js
@@ -0,0 +1,71 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+var Language = require('./language');
+
+var Languages = Immutable.Record({
+ file: File(),
+ list: Immutable.OrderedMap()
+});
+
+Languages.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Languages.prototype.getList = function() {
+ return this.get('list');
+};
+
+/**
+ Get default languages
+
+ @return {Language}
+*/
+Languages.prototype.getDefaultLanguage = function() {
+ return this.getList().first();
+};
+
+/**
+ Get a language by its ID
+
+ @param {String} lang
+ @return {Language}
+*/
+Languages.prototype.getLanguage = function(lang) {
+ return this.getList().get(lang);
+};
+
+/**
+ Return count of langs
+
+ @return {Number}
+*/
+Languages.prototype.getCount = function() {
+ return this.getList().size;
+};
+
+/**
+ Create a languages list from a JS object
+
+ @param {File}
+ @param {Array}
+ @return {Language}
+*/
+Languages.createFromList = function(file, langs) {
+ var list = Immutable.OrderedMap();
+
+ langs.forEach(function(lang) {
+ lang = Language({
+ title: lang.title,
+ path: lang.path
+ });
+ list = list.set(lang.getID(), lang);
+ });
+
+ return Languages({
+ file: file,
+ list: list
+ });
+};
+
+module.exports = Languages;
diff --git a/lib/models/output.js b/lib/models/output.js
new file mode 100644
index 0000000..43e36f8
--- /dev/null
+++ b/lib/models/output.js
@@ -0,0 +1,93 @@
+var Immutable = require('immutable');
+
+var Book = require('./book');
+
+var Output = Immutable.Record({
+ book: Book(),
+
+ // Name of the generator being used
+ generator: String(),
+
+ // Map of plugins to use (String -> Plugin)
+ plugins: Immutable.OrderedMap(),
+
+ // Map pages to generation (String -> Page)
+ pages: Immutable.OrderedMap(),
+
+ // List assets (String)
+ assets: Immutable.List(),
+
+ // Option for the generation
+ options: Immutable.Map(),
+
+ // Internal state for the generation
+ state: Immutable.Map()
+});
+
+Output.prototype.getBook = function() {
+ return this.get('book');
+};
+
+Output.prototype.getGenerator = function() {
+ return this.get('generator');
+};
+
+Output.prototype.getPlugins = function() {
+ return this.get('plugins');
+};
+
+Output.prototype.getPages = function() {
+ return this.get('pages');
+};
+
+Output.prototype.getOptions = function() {
+ return this.get('options');
+};
+
+Output.prototype.getAssets = function() {
+ return this.get('assets');
+};
+
+Output.prototype.getState = function() {
+ return this.get('state');
+};
+
+/**
+ Get root folder for output
+
+ @return {String}
+*/
+Output.prototype.getRoot = function() {
+ return this.getOptions().get('root');
+};
+
+/**
+ Update state of output
+
+ @param {Map} newState
+ @return {Output}
+*/
+Output.prototype.setState = function(newState) {
+ return this.set('state', newState);
+};
+
+/**
+ Update options
+
+ @param {Map} newOptions
+ @return {Output}
+*/
+Output.prototype.setOptions = function(newOptions) {
+ return this.set('options', newOptions);
+};
+
+/**
+ Return logegr for this output (same as book)
+
+ @return {Logger}
+*/
+Output.prototype.getLogger = function() {
+ return this.getBook().getLogger();
+};
+
+module.exports = Output;
diff --git a/lib/models/page.js b/lib/models/page.js
new file mode 100644
index 0000000..ffb9601
--- /dev/null
+++ b/lib/models/page.js
@@ -0,0 +1,55 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+
+var Page = Immutable.Record({
+ file: File(),
+
+ // Attributes extracted from the YAML header
+ attributes: Immutable.Map(),
+
+ // Content of the page
+ content: String(),
+
+ // Direction of the text
+ dir: String('ltr')
+});
+
+Page.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Page.prototype.getAttributes = function() {
+ return this.get('attributes');
+};
+
+Page.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Page.prototype.getDir = function() {
+ return this.get('dir');
+};
+
+/**
+ Return path of the page
+
+ @return {String}
+*/
+Page.prototype.getPath = function() {
+ return this.getFile().getPath();
+};
+
+/**
+ Create a page for a file
+
+ @param {File} file
+ @return {Page}
+*/
+Page.createForFile = function(file) {
+ return new Page({
+ file: file
+ });
+};
+
+module.exports = Page;
diff --git a/lib/models/plugin.js b/lib/models/plugin.js
new file mode 100644
index 0000000..dd7bc90
--- /dev/null
+++ b/lib/models/plugin.js
@@ -0,0 +1,152 @@
+var Immutable = require('immutable');
+
+var TemplateBlock = require('./templateBlock');
+var PREFIX = require('../constants/pluginPrefix');
+var DEFAULT_VERSION = '*';
+
+var Plugin = Immutable.Record({
+ name: String(),
+
+ // Requirement version (ex: ">1.0.0")
+ version: String(DEFAULT_VERSION),
+
+ // Path to load this plugin
+ path: String(),
+
+ // Depth of this plugin in the dependency tree
+ depth: Number(0),
+
+ // Content of the "package.json"
+ package: Immutable.Map(),
+
+ // Content of the package itself
+ content: Immutable.Map()
+}, 'Plugin');
+
+Plugin.prototype.getName = function() {
+ return this.get('name');
+};
+
+Plugin.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Plugin.prototype.getVersion = function() {
+ return this.get('version');
+};
+
+Plugin.prototype.getPackage = function() {
+ return this.get('package');
+};
+
+Plugin.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Plugin.prototype.getDepth = function() {
+ return this.get('depth');
+};
+
+/**
+ Return the ID on NPM for this plugin
+
+ @return {String}
+*/
+Plugin.prototype.getNpmID = function() {
+ return Plugin.nameToNpmID(this.getName());
+};
+
+/**
+ Check if a plugin is loaded
+
+ @return {Boolean}
+*/
+Plugin.prototype.isLoaded = function() {
+ return Boolean(this.getPackage().size > 0);
+};
+
+/**
+ Return map of hooks
+ @return {Map<String:Function>}
+*/
+Plugin.prototype.getHooks = function() {
+ return this.getContent().get('hooks') || Immutable.Map();
+};
+
+/**
+ Return infos about resources for a specific type
+
+ @param {String} type
+ @return {Map<String:Mixed>}
+*/
+Plugin.prototype.getResources = function(type) {
+ if (type != 'website' && type != 'ebook') {
+ throw new Error('Invalid assets type ' + type);
+ }
+
+ var content = this.getContent();
+ return (content.get(type)
+ || (type == 'website'? content.get('book') : null)
+ || Immutable.Map());
+};
+
+/**
+ Return map of filters
+ @return {Map<String:Function>}
+*/
+Plugin.prototype.getFilters = function() {
+ return this.getContent().get('filters');
+};
+
+/**
+ Return map of blocks
+ @return {Map<String:TemplateBlock>}
+*/
+Plugin.prototype.getBlocks = function() {
+ var blocks = this.getContent().get('blocks');
+ blocks = blocks || Immutable.Map();
+
+ return blocks
+ .map(function(block, blockName) {
+ return TemplateBlock.create(blockName, block);
+ });
+};
+
+/**
+ Return a specific hook
+
+ @param {String} name
+ @return {Function|undefined}
+*/
+Plugin.prototype.getHook = function(name) {
+ return this.getHooks().get(name);
+};
+
+/**
+ Create a plugin from a string
+
+ @param {String}
+ @return {Plugin}
+*/
+Plugin.createFromString = function(s) {
+ var parts = s.split('@');
+ var name = parts[0];
+ var version = parts.slice(1).join('@');
+
+ return new Plugin({
+ name: name,
+ version: version || DEFAULT_VERSION
+ });
+};
+
+/**
+ Return NPM id for a plugin name
+
+ @param {String}
+ @return {String}
+*/
+Plugin.nameToNpmID = function(s) {
+ return PREFIX + s;
+};
+
+module.exports = Plugin;
diff --git a/lib/models/readme.js b/lib/models/readme.js
new file mode 100644
index 0000000..c655c82
--- /dev/null
+++ b/lib/models/readme.js
@@ -0,0 +1,40 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+
+var Readme = Immutable.Record({
+ file: File(),
+ title: String(),
+ description: String()
+});
+
+Readme.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Readme.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Readme.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+/**
+ Create a new readme
+
+ @param {File} file
+ @param {Object} def
+ @return {Readme}
+*/
+Readme.create = function(file, def) {
+ def = def || {};
+
+ return new Readme({
+ file: file,
+ title: def.title || '',
+ description: def.description || ''
+ });
+};
+
+module.exports = Readme;
diff --git a/lib/models/summary.js b/lib/models/summary.js
new file mode 100644
index 0000000..5314bb0
--- /dev/null
+++ b/lib/models/summary.js
@@ -0,0 +1,190 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var LocationUtils = require('../utils/location');
+var File = require('./file');
+var SummaryPart = require('./summaryPart');
+var SummaryArticle = require('./summaryArticle');
+var parsers = require('../parsers');
+
+var Summary = Immutable.Record({
+ file: File(),
+ parts: Immutable.List()
+}, 'Summary');
+
+Summary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Summary.prototype.getParts = function() {
+ return this.get('parts');
+};
+
+/**
+ Return a part by its index
+
+ @param {Number}
+ @return {Part}
+*/
+Summary.prototype.getPart = function(i) {
+ var parts = this.getParts();
+ return parts.get(i);
+};
+
+/**
+ Return an article using an iterator to find it.
+ if "partIter" is set, it can also return a Part.
+
+ @param {Function} iter
+ @param {Function} partIter
+ @return {Article|Part}
+*/
+Summary.prototype.getArticle = function(iter, partIter) {
+ var parts = this.getParts();
+
+ return parts.reduce(function(result, part) {
+ if (result) return result;
+
+ if (partIter && partIter(part)) return part;
+ return SummaryArticle.findArticle(part, iter);
+ }, null);
+};
+
+
+/**
+ Return a part/article by its level
+
+ @param {String} level
+ @return {Article}
+*/
+Summary.prototype.getByLevel = function(level) {
+ function iterByLevel(article) {
+ return (article.getLevel() === level);
+ }
+
+ return this.getArticle(iterByLevel, iterByLevel);
+};
+
+/**
+ Return an article by its path
+
+ @param {String} filePath
+ @return {Article}
+*/
+Summary.prototype.getByPath = function(filePath) {
+ return this.getArticle(function(article) {
+ return (LocationUtils.areIdenticalPaths(article.getPath(), filePath));
+ });
+};
+
+/**
+ Return the first article
+
+ @return {Article}
+*/
+Summary.prototype.getFirstArticle = function() {
+ return this.getArticle(function(article) {
+ return true;
+ });
+};
+
+/**
+ Return next article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getNextArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var wasPrev = false;
+
+ return this.getArticle(function(article) {
+ if (wasPrev) return true;
+
+ wasPrev = article.getLevel() == level;
+ return false;
+ });
+};
+
+/**
+ Return previous article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getPrevArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var prev = undefined;
+
+ this.getArticle(function(article) {
+ if (article.getLevel() == level) {
+ return true;
+ }
+
+ prev = article;
+ return false;
+ });
+
+ return prev;
+};
+
+/**
+ Render summary as text
+
+ @return {Promise<String>}
+*/
+Summary.prototype.toText = function(parser) {
+ var file = this.getFile();
+ var parts = this.getParts();
+
+ parser = parser? parsers.getByExt(parser) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.summary.toText({
+ parts: parts.toJS()
+ });
+};
+
+/**
+ Return all articles as a list
+
+ @return {List<Article>}
+*/
+Summary.prototype.getArticlesAsList = function() {
+ var accu = [];
+
+ this.getArticle(function(article) {
+ accu.push(article);
+ });
+
+ return Immutable.List(accu);
+};
+
+/**
+ Create a new summary for a list of parts
+
+ @param {Lust|Array} parts
+ @return {Summary}
+*/
+Summary.createFromParts = function createFromParts(file, parts) {
+ parts = parts.map(function(part, i) {
+ if (part instanceof SummaryPart) {
+ return part;
+ }
+
+ return SummaryPart.create(part, i + 1);
+ });
+
+ return new Summary({
+ file: file,
+ parts: new Immutable.List(parts)
+ });
+};
+
+module.exports = Summary;
diff --git a/lib/models/summaryArticle.js b/lib/models/summaryArticle.js
new file mode 100644
index 0000000..da82790
--- /dev/null
+++ b/lib/models/summaryArticle.js
@@ -0,0 +1,150 @@
+var Immutable = require('immutable');
+
+var location = require('../utils/location');
+
+/*
+ An article represents an entry in the Summary / table of Contents
+*/
+
+var SummaryArticle = Immutable.Record({
+ level: String(),
+ title: String(),
+ ref: String(),
+ articles: Immutable.List()
+}, 'SummaryArticle');
+
+SummaryArticle.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryArticle.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryArticle.prototype.getRef = function() {
+ return this.get('ref');
+};
+
+SummaryArticle.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ Return how deep the article is
+
+ @return {Number}
+*/
+SummaryArticle.prototype.getDepth = function() {
+ return this.getLevel().split('.').length;
+};
+
+/**
+ Get path (without anchor) to the pointing file
+
+ @return {String}
+*/
+SummaryArticle.prototype.getPath = function() {
+ if (this.isExternal()) {
+ return undefined;
+ }
+
+ var ref = this.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+
+ var parts = ref.split('#');
+
+ var pathname = (parts.length > 1? parts.slice(0, -1).join('#') : ref);
+
+ // Normalize path to remove ('./', etc)
+ return location.normalize(pathname);
+};
+
+/**
+ Return url if article is external
+
+ @return {String}
+*/
+SummaryArticle.prototype.getUrl = function() {
+ return this.isExternal()? this.getRef() : undefined;
+};
+
+/**
+ Get anchor for this article (or undefined)
+
+ @return {String}
+*/
+SummaryArticle.prototype.getAnchor = function() {
+ var ref = this.getRef();
+ var parts = ref.split('#');
+
+ var anchor = (parts.length > 1? '#' + parts[parts.length - 1] : undefined);
+ return anchor;
+};
+
+/**
+ Is article pointing to a page of an absolute url
+
+ @return {Boolean}
+*/
+SummaryArticle.prototype.isPage = function() {
+ return !this.isExternal() && this.getRef();
+};
+
+/**
+ Is article pointing to aan absolute url
+
+ @return {Boolean}
+*/
+SummaryArticle.prototype.isExternal = function() {
+ return location.isExternal(this.getRef());
+};
+
+/**
+ Create a SummaryArticle
+
+ @param {Object} def
+ @return {SummaryArticle}
+*/
+SummaryArticle.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryArticle({
+ level: level,
+ title: def.title,
+ ref: def.ref || def.path || '',
+ articles: Immutable.List(articles)
+ });
+};
+
+
+/**
+ Find an article from a base one
+
+ @param {Article|Part} base
+ @param {Function(article)} iter
+ @return {Article}
+*/
+SummaryArticle.findArticle = function(base, iter) {
+ var articles = base.getArticles();
+
+ return articles.reduce(function(result, article) {
+ if (result) return result;
+
+ if (iter(article)) {
+ return article;
+ }
+
+ return SummaryArticle.findArticle(article, iter);
+ }, null);
+};
+
+
+module.exports = SummaryArticle;
diff --git a/lib/models/summaryPart.js b/lib/models/summaryPart.js
new file mode 100644
index 0000000..f7a82ce
--- /dev/null
+++ b/lib/models/summaryPart.js
@@ -0,0 +1,48 @@
+var Immutable = require('immutable');
+
+var SummaryArticle = require('./summaryArticle');
+
+/*
+ A part represents a section in the Summary / table of Contents
+*/
+
+var SummaryPart = Immutable.Record({
+ level: String(),
+ title: String(),
+ articles: Immutable.List()
+});
+
+SummaryPart.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryPart.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryPart.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ Create a SummaryPart
+
+ @param {Object} def
+ @return {SummaryPart}
+*/
+SummaryPart.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryPart({
+ level: String(level),
+ title: def.title,
+ articles: Immutable.List(articles)
+ });
+};
+
+module.exports = SummaryPart;
diff --git a/lib/models/templateBlock.js b/lib/models/templateBlock.js
new file mode 100644
index 0000000..4e47da7
--- /dev/null
+++ b/lib/models/templateBlock.js
@@ -0,0 +1,310 @@
+var is = require('is');
+var extend = require('extend');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var genKey = require('../utils/genKey');
+
+var NODE_ENDARGS = '%%endargs%%';
+
+var blockBodies = {};
+
+var TemplateBlock = Immutable.Record({
+ // Name of block, also the start tag
+ name: String(),
+
+ // End tag, default to "end<name>"
+ end: String(),
+
+ // Function to process the block content
+ process: Function(),
+
+ // List of String, for inner block tags
+ blocks: Immutable.List(),
+
+ // List of shortcuts to replace with this block
+ shortcuts: Immutable.List(),
+
+ // Function to execute in post processing
+ post: null,
+
+ parse: true
+}, 'TemplateBlock');
+
+TemplateBlock.prototype.getName = function() {
+ return this.get('name');
+};
+
+TemplateBlock.prototype.getPost = function() {
+ return this.get('post');
+};
+
+TemplateBlock.prototype.getParse = function() {
+ return this.get('parse');
+};
+
+TemplateBlock.prototype.getEndTag = function() {
+ return this.get('end') || ('end' + this.getName());
+};
+
+TemplateBlock.prototype.getProcess = function() {
+ return this.get('process');
+};
+
+TemplateBlock.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+TemplateBlock.prototype.getShortcuts = function() {
+ return this.get('shortcuts');
+};
+
+/**
+ Return name for the nunjucks extension
+
+ @return {String}
+*/
+TemplateBlock.prototype.getExtensionName = function() {
+ return 'Block' + this.getName() + 'Extension';
+};
+
+/**
+ Return a nunjucks extension to represents this block
+
+ @return {Nunjucks.Extension}
+*/
+TemplateBlock.prototype.toNunjucksExt = function(mainContext) {
+ var that = this;
+ var name = this.getName();
+ var endTag = this.getEndTag();
+ var blocks = this.getBlocks().toJS();
+
+ function Ext() {
+ this.tags = [name];
+
+ this.parse = function(parser, nodes) {
+ var lastBlockName = null;
+ var lastBlockArgs = null;
+ var allBlocks = blocks.concat([endTag]);
+
+ // Parse first block
+ var tok = parser.nextToken();
+ lastBlockArgs = parser.parseSignature(null, true);
+ parser.advanceAfterBlockEnd(tok.value);
+
+ var args = new nodes.NodeList();
+ var bodies = [];
+ var blockNamesNode = new nodes.Array(tok.lineno, tok.colno);
+ var blockArgCounts = new nodes.Array(tok.lineno, tok.colno);
+
+ // Parse while we found "end<block>"
+ do {
+ // Read body
+ var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks);
+
+ // Handle body with previous block name and args
+ blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName));
+ blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length));
+ bodies.push(currentBody);
+
+ // Append arguments of this block as arguments of the run function
+ lastBlockArgs.children.forEach(function(child) {
+ args.addChild(child);
+ });
+
+ // Read new block
+ lastBlockName = parser.nextToken().value;
+
+ // Parse signature and move to the end of the block
+ if (lastBlockName != endTag) {
+ lastBlockArgs = parser.parseSignature(null, true);
+ }
+
+ parser.advanceAfterBlockEnd(lastBlockName);
+ } while (lastBlockName != endTag);
+
+ args.addChild(blockNamesNode);
+ args.addChild(blockArgCounts);
+ args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS));
+
+ return new nodes.CallExtensionAsync(this, 'run', args, bodies);
+ };
+
+ this.run = function(context) {
+ var fnArgs = Array.prototype.slice.call(arguments, 1);
+
+ var args;
+ var blocks = [];
+ var bodies = [];
+ var blockNames;
+ var blockArgCounts;
+ var callback;
+
+ // Extract callback
+ callback = fnArgs.pop();
+
+ // Detect end of arguments
+ var endArgIndex = fnArgs.indexOf(NODE_ENDARGS);
+
+ // Extract arguments and bodies
+ args = fnArgs.slice(0, endArgIndex);
+ bodies = fnArgs.slice(endArgIndex + 1);
+
+ // Extract block counts
+ blockArgCounts = args.pop();
+ blockNames = args.pop();
+
+ // Recreate list of blocks
+ blockNames.forEach(function(name, i) {
+ var countArgs = blockArgCounts[i];
+ var blockBody = bodies.shift();
+
+ var blockArgs = countArgs > 0? args.slice(0, countArgs) : [];
+ args = args.slice(countArgs);
+ var blockKwargs = extractKwargs(blockArgs);
+
+ blocks.push({
+ name: name,
+ body: blockBody(),
+ args: blockArgs,
+ kwargs: blockKwargs
+ });
+ });
+
+ var mainBlock = blocks.shift();
+ mainBlock.blocks = blocks;
+
+ Promise()
+ .then(function() {
+ var ctx = extend({
+ ctx: context
+ }, mainContext || {});
+
+ return that.applyBlock(mainBlock, ctx);
+ })
+ .then(function(result) {
+ return that.blockResultToHtml(result);
+ })
+ .nodeify(callback);
+ };
+ };
+
+ return Ext;
+};
+
+/**
+ Apply a block to a content
+ @param {Object} inner
+ @param {Object} context
+ @return {Promise<String>|String}
+*/
+TemplateBlock.prototype.applyBlock = function(inner, context) {
+ var processFn = this.getProcess();
+
+ inner = inner || {};
+ inner.args = inner.args || [];
+ inner.kwargs = inner.kwargs || {};
+ inner.blocks = inner.blocks || [];
+
+ var r = processFn.call(context, inner);
+
+ if (Promise.isPromiseAlike(r)) {
+ return r.then(this.handleBlockResult);
+ } else {
+ return this.handleBlockResult(r);
+ }
+};
+
+/**
+ Handle result from a block process function
+
+ @param {Object} result
+ @return {Object}
+*/
+TemplateBlock.prototype.handleBlockResult = function(result) {
+ if (is.string(result)) {
+ result = { body: result };
+ }
+ result.name = this.getName();
+
+ return result;
+};
+
+/**
+ Convert a block result to HTML
+
+ @param {Object} result
+ @return {String}
+*/
+TemplateBlock.prototype.blockResultToHtml = function(result) {
+ var parse = this.getParse();
+ var indexedKey;
+ var toIndex = (!parse) || (this.getPost() !== undefined);
+
+ if (toIndex) {
+ indexedKey = TemplateBlock.indexBlockResult(result);
+ }
+
+ // Parsable block, just return it
+ if (parse) {
+ return result.body;
+ }
+
+ // Return it as a position marker
+ return '{{-%' + indexedKey + '%-}}';
+
+};
+
+/**
+ Index a block result, and return the indexed key
+
+ @param {Object} blk
+ @return {String}
+*/
+TemplateBlock.indexBlockResult = function(blk) {
+ var key = genKey();
+ blockBodies[key] = blk;
+
+ return key;
+};
+
+/**
+ Get a block results indexed for a specific key
+
+ @param {String} key
+ @return {Object|undefined}
+*/
+TemplateBlock.getBlockResultByKey = function(key) {
+ return blockBodies[key];
+};
+
+/**
+ Create a template block from a function or an object
+
+ @param {String} blockName
+ @param {Object} block
+ @return {TemplateBlock}
+*/
+TemplateBlock.create = function(blockName, block) {
+ if (is.fn(block)) {
+ block = new Immutable.Map({
+ process: block
+ });
+ }
+
+ block = block.set('name', blockName);
+ return new TemplateBlock(block);
+};
+
+/**
+ Extract kwargs from an arguments array
+
+ @param {Array} args
+ @return {Object}
+*/
+function extractKwargs(args) {
+ var last = args[args.length - 1];
+ return (is.object(last) && last.__keywords)? args.pop() : {};
+}
+
+module.exports = TemplateBlock;
diff --git a/lib/models/templateEngine.js b/lib/models/templateEngine.js
new file mode 100644
index 0000000..243bfc6
--- /dev/null
+++ b/lib/models/templateEngine.js
@@ -0,0 +1,139 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+
+var TemplateEngine = Immutable.Record({
+ // Map of {TemplateBlock}
+ blocks: Immutable.Map(),
+
+ // Map of Extension
+ extensions: Immutable.Map(),
+
+ // Map of filters: {String} name -> {Function} fn
+ filters: Immutable.Map(),
+
+ // Map of globals: {String} name -> {Mixed}
+ globals: Immutable.Map(),
+
+ // Context for filters / blocks
+ context: Object(),
+
+ // Nunjucks loader
+ loader: nunjucks.FileSystemLoader('views')
+}, 'TemplateEngine');
+
+TemplateEngine.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+TemplateEngine.prototype.getGlobals = function() {
+ return this.get('globals');
+};
+
+TemplateEngine.prototype.getFilters = function() {
+ return this.get('filters');
+};
+
+TemplateEngine.prototype.getShortcuts = function() {
+ return this.get('shortcuts');
+};
+
+TemplateEngine.prototype.getLoader = function() {
+ return this.get('loader');
+};
+
+TemplateEngine.prototype.getContext = function() {
+ return this.get('context');
+};
+
+TemplateEngine.prototype.getExtensions = function() {
+ return this.get('extensions');
+};
+
+/**
+ Return a block by its name (or undefined)
+
+ @param {String} name
+ @return {TemplateBlock}
+*/
+TemplateEngine.prototype.getBlock = function(name) {
+ var blocks = this.getBlocks();
+ return blocks.find(function(block) {
+ return block.getName() === name;
+ });
+};
+
+/**
+ Return a nunjucks environment from this configuration
+
+ @return {Nunjucks.Environment}
+*/
+TemplateEngine.prototype.toNunjucks = function() {
+ var loader = this.getLoader();
+ var blocks = this.getBlocks();
+ var filters = this.getFilters();
+ var globals = this.getGlobals();
+ var extensions = this.getExtensions();
+ var context = this.getContext();
+
+ var env = new nunjucks.Environment(
+ loader,
+ {
+ // Escaping is done after by the asciidoc/markdown parser
+ autoescape: false,
+
+ // Syntax
+ tags: {
+ blockStart: '{%',
+ blockEnd: '%}',
+ variableStart: '{{',
+ variableEnd: '}}',
+ commentStart: '{###',
+ commentEnd: '###}'
+ }
+ }
+ );
+
+ // Add filters
+ filters.forEach(function(filterFn, filterName) {
+ env.addFilter(filterName, filterFn.bind(context));
+ });
+
+ // Add blocks
+ blocks.forEach(function(block) {
+ var extName = block.getExtensionName();
+ var Ext = block.toNunjucksExt(context);
+
+ env.addExtension(extName, new Ext());
+ });
+
+ // Add globals
+ globals.forEach(function(globalValue, globalName) {
+ env.addGlobal(globalName, globalValue);
+ });
+
+ // Add other extensions
+ extensions.forEach(function(ext, extName) {
+ env.addExtension(extName, ext);
+ });
+
+ return env;
+};
+
+/**
+ Create a template engine
+
+ @param {Object} def
+ @return {TemplateEngine}
+*/
+TemplateEngine.create = function(def) {
+ return new TemplateEngine({
+ blocks: Immutable.List(def.blocks || []),
+ extensions: Immutable.Map(def.extensions || {}),
+ filters: Immutable.Map(def.filters || {}),
+ globals: Immutable.Map(def.globals || {}),
+ context: def.context,
+ loader: def.loader
+ });
+};
+
+module.exports = TemplateEngine;
diff --git a/lib/modifiers/index.js b/lib/modifiers/index.js
new file mode 100644
index 0000000..ed09e31
--- /dev/null
+++ b/lib/modifiers/index.js
@@ -0,0 +1,4 @@
+
+module.exports = {
+ Summary: require('./summary')
+};
diff --git a/lib/modifiers/summary/__tests__/editArticle.js b/lib/modifiers/summary/__tests__/editArticle.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/modifiers/summary/__tests__/editArticle.js
diff --git a/lib/modifiers/summary/__tests__/editPartTitle.js b/lib/modifiers/summary/__tests__/editPartTitle.js
new file mode 100644
index 0000000..d1b916b
--- /dev/null
+++ b/lib/modifiers/summary/__tests__/editPartTitle.js
@@ -0,0 +1,44 @@
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('editPartTitle', function() {
+ var editPartTitle = require('../editPartTitle');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: 'My First Article',
+ path: 'README.md'
+ },
+ {
+ title: 'My Second Article',
+ path: 'article.md'
+ }
+ ]
+ },
+ {
+ title: 'Test'
+ }
+ ]);
+
+ it('should correctly set title of first part', function() {
+ var newSummary = editPartTitle(summary, 0, 'Hello World');
+ var part = newSummary.getPart(0);
+
+ expect(part.getTitle()).toBe('Hello World');
+ });
+
+ it('should correctly set title of second part', function() {
+ var newSummary = editPartTitle(summary, 1, 'Hello');
+ var part = newSummary.getPart(1);
+
+ expect(part.getTitle()).toBe('Hello');
+ });
+
+ it('should not fail if part doesn\'t exist', function() {
+ var newSummary = editPartTitle(summary, 3, 'Hello');
+ expect(newSummary.getParts().size).toBe(2);
+ });
+});
+
+
diff --git a/lib/modifiers/summary/editArticle.js b/lib/modifiers/summary/editArticle.js
new file mode 100644
index 0000000..1625398
--- /dev/null
+++ b/lib/modifiers/summary/editArticle.js
@@ -0,0 +1,70 @@
+
+/**
+ Edit a list of articles
+
+ @param {List<Article>} articles
+ @param {String} level
+ @param {Article} newArticle
+ @return {List<Article>}
+*/
+function editArticleInList(articles, level, newArticle) {
+ return articles.map(function(article) {
+ var articleLevel = article.getLevel();
+
+ if (articleLevel == level) {
+ return article.merge(newArticle);
+ }
+
+ if (level.indexOf(articleLevel) === 0) {
+ var articles = editArticleInList(article.getArticles(), level, newArticle);
+ return article.set('articles', articles);
+ }
+
+ return article;
+ });
+}
+
+
+/**
+ Edit an article in a part
+
+ @param {Part} part
+ @param {String} level
+ @param {Article} newArticle
+ @return {Part}
+*/
+function editArticleInPart(part, level, newArticle) {
+ var articles = part.getArticles();
+ articles = editArticleInList(articles);
+
+ return part.set('articles', articles);
+}
+
+
+/**
+ Edit an article in a summary
+
+ @param {Summary} summary
+ @param {String} level
+ @param {Article} newArticle
+ @return {Summary}
+*/
+function editArticle(summary, level, newArticle) {
+ var parts = summary.getParts();
+
+ var levelParts = level.split('.');
+ var partIndex = Number(levelParts[0]);
+
+ var part = parts.get(partIndex);
+ if (!part) {
+ return summary;
+ }
+
+ part = editArticleInPart(part, level, newArticle);
+ parts = parts.set(partIndex, part);
+
+ return summary.set('parts', parts);
+}
+
+
+module.exports = editArticle;
diff --git a/lib/modifiers/summary/editArticleTitle.js b/lib/modifiers/summary/editArticleTitle.js
new file mode 100644
index 0000000..bd9b6f2
--- /dev/null
+++ b/lib/modifiers/summary/editArticleTitle.js
@@ -0,0 +1,17 @@
+var editArticle = require('./editArticle');
+
+/**
+ Edit title of an article
+
+ @param {Summary} summary
+ @param {String} level
+ @param {String} newTitle
+ @return {Summary}
+*/
+function editArticleTitle(summary, level, newTitle) {
+ return editArticle(summary, level, {
+ title: newTitle
+ });
+}
+
+module.exports = editArticleTitle;
diff --git a/lib/modifiers/summary/editPartTitle.js b/lib/modifiers/summary/editPartTitle.js
new file mode 100644
index 0000000..472399b
--- /dev/null
+++ b/lib/modifiers/summary/editPartTitle.js
@@ -0,0 +1,24 @@
+
+/**
+ Edit title of a part in the summary
+
+ @param {Summary} summary
+ @param {Number} index
+ @param {String} newTitle
+ @return {Summary}
+*/
+function editPartTitle(summary, index, newTitle) {
+ var parts = summary.getParts();
+
+ var part = parts.get(index);
+ if (!part) {
+ return summary;
+ }
+
+ part = part.set('title', newTitle);
+ parts = parts.set(index, part);
+
+ return summary.set('parts', parts);
+}
+
+module.exports = editPartTitle;
diff --git a/lib/modifiers/summary/index.js b/lib/modifiers/summary/index.js
new file mode 100644
index 0000000..855d7cc
--- /dev/null
+++ b/lib/modifiers/summary/index.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ insertArticle: require('./insertArticle'),
+ unshiftArticle: require('./unshiftArticle'),
+
+ editPartTitle: require('./editPartTitle'),
+ editArticleTitle: require('./editArticleTitle')
+};
diff --git a/lib/modifiers/summary/indexArticleLevels.js b/lib/modifiers/summary/indexArticleLevels.js
new file mode 100644
index 0000000..f311f74
--- /dev/null
+++ b/lib/modifiers/summary/indexArticleLevels.js
@@ -0,0 +1,23 @@
+
+/**
+ Index levels in an article tree
+
+ @param {Article}
+ @param {String} baseLevel
+ @return {Article}
+*/
+function indexArticleLevels(article, baseLevel) {
+ baseLevel = baseLevel || article.getLevel();
+ var articles = article.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return article.merge({
+ level: baseLevel,
+ articles: articles
+ });
+}
+
+module.exports = indexArticleLevels;
diff --git a/lib/modifiers/summary/indexLevels.js b/lib/modifiers/summary/indexLevels.js
new file mode 100644
index 0000000..604e9ff
--- /dev/null
+++ b/lib/modifiers/summary/indexLevels.js
@@ -0,0 +1,17 @@
+var indexPartLevels = require('./indexPartLevels');
+
+/**
+ Index all levels in the summary
+
+ @param {Summary}
+ @return {Summary}
+*/
+function indexLevels(summary) {
+ var parts = summary.getParts();
+ parts = parts.map(indexPartLevels);
+
+ return summary.set('parts', parts);
+}
+
+
+module.exports = indexLevels;
diff --git a/lib/modifiers/summary/indexPartLevels.js b/lib/modifiers/summary/indexPartLevels.js
new file mode 100644
index 0000000..d19c70a
--- /dev/null
+++ b/lib/modifiers/summary/indexPartLevels.js
@@ -0,0 +1,24 @@
+var indexArticleLevels = require('./indexArticleLevels');
+
+/**
+ Index levels in a part
+
+ @param {Part}
+ @param {Number} index
+ @return {Part}
+*/
+function indexPartLevels(part, index) {
+ var baseLevel = String(index + 1);
+ var articles = part.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return part.merge({
+ level: baseLevel,
+ articles: articles
+ });
+}
+
+module.exports = indexPartLevels;
diff --git a/lib/modifiers/summary/insertArticle.js b/lib/modifiers/summary/insertArticle.js
new file mode 100644
index 0000000..ae920c2
--- /dev/null
+++ b/lib/modifiers/summary/insertArticle.js
@@ -0,0 +1,63 @@
+var is = require('is');
+var SummaryArticle = require('../../models/summaryArticle');
+var editArticle = require('./editArticle');
+var indexArticleLevels = require('./indexArticleLevels');
+
+
+/**
+ Get level of parent of an article
+
+ @param {String} level
+ @return {String}
+*/
+function getParentLevel(level) {
+ var parts = level.split('.');
+ return parts.slice(0, -1).join('.');
+}
+
+/**
+ Insert an article in a summary at a specific position
+
+ @param {Summary} summary
+ @param {String|Article} level: level to insert after
+ @param {Article} article
+ @return {Summary}
+*/
+function insertArticle(summary, level, article) {
+ article = SummaryArticle(article);
+ level = is.string(level)? level : level.getLevel();
+
+ var parentLevel = getParentLevel(level);
+
+ if (!parentLevel) {
+ // todo: insert new part
+ return summary;
+ }
+
+ // Get parent of the position
+ var parentArticle = summary.getByLevel(parentLevel);
+ if (!parentLevel) {
+ return summary;
+ }
+
+ // Find the index to insert at
+ var articles = parentArticle.getArticles();
+ var index = articles.findIndex(function(art) {
+ return art.getLevel() === level;
+ });
+ if (!index) {
+ return summary;
+ }
+
+ // Insert the article at the right index
+ articles = articles.insert(index, article);
+
+ // Reindex the level from here
+ parentArticle = parentArticle.set('articles', articles);
+ parentArticle = indexArticleLevels(parentArticle);
+
+ return editArticle(summary, parentLevel, parentArticle);
+
+}
+
+module.exports = insertArticle;
diff --git a/lib/modifiers/summary/unshiftArticle.js b/lib/modifiers/summary/unshiftArticle.js
new file mode 100644
index 0000000..3f2ae4d
--- /dev/null
+++ b/lib/modifiers/summary/unshiftArticle.js
@@ -0,0 +1,29 @@
+var SummaryArticle = require('../../models/summaryArticle');
+var SummaryPart = require('../../models/summaryPart');
+
+var indexLevels = require('./indexLevels');
+
+/**
+ Insert an article at the
+
+ @param {Summary} summary
+ @param {Article} article
+ @return {Summary}
+*/
+function unshiftArticle(summary, article) {
+ article = SummaryArticle(article);
+
+ var parts = summary.getParts();
+ var part = parts.get(0) || SummaryPart();
+
+ var articles = part.getArticles();
+ articles = articles.unshift(article);
+ part = part.set('articles', articles);
+
+ parts = parts.set(0, part);
+ summary = summary.set('parts', parts);
+
+ return indexLevels(summary);
+}
+
+module.exports = unshiftArticle;
diff --git a/lib/output/__tests__/ebook.js b/lib/output/__tests__/ebook.js
new file mode 100644
index 0000000..dabf360
--- /dev/null
+++ b/lib/output/__tests__/ebook.js
@@ -0,0 +1,16 @@
+var generateMock = require('../generateMock');
+var EbookGenerator = require('../ebook');
+
+describe('EbookGenerator', function() {
+
+ pit('should generate a SUMMARY.html', function() {
+ return generateMock(EbookGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('SUMMARY.html');
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/lib/output/__tests__/json.js b/lib/output/__tests__/json.js
new file mode 100644
index 0000000..94a0362
--- /dev/null
+++ b/lib/output/__tests__/json.js
@@ -0,0 +1,29 @@
+var generateMock = require('../generateMock');
+var JSONGenerator = require('../json');
+
+describe('JSONGenerator', function() {
+
+ pit('should generate a README.json', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ });
+ });
+
+ pit('should generate a json file for each articles', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ expect(folder).toHaveFile('test/page.json');
+ });
+ });
+});
+
diff --git a/lib/output/__tests__/website.js b/lib/output/__tests__/website.js
new file mode 100644
index 0000000..6b949a4
--- /dev/null
+++ b/lib/output/__tests__/website.js
@@ -0,0 +1,71 @@
+var generateMock = require('../generateMock');
+var WebsiteGenerator = require('../website');
+
+describe('WebsiteGenerator', function() {
+
+ pit('should generate an index.html', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ pit('should generate an HTML file for each articles', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ pit('should not generate file if entry file doesn\'t exist', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).not.toHaveFile('page.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ pit('should generate a multilingual book', function() {
+ return generateMock(WebsiteGenerator, {
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ })
+ .then(function(folder) {
+ // It should generate languages
+ expect(folder).toHaveFile('en/index.html');
+ expect(folder).toHaveFile('fr/index.html');
+
+ // Should not copy languages as assets
+ expect(folder).not.toHaveFile('en/README.md');
+ expect(folder).not.toHaveFile('fr/README.md');
+
+ // Should copy assets only once
+ expect(folder).toHaveFile('gitbook/style.css');
+ expect(folder).not.toHaveFile('en/gitbook/style.css');
+
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js
deleted file mode 100644
index 6f1f02d..0000000
--- a/lib/output/assets-inliner.js
+++ /dev/null
@@ -1,140 +0,0 @@
-var util = require('util');
-var path = require('path');
-var crc = require('crc');
-
-var FolderOutput = require('./folder')();
-var Promise = require('../utils/promise');
-var fs = require('../utils/fs');
-var imagesUtil = require('../utils/images');
-var location = require('../utils/location');
-
-var DEFAULT_ASSETS_FOLDER = 'assets';
-
-/*
-Mixin to inline all the assets in a book:
- - Outline <svg> tags
- - Download remote images
- - Convert .svg images as png
-*/
-
-module.exports = function assetsInliner(Base) {
- Base = Base || FolderOutput;
-
- function AssetsInliner() {
- Base.apply(this, arguments);
-
- // Map of svg already converted
- this.svgs = {};
- this.inlineSvgs = {};
-
- // Map of images already downloaded
- this.downloaded = {};
- }
- util.inherits(AssetsInliner, Base);
-
- // Output a SVG buffer as a file
- AssetsInliner.prototype.onOutputSVG = function(page, svg) {
- this.log.debug.ln('output svg from', page.path);
-
- // Convert svg buffer to a png file
- return this.convertSVGBuffer(svg)
-
- // Return relative path from the page
- .then(function(filename) {
- return page.relative('/' + filename);
- });
- };
-
-
- // Output an image as a file
- AssetsInliner.prototype.onOutputImage = function(page, src) {
- var that = this;
-
- return Promise()
-
- // Download file if external
- .then(function() {
- if (!location.isExternal(src)) return;
-
- return that.downloadAsset(src)
- .then(function(_asset) {
- src = '/' + _asset;
- });
-
- })
- .then(function() {
- // Resolve src to a relative filepath to the book's root
- src = page.resolveLocal(src);
-
- // Already a PNG/JPG/.. ?
- if (path.extname(src).toLowerCase() != '.svg') {
- return src;
- }
-
- // Convert SVG to PNG
- return that.convertSVGFile(that.resolve(src));
- })
-
- // Return relative path from the page
- .then(function(filename) {
- return page.relative(filename);
- });
- };
-
- // Download an asset if not already download; returns the output file
- AssetsInliner.prototype.downloadAsset = function(src) {
- if (this.downloaded[src]) return Promise(this.downloaded[src]);
-
- var that = this;
- var ext = path.extname(src);
- var hash = crc.crc32(src).toString(16);
-
- // Create new file
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + ext)
- .then(function(filename) {
- that.downloaded[src] = filename;
-
- that.log.debug.ln('downloading asset', src);
- return fs.download(src, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- // Convert a .svg into an .png
- // Return the output filename for the .png
- AssetsInliner.prototype.convertSVGFile = function(src) {
- if (this.svgs[src]) return Promise(this.svgs[src]);
-
- var that = this;
- var hash = crc.crc32(src).toString(16);
-
- // Create new file
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
- .then(function(filename) {
- that.svgs[src] = filename;
-
- return imagesUtil.convertSVGToPNG(src, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- // Convert an inline svg into an .png
- // Return the output filename for the .png
- AssetsInliner.prototype.convertSVGBuffer = function(buf) {
- var that = this;
- var hash = crc.crc32(buf).toString(16);
-
- // Already converted?
- if (this.inlineSvgs[hash]) return Promise(this.inlineSvgs[hash]);
-
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
- .then(function(filename) {
- that.inlineSvgs[hash] = filename;
-
- return imagesUtil.convertSVGBufferToPNG(buf, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- return AssetsInliner;
-};
diff --git a/lib/output/base.js b/lib/output/base.js
deleted file mode 100644
index 868b85b..0000000
--- a/lib/output/base.js
+++ /dev/null
@@ -1,309 +0,0 @@
-var _ = require('lodash');
-var Ignore = require('ignore');
-var path = require('path');
-
-var Promise = require('../utils/promise');
-var pathUtil = require('../utils/path');
-var location = require('../utils/location');
-var error = require('../utils/error');
-var PluginsManager = require('../plugins');
-var TemplateEngine = require('../template');
-var gitbook = require('../gitbook');
-
-/*
-Output is like a stream interface for a parsed book
-to output "something".
-
-The process is mostly on the behavior of "onPage" and "onAsset"
-*/
-
-function Output(book, opts, parent) {
- _.bindAll(this);
- this.parent = parent;
-
- this.opts = _.defaults({}, opts || {}, {
- directoryIndex: true
- });
-
- this.book = book;
- book.output = this;
- this.log = this.book.log;
-
- // Create plugins manager
- this.plugins = new PluginsManager(this.book);
-
- // Create template engine
- this.template = new TemplateEngine(this);
-
- // Files to ignore in output
- this.ignore = Ignore();
-
- // Hack to inherits from rules of the book
- this.ignore.add(this.book.ignore);
-}
-
-// Default name for generator
-Output.prototype.name = 'base';
-
-// Default extension for output
-Output.prototype.defaultExtension = '.html';
-
-// Start the generation, for a parsed book
-Output.prototype.generate = function() {
- var that = this;
- var isMultilingual = this.book.isMultilingual();
-
- return Promise()
-
- // Load all plugins
- .then(function() {
- return that.plugins.loadAll()
- .then(function() {
- that.template.addFilters(that.plugins.getFilters());
- that.template.addBlocks(that.plugins.getBlocks());
- });
- })
-
- // Transform the configuration
- .then(function() {
- return that.plugins.hook('config', that.book.config.dump())
- .then(function(cfg) {
- that.book.config.replace(cfg);
- });
- })
-
- // Initialize the generation
- .then(function() {
- return that.plugins.hook('init');
- })
- .then(function() {
- that.log.info.ln('preparing the generation');
- return that.prepare();
- })
-
- // Process all files
- .then(function() {
- that.log.debug.ln('listing files');
- return that.book.fs.listAllFiles(that.book.root);
- })
-
- // We want to process assets first, then pages
- // Since pages can have logic based on existance of assets
- .then(function(files) {
- // Split into pages/assets
- var byTypes = _.chain(files)
- .filter(that.ignore.createFilter())
-
- // Ignore file present in a language book
- .filter(function(filename) {
- return !(isMultilingual && that.book.isInLanguageBook(filename));
- })
-
- .groupBy(function(filename) {
- return (that.book.hasPage(filename)? 'page' : 'asset');
- })
-
- .value();
-
- return Promise.serie(byTypes.asset, function(filename) {
- that.log.debug.ln('copy asset', filename);
- return that.onAsset(filename);
- })
- .then(function() {
- return Promise.serie(byTypes.page, function(filename) {
- that.log.debug.ln('process page', filename);
- return that.onPage(that.book.getPage(filename));
- });
- });
- })
-
- // Generate sub-books
- .then(function() {
- if (!that.book.isMultilingual()) return;
-
- return Promise.serie(that.book.books, function(subbook) {
- that.log.info.ln('');
- that.log.info.ln('start generation of language "' + path.relative(that.book.root, subbook.root) + '"');
-
- var out = that.onLanguageBook(subbook);
- return out.generate();
- });
- })
-
- // Finish the generation
- .then(function() {
- return that.plugins.hook('finish:before');
- })
- .then(function() {
- that.log.debug.ln('finishing the generation');
- return that.finish();
- })
- .then(function() {
- return that.plugins.hook('finish');
- })
-
- .then(function() {
- if (!that.book.isLanguageBook()) that.log.info.ln('');
- that.log.info.ok('generation finished with success!');
- });
-};
-
-// Prepare the generation
-Output.prototype.prepare = function() {
- this.ignore.addPattern(_.compact([
- '.gitignore',
- '.ignore',
- '.bookignore',
- 'node_modules',
- '_layouts',
-
- // The configuration file should not be copied in the output
- '/' + this.book.config.path,
-
- // Structure file to ignore
- '/' + this.book.summary.path,
- '/' + this.book.langs.path
- ]));
-};
-
-// Write a page (parsable file), ex: markdown, etc
-Output.prototype.onPage = function(page) {
- return page.toHTML(this);
-};
-
-// Copy an asset file (non-parsable), ex: images, etc
-Output.prototype.onAsset = function(filename) {
-
-};
-
-// Finish the generation
-Output.prototype.finish = function() {
-
-};
-
-// Resolve an HTML link
-Output.prototype.onRelativeLink = function(currentPage, href) {
- var to = currentPage.followPage(href);
-
- // Replace by an .html link
- if (to) {
- href = to.path;
-
- // Change README path to be "index.html"
- if (href == this.book.readme.path) {
- href = 'index.html';
- }
-
- // Recalcul as relative link
- href = currentPage.relative(href);
-
- // Replace .md by .html
- href = this.toURL(href);
- }
-
- return href;
-};
-
-// Output a SVG buffer as a file
-Output.prototype.onOutputSVG = function(page, svg) {
- return null;
-};
-
-// Output an image as a file
-// Normalize the relative link
-Output.prototype.onOutputImage = function(page, imgFile) {
- if (location.isExternal(imgFile)) {
- return imgFile;
- }
-
- imgFile = page.resolveLocal(imgFile);
- return page.relative(imgFile);
-};
-
-// Read a template by its source URL
-Output.prototype.onGetTemplate = function(sourceUrl) {
- throw new Error('template not found '+sourceUrl);
-};
-
-// Generate a source URL for a template
-Output.prototype.onResolveTemplate = function(from, to) {
- return path.resolve(path.dirname(from), to);
-};
-
-// Prepare output for a language book
-Output.prototype.onLanguageBook = function(book) {
- return new this.constructor(book, this.opts, this);
-};
-
-
-// ---- Utilities ----
-
-// Return conetxt for the output itself
-Output.prototype.getSelfContext = function() {
- return {
- name: this.name
- };
-};
-
-// Return a default context for templates
-Output.prototype.getContext = function() {
- var ctx = _.extend(
- {
- output: this.getSelfContext()
- },
- this.book.getContext(),
- (this.book.isLanguageBook()? this.book.parent: this.book).langs.getContext(),
- this.book.readme.getContext(),
- this.book.summary.getContext(),
- this.book.glossary.getContext(),
- this.book.config.getContext(),
- gitbook.getContext()
- );
-
- // Deprecated fields
- error.deprecateField(ctx.gitbook, 'generator', this.name, '"gitbook.generator" property is deprecated, use "output.name" instead');
-
- return ctx;
-};
-
-// Resolve a file path in the context of a specific page
-// Result is an "absolute path relative to the output folder"
-Output.prototype.resolveForPage = function(page, href) {
- if (_.isString(page)) page = this.book.getPage(page);
-
- href = page.relative(href);
- return this.onRelativeLink(page, href);
-};
-
-// Filename for output
-// READMEs are replaced by index.html
-// /test/README.md -> /test/index.html
-Output.prototype.outputPath = function(filename, ext) {
- ext = ext || this.defaultExtension;
- var output = filename;
-
- if (
- path.basename(filename, path.extname(filename)) == 'README' ||
- output == this.book.readme.path
- ) {
- output = path.join(path.dirname(output), 'index'+ext);
- } else {
- output = pathUtil.setExtension(output, ext);
- }
-
- return output;
-};
-
-// Filename for output
-// /test/index.html -> /test/
-Output.prototype.toURL = function(filename, ext) {
- var href = this.outputPath(filename, ext);
-
- if (path.basename(href) == 'index.html' && this.opts.directoryIndex) {
- href = path.dirname(href) + '/';
- }
-
- return location.normalize(href);
-};
-
-module.exports = Output;
diff --git a/lib/output/callHook.js b/lib/output/callHook.js
new file mode 100644
index 0000000..4914e52
--- /dev/null
+++ b/lib/output/callHook.js
@@ -0,0 +1,60 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var Api = require('../api');
+
+function defaultGetArgument() {
+ return undefined;
+}
+
+function defaultHandleResult(output, result) {
+ return output;
+}
+
+/**
+ Call a "global" hook for an output
+
+ @param {String} name
+ @param {Function(Output) -> Mixed} getArgument
+ @param {Function(Output, result) -> Output} handleResult
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function callHook(name, getArgument, handleResult, output) {
+ getArgument = getArgument || defaultGetArgument;
+ handleResult = handleResult || defaultHandleResult;
+
+ var logger = output.getLogger();
+ var plugins = output.getPlugins();
+
+ logger.debug.ln('calling hook "' + name + '"');
+
+ // Create the JS context for plugins
+ var context = Api.encodeGlobal(output);
+
+ return timing.measure(
+ 'call.hook.' + name,
+
+ // Get the arguments
+ Promise(getArgument(output))
+
+ // Call the hooks in serie
+ .then(function(arg) {
+ return Promise.reduce(plugins, function(prev, plugin) {
+ var hook = plugin.getHook(name);
+ if (!hook) {
+ return prev;
+ }
+
+ return hook.call(context, prev);
+ }, arg);
+ })
+
+ // Handle final result
+ .then(function(result) {
+ output = Api.decodeGlobal(output, context);
+ return handleResult(output, result);
+ })
+ );
+}
+
+module.exports = callHook;
diff --git a/lib/output/callPageHook.js b/lib/output/callPageHook.js
new file mode 100644
index 0000000..c66cef0
--- /dev/null
+++ b/lib/output/callPageHook.js
@@ -0,0 +1,28 @@
+var Api = require('../api');
+var callHook = require('./callHook');
+
+/**
+ Call a hook for a specific page
+
+ @param {String} name
+ @param {Output} output
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function callPageHook(name, output, page) {
+ return callHook(
+ name,
+
+ function(out) {
+ return Api.encodePage(out, page);
+ },
+
+ function(out, result) {
+ return Api.decodePage(out, page, result);
+ },
+
+ output
+ );
+}
+
+module.exports = callPageHook;
diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js
deleted file mode 100644
index e58f836..0000000
--- a/lib/output/conrefs.js
+++ /dev/null
@@ -1,67 +0,0 @@
-var path = require('path');
-var util = require('util');
-
-var folderOutput = require('./folder');
-var Git = require('../utils/git');
-var fs = require('../utils/fs');
-var pathUtil = require('../utils/path');
-var location = require('../utils/location');
-
-/*
-Mixin for output to resolve git conrefs
-*/
-
-module.exports = function conrefsLoader(Base) {
- Base = folderOutput(Base);
-
- function ConrefsLoader() {
- Base.apply(this, arguments);
-
- this.git = new Git();
- }
- util.inherits(ConrefsLoader, Base);
-
- // Read a template by its source URL
- ConrefsLoader.prototype.onGetTemplate = function(sourceURL) {
- var that = this;
-
- return this.git.resolve(sourceURL)
- .then(function(filepath) {
- // Is local file
- if (!filepath) {
- filepath = that.book.resolve(sourceURL);
- } else {
- that.book.log.debug.ln('resolve from git', sourceURL, 'to', filepath);
- }
-
- // Read file from absolute path
- return fs.readFile(filepath)
- .then(function(source) {
- return {
- src: source.toString('utf8'),
- path: filepath
- };
- });
- });
- };
-
- // Generate a source URL for a template
- ConrefsLoader.prototype.onResolveTemplate = function(from, to) {
- // If origin is in the book, we enforce result file to be in the book
- if (this.book.isInBook(from)) {
- var href = location.toAbsolute(to, path.dirname(from), '');
- return this.book.resolve(href);
- }
-
- // If origin is in a git repository, we resolve file in the git repository
- var gitRoot = this.git.resolveRoot(from);
- if (gitRoot) {
- return pathUtil.resolveInRoot(gitRoot, to);
- }
-
- // If origin is not in the book (include from a git content ref)
- return path.resolve(path.dirname(from), to);
- };
-
- return ConrefsLoader;
-};
diff --git a/lib/output/createTemplateEngine.js b/lib/output/createTemplateEngine.js
new file mode 100644
index 0000000..37b3c27
--- /dev/null
+++ b/lib/output/createTemplateEngine.js
@@ -0,0 +1,44 @@
+var Templating = require('../templating');
+var TemplateEngine = require('../models/templateEngine');
+
+var Api = require('../api');
+var Plugins = require('../plugins');
+
+var defaultBlocks = require('../constants/defaultBlocks');
+var defaultFilters = require('../constants/defaultFilters');
+
+/**
+ Create template engine for an output.
+ It adds default filters/blocks, then add the ones from plugins
+
+ @param {Output} output
+ @return {TemplateEngine}
+*/
+function createTemplateEngine(output) {
+ var plugins = output.getPlugins();
+ var book = output.getBook();
+ var rootFolder = book.getContentRoot();
+ var logger = book.getLogger();
+
+ var filters = Plugins.listFilters(plugins);
+ var blocks = Plugins.listBlocks(plugins);
+
+ // Extend with default
+ blocks = defaultBlocks.merge(blocks);
+ filters = defaultFilters.merge(filters);
+
+ // Create loader
+ var loader = new Templating.ConrefsLoader(rootFolder, logger);
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+ return new TemplateEngine({
+ filters: filters,
+ blocks: blocks,
+ loader: loader,
+ context: context
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/lib/output/ebook.js b/lib/output/ebook.js
deleted file mode 100644
index 2b8fac9..0000000
--- a/lib/output/ebook.js
+++ /dev/null
@@ -1,193 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-var juice = require('juice');
-
-var command = require('../utils/command');
-var fs = require('../utils/fs');
-var Promise = require('../utils/promise');
-var error = require('../utils/error');
-var WebsiteOutput = require('./website');
-var assetsInliner = require('./assets-inliner');
-
-function _EbookOutput() {
- WebsiteOutput.apply(this, arguments);
-
- // ebook-convert does not support link like "./"
- this.opts.directoryIndex = false;
-}
-util.inherits(_EbookOutput, WebsiteOutput);
-
-var EbookOutput = assetsInliner(_EbookOutput);
-
-EbookOutput.prototype.name = 'ebook';
-
-// Return context for templating
-// Incldue type of ebbook generated
-EbookOutput.prototype.getSelfContext = function() {
- var ctx = EbookOutput.super_.prototype.getSelfContext.apply(this);
- ctx.format = this.opts.format;
-
- return ctx;
-};
-
-// Finish generation, create ebook using ebook-convert
-EbookOutput.prototype.finish = function() {
- var that = this;
- if (that.book.isMultilingual()) {
- return EbookOutput.super_.prototype.finish.apply(that);
- }
-
- return Promise()
- .then(function() {
- return EbookOutput.super_.prototype.finish.apply(that);
- })
-
- // Generate SUMMARY.html
- .then(function() {
- return that.render('summary', 'SUMMARY.html', that.getContext());
- })
-
- // Start ebook-convert
- .then(function() {
- return that.ebookConvertOption();
- })
-
- .then(function(options) {
- if (!that.opts.format) return;
-
- var cmd = [
- 'ebook-convert',
- that.resolve('SUMMARY.html'),
- that.resolve('index.'+that.opts.format),
- command.optionsToShellArgs(options)
- ].join(' ');
-
- return command.exec(cmd)
- .progress(function(data) {
- that.book.log.debug(data);
- })
- .fail(function(err) {
- if (err.code == 127) {
- throw error.RequireInstallError({
- cmd: 'ebook-convert',
- install: 'Install it from Calibre: https://calibre-ebook.com'
- });
- }
-
- throw error.EbookError(err);
- });
- });
-};
-
-// Generate header/footer for PDF
-EbookOutput.prototype.getPDFTemplate = function(tpl) {
- var that = this;
- var context = _.extend(
- {
- // Nunjucks context mapping to ebook-convert templating
- page: {
- num: '_PAGENUM_',
- title: '_TITLE_',
- section: '_SECTION_'
- }
- },
- this.getContext()
- );
-
- return this.renderAsString('pdf_'+tpl, context)
-
- // Inline css, include css relative to the output folder
- .then(function(output) {
- return Promise.nfcall(juice.juiceResources, output, {
- webResources: {
- relativeTo: that.root()
- }
- });
- });
-};
-
-// Locate the cover file to use
-// Use configuration or search a "cover.jpg" file
-// For multi-lingual book, it can use the one from the main book
-EbookOutput.prototype.locateCover = function() {
- var cover = this.book.config.get('cover', 'cover.jpg');
-
- // Resolve to absolute
- cover = this.resolve(cover);
-
- // Cover doesn't exist and multilingual?
- if (!fs.existsSync(cover)) {
- if (this.parent) return this.parent.locateCover();
- else return undefined;
- }
-
- return cover;
-};
-
-// Generate options for ebook-convert
-EbookOutput.prototype.ebookConvertOption = function() {
- var that = this;
-
- var options = {
- '--cover': this.locateCover(),
- '--title': that.book.config.get('title'),
- '--comments': that.book.config.get('description'),
- '--isbn': that.book.config.get('isbn'),
- '--authors': that.book.config.get('author'),
- '--language': that.book.config.get('language'),
- '--book-producer': 'GitBook',
- '--publisher': 'GitBook',
- '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
- '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
- '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
- '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
- '--no-chapters-in-toc': true,
- '--max-levels': '1',
- '--breadth-first': true
- };
-
- if (that.opts.format == 'epub') {
- options = _.extend(options, {
- '--dont-split-on-page-breaks': true
- });
- }
-
- if (that.opts.format != 'pdf') return Promise(options);
-
- var pdfOptions = that.book.config.get('pdf');
-
- options = _.extend(options, {
- '--chapter-mark': String(pdfOptions.chapterMark),
- '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
- '--margin-left': String(pdfOptions.margin.left),
- '--margin-right': String(pdfOptions.margin.right),
- '--margin-top': String(pdfOptions.margin.top),
- '--margin-bottom': String(pdfOptions.margin.bottom),
- '--pdf-default-font-size': String(pdfOptions.fontSize),
- '--pdf-mono-font-size': String(pdfOptions.fontSize),
- '--paper-size': String(pdfOptions.paperSize),
- '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
- '--pdf-header-template': that.getPDFTemplate('header'),
- '--pdf-footer-template': that.getPDFTemplate('footer'),
- '--pdf-sans-family': String(pdfOptions.fontFamily)
- });
-
- return that.getPDFTemplate('header')
- .then(function(tpl) {
- options['--pdf-header-template'] = tpl;
-
- return that.getPDFTemplate('footer');
- })
- .then(function(tpl) {
- options['--pdf-footer-template'] = tpl;
-
- return options;
- });
-};
-
-// Don't write multi-lingual index for wbook
-EbookOutput.prototype.outputMultilingualIndex = function() {
-
-};
-
-module.exports = EbookOutput;
diff --git a/lib/output/ebook/getConvertOptions.js b/lib/output/ebook/getConvertOptions.js
new file mode 100644
index 0000000..bc80493
--- /dev/null
+++ b/lib/output/ebook/getConvertOptions.js
@@ -0,0 +1,73 @@
+var extend = require('extend');
+
+var Promise = require('../../utils/promise');
+var getPDFTemplate = require('./getPDFTemplate');
+var getCoverPath = require('./getCoverPath');
+
+/**
+ Generate options for ebook-convert
+
+ @param {Output}
+ @return {Promise<Object>}
+*/
+function getConvertOptions(output) {
+ var options = output.getOptions();
+ var format = options.get('format');
+
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ return Promise()
+ .then(function() {
+ var coverPath = getCoverPath(output);
+ var options = {
+ '--cover': coverPath,
+ '--title': config.getValue('title'),
+ '--comments': config.getValue('description'),
+ '--isbn': config.getValue('isbn'),
+ '--authors': config.getValue('author'),
+ '--language': book.getLanguage() || config.getValue('language'),
+ '--book-producer': 'GitBook',
+ '--publisher': 'GitBook',
+ '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
+ '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
+ '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
+ '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
+ '--max-levels': '1',
+ '--no-chapters-in-toc': true,
+ '--breadth-first': true,
+ '--dont-split-on-page-breaks': format === 'epub'? true : undefined
+ };
+
+ if (format !== 'pdf') {
+ return options;
+ }
+
+ return Promise.all([
+ getPDFTemplate(output, 'header'),
+ getPDFTemplate(output, 'footer')
+ ])
+ .spread(function(headerTpl, footerTpl) {
+ var pdfOptions = config.getValue('pdf').toJS();
+
+ return options = extend(options, {
+ '--chapter-mark': String(pdfOptions.chapterMark),
+ '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
+ '--margin-left': String(pdfOptions.margin.left),
+ '--margin-right': String(pdfOptions.margin.right),
+ '--margin-top': String(pdfOptions.margin.top),
+ '--margin-bottom': String(pdfOptions.margin.bottom),
+ '--pdf-default-font-size': String(pdfOptions.fontSize),
+ '--pdf-mono-font-size': String(pdfOptions.fontSize),
+ '--paper-size': String(pdfOptions.paperSize),
+ '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
+ '--pdf-sans-family': String(pdfOptions.fontFamily),
+ '--pdf-header-template': headerTpl,
+ '--pdf-footer-template': footerTpl
+ });
+ });
+ });
+}
+
+
+module.exports = getConvertOptions;
diff --git a/lib/output/ebook/getCoverPath.js b/lib/output/ebook/getCoverPath.js
new file mode 100644
index 0000000..c2192d4
--- /dev/null
+++ b/lib/output/ebook/getCoverPath.js
@@ -0,0 +1,30 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Resolve path to cover file to use
+
+ @param {Output}
+ @return {String}
+*/
+function getCoverPath(output) {
+ var outputRoot = output.getRoot();
+ var book = output.getBook();
+ var config = book.getConfig();
+ var cover = config.getValue('cover', 'cover.jpg');
+
+ // Resolve to absolute
+ cover = fs.pickFile(outputRoot, cover);
+ if (cover) {
+ return cover;
+ }
+
+ // Multilingual? try parent folder
+ if (book.isLanguageBook()) {
+ cover = fs.pickFile(path.join(outputRoot, '..'), cover);
+ }
+
+ return cover;
+}
+
+module.exports = getCoverPath;
diff --git a/lib/output/ebook/getPDFTemplate.js b/lib/output/ebook/getPDFTemplate.js
new file mode 100644
index 0000000..f7a450d
--- /dev/null
+++ b/lib/output/ebook/getPDFTemplate.js
@@ -0,0 +1,42 @@
+var juice = require('juice');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+
+
+/**
+ Generate PDF header/footer templates
+
+ @param {Output} output
+ @param {String} type
+ @return {String}
+*/
+function getPDFTemplate(output, type) {
+ var filePath = 'pdf_' + type + '.html';
+ var outputRoot = output.getRoot();
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+
+ // Generate context
+ var context = JSONUtils.encodeOutput(output);
+ context.page = {
+ num: '_PAGENUM_',
+ title: '_TITLE_',
+ section: '_SECTION_'
+ };
+
+ // Render the theme
+ return Templating.renderFile(engine, 'ebook/' + filePath, context)
+
+ // Inline css and assets
+ .then(function(html) {
+ return Promise.nfcall(juice.juiceResources, html, {
+ webResources: {
+ relativeTo: outputRoot
+ }
+ });
+ });
+}
+
+module.exports = getPDFTemplate;
diff --git a/lib/output/ebook/index.js b/lib/output/ebook/index.js
new file mode 100644
index 0000000..786a10a
--- /dev/null
+++ b/lib/output/ebook/index.js
@@ -0,0 +1,9 @@
+var extend = require('extend');
+var WebsiteGenerator = require('../website');
+
+module.exports = extend({}, WebsiteGenerator, {
+ name: 'ebook',
+ Options: require('./options'),
+ onPage: require('./onPage'),
+ onFinish: require('./onFinish')
+});
diff --git a/lib/output/ebook/onFinish.js b/lib/output/ebook/onFinish.js
new file mode 100644
index 0000000..17a8e5e
--- /dev/null
+++ b/lib/output/ebook/onFinish.js
@@ -0,0 +1,90 @@
+var path = require('path');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+var error = require('../../utils/error');
+var command = require('../../utils/command');
+var writeFile = require('../helper/writeFile');
+
+var getConvertOptions = require('./getConvertOptions');
+
+/**
+ Write the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function writeSummary(output) {
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ var filePath = 'SUMMARY.html';
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/SUMMARY.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+}
+
+/**
+ Generate the ebook file as "index.pdf"
+
+ @param {Output}
+ @return {Output}
+*/
+function runEbookConvert(output) {
+ var logger = output.getLogger();
+ var options = output.getOptions();
+ var format = options.get('format');
+ var outputFolder = output.getRoot();
+
+ if (!format) {
+ return Promise(output);
+ }
+
+ return getConvertOptions(output)
+ .then(function(options) {
+ var cmd = [
+ 'ebook-convert',
+ path.resolve(outputFolder, 'SUMMARY.html'),
+ path.resolve(outputFolder, 'index.' + format),
+ command.optionsToShellArgs(options)
+ ].join(' ');
+
+ return command.exec(cmd)
+ .progress(function(data) {
+ logger.debug(data);
+ })
+ .fail(function(err) {
+ if (err.code == 127) {
+ throw error.RequireInstallError({
+ cmd: 'ebook-convert',
+ install: 'Install it from Calibre: https://calibre-ebook.com'
+ });
+ }
+
+ throw error.EbookError(err);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Finish the generation, generates the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ return writeSummary(output)
+ .then(runEbookConvert);
+}
+
+module.exports = onFinish;
diff --git a/lib/output/ebook/onPage.js b/lib/output/ebook/onPage.js
new file mode 100644
index 0000000..21fd34c
--- /dev/null
+++ b/lib/output/ebook/onPage.js
@@ -0,0 +1,24 @@
+var WebsiteGenerator = require('../website');
+var Modifiers = require('../modifiers');
+
+/**
+ Write a page for ebook output
+
+ @param {Output} output
+ @param {Output}
+*/
+function onPage(output, page) {
+ var options = output.getOptions();
+
+ // Inline assets
+ return Modifiers.modifyHTML(page, [
+ Modifiers.inlineAssets(options.get('root'))
+ ])
+
+ // Write page using website generator
+ .then(function(resultPage) {
+ return WebsiteGenerator.onPage(output, resultPage);
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/ebook/options.js b/lib/output/ebook/options.js
new file mode 100644
index 0000000..ea7b8b4
--- /dev/null
+++ b/lib/output/ebook/options.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('ebook'),
+
+ // Format to generate using ebook-convert
+ format: String(),
+
+ // Force use of absolute urls ("index.html" instead of "/")
+ directoryIndex: Boolean(false)
+});
+
+module.exports = Options;
diff --git a/lib/output/folder.js b/lib/output/folder.js
deleted file mode 100644
index 8303ed2..0000000
--- a/lib/output/folder.js
+++ /dev/null
@@ -1,152 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-var path = require('path');
-
-var Output = require('./base');
-var fs = require('../utils/fs');
-var pathUtil = require('../utils/path');
-var Promise = require('../utils/promise');
-
-/*
-This output requires the native fs module to output
-book as a directory (mapping assets and pages)
-*/
-
-module.exports = function folderOutput(Base) {
- Base = Base || Output;
-
- function FolderOutput() {
- Base.apply(this, arguments);
-
- this.opts.root = path.resolve(this.opts.root || this.book.resolve('_book'));
- }
- util.inherits(FolderOutput, Base);
-
- // Copy an asset file (non-parsable), ex: images, etc
- FolderOutput.prototype.onAsset = function(filename) {
- return this.copyFile(
- this.book.resolve(filename),
- filename
- );
- };
-
- // Prepare the generation by creating the output folder
- FolderOutput.prototype.prepare = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return FolderOutput.super_.prototype.prepare.apply(that);
- })
-
- // Cleanup output folder
- .then(function() {
- that.log.debug.ln('removing previous output directory');
- return fs.rmDir(that.root())
- .fail(function() {
- return Promise();
- });
- })
-
- // Create output folder
- .then(function() {
- that.log.debug.ln('creating output directory');
- return fs.mkdirp(that.root());
- })
-
- // Add output folder to ignored files
- .then(function() {
- that.ignore.addPattern([
- path.relative(that.book.root, that.root())
- ]);
- });
- };
-
- // Prepare output for a language book
- FolderOutput.prototype.onLanguageBook = function(book) {
- return new this.constructor(book, _.extend({}, this.opts, {
-
- // Language output should be output in sub-directory of output
- root: path.resolve(this.root(), book.language)
- }), this);
- };
-
- // ----- Utility methods -----
-
- // Return path to the root folder
- FolderOutput.prototype.root = function() {
- return this.opts.root;
- };
-
- // Resolve a file in the output directory
- FolderOutput.prototype.resolve = function(filename) {
- return pathUtil.resolveInRoot.apply(null, [this.root()].concat(_.toArray(arguments)));
- };
-
- // Copy a file to the output
- FolderOutput.prototype.copyFile = function(from, to) {
- var that = this;
-
- return Promise()
- .then(function() {
- to = that.resolve(to);
- var folder = path.dirname(to);
-
- // Ensure folder exists
- return fs.mkdirp(folder);
- })
- .then(function() {
- return fs.copy(from, to);
- });
- };
-
- // Write a file/buffer to the output folder
- FolderOutput.prototype.writeFile = function(filename, buf) {
- var that = this;
-
- return Promise()
- .then(function() {
- filename = that.resolve(filename);
- var folder = path.dirname(filename);
-
- // Ensure folder exists
- return fs.mkdirp(folder);
- })
-
- // Write the file
- .then(function() {
- return fs.writeFile(filename, buf);
- });
- };
-
- // Return true if a file exists in the output folder
- FolderOutput.prototype.hasFile = function(filename) {
- var that = this;
-
- return Promise()
- .then(function() {
- return fs.exists(that.resolve(filename));
- });
- };
-
- // Create a new unique file
- // Returns its filename
- FolderOutput.prototype.createNewFile = function(base, filename) {
- var that = this;
-
- if (!filename) {
- filename = path.basename(filename);
- base = path.dirname(base);
- }
-
- return fs.uniqueFilename(this.resolve(base), filename)
- .then(function(out) {
- out = path.join(base, out);
-
- return fs.ensure(that.resolve(out))
- .thenResolve(out);
- });
- };
-
- return FolderOutput;
-};
diff --git a/lib/output/generateAssets.js b/lib/output/generateAssets.js
new file mode 100644
index 0000000..7a6e104
--- /dev/null
+++ b/lib/output/generateAssets.js
@@ -0,0 +1,26 @@
+var Promise = require('../utils/promise');
+
+/**
+ Output all assets using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generateAssets(generator, output) {
+ var assets = output.getAssets();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onAsset) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(assets, function(out, assetFile) {
+ logger.debug.ln('copy asset "' + assetFile + '"');
+
+ return generator.onAsset(out, assetFile);
+ }, output);
+}
+
+module.exports = generateAssets;
diff --git a/lib/output/generateBook.js b/lib/output/generateBook.js
new file mode 100644
index 0000000..6fcade0
--- /dev/null
+++ b/lib/output/generateBook.js
@@ -0,0 +1,181 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Output = require('../models/output');
+var Config = require('../models/config');
+var Promise = require('../utils/promise');
+
+var callHook = require('./callHook');
+var preparePlugins = require('./preparePlugins');
+var preparePages = require('./preparePages');
+var prepareAssets = require('./prepareAssets');
+var generateAssets = require('./generateAssets');
+var generatePages = require('./generatePages');
+
+/**
+ Process an output to generate the book
+
+ @param {Generator} generator
+ @param {Output} output
+
+ @return {Promise<Output>}
+*/
+function processOutput(generator, startOutput) {
+ return Promise(startOutput)
+ .then(preparePlugins)
+ .then(preparePages)
+ .then(prepareAssets)
+
+ .then(
+ callHook.bind(null,
+ 'config',
+ function(output) {
+ var book = output.getBook();
+ var config = book.getConfig();
+ var values = config.getValues();
+
+ return values.toJS();
+ },
+ function(output, result) {
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ config = Config.updateValues(config, result);
+ book = book.set('config', config);
+ return output.set('book', book);
+ }
+ )
+ )
+
+ .then(
+ callHook.bind(null,
+ 'init',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onInit) {
+ return output;
+ }
+
+ return generator.onInit(output);
+ })
+
+ .then(generateAssets.bind(null, generator))
+ .then(generatePages.bind(null, generator))
+
+ .tap(function(output) {
+ var book = output.getBook();
+
+ if (!book.isMultilingual()) {
+ return;
+ }
+
+ var books = book.getBooks();
+ var outputRoot = output.getRoot();
+ var plugins = output.getPlugins();
+ var state = output.getState();
+ var options = output.getOptions();
+
+ return Promise.forEach(books, function(langBook) {
+ // Inherits plugins list, options and state
+ var langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage()));
+ var langOutput = new Output({
+ book: langBook,
+ options: langOptions,
+ state: state,
+ generator: generator.name,
+ plugins: plugins
+ });
+
+ return processOutput(generator, langOutput);
+ });
+ })
+
+ .then(callHook.bind(null,
+ 'finish:before',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onFinish) {
+ return output;
+ }
+
+ return generator.onFinish(output);
+ })
+
+ .then(callHook.bind(null,
+ 'finish',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ );
+}
+
+/**
+ Generate a book using a generator.
+
+ The overall process is:
+ 1. List and load plugins for this book
+ 2. Call hook "config"
+ 3. Call hook "init"
+ 4. Initialize generator
+ 5. List all assets and pages
+ 6. Copy all assets to output
+ 7. Generate all pages
+ 8. Call hook "finish:before"
+ 9. Finish generation
+ 10. Call hook "finish"
+
+
+ @param {Generator} generator
+ @param {Book} book
+ @param {Object} options
+
+ @return {Promise<Output>}
+*/
+function generateBook(generator, book, options) {
+ options = generator.Options(options);
+ var state = generator.State? generator.State({}) : Immutable.Map();
+ var start = Date.now();
+
+ return Promise(
+ new Output({
+ book: book,
+ options: options,
+ state: state,
+ generator: generator.name
+ })
+ )
+ .then(processOutput.bind(null, generator))
+
+ // Log duration and end message
+ .then(function(output) {
+ var logger = output.getLogger();
+ var end = Date.now();
+ var duration = (end - start)/1000;
+
+ logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !');
+
+ return output;
+ });
+}
+
+module.exports = generateBook;
diff --git a/lib/output/generateMock.js b/lib/output/generateMock.js
new file mode 100644
index 0000000..47d29dc
--- /dev/null
+++ b/lib/output/generateMock.js
@@ -0,0 +1,35 @@
+var tmp = require('tmp');
+
+var Book = require('../models/book');
+var createMockFS = require('../fs/mock');
+var parseBook = require('../parse/parseBook');
+var generateBook = require('./generateBook');
+
+
+/**
+ Generate a book using JSON generator
+ And returns the path to the output dir.
+
+ FOR TESTING PURPOSE ONLY
+
+ @param {Generator}
+ @param {Map<String:String|Map>} files
+ @return {Promise<String>}
+*/
+function generateMock(Generator, files) {
+ var fs = createMockFS(files);
+ var book = Book.createForFS(fs);
+ var dir = tmp.dirSync();
+
+ book = book.setLogLevel('disabled');
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ return generateBook(Generator, resultBook, {
+ root: dir.name
+ });
+ })
+ .thenResolve(dir.name);
+}
+
+module.exports = generateMock;
diff --git a/lib/output/generatePage.js b/lib/output/generatePage.js
new file mode 100644
index 0000000..a93d4b0
--- /dev/null
+++ b/lib/output/generatePage.js
@@ -0,0 +1,71 @@
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var timing = require('../utils/timing');
+
+var Parse = require('../parse');
+var Templating = require('../templating');
+var JSONUtils = require('../json');
+var createTemplateEngine = require('./createTemplateEngine');
+var callPageHook = require('./callPageHook');
+
+/**
+ Prepare and generate HTML for a page
+
+ @param {Output} output
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function generatePage(output, page) {
+ var book = output.getBook();
+ var engine = createTemplateEngine(output);
+
+ return timing.measure(
+ 'page.generate',
+ Parse.parsePage(book, page)
+ .then(function(resultPage) {
+ var file = resultPage.getFile();
+ var filePath = file.getPath();
+ var parser = file.getParser();
+ var context = JSONUtils.encodeBookWithPage(book, resultPage);
+
+ if (!parser) {
+ return Promise.reject(error.FileNotParsableError({
+ filename: filePath
+ }));
+ }
+
+ // Call hook "page:before"
+ return callPageHook('page:before', output, resultPage)
+
+ // Escape code blocks with raw tags
+ .then(function(currentPage) {
+ return parser.page.prepare(currentPage.getContent());
+ })
+
+ // Render templating syntax
+ .then(function(content) {
+ return Templating.render(engine, filePath, content, context);
+ })
+
+ // Render page using parser (markdown -> HTML)
+ .then(parser.page).get('content')
+
+ // Post processing for templating syntax
+ .then(function(content) {
+ return Templating.postRender(engine, content);
+ })
+
+ // Return new page
+ .then(function(content) {
+ return resultPage.set('content', content);
+ })
+
+ // Call final hook
+ .then(function(currentPage) {
+ return callPageHook('page', output, currentPage);
+ });
+ })
+ );
+}
+
+module.exports = generatePage;
diff --git a/lib/output/generatePages.js b/lib/output/generatePages.js
new file mode 100644
index 0000000..73c5c09
--- /dev/null
+++ b/lib/output/generatePages.js
@@ -0,0 +1,36 @@
+var Promise = require('../utils/promise');
+var generatePage = require('./generatePage');
+
+/**
+ Output all pages using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generatePages(generator, output) {
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onPage) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(pages, function(out, page) {
+ var file = page.getFile();
+
+ logger.debug.ln('generate page "' + file.getPath() + '"');
+
+ return generatePage(out, page)
+ .then(function(resultPage) {
+ return generator.onPage(out, resultPage);
+ })
+ .fail(function(err) {
+ logger.error.ln('error while generating page "' + file.getPath() + '":');
+ throw err;
+ });
+ }, output);
+}
+
+module.exports = generatePages;
diff --git a/lib/output/getModifiers.js b/lib/output/getModifiers.js
new file mode 100644
index 0000000..e649df6
--- /dev/null
+++ b/lib/output/getModifiers.js
@@ -0,0 +1,68 @@
+var Modifiers = require('./modifiers');
+var resolveFileToURL = require('./helper/resolveFileToURL');
+var Api = require('../api');
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+var defaultBlocks = require('../constants/defaultBlocks');
+
+var CODEBLOCK = 'code';
+
+/**
+ Return default modifier to prepare a page for
+ rendering.
+
+ @return {Array<Modifier>}
+*/
+function getModifiers(output, page) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+ var glossary = book.getGlossary();
+ var entries = glossary.getEntries();
+ var file = page.getFile();
+
+ // Current file path
+ var currentFilePath = file.getPath();
+
+ // Get TemplateBlock for highlighting
+ var blocks = Plugins.listBlocks(plugins);
+ var code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK);
+
+ // Current context
+ var context = Api.encodeGlobal(output);
+
+ return [
+ // Normalize IDs on headings
+ Modifiers.addHeadingId,
+
+ // Resolve links (.md -> .html)
+ Modifiers.resolveLinks.bind(null,
+ currentFilePath,
+ resolveFileToURL.bind(null, output)
+ ),
+
+ // Resolve images
+ Modifiers.resolveImages.bind(null, currentFilePath),
+
+ // Annotate text with glossary entries
+ Modifiers.annotateText.bind(null, entries),
+
+ // Highlight code blocks using "code" block
+ Modifiers.highlightCode.bind(null, function(lang, source) {
+ return Promise(code.applyBlock({
+ body: source,
+ kwargs: {
+ language: lang
+ }
+ }, context))
+ .then(function(result) {
+ if (result.html === false) {
+ return { text: result.body };
+ } else {
+ return { html: result.body };
+ }
+ });
+ })
+ ];
+}
+
+module.exports = getModifiers;
diff --git a/lib/output/helper/fileToOutput.js b/lib/output/helper/fileToOutput.js
new file mode 100644
index 0000000..9673162
--- /dev/null
+++ b/lib/output/helper/fileToOutput.js
@@ -0,0 +1,32 @@
+var path = require('path');
+
+var PathUtils = require('../../utils/path');
+var LocationUtils = require('../../utils/location');
+
+var OUTPUT_EXTENSION = '.html';
+
+/**
+ Convert a filePath (absolute) to a filename for output
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function fileToOutput(output, filePath) {
+ var book = output.getBook();
+ var readme = book.getReadme();
+ var fileReadme = readme.getFile();
+
+ if (
+ path.basename(filePath, path.extname(filePath)) == 'README' ||
+ (fileReadme.exists() && filePath == fileReadme.getPath())
+ ) {
+ filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION);
+ } else {
+ filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToOutput;
diff --git a/lib/output/helper/fileToURL.js b/lib/output/helper/fileToURL.js
new file mode 100644
index 0000000..44ad2d8
--- /dev/null
+++ b/lib/output/helper/fileToURL.js
@@ -0,0 +1,31 @@
+var path = require('path');
+var LocationUtils = require('../../utils/location');
+
+var fileToOutput = require('./fileToOutput');
+
+/**
+ Convert a filePath (absolute) to an url (without hostname).
+ It returns an absolute path.
+
+ "README.md" -> "/"
+ "test/hello.md" -> "test/hello.html"
+ "test/README.md" -> "test/"
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function fileToURL(output, filePath) {
+ var options = output.getOptions();
+ var directoryIndex = options.get('directoryIndex');
+
+ filePath = fileToOutput(output, filePath);
+
+ if (directoryIndex && path.basename(filePath) == 'index.html') {
+ filePath = path.dirname(filePath) + '/';
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToURL;
diff --git a/lib/output/helper/index.js b/lib/output/helper/index.js
new file mode 100644
index 0000000..f8bc109
--- /dev/null
+++ b/lib/output/helper/index.js
@@ -0,0 +1,2 @@
+
+module.exports = {};
diff --git a/lib/output/helper/resolveFileToUrl.js b/lib/output/helper/resolveFileToUrl.js
new file mode 100644
index 0000000..3dba8f7
--- /dev/null
+++ b/lib/output/helper/resolveFileToUrl.js
@@ -0,0 +1,27 @@
+var LocationUtils = require('../../utils/location');
+
+var fileToURL = require('./fileToURL');
+
+/**
+ Resolve an absolute path (extracted from a link)
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function resolveFileToURL(output, filePath) {
+ // Convert /test.png -> test.png
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+
+ var pages = output.getPages();
+ var page = pages.get(filePath);
+
+ // if file is a page, return correct .html url
+ if (page) {
+ filePath = fileToURL(output, filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = resolveFileToURL;
diff --git a/lib/output/helper/writeFile.js b/lib/output/helper/writeFile.js
new file mode 100644
index 0000000..a6d4645
--- /dev/null
+++ b/lib/output/helper/writeFile.js
@@ -0,0 +1,23 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Write a file to the output folder
+
+ @param {Output} output
+ @param {String} filePath
+ @param {Buffer|String} content
+ @return {Promise}
+*/
+function writeFile(output, filePath, content) {
+ var rootFolder = output.getRoot();
+ filePath = path.join(rootFolder, filePath);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ return fs.writeFile(filePath, content);
+ })
+ .thenResolve(output);
+}
+
+module.exports = writeFile;
diff --git a/lib/output/index.js b/lib/output/index.js
new file mode 100644
index 0000000..9b8ec17
--- /dev/null
+++ b/lib/output/index.js
@@ -0,0 +1,24 @@
+var Immutable = require('immutable');
+
+var generators = Immutable.List([
+ require('./json'),
+ require('./website'),
+ require('./ebook')
+]);
+
+/**
+ Return a specific generator by its name
+
+ @param {String}
+ @return {Generator}
+*/
+function getGenerator(name) {
+ return generators.find(function(generator) {
+ return generator.name == name;
+ });
+}
+
+module.exports = {
+ generate: require('./generateBook'),
+ getGenerator: getGenerator
+};
diff --git a/lib/output/json.js b/lib/output/json.js
deleted file mode 100644
index 7061141..0000000
--- a/lib/output/json.js
+++ /dev/null
@@ -1,47 +0,0 @@
-var conrefsLoader = require('./conrefs');
-
-var JSONOutput = conrefsLoader();
-
-JSONOutput.prototype.name = 'json';
-
-// Don't copy asset on JSON output
-JSONOutput.prototype.onAsset = function(filename) {};
-
-// Write a page (parsable file)
-JSONOutput.prototype.onPage = function(page) {
- var that = this;
-
- // Parse the page
- return page.toHTML(this)
-
- // Write as json
- .then(function() {
- var json = page.getOutputContext(that);
-
- // Delete some private properties
- delete json.config;
-
- // Specify JSON output version
- json.version = '3';
-
- return that.writeFile(
- page.withExtension('.json'),
- JSON.stringify(json, null, 4)
- );
- });
-};
-
-// At the end of generation, generate README.json for multilingual books
-JSONOutput.prototype.finish = function() {
- if (!this.book.isMultilingual()) return;
-
- // Copy README.json from main book
- var mainLanguage = this.book.langs.getDefault().id;
- return this.copyFile(
- this.resolve(mainLanguage, 'README.json'),
- 'README.json'
- );
-};
-
-
-module.exports = JSONOutput;
diff --git a/lib/output/json/index.js b/lib/output/json/index.js
new file mode 100644
index 0000000..e24c127
--- /dev/null
+++ b/lib/output/json/index.js
@@ -0,0 +1,6 @@
+
+module.exports = {
+ name: 'json',
+ Options: require('./options'),
+ onPage: require('./onPage')
+};
diff --git a/lib/output/json/onFinish.js b/lib/output/json/onFinish.js
new file mode 100644
index 0000000..ff336a2
--- /dev/null
+++ b/lib/output/json/onFinish.js
@@ -0,0 +1,32 @@
+var path = require('path');
+
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+
+/**
+ Finish the generation
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var outputRoot = output.getRoot();
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ // Get main language
+ var languages = book.getLanguages();
+ var mainLanguage = languages.getDefaultLanguage();
+
+ // Copy README.json from it
+ return fs.copy(
+ path.resolve(outputRoot, mainLanguage.getID(), 'README.json'),
+ path.resolve(outputRoot, 'README.json')
+ )
+ .thenResolve(output);
+}
+
+module.exports = onFinish;
diff --git a/lib/output/json/onPage.js b/lib/output/json/onPage.js
new file mode 100644
index 0000000..fece540
--- /dev/null
+++ b/lib/output/json/onPage.js
@@ -0,0 +1,43 @@
+var JSONUtils = require('../../json');
+var PathUtils = require('../../utils/path');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+
+var JSON_VERSION = '3';
+
+/**
+ Write a page as a json file
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onPage(output, page) {
+ var file = page.getFile();
+ var readme = output.getBook().getReadme().getFile();
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the JSON
+ var json = JSONUtils.encodeBookWithPage(output.getBook(), resultPage);
+
+ // Delete some private properties
+ delete json.config;
+
+ // Specify JSON output version
+ json.version = JSON_VERSION;
+
+ // File path in the output folder
+ var filePath = file.getPath() == readme.getPath()? 'README.json' : file.getPath();
+ filePath = PathUtils.setExtension(filePath, '.json');
+
+ // Write it to the disk
+ return writeFile(
+ output,
+ filePath,
+ JSON.stringify(json, null, 4)
+ );
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/json/options.js b/lib/output/json/options.js
new file mode 100644
index 0000000..79167b1
--- /dev/null
+++ b/lib/output/json/options.js
@@ -0,0 +1,8 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String()
+});
+
+module.exports = Options;
diff --git a/lib/output/modifiers/__tests__/addHeadingId.js b/lib/output/modifiers/__tests__/addHeadingId.js
new file mode 100644
index 0000000..7277440
--- /dev/null
+++ b/lib/output/modifiers/__tests__/addHeadingId.js
@@ -0,0 +1,29 @@
+jest.autoMockOff();
+
+var cheerio = require('cheerio');
+
+describe('addHeadingId', function() {
+ var addHeadingId = require('../addHeadingId');
+
+ pit('should add an ID if none', function() {
+ var $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>');
+ });
+ });
+
+ pit('should not change existing IDs', function() {
+ var $ = cheerio.load('<h1 id="awesome">Hello World</h1>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="awesome">Hello World</h1>');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/annotateText.js b/lib/output/modifiers/__tests__/annotateText.js
new file mode 100644
index 0000000..15d4c30
--- /dev/null
+++ b/lib/output/modifiers/__tests__/annotateText.js
@@ -0,0 +1,49 @@
+jest.autoMockOff();
+
+var Immutable = require('immutable');
+var cheerio = require('cheerio');
+var GlossaryEntry = require('../../../models/glossaryEntry');
+
+describe('annotateText', function() {
+ var annotateText = require('../annotateText');
+
+ var entries = Immutable.List([
+ GlossaryEntry({ name: 'Word' }),
+ GlossaryEntry({ name: 'Multiple Words' })
+ ]);
+
+ it('should annotate text', function() {
+ var $ = cheerio.load('<p>This is a word, and multiple words</p>');
+
+ annotateText(entries, $);
+
+ var links = $('a');
+ expect(links.length).toBe(2);
+
+ var word = $(links.get(0));
+ expect(word.attr('href')).toBe('/GLOSSARY.md#word');
+ expect(word.text()).toBe('word');
+ expect(word.hasClass('glossary-term')).toBeTruthy();
+
+ var words = $(links.get(1));
+ expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words');
+ expect(words.text()).toBe('multiple words');
+ expect(words.hasClass('glossary-term')).toBeTruthy();
+ });
+
+ it('should not annotate scripts', function() {
+ var $ = cheerio.load('<script>This is a word, and multiple words</script>');
+
+ annotateText(entries, $);
+ expect($('a').length).toBe(0);
+ });
+
+ it('should not annotate when has class "no-glossary"', function() {
+ var $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>');
+
+ annotateText(entries, $);
+ expect($('a').length).toBe(0);
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/fetchRemoteImages.js b/lib/output/modifiers/__tests__/fetchRemoteImages.js
new file mode 100644
index 0000000..f5610a2
--- /dev/null
+++ b/lib/output/modifiers/__tests__/fetchRemoteImages.js
@@ -0,0 +1,40 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+var URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png';
+
+describe('fetchRemoteImages', function() {
+ var dir;
+ var fetchRemoteImages = require('../fetchRemoteImages');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should download image file', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+
+ pit('should download image file and replace with relative path', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'test/index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(path.join('test', src));
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/highlightCode.js b/lib/output/modifiers/__tests__/highlightCode.js
new file mode 100644
index 0000000..bd7d422
--- /dev/null
+++ b/lib/output/modifiers/__tests__/highlightCode.js
@@ -0,0 +1,63 @@
+jest.autoMockOff();
+
+var cheerio = require('cheerio');
+var Promise = require('../../../utils/promise');
+
+describe('highlightCode', function() {
+ var highlightCode = require('../highlightCode');
+
+ function doHighlight(lang, code) {
+ return {
+ text: '' + (lang || '') + '$' + code
+ };
+ }
+
+ function doHighlightAsync(lang, code) {
+ return Promise()
+ .then(function() {
+ return doHighlight(lang, code);
+ });
+ }
+
+ pit('should call it for normal code element', function() {
+ var $ = cheerio.load('<p>This is a <code>test</code></p>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('$test');
+ });
+ });
+
+ pit('should call it for markdown code block', function() {
+ var $ = cheerio.load('<pre><code class="lang-js">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('js$test');
+ });
+ });
+
+ pit('should call it for asciidoc code block', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+
+ pit('should accept async highlighter', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlightAsync, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/resolveLinks.js b/lib/output/modifiers/__tests__/resolveLinks.js
new file mode 100644
index 0000000..3d50d80
--- /dev/null
+++ b/lib/output/modifiers/__tests__/resolveLinks.js
@@ -0,0 +1,71 @@
+jest.autoMockOff();
+
+var path = require('path');
+var cheerio = require('cheerio');
+
+describe('resolveLinks', function() {
+ var resolveLinks = require('../resolveLinks');
+
+ function resolveFileBasic(href) {
+ return href;
+ }
+
+ function resolveFileCustom(href) {
+ if (path.extname(href) == '.md') {
+ return href.slice(0, -3) + '.html';
+ }
+
+ return href;
+ }
+
+ describe('Absolute path', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a></p>';
+
+ pit('should resolve path starting by "/" in root directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('test/cool.md');
+ });
+ });
+
+ pit('should resolve path starting by "/" in child directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afolder/hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('../test/cool.md');
+ });
+ });
+ });
+
+ describe('Custom Resolver', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>';
+
+ pit('should resolve path correctly for absolute path', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('test/cool.html');
+ });
+ });
+
+ pit('should resolve path correctly for absolute path (2)', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afodler/hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('../test/cool.html');
+ });
+ });
+ });
+
+});
+
+
diff --git a/lib/output/modifiers/__tests__/svgToImg.js b/lib/output/modifiers/__tests__/svgToImg.js
new file mode 100644
index 0000000..793395e
--- /dev/null
+++ b/lib/output/modifiers/__tests__/svgToImg.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+
+describe('svgToImg', function() {
+ var dir;
+ var svgToImg = require('../svgToImg');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should write svg as a file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+
+ return svgToImg(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/svgToPng.js b/lib/output/modifiers/__tests__/svgToPng.js
new file mode 100644
index 0000000..163d72e
--- /dev/null
+++ b/lib/output/modifiers/__tests__/svgToPng.js
@@ -0,0 +1,32 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+describe('svgToPng', function() {
+ var dir;
+ var svgToImg = require('../svgToImg');
+ var svgToPng = require('../svgToPng');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should write svg as png file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+ var fileName = 'index.html';
+
+ return svgToImg(dir.name, fileName, $)
+ .then(function() {
+ return svgToPng(dir.name, fileName, $);
+ })
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ expect(path.extname(src)).toBe('.png');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/addHeadingId.js b/lib/output/modifiers/addHeadingId.js
new file mode 100644
index 0000000..e2e2720
--- /dev/null
+++ b/lib/output/modifiers/addHeadingId.js
@@ -0,0 +1,23 @@
+var slug = require('github-slugid');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Add ID to an heading
+
+ @param {HTMLElement} heading
+*/
+function addId(heading) {
+ if (heading.attr('id')) return;
+ heading.attr('id', slug(heading.text()));
+}
+
+/**
+ Add ID to all headings
+
+ @param {HTMLDom} $
+*/
+function addHeadingId($) {
+ return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId);
+}
+
+module.exports = addHeadingId;
diff --git a/lib/output/modifiers/annotateText.js b/lib/output/modifiers/annotateText.js
new file mode 100644
index 0000000..d8443cf
--- /dev/null
+++ b/lib/output/modifiers/annotateText.js
@@ -0,0 +1,94 @@
+var escape = require('escape-html');
+
+// Selector to ignore
+var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6';
+
+function pregQuote( str ) {
+ return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
+}
+
+function replaceText($, el, search, replace, text_only ) {
+ return $(el).each(function(){
+ var node = this.firstChild,
+ val,
+ new_val,
+
+ // Elements to be removed at the end.
+ remove = [];
+
+ // Only continue if firstChild exists.
+ if ( node ) {
+
+ // Loop over all childNodes.
+ while (node) {
+
+ // Only process text nodes.
+ if ( node.nodeType === 3 ) {
+
+ // The original node value.
+ val = node.nodeValue;
+
+ // The new value.
+ new_val = val.replace( search, replace );
+
+ // Only replace text if the new value is actually different!
+ if ( new_val !== val ) {
+
+ if ( !text_only && /</.test( new_val ) ) {
+ // The new value contains HTML, set it in a slower but far more
+ // robust way.
+ $(node).before( new_val );
+
+ // Don't remove the node yet, or the loop will lose its place.
+ remove.push( node );
+ } else {
+ // The new value contains no HTML, so it can be set in this
+ // very fast, simple way.
+ node.nodeValue = new_val;
+ }
+ }
+ }
+
+ node = node.nextSibling;
+ }
+ }
+
+ // Time to remove those elements!
+ if (remove.length) $(remove).remove();
+ });
+}
+
+/**
+ Annotate text using a list of GlossaryEntry
+
+ @param {List<GlossaryEntry>}
+ @param {HTMLDom} $
+*/
+function annotateText(entries, $) {
+ entries.forEach(function(entry) {
+ var entryId = entry.getID();
+ var name = entry.getName();
+ var description = entry.getDescription();
+
+ var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' );
+
+ $('*').each(function() {
+ var $this = $(this);
+
+ if (
+ $this.is(ANNOTATION_IGNORE) ||
+ $this.parents(ANNOTATION_IGNORE).length > 0
+ ) return;
+
+ replaceText($, this, searchRegex, function(match) {
+ return '<a href="/GLOSSARY.md#' + entryId + '" '
+ + 'class="glossary-term" title="' + escape(description) + '">'
+ + match
+ + '</a>';
+ });
+ });
+
+ });
+}
+
+module.exports = annotateText;
diff --git a/lib/output/modifiers/editHTMLElement.js b/lib/output/modifiers/editHTMLElement.js
new file mode 100644
index 0000000..755598e
--- /dev/null
+++ b/lib/output/modifiers/editHTMLElement.js
@@ -0,0 +1,15 @@
+var Promise = require('../../utils/promise');
+
+/**
+ Edit all elements matching a selector
+*/
+function editHTMLElement($, selector, fn) {
+ var $elements = $(selector);
+
+ return Promise.forEach($elements, function(el) {
+ var $el = $(el);
+ return fn($el);
+ });
+}
+
+module.exports = editHTMLElement;
diff --git a/lib/output/modifiers/fetchRemoteImages.js b/lib/output/modifiers/fetchRemoteImages.js
new file mode 100644
index 0000000..ef868b9
--- /dev/null
+++ b/lib/output/modifiers/fetchRemoteImages.js
@@ -0,0 +1,44 @@
+var path = require('path');
+var crc = require('crc');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Fetch all remote images
+
+ @param {String} rootFolder
+ @param {String} currentFile
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function fetchRemoteImages(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ var extension = path.extname(src);
+
+ if (!LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + extension;
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return fs.download(src, filePath);
+ })
+ .then(function() {
+ // Convert to relative
+ src = LocationUtils.relative(currentDirectory, fileName);
+
+ $img.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = fetchRemoteImages;
diff --git a/lib/output/modifiers/highlightCode.js b/lib/output/modifiers/highlightCode.js
new file mode 100644
index 0000000..dcd9d24
--- /dev/null
+++ b/lib/output/modifiers/highlightCode.js
@@ -0,0 +1,56 @@
+var is = require('is');
+var Promise = require('../../utils/promise');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Return language for a code blocks from a list of class names
+
+ @param {Array<String>}
+ @return {String}
+*/
+function getLanguageForClass(classNames) {
+ return classNames
+ .map(function(cl) {
+ // Markdown
+ if (cl.search('lang-') === 0) {
+ return cl.slice('lang-'.length);
+ }
+
+ // Asciidoc
+ if (cl.search('language-') === 0) {
+ return cl.slice('language-'.length);
+ }
+
+ return null;
+ })
+ .find(function(cl) {
+ return Boolean(cl);
+ });
+}
+
+
+/**
+ Highlight all code elements
+
+ @param {Function(lang, body) -> String} highlight
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function highlightCode(highlight, $) {
+ return editHTMLElement($, 'code', function($code) {
+ var classNames = ($code.attr('class') || '').split(' ');
+ var lang = getLanguageForClass(classNames);
+ var source = $code.text();
+
+ return Promise(highlight(lang, source))
+ .then(function(r) {
+ if (is.string(r.html)) {
+ $code.html(r.html);
+ } else {
+ $code.text(r.text);
+ }
+ });
+ });
+}
+
+module.exports = highlightCode;
diff --git a/lib/output/modifiers/index.js b/lib/output/modifiers/index.js
new file mode 100644
index 0000000..f1daa2b
--- /dev/null
+++ b/lib/output/modifiers/index.js
@@ -0,0 +1,15 @@
+
+module.exports = {
+ modifyHTML: require('./modifyHTML'),
+ inlineAssets: require('./inlineAssets'),
+
+ // HTML transformations
+ addHeadingId: require('./addHeadingId'),
+ svgToImg: require('./svgToImg'),
+ fetchRemoteImages: require('./fetchRemoteImages'),
+ svgToPng: require('./svgToPng'),
+ resolveLinks: require('./resolveLinks'),
+ resolveImages: require('./resolveImages'),
+ annotateText: require('./annotateText'),
+ highlightCode: require('./highlightCode')
+};
diff --git a/lib/output/modifiers/inlineAssets.js b/lib/output/modifiers/inlineAssets.js
new file mode 100644
index 0000000..9f19fd7
--- /dev/null
+++ b/lib/output/modifiers/inlineAssets.js
@@ -0,0 +1,27 @@
+var svgToImg = require('./svgToImg');
+var svgToPng = require('./svgToPng');
+var resolveImages = require('./resolveImages');
+var fetchRemoteImages = require('./fetchRemoteImages');
+
+var Promise = require('../../utils/promise');
+
+/**
+ Inline all assets in a page
+
+ @param {String} rootFolder
+*/
+function inlineAssets(rootFolder, currentFile) {
+ return function($) {
+ return Promise()
+
+ // Resolving images and fetching external images should be
+ // done before svg conversion
+ .then(resolveImages.bind(null, currentFile))
+ .then(fetchRemoteImages.bind(null, rootFolder, currentFile))
+
+ .then(svgToImg.bind(null, rootFolder, currentFile))
+ .then(svgToPng.bind(null, rootFolder, currentFile));
+ };
+}
+
+module.exports = inlineAssets;
diff --git a/lib/output/modifiers/modifyHTML.js b/lib/output/modifiers/modifyHTML.js
new file mode 100644
index 0000000..0fcf994
--- /dev/null
+++ b/lib/output/modifiers/modifyHTML.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var Promise = require('../../utils/promise');
+
+/**
+ Apply a list of operations to a page and
+ output the new page.
+
+ @param {Page}
+ @param {List|Array<Transformation>}
+ @return {Promise<Page>}
+*/
+function modifyHTML(page, operations) {
+ var html = page.getContent();
+ var $ = cheerio.load(html);
+
+ return Promise.forEach(operations, function(op) {
+ op($);
+ })
+ .then(function() {
+ var resultHTML = $.html();
+ return page.set('content', resultHTML);
+ });
+}
+
+module.exports = modifyHTML;
diff --git a/lib/output/modifiers/resolveImages.js b/lib/output/modifiers/resolveImages.js
new file mode 100644
index 0000000..e401cf5
--- /dev/null
+++ b/lib/output/modifiers/resolveImages.js
@@ -0,0 +1,33 @@
+var path = require('path');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML images:
+ - /test.png in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {HTMLDom} $
+*/
+function resolveImages(currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+
+ if (LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // Convert back to relative
+ src = LocationUtils.relative(currentDirectory, src);
+
+ $img.attr('src', src);
+ });
+}
+
+module.exports = resolveImages;
diff --git a/lib/output/modifiers/resolveLinks.js b/lib/output/modifiers/resolveLinks.js
new file mode 100644
index 0000000..bf3fd10
--- /dev/null
+++ b/lib/output/modifiers/resolveLinks.js
@@ -0,0 +1,38 @@
+var path = require('path');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML links:
+ - /test.md in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {Function(String) -> String} resolveFile
+ @param {HTMLDom} $
+*/
+function resolveLinks(currentFile, resolveFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'a', function($a) {
+ var href = $a.attr('href');
+
+ if (LocationUtils.isExternal(href)) {
+ $a.attr('_target', 'blank');
+ return;
+ }
+
+ // Calcul absolute path for this
+ href = LocationUtils.toAbsolute(href, currentDirectory, '.');
+
+ // Resolve file
+ href = resolveFile(href);
+
+ // Convert back to relative
+ href = LocationUtils.relative(currentDirectory, href);
+
+ $a.attr('href', href);
+ });
+}
+
+module.exports = resolveLinks;
diff --git a/lib/output/modifiers/svgToImg.js b/lib/output/modifiers/svgToImg.js
new file mode 100644
index 0000000..f31b06d
--- /dev/null
+++ b/lib/output/modifiers/svgToImg.js
@@ -0,0 +1,56 @@
+var path = require('path');
+var crc = require('crc');
+var domSerializer = require('dom-serializer');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Render a cheerio DOM as html
+
+ @param {HTMLDom} $
+ @param {HTMLElement} dom
+ @param {Object}
+ @return {String}
+*/
+function renderDOM($, dom, options) {
+ if (!dom && $._root && $._root.children) {
+ dom = $._root.children;
+ }
+ options = options|| dom.options || $._options;
+ return domSerializer(dom, options);
+}
+
+/**
+ Replace SVG tag by IMG
+
+ @param {String} baseFolder
+ @param {HTMLDom} $
+*/
+function svgToImg(baseFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'svg', function($svg) {
+ var content = '<?xml version="1.0" encoding="UTF-8"?>' +
+ renderDOM($, $svg);
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(content).toString(16);
+ var fileName = hash + '.svg';
+ var filePath = path.join(baseFolder, fileName);
+
+ // Write the svg to the file
+ return fs.assertFile(filePath, function() {
+ return fs.writeFile(filePath, content, 'utf8');
+ })
+
+ // Return as image
+ .then(function() {
+ var src = LocationUtils.relative(currentDirectory, fileName);
+ $svg.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = svgToImg;
diff --git a/lib/output/modifiers/svgToPng.js b/lib/output/modifiers/svgToPng.js
new file mode 100644
index 0000000..1093106
--- /dev/null
+++ b/lib/output/modifiers/svgToPng.js
@@ -0,0 +1,53 @@
+var crc = require('crc');
+var path = require('path');
+
+var imagesUtil = require('../../utils/images');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Convert all SVG images to PNG
+
+ @param {String} rootFolder
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function svgToPng(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ if (path.extname(src) !== '.svg') {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + '.png';
+
+ // Input file path
+ var inputPath = path.join(rootFolder, src);
+
+ // Result file path
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return imagesUtil.convertSVGToPNG(inputPath, filePath);
+ })
+ .then(function() {
+ // Convert filename to a relative filename
+ fileName = LocationUtils.relative(currentDirectory, fileName);
+
+ // Replace src
+ $img.attr('src', fileName);
+ });
+ });
+}
+
+
+module.exports = svgToPng;
diff --git a/lib/output/prepareAssets.js b/lib/output/prepareAssets.js
new file mode 100644
index 0000000..ae9b55a
--- /dev/null
+++ b/lib/output/prepareAssets.js
@@ -0,0 +1,22 @@
+var Parse = require('../parse');
+
+/**
+ List all assets in the book
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareAssets(output) {
+ var book = output.getBook();
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ return Parse.listAssets(book, pages)
+ .then(function(assets) {
+ logger.info.ln('found', assets.size, 'asset files');
+
+ return output.set('assets', assets);
+ });
+}
+
+module.exports = prepareAssets;
diff --git a/lib/output/preparePages.js b/lib/output/preparePages.js
new file mode 100644
index 0000000..8ad5f8c
--- /dev/null
+++ b/lib/output/preparePages.js
@@ -0,0 +1,21 @@
+var Parse = require('../parse');
+
+/**
+ List and prepare all pages
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function preparePages(output) {
+ var book = output.getBook();
+ var logger = book.getLogger();
+
+ return Parse.parsePagesList(book)
+ .then(function(pages) {
+ logger.info.ln('found', pages.size, 'pages');
+
+ return output.set('pages', pages);
+ });
+}
+
+module.exports = preparePages;
diff --git a/lib/output/preparePlugins.js b/lib/output/preparePlugins.js
new file mode 100644
index 0000000..54837ed
--- /dev/null
+++ b/lib/output/preparePlugins.js
@@ -0,0 +1,36 @@
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+
+/**
+ Load and setup plugins
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function preparePlugins(output) {
+ var book = output.getBook();
+
+ return Promise()
+
+ // Only load plugins for main book
+ .then(function() {
+ if (book.isLanguageBook()) {
+ return output.getPlugins();
+ } else {
+ return Plugins.loadForBook(book);
+ }
+ })
+
+ // Update book's configuration using the plugins
+ .then(function(plugins) {
+ return Plugins.validateConfig(book, plugins)
+ .then(function(newBook) {
+ return output.merge({
+ book: newBook,
+ plugins: plugins
+ });
+ });
+ });
+}
+
+module.exports = preparePlugins;
diff --git a/lib/output/website/copyPluginAssets.js b/lib/output/website/copyPluginAssets.js
new file mode 100644
index 0000000..9dc876f
--- /dev/null
+++ b/lib/output/website/copyPluginAssets.js
@@ -0,0 +1,115 @@
+var path = require('path');
+
+var ASSET_FOLDER = require('../../constants/pluginAssetsFolder');
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+
+/**
+ Copy all assets from plugins.
+ Assets are files stored in "_assets"
+ nd resources declared in the plugin itself.
+
+ @param {Output}
+ @return {Promise}
+*/
+function copyPluginAssets(output) {
+ var book = output.getBook();
+
+ // Don't copy plugins assets for language book
+ // It'll be resolved to the parent folder
+ if (book.isLanguageBook()) {
+ return Promise(output);
+ }
+
+ var plugins = output.getPlugins()
+
+ // We reverse the order of plugins to copy
+ // so that first plugins can replace assets from other plugins.
+ .reverse();
+
+ return Promise.forEach(plugins, function(plugin) {
+ return copyAssets(output, plugin)
+ .then(function() {
+ return copyResources(output, plugin);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Copy assets from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyAssets(output, plugin) {
+ var logger = output.getLogger();
+ var pluginRoot = plugin.getPath();
+ var options = output.getOptions();
+
+ var outputRoot = options.get('root');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook');
+ var prefix = options.get('prefix');
+
+ var assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix);
+
+ if (!fs.existsSync(assetFolder)) {
+ return Promise();
+ }
+
+ logger.debug.ln('copy assets from theme', assetFolder);
+ return fs.copyDir(
+ assetFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+/**
+ Copy resources from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyResources(output, plugin) {
+ var logger = output.getLogger();
+
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+ var outputRoot = options.get('root');
+
+ var pluginRoot = plugin.getPath();
+ var resources = plugin.getResources(prefix);
+
+ var assetsFolder = resources.get('assets');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getNpmID());
+
+ if (!assetsFolder) {
+ return Promise();
+ }
+
+ // Resolve assets folder
+ assetsFolder = path.resolve(pluginRoot, assetsFolder);
+ if (!fs.existsSync(assetsFolder)) {
+ logger.warn.ln('assets folder for plugin "' + plugin.getName() + '" doesn\'t exist');
+ return Promise();
+ }
+
+ logger.debug.ln('copy resources from plugin', assetsFolder);
+
+ return fs.copyDir(
+ assetsFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+module.exports = copyPluginAssets;
diff --git a/lib/output/website/createTemplateEngine.js b/lib/output/website/createTemplateEngine.js
new file mode 100644
index 0000000..334ec13
--- /dev/null
+++ b/lib/output/website/createTemplateEngine.js
@@ -0,0 +1,118 @@
+var path = require('path');
+var nunjucks = require('nunjucks');
+var DoExtension = require('nunjucks-do')(nunjucks);
+
+var Api = require('../../api');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var fs = require('../../utils/fs');
+var PathUtils = require('../../utils/path');
+var TemplateEngine = require('../../models/templateEngine');
+var templatesFolder = require('../../constants/templatesFolder');
+var defaultFilters = require('../../constants/defaultFilters');
+var Templating = require('../../templating');
+var listSearchPaths = require('./listSearchPaths');
+
+var fileToURL = require('../helper/fileToURL');
+var resolveFileToURL = require('../helper/resolveFileToURL');
+
+/**
+ Directory for a theme with the templates
+*/
+function templateFolder(dir) {
+ return path.join(dir, templatesFolder);
+}
+
+/**
+ Create templating engine to render themes
+
+ @param {Output} output
+ @param {String} currentFile
+ @return {TemplateEngine}
+*/
+function createTemplateEngine(output, currentFile) {
+ var book = output.getBook();
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var config = book.getConfig();
+ var summary = book.getSummary();
+ var outputFolder = output.getRoot();
+
+ // Search paths for templates
+ var searchPaths = listSearchPaths(output);
+ var tplSearchPaths = searchPaths.map(templateFolder);
+
+ // Create loader
+ var loader = new Templating.ThemesLoader(tplSearchPaths);
+
+ // Get languages
+ var language = config.get('language');
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+ return TemplateEngine.create({
+ loader: loader,
+
+ context: context,
+
+ filters: defaultFilters.merge({
+ /**
+ Translate a sentence
+ */
+ t: function t(s) {
+ return i18n.t(language, s);
+ },
+
+ /**
+ Resolve an absolute file path into a
+ relative path.
+ it also resolve pages
+ */
+ resolveFile: function(filePath) {
+ filePath = resolveFileToURL(output, filePath);
+ return LocationUtils.relativeForFile(currentFile, filePath);
+ },
+
+ resolveAsset: function(filePath) {
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+ filePath = path.join('gitbook', filePath);
+ filePath = LocationUtils.relativeForFile(currentFile, filePath);
+
+ // Use assets from parent if language book
+ if (book.isLanguageBook()) {
+ filePath = path.join('../', filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+ },
+
+ /**
+ Check if a file exists
+ */
+ fileExists: function(fileName) {
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+ return fs.existsSync(filePath);
+ },
+
+ contentURL: function(filePath) {
+ return fileToURL(output, filePath);
+ },
+
+ /**
+ Return an article by its path
+ */
+ getArticleByPath: function(s) {
+ var article = summary.getByPath(s);
+ if (!article) return undefined;
+ return JSONUtils.encodeSummaryArticle(article);
+ }
+ }),
+
+ extensions: {
+ 'DoExtension': new DoExtension()
+ }
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/lib/output/website/index.js b/lib/output/website/index.js
index 0a8618c..7818a28 100644
--- a/lib/output/website/index.js
+++ b/lib/output/website/index.js
@@ -1,225 +1,11 @@
-var _ = require('lodash');
-var path = require('path');
-var util = require('util');
-var I18n = require('i18n-t');
-var Promise = require('../../utils/promise');
-var location = require('../../utils/location');
-var fs = require('../../utils/fs');
-var conrefsLoader = require('../conrefs');
-var Output = require('../base');
-var setupTemplateEnv = require('./templateEnv');
-
-function _WebsiteOutput() {
- Output.apply(this, arguments);
-
- // Nunjucks environment
- this.env;
-
- // Plugin instance for the main theme
- this.theme;
-
- // Plugin instance for the default theme
- this.defaultTheme;
-
- // Resources loaded from plugins
- this.resources;
-
- // i18n for themes
- this.i18n = new I18n();
-}
-util.inherits(_WebsiteOutput, Output);
-
-var WebsiteOutput = conrefsLoader(_WebsiteOutput);
-
-// Name of the generator
-// It's being used as a prefix for templates
-WebsiteOutput.prototype.name = 'website';
-
-// Load and setup the theme
-WebsiteOutput.prototype.prepare = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return WebsiteOutput.super_.prototype.prepare.apply(that);
- })
-
- .then(function() {
- // This list is ordered to give priority to templates in the book
- var searchPaths = _.pluck(that.plugins.list(), 'root');
-
- // The book itself can contains a "_layouts" folder
- searchPaths.unshift(that.book.root);
-
- // Load i18n
- _.each(searchPaths.concat().reverse(), function(searchPath) {
- var i18nRoot = path.resolve(searchPath, '_i18n');
-
- if (!fs.existsSync(i18nRoot)) return;
- that.i18n.load(i18nRoot);
- });
-
- that.searchPaths = searchPaths;
- })
-
- // Copy assets from themes before copying files from book
- .then(function() {
- if (that.book.isLanguageBook()) return;
-
- // Assets from the book are already copied
- // Copy assets from plugins (start with default plugins)
- return Promise.serie(that.plugins.list().reverse(), function(plugin) {
- // Copy assets only if exists (don't fail otherwise)
- var assetFolder = path.join(plugin.root, '_assets', that.name);
- if (!fs.existsSync(assetFolder)) return;
-
- that.log.debug.ln('copy assets from theme', assetFolder);
- return fs.copyDir(
- assetFolder,
- that.resolve('gitbook'),
- {
- deleteFirst: false,
- overwrite: true,
- confirm: true
- }
- );
- });
- })
-
- // Load resources for plugins
- .then(function() {
- return that.plugins.getResources(that.name)
- .then(function(resources) {
- that.resources = resources;
- });
- });
-};
-
-// Write a page (parsable file)
-WebsiteOutput.prototype.onPage = function(page) {
- var that = this;
-
- // Parse the page
- return page.toHTML(this)
-
- // Render the page template with the same context as the json output
- .then(function() {
- return that.render('page', that.outputPath(page.path), page.getOutputContext(that));
- });
-};
-
-// Finish generation, create ebook using ebook-convert
-WebsiteOutput.prototype.finish = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return WebsiteOutput.super_.prototype.finish.apply(that);
- })
-
- // Copy assets from plugins
- .then(function() {
- if (that.book.isLanguageBook()) return;
- return that.plugins.copyResources(that.name, that.resolve('gitbook'));
- })
-
- // Generate homepage to select languages
- .then(function() {
- if (!that.book.isMultilingual()) return;
- return that.outputMultilingualIndex();
- });
-};
-
-// ----- Utilities ----
-
-// Write multi-languages index
-WebsiteOutput.prototype.outputMultilingualIndex = function() {
- var that = this;
-
- return that.render('languages', 'index.html', that.getContext());
-};
-
-/*
- Render a template as an HTML string
- Templates are stored in `_layouts` folders
-
-
- @param {String} tpl: template name (ex: "page")
- @param {String} outputFile: filename to write, relative to the output
- @param {Object} context: context for the page
- @return {Promise}
-*/
-WebsiteOutput.prototype.renderAsString = function(tpl, context) {
- // Calcul template name
- var filename = this.templateName(tpl);
-
- context = _.extend(context, {
- plugins: {
- resources: this.resources
- },
-
- options: this.opts
- });
-
- // Create environment
- var env = setupTemplateEnv(this, context);
-
- return Promise.nfcall(env.render.bind(env), filename, context);
+module.exports = {
+ name: 'website',
+ State: require('./state'),
+ Options: require('./options'),
+ onInit: require('./onInit'),
+ onFinish: require('./onFinish'),
+ onPage: require('./onPage'),
+ onAsset: require('./onAsset'),
+ createTemplateEngine: require('./createTemplateEngine')
};
-
-/*
- Render a template using nunjucks
- Templates are stored in `_layouts` folders
-
-
- @param {String} tpl: template name (ex: "page")
- @param {String} outputFile: filename to write, relative to the output
- @param {Object} context: context for the page
- @return {Promise}
-*/
-WebsiteOutput.prototype.render = function(tpl, outputFile, context) {
- var that = this;
-
- // Calcul relative path to the root
- var outputDirName = path.dirname(outputFile);
- var basePath = location.normalize(path.relative(outputDirName, './'));
-
- // Setup complete context
- context = _.extend(context, {
- basePath: basePath,
-
- template: {
- getJSContext: function() {
- return {
- page: _.omit(context.page, 'content'),
- config: context.config,
- file: context.file,
- gitbook: context.gitbook,
- basePath: basePath,
- book: {
- language: context.book.language
- }
- };
- }
- }
- });
-
- return this.renderAsString(tpl, context)
- .then(function(html) {
- return that.writeFile(
- outputFile,
- html
- );
- });
-};
-
-// Return a complete name for a template
-WebsiteOutput.prototype.templateName = function(name) {
- return path.join(this.name, name+'.html');
-};
-
-module.exports = WebsiteOutput;
-
-
-
diff --git a/lib/output/website/listSearchPaths.js b/lib/output/website/listSearchPaths.js
new file mode 100644
index 0000000..c45f39c
--- /dev/null
+++ b/lib/output/website/listSearchPaths.js
@@ -0,0 +1,23 @@
+
+/**
+ List search paths for templates / i18n, etc
+
+ @param {Output} output
+ @return {List<String>}
+*/
+function listSearchPaths(output) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+
+ var searchPaths = plugins
+ .valueSeq()
+ .map(function(plugin) {
+ return plugin.getPath();
+ })
+ .toList();
+
+ return searchPaths.unshift(book.getContentRoot());
+}
+
+
+module.exports = listSearchPaths;
diff --git a/lib/output/website/onAsset.js b/lib/output/website/onAsset.js
new file mode 100644
index 0000000..17b6ba7
--- /dev/null
+++ b/lib/output/website/onAsset.js
@@ -0,0 +1,27 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Copy an asset to the output folder
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onAsset(output, asset) {
+ var book = output.getBook();
+ var options = output.getOptions();
+
+ var rootFolder = book.getContentRoot();
+ var outputFolder = options.get('root');
+
+ var filePath = path.resolve(rootFolder, asset);
+ var outputPath = path.resolve(outputFolder, asset);
+
+ return fs.ensureFile(outputPath)
+ .then(function() {
+ return fs.copy(filePath, outputPath);
+ })
+ .thenResolve(output);
+}
+
+module.exports = onAsset;
diff --git a/lib/output/website/onFinish.js b/lib/output/website/onFinish.js
new file mode 100644
index 0000000..e3560e2
--- /dev/null
+++ b/lib/output/website/onFinish.js
@@ -0,0 +1,35 @@
+var Promise = require('../../utils/promise');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var writeFile = require('../helper/writeFile');
+var createTemplateEngine = require('./createTemplateEngine');
+
+/**
+ Finish the generation, write the languages index
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ var filePath = 'index.html';
+ var engine = createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/languages.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+}
+
+module.exports = onFinish;
diff --git a/lib/output/website/onInit.js b/lib/output/website/onInit.js
new file mode 100644
index 0000000..979a90d
--- /dev/null
+++ b/lib/output/website/onInit.js
@@ -0,0 +1,18 @@
+var Promise = require('../../utils/promise');
+
+var copyPluginAssets = require('./copyPluginAssets');
+var prepareI18n = require('./prepareI18n');
+
+/**
+ Initialize the generator
+
+ @param {Output}
+ @return {Output}
+*/
+function onInit(output) {
+ return Promise(output)
+ .then(prepareI18n)
+ .then(copyPluginAssets);
+}
+
+module.exports = onInit;
diff --git a/lib/output/website/onPage.js b/lib/output/website/onPage.js
new file mode 100644
index 0000000..64b4e04
--- /dev/null
+++ b/lib/output/website/onPage.js
@@ -0,0 +1,72 @@
+var path = require('path');
+var omit = require('omit-keys');
+
+var Templating = require('../../templating');
+var Plugins = require('../../plugins');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+var createTemplateEngine = require('./createTemplateEngine');
+var fileToOutput = require('../helper/fileToOutput');
+
+/**
+ Write a page as a json file
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onPage(output, page) {
+ var options = output.getOptions();
+ var file = page.getFile();
+ var prefix = options.get('prefix');
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+
+ var engine = createTemplateEngine(output, page.getPath());
+
+ // Output file path
+ var filePath = fileToOutput(output, file.getPath());
+
+ // Calcul relative path to the root
+ var outputDirName = path.dirname(filePath);
+ var basePath = LocationUtils.normalize(path.relative(outputDirName, './'));
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the context
+ var context = JSONUtils.encodeBookWithPage(output.getBook(), resultPage);
+ context.plugins = {
+ resources: Plugins.listResources(plugins, prefix).toJS()
+ };
+
+ context.template = {
+ getJSContext: function() {
+ return {
+ page: omit(context.page, 'content'),
+ config: context.config,
+ file: context.file,
+ gitbook: context.gitbook,
+ basePath: basePath,
+ book: {
+ language: book.getLanguage()
+ }
+ };
+ }
+ };
+
+ // We should probabbly move it to "template" or a "site" namespace
+ context.basePath = basePath;
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/page.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/website/options.js b/lib/output/website/options.js
new file mode 100644
index 0000000..ac9cdad
--- /dev/null
+++ b/lib/output/website/options.js
@@ -0,0 +1,14 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('website'),
+
+ // Use directory index url instead of "index.html"
+ directoryIndex: Boolean(true)
+});
+
+module.exports = Options;
diff --git a/lib/output/website/prepareI18n.js b/lib/output/website/prepareI18n.js
new file mode 100644
index 0000000..b57d178
--- /dev/null
+++ b/lib/output/website/prepareI18n.js
@@ -0,0 +1,30 @@
+var path = require('path');
+
+var fs = require('../../utils/fs');
+var Promise = require('../../utils/promise');
+var listSearchPaths = require('./listSearchPaths');
+
+/**
+ Prepare i18n, load translations from plugins and book
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareI18n(output) {
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var searchPaths = listSearchPaths(output);
+
+ searchPaths
+ .reverse()
+ .forEach(function(searchPath) {
+ var i18nRoot = path.resolve(searchPath, '_i18n');
+
+ if (!fs.existsSync(i18nRoot)) return;
+ i18n.load(i18nRoot);
+ });
+
+ return Promise(output);
+}
+
+module.exports = prepareI18n;
diff --git a/lib/output/website/state.js b/lib/output/website/state.js
new file mode 100644
index 0000000..99e7f04
--- /dev/null
+++ b/lib/output/website/state.js
@@ -0,0 +1,12 @@
+var I18n = require('i18n-t');
+var Immutable = require('immutable');
+
+var GeneratorState = Immutable.Record({
+ i18n: I18n()
+});
+
+GeneratorState.prototype.getI18n = function() {
+ return this.get('i18n');
+};
+
+module.exports = GeneratorState;
diff --git a/lib/output/website/templateEnv.js b/lib/output/website/templateEnv.js
deleted file mode 100644
index d385108..0000000
--- a/lib/output/website/templateEnv.js
+++ /dev/null
@@ -1,95 +0,0 @@
-var _ = require('lodash');
-var nunjucks = require('nunjucks');
-var path = require('path');
-var fs = require('fs');
-var DoExtension = require('nunjucks-do')(nunjucks);
-
-
-var location = require('../../utils/location');
-var defaultFilters = require('../../template/filters');
-
-var ThemeLoader = require('./themeLoader');
-
-// Directory for a theme with the templates
-function templatesPath(dir) {
- return path.join(dir, '_layouts');
-}
-
-/*
- Create and setup at Nunjucks template environment
-
- @return {Nunjucks.Environment}
-*/
-function setupTemplateEnv(output, context) {
- context = _.defaults(context || {}, {
- // Required by ThemeLoader
- template: {}
- });
-
- var loader = new ThemeLoader(
- _.map(output.searchPaths, templatesPath)
- );
- var env = new nunjucks.Environment(loader);
-
- env.addExtension('DoExtension', new DoExtension());
-
- // Add context as global
- _.each(context, function(value, key) {
- env.addGlobal(key, value);
- });
-
- // Add GitBook default filters
- _.each(defaultFilters, function(fn, filter) {
- env.addFilter(filter, fn);
- });
-
- // Translate using _i18n locales
- env.addFilter('t', function t(s) {
- return output.i18n.t(output.book.config.get('language'), s);
- });
-
- // Transform an absolute path into a relative path
- // using this.ctx.page.path
- env.addFilter('resolveFile', function resolveFile(href) {
- return location.normalize(output.resolveForPage(context.file.path, href));
- });
-
- // Test if a file exists
- env.addFilter('fileExists', function fileExists(href) {
- return fs.existsSync(output.resolve(href));
- });
-
- // Transform a '.md' into a '.html' (README -> index)
- env.addFilter('contentURL', function contentURL(s) {
- return output.toURL(s);
- });
-
- // Get an article using its path
- env.addFilter('getArticleByPath', function getArticleByPath(s) {
- var article = output.book.summary.getArticle(s);
- if (!article) return undefined;
-
- return article.getContext();
- });
-
- // Relase path to an asset
- env.addFilter('resolveAsset', function resolveAsset(href) {
- href = path.join('gitbook', href);
-
- // Resolve for current file
- if (context.file) {
- href = output.resolveForPage(context.file.path, '/' + href);
- }
-
- // Use assets from parent
- if (output.book.isLanguageBook()) {
- href = path.join('../', href);
- }
-
- return location.normalize(href);
- });
-
- return env;
-}
-
-module.exports = setupTemplateEnv;
diff --git a/lib/page/html.js b/lib/page/html.js
deleted file mode 100644
index e8d3a85..0000000
--- a/lib/page/html.js
+++ /dev/null
@@ -1,290 +0,0 @@
-var _ = require('lodash');
-var url = require('url');
-var cheerio = require('cheerio');
-var domSerializer = require('dom-serializer');
-var slug = require('github-slugid');
-
-var Promise = require('../utils/promise');
-var location = require('../utils/location');
-
-// Selector to ignore
-var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6';
-
-function HTMLPipeline(htmlString, opts) {
- _.bindAll(this);
-
- this.opts = _.defaults(opts || {}, {
- // Called once the description has been found
- onDescription: function(description) { },
-
- // Calcul new href for a relative link
- onRelativeLink: _.identity,
-
- // Output an image
- onImage: _.identity,
-
- // Syntax highlighting
- onCodeBlock: _.identity,
-
- // Output a svg, if returns null the svg is kept inlined
- onOutputSVG: _.constant(null),
-
- // Words to annotate
- annotations: [],
-
- // When an annotation is applied
- onAnnotation: function () { }
- });
-
- this.$ = cheerio.load(htmlString, {
- // We should parse html without trying to normalize too much
- xmlMode: false,
-
- // SVG need some attributes to use uppercases
- lowerCaseAttributeNames: false,
- lowerCaseTags: false
- });
-}
-
-// Transform a query of elements in the page
-HTMLPipeline.prototype._transform = function(query, fn) {
- var that = this;
-
- var $elements = this.$(query);
-
- return Promise.serie($elements, function(el) {
- var $el = that.$(el);
- return fn.call(that, $el);
- });
-};
-
-// Normalize links
-HTMLPipeline.prototype.transformLinks = function() {
- return this._transform('a', function($a) {
- var href = $a.attr('href');
- if (!href) return;
-
- if (location.isAnchor(href)) {
- // Don't "change" anchor links
- } else if (location.isRelative(href)) {
- // Preserve anchor
- var parsed = url.parse(href);
- var filename = this.opts.onRelativeLink(parsed.pathname);
-
- $a.attr('href', filename + (parsed.hash || ''));
- } else {
- // External links
- $a.attr('target', '_blank');
- }
- });
-};
-
-// Normalize images
-HTMLPipeline.prototype.transformImages = function() {
- return this._transform('img', function($img) {
- return Promise(this.opts.onImage($img.attr('src')))
- .then(function(filename) {
- $img.attr('src', filename);
- });
- });
-};
-
-// Normalize code blocks
-HTMLPipeline.prototype.transformCodeBlocks = function() {
- return this._transform('code', function($code) {
- // Extract language
- var lang = _.chain(
- ($code.attr('class') || '').split(' ')
- )
- .map(function(cl) {
- // Markdown
- if (cl.search('lang-') === 0) return cl.slice('lang-'.length);
-
- // Asciidoc
- if (cl.search('language-') === 0) return cl.slice('language-'.length);
-
- return null;
- })
- .compact()
- .first()
- .value();
-
- var source = $code.text();
-
- return Promise(this.opts.onCodeBlock(source, lang))
- .then(function(blk) {
- if (blk.html === false) {
- $code.text(blk.body);
- } else {
- $code.html(blk.body);
- }
- });
- });
-};
-
-// Add ID to headings
-HTMLPipeline.prototype.transformHeadings = function() {
- var that = this;
-
- this.$('h1,h2,h3,h4,h5,h6').each(function() {
- var $h = that.$(this);
-
- // Already has an ID?
- if ($h.attr('id')) return;
- $h.attr('id', slug($h.text()));
- });
-};
-
-// Outline SVG from the HML
-HTMLPipeline.prototype.transformSvgs = function() {
- var that = this;
-
- return this._transform('svg', function($svg) {
- var content = [
- '<?xml version="1.0" encoding="UTF-8"?>',
- renderDOM(that.$, $svg)
- ].join('\n');
-
- return Promise(that.opts.onOutputSVG(content))
- .then(function(filename) {
- if (!filename) return;
-
- $svg.replaceWith(that.$('<img>').attr('src', filename));
- });
- });
-};
-
-// Annotate the content
-HTMLPipeline.prototype.applyAnnotations = function() {
- var that = this;
-
- _.each(this.opts.annotations, function(annotation) {
- var searchRegex = new RegExp( '\\b(' + pregQuote(annotation.name.toLowerCase()) + ')\\b' , 'gi' );
-
- that.$('*').each(function() {
- var $this = that.$(this);
-
- if (
- $this.is(ANNOTATION_IGNORE) ||
- $this.parents(ANNOTATION_IGNORE).length > 0
- ) return;
-
- replaceText(that.$, this, searchRegex, function(match) {
- that.opts.onAnnotation(annotation);
-
- return '<a href="' + that.opts.onRelativeLink(annotation.href) + '" '
- + 'class="glossary-term" title="'+_.escape(annotation.description)+'">'
- + match
- + '</a>';
- });
- });
- });
-};
-
-// Extract page description from html
-// This can totally be improved
-HTMLPipeline.prototype.extractDescription = function() {
- var $ = this.$;
- var $p = $('p').first();
- var $next = $p.nextUntil('h1,h2,h3,h4,h5,h6,pre,blockquote,ul,ol,div');
-
- var description = $p.text().trim();
-
- $next.each(function() {
- description += ' ' + $(this).text().trim();
- });
-
- // Truncate description
- description = _.trunc(description, 300);
-
- this.opts.onDescription(description);
-};
-
-// Write content to the pipeline
-HTMLPipeline.prototype.output = function() {
- var that = this;
-
- return Promise()
- .then(this.extractDescription)
- .then(this.transformImages)
- .then(this.transformHeadings)
- .then(this.transformCodeBlocks)
- .then(this.transformSvgs)
- .then(this.applyAnnotations)
-
- // Transform of links should be applied after annotations
- // because annotations are created as links
- .then(this.transformLinks)
-
- .then(function() {
- return renderDOM(that.$);
- });
-};
-
-
-// Render a cheerio DOM as html
-function renderDOM($, dom, options) {
- if (!dom && $._root && $._root.children) {
- dom = $._root.children;
- }
- options = options|| dom.options || $._options;
- return domSerializer(dom, options);
-}
-
-// Replace text in an element
-function replaceText($, el, search, replace, text_only ) {
- return $(el).each(function(){
- var node = this.firstChild,
- val,
- new_val,
-
- // Elements to be removed at the end.
- remove = [];
-
- // Only continue if firstChild exists.
- if ( node ) {
-
- // Loop over all childNodes.
- while (node) {
-
- // Only process text nodes.
- if ( node.nodeType === 3 ) {
-
- // The original node value.
- val = node.nodeValue;
-
- // The new value.
- new_val = val.replace( search, replace );
-
- // Only replace text if the new value is actually different!
- if ( new_val !== val ) {
-
- if ( !text_only && /</.test( new_val ) ) {
- // The new value contains HTML, set it in a slower but far more
- // robust way.
- $(node).before( new_val );
-
- // Don't remove the node yet, or the loop will lose its place.
- remove.push( node );
- } else {
- // The new value contains no HTML, so it can be set in this
- // very fast, simple way.
- node.nodeValue = new_val;
- }
- }
- }
-
- node = node.nextSibling;
- }
- }
-
- // Time to remove those elements!
- if (remove.length) $(remove).remove();
- });
-}
-
-function pregQuote( str ) {
- return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
-}
-
-module.exports = HTMLPipeline;
diff --git a/lib/page/index.js b/lib/page/index.js
deleted file mode 100644
index f0d7f57..0000000
--- a/lib/page/index.js
+++ /dev/null
@@ -1,246 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var direction = require('direction');
-var fm = require('front-matter');
-
-var error = require('../utils/error');
-var pathUtil = require('../utils/path');
-var location = require('../utils/location');
-var parsers = require('../parsers');
-var pluginCompatibility = require('../plugins/compatibility');
-var HTMLPipeline = require('./html');
-
-/*
-A page represent a parsable file in the book (Markdown, Asciidoc, etc)
-*/
-
-function Page(book, filename) {
- if (!(this instanceof Page)) return new Page(book, filename);
- var extension;
- _.bindAll(this);
-
- this.book = book;
- this.log = this.book.log;
-
- // Map of attributes from YAML frontmatter
- // Description is also extracted by default from content
- this.attributes = {};
-
- // Current content
- this.content = '';
-
- // Relative path to the page
- this.path = location.normalize(filename);
-
- // Absolute path to the page
- this.rawPath = this.book.resolve(filename);
-
- // Last modification date
- this.mtime = 0;
-
- // Can we parse it?
- extension = path.extname(this.path);
- this.parser = parsers.getByExt(extension);
- if (!this.parser) throw error.ParsingError(new Error('Can\'t parse file "'+this.path+'"'));
-
- this.type = this.parser.name;
-}
-
-// Return the filename of the page with another extension
-// "README.md" -> "README.html"
-Page.prototype.withExtension = function(ext) {
- return pathUtil.setExtension(this.path, ext);
-};
-
-// Resolve a filename relative to this page
-// It returns a path relative to the book root folder
-Page.prototype.resolveLocal = function() {
- var dir = path.dirname(this.path);
- var file = path.join.apply(path, _.toArray(arguments));
-
- return location.toAbsolute(file, dir, '');
-};
-
-// Resolve a filename relative to this page
-// It returns an absolute path for the FS
-Page.prototype.resolve = function() {
- return this.book.resolve(this.resolveLocal.apply(this, arguments));
-};
-
-// Convert an absolute path (in the book) to a relative path from this page
-Page.prototype.relative = function(name) {
- // Convert /test.png -> test.png
- name = location.toAbsolute(name, '', '');
-
- return location.relative(
- this.resolve('.') + '/',
- this.book.resolve(name)
- );
-};
-
-// Return a page result of a relative page from this page
-Page.prototype.followPage = function(filename) {
- var absPath = this.resolveLocal(filename);
- return this.book.getPage(absPath);
-};
-
-// Update content of the page
-Page.prototype.update = function(content) {
- this.content = content;
-};
-
-// Read the page as a string
-Page.prototype.read = function() {
- var that = this;
-
- return this.book.statFile(this.path)
- .then(function(stat) {
- that.mtime = stat.mtime;
- return that.book.readFile(that.path);
- })
- .then(this.update);
-};
-
-// Return templating context for this page
-// This is used both for themes and page parsing
-Page.prototype.getContext = function() {
- var article = this.book.summary.getArticle(this);
- var next = article? article.next() : null;
- var prev = article? article.prev() : null;
-
- // Detect text direction in this page
- var dir = this.book.config.get('direction');
- if (!dir) {
- dir = direction(this.content);
- if (dir == 'neutral') dir = null;
- }
-
- return {
- file: {
- path: this.path,
- mtime: this.mtime,
- type: this.type
- },
- page: _.extend({}, this.attributes, {
- title: article? article.title : null,
- next: next? next.getContext() : null,
- previous: prev? prev.getContext() : null,
- level: article? article.level : null,
- depth: article? article.depth() : 0,
- content: this.content,
- dir: dir
- })
- };
-};
-
-// Return complete context for templating (page + book + summary + ...)
-Page.prototype.getOutputContext = function(output) {
- return _.extend({}, this.getContext(), output.getContext());
-};
-
-// Parse the page and return its content
-Page.prototype.toHTML = function(output) {
- var that = this;
-
- this.log.debug.ln('start parsing file', this.path);
-
- // Call a hook in the output
- // using an utility to "keep" compatibility with gitbook 2
- function hook(name) {
- return pluginCompatibility.pageHook(that, function(ctx) {
- return output.plugins.hook(name, ctx);
- })
- .then(function(result) {
- if(_.isString(result)) that.update(result);
- });
- }
-
- return this.read()
-
- // Parse yaml front matter
- .then(function() {
- var parsed = fm(that.content);
-
- // Extract attributes
- that.attributes = parsed.attributes;
-
- // Keep only the body
- that.update(parsed.body);
- })
-
- .then(function() {
- return hook('page:before');
- })
-
- // Pre-process page with parser
- .then(function() {
- return that.parser.page.prepare(that.content)
- .then(that.update);
- })
-
- // Render template
- .then(function() {
- return output.template.render(that.content, that.getOutputContext(output), {
- path: that.path
- })
- .then(that.update);
- })
-
- // Render markup using the parser
- .then(function() {
- return that.parser.page(that.content)
- .then(function(out) {
- that.update(out.content);
- });
- })
-
- // Post process templating
- .then(function() {
- return output.template.postProcess(that.content)
- .then(that.update);
- })
-
- // Normalize HTML output
- .then(function() {
- var pipelineOpts = {
- onRelativeLink: _.partial(output.onRelativeLink, that),
- onImage: _.partial(output.onOutputImage, that),
- onOutputSVG: _.partial(output.onOutputSVG, that),
-
- // Use 'code' template block
- onCodeBlock: function(source, lang) {
- return output.template.applyBlock('code', {
- body: source,
- kwargs: {
- language: lang
- }
- });
- },
-
- // Extract description from page's content if no frontmatter
- onDescription: function(description) {
- if (that.attributes.description) return;
- that.attributes.description = description;
- },
-
- // Convert glossary entries to annotations
- annotations: that.book.glossary.annotations()
- };
- var pipeline = new HTMLPipeline(that.content, pipelineOpts);
-
- return pipeline.output()
- .then(that.update);
- })
-
- .then(function() {
- return hook('page');
- })
-
- // Return content itself
- .then(function() {
- return that.content;
- });
-};
-
-
-module.exports = Page;
diff --git a/lib/parse/__tests__/parseBook.js b/lib/parse/__tests__/parseBook.js
new file mode 100644
index 0000000..25d1802
--- /dev/null
+++ b/lib/parse/__tests__/parseBook.js
@@ -0,0 +1,55 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseBook', function() {
+ var parseBook = require('../parseBook');
+
+ pit('should parse multilingual book', function() {
+ var fs = createMockFS({
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ var languages = resultBook.getLanguages();
+ var books = resultBook.getBooks();
+
+ expect(resultBook.isMultilingual()).toBe(true);
+ expect(languages.getList().size).toBe(2);
+ expect(books.size).toBe(2);
+ });
+ });
+
+ pit('should parse book in a directory', function() {
+ var fs = createMockFS({
+ 'book.json': JSON.stringify({
+ root: './test'
+ }),
+ 'test': {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](page.md)\n',
+ 'page.md': 'Page'
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ var readme = resultBook.getReadme();
+ var summary = resultBook.getSummary();
+ var articles = summary.getArticlesAsList();
+
+ expect(summary.getFile().exists()).toBe(true);
+ expect(readme.getFile().exists()).toBe(true);
+ expect(articles.size).toBe(2);
+ });
+ });
+
+});
diff --git a/lib/parse/__tests__/parseGlossary.js b/lib/parse/__tests__/parseGlossary.js
new file mode 100644
index 0000000..53805fe
--- /dev/null
+++ b/lib/parse/__tests__/parseGlossary.js
@@ -0,0 +1,36 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseGlossary', function() {
+ var parseGlossary = require('../parseGlossary');
+
+ pit('should parse glossary if exists', function() {
+ var fs = createMockFS({
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ var glossary = resultBook.getGlossary();
+ var file = glossary.getFile();
+ var entries = glossary.getEntries();
+
+ expect(file.exists()).toBeTruthy();
+ expect(entries.size).toBe(1);
+ });
+ });
+
+ pit('should not fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ var glossary = resultBook.getGlossary();
+ var file = glossary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/lib/parse/__tests__/parseIgnore.js b/lib/parse/__tests__/parseIgnore.js
new file mode 100644
index 0000000..bee4236
--- /dev/null
+++ b/lib/parse/__tests__/parseIgnore.js
@@ -0,0 +1,40 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseIgnore', function() {
+ var parseIgnore = require('../parseIgnore');
+ var fs = createMockFS({
+ '.ignore': 'test-1.js',
+ '.gitignore': 'test-2.js\ntest-3.js',
+ '.bookignore': '!test-3.js',
+ 'test-1.js': '1',
+ 'test-2.js': '2',
+ 'test-3.js': '3'
+ });
+
+ function getBook() {
+ var book = Book.createForFS(fs);
+ return parseIgnore(book);
+ }
+
+ pit('should load rules from .ignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-1.js')).toBeTruthy();
+ });
+ });
+
+ pit('should load rules from .gitignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-2.js')).toBeTruthy();
+ });
+ });
+
+ pit('should load rules from .bookignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-3.js')).toBeFalsy();
+ });
+ });
+});
diff --git a/lib/parse/__tests__/parseReadme.js b/lib/parse/__tests__/parseReadme.js
new file mode 100644
index 0000000..1b1567b
--- /dev/null
+++ b/lib/parse/__tests__/parseReadme.js
@@ -0,0 +1,36 @@
+var Promise = require('../../utils/promise');
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseReadme', function() {
+ var parseReadme = require('../parseReadme');
+
+ pit('should parse summary if exists', function() {
+ var fs = createMockFS({
+ 'README.md': '# Hello\n\nAnd here is the description.'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseReadme(book)
+ .then(function(resultBook) {
+ var readme = resultBook.getReadme();
+ var file = readme.getFile();
+
+ expect(file.exists()).toBeTruthy();
+ expect(readme.getTitle()).toBe('Hello');
+ expect(readme.getDescription()).toBe('And here is the description.');
+ });
+ });
+
+ pit('should fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseReadme(book)
+ .then(function(resultBook) {
+ throw new Error('It should have fail');
+ }, function() {
+ return Promise();
+ });
+ });
+});
diff --git a/lib/parse/__tests__/parseSummary.js b/lib/parse/__tests__/parseSummary.js
new file mode 100644
index 0000000..4b4650d
--- /dev/null
+++ b/lib/parse/__tests__/parseSummary.js
@@ -0,0 +1,34 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseSummary', function() {
+ var parseSummary = require('../parseSummary');
+
+ pit('should parse summary if exists', function() {
+ var fs = createMockFS({
+ 'SUMMARY.md': '# Summary\n\n* [Hello](hello.md)'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ var summary = resultBook.getSummary();
+ var file = summary.getFile();
+
+ expect(file.exists()).toBeTruthy();
+ });
+ });
+
+ pit('should not fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ var summary = resultBook.getSummary();
+ var file = summary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/lib/parse/findParsableFile.js b/lib/parse/findParsableFile.js
new file mode 100644
index 0000000..4434d64
--- /dev/null
+++ b/lib/parse/findParsableFile.js
@@ -0,0 +1,36 @@
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var parsers = require('../parsers');
+
+/**
+ Find a file parsable (Markdown or AsciiDoc) in a book
+
+ @param {Book} book
+ @param {String} filename
+ @return {Promise<>}
+*/
+function findParsableFile(book, filename) {
+ var fs = book.getContentFS();
+ var ext = path.extname(filename);
+ var basename = path.basename(filename, ext);
+ var basedir = path.dirname(filename);
+
+ // Ordered list of extensions to test
+ var exts = parsers.extensions;
+
+ return Promise.some(exts, function(ext) {
+ var filepath = basename + ext;
+
+ return fs.findFile(basedir, filepath)
+ .then(function(found) {
+ if (!found || book.isContentFileIgnored(found)) {
+ return undefined;
+ }
+
+ return fs.statFile(found);
+ });
+ });
+}
+
+module.exports = findParsableFile;
diff --git a/lib/parse/index.js b/lib/parse/index.js
new file mode 100644
index 0000000..ac27fcf
--- /dev/null
+++ b/lib/parse/index.js
@@ -0,0 +1,13 @@
+
+module.exports = {
+ parseBook: require('./parseBook'),
+ parseSummary: require('./parseSummary'),
+ parseGlossary: require('./parseGlossary'),
+ parseReadme: require('./parseReadme'),
+ parseConfig: require('./parseConfig'),
+ parsePagesList: require('./parsePagesList'),
+ parseIgnore: require('./parseIgnore'),
+ listAssets: require('./listAssets'),
+ parseLanguages: require('./parseLanguages'),
+ parsePage: require('./parsePage')
+};
diff --git a/lib/parse/listAssets.js b/lib/parse/listAssets.js
new file mode 100644
index 0000000..c43b054
--- /dev/null
+++ b/lib/parse/listAssets.js
@@ -0,0 +1,36 @@
+var timing = require('../utils/timing');
+
+/**
+ List all assets in a book
+ Assets are file not ignored and not a page
+
+ @param {Book} book
+ @param {List<String>} pages
+ @param
+*/
+function listAssets(book, pages) {
+ var fs = book.getContentFS();
+
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile().getPath();
+
+ var glossary = book.getGlossary();
+ var glossaryFile = glossary.getFile().getPath();
+
+ return timing.measure(
+ 'parse.listAssets',
+ fs.listAllFiles()
+ .then(function(files) {
+ return files.filterNot(function(file) {
+ return (
+ book.isContentFileIgnored(file) ||
+ pages.has(file) ||
+ file !== summaryFile ||
+ file !== glossaryFile
+ );
+ });
+ })
+ );
+}
+
+module.exports = listAssets;
diff --git a/lib/parse/parseBook.js b/lib/parse/parseBook.js
new file mode 100644
index 0000000..84a4038
--- /dev/null
+++ b/lib/parse/parseBook.js
@@ -0,0 +1,77 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var Book = require('../models/book');
+
+var parseIgnore = require('./parseIgnore');
+var parseConfig = require('./parseConfig');
+var parseGlossary = require('./parseGlossary');
+var parseSummary = require('./parseSummary');
+var parseReadme = require('./parseReadme');
+var parseLanguages = require('./parseLanguages');
+
+/**
+ Parse content of a book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseBookContent(book) {
+ return Promise(book)
+ .then(parseReadme)
+ .then(parseSummary)
+ .then(parseGlossary);
+}
+
+/**
+ Parse a multilingual book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseMultilingualBook(book) {
+ var languages = book.getLanguages();
+ var langList = languages.getList();
+
+ return Promise.reduce(langList, function(currentBook, lang) {
+ var langID = lang.getID();
+ var child = Book.createFromParent(currentBook, langID);
+ var ignore = currentBook.getIgnore();
+
+ return Promise(child)
+ .then(parseConfig)
+ .then(parseBookContent)
+ .then(function(result) {
+ // Ignore content of this book when generating parent book
+ ignore.add(langID + '/**');
+ currentBook = currentBook.set('ignore', ignore);
+
+ return currentBook.addLanguageBook(langID, result);
+ });
+ }, book);
+}
+
+
+/**
+ Parse a whole book from a filesystem
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseBook(book) {
+ return timing.measure(
+ 'parse.book',
+ Promise(book)
+ .then(parseIgnore)
+ .then(parseConfig)
+ .then(parseLanguages)
+ .then(function(resultBook) {
+ if (resultBook.isMultilingual()) {
+ return parseMultilingualBook(resultBook);
+ } else {
+ return parseBookContent(resultBook);
+ }
+ })
+ );
+}
+
+module.exports = parseBook;
diff --git a/lib/parse/parseConfig.js b/lib/parse/parseConfig.js
new file mode 100644
index 0000000..5200de2
--- /dev/null
+++ b/lib/parse/parseConfig.js
@@ -0,0 +1,51 @@
+var Promise = require('../utils/promise');
+var Config = require('../models/config');
+
+var File = require('../models/file');
+var validateConfig = require('./validateConfig');
+var CONFIG_FILES = require('../constants/configFiles');
+
+/**
+ Parse configuration from "book.json" or "book.js"
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseConfig(book) {
+ var fs = book.getFS();
+
+ return Promise.some(CONFIG_FILES, function(filename) {
+ // Is this file ignored?
+ if (book.isFileIgnored(filename)) {
+ return;
+ }
+
+ // Try loading it
+ return Promise.all([
+ fs.loadAsObject(filename),
+ fs.statFile(filename)
+ ])
+ .spread(function(cfg, file) {
+ return {
+ file: file,
+ values: cfg
+ };
+ })
+ .fail(function(err) {
+ if (err.code != 'MODULE_NOT_FOUND') throw(err);
+ else return Promise(false);
+ });
+ })
+
+ .then(function(result) {
+ var file = result? result.file : File();
+ var values = result? result.values : {};
+
+ values = validateConfig(values);
+
+ var config = Config.create(file, values);
+ return book.set('config', config);
+ });
+}
+
+module.exports = parseConfig;
diff --git a/lib/parse/parseGlossary.js b/lib/parse/parseGlossary.js
new file mode 100644
index 0000000..a96e5fc
--- /dev/null
+++ b/lib/parse/parseGlossary.js
@@ -0,0 +1,26 @@
+var parseStructureFile = require('./parseStructureFile');
+var Glossary = require('../models/glossary');
+
+/**
+ Parse glossary
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseGlossary(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'glossary')
+ .spread(function(file, entries) {
+ if (!file) {
+ return book;
+ }
+
+ logger.debug.ln('glossary index file found at', file.getPath());
+
+ var glossary = Glossary.createFromEntries(file, entries);
+ return book.set('glossary', glossary);
+ });
+}
+
+module.exports = parseGlossary;
diff --git a/lib/parse/parseIgnore.js b/lib/parse/parseIgnore.js
new file mode 100644
index 0000000..b23bfd8
--- /dev/null
+++ b/lib/parse/parseIgnore.js
@@ -0,0 +1,50 @@
+var Promise = require('../utils/promise');
+var IGNORE_FILES = require('../constants/ignoreFiles');
+
+/**
+ Parse ignore files
+
+ @param {Book}
+ @return {Book}
+*/
+function parseIgnore(book) {
+ if (book.isLanguageBook()) {
+ return Promise.reject(new Error('Ignore files could be parsed for language books'));
+ }
+
+ var fs = book.getFS();
+ var ignore = book.getIgnore();
+
+ ignore.addPattern([
+ // Skip Git stuff
+ '.git/',
+
+ // Skip OS X meta data
+ '.DS_Store',
+
+ // Skip stuff installed by plugins
+ 'node_modules',
+
+ // Skip book outputs
+ '_book',
+ '*.pdf',
+ '*.epub',
+ '*.mobi',
+
+ // Ignore files in the templates folder
+ '_layouts'
+ ]);
+
+ return Promise.serie(IGNORE_FILES, function(filename) {
+ return fs.readAsString(filename)
+ .then(function(content) {
+ ignore.addPattern(content.toString().split(/\r?\n/));
+ }, function(err) {
+ return Promise();
+ });
+ })
+
+ .thenResolve(book);
+}
+
+module.exports = parseIgnore;
diff --git a/lib/parse/parseLanguages.js b/lib/parse/parseLanguages.js
new file mode 100644
index 0000000..346f3a3
--- /dev/null
+++ b/lib/parse/parseLanguages.js
@@ -0,0 +1,28 @@
+var parseStructureFile = require('./parseStructureFile');
+var Languages = require('../models/languages');
+
+/**
+ Parse languages list from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseLanguages(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'langs')
+ .spread(function(file, result) {
+ if (!file) {
+ return book;
+ }
+
+ var languages = Languages.createFromList(file, result);
+
+ logger.debug.ln('languages index file found at', file.getPath());
+ logger.info.ln('parsing multilingual book, with', languages.getList().size, 'languages');
+
+ return book.set('languages', languages);
+ });
+}
+
+module.exports = parseLanguages;
diff --git a/lib/parse/parsePage.js b/lib/parse/parsePage.js
new file mode 100644
index 0000000..1d515d6
--- /dev/null
+++ b/lib/parse/parsePage.js
@@ -0,0 +1,29 @@
+var Immutable = require('immutable');
+var fm = require('front-matter');
+var direction = require('direction');
+
+/**
+ Parse a page, read its content and parse the YAMl header
+
+ @param {Book} book
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function parsePage(book, page) {
+ var fs = book.getContentFS();
+ var file = page.getFile();
+
+ return fs.readAsString(file.getPath())
+ .then(function(content) {
+ var parsed = fm(content);
+
+ return page.merge({
+ content: parsed.body,
+ attributes: Immutable.fromJS(parsed.attributes),
+ dir: direction(parsed.body)
+ });
+ });
+}
+
+
+module.exports = parsePage;
diff --git a/lib/parse/parsePagesList.js b/lib/parse/parsePagesList.js
new file mode 100644
index 0000000..a3a52f8
--- /dev/null
+++ b/lib/parse/parsePagesList.js
@@ -0,0 +1,45 @@
+var Immutable = require('immutable');
+
+var timing = require('../utils/timing');
+var Page = require('../models/page');
+var walkSummary = require('./walkSummary');
+
+/**
+ Parse all pages from a book as an OrderedMap
+
+ @param {Book} book
+ @return {Promise<OrderedMap<Page>>}
+*/
+function parsePagesList(book) {
+ var fs = book.getContentFS();
+ var summary = book.getSummary();
+ var map = Immutable.OrderedMap();
+
+ return timing.measure(
+ 'parse.listPages',
+ walkSummary(summary, function(article) {
+ if (!article.isPage()) return;
+
+ var filepath = article.getPath();
+
+ // Is the page ignored?
+ if (book.isContentFileIgnored(filepath)) return;
+
+ return fs.statFile(filepath)
+ .then(function(file) {
+ map = map.set(
+ filepath,
+ Page.createForFile(file)
+ );
+ }, function() {
+ // file doesn't exist
+ });
+ })
+ .then(function() {
+ return map;
+ })
+ );
+}
+
+
+module.exports = parsePagesList;
diff --git a/lib/parse/parseReadme.js b/lib/parse/parseReadme.js
new file mode 100644
index 0000000..a2ede77
--- /dev/null
+++ b/lib/parse/parseReadme.js
@@ -0,0 +1,28 @@
+var parseStructureFile = require('./parseStructureFile');
+var Readme = require('../models/readme');
+
+var error = require('../utils/error');
+
+/**
+ Parse readme from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseReadme(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'readme')
+ .spread(function(file, result) {
+ if (!file) {
+ throw new error.FileNotFoundError({ filename: 'README' });
+ }
+
+ logger.debug.ln('readme found at', file.getPath());
+
+ var readme = Readme.create(file, result);
+ return book.set('readme', readme);
+ });
+}
+
+module.exports = parseReadme;
diff --git a/lib/parse/parseStructureFile.js b/lib/parse/parseStructureFile.js
new file mode 100644
index 0000000..bdb97db
--- /dev/null
+++ b/lib/parse/parseStructureFile.js
@@ -0,0 +1,57 @@
+var findParsableFile = require('./findParsableFile');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+
+/**
+ Parse a ParsableFile using a specific method
+
+ @param {FS} fs
+ @param {ParsableFile} file
+ @param {String} type
+ @return {Promise<Array<String, List|Map>>}
+*/
+function parseFile(fs, file, type) {
+ var filepath = file.getPath();
+ var parser = file.getParser();
+
+ if (!parser) {
+ return Promise.reject(
+ error.FileNotParsableError({
+ filename: filepath
+ })
+ );
+ }
+
+ return fs.readAsString(filepath)
+ .then(function(content) {
+ return [
+ file,
+ parser[type](content)
+ ];
+ });
+}
+
+
+/**
+ Parse a structure file (ex: SUMMARY.md, GLOSSARY.md).
+ It uses the configuration to find the specified file.
+
+ @param {Book} book
+ @param {String} type: one of ["glossary", "readme", "summary"]
+ @return {Promise<List|Map>}
+*/
+function parseStructureFile(book, type) {
+ var fs = book.getContentFS();
+ var config = book.getConfig();
+
+ var fileToSearch = config.getValue(['structure', type]);
+
+ return findParsableFile(book, fileToSearch)
+ .then(function(file) {
+ if (!file) return [undefined, undefined];
+
+ return parseFile(fs, file, type);
+ });
+}
+
+module.exports = parseStructureFile;
diff --git a/lib/parse/parseSummary.js b/lib/parse/parseSummary.js
new file mode 100644
index 0000000..72bf224
--- /dev/null
+++ b/lib/parse/parseSummary.js
@@ -0,0 +1,46 @@
+var parseStructureFile = require('./parseStructureFile');
+var Summary = require('../models/summary');
+var SummaryModifier = require('../modifiers').Summary;
+var location = require('../utils/location');
+
+/**
+ Parse summary in a book, the summary can only be parsed
+ if the readme as be detected before.
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseSummary(book) {
+ var readme = book.getReadme();
+ var logger = book.getLogger();
+ var readmeFile = readme.getFile();
+
+ return parseStructureFile(book, 'summary')
+ .spread(function(file, result) {
+ var summary;
+
+ if (!file) {
+ logger.warn.ln('no summary file in this book');
+ summary = Summary();
+ } else {
+ logger.debug.ln('summary file found at', file.getPath());
+ summary = Summary.createFromParts(file, result.parts);
+ }
+
+ // Insert readme as first entry
+ var firstArticle = summary.getFirstArticle();
+
+ if (readmeFile.exists() &&
+ (!firstArticle || !location.areIdenticalPaths(firstArticle.getRef(), readmeFile.getPath()))) {
+ summary = SummaryModifier.unshiftArticle(summary, {
+ title: 'Introduction',
+ ref: readmeFile.getPath()
+ });
+ }
+
+ // Set new summary
+ return book.setSummary(summary);
+ });
+}
+
+module.exports = parseSummary;
diff --git a/lib/config/validator.js b/lib/parse/validateConfig.js
index 764b19a..855edc3 100644
--- a/lib/config/validator.js
+++ b/lib/parse/validateConfig.js
@@ -2,12 +2,17 @@ var jsonschema = require('jsonschema');
var jsonSchemaDefaults = require('json-schema-defaults');
var mergeDefaults = require('merge-defaults');
-var schema = require('./schema');
+var schema = require('../constants/configSchema');
var error = require('../utils/error');
-// Validate a book.json content
-// And return a mix with the default value
-function validate(bookJson) {
+/**
+ Validate a book.json content
+ And return a mix with the default value
+
+ @param {Object} bookJson
+ @return {Object}
+*/
+function validateConfig(bookJson) {
var v = new jsonschema.Validator();
var result = v.validate(bookJson, schema, {
propertyName: 'config'
@@ -23,6 +28,4 @@ function validate(bookJson) {
return mergeDefaults(bookJson, defaults);
}
-module.exports = {
- validate: validate
-};
+module.exports = validateConfig;
diff --git a/lib/parse/walkSummary.js b/lib/parse/walkSummary.js
new file mode 100644
index 0000000..0117752
--- /dev/null
+++ b/lib/parse/walkSummary.js
@@ -0,0 +1,34 @@
+var Promise = require('../utils/promise');
+
+/**
+ Walk over a list of articles
+
+ @param {List<Article>} articles
+ @param {Function(article)}
+ @return {Promise}
+*/
+function walkArticles(articles, fn) {
+ return Promise.forEach(articles, function(article) {
+ return Promise(fn(article))
+ .then(function() {
+ return walkArticles(article.getArticles(), fn);
+ });
+ });
+}
+
+/**
+ Walk over summary and execute "fn" on each article
+
+ @param {Summary} summary
+ @param {Function(article)}
+ @return {Promise}
+*/
+function walkSummary(summary, fn) {
+ var parts = summary.getParts();
+
+ return Promise.forEach(parts, function(part) {
+ return walkArticles(part.getArticles(), fn);
+ });
+}
+
+module.exports = walkSummary;
diff --git a/lib/plugins/__tests__/findInstalled.js b/lib/plugins/__tests__/findInstalled.js
new file mode 100644
index 0000000..93912d3
--- /dev/null
+++ b/lib/plugins/__tests__/findInstalled.js
@@ -0,0 +1,16 @@
+var path = require('path');
+
+describe('findInstalled', function() {
+ var findInstalled = require('../findInstalled');
+
+ pit('must list default plugins for gitbook directory', function() {
+ return findInstalled(path.resolve(__dirname, '../../../'))
+ .then(function(plugins) {
+ expect(plugins.size > 7).toBeTruthy();
+
+ expect(plugins.has('fontsettings')).toBe(true);
+ expect(plugins.has('search')).toBe(true);
+ });
+ });
+
+});
diff --git a/lib/plugins/__tests__/listAll.js b/lib/plugins/__tests__/listAll.js
new file mode 100644
index 0000000..71483a7
--- /dev/null
+++ b/lib/plugins/__tests__/listAll.js
@@ -0,0 +1,71 @@
+jest.autoMockOff();
+
+describe('listAll', function() {
+ var listAll = require('../listAll');
+
+ it('must list from string', function() {
+ var plugins = listAll('ga,great');
+
+ expect(plugins.size).toBe(8);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('great')).toBe(true);
+
+ expect(plugins.has('search')).toBe(true);
+ });
+
+ it('must list from array', function() {
+ var plugins = listAll(['ga', 'great']);
+
+ expect(plugins.size).toBe(8);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('great')).toBe(true);
+
+ expect(plugins.has('search')).toBe(true);
+ });
+
+ it('must parse version (semver)', function() {
+ var plugins = listAll(['ga@1.0.0', 'great@>=4.0.0']);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('great')).toBe(true);
+
+ var ga = plugins.get('ga');
+ expect(ga.getVersion()).toBe('1.0.0');
+
+ var great = plugins.get('great');
+ expect(great.getVersion()).toBe('>=4.0.0');
+ });
+
+ it('must parse version (git)', function() {
+ var plugins = listAll(['ga@git+https://github.com/GitbookIO/plugin-ga.git', 'great@git+ssh://samy@github.com/GitbookIO/plugin-ga.git']);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('great')).toBe(true);
+
+ var ga = plugins.get('ga');
+ expect(ga.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git');
+
+ var great = plugins.get('great');
+ expect(great.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+ });
+
+ it('must list from array with -', function() {
+ var plugins = listAll(['ga', '-great']);
+
+ expect(plugins.size).toBe(7);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('great')).toBe(false);
+ });
+
+ it('must remove default plugins using -', function() {
+ var plugins = listAll(['ga', '-search']);
+
+ expect(plugins.size).toBe(6);
+
+ expect(plugins.has('ga')).toBe(true);
+ expect(plugins.has('search')).toBe(false);
+ });
+});
diff --git a/lib/plugins/__tests__/validatePlugin.js b/lib/plugins/__tests__/validatePlugin.js
new file mode 100644
index 0000000..3d50839
--- /dev/null
+++ b/lib/plugins/__tests__/validatePlugin.js
@@ -0,0 +1,21 @@
+jest.autoMockOff();
+
+var Promise = require('../../utils/promise');
+var Plugin = require('../../models/plugin');
+
+
+describe('validatePlugin', function() {
+ var validatePlugin = require('../validatePlugin');
+
+ pit('must not validate a not loaded plugin', function() {
+ var plugin = Plugin.createFromString('test');
+
+ return validatePlugin(plugin)
+ .then(function() {
+ throw new Error('Should not be validate');
+ }, function(err) {
+ return Promise();
+ });
+ });
+
+});
diff --git a/lib/plugins/compatibility.js b/lib/plugins/compatibility.js
deleted file mode 100644
index 77f4be2..0000000
--- a/lib/plugins/compatibility.js
+++ /dev/null
@@ -1,61 +0,0 @@
-var _ = require('lodash');
-var error = require('../utils/error');
-
-/*
- Return the context for a plugin.
- It tries to keep compatibilities with GitBook v2
-*/
-function pluginCtx(plugin) {
- var book = plugin.book;
- var ctx = book;
-
- return ctx;
-}
-
-/*
- Call a function "fn" with a context of page similar to the one in GitBook v2
-
- @params {Page}
- @returns {String|undefined} new content of the page
-*/
-function pageHook(page, fn) {
- // Get page context
- var ctx = page.getContext().page;
-
- // Add other informations
- ctx.type = page.type;
- ctx.rawPath = page.rawPath;
- ctx.path = page.path;
-
- // Deprecate sections
- error.deprecateField(ctx, 'sections', [
- { content: ctx.content, type: 'normal' }
- ], '"sections" property is deprecated, use page.content instead');
-
- // Keep reference of original content for compatibility
- var originalContent = ctx.content;
-
- return fn(ctx)
- .then(function(result) {
- // No returned value
- // Existing content will be used
- if (!result) return undefined;
-
- // GitBook 3
- // Use returned page.content if different from original content
- if (result.content != originalContent) {
- return result.content;
- }
-
- // GitBook 2 compatibility
- // Finally, use page.sections
- if (result.sections) {
- return _.pluck(result.sections, 'content').join('\n');
- }
- });
-}
-
-module.exports = {
- pluginCtx: pluginCtx,
- pageHook: pageHook
-};
diff --git a/lib/plugins/findForBook.js b/lib/plugins/findForBook.js
new file mode 100644
index 0000000..14ccc05
--- /dev/null
+++ b/lib/plugins/findForBook.js
@@ -0,0 +1,34 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var findInstalled = require('./findInstalled');
+
+/**
+ List all plugins installed in a book
+
+ @param {Book}
+ @return {Promise<OrderedMap<String:Plugin>>}
+*/
+function findForBook(book) {
+ return timing.measure(
+ 'plugins.findForBook',
+
+ Promise.all([
+ findInstalled(path.resolve(__dirname, '../..')),
+ findInstalled(book.getRoot())
+ ])
+
+ // Merge all plugins
+ .then(function(results) {
+ return Immutable.List(results)
+ .reduce(function(out, result) {
+ return out.merge(result);
+ }, Immutable.OrderedMap());
+ })
+ );
+}
+
+
+module.exports = findForBook;
diff --git a/lib/plugins/findInstalled.js b/lib/plugins/findInstalled.js
new file mode 100644
index 0000000..2259230
--- /dev/null
+++ b/lib/plugins/findInstalled.js
@@ -0,0 +1,87 @@
+var readInstalled = require('read-installed');
+var Immutable = require('immutable');
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var Plugin = require('../models/plugin');
+var PREFIX = require('../constants/pluginPrefix');
+
+/**
+ Validate if a package name is a GitBook plugin
+
+ @return {Boolean}
+*/
+function validateId(name) {
+ return name && name.indexOf(PREFIX) === 0;
+}
+
+
+/**
+ List all packages installed inside a folder
+
+ @param {String} folder
+ @return {OrderedMap<String:Plugin>}
+*/
+function findInstalled(folder) {
+ var options = {
+ dev: false,
+ log: function() {},
+ depth: 4
+ };
+ var results = Immutable.OrderedMap();
+
+ function onPackage(pkg, isRoot) {
+ if (!pkg.name) return;
+
+ var name = pkg.name;
+ var version = pkg.version;
+ var pkgPath = pkg.realPath;
+ var depth = pkg.depth;
+ var dependencies = pkg.dependencies;
+
+ var pluginName = name.slice(PREFIX.length);
+
+ if (!validateId(name)){
+ if (!isRoot) return;
+ } else {
+ results = results.set(pluginName, Plugin({
+ name: pluginName,
+ version: version,
+ path: pkgPath,
+ depth: depth
+ }));
+ }
+
+ Immutable.Map(dependencies).forEach(function(dep) {
+ onPackage(dep);
+ });
+ }
+
+ // Search for gitbook-plugins in node_modules folder
+ var node_modules = path.join(folder, 'node_modules');
+
+ // List all folders in node_modules
+ return fs.readdir(node_modules)
+ .then(function(modules) {
+ return Promise.serie(modules, function(module) {
+ // Not a gitbook-plugin
+ if (!validateId(module)) {
+ return Promise();
+ }
+
+ // Read gitbook-plugin package details
+ var module_folder = path.join(node_modules, module);
+ return Promise.nfcall(readInstalled, module_folder, options)
+ .then(function(data) {
+ onPackage(data, true);
+ });
+ });
+ })
+ .then(function() {
+ // Return installed plugins
+ return results;
+ });
+}
+
+module.exports = findInstalled;
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index c6f1686..607a7f1 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -1,188 +1,10 @@
-var _ = require('lodash');
-var path = require('path');
-var Promise = require('../utils/promise');
-var fs = require('../utils/fs');
-var BookPlugin = require('./plugin');
-var registry = require('./registry');
-var pluginsConfig = require('../config/plugins');
-
-/*
-PluginsManager is an interface to work with multiple plugins at once:
-- Extract assets from plugins
-- Call hooks for all plugins, etc
-*/
-
-function PluginsManager(book) {
- this.book = book;
- this.log = this.book.log;
- this.plugins = [];
-
- _.bindAll(this);
-}
-
-// Returns the list of plugins
-PluginsManager.prototype.list = function() {
- return this.plugins;
-};
-
-// Return count of plugins loaded
-PluginsManager.prototype.count = function() {
- return _.size(this.plugins);
-};
-
-// Returns a plugin by its name
-PluginsManager.prototype.get = function(name) {
- return _.find(this.plugins, {
- id: name
- });
-};
-
-// Load a plugin (could be a BookPlugin or {name,path})
-PluginsManager.prototype.load = function(plugin) {
- var that = this;
-
- if (_.isArray(plugin)) {
- return Promise.serie(plugin, that.load);
- }
-
- return Promise()
-
- // Initiate and load the plugin
- .then(function() {
- if (!(plugin instanceof BookPlugin)) {
- plugin = new BookPlugin(that.book, plugin.name, plugin.path);
- }
-
- if (that.get(plugin.id)) {
- throw new Error('Plugin "'+plugin.id+'" is already loaded');
- }
-
-
- if (plugin.isLoaded()) return plugin;
- else return plugin.load()
- .thenResolve(plugin);
- })
-
- // Setup the plugin
- .then(this._setup);
-};
-
-// Load all plugins from the book's configuration
-PluginsManager.prototype.loadAll = function() {
- var that = this;
- var pluginNames = _.pluck(this.book.config.get('plugins'), 'name');
-
- return registry.list(this.book)
- .then(function(plugins) {
- // Filter out plugins not listed of first level
- // (aka pre-installed plugins)
- plugins = _.filter(plugins, function(plugin) {
- return (
- plugin.depth > 1 ||
- _.contains(pluginNames, plugin.name)
- );
- });
-
- // Sort plugins to match list in book.json
- plugins.sort(function(a, b){
- return pluginNames.indexOf(a.name) < pluginNames.indexOf(b.name) ? -1 : 1;
- });
-
- // Log state
- that.log.info.ln(_.size(plugins) + ' are installed');
- if (_.size(pluginNames) != _.size(plugins)) that.log.info.ln(_.size(pluginNames) + ' explicitly listed');
-
- // Verify that all plugins are present
- var notInstalled = _.filter(pluginNames, function(name) {
- return !_.find(plugins, { name: name });
- });
-
- if (_.size(notInstalled) > 0) {
- throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.');
- }
-
- // Load plugins
- return that.load(plugins);
- });
-};
-
-// Setup a plugin
-// Register its filter, blocks, etc
-PluginsManager.prototype._setup = function(plugin) {
- this.plugins.push(plugin);
-};
-
-// Install all plugins for the book
-PluginsManager.prototype.install = function() {
- var that = this;
- var plugins = _.filter(this.book.config.get('plugins'), function(plugin) {
- return !pluginsConfig.isDefaultPlugin(plugin.name);
- });
-
- if (plugins.length == 0) {
- this.log.info.ln('nothing to install!');
- return Promise(0);
- }
-
- this.log.info.ln('installing', plugins.length, 'plugins');
-
- return Promise.serie(plugins, function(plugin) {
- return registry.install(that.book, plugin.name, plugin.version);
- })
- .thenResolve(plugins.length);
-};
-
-// Call a hook on all plugins to transform an input
-PluginsManager.prototype.hook = function(name, input) {
- return Promise.reduce(this.plugins, function(current, plugin) {
- return plugin.hook(name, current);
- }, input);
-};
-
-// Extract all resources for a namespace
-PluginsManager.prototype.getResources = function(namespace) {
- return Promise.reduce(this.plugins, function(out, plugin) {
- return plugin.getResources(namespace)
- .then(function(pluginResources) {
- _.each(BookPlugin.RESOURCES, function(resourceType) {
- out[resourceType] = (out[resourceType] || []).concat(pluginResources[resourceType] || []);
- });
-
- return out;
- });
- }, {});
-};
-
-// Copy all resources for a plugin
-PluginsManager.prototype.copyResources = function(namespace, outputRoot) {
- return Promise.serie(this.plugins, function(plugin) {
- return plugin.getResources(namespace)
- .then(function(resources) {
- if (!resources.assets) return;
-
- var input = path.resolve(plugin.root, resources.assets);
- var output = path.resolve(outputRoot, plugin.npmId);
-
- return fs.copyDir(input, output);
- });
- });
-};
-
-// Get all filters and blocks
-PluginsManager.prototype.getFilters = function() {
- return _.reduce(this.plugins, function(out, plugin) {
- var filters = plugin.getFilters();
-
- return _.extend(out, filters);
- }, {});
-};
-PluginsManager.prototype.getBlocks = function() {
- return _.reduce(this.plugins, function(out, plugin) {
- var blocks = plugin.getBlocks();
-
- return _.extend(out, blocks);
- }, {});
+module.exports = {
+ loadForBook: require('./loadForBook'),
+ validateConfig: require('./validateConfig'),
+ installPlugins: require('./installPlugins'),
+ listResources: require('./listResources'),
+ listBlocks: require('./listBlocks'),
+ listFilters: require('./listFilters')
};
-module.exports = PluginsManager;
diff --git a/lib/plugins/installPlugins.js b/lib/plugins/installPlugins.js
new file mode 100644
index 0000000..05a5316
--- /dev/null
+++ b/lib/plugins/installPlugins.js
@@ -0,0 +1,146 @@
+var npm = require('npm');
+var npmi = require('npmi');
+var semver = require('semver');
+var Immutable = require('immutable');
+
+var pkg = require('../../package.json');
+var DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+var Promise = require('../utils/promise');
+var Plugin = require('../models/plugin');
+var gitbook = require('../gitbook');
+var listForBook = require('./listForBook');
+
+var npmIsReady;
+
+/**
+ Initialize and prepare NPM
+
+ @return {Promise}
+*/
+function initNPM() {
+ if (npmIsReady) return npmIsReady;
+
+ npmIsReady = Promise.nfcall(npm.load, {
+ silent: true,
+ loglevel: 'silent'
+ });
+
+ return npmIsReady;
+}
+
+
+
+/**
+ Resolve a plugin to a version
+
+ @param {Plugin}
+ @return {Promise<String>}
+*/
+function resolveVersion(plugin) {
+ var npmId = Plugin.nameToNpmID(plugin.getName());
+ var requiredVersion = plugin.getVersion();
+
+ return initNPM()
+ .then(function() {
+ return Promise.nfcall(npm.commands.view, [npmId + '@' + requiredVersion, 'engines'], true);
+ })
+ .then(function(versions) {
+ versions = Immutable.Map(versions).entrySeq();
+
+ var result = versions
+ .map(function(entry) {
+ return {
+ version: entry[0],
+ gitbook: (entry[1].engines || {}).gitbook
+ };
+ })
+ .filter(function(v) {
+ return v.gitbook && gitbook.satisfies(v.gitbook);
+ })
+ .sort(function(v1, v2) {
+ return semver.lt(v1.version, v2.version)? 1 : -1;
+ })
+ .get(0);
+
+ if (!result) {
+ return undefined;
+ } else {
+ return result.version;
+ }
+ });
+}
+
+
+/**
+ Install a plugin for a book
+
+ @param {Book}
+ @param {Plugin}
+ @return {Promise}
+*/
+function installPlugin(book, plugin) {
+ var logger = book.getLogger();
+
+ var installFolder = book.getRoot();
+ var name = plugin.getName();
+ var requirement = plugin.getVersion();
+
+ logger.info.ln('installing plugin "' + name + '"');
+
+ // Find a version to install
+ return resolveVersion(plugin)
+ .then(function(version) {
+ if (!version) {
+ throw new Error('Found no satisfactory version for plugin "' + name + '" with requirement "' + requirement + '"');
+ }
+
+ logger.info.ln('install plugin "' + name +'" from NPM with version', requirement);
+ return Promise.nfcall(npmi, {
+ 'name': plugin.getNpmID(),
+ 'version': version,
+ 'path': installFolder,
+ 'npmLoad': {
+ 'loglevel': 'silent',
+ 'loaded': true,
+ 'prefix': installFolder
+ }
+ });
+ })
+ .then(function() {
+ logger.info.ok('plugin "' + name + '" installed with success');
+ });
+}
+
+
+/**
+ Install plugin requirements for a book
+
+ @param {Book}
+ @return {Promise}
+*/
+function installPlugins(book) {
+ var logger = book.getLogger();
+ var plugins = listForBook(book);
+
+ // Remove default plugins
+ // (only if version is same as installed)
+ plugins = plugins.filterNot(function(plugin) {
+ return (
+ DEFAULT_PLUGINS.includes(plugin.getName()) &&
+ plugin.getVersion() === pkg.dependencies[plugin.getNpmID()]
+ );
+ });
+
+ if (plugins.size == 0) {
+ logger.info.ln('nothing to install!');
+ return Promise();
+ }
+
+ logger.info.ln('installing', plugins.size, 'plugins');
+
+ return Promise.forEach(plugins, function(plugin) {
+ return installPlugin(book, plugin);
+ });
+}
+
+module.exports = installPlugins;
diff --git a/lib/plugins/listAll.js b/lib/plugins/listAll.js
new file mode 100644
index 0000000..65b8d7f
--- /dev/null
+++ b/lib/plugins/listAll.js
@@ -0,0 +1,67 @@
+var is = require('is');
+var Immutable = require('immutable');
+var Plugin = require('../models/plugin');
+
+var pkg = require('../../package.json');
+var DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+
+/**
+ List all plugins for a book
+
+ @param {List<Plugin|String>}
+ @return {OrderedMap<Plugin>}
+*/
+function listAll(plugins) {
+ if (is.string(plugins)) {
+ plugins = new Immutable.List(plugins.split(','));
+ }
+
+ // Convert to an ordered map
+ plugins = plugins.map(function(plugin) {
+ if (is.string(plugin)) {
+ plugin = Plugin.createFromString(plugin);
+ } else {
+ plugin = new Plugin(plugin);
+ }
+
+ return [plugin.getName(), plugin];
+ });
+ plugins = Immutable.OrderedMap(plugins);
+
+ // Extract list of plugins to disable (starting with -)
+ var toRemove = plugins.toList()
+ .filter(function(plugin) {
+ return plugin.getName()[0] === '-';
+ })
+ .map(function(plugin) {
+ return plugin.getName().slice(1);
+ });
+
+ // Remove the '-'
+ plugins = plugins.mapKeys(function(name) {
+ if (name[0] === '-') {
+ return name.slice(1);
+ } else {
+ return name;
+ }
+ });
+
+ // Append default plugins
+ DEFAULT_PLUGINS.forEach(function(pluginName) {
+ if (plugins.has(pluginName)) return;
+
+ plugins = plugins.set(pluginName, new Plugin({
+ name: pluginName,
+ version: pkg.dependencies[Plugin.nameToNpmID(pluginName)]
+ }));
+ });
+
+ // Remove plugins
+ plugins = plugins.filterNot(function(plugin, name) {
+ return toRemove.includes(name);
+ });
+
+ return plugins;
+}
+
+module.exports = listAll;
diff --git a/lib/plugins/listBlocks.js b/lib/plugins/listBlocks.js
new file mode 100644
index 0000000..f738937
--- /dev/null
+++ b/lib/plugins/listBlocks.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+/**
+ List blocks from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @return {Map<String:TemplateBlock>}
+*/
+function listBlocks(plugins) {
+ return plugins
+ .reverse()
+ .reduce(function(result, plugin) {
+ return result.merge(plugin.getBlocks());
+ }, Immutable.Map());
+}
+
+module.exports = listBlocks;
diff --git a/lib/plugins/listFilters.js b/lib/plugins/listFilters.js
new file mode 100644
index 0000000..4d8a471
--- /dev/null
+++ b/lib/plugins/listFilters.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+/**
+ List filters from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @return {Map<String:Function>}
+*/
+function listFilters(plugins) {
+ return plugins
+ .reverse()
+ .reduce(function(result, plugin) {
+ return result.merge(plugin.getFilters());
+ }, Immutable.Map());
+}
+
+module.exports = listFilters;
diff --git a/lib/plugins/listForBook.js b/lib/plugins/listForBook.js
new file mode 100644
index 0000000..ce94678
--- /dev/null
+++ b/lib/plugins/listForBook.js
@@ -0,0 +1,18 @@
+var listAll = require('./listAll');
+
+/**
+ List all plugin requirements for a book.
+ It can be different from the final list of plugins,
+ since plugins can have their own dependencies
+
+ @param {Book}
+ @return {OrderedMap<Plugin>}
+*/
+function listForBook(book) {
+ var config = book.getConfig();
+ var plugins = config.getValue('plugins');
+
+ return listAll(plugins);
+}
+
+module.exports = listForBook;
diff --git a/lib/plugins/listResources.js b/lib/plugins/listResources.js
new file mode 100644
index 0000000..4a73a2c
--- /dev/null
+++ b/lib/plugins/listResources.js
@@ -0,0 +1,45 @@
+var Immutable = require('immutable');
+var path = require('path');
+
+var LocationUtils = require('../utils/location');
+var PLUGIN_RESOURCES = require('../constants/pluginResources');
+
+/**
+ List all resources from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @param {String} type
+ @return {Map<String:List<{url, path}>}
+*/
+function listResources(plugins, type) {
+ return plugins.reduce(function(result, plugin) {
+ var npmId = plugin.getNpmID();
+ var resources = plugin.getResources(type);
+
+ PLUGIN_RESOURCES.forEach(function(resourceType) {
+ var assets = resources.get(resourceType);
+ if (!assets) return;
+
+ var list = result.get(resourceType) || Immutable.List();
+
+ assets = assets.map(function(assetFile) {
+ if (LocationUtils.isExternal(assetFile)) {
+ return {
+ url: assetFile
+ };
+ } else {
+ return {
+ path: LocationUtils.normalize(path.join(npmId, assetFile))
+ };
+ }
+ });
+
+ list = list.concat(assets);
+ result = result.set(resourceType, list);
+ });
+
+ return result;
+ }, Immutable.Map());
+}
+
+module.exports = listResources;
diff --git a/lib/plugins/loadForBook.js b/lib/plugins/loadForBook.js
new file mode 100644
index 0000000..c4acb5f
--- /dev/null
+++ b/lib/plugins/loadForBook.js
@@ -0,0 +1,57 @@
+var Promise = require('../utils/promise');
+
+var listForBook = require('./listForBook');
+var findForBook = require('./findForBook');
+var loadPlugin = require('./loadPlugin');
+
+
+/**
+ Load a list of plugins in a book
+
+ @param {Book}
+ @return {Promise<Map<String:Plugin>}
+*/
+function loadForBook(book) {
+ var logger = book.getLogger();
+ var requirements = listForBook(book);
+ var requirementsKeys = requirements.keySeq().toList();
+
+ return findForBook(book)
+ .then(function(installed) {
+ // Filter out plugins not listed of first level
+ // (aka pre-installed plugins)
+ installed = installed.filter(function(plugin) {
+ return (
+ plugin.getDepth() > 0 ||
+ requirements.has(plugin.getName())
+ );
+ });
+
+ // Sort plugins to match list in book.json
+ installed = installed.sort(function(a, b){
+ return requirementsKeys.indexOf(a.getName()) < requirementsKeys.indexOf(b.getName()) ? -1 : 1;
+ });
+
+ // Log state
+ logger.info.ln(installed.size + ' plugins are installed');
+ if (requirements.size != installed.size) {
+ logger.info.ln(requirements.size + ' explicitly listed');
+ }
+
+ // Verify that all plugins are present
+ var notInstalled = requirementsKeys.filter(function(name) {
+ return !installed.has(name);
+ });
+
+ if (notInstalled.size > 0) {
+ throw new Error('Couldn\'t locate plugins "' + notInstalled.join(', ') + '", Run \'gitbook install\' to install plugins from registry.');
+ }
+
+ return Promise.map(installed, function(plugin) {
+ return loadPlugin(book, plugin);
+ });
+ });
+}
+
+
+module.exports = loadForBook;
diff --git a/lib/plugins/loadPlugin.js b/lib/plugins/loadPlugin.js
new file mode 100644
index 0000000..400146e
--- /dev/null
+++ b/lib/plugins/loadPlugin.js
@@ -0,0 +1,83 @@
+var path = require('path');
+var resolve = require('resolve');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var timing = require('../utils/timing');
+
+var validatePlugin = require('./validatePlugin');
+
+// Return true if an error is a "module not found"
+// Wait on https://github.com/substack/node-resolve/pull/81 to be merged
+function isModuleNotFound(err) {
+ return err.code == 'MODULE_NOT_FOUND' || err.message.indexOf('Cannot find module') >= 0;
+}
+
+/**
+ Load a plugin in a book
+
+ @param {Book} book
+ @param {Plugin} plugin
+ @param {String} pkgPath (optional)
+ @return {Promise<Plugin>}
+*/
+function loadPlugin(book, plugin) {
+ var logger = book.getLogger();
+
+ var name = plugin.getName();
+ var pkgPath = plugin.getPath();
+
+ // Try loading plugins from different location
+ var p = Promise()
+ .then(function() {
+ var packageContent;
+ var content;
+
+ // Locate plugin and load package.json
+ try {
+ var res = resolve.sync('./package.json', { basedir: pkgPath });
+
+ pkgPath = path.dirname(res);
+ packageContent = require(res);
+ } catch (err) {
+ if (!isModuleNotFound(err)) throw err;
+
+ packageContent = undefined;
+ content = undefined;
+
+ return;
+ }
+
+ // Load plugin JS content
+ try {
+ content = require(pkgPath);
+ } catch(err) {
+ // It's no big deal if the plugin doesn't have an "index.js"
+ // (For example: themes)
+ if (isModuleNotFound(err)) {
+ content = {};
+ } else {
+ throw new error.PluginError(err, {
+ plugin: name
+ });
+ }
+ }
+
+ // Update plugin
+ return plugin.merge({
+ 'package': Immutable.fromJS(packageContent),
+ 'content': Immutable.fromJS(content)
+ });
+ })
+
+ .then(validatePlugin);
+
+ p = timing.measure('plugin.load', p);
+
+ logger.info('loading plugin "' + name + '"... ');
+ return logger.info.promise(p);
+}
+
+
+module.exports = loadPlugin;
diff --git a/lib/plugins/plugin.js b/lib/plugins/plugin.js
deleted file mode 100644
index d1c00d8..0000000
--- a/lib/plugins/plugin.js
+++ /dev/null
@@ -1,288 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var url = require('url');
-var resolve = require('resolve');
-var mergeDefaults = require('merge-defaults');
-var jsonschema = require('jsonschema');
-var jsonSchemaDefaults = require('json-schema-defaults');
-
-var Promise = require('../utils/promise');
-var error = require('../utils/error');
-var gitbook = require('../gitbook');
-var registry = require('./registry');
-var compatibility = require('./compatibility');
-
-var HOOKS = [
- 'init', 'finish', 'finish:before', 'config', 'page', 'page:before'
-];
-
-var RESOURCES = ['js', 'css'];
-
-// Return true if an error is a "module not found"
-// Wait on https://github.com/substack/node-resolve/pull/81 to be merged
-function isModuleNotFound(err) {
- return err.message.indexOf('Cannot find module') >= 0;
-}
-
-function BookPlugin(book, pluginId, pluginFolder) {
- this.book = book;
- this.log = this.book.log.prefix(pluginId);
-
-
- this.id = pluginId;
- this.npmId = registry.npmId(pluginId);
- this.root = pluginFolder;
-
- this.packageInfos = undefined;
- this.content = undefined;
-
- // Cache for resources
- this._resources = {};
-
- _.bindAll(this);
-}
-
-// Return true if plugin has been loaded correctly
-BookPlugin.prototype.isLoaded = function() {
- return Boolean(this.packageInfos && this.content);
-};
-
-// Bind a function to the plugin's context
-BookPlugin.prototype.bind = function(fn) {
- return fn.bind(compatibility.pluginCtx(this));
-};
-
-// Load this plugin from its root folder
-BookPlugin.prototype.load = function(folder) {
- var that = this;
-
- if (this.isLoaded()) {
- return Promise.reject(new Error('Plugin "' + this.id + '" is already loaded'));
- }
-
- // Try loading plugins from different location
- var p = Promise()
- .then(function() {
- // Locate plugin and load pacjage.json
- try {
- var res = resolve.sync('./package.json', { basedir: that.root });
-
- that.root = path.dirname(res);
- that.packageInfos = require(res);
- } catch (err) {
- if (!isModuleNotFound(err)) throw err;
-
- that.packageInfos = undefined;
- that.content = undefined;
-
- return;
- }
-
- // Load plugin JS content
- try {
- that.content = require(that.root);
- } catch(err) {
- // It's no big deal if the plugin doesn't have an "index.js"
- // (For example: themes)
- if (isModuleNotFound(err)) {
- that.content = {};
- } else {
- throw new error.PluginError(err, {
- plugin: that.id
- });
- }
- }
- })
-
- .then(that.validate)
-
- // Validate the configuration and update it
- .then(function() {
- var config = that.book.config.get(that.getConfigKey(), {});
- return that.validateConfig(config);
- })
- .then(function(config) {
- that.book.config.set(that.getConfigKey(), config);
- });
-
- this.log.info('loading plugin "' + this.id + '"... ');
- return this.log.info.promise(p);
-};
-
-// Verify the definition of a plugin
-// Also verify that the plugin accepts the current gitbook version
-// This method throws erros if plugin is invalid
-BookPlugin.prototype.validate = function() {
- var isValid = (
- this.isLoaded() &&
- this.packageInfos &&
- this.packageInfos.name &&
- this.packageInfos.engines &&
- this.packageInfos.engines.gitbook
- );
-
- if (!isValid) {
- throw new Error('Error loading plugin "' + this.id + '" at "' + this.root + '"');
- }
-
- if (!gitbook.satisfies(this.packageInfos.engines.gitbook)) {
- throw new Error('GitBook doesn\'t satisfy the requirements of this plugin: '+this.packageInfos.engines.gitbook);
- }
-};
-
-// Normalize, validate configuration for this plugin using its schema
-// Throw an error when shcema is not respected
-BookPlugin.prototype.validateConfig = function(config) {
- var that = this;
-
- return Promise()
- .then(function() {
- var schema = that.packageInfos.gitbook || {};
- if (!schema) return config;
-
- // Normalize schema
- schema.id = '/'+that.getConfigKey();
- schema.type = 'object';
-
- // Validate and throw if invalid
- var v = new jsonschema.Validator();
- var result = v.validate(config, schema, {
- propertyName: that.getConfigKey()
- });
-
- // Throw error
- if (result.errors.length > 0) {
- throw new error.ConfigurationError(new Error(result.errors[0].stack));
- }
-
- // Insert default values
- var defaults = jsonSchemaDefaults(schema);
- return mergeDefaults(config, defaults);
- });
-};
-
-// Return key for configuration
-BookPlugin.prototype.getConfigKey = function() {
- return 'pluginsConfig.'+this.id;
-};
-
-// Call a hook and returns its result
-BookPlugin.prototype.hook = function(name, input) {
- var that = this;
- var hookFunc = this.content.hooks? this.content.hooks[name] : null;
- input = input || {};
-
- if (!hookFunc) return Promise(input);
-
- this.book.log.debug.ln('call hook "' + name + '" for plugin "' + this.id + '"');
- if (!_.contains(HOOKS, name)) {
- this.book.log.warn.ln('hook "'+name+'" used by plugin "'+this.name+'" is deprecated, and will be removed in the coming versions');
- }
-
- return Promise()
- .then(function() {
- return that.bind(hookFunc)(input);
- });
-};
-
-// Return resources without normalization
-BookPlugin.prototype._getResources = function(base) {
- var that = this;
-
- return Promise()
- .then(function() {
- if (that._resources[base]) return that._resources[base];
-
- var book = that.content[base];
-
- // Compatibility with version 1.x.x
- if (base == 'website') book = book || that.content.book;
-
- // Nothing specified, fallback to default
- if (!book) {
- return Promise({});
- }
-
- // Dynamic function
- if(typeof book === 'function') {
- // Call giving it the context of our book
- return that.bind(book)();
- }
-
- // Plain data object
- return book;
- })
-
- .then(function(resources) {
- that._resources[base] = resources;
- return _.cloneDeep(resources);
- });
-};
-
-// Normalize a specific resource
-BookPlugin.prototype.normalizeResource = function(resource) {
- // Parse the resource path
- var parsed = url.parse(resource);
-
- // This is a remote resource
- // so we will simply link to using it's URL
- if (parsed.protocol) {
- return {
- 'url': resource
- };
- }
-
- // This will be copied over from disk
- // and shipped with the book's build
- return { 'path': this.npmId+'/'+resource };
-};
-
-
-// Normalize resources and return them
-BookPlugin.prototype.getResources = function(base) {
- var that = this;
-
- return this._getResources(base)
- .then(function(resources) {
- _.each(RESOURCES, function(resourceType) {
- resources[resourceType] = _.map(resources[resourceType] || [], that.normalizeResource);
- });
-
- return resources;
- });
-};
-
-// Normalize filters and return them
-BookPlugin.prototype.getFilters = function() {
- var that = this;
-
- return _.mapValues(this.content.filters || {}, function(fn, filter) {
- return function() {
- var ctx = _.extend(compatibility.pluginCtx(that), this);
-
- return fn.apply(ctx, arguments);
- };
- });
-};
-
-// Normalize blocks and return them
-BookPlugin.prototype.getBlocks = function() {
- var that = this;
-
- return _.mapValues(this.content.blocks || {}, function(block, blockName) {
- block = _.isFunction(block)? { process: block } : block;
-
- var fn = block.process;
- block.process = function() {
- var ctx = _.extend(compatibility.pluginCtx(that), this);
-
- return fn.apply(ctx, arguments);
- };
-
- return block;
- });
-};
-
-module.exports = BookPlugin;
-module.exports.RESOURCES = RESOURCES;
-
diff --git a/lib/plugins/registry.js b/lib/plugins/registry.js
deleted file mode 100644
index fe9406d..0000000
--- a/lib/plugins/registry.js
+++ /dev/null
@@ -1,172 +0,0 @@
-var npm = require('npm');
-var npmi = require('npmi');
-var path = require('path');
-var semver = require('semver');
-var _ = require('lodash');
-var readInstalled = require('read-installed');
-
-var Promise = require('../utils/promise');
-var gitbook = require('../gitbook');
-
-var PLUGIN_PREFIX = 'gitbook-plugin-';
-
-// Return an absolute name for the plugin (the one on NPM)
-function npmId(name) {
- if (name.indexOf(PLUGIN_PREFIX) === 0) return name;
- return [PLUGIN_PREFIX, name].join('');
-}
-
-// Return a plugin ID 9the one on GitBook
-function pluginId(name) {
- return name.replace(PLUGIN_PREFIX, '');
-}
-
-// Validate an NPM plugin ID
-function validateId(name) {
- return name && name.indexOf(PLUGIN_PREFIX) === 0;
-}
-
-// Initialize NPM for operations
-var initNPM = _.memoize(function() {
- return Promise.nfcall(npm.load, {
- silent: true,
- loglevel: 'silent'
- });
-});
-
-// Link a plugin for use in a specific book
-function linkPlugin(book, pluginPath) {
- book.log('linking', pluginPath);
-}
-
-// Resolve the latest version for a plugin
-function resolveVersion(plugin) {
- var npnName = npmId(plugin);
-
- return initNPM()
- .then(function() {
- return Promise.nfcall(npm.commands.view, [npnName+'@*', 'engines'], true);
- })
- .then(function(versions) {
- return _.chain(versions)
- .pairs()
- .map(function(v) {
- return {
- version: v[0],
- gitbook: (v[1].engines || {}).gitbook
- };
- })
- .filter(function(v) {
- return v.gitbook && gitbook.satisfies(v.gitbook);
- })
- .sort(function(v1, v2) {
- return semver.lt(v1.version, v2.version)? 1 : -1;
- })
- .pluck('version')
- .first()
- .value();
- });
-}
-
-
-// Install a plugin in a book
-function installPlugin(book, plugin, version) {
- book.log.info.ln('installing plugin', plugin);
-
- var npnName = npmId(plugin);
-
- return Promise()
- .then(function() {
- if (version) return version;
-
- book.log.info.ln('No version specified, resolve plugin "' + plugin + '"');
- return resolveVersion(plugin);
- })
-
- // Install the plugin with the resolved version
- .then(function(version) {
- if (!version) {
- throw new Error('Found no satisfactory version for plugin "' + plugin + '"');
- }
-
- book.log.info.ln('install plugin "' + plugin +'" from npm ('+npnName+') with version', version);
- return Promise.nfcall(npmi, {
- 'name': npnName,
- 'version': version,
- 'path': book.root,
- 'npmLoad': {
- 'loglevel': 'silent',
- 'loaded': true,
- 'prefix': book.root
- }
- });
- })
- .then(function() {
- book.log.info.ok('plugin "' + plugin + '" installed with success');
- });
-}
-
-// List all packages installed inside a folder
-// Returns an ordered list of plugins
-function listInstalled(folder) {
- var options = {
- dev: false,
- log: function() {},
- depth: 4
- };
- var results = [];
-
- function onPackage(pkg, isRoot) {
- if (!validateId(pkg.name)){
- if (!isRoot) return;
- } else {
- results.push({
- name: pluginId(pkg.name),
- version: pkg.version,
- path: pkg.realPath,
- depth: pkg.depth
- });
- }
-
- _.each(pkg.dependencies, function(dep) {
- onPackage(dep);
- });
- }
-
- return Promise.nfcall(readInstalled, folder, options)
- .then(function(data) {
- onPackage(data, true);
- return _.uniq(results, 'name');
- });
-}
-
-// List installed plugins for a book (defaults and installed)
-function listPlugins(book) {
- return Promise.all([
- listInstalled(path.resolve(__dirname, '../..')),
- listInstalled(book.root),
- book.originalRoot? listInstalled(book.originalRoot) : Promise([]),
- book.isLanguageBook()? listInstalled(book.parent.root) : Promise([])
- ])
- .spread(function() {
- var args = _.toArray(arguments);
-
- var results = _.reduce(args, function(out, a) {
- return out.concat(a);
- }, []);
-
- return _.uniq(results, 'name');
- });
-}
-
-module.exports = {
- npmId: npmId,
- pluginId: pluginId,
- validateId: validateId,
-
- resolve: resolveVersion,
- link: linkPlugin,
- install: installPlugin,
- list: listPlugins,
- listInstalled: listInstalled
-};
diff --git a/lib/plugins/validateConfig.js b/lib/plugins/validateConfig.js
new file mode 100644
index 0000000..37f3c96
--- /dev/null
+++ b/lib/plugins/validateConfig.js
@@ -0,0 +1,71 @@
+var Immutable = require('immutable');
+var jsonschema = require('jsonschema');
+var jsonSchemaDefaults = require('json-schema-defaults');
+var mergeDefaults = require('merge-defaults');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+
+/**
+ Validate one plugin for a book and update book's confiration
+
+ @param {Book}
+ @param {Plugin}
+ @return {Book}
+*/
+function validatePluginConfig(book, plugin) {
+ var config = book.getConfig();
+ var packageInfos = plugin.getPackage();
+
+ var configKey = [
+ 'pluginsConfig',
+ plugin.getName()
+ ].join('.');
+
+ var pluginConfig = config.getValue(configKey, {}).toJS();
+
+ var schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS();
+ if (!schema) return book;
+
+ // Normalize schema
+ schema.id = '/' + configKey;
+ schema.type = 'object';
+
+ // Validate and throw if invalid
+ var v = new jsonschema.Validator();
+ var result = v.validate(pluginConfig, schema, {
+ propertyName: configKey
+ });
+
+ // Throw error
+ if (result.errors.length > 0) {
+ throw new error.ConfigurationError(new Error(result.errors[0].stack));
+ }
+
+ // Insert default values
+ var defaults = jsonSchemaDefaults(schema);
+ pluginConfig = mergeDefaults(pluginConfig, defaults);
+
+
+ // Update configuration
+ config = config.setValue(configKey, pluginConfig);
+
+ // Return new book
+ return book.set('config', config);
+}
+
+/**
+ Validate a book configuration for plugins and
+ returns an update configuration with default values.
+
+ @param {Book}
+ @param {OrderedMap<String:Plugin>}
+ @return {Promise<Book>}
+*/
+function validateConfig(book, plugins) {
+ return Promise.reduce(plugins, function(newBook, plugin) {
+ return validatePluginConfig(newBook, plugin);
+ }, book);
+}
+
+module.exports = validateConfig;
diff --git a/lib/plugins/validatePlugin.js b/lib/plugins/validatePlugin.js
new file mode 100644
index 0000000..4baa911
--- /dev/null
+++ b/lib/plugins/validatePlugin.js
@@ -0,0 +1,34 @@
+var gitbook = require('../gitbook');
+
+var Promise = require('../utils/promise');
+
+/**
+ Validate a plugin
+
+ @param {Plugin}
+ @return {Promise<Plugin>}
+*/
+function validatePlugin(plugin) {
+ var packageInfos = plugin.getPackage();
+
+ var isValid = (
+ plugin.isLoaded() &&
+ packageInfos &&
+ packageInfos.get('name') &&
+ packageInfos.get('engines') &&
+ packageInfos.get('engines').get('gitbook')
+ );
+
+ if (!isValid) {
+ return Promise.reject(new Error('Error loading plugin "' + plugin.getName() + '" at "' + plugin.getPath() + '"'));
+ }
+
+ var engine = packageInfos.get('engines').get('gitbook');
+ if (!gitbook.satisfies(engine)) {
+ return Promise.reject(new Error('GitBook doesn\'t satisfy the requirements of this plugin: ' + engine));
+ }
+
+ return Promise(plugin);
+}
+
+module.exports = validatePlugin;
diff --git a/lib/template/blocks.js b/lib/template/blocks.js
deleted file mode 100644
index 5dfb0c8..0000000
--- a/lib/template/blocks.js
+++ /dev/null
@@ -1,36 +0,0 @@
-var _ = require('lodash');
-
-module.exports = {
- // Return non-parsed html
- // since blocks are by default non-parsable, a simple identity method works fine
- html: _.identity,
-
- // Highlight a code block
- // This block can be replaced by plugins
- code: function(blk) {
- return {
- html: false,
- body: blk.body
- };
- },
-
- // Render some markdown to HTML
- markdown: function(blk) {
- return this.book.renderInline('markdown', blk.body)
- .then(function(out) {
- return { body: out };
- });
- },
- asciidoc: function(blk) {
- return this.book.renderInline('asciidoc', blk.body)
- .then(function(out) {
- return { body: out };
- });
- },
- markup: function(blk) {
- return this.book.renderInline(this.ctx.file.type, blk.body)
- .then(function(out) {
- return { body: out };
- });
- }
-};
diff --git a/lib/template/index.js b/lib/template/index.js
deleted file mode 100644
index ae11bc9..0000000
--- a/lib/template/index.js
+++ /dev/null
@@ -1,552 +0,0 @@
-var _ = require('lodash');
-var path = require('path');
-var nunjucks = require('nunjucks');
-var escapeStringRegexp = require('escape-string-regexp');
-
-var Promise = require('../utils/promise');
-var error = require('../utils/error');
-var parsers = require('../parsers');
-var defaultBlocks = require('./blocks');
-var defaultFilters = require('./filters');
-var Loader = require('./loader');
-
-var NODE_ENDARGS = '%%endargs%%';
-
-// Return extension name for a specific block
-function blockExtName(name) {
- return 'Block'+name+'Extension';
-}
-
-// Normalize the result of block process function
-function normBlockResult(blk) {
- if (_.isString(blk)) blk = { body: blk };
- return blk;
-}
-
-// Extract kwargs from an arguments array
-function extractKwargs(args) {
- var last = _.last(args);
- return (_.isObject(last) && last.__keywords)? args.pop() : {};
-}
-
-function TemplateEngine(output) {
- this.output = output;
- this.book = output.book;
- this.log = this.book.log;
-
- // Create file loader
- this.loader = new Loader(this);
-
- // Create nunjucks instance
- this.env = new nunjucks.Environment(
- this.loader,
- {
- // Escaping is done after by the asciidoc/markdown parser
- autoescape: false,
-
- // Syntax
- tags: {
- blockStart: '{%',
- blockEnd: '%}',
- variableStart: '{{',
- variableEnd: '}}',
- commentStart: '{###',
- commentEnd: '###}'
- }
- }
- );
-
- // List of tags shortcuts
- this.shortcuts = [];
-
- // Map of blocks bodies (that requires post-processing)
- this.blockBodies = {};
-
- // Map of added blocks
- this.blocks = {};
-
- // Bind methods
- _.bindAll(this);
-
- // Add default blocks and filters
- this.addBlocks(defaultBlocks);
- this.addFilters(defaultFilters);
-
- // Build context for this book with depreacted fields
- this.ctx = {
- template: this,
- book: this.book,
- output: this.output
- };
- error.deprecateField(this.ctx, 'generator', this.output.name, '"generator" property is deprecated, use "output.generator" instead');
-}
-
-/*
- Bind a function to a context
- Filters and blocks are binded to this context.
-
- @param {Function}
- @param {Function}
-*/
-TemplateEngine.prototype.bindContext = function(func) {
- var that = this;
-
- return function() {
- var ctx = _.extend({
- ctx: this.ctx
- }, that.ctx);
-
- return func.apply(ctx, arguments);
- };
-};
-
-/*
- Interpolate a string content to replace shortcuts according to the filetype.
-
- @param {String} filepath
- @param {String} source
- @param {String}
-*/
-TemplateEngine.prototype.interpolate = function(filepath, source) {
- var parser = parsers.getByExt(path.extname(filepath));
- var type = parser? parser.name : null;
-
- return this.applyShortcuts(type, source);
-};
-
-/*
- Add a new custom filter, it bind to the right context
-
- @param {String}
- @param {Function}
-*/
-TemplateEngine.prototype.addFilter = function(filterName, func) {
- try {
- this.env.getFilter(filterName);
- this.log.error.ln('conflict in filters, "'+filterName+'" is already set');
- return false;
- } catch(e) {
- // Filter doesn't exist
- }
-
- this.log.debug.ln('add filter "'+filterName+'"');
- this.env.addFilter(filterName, this.bindContext(function() {
- var ctx = this;
- var args = Array.prototype.slice.apply(arguments);
- var callback = _.last(args);
-
- Promise()
- .then(function() {
- return func.apply(ctx, args.slice(0, -1));
- })
- .nodeify(callback);
- }), true);
- return true;
-};
-
-/*
- Add multiple filters at once
-
- @param {Map<String:Function>}
-*/
-TemplateEngine.prototype.addFilters = function(filters) {
- _.each(filters, function(filter, name) {
- this.addFilter(name, filter);
- }, this);
-};
-
-/*
- Return true if a block is defined
-
- @param {String}
-*/
-TemplateEngine.prototype.hasBlock = function(name) {
- return this.env.hasExtension(blockExtName(name));
-};
-
-/*
- Remove/Disable a block
-
- @param {String}
-*/
-TemplateEngine.prototype.removeBlock = function(name) {
- if (!this.hasBlock(name)) return;
-
- // Remove nunjucks extension
- this.env.removeExtension(blockExtName(name));
-
- // Cleanup shortcuts
- this.shortcuts = _.reject(this.shortcuts, {
- block: name
- });
-};
-
-/*
- Add a block.
- Using the extensions of nunjucks: https://mozilla.github.io/nunjucks/api.html#addextension
-
- @param {String} name
- @param {BlockDescriptor|Function} block
- @param {Function} block.process: function to be called to render the block
- @param {String} block.end: name of the end tag of this block (default to "end<name>")
- @param {Array<String>} block.blocks: list of inner blocks to parse
- @param {Array<Shortcut>} block.shortcuts: list of shortcuts to parse this block
-*/
-TemplateEngine.prototype.addBlock = function(name, block) {
- var that = this, Ext, extName;
-
- // Block can be a simple function
- if (_.isFunction(block)) block = { process: block };
-
- block = _.defaults(block || {}, {
- shortcuts: [],
- end: 'end'+name,
- blocks: []
- });
-
- extName = blockExtName(name);
-
- if (!block.process) {
- throw new Error('Invalid block "' + name + '", it should have a "process" method');
- }
-
- if (this.hasBlock(name) && !defaultBlocks[name]) {
- this.log.warn.ln('conflict in blocks, "'+name+'" is already defined');
- }
-
- // Cleanup previous block
- this.removeBlock(name);
-
- this.log.debug.ln('add block \''+name+'\'');
- this.blocks[name] = block;
-
- Ext = function () {
- this.tags = [name];
-
- this.parse = function(parser, nodes) {
- var lastBlockName = null;
- var lastBlockArgs = null;
- var allBlocks = block.blocks.concat([block.end]);
-
- // Parse first block
- var tok = parser.nextToken();
- lastBlockArgs = parser.parseSignature(null, true);
- parser.advanceAfterBlockEnd(tok.value);
-
- var args = new nodes.NodeList();
- var bodies = [];
- var blockNamesNode = new nodes.Array(tok.lineno, tok.colno);
- var blockArgCounts = new nodes.Array(tok.lineno, tok.colno);
-
- // Parse while we found "end<block>"
- do {
- // Read body
- var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks);
-
- // Handle body with previous block name and args
- blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName));
- blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length));
- bodies.push(currentBody);
-
- // Append arguments of this block as arguments of the run function
- _.each(lastBlockArgs.children, function(child) {
- args.addChild(child);
- });
-
- // Read new block
- lastBlockName = parser.nextToken().value;
-
- // Parse signature and move to the end of the block
- if (lastBlockName != block.end) {
- lastBlockArgs = parser.parseSignature(null, true);
- }
-
- parser.advanceAfterBlockEnd(lastBlockName);
- } while (lastBlockName != block.end);
-
- args.addChild(blockNamesNode);
- args.addChild(blockArgCounts);
- args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS));
-
- return new nodes.CallExtensionAsync(this, 'run', args, bodies);
- };
-
- this.run = function(context) {
- var fnArgs = Array.prototype.slice.call(arguments, 1);
-
- var args;
- var blocks = [];
- var bodies = [];
- var blockNames;
- var blockArgCounts;
- var callback;
-
- // Extract callback
- callback = fnArgs.pop();
-
- // Detect end of arguments
- var endArgIndex = fnArgs.indexOf(NODE_ENDARGS);
-
- // Extract arguments and bodies
- args = fnArgs.slice(0, endArgIndex);
- bodies = fnArgs.slice(endArgIndex + 1);
-
- // Extract block counts
- blockArgCounts = args.pop();
- blockNames = args.pop();
-
- // Recreate list of blocks
- _.each(blockNames, function(name, i) {
- var countArgs = blockArgCounts[i];
- var blockBody = bodies.shift();
-
- var blockArgs = countArgs > 0? args.slice(0, countArgs) : [];
- args = args.slice(countArgs);
- var blockKwargs = extractKwargs(blockArgs);
-
- blocks.push({
- name: name,
- body: blockBody(),
- args: blockArgs,
- kwargs: blockKwargs
- });
- });
-
- var mainBlock = blocks.shift();
- mainBlock.blocks = blocks;
-
- Promise()
- .then(function() {
- return that.applyBlock(name, mainBlock, context);
- })
-
- // Process the block returned
- .then(that.processBlock)
- .nodeify(callback);
- };
- };
-
- // Add the Extension
- this.env.addExtension(extName, new Ext());
-
- // Add shortcuts if any
- if (!_.isArray(block.shortcuts)) {
- block.shortcuts = [block.shortcuts];
- }
-
- _.each(block.shortcuts, function(shortcut) {
- this.log.debug.ln('add template shortcut from "'+shortcut.start+'" to block "'+name+'" for parsers ', shortcut.parsers);
- this.shortcuts.push({
- block: name,
- parsers: shortcut.parsers,
- start: shortcut.start,
- end: shortcut.end,
- tag: {
- start: name,
- end: block.end
- }
- });
- }, this);
-};
-
-/*
- Add multiple blocks at once
-
- @param {Array<BlockDescriptor>}
-*/
-TemplateEngine.prototype.addBlocks = function(blocks) {
- _.each(blocks, function(block, name) {
- this.addBlock(name, block);
- }, this);
-};
-
-/*
- Apply a block to some content
- This method result depends on the type of block (async or sync)
-
-
- @param {String} name: name of the block type to apply
- @param {Block} blk: content of the block
- @param {Object} ctx: context of execution of the block
- @return {Block|Promise<Block>}
-*/
-TemplateEngine.prototype.applyBlock = function(name, blk, ctx) {
- var func, block, r;
-
- block = this.blocks[name];
- if (!block) throw new Error('Block not found "'+name+'"');
- if (_.isString(blk)) {
- blk = {
- body: blk
- };
- }
-
- blk = _.defaults(blk, {
- args: [],
- kwargs: {},
- blocks: []
- });
-
- // Bind and call block processor
- func = this.bindContext(block.process);
- r = func.call(ctx || {}, blk);
-
- if (Promise.isPromiseAlike(r)) return Promise(r).then(normBlockResult);
- else return normBlockResult(r);
-};
-
-/*
- Process the result of block in a context. It returns the content to append to the output.
- It can return an "anchor" that will be replaced by "replaceBlocks" in "postProcess"
-
- @param {Block}
- @return {String}
-*/
-TemplateEngine.prototype.processBlock = function(blk) {
- blk = _.defaults(blk, {
- parse: false,
- post: undefined
- });
- blk.id = _.uniqueId('blk');
-
- var toAdd = (!blk.parse) || (blk.post !== undefined);
-
- // Add to global map
- if (toAdd) this.blockBodies[blk.id] = blk;
-
- // Parsable block, just return it
- if (blk.parse) {
- return blk.body;
- }
-
- // Return it as a position marker
- return '{{-%'+blk.id+'%-}}';
-};
-
-/*
- Render a string (without post processing)
-
- @param {String} content: template's content to render
- @param {Object} context
- @param {Object} options
- @param {String} options.path: pathname to the template
- @return {Promise<String>}
-*/
-TemplateEngine.prototype.render = function(content, context, options) {
- options = _.defaults(options || {}, {
- path: null
- });
- var filename = options.path;
-
- // Setup path and type
- if (options.path) {
- options.path = this.book.resolve(options.path);
- }
-
- // Replace shortcuts
- content = this.applyShortcuts(options.type, content);
-
- return Promise.nfcall(this.env.renderString.bind(this.env), content, context, options)
- .fail(function(err) {
- throw error.TemplateError(err, {
- filename: filename || '<inline>'
- });
- });
-};
-
-/*
- Render a string (with post processing)
-
- @param {String} content: template's content to render
- @param {Object} context
- @param {Object} options
- @return {Promise<String>}
-*/
-TemplateEngine.prototype.renderString = function(content, context, options) {
- return this.render(content, context, options)
- .then(this.postProcess);
-};
-
-/*
- Apply a shortcut of block to a template
-
- @param {String} content
- @param {Shortcut} shortcut
- @return {String}
-*/
-TemplateEngine.prototype.applyShortcut = function(content, shortcut) {
- var regex = new RegExp(
- escapeStringRegexp(shortcut.start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(shortcut.end),
- 'g'
- );
- return content.replace(regex, function(all, match) {
- return '{% '+shortcut.tag.start+' %}'+ match + '{% '+shortcut.tag.end+' %}';
- });
-};
-
-
-/*
- Apply all shortcut of blocks to a template
-
- @param {String} type: type of template ("markdown", "asciidoc")
- @param {String} content
- @return {String}
-*/
-TemplateEngine.prototype.applyShortcuts = function(type, content) {
- return _.chain(this.shortcuts)
- .filter(function(shortcut) {
- return _.contains(shortcut.parsers, type);
- })
- .reduce(this.applyShortcut, content)
- .value();
-};
-
-/*
- Replace position markers of blocks by body after processing
- This is done to avoid that markdown/asciidoc processer parse the block content
-
- @param {String} content
- @return {String}
-*/
-TemplateEngine.prototype.replaceBlocks = function(content) {
- var that = this;
-
- return content.replace(/\{\{\-\%([\s\S]+?)\%\-\}\}/g, function(match, key) {
- var blk = that.blockBodies[key];
- if (!blk) return match;
-
- var body = blk.body;
-
- return body;
- });
-};
-
-
-
-/*
- Post process templating result: remplace block's anchors and apply "post"
-
- @param {String} content
- @return {Promise<String>}
-*/
-TemplateEngine.prototype.postProcess = function(content) {
- var that = this;
-
- return Promise(content)
- .then(that.replaceBlocks)
- .then(function(_content) {
- return Promise.serie(that.blockBodies, function(blk, blkId) {
- return Promise()
- .then(function() {
- if (!blk.post) return;
- return blk.post();
- })
- .then(function() {
- delete that.blockBodies[blkId];
- });
- })
- .thenResolve(_content);
- });
-};
-
-module.exports = TemplateEngine;
diff --git a/lib/template/loader.js b/lib/template/loader.js
deleted file mode 100644
index 23d179a..0000000
--- a/lib/template/loader.js
+++ /dev/null
@@ -1,42 +0,0 @@
-var nunjucks = require('nunjucks');
-var location = require('../utils/location');
-
-/*
-Simple nunjucks loader which is passing the reponsability to the Output
-*/
-
-var Loader = nunjucks.Loader.extend({
- async: true,
-
- init: function(engine, opts) {
- this.engine = engine;
- this.output = engine.output;
- },
-
- getSource: function(sourceURL, callback) {
- var that = this;
-
- this.output.onGetTemplate(sourceURL)
- .then(function(out) {
- // We disable cache since content is modified (shortcuts, ...)
- out.noCache = true;
-
- // Transform template before runnign it
- out.source = that.engine.interpolate(out.path, out.source);
-
- return out;
- })
- .nodeify(callback);
- },
-
- resolve: function(from, to) {
- return this.output.onResolveTemplate(from, to);
- },
-
- // Handle all files as relative, so that nunjucks pass responsability to 'resolve'
- isRelative: function(filename) {
- return location.isRelative(filename);
- }
-});
-
-module.exports = Loader;
diff --git a/lib/templating/__tests__/conrefsLoader.js b/lib/templating/__tests__/conrefsLoader.js
new file mode 100644
index 0000000..3480a48
--- /dev/null
+++ b/lib/templating/__tests__/conrefsLoader.js
@@ -0,0 +1,34 @@
+var TemplateEngine = require('../../models/templateEngine');
+var renderTemplate = require('../render');
+
+describe('ConrefsLoader', function() {
+ var ConrefsLoader = require('../conrefsLoader');
+
+ var engine = TemplateEngine({
+ loader: new ConrefsLoader(__dirname)
+ });
+
+ describe('Git', function() {
+ pit('should include content from git', function() {
+ return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}')
+ .then(function(str) {
+ expect(str).toBe('Hello from git');
+ });
+ });
+
+ pit('should handle deep inclusion (1)', function() {
+ return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}')
+ .then(function(str) {
+ expect(str).toBe('First Hello. Hello from git');
+ });
+ });
+
+ pit('should handle deep inclusion (2)', function() {
+ return renderTemplate(engine, 'test.md', '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}')
+ .then(function(str) {
+ expect(str).toBe('First Hello. Hello from git');
+ });
+ });
+ });
+});
+
diff --git a/lib/templating/conrefsLoader.js b/lib/templating/conrefsLoader.js
new file mode 100644
index 0000000..c3e5048
--- /dev/null
+++ b/lib/templating/conrefsLoader.js
@@ -0,0 +1,72 @@
+var path = require('path');
+var nunjucks = require('nunjucks');
+
+var fs = require('../utils/fs');
+var Git = require('../utils/git');
+var LocationUtils = require('../utils/location');
+var PathUtils = require('../utils/path');
+
+
+/**
+ Template loader resolving both:
+ - relative url ("./test.md")
+ - absolute url ("/test.md")
+ - git url ("")
+*/
+var ConrefsLoader = nunjucks.Loader.extend({
+ async: true,
+
+ init: function(rootFolder, logger) {
+ this.rootFolder = rootFolder;
+ this.logger = logger;
+ this.git = new Git();
+ },
+
+ getSource: function(sourceURL, callback) {
+ var that = this;
+
+ this.git.resolve(sourceURL)
+ .then(function(filepath) {
+ // Is local file
+ if (!filepath) {
+ filepath = path.resolve(sourceURL);
+ } else {
+ if (that.logger) that.logger.debug.ln('resolve from git', sourceURL, 'to', filepath);
+ }
+
+ // Read file from absolute path
+ return fs.readFile(filepath)
+ .then(function(source) {
+ return {
+ src: source.toString('utf8'),
+ path: filepath
+ };
+ });
+ })
+ .nodeify(callback);
+ },
+
+ resolve: function(from, to) {
+ // If origin is in the book, we enforce result file to be in the book
+ if (PathUtils.isInRoot(this.rootFolder, from)) {
+ var href = LocationUtils.toAbsolute(to, path.dirname(from), '');
+ return PathUtils.resolveInRoot(this.rootFolder, href);
+ }
+
+ // If origin is in a git repository, we resolve file in the git repository
+ var gitRoot = this.git.resolveRoot(from);
+ if (gitRoot) {
+ return PathUtils.resolveInRoot(gitRoot, to);
+ }
+
+ // If origin is not in the book (include from a git content ref)
+ return path.resolve(path.dirname(from), to);
+ },
+
+ // Handle all files as relative, so that nunjucks pass responsability to 'resolve'
+ isRelative: function(filename) {
+ return LocationUtils.isRelative(filename);
+ }
+});
+
+module.exports = ConrefsLoader;
diff --git a/lib/templating/index.js b/lib/templating/index.js
new file mode 100644
index 0000000..a33965d
--- /dev/null
+++ b/lib/templating/index.js
@@ -0,0 +1,9 @@
+
+module.exports = {
+ render: require('./render'),
+ renderFile: require('./renderFile'),
+ postRender: require('./postRender'),
+
+ ConrefsLoader: require('./conrefsLoader'),
+ ThemesLoader: require('./themesLoader')
+};
diff --git a/lib/templating/listShortcuts.js b/lib/templating/listShortcuts.js
new file mode 100644
index 0000000..8f2388b
--- /dev/null
+++ b/lib/templating/listShortcuts.js
@@ -0,0 +1,31 @@
+var Immutable = require('immutable');
+var parsers = require('../parsers');
+
+/**
+ Return a list of all shortcuts that can apply
+ to a file for a TemplatEngine
+
+ @param {TemplateEngine} engine
+ @param {String} filePath
+ @return {List<Shortcut>}
+*/
+function listShortcuts(engine, filePath) {
+ var blocks = engine.getBlocks();
+ var parser = parsers.getForFile(filePath);
+ if (!parser) {
+ return Immutable.List();
+ }
+
+ return blocks
+ .map(function(block) {
+ var shortcuts = block.getShortcuts();
+
+ return shortcuts.filter(function(shortcut) {
+ var parsers = shortcut.get('parsers');
+ return parsers.includes(parser.name);
+ });
+ })
+ .flatten(1);
+}
+
+module.exports = listShortcuts;
diff --git a/lib/templating/postRender.js b/lib/templating/postRender.js
new file mode 100644
index 0000000..6928e82
--- /dev/null
+++ b/lib/templating/postRender.js
@@ -0,0 +1,28 @@
+var Promise = require('../utils/promise');
+var replaceBlocks = require('./replaceBlocks');
+
+/**
+ Post render a template:
+ - Execute "post" for blocks
+ - Replace block content
+
+ @param {TemplateEngine} engine
+ @param {String} content
+ @return {Promise<String>}
+*/
+function postRender(engine, content) {
+ var result = replaceBlocks(content);
+
+ return Promise.forEach(result.blocks, function(blockType) {
+ var block = engine.getBlock();
+ var post = block.getPost();
+ if (!post) {
+ return;
+ }
+
+ return post();
+ })
+ .thenResolve(result.content);
+}
+
+module.exports = postRender;
diff --git a/lib/templating/render.js b/lib/templating/render.js
new file mode 100644
index 0000000..bf21cfe
--- /dev/null
+++ b/lib/templating/render.js
@@ -0,0 +1,30 @@
+var Promise = require('../utils/promise');
+
+var replaceShortcuts = require('./replaceShortcuts');
+
+/**
+ Render a template
+
+ @param {TemplateEngine} engine
+ @param {String} filePath
+ @param {String} content
+ @param {Object} context
+ @return {Promise<String>}
+*/
+function renderTemplate(engine, filePath, content, context) {
+ context = context || {};
+ var env = engine.toNunjucks();
+
+ content = replaceShortcuts(engine, filePath, content);
+
+ return Promise.nfcall(
+ env.renderString.bind(env),
+ content,
+ context,
+ {
+ path: filePath
+ }
+ );
+}
+
+module.exports = renderTemplate;
diff --git a/lib/templating/renderFile.js b/lib/templating/renderFile.js
new file mode 100644
index 0000000..9b74e5b
--- /dev/null
+++ b/lib/templating/renderFile.js
@@ -0,0 +1,38 @@
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var render = require('./render');
+
+/**
+ Render a template
+
+ @param {TemplateEngine} engine
+ @param {String} filePath
+ @param {Object} context
+ @return {Promise<String>}
+*/
+function renderTemplateFile(engine, filePath, context) {
+ var loader = engine.getLoader();
+
+ return Promise()
+ .then(function() {
+ if (!loader.async) {
+ return loader.getSource(filePath);
+ }
+
+ var deferred = Promise.defer();
+ loader.getSource(filePath, deferred.makeNodeResolver());
+ return deferred.promise;
+ })
+ .then(function(result) {
+ if (!result) {
+ throw error.TemplateError(new Error('Not found'), {
+ filename: filePath
+ });
+ }
+
+ return render(engine, result.path, result.src, context);
+ });
+
+}
+
+module.exports = renderTemplateFile;
diff --git a/lib/templating/replaceBlocks.js b/lib/templating/replaceBlocks.js
new file mode 100644
index 0000000..4b1c37f
--- /dev/null
+++ b/lib/templating/replaceBlocks.js
@@ -0,0 +1,34 @@
+var Immutable = require('immutable');
+var TemplateBlock = require('../models/templateBlock');
+
+/**
+ Replace position markers of blocks by body after processing
+ This is done to avoid that markdown/asciidoc processer parse the block content
+
+ @param {String} content
+ @return {Object} {blocks: Set, content: String}
+*/
+function replaceBlocks(content) {
+ var blockTypes = new Immutable.Set();
+ var newContent = content.replace(/\{\{\-\%([\s\S]+?)\%\-\}\}/g, function(match, key) {
+ var replacedWith = match;
+
+ var block = TemplateBlock.getBlockResultByKey(key);
+ if (block) {
+ var result = replaceBlocks(block.body);
+
+ blockTypes = blockTypes.add(block.name);
+ blockTypes = blockTypes.concat(result.blocks);
+ replacedWith = result.content;
+ }
+
+ return replacedWith;
+ });
+
+ return {
+ content: newContent,
+ blocks: blockTypes
+ };
+}
+
+module.exports = replaceBlocks;
diff --git a/lib/templating/replaceShortcuts.js b/lib/templating/replaceShortcuts.js
new file mode 100644
index 0000000..f6a51cb
--- /dev/null
+++ b/lib/templating/replaceShortcuts.js
@@ -0,0 +1,37 @@
+var escapeStringRegexp = require('escape-string-regexp');
+var listShortcuts = require('./listShortcuts');
+
+/*
+ Apply a shortcut of block to a template
+ @param {String} content
+ @param {Shortcut} shortcut
+ @return {String}
+*/
+function applyShortcut(content, shortcut) {
+ var tags = shortcut.get('tag');
+ var start = shortcut.get('start');
+ var end = shortcut.get('end');
+
+ var regex = new RegExp(
+ escapeStringRegexp(start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(end),
+ 'g'
+ );
+ return content.replace(regex, function(all, match) {
+ return '{% ' + tags.start + ' %}' + match + '{% ' + tags.end + ' %}';
+ });
+}
+
+/**
+ Replace shortcuts from blocks in a string
+
+ @param {TemplateEngine} engine
+ @param {String} filePath
+ @param {String} content
+ @return {String}
+*/
+function replaceShortcuts(engine, filePath, content) {
+ var shortcuts = listShortcuts(engine, filePath);
+ return shortcuts.reduce(applyShortcut, content);
+}
+
+module.exports = replaceShortcuts;
diff --git a/lib/output/website/themeLoader.js b/lib/templating/themesLoader.js
index 774a39e..69c3879 100644
--- a/lib/output/website/themeLoader.js
+++ b/lib/templating/themesLoader.js
@@ -1,27 +1,19 @@
-var _ = require('lodash');
+var Immutable = require('immutable');
+var nunjucks = require('nunjucks');
var fs = require('fs');
var path = require('path');
-var nunjucks = require('nunjucks');
-/*
- Nunjucks loader similar to FileSystemLoader, but avoid infinite looping
-*/
+var PathUtils = require('../utils/path');
-/*
- Return true if a filename is relative.
-*/
-function isRelative(filename) {
- return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0);
-}
-var ThemeLoader = nunjucks.Loader.extend({
+var ThemesLoader = nunjucks.Loader.extend({
init: function(searchPaths) {
- this.searchPaths = _.map(searchPaths, path.normalize);
+ this.searchPaths = Immutable.List(searchPaths)
+ .map(path.normalize);
},
/*
Read source of a resolved filepath
-
@param {String}
@return {Object}
*/
@@ -58,24 +50,21 @@ var ThemeLoader = nunjucks.Loader.extend({
/*
Get original search path containing a template
-
@param {String} filepath
@return {String} searchPath
*/
getSearchPath: function(filepath) {
- return _.chain(this.searchPaths)
+ return this.searchPaths
.sortBy(function(s) {
return -s.length;
})
.find(function(basePath) {
return (filepath && filepath.indexOf(basePath) === 0);
- })
- .value();
+ });
},
/*
Get template name from a filepath
-
@param {String} filepath
@return {String} name
*/
@@ -86,7 +75,6 @@ var ThemeLoader = nunjucks.Loader.extend({
/*
Resolve a template from a current template
-
@param {String|null} from
@param {String} to
@return {String|null}
@@ -95,7 +83,7 @@ var ThemeLoader = nunjucks.Loader.extend({
var searchPaths = this.searchPaths;
// Relative template like "./test.html"
- if (isRelative(to) && from) {
+ if (PathUtils.isPureRelative(to) && from) {
return path.resolve(path.dirname(from), to);
}
@@ -111,7 +99,7 @@ var ThemeLoader = nunjucks.Loader.extend({
}
// Absolute template to resolve in root folder
- var resultFolder = _.find(searchPaths, function(basePath) {
+ var resultFolder = searchPaths.find(function(basePath) {
var p = path.resolve(basePath, to);
return (
@@ -124,4 +112,4 @@ var ThemeLoader = nunjucks.Loader.extend({
}
});
-module.exports = ThemeLoader;
+module.exports = ThemesLoader;
diff --git a/lib/utils/__tests__/git.js b/lib/utils/__tests__/git.js
new file mode 100644
index 0000000..6eed81e
--- /dev/null
+++ b/lib/utils/__tests__/git.js
@@ -0,0 +1,58 @@
+var should = require('should');
+var path = require('path');
+var os = require('os');
+
+var Git = require('../git');
+
+describe('Git', function() {
+
+ describe('URL parsing', function() {
+
+ it('should correctly validate git urls', function() {
+ // HTTPS
+ expect(Git.isUrl('git+https://github.com/Hello/world.git')).toBeTruthy();
+
+ // SSH
+ expect(Git.isUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1')).toBeTruthy();
+
+ // Non valid
+ expect(Git.isUrl('https://github.com/Hello/world.git')).not.toBeTruthy();
+ expect(Git.isUrl('README.md')).not.toBeTruthy();
+ });
+
+ it('should parse HTTPS urls', function() {
+ var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md');
+
+ expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git');
+ expect(parts.ref).toBe(null);
+ expect(parts.filepath).toBe('test.md');
+ });
+
+ it('should parse HTTPS urls with a reference', function() {
+ var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#1.0.0');
+
+ expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git');
+ expect(parts.ref).toBe('1.0.0');
+ expect(parts.filepath).toBe('test.md');
+ });
+
+ it('should parse SSH urls', function() {
+ var parts = Git.parseUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1');
+
+ expect(parts.host).toBe('git@github.com:GitbookIO/gitbook.git');
+ expect(parts.ref).toBe('e1594cde2c32e4ff48f6c4eff3d3d461743d74e1');
+ expect(parts.filepath).toBe('directory/README.md');
+ });
+ });
+
+ describe('Cloning and resolving', function() {
+ pit('should clone an HTTPS url', function() {
+ var git = new Git(path.join(os.tmpdir(), 'test-git-'+Date.now()));
+ return git.resolve('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md')
+ .then(function(filename) {
+ expect(path.extname(filename)).toBe('.md');
+ });
+ });
+ });
+
+});
diff --git a/lib/utils/__tests__/location.js b/lib/utils/__tests__/location.js
new file mode 100644
index 0000000..f2037ff
--- /dev/null
+++ b/lib/utils/__tests__/location.js
@@ -0,0 +1,78 @@
+jest.autoMockOff();
+
+describe('LocationUtils', function() {
+ var LocationUtils = require('../location');
+
+ it('should correctly test external location', function() {
+ expect(LocationUtils.isExternal('http://google.fr')).toBe(true);
+ expect(LocationUtils.isExternal('https://google.fr')).toBe(true);
+ expect(LocationUtils.isExternal('test.md')).toBe(false);
+ expect(LocationUtils.isExternal('folder/test.md')).toBe(false);
+ expect(LocationUtils.isExternal('/folder/test.md')).toBe(false);
+ });
+
+ it('should correctly detect anchor location', function() {
+ expect(LocationUtils.isAnchor('#test')).toBe(true);
+ expect(LocationUtils.isAnchor(' #test')).toBe(true);
+ expect(LocationUtils.isAnchor('https://google.fr#test')).toBe(false);
+ expect(LocationUtils.isAnchor('test.md#test')).toBe(false);
+ });
+
+ describe('.relative', function() {
+ it('should resolve to a relative path (same folder)', function() {
+ expect(LocationUtils.relative('links/', 'links/test.md')).toBe('test.md');
+ });
+
+ it('should resolve to a relative path (parent folder)', function() {
+ expect(LocationUtils.relative('links/', 'test.md')).toBe('../test.md');
+ });
+
+ it('should resolve to a relative path (child folder)', function() {
+ expect(LocationUtils.relative('links/', 'links/hello/test.md')).toBe('hello/test.md');
+ });
+ });
+
+ describe('.toAbsolute', function() {
+ it('should correctly transform as absolute', function() {
+ expect(LocationUtils.toAbsolute('http://google.fr')).toBe('http://google.fr');
+ expect(LocationUtils.toAbsolute('test.md', './', './')).toBe('test.md');
+ expect(LocationUtils.toAbsolute('folder/test.md', './', './')).toBe('folder/test.md');
+ });
+
+ it('should correctly handle windows path', function() {
+ expect(LocationUtils.toAbsolute('folder\\test.md', './', './')).toBe('folder/test.md');
+ });
+
+ it('should correctly handle absolute path', function() {
+ expect(LocationUtils.toAbsolute('/test.md', './', './')).toBe('test.md');
+ expect(LocationUtils.toAbsolute('/test.md', 'test', 'test')).toBe('../test.md');
+ expect(LocationUtils.toAbsolute('/sub/test.md', 'test', 'test')).toBe('../sub/test.md');
+ expect(LocationUtils.toAbsolute('/test.png', 'folder', '')).toBe('test.png');
+ });
+
+ it('should correctly handle absolute path (windows)', function() {
+ expect(LocationUtils.toAbsolute('\\test.png', 'folder', '')).toBe('test.png');
+ });
+
+ it('should resolve path starting by "/" in root directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './', './')
+ ).toBe('test/hello.md');
+ });
+
+ it('should resolve path starting by "/" in child directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './hello', './')
+ ).toBe('test/hello.md');
+ });
+
+ it('should resolve path starting by "/" in child directory, with same output directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './hello', './hello')
+ ).toBe('../test/hello.md');
+ });
+ });
+
+});
+
+
diff --git a/lib/utils/__tests__/path.js b/lib/utils/__tests__/path.js
new file mode 100644
index 0000000..22bb016
--- /dev/null
+++ b/lib/utils/__tests__/path.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+describe('Paths', function() {
+ var PathUtils = require('..//path');
+
+ describe('setExtension', function() {
+ it('should correctly change extension of filename', function() {
+ expect(PathUtils.setExtension('test.md', '.html')).toBe('test.html');
+ expect(PathUtils.setExtension('test.md', '.json')).toBe('test.json');
+ });
+
+ it('should correctly change extension of path', function() {
+ expect(PathUtils.setExtension('hello/test.md', '.html')).toBe(path.normalize('hello/test.html'));
+ expect(PathUtils.setExtension('hello/test.md', '.json')).toBe(path.normalize('hello/test.json'));
+ });
+ });
+});
diff --git a/lib/utils/error.js b/lib/utils/error.js
index 27fa59d..7686779 100644
--- a/lib/utils/error.js
+++ b/lib/utils/error.js
@@ -1,15 +1,12 @@
-var _ = require('lodash');
+var is = require('is');
+
var TypedError = require('error/typed');
var WrappedError = require('error/wrapped');
-var deprecated = require('deprecated');
-
-var Logger = require('./logger');
-var log = new Logger();
// Enforce as an Error object, and cleanup message
function enforce(err) {
- if (_.isString(err)) err = new Error(err);
+ if (is.string(err)) err = new Error(err);
err.message = err.message.replace(/^Error: /, '');
return err;
@@ -32,6 +29,13 @@ var FileNotFoundError = TypedError({
filename: null
});
+// A file cannot be parsed
+var FileNotParsableError = TypedError({
+ type: 'file.not-parsable',
+ message: '"{filename}" file cannot be parsed',
+ filename: null
+});
+
// A file is outside the scope
var FileOutOfScopeError = TypedError({
type: 'file.out-of-scope',
@@ -77,14 +81,6 @@ var EbookError = WrappedError({
stdout: ''
});
-// Deprecate methods/fields
-function deprecateMethod(fn, msg) {
- return deprecated.method(msg, log.warn.ln, fn);
-}
-function deprecateField(obj, prop, value, msg) {
- return deprecated.field(msg, log.warn.ln, obj, prop, value);
-}
-
module.exports = {
enforce: enforce,
@@ -92,14 +88,12 @@ module.exports = {
OutputError: OutputError,
RequireInstallError: RequireInstallError,
+ FileNotParsableError: FileNotParsableError,
FileNotFoundError: FileNotFoundError,
FileOutOfScopeError: FileOutOfScopeError,
TemplateError: TemplateError,
PluginError: PluginError,
ConfigurationError: ConfigurationError,
- EbookError: EbookError,
-
- deprecateMethod: deprecateMethod,
- deprecateField: deprecateField
+ EbookError: EbookError
};
diff --git a/lib/utils/fs.js b/lib/utils/fs.js
index 42fd3c6..3f97096 100644
--- a/lib/utils/fs.js
+++ b/lib/utils/fs.js
@@ -97,12 +97,46 @@ function rmDir(base) {
});
}
+/**
+ Assert a file, if it doesn't exist, call "generator"
+
+ @param {String} filePath
+ @param {Function} generator
+ @return {Promise}
+*/
+function assertFile(filePath, generator) {
+ return fileExists(filePath)
+ .then(function(exists) {
+ if (exists) return;
+
+ return generator();
+ });
+}
+
+/**
+ Pick a file, returns the absolute path if exists, undefined otherwise
+
+ @param {String} rootFolder
+ @param {String} fileName
+ @return {String}
+*/
+function pickFile(rootFolder, fileName) {
+ var result = path.join(rootFolder, fileName);
+ if (fs.existsSync(result)) {
+ return result;
+ }
+
+ return undefined;
+}
+
module.exports = {
exists: fileExists,
existsSync: fs.existsSync,
mkdirp: Promise.nfbind(mkdirp),
readFile: Promise.nfbind(fs.readFile),
writeFile: Promise.nfbind(fs.writeFile),
+ assertFile: assertFile,
+ pickFile: pickFile,
stat: Promise.nfbind(fs.stat),
statSync: fs.statSync,
readdir: Promise.nfbind(fs.readdir),
@@ -113,6 +147,6 @@ module.exports = {
tmpDir: genTmpDir,
download: download,
uniqueFilename: uniqueFilename,
- ensure: ensureFile,
+ ensureFile: ensureFile,
rmDir: rmDir
};
diff --git a/lib/utils/genKey.js b/lib/utils/genKey.js
new file mode 100644
index 0000000..0650011
--- /dev/null
+++ b/lib/utils/genKey.js
@@ -0,0 +1,13 @@
+var lastKey = 0;
+
+/*
+ Generate a random key
+ @return {String}
+*/
+function generateKey() {
+ lastKey += 1;
+ var str = lastKey.toString(16);
+ return '00000'.slice(str.length) + str;
+}
+
+module.exports = generateKey;
diff --git a/lib/utils/location.js b/lib/utils/location.js
index ba43644..84a71ad 100644
--- a/lib/utils/location.js
+++ b/lib/utils/location.js
@@ -30,9 +30,14 @@ function normalize(s) {
return path.normalize(s).replace(/\\/g, '/');
}
-// Convert relative to absolute path
-// dir: directory parent of the file currently in rendering process
-// outdir: directory parent from the html output
+/**
+ Convert relative to absolute path
+
+ @param {String} href
+ @param {String} dir: directory parent of the file currently in rendering process
+ @param {String} outdir: directory parent from the html output
+ @return {String}
+*/
function toAbsolute(_href, dir, outdir) {
if (isExternal(_href)) return _href;
outdir = outdir == undefined? dir : outdir;
@@ -54,17 +59,49 @@ function toAbsolute(_href, dir, outdir) {
return _href;
}
-// Convert an absolute path to a relative path for a specific folder (dir)
-// ('test/', 'hello.md') -> '../hello.md'
+/**
+ Convert an absolute path to a relative path for a specific folder (dir)
+ ('test/', 'hello.md') -> '../hello.md'
+
+ @param {String} dir: current directory
+ @param {String} file: absolute path of file
+ @return {String}
+*/
function relative(dir, file) {
return normalize(path.relative(dir, file));
}
+/**
+ Convert an absolute path to a relative path for a specific folder (dir)
+ ('test/test.md', 'hello.md') -> '../hello.md'
+
+ @param {String} baseFile: current file
+ @param {String} file: absolute path of file
+ @return {String}
+*/
+function relativeForFile(baseFile, file) {
+ return relative(path.dirname(baseFile), file);
+}
+
+/**
+ Compare two paths, return true if they are identical
+ ('README.md', './README.md') -> true
+
+ @param {String} p1: first path
+ @param {String} p2: second path
+ @return {Boolean}
+*/
+function areIdenticalPaths(p1, p2) {
+ return normalize(p1) === normalize(p2);
+}
+
module.exports = {
+ areIdenticalPaths: areIdenticalPaths,
isExternal: isExternal,
isRelative: isRelative,
isAnchor: isAnchor,
normalize: normalize,
toAbsolute: toAbsolute,
- relative: relative
+ relative: relative,
+ relativeForFile: relativeForFile
};
diff --git a/lib/utils/logger.js b/lib/utils/logger.js
index 60215af..fc9c394 100644
--- a/lib/utils/logger.js
+++ b/lib/utils/logger.js
@@ -17,14 +17,17 @@ var COLORS = {
ERROR: color.red
};
-function Logger(write, logLevel, prefix) {
+function Logger(write, logLevel) {
if (!(this instanceof Logger)) return new Logger(write, logLevel);
- this._write = write || function(msg) { process.stdout.write(msg); };
+ this._write = write || function(msg) {
+ if(process.stdout) {
+ process.stdout.write(msg);
+ }
+ };
this.lastChar = '\n';
- // Define log level
- this.setLevel(logLevel);
+ this.setLevel(logLevel || 'info');
_.bindAll(this);
@@ -40,35 +43,48 @@ function Logger(write, logLevel, prefix) {
}, this);
}
-// Create a new logger prefixed from this logger
-Logger.prototype.prefix = function(prefix) {
- return (new Logger(this._write, this.logLevel, prefix));
-};
+/**
+ Change minimum level
-// Change minimum level
+ @param {String} logLevel
+*/
Logger.prototype.setLevel = function(logLevel) {
if (_.isString(logLevel)) logLevel = LEVELS[logLevel.toUpperCase()];
this.logLevel = logLevel;
};
-// Print a simple string
+/**
+ Print a simple string
+
+ @param {String}
+*/
Logger.prototype.write = function(msg) {
msg = msg.toString();
this.lastChar = _.last(msg);
return this._write(msg);
};
-// Format a string using the first argument as a printf-like format.
+/**
+ Format a string using the first argument as a printf-like format.
+*/
Logger.prototype.format = function() {
return util.format.apply(util, arguments);
};
-// Print a line
+/**
+ Print a line
+
+ @param {String}
+*/
Logger.prototype.writeLn = function(msg) {
return this.write((msg || '')+'\n');
};
-// Log/Print a message if level is allowed
+/**
+ Log/Print a message if level is allowed
+
+ @param {Number} level
+*/
Logger.prototype.log = function(level) {
if (level < this.logLevel) return;
@@ -83,7 +99,9 @@ Logger.prototype.log = function(level) {
return this.write(msg);
};
-// Log/Print a line if level is allowed
+/**
+ Log/Print a line if level is allowed
+*/
Logger.prototype.logLn = function() {
if (this.lastChar != '\n') this.write('\n');
@@ -92,7 +110,9 @@ Logger.prototype.logLn = function() {
return this.log.apply(this, args);
};
-// Log a confirmation [OK]
+/**
+ Log a confirmation [OK]
+*/
Logger.prototype.ok = function(level) {
var args = Array.prototype.slice.apply(arguments, [1]);
var msg = this.format.apply(this, args);
@@ -103,12 +123,20 @@ Logger.prototype.ok = function(level) {
}
};
-// Log a "FAIL"
+/**
+ Log a "FAIL"
+*/
Logger.prototype.fail = function(level) {
return this.log(level, color.red('ERROR') + '\n');
};
-// Log state of a promise
+/**
+ Log state of a promise
+
+ @param {Number} level
+ @param {Promise}
+ @return {Promise}
+*/
Logger.prototype.promise = function(level, p) {
var that = this;
diff --git a/lib/utils/path.js b/lib/utils/path.js
index c233c92..a4968c8 100644
--- a/lib/utils/path.js
+++ b/lib/utils/path.js
@@ -42,7 +42,7 @@ function resolveInRoot(root) {
return result;
}
-// Chnage extension
+// Chnage extension of a file
function setExtension(filename, ext) {
return path.join(
path.dirname(filename),
@@ -50,9 +50,20 @@ function setExtension(filename, ext) {
);
}
+/*
+ Return true if a filename is relative.
+
+ @param {String}
+ @return {Boolean}
+*/
+function isPureRelative(filename) {
+ return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0);
+}
+
module.exports = {
isInRoot: isInRoot,
resolveInRoot: resolveInRoot,
normalize: normalizePath,
- setExtension: setExtension
+ setExtension: setExtension,
+ isPureRelative: isPureRelative
};
diff --git a/lib/utils/promise.js b/lib/utils/promise.js
index d49cf27..19d7554 100644
--- a/lib/utils/promise.js
+++ b/lib/utils/promise.js
@@ -1,19 +1,46 @@
var Q = require('q');
-var _ = require('lodash');
+var Immutable = require('immutable');
-// Reduce an array to a promise
+/**
+ Reduce an array to a promise
+
+ @param {Array|List} arr
+ @param {Function(value, element, index)}
+ @return {Promise<Mixed>}
+*/
function reduce(arr, iter, base) {
- return _.reduce(arr, function(prev, elem, i) {
+ arr = Immutable.Iterable.isIterable(arr)? arr : Immutable.List(arr);
+
+ return arr.reduce(function(prev, elem, key) {
return prev.then(function(val) {
- return iter(val, elem, i);
+ return iter(val, elem, key);
});
}, Q(base));
}
-// Transform an array
+/**
+ Iterate over an array using an async iter
+
+ @param {Array|List} arr
+ @param {Function(value, element, index)}
+ @return {Promise}
+*/
+function forEach(arr, iter) {
+ return reduce(arr, function(val, el, key) {
+ return iter(el, key);
+ });
+}
+
+/**
+ Transform an array
+
+ @param {Array|List} arr
+ @param {Function(value, element, index)}
+ @return {Promise}
+*/
function serie(arr, iter, base) {
- return reduce(arr, function(before, item, i) {
- return Q(iter(item, i))
+ return reduce(arr, function(before, item, key) {
+ return Q(iter(item, key))
.then(function(r) {
before.push(r);
return before;
@@ -21,9 +48,17 @@ function serie(arr, iter, base) {
}, []);
}
-// Iter over an array and return first result (not null)
+/**
+ Iter over an array and return first result (not null)
+
+ @param {Array|List} arr
+ @param {Function(element, index)}
+ @return {Promise<Mixed>}
+*/
function some(arr, iter) {
- return _.reduce(arr, function(prev, elem, i) {
+ arr = Immutable.List(arr);
+
+ return arr.reduce(function(prev, elem, i) {
return prev.then(function(val) {
if (val) return val;
@@ -32,8 +67,14 @@ function some(arr, iter) {
}, Q());
}
-// Map an array using an async (promised) iterator
-function map(arr, iter) {
+/**
+ Map an array using an async (promised) iterator
+
+ @param {Array|List} arr
+ @param {Function(element, index)}
+ @return {Promise<List>}
+*/
+function mapAsList(arr, iter) {
return reduce(arr, function(prev, entry, i) {
return Q(iter(entry, i))
.then(function(out) {
@@ -43,18 +84,57 @@ function map(arr, iter) {
}, []);
}
-// Wrap a fucntion in a promise
+/**
+ Map an array or map
+
+ @param {Array|List|Map|OrderedMap} arr
+ @param {Function(element, key)}
+ @return {Promise<List|Map|OrderedMap>}
+*/
+function map(arr, iter) {
+ if (Immutable.Map.isMap(arr)) {
+ var type = 'Map';
+ if (Immutable.OrderedMap.isOrderedMap(arr)) {
+ type = 'OrderedMap';
+ }
+
+ return mapAsList(arr, function(value, key) {
+ return Q(iter(value, key))
+ .then(function(result) {
+ return [key, result];
+ });
+ })
+ .then(function(result) {
+ return Immutable[type](result);
+ });
+ } else {
+ return mapAsList(arr, iter)
+ .then(function(result) {
+ return Immutable.List(result);
+ });
+ }
+}
+
+
+/**
+ Wrap a function in a promise
+
+ @param {Function} func
+ @return {Funciton}
+*/
function wrap(func) {
- return _.wrap(func, function(_func) {
- var args = Array.prototype.slice.call(arguments, 1);
+ return function() {
+ var args = Array.prototype.slice.call(arguments, 0);
+
return Q()
.then(function() {
- return _func.apply(null, args);
+ return func.apply(null, args);
});
- });
+ };
}
module.exports = Q;
+module.exports.forEach = forEach;
module.exports.reduce = reduce;
module.exports.map = map;
module.exports.serie = serie;
diff --git a/lib/utils/timing.js b/lib/utils/timing.js
new file mode 100644
index 0000000..21a4b91
--- /dev/null
+++ b/lib/utils/timing.js
@@ -0,0 +1,89 @@
+var Immutable = require('immutable');
+var is = require('is');
+
+var timers = {};
+var startDate = Date.now();
+
+/**
+ Mesure an operation
+
+ @parqm {String} type
+ @param {Promise} p
+ @return {Promise}
+*/
+function measure(type, p) {
+ timers[type] = timers[type] || {
+ type: type,
+ count: 0,
+ total: 0,
+ min: undefined,
+ max: 0
+ };
+
+ var start = Date.now();
+
+ return p
+ .fin(function() {
+ var end = Date.now();
+ var duration = (end - start);
+
+ timers[type].count ++;
+ timers[type].total += duration;
+
+ if (is.undefined(timers[type].min)) {
+ timers[type].min = duration;
+ } else {
+ timers[type].min = Math.min(timers[type].min, duration);
+ }
+
+ timers[type].max = Math.max(timers[type].max, duration);
+ });
+}
+
+/**
+ Return a milliseconds number as a second string
+
+ @param {Number} ms
+ @return {String}
+*/
+function time(ms) {
+ if (ms < 1000) {
+ return (ms.toFixed(0)) + 'ms';
+ }
+
+ return (ms/1000).toFixed(2) + 's';
+}
+
+/**
+ Dump all timers to a logger
+
+ @param {Logger} logger
+*/
+function dump(logger) {
+ var prefix = ' > ';
+ var measured = 0;
+ var totalDuration = Date.now() - startDate;
+
+ Immutable.Map(timers)
+ .valueSeq()
+ .sortBy(function(timer) {
+ measured += timer.total;
+ return timer.total;
+ })
+ .forEach(function(timer) {
+ logger.debug.ln('Timer "' + timer.type + '" (' + timer.count + ' times) :');
+ logger.debug.ln(prefix + 'Total: ' + time(timer.total));
+ logger.debug.ln(prefix + 'Average: ' + time(timer.total / timer.count));
+ logger.debug.ln(prefix + 'Min: ' + time(timer.min));
+ logger.debug.ln(prefix + 'Max: ' + time(timer.max));
+ logger.debug.ln('---------------------------');
+ });
+
+
+ logger.debug.ln(time(totalDuration - measured) + ' spent in non-mesured sections');
+}
+
+module.exports = {
+ measure: measure,
+ dump: dump
+};