summaryrefslogtreecommitdiffstats
path: root/packages/gitbook
diff options
context:
space:
mode:
Diffstat (limited to 'packages/gitbook')
-rwxr-xr-xpackages/gitbook/bin/gitbook.js8
-rw-r--r--packages/gitbook/lib/__tests__/gitbook.js9
-rw-r--r--packages/gitbook/lib/__tests__/init.js16
-rw-r--r--packages/gitbook/lib/__tests__/module.js6
-rw-r--r--packages/gitbook/lib/api/decodeConfig.js17
-rw-r--r--packages/gitbook/lib/api/decodeGlobal.js22
-rw-r--r--packages/gitbook/lib/api/decodePage.js44
-rw-r--r--packages/gitbook/lib/api/deprecate.js122
-rw-r--r--packages/gitbook/lib/api/encodeConfig.js36
-rw-r--r--packages/gitbook/lib/api/encodeGlobal.js257
-rw-r--r--packages/gitbook/lib/api/encodeNavigation.js64
-rw-r--r--packages/gitbook/lib/api/encodePage.js39
-rw-r--r--packages/gitbook/lib/api/encodeProgress.js63
-rw-r--r--packages/gitbook/lib/api/encodeSummary.js51
-rw-r--r--packages/gitbook/lib/api/index.js8
-rw-r--r--packages/gitbook/lib/browser.js26
-rw-r--r--packages/gitbook/lib/cli/build.js34
-rw-r--r--packages/gitbook/lib/cli/buildEbook.js78
-rw-r--r--packages/gitbook/lib/cli/getBook.js23
-rw-r--r--packages/gitbook/lib/cli/getOutputFolder.js17
-rw-r--r--packages/gitbook/lib/cli/index.js12
-rw-r--r--packages/gitbook/lib/cli/init.js17
-rw-r--r--packages/gitbook/lib/cli/install.js21
-rw-r--r--packages/gitbook/lib/cli/options.js31
-rw-r--r--packages/gitbook/lib/cli/parse.js79
-rw-r--r--packages/gitbook/lib/cli/serve.js159
-rw-r--r--packages/gitbook/lib/cli/server.js128
-rw-r--r--packages/gitbook/lib/cli/watch.js46
-rw-r--r--packages/gitbook/lib/constants/__tests__/configSchema.js46
-rw-r--r--packages/gitbook/lib/constants/configDefault.js6
-rw-r--r--packages/gitbook/lib/constants/configFiles.js5
-rw-r--r--packages/gitbook/lib/constants/configSchema.js194
-rw-r--r--packages/gitbook/lib/constants/defaultBlocks.js51
-rw-r--r--packages/gitbook/lib/constants/defaultFilters.js15
-rw-r--r--packages/gitbook/lib/constants/defaultPlugins.js29
-rw-r--r--packages/gitbook/lib/constants/extsAsciidoc.js4
-rw-r--r--packages/gitbook/lib/constants/extsMarkdown.js5
-rw-r--r--packages/gitbook/lib/constants/ignoreFiles.js6
-rw-r--r--packages/gitbook/lib/constants/pluginAssetsFolder.js2
-rw-r--r--packages/gitbook/lib/constants/pluginHooks.js8
-rw-r--r--packages/gitbook/lib/constants/pluginPrefix.js5
-rw-r--r--packages/gitbook/lib/constants/pluginResources.js6
-rw-r--r--packages/gitbook/lib/constants/templatesFolder.js2
-rw-r--r--packages/gitbook/lib/constants/themePrefix.js4
-rw-r--r--packages/gitbook/lib/fs/__tests__/mock.js82
-rw-r--r--packages/gitbook/lib/fs/mock.js95
-rw-r--r--packages/gitbook/lib/fs/node.js42
-rw-r--r--packages/gitbook/lib/gitbook.js28
-rw-r--r--packages/gitbook/lib/index.js10
-rw-r--r--packages/gitbook/lib/init.js83
-rw-r--r--packages/gitbook/lib/json/encodeBook.js39
-rw-r--r--packages/gitbook/lib/json/encodeBookWithPage.js22
-rw-r--r--packages/gitbook/lib/json/encodeFile.js21
-rw-r--r--packages/gitbook/lib/json/encodeGlossary.js21
-rw-r--r--packages/gitbook/lib/json/encodeGlossaryEntry.js16
-rw-r--r--packages/gitbook/lib/json/encodeLanguages.js26
-rw-r--r--packages/gitbook/lib/json/encodeOutput.js25
-rw-r--r--packages/gitbook/lib/json/encodeOutputWithPage.js23
-rw-r--r--packages/gitbook/lib/json/encodePage.js39
-rw-r--r--packages/gitbook/lib/json/encodeReadme.js17
-rw-r--r--packages/gitbook/lib/json/encodeSummary.js20
-rw-r--r--packages/gitbook/lib/json/encodeSummaryArticle.js28
-rw-r--r--packages/gitbook/lib/json/encodeSummaryPart.js17
-rw-r--r--packages/gitbook/lib/json/index.js13
-rw-r--r--packages/gitbook/lib/models/__tests__/config.js90
-rw-r--r--packages/gitbook/lib/models/__tests__/glossary.js40
-rw-r--r--packages/gitbook/lib/models/__tests__/glossaryEntry.js15
-rw-r--r--packages/gitbook/lib/models/__tests__/page.js28
-rw-r--r--packages/gitbook/lib/models/__tests__/plugin.js27
-rw-r--r--packages/gitbook/lib/models/__tests__/pluginDependency.js80
-rw-r--r--packages/gitbook/lib/models/__tests__/summary.js94
-rw-r--r--packages/gitbook/lib/models/__tests__/summaryArticle.js53
-rw-r--r--packages/gitbook/lib/models/__tests__/summaryPart.js23
-rw-r--r--packages/gitbook/lib/models/__tests__/templateBlock.js205
-rw-r--r--packages/gitbook/lib/models/__tests__/templateEngine.js51
-rw-r--r--packages/gitbook/lib/models/book.js364
-rw-r--r--packages/gitbook/lib/models/config.js181
-rw-r--r--packages/gitbook/lib/models/file.js89
-rw-r--r--packages/gitbook/lib/models/fs.js307
-rw-r--r--packages/gitbook/lib/models/glossary.js109
-rw-r--r--packages/gitbook/lib/models/glossaryEntry.js43
-rw-r--r--packages/gitbook/lib/models/ignore.js42
-rw-r--r--packages/gitbook/lib/models/language.js21
-rw-r--r--packages/gitbook/lib/models/languages.js71
-rw-r--r--packages/gitbook/lib/models/output.js107
-rw-r--r--packages/gitbook/lib/models/page.js70
-rw-r--r--packages/gitbook/lib/models/parser.js122
-rw-r--r--packages/gitbook/lib/models/plugin.js169
-rw-r--r--packages/gitbook/lib/models/pluginDependency.js168
-rw-r--r--packages/gitbook/lib/models/readme.js40
-rw-r--r--packages/gitbook/lib/models/summary.js228
-rw-r--r--packages/gitbook/lib/models/summaryArticle.js189
-rw-r--r--packages/gitbook/lib/models/summaryPart.js61
-rw-r--r--packages/gitbook/lib/models/templateBlock.js281
-rw-r--r--packages/gitbook/lib/models/templateEngine.js139
-rw-r--r--packages/gitbook/lib/models/templateOutput.js42
-rw-r--r--packages/gitbook/lib/models/templateShortcut.js73
-rw-r--r--packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js32
-rw-r--r--packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js33
-rw-r--r--packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js28
-rw-r--r--packages/gitbook/lib/modifiers/config/addPlugin.js25
-rw-r--r--packages/gitbook/lib/modifiers/config/editPlugin.js13
-rw-r--r--packages/gitbook/lib/modifiers/config/getPluginConfig.js20
-rw-r--r--packages/gitbook/lib/modifiers/config/hasPlugin.js15
-rw-r--r--packages/gitbook/lib/modifiers/config/index.js10
-rw-r--r--packages/gitbook/lib/modifiers/config/isDefaultPlugin.js14
-rw-r--r--packages/gitbook/lib/modifiers/config/removePlugin.js25
-rw-r--r--packages/gitbook/lib/modifiers/config/togglePlugin.js31
-rw-r--r--packages/gitbook/lib/modifiers/index.js5
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js0
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js44
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js78
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js60
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js45
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js68
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js82
-rw-r--r--packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js53
-rw-r--r--packages/gitbook/lib/modifiers/summary/editArticleRef.js17
-rw-r--r--packages/gitbook/lib/modifiers/summary/editArticleTitle.js17
-rw-r--r--packages/gitbook/lib/modifiers/summary/editPartTitle.js23
-rw-r--r--packages/gitbook/lib/modifiers/summary/index.js13
-rw-r--r--packages/gitbook/lib/modifiers/summary/indexArticleLevels.js23
-rw-r--r--packages/gitbook/lib/modifiers/summary/indexLevels.js17
-rw-r--r--packages/gitbook/lib/modifiers/summary/indexPartLevels.js24
-rw-r--r--packages/gitbook/lib/modifiers/summary/insertArticle.js49
-rw-r--r--packages/gitbook/lib/modifiers/summary/insertPart.js19
-rw-r--r--packages/gitbook/lib/modifiers/summary/mergeAtLevel.js75
-rw-r--r--packages/gitbook/lib/modifiers/summary/moveArticle.js25
-rw-r--r--packages/gitbook/lib/modifiers/summary/moveArticleAfter.js60
-rw-r--r--packages/gitbook/lib/modifiers/summary/removeArticle.js37
-rw-r--r--packages/gitbook/lib/modifiers/summary/removePart.js15
-rw-r--r--packages/gitbook/lib/modifiers/summary/unshiftArticle.js29
-rw-r--r--packages/gitbook/lib/output/__tests__/createMock.js38
-rw-r--r--packages/gitbook/lib/output/__tests__/ebook.js16
-rw-r--r--packages/gitbook/lib/output/__tests__/generateMock.js40
-rw-r--r--packages/gitbook/lib/output/__tests__/json.js46
-rw-r--r--packages/gitbook/lib/output/__tests__/website.js144
-rw-r--r--packages/gitbook/lib/output/callHook.js60
-rw-r--r--packages/gitbook/lib/output/callPageHook.js28
-rw-r--r--packages/gitbook/lib/output/createTemplateEngine.js45
-rw-r--r--packages/gitbook/lib/output/ebook/getConvertOptions.js73
-rw-r--r--packages/gitbook/lib/output/ebook/getCoverPath.js30
-rw-r--r--packages/gitbook/lib/output/ebook/getPDFTemplate.js41
-rw-r--r--packages/gitbook/lib/output/ebook/index.js9
-rw-r--r--packages/gitbook/lib/output/ebook/onFinish.js91
-rw-r--r--packages/gitbook/lib/output/ebook/onPage.js24
-rw-r--r--packages/gitbook/lib/output/ebook/options.js17
-rw-r--r--packages/gitbook/lib/output/generateAssets.js26
-rw-r--r--packages/gitbook/lib/output/generateBook.js193
-rw-r--r--packages/gitbook/lib/output/generatePage.js79
-rw-r--r--packages/gitbook/lib/output/generatePages.js36
-rw-r--r--packages/gitbook/lib/output/getModifiers.js73
-rw-r--r--packages/gitbook/lib/output/helper/fileToOutput.js32
-rw-r--r--packages/gitbook/lib/output/helper/fileToURL.js31
-rw-r--r--packages/gitbook/lib/output/helper/index.js2
-rw-r--r--packages/gitbook/lib/output/helper/resolveFileToURL.js26
-rw-r--r--packages/gitbook/lib/output/helper/writeFile.js23
-rw-r--r--packages/gitbook/lib/output/index.js24
-rw-r--r--packages/gitbook/lib/output/json/index.js7
-rw-r--r--packages/gitbook/lib/output/json/onFinish.js47
-rw-r--r--packages/gitbook/lib/output/json/onPage.js43
-rw-r--r--packages/gitbook/lib/output/json/options.js8
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js26
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/annotateText.js46
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js40
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js60
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js25
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js104
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js25
-rw-r--r--packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js33
-rw-r--r--packages/gitbook/lib/output/modifiers/addHeadingId.js23
-rw-r--r--packages/gitbook/lib/output/modifiers/annotateText.js94
-rw-r--r--packages/gitbook/lib/output/modifiers/editHTMLElement.js15
-rw-r--r--packages/gitbook/lib/output/modifiers/fetchRemoteImages.js44
-rw-r--r--packages/gitbook/lib/output/modifiers/highlightCode.js58
-rw-r--r--packages/gitbook/lib/output/modifiers/index.js15
-rw-r--r--packages/gitbook/lib/output/modifiers/inlineAssets.js29
-rw-r--r--packages/gitbook/lib/output/modifiers/inlinePng.js47
-rw-r--r--packages/gitbook/lib/output/modifiers/modifyHTML.js25
-rw-r--r--packages/gitbook/lib/output/modifiers/resolveImages.js33
-rw-r--r--packages/gitbook/lib/output/modifiers/resolveLinks.js53
-rw-r--r--packages/gitbook/lib/output/modifiers/svgToImg.js56
-rw-r--r--packages/gitbook/lib/output/modifiers/svgToPng.js53
-rw-r--r--packages/gitbook/lib/output/prepareAssets.js22
-rw-r--r--packages/gitbook/lib/output/preparePages.js26
-rw-r--r--packages/gitbook/lib/output/preparePlugins.js36
-rw-r--r--packages/gitbook/lib/output/website/__tests__/i18n.js38
-rw-r--r--packages/gitbook/lib/output/website/copyPluginAssets.js117
-rw-r--r--packages/gitbook/lib/output/website/createTemplateEngine.js151
-rw-r--r--packages/gitbook/lib/output/website/index.js11
-rw-r--r--packages/gitbook/lib/output/website/listSearchPaths.js23
-rw-r--r--packages/gitbook/lib/output/website/onAsset.js28
-rw-r--r--packages/gitbook/lib/output/website/onFinish.js35
-rw-r--r--packages/gitbook/lib/output/website/onInit.js20
-rw-r--r--packages/gitbook/lib/output/website/onPage.js76
-rw-r--r--packages/gitbook/lib/output/website/options.js14
-rw-r--r--packages/gitbook/lib/output/website/prepareI18n.js30
-rw-r--r--packages/gitbook/lib/output/website/prepareResources.js54
-rw-r--r--packages/gitbook/lib/output/website/state.js19
-rw-r--r--packages/gitbook/lib/parse/__tests__/listAssets.js29
-rw-r--r--packages/gitbook/lib/parse/__tests__/parseBook.js90
-rw-r--r--packages/gitbook/lib/parse/__tests__/parseGlossary.js36
-rw-r--r--packages/gitbook/lib/parse/__tests__/parseIgnore.js40
-rw-r--r--packages/gitbook/lib/parse/__tests__/parsePageFromString.js37
-rw-r--r--packages/gitbook/lib/parse/__tests__/parseReadme.js36
-rw-r--r--packages/gitbook/lib/parse/__tests__/parseSummary.js34
-rw-r--r--packages/gitbook/lib/parse/findParsableFile.js36
-rw-r--r--packages/gitbook/lib/parse/index.js15
-rw-r--r--packages/gitbook/lib/parse/listAssets.js43
-rw-r--r--packages/gitbook/lib/parse/lookupStructureFile.js20
-rw-r--r--packages/gitbook/lib/parse/parseBook.js77
-rw-r--r--packages/gitbook/lib/parse/parseConfig.js55
-rw-r--r--packages/gitbook/lib/parse/parseGlossary.js26
-rw-r--r--packages/gitbook/lib/parse/parseIgnore.js51
-rw-r--r--packages/gitbook/lib/parse/parseLanguages.js28
-rw-r--r--packages/gitbook/lib/parse/parsePage.js21
-rw-r--r--packages/gitbook/lib/parse/parsePageFromString.js22
-rw-r--r--packages/gitbook/lib/parse/parsePagesList.js78
-rw-r--r--packages/gitbook/lib/parse/parseReadme.js28
-rw-r--r--packages/gitbook/lib/parse/parseStructureFile.js67
-rw-r--r--packages/gitbook/lib/parse/parseSummary.js44
-rw-r--r--packages/gitbook/lib/parse/validateConfig.js31
-rw-r--r--packages/gitbook/lib/parse/walkSummary.js34
-rw-r--r--packages/gitbook/lib/parsers.js63
-rw-r--r--packages/gitbook/lib/plugins/__tests__/findForBook.js19
-rw-r--r--packages/gitbook/lib/plugins/__tests__/findInstalled.js25
-rw-r--r--packages/gitbook/lib/plugins/__tests__/installPlugin.js29
-rw-r--r--packages/gitbook/lib/plugins/__tests__/installPlugins.js30
-rw-r--r--packages/gitbook/lib/plugins/__tests__/listDependencies.js38
-rw-r--r--packages/gitbook/lib/plugins/__tests__/locateRootFolder.js10
-rw-r--r--packages/gitbook/lib/plugins/__tests__/resolveVersion.js22
-rw-r--r--packages/gitbook/lib/plugins/__tests__/sortDependencies.js42
-rw-r--r--packages/gitbook/lib/plugins/__tests__/validatePlugin.js16
-rw-r--r--packages/gitbook/lib/plugins/findForBook.js34
-rw-r--r--packages/gitbook/lib/plugins/findInstalled.js91
-rw-r--r--packages/gitbook/lib/plugins/index.js10
-rw-r--r--packages/gitbook/lib/plugins/installPlugin.js47
-rw-r--r--packages/gitbook/lib/plugins/installPlugins.js48
-rw-r--r--packages/gitbook/lib/plugins/listBlocks.js18
-rw-r--r--packages/gitbook/lib/plugins/listDependencies.js33
-rw-r--r--packages/gitbook/lib/plugins/listDepsForBook.js18
-rw-r--r--packages/gitbook/lib/plugins/listFilters.js17
-rw-r--r--packages/gitbook/lib/plugins/listResources.js45
-rw-r--r--packages/gitbook/lib/plugins/loadForBook.js73
-rw-r--r--packages/gitbook/lib/plugins/loadPlugin.js89
-rw-r--r--packages/gitbook/lib/plugins/locateRootFolder.js22
-rw-r--r--packages/gitbook/lib/plugins/resolveVersion.js71
-rw-r--r--packages/gitbook/lib/plugins/sortDependencies.js34
-rw-r--r--packages/gitbook/lib/plugins/toNames.js16
-rw-r--r--packages/gitbook/lib/plugins/validateConfig.js71
-rw-r--r--packages/gitbook/lib/plugins/validatePlugin.js34
-rw-r--r--packages/gitbook/lib/templating/__tests__/conrefsLoader.js98
-rw-r--r--packages/gitbook/lib/templating/__tests__/include.md1
-rw-r--r--packages/gitbook/lib/templating/__tests__/postRender.js51
-rw-r--r--packages/gitbook/lib/templating/__tests__/replaceShortcuts.js27
-rw-r--r--packages/gitbook/lib/templating/conrefsLoader.js93
-rw-r--r--packages/gitbook/lib/templating/index.js10
-rw-r--r--packages/gitbook/lib/templating/listShortcuts.js31
-rw-r--r--packages/gitbook/lib/templating/postRender.js53
-rw-r--r--packages/gitbook/lib/templating/render.js44
-rw-r--r--packages/gitbook/lib/templating/renderFile.js41
-rw-r--r--packages/gitbook/lib/templating/replaceShortcuts.js39
-rw-r--r--packages/gitbook/lib/templating/themesLoader.js115
-rw-r--r--packages/gitbook/lib/utils/__tests__/git.js57
-rw-r--r--packages/gitbook/lib/utils/__tests__/location.js100
-rw-r--r--packages/gitbook/lib/utils/__tests__/path.js17
-rw-r--r--packages/gitbook/lib/utils/command.js118
-rw-r--r--packages/gitbook/lib/utils/error.js99
-rw-r--r--packages/gitbook/lib/utils/fs.js170
-rw-r--r--packages/gitbook/lib/utils/genKey.js13
-rw-r--r--packages/gitbook/lib/utils/git.js133
-rw-r--r--packages/gitbook/lib/utils/images.js60
-rw-r--r--packages/gitbook/lib/utils/location.js139
-rw-r--r--packages/gitbook/lib/utils/logger.js172
-rw-r--r--packages/gitbook/lib/utils/mergeDefaults.js16
-rw-r--r--packages/gitbook/lib/utils/path.js74
-rw-r--r--packages/gitbook/lib/utils/promise.js148
-rw-r--r--packages/gitbook/lib/utils/reducedObject.js33
-rw-r--r--packages/gitbook/lib/utils/timing.js97
-rw-r--r--packages/gitbook/package.json98
-rw-r--r--packages/gitbook/testing/setup.js72
281 files changed, 14452 insertions, 0 deletions
diff --git a/packages/gitbook/bin/gitbook.js b/packages/gitbook/bin/gitbook.js
new file mode 100755
index 0000000..5cadbc9
--- /dev/null
+++ b/packages/gitbook/bin/gitbook.js
@@ -0,0 +1,8 @@
+#! /usr/bin/env node
+/* eslint-disable no-console */
+
+var color = require('bash-color');
+
+console.log(color.red('You need to install "gitbook-cli" to have access to the gitbook command anywhere on your system.'));
+console.log(color.red('If you\'ve installed this package globally, you need to uninstall it.'));
+console.log(color.red('>> Run "npm uninstall -g gitbook" then "npm install -g gitbook-cli"'));
diff --git a/packages/gitbook/lib/__tests__/gitbook.js b/packages/gitbook/lib/__tests__/gitbook.js
new file mode 100644
index 0000000..c3669bb
--- /dev/null
+++ b/packages/gitbook/lib/__tests__/gitbook.js
@@ -0,0 +1,9 @@
+var gitbook = require('../gitbook');
+
+describe('satisfies', function() {
+
+ it('should return true for *', function() {
+ expect(gitbook.satisfies('*')).toBe(true);
+ });
+
+});
diff --git a/packages/gitbook/lib/__tests__/init.js b/packages/gitbook/lib/__tests__/init.js
new file mode 100644
index 0000000..66188a3
--- /dev/null
+++ b/packages/gitbook/lib/__tests__/init.js
@@ -0,0 +1,16 @@
+var tmp = require('tmp');
+var initBook = require('../init');
+
+describe('initBook', function() {
+
+ it('should create a README and SUMMARY for empty book', function() {
+ var dir = tmp.dirSync();
+
+ return initBook(dir.name)
+ .then(function() {
+ expect(dir.name).toHaveFile('README.md');
+ expect(dir.name).toHaveFile('SUMMARY.md');
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/__tests__/module.js b/packages/gitbook/lib/__tests__/module.js
new file mode 100644
index 0000000..d9220f5
--- /dev/null
+++ b/packages/gitbook/lib/__tests__/module.js
@@ -0,0 +1,6 @@
+
+describe('GitBook', function() {
+ it('should correctly export', function() {
+ require('../');
+ });
+});
diff --git a/packages/gitbook/lib/api/decodeConfig.js b/packages/gitbook/lib/api/decodeConfig.js
new file mode 100644
index 0000000..5e00df5
--- /dev/null
+++ b/packages/gitbook/lib/api/decodeConfig.js
@@ -0,0 +1,17 @@
+/**
+ Decode changes from a JS API to a config object
+
+ @param {Config} config
+ @param {Object} result: result from API
+ @return {Config}
+*/
+function decodeGlobal(config, result) {
+ var values = result.values;
+
+ delete values.generator;
+ delete values.output;
+
+ return config.updateValues(values);
+}
+
+module.exports = decodeGlobal;
diff --git a/packages/gitbook/lib/api/decodeGlobal.js b/packages/gitbook/lib/api/decodeGlobal.js
new file mode 100644
index 0000000..118afb2
--- /dev/null
+++ b/packages/gitbook/lib/api/decodeGlobal.js
@@ -0,0 +1,22 @@
+var decodeConfig = require('./decodeConfig');
+
+/**
+ Decode changes from a JS API to a output object.
+ Only the configuration can be edited by plugin's hooks
+
+ @param {Output} output
+ @param {Object} result: result from API
+ @return {Output}
+*/
+function decodeGlobal(output, result) {
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ // Update config
+ config = decodeConfig(config, result.config);
+ book = book.set('config', config);
+
+ return output.set('book', book);
+}
+
+module.exports = decodeGlobal;
diff --git a/packages/gitbook/lib/api/decodePage.js b/packages/gitbook/lib/api/decodePage.js
new file mode 100644
index 0000000..c85dd1b
--- /dev/null
+++ b/packages/gitbook/lib/api/decodePage.js
@@ -0,0 +1,44 @@
+var deprecate = require('./deprecate');
+
+/**
+ Decode changes from a JS API to a page object.
+ Only the content can be edited by plugin's hooks.
+
+ @param {Output} output
+ @param {Page} page: page instance to edit
+ @param {Object} result: result from API
+ @return {Page}
+*/
+function decodePage(output, page, result) {
+ var originalContent = page.getContent();
+
+ // No returned value
+ // Existing content will be used
+ if (!result) {
+ return page;
+ }
+
+ deprecate.disable('page.sections');
+
+ // GitBook 3
+ // Use returned page.content if different from original content
+ if (result.content != originalContent) {
+ page = page.set('content', result.content);
+ }
+
+ // GitBook 2 compatibility
+ // Finally, use page.sections
+ else if (result.sections) {
+ page = page.set('content',
+ result.sections.map(function(section) {
+ return section.content;
+ }).join('\n')
+ );
+ }
+
+ deprecate.enable('page.sections');
+
+ return page;
+}
+
+module.exports = decodePage;
diff --git a/packages/gitbook/lib/api/deprecate.js b/packages/gitbook/lib/api/deprecate.js
new file mode 100644
index 0000000..7a93a91
--- /dev/null
+++ b/packages/gitbook/lib/api/deprecate.js
@@ -0,0 +1,122 @@
+var is = require('is');
+var objectPath = require('object-path');
+
+var logged = {};
+var disabled = {};
+
+/**
+ Log a deprecated notice
+
+ @param {Book|Output} book
+ @param {String} key
+ @param {String} message
+*/
+function logNotice(book, key, message) {
+ if (logged[key] || disabled[key]) return;
+
+ logged[key] = true;
+
+ var logger = book.getLogger();
+ logger.warn.ln(message);
+}
+
+/**
+ Deprecate a function
+
+ @param {Book|Output} book
+ @param {String} key: unique identitifer for the deprecated
+ @param {Function} fn
+ @param {String} msg: message to print when called
+ @return {Function}
+*/
+function deprecateMethod(book, key, fn, msg) {
+ return function() {
+ logNotice(book, key, msg);
+
+ return fn.apply(this, arguments);
+ };
+}
+
+/**
+ Deprecate a property of an object
+
+ @param {Book|Output} book
+ @param {String} key: unique identitifer for the deprecated
+ @param {Object} instance
+ @param {String|Function} property
+ @param {String} msg: message to print when called
+ @return {Function}
+*/
+function deprecateField(book, key, instance, property, value, msg) {
+ var store = undefined;
+
+ var prepare = function() {
+ if (!is.undefined(store)) return;
+
+ if (is.fn(value)) store = value();
+ else store = value;
+ };
+
+ var getter = function(){
+ prepare();
+
+ logNotice(book, key, msg);
+ return store;
+ };
+ var setter = function(v) {
+ prepare();
+
+ logNotice(book, key, msg);
+ store = v;
+ return store;
+ };
+
+ Object.defineProperty(instance, property, {
+ get: getter,
+ set: setter,
+ enumerable: true,
+ configurable: true
+ });
+}
+
+/**
+ Enable a deprecation
+
+ @param {String} key: unique identitifer
+*/
+function enableDeprecation(key) {
+ disabled[key] = false;
+}
+
+/**
+ Disable a deprecation
+
+ @param {String} key: unique identitifer
+*/
+function disableDeprecation(key) {
+ disabled[key] = true;
+}
+
+/**
+ Deprecate a method in favor of another one
+
+ @param {Book} book
+ @param {String} key
+ @param {Object} instance
+ @param {String} oldName
+ @param {String} newName
+*/
+function deprecateRenamedMethod(book, key, instance, oldName, newName, msg) {
+ msg = msg || ('"' + oldName + '" is deprecated, use "' + newName + '()" instead');
+ var fn = objectPath.get(instance, newName);
+
+ instance[oldName] = deprecateMethod(book, key, fn, msg);
+}
+
+module.exports = {
+ method: deprecateMethod,
+ renamedMethod: deprecateRenamedMethod,
+ field: deprecateField,
+ enable: enableDeprecation,
+ disable: disableDeprecation
+};
diff --git a/packages/gitbook/lib/api/encodeConfig.js b/packages/gitbook/lib/api/encodeConfig.js
new file mode 100644
index 0000000..2a05528
--- /dev/null
+++ b/packages/gitbook/lib/api/encodeConfig.js
@@ -0,0 +1,36 @@
+var objectPath = require('object-path');
+var deprecate = require('./deprecate');
+
+/**
+ Encode a config object into a JS config api
+
+ @param {Output} output
+ @param {Config} config
+ @return {Object}
+*/
+function encodeConfig(output, config) {
+ var result = {
+ values: config.getValues().toJS(),
+
+ get: function(key, defaultValue) {
+ return objectPath.get(result.values, key, defaultValue);
+ },
+
+ set: function(key, value) {
+ return objectPath.set(result.values, key, value);
+ }
+ };
+
+ deprecate.field(output, 'config.options', result, 'options',
+ result.values, '"config.options" property is deprecated, use "config.get(key)" instead');
+
+ deprecate.field(output, 'config.options.generator', result.values, 'generator',
+ output.getGenerator(), '"options.generator" property is deprecated, use "output.name" instead');
+
+ deprecate.field(output, 'config.options.generator', result.values, 'output',
+ output.getRoot(), '"options.output" property is deprecated, use "output.root()" instead');
+
+ return result;
+}
+
+module.exports = encodeConfig;
diff --git a/packages/gitbook/lib/api/encodeGlobal.js b/packages/gitbook/lib/api/encodeGlobal.js
new file mode 100644
index 0000000..a366526
--- /dev/null
+++ b/packages/gitbook/lib/api/encodeGlobal.js
@@ -0,0 +1,257 @@
+var path = require('path');
+var Promise = require('../utils/promise');
+var PathUtils = require('../utils/path');
+var fs = require('../utils/fs');
+
+var Plugins = require('../plugins');
+var deprecate = require('./deprecate');
+var fileToURL = require('../output/helper/fileToURL');
+var defaultBlocks = require('../constants/defaultBlocks');
+var gitbook = require('../gitbook');
+var parsers = require('../parsers');
+
+var encodeConfig = require('./encodeConfig');
+var encodeSummary = require('./encodeSummary');
+var encodeNavigation = require('./encodeNavigation');
+var encodePage = require('./encodePage');
+
+/**
+ Encode a global context into a JS object
+ It's the context for page's hook, etc
+
+ @param {Output} output
+ @return {Object}
+*/
+function encodeGlobal(output) {
+ var book = output.getBook();
+ var bookFS = book.getContentFS();
+ var logger = output.getLogger();
+ var outputFolder = output.getRoot();
+ var plugins = output.getPlugins();
+ var blocks = Plugins.listBlocks(plugins);
+
+ var result = {
+ log: logger,
+ config: encodeConfig(output, book.getConfig()),
+ summary: encodeSummary(output, book.getSummary()),
+
+ /**
+ Check if the book is a multilingual book
+
+ @return {Boolean}
+ */
+ isMultilingual: function() {
+ return book.isMultilingual();
+ },
+
+ /**
+ Check if the book is a language book for a multilingual book
+
+ @return {Boolean}
+ */
+ isLanguageBook: function() {
+ return book.isLanguageBook();
+ },
+
+ /**
+ Read a file from the book
+
+ @param {String} fileName
+ @return {Promise<Buffer>}
+ */
+ readFile: function(fileName) {
+ return bookFS.read(fileName);
+ },
+
+ /**
+ Read a file from the book as a string
+
+ @param {String} fileName
+ @return {Promise<String>}
+ */
+ readFileAsString: function(fileName) {
+ return bookFS.readAsString(fileName);
+ },
+
+ /**
+ Resolve a file from the book root
+
+ @param {String} fileName
+ @return {String}
+ */
+ resolve: function(fileName) {
+ return path.resolve(book.getContentRoot(), fileName);
+ },
+
+ /**
+ Resolve a page by it path
+
+ @param {String} filePath
+ @return {String}
+ */
+ getPageByPath: function(filePath) {
+ var page = output.getPage(filePath);
+ if (!page) return undefined;
+
+ return encodePage(output, page);
+ },
+
+ /**
+ Render a block of text (markdown/asciidoc)
+
+ @param {String} type
+ @param {String} text
+ @return {Promise<String>}
+ */
+ renderBlock: function(type, text) {
+ var parser = parsers.get(type);
+
+ return parser.parsePage(text)
+ .get('content');
+ },
+
+ /**
+ Render an inline text (markdown/asciidoc)
+
+ @param {String} type
+ @param {String} text
+ @return {Promise<String>}
+ */
+ renderInline: function(type, text) {
+ var parser = parsers.get(type);
+
+ return parser.parseInline(text)
+ .get('content');
+ },
+
+ template: {
+ /**
+ Apply a templating block and returns its result
+
+ @param {String} name
+ @param {Object} blockData
+ @return {Promise|Object}
+ */
+ applyBlock: function(name, blockData) {
+ var block = blocks.get(name) || defaultBlocks.get(name);
+ return Promise(block.applyBlock(blockData, result));
+ }
+ },
+
+ output: {
+ /**
+ Name of the generator being used
+ {String}
+ */
+ name: output.getGenerator(),
+
+ /**
+ Return absolute path to the root folder of output
+ @return {String}
+ */
+ root: function() {
+ return outputFolder;
+ },
+
+ /**
+ Resolve a file from the output root
+
+ @param {String} fileName
+ @return {String}
+ */
+ resolve: function(fileName) {
+ return path.resolve(outputFolder, fileName);
+ },
+
+ /**
+ Convert a filepath into an url
+ @return {String}
+ */
+ toURL: function(filePath) {
+ return fileToURL(output, filePath);
+ },
+
+ /**
+ Check that a file exists.
+
+ @param {String} fileName
+ @return {Promise}
+ */
+ hasFile: function(fileName, content) {
+ return Promise()
+ .then(function() {
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+
+ return fs.exists(filePath);
+ });
+ },
+
+ /**
+ Write a file to the output folder,
+ It creates the required folder
+
+ @param {String} fileName
+ @param {Buffer} content
+ @return {Promise}
+ */
+ writeFile: function(fileName, content) {
+ return Promise()
+ .then(function() {
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ return fs.writeFile(filePath, content);
+ });
+ });
+ },
+
+ /**
+ Copy a file to the output folder
+ It creates the required folder.
+
+ @param {String} inputFile
+ @param {String} outputFile
+ @param {Buffer} content
+ @return {Promise}
+ */
+ copyFile: function(inputFile, outputFile, content) {
+ return Promise()
+ .then(function() {
+ var outputFilePath = PathUtils.resolveInRoot(outputFolder, outputFile);
+
+ return fs.ensureFile(outputFilePath)
+ .then(function() {
+ return fs.copy(inputFile, outputFilePath);
+ });
+ });
+ }
+ },
+
+ gitbook: {
+ version: gitbook.version
+ }
+ };
+
+ // Deprecated properties
+
+ deprecate.renamedMethod(output, 'this.isSubBook', result, 'isSubBook', 'isLanguageBook');
+ deprecate.renamedMethod(output, 'this.contentLink', result, 'contentLink', 'output.toURL');
+
+ deprecate.field(output, 'this.generator', result, 'generator',
+ output.getGenerator(), '"this.generator" property is deprecated, use "this.output.name" instead');
+
+ deprecate.field(output, 'this.navigation', result, 'navigation', function() {
+ return encodeNavigation(output);
+ }, '"navigation" property is deprecated');
+
+ deprecate.field(output, 'this.book', result, 'book',
+ result, '"book" property is deprecated, use "this" directly instead');
+
+ deprecate.field(output, 'this.options', result, 'options',
+ result.config.values, '"options" property is deprecated, use config.get(key) instead');
+
+ return result;
+}
+
+module.exports = encodeGlobal;
diff --git a/packages/gitbook/lib/api/encodeNavigation.js b/packages/gitbook/lib/api/encodeNavigation.js
new file mode 100644
index 0000000..8e329a1
--- /dev/null
+++ b/packages/gitbook/lib/api/encodeNavigation.js
@@ -0,0 +1,64 @@
+var Immutable = require('immutable');
+
+/**
+ Encode an article for next/prev
+
+ @param {Map<String:Page>}
+ @param {Article}
+ @return {Object}
+*/
+function encodeArticle(pages, article) {
+ var articlePath = article.getPath();
+
+ return {
+ path: articlePath,
+ title: article.getTitle(),
+ level: article.getLevel(),
+ exists: (articlePath && pages.has(articlePath)),
+ external: article.isExternal()
+ };
+}
+
+/**
+ this.navigation is a deprecated property from GitBook v2
+
+ @param {Output}
+ @return {Object}
+*/
+function encodeNavigation(output) {
+ var book = output.getBook();
+ var pages = output.getPages();
+ var summary = book.getSummary();
+ var articles = summary.getArticlesAsList();
+
+
+ var navigation = articles
+ .map(function(article, i) {
+ var ref = article.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+ var prev = articles.get(i - 1);
+ var next = articles.get(i + 1);
+
+ return [
+ ref,
+ {
+ index: i,
+ title: article.getTitle(),
+ introduction: (i === 0),
+ prev: prev? encodeArticle(pages, prev) : undefined,
+ next: next? encodeArticle(pages, next) : undefined,
+ level: article.getLevel()
+ }
+ ];
+ })
+ .filter(function(e) {
+ return Boolean(e);
+ });
+
+ return Immutable.Map(navigation).toJS();
+}
+
+module.exports = encodeNavigation;
diff --git a/packages/gitbook/lib/api/encodePage.js b/packages/gitbook/lib/api/encodePage.js
new file mode 100644
index 0000000..379d3d5
--- /dev/null
+++ b/packages/gitbook/lib/api/encodePage.js
@@ -0,0 +1,39 @@
+var JSONUtils = require('../json');
+var deprecate = require('./deprecate');
+var encodeProgress = require('./encodeProgress');
+
+/**
+ Encode a page in a context to a JS API
+
+ @param {Output} output
+ @param {Page} page
+ @return {Object}
+*/
+function encodePage(output, page) {
+ var book = output.getBook();
+ var summary = book.getSummary();
+ var fs = book.getContentFS();
+ var file = page.getFile();
+
+ // JS Page is based on the JSON output
+ var result = JSONUtils.encodePage(page, summary);
+
+ result.type = file.getType();
+ result.path = file.getPath();
+ result.rawPath = fs.resolve(result.path);
+
+ deprecate.field(output, 'page.progress', result, 'progress', function() {
+ return encodeProgress(output, page);
+ }, '"page.progress" property is deprecated');
+
+ deprecate.field(output, 'page.sections', result, 'sections', [
+ {
+ content: result.content,
+ type: 'normal'
+ }
+ ], '"sections" property is deprecated, use page.content instead');
+
+ return result;
+}
+
+module.exports = encodePage;
diff --git a/packages/gitbook/lib/api/encodeProgress.js b/packages/gitbook/lib/api/encodeProgress.js
new file mode 100644
index 0000000..afa0341
--- /dev/null
+++ b/packages/gitbook/lib/api/encodeProgress.js
@@ -0,0 +1,63 @@
+var Immutable = require('immutable');
+var encodeNavigation = require('./encodeNavigation');
+
+/**
+ page.progress is a deprecated property from GitBook v2
+
+ @param {Output}
+ @param {Page}
+ @return {Object}
+*/
+function encodeProgress(output, page) {
+ var current = page.getPath();
+ var navigation = encodeNavigation(output);
+ navigation = Immutable.Map(navigation);
+
+ var n = navigation.size;
+ var percent = 0, prevPercent = 0, currentChapter = null;
+ var done = true;
+
+ var chapters = navigation
+ .map(function(nav, chapterPath) {
+ nav.path = chapterPath;
+ return nav;
+ })
+ .valueSeq()
+ .sortBy(function(nav) {
+ return nav.index;
+ })
+ .map(function(nav, i) {
+ // Calcul percent
+ nav.percent = (i * 100) / Math.max((n - 1), 1);
+
+ // Is it done
+ nav.done = done;
+ if (nav.path == current) {
+ currentChapter = nav;
+ percent = nav.percent;
+ done = false;
+ } else if (done) {
+ prevPercent = nav.percent;
+ }
+
+ return nav;
+ })
+ .toJS();
+
+ return {
+ // Previous percent
+ prevPercent: prevPercent,
+
+ // Current percent
+ percent: percent,
+
+ // List of chapter with progress
+ chapters: chapters,
+
+ // Current chapter
+ current: currentChapter
+ };
+}
+
+module.exports = encodeProgress;
+
diff --git a/packages/gitbook/lib/api/encodeSummary.js b/packages/gitbook/lib/api/encodeSummary.js
new file mode 100644
index 0000000..0d66ded
--- /dev/null
+++ b/packages/gitbook/lib/api/encodeSummary.js
@@ -0,0 +1,51 @@
+var encodeSummaryArticle = require('../json/encodeSummaryArticle');
+
+/**
+ Encode summary to provide an API to plugin
+
+ @param {Output} output
+ @param {Config} config
+ @return {Object}
+*/
+function encodeSummary(output, summary) {
+ var result = {
+ /**
+ Iterate over the summary, it stops when the "iter" returns false
+
+ @param {Function} iter
+ */
+ walk: function (iter) {
+ summary.getArticle(function(article) {
+ var jsonArticle = encodeSummaryArticle(article, false);
+
+ return iter(jsonArticle);
+ });
+ },
+
+ /**
+ Get an article by its level
+
+ @param {String} level
+ @return {Object}
+ */
+ getArticleByLevel: function(level) {
+ var article = summary.getByLevel(level);
+ return (article? encodeSummaryArticle(article) : undefined);
+ },
+
+ /**
+ Get an article by its path
+
+ @param {String} level
+ @return {Object}
+ */
+ getArticleByPath: function(level) {
+ var article = summary.getByPath(level);
+ return (article? encodeSummaryArticle(article) : undefined);
+ }
+ };
+
+ return result;
+}
+
+module.exports = encodeSummary;
diff --git a/packages/gitbook/lib/api/index.js b/packages/gitbook/lib/api/index.js
new file mode 100644
index 0000000..5e67525
--- /dev/null
+++ b/packages/gitbook/lib/api/index.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ encodePage: require('./encodePage'),
+ decodePage: require('./decodePage'),
+
+ encodeGlobal: require('./encodeGlobal'),
+ decodeGlobal: require('./decodeGlobal')
+};
diff --git a/packages/gitbook/lib/browser.js b/packages/gitbook/lib/browser.js
new file mode 100644
index 0000000..87a4dc4
--- /dev/null
+++ b/packages/gitbook/lib/browser.js
@@ -0,0 +1,26 @@
+var Modifiers = require('./modifiers');
+
+module.exports = {
+ Parse: require('./parse'),
+
+ // Models
+ Book: require('./models/book'),
+ FS: require('./models/fs'),
+ File: require('./models/file'),
+ Summary: require('./models/summary'),
+ Glossary: require('./models/glossary'),
+ Config: require('./models/config'),
+ Page: require('./models/page'),
+ PluginDependency: require('./models/pluginDependency'),
+
+ // Modifiers
+ SummaryModifier: Modifiers.Summary,
+ ConfigModifier: Modifiers.Config,
+
+ // Constants
+ CONFIG_FILES: require('./constants/configFiles.js'),
+ IGNORE_FILES: require('./constants/ignoreFiles.js'),
+ DEFAULT_PLUGINS: require('./constants/defaultPlugins'),
+ EXTENSIONS_MARKDOWN: require('./constants/extsMarkdown'),
+ EXTENSIONS_ASCIIDOC: require('./constants/extsAsciidoc')
+};
diff --git a/packages/gitbook/lib/cli/build.js b/packages/gitbook/lib/cli/build.js
new file mode 100644
index 0000000..023901e
--- /dev/null
+++ b/packages/gitbook/lib/cli/build.js
@@ -0,0 +1,34 @@
+var Parse = require('../parse');
+var Output = require('../output');
+var timing = require('../utils/timing');
+
+var options = require('./options');
+var getBook = require('./getBook');
+var getOutputFolder = require('./getOutputFolder');
+
+
+module.exports = {
+ name: 'build [book] [output]',
+ description: 'build a book',
+ options: [
+ options.log,
+ options.format,
+ options.timing
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+ var outputFolder = getOutputFolder(args);
+
+ var Generator = Output.getGenerator(kwargs.format);
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder
+ });
+ })
+ .fin(function() {
+ if (kwargs.timing) timing.dump(book.getLogger());
+ });
+ }
+};
diff --git a/packages/gitbook/lib/cli/buildEbook.js b/packages/gitbook/lib/cli/buildEbook.js
new file mode 100644
index 0000000..a87fac7
--- /dev/null
+++ b/packages/gitbook/lib/cli/buildEbook.js
@@ -0,0 +1,78 @@
+var path = require('path');
+var tmp = require('tmp');
+
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var Parse = require('../parse');
+var Output = require('../output');
+
+var options = require('./options');
+var getBook = require('./getBook');
+
+
+module.exports = function(format) {
+ return {
+ name: (format + ' [book] [output]'),
+ description: 'build a book into an ebook file',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var extension = '.' + format;
+
+ // Output file will be stored in
+ var outputFile = args[1] || ('book' + extension);
+
+ // Create temporary directory
+ var outputFolder = tmp.dirSync().name;
+
+ var book = getBook(args, kwargs);
+ var logger = book.getLogger();
+ var Generator = Output.getGenerator('ebook');
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder,
+ format: format
+ });
+ })
+
+ // Extract ebook file
+ .then(function(output) {
+ var book = output.getBook();
+ var languages = book.getLanguages();
+
+ if (book.isMultilingual()) {
+ return Promise.forEach(languages.getList(), function(lang) {
+ var langID = lang.getID();
+
+ var langOutputFile = path.join(
+ path.dirname(outputFile),
+ path.basename(outputFile, extension) + '_' + langID + extension
+ );
+
+ return fs.copy(
+ path.resolve(outputFolder, langID, 'index' + extension),
+ langOutputFile
+ );
+ })
+ .thenResolve(languages.getCount());
+ } else {
+ return fs.copy(
+ path.resolve(outputFolder, 'index' + extension),
+ outputFile
+ ).thenResolve(1);
+ }
+ })
+
+ // Log end
+ .then(function(count) {
+ logger.info.ok(count + ' file(s) generated');
+
+ logger.debug('cleaning up... ');
+ return logger.debug.promise(fs.rmDir(outputFolder));
+ });
+ }
+ };
+};
diff --git a/packages/gitbook/lib/cli/getBook.js b/packages/gitbook/lib/cli/getBook.js
new file mode 100644
index 0000000..ac82187
--- /dev/null
+++ b/packages/gitbook/lib/cli/getBook.js
@@ -0,0 +1,23 @@
+var path = require('path');
+var Book = require('../models/book');
+var createNodeFS = require('../fs/node');
+
+/**
+ Return a book instance to work on from
+ command line args/kwargs
+
+ @param {Array} args
+ @param {Object} kwargs
+ @return {Book}
+*/
+function getBook(args, kwargs) {
+ var input = path.resolve(args[0] || process.cwd());
+ var logLevel = kwargs.log;
+
+ var fs = createNodeFS(input);
+ var book = Book.createForFS(fs);
+
+ return book.setLogLevel(logLevel);
+}
+
+module.exports = getBook;
diff --git a/packages/gitbook/lib/cli/getOutputFolder.js b/packages/gitbook/lib/cli/getOutputFolder.js
new file mode 100644
index 0000000..272dff9
--- /dev/null
+++ b/packages/gitbook/lib/cli/getOutputFolder.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+/**
+ Return path to output folder
+
+ @param {Array} args
+ @return {String}
+*/
+function getOutputFolder(args) {
+ var bookRoot = path.resolve(args[0] || process.cwd());
+ var defaultOutputRoot = path.join(bookRoot, '_book');
+ var outputFolder = args[1]? path.resolve(process.cwd(), args[1]) : defaultOutputRoot;
+
+ return outputFolder;
+}
+
+module.exports = getOutputFolder;
diff --git a/packages/gitbook/lib/cli/index.js b/packages/gitbook/lib/cli/index.js
new file mode 100644
index 0000000..f1fca1d
--- /dev/null
+++ b/packages/gitbook/lib/cli/index.js
@@ -0,0 +1,12 @@
+var buildEbook = require('./buildEbook');
+
+module.exports = [
+ require('./build'),
+ require('./serve'),
+ require('./install'),
+ require('./parse'),
+ require('./init'),
+ buildEbook('pdf'),
+ buildEbook('epub'),
+ buildEbook('mobi')
+];
diff --git a/packages/gitbook/lib/cli/init.js b/packages/gitbook/lib/cli/init.js
new file mode 100644
index 0000000..55f1b15
--- /dev/null
+++ b/packages/gitbook/lib/cli/init.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+var options = require('./options');
+var initBook = require('../init');
+
+module.exports = {
+ name: 'init [book]',
+ description: 'setup and create files for chapters',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var bookRoot = path.resolve(process.cwd(), args[0] || './');
+
+ return initBook(bookRoot);
+ }
+};
diff --git a/packages/gitbook/lib/cli/install.js b/packages/gitbook/lib/cli/install.js
new file mode 100644
index 0000000..c001711
--- /dev/null
+++ b/packages/gitbook/lib/cli/install.js
@@ -0,0 +1,21 @@
+var options = require('./options');
+var getBook = require('./getBook');
+
+var Parse = require('../parse');
+var Plugins = require('../plugins');
+
+module.exports = {
+ name: 'install [book]',
+ description: 'install all plugins dependencies',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+
+ return Parse.parseConfig(book)
+ .then(function(resultBook) {
+ return Plugins.installPlugins(resultBook);
+ });
+ }
+};
diff --git a/packages/gitbook/lib/cli/options.js b/packages/gitbook/lib/cli/options.js
new file mode 100644
index 0000000..72961ab
--- /dev/null
+++ b/packages/gitbook/lib/cli/options.js
@@ -0,0 +1,31 @@
+var Logger = require('../utils/logger');
+
+var logOptions = {
+ name: 'log',
+ description: 'Minimum log level to display',
+ values: Logger.LEVELS
+ .keySeq()
+ .map(function(s) {
+ return s.toLowerCase();
+ }).toJS(),
+ defaults: 'info'
+};
+
+var formatOption = {
+ name: 'format',
+ description: 'Format to build to',
+ values: ['website', 'json', 'ebook'],
+ defaults: 'website'
+};
+
+var timingOption = {
+ name: 'timing',
+ description: 'Print timing debug information',
+ defaults: false
+};
+
+module.exports = {
+ log: logOptions,
+ format: formatOption,
+ timing: timingOption
+};
diff --git a/packages/gitbook/lib/cli/parse.js b/packages/gitbook/lib/cli/parse.js
new file mode 100644
index 0000000..0fa509a
--- /dev/null
+++ b/packages/gitbook/lib/cli/parse.js
@@ -0,0 +1,79 @@
+var options = require('./options');
+var getBook = require('./getBook');
+
+var Parse = require('../parse');
+
+function printBook(book) {
+ var logger = book.getLogger();
+
+ var config = book.getConfig();
+ var configFile = config.getFile();
+
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile();
+
+ var readme = book.getReadme();
+ var readmeFile = readme.getFile();
+
+ var glossary = book.getGlossary();
+ var glossaryFile = glossary.getFile();
+
+ if (configFile.exists()) {
+ logger.info.ln('Configuration file is', configFile.getPath());
+ }
+
+ if (readmeFile.exists()) {
+ logger.info.ln('Introduction file is', readmeFile.getPath());
+ }
+
+ if (glossaryFile.exists()) {
+ logger.info.ln('Glossary file is', glossaryFile.getPath());
+ }
+
+ if (summaryFile.exists()) {
+ logger.info.ln('Table of Contents file is', summaryFile.getPath());
+ }
+}
+
+function printMultingualBook(book) {
+ var logger = book.getLogger();
+ var languages = book.getLanguages();
+ var books = book.getBooks();
+
+ logger.info.ln(languages.size + ' languages');
+
+ languages.forEach(function(lang) {
+ logger.info.ln('Language:', lang.getTitle());
+ printBook(books.get(lang.getID()));
+ logger.info.ln('');
+ });
+}
+
+module.exports = {
+ name: 'parse [book]',
+ description: 'parse and print debug information about a book',
+ options: [
+ options.log
+ ],
+ exec: function(args, kwargs) {
+ var book = getBook(args, kwargs);
+ var logger = book.getLogger();
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ var rootFolder = book.getRoot();
+ var contentFolder = book.getContentRoot();
+
+ logger.info.ln('Book located in:', rootFolder);
+ if (contentFolder != rootFolder) {
+ logger.info.ln('Content located in:', contentFolder);
+ }
+
+ if (resultBook.isMultilingual()) {
+ printMultingualBook(resultBook);
+ } else {
+ printBook(resultBook);
+ }
+ });
+ }
+};
diff --git a/packages/gitbook/lib/cli/serve.js b/packages/gitbook/lib/cli/serve.js
new file mode 100644
index 0000000..5340851
--- /dev/null
+++ b/packages/gitbook/lib/cli/serve.js
@@ -0,0 +1,159 @@
+/* eslint-disable no-console */
+
+var tinylr = require('tiny-lr');
+var open = require('open');
+
+var Parse = require('../parse');
+var Output = require('../output');
+var ConfigModifier = require('../modifiers').Config;
+
+var Promise = require('../utils/promise');
+
+var options = require('./options');
+var getBook = require('./getBook');
+var getOutputFolder = require('./getOutputFolder');
+var Server = require('./server');
+var watch = require('./watch');
+
+var server, lrServer, lrPath;
+
+function waitForCtrlC() {
+ var d = Promise.defer();
+
+ process.on('SIGINT', function() {
+ d.resolve();
+ });
+
+ return d.promise;
+}
+
+
+function generateBook(args, kwargs) {
+ var port = kwargs.port;
+ var outputFolder = getOutputFolder(args);
+ var book = getBook(args, kwargs);
+ var Generator = Output.getGenerator(kwargs.format);
+ var browser = kwargs['browser'];
+
+ var hasWatch = kwargs['watch'];
+ var hasLiveReloading = kwargs['live'];
+ var hasOpen = kwargs['open'];
+
+ // Stop server if running
+ if (server.isRunning()) console.log('Stopping server');
+
+ return server.stop()
+ .then(function() {
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ if (hasLiveReloading) {
+ // Enable livereload plugin
+ var config = resultBook.getConfig();
+ config = ConfigModifier.addPlugin(config, 'livereload');
+ resultBook = resultBook.set('config', config);
+ }
+
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder
+ });
+ });
+ })
+ .then(function() {
+ console.log();
+ console.log('Starting server ...');
+ return server.start(outputFolder, port);
+ })
+ .then(function() {
+ console.log('Serving book on http://localhost:'+port);
+
+ if (lrPath && hasLiveReloading) {
+ // trigger livereload
+ lrServer.changed({
+ body: {
+ files: [lrPath]
+ }
+ });
+ }
+
+ if (hasOpen) {
+ open('http://localhost:'+port, browser);
+ }
+ })
+ .then(function() {
+ if (!hasWatch) {
+ return waitForCtrlC();
+ }
+
+ return watch(book.getRoot())
+ .then(function(filepath) {
+ // set livereload path
+ lrPath = filepath;
+ console.log('Restart after change in file', filepath);
+ console.log('');
+ return generateBook(args, kwargs);
+ });
+ });
+}
+
+module.exports = {
+ name: 'serve [book] [output]',
+ description: 'serve the book as a website for testing',
+ options: [
+ {
+ name: 'port',
+ description: 'Port for server to listen on',
+ defaults: 4000
+ },
+ {
+ name: 'lrport',
+ description: 'Port for livereload server to listen on',
+ defaults: 35729
+ },
+ {
+ name: 'watch',
+ description: 'Enable file watcher and live reloading',
+ defaults: true
+ },
+ {
+ name: 'live',
+ description: 'Enable live reloading',
+ defaults: true
+ },
+ {
+ name: 'open',
+ description: 'Enable opening book in browser',
+ defaults: false
+ },
+ {
+ name: 'browser',
+ description: 'Specify browser for opening book',
+ defaults: ''
+ },
+ options.log,
+ options.format
+ ],
+ exec: function(args, kwargs) {
+ server = new Server();
+ var hasWatch = kwargs['watch'];
+ var hasLiveReloading = kwargs['live'];
+
+ return Promise()
+ .then(function() {
+ if (!hasWatch || !hasLiveReloading) {
+ return;
+ }
+
+ lrServer = tinylr({});
+ return Promise.nfcall(lrServer.listen.bind(lrServer), kwargs.lrport)
+ .then(function() {
+ console.log('Live reload server started on port:', kwargs.lrport);
+ console.log('Press CTRL+C to quit ...');
+ console.log('');
+
+ });
+ })
+ .then(function() {
+ return generateBook(args, kwargs);
+ });
+ }
+};
diff --git a/packages/gitbook/lib/cli/server.js b/packages/gitbook/lib/cli/server.js
new file mode 100644
index 0000000..752f867
--- /dev/null
+++ b/packages/gitbook/lib/cli/server.js
@@ -0,0 +1,128 @@
+var events = require('events');
+var http = require('http');
+var send = require('send');
+var util = require('util');
+var url = require('url');
+
+var Promise = require('../utils/promise');
+
+function Server() {
+ this.running = null;
+ this.dir = null;
+ this.port = 0;
+ this.sockets = [];
+}
+util.inherits(Server, events.EventEmitter);
+
+/**
+ Return true if the server is running
+
+ @return {Boolean}
+*/
+Server.prototype.isRunning = function() {
+ return !!this.running;
+};
+
+/**
+ Stop the server
+
+ @return {Promise}
+*/
+Server.prototype.stop = function() {
+ var that = this;
+ if (!this.isRunning()) return Promise();
+
+ var d = Promise.defer();
+ this.running.close(function(err) {
+ that.running = null;
+ that.emit('state', false);
+
+ if (err) d.reject(err);
+ else d.resolve();
+ });
+
+ for (var i = 0; i < this.sockets.length; i++) {
+ this.sockets[i].destroy();
+ }
+
+ return d.promise;
+};
+
+/**
+ Start the server
+
+ @return {Promise}
+*/
+Server.prototype.start = function(dir, port) {
+ var that = this, pre = Promise();
+ port = port || 8004;
+
+ if (that.isRunning()) pre = this.stop();
+ return pre
+ .then(function() {
+ var d = Promise.defer();
+
+ that.running = http.createServer(function(req, res){
+ // Render error
+ function error(err) {
+ res.statusCode = err.status || 500;
+ res.end(err.message);
+ }
+
+ // Redirect to directory's index.html
+ function redirect() {
+ var resultURL = urlTransform(req.url, function(parsed) {
+ parsed.pathname += '/';
+ return parsed;
+ });
+
+ res.statusCode = 301;
+ res.setHeader('Location', resultURL);
+ res.end('Redirecting to ' + resultURL);
+ }
+
+ res.setHeader('X-Current-Location', req.url);
+
+ // Send file
+ send(req, url.parse(req.url).pathname, {
+ root: dir
+ })
+ .on('error', error)
+ .on('directory', redirect)
+ .pipe(res);
+ });
+
+ that.running.on('connection', function (socket) {
+ that.sockets.push(socket);
+ socket.setTimeout(4000);
+ socket.on('close', function () {
+ that.sockets.splice(that.sockets.indexOf(socket), 1);
+ });
+ });
+
+ that.running.listen(port, function(err) {
+ if (err) return d.reject(err);
+
+ that.port = port;
+ that.dir = dir;
+ that.emit('state', true);
+ d.resolve();
+ });
+
+ return d.promise;
+ });
+};
+
+/**
+ urlTransform is a helper function that allows a function to transform
+ a url string in it's parsed form and returns the new url as a string
+
+ @param {String} uri
+ @param {Function} fn
+ @return {String}
+*/
+function urlTransform(uri, fn) {
+ return url.format(fn(url.parse(uri)));
+}
+
+module.exports = Server;
diff --git a/packages/gitbook/lib/cli/watch.js b/packages/gitbook/lib/cli/watch.js
new file mode 100644
index 0000000..14434ab
--- /dev/null
+++ b/packages/gitbook/lib/cli/watch.js
@@ -0,0 +1,46 @@
+var path = require('path');
+var chokidar = require('chokidar');
+
+var Promise = require('../utils/promise');
+var parsers = require('../parsers');
+
+/**
+ Watch a folder and resolve promise once a file is modified
+
+ @param {String} dir
+ @return {Promise}
+*/
+function watch(dir) {
+ var d = Promise.defer();
+ dir = path.resolve(dir);
+
+ var toWatch = [
+ 'book.json', 'book.js', '_layouts/**'
+ ];
+
+ // Watch all parsable files
+ parsers.extensions.forEach(function(ext) {
+ toWatch.push('**/*'+ext);
+ });
+
+ var watcher = chokidar.watch(toWatch, {
+ cwd: dir,
+ ignored: '_book/**',
+ ignoreInitial: true
+ });
+
+ watcher.once('all', function(e, filepath) {
+ watcher.close();
+
+ d.resolve(filepath);
+ });
+ watcher.once('error', function(err) {
+ watcher.close();
+
+ d.reject(err);
+ });
+
+ return d.promise;
+}
+
+module.exports = watch;
diff --git a/packages/gitbook/lib/constants/__tests__/configSchema.js b/packages/gitbook/lib/constants/__tests__/configSchema.js
new file mode 100644
index 0000000..efc99b9
--- /dev/null
+++ b/packages/gitbook/lib/constants/__tests__/configSchema.js
@@ -0,0 +1,46 @@
+var jsonschema = require('jsonschema');
+var schema = require('../configSchema');
+
+describe('configSchema', function() {
+
+ function validate(cfg) {
+ var v = new jsonschema.Validator();
+ return v.validate(cfg, schema, {
+ propertyName: 'config'
+ });
+ }
+
+ describe('structure', function() {
+
+ it('should accept dot in filename', function() {
+ var result = validate({
+ structure: {
+ readme: 'book-intro.adoc'
+ }
+ });
+
+ expect(result.errors.length).toBe(0);
+ });
+
+ it('should accept uppercase in filename', function() {
+ var result = validate({
+ structure: {
+ readme: 'BOOK.adoc'
+ }
+ });
+
+ expect(result.errors.length).toBe(0);
+ });
+
+ it('should not accept filepath', function() {
+ var result = validate({
+ structure: {
+ readme: 'folder/myFile.md'
+ }
+ });
+
+ expect(result.errors.length).toBe(1);
+ });
+
+ });
+});
diff --git a/packages/gitbook/lib/constants/configDefault.js b/packages/gitbook/lib/constants/configDefault.js
new file mode 100644
index 0000000..0d95883
--- /dev/null
+++ b/packages/gitbook/lib/constants/configDefault.js
@@ -0,0 +1,6 @@
+var Immutable = require('immutable');
+var jsonSchemaDefaults = require('json-schema-defaults');
+
+var schema = require('./configSchema');
+
+module.exports = Immutable.fromJS(jsonSchemaDefaults(schema));
diff --git a/packages/gitbook/lib/constants/configFiles.js b/packages/gitbook/lib/constants/configFiles.js
new file mode 100644
index 0000000..a67fd74
--- /dev/null
+++ b/packages/gitbook/lib/constants/configFiles.js
@@ -0,0 +1,5 @@
+// Configuration files to test (sorted)
+module.exports = [
+ 'book.js',
+ 'book.json'
+];
diff --git a/packages/gitbook/lib/constants/configSchema.js b/packages/gitbook/lib/constants/configSchema.js
new file mode 100644
index 0000000..d2126c6
--- /dev/null
+++ b/packages/gitbook/lib/constants/configSchema.js
@@ -0,0 +1,194 @@
+var FILENAME_REGEX = '^[a-zA-Z-._\d,\s]+$';
+
+module.exports = {
+ '$schema': 'http://json-schema.org/schema#',
+ 'id': 'https://gitbook.com/schemas/book.json',
+ 'title': 'GitBook Configuration',
+ 'type': 'object',
+ 'properties': {
+ 'root': {
+ 'type': 'string',
+ 'title': 'Path fro the root folder containing the book\'s content'
+ },
+ 'title': {
+ 'type': 'string',
+ 'title': 'Title of the book, default is extracted from README'
+ },
+ 'isbn': {
+ 'type': 'string',
+ 'title': 'ISBN for published book'
+ },
+ 'language': {
+ 'type': 'string',
+ 'title': 'Language of the book'
+ },
+ 'author': {
+ 'type': 'string',
+ 'title': 'Name of the author'
+ },
+ 'gitbook': {
+ 'type': 'string',
+ 'default': '*',
+ 'title': 'GitBook version to match'
+ },
+ 'direction': {
+ 'type': 'string',
+ 'enum': ['ltr', 'rtl'],
+ 'title': 'Direction of texts, default is detected in the pages'
+ },
+ 'theme': {
+ 'type': 'string',
+ 'default': 'default',
+ 'title': 'Name of the theme plugin to use'
+ },
+ 'variables': {
+ 'type': 'object',
+ 'title': 'Templating context variables'
+ },
+ 'plugins': {
+ 'oneOf': [
+ { '$ref': '#/definitions/pluginsArray' },
+ { '$ref': '#/definitions/pluginsString' }
+ ],
+ 'default': []
+ },
+ 'pluginsConfig': {
+ 'type': 'object',
+ 'title': 'Configuration for plugins'
+ },
+ 'structure': {
+ 'type': 'object',
+ 'properties': {
+ 'langs': {
+ 'default': 'LANGS.md',
+ 'type': 'string',
+ 'title': 'File to use as languages index',
+ 'pattern': FILENAME_REGEX
+ },
+ 'readme': {
+ 'default': 'README.md',
+ 'type': 'string',
+ 'title': 'File to use as preface',
+ 'pattern': FILENAME_REGEX
+ },
+ 'glossary': {
+ 'default': 'GLOSSARY.md',
+ 'type': 'string',
+ 'title': 'File to use as glossary index',
+ 'pattern': FILENAME_REGEX
+ },
+ 'summary': {
+ 'default': 'SUMMARY.md',
+ 'type': 'string',
+ 'title': 'File to use as table of contents',
+ 'pattern': FILENAME_REGEX
+ }
+ },
+ 'additionalProperties': false
+ },
+ 'pdf': {
+ 'type': 'object',
+ 'title': 'PDF specific configurations',
+ 'properties': {
+ 'pageNumbers': {
+ 'type': 'boolean',
+ 'default': true,
+ 'title': 'Add page numbers to the bottom of every page'
+ },
+ 'fontSize': {
+ 'type': 'integer',
+ 'minimum': 8,
+ 'maximum': 30,
+ 'default': 12,
+ 'title': 'Font size for the PDF output'
+ },
+ 'fontFamily': {
+ 'type': 'string',
+ 'default': 'Arial',
+ 'title': 'Font family for the PDF output'
+ },
+ 'paperSize': {
+ 'type': 'string',
+ 'enum': ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'legal', 'letter'],
+ 'default': 'a4',
+ 'title': 'Paper size for the PDF'
+ },
+ 'chapterMark': {
+ 'type': 'string',
+ 'enum': ['pagebreak', 'rule', 'both', 'none'],
+ 'default': 'pagebreak',
+ 'title': 'How to mark detected chapters'
+ },
+ 'pageBreaksBefore': {
+ 'type': 'string',
+ 'default': '/',
+ 'title': 'An XPath expression. Page breaks are inserted before the specified elements. To disable use the expression: "/"'
+ },
+ 'margin': {
+ 'type': 'object',
+ 'properties': {
+ 'right': {
+ 'type': 'integer',
+ 'title': 'Right Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 62
+ },
+ 'left': {
+ 'type': 'integer',
+ 'title': 'Left Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 62
+ },
+ 'top': {
+ 'type': 'integer',
+ 'title': 'Top Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 56
+ },
+ 'bottom': {
+ 'type': 'integer',
+ 'title': 'Bottom Margin',
+ 'minimum': 0,
+ 'maximum': 100,
+ 'default': 56
+ }
+ }
+ }
+ }
+ }
+ },
+ 'required': [],
+ 'definitions': {
+ 'pluginsArray': {
+ 'type': 'array',
+ 'items': {
+ 'oneOf': [
+ { '$ref': '#/definitions/pluginObject' },
+ { '$ref': '#/definitions/pluginString' }
+ ]
+ }
+ },
+ 'pluginsString': {
+ 'type': 'string'
+ },
+ 'pluginString': {
+ 'type': 'string'
+ },
+ 'pluginObject': {
+ 'type': 'object',
+ 'properties': {
+ 'name': {
+ 'type': 'string'
+ },
+ 'version': {
+ 'type': 'string'
+ }
+ },
+ 'additionalProperties': false,
+ 'required': ['name']
+ }
+ }
+};
diff --git a/packages/gitbook/lib/constants/defaultBlocks.js b/packages/gitbook/lib/constants/defaultBlocks.js
new file mode 100644
index 0000000..74d1f1f
--- /dev/null
+++ b/packages/gitbook/lib/constants/defaultBlocks.js
@@ -0,0 +1,51 @@
+var Immutable = require('immutable');
+var TemplateBlock = require('../models/templateBlock');
+
+module.exports = Immutable.Map({
+ html: TemplateBlock({
+ name: 'html',
+ process: function(blk) {
+ return blk;
+ }
+ }),
+
+ code: TemplateBlock({
+ name: 'code',
+ process: function(blk) {
+ return {
+ html: false,
+ body: blk.body
+ };
+ }
+ }),
+
+ markdown: TemplateBlock({
+ name: 'markdown',
+ process: function(blk) {
+ return this.book.renderInline('markdown', blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ }),
+
+ asciidoc: TemplateBlock({
+ name: 'asciidoc',
+ process: function(blk) {
+ return this.book.renderInline('asciidoc', blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ }),
+
+ markup: TemplateBlock({
+ name: 'markup',
+ process: function(blk) {
+ return this.book.renderInline(this.ctx.file.type, blk.body)
+ .then(function(out) {
+ return { body: out };
+ });
+ }
+ })
+});
diff --git a/packages/gitbook/lib/constants/defaultFilters.js b/packages/gitbook/lib/constants/defaultFilters.js
new file mode 100644
index 0000000..35025cc
--- /dev/null
+++ b/packages/gitbook/lib/constants/defaultFilters.js
@@ -0,0 +1,15 @@
+var Immutable = require('immutable');
+var moment = require('moment');
+
+module.exports = Immutable.Map({
+ // Format a date
+ // ex: 'MMMM Do YYYY, h:mm:ss a
+ date: function(time, format) {
+ return moment(time).format(format);
+ },
+
+ // Relative Time
+ dateFromNow: function(time) {
+ return moment(time).fromNow();
+ }
+});
diff --git a/packages/gitbook/lib/constants/defaultPlugins.js b/packages/gitbook/lib/constants/defaultPlugins.js
new file mode 100644
index 0000000..6d15971
--- /dev/null
+++ b/packages/gitbook/lib/constants/defaultPlugins.js
@@ -0,0 +1,29 @@
+var Immutable = require('immutable');
+var PluginDependency = require('../models/pluginDependency');
+
+var pkg = require('../../package.json');
+
+/**
+ * Create a PluginDependency from a dependency of gitbook
+ * @param {String} pluginName
+ * @return {PluginDependency}
+ */
+function createFromDependency(pluginName) {
+ var npmID = PluginDependency.nameToNpmID(pluginName);
+ var version = pkg.dependencies[npmID];
+
+ return PluginDependency.create(pluginName, version);
+}
+
+/*
+ * List of default plugins for all books,
+ * default plugins should be installed in node dependencies of GitBook
+ */
+module.exports = Immutable.List([
+ 'highlight',
+ 'search',
+ 'lunr',
+ 'sharing',
+ 'fontsettings',
+ 'theme-default'
+]).map(createFromDependency);
diff --git a/packages/gitbook/lib/constants/extsAsciidoc.js b/packages/gitbook/lib/constants/extsAsciidoc.js
new file mode 100644
index 0000000..b2f4ce4
--- /dev/null
+++ b/packages/gitbook/lib/constants/extsAsciidoc.js
@@ -0,0 +1,4 @@
+module.exports = [
+ '.adoc',
+ '.asciidoc'
+];
diff --git a/packages/gitbook/lib/constants/extsMarkdown.js b/packages/gitbook/lib/constants/extsMarkdown.js
new file mode 100644
index 0000000..44bf36b
--- /dev/null
+++ b/packages/gitbook/lib/constants/extsMarkdown.js
@@ -0,0 +1,5 @@
+module.exports = [
+ '.md',
+ '.markdown',
+ '.mdown'
+];
diff --git a/packages/gitbook/lib/constants/ignoreFiles.js b/packages/gitbook/lib/constants/ignoreFiles.js
new file mode 100644
index 0000000..aac225e
--- /dev/null
+++ b/packages/gitbook/lib/constants/ignoreFiles.js
@@ -0,0 +1,6 @@
+// Files containing ignore pattner (sorted by priority)
+module.exports = [
+ '.ignore',
+ '.gitignore',
+ '.bookignore'
+];
diff --git a/packages/gitbook/lib/constants/pluginAssetsFolder.js b/packages/gitbook/lib/constants/pluginAssetsFolder.js
new file mode 100644
index 0000000..cd44722
--- /dev/null
+++ b/packages/gitbook/lib/constants/pluginAssetsFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_assets';
diff --git a/packages/gitbook/lib/constants/pluginHooks.js b/packages/gitbook/lib/constants/pluginHooks.js
new file mode 100644
index 0000000..2d5dcaa
--- /dev/null
+++ b/packages/gitbook/lib/constants/pluginHooks.js
@@ -0,0 +1,8 @@
+module.exports = [
+ 'init',
+ 'finish',
+ 'finish:before',
+ 'config',
+ 'page',
+ 'page:before'
+];
diff --git a/packages/gitbook/lib/constants/pluginPrefix.js b/packages/gitbook/lib/constants/pluginPrefix.js
new file mode 100644
index 0000000..c7f2dd0
--- /dev/null
+++ b/packages/gitbook/lib/constants/pluginPrefix.js
@@ -0,0 +1,5 @@
+
+/*
+ All GitBook plugins are NPM packages starting with this prefix.
+*/
+module.exports = 'gitbook-plugin-';
diff --git a/packages/gitbook/lib/constants/pluginResources.js b/packages/gitbook/lib/constants/pluginResources.js
new file mode 100644
index 0000000..ae283bf
--- /dev/null
+++ b/packages/gitbook/lib/constants/pluginResources.js
@@ -0,0 +1,6 @@
+var Immutable = require('immutable');
+
+module.exports = Immutable.List([
+ 'js',
+ 'css'
+]);
diff --git a/packages/gitbook/lib/constants/templatesFolder.js b/packages/gitbook/lib/constants/templatesFolder.js
new file mode 100644
index 0000000..aad6a72
--- /dev/null
+++ b/packages/gitbook/lib/constants/templatesFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_layouts';
diff --git a/packages/gitbook/lib/constants/themePrefix.js b/packages/gitbook/lib/constants/themePrefix.js
new file mode 100644
index 0000000..99428de
--- /dev/null
+++ b/packages/gitbook/lib/constants/themePrefix.js
@@ -0,0 +1,4 @@
+/*
+ All GitBook themes plugins name start with this prefix once shorted.
+*/
+module.exports = 'theme-'; \ No newline at end of file
diff --git a/packages/gitbook/lib/fs/__tests__/mock.js b/packages/gitbook/lib/fs/__tests__/mock.js
new file mode 100644
index 0000000..04bd46a
--- /dev/null
+++ b/packages/gitbook/lib/fs/__tests__/mock.js
@@ -0,0 +1,82 @@
+var createMockFS = require('../mock');
+
+describe('MockFS', function() {
+ var fs = createMockFS({
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary',
+ 'folder': {
+ 'test.md': 'Cool',
+ 'folder2': {
+ 'hello.md': 'Hello',
+ 'world.md': 'World'
+ }
+ }
+ });
+
+ describe('exists', function() {
+ it('must return true for a file', function() {
+ return fs.exists('README.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ it('must return false for a non existing file', function() {
+ return fs.exists('README_NOTEXISTS.md')
+ .then(function(result) {
+ expect(result).toBeFalsy();
+ });
+ });
+
+ it('must return true for a directory', function() {
+ return fs.exists('folder')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ it('must return true for a deep file', function() {
+ return fs.exists('folder/test.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+
+ it('must return true for a deep file (2)', function() {
+ return fs.exists('folder/folder2/hello.md')
+ .then(function(result) {
+ expect(result).toBeTruthy();
+ });
+ });
+ });
+
+ describe('readAsString', function() {
+ it('must return content for a file', function() {
+ return fs.readAsString('README.md')
+ .then(function(result) {
+ expect(result).toBe('Hello World');
+ });
+ });
+
+ it('must return content for a deep file', function() {
+ return fs.readAsString('folder/test.md')
+ .then(function(result) {
+ expect(result).toBe('Cool');
+ });
+ });
+ });
+
+ describe('readDir', function() {
+ it('must return content for a directory', function() {
+ return fs.readDir('./')
+ .then(function(files) {
+ expect(files.size).toBe(3);
+ expect(files.includes('README.md')).toBeTruthy();
+ expect(files.includes('SUMMARY.md')).toBeTruthy();
+ expect(files.includes('folder/')).toBeTruthy();
+ });
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/fs/mock.js b/packages/gitbook/lib/fs/mock.js
new file mode 100644
index 0000000..784c533
--- /dev/null
+++ b/packages/gitbook/lib/fs/mock.js
@@ -0,0 +1,95 @@
+var path = require('path');
+var is = require('is');
+var Buffer = require('buffer').Buffer;
+var Immutable = require('immutable');
+
+var FS = require('../models/fs');
+var error = require('../utils/error');
+
+/**
+ Create a fake filesystem for unit testing GitBook.
+
+ @param {Map<String:String|Map>}
+*/
+function createMockFS(files) {
+ files = Immutable.fromJS(files);
+ var mtime = new Date();
+
+ function getFile(filePath) {
+ var parts = path.normalize(filePath).split(path.sep);
+ return parts.reduce(function(list, part, i) {
+ if (!list) return null;
+
+ var file;
+
+ if (!part || part === '.') file = list;
+ else file = list.get(part);
+
+ if (!file) return null;
+
+ if (is.string(file)) {
+ if (i === (parts.length - 1)) return file;
+ else return null;
+ }
+
+ return file;
+ }, files);
+ }
+
+ function fsExists(filePath) {
+ return Boolean(getFile(filePath) !== null);
+ }
+
+ function fsReadFile(filePath) {
+ var file = getFile(filePath);
+ if (!is.string(file)) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return new Buffer(file, 'utf8');
+ }
+
+ function fsStatFile(filePath) {
+ var file = getFile(filePath);
+ if (!file) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return {
+ mtime: mtime
+ };
+ }
+
+ function fsReadDir(filePath) {
+ var dir = getFile(filePath);
+ if (!dir || is.string(dir)) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return dir
+ .map(function(content, name) {
+ if (!is.string(content)) {
+ name = name + '/';
+ }
+
+ return name;
+ })
+ .valueSeq();
+ }
+
+ return FS.create({
+ root: '',
+ fsExists: fsExists,
+ fsReadFile: fsReadFile,
+ fsStatFile: fsStatFile,
+ fsReadDir: fsReadDir
+ });
+}
+
+module.exports = createMockFS;
diff --git a/packages/gitbook/lib/fs/node.js b/packages/gitbook/lib/fs/node.js
new file mode 100644
index 0000000..dfe9fae
--- /dev/null
+++ b/packages/gitbook/lib/fs/node.js
@@ -0,0 +1,42 @@
+var path = require('path');
+var Immutable = require('immutable');
+var fresh = require('fresh-require');
+
+var fs = require('../utils/fs');
+var FS = require('../models/fs');
+
+function fsReadDir(folder) {
+ return fs.readdir(folder)
+ .then(function(files) {
+ files = Immutable.List(files);
+
+ return files
+ .map(function(file) {
+ if (file == '.' || file == '..') return;
+
+ var stat = fs.statSync(path.join(folder, file));
+ if (stat.isDirectory()) file = file + path.sep;
+ return file;
+ })
+ .filter(function(file) {
+ return Boolean(file);
+ });
+ });
+}
+
+function fsLoadObject(filename) {
+ return fresh(filename, require);
+}
+
+module.exports = function createNodeFS(root) {
+ return FS.create({
+ root: root,
+
+ fsExists: fs.exists,
+ fsReadFile: fs.readFile,
+ fsStatFile: fs.stat,
+ fsReadDir: fsReadDir,
+ fsLoadObject: fsLoadObject,
+ fsReadAsStream: fs.readStream
+ });
+};
diff --git a/packages/gitbook/lib/gitbook.js b/packages/gitbook/lib/gitbook.js
new file mode 100644
index 0000000..bafd3b8
--- /dev/null
+++ b/packages/gitbook/lib/gitbook.js
@@ -0,0 +1,28 @@
+var semver = require('semver');
+var pkg = require('../package.json');
+
+var VERSION = pkg.version;
+var VERSION_STABLE = VERSION.replace(/\-(\S+)/g, '');
+
+var START_TIME = new Date();
+
+/**
+ Verify that this gitbook version satisfies a requirement
+ We can't directly use samver.satisfies since it will break all plugins when gitbook version is a prerelease (beta, alpha)
+
+ @param {String} condition
+ @return {Boolean}
+*/
+function satisfies(condition) {
+ // Test with real version
+ if (semver.satisfies(VERSION, condition)) return true;
+
+ // Test with future stable release
+ return semver.satisfies(VERSION_STABLE, condition);
+}
+
+module.exports = {
+ version: pkg.version,
+ satisfies: satisfies,
+ START_TIME: START_TIME
+};
diff --git a/packages/gitbook/lib/index.js b/packages/gitbook/lib/index.js
new file mode 100644
index 0000000..1f683e2
--- /dev/null
+++ b/packages/gitbook/lib/index.js
@@ -0,0 +1,10 @@
+var extend = require('extend');
+
+var common = require('./browser');
+
+module.exports = extend({
+ initBook: require('./init'),
+ createNodeFS: require('./fs/node'),
+ Output: require('./output'),
+ commands: require('./cli')
+}, common);
diff --git a/packages/gitbook/lib/init.js b/packages/gitbook/lib/init.js
new file mode 100644
index 0000000..c112d4d
--- /dev/null
+++ b/packages/gitbook/lib/init.js
@@ -0,0 +1,83 @@
+var path = require('path');
+
+var createNodeFS = require('./fs/node');
+var fs = require('./utils/fs');
+var Promise = require('./utils/promise');
+var File = require('./models/file');
+var Readme = require('./models/readme');
+var Book = require('./models/book');
+var Parse = require('./parse');
+
+/**
+ Initialize folder structure for a book
+ Read SUMMARY to created the right chapter
+
+ @param {Book}
+ @param {String}
+ @return {Promise}
+*/
+function initBook(rootFolder) {
+ var extension = '.md';
+
+ return fs.mkdirp(rootFolder)
+
+ // Parse the summary and readme
+ .then(function() {
+ var fs = createNodeFS(rootFolder);
+ var book = Book.createForFS(fs);
+
+ return Parse.parseReadme(book)
+
+ // Setup default readme if doesn't found one
+ .fail(function() {
+ var readmeFile = File.createWithFilepath('README' + extension);
+ var readme = Readme.create(readmeFile);
+ return book.setReadme(readme);
+ });
+ })
+ .then(Parse.parseSummary)
+
+ .then(function(book) {
+ var logger = book.getLogger();
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile();
+ var summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension);
+
+ var articles = summary.getArticlesAsList();
+
+ // Write pages
+ return Promise.forEach(articles, function(article) {
+ var articlePath = article.getPath();
+ var filePath = articlePath? path.join(rootFolder, articlePath) : null;
+ if (!filePath) {
+ return;
+ }
+
+ return fs.assertFile(filePath, function() {
+ return fs.ensureFile(filePath)
+ .then(function() {
+ logger.info.ln('create', article.getPath());
+ return fs.writeFile(filePath, '# ' + article.getTitle() + '\n\n');
+ });
+ });
+ })
+
+ // Write summary
+ .then(function() {
+ var filePath = path.join(rootFolder, summaryFilename);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ logger.info.ln('create ' + path.basename(filePath));
+ return fs.writeFile(filePath, summary.toText(extension));
+ });
+ })
+
+ // Log end
+ .then(function() {
+ logger.info.ln('initialization is finished');
+ });
+ });
+}
+
+module.exports = initBook;
diff --git a/packages/gitbook/lib/json/encodeBook.js b/packages/gitbook/lib/json/encodeBook.js
new file mode 100644
index 0000000..9d7ec77
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeBook.js
@@ -0,0 +1,39 @@
+var extend = require('extend');
+
+var gitbook = require('../gitbook');
+var encodeSummary = require('./encodeSummary');
+var encodeGlossary = require('./encodeGlossary');
+var encodeReadme = require('./encodeReadme');
+var encodeLanguages = require('./encodeLanguages');
+
+/**
+ Encode a book to JSON
+
+ @param {Book}
+ @return {Object}
+*/
+function encodeBookToJson(book) {
+ var config = book.getConfig();
+ var language = book.getLanguage();
+
+ var variables = config.getValue('variables', {});
+
+ return {
+ summary: encodeSummary(book.getSummary()),
+ glossary: encodeGlossary(book.getGlossary()),
+ readme: encodeReadme(book.getReadme()),
+ config: book.getConfig().getValues().toJS(),
+
+ languages: book.isMultilingual()? encodeLanguages(book.getLanguages()) : undefined,
+
+ gitbook: {
+ version: gitbook.version,
+ time: gitbook.START_TIME
+ },
+ book: extend({
+ language: language? language : undefined
+ }, variables.toJS())
+ };
+}
+
+module.exports = encodeBookToJson;
diff --git a/packages/gitbook/lib/json/encodeBookWithPage.js b/packages/gitbook/lib/json/encodeBookWithPage.js
new file mode 100644
index 0000000..1c5c7a3
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeBookWithPage.js
@@ -0,0 +1,22 @@
+var encodeBook = require('./encodeBook');
+var encodePage = require('./encodePage');
+var encodeFile = require('./encodeFile');
+
+/**
+ * Return a JSON representation of a book with a specific file
+ *
+ * @param {Book} output
+ * @param {Page} page
+ * @return {Object}
+ */
+function encodeBookWithPage(book, page) {
+ var file = page.getFile();
+
+ var result = encodeBook(book);
+ result.page = encodePage(page, book.getSummary());
+ result.file = encodeFile(file);
+
+ return result;
+}
+
+module.exports = encodeBookWithPage;
diff --git a/packages/gitbook/lib/json/encodeFile.js b/packages/gitbook/lib/json/encodeFile.js
new file mode 100644
index 0000000..d2c9e8a
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeFile.js
@@ -0,0 +1,21 @@
+
+/**
+ Return a JSON representation of a file
+
+ @param {File} file
+ @return {Object}
+*/
+function encodeFileToJson(file) {
+ var filePath = file.getPath();
+ if (!filePath) {
+ return undefined;
+ }
+
+ return {
+ path: filePath,
+ mtime: file.getMTime(),
+ type: file.getType()
+ };
+}
+
+module.exports = encodeFileToJson;
diff --git a/packages/gitbook/lib/json/encodeGlossary.js b/packages/gitbook/lib/json/encodeGlossary.js
new file mode 100644
index 0000000..e9bcfc9
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeGlossary.js
@@ -0,0 +1,21 @@
+var encodeFile = require('./encodeFile');
+var encodeGlossaryEntry = require('./encodeGlossaryEntry');
+
+/**
+ Encode a glossary to JSON
+
+ @param {Glossary}
+ @return {Object}
+*/
+function encodeGlossary(glossary) {
+ var file = glossary.getFile();
+ var entries = glossary.getEntries();
+
+ return {
+ file: encodeFile(file),
+ entries: entries
+ .map(encodeGlossaryEntry).toJS()
+ };
+}
+
+module.exports = encodeGlossary;
diff --git a/packages/gitbook/lib/json/encodeGlossaryEntry.js b/packages/gitbook/lib/json/encodeGlossaryEntry.js
new file mode 100644
index 0000000..d163f45
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeGlossaryEntry.js
@@ -0,0 +1,16 @@
+
+/**
+ Encode a SummaryArticle to JSON
+
+ @param {GlossaryEntry}
+ @return {Object}
+*/
+function encodeGlossaryEntry(entry) {
+ return {
+ id: entry.getID(),
+ name: entry.getName(),
+ description: entry.getDescription()
+ };
+}
+
+module.exports = encodeGlossaryEntry;
diff --git a/packages/gitbook/lib/json/encodeLanguages.js b/packages/gitbook/lib/json/encodeLanguages.js
new file mode 100644
index 0000000..8447e80
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeLanguages.js
@@ -0,0 +1,26 @@
+var encodeFile = require('./encodeFile');
+
+/**
+ Encode a languages listing to JSON
+
+ @param {Languages}
+ @return {Object}
+*/
+function encodeLanguages(languages) {
+ var file = languages.getFile();
+ var list = languages.getList();
+
+ return {
+ file: encodeFile(file),
+ list: list
+ .valueSeq()
+ .map(function(lang) {
+ return {
+ id: lang.getID(),
+ title: lang.getTitle()
+ };
+ }).toJS()
+ };
+}
+
+module.exports = encodeLanguages;
diff --git a/packages/gitbook/lib/json/encodeOutput.js b/packages/gitbook/lib/json/encodeOutput.js
new file mode 100644
index 0000000..7347e57
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeOutput.js
@@ -0,0 +1,25 @@
+var encodeBook = require('./encodeBook');
+
+/**
+ * Encode an output to JSON
+ *
+ * @param {Output}
+ * @return {Object}
+ */
+function encodeOutputToJson(output) {
+ var book = output.getBook();
+ var generator = output.getGenerator();
+ var options = output.getOptions();
+
+ var result = encodeBook(book);
+
+ result.output = {
+ name: generator
+ };
+
+ result.options = options.toJS();
+
+ return result;
+}
+
+module.exports = encodeOutputToJson;
diff --git a/packages/gitbook/lib/json/encodeOutputWithPage.js b/packages/gitbook/lib/json/encodeOutputWithPage.js
new file mode 100644
index 0000000..8b21e3d
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeOutputWithPage.js
@@ -0,0 +1,23 @@
+var encodeOutput = require('./encodeOutput');
+var encodePage = require('./encodePage');
+var encodeFile = require('./encodeFile');
+
+/**
+ * Return a JSON representation of a book with a specific file
+ *
+ * @param {Book} output
+ * @param {Page} page
+ * @return {Object}
+ */
+function encodeOutputWithPage(output, page) {
+ var file = page.getFile();
+ var book = output.getBook();
+
+ var result = encodeOutput(output);
+ result.page = encodePage(page, book.getSummary());
+ result.file = encodeFile(file);
+
+ return result;
+}
+
+module.exports = encodeOutputWithPage;
diff --git a/packages/gitbook/lib/json/encodePage.js b/packages/gitbook/lib/json/encodePage.js
new file mode 100644
index 0000000..be92117
--- /dev/null
+++ b/packages/gitbook/lib/json/encodePage.js
@@ -0,0 +1,39 @@
+var encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ Return a JSON representation of a page
+
+ @param {Page} page
+ @param {Summary} summary
+ @return {Object}
+*/
+function encodePage(page, summary) {
+ var file = page.getFile();
+ var attributes = page.getAttributes();
+ var article = summary.getByPath(file.getPath());
+
+ var result = attributes.toJS();
+
+ if (article) {
+ result.title = article.getTitle();
+ result.level = article.getLevel();
+ result.depth = article.getDepth();
+
+ var nextArticle = summary.getNextArticle(article);
+ if (nextArticle) {
+ result.next = encodeSummaryArticle(nextArticle);
+ }
+
+ var prevArticle = summary.getPrevArticle(article);
+ if (prevArticle) {
+ result.previous = encodeSummaryArticle(prevArticle);
+ }
+ }
+
+ result.content = page.getContent();
+ result.dir = page.getDir();
+
+ return result;
+}
+
+module.exports = encodePage;
diff --git a/packages/gitbook/lib/json/encodeReadme.js b/packages/gitbook/lib/json/encodeReadme.js
new file mode 100644
index 0000000..96176a3
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeReadme.js
@@ -0,0 +1,17 @@
+var encodeFile = require('./encodeFile');
+
+/**
+ Encode a readme to JSON
+
+ @param {Readme}
+ @return {Object}
+*/
+function encodeReadme(readme) {
+ var file = readme.getFile();
+
+ return {
+ file: encodeFile(file)
+ };
+}
+
+module.exports = encodeReadme;
diff --git a/packages/gitbook/lib/json/encodeSummary.js b/packages/gitbook/lib/json/encodeSummary.js
new file mode 100644
index 0000000..97db910
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeSummary.js
@@ -0,0 +1,20 @@
+var encodeFile = require('./encodeFile');
+var encodeSummaryPart = require('./encodeSummaryPart');
+
+/**
+ Encode a summary to JSON
+
+ @param {Summary}
+ @return {Object}
+*/
+function encodeSummary(summary) {
+ var file = summary.getFile();
+ var parts = summary.getParts();
+
+ return {
+ file: encodeFile(file),
+ parts: parts.map(encodeSummaryPart).toJS()
+ };
+}
+
+module.exports = encodeSummary;
diff --git a/packages/gitbook/lib/json/encodeSummaryArticle.js b/packages/gitbook/lib/json/encodeSummaryArticle.js
new file mode 100644
index 0000000..2fc5144
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeSummaryArticle.js
@@ -0,0 +1,28 @@
+
+/**
+ Encode a SummaryArticle to JSON
+
+ @param {SummaryArticle}
+ @return {Object}
+*/
+function encodeSummaryArticle(article, recursive) {
+ var articles = undefined;
+ if (recursive !== false) {
+ articles = article.getArticles()
+ .map(encodeSummaryArticle)
+ .toJS();
+ }
+
+ return {
+ title: article.getTitle(),
+ level: article.getLevel(),
+ depth: article.getDepth(),
+ anchor: article.getAnchor(),
+ url: article.getUrl(),
+ path: article.getPath(),
+ ref: article.getRef(),
+ articles: articles
+ };
+}
+
+module.exports = encodeSummaryArticle;
diff --git a/packages/gitbook/lib/json/encodeSummaryPart.js b/packages/gitbook/lib/json/encodeSummaryPart.js
new file mode 100644
index 0000000..a5e7218
--- /dev/null
+++ b/packages/gitbook/lib/json/encodeSummaryPart.js
@@ -0,0 +1,17 @@
+var encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ Encode a SummaryPart to JSON
+
+ @param {SummaryPart}
+ @return {Object}
+*/
+function encodeSummaryPart(part) {
+ return {
+ title: part.getTitle(),
+ articles: part.getArticles()
+ .map(encodeSummaryArticle).toJS()
+ };
+}
+
+module.exports = encodeSummaryPart;
diff --git a/packages/gitbook/lib/json/index.js b/packages/gitbook/lib/json/index.js
new file mode 100644
index 0000000..3b68f5e
--- /dev/null
+++ b/packages/gitbook/lib/json/index.js
@@ -0,0 +1,13 @@
+
+module.exports = {
+ encodeOutput: require('./encodeOutput'),
+ encodeBookWithPage: require('./encodeBookWithPage'),
+ encodeOutputWithPage: require('./encodeOutputWithPage'),
+ encodeBook: require('./encodeBook'),
+ encodeFile: require('./encodeFile'),
+ encodePage: require('./encodePage'),
+ encodeSummary: require('./encodeSummary'),
+ encodeSummaryArticle: require('./encodeSummaryArticle'),
+ encodeReadme: require('./encodeReadme'),
+ encodeLanguages: require('./encodeLanguages')
+};
diff --git a/packages/gitbook/lib/models/__tests__/config.js b/packages/gitbook/lib/models/__tests__/config.js
new file mode 100644
index 0000000..abad754
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/config.js
@@ -0,0 +1,90 @@
+var Immutable = require('immutable');
+var Config = require('../config');
+
+describe('Config', function() {
+ var config = Config.createWithValues({
+ hello: {
+ world: 1,
+ test: 'Hello',
+ isFalse: false
+ }
+ });
+
+ describe('getValue', function() {
+ it('must return value as immutable', function() {
+ var value = config.getValue('hello');
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ });
+
+ it('must return deep value', function() {
+ var value = config.getValue('hello.world');
+ expect(value).toBe(1);
+ });
+
+ it('must return default value if non existant', function() {
+ var value = config.getValue('hello.nonExistant', 'defaultValue');
+ expect(value).toBe('defaultValue');
+ });
+
+ it('must not return default value for falsy values', function() {
+ var value = config.getValue('hello.isFalse', 'defaultValue');
+ expect(value).toBe(false);
+ });
+ });
+
+ describe('setValue', function() {
+ it('must set value as immutable', function() {
+ var testConfig = config.setValue('hello', {
+ 'cool': 1
+ });
+ var value = testConfig.getValue('hello');
+
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ expect(value.size).toBe(1);
+ expect(value.has('cool')).toBeTruthy();
+ });
+
+ it('must set deep value', function() {
+ var testConfig = config.setValue('hello.world', 2);
+ var hello = testConfig.getValue('hello');
+ var world = testConfig.getValue('hello.world');
+
+ expect(Immutable.Map.isMap(hello)).toBeTruthy();
+ expect(hello.size).toBe(3);
+
+ expect(world).toBe(2);
+ });
+ });
+
+ describe('toReducedVersion', function() {
+ it('must only return diffs for simple values', function() {
+ var _config = Config.createWithValues({
+ gitbook: '3.0.0'
+ });
+
+ var reducedVersion = _config.toReducedVersion();
+
+ expect(reducedVersion.toJS()).toEqual({
+ gitbook: '3.0.0'
+ });
+ });
+
+ it('must only return diffs for deep values', function() {
+ var _config = Config.createWithValues({
+ structure: {
+ readme: 'intro.md'
+ }
+ });
+
+ var reducedVersion = _config.toReducedVersion();
+
+ expect(reducedVersion.toJS()).toEqual({
+ structure: {
+ readme: 'intro.md'
+ }
+ });
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/glossary.js b/packages/gitbook/lib/models/__tests__/glossary.js
new file mode 100644
index 0000000..5bf64dc
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/glossary.js
@@ -0,0 +1,40 @@
+var File = require('../file');
+var Glossary = require('../glossary');
+var GlossaryEntry = require('../glossaryEntry');
+
+describe('Glossary', function() {
+ var glossary = Glossary.createFromEntries(File(), [
+ {
+ name: 'Hello World',
+ description: 'Awesome!'
+ },
+ {
+ name: 'JavaScript',
+ description: 'This is a cool language'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all entries', function() {
+ var entries = glossary.getEntries();
+ expect(entries.size).toBe(2);
+ });
+
+ it('must add entries as GlossaryEntries', function() {
+ var entries = glossary.getEntries();
+ var entry = entries.get('hello-world');
+ expect(entry instanceof GlossaryEntry).toBeTruthy();
+ });
+ });
+
+ describe('toText', function() {
+ it('return as markdown', function() {
+ return glossary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Glossary');
+ });
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/glossaryEntry.js b/packages/gitbook/lib/models/__tests__/glossaryEntry.js
new file mode 100644
index 0000000..833115d
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/glossaryEntry.js
@@ -0,0 +1,15 @@
+var GlossaryEntry = require('../glossaryEntry');
+
+describe('GlossaryEntry', function() {
+ describe('getID', function() {
+ it('must return a normalized ID', function() {
+ var entry = new GlossaryEntry({
+ name: 'Hello World'
+ });
+
+ expect(entry.getID()).toBe('hello-world');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/page.js b/packages/gitbook/lib/models/__tests__/page.js
new file mode 100644
index 0000000..479d276
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/page.js
@@ -0,0 +1,28 @@
+var Immutable = require('immutable');
+var Page = require('../page');
+
+describe('Page', function() {
+
+ describe('toText', function() {
+ it('must not prepend frontmatter if no attributes', function() {
+ var page = Page().merge({
+ content: 'Hello World'
+ });
+
+ expect(page.toText()).toBe('Hello World');
+ });
+
+ it('must prepend frontmatter if attributes', function() {
+ var page = Page().merge({
+ content: 'Hello World',
+ attributes: Immutable.fromJS({
+ hello: 'world'
+ })
+ });
+
+ expect(page.toText()).toBe('---\nhello: world\n---\n\nHello World');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/plugin.js b/packages/gitbook/lib/models/__tests__/plugin.js
new file mode 100644
index 0000000..b229664
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/plugin.js
@@ -0,0 +1,27 @@
+describe('Plugin', function() {
+ var Plugin = require('../plugin');
+
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse version', function() {
+ var plugin = Plugin.createFromString('hello@1.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('1.0.0');
+ });
+ });
+
+ describe('isLoaded', function() {
+ it('must return false for empty plugin', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.isLoaded()).toBe(false);
+ });
+
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/pluginDependency.js b/packages/gitbook/lib/models/__tests__/pluginDependency.js
new file mode 100644
index 0000000..cb04cf2
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/pluginDependency.js
@@ -0,0 +1,80 @@
+var Immutable = require('immutable');
+var PluginDependency = require('../pluginDependency');
+
+describe('PluginDependency', function() {
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ var plugin = PluginDependency.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse state', function() {
+ var plugin = PluginDependency.createFromString('-hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.isEnabled()).toBe(false);
+ });
+
+ describe('Version', function() {
+ it('must parse version', function() {
+ var plugin = PluginDependency.createFromString('hello@1.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('1.0.0');
+ });
+
+ it('must parse semver', function() {
+ var plugin = PluginDependency.createFromString('hello@>=4.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('>=4.0.0');
+ });
+ });
+
+ describe('GIT Version', function() {
+ it('must handle HTTPS urls', function() {
+ var plugin = PluginDependency.createFromString('hello@git+https://github.com/GitbookIO/plugin-ga.git');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('git+https://github.com/GitbookIO/plugin-ga.git');
+ });
+
+ it('must handle SSH urls', function() {
+ var plugin = PluginDependency.createFromString('hello@git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+ });
+ });
+
+ describe('listToArray', function() {
+ it('must create an array from a list of plugin dependencies', function() {
+ var list = PluginDependency.listToArray(Immutable.List([
+ PluginDependency.createFromString('hello@1.0.0'),
+ PluginDependency.createFromString('noversion'),
+ PluginDependency.createFromString('-disabled')
+ ]));
+
+ expect(list).toEqual([
+ 'hello@1.0.0',
+ 'noversion',
+ '-disabled'
+ ]);
+ });
+ });
+
+ describe('listFromArray', function() {
+ it('must create an array from a list of plugin dependencies', function() {
+ var arr = Immutable.fromJS([
+ 'hello@1.0.0',
+ {
+ 'name': 'plugin-ga',
+ 'version': 'git+ssh://samy@github.com/GitbookIO/plugin-ga.git'
+ }
+ ]);
+ var list = PluginDependency.listFromArray(arr);
+
+ expect(list.first().getName()).toBe('hello');
+ expect(list.first().getVersion()).toBe('1.0.0');
+ expect(list.last().getName()).toBe('plugin-ga');
+ expect(list.last().getVersion()).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+ });
+ });
+ });
+});
diff --git a/packages/gitbook/lib/models/__tests__/summary.js b/packages/gitbook/lib/models/__tests__/summary.js
new file mode 100644
index 0000000..29c9330
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/summary.js
@@ -0,0 +1,94 @@
+
+describe('Summary', function() {
+ var File = require('../file');
+ var Summary = require('../summary');
+
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: 'My First Article',
+ ref: 'README.md'
+ },
+ {
+ title: 'My Second Article',
+ ref: 'article.md'
+ },
+ {
+ title: 'Article without ref'
+ },
+ {
+ title: 'Article with absolute ref',
+ ref: 'https://google.fr'
+ }
+ ]
+ },
+ {
+ title: 'Test'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all parts', function() {
+ var parts = summary.getParts();
+ expect(parts.size).toBe(2);
+ });
+ });
+
+ describe('getByLevel', function() {
+ it('can return a Part', function() {
+ var part = summary.getByLevel('1');
+
+ expect(part).toBeDefined();
+ expect(part.getArticles().size).toBe(4);
+ });
+
+ it('can return a Part (2)', function() {
+ var part = summary.getByLevel('2');
+
+ expect(part).toBeDefined();
+ expect(part.getTitle()).toBe('Test');
+ expect(part.getArticles().size).toBe(0);
+ });
+
+ it('can return an Article', function() {
+ var article = summary.getByLevel('1.1');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+ });
+
+ describe('getByPath', function() {
+ it('return correct article', function() {
+ var article = summary.getByPath('README.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+
+ it('return correct article', function() {
+ var article = summary.getByPath('article.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My Second Article');
+ });
+
+ it('return undefined if not found', function() {
+ var article = summary.getByPath('NOT_EXISTING.md');
+
+ expect(article).toBeFalsy();
+ });
+ });
+
+ describe('toText', function() {
+ it('return as markdown', function() {
+ return summary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Summary');
+ });
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/summaryArticle.js b/packages/gitbook/lib/models/__tests__/summaryArticle.js
new file mode 100644
index 0000000..22a7a20
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/summaryArticle.js
@@ -0,0 +1,53 @@
+var SummaryArticle = require('../summaryArticle');
+var File = require('../file');
+
+describe('SummaryArticle', function() {
+ describe('createChildLevel', function() {
+ it('must create the right level', function() {
+ var article = SummaryArticle.create({}, '1.1');
+ expect(article.createChildLevel()).toBe('1.1.1');
+ });
+
+ it('must create the right level when has articles', function() {
+ var article = SummaryArticle.create({
+ articles: [
+ {
+ title: 'Test'
+ }
+ ]
+ }, '1.1');
+ expect(article.createChildLevel()).toBe('1.1.2');
+ });
+ });
+
+ describe('isFile', function() {
+ it('must return true when exactly the file', function() {
+ var article = SummaryArticle.create({
+ ref: 'hello.md'
+ }, '1.1');
+ var file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(true);
+ });
+
+ it('must return true when path is not normalized', function() {
+ var article = SummaryArticle.create({
+ ref: '/hello.md'
+ }, '1.1');
+ var file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(true);
+ });
+
+ it('must return false when has anchor', function() {
+ var article = SummaryArticle.create({
+ ref: 'hello.md#world'
+ }, '1.1');
+ var file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(false);
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/summaryPart.js b/packages/gitbook/lib/models/__tests__/summaryPart.js
new file mode 100644
index 0000000..8ee50b6
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/summaryPart.js
@@ -0,0 +1,23 @@
+var SummaryPart = require('../summaryPart');
+
+describe('SummaryPart', function() {
+ describe('createChildLevel', function() {
+ it('must create the right level', function() {
+ var article = SummaryPart.create({}, '1');
+ expect(article.createChildLevel()).toBe('1.1');
+ });
+
+ it('must create the right level when has articles', function() {
+ var article = SummaryPart.create({
+ articles: [
+ {
+ title: 'Test'
+ }
+ ]
+ }, '1');
+ expect(article.createChildLevel()).toBe('1.2');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/models/__tests__/templateBlock.js b/packages/gitbook/lib/models/__tests__/templateBlock.js
new file mode 100644
index 0000000..e5f7666
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/templateBlock.js
@@ -0,0 +1,205 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+var Promise = require('../../utils/promise');
+
+describe('TemplateBlock', function() {
+ var TemplateBlock = require('../templateBlock');
+
+ describe('create', function() {
+ it('must initialize a simple TemplateBlock from a function', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return {
+ body: '<p>Hello, World!</p>',
+ parse: true
+ };
+ });
+
+ // Check basic templateBlock properties
+ expect(templateBlock.getName()).toBe('sayhello');
+ expect(templateBlock.getEndTag()).toBe('endsayhello');
+ expect(templateBlock.getBlocks().size).toBe(0);
+ expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension');
+
+ // Check result of applying block
+ return Promise()
+ .then(function() {
+ return templateBlock.applyBlock();
+ })
+ .then(function(result) {
+ expect(result.name).toBe('sayhello');
+ expect(result.body).toBe('<p>Hello, World!</p>');
+ });
+ });
+ });
+
+ describe('getShortcuts', function() {
+ it('must return undefined if no shortcuts', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return {
+ body: '<p>Hello, World!</p>',
+ parse: true
+ };
+ });
+
+ expect(templateBlock.getShortcuts()).toNotExist();
+ });
+
+ it('must return complete shortcut', function() {
+ var templateBlock = TemplateBlock.create('sayhello', {
+ process: function(block) {
+ return '<p>Hello, World!</p>';
+ },
+ shortcuts: {
+ parsers: ['markdown'],
+ start: '$',
+ end: '-'
+ }
+ });
+
+ var shortcut = templateBlock.getShortcuts();
+
+ expect(shortcut).toBeDefined();
+ expect(shortcut.getStart()).toEqual('$');
+ expect(shortcut.getEnd()).toEqual('-');
+ expect(shortcut.getStartTag()).toEqual('sayhello');
+ expect(shortcut.getEndTag()).toEqual('endsayhello');
+ });
+ });
+
+ describe('toNunjucksExt()', function() {
+ it('should replace by block anchor', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return 'Hello';
+ });
+
+ var blocks = {};
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt({}, blocks);
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ blocks = Immutable.fromJS(blocks);
+ expect(blocks.size).toBe(1);
+
+ var blockId = blocks.keySeq().get(0);
+ var block = blocks.get(blockId);
+
+ expect(res).toBe('{{-%' + blockId + '%-}}');
+ expect(block.get('body')).toBe('Hello');
+ expect(block.get('name')).toBe('sayhello');
+ });
+ });
+
+ it('must create a valid nunjucks extension', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return {
+ body: '<p>Hello, World!</p>',
+ parse: true
+ };
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, World!</p>');
+ });
+ });
+
+ it('must apply block arguments correctly', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return {
+ body: '<'+block.kwargs.tag+'>Hello, '+block.kwargs.name+'!</'+block.kwargs.tag+'>',
+ parse: true
+ };
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, Samy!</p>');
+ });
+ });
+
+ it('must accept an async function', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return Promise()
+ .then(function() {
+ return {
+ body: 'Hello ' + block.body,
+ parse: true
+ };
+ });
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello %}Samy{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('Hello Samy');
+ });
+ });
+
+ it('must handle nested blocks', function() {
+ var templateBlock = new TemplateBlock({
+ name: 'yoda',
+ blocks: Immutable.List(['start', 'end']),
+ process: function(block) {
+ var nested = {};
+
+ block.blocks.forEach(function(blk) {
+ nested[blk.name] = blk.body.trim();
+ });
+
+ return {
+ body: '<p class="yoda">'+nested.end+' '+nested.start+'</p>',
+ parse: true
+ };
+ }
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p class="yoda">inverted this sentence should be</p>');
+ });
+ });
+ });
+}); \ No newline at end of file
diff --git a/packages/gitbook/lib/models/__tests__/templateEngine.js b/packages/gitbook/lib/models/__tests__/templateEngine.js
new file mode 100644
index 0000000..6f18b18
--- /dev/null
+++ b/packages/gitbook/lib/models/__tests__/templateEngine.js
@@ -0,0 +1,51 @@
+
+describe('TemplateBlock', function() {
+ var TemplateEngine = require('../templateEngine');
+
+ describe('create', function() {
+ it('must initialize with a list of filters', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must initialize with a list of globals', function() {
+ var engine = TemplateEngine.create({
+ globals: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ hello("Luke") }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must pass context to filters and blocks', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + ' ' + this.lastName + '!';
+ }
+ },
+ context: {
+ lastName: 'Skywalker'
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke Skywalker!');
+ });
+ });
+}); \ No newline at end of file
diff --git a/packages/gitbook/lib/models/book.js b/packages/gitbook/lib/models/book.js
new file mode 100644
index 0000000..f774ee8
--- /dev/null
+++ b/packages/gitbook/lib/models/book.js
@@ -0,0 +1,364 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Logger = require('../utils/logger');
+
+var FS = require('./fs');
+var Config = require('./config');
+var Readme = require('./readme');
+var Summary = require('./summary');
+var Glossary = require('./glossary');
+var Languages = require('./languages');
+var Ignore = require('./ignore');
+
+var Book = Immutable.Record({
+ // Logger for outptu message
+ logger: Logger(),
+
+ // Filesystem binded to the book scope to read files/directories
+ fs: FS(),
+
+ // Ignore files parser
+ ignore: Ignore(),
+
+ // Structure files
+ config: Config(),
+ readme: Readme(),
+ summary: Summary(),
+ glossary: Glossary(),
+ languages: Languages(),
+
+ // ID of the language for language books
+ language: String(),
+
+ // List of children, if multilingual (String -> Book)
+ books: Immutable.OrderedMap()
+});
+
+Book.prototype.getLogger = function() {
+ return this.get('logger');
+};
+
+Book.prototype.getFS = function() {
+ return this.get('fs');
+};
+
+Book.prototype.getIgnore = function() {
+ return this.get('ignore');
+};
+
+Book.prototype.getConfig = function() {
+ return this.get('config');
+};
+
+Book.prototype.getReadme = function() {
+ return this.get('readme');
+};
+
+Book.prototype.getSummary = function() {
+ return this.get('summary');
+};
+
+Book.prototype.getGlossary = function() {
+ return this.get('glossary');
+};
+
+Book.prototype.getLanguages = function() {
+ return this.get('languages');
+};
+
+Book.prototype.getBooks = function() {
+ return this.get('books');
+};
+
+Book.prototype.getLanguage = function() {
+ return this.get('language');
+};
+
+/**
+ Return FS instance to access the content
+
+ @return {FS}
+*/
+Book.prototype.getContentFS = function() {
+ var fs = this.getFS();
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ return FS.reduceScope(fs, rootFolder);
+ }
+
+ return fs;
+};
+
+/**
+ Return root of the book
+
+ @return {String}
+*/
+Book.prototype.getRoot = function() {
+ var fs = this.getFS();
+ return fs.getRoot();
+};
+
+/**
+ Return root for content of the book
+
+ @return {String}
+*/
+Book.prototype.getContentRoot = function() {
+ var fs = this.getContentFS();
+ return fs.getRoot();
+};
+
+/**
+ Check if a file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isFileIgnored = function(filename) {
+ var ignore = this.getIgnore();
+ var language = this.getLanguage();
+
+ // Ignore is always relative to the root of the main book
+ if (language) {
+ filename = path.join(language, filename);
+ }
+
+ return ignore.isFileIgnored(filename);
+};
+
+/**
+ Check if a content file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isContentFileIgnored = function(filename) {
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ filename = path.join(rootFolder, filename);
+ }
+
+ return this.isFileIgnored(filename);
+};
+
+/**
+ Return a page from a book by its path
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.getPage = function(ref) {
+ return this.getPages().get(ref);
+};
+
+/**
+ Is this book the parent of language's books
+
+ @return {Boolean}
+*/
+Book.prototype.isMultilingual = function() {
+ return (this.getLanguages().getCount() > 0);
+};
+
+/**
+ Return true if book is associated to a language
+
+ @return {Boolean}
+*/
+Book.prototype.isLanguageBook = function() {
+ return Boolean(this.getLanguage());
+};
+
+/**
+ Return a languages book
+
+ @param {String} language
+ @return {Book}
+*/
+Book.prototype.getLanguageBook = function(language) {
+ var books = this.getBooks();
+ return books.get(language);
+};
+
+/**
+ Add a new language book
+
+ @param {String} language
+ @param {Book} book
+ @return {Book}
+*/
+Book.prototype.addLanguageBook = function(language, book) {
+ var books = this.getBooks();
+ books = books.set(language, book);
+
+ return this.set('books', books);
+};
+
+/**
+ Set the summary for this book
+
+ @param {Summary}
+ @return {Book}
+*/
+Book.prototype.setSummary = function(summary) {
+ return this.set('summary', summary);
+};
+
+/**
+ Set the readme for this book
+
+ @param {Readme}
+ @return {Book}
+*/
+Book.prototype.setReadme = function(readme) {
+ return this.set('readme', readme);
+};
+
+/**
+ Set the configuration for this book
+
+ @param {Config}
+ @return {Book}
+*/
+Book.prototype.setConfig = function(config) {
+ return this.set('config', config);
+};
+
+/**
+ Set the ignore instance for this book
+
+ @param {Ignore}
+ @return {Book}
+*/
+Book.prototype.setIgnore = function(ignore) {
+ return this.set('ignore', ignore);
+};
+
+/**
+ Change log level
+
+ @param {String} level
+ @return {Book}
+*/
+Book.prototype.setLogLevel = function(level) {
+ this.getLogger().setLevel(level);
+ return this;
+};
+
+/**
+ Create a book using a filesystem
+
+ @param {FS} fs
+ @return {Book}
+*/
+Book.createForFS = function createForFS(fs) {
+ return new Book({
+ fs: fs
+ });
+};
+
+/**
+ Infers the default extension for files
+ @return {String}
+*/
+Book.prototype.getDefaultExt = function() {
+ // Inferring sources
+ var clues = [
+ this.getReadme(),
+ this.getSummary(),
+ this.getGlossary()
+ ];
+
+ // List their extensions
+ var exts = clues.map(function (clue) {
+ var file = clue.getFile();
+ if (file.exists()) {
+ return file.getParser().getExtensions().first();
+ } else {
+ return null;
+ }
+ });
+ // Adds the general default extension
+ exts.push('.md');
+
+ // Choose the first non null
+ return exts.find(function (e) { return e !== null; });
+};
+
+/**
+ Infer the default path for a Readme.
+ @param {Boolean} [absolute=false] False for a path relative to
+ this book's content root
+ @return {String}
+*/
+Book.prototype.getDefaultReadmePath = function(absolute) {
+ var defaultPath = 'README'+this.getDefaultExt();
+ if (absolute) {
+ return path.join(this.getContentRoot(), defaultPath);
+ } else {
+ return defaultPath;
+ }
+};
+
+/**
+ Infer the default path for a Summary.
+ @param {Boolean} [absolute=false] False for a path relative to
+ this book's content root
+ @return {String}
+*/
+Book.prototype.getDefaultSummaryPath = function(absolute) {
+ var defaultPath = 'SUMMARY'+this.getDefaultExt();
+ if (absolute) {
+ return path.join(this.getContentRoot(), defaultPath);
+ } else {
+ return defaultPath;
+ }
+};
+
+/**
+ Infer the default path for a Glossary.
+ @param {Boolean} [absolute=false] False for a path relative to
+ this book's content root
+ @return {String}
+*/
+Book.prototype.getDefaultGlossaryPath = function(absolute) {
+ var defaultPath = 'GLOSSARY'+this.getDefaultExt();
+ if (absolute) {
+ return path.join(this.getContentRoot(), defaultPath);
+ } else {
+ return defaultPath;
+ }
+};
+
+/**
+ Create a language book from a parent
+
+ @param {Book} parent
+ @param {String} language
+ @return {Book}
+*/
+Book.createFromParent = function createFromParent(parent, language) {
+ var ignore = parent.getIgnore();
+ var config = parent.getConfig();
+
+ // Set language in configuration
+ config = config.setValue('language', language);
+
+ return new Book({
+ // Inherits config. logegr and list of ignored files
+ logger: parent.getLogger(),
+ config: config,
+ ignore: ignore,
+
+ language: language,
+ fs: FS.reduceScope(parent.getContentFS(), language)
+ });
+};
+
+module.exports = Book;
diff --git a/packages/gitbook/lib/models/config.js b/packages/gitbook/lib/models/config.js
new file mode 100644
index 0000000..6de52f9
--- /dev/null
+++ b/packages/gitbook/lib/models/config.js
@@ -0,0 +1,181 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var File = require('./file');
+var PluginDependency = require('./pluginDependency');
+var configDefault = require('../constants/configDefault');
+var reducedObject = require('../utils/reducedObject');
+
+var Config = Immutable.Record({
+ file: File(),
+ values: configDefault
+}, 'Config');
+
+Config.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Config.prototype.getValues = function() {
+ return this.get('values');
+};
+
+/**
+ * Return minimum version of configuration,
+ * Basically it returns the current config minus the default one
+ * @return {Map}
+ */
+Config.prototype.toReducedVersion = function() {
+ return reducedObject(configDefault, this.getValues());
+};
+
+/**
+ * Render config as text
+ * @return {Promise<String>}
+ */
+Config.prototype.toText = function() {
+ return JSON.stringify(this.toReducedVersion().toJS(), null, 4);
+};
+
+/**
+ * Change the file for the configuration
+ * @param {File} file
+ * @return {Config}
+ */
+Config.prototype.setFile = function(file) {
+ return this.set('file', file);
+};
+
+/**
+ * Return a configuration value by its key path
+ * @param {String} key
+ * @return {Mixed}
+ */
+Config.prototype.getValue = function(keyPath, def) {
+ var values = this.getValues();
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ if (!values.hasIn(keyPath)) {
+ return Immutable.fromJS(def);
+ }
+
+ return values.getIn(keyPath);
+};
+
+/**
+ * Update a configuration value
+ * @param {String} key
+ * @param {Mixed} value
+ * @return {Config}
+ */
+Config.prototype.setValue = function(keyPath, value) {
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ value = Immutable.fromJS(value);
+
+ var values = this.getValues();
+ values = values.setIn(keyPath, value);
+
+ return this.set('values', values);
+};
+
+/**
+ * Return a list of plugin dependencies
+ * @return {List<PluginDependency>}
+ */
+Config.prototype.getPluginDependencies = function() {
+ var plugins = this.getValue('plugins');
+
+ if (is.string(plugins)) {
+ return PluginDependency.listFromString(plugins);
+ } else {
+ return PluginDependency.listFromArray(plugins);
+ }
+};
+
+/**
+ * Return a plugin dependency by its name
+ * @param {String} name
+ * @return {PluginDependency}
+ */
+Config.prototype.getPluginDependency = function(name) {
+ var plugins = this.getPluginDependencies();
+
+ return plugins.find(function(dep) {
+ return dep.getName() === name;
+ });
+};
+
+/**
+ * Update the list of plugins dependencies
+ * @param {List<PluginDependency>}
+ * @return {Config}
+ */
+Config.prototype.setPluginDependencies = function(deps) {
+ var plugins = PluginDependency.listToArray(deps);
+
+ return this.setValue('plugins', plugins);
+};
+
+
+/**
+ * Update values for an existing configuration
+ * @param {Object} values
+ * @returns {Config}
+ */
+Config.prototype.updateValues = function(values) {
+ values = Immutable.fromJS(values);
+
+ return this.set('values', values);
+};
+
+/**
+ * Update values for an existing configuration
+ * @param {Config} config
+ * @param {Object} values
+ * @returns {Config}
+ */
+Config.prototype.mergeValues = function(values) {
+ var currentValues = this.getValues();
+ values = Immutable.fromJS(values);
+
+ currentValues = currentValues.mergeDeep(values);
+
+ return this.set('values', currentValues);
+};
+
+/**
+ * Create a new config for a file
+ * @param {File} file
+ * @param {Object} values
+ * @returns {Config}
+ */
+Config.create = function(file, values) {
+ return new Config({
+ file: file,
+ values: Immutable.fromJS(values)
+ });
+};
+
+/**
+ * Create a new config
+ * @param {Object} values
+ * @returns {Config}
+ */
+Config.createWithValues = function(values) {
+ return new Config({
+ values: Immutable.fromJS(values)
+ });
+};
+
+
+/**
+ * Convert a keyPath to an array of keys
+ * @param {String|Array}
+ * @return {Array}
+ */
+Config.keyToKeyPath = function(keyPath) {
+ if (is.string(keyPath)) keyPath = keyPath.split('.');
+ return keyPath;
+};
+
+module.exports = Config;
diff --git a/packages/gitbook/lib/models/file.js b/packages/gitbook/lib/models/file.js
new file mode 100644
index 0000000..8ddd4af
--- /dev/null
+++ b/packages/gitbook/lib/models/file.js
@@ -0,0 +1,89 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var parsers = require('../parsers');
+
+var File = Immutable.Record({
+ // Path of the file, relative to the FS
+ path: String(),
+
+ // Time when file data last modified
+ mtime: Date()
+});
+
+File.prototype.getPath = function() {
+ return this.get('path');
+};
+
+File.prototype.getMTime = function() {
+ return this.get('mtime');
+};
+
+/**
+ Does the file exists / is set
+
+ @return {Boolean}
+*/
+File.prototype.exists = function() {
+ return Boolean(this.getPath());
+};
+
+/**
+ Return type of file ('markdown' or 'asciidoc')
+
+ @return {String}
+*/
+File.prototype.getType = function() {
+ var parser = this.getParser();
+ if (parser) {
+ return parser.getName();
+ } else {
+ return undefined;
+ }
+};
+
+/**
+ Return extension of this file (lowercased)
+
+ @return {String}
+*/
+File.prototype.getExtension = function() {
+ return path.extname(this.getPath()).toLowerCase();
+};
+
+/**
+ Return parser for this file
+
+ @return {Parser}
+*/
+File.prototype.getParser = function() {
+ return parsers.getByExt(this.getExtension());
+};
+
+/**
+ Create a file from stats informations
+
+ @param {String} filepath
+ @param {Object|fs.Stats} stat
+ @return {File}
+*/
+File.createFromStat = function createFromStat(filepath, stat) {
+ return new File({
+ path: filepath,
+ mtime: stat.mtime
+ });
+};
+
+/**
+ Create a file with only a path
+
+ @param {String} filepath
+ @return {File}
+*/
+File.createWithFilepath = function createWithFilepath(filepath) {
+ return new File({
+ path: filepath
+ });
+};
+
+module.exports = File;
diff --git a/packages/gitbook/lib/models/fs.js b/packages/gitbook/lib/models/fs.js
new file mode 100644
index 0000000..16bd4ea
--- /dev/null
+++ b/packages/gitbook/lib/models/fs.js
@@ -0,0 +1,307 @@
+var path = require('path');
+var Immutable = require('immutable');
+var stream = require('stream');
+
+var File = require('./file');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var PathUtil = require('../utils/path');
+
+var FS = Immutable.Record({
+ root: String(),
+
+ fsExists: Function(),
+ fsReadFile: Function(),
+ fsStatFile: Function(),
+ fsReadDir: Function(),
+
+ fsLoadObject: null,
+ fsReadAsStream: null
+});
+
+/**
+ Return path to the root
+
+ @return {String}
+*/
+FS.prototype.getRoot = function() {
+ return this.get('root');
+};
+
+/**
+ Verify that a file is in the fs scope
+
+ @param {String} filename
+ @return {Boolean}
+*/
+FS.prototype.isInScope = function(filename) {
+ var rootPath = this.getRoot();
+ filename = path.join(rootPath, filename);
+
+ return PathUtil.isInRoot(rootPath, filename);
+};
+
+/**
+ Resolve a file in this FS
+
+ @param {String}
+ @return {String}
+*/
+FS.prototype.resolve = function() {
+ var rootPath = this.getRoot();
+ var args = Array.prototype.slice.call(arguments);
+ var filename = path.join.apply(path, [rootPath].concat(args));
+ filename = path.normalize(filename);
+
+ if (!this.isInScope(filename)) {
+ throw error.FileOutOfScopeError({
+ filename: filename,
+ root: this.root
+ });
+ }
+
+ return filename;
+};
+
+/**
+ Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
+
+ @param {String} filename
+ @return {Promise<Boolean>}
+*/
+FS.prototype.exists = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var exists = that.get('fsExists');
+
+ return exists(filename);
+ });
+};
+
+/**
+ Read a file and returns a promise with the content as a buffer
+
+ @param {String} filename
+ @return {Promise<Buffer>}
+*/
+FS.prototype.read = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var read = that.get('fsReadFile');
+
+ return read(filename);
+ });
+};
+
+/**
+ Read a file as a string (utf-8)
+
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.readAsString = function(filename, encoding) {
+ encoding = encoding || 'utf8';
+
+ return this.read(filename)
+ .then(function(buf) {
+ return buf.toString(encoding);
+ });
+};
+
+/**
+ Read file as a stream
+
+ @param {String} filename
+ @return {Promise<Stream>}
+*/
+FS.prototype.readAsStream = function(filename) {
+ var that = this;
+ var filepath = that.resolve(filename);
+ var fsReadAsStream = this.get('fsReadAsStream');
+
+ if (fsReadAsStream) {
+ return Promise(fsReadAsStream(filepath));
+ }
+
+ return this.read(filename)
+ .then(function(buf) {
+ var bufferStream = new stream.PassThrough();
+ bufferStream.end(buf);
+
+ return bufferStream;
+ });
+};
+
+/**
+ Read stat infos about a file
+
+ @param {String} filename
+ @return {Promise<File>}
+*/
+FS.prototype.statFile = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var filepath = that.resolve(filename);
+ var stat = that.get('fsStatFile');
+
+ return stat(filepath);
+ })
+ .then(function(stat) {
+ return File.createFromStat(filename, stat);
+ });
+};
+
+/**
+ List files/directories in a directory.
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.readDir = function(dirname) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var dirpath = that.resolve(dirname);
+ var readDir = that.get('fsReadDir');
+
+ return readDir(dirpath);
+ })
+ .then(function(files) {
+ return Immutable.List(files);
+ });
+};
+
+/**
+ List only files in a diretcory
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listFiles = function(dirname) {
+ return this.readDir(dirname)
+ .then(function(files) {
+ return files.filterNot(pathIsFolder);
+ });
+};
+
+/**
+ List all files in a directory
+
+ @param {String} dirName
+ @param {Function(dirName)} filterFn: call it for each file/directory to test if it should stop iterating
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listAllFiles = function(dirName, filterFn) {
+ var that = this;
+ dirName = dirName || '.';
+
+ return this.readDir(dirName)
+ .then(function(files) {
+ return Promise.reduce(files, function(out, file) {
+ var isDirectory = pathIsFolder(file);
+ var newDirName = path.join(dirName, file);
+
+ if (filterFn && filterFn(newDirName) === false) {
+ return out;
+ }
+
+ if (!isDirectory) {
+ return out.push(newDirName);
+ }
+
+ return that.listAllFiles(newDirName, filterFn)
+ .then(function(inner) {
+ return out.concat(inner);
+ });
+ }, Immutable.List());
+ });
+};
+
+/**
+ Find a file in a folder (case insensitive)
+ Return the found filename
+
+ @param {String} dirname
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.findFile = function(dirname, filename) {
+ return this.listFiles(dirname)
+ .then(function(files) {
+ return files.find(function(file) {
+ return (file.toLowerCase() == filename.toLowerCase());
+ });
+ });
+};
+
+/**
+ Load a JSON file
+ By default, fs only supports JSON
+
+ @param {String} filename
+ @return {Promise<Object>}
+*/
+FS.prototype.loadAsObject = function(filename) {
+ var that = this;
+ var fsLoadObject = this.get('fsLoadObject');
+
+ return this.exists(filename)
+ .then(function(exists) {
+ if (!exists) {
+ var err = new Error('Module doesn\'t exist');
+ err.code = 'MODULE_NOT_FOUND';
+
+ throw err;
+ }
+
+ if (fsLoadObject) {
+ return fsLoadObject(that.resolve(filename));
+ } else {
+ return that.readAsString(filename)
+ .then(function(str) {
+ return JSON.parse(str);
+ });
+ }
+ });
+};
+
+/**
+ Create a FS instance
+
+ @param {Object} def
+ @return {FS}
+*/
+FS.create = function create(def) {
+ return new FS(def);
+};
+
+/**
+ Create a new FS instance with a reduced scope
+
+ @param {FS} fs
+ @param {String} scope
+ @return {FS}
+*/
+FS.reduceScope = function reduceScope(fs, scope) {
+ return fs.set('root', path.join(fs.getRoot(), scope));
+};
+
+
+// .readdir return files/folder as a list of string, folder ending with '/'
+function pathIsFolder(filename) {
+ var lastChar = filename[filename.length - 1];
+ return lastChar == '/' || lastChar == '\\';
+}
+
+module.exports = FS; \ No newline at end of file
diff --git a/packages/gitbook/lib/models/glossary.js b/packages/gitbook/lib/models/glossary.js
new file mode 100644
index 0000000..0033248
--- /dev/null
+++ b/packages/gitbook/lib/models/glossary.js
@@ -0,0 +1,109 @@
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var File = require('./file');
+var GlossaryEntry = require('./glossaryEntry');
+var parsers = require('../parsers');
+
+var Glossary = Immutable.Record({
+ file: File(),
+ entries: Immutable.OrderedMap()
+});
+
+Glossary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Glossary.prototype.getEntries = function() {
+ return this.get('entries');
+};
+
+/**
+ Return an entry by its name
+
+ @param {String} name
+ @return {GlossaryEntry}
+*/
+Glossary.prototype.getEntry = function(name) {
+ var entries = this.getEntries();
+ var id = GlossaryEntry.nameToID(name);
+
+ return entries.get(id);
+};
+
+/**
+ Render glossary as text
+
+ @return {Promise<String>}
+*/
+Glossary.prototype.toText = function(parser) {
+ var file = this.getFile();
+ var entries = this.getEntries();
+
+ parser = parser? parsers.getByExt(parser) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.renderGlossary(entries.toJS());
+};
+
+
+/**
+ Add/Replace an entry to a glossary
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntry = function addEntry(glossary, entry) {
+ var id = entry.getID();
+ var entries = glossary.getEntries();
+
+ entries = entries.set(id, entry);
+ return glossary.set('entries', entries);
+};
+
+/**
+ Add/Replace an entry to a glossary by name/description
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntryByName = function addEntryByName(glossary, name, description) {
+ var entry = new GlossaryEntry({
+ name: name,
+ description: description
+ });
+
+ return Glossary.addEntry(glossary, entry);
+};
+
+/**
+ Create a glossary from a list of entries
+
+ @param {String} filename
+ @param {Array|List} entries
+ @return {Glossary}
+*/
+Glossary.createFromEntries = function createFromEntries(file, entries) {
+ entries = entries.map(function(entry) {
+ if (!(entry instanceof GlossaryEntry)) {
+ entry = new GlossaryEntry(entry);
+ }
+
+ return [entry.getID(), entry];
+ });
+
+ return new Glossary({
+ file: file,
+ entries: Immutable.OrderedMap(entries)
+ });
+};
+
+
+module.exports = Glossary;
diff --git a/packages/gitbook/lib/models/glossaryEntry.js b/packages/gitbook/lib/models/glossaryEntry.js
new file mode 100644
index 0000000..10791db
--- /dev/null
+++ b/packages/gitbook/lib/models/glossaryEntry.js
@@ -0,0 +1,43 @@
+var Immutable = require('immutable');
+var slug = require('github-slugid');
+
+/*
+ A definition represents an entry in the glossary
+*/
+
+var GlossaryEntry = Immutable.Record({
+ name: String(),
+ description: String()
+});
+
+GlossaryEntry.prototype.getName = function() {
+ return this.get('name');
+};
+
+GlossaryEntry.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+
+/**
+ Get identifier for this entry
+
+ @retrun {Boolean}
+*/
+GlossaryEntry.prototype.getID = function() {
+ return GlossaryEntry.nameToID(this.getName());
+};
+
+
+/**
+ Normalize a glossary entry name into a unique id
+
+ @param {String}
+ @return {String}
+*/
+GlossaryEntry.nameToID = function nameToID(name) {
+ return slug(name);
+};
+
+
+module.exports = GlossaryEntry;
diff --git a/packages/gitbook/lib/models/ignore.js b/packages/gitbook/lib/models/ignore.js
new file mode 100644
index 0000000..499195e
--- /dev/null
+++ b/packages/gitbook/lib/models/ignore.js
@@ -0,0 +1,42 @@
+var Immutable = require('immutable');
+var IgnoreMutable = require('ignore');
+
+/*
+ Immutable version of node-ignore
+*/
+var Ignore = Immutable.Record({
+ ignore: new IgnoreMutable()
+}, 'Ignore');
+
+Ignore.prototype.getIgnore = function() {
+ return this.get('ignore');
+};
+
+/**
+ Test if a file is ignored by these rules
+
+ @param {String} filePath
+ @return {Boolean}
+*/
+Ignore.prototype.isFileIgnored = function(filename) {
+ var ignore = this.getIgnore();
+ return ignore.filter([filename]).length == 0;
+};
+
+/**
+ Add rules
+
+ @param {String}
+ @return {Ignore}
+*/
+Ignore.prototype.add = function(rule) {
+ var ignore = this.getIgnore();
+ var newIgnore = new IgnoreMutable();
+
+ newIgnore.add(ignore);
+ newIgnore.add(rule);
+
+ return this.set('ignore', newIgnore);
+};
+
+module.exports = Ignore;
diff --git a/packages/gitbook/lib/models/language.js b/packages/gitbook/lib/models/language.js
new file mode 100644
index 0000000..dcefbf6
--- /dev/null
+++ b/packages/gitbook/lib/models/language.js
@@ -0,0 +1,21 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Language = Immutable.Record({
+ title: String(),
+ path: String()
+});
+
+Language.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Language.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Language.prototype.getID = function() {
+ return path.basename(this.getPath());
+};
+
+module.exports = Language;
diff --git a/packages/gitbook/lib/models/languages.js b/packages/gitbook/lib/models/languages.js
new file mode 100644
index 0000000..42f05f9
--- /dev/null
+++ b/packages/gitbook/lib/models/languages.js
@@ -0,0 +1,71 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+var Language = require('./language');
+
+var Languages = Immutable.Record({
+ file: File(),
+ list: Immutable.OrderedMap()
+});
+
+Languages.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Languages.prototype.getList = function() {
+ return this.get('list');
+};
+
+/**
+ Get default languages
+
+ @return {Language}
+*/
+Languages.prototype.getDefaultLanguage = function() {
+ return this.getList().first();
+};
+
+/**
+ Get a language by its ID
+
+ @param {String} lang
+ @return {Language}
+*/
+Languages.prototype.getLanguage = function(lang) {
+ return this.getList().get(lang);
+};
+
+/**
+ Return count of langs
+
+ @return {Number}
+*/
+Languages.prototype.getCount = function() {
+ return this.getList().size;
+};
+
+/**
+ Create a languages list from a JS object
+
+ @param {File}
+ @param {Array}
+ @return {Language}
+*/
+Languages.createFromList = function(file, langs) {
+ var list = Immutable.OrderedMap();
+
+ langs.forEach(function(lang) {
+ lang = Language({
+ title: lang.title,
+ path: lang.ref
+ });
+ list = list.set(lang.getID(), lang);
+ });
+
+ return Languages({
+ file: file,
+ list: list
+ });
+};
+
+module.exports = Languages;
diff --git a/packages/gitbook/lib/models/output.js b/packages/gitbook/lib/models/output.js
new file mode 100644
index 0000000..0f008ec
--- /dev/null
+++ b/packages/gitbook/lib/models/output.js
@@ -0,0 +1,107 @@
+var Immutable = require('immutable');
+
+var Book = require('./book');
+var LocationUtils = require('../utils/location');
+
+var Output = Immutable.Record({
+ book: Book(),
+
+ // Name of the generator being used
+ generator: String(),
+
+ // Map of plugins to use (String -> Plugin)
+ plugins: Immutable.OrderedMap(),
+
+ // Map pages to generation (String -> Page)
+ pages: Immutable.OrderedMap(),
+
+ // List assets (String)
+ assets: Immutable.List(),
+
+ // Option for the generation
+ options: Immutable.Map(),
+
+ // Internal state for the generation
+ state: Immutable.Map()
+});
+
+Output.prototype.getBook = function() {
+ return this.get('book');
+};
+
+Output.prototype.getGenerator = function() {
+ return this.get('generator');
+};
+
+Output.prototype.getPlugins = function() {
+ return this.get('plugins');
+};
+
+Output.prototype.getPages = function() {
+ return this.get('pages');
+};
+
+Output.prototype.getOptions = function() {
+ return this.get('options');
+};
+
+Output.prototype.getAssets = function() {
+ return this.get('assets');
+};
+
+Output.prototype.getState = function() {
+ return this.get('state');
+};
+
+/**
+ Return a page byt its file path
+
+ @param {String} filePath
+ @return {Page|undefined}
+*/
+Output.prototype.getPage = function(filePath) {
+ filePath = LocationUtils.normalize(filePath);
+
+ var pages = this.getPages();
+ return pages.get(filePath);
+};
+
+/**
+ Get root folder for output
+
+ @return {String}
+*/
+Output.prototype.getRoot = function() {
+ return this.getOptions().get('root');
+};
+
+/**
+ Update state of output
+
+ @param {Map} newState
+ @return {Output}
+*/
+Output.prototype.setState = function(newState) {
+ return this.set('state', newState);
+};
+
+/**
+ Update options
+
+ @param {Map} newOptions
+ @return {Output}
+*/
+Output.prototype.setOptions = function(newOptions) {
+ return this.set('options', newOptions);
+};
+
+/**
+ Return logegr for this output (same as book)
+
+ @return {Logger}
+*/
+Output.prototype.getLogger = function() {
+ return this.getBook().getLogger();
+};
+
+module.exports = Output;
diff --git a/packages/gitbook/lib/models/page.js b/packages/gitbook/lib/models/page.js
new file mode 100644
index 0000000..275a034
--- /dev/null
+++ b/packages/gitbook/lib/models/page.js
@@ -0,0 +1,70 @@
+var Immutable = require('immutable');
+var yaml = require('js-yaml');
+
+var File = require('./file');
+
+var Page = Immutable.Record({
+ file: File(),
+
+ // Attributes extracted from the YAML header
+ attributes: Immutable.Map(),
+
+ // Content of the page
+ content: String(),
+
+ // Direction of the text
+ dir: String('ltr')
+});
+
+Page.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Page.prototype.getAttributes = function() {
+ return this.get('attributes');
+};
+
+Page.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Page.prototype.getDir = function() {
+ return this.get('dir');
+};
+
+/**
+ * Return page as text
+ * @return {String}
+*/
+Page.prototype.toText = function() {
+ var attrs = this.getAttributes();
+ var content = this.getContent();
+
+ if (attrs.size === 0) {
+ return content;
+ }
+
+ var frontMatter = '---\n' + yaml.safeDump(attrs.toJS(), { skipInvalid: true }) + '---\n\n';
+ return (frontMatter + content);
+};
+
+/**
+ * Return path of the page
+ * @return {String}
+*/
+Page.prototype.getPath = function() {
+ return this.getFile().getPath();
+};
+
+/**
+ * Create a page for a file
+ * @param {File} file
+ * @return {Page}
+*/
+Page.createForFile = function(file) {
+ return new Page({
+ file: file
+ });
+};
+
+module.exports = Page;
diff --git a/packages/gitbook/lib/models/parser.js b/packages/gitbook/lib/models/parser.js
new file mode 100644
index 0000000..d64542f
--- /dev/null
+++ b/packages/gitbook/lib/models/parser.js
@@ -0,0 +1,122 @@
+var Immutable = require('immutable');
+var Promise = require('../utils/promise');
+
+var Parser = Immutable.Record({
+ name: String(),
+
+ // List of extensions that can be processed using this parser
+ extensions: Immutable.List(),
+
+ // Parsing functions
+ readme: Function(),
+ langs: Function(),
+ summary: Function(),
+ glossary: Function(),
+ page: Function(),
+ inline: Function()
+});
+
+Parser.prototype.getName = function() {
+ return this.get('name');
+};
+
+Parser.prototype.getExtensions = function() {
+ return this.get('extensions');
+};
+
+// PARSE
+
+Parser.prototype.parseReadme = function(content) {
+ var readme = this.get('readme');
+ return Promise(readme(content));
+};
+
+Parser.prototype.parseSummary = function(content) {
+ var summary = this.get('summary');
+ return Promise(summary(content));
+};
+
+Parser.prototype.parseGlossary = function(content) {
+ var glossary = this.get('glossary');
+ return Promise(glossary(content));
+};
+
+Parser.prototype.preparePage = function(content) {
+ var page = this.get('page');
+ if (!page.prepare) {
+ return Promise(content);
+ }
+
+ return Promise(page.prepare(content));
+};
+
+Parser.prototype.parsePage = function(content) {
+ var page = this.get('page');
+ return Promise(page(content));
+};
+
+Parser.prototype.parseInline = function(content) {
+ var inline = this.get('inline');
+ return Promise(inline(content));
+};
+
+Parser.prototype.parseLanguages = function(content) {
+ var langs = this.get('langs');
+ return Promise(langs(content));
+};
+
+Parser.prototype.parseInline = function(content) {
+ var inline = this.get('inline');
+ return Promise(inline(content));
+};
+
+// TO TEXT
+
+Parser.prototype.renderLanguages = function(content) {
+ var langs = this.get('langs');
+ return Promise(langs.toText(content));
+};
+
+Parser.prototype.renderSummary = function(content) {
+ var summary = this.get('summary');
+ return Promise(summary.toText(content));
+};
+
+Parser.prototype.renderGlossary = function(content) {
+ var glossary = this.get('glossary');
+ return Promise(glossary.toText(content));
+};
+
+/**
+ Test if this parser matches an extension
+
+ @param {String} ext
+ @return {Boolean}
+*/
+Parser.prototype.matchExtension = function(ext) {
+ var exts = this.getExtensions();
+ return exts.includes(ext.toLowerCase());
+};
+
+/**
+ Create a new parser using a module (gitbook-markdown, etc)
+
+ @param {String} name
+ @param {Array<String>} extensions
+ @param {Object} module
+ @return {Parser}
+*/
+Parser.create = function(name, extensions, module) {
+ return new Parser({
+ name: name,
+ extensions: Immutable.List(extensions),
+ readme: module.readme,
+ langs: module.langs,
+ summary: module.summary,
+ glossary: module.glossary,
+ page: module.page,
+ inline: module.inline
+ });
+};
+
+module.exports = Parser;
diff --git a/packages/gitbook/lib/models/plugin.js b/packages/gitbook/lib/models/plugin.js
new file mode 100644
index 0000000..acabba9
--- /dev/null
+++ b/packages/gitbook/lib/models/plugin.js
@@ -0,0 +1,169 @@
+var Immutable = require('immutable');
+
+var TemplateBlock = require('./templateBlock');
+var PluginDependency = require('./pluginDependency');
+var THEME_PREFIX = require('../constants/themePrefix');
+
+var DEFAULT_VERSION = '*';
+
+var Plugin = Immutable.Record({
+ name: String(),
+
+ // Requirement version (ex: ">1.0.0")
+ version: String(DEFAULT_VERSION),
+
+ // Path to load this plugin
+ path: String(),
+
+ // Depth of this plugin in the dependency tree
+ depth: Number(0),
+
+ // Parent depending on this plugin
+ parent: String(),
+
+ // Content of the "package.json"
+ package: Immutable.Map(),
+
+ // Content of the package itself
+ content: Immutable.Map()
+}, 'Plugin');
+
+Plugin.prototype.getName = function() {
+ return this.get('name');
+};
+
+Plugin.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Plugin.prototype.getVersion = function() {
+ return this.get('version');
+};
+
+Plugin.prototype.getPackage = function() {
+ return this.get('package');
+};
+
+Plugin.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Plugin.prototype.getDepth = function() {
+ return this.get('depth');
+};
+
+Plugin.prototype.getParent = function() {
+ return this.get('parent');
+};
+
+/**
+ * Return the ID on NPM for this plugin
+ * @return {String}
+ */
+Plugin.prototype.getNpmID = function() {
+ return PluginDependency.nameToNpmID(this.getName());
+};
+
+/**
+ * Check if a plugin is loaded
+ * @return {Boolean}
+ */
+Plugin.prototype.isLoaded = function() {
+ return Boolean(this.getPackage().size > 0);
+};
+
+/**
+ * Check if a plugin is a theme given its name
+ * @return {Boolean}
+ */
+Plugin.prototype.isTheme = function() {
+ var name = this.getName();
+ return (name && name.indexOf(THEME_PREFIX) === 0);
+};
+
+/**
+ * Return map of hooks
+ * @return {Map<String:Function>}
+ */
+Plugin.prototype.getHooks = function() {
+ return this.getContent().get('hooks') || Immutable.Map();
+};
+
+/**
+ * Return infos about resources for a specific type
+ * @param {String} type
+ * @return {Map<String:Mixed>}
+ */
+Plugin.prototype.getResources = function(type) {
+ if (type != 'website' && type != 'ebook') {
+ throw new Error('Invalid assets type ' + type);
+ }
+
+ var content = this.getContent();
+ return (content.get(type)
+ || (type == 'website'? content.get('book') : null)
+ || Immutable.Map());
+};
+
+/**
+ * Return map of filters
+ * @return {Map<String:Function>}
+ */
+Plugin.prototype.getFilters = function() {
+ return this.getContent().get('filters');
+};
+
+/**
+ * Return map of blocks
+ * @return {Map<String:TemplateBlock>}
+ */
+Plugin.prototype.getBlocks = function() {
+ var blocks = this.getContent().get('blocks');
+ blocks = blocks || Immutable.Map();
+
+ return blocks
+ .map(function(block, blockName) {
+ return TemplateBlock.create(blockName, block);
+ });
+};
+
+/**
+ * Return a specific hook
+ * @param {String} name
+ * @return {Function|undefined}
+ */
+Plugin.prototype.getHook = function(name) {
+ return this.getHooks().get(name);
+};
+
+/**
+ * Create a plugin from a string
+ * @param {String}
+ * @return {Plugin}
+ */
+Plugin.createFromString = function(s) {
+ var parts = s.split('@');
+ var name = parts[0];
+ var version = parts.slice(1).join('@');
+
+ return new Plugin({
+ name: name,
+ version: version || DEFAULT_VERSION
+ });
+};
+
+/**
+ * Create a plugin from a dependency
+ * @param {PluginDependency}
+ * @return {Plugin}
+ */
+Plugin.createFromDep = function(dep) {
+ return new Plugin({
+ name: dep.getName(),
+ version: dep.getVersion()
+ });
+};
+
+Plugin.nameToNpmID = PluginDependency.nameToNpmID;
+
+module.exports = Plugin;
diff --git a/packages/gitbook/lib/models/pluginDependency.js b/packages/gitbook/lib/models/pluginDependency.js
new file mode 100644
index 0000000..8866294
--- /dev/null
+++ b/packages/gitbook/lib/models/pluginDependency.js
@@ -0,0 +1,168 @@
+var is = require('is');
+var semver = require('semver');
+var Immutable = require('immutable');
+
+var PREFIX = require('../constants/pluginPrefix');
+var DEFAULT_VERSION = '*';
+
+/*
+ * PluginDependency represents the informations about a plugin
+ * stored in config.plugins
+ */
+var PluginDependency = Immutable.Record({
+ name: String(),
+
+ // Requirement version (ex: ">1.0.0")
+ version: String(DEFAULT_VERSION),
+
+ // Is this plugin enabled or disabled?
+ enabled: Boolean(true)
+}, 'PluginDependency');
+
+PluginDependency.prototype.getName = function() {
+ return this.get('name');
+};
+
+PluginDependency.prototype.getVersion = function() {
+ return this.get('version');
+};
+
+PluginDependency.prototype.isEnabled = function() {
+ return this.get('enabled');
+};
+
+/**
+ * Toggle this plugin state
+ * @param {Boolean}
+ * @return {PluginDependency}
+ */
+PluginDependency.prototype.toggle = function(state) {
+ if (is.undef(state)) {
+ state = !this.isEnabled();
+ }
+
+ return this.set('enabled', state);
+};
+
+/**
+ * Return NPM ID for the dependency
+ * @return {String}
+ */
+PluginDependency.prototype.getNpmID = function() {
+ return PluginDependency.nameToNpmID(this.getName());
+};
+
+/**
+ * Is the plugin using a git dependency
+ * @return {Boolean}
+ */
+PluginDependency.prototype.isGitDependency = function() {
+ return !semver.validRange(this.getVersion());
+};
+
+/**
+ * Create a plugin with a name and a plugin
+ * @param {String}
+ * @return {Plugin|undefined}
+ */
+PluginDependency.create = function(name, version, enabled) {
+ if (is.undefined(enabled)) {
+ enabled = true;
+ }
+
+ return new PluginDependency({
+ name: name,
+ version: version || DEFAULT_VERSION,
+ enabled: Boolean(enabled)
+ });
+};
+
+/**
+ * Create a plugin from a string
+ * @param {String}
+ * @return {Plugin|undefined}
+ */
+PluginDependency.createFromString = function(s) {
+ var parts = s.split('@');
+ var name = parts[0];
+ var version = parts.slice(1).join('@');
+ var enabled = true;
+
+ if (name[0] === '-') {
+ enabled = false;
+ name = name.slice(1);
+ }
+
+ return new PluginDependency({
+ name: name,
+ version: version || DEFAULT_VERSION,
+ enabled: enabled
+ });
+};
+
+/**
+ * Create a PluginDependency from a string
+ * @param {String}
+ * @return {List<PluginDependency>}
+ */
+PluginDependency.listFromString = function(s) {
+ var parts = s.split(',');
+ return PluginDependency.listFromArray(parts);
+};
+
+/**
+ * Create a PluginDependency from an array
+ * @param {Array}
+ * @return {List<PluginDependency>}
+ */
+PluginDependency.listFromArray = function(arr) {
+ return Immutable.List(arr)
+ .map(function(entry) {
+ if (is.string(entry)) {
+ return PluginDependency.createFromString(entry);
+ } else {
+ return PluginDependency({
+ name: entry.get('name'),
+ version: entry.get('version')
+ });
+ }
+ })
+ .filter(function(dep) {
+ return Boolean(dep.getName());
+ });
+};
+
+/**
+ * Export plugin dependencies as an array
+ * @param {List<PluginDependency>} list
+ * @return {Array<String>}
+ */
+PluginDependency.listToArray = function(list) {
+ return list
+ .map(function(dep) {
+ var result = '';
+
+ if (!dep.isEnabled()) {
+ result += '-';
+ }
+
+ result += dep.getName();
+ if (dep.getVersion() !== DEFAULT_VERSION) {
+ result += '@' + dep.getVersion();
+ }
+
+ return result;
+ })
+ .toJS();
+};
+
+/**
+ * Return NPM id for a plugin name
+ * @param {String}
+ * @return {String}
+ */
+PluginDependency.nameToNpmID = function(s) {
+ return PREFIX + s;
+};
+
+module.exports = PluginDependency;
diff --git a/packages/gitbook/lib/models/readme.js b/packages/gitbook/lib/models/readme.js
new file mode 100644
index 0000000..c655c82
--- /dev/null
+++ b/packages/gitbook/lib/models/readme.js
@@ -0,0 +1,40 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+
+var Readme = Immutable.Record({
+ file: File(),
+ title: String(),
+ description: String()
+});
+
+Readme.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Readme.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Readme.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+/**
+ Create a new readme
+
+ @param {File} file
+ @param {Object} def
+ @return {Readme}
+*/
+Readme.create = function(file, def) {
+ def = def || {};
+
+ return new Readme({
+ file: file,
+ title: def.title || '',
+ description: def.description || ''
+ });
+};
+
+module.exports = Readme;
diff --git a/packages/gitbook/lib/models/summary.js b/packages/gitbook/lib/models/summary.js
new file mode 100644
index 0000000..70f0535
--- /dev/null
+++ b/packages/gitbook/lib/models/summary.js
@@ -0,0 +1,228 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var LocationUtils = require('../utils/location');
+var File = require('./file');
+var SummaryPart = require('./summaryPart');
+var SummaryArticle = require('./summaryArticle');
+var parsers = require('../parsers');
+
+var Summary = Immutable.Record({
+ file: File(),
+ parts: Immutable.List()
+}, 'Summary');
+
+Summary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Summary.prototype.getParts = function() {
+ return this.get('parts');
+};
+
+/**
+ Return a part by its index
+
+ @param {Number}
+ @return {Part}
+*/
+Summary.prototype.getPart = function(i) {
+ var parts = this.getParts();
+ return parts.get(i);
+};
+
+/**
+ Return an article using an iterator to find it.
+ if "partIter" is set, it can also return a Part.
+
+ @param {Function} iter
+ @param {Function} partIter
+ @return {Article|Part}
+*/
+Summary.prototype.getArticle = function(iter, partIter) {
+ var parts = this.getParts();
+
+ return parts.reduce(function(result, part) {
+ if (result) return result;
+
+ if (partIter && partIter(part)) return part;
+ return SummaryArticle.findArticle(part, iter);
+ }, null);
+};
+
+
+/**
+ Return a part/article by its level
+
+ @param {String} level
+ @return {Article|Part}
+*/
+Summary.prototype.getByLevel = function(level) {
+ function iterByLevel(article) {
+ return (article.getLevel() === level);
+ }
+
+ return this.getArticle(iterByLevel, iterByLevel);
+};
+
+/**
+ Return an article by its path
+
+ @param {String} filePath
+ @return {Article}
+*/
+Summary.prototype.getByPath = function(filePath) {
+ return this.getArticle(function(article) {
+ var articlePath = article.getPath();
+
+ return (
+ articlePath &&
+ LocationUtils.areIdenticalPaths(articlePath, filePath)
+ );
+ });
+};
+
+/**
+ Return the first article
+
+ @return {Article}
+*/
+Summary.prototype.getFirstArticle = function() {
+ return this.getArticle(function(article) {
+ return true;
+ });
+};
+
+/**
+ Return next article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getNextArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var wasPrev = false;
+
+ return this.getArticle(function(article) {
+ if (wasPrev) return true;
+
+ wasPrev = article.getLevel() == level;
+ return false;
+ });
+};
+
+/**
+ Return previous article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getPrevArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var prev = undefined;
+
+ this.getArticle(function(article) {
+ if (article.getLevel() == level) {
+ return true;
+ }
+
+ prev = article;
+ return false;
+ });
+
+ return prev;
+};
+
+/**
+ Return the parent article, or parent part of an article
+
+ @param {String|Article} current
+ @return {Article|Part|Null}
+*/
+Summary.prototype.getParent = function (level) {
+ // Coerce to level
+ level = is.string(level)? level : level.getLevel();
+
+ // Get parent level
+ var parentLevel = getParentLevel(level);
+ if (!parentLevel) {
+ return null;
+ }
+
+ // Get parent of the position
+ var parentArticle = this.getByLevel(parentLevel);
+ return parentArticle || null;
+};
+
+/**
+ Render summary as text
+
+ @param {String} parseExt Extension of the parser to use
+ @return {Promise<String>}
+*/
+Summary.prototype.toText = function(parseExt) {
+ var file = this.getFile();
+ var parts = this.getParts();
+
+ var parser = parseExt? parsers.getByExt(parseExt) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.renderSummary({
+ parts: parts.toJS()
+ });
+};
+
+/**
+ Return all articles as a list
+
+ @return {List<Article>}
+*/
+Summary.prototype.getArticlesAsList = function() {
+ var accu = [];
+
+ this.getArticle(function(article) {
+ accu.push(article);
+ });
+
+ return Immutable.List(accu);
+};
+
+/**
+ Create a new summary for a list of parts
+
+ @param {Lust|Array} parts
+ @return {Summary}
+*/
+Summary.createFromParts = function createFromParts(file, parts) {
+ parts = parts.map(function(part, i) {
+ if (part instanceof SummaryPart) {
+ return part;
+ }
+
+ return SummaryPart.create(part, i + 1);
+ });
+
+ return new Summary({
+ file: file,
+ parts: new Immutable.List(parts)
+ });
+};
+
+/**
+ Returns parent level of a level
+
+ @param {String} level
+ @return {String}
+*/
+function getParentLevel(level) {
+ var parts = level.split('.');
+ return parts.slice(0, -1).join('.');
+}
+
+module.exports = Summary;
diff --git a/packages/gitbook/lib/models/summaryArticle.js b/packages/gitbook/lib/models/summaryArticle.js
new file mode 100644
index 0000000..6da8d1d
--- /dev/null
+++ b/packages/gitbook/lib/models/summaryArticle.js
@@ -0,0 +1,189 @@
+var Immutable = require('immutable');
+
+var location = require('../utils/location');
+
+/*
+ An article represents an entry in the Summary / table of Contents
+*/
+
+var SummaryArticle = Immutable.Record({
+ level: String(),
+ title: String(),
+ ref: String(),
+ articles: Immutable.List()
+}, 'SummaryArticle');
+
+SummaryArticle.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryArticle.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryArticle.prototype.getRef = function() {
+ return this.get('ref');
+};
+
+SummaryArticle.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ * Return how deep the article is.
+ * The README has a depth of 1
+ *
+ * @return {Number}
+ */
+SummaryArticle.prototype.getDepth = function() {
+ return (this.getLevel().split('.').length - 1);
+};
+
+/**
+ * Get path (without anchor) to the pointing file.
+ * It also normalizes the file path.
+ *
+ * @return {String}
+ */
+SummaryArticle.prototype.getPath = function() {
+ if (this.isExternal()) {
+ return undefined;
+ }
+
+ var ref = this.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+ var parts = ref.split('#');
+
+ var pathname = (parts.length > 1? parts.slice(0, -1).join('#') : ref);
+
+ // Normalize path to remove ('./', '/...', etc)
+ return location.flatten(pathname);
+};
+
+/**
+ * Return url if article is external
+ *
+ * @return {String}
+ */
+SummaryArticle.prototype.getUrl = function() {
+ return this.isExternal()? this.getRef() : undefined;
+};
+
+/**
+ * Get anchor for this article (or undefined)
+ *
+ * @return {String}
+ */
+SummaryArticle.prototype.getAnchor = function() {
+ var ref = this.getRef();
+ var parts = ref.split('#');
+
+ var anchor = (parts.length > 1? '#' + parts[parts.length - 1] : undefined);
+ return anchor;
+};
+
+/**
+ * Create a new level for a new child article
+ *
+ * @return {String}
+ */
+SummaryArticle.prototype.createChildLevel = function() {
+ var level = this.getLevel();
+ var subArticles = this.getArticles();
+ var childLevel = level + '.' + (subArticles.size + 1);
+
+ return childLevel;
+};
+
+/**
+ * Is article pointing to a page of an absolute url
+ *
+ * @return {Boolean}
+ */
+SummaryArticle.prototype.isPage = function() {
+ return !this.isExternal() && this.getRef();
+};
+
+/**
+ * Check if this article is a file (exatcly)
+ *
+ * @param {File} file
+ * @return {Boolean}
+ */
+SummaryArticle.prototype.isFile = function(file) {
+ return (
+ file.getPath() === this.getPath()
+ && this.getAnchor() === undefined
+ );
+};
+
+/**
+ * Check if this article is the introduction of the book
+ *
+ * @param {Book|Readme} book
+ * @return {Boolean}
+ */
+SummaryArticle.prototype.isReadme = function(book) {
+ var readme = book.getFile? book : book.getReadme();
+ var file = readme.getFile();
+
+ return this.isFile(file);
+};
+
+/**
+ * Is article pointing to aan absolute url
+ *
+ * @return {Boolean}
+ */
+SummaryArticle.prototype.isExternal = function() {
+ return location.isExternal(this.getRef());
+};
+
+/**
+ * Create a SummaryArticle
+ *
+ * @param {Object} def
+ * @return {SummaryArticle}
+ */
+SummaryArticle.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryArticle({
+ level: level,
+ title: def.title,
+ ref: def.ref || def.path || '',
+ articles: Immutable.List(articles)
+ });
+};
+
+/**
+ * Find an article from a base one
+ *
+ * @param {Article|Part} base
+ * @param {Function(article)} iter
+ * @return {Article}
+ */
+SummaryArticle.findArticle = function(base, iter) {
+ var articles = base.getArticles();
+
+ return articles.reduce(function(result, article) {
+ if (result) return result;
+
+ if (iter(article)) {
+ return article;
+ }
+
+ return SummaryArticle.findArticle(article, iter);
+ }, null);
+};
+
+
+module.exports = SummaryArticle;
diff --git a/packages/gitbook/lib/models/summaryPart.js b/packages/gitbook/lib/models/summaryPart.js
new file mode 100644
index 0000000..f0e6f57
--- /dev/null
+++ b/packages/gitbook/lib/models/summaryPart.js
@@ -0,0 +1,61 @@
+var Immutable = require('immutable');
+
+var SummaryArticle = require('./summaryArticle');
+
+/*
+ A part represents a section in the Summary / table of Contents
+*/
+
+var SummaryPart = Immutable.Record({
+ level: String(),
+ title: String(),
+ articles: Immutable.List()
+});
+
+SummaryPart.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryPart.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryPart.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ * Create a new level for a new child article
+ *
+ * @return {String}
+ */
+SummaryPart.prototype.createChildLevel = function() {
+ var level = this.getLevel();
+ var subArticles = this.getArticles();
+ var childLevel = level + '.' + (subArticles.size + 1);
+
+ return childLevel;
+};
+
+/**
+ * Create a SummaryPart
+ *
+ * @param {Object} def
+ * @return {SummaryPart}
+ */
+SummaryPart.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryPart({
+ level: String(level),
+ title: def.title,
+ articles: Immutable.List(articles)
+ });
+};
+
+module.exports = SummaryPart;
diff --git a/packages/gitbook/lib/models/templateBlock.js b/packages/gitbook/lib/models/templateBlock.js
new file mode 100644
index 0000000..458f084
--- /dev/null
+++ b/packages/gitbook/lib/models/templateBlock.js
@@ -0,0 +1,281 @@
+var is = require('is');
+var extend = require('extend');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var genKey = require('../utils/genKey');
+var TemplateShortcut = require('./templateShortcut');
+
+var NODE_ENDARGS = '%%endargs%%';
+
+var TemplateBlock = Immutable.Record({
+ // Name of block, also the start tag
+ name: String(),
+
+ // End tag, default to "end<name>"
+ end: String(),
+
+ // Function to process the block content
+ process: Function(),
+
+ // List of String, for inner block tags
+ blocks: Immutable.List(),
+
+ // List of shortcuts to replace with this block
+ shortcuts: Immutable.Map()
+}, 'TemplateBlock');
+
+TemplateBlock.prototype.getName = function() {
+ return this.get('name');
+};
+
+TemplateBlock.prototype.getEndTag = function() {
+ return this.get('end') || ('end' + this.getName());
+};
+
+TemplateBlock.prototype.getProcess = function() {
+ return this.get('process');
+};
+
+TemplateBlock.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+
+/**
+ * Return shortcuts associated with this block or undefined
+ * @return {TemplateShortcut|undefined}
+ */
+TemplateBlock.prototype.getShortcuts = function() {
+ var shortcuts = this.get('shortcuts');
+ if (shortcuts.size === 0) {
+ return undefined;
+ }
+
+ return TemplateShortcut.createForBlock(this, shortcuts);
+};
+
+/**
+ * Return name for the nunjucks extension
+ * @return {String}
+ */
+TemplateBlock.prototype.getExtensionName = function() {
+ return 'Block' + this.getName() + 'Extension';
+};
+
+/**
+ * Return a nunjucks extension to represents this block
+ * @return {Nunjucks.Extension}
+ */
+TemplateBlock.prototype.toNunjucksExt = function(mainContext, blocksOutput) {
+ blocksOutput = blocksOutput || {};
+
+ var that = this;
+ var name = this.getName();
+ var endTag = this.getEndTag();
+ var blocks = this.getBlocks().toJS();
+
+ function Ext() {
+ this.tags = [name];
+
+ this.parse = function(parser, nodes) {
+ var lastBlockName = null;
+ var lastBlockArgs = null;
+ var allBlocks = blocks.concat([endTag]);
+
+ // Parse first block
+ var tok = parser.nextToken();
+ lastBlockArgs = parser.parseSignature(null, true);
+ parser.advanceAfterBlockEnd(tok.value);
+
+ var args = new nodes.NodeList();
+ var bodies = [];
+ var blockNamesNode = new nodes.Array(tok.lineno, tok.colno);
+ var blockArgCounts = new nodes.Array(tok.lineno, tok.colno);
+
+ // Parse while we found "end<block>"
+ do {
+ // Read body
+ var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks);
+
+ // Handle body with previous block name and args
+ blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName));
+ blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length));
+ bodies.push(currentBody);
+
+ // Append arguments of this block as arguments of the run function
+ lastBlockArgs.children.forEach(function(child) {
+ args.addChild(child);
+ });
+
+ // Read new block
+ lastBlockName = parser.nextToken().value;
+
+ // Parse signature and move to the end of the block
+ if (lastBlockName != endTag) {
+ lastBlockArgs = parser.parseSignature(null, true);
+ }
+
+ parser.advanceAfterBlockEnd(lastBlockName);
+ } while (lastBlockName != endTag);
+
+ args.addChild(blockNamesNode);
+ args.addChild(blockArgCounts);
+ args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS));
+
+ return new nodes.CallExtensionAsync(this, 'run', args, bodies);
+ };
+
+ this.run = function(context) {
+ var fnArgs = Array.prototype.slice.call(arguments, 1);
+
+ var args;
+ var blocks = [];
+ var bodies = [];
+ var blockNames;
+ var blockArgCounts;
+ var callback;
+
+ // Extract callback
+ callback = fnArgs.pop();
+
+ // Detect end of arguments
+ var endArgIndex = fnArgs.indexOf(NODE_ENDARGS);
+
+ // Extract arguments and bodies
+ args = fnArgs.slice(0, endArgIndex);
+ bodies = fnArgs.slice(endArgIndex + 1);
+
+ // Extract block counts
+ blockArgCounts = args.pop();
+ blockNames = args.pop();
+
+ // Recreate list of blocks
+ blockNames.forEach(function(name, i) {
+ var countArgs = blockArgCounts[i];
+ var blockBody = bodies.shift();
+
+ var blockArgs = countArgs > 0? args.slice(0, countArgs) : [];
+ args = args.slice(countArgs);
+ var blockKwargs = extractKwargs(blockArgs);
+
+ blocks.push({
+ name: name,
+ body: blockBody(),
+ args: blockArgs,
+ kwargs: blockKwargs
+ });
+ });
+
+ var mainBlock = blocks.shift();
+ mainBlock.blocks = blocks;
+
+ Promise()
+ .then(function() {
+ var ctx = extend({
+ ctx: context
+ }, mainContext || {});
+
+ return that.applyBlock(mainBlock, ctx);
+ })
+ .then(function(result) {
+ return that.blockResultToHtml(result, blocksOutput);
+ })
+ .nodeify(callback);
+ };
+ }
+
+ return Ext;
+};
+
+/**
+ * Apply a block to a content
+ * @param {Object} inner
+ * @param {Object} context
+ * @return {Promise<String>|String}
+ */
+TemplateBlock.prototype.applyBlock = function(inner, context) {
+ var processFn = this.getProcess();
+
+ inner = inner || {};
+ inner.args = inner.args || [];
+ inner.kwargs = inner.kwargs || {};
+ inner.blocks = inner.blocks || [];
+
+ var r = processFn.call(context, inner);
+
+ if (Promise.isPromiseAlike(r)) {
+ return r.then(this.normalizeBlockResult.bind(this));
+ } else {
+ return this.normalizeBlockResult(r);
+ }
+};
+
+/**
+ * Normalize result from a block process function
+ * @param {Object|String} result
+ * @return {Object}
+ */
+TemplateBlock.prototype.normalizeBlockResult = function(result) {
+ if (is.string(result)) {
+ result = { body: result };
+ }
+ result.name = this.getName();
+
+ return result;
+};
+
+/**
+ * Convert a block result to HTML
+ * @param {Object} result
+ * @param {Object} blocksOutput: stored post processing blocks in this object
+ * @return {String}
+ */
+TemplateBlock.prototype.blockResultToHtml = function(result, blocksOutput) {
+ var indexedKey;
+ var toIndex = (!result.parse) || (result.post !== undefined);
+
+ if (toIndex) {
+ indexedKey = genKey();
+ blocksOutput[indexedKey] = result;
+ }
+
+ // Parsable block, just return it
+ if (result.parse) {
+ return result.body;
+ }
+
+ // Return it as a position marker
+ return '{{-%' + indexedKey + '%-}}';
+
+};
+
+/**
+ * Create a template block from a function or an object
+ * @param {String} blockName
+ * @param {Object} block
+ * @return {TemplateBlock}
+ */
+TemplateBlock.create = function(blockName, block) {
+ if (is.fn(block)) {
+ block = new Immutable.Map({
+ process: block
+ });
+ }
+
+ block = new TemplateBlock(block);
+ block = block.set('name', blockName);
+ return block;
+};
+
+/**
+ * Extract kwargs from an arguments array
+ * @param {Array} args
+ * @return {Object}
+ */
+function extractKwargs(args) {
+ var last = args[args.length - 1];
+ return (is.object(last) && last.__keywords)? args.pop() : {};
+}
+
+module.exports = TemplateBlock;
diff --git a/packages/gitbook/lib/models/templateEngine.js b/packages/gitbook/lib/models/templateEngine.js
new file mode 100644
index 0000000..5724d55
--- /dev/null
+++ b/packages/gitbook/lib/models/templateEngine.js
@@ -0,0 +1,139 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+
+var TemplateEngine = Immutable.Record({
+ // Map of {TemplateBlock}
+ blocks: Immutable.Map(),
+
+ // Map of Extension
+ extensions: Immutable.Map(),
+
+ // Map of filters: {String} name -> {Function} fn
+ filters: Immutable.Map(),
+
+ // Map of globals: {String} name -> {Mixed}
+ globals: Immutable.Map(),
+
+ // Context for filters / blocks
+ context: Object(),
+
+ // Nunjucks loader
+ loader: nunjucks.FileSystemLoader('views')
+}, 'TemplateEngine');
+
+TemplateEngine.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+TemplateEngine.prototype.getGlobals = function() {
+ return this.get('globals');
+};
+
+TemplateEngine.prototype.getFilters = function() {
+ return this.get('filters');
+};
+
+TemplateEngine.prototype.getShortcuts = function() {
+ return this.get('shortcuts');
+};
+
+TemplateEngine.prototype.getLoader = function() {
+ return this.get('loader');
+};
+
+TemplateEngine.prototype.getContext = function() {
+ return this.get('context');
+};
+
+TemplateEngine.prototype.getExtensions = function() {
+ return this.get('extensions');
+};
+
+/**
+ Return a block by its name (or undefined)
+
+ @param {String} name
+ @return {TemplateBlock}
+*/
+TemplateEngine.prototype.getBlock = function(name) {
+ var blocks = this.getBlocks();
+ return blocks.find(function(block) {
+ return block.getName() === name;
+ });
+};
+
+/**
+ Return a nunjucks environment from this configuration
+
+ @return {Nunjucks.Environment}
+*/
+TemplateEngine.prototype.toNunjucks = function(blocksOutput) {
+ var loader = this.getLoader();
+ var blocks = this.getBlocks();
+ var filters = this.getFilters();
+ var globals = this.getGlobals();
+ var extensions = this.getExtensions();
+ var context = this.getContext();
+
+ var env = new nunjucks.Environment(
+ loader,
+ {
+ // Escaping is done after by the asciidoc/markdown parser
+ autoescape: false,
+
+ // Syntax
+ tags: {
+ blockStart: '{%',
+ blockEnd: '%}',
+ variableStart: '{{',
+ variableEnd: '}}',
+ commentStart: '{###',
+ commentEnd: '###}'
+ }
+ }
+ );
+
+ // Add filters
+ filters.forEach(function(filterFn, filterName) {
+ env.addFilter(filterName, filterFn.bind(context));
+ });
+
+ // Add blocks
+ blocks.forEach(function(block) {
+ var extName = block.getExtensionName();
+ var Ext = block.toNunjucksExt(context, blocksOutput);
+
+ env.addExtension(extName, new Ext());
+ });
+
+ // Add globals
+ globals.forEach(function(globalValue, globalName) {
+ env.addGlobal(globalName, globalValue);
+ });
+
+ // Add other extensions
+ extensions.forEach(function(ext, extName) {
+ env.addExtension(extName, ext);
+ });
+
+ return env;
+};
+
+/**
+ Create a template engine
+
+ @param {Object} def
+ @return {TemplateEngine}
+*/
+TemplateEngine.create = function(def) {
+ return new TemplateEngine({
+ blocks: Immutable.List(def.blocks || []),
+ extensions: Immutable.Map(def.extensions || {}),
+ filters: Immutable.Map(def.filters || {}),
+ globals: Immutable.Map(def.globals || {}),
+ context: def.context,
+ loader: def.loader
+ });
+};
+
+module.exports = TemplateEngine;
diff --git a/packages/gitbook/lib/models/templateOutput.js b/packages/gitbook/lib/models/templateOutput.js
new file mode 100644
index 0000000..ae63c06
--- /dev/null
+++ b/packages/gitbook/lib/models/templateOutput.js
@@ -0,0 +1,42 @@
+var Immutable = require('immutable');
+
+var TemplateOutput = Immutable.Record({
+ // Text content of the template
+ content: String(),
+
+ // Map of blocks to replace / post process
+ blocks: Immutable.Map()
+}, 'TemplateOutput');
+
+TemplateOutput.prototype.getContent = function() {
+ return this.get('content');
+};
+
+TemplateOutput.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+/**
+ * Update content of this output
+ * @param {String} content
+ * @return {TemplateContent}
+ */
+TemplateOutput.prototype.setContent = function(content) {
+ return this.set('content', content);
+};
+
+/**
+ * Create a TemplateOutput from a text content
+ * and an object containing block definition
+ * @param {String} content
+ * @param {Object} blocks
+ * @return {TemplateOutput}
+ */
+TemplateOutput.create = function(content, blocks) {
+ return new TemplateOutput({
+ content: content,
+ blocks: Immutable.fromJS(blocks)
+ });
+};
+
+module.exports = TemplateOutput;
diff --git a/packages/gitbook/lib/models/templateShortcut.js b/packages/gitbook/lib/models/templateShortcut.js
new file mode 100644
index 0000000..309fa6d
--- /dev/null
+++ b/packages/gitbook/lib/models/templateShortcut.js
@@ -0,0 +1,73 @@
+var Immutable = require('immutable');
+var is = require('is');
+
+/*
+ A TemplateShortcut is defined in plugin's template blocks
+ to replace content with a templating block using delimiters.
+*/
+var TemplateShortcut = Immutable.Record({
+ // List of parser names accepting this shortcut
+ parsers: Immutable.Map(),
+
+ start: String(),
+ end: String(),
+
+ startTag: String(),
+ endTag: String()
+}, 'TemplateShortcut');
+
+TemplateShortcut.prototype.getStart = function() {
+ return this.get('start');
+};
+
+TemplateShortcut.prototype.getEnd = function() {
+ return this.get('end');
+};
+
+TemplateShortcut.prototype.getStartTag = function() {
+ return this.get('startTag');
+};
+
+TemplateShortcut.prototype.getEndTag = function() {
+ return this.get('endTag');
+};
+
+TemplateShortcut.prototype.getParsers = function() {
+ return this.get('parsers');
+};
+
+/**
+ Test if this shortcut accept a parser
+
+ @param {Parser|String} parser
+ @return {Boolean}
+*/
+TemplateShortcut.prototype.acceptParser = function(parser) {
+ if (!is.string(parser)) {
+ parser = parser.getName();
+ }
+
+ var parserNames = this.get('parsers');
+ return parserNames.includes(parser);
+};
+
+/**
+ Create a shortcut for a block
+
+ @param {TemplateBlock} block
+ @param {Map} details
+ @return {TemplateShortcut}
+*/
+TemplateShortcut.createForBlock = function(block, details) {
+ details = Immutable.fromJS(details);
+
+ return new TemplateShortcut({
+ parsers: details.get('parsers'),
+ start: details.get('start'),
+ end: details.get('end'),
+ startTag: block.getName(),
+ endTag: block.getEndTag()
+ });
+};
+
+module.exports = TemplateShortcut;
diff --git a/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js
new file mode 100644
index 0000000..61082c9
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/__tests__/addPlugin.js
@@ -0,0 +1,32 @@
+var addPlugin = require('../addPlugin');
+var Config = require('../../../models/config');
+
+describe('addPlugin', function() {
+ var config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should have correct state of dependencies', function() {
+ var disabledDep = config.getPluginDependency('disabled');
+
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+
+ it('should add the plugin to the list', function() {
+ var newConfig = addPlugin(config, 'test');
+
+ var testDep = newConfig.getPluginDependency('test');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeTruthy();
+
+ var disabledDep = newConfig.getPluginDependency('disabled');
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+});
+
+
diff --git a/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js
new file mode 100644
index 0000000..253cc39
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/__tests__/removePlugin.js
@@ -0,0 +1,33 @@
+var removePlugin = require('../removePlugin');
+var Config = require('../../../models/config');
+
+describe('removePlugin', function() {
+ var config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should remove the plugin from the list', function() {
+ var newConfig = removePlugin(config, 'hello');
+
+ var testDep = newConfig.getPluginDependency('hello');
+ expect(testDep).toNotBeDefined();
+ });
+
+ it('should remove the disabled plugin from the list', function() {
+ var newConfig = removePlugin(config, 'disabled');
+
+ var testDep = newConfig.getPluginDependency('disabled');
+ expect(testDep).toNotBeDefined();
+ });
+
+ it('should disable default plugin', function() {
+ var newConfig = removePlugin(config, 'search');
+
+ var disabledDep = newConfig.getPluginDependency('search');
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+});
+
+
diff --git a/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js b/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js
new file mode 100644
index 0000000..4127853
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/__tests__/togglePlugin.js
@@ -0,0 +1,28 @@
+var togglePlugin = require('../togglePlugin');
+var Config = require('../../../models/config');
+
+describe('togglePlugin', function() {
+ var config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should enable plugin', function() {
+ var newConfig = togglePlugin(config, 'disabled');
+
+ var testDep = newConfig.getPluginDependency('disabled');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeTruthy();
+ });
+
+ it('should disable plugin', function() {
+ var newConfig = togglePlugin(config, 'world');
+
+ var testDep = newConfig.getPluginDependency('world');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeFalsy();
+ });
+});
+
+
diff --git a/packages/gitbook/lib/modifiers/config/addPlugin.js b/packages/gitbook/lib/modifiers/config/addPlugin.js
new file mode 100644
index 0000000..b8d4ea1
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/addPlugin.js
@@ -0,0 +1,25 @@
+var PluginDependency = require('../../models/pluginDependency');
+var togglePlugin = require('./togglePlugin');
+var isDefaultPlugin = require('./isDefaultPlugin');
+
+/**
+ * Add a plugin to a book's configuration
+ * @param {Config} config
+ * @param {String} pluginName
+ * @param {String} version (optional)
+ * @return {Config}
+ */
+function addPlugin(config, pluginName, version) {
+ // For default plugin, we only ensure it is enabled
+ if (isDefaultPlugin(pluginName, version)) {
+ return togglePlugin(config, pluginName, true);
+ }
+
+ var deps = config.getPluginDependencies();
+ var dep = PluginDependency.create(pluginName, version);
+
+ deps = deps.push(dep);
+ return config.setPluginDependencies(deps);
+}
+
+module.exports = addPlugin;
diff --git a/packages/gitbook/lib/modifiers/config/editPlugin.js b/packages/gitbook/lib/modifiers/config/editPlugin.js
new file mode 100644
index 0000000..a792acd
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/editPlugin.js
@@ -0,0 +1,13 @@
+
+/**
+ * Edit configuration of a plugin
+ * @param {Config} config
+ * @param {String} plugin
+ * @param {Object} pluginConfig
+ * @return {Config}
+ */
+function editPlugin(config, pluginName, pluginConfig) {
+ return config.setValue('pluginsConfig.'+pluginName, pluginConfig);
+}
+
+module.exports = editPlugin;
diff --git a/packages/gitbook/lib/modifiers/config/getPluginConfig.js b/packages/gitbook/lib/modifiers/config/getPluginConfig.js
new file mode 100644
index 0000000..ae76de8
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/getPluginConfig.js
@@ -0,0 +1,20 @@
+/**
+ * Return the configuration for a plugin
+ * @param {Config} config
+ * @param {String} pluginName
+ * @return {Object}
+ */
+function getPluginConfig(config, pluginName) {
+ var pluginsConfig = config.getValues().get('pluginsConfig');
+ if (pluginsConfig === undefined) {
+ return {};
+ }
+ var pluginConf = pluginsConfig.get(pluginName);
+ if (pluginConf === undefined) {
+ return {};
+ } else {
+ return pluginConf.toJS();
+ }
+}
+
+module.exports = getPluginConfig;
diff --git a/packages/gitbook/lib/modifiers/config/hasPlugin.js b/packages/gitbook/lib/modifiers/config/hasPlugin.js
new file mode 100644
index 0000000..9aab4f2
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/hasPlugin.js
@@ -0,0 +1,15 @@
+
+/**
+ * Test if a plugin is listed
+ * @param { {List<PluginDependency}} deps
+ * @param {String} plugin
+ * @param {String} version
+ * @return {Boolean}
+ */
+function hasPlugin(deps, pluginName, version) {
+ return !!deps.find(function(dep) {
+ return dep.getName() === pluginName && (!version || dep.getVersion() === version);
+ });
+}
+
+module.exports = hasPlugin;
diff --git a/packages/gitbook/lib/modifiers/config/index.js b/packages/gitbook/lib/modifiers/config/index.js
new file mode 100644
index 0000000..b3de0b0
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ addPlugin: require('./addPlugin'),
+ removePlugin: require('./removePlugin'),
+ togglePlugin: require('./togglePlugin'),
+ editPlugin: require('./editPlugin'),
+ hasPlugin: require('./hasPlugin'),
+ getPluginConfig: require('./getPluginConfig'),
+ isDefaultPlugin: require('./isDefaultPlugin')
+};
diff --git a/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js b/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js
new file mode 100644
index 0000000..63a141d
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/isDefaultPlugin.js
@@ -0,0 +1,14 @@
+var DEFAULT_PLUGINS = require('../../constants/defaultPlugins');
+var hasPlugin = require('./hasPlugin');
+
+/**
+ * Test if a plugin is a default one
+ * @param {String} plugin
+ * @param {String} version
+ * @return {Boolean}
+ */
+function isDefaultPlugin(pluginName, version) {
+ return hasPlugin(DEFAULT_PLUGINS, pluginName, version);
+}
+
+module.exports = isDefaultPlugin;
diff --git a/packages/gitbook/lib/modifiers/config/removePlugin.js b/packages/gitbook/lib/modifiers/config/removePlugin.js
new file mode 100644
index 0000000..ec06d1e
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/removePlugin.js
@@ -0,0 +1,25 @@
+var togglePlugin = require('./togglePlugin');
+var isDefaultPlugin = require('./isDefaultPlugin');
+
+/**
+ * Remove a plugin from a book's configuration
+ * @param {Config} config
+ * @param {String} plugin
+ * @return {Config}
+ */
+function removePlugin(config, pluginName) {
+ var deps = config.getPluginDependencies();
+
+ // For default plugin, we have to disable it instead of removing from the list
+ if (isDefaultPlugin(pluginName)) {
+ return togglePlugin(config, pluginName, false);
+ }
+
+ // Remove the dependency from the list
+ deps = deps.filterNot(function(dep) {
+ return dep.getName() === pluginName;
+ });
+ return config.setPluginDependencies(deps);
+}
+
+module.exports = removePlugin;
diff --git a/packages/gitbook/lib/modifiers/config/togglePlugin.js b/packages/gitbook/lib/modifiers/config/togglePlugin.js
new file mode 100644
index 0000000..a49e3b9
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/config/togglePlugin.js
@@ -0,0 +1,31 @@
+var PluginDependency = require('../../models/pluginDependency');
+var hasPlugin = require('./hasPlugin');
+var isDefaultPlugin = require('./isDefaultPlugin');
+
+/**
+ * Enable/disable a plugin dependency
+ * @param {Config} config
+ * @param {String} pluginName
+ * @param {Boolean} state (optional)
+ * @return {Config}
+ */
+function togglePlugin(config, pluginName, state) {
+ var deps = config.getPluginDependencies();
+
+ // For default plugin, we should ensure it's listed first
+ if (isDefaultPlugin(pluginName) && !hasPlugin(deps, pluginName)) {
+ deps = deps.push(PluginDependency.create(pluginName));
+ }
+
+ deps = deps.map(function(dep) {
+ if (dep.getName() === pluginName) {
+ return dep.toggle(state);
+ }
+
+ return dep;
+ });
+
+ return config.setPluginDependencies(deps);
+}
+
+module.exports = togglePlugin;
diff --git a/packages/gitbook/lib/modifiers/index.js b/packages/gitbook/lib/modifiers/index.js
new file mode 100644
index 0000000..ad24604
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/index.js
@@ -0,0 +1,5 @@
+
+module.exports = {
+ Summary: require('./summary'),
+ Config: require('./config')
+};
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/editArticle.js
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js b/packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js
new file mode 100644
index 0000000..d1b916b
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/editPartTitle.js
@@ -0,0 +1,44 @@
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('editPartTitle', function() {
+ var editPartTitle = require('../editPartTitle');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: 'My First Article',
+ path: 'README.md'
+ },
+ {
+ title: 'My Second Article',
+ path: 'article.md'
+ }
+ ]
+ },
+ {
+ title: 'Test'
+ }
+ ]);
+
+ it('should correctly set title of first part', function() {
+ var newSummary = editPartTitle(summary, 0, 'Hello World');
+ var part = newSummary.getPart(0);
+
+ expect(part.getTitle()).toBe('Hello World');
+ });
+
+ it('should correctly set title of second part', function() {
+ var newSummary = editPartTitle(summary, 1, 'Hello');
+ var part = newSummary.getPart(1);
+
+ expect(part.getTitle()).toBe('Hello');
+ });
+
+ it('should not fail if part doesn\'t exist', function() {
+ var newSummary = editPartTitle(summary, 3, 'Hello');
+ expect(newSummary.getParts().size).toBe(2);
+ });
+});
+
+
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js
new file mode 100644
index 0000000..1ee1c8a
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/insertArticle.js
@@ -0,0 +1,78 @@
+var Summary = require('../../../models/summary');
+var SummaryArticle = require('../../../models/summaryArticle');
+var File = require('../../../models/file');
+
+describe('insertArticle', function() {
+ var insertArticle = require('../insertArticle');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ },
+ {
+ title: '1.2',
+ path: '1.2'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: [
+ {
+ title: '2.1',
+ path: '2.1',
+ articles: [
+ {
+ title: '2.1.1',
+ path: '2.1.1'
+ },
+ {
+ title: '2.1.2',
+ path: '2.1.2'
+ }
+ ]
+ },
+ {
+ title: '2.2',
+ path: '2.2'
+ }
+ ]
+ }
+ ]);
+
+ it('should insert an article at a given level', function() {
+ var article = SummaryArticle.create({
+ title: 'Inserted'
+ }, 'fake.level');
+
+ var newSummary = insertArticle(summary, article, '2.1.1');
+
+ var inserted = newSummary.getByLevel('2.1.1');
+ var nextOne = newSummary.getByLevel('2.1.2');
+
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(inserted.getLevel()).toBe('2.1.1');
+
+ expect(nextOne.getTitle()).toBe('2.1.1');
+ expect(nextOne.getLevel()).toBe('2.1.2');
+ });
+
+ it('should insert an article in last position', function() {
+ var article = SummaryArticle.create({
+ title: 'Inserted'
+ }, 'fake.level');
+
+ var newSummary = insertArticle(summary, article, '2.2');
+
+ var inserted = newSummary.getByLevel('2.2');
+ var previousOne = newSummary.getByLevel('2.1');
+
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(inserted.getLevel()).toBe('2.2');
+
+ expect(previousOne.getTitle()).toBe('2.1'); // Unchanged
+ expect(previousOne.getLevel()).toBe('2.1');
+ });
+});
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js b/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js
new file mode 100644
index 0000000..11c2cbc
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/insertPart.js
@@ -0,0 +1,60 @@
+var Summary = require('../../../models/summary');
+var SummaryPart = require('../../../models/summaryPart');
+
+var File = require('../../../models/file');
+
+describe('insertPart', function() {
+ var insertPart = require('../insertPart');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: [
+ {
+ title: '2.1',
+ path: '2.1',
+ articles: []
+ },
+ {
+ title: '2.2',
+ path: '2.2'
+ }
+ ]
+ }
+ ]);
+
+ it('should insert an part at a given level', function() {
+ var part = SummaryPart.create({
+ title: 'Inserted'
+ }, 'meaningless.level');
+
+ var newSummary = insertPart(summary, part, 1);
+
+ var inserted = newSummary.getPart(1);
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(newSummary.getParts().count()).toBe(3);
+
+ var otherArticle = newSummary.getByLevel('3.1');
+ expect(otherArticle.getTitle()).toBe('2.1');
+ expect(otherArticle.getLevel()).toBe('3.1');
+ });
+
+ it('should insert an part in last position', function() {
+ var part = SummaryPart.create({
+ title: 'Inserted'
+ }, 'meaningless.level');
+
+ var newSummary = insertPart(summary, part, 2);
+
+ var inserted = newSummary.getPart(2);
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(newSummary.getParts().count()).toBe(3);
+ });
+});
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js b/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js
new file mode 100644
index 0000000..e2635ec
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/mergeAtLevel.js
@@ -0,0 +1,45 @@
+var Immutable = require('immutable');
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('mergeAtLevel', function() {
+ var mergeAtLevel = require('../mergeAtLevel');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ },
+ {
+ title: '1.2',
+ path: '1.2'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: []
+ }
+ ]);
+
+ it('should edit a part', function() {
+ var beforeChildren = summary.getByLevel('1').getArticles();
+ var newSummary = mergeAtLevel(summary, '1', {title: 'Part O'});
+ var edited = newSummary.getByLevel('1');
+
+ expect(edited.getTitle()).toBe('Part O');
+ // Same children
+ expect(Immutable.is(beforeChildren, edited.getArticles())).toBe(true);
+ });
+
+ it('should edit a part', function() {
+ var beforePath = summary.getByLevel('1.2').getPath();
+ var newSummary = mergeAtLevel(summary, '1.2', {title: 'Renamed article'});
+ var edited = newSummary.getByLevel('1.2');
+
+ expect(edited.getTitle()).toBe('Renamed article');
+ // Same children
+ expect(Immutable.is(beforePath, edited.getPath())).toBe(true);
+ });
+});
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js
new file mode 100644
index 0000000..aed0b94
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticle.js
@@ -0,0 +1,68 @@
+var Immutable = require('immutable');
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('moveArticle', function() {
+ var moveArticle = require('../moveArticle');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ },
+ {
+ title: '1.2',
+ path: '1.2'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: [
+ {
+ title: '2.1',
+ path: '2.1',
+ articles: [
+ {
+ title: '2.1.1',
+ path: '2.1.1'
+ },
+ {
+ title: '2.1.2',
+ path: '2.1.2'
+ }
+ ]
+ },
+ {
+ title: '2.2',
+ path: '2.2'
+ }
+ ]
+ }
+ ]);
+
+ it('should move an article to the same place', function() {
+ var newSummary = moveArticle(summary, '2.1', '2.1');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('should move an article to an previous level', function() {
+ var newSummary = moveArticle(summary, '2.2', '2.1');
+ var moved = newSummary.getByLevel('2.1');
+ var other = newSummary.getByLevel('2.2');
+
+ expect(moved.getTitle()).toBe('2.2');
+ expect(other.getTitle()).toBe('2.1');
+ });
+
+ it('should move an article to a next level', function() {
+ var newSummary = moveArticle(summary, '2.1', '2.2');
+ var moved = newSummary.getByLevel('2.1');
+ var other = newSummary.getByLevel('2.2');
+
+ expect(moved.getTitle()).toBe('2.2');
+ expect(other.getTitle()).toBe('2.1');
+ });
+});
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js
new file mode 100644
index 0000000..c380575
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/moveArticleAfter.js
@@ -0,0 +1,82 @@
+var Immutable = require('immutable');
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('moveArticleAfter', function() {
+ var moveArticleAfter = require('../moveArticleAfter');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ },
+ {
+ title: '1.2',
+ path: '1.2'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: [
+ {
+ title: '2.1',
+ path: '2.1',
+ articles: [
+ {
+ title: '2.1.1',
+ path: '2.1.1'
+ },
+ {
+ title: '2.1.2',
+ path: '2.1.2'
+ }
+ ]
+ },
+ {
+ title: '2.2',
+ path: '2.2'
+ }
+ ]
+ }
+ ]);
+
+ it('moving right after itself should be invariant', function() {
+ var newSummary = moveArticleAfter(summary, '2.1', '2.1');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('moving after previous one should be invariant too', function() {
+ var newSummary = moveArticleAfter(summary, '2.1', '2.0');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('should move an article after a previous level', function() {
+ var newSummary = moveArticleAfter(summary, '2.2', '2.0');
+ var moved = newSummary.getByLevel('2.1');
+
+ expect(moved.getTitle()).toBe('2.2');
+ expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1');
+ });
+
+ it('should move an article after a previous and less deep level', function() {
+ var newSummary = moveArticleAfter(summary, '2.1.1', '2.0');
+ var moved = newSummary.getByLevel('2.1');
+
+ expect(moved.getTitle()).toBe('2.1.1');
+ expect(newSummary.getByLevel('2.2.1').getTitle()).toBe('2.1.2');
+ expect(newSummary.getByLevel('2.2').getTitle()).toBe('2.1');
+ });
+
+ it('should move an article after a next level', function() {
+ var newSummary = moveArticleAfter(summary, '2.1', '2.2');
+ var moved = newSummary.getByLevel('2.2');
+
+ expect(moved.getTitle()).toBe('2.1');
+ expect(newSummary.getByLevel('2.1').getTitle()).toBe('2.2');
+ });
+
+});
diff --git a/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js b/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js
new file mode 100644
index 0000000..b45fb49
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/__tests__/removeArticle.js
@@ -0,0 +1,53 @@
+var Summary = require('../../../models/summary');
+var File = require('../../../models/file');
+
+describe('removeArticle', function() {
+ var removeArticle = require('../removeArticle');
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: '1.1',
+ path: '1.1'
+ },
+ {
+ title: '1.2',
+ path: '1.2'
+ }
+ ]
+ },
+ {
+ title: 'Part I',
+ articles: [
+ {
+ title: '2.1',
+ path: '2.1',
+ articles: [
+ {
+ title: '2.1.1',
+ path: '2.1.1'
+ },
+ {
+ title: '2.1.2',
+ path: '2.1.2'
+ }
+ ]
+ },
+ {
+ title: '2.2',
+ path: '2.2'
+ }
+ ]
+ }
+ ]);
+
+ it('should remove an article at a given level', function() {
+ var newSummary = removeArticle(summary, '2.1.1');
+
+ var removed = newSummary.getByLevel('2.1.1');
+ var nextOne = newSummary.getByLevel('2.1.2');
+
+ expect(removed.getTitle()).toBe('2.1.2');
+ expect(nextOne).toBe(null);
+ });
+});
diff --git a/packages/gitbook/lib/modifiers/summary/editArticleRef.js b/packages/gitbook/lib/modifiers/summary/editArticleRef.js
new file mode 100644
index 0000000..7106960
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/editArticleRef.js
@@ -0,0 +1,17 @@
+var mergeAtLevel = require('./mergeAtLevel');
+
+/**
+ Edit the ref of an article
+
+ @param {Summary} summary
+ @param {String} level
+ @param {String} newRef
+ @return {Summary}
+*/
+function editArticleRef(summary, level, newRef) {
+ return mergeAtLevel(summary, level, {
+ ref: newRef
+ });
+}
+
+module.exports = editArticleRef;
diff --git a/packages/gitbook/lib/modifiers/summary/editArticleTitle.js b/packages/gitbook/lib/modifiers/summary/editArticleTitle.js
new file mode 100644
index 0000000..4edee83
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/editArticleTitle.js
@@ -0,0 +1,17 @@
+var mergeAtLevel = require('./mergeAtLevel');
+
+/**
+ Edit title of an article
+
+ @param {Summary} summary
+ @param {String} level
+ @param {String} newTitle
+ @return {Summary}
+*/
+function editArticleTitle(summary, level, newTitle) {
+ return mergeAtLevel(summary, level, {
+ title: newTitle
+ });
+}
+
+module.exports = editArticleTitle;
diff --git a/packages/gitbook/lib/modifiers/summary/editPartTitle.js b/packages/gitbook/lib/modifiers/summary/editPartTitle.js
new file mode 100644
index 0000000..b79ac1e
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/editPartTitle.js
@@ -0,0 +1,23 @@
+/**
+ Edit title of a part in the summary
+
+ @param {Summary} summary
+ @param {Number} index
+ @param {String} newTitle
+ @return {Summary}
+*/
+function editPartTitle(summary, index, newTitle) {
+ var parts = summary.getParts();
+
+ var part = parts.get(index);
+ if (!part) {
+ return summary;
+ }
+
+ part = part.set('title', newTitle);
+ parts = parts.set(index, part);
+
+ return summary.set('parts', parts);
+}
+
+module.exports = editPartTitle;
diff --git a/packages/gitbook/lib/modifiers/summary/index.js b/packages/gitbook/lib/modifiers/summary/index.js
new file mode 100644
index 0000000..f91fdb6
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/index.js
@@ -0,0 +1,13 @@
+module.exports = {
+ insertArticle: require('./insertArticle'),
+ moveArticle: require('./moveArticle'),
+ moveArticleAfter: require('./moveArticleAfter'),
+ removeArticle: require('./removeArticle'),
+ unshiftArticle: require('./unshiftArticle'),
+ editArticleTitle: require('./editArticleTitle'),
+ editArticleRef: require('./editArticleRef'),
+
+ insertPart: require('./insertPart'),
+ removePart: require('./removePart'),
+ editPartTitle: require('./editPartTitle')
+};
diff --git a/packages/gitbook/lib/modifiers/summary/indexArticleLevels.js b/packages/gitbook/lib/modifiers/summary/indexArticleLevels.js
new file mode 100644
index 0000000..f311f74
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/indexArticleLevels.js
@@ -0,0 +1,23 @@
+
+/**
+ Index levels in an article tree
+
+ @param {Article}
+ @param {String} baseLevel
+ @return {Article}
+*/
+function indexArticleLevels(article, baseLevel) {
+ baseLevel = baseLevel || article.getLevel();
+ var articles = article.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return article.merge({
+ level: baseLevel,
+ articles: articles
+ });
+}
+
+module.exports = indexArticleLevels;
diff --git a/packages/gitbook/lib/modifiers/summary/indexLevels.js b/packages/gitbook/lib/modifiers/summary/indexLevels.js
new file mode 100644
index 0000000..604e9ff
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/indexLevels.js
@@ -0,0 +1,17 @@
+var indexPartLevels = require('./indexPartLevels');
+
+/**
+ Index all levels in the summary
+
+ @param {Summary}
+ @return {Summary}
+*/
+function indexLevels(summary) {
+ var parts = summary.getParts();
+ parts = parts.map(indexPartLevels);
+
+ return summary.set('parts', parts);
+}
+
+
+module.exports = indexLevels;
diff --git a/packages/gitbook/lib/modifiers/summary/indexPartLevels.js b/packages/gitbook/lib/modifiers/summary/indexPartLevels.js
new file mode 100644
index 0000000..d19c70a
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/indexPartLevels.js
@@ -0,0 +1,24 @@
+var indexArticleLevels = require('./indexArticleLevels');
+
+/**
+ Index levels in a part
+
+ @param {Part}
+ @param {Number} index
+ @return {Part}
+*/
+function indexPartLevels(part, index) {
+ var baseLevel = String(index + 1);
+ var articles = part.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return part.merge({
+ level: baseLevel,
+ articles: articles
+ });
+}
+
+module.exports = indexPartLevels;
diff --git a/packages/gitbook/lib/modifiers/summary/insertArticle.js b/packages/gitbook/lib/modifiers/summary/insertArticle.js
new file mode 100644
index 0000000..3a084b3
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/insertArticle.js
@@ -0,0 +1,49 @@
+var is = require('is');
+var SummaryArticle = require('../../models/summaryArticle');
+var mergeAtLevel = require('./mergeAtLevel');
+var indexArticleLevels = require('./indexArticleLevels');
+
+/**
+ Returns a new Summary with the article at the given level, with
+ subsequent article shifted.
+
+ @param {Summary} summary
+ @param {Article} article
+ @param {String|Article} level: level to insert at
+ @return {Summary}
+*/
+function insertArticle(summary, article, level) {
+ article = SummaryArticle(article);
+ level = is.string(level)? level : level.getLevel();
+
+ var parent = summary.getParent(level);
+ if (!parent) {
+ return summary;
+ }
+
+ // Find the index to insert at
+ var articles = parent.getArticles();
+ var index = getLeafIndex(level);
+
+ // Insert the article at the right index
+ articles = articles.insert(index, article);
+
+ // Reindex the level from here
+ parent = parent.set('articles', articles);
+ parent = indexArticleLevels(parent);
+
+ return mergeAtLevel(summary, parent.getLevel(), parent);
+}
+
+/**
+ @param {String}
+ @return {Number} The index of this level within its parent's children
+ */
+function getLeafIndex(level) {
+ var arr = level.split('.').map(function (char) {
+ return parseInt(char, 10);
+ });
+ return arr[arr.length - 1] - 1;
+}
+
+module.exports = insertArticle;
diff --git a/packages/gitbook/lib/modifiers/summary/insertPart.js b/packages/gitbook/lib/modifiers/summary/insertPart.js
new file mode 100644
index 0000000..199cba7
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/insertPart.js
@@ -0,0 +1,19 @@
+var SummaryPart = require('../../models/summaryPart');
+var indexLevels = require('./indexLevels');
+
+/**
+ Returns a new Summary with a part inserted at given index
+
+ @param {Summary} summary
+ @param {Part} part
+ @param {Number} index
+ @return {Summary}
+*/
+function insertPart(summary, part, index) {
+ part = SummaryPart(part);
+
+ var parts = summary.getParts().insert(index, part);
+ return indexLevels(summary.set('parts', parts));
+}
+
+module.exports = insertPart;
diff --git a/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js b/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js
new file mode 100644
index 0000000..9a95ffc
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/mergeAtLevel.js
@@ -0,0 +1,75 @@
+
+/**
+ Edit a list of articles
+
+ @param {List<Article>} articles
+ @param {String} level
+ @param {Article} newArticle
+ @return {List<Article>}
+*/
+function editArticleInList(articles, level, newArticle) {
+ return articles.map(function(article) {
+ var articleLevel = article.getLevel();
+
+ if (articleLevel === level) {
+ // it is the article to edit
+ return article.merge(newArticle);
+ } else if (level.indexOf(articleLevel) === 0) {
+ // it is a parent
+ var articles = editArticleInList(article.getArticles(), level, newArticle);
+ return article.set('articles', articles);
+ } else {
+ // This is not the article you are looking for
+ return article;
+ }
+ });
+}
+
+
+/**
+ Edit an article in a part
+
+ @param {Part} part
+ @param {String} level
+ @param {Article} newArticle
+ @return {Part}
+*/
+function editArticleInPart(part, level, newArticle) {
+ var articles = part.getArticles();
+ articles = editArticleInList(articles, level, newArticle);
+
+ return part.set('articles', articles);
+}
+
+
+/**
+ Edit an article, or a part, in a summary. Does a shallow merge.
+
+ @param {Summary} summary
+ @param {String} level
+ @param {Article|Part} newValue
+ @return {Summary}
+*/
+function mergeAtLevel(summary, level, newValue) {
+ var levelParts = level.split('.');
+ var partIndex = Number(levelParts[0]) -1;
+
+ var parts = summary.getParts();
+ var part = parts.get(partIndex);
+ if (!part) {
+ return summary;
+ }
+
+ var isEditingPart = levelParts.length < 2;
+ if (isEditingPart) {
+ part = part.merge(newValue);
+ } else {
+ part = editArticleInPart(part, level, newValue);
+ }
+
+ parts = parts.set(partIndex, part);
+ return summary.set('parts', parts);
+}
+
+
+module.exports = mergeAtLevel;
diff --git a/packages/gitbook/lib/modifiers/summary/moveArticle.js b/packages/gitbook/lib/modifiers/summary/moveArticle.js
new file mode 100644
index 0000000..5cb1868
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/moveArticle.js
@@ -0,0 +1,25 @@
+var is = require('is');
+var removeArticle = require('./removeArticle');
+var insertArticle = require('./insertArticle');
+
+/**
+ Returns a new summary, with the given article removed from its
+ origin level, and placed at the given target level.
+
+ @param {Summary} summary
+ @param {String|SummaryArticle} origin: level to remove
+ @param {String|SummaryArticle} target: the level where the article will be found
+ @return {Summary}
+*/
+function moveArticle(summary, origin, target) {
+ // Coerce to level
+ var originLevel = is.string(origin)? origin : origin.getLevel();
+ var targetLevel = is.string(target)? target : target.getLevel();
+ var article = summary.getByLevel(originLevel);
+
+ // Remove first
+ var removed = removeArticle(summary, originLevel);
+ return insertArticle(removed, article, targetLevel);
+}
+
+module.exports = moveArticle;
diff --git a/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js b/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js
new file mode 100644
index 0000000..e268f73
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/moveArticleAfter.js
@@ -0,0 +1,60 @@
+var is = require('is');
+var removeArticle = require('./removeArticle');
+var insertArticle = require('./insertArticle');
+
+/**
+ Returns a new summary, with the an article moved after another
+ article. Unlike `moveArticle`, does not ensure that the article
+ will be found at the target's level plus one.
+
+ @param {Summary} summary
+ @param {String|SummaryArticle} origin
+ @param {String|SummaryArticle} afterTarget
+ @return {Summary}
+*/
+function moveArticleAfter(summary, origin, afterTarget) {
+ // Coerce to level
+ var originLevel = is.string(origin)? origin : origin.getLevel();
+ var afterTargetLevel = is.string(afterTarget)? afterTarget : afterTarget.getLevel();
+ var article = summary.getByLevel(originLevel);
+
+ var targetLevel = increment(afterTargetLevel);
+
+ if (targetLevel < origin) {
+ // Remove first
+ var removed = removeArticle(summary, originLevel);
+ // Insert then
+ return insertArticle(removed, article, targetLevel);
+ } else {
+ // Insert right after first
+ var inserted = insertArticle(summary, article, targetLevel);
+ // Remove old one
+ return removeArticle(inserted, originLevel);
+ }
+}
+
+/**
+ @param {String}
+ @return {Array<Number>}
+ */
+function levelToArray(l) {
+ return l.split('.').map(function (char) {
+ return parseInt(char, 10);
+ });
+}
+
+/**
+ @param {Array<Number>}
+ @return {String}
+ */
+function arrayToLevel(a) {
+ return a.join('.');
+}
+
+function increment(level) {
+ level = levelToArray(level);
+ level[level.length - 1]++;
+ return arrayToLevel(level);
+}
+
+module.exports = moveArticleAfter;
diff --git a/packages/gitbook/lib/modifiers/summary/removeArticle.js b/packages/gitbook/lib/modifiers/summary/removeArticle.js
new file mode 100644
index 0000000..8a30d0a
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/removeArticle.js
@@ -0,0 +1,37 @@
+var is = require('is');
+var mergeAtLevel = require('./mergeAtLevel');
+var indexArticleLevels = require('./indexArticleLevels');
+
+/**
+ Remove an article from a level.
+
+ @param {Summary} summary
+ @param {String|SummaryArticle} level: level to remove
+ @return {Summary}
+*/
+function removeArticle(summary, level) {
+ // Coerce to level
+ level = is.string(level)? level : level.getLevel();
+
+ var parent = summary.getParent(level);
+
+ var articles = parent.getArticles();
+ // Find the index to remove
+ var index = articles.findIndex(function(art) {
+ return art.getLevel() === level;
+ });
+ if (index === -1) {
+ return summary;
+ }
+
+ // Remove from children
+ articles = articles.remove(index);
+ parent = parent.set('articles', articles);
+
+ // Reindex the level from here
+ parent = indexArticleLevels(parent);
+
+ return mergeAtLevel(summary, parent.getLevel(), parent);
+}
+
+module.exports = removeArticle;
diff --git a/packages/gitbook/lib/modifiers/summary/removePart.js b/packages/gitbook/lib/modifiers/summary/removePart.js
new file mode 100644
index 0000000..2f8affc
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/removePart.js
@@ -0,0 +1,15 @@
+var indexLevels = require('./indexLevels');
+
+/**
+ Remove a part at given index
+
+ @param {Summary} summary
+ @param {Number|} index
+ @return {Summary}
+*/
+function removePart(summary, index) {
+ var parts = summary.getParts().remove(index);
+ return indexLevels(summary.set('parts', parts));
+}
+
+module.exports = removePart;
diff --git a/packages/gitbook/lib/modifiers/summary/unshiftArticle.js b/packages/gitbook/lib/modifiers/summary/unshiftArticle.js
new file mode 100644
index 0000000..d1ebc05
--- /dev/null
+++ b/packages/gitbook/lib/modifiers/summary/unshiftArticle.js
@@ -0,0 +1,29 @@
+var SummaryArticle = require('../../models/summaryArticle');
+var SummaryPart = require('../../models/summaryPart');
+
+var indexLevels = require('./indexLevels');
+
+/**
+ Insert an article at the beginning of summary
+
+ @param {Summary} summary
+ @param {Article} article
+ @return {Summary}
+*/
+function unshiftArticle(summary, article) {
+ article = SummaryArticle(article);
+
+ var parts = summary.getParts();
+ var part = parts.get(0) || SummaryPart();
+
+ var articles = part.getArticles();
+ articles = articles.unshift(article);
+ part = part.set('articles', articles);
+
+ parts = parts.set(0, part);
+ summary = summary.set('parts', parts);
+
+ return indexLevels(summary);
+}
+
+module.exports = unshiftArticle;
diff --git a/packages/gitbook/lib/output/__tests__/createMock.js b/packages/gitbook/lib/output/__tests__/createMock.js
new file mode 100644
index 0000000..f21c544
--- /dev/null
+++ b/packages/gitbook/lib/output/__tests__/createMock.js
@@ -0,0 +1,38 @@
+var Immutable = require('immutable');
+
+var Output = require('../../models/output');
+var Book = require('../../models/book');
+var parseBook = require('../../parse/parseBook');
+var createMockFS = require('../../fs/mock');
+var preparePlugins = require('../preparePlugins');
+
+/**
+ * Create an output using a generator
+ *
+ * FOR TESTING PURPOSE ONLY
+ *
+ * @param {Generator} generator
+ * @param {Map<String:String|Map>} files
+ * @return {Promise<Output>}
+ */
+function createMockOutput(generator, files, options) {
+ var fs = createMockFS(files);
+ var book = Book.createForFS(fs);
+ var state = generator.State? generator.State({}) : Immutable.Map();
+
+ book = book.setLogLevel('disabled');
+ options = generator.Options(options);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ return new Output({
+ book: resultBook,
+ options: options,
+ state: state,
+ generator: generator.name
+ });
+ })
+ .then(preparePlugins);
+}
+
+module.exports = createMockOutput;
diff --git a/packages/gitbook/lib/output/__tests__/ebook.js b/packages/gitbook/lib/output/__tests__/ebook.js
new file mode 100644
index 0000000..9266e9f
--- /dev/null
+++ b/packages/gitbook/lib/output/__tests__/ebook.js
@@ -0,0 +1,16 @@
+var generateMock = require('./generateMock');
+var EbookGenerator = require('../ebook');
+
+describe('EbookGenerator', function() {
+
+ it('should generate a SUMMARY.html', function() {
+ return generateMock(EbookGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('SUMMARY.html');
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/packages/gitbook/lib/output/__tests__/generateMock.js b/packages/gitbook/lib/output/__tests__/generateMock.js
new file mode 100644
index 0000000..691ee2d
--- /dev/null
+++ b/packages/gitbook/lib/output/__tests__/generateMock.js
@@ -0,0 +1,40 @@
+var tmp = require('tmp');
+
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+var parseBook = require('../../parse/parseBook');
+var generateBook = require('../generateBook');
+
+/**
+ * Generate a book using a generator
+ * And returns the path to the output dir.
+ *
+ * FOR TESTING PURPOSE ONLY
+ *
+ * @param {Generator}
+ * @param {Map<String:String|Map>} files
+ * @return {Promise<String>}
+ */
+function generateMock(Generator, files) {
+ var fs = createMockFS(files);
+ var book = Book.createForFS(fs);
+ var dir;
+
+ try {
+ dir = tmp.dirSync();
+ } catch(err) {
+ throw err;
+ }
+
+ book = book.setLogLevel('disabled');
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ return generateBook(Generator, resultBook, {
+ root: dir.name
+ });
+ })
+ .thenResolve(dir.name);
+}
+
+module.exports = generateMock;
diff --git a/packages/gitbook/lib/output/__tests__/json.js b/packages/gitbook/lib/output/__tests__/json.js
new file mode 100644
index 0000000..12ab567
--- /dev/null
+++ b/packages/gitbook/lib/output/__tests__/json.js
@@ -0,0 +1,46 @@
+var generateMock = require('./generateMock');
+var JSONGenerator = require('../json');
+
+describe('JSONGenerator', function() {
+
+ it('should generate a README.json', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ });
+ });
+
+ it('should generate a json file for each articles', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ expect(folder).toHaveFile('test/page.json');
+ });
+ });
+
+ it('should generate a multilingual book', function() {
+ return generateMock(JSONGenerator, {
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('en/README.json');
+ expect(folder).toHaveFile('fr/README.json');
+ expect(folder).toHaveFile('README.json');
+ });
+ });
+});
+
diff --git a/packages/gitbook/lib/output/__tests__/website.js b/packages/gitbook/lib/output/__tests__/website.js
new file mode 100644
index 0000000..1f8c3c0
--- /dev/null
+++ b/packages/gitbook/lib/output/__tests__/website.js
@@ -0,0 +1,144 @@
+var fs = require('fs');
+var generateMock = require('./generateMock');
+var WebsiteGenerator = require('../website');
+
+describe('WebsiteGenerator', function() {
+
+ it('should generate an index.html', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ describe('Glossary', function() {
+ var folder;
+
+ before(function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '* [Deep](folder/page.md)',
+ 'folder': {
+ 'page.md': 'Hello World'
+ },
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\n\nHello World'
+ })
+ .then(function(_folder) {
+ folder = _folder;
+ });
+ });
+
+ it('should generate a GLOSSARY.html', function() {
+ expect(folder).toHaveFile('GLOSSARY.html');
+ });
+
+ it('should correctly resolve glossary links in README', function() {
+ var html = fs.readFileSync(folder + '/index.html', 'utf8');
+ expect(html).toHaveDOMElement('.page-inner a[href="GLOSSARY.html#hello"]');
+ });
+
+ it('should correctly resolve glossary links in directory', function() {
+ var html = fs.readFileSync(folder + '/folder/page.html', 'utf8');
+ expect(html).toHaveDOMElement('.page-inner a[href="../GLOSSARY.html#hello"]');
+ });
+
+ it('should accept a custom glossary file', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'book.json': '{ "structure": { "glossary": "custom.md" } }',
+ 'custom.md': '# Glossary\n\n## Hello\n\nHello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('custom.html');
+ expect(folder).toNotHaveFile('GLOSSARY.html');
+
+ var html = fs.readFileSync(folder + '/index.html', 'utf8');
+ expect(html).toHaveDOMElement('.page-inner a[href="custom.html#hello"]');
+ });
+ });
+ });
+
+
+ it('should copy asset files', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'myJsFile.js': 'var a = "test";',
+ 'folder': {
+ 'AnotherAssetFile.md': '# Even md'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toHaveFile('myJsFile.js');
+ expect(folder).toHaveFile('folder/AnotherAssetFile.md');
+ });
+ });
+
+ it('should generate an index.html for AsciiDoc', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.adoc': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ it('should generate an HTML file for each articles', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ it('should not generate file if entry file doesn\'t exist', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toNotHaveFile('page.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ it('should generate a multilingual book', function() {
+ return generateMock(WebsiteGenerator, {
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ })
+ .then(function(folder) {
+ // It should generate languages
+ expect(folder).toHaveFile('en/index.html');
+ expect(folder).toHaveFile('fr/index.html');
+
+ // Should not copy languages as assets
+ expect(folder).toNotHaveFile('en/README.md');
+ expect(folder).toNotHaveFile('fr/README.md');
+
+ // Should copy assets only once
+ expect(folder).toHaveFile('gitbook/style.css');
+ expect(folder).toNotHaveFile('en/gitbook/style.css');
+
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/packages/gitbook/lib/output/callHook.js b/packages/gitbook/lib/output/callHook.js
new file mode 100644
index 0000000..4914e52
--- /dev/null
+++ b/packages/gitbook/lib/output/callHook.js
@@ -0,0 +1,60 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var Api = require('../api');
+
+function defaultGetArgument() {
+ return undefined;
+}
+
+function defaultHandleResult(output, result) {
+ return output;
+}
+
+/**
+ Call a "global" hook for an output
+
+ @param {String} name
+ @param {Function(Output) -> Mixed} getArgument
+ @param {Function(Output, result) -> Output} handleResult
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function callHook(name, getArgument, handleResult, output) {
+ getArgument = getArgument || defaultGetArgument;
+ handleResult = handleResult || defaultHandleResult;
+
+ var logger = output.getLogger();
+ var plugins = output.getPlugins();
+
+ logger.debug.ln('calling hook "' + name + '"');
+
+ // Create the JS context for plugins
+ var context = Api.encodeGlobal(output);
+
+ return timing.measure(
+ 'call.hook.' + name,
+
+ // Get the arguments
+ Promise(getArgument(output))
+
+ // Call the hooks in serie
+ .then(function(arg) {
+ return Promise.reduce(plugins, function(prev, plugin) {
+ var hook = plugin.getHook(name);
+ if (!hook) {
+ return prev;
+ }
+
+ return hook.call(context, prev);
+ }, arg);
+ })
+
+ // Handle final result
+ .then(function(result) {
+ output = Api.decodeGlobal(output, context);
+ return handleResult(output, result);
+ })
+ );
+}
+
+module.exports = callHook;
diff --git a/packages/gitbook/lib/output/callPageHook.js b/packages/gitbook/lib/output/callPageHook.js
new file mode 100644
index 0000000..c66cef0
--- /dev/null
+++ b/packages/gitbook/lib/output/callPageHook.js
@@ -0,0 +1,28 @@
+var Api = require('../api');
+var callHook = require('./callHook');
+
+/**
+ Call a hook for a specific page
+
+ @param {String} name
+ @param {Output} output
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function callPageHook(name, output, page) {
+ return callHook(
+ name,
+
+ function(out) {
+ return Api.encodePage(out, page);
+ },
+
+ function(out, result) {
+ return Api.decodePage(out, page, result);
+ },
+
+ output
+ );
+}
+
+module.exports = callPageHook;
diff --git a/packages/gitbook/lib/output/createTemplateEngine.js b/packages/gitbook/lib/output/createTemplateEngine.js
new file mode 100644
index 0000000..8cf320e
--- /dev/null
+++ b/packages/gitbook/lib/output/createTemplateEngine.js
@@ -0,0 +1,45 @@
+var Templating = require('../templating');
+var TemplateEngine = require('../models/templateEngine');
+
+var Api = require('../api');
+var Plugins = require('../plugins');
+
+var defaultBlocks = require('../constants/defaultBlocks');
+var defaultFilters = require('../constants/defaultFilters');
+
+/**
+ Create template engine for an output.
+ It adds default filters/blocks, then add the ones from plugins
+
+ @param {Output} output
+ @return {TemplateEngine}
+*/
+function createTemplateEngine(output) {
+ var plugins = output.getPlugins();
+ var book = output.getBook();
+ var rootFolder = book.getContentRoot();
+ var logger = book.getLogger();
+
+ var filters = Plugins.listFilters(plugins);
+ var blocks = Plugins.listBlocks(plugins);
+
+ // Extend with default
+ blocks = defaultBlocks.merge(blocks);
+ filters = defaultFilters.merge(filters);
+
+ // Create loader
+ var transformFn = Templating.replaceShortcuts.bind(null, blocks);
+ var loader = new Templating.ConrefsLoader(rootFolder, transformFn, logger);
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+ return new TemplateEngine({
+ filters: filters,
+ blocks: blocks,
+ loader: loader,
+ context: context
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/packages/gitbook/lib/output/ebook/getConvertOptions.js b/packages/gitbook/lib/output/ebook/getConvertOptions.js
new file mode 100644
index 0000000..bc80493
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/getConvertOptions.js
@@ -0,0 +1,73 @@
+var extend = require('extend');
+
+var Promise = require('../../utils/promise');
+var getPDFTemplate = require('./getPDFTemplate');
+var getCoverPath = require('./getCoverPath');
+
+/**
+ Generate options for ebook-convert
+
+ @param {Output}
+ @return {Promise<Object>}
+*/
+function getConvertOptions(output) {
+ var options = output.getOptions();
+ var format = options.get('format');
+
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ return Promise()
+ .then(function() {
+ var coverPath = getCoverPath(output);
+ var options = {
+ '--cover': coverPath,
+ '--title': config.getValue('title'),
+ '--comments': config.getValue('description'),
+ '--isbn': config.getValue('isbn'),
+ '--authors': config.getValue('author'),
+ '--language': book.getLanguage() || config.getValue('language'),
+ '--book-producer': 'GitBook',
+ '--publisher': 'GitBook',
+ '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
+ '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
+ '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
+ '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
+ '--max-levels': '1',
+ '--no-chapters-in-toc': true,
+ '--breadth-first': true,
+ '--dont-split-on-page-breaks': format === 'epub'? true : undefined
+ };
+
+ if (format !== 'pdf') {
+ return options;
+ }
+
+ return Promise.all([
+ getPDFTemplate(output, 'header'),
+ getPDFTemplate(output, 'footer')
+ ])
+ .spread(function(headerTpl, footerTpl) {
+ var pdfOptions = config.getValue('pdf').toJS();
+
+ return options = extend(options, {
+ '--chapter-mark': String(pdfOptions.chapterMark),
+ '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
+ '--margin-left': String(pdfOptions.margin.left),
+ '--margin-right': String(pdfOptions.margin.right),
+ '--margin-top': String(pdfOptions.margin.top),
+ '--margin-bottom': String(pdfOptions.margin.bottom),
+ '--pdf-default-font-size': String(pdfOptions.fontSize),
+ '--pdf-mono-font-size': String(pdfOptions.fontSize),
+ '--paper-size': String(pdfOptions.paperSize),
+ '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
+ '--pdf-sans-family': String(pdfOptions.fontFamily),
+ '--pdf-header-template': headerTpl,
+ '--pdf-footer-template': footerTpl
+ });
+ });
+ });
+}
+
+
+module.exports = getConvertOptions;
diff --git a/packages/gitbook/lib/output/ebook/getCoverPath.js b/packages/gitbook/lib/output/ebook/getCoverPath.js
new file mode 100644
index 0000000..ab6b579
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/getCoverPath.js
@@ -0,0 +1,30 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Resolve path to cover file to use
+
+ @param {Output}
+ @return {String}
+*/
+function getCoverPath(output) {
+ var outputRoot = output.getRoot();
+ var book = output.getBook();
+ var config = book.getConfig();
+ var coverName = config.getValue('cover', 'cover.jpg');
+
+ // Resolve to absolute
+ var cover = fs.pickFile(outputRoot, coverName);
+ if (cover) {
+ return cover;
+ }
+
+ // Multilingual? try parent folder
+ if (book.isLanguageBook()) {
+ cover = fs.pickFile(path.join(outputRoot, '..'), coverName);
+ }
+
+ return cover;
+}
+
+module.exports = getCoverPath;
diff --git a/packages/gitbook/lib/output/ebook/getPDFTemplate.js b/packages/gitbook/lib/output/ebook/getPDFTemplate.js
new file mode 100644
index 0000000..b767daf
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/getPDFTemplate.js
@@ -0,0 +1,41 @@
+var juice = require('juice');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+
+
+/**
+ Generate PDF header/footer templates
+
+ @param {Output} output
+ @param {String} type
+ @return {String}
+*/
+function getPDFTemplate(output, type) {
+ var filePath = 'pdf_' + type + '.html';
+ var outputRoot = output.getRoot();
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+
+ // Generate context
+ var context = JSONUtils.encodeOutput(output);
+ context.page = {
+ num: '_PAGENUM_',
+ title: '_SECTION_'
+ };
+
+ // Render the theme
+ return Templating.renderFile(engine, 'ebook/' + filePath, context)
+
+ // Inline css and assets
+ .then(function(tplOut) {
+ return Promise.nfcall(juice.juiceResources, tplOut.getContent(), {
+ webResources: {
+ relativeTo: outputRoot
+ }
+ });
+ });
+}
+
+module.exports = getPDFTemplate;
diff --git a/packages/gitbook/lib/output/ebook/index.js b/packages/gitbook/lib/output/ebook/index.js
new file mode 100644
index 0000000..786a10a
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/index.js
@@ -0,0 +1,9 @@
+var extend = require('extend');
+var WebsiteGenerator = require('../website');
+
+module.exports = extend({}, WebsiteGenerator, {
+ name: 'ebook',
+ Options: require('./options'),
+ onPage: require('./onPage'),
+ onFinish: require('./onFinish')
+});
diff --git a/packages/gitbook/lib/output/ebook/onFinish.js b/packages/gitbook/lib/output/ebook/onFinish.js
new file mode 100644
index 0000000..7f21548
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/onFinish.js
@@ -0,0 +1,91 @@
+var path = require('path');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+var error = require('../../utils/error');
+var command = require('../../utils/command');
+var writeFile = require('../helper/writeFile');
+
+var getConvertOptions = require('./getConvertOptions');
+var SUMMARY_FILE = 'SUMMARY.html';
+
+/**
+ Write the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function writeSummary(output) {
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ var filePath = SUMMARY_FILE;
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/summary.html', context)
+
+ // Write it to the disk
+ .then(function(tplOut) {
+ return writeFile(output, filePath, tplOut.getContent());
+ });
+}
+
+/**
+ Generate the ebook file as "index.pdf"
+
+ @param {Output}
+ @return {Output}
+*/
+function runEbookConvert(output) {
+ var logger = output.getLogger();
+ var options = output.getOptions();
+ var format = options.get('format');
+ var outputFolder = output.getRoot();
+
+ if (!format) {
+ return Promise(output);
+ }
+
+ return getConvertOptions(output)
+ .then(function(options) {
+ var cmd = [
+ 'ebook-convert',
+ path.resolve(outputFolder, SUMMARY_FILE),
+ path.resolve(outputFolder, 'index.' + format),
+ command.optionsToShellArgs(options)
+ ].join(' ');
+
+ return command.exec(cmd)
+ .progress(function(data) {
+ logger.debug(data);
+ })
+ .fail(function(err) {
+ if (err.code == 127) {
+ throw error.RequireInstallError({
+ cmd: 'ebook-convert',
+ install: 'Install it from Calibre: https://calibre-ebook.com'
+ });
+ }
+
+ throw error.EbookError(err);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Finish the generation, generates the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ return writeSummary(output)
+ .then(runEbookConvert);
+}
+
+module.exports = onFinish;
diff --git a/packages/gitbook/lib/output/ebook/onPage.js b/packages/gitbook/lib/output/ebook/onPage.js
new file mode 100644
index 0000000..b7b9b42
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/onPage.js
@@ -0,0 +1,24 @@
+var WebsiteGenerator = require('../website');
+var Modifiers = require('../modifiers');
+
+/**
+ Write a page for ebook output
+
+ @param {Output} output
+ @param {Output}
+*/
+function onPage(output, page) {
+ var options = output.getOptions();
+
+ // Inline assets
+ return Modifiers.modifyHTML(page, [
+ Modifiers.inlineAssets(options.get('root'), page.getFile().getPath())
+ ])
+
+ // Write page using website generator
+ .then(function(resultPage) {
+ return WebsiteGenerator.onPage(output, resultPage);
+ });
+}
+
+module.exports = onPage;
diff --git a/packages/gitbook/lib/output/ebook/options.js b/packages/gitbook/lib/output/ebook/options.js
new file mode 100644
index 0000000..ea7b8b4
--- /dev/null
+++ b/packages/gitbook/lib/output/ebook/options.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('ebook'),
+
+ // Format to generate using ebook-convert
+ format: String(),
+
+ // Force use of absolute urls ("index.html" instead of "/")
+ directoryIndex: Boolean(false)
+});
+
+module.exports = Options;
diff --git a/packages/gitbook/lib/output/generateAssets.js b/packages/gitbook/lib/output/generateAssets.js
new file mode 100644
index 0000000..7a6e104
--- /dev/null
+++ b/packages/gitbook/lib/output/generateAssets.js
@@ -0,0 +1,26 @@
+var Promise = require('../utils/promise');
+
+/**
+ Output all assets using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generateAssets(generator, output) {
+ var assets = output.getAssets();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onAsset) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(assets, function(out, assetFile) {
+ logger.debug.ln('copy asset "' + assetFile + '"');
+
+ return generator.onAsset(out, assetFile);
+ }, output);
+}
+
+module.exports = generateAssets;
diff --git a/packages/gitbook/lib/output/generateBook.js b/packages/gitbook/lib/output/generateBook.js
new file mode 100644
index 0000000..46712bd
--- /dev/null
+++ b/packages/gitbook/lib/output/generateBook.js
@@ -0,0 +1,193 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Output = require('../models/output');
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+
+var callHook = require('./callHook');
+var preparePlugins = require('./preparePlugins');
+var preparePages = require('./preparePages');
+var prepareAssets = require('./prepareAssets');
+var generateAssets = require('./generateAssets');
+var generatePages = require('./generatePages');
+
+/**
+ * Process an output to generate the book
+ *
+ * @param {Generator} generator
+ * @param {Output} output
+ * @return {Promise<Output>}
+ */
+function processOutput(generator, startOutput) {
+ return Promise(startOutput)
+ .then(preparePlugins)
+ .then(preparePages)
+ .then(prepareAssets)
+
+ .then(
+ callHook.bind(null,
+ 'config',
+ function(output) {
+ var book = output.getBook();
+ var config = book.getConfig();
+ var values = config.getValues();
+
+ return values.toJS();
+ },
+ function(output, result) {
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ config = config.updateValues(result);
+ book = book.set('config', config);
+ return output.set('book', book);
+ }
+ )
+ )
+
+ .then(
+ callHook.bind(null,
+ 'init',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onInit) {
+ return output;
+ }
+
+ return generator.onInit(output);
+ })
+
+ .then(generateAssets.bind(null, generator))
+ .then(generatePages.bind(null, generator))
+
+ .tap(function(output) {
+ var book = output.getBook();
+
+ if (!book.isMultilingual()) {
+ return;
+ }
+
+ var logger = book.getLogger();
+ var books = book.getBooks();
+ var outputRoot = output.getRoot();
+ var plugins = output.getPlugins();
+ var state = output.getState();
+ var options = output.getOptions();
+
+ return Promise.forEach(books, function(langBook) {
+ // Inherits plugins list, options and state
+ var langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage()));
+ var langOutput = new Output({
+ book: langBook,
+ options: langOptions,
+ state: state,
+ generator: generator.name,
+ plugins: plugins
+ });
+
+ logger.info.ln('');
+ logger.info.ln('generating language "' + langBook.getLanguage() + '"');
+ return processOutput(generator, langOutput);
+ });
+ })
+
+ .then(callHook.bind(null,
+ 'finish:before',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onFinish) {
+ return output;
+ }
+
+ return generator.onFinish(output);
+ })
+
+ .then(callHook.bind(null,
+ 'finish',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ );
+}
+
+/**
+ * Generate a book using a generator.
+ *
+ * The overall process is:
+ * 1. List and load plugins for this book
+ * 2. Call hook "config"
+ * 3. Call hook "init"
+ * 4. Initialize generator
+ * 5. List all assets and pages
+ * 6. Copy all assets to output
+ * 7. Generate all pages
+ * 8. Call hook "finish:before"
+ * 9. Finish generation
+ * 10. Call hook "finish"
+ *
+ *
+ * @param {Generator} generator
+ * @param {Book} book
+ * @param {Object} options
+ * @return {Promise<Output>}
+ */
+function generateBook(generator, book, options) {
+ options = generator.Options(options);
+ var state = generator.State? generator.State({}) : Immutable.Map();
+ var start = Date.now();
+
+ return Promise(
+ new Output({
+ book: book,
+ options: options,
+ state: state,
+ generator: generator.name
+ })
+ )
+
+ // Cleanup output folder
+ .then(function(output) {
+ var logger = output.getLogger();
+ var rootFolder = output.getRoot();
+
+ logger.debug.ln('cleanup folder "' + rootFolder + '"');
+ return fs.ensureFolder(rootFolder)
+ .thenResolve(output);
+ })
+
+ .then(processOutput.bind(null, generator))
+
+ // Log duration and end message
+ .then(function(output) {
+ var logger = output.getLogger();
+ var end = Date.now();
+ var duration = (end - start)/1000;
+
+ logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !');
+
+ return output;
+ });
+}
+
+module.exports = generateBook;
diff --git a/packages/gitbook/lib/output/generatePage.js b/packages/gitbook/lib/output/generatePage.js
new file mode 100644
index 0000000..090a870
--- /dev/null
+++ b/packages/gitbook/lib/output/generatePage.js
@@ -0,0 +1,79 @@
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var timing = require('../utils/timing');
+
+var Templating = require('../templating');
+var JSONUtils = require('../json');
+var createTemplateEngine = require('./createTemplateEngine');
+var callPageHook = require('./callPageHook');
+
+/**
+ * Prepare and generate HTML for a page
+ *
+ * @param {Output} output
+ * @param {Page} page
+ * @return {Promise<Page>}
+ */
+function generatePage(output, page) {
+ var book = output.getBook();
+ var engine = createTemplateEngine(output);
+
+ return timing.measure(
+ 'page.generate',
+ Promise(page)
+ .then(function(resultPage) {
+ var file = resultPage.getFile();
+ var filePath = file.getPath();
+ var parser = file.getParser();
+ var context = JSONUtils.encodeOutputWithPage(output, resultPage);
+
+ if (!parser) {
+ return Promise.reject(error.FileNotParsableError({
+ filename: filePath
+ }));
+ }
+
+ // Call hook "page:before"
+ return callPageHook('page:before', output, resultPage)
+
+ // Escape code blocks with raw tags
+ .then(function(currentPage) {
+ return parser.preparePage(currentPage.getContent());
+ })
+
+ // Render templating syntax
+ .then(function(content) {
+ var absoluteFilePath = path.join(book.getContentRoot(), filePath);
+ return Templating.render(engine, absoluteFilePath, content, context);
+ })
+
+ .then(function(output) {
+ var content = output.getContent();
+
+ return parser.parsePage(content)
+ .then(function(result) {
+ return output.setContent(result.content);
+ });
+ })
+
+ // Post processing for templating syntax
+ .then(function(output) {
+ return Templating.postRender(engine, output);
+ })
+
+ // Return new page
+ .then(function(content) {
+ return resultPage.set('content', content);
+ })
+
+ // Call final hook
+ .then(function(currentPage) {
+ return callPageHook('page', output, currentPage);
+ });
+ })
+ );
+}
+
+module.exports = generatePage;
diff --git a/packages/gitbook/lib/output/generatePages.js b/packages/gitbook/lib/output/generatePages.js
new file mode 100644
index 0000000..73c5c09
--- /dev/null
+++ b/packages/gitbook/lib/output/generatePages.js
@@ -0,0 +1,36 @@
+var Promise = require('../utils/promise');
+var generatePage = require('./generatePage');
+
+/**
+ Output all pages using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generatePages(generator, output) {
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onPage) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(pages, function(out, page) {
+ var file = page.getFile();
+
+ logger.debug.ln('generate page "' + file.getPath() + '"');
+
+ return generatePage(out, page)
+ .then(function(resultPage) {
+ return generator.onPage(out, resultPage);
+ })
+ .fail(function(err) {
+ logger.error.ln('error while generating page "' + file.getPath() + '":');
+ throw err;
+ });
+ }, output);
+}
+
+module.exports = generatePages;
diff --git a/packages/gitbook/lib/output/getModifiers.js b/packages/gitbook/lib/output/getModifiers.js
new file mode 100644
index 0000000..bb44e80
--- /dev/null
+++ b/packages/gitbook/lib/output/getModifiers.js
@@ -0,0 +1,73 @@
+var Modifiers = require('./modifiers');
+var resolveFileToURL = require('./helper/resolveFileToURL');
+var Api = require('../api');
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+var defaultBlocks = require('../constants/defaultBlocks');
+var fileToOutput = require('./helper/fileToOutput');
+
+var CODEBLOCK = 'code';
+
+/**
+ * Return default modifier to prepare a page for
+ * rendering.
+ *
+ * @return {Array<Modifier>}
+ */
+function getModifiers(output, page) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+ var glossary = book.getGlossary();
+ var file = page.getFile();
+
+ // Glossary entries
+ var entries = glossary.getEntries();
+ var glossaryFile = glossary.getFile();
+ var glossaryFilename = fileToOutput(output, glossaryFile.getPath());
+
+ // Current file path
+ var currentFilePath = file.getPath();
+
+ // Get TemplateBlock for highlighting
+ var blocks = Plugins.listBlocks(plugins);
+ var code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK);
+
+ // Current context
+ var context = Api.encodeGlobal(output);
+
+ return [
+ // Normalize IDs on headings
+ Modifiers.addHeadingId,
+
+ // Annotate text with glossary entries
+ Modifiers.annotateText.bind(null, entries, glossaryFilename),
+
+ // Resolve images
+ Modifiers.resolveImages.bind(null, currentFilePath),
+
+ // Resolve links (.md -> .html)
+ Modifiers.resolveLinks.bind(null,
+ currentFilePath,
+ resolveFileToURL.bind(null, output)
+ ),
+
+ // Highlight code blocks using "code" block
+ Modifiers.highlightCode.bind(null, function(lang, source) {
+ return Promise(code.applyBlock({
+ body: source,
+ kwargs: {
+ language: lang
+ }
+ }, context))
+ .then(function(result) {
+ if (result.html === false) {
+ return { text: result.body };
+ } else {
+ return { html: result.body };
+ }
+ });
+ })
+ ];
+}
+
+module.exports = getModifiers;
diff --git a/packages/gitbook/lib/output/helper/fileToOutput.js b/packages/gitbook/lib/output/helper/fileToOutput.js
new file mode 100644
index 0000000..361c6eb
--- /dev/null
+++ b/packages/gitbook/lib/output/helper/fileToOutput.js
@@ -0,0 +1,32 @@
+var path = require('path');
+
+var PathUtils = require('../../utils/path');
+var LocationUtils = require('../../utils/location');
+
+var OUTPUT_EXTENSION = '.html';
+
+/**
+ * Convert a filePath (absolute) to a filename for output
+ *
+ * @param {Output} output
+ * @param {String} filePath
+ * @return {String}
+ */
+function fileToOutput(output, filePath) {
+ var book = output.getBook();
+ var readme = book.getReadme();
+ var fileReadme = readme.getFile();
+
+ if (
+ path.basename(filePath, path.extname(filePath)) == 'README' ||
+ (fileReadme.exists() && filePath == fileReadme.getPath())
+ ) {
+ filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION);
+ } else {
+ filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToOutput;
diff --git a/packages/gitbook/lib/output/helper/fileToURL.js b/packages/gitbook/lib/output/helper/fileToURL.js
new file mode 100644
index 0000000..44ad2d8
--- /dev/null
+++ b/packages/gitbook/lib/output/helper/fileToURL.js
@@ -0,0 +1,31 @@
+var path = require('path');
+var LocationUtils = require('../../utils/location');
+
+var fileToOutput = require('./fileToOutput');
+
+/**
+ Convert a filePath (absolute) to an url (without hostname).
+ It returns an absolute path.
+
+ "README.md" -> "/"
+ "test/hello.md" -> "test/hello.html"
+ "test/README.md" -> "test/"
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function fileToURL(output, filePath) {
+ var options = output.getOptions();
+ var directoryIndex = options.get('directoryIndex');
+
+ filePath = fileToOutput(output, filePath);
+
+ if (directoryIndex && path.basename(filePath) == 'index.html') {
+ filePath = path.dirname(filePath) + '/';
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToURL;
diff --git a/packages/gitbook/lib/output/helper/index.js b/packages/gitbook/lib/output/helper/index.js
new file mode 100644
index 0000000..f8bc109
--- /dev/null
+++ b/packages/gitbook/lib/output/helper/index.js
@@ -0,0 +1,2 @@
+
+module.exports = {};
diff --git a/packages/gitbook/lib/output/helper/resolveFileToURL.js b/packages/gitbook/lib/output/helper/resolveFileToURL.js
new file mode 100644
index 0000000..3f52713
--- /dev/null
+++ b/packages/gitbook/lib/output/helper/resolveFileToURL.js
@@ -0,0 +1,26 @@
+var LocationUtils = require('../../utils/location');
+
+var fileToURL = require('./fileToURL');
+
+/**
+ * Resolve an absolute path (extracted from a link)
+ *
+ * @param {Output} output
+ * @param {String} filePath
+ * @return {String}
+ */
+function resolveFileToURL(output, filePath) {
+ // Convert /test.png -> test.png
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+
+ var page = output.getPage(filePath);
+
+ // if file is a page, return correct .html url
+ if (page) {
+ filePath = fileToURL(output, filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = resolveFileToURL;
diff --git a/packages/gitbook/lib/output/helper/writeFile.js b/packages/gitbook/lib/output/helper/writeFile.js
new file mode 100644
index 0000000..a6d4645
--- /dev/null
+++ b/packages/gitbook/lib/output/helper/writeFile.js
@@ -0,0 +1,23 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Write a file to the output folder
+
+ @param {Output} output
+ @param {String} filePath
+ @param {Buffer|String} content
+ @return {Promise}
+*/
+function writeFile(output, filePath, content) {
+ var rootFolder = output.getRoot();
+ filePath = path.join(rootFolder, filePath);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ return fs.writeFile(filePath, content);
+ })
+ .thenResolve(output);
+}
+
+module.exports = writeFile;
diff --git a/packages/gitbook/lib/output/index.js b/packages/gitbook/lib/output/index.js
new file mode 100644
index 0000000..9b8ec17
--- /dev/null
+++ b/packages/gitbook/lib/output/index.js
@@ -0,0 +1,24 @@
+var Immutable = require('immutable');
+
+var generators = Immutable.List([
+ require('./json'),
+ require('./website'),
+ require('./ebook')
+]);
+
+/**
+ Return a specific generator by its name
+
+ @param {String}
+ @return {Generator}
+*/
+function getGenerator(name) {
+ return generators.find(function(generator) {
+ return generator.name == name;
+ });
+}
+
+module.exports = {
+ generate: require('./generateBook'),
+ getGenerator: getGenerator
+};
diff --git a/packages/gitbook/lib/output/json/index.js b/packages/gitbook/lib/output/json/index.js
new file mode 100644
index 0000000..361da06
--- /dev/null
+++ b/packages/gitbook/lib/output/json/index.js
@@ -0,0 +1,7 @@
+
+module.exports = {
+ name: 'json',
+ Options: require('./options'),
+ onPage: require('./onPage'),
+ onFinish: require('./onFinish')
+};
diff --git a/packages/gitbook/lib/output/json/onFinish.js b/packages/gitbook/lib/output/json/onFinish.js
new file mode 100644
index 0000000..d41d778
--- /dev/null
+++ b/packages/gitbook/lib/output/json/onFinish.js
@@ -0,0 +1,47 @@
+var path = require('path');
+
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+var JSONUtils = require('../../json');
+
+/**
+ Finish the generation
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var outputRoot = output.getRoot();
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ // Get main language
+ var languages = book.getLanguages();
+ var mainLanguage = languages.getDefaultLanguage();
+
+ // Read the main JSON
+ return fs.readFile(path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), 'utf8')
+
+ // Extend the JSON
+ .then(function(content) {
+ var json = JSON.parse(content);
+
+ json.languages = JSONUtils.encodeLanguages(languages);
+
+ return json;
+ })
+
+ .then(function(json) {
+ return fs.writeFile(
+ path.resolve(outputRoot, 'README.json'),
+ JSON.stringify(json, null, 4)
+ );
+ })
+
+ .thenResolve(output);
+}
+
+module.exports = onFinish;
diff --git a/packages/gitbook/lib/output/json/onPage.js b/packages/gitbook/lib/output/json/onPage.js
new file mode 100644
index 0000000..2315ba0
--- /dev/null
+++ b/packages/gitbook/lib/output/json/onPage.js
@@ -0,0 +1,43 @@
+var JSONUtils = require('../../json');
+var PathUtils = require('../../utils/path');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+
+var JSON_VERSION = '3';
+
+/**
+ * Write a page as a json file
+ *
+ * @param {Output} output
+ * @param {Page} page
+ */
+function onPage(output, page) {
+ var file = page.getFile();
+ var readme = output.getBook().getReadme().getFile();
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the JSON
+ var json = JSONUtils.encodeBookWithPage(output.getBook(), resultPage);
+
+ // Delete some private properties
+ delete json.config;
+
+ // Specify JSON output version
+ json.version = JSON_VERSION;
+
+ // File path in the output folder
+ var filePath = file.getPath() == readme.getPath()? 'README.json' : file.getPath();
+ filePath = PathUtils.setExtension(filePath, '.json');
+
+ // Write it to the disk
+ return writeFile(
+ output,
+ filePath,
+ JSON.stringify(json, null, 4)
+ );
+ });
+}
+
+module.exports = onPage;
diff --git a/packages/gitbook/lib/output/json/options.js b/packages/gitbook/lib/output/json/options.js
new file mode 100644
index 0000000..79167b1
--- /dev/null
+++ b/packages/gitbook/lib/output/json/options.js
@@ -0,0 +1,8 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String()
+});
+
+module.exports = Options;
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js b/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js
new file mode 100644
index 0000000..a3b1d81
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/addHeadingId.js
@@ -0,0 +1,26 @@
+var cheerio = require('cheerio');
+var addHeadingId = require('../addHeadingId');
+
+describe('addHeadingId', function() {
+ it('should add an ID if none', function() {
+ var $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>');
+ });
+ });
+
+ it('should not change existing IDs', function() {
+ var $ = cheerio.load('<h1 id="awesome">Hello World</h1>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="awesome">Hello World</h1>');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js b/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js
new file mode 100644
index 0000000..67e7a10
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/annotateText.js
@@ -0,0 +1,46 @@
+var Immutable = require('immutable');
+var cheerio = require('cheerio');
+var GlossaryEntry = require('../../../models/glossaryEntry');
+var annotateText = require('../annotateText');
+
+describe('annotateText', function() {
+ var entries = Immutable.List([
+ GlossaryEntry({ name: 'Word' }),
+ GlossaryEntry({ name: 'Multiple Words' })
+ ]);
+
+ it('should annotate text', function() {
+ var $ = cheerio.load('<p>This is a word, and multiple words</p>');
+
+ annotateText(entries, 'GLOSSARY.md', $);
+
+ var links = $('a');
+ expect(links.length).toBe(2);
+
+ var word = $(links.get(0));
+ expect(word.attr('href')).toBe('/GLOSSARY.md#word');
+ expect(word.text()).toBe('word');
+ expect(word.hasClass('glossary-term')).toBeTruthy();
+
+ var words = $(links.get(1));
+ expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words');
+ expect(words.text()).toBe('multiple words');
+ expect(words.hasClass('glossary-term')).toBeTruthy();
+ });
+
+ it('should not annotate scripts', function() {
+ var $ = cheerio.load('<script>This is a word, and multiple words</script>');
+
+ annotateText(entries, 'GLOSSARY.md', $);
+ expect($('a').length).toBe(0);
+ });
+
+ it('should not annotate when has class "no-glossary"', function() {
+ var $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>');
+
+ annotateText(entries, 'GLOSSARY.md', $);
+ expect($('a').length).toBe(0);
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js b/packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js
new file mode 100644
index 0000000..bc1704d
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/fetchRemoteImages.js
@@ -0,0 +1,40 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+var URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png';
+
+describe('fetchRemoteImages', function() {
+ var dir;
+ var fetchRemoteImages = require('../fetchRemoteImages');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should download image file', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+
+ it('should download image file and replace with relative path', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'test/index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(path.join('test', src));
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js b/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js
new file mode 100644
index 0000000..75d9902
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/highlightCode.js
@@ -0,0 +1,60 @@
+var cheerio = require('cheerio');
+var Promise = require('../../../utils/promise');
+var highlightCode = require('../highlightCode');
+
+describe('highlightCode', function() {
+ function doHighlight(lang, code) {
+ return {
+ text: '' + (lang || '') + '$' + code
+ };
+ }
+
+ function doHighlightAsync(lang, code) {
+ return Promise()
+ .then(function() {
+ return doHighlight(lang, code);
+ });
+ }
+
+ it('should call it for normal code element', function() {
+ var $ = cheerio.load('<p>This is a <code>test</code></p>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('$test');
+ });
+ });
+
+ it('should call it for markdown code block', function() {
+ var $ = cheerio.load('<pre><code class="lang-js">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('js$test');
+ });
+ });
+
+ it('should call it for asciidoc code block', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+
+ it('should accept async highlighter', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlightAsync, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js b/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js
new file mode 100644
index 0000000..0073cff
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/inlinePng.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var inlinePng = require('../inlinePng');
+
+describe('inlinePng', function() {
+ var dir;
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write an inline PNG using data URI as a file', function() {
+ var $ = cheerio.load('<img alt="GitBook Logo 20x20" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUEAYAAADdGcFOAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAAF+klEQVRIDY3Wf5CVVR3H8c9z791fyI9dQwdQ4TTI7wEWnQZZAa/mJE4Z0OaKUuN1KoaykZxUGGHay+iIVFMoEYrUPhDCKEKW2ChT8dA0RCSxWi6EW3sYYpcfxq5C+4O9957O+7m7O/qHQ9/XzH1+nHuec57z8wkWTsKw0y6N/LxXN6KzTnEUHi8eP/l3YStSU/MdsYvBbGh8six2YXcbcgc++QkfTQkWz/81KtqDA0hlUoWnsX+5uxe5X365BB9my2bjrHNHccLk16BpS9CExjcmXMDbD6wehdyEjxbjz1uK1zn9qga6dcfnMLXeXY/qjuQqTF4W1MKke8ZgeNhjMCxMPIWSd4OF78C55CFI/1kF6WwXpMqjkAZ/CKniNDrCsmU4lE1YbPlgR2x7R39FF23D4mq3A1+Z35PGTNs1E1XhxcGQOh6HNPwXkK56BVJhOaRg/pvoHXNxHFw410B25EYE2RMvI0i/twFJvXcrFObykEa+DmnQGLwYqR0l2a6JqItaj8C/4E2QxtZCofkC8tF1t8HZc/fAZaLnIF2xEsoEtW1w7vBSSFtfhDTnCki9cSi81Ain1uko2Ld+Dmf2rkUq0/5t+PYbFtPQdkjzNiAXTWtDEF49FgkzJInAVPwNyhzcDOmrdZCm/Rn+ebWtcPs+/U24hmg2XL0rRkPPELh9R8fDtXR2oC/VuZbGaci79Ajkb6lZgfyYtyzy/X9s6T/pO/ZfN/RdNxxIwTWM2wbX8KVmuIaEqmKm6zEondwGpd0SyOy5DrJ//TFkX9kMhd3XQHbEVCSsm4OECV5HIv2p15CwfWPSntoHRbv2Q1HzSvSlSqZwATIuBxk/zZBOBbdB+u9hSKU3Q7pwAjInZkFm6U8hu7MSMqe/Dqn8fUj5GVCmpxK+4N/F1LMa0p5eSOPqIPP7NGSunAI/+R6GnzQzIBt8A1LC/QZ+6HwLst1rITv0n5CtXgSZ78yFTNkR+FdeDZneJkip3fAtsQ5Scilkek7CH9dAmjIWvkK7IXXOh6/IzZDNPQdZXR1TQmdjKv0ZfEu0YKDpNflpyG5aDtnRv8VAuu3dBV+huyBbvgdS97tQNLQc0mfugKy5Cb4BipPIXvsUpK5N8Mvao/Bd3QDZRH9Rrtj3Cl6FHwPFMLmNkKrj8BnHoT+XX6f2wl+XxFS4Ab7C72Dgf7bi+5DpTkNm8kQMpCs/BzIlz8LfPxnzLdh3EjwMX4GX4Ju4GNb9A1L7k/D3J8b6kv2LFCtmCmcgUzoJsr2z4MfwFsh87xikZefg188fYaAhpPUxm3ge/vFnYkoED0HqeQiyJYcwkNGWnoNv6s9C1p1Bf/389VYoCjohW7UfMms3wXdpBv7+FEiPLIHs4DIMNERUNhbSpY3wk6QOsqlCDVx2xCrInMpBmfNPQOnzKxBkkrugdOl9GKigSZZCUWIm/GqwDtLUI5D+WAOlb9wKP0YvQLbjZSjsaYaL/n0/FA3fDtnCGihK5UYjCK+ZDr+TDIKLdm2Fs1UOzo76F5wO74XSZj0S6d7RCMLkCshcXALZxaWQRjXDZQ62oRAdCeG/Ju5HELX2QFH3C0hkRy6GovyfwF58AoVbguOxyB2H7/I34Gf11yANnQSp7Vr4MbQH0vg7kbNNp5AM3UrIVDchnz56B1Jm573wW9gZSFVPwO/hefg5FsIvN09CchtQCIOFw/F5U8ii3CZn4cqo7C8YlXEPYkx9cacZl00+iwnprrtwVdj1Q/gXmAs/pu6LZc9XQOGgSvh19n2cDZN341g2EcfxTEGwH/RewqlMsUfbbWIGLjUG+j/j9nokD1beiOvLS5dhjr30Gu6ZnivgdtM/6VJvY1+6pBHbH+h9CX84vfMxNJtisYVFlys+WNCIZJNmIsjohlhNSQC3f8R55H+y/hjkN8GPR9ndCLJxT4/3n0Px51ay8XQnNrYfDJHf//Fc0oMrEZSeeQGJ7+Z+gKCgLbHNWgXnB9FlYt5JaN38JIINC95EakjtAqQeuUx21c5B6tEFf0fSfbEFQf28Z6D6y+X/H0jf40QQJhYwAAAAAElFTkSuQmCC"/>');
+
+ return inlinePng(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js b/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js
new file mode 100644
index 0000000..8904c11
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/resolveLinks.js
@@ -0,0 +1,104 @@
+var path = require('path');
+var cheerio = require('cheerio');
+var resolveLinks = require('../resolveLinks');
+
+describe('resolveLinks', function() {
+ function resolveFileBasic(href) {
+ return 'fakeDir/' + href;
+ }
+
+ function resolveFileCustom(href) {
+ if (path.extname(href) == '.md') {
+ return href.slice(0, -3) + '.html';
+ }
+
+ return href;
+ }
+
+ describe('Absolute path', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a></p>';
+
+ it('should resolve path starting by "/" in root directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('fakeDir/test/cool.md');
+ });
+ });
+
+ it('should resolve path starting by "/" in child directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afolder/hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('../fakeDir/test/cool.md');
+ });
+ });
+ });
+
+ describe('Anchor', function() {
+ it('should prevent anchors in resolution', function() {
+ var TEST = '<p>This is a <a href="test/cool.md#an-anchor"></a></p>';
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('test/cool.html#an-anchor');
+ });
+ });
+
+ it('should ignore pure anchor links', function() {
+ var TEST = '<p>This is a <a href="#an-anchor"></a></p>';
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('#an-anchor');
+ });
+ });
+ });
+
+ describe('Custom Resolver', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>';
+
+ it('should resolve path correctly for absolute path', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('test/cool.html');
+ });
+ });
+
+ it('should resolve path correctly for absolute path (2)', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afodler/hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('../test/cool.html');
+ });
+ });
+ });
+
+ describe('External link', function() {
+ var TEST = '<p>This is a <a href="http://www.github.com">external link</a></p>';
+
+ it('should have target="_blank" attribute', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('target')).toBe('_blank');
+ });
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js b/packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js
new file mode 100644
index 0000000..5fe9796
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/svgToImg.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+
+describe('svgToImg', function() {
+ var dir;
+ var svgToImg = require('../svgToImg');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write svg as a file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+
+ return svgToImg(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js b/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js
new file mode 100644
index 0000000..dbb3502
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/__tests__/svgToPng.js
@@ -0,0 +1,33 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+var svgToImg = require('../svgToImg');
+var svgToPng = require('../svgToPng');
+
+describe('svgToPng', function() {
+ var dir;
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write svg as png file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+ var fileName = 'index.html';
+
+ return svgToImg(dir.name, fileName, $)
+ .then(function() {
+ return svgToPng(dir.name, fileName, $);
+ })
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ expect(path.extname(src)).toBe('.png');
+ });
+ });
+});
+
+
diff --git a/packages/gitbook/lib/output/modifiers/addHeadingId.js b/packages/gitbook/lib/output/modifiers/addHeadingId.js
new file mode 100644
index 0000000..e2e2720
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/addHeadingId.js
@@ -0,0 +1,23 @@
+var slug = require('github-slugid');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Add ID to an heading
+
+ @param {HTMLElement} heading
+*/
+function addId(heading) {
+ if (heading.attr('id')) return;
+ heading.attr('id', slug(heading.text()));
+}
+
+/**
+ Add ID to all headings
+
+ @param {HTMLDom} $
+*/
+function addHeadingId($) {
+ return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId);
+}
+
+module.exports = addHeadingId;
diff --git a/packages/gitbook/lib/output/modifiers/annotateText.js b/packages/gitbook/lib/output/modifiers/annotateText.js
new file mode 100644
index 0000000..490c228
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/annotateText.js
@@ -0,0 +1,94 @@
+var escape = require('escape-html');
+
+// Selector to ignore
+var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6';
+
+function pregQuote( str ) {
+ return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
+}
+
+function replaceText($, el, search, replace, text_only ) {
+ return $(el).each(function(){
+ var node = this.firstChild,
+ val,
+ new_val,
+
+ // Elements to be removed at the end.
+ remove = [];
+
+ // Only continue if firstChild exists.
+ if ( node ) {
+
+ // Loop over all childNodes.
+ while (node) {
+
+ // Only process text nodes.
+ if ( node.nodeType === 3 ) {
+
+ // The original node value.
+ val = node.nodeValue;
+
+ // The new value.
+ new_val = val.replace( search, replace );
+
+ // Only replace text if the new value is actually different!
+ if ( new_val !== val ) {
+
+ if ( !text_only && /</.test( new_val ) ) {
+ // The new value contains HTML, set it in a slower but far more
+ // robust way.
+ $(node).before( new_val );
+
+ // Don't remove the node yet, or the loop will lose its place.
+ remove.push( node );
+ } else {
+ // The new value contains no HTML, so it can be set in this
+ // very fast, simple way.
+ node.nodeValue = new_val;
+ }
+ }
+ }
+
+ node = node.nextSibling;
+ }
+ }
+
+ // Time to remove those elements!
+ if (remove.length) $(remove).remove();
+ });
+}
+
+/**
+ * Annotate text using a list of GlossaryEntry
+ *
+ * @param {List<GlossaryEntry>}
+ * @param {String} glossaryFilePath
+ * @param {HTMLDom} $
+ */
+function annotateText(entries, glossaryFilePath, $) {
+ entries.forEach(function(entry) {
+ var entryId = entry.getID();
+ var name = entry.getName();
+ var description = entry.getDescription();
+ var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' );
+
+ $('*').each(function() {
+ var $this = $(this);
+
+ if (
+ $this.is(ANNOTATION_IGNORE) ||
+ $this.parents(ANNOTATION_IGNORE).length > 0
+ ) return;
+
+ replaceText($, this, searchRegex, function(match) {
+ return '<a href="/' + glossaryFilePath + '#' + entryId + '" '
+ + 'class="glossary-term" title="' + escape(description) + '">'
+ + match
+ + '</a>';
+ });
+ });
+
+ });
+}
+
+module.exports = annotateText;
diff --git a/packages/gitbook/lib/output/modifiers/editHTMLElement.js b/packages/gitbook/lib/output/modifiers/editHTMLElement.js
new file mode 100644
index 0000000..755598e
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/editHTMLElement.js
@@ -0,0 +1,15 @@
+var Promise = require('../../utils/promise');
+
+/**
+ Edit all elements matching a selector
+*/
+function editHTMLElement($, selector, fn) {
+ var $elements = $(selector);
+
+ return Promise.forEach($elements, function(el) {
+ var $el = $(el);
+ return fn($el);
+ });
+}
+
+module.exports = editHTMLElement;
diff --git a/packages/gitbook/lib/output/modifiers/fetchRemoteImages.js b/packages/gitbook/lib/output/modifiers/fetchRemoteImages.js
new file mode 100644
index 0000000..ef868b9
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/fetchRemoteImages.js
@@ -0,0 +1,44 @@
+var path = require('path');
+var crc = require('crc');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Fetch all remote images
+
+ @param {String} rootFolder
+ @param {String} currentFile
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function fetchRemoteImages(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ var extension = path.extname(src);
+
+ if (!LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + extension;
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return fs.download(src, filePath);
+ })
+ .then(function() {
+ // Convert to relative
+ src = LocationUtils.relative(currentDirectory, fileName);
+
+ $img.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = fetchRemoteImages;
diff --git a/packages/gitbook/lib/output/modifiers/highlightCode.js b/packages/gitbook/lib/output/modifiers/highlightCode.js
new file mode 100644
index 0000000..5d397bb
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/highlightCode.js
@@ -0,0 +1,58 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var Promise = require('../../utils/promise');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Return language for a code blocks from a list of class names
+
+ @param {Array<String>}
+ @return {String}
+*/
+function getLanguageForClass(classNames) {
+ return Immutable.List(classNames)
+ .map(function(cl) {
+ // Markdown
+ if (cl.search('lang-') === 0) {
+ return cl.slice('lang-'.length);
+ }
+
+ // Asciidoc
+ if (cl.search('language-') === 0) {
+ return cl.slice('language-'.length);
+ }
+
+ return null;
+ })
+ .find(function(cl) {
+ return Boolean(cl);
+ });
+}
+
+
+/**
+ Highlight all code elements
+
+ @param {Function(lang, body) -> String} highlight
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function highlightCode(highlight, $) {
+ return editHTMLElement($, 'code', function($code) {
+ var classNames = ($code.attr('class') || '').split(' ');
+ var lang = getLanguageForClass(classNames);
+ var source = $code.text();
+
+ return Promise(highlight(lang, source))
+ .then(function(r) {
+ if (is.string(r.html)) {
+ $code.html(r.html);
+ } else {
+ $code.text(r.text);
+ }
+ });
+ });
+}
+
+module.exports = highlightCode;
diff --git a/packages/gitbook/lib/output/modifiers/index.js b/packages/gitbook/lib/output/modifiers/index.js
new file mode 100644
index 0000000..f1daa2b
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/index.js
@@ -0,0 +1,15 @@
+
+module.exports = {
+ modifyHTML: require('./modifyHTML'),
+ inlineAssets: require('./inlineAssets'),
+
+ // HTML transformations
+ addHeadingId: require('./addHeadingId'),
+ svgToImg: require('./svgToImg'),
+ fetchRemoteImages: require('./fetchRemoteImages'),
+ svgToPng: require('./svgToPng'),
+ resolveLinks: require('./resolveLinks'),
+ resolveImages: require('./resolveImages'),
+ annotateText: require('./annotateText'),
+ highlightCode: require('./highlightCode')
+};
diff --git a/packages/gitbook/lib/output/modifiers/inlineAssets.js b/packages/gitbook/lib/output/modifiers/inlineAssets.js
new file mode 100644
index 0000000..7cd874b
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/inlineAssets.js
@@ -0,0 +1,29 @@
+var svgToImg = require('./svgToImg');
+var svgToPng = require('./svgToPng');
+var inlinePng = require('./inlinePng');
+var resolveImages = require('./resolveImages');
+var fetchRemoteImages = require('./fetchRemoteImages');
+
+var Promise = require('../../utils/promise');
+
+/**
+ Inline all assets in a page
+
+ @param {String} rootFolder
+*/
+function inlineAssets(rootFolder, currentFile) {
+ return function($) {
+ return Promise()
+
+ // Resolving images and fetching external images should be
+ // done before svg conversion
+ .then(resolveImages.bind(null, currentFile, $))
+ .then(fetchRemoteImages.bind(null, rootFolder, currentFile, $))
+
+ .then(svgToImg.bind(null, rootFolder, currentFile, $))
+ .then(svgToPng.bind(null, rootFolder, currentFile, $))
+ .then(inlinePng.bind(null, rootFolder, currentFile, $));
+ };
+}
+
+module.exports = inlineAssets;
diff --git a/packages/gitbook/lib/output/modifiers/inlinePng.js b/packages/gitbook/lib/output/modifiers/inlinePng.js
new file mode 100644
index 0000000..161f164
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/inlinePng.js
@@ -0,0 +1,47 @@
+var crc = require('crc');
+var path = require('path');
+
+var imagesUtil = require('../../utils/images');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Convert all inline PNG images to PNG file
+
+ @param {String} rootFolder
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function inlinePng(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ if (!LocationUtils.isDataURI(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + '.png';
+
+ // Result file path
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return imagesUtil.convertInlinePNG(src, filePath);
+ })
+ .then(function() {
+ // Convert filename to a relative filename
+ fileName = LocationUtils.relative(currentDirectory, fileName);
+
+ // Replace src
+ $img.attr('src', fileName);
+ });
+ });
+}
+
+
+module.exports = inlinePng;
diff --git a/packages/gitbook/lib/output/modifiers/modifyHTML.js b/packages/gitbook/lib/output/modifiers/modifyHTML.js
new file mode 100644
index 0000000..cd3d6e5
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/modifyHTML.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var Promise = require('../../utils/promise');
+
+/**
+ Apply a list of operations to a page and
+ output the new page.
+
+ @param {Page}
+ @param {List|Array<Transformation>}
+ @return {Promise<Page>}
+*/
+function modifyHTML(page, operations) {
+ var html = page.getContent();
+ var $ = cheerio.load(html);
+
+ return Promise.forEach(operations, function(op) {
+ return op($);
+ })
+ .then(function() {
+ var resultHTML = $.html();
+ return page.set('content', resultHTML);
+ });
+}
+
+module.exports = modifyHTML;
diff --git a/packages/gitbook/lib/output/modifiers/resolveImages.js b/packages/gitbook/lib/output/modifiers/resolveImages.js
new file mode 100644
index 0000000..cc25cfa
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/resolveImages.js
@@ -0,0 +1,33 @@
+var path = require('path');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML images:
+ - /test.png in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {HTMLDom} $
+*/
+function resolveImages(currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+
+ if (LocationUtils.isExternal(src) || LocationUtils.isDataURI(src)) {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // Convert back to relative
+ src = LocationUtils.relative(currentDirectory, src);
+
+ $img.attr('src', src);
+ });
+}
+
+module.exports = resolveImages;
diff --git a/packages/gitbook/lib/output/modifiers/resolveLinks.js b/packages/gitbook/lib/output/modifiers/resolveLinks.js
new file mode 100644
index 0000000..9d15e5e
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/resolveLinks.js
@@ -0,0 +1,53 @@
+var path = require('path');
+var url = require('url');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML links:
+ - /test.md in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {Function(String) -> String} resolveFile
+ @param {HTMLDom} $
+*/
+function resolveLinks(currentFile, resolveFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'a', function($a) {
+ var href = $a.attr('href');
+
+ // Don't change a tag without href
+ if (!href) {
+ return;
+ }
+
+ if (LocationUtils.isExternal(href)) {
+ $a.attr('target', '_blank');
+ return;
+ }
+
+ // Split anchor
+ var parsed = url.parse(href);
+ href = parsed.pathname || '';
+
+ if (href) {
+ // Calcul absolute path for this
+ href = LocationUtils.toAbsolute(href, currentDirectory, '.');
+
+ // Resolve file
+ href = resolveFile(href);
+
+ // Convert back to relative
+ href = LocationUtils.relative(currentDirectory, href);
+ }
+
+ // Add back anchor
+ href = href + (parsed.hash || '');
+
+ $a.attr('href', href);
+ });
+}
+
+module.exports = resolveLinks;
diff --git a/packages/gitbook/lib/output/modifiers/svgToImg.js b/packages/gitbook/lib/output/modifiers/svgToImg.js
new file mode 100644
index 0000000..f31b06d
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/svgToImg.js
@@ -0,0 +1,56 @@
+var path = require('path');
+var crc = require('crc');
+var domSerializer = require('dom-serializer');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Render a cheerio DOM as html
+
+ @param {HTMLDom} $
+ @param {HTMLElement} dom
+ @param {Object}
+ @return {String}
+*/
+function renderDOM($, dom, options) {
+ if (!dom && $._root && $._root.children) {
+ dom = $._root.children;
+ }
+ options = options|| dom.options || $._options;
+ return domSerializer(dom, options);
+}
+
+/**
+ Replace SVG tag by IMG
+
+ @param {String} baseFolder
+ @param {HTMLDom} $
+*/
+function svgToImg(baseFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'svg', function($svg) {
+ var content = '<?xml version="1.0" encoding="UTF-8"?>' +
+ renderDOM($, $svg);
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(content).toString(16);
+ var fileName = hash + '.svg';
+ var filePath = path.join(baseFolder, fileName);
+
+ // Write the svg to the file
+ return fs.assertFile(filePath, function() {
+ return fs.writeFile(filePath, content, 'utf8');
+ })
+
+ // Return as image
+ .then(function() {
+ var src = LocationUtils.relative(currentDirectory, fileName);
+ $svg.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = svgToImg;
diff --git a/packages/gitbook/lib/output/modifiers/svgToPng.js b/packages/gitbook/lib/output/modifiers/svgToPng.js
new file mode 100644
index 0000000..1093106
--- /dev/null
+++ b/packages/gitbook/lib/output/modifiers/svgToPng.js
@@ -0,0 +1,53 @@
+var crc = require('crc');
+var path = require('path');
+
+var imagesUtil = require('../../utils/images');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Convert all SVG images to PNG
+
+ @param {String} rootFolder
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function svgToPng(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ if (path.extname(src) !== '.svg') {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + '.png';
+
+ // Input file path
+ var inputPath = path.join(rootFolder, src);
+
+ // Result file path
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return imagesUtil.convertSVGToPNG(inputPath, filePath);
+ })
+ .then(function() {
+ // Convert filename to a relative filename
+ fileName = LocationUtils.relative(currentDirectory, fileName);
+
+ // Replace src
+ $img.attr('src', fileName);
+ });
+ });
+}
+
+
+module.exports = svgToPng;
diff --git a/packages/gitbook/lib/output/prepareAssets.js b/packages/gitbook/lib/output/prepareAssets.js
new file mode 100644
index 0000000..ae9b55a
--- /dev/null
+++ b/packages/gitbook/lib/output/prepareAssets.js
@@ -0,0 +1,22 @@
+var Parse = require('../parse');
+
+/**
+ List all assets in the book
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareAssets(output) {
+ var book = output.getBook();
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ return Parse.listAssets(book, pages)
+ .then(function(assets) {
+ logger.info.ln('found', assets.size, 'asset files');
+
+ return output.set('assets', assets);
+ });
+}
+
+module.exports = prepareAssets;
diff --git a/packages/gitbook/lib/output/preparePages.js b/packages/gitbook/lib/output/preparePages.js
new file mode 100644
index 0000000..83944ed
--- /dev/null
+++ b/packages/gitbook/lib/output/preparePages.js
@@ -0,0 +1,26 @@
+var Parse = require('../parse');
+var Promise = require('../utils/promise');
+
+/**
+ List and prepare all pages
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function preparePages(output) {
+ var book = output.getBook();
+ var logger = book.getLogger();
+
+ if (book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ return Parse.parsePagesList(book)
+ .then(function(pages) {
+ logger.info.ln('found', pages.size, 'pages');
+
+ return output.set('pages', pages);
+ });
+}
+
+module.exports = preparePages;
diff --git a/packages/gitbook/lib/output/preparePlugins.js b/packages/gitbook/lib/output/preparePlugins.js
new file mode 100644
index 0000000..5c4be93
--- /dev/null
+++ b/packages/gitbook/lib/output/preparePlugins.js
@@ -0,0 +1,36 @@
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+
+/**
+ * Load and setup plugins
+ *
+ * @param {Output}
+ * @return {Promise<Output>}
+ */
+function preparePlugins(output) {
+ var book = output.getBook();
+
+ return Promise()
+
+ // Only load plugins for main book
+ .then(function() {
+ if (book.isLanguageBook()) {
+ return output.getPlugins();
+ } else {
+ return Plugins.loadForBook(book);
+ }
+ })
+
+ // Update book's configuration using the plugins
+ .then(function(plugins) {
+ return Plugins.validateConfig(book, plugins)
+ .then(function(newBook) {
+ return output.merge({
+ book: newBook,
+ plugins: plugins
+ });
+ });
+ });
+}
+
+module.exports = preparePlugins;
diff --git a/packages/gitbook/lib/output/website/__tests__/i18n.js b/packages/gitbook/lib/output/website/__tests__/i18n.js
new file mode 100644
index 0000000..fd610fb
--- /dev/null
+++ b/packages/gitbook/lib/output/website/__tests__/i18n.js
@@ -0,0 +1,38 @@
+var createMockOutput = require('../../__tests__/createMock');
+var prepareI18n = require('../prepareI18n');
+var createTemplateEngine = require('../createTemplateEngine');
+
+var WebsiteGenerator = require('../');
+
+describe('i18n', function() {
+ it('should correctly use english as default language', function() {
+ return createMockOutput(WebsiteGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(output) {
+ return prepareI18n(output);
+ })
+ .then(function(output) {
+ var engine = createTemplateEngine(output, 'README.md');
+ var t = engine.getFilters().get('t');
+
+ expect(t('SUMMARY_INTRODUCTION')).toEqual('Introduction');
+ });
+ });
+
+ it('should correctly use language from book.json', function() {
+ return createMockOutput(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'book.json': JSON.stringify({ language: 'fr' })
+ })
+ .then(function(output) {
+ return prepareI18n(output);
+ })
+ .then(function(output) {
+ var engine = createTemplateEngine(output, 'README.md');
+ var t = engine.getFilters().get('t');
+
+ expect(t('GITBOOK_LINK')).toEqual('Publié avec GitBook');
+ });
+ });
+});
diff --git a/packages/gitbook/lib/output/website/copyPluginAssets.js b/packages/gitbook/lib/output/website/copyPluginAssets.js
new file mode 100644
index 0000000..9150636
--- /dev/null
+++ b/packages/gitbook/lib/output/website/copyPluginAssets.js
@@ -0,0 +1,117 @@
+var path = require('path');
+
+var ASSET_FOLDER = require('../../constants/pluginAssetsFolder');
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+
+/**
+ Copy all assets from plugins.
+ Assets are files stored in "_assets"
+ nd resources declared in the plugin itself.
+
+ @param {Output}
+ @return {Promise}
+*/
+function copyPluginAssets(output) {
+ var book = output.getBook();
+
+ // Don't copy plugins assets for language book
+ // It'll be resolved to the parent folder
+ if (book.isLanguageBook()) {
+ return Promise(output);
+ }
+
+ var plugins = output.getPlugins()
+
+ // We reverse the order of plugins to copy
+ // so that first plugins can replace assets from other plugins.
+ .reverse();
+
+ return Promise.forEach(plugins, function(plugin) {
+ return copyAssets(output, plugin)
+ .then(function() {
+ return copyResources(output, plugin);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Copy assets from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyAssets(output, plugin) {
+ var logger = output.getLogger();
+ var pluginRoot = plugin.getPath();
+ var options = output.getOptions();
+
+ var outputRoot = options.get('root');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook');
+ var prefix = options.get('prefix');
+
+ var assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix);
+
+ if (!fs.existsSync(assetFolder)) {
+ return Promise();
+ }
+
+ logger.debug.ln('copy assets from theme', assetFolder);
+ return fs.copyDir(
+ assetFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+/**
+ Copy resources from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyResources(output, plugin) {
+ var logger = output.getLogger();
+
+ var options = output.getOptions();
+ var outputRoot = options.get('root');
+
+ var state = output.getState();
+ var resources = state.getResources();
+
+ var pluginRoot = plugin.getPath();
+ var pluginResources = resources.get(plugin.getName());
+
+ var assetsFolder = pluginResources.get('assets');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getNpmID());
+
+ if (!assetsFolder) {
+ return Promise();
+ }
+
+ // Resolve assets folder
+ assetsFolder = path.resolve(pluginRoot, assetsFolder);
+ if (!fs.existsSync(assetsFolder)) {
+ logger.warn.ln('assets folder for plugin "' + plugin.getName() + '" doesn\'t exist');
+ return Promise();
+ }
+
+ logger.debug.ln('copy resources from plugin', assetsFolder);
+
+ return fs.copyDir(
+ assetsFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+module.exports = copyPluginAssets;
diff --git a/packages/gitbook/lib/output/website/createTemplateEngine.js b/packages/gitbook/lib/output/website/createTemplateEngine.js
new file mode 100644
index 0000000..02ec796
--- /dev/null
+++ b/packages/gitbook/lib/output/website/createTemplateEngine.js
@@ -0,0 +1,151 @@
+var path = require('path');
+var nunjucks = require('nunjucks');
+var DoExtension = require('nunjucks-do')(nunjucks);
+
+var Api = require('../../api');
+var deprecate = require('../../api/deprecate');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var fs = require('../../utils/fs');
+var PathUtils = require('../../utils/path');
+var TemplateEngine = require('../../models/templateEngine');
+var templatesFolder = require('../../constants/templatesFolder');
+var defaultFilters = require('../../constants/defaultFilters');
+var Templating = require('../../templating');
+var listSearchPaths = require('./listSearchPaths');
+
+var fileToURL = require('../helper/fileToURL');
+var resolveFileToURL = require('../helper/resolveFileToURL');
+
+/**
+ * Directory for a theme with the templates
+ */
+function templateFolder(dir) {
+ return path.join(dir, templatesFolder);
+}
+
+/**
+ * Create templating engine to render themes
+ *
+ * @param {Output} output
+ * @param {String} currentFile
+ * @return {TemplateEngine}
+ */
+function createTemplateEngine(output, currentFile) {
+ var book = output.getBook();
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var config = book.getConfig();
+ var summary = book.getSummary();
+ var outputFolder = output.getRoot();
+
+ // Search paths for templates
+ var searchPaths = listSearchPaths(output);
+ var tplSearchPaths = searchPaths.map(templateFolder);
+
+ // Create loader
+ var loader = new Templating.ThemesLoader(tplSearchPaths);
+
+ // Get languages
+ var language = config.getValue('language');
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+
+ /**
+ * Check if a file exists
+ * @param {String} fileName
+ * @return {Boolean}
+ */
+ function fileExists(fileName) {
+ if (!fileName) {
+ return false;
+ }
+
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+ return fs.existsSync(filePath);
+ }
+
+ /**
+ * Return an article by its path
+ * @param {String} filePath
+ * @return {Object|undefined}
+ */
+ function getArticleByPath(filePath) {
+ var article = summary.getByPath(filePath);
+ if (!article) return undefined;
+
+ return JSONUtils.encodeSummaryArticle(article);
+ }
+
+ /**
+ * Return a page by its path
+ * @param {String} filePath
+ * @return {Object|undefined}
+ */
+ function getPageByPath(filePath) {
+ var page = output.getPage(filePath);
+ if (!page) return undefined;
+
+ return JSONUtils.encodePage(page, summary);
+ }
+
+ return TemplateEngine.create({
+ loader: loader,
+
+ context: context,
+
+ globals: {
+ getArticleByPath: getArticleByPath,
+ getPageByPath: getPageByPath,
+ fileExists: fileExists
+ },
+
+ filters: defaultFilters.merge({
+ /**
+ * Translate a sentence
+ */
+ t: function t(s) {
+ return i18n.t(language, s);
+ },
+
+ /**
+ * Resolve an absolute file path into a
+ * relative path.
+ * it also resolve pages
+ */
+ resolveFile: function(filePath) {
+ filePath = resolveFileToURL(output, filePath);
+ return LocationUtils.relativeForFile(currentFile, filePath);
+ },
+
+ resolveAsset: function(filePath) {
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+ filePath = path.join('gitbook', filePath);
+ filePath = LocationUtils.relativeForFile(currentFile, filePath);
+
+ // Use assets from parent if language book
+ if (book.isLanguageBook()) {
+ filePath = path.join('../', filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+ },
+
+
+ fileExists: deprecate.method(book, 'fileExists', fileExists, 'Filter "fileExists" is deprecated, use "fileExists(filename)" '),
+ getArticleByPath: deprecate.method(book, 'getArticleByPath', fileExists, 'Filter "getArticleByPath" is deprecated, use "getArticleByPath(filename)" '),
+
+ contentURL: function(filePath) {
+ return fileToURL(output, filePath);
+ }
+ }),
+
+ extensions: {
+ 'DoExtension': new DoExtension()
+ }
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/packages/gitbook/lib/output/website/index.js b/packages/gitbook/lib/output/website/index.js
new file mode 100644
index 0000000..7818a28
--- /dev/null
+++ b/packages/gitbook/lib/output/website/index.js
@@ -0,0 +1,11 @@
+
+module.exports = {
+ name: 'website',
+ State: require('./state'),
+ Options: require('./options'),
+ onInit: require('./onInit'),
+ onFinish: require('./onFinish'),
+ onPage: require('./onPage'),
+ onAsset: require('./onAsset'),
+ createTemplateEngine: require('./createTemplateEngine')
+};
diff --git a/packages/gitbook/lib/output/website/listSearchPaths.js b/packages/gitbook/lib/output/website/listSearchPaths.js
new file mode 100644
index 0000000..c45f39c
--- /dev/null
+++ b/packages/gitbook/lib/output/website/listSearchPaths.js
@@ -0,0 +1,23 @@
+
+/**
+ List search paths for templates / i18n, etc
+
+ @param {Output} output
+ @return {List<String>}
+*/
+function listSearchPaths(output) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+
+ var searchPaths = plugins
+ .valueSeq()
+ .map(function(plugin) {
+ return plugin.getPath();
+ })
+ .toList();
+
+ return searchPaths.unshift(book.getContentRoot());
+}
+
+
+module.exports = listSearchPaths;
diff --git a/packages/gitbook/lib/output/website/onAsset.js b/packages/gitbook/lib/output/website/onAsset.js
new file mode 100644
index 0000000..69dfc4f
--- /dev/null
+++ b/packages/gitbook/lib/output/website/onAsset.js
@@ -0,0 +1,28 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Copy an asset to the output folder
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onAsset(output, asset) {
+ var book = output.getBook();
+ var options = output.getOptions();
+ var bookFS = book.getContentFS();
+
+ var outputFolder = options.get('root');
+ var outputPath = path.resolve(outputFolder, asset);
+
+ return fs.ensureFile(outputPath)
+ .then(function() {
+ return bookFS.readAsStream(asset)
+ .then(function(stream) {
+ return fs.writeStream(outputPath, stream);
+ });
+ })
+ .thenResolve(output);
+}
+
+module.exports = onAsset;
diff --git a/packages/gitbook/lib/output/website/onFinish.js b/packages/gitbook/lib/output/website/onFinish.js
new file mode 100644
index 0000000..5267458
--- /dev/null
+++ b/packages/gitbook/lib/output/website/onFinish.js
@@ -0,0 +1,35 @@
+var Promise = require('../../utils/promise');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var writeFile = require('../helper/writeFile');
+var createTemplateEngine = require('./createTemplateEngine');
+
+/**
+ Finish the generation, write the languages index
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ var filePath = 'index.html';
+ var engine = createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/languages.html', context)
+
+ // Write it to the disk
+ .then(function(tplOut) {
+ return writeFile(output, filePath, tplOut.getContent());
+ });
+}
+
+module.exports = onFinish;
diff --git a/packages/gitbook/lib/output/website/onInit.js b/packages/gitbook/lib/output/website/onInit.js
new file mode 100644
index 0000000..3465eef
--- /dev/null
+++ b/packages/gitbook/lib/output/website/onInit.js
@@ -0,0 +1,20 @@
+var Promise = require('../../utils/promise');
+
+var copyPluginAssets = require('./copyPluginAssets');
+var prepareI18n = require('./prepareI18n');
+var prepareResources = require('./prepareResources');
+
+/**
+ Initialize the generator
+
+ @param {Output}
+ @return {Output}
+*/
+function onInit(output) {
+ return Promise(output)
+ .then(prepareI18n)
+ .then(prepareResources)
+ .then(copyPluginAssets);
+}
+
+module.exports = onInit;
diff --git a/packages/gitbook/lib/output/website/onPage.js b/packages/gitbook/lib/output/website/onPage.js
new file mode 100644
index 0000000..5fb40a7
--- /dev/null
+++ b/packages/gitbook/lib/output/website/onPage.js
@@ -0,0 +1,76 @@
+var path = require('path');
+var omit = require('omit-keys');
+
+var Templating = require('../../templating');
+var Plugins = require('../../plugins');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+var createTemplateEngine = require('./createTemplateEngine');
+var fileToOutput = require('../helper/fileToOutput');
+
+/**
+ * Write a page as a json file
+ *
+ * @param {Output} output
+ * @param {Page} page
+ */
+function onPage(output, page) {
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ var file = page.getFile();
+
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+ var state = output.getState();
+ var resources = state.getResources();
+
+ var engine = createTemplateEngine(output, page.getPath());
+
+ // Output file path
+ var filePath = fileToOutput(output, file.getPath());
+
+ // Calcul relative path to the root
+ var outputDirName = path.dirname(filePath);
+ var basePath = LocationUtils.normalize(path.relative(outputDirName, './'));
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the context
+ var context = JSONUtils.encodeOutputWithPage(output, resultPage);
+ context.plugins = {
+ resources: Plugins.listResources(plugins, resources).toJS()
+ };
+
+ context.template = {
+ getJSContext: function() {
+ return {
+ page: omit(context.page, 'content'),
+ config: context.config,
+ file: context.file,
+ gitbook: context.gitbook,
+ basePath: basePath,
+ book: {
+ language: book.getLanguage()
+ }
+ };
+ }
+ };
+
+ // We should probabbly move it to "template" or a "site" namespace
+ context.basePath = basePath;
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/page.html', context)
+
+ // Write it to the disk
+ .then(function(tplOut) {
+ return writeFile(output, filePath, tplOut.getContent());
+ });
+ });
+}
+
+module.exports = onPage;
diff --git a/packages/gitbook/lib/output/website/options.js b/packages/gitbook/lib/output/website/options.js
new file mode 100644
index 0000000..ac9cdad
--- /dev/null
+++ b/packages/gitbook/lib/output/website/options.js
@@ -0,0 +1,14 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('website'),
+
+ // Use directory index url instead of "index.html"
+ directoryIndex: Boolean(true)
+});
+
+module.exports = Options;
diff --git a/packages/gitbook/lib/output/website/prepareI18n.js b/packages/gitbook/lib/output/website/prepareI18n.js
new file mode 100644
index 0000000..cedd3b9
--- /dev/null
+++ b/packages/gitbook/lib/output/website/prepareI18n.js
@@ -0,0 +1,30 @@
+var path = require('path');
+
+var fs = require('../../utils/fs');
+var Promise = require('../../utils/promise');
+var listSearchPaths = require('./listSearchPaths');
+
+/**
+ * Prepare i18n, load translations from plugins and book
+ *
+ * @param {Output}
+ * @return {Promise<Output>}
+ */
+function prepareI18n(output) {
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var searchPaths = listSearchPaths(output);
+
+ searchPaths
+ .reverse()
+ .forEach(function(searchPath) {
+ var i18nRoot = path.resolve(searchPath, '_i18n');
+
+ if (!fs.existsSync(i18nRoot)) return;
+ i18n.load(i18nRoot);
+ });
+
+ return Promise(output);
+}
+
+module.exports = prepareI18n;
diff --git a/packages/gitbook/lib/output/website/prepareResources.js b/packages/gitbook/lib/output/website/prepareResources.js
new file mode 100644
index 0000000..4e6835d
--- /dev/null
+++ b/packages/gitbook/lib/output/website/prepareResources.js
@@ -0,0 +1,54 @@
+var is = require('is');
+var Immutable = require('immutable');
+var Promise = require('../../utils/promise');
+
+var Api = require('../../api');
+
+/**
+ Prepare plugins resources, add all output corresponding type resources
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareResources(output) {
+ var plugins = output.getPlugins();
+ var options = output.getOptions();
+ var type = options.get('prefix');
+ var state = output.getState();
+ var context = Api.encodeGlobal(output);
+
+ var result = Immutable.Map();
+
+ return Promise.forEach(plugins, function(plugin) {
+ var pluginResources = plugin.getResources(type);
+
+ return Promise()
+ .then(function() {
+ // Apply resources if is a function
+ if (is.fn(pluginResources)) {
+ return Promise()
+ .then(pluginResources.bind(context));
+ }
+ else {
+ return pluginResources;
+ }
+ })
+ .then(function(resources) {
+ result = result.set(plugin.getName(), Immutable.Map(resources));
+ });
+ })
+ .then(function() {
+ // Set output resources
+ state = state.merge({
+ resources: result
+ });
+
+ output = output.merge({
+ state: state
+ });
+
+ return output;
+ });
+}
+
+module.exports = prepareResources; \ No newline at end of file
diff --git a/packages/gitbook/lib/output/website/state.js b/packages/gitbook/lib/output/website/state.js
new file mode 100644
index 0000000..cb8f750
--- /dev/null
+++ b/packages/gitbook/lib/output/website/state.js
@@ -0,0 +1,19 @@
+var I18n = require('i18n-t');
+var Immutable = require('immutable');
+
+var GeneratorState = Immutable.Record({
+ i18n: I18n(),
+
+ // List of plugins' resources
+ resources: Immutable.Map()
+});
+
+GeneratorState.prototype.getI18n = function() {
+ return this.get('i18n');
+};
+
+GeneratorState.prototype.getResources = function() {
+ return this.get('resources');
+};
+
+module.exports = GeneratorState;
diff --git a/packages/gitbook/lib/parse/__tests__/listAssets.js b/packages/gitbook/lib/parse/__tests__/listAssets.js
new file mode 100644
index 0000000..4c5b0a0
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/listAssets.js
@@ -0,0 +1,29 @@
+var Immutable = require('immutable');
+
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+var listAssets = require('../listAssets');
+var parseGlossary = require('../parseGlossary');
+
+describe('listAssets', function() {
+ it('should not list glossary as asset', function() {
+ var fs = createMockFS({
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello',
+ 'assetFile.js': '',
+ 'assets': {
+ 'file.js': ''
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ return listAssets(resultBook, Immutable.Map());
+ })
+ .then(function(assets) {
+ expect(assets.size).toBe(2);
+ expect(assets.includes('assetFile.js'));
+ expect(assets.includes('assets/file.js'));
+ });
+ });
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parseBook.js b/packages/gitbook/lib/parse/__tests__/parseBook.js
new file mode 100644
index 0000000..b1236c9
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parseBook.js
@@ -0,0 +1,90 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseBook', function() {
+ var parseBook = require('../parseBook');
+
+ it('should parse multilingual book', function() {
+ var fs = createMockFS({
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ var languages = resultBook.getLanguages();
+ var books = resultBook.getBooks();
+
+ expect(resultBook.isMultilingual()).toBe(true);
+ expect(languages.getList().size).toBe(2);
+ expect(books.size).toBe(2);
+ });
+ });
+
+ it('should extend configuration for multilingual book', function() {
+ var fs = createMockFS({
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'book.json': '{ "title": "Test", "author": "GitBook" }',
+ 'en': {
+ 'README.md': 'Hello',
+ 'book.json': '{ "title": "Test EN" }'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ var books = resultBook.getBooks();
+
+ expect(resultBook.isMultilingual()).toBe(true);
+ expect(books.size).toBe(2);
+
+ var en = books.get('en');
+ var fr = books.get('fr');
+
+ var enConfig = en.getConfig();
+ var frConfig = fr.getConfig();
+
+ expect(enConfig.getValue('title')).toBe('Test EN');
+ expect(enConfig.getValue('author')).toBe('GitBook');
+
+ expect(frConfig.getValue('title')).toBe('Test');
+ expect(frConfig.getValue('author')).toBe('GitBook');
+ });
+ });
+
+ it('should parse book in a directory', function() {
+ var fs = createMockFS({
+ 'book.json': JSON.stringify({
+ root: './test'
+ }),
+ 'test': {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](page.md)\n',
+ 'page.md': 'Page'
+ }
+ });
+ var book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ var readme = resultBook.getReadme();
+ var summary = resultBook.getSummary();
+ var articles = summary.getArticlesAsList();
+
+ expect(summary.getFile().exists()).toBe(true);
+ expect(readme.getFile().exists()).toBe(true);
+ expect(articles.size).toBe(2);
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parseGlossary.js b/packages/gitbook/lib/parse/__tests__/parseGlossary.js
new file mode 100644
index 0000000..9069af6
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parseGlossary.js
@@ -0,0 +1,36 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseGlossary', function() {
+ var parseGlossary = require('../parseGlossary');
+
+ it('should parse glossary if exists', function() {
+ var fs = createMockFS({
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ var glossary = resultBook.getGlossary();
+ var file = glossary.getFile();
+ var entries = glossary.getEntries();
+
+ expect(file.exists()).toBeTruthy();
+ expect(entries.size).toBe(1);
+ });
+ });
+
+ it('should not fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ var glossary = resultBook.getGlossary();
+ var file = glossary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parseIgnore.js b/packages/gitbook/lib/parse/__tests__/parseIgnore.js
new file mode 100644
index 0000000..54e7dae
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parseIgnore.js
@@ -0,0 +1,40 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseIgnore', function() {
+ var parseIgnore = require('../parseIgnore');
+ var fs = createMockFS({
+ '.ignore': 'test-1.js',
+ '.gitignore': 'test-2.js\ntest-3.js',
+ '.bookignore': '!test-3.js',
+ 'test-1.js': '1',
+ 'test-2.js': '2',
+ 'test-3.js': '3'
+ });
+
+ function getBook() {
+ var book = Book.createForFS(fs);
+ return parseIgnore(book);
+ }
+
+ it('should load rules from .ignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-1.js')).toBeTruthy();
+ });
+ });
+
+ it('should load rules from .gitignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-2.js')).toBeTruthy();
+ });
+ });
+
+ it('should load rules from .bookignore', function() {
+ return getBook()
+ .then(function(book) {
+ expect(book.isFileIgnored('test-3.js')).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parsePageFromString.js b/packages/gitbook/lib/parse/__tests__/parsePageFromString.js
new file mode 100644
index 0000000..2911fa3
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parsePageFromString.js
@@ -0,0 +1,37 @@
+var parsePageFromString = require('../parsePageFromString');
+var Page = require('../../models/page');
+
+describe('parsePageFromString', function() {
+ var page = new Page();
+
+ it('should parse YAML frontmatter', function() {
+ var CONTENT = '---\nhello: true\nworld: "cool"\n---\n# Hello World\n';
+ var newPage = parsePageFromString(page, CONTENT);
+
+ expect(newPage.getDir()).toBe('ltr');
+ expect(newPage.getContent()).toBe('# Hello World\n');
+
+ var attrs = newPage.getAttributes();
+ expect(attrs.size).toBe(2);
+ expect(attrs.get('hello')).toBe(true);
+ expect(attrs.get('world')).toBe('cool');
+ });
+
+ it('should parse text direction (english)', function() {
+ var CONTENT = 'Hello World';
+ var newPage = parsePageFromString(page, CONTENT);
+
+ expect(newPage.getDir()).toBe('ltr');
+ expect(newPage.getContent()).toBe('Hello World');
+ expect(newPage.getAttributes().size).toBe(0);
+ });
+
+ it('should parse text direction (arab)', function() {
+ var CONTENT = 'مرحبا بالعالم';
+ var newPage = parsePageFromString(page, CONTENT);
+
+ expect(newPage.getDir()).toBe('rtl');
+ expect(newPage.getContent()).toBe('مرحبا بالعالم');
+ expect(newPage.getAttributes().size).toBe(0);
+ });
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parseReadme.js b/packages/gitbook/lib/parse/__tests__/parseReadme.js
new file mode 100644
index 0000000..4270ea3
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parseReadme.js
@@ -0,0 +1,36 @@
+var Promise = require('../../utils/promise');
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseReadme', function() {
+ var parseReadme = require('../parseReadme');
+
+ it('should parse summary if exists', function() {
+ var fs = createMockFS({
+ 'README.md': '# Hello\n\nAnd here is the description.'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseReadme(book)
+ .then(function(resultBook) {
+ var readme = resultBook.getReadme();
+ var file = readme.getFile();
+
+ expect(file.exists()).toBeTruthy();
+ expect(readme.getTitle()).toBe('Hello');
+ expect(readme.getDescription()).toBe('And here is the description.');
+ });
+ });
+
+ it('should fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseReadme(book)
+ .then(function(resultBook) {
+ throw new Error('It should have fail');
+ }, function() {
+ return Promise();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/parse/__tests__/parseSummary.js b/packages/gitbook/lib/parse/__tests__/parseSummary.js
new file mode 100644
index 0000000..55a445e
--- /dev/null
+++ b/packages/gitbook/lib/parse/__tests__/parseSummary.js
@@ -0,0 +1,34 @@
+var Book = require('../../models/book');
+var createMockFS = require('../../fs/mock');
+
+describe('parseSummary', function() {
+ var parseSummary = require('../parseSummary');
+
+ it('should parse summary if exists', function() {
+ var fs = createMockFS({
+ 'SUMMARY.md': '# Summary\n\n* [Hello](hello.md)'
+ });
+ var book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ var summary = resultBook.getSummary();
+ var file = summary.getFile();
+
+ expect(file.exists()).toBeTruthy();
+ });
+ });
+
+ it('should not fail if doesn\'t exist', function() {
+ var fs = createMockFS({});
+ var book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ var summary = resultBook.getSummary();
+ var file = summary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/parse/findParsableFile.js b/packages/gitbook/lib/parse/findParsableFile.js
new file mode 100644
index 0000000..51e2dd0
--- /dev/null
+++ b/packages/gitbook/lib/parse/findParsableFile.js
@@ -0,0 +1,36 @@
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var parsers = require('../parsers');
+
+/**
+ Find a file parsable (Markdown or AsciiDoc) in a book
+
+ @param {Book} book
+ @param {String} filename
+ @return {Promise<File | Undefined>}
+*/
+function findParsableFile(book, filename) {
+ var fs = book.getContentFS();
+ var ext = path.extname(filename);
+ var basename = path.basename(filename, ext);
+ var basedir = path.dirname(filename);
+
+ // Ordered list of extensions to test
+ var exts = parsers.extensions;
+
+ return Promise.some(exts, function(ext) {
+ var filepath = basename + ext;
+
+ return fs.findFile(basedir, filepath)
+ .then(function(found) {
+ if (!found || book.isContentFileIgnored(found)) {
+ return undefined;
+ }
+
+ return fs.statFile(found);
+ });
+ });
+}
+
+module.exports = findParsableFile;
diff --git a/packages/gitbook/lib/parse/index.js b/packages/gitbook/lib/parse/index.js
new file mode 100644
index 0000000..1f73946
--- /dev/null
+++ b/packages/gitbook/lib/parse/index.js
@@ -0,0 +1,15 @@
+
+module.exports = {
+ parseBook: require('./parseBook'),
+ parseSummary: require('./parseSummary'),
+ parseGlossary: require('./parseGlossary'),
+ parseReadme: require('./parseReadme'),
+ parseConfig: require('./parseConfig'),
+ parsePagesList: require('./parsePagesList'),
+ parseIgnore: require('./parseIgnore'),
+ listAssets: require('./listAssets'),
+ parseLanguages: require('./parseLanguages'),
+ parsePage: require('./parsePage'),
+ parsePageFromString: require('./parsePageFromString'),
+ lookupStructureFile: require('./lookupStructureFile')
+};
diff --git a/packages/gitbook/lib/parse/listAssets.js b/packages/gitbook/lib/parse/listAssets.js
new file mode 100644
index 0000000..d83d8fd
--- /dev/null
+++ b/packages/gitbook/lib/parse/listAssets.js
@@ -0,0 +1,43 @@
+var timing = require('../utils/timing');
+
+/**
+ List all assets in a book
+ Assets are file not ignored and not a page
+
+ @param {Book} book
+ @param {List<String>} pages
+ @param
+*/
+function listAssets(book, pages) {
+ var fs = book.getContentFS();
+
+ var summary = book.getSummary();
+ var summaryFile = summary.getFile().getPath();
+
+ var glossary = book.getGlossary();
+ var glossaryFile = glossary.getFile().getPath();
+
+ var langs = book.getLanguages();
+ var langsFile = langs.getFile().getPath();
+
+ var config = book.getConfig();
+ var configFile = config.getFile().getPath();
+
+ function filterFile(file) {
+ return !(
+ file === summaryFile ||
+ file === glossaryFile ||
+ file === langsFile ||
+ file === configFile ||
+ book.isContentFileIgnored(file) ||
+ pages.has(file)
+ );
+ }
+
+ return timing.measure(
+ 'parse.listAssets',
+ fs.listAllFiles('.', filterFile)
+ );
+}
+
+module.exports = listAssets;
diff --git a/packages/gitbook/lib/parse/lookupStructureFile.js b/packages/gitbook/lib/parse/lookupStructureFile.js
new file mode 100644
index 0000000..36b37f8
--- /dev/null
+++ b/packages/gitbook/lib/parse/lookupStructureFile.js
@@ -0,0 +1,20 @@
+var findParsableFile = require('./findParsableFile');
+
+/**
+ Lookup a structure file (ex: SUMMARY.md, GLOSSARY.md) in a book. Uses
+ book's config to find it.
+
+ @param {Book} book
+ @param {String} type: one of ["glossary", "readme", "summary", "langs"]
+ @return {Promise<File | Undefined>} The path of the file found, relative
+ to the book content root.
+*/
+function lookupStructureFile(book, type) {
+ var config = book.getConfig();
+
+ var fileToSearch = config.getValue(['structure', type]);
+
+ return findParsableFile(book, fileToSearch);
+}
+
+module.exports = lookupStructureFile;
diff --git a/packages/gitbook/lib/parse/parseBook.js b/packages/gitbook/lib/parse/parseBook.js
new file mode 100644
index 0000000..a92f39e
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseBook.js
@@ -0,0 +1,77 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var Book = require('../models/book');
+
+var parseIgnore = require('./parseIgnore');
+var parseConfig = require('./parseConfig');
+var parseGlossary = require('./parseGlossary');
+var parseSummary = require('./parseSummary');
+var parseReadme = require('./parseReadme');
+var parseLanguages = require('./parseLanguages');
+
+/**
+ Parse content of a book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseBookContent(book) {
+ return Promise(book)
+ .then(parseReadme)
+ .then(parseSummary)
+ .then(parseGlossary);
+}
+
+/**
+ Parse a multilingual book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseMultilingualBook(book) {
+ var languages = book.getLanguages();
+ var langList = languages.getList();
+
+ return Promise.reduce(langList, function(currentBook, lang) {
+ var langID = lang.getID();
+ var child = Book.createFromParent(currentBook, langID);
+ var ignore = currentBook.getIgnore();
+
+ return Promise(child)
+ .then(parseConfig)
+ .then(parseBookContent)
+ .then(function(result) {
+ // Ignore content of this book when generating parent book
+ ignore = ignore.add(langID + '/**');
+ currentBook = currentBook.set('ignore', ignore);
+
+ return currentBook.addLanguageBook(langID, result);
+ });
+ }, book);
+}
+
+
+/**
+ Parse a whole book from a filesystem
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseBook(book) {
+ return timing.measure(
+ 'parse.book',
+ Promise(book)
+ .then(parseIgnore)
+ .then(parseConfig)
+ .then(parseLanguages)
+ .then(function(resultBook) {
+ if (resultBook.isMultilingual()) {
+ return parseMultilingualBook(resultBook);
+ } else {
+ return parseBookContent(resultBook);
+ }
+ })
+ );
+}
+
+module.exports = parseBook;
diff --git a/packages/gitbook/lib/parse/parseConfig.js b/packages/gitbook/lib/parse/parseConfig.js
new file mode 100644
index 0000000..a411af8
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseConfig.js
@@ -0,0 +1,55 @@
+var Promise = require('../utils/promise');
+
+var validateConfig = require('./validateConfig');
+var CONFIG_FILES = require('../constants/configFiles');
+
+/**
+ Parse configuration from "book.json" or "book.js"
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseConfig(book) {
+ var fs = book.getFS();
+ var config = book.getConfig();
+
+ return Promise.some(CONFIG_FILES, function(filename) {
+ // Is this file ignored?
+ if (book.isFileIgnored(filename)) {
+ return;
+ }
+
+ // Try loading it
+ return fs.loadAsObject(filename)
+ .then(function(cfg) {
+ return fs.statFile(filename)
+ .then(function(file) {
+ return {
+ file: file,
+ values: cfg
+ };
+ });
+ })
+ .fail(function(err) {
+ if (err.code != 'MODULE_NOT_FOUND') throw(err);
+ else return Promise(false);
+ });
+ })
+
+ .then(function(result) {
+ var values = result? result.values : {};
+ values = validateConfig(values);
+
+ // Set the file
+ if (result.file) {
+ config = config.setFile(result.file);
+ }
+
+ // Merge with old values
+ config = config.mergeValues(values);
+
+ return book.setConfig(config);
+ });
+}
+
+module.exports = parseConfig;
diff --git a/packages/gitbook/lib/parse/parseGlossary.js b/packages/gitbook/lib/parse/parseGlossary.js
new file mode 100644
index 0000000..a96e5fc
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseGlossary.js
@@ -0,0 +1,26 @@
+var parseStructureFile = require('./parseStructureFile');
+var Glossary = require('../models/glossary');
+
+/**
+ Parse glossary
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseGlossary(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'glossary')
+ .spread(function(file, entries) {
+ if (!file) {
+ return book;
+ }
+
+ logger.debug.ln('glossary index file found at', file.getPath());
+
+ var glossary = Glossary.createFromEntries(file, entries);
+ return book.set('glossary', glossary);
+ });
+}
+
+module.exports = parseGlossary;
diff --git a/packages/gitbook/lib/parse/parseIgnore.js b/packages/gitbook/lib/parse/parseIgnore.js
new file mode 100644
index 0000000..84d8c33
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseIgnore.js
@@ -0,0 +1,51 @@
+var Promise = require('../utils/promise');
+var IGNORE_FILES = require('../constants/ignoreFiles');
+
+var DEFAULT_IGNORES = [
+ // Skip Git stuff
+ '.git/',
+
+ // Skip OS X meta data
+ '.DS_Store',
+
+ // Skip stuff installed by plugins
+ 'node_modules',
+
+ // Skip book outputs
+ '_book',
+
+ // Ignore files in the templates folder
+ '_layouts'
+];
+
+/**
+ Parse ignore files
+
+ @param {Book}
+ @return {Book}
+*/
+function parseIgnore(book) {
+ if (book.isLanguageBook()) {
+ return Promise.reject(new Error('Ignore files could be parsed for language books'));
+ }
+
+ var fs = book.getFS();
+ var ignore = book.getIgnore();
+
+ ignore = ignore.add(DEFAULT_IGNORES);
+
+ return Promise.serie(IGNORE_FILES, function(filename) {
+ return fs.readAsString(filename)
+ .then(function(content) {
+ ignore = ignore.add(content.toString().split(/\r?\n/));
+ }, function(err) {
+ return Promise();
+ });
+ })
+
+ .then(function() {
+ return book.setIgnore(ignore);
+ });
+}
+
+module.exports = parseIgnore;
diff --git a/packages/gitbook/lib/parse/parseLanguages.js b/packages/gitbook/lib/parse/parseLanguages.js
new file mode 100644
index 0000000..346f3a3
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseLanguages.js
@@ -0,0 +1,28 @@
+var parseStructureFile = require('./parseStructureFile');
+var Languages = require('../models/languages');
+
+/**
+ Parse languages list from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseLanguages(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'langs')
+ .spread(function(file, result) {
+ if (!file) {
+ return book;
+ }
+
+ var languages = Languages.createFromList(file, result);
+
+ logger.debug.ln('languages index file found at', file.getPath());
+ logger.info.ln('parsing multilingual book, with', languages.getList().size, 'languages');
+
+ return book.set('languages', languages);
+ });
+}
+
+module.exports = parseLanguages;
diff --git a/packages/gitbook/lib/parse/parsePage.js b/packages/gitbook/lib/parse/parsePage.js
new file mode 100644
index 0000000..fdc56a3
--- /dev/null
+++ b/packages/gitbook/lib/parse/parsePage.js
@@ -0,0 +1,21 @@
+var parsePageFromString = require('./parsePageFromString');
+
+/**
+ * Parse a page, read its content and parse the YAMl header
+ *
+ * @param {Book} book
+ * @param {Page} page
+ * @return {Promise<Page>}
+ */
+function parsePage(book, page) {
+ var fs = book.getContentFS();
+ var file = page.getFile();
+
+ return fs.readAsString(file.getPath())
+ .then(function(content) {
+ return parsePageFromString(page, content);
+ });
+}
+
+
+module.exports = parsePage;
diff --git a/packages/gitbook/lib/parse/parsePageFromString.js b/packages/gitbook/lib/parse/parsePageFromString.js
new file mode 100644
index 0000000..80c147b
--- /dev/null
+++ b/packages/gitbook/lib/parse/parsePageFromString.js
@@ -0,0 +1,22 @@
+var Immutable = require('immutable');
+var fm = require('front-matter');
+var direction = require('direction');
+
+/**
+ * Parse a page, its content and the YAMl header
+ *
+ * @param {Page} page
+ * @return {Page}
+ */
+function parsePageFromString(page, content) {
+ var parsed = fm(content);
+
+ return page.merge({
+ content: parsed.body,
+ attributes: Immutable.fromJS(parsed.attributes),
+ dir: direction(parsed.body)
+ });
+}
+
+
+module.exports = parsePageFromString;
diff --git a/packages/gitbook/lib/parse/parsePagesList.js b/packages/gitbook/lib/parse/parsePagesList.js
new file mode 100644
index 0000000..1cf42f5
--- /dev/null
+++ b/packages/gitbook/lib/parse/parsePagesList.js
@@ -0,0 +1,78 @@
+var Immutable = require('immutable');
+
+var timing = require('../utils/timing');
+var Page = require('../models/page');
+var walkSummary = require('./walkSummary');
+var parsePage = require('./parsePage');
+
+
+/**
+ Parse a page from a path
+
+ @param {Book} book
+ @param {String} filePath
+ @return {Page}
+*/
+function parseFilePage(book, filePath) {
+ var fs = book.getContentFS();
+
+ return fs.statFile(filePath)
+ .then(function(file) {
+ var page = Page.createForFile(file);
+ return parsePage(book, page);
+ });
+}
+
+
+/**
+ Parse all pages from a book as an OrderedMap
+
+ @param {Book} book
+ @return {Promise<OrderedMap<Page>>}
+*/
+function parsePagesList(book) {
+ var summary = book.getSummary();
+ var glossary = book.getGlossary();
+ var map = Immutable.OrderedMap();
+
+ // Parse pages from summary
+ return timing.measure(
+ 'parse.listPages',
+ walkSummary(summary, function(article) {
+ if (!article.isPage()) return;
+
+ var filepath = article.getPath();
+
+ // Is the page ignored?
+ if (book.isContentFileIgnored(filepath)) return;
+
+ return parseFilePage(book, filepath)
+ .then(function(page) {
+ map = map.set(filepath, page);
+ }, function() {
+ // file doesn't exist
+ });
+ })
+ )
+
+ // Parse glossary
+ .then(function() {
+ var file = glossary.getFile();
+
+ if (!file.exists()) {
+ return;
+ }
+
+ return parseFilePage(book, file.getPath())
+ .then(function(page) {
+ map = map.set(file.getPath(), page);
+ });
+ })
+
+ .then(function() {
+ return map;
+ });
+}
+
+
+module.exports = parsePagesList;
diff --git a/packages/gitbook/lib/parse/parseReadme.js b/packages/gitbook/lib/parse/parseReadme.js
new file mode 100644
index 0000000..a2ede77
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseReadme.js
@@ -0,0 +1,28 @@
+var parseStructureFile = require('./parseStructureFile');
+var Readme = require('../models/readme');
+
+var error = require('../utils/error');
+
+/**
+ Parse readme from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseReadme(book) {
+ var logger = book.getLogger();
+
+ return parseStructureFile(book, 'readme')
+ .spread(function(file, result) {
+ if (!file) {
+ throw new error.FileNotFoundError({ filename: 'README' });
+ }
+
+ logger.debug.ln('readme found at', file.getPath());
+
+ var readme = Readme.create(file, result);
+ return book.set('readme', readme);
+ });
+}
+
+module.exports = parseReadme;
diff --git a/packages/gitbook/lib/parse/parseStructureFile.js b/packages/gitbook/lib/parse/parseStructureFile.js
new file mode 100644
index 0000000..718f731
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseStructureFile.js
@@ -0,0 +1,67 @@
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var lookupStructureFile = require('./lookupStructureFile');
+
+/**
+ Parse a ParsableFile using a specific method
+
+ @param {FS} fs
+ @param {ParsableFile} file
+ @param {String} type
+ @return {Promise<Array<String, List|Map>>}
+*/
+function parseFile(fs, file, type) {
+ var filepath = file.getPath();
+ var parser = file.getParser();
+
+ if (!parser) {
+ return Promise.reject(
+ error.FileNotParsableError({
+ filename: filepath
+ })
+ );
+ }
+
+ return fs.readAsString(filepath)
+ .then(function(content) {
+ if (type === 'readme') {
+ return parser.parseReadme(content);
+ } else if (type === 'glossary') {
+ return parser.parseGlossary(content);
+ } else if (type === 'summary') {
+ return parser.parseSummary(content);
+ } else if (type === 'langs') {
+ return parser.parseLanguages(content);
+ } else {
+ throw new Error('Parsing invalid type "' + type + '"');
+ }
+ })
+ .then(function(result) {
+ return [
+ file,
+ result
+ ];
+ });
+}
+
+
+/**
+ Parse a structure file (ex: SUMMARY.md, GLOSSARY.md).
+ It uses the configuration to find the specified file.
+
+ @param {Book} book
+ @param {String} type: one of ["glossary", "readme", "summary"]
+ @return {Promise<List|Map>}
+*/
+function parseStructureFile(book, type) {
+ var fs = book.getContentFS();
+
+ return lookupStructureFile(book, type)
+ .then(function(file) {
+ if (!file) return [undefined, undefined];
+
+ return parseFile(fs, file, type);
+ });
+}
+
+module.exports = parseStructureFile;
diff --git a/packages/gitbook/lib/parse/parseSummary.js b/packages/gitbook/lib/parse/parseSummary.js
new file mode 100644
index 0000000..2c1e3b3
--- /dev/null
+++ b/packages/gitbook/lib/parse/parseSummary.js
@@ -0,0 +1,44 @@
+var parseStructureFile = require('./parseStructureFile');
+var Summary = require('../models/summary');
+var SummaryModifier = require('../modifiers').Summary;
+
+/**
+ Parse summary in a book, the summary can only be parsed
+ if the readme as be detected before.
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseSummary(book) {
+ var readme = book.getReadme();
+ var logger = book.getLogger();
+ var readmeFile = readme.getFile();
+
+ return parseStructureFile(book, 'summary')
+ .spread(function(file, result) {
+ var summary;
+
+ if (!file) {
+ logger.warn.ln('no summary file in this book');
+ summary = Summary();
+ } else {
+ logger.debug.ln('summary file found at', file.getPath());
+ summary = Summary.createFromParts(file, result.parts);
+ }
+
+ // Insert readme as first entry if not in SUMMARY.md
+ var readmeArticle = summary.getByPath(readmeFile.getPath());
+
+ if (readmeFile.exists() && !readmeArticle) {
+ summary = SummaryModifier.unshiftArticle(summary, {
+ title: 'Introduction',
+ ref: readmeFile.getPath()
+ });
+ }
+
+ // Set new summary
+ return book.setSummary(summary);
+ });
+}
+
+module.exports = parseSummary;
diff --git a/packages/gitbook/lib/parse/validateConfig.js b/packages/gitbook/lib/parse/validateConfig.js
new file mode 100644
index 0000000..21294ac
--- /dev/null
+++ b/packages/gitbook/lib/parse/validateConfig.js
@@ -0,0 +1,31 @@
+var jsonschema = require('jsonschema');
+var jsonSchemaDefaults = require('json-schema-defaults');
+
+var schema = require('../constants/configSchema');
+var error = require('../utils/error');
+var mergeDefaults = require('../utils/mergeDefaults');
+
+/**
+ Validate a book.json content
+ And return a mix with the default value
+
+ @param {Object} bookJson
+ @return {Object}
+*/
+function validateConfig(bookJson) {
+ var v = new jsonschema.Validator();
+ var result = v.validate(bookJson, schema, {
+ propertyName: 'config'
+ });
+
+ // Throw error
+ if (result.errors.length > 0) {
+ throw new error.ConfigurationError(new Error(result.errors[0].stack));
+ }
+
+ // Insert default values
+ var defaults = jsonSchemaDefaults(schema);
+ return mergeDefaults(bookJson, defaults);
+}
+
+module.exports = validateConfig;
diff --git a/packages/gitbook/lib/parse/walkSummary.js b/packages/gitbook/lib/parse/walkSummary.js
new file mode 100644
index 0000000..0117752
--- /dev/null
+++ b/packages/gitbook/lib/parse/walkSummary.js
@@ -0,0 +1,34 @@
+var Promise = require('../utils/promise');
+
+/**
+ Walk over a list of articles
+
+ @param {List<Article>} articles
+ @param {Function(article)}
+ @return {Promise}
+*/
+function walkArticles(articles, fn) {
+ return Promise.forEach(articles, function(article) {
+ return Promise(fn(article))
+ .then(function() {
+ return walkArticles(article.getArticles(), fn);
+ });
+ });
+}
+
+/**
+ Walk over summary and execute "fn" on each article
+
+ @param {Summary} summary
+ @param {Function(article)}
+ @return {Promise}
+*/
+function walkSummary(summary, fn) {
+ var parts = summary.getParts();
+
+ return Promise.forEach(parts, function(part) {
+ return walkArticles(part.getArticles(), fn);
+ });
+}
+
+module.exports = walkSummary;
diff --git a/packages/gitbook/lib/parsers.js b/packages/gitbook/lib/parsers.js
new file mode 100644
index 0000000..70e44f4
--- /dev/null
+++ b/packages/gitbook/lib/parsers.js
@@ -0,0 +1,63 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var markdownParser = require('gitbook-markdown');
+var asciidocParser = require('gitbook-asciidoc');
+
+var EXTENSIONS_MARKDOWN = require('./constants/extsMarkdown');
+var EXTENSIONS_ASCIIDOC = require('./constants/extsAsciidoc');
+var Parser = require('./models/parser');
+
+// This list is ordered by priority of parsers to use
+var parsers = Immutable.List([
+ Parser.create('markdown', EXTENSIONS_MARKDOWN, markdownParser),
+ Parser.create('asciidoc', EXTENSIONS_ASCIIDOC, asciidocParser)
+]);
+
+/**
+ * Return a specific parser by its name
+ *
+ * @param {String} name
+ * @return {Parser|undefined}
+ */
+function getParser(name) {
+ return parsers.find(function(parser) {
+ return parser.getName() === name;
+ });
+}
+
+/**
+ * Return a specific parser according to an extension
+ *
+ * @param {String} ext
+ * @return {Parser|undefined}
+ */
+function getParserByExt(ext) {
+ return parsers.find(function(parser) {
+ return parser.matchExtension(ext);
+ });
+}
+
+/**
+ * Return parser for a file
+ *
+ * @param {String} ext
+ * @return {Parser|undefined}
+ */
+function getParserForFile(filename) {
+ return getParserByExt(path.extname(filename));
+}
+
+// List all parsable extensions
+var extensions = parsers
+ .map(function(parser) {
+ return parser.getExtensions();
+ })
+ .flatten();
+
+module.exports = {
+ extensions: extensions,
+ get: getParser,
+ getByExt: getParserByExt,
+ getForFile: getParserForFile
+};
diff --git a/packages/gitbook/lib/plugins/__tests__/findForBook.js b/packages/gitbook/lib/plugins/__tests__/findForBook.js
new file mode 100644
index 0000000..d8af2e9
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/findForBook.js
@@ -0,0 +1,19 @@
+var path = require('path');
+
+var Book = require('../../models/book');
+var createNodeFS = require('../../fs/node');
+var findForBook = require('../findForBook');
+
+describe('findForBook', function() {
+ var fs = createNodeFS(
+ path.resolve(__dirname, '../../..')
+ );
+ var book = Book.createForFS(fs);
+
+ it('should list default plugins', function() {
+ return findForBook(book)
+ .then(function(plugins) {
+ expect(plugins.has('fontsettings')).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/findInstalled.js b/packages/gitbook/lib/plugins/__tests__/findInstalled.js
new file mode 100644
index 0000000..9377190
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/findInstalled.js
@@ -0,0 +1,25 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+describe('findInstalled', function() {
+ var findInstalled = require('../findInstalled');
+
+ it('must list default plugins for gitbook directory', function() {
+ // Read gitbook-plugins from package.json
+ var pkg = require(path.resolve(__dirname, '../../../package.json'));
+ var gitbookPlugins = Immutable.Seq(pkg.dependencies)
+ .filter(function(v, k) {
+ return k.indexOf('gitbook-plugin') === 0;
+ })
+ .cacheResult();
+
+ return findInstalled(path.resolve(__dirname, '../../../'))
+ .then(function(plugins) {
+ expect(plugins.size >= gitbookPlugins.size).toBeTruthy();
+
+ expect(plugins.has('fontsettings')).toBe(true);
+ expect(plugins.has('search')).toBe(true);
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/installPlugin.js b/packages/gitbook/lib/plugins/__tests__/installPlugin.js
new file mode 100644
index 0000000..0c1a346
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/installPlugin.js
@@ -0,0 +1,29 @@
+var path = require('path');
+
+var PluginDependency = require('../../models/pluginDependency');
+var Book = require('../../models/book');
+var NodeFS = require('../../fs/node');
+var installPlugin = require('../installPlugin');
+
+var Parse = require('../../parse');
+
+describe('installPlugin', function() {
+ var book;
+
+ this.timeout(30000);
+
+ before(function() {
+ var fs = NodeFS(path.resolve(__dirname, '../../../'));
+ var baseBook = Book.createForFS(fs);
+
+ return Parse.parseConfig(baseBook)
+ .then(function(_book) {
+ book = _book;
+ });
+ });
+
+ it('must install a plugin from NPM', function() {
+ var dep = PluginDependency.createFromString('ga');
+ return installPlugin(book, dep);
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/installPlugins.js b/packages/gitbook/lib/plugins/__tests__/installPlugins.js
new file mode 100644
index 0000000..1a66f90
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/installPlugins.js
@@ -0,0 +1,30 @@
+var path = require('path');
+
+var Book = require('../../models/book');
+var NodeFS = require('../../fs/node');
+var installPlugins = require('../installPlugins');
+
+var Parse = require('../../parse');
+
+describe('installPlugins', function() {
+ var book;
+
+ this.timeout(30000);
+
+ before(function() {
+ var fs = NodeFS(path.resolve(__dirname, '../../../'));
+ var baseBook = Book.createForFS(fs);
+
+ return Parse.parseConfig(baseBook)
+ .then(function(_book) {
+ book = _book;
+ });
+ });
+
+ it('must install all plugins from NPM', function() {
+ return installPlugins(book)
+ .then(function(n) {
+ expect(n).toBe(2);
+ });
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/listDependencies.js b/packages/gitbook/lib/plugins/__tests__/listDependencies.js
new file mode 100644
index 0000000..940faba
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/listDependencies.js
@@ -0,0 +1,38 @@
+var PluginDependency = require('../../models/pluginDependency');
+var listDependencies = require('../listDependencies');
+var toNames = require('../toNames');
+
+describe('listDependencies', function() {
+ it('must list default', function() {
+ var deps = PluginDependency.listFromString('ga,great');
+ var plugins = listDependencies(deps);
+ var names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga', 'great',
+ 'highlight', 'search', 'lunr', 'sharing', 'fontsettings',
+ 'theme-default' ]);
+ });
+
+ it('must list from array with -', function() {
+ var deps = PluginDependency.listFromString('ga,-great');
+ var plugins = listDependencies(deps);
+ var names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga',
+ 'highlight', 'search', 'lunr', 'sharing', 'fontsettings',
+ 'theme-default' ]);
+ });
+
+ it('must remove default plugins using -', function() {
+ var deps = PluginDependency.listFromString('ga,-search');
+ var plugins = listDependencies(deps);
+ var names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga',
+ 'highlight', 'lunr', 'sharing', 'fontsettings',
+ 'theme-default' ]);
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js b/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js
new file mode 100644
index 0000000..bb414a3
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/locateRootFolder.js
@@ -0,0 +1,10 @@
+var path = require('path');
+var locateRootFolder = require('../locateRootFolder');
+
+describe('locateRootFolder', function() {
+ it('should correctly resolve the node_modules for gitbook', function() {
+ expect(locateRootFolder()).toBe(
+ path.resolve(__dirname, '../../../')
+ );
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/resolveVersion.js b/packages/gitbook/lib/plugins/__tests__/resolveVersion.js
new file mode 100644
index 0000000..1877c9e
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/resolveVersion.js
@@ -0,0 +1,22 @@
+var PluginDependency = require('../../models/pluginDependency');
+var resolveVersion = require('../resolveVersion');
+
+describe('resolveVersion', function() {
+ it('must skip resolving and return non-semver versions', function() {
+ var plugin = PluginDependency.createFromString('ga@git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+
+ return resolveVersion(plugin)
+ .then(function(version) {
+ expect(version).toBe('git+ssh://samy@github.com/GitbookIO/plugin-ga.git');
+ });
+ });
+
+ it('must resolve a normal plugin dependency', function() {
+ var plugin = PluginDependency.createFromString('ga@>0.9.0 < 1.0.1');
+
+ return resolveVersion(plugin)
+ .then(function(version) {
+ expect(version).toBe('1.0.0');
+ });
+ });
+});
diff --git a/packages/gitbook/lib/plugins/__tests__/sortDependencies.js b/packages/gitbook/lib/plugins/__tests__/sortDependencies.js
new file mode 100644
index 0000000..87df477
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/sortDependencies.js
@@ -0,0 +1,42 @@
+var PluginDependency = require('../../models/pluginDependency');
+var sortDependencies = require('../sortDependencies');
+var toNames = require('../toNames');
+
+describe('sortDependencies', function() {
+ it('must load themes after plugins', function() {
+ var allPlugins = PluginDependency.listFromArray([
+ 'hello',
+ 'theme-test',
+ 'world'
+ ]);
+
+ var sorted = sortDependencies(allPlugins);
+ var names = toNames(sorted);
+
+ expect(names).toEqual([
+ 'hello',
+ 'world',
+ 'theme-test'
+ ]);
+ });
+
+ it('must keep order of themes', function() {
+ var allPlugins = PluginDependency.listFromArray([
+ 'theme-test',
+ 'theme-test1',
+ 'hello',
+ 'theme-test2',
+ 'world'
+ ]);
+ var sorted = sortDependencies(allPlugins);
+ var names = toNames(sorted);
+
+ expect(names).toEqual([
+ 'hello',
+ 'world',
+ 'theme-test',
+ 'theme-test1',
+ 'theme-test2'
+ ]);
+ });
+}); \ No newline at end of file
diff --git a/packages/gitbook/lib/plugins/__tests__/validatePlugin.js b/packages/gitbook/lib/plugins/__tests__/validatePlugin.js
new file mode 100644
index 0000000..635423c
--- /dev/null
+++ b/packages/gitbook/lib/plugins/__tests__/validatePlugin.js
@@ -0,0 +1,16 @@
+var Promise = require('../../utils/promise');
+var Plugin = require('../../models/plugin');
+var validatePlugin = require('../validatePlugin');
+
+describe('validatePlugin', function() {
+ it('must not validate a not loaded plugin', function() {
+ var plugin = Plugin.createFromString('test');
+
+ return validatePlugin(plugin)
+ .then(function() {
+ throw new Error('Should not be validate');
+ }, function(err) {
+ return Promise();
+ });
+ });
+});
diff --git a/packages/gitbook/lib/plugins/findForBook.js b/packages/gitbook/lib/plugins/findForBook.js
new file mode 100644
index 0000000..be2ad9f
--- /dev/null
+++ b/packages/gitbook/lib/plugins/findForBook.js
@@ -0,0 +1,34 @@
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var findInstalled = require('./findInstalled');
+var locateRootFolder = require('./locateRootFolder');
+
+/**
+ * List all plugins installed in a book
+ *
+ * @param {Book}
+ * @return {Promise<OrderedMap<String:Plugin>>}
+ */
+function findForBook(book) {
+ return timing.measure(
+ 'plugins.findForBook',
+
+ Promise.all([
+ findInstalled(locateRootFolder()),
+ findInstalled(book.getRoot())
+ ])
+
+ // Merge all plugins
+ .then(function(results) {
+ return Immutable.List(results)
+ .reduce(function(out, result) {
+ return out.merge(result);
+ }, Immutable.OrderedMap());
+ })
+ );
+}
+
+
+module.exports = findForBook;
diff --git a/packages/gitbook/lib/plugins/findInstalled.js b/packages/gitbook/lib/plugins/findInstalled.js
new file mode 100644
index 0000000..06cc6c4
--- /dev/null
+++ b/packages/gitbook/lib/plugins/findInstalled.js
@@ -0,0 +1,91 @@
+var readInstalled = require('read-installed');
+var Immutable = require('immutable');
+var path = require('path');
+
+var Promise = require('../utils/promise');
+var fs = require('../utils/fs');
+var Plugin = require('../models/plugin');
+var PREFIX = require('../constants/pluginPrefix');
+
+/**
+ * Validate if a package name is a GitBook plugin
+ *
+ * @return {Boolean}
+ */
+function validateId(name) {
+ return name && name.indexOf(PREFIX) === 0;
+}
+
+
+/**
+ * List all packages installed inside a folder
+ *
+ * @param {String} folder
+ * @return {OrderedMap<String:Plugin>}
+ */
+function findInstalled(folder) {
+ var options = {
+ dev: false,
+ log: function() {},
+ depth: 4
+ };
+ var results = Immutable.OrderedMap();
+
+ function onPackage(pkg, parent) {
+ if (!pkg.name) return;
+
+ var name = pkg.name;
+ var version = pkg.version;
+ var pkgPath = pkg.realPath;
+ var depth = pkg.depth;
+ var dependencies = pkg.dependencies;
+
+ var pluginName = name.slice(PREFIX.length);
+
+ if (!validateId(name)){
+ if (parent) return;
+ } else {
+ results = results.set(pluginName, Plugin({
+ name: pluginName,
+ version: version,
+ path: pkgPath,
+ depth: depth,
+ parent: parent
+ }));
+ }
+
+ Immutable.Map(dependencies).forEach(function(dep) {
+ onPackage(dep, pluginName);
+ });
+ }
+
+ // Search for gitbook-plugins in node_modules folder
+ var node_modules = path.join(folder, 'node_modules');
+
+ // List all folders in node_modules
+ return fs.readdir(node_modules)
+ .fail(function() {
+ return Promise([]);
+ })
+ .then(function(modules) {
+ return Promise.serie(modules, function(module) {
+ // Not a gitbook-plugin
+ if (!validateId(module)) {
+ return Promise();
+ }
+
+ // Read gitbook-plugin package details
+ var module_folder = path.join(node_modules, module);
+ return Promise.nfcall(readInstalled, module_folder, options)
+ .then(function(data) {
+ onPackage(data);
+ });
+ });
+ })
+ .then(function() {
+ // Return installed plugins
+ return results;
+ });
+}
+
+module.exports = findInstalled;
diff --git a/packages/gitbook/lib/plugins/index.js b/packages/gitbook/lib/plugins/index.js
new file mode 100644
index 0000000..607a7f1
--- /dev/null
+++ b/packages/gitbook/lib/plugins/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ loadForBook: require('./loadForBook'),
+ validateConfig: require('./validateConfig'),
+ installPlugins: require('./installPlugins'),
+ listResources: require('./listResources'),
+ listBlocks: require('./listBlocks'),
+ listFilters: require('./listFilters')
+};
+
diff --git a/packages/gitbook/lib/plugins/installPlugin.js b/packages/gitbook/lib/plugins/installPlugin.js
new file mode 100644
index 0000000..37852df
--- /dev/null
+++ b/packages/gitbook/lib/plugins/installPlugin.js
@@ -0,0 +1,47 @@
+var npmi = require('npmi');
+
+var Promise = require('../utils/promise');
+var resolveVersion = require('./resolveVersion');
+
+/**
+ Install a plugin for a book
+
+ @param {Book}
+ @param {PluginDependency}
+ @return {Promise}
+*/
+function installPlugin(book, plugin) {
+ var logger = book.getLogger();
+
+ var installFolder = book.getRoot();
+ var name = plugin.getName();
+ var requirement = plugin.getVersion();
+
+ logger.info.ln('');
+ logger.info.ln('installing plugin "' + name + '"');
+
+ // Find a version to install
+ return resolveVersion(plugin)
+ .then(function(version) {
+ if (!version) {
+ throw new Error('Found no satisfactory version for plugin "' + name + '" with requirement "' + requirement + '"');
+ }
+
+ logger.info.ln('install plugin "' + name +'" (' + requirement + ') from NPM with version', version);
+ return Promise.nfcall(npmi, {
+ 'name': plugin.getNpmID(),
+ 'version': version,
+ 'path': installFolder,
+ 'npmLoad': {
+ 'loglevel': 'silent',
+ 'loaded': true,
+ 'prefix': installFolder
+ }
+ });
+ })
+ .then(function() {
+ logger.info.ok('plugin "' + name + '" installed with success');
+ });
+}
+
+module.exports = installPlugin;
diff --git a/packages/gitbook/lib/plugins/installPlugins.js b/packages/gitbook/lib/plugins/installPlugins.js
new file mode 100644
index 0000000..307c41e
--- /dev/null
+++ b/packages/gitbook/lib/plugins/installPlugins.js
@@ -0,0 +1,48 @@
+var npmi = require('npmi');
+
+var DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+var Promise = require('../utils/promise');
+var installPlugin = require('./installPlugin');
+
+/**
+ Install plugin requirements for a book
+
+ @param {Book}
+ @return {Promise<Number>}
+*/
+function installPlugins(book) {
+ var logger = book.getLogger();
+ var config = book.getConfig();
+ var plugins = config.getPluginDependencies();
+
+ // Remove default plugins
+ // (only if version is same as installed)
+ plugins = plugins.filterNot(function(plugin) {
+ var dependency = DEFAULT_PLUGINS.find(function(dep) {
+ return dep.getName() === plugin.getName();
+ });
+
+ return (
+ // Disabled plugin
+ !plugin.isEnabled() ||
+
+ // Or default one installed in GitBook itself
+ (dependency &&
+ plugin.getVersion() === dependency.getVersion())
+ );
+ });
+
+ if (plugins.size == 0) {
+ logger.info.ln('nothing to install!');
+ return Promise();
+ }
+
+ logger.info.ln('installing', plugins.size, 'plugins using npm@' + npmi.NPM_VERSION);
+
+ return Promise.forEach(plugins, function(plugin) {
+ return installPlugin(book, plugin);
+ })
+ .thenResolve(plugins.size);
+}
+
+module.exports = installPlugins;
diff --git a/packages/gitbook/lib/plugins/listBlocks.js b/packages/gitbook/lib/plugins/listBlocks.js
new file mode 100644
index 0000000..3ac28af
--- /dev/null
+++ b/packages/gitbook/lib/plugins/listBlocks.js
@@ -0,0 +1,18 @@
+var Immutable = require('immutable');
+
+/**
+ List blocks from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @return {Map<String:TemplateBlock>}
+*/
+function listBlocks(plugins) {
+ return plugins
+ .reverse()
+ .reduce(function(result, plugin) {
+ var blocks = plugin.getBlocks();
+ return result.merge(blocks);
+ }, Immutable.Map());
+}
+
+module.exports = listBlocks;
diff --git a/packages/gitbook/lib/plugins/listDependencies.js b/packages/gitbook/lib/plugins/listDependencies.js
new file mode 100644
index 0000000..d52eaa9
--- /dev/null
+++ b/packages/gitbook/lib/plugins/listDependencies.js
@@ -0,0 +1,33 @@
+var DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+var sortDependencies = require('./sortDependencies');
+
+/**
+ * List all dependencies for a book, including default plugins.
+ * It returns a concat with default plugins and remove disabled ones.
+ *
+ * @param {List<PluginDependency>} deps
+ * @return {List<PluginDependency>}
+ */
+function listDependencies(deps) {
+ // Extract list of plugins to disable (starting with -)
+ var toRemove = deps
+ .filter(function(plugin) {
+ return !plugin.isEnabled();
+ })
+ .map(function(plugin) {
+ return plugin.getName();
+ });
+
+ // Concat with default plugins
+ deps = deps.concat(DEFAULT_PLUGINS);
+
+ // Remove plugins
+ deps = deps.filterNot(function(plugin) {
+ return toRemove.includes(plugin.getName());
+ });
+
+ // Sort
+ return sortDependencies(deps);
+}
+
+module.exports = listDependencies;
diff --git a/packages/gitbook/lib/plugins/listDepsForBook.js b/packages/gitbook/lib/plugins/listDepsForBook.js
new file mode 100644
index 0000000..196e3aa
--- /dev/null
+++ b/packages/gitbook/lib/plugins/listDepsForBook.js
@@ -0,0 +1,18 @@
+var listDependencies = require('./listDependencies');
+
+/**
+ * List all plugin requirements for a book.
+ * It can be different from the final list of plugins,
+ * since plugins can have their own dependencies
+ *
+ * @param {Book}
+ * @return {List<PluginDependency>}
+ */
+function listDepsForBook(book) {
+ var config = book.getConfig();
+ var plugins = config.getPluginDependencies();
+
+ return listDependencies(plugins);
+}
+
+module.exports = listDepsForBook;
diff --git a/packages/gitbook/lib/plugins/listFilters.js b/packages/gitbook/lib/plugins/listFilters.js
new file mode 100644
index 0000000..4d8a471
--- /dev/null
+++ b/packages/gitbook/lib/plugins/listFilters.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+/**
+ List filters from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @return {Map<String:Function>}
+*/
+function listFilters(plugins) {
+ return plugins
+ .reverse()
+ .reduce(function(result, plugin) {
+ return result.merge(plugin.getFilters());
+ }, Immutable.Map());
+}
+
+module.exports = listFilters;
diff --git a/packages/gitbook/lib/plugins/listResources.js b/packages/gitbook/lib/plugins/listResources.js
new file mode 100644
index 0000000..fe31b5a
--- /dev/null
+++ b/packages/gitbook/lib/plugins/listResources.js
@@ -0,0 +1,45 @@
+var Immutable = require('immutable');
+var path = require('path');
+
+var LocationUtils = require('../utils/location');
+var PLUGIN_RESOURCES = require('../constants/pluginResources');
+
+/**
+ List all resources from a list of plugins
+
+ @param {OrderedMap<String:Plugin>}
+ @param {String} type
+ @return {Map<String:List<{url, path}>}
+*/
+function listResources(plugins, resources) {
+ return plugins.reduce(function(result, plugin) {
+ var npmId = plugin.getNpmID();
+ var pluginResources = resources.get(plugin.getName());
+
+ PLUGIN_RESOURCES.forEach(function(resourceType) {
+ var assets = pluginResources.get(resourceType);
+ if (!assets) return;
+
+ var list = result.get(resourceType) || Immutable.List();
+
+ assets = assets.map(function(assetFile) {
+ if (LocationUtils.isExternal(assetFile)) {
+ return {
+ url: assetFile
+ };
+ } else {
+ return {
+ path: LocationUtils.normalize(path.join(npmId, assetFile))
+ };
+ }
+ });
+
+ list = list.concat(assets);
+ result = result.set(resourceType, list);
+ });
+
+ return result;
+ }, Immutable.Map());
+}
+
+module.exports = listResources;
diff --git a/packages/gitbook/lib/plugins/loadForBook.js b/packages/gitbook/lib/plugins/loadForBook.js
new file mode 100644
index 0000000..757677e
--- /dev/null
+++ b/packages/gitbook/lib/plugins/loadForBook.js
@@ -0,0 +1,73 @@
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var listDepsForBook = require('./listDepsForBook');
+var findForBook = require('./findForBook');
+var loadPlugin = require('./loadPlugin');
+
+
+/**
+ * Load all plugins in a book
+ *
+ * @param {Book}
+ * @return {Promise<Map<String:Plugin>}
+ */
+function loadForBook(book) {
+ var logger = book.getLogger();
+
+ // List the dependencies
+ var requirements = listDepsForBook(book);
+
+ // List all plugins installed in the book
+ return findForBook(book)
+ .then(function(installedMap) {
+ var missing = [];
+ var plugins = requirements.reduce(function(result, dep) {
+ var name = dep.getName();
+ var installed = installedMap.get(name);
+
+ if (installed) {
+ var deps = installedMap
+ .filter(function(plugin) {
+ return plugin.getParent() === name;
+ })
+ .toArray();
+
+ result = result.concat(deps);
+ result.push(installed);
+ } else {
+ missing.push(name);
+ }
+
+ return result;
+ }, []);
+
+ // Convert plugins list to a map
+ plugins = Immutable.List(plugins)
+ .map(function(plugin) {
+ return [
+ plugin.getName(),
+ plugin
+ ];
+ });
+ plugins = Immutable.OrderedMap(plugins);
+
+ // Log state
+ logger.info.ln(installedMap.size + ' plugins are installed');
+ if (requirements.size != installedMap.size) {
+ logger.info.ln(requirements.size + ' explicitly listed');
+ }
+
+ // Verify that all plugins are present
+ if (missing.length > 0) {
+ throw new Error('Couldn\'t locate plugins "' + missing.join(', ') + '", Run \'gitbook install\' to install plugins from registry.');
+ }
+
+ return Promise.map(plugins, function(plugin) {
+ return loadPlugin(book, plugin);
+ });
+ });
+}
+
+
+module.exports = loadForBook;
diff --git a/packages/gitbook/lib/plugins/loadPlugin.js b/packages/gitbook/lib/plugins/loadPlugin.js
new file mode 100644
index 0000000..9ed83a1
--- /dev/null
+++ b/packages/gitbook/lib/plugins/loadPlugin.js
@@ -0,0 +1,89 @@
+var path = require('path');
+var resolve = require('resolve');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var timing = require('../utils/timing');
+
+var validatePlugin = require('./validatePlugin');
+
+// Return true if an error is a "module not found"
+// Wait on https://github.com/substack/node-resolve/pull/81 to be merged
+function isModuleNotFound(err) {
+ return err.code == 'MODULE_NOT_FOUND' || err.message.indexOf('Cannot find module') >= 0;
+}
+
+/**
+ Load a plugin in a book
+
+ @param {Book} book
+ @param {Plugin} plugin
+ @param {String} pkgPath (optional)
+ @return {Promise<Plugin>}
+*/
+function loadPlugin(book, plugin) {
+ var logger = book.getLogger();
+
+ var name = plugin.getName();
+ var pkgPath = plugin.getPath();
+
+ // Try loading plugins from different location
+ var p = Promise()
+ .then(function() {
+ var packageContent;
+ var packageMain;
+ var content;
+
+ // Locate plugin and load package.json
+ try {
+ var res = resolve.sync('./package.json', { basedir: pkgPath });
+
+ pkgPath = path.dirname(res);
+ packageContent = require(res);
+ } catch (err) {
+ if (!isModuleNotFound(err)) throw err;
+
+ packageContent = undefined;
+ content = undefined;
+
+ return;
+ }
+
+ // Locate the main package
+ try {
+ var indexJs = path.normalize(packageContent.main || 'index.js');
+ packageMain = resolve.sync('./' + indexJs, { basedir: pkgPath });
+ } catch (err) {
+ if (!isModuleNotFound(err)) throw err;
+ packageMain = undefined;
+ }
+
+ // Load plugin JS content
+ if (packageMain) {
+ try {
+ content = require(packageMain);
+ } catch(err) {
+ throw new error.PluginError(err, {
+ plugin: name
+ });
+ }
+ }
+
+ // Update plugin
+ return plugin.merge({
+ 'package': Immutable.fromJS(packageContent),
+ 'content': Immutable.fromJS(content || {})
+ });
+ })
+
+ .then(validatePlugin);
+
+ p = timing.measure('plugin.load', p);
+
+ logger.info('loading plugin "' + name + '"... ');
+ return logger.info.promise(p);
+}
+
+
+module.exports = loadPlugin;
diff --git a/packages/gitbook/lib/plugins/locateRootFolder.js b/packages/gitbook/lib/plugins/locateRootFolder.js
new file mode 100644
index 0000000..1139510
--- /dev/null
+++ b/packages/gitbook/lib/plugins/locateRootFolder.js
@@ -0,0 +1,22 @@
+var path = require('path');
+var resolve = require('resolve');
+
+var DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+
+/**
+ * Resolve the root folder containing for node_modules
+ * since gitbook can be used as a library and dependency can be flattened.
+ *
+ * @return {String} folderPath
+ */
+function locateRootFolder() {
+ var firstDefaultPlugin = DEFAULT_PLUGINS.first();
+ var pluginPath = resolve.sync(firstDefaultPlugin.getNpmID() + '/package.json', {
+ basedir: __dirname
+ });
+ var nodeModules = path.resolve(pluginPath, '../../..');
+
+ return nodeModules;
+}
+
+module.exports = locateRootFolder;
diff --git a/packages/gitbook/lib/plugins/resolveVersion.js b/packages/gitbook/lib/plugins/resolveVersion.js
new file mode 100644
index 0000000..61aef8d
--- /dev/null
+++ b/packages/gitbook/lib/plugins/resolveVersion.js
@@ -0,0 +1,71 @@
+var npm = require('npm');
+var semver = require('semver');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var Plugin = require('../models/plugin');
+var gitbook = require('../gitbook');
+
+var npmIsReady;
+
+/**
+ Initialize and prepare NPM
+
+ @return {Promise}
+*/
+function initNPM() {
+ if (npmIsReady) return npmIsReady;
+
+ npmIsReady = Promise.nfcall(npm.load, {
+ silent: true,
+ loglevel: 'silent'
+ });
+
+ return npmIsReady;
+}
+
+/**
+ Resolve a plugin dependency to a version
+
+ @param {PluginDependency} plugin
+ @return {Promise<String>}
+*/
+function resolveVersion(plugin) {
+ var npmId = Plugin.nameToNpmID(plugin.getName());
+ var requiredVersion = plugin.getVersion();
+
+ if (plugin.isGitDependency()) {
+ return Promise.resolve(requiredVersion);
+ }
+
+ return initNPM()
+ .then(function() {
+ return Promise.nfcall(npm.commands.view, [npmId + '@' + requiredVersion, 'engines'], true);
+ })
+ .then(function(versions) {
+ versions = Immutable.Map(versions).entrySeq();
+
+ var result = versions
+ .map(function(entry) {
+ return {
+ version: entry[0],
+ gitbook: (entry[1].engines || {}).gitbook
+ };
+ })
+ .filter(function(v) {
+ return v.gitbook && gitbook.satisfies(v.gitbook);
+ })
+ .sort(function(v1, v2) {
+ return semver.lt(v1.version, v2.version)? 1 : -1;
+ })
+ .get(0);
+
+ if (!result) {
+ return undefined;
+ } else {
+ return result.version;
+ }
+ });
+}
+
+module.exports = resolveVersion;
diff --git a/packages/gitbook/lib/plugins/sortDependencies.js b/packages/gitbook/lib/plugins/sortDependencies.js
new file mode 100644
index 0000000..7f10095
--- /dev/null
+++ b/packages/gitbook/lib/plugins/sortDependencies.js
@@ -0,0 +1,34 @@
+var Immutable = require('immutable');
+
+var THEME_PREFIX = require('../constants/themePrefix');
+
+var TYPE_PLUGIN = 'plugin';
+var TYPE_THEME = 'theme';
+
+
+/**
+ * Returns the type of a plugin given its name
+ * @param {Plugin} plugin
+ * @return {String}
+ */
+function pluginType(plugin) {
+ var name = plugin.getName();
+ return (name && name.indexOf(THEME_PREFIX) === 0) ? TYPE_THEME : TYPE_PLUGIN;
+}
+
+
+/**
+ * Sort the list of dependencies to match list in book.json
+ * The themes should always be loaded after the plugins
+ *
+ * @param {List<PluginDependency>} deps
+ * @return {List<PluginDependency>}
+ */
+function sortDependencies(plugins) {
+ var byTypes = plugins.groupBy(pluginType);
+
+ return byTypes.get(TYPE_PLUGIN, Immutable.List())
+ .concat(byTypes.get(TYPE_THEME, Immutable.List()));
+}
+
+module.exports = sortDependencies; \ No newline at end of file
diff --git a/packages/gitbook/lib/plugins/toNames.js b/packages/gitbook/lib/plugins/toNames.js
new file mode 100644
index 0000000..ad0dd8f
--- /dev/null
+++ b/packages/gitbook/lib/plugins/toNames.js
@@ -0,0 +1,16 @@
+
+/**
+ * Return list of plugin names. This method is nly used in unit tests.
+ *
+ * @param {OrderedMap<String:Plugin} plugins
+ * @return {Array<String>}
+ */
+function toNames(plugins) {
+ return plugins
+ .map(function(plugin) {
+ return plugin.getName();
+ })
+ .toArray();
+}
+
+module.exports = toNames;
diff --git a/packages/gitbook/lib/plugins/validateConfig.js b/packages/gitbook/lib/plugins/validateConfig.js
new file mode 100644
index 0000000..fab1fef
--- /dev/null
+++ b/packages/gitbook/lib/plugins/validateConfig.js
@@ -0,0 +1,71 @@
+var Immutable = require('immutable');
+var jsonschema = require('jsonschema');
+var jsonSchemaDefaults = require('json-schema-defaults');
+
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var mergeDefaults = require('../utils/mergeDefaults');
+
+/**
+ Validate one plugin for a book and update book's confiration
+
+ @param {Book}
+ @param {Plugin}
+ @return {Book}
+*/
+function validatePluginConfig(book, plugin) {
+ var config = book.getConfig();
+ var packageInfos = plugin.getPackage();
+
+ var configKey = [
+ 'pluginsConfig',
+ plugin.getName()
+ ].join('.');
+
+ var pluginConfig = config.getValue(configKey, {}).toJS();
+
+ var schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS();
+ if (!schema) return book;
+
+ // Normalize schema
+ schema.id = '/' + configKey;
+ schema.type = 'object';
+
+ // Validate and throw if invalid
+ var v = new jsonschema.Validator();
+ var result = v.validate(pluginConfig, schema, {
+ propertyName: configKey
+ });
+
+ // Throw error
+ if (result.errors.length > 0) {
+ throw new error.ConfigurationError(new Error(result.errors[0].stack));
+ }
+
+ // Insert default values
+ var defaults = jsonSchemaDefaults(schema);
+ pluginConfig = mergeDefaults(pluginConfig, defaults);
+
+
+ // Update configuration
+ config = config.setValue(configKey, pluginConfig);
+
+ // Return new book
+ return book.set('config', config);
+}
+
+/**
+ Validate a book configuration for plugins and
+ returns an update configuration with default values.
+
+ @param {Book}
+ @param {OrderedMap<String:Plugin>}
+ @return {Promise<Book>}
+*/
+function validateConfig(book, plugins) {
+ return Promise.reduce(plugins, function(newBook, plugin) {
+ return validatePluginConfig(newBook, plugin);
+ }, book);
+}
+
+module.exports = validateConfig;
diff --git a/packages/gitbook/lib/plugins/validatePlugin.js b/packages/gitbook/lib/plugins/validatePlugin.js
new file mode 100644
index 0000000..4baa911
--- /dev/null
+++ b/packages/gitbook/lib/plugins/validatePlugin.js
@@ -0,0 +1,34 @@
+var gitbook = require('../gitbook');
+
+var Promise = require('../utils/promise');
+
+/**
+ Validate a plugin
+
+ @param {Plugin}
+ @return {Promise<Plugin>}
+*/
+function validatePlugin(plugin) {
+ var packageInfos = plugin.getPackage();
+
+ var isValid = (
+ plugin.isLoaded() &&
+ packageInfos &&
+ packageInfos.get('name') &&
+ packageInfos.get('engines') &&
+ packageInfos.get('engines').get('gitbook')
+ );
+
+ if (!isValid) {
+ return Promise.reject(new Error('Error loading plugin "' + plugin.getName() + '" at "' + plugin.getPath() + '"'));
+ }
+
+ var engine = packageInfos.get('engines').get('gitbook');
+ if (!gitbook.satisfies(engine)) {
+ return Promise.reject(new Error('GitBook doesn\'t satisfy the requirements of this plugin: ' + engine));
+ }
+
+ return Promise(plugin);
+}
+
+module.exports = validatePlugin;
diff --git a/packages/gitbook/lib/templating/__tests__/conrefsLoader.js b/packages/gitbook/lib/templating/__tests__/conrefsLoader.js
new file mode 100644
index 0000000..196b513
--- /dev/null
+++ b/packages/gitbook/lib/templating/__tests__/conrefsLoader.js
@@ -0,0 +1,98 @@
+var path = require('path');
+
+var TemplateEngine = require('../../models/templateEngine');
+var renderTemplate = require('../render');
+var ConrefsLoader = require('../conrefsLoader');
+
+describe('ConrefsLoader', function() {
+ var dirName = __dirname + '/';
+ var fileName = path.join(dirName, 'test.md');
+
+ describe('Git', function() {
+ var engine = TemplateEngine({
+ loader: new ConrefsLoader(dirName)
+ });
+
+ it('should include content from git', function() {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('Hello from git');
+ });
+ });
+
+ it('should handle deep inclusion (1)', function() {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('First Hello. Hello from git');
+ });
+ });
+
+ it('should handle deep inclusion (2)', function() {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('First Hello. Hello from git');
+ });
+ });
+ });
+
+ describe('Local', function() {
+ var engine = TemplateEngine({
+ loader: new ConrefsLoader(dirName)
+ });
+
+ describe('Relative', function() {
+ it('should resolve basic relative filepath', function() {
+ return renderTemplate(engine, fileName, '{% include "include.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('Hello World');
+ });
+ });
+
+ it('should resolve basic parent filepath', function() {
+ return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "../include.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('Hello World');
+ });
+ });
+ });
+
+ describe('Absolute', function() {
+ it('should resolve absolute filepath', function() {
+ return renderTemplate(engine, fileName, '{% include "/include.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('Hello World');
+ });
+ });
+
+ it('should resolve absolute filepath when in a directory', function() {
+ return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "/include.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('Hello World');
+ });
+ });
+ });
+ });
+
+ describe('transform', function() {
+ function transform(filePath, source) {
+ expect(filePath).toBeA('string');
+ expect(source).toBeA('string');
+
+ expect(filePath).toBe(path.resolve(__dirname, 'include.md'));
+ expect(source).toBe('Hello World');
+
+ return 'test-' + source + '-endtest';
+ }
+ var engine = TemplateEngine({
+ loader: new ConrefsLoader(dirName, transform)
+ });
+
+ it('should transform included content', function() {
+ return renderTemplate(engine, fileName, '{% include "include.md" %}')
+ .then(function(out) {
+ expect(out.getContent()).toBe('test-Hello World-endtest');
+ });
+ });
+ });
+});
+
diff --git a/packages/gitbook/lib/templating/__tests__/include.md b/packages/gitbook/lib/templating/__tests__/include.md
new file mode 100644
index 0000000..5e1c309
--- /dev/null
+++ b/packages/gitbook/lib/templating/__tests__/include.md
@@ -0,0 +1 @@
+Hello World \ No newline at end of file
diff --git a/packages/gitbook/lib/templating/__tests__/postRender.js b/packages/gitbook/lib/templating/__tests__/postRender.js
new file mode 100644
index 0000000..131e29b
--- /dev/null
+++ b/packages/gitbook/lib/templating/__tests__/postRender.js
@@ -0,0 +1,51 @@
+var TemplateEngine = require('../../models/templateEngine');
+var TemplateBlock = require('../../models/templateBlock');
+
+var renderTemplate = require('../render');
+var postRender = require('../postRender');
+
+describe('postRender', function() {
+ var testPost;
+ var engine = TemplateEngine.create({
+ blocks: [
+ TemplateBlock.create('lower', function(blk) {
+ return blk.body.toLowerCase();
+ }),
+ TemplateBlock.create('prefix', function(blk) {
+ return {
+ body: '_' + blk.body + '_',
+ post: function() {
+ testPost = true;
+ }
+ };
+ })
+ ]
+ });
+
+ it('should correctly replace block', function() {
+ return renderTemplate(engine, 'README.md', 'Hello {% lower %}Samy{% endlower %}')
+ .then(function(output) {
+ expect(output.getContent()).toMatch(/Hello \{\{\-([\S]+)\-\}\}/);
+ expect(output.getBlocks().size).toBe(1);
+
+ return postRender(engine, output);
+ })
+ .then(function(result) {
+ expect(result).toBe('Hello samy');
+ });
+ });
+
+ it('should correctly replace blocks', function() {
+ return renderTemplate(engine, 'README.md', 'Hello {% lower %}Samy{% endlower %}{% prefix %}Pesse{% endprefix %}')
+ .then(function(output) {
+ expect(output.getContent()).toMatch(/Hello \{\{\-([\S]+)\-\}\}\{\{\-([\S]+)\-\}\}/);
+ expect(output.getBlocks().size).toBe(2);
+ return postRender(engine, output);
+ })
+ .then(function(result) {
+ expect(result).toBe('Hello samy_Pesse_');
+ expect(testPost).toBe(true);
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js b/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js
new file mode 100644
index 0000000..216a1c8
--- /dev/null
+++ b/packages/gitbook/lib/templating/__tests__/replaceShortcuts.js
@@ -0,0 +1,27 @@
+var Immutable = require('immutable');
+
+var TemplateBlock = require('../../models/templateBlock');
+var replaceShortcuts = require('../replaceShortcuts');
+
+describe('replaceShortcuts', function() {
+ var blocks = Immutable.List([
+ TemplateBlock.create('math', {
+ shortcuts: {
+ start: '$$',
+ end: '$$',
+ parsers: ['markdown']
+ }
+ })
+ ]);
+
+ it('should correctly replace inline matches by block', function() {
+ var content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$');
+ expect(content).toBe('Hello {% math %}a = b{% endmath %}');
+ });
+
+ it('should correctly replace block matches', function() {
+ var content = replaceShortcuts(blocks, 'test.md', 'Hello\n$$\na = b\n$$\n');
+ expect(content).toBe('Hello\n{% math %}\na = b\n{% endmath %}\n');
+ });
+});
+
diff --git a/packages/gitbook/lib/templating/conrefsLoader.js b/packages/gitbook/lib/templating/conrefsLoader.js
new file mode 100644
index 0000000..b3cdb3f
--- /dev/null
+++ b/packages/gitbook/lib/templating/conrefsLoader.js
@@ -0,0 +1,93 @@
+var path = require('path');
+var nunjucks = require('nunjucks');
+
+var fs = require('../utils/fs');
+var Git = require('../utils/git');
+var LocationUtils = require('../utils/location');
+var PathUtils = require('../utils/path');
+
+
+/**
+ * Template loader resolving both:
+ * - relative url ("./test.md")
+ * - absolute url ("/test.md")
+ * - git url ("")
+ *
+ * @param {String} rootFolder
+ * @param {Function(filePath, source)} transformFn (optional)
+ * @param {Logger} logger (optional)
+ */
+var ConrefsLoader = nunjucks.Loader.extend({
+ async: true,
+
+ init: function(rootFolder, transformFn, logger) {
+ this.rootFolder = rootFolder;
+ this.transformFn = transformFn;
+ this.logger = logger;
+ this.git = new Git();
+ },
+
+ getSource: function(sourceURL, callback) {
+ var that = this;
+
+ this.git.resolve(sourceURL)
+ .then(function(filepath) {
+ // Is local file
+ if (!filepath) {
+ filepath = path.resolve(sourceURL);
+ } else {
+ if (that.logger) that.logger.debug.ln('resolve from git', sourceURL, 'to', filepath);
+ }
+
+ // Read file from absolute path
+ return fs.readFile(filepath)
+ .then(function(source) {
+ source = source.toString('utf8');
+
+ if (that.transformFn) {
+ return that.transformFn(filepath, source);
+ }
+
+ return source;
+ })
+ .then(function(source) {
+ return {
+ src: source,
+ path: filepath
+ };
+ });
+ })
+ .nodeify(callback);
+ },
+
+ resolve: function(from, to) {
+ // If origin is in the book, we enforce result file to be in the book
+ if (PathUtils.isInRoot(this.rootFolder, from)) {
+
+ // Path of current template in the rootFolder (not absolute to fs)
+ var fromRelative = path.relative(this.rootFolder, from);
+
+ // Resolve "to" to a filepath relative to rootFolder
+ var href = LocationUtils.toAbsolute(to, path.dirname(fromRelative), '');
+
+ // Return absolute path
+ return PathUtils.resolveInRoot(this.rootFolder, href);
+ }
+
+ // If origin is in a git repository, we resolve file in the git repository
+ var gitRoot = this.git.resolveRoot(from);
+ if (gitRoot) {
+ return PathUtils.resolveInRoot(gitRoot, to);
+ }
+
+ // If origin is not in the book (include from a git content ref)
+ return path.resolve(path.dirname(from), to);
+ },
+
+ // Handle all files as relative, so that nunjucks pass responsability to 'resolve'
+ isRelative: function(filename) {
+ return LocationUtils.isRelative(filename);
+ }
+});
+
+module.exports = ConrefsLoader;
diff --git a/packages/gitbook/lib/templating/index.js b/packages/gitbook/lib/templating/index.js
new file mode 100644
index 0000000..bd74aca
--- /dev/null
+++ b/packages/gitbook/lib/templating/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ render: require('./render'),
+ renderFile: require('./renderFile'),
+ postRender: require('./postRender'),
+ replaceShortcuts: require('./replaceShortcuts'),
+
+ ConrefsLoader: require('./conrefsLoader'),
+ ThemesLoader: require('./themesLoader')
+};
diff --git a/packages/gitbook/lib/templating/listShortcuts.js b/packages/gitbook/lib/templating/listShortcuts.js
new file mode 100644
index 0000000..8d0a64a
--- /dev/null
+++ b/packages/gitbook/lib/templating/listShortcuts.js
@@ -0,0 +1,31 @@
+var Immutable = require('immutable');
+var parsers = require('../parsers');
+
+/**
+ * Return a list of all shortcuts that can apply
+ * to a file for a TemplatEngine
+ *
+ * @param {List<TemplateBlock>} engine
+ * @param {String} filePath
+ * @return {List<TemplateShortcut>}
+ */
+function listShortcuts(blocks, filePath) {
+ var parser = parsers.getForFile(filePath);
+
+ if (!parser) {
+ return Immutable.List();
+ }
+
+ return blocks
+ .map(function(block) {
+ return block.getShortcuts();
+ })
+ .filter(function(shortcuts) {
+ return (
+ shortcuts &&
+ shortcuts.acceptParser(parser.getName())
+ );
+ });
+}
+
+module.exports = listShortcuts;
diff --git a/packages/gitbook/lib/templating/postRender.js b/packages/gitbook/lib/templating/postRender.js
new file mode 100644
index 0000000..f464f86
--- /dev/null
+++ b/packages/gitbook/lib/templating/postRender.js
@@ -0,0 +1,53 @@
+var Promise = require('../utils/promise');
+
+
+/**
+ * Replace position markers of blocks by body after processing
+ * This is done to avoid that markdown/asciidoc processer parse the block content
+ *
+ * @param {String} content
+ * @return {Object} {blocks: Set, content: String}
+ */
+function replaceBlocks(content, blocks) {
+ var newContent = content.replace(/\{\{\-\%([\s\S]+?)\%\-\}\}/g, function(match, key) {
+ var replacedWith = match;
+
+ var block = blocks.get(key);
+ if (block) {
+ replacedWith = replaceBlocks(block.get('body'), blocks);
+ }
+
+ return replacedWith;
+ });
+
+ return newContent;
+}
+
+/**
+ * Post render a template:
+ * - Execute "post" for blocks
+ * - Replace block content
+ *
+ * @param {TemplateEngine} engine
+ * @param {TemplateOutput} content
+ * @return {Promise<String>}
+ */
+function postRender(engine, output) {
+ var content = output.getContent();
+ var blocks = output.getBlocks();
+
+ var result = replaceBlocks(content, blocks);
+
+ return Promise.forEach(blocks, function(block) {
+ var post = block.get('post');
+
+ if (!post) {
+ return;
+ }
+
+ return post();
+ })
+ .thenResolve(result);
+}
+
+module.exports = postRender;
diff --git a/packages/gitbook/lib/templating/render.js b/packages/gitbook/lib/templating/render.js
new file mode 100644
index 0000000..1a8b0cd
--- /dev/null
+++ b/packages/gitbook/lib/templating/render.js
@@ -0,0 +1,44 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var TemplateOutput = require('../models/templateOutput');
+var replaceShortcuts = require('./replaceShortcuts');
+
+/**
+ * Render a template
+ *
+ * @param {TemplateEngine} engine
+ * @param {String} filePath: absolute path for the loader
+ * @param {String} content
+ * @param {Object} context (optional)
+ * @return {Promise<TemplateOutput>}
+ */
+function renderTemplate(engine, filePath, content, context) {
+ context = context || {};
+
+ // Mutable objects to contains all blocks requiring post-processing
+ var blocks = {};
+
+ // Create nunjucks environment
+ var env = engine.toNunjucks(blocks);
+
+ // Replace shortcuts from plugin's blocks
+ content = replaceShortcuts(engine.getBlocks(), filePath, content);
+
+ return timing.measure(
+ 'template.render',
+
+ Promise.nfcall(
+ env.renderString.bind(env),
+ content,
+ context,
+ {
+ path: filePath
+ }
+ )
+ .then(function(content) {
+ return TemplateOutput.create(content, blocks);
+ })
+ );
+}
+
+module.exports = renderTemplate;
diff --git a/packages/gitbook/lib/templating/renderFile.js b/packages/gitbook/lib/templating/renderFile.js
new file mode 100644
index 0000000..8672e8b
--- /dev/null
+++ b/packages/gitbook/lib/templating/renderFile.js
@@ -0,0 +1,41 @@
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var render = require('./render');
+
+/**
+ * Render a template
+ *
+ * @param {TemplateEngine} engine
+ * @param {String} filePath
+ * @param {Object} context
+ * @return {Promise<TemplateOutput>}
+ */
+function renderTemplateFile(engine, filePath, context) {
+ var loader = engine.getLoader();
+
+ // Resolve the filePath
+ var resolvedFilePath = loader.resolve(null, filePath);
+
+ return Promise()
+ .then(function() {
+ if (!loader.async) {
+ return loader.getSource(resolvedFilePath);
+ }
+
+ var deferred = Promise.defer();
+ loader.getSource(resolvedFilePath, deferred.makeNodeResolver());
+ return deferred.promise;
+ })
+ .then(function(result) {
+ if (!result) {
+ throw error.TemplateError(new Error('Not found'), {
+ filename: filePath
+ });
+ }
+
+ return render(engine, result.path, result.src, context);
+ });
+
+}
+
+module.exports = renderTemplateFile;
diff --git a/packages/gitbook/lib/templating/replaceShortcuts.js b/packages/gitbook/lib/templating/replaceShortcuts.js
new file mode 100644
index 0000000..1cfdbf0
--- /dev/null
+++ b/packages/gitbook/lib/templating/replaceShortcuts.js
@@ -0,0 +1,39 @@
+var escapeStringRegexp = require('escape-string-regexp');
+var listShortcuts = require('./listShortcuts');
+
+/**
+ * Apply a shortcut of block to a template
+ * @param {String} content
+ * @param {Shortcut} shortcut
+ * @return {String}
+ */
+function applyShortcut(content, shortcut) {
+ var start = shortcut.getStart();
+ var end = shortcut.getEnd();
+
+ var tagStart = shortcut.getStartTag();
+ var tagEnd = shortcut.getEndTag();
+
+ var regex = new RegExp(
+ escapeStringRegexp(start) + '([\\s\\S]*?[^\\$])' + escapeStringRegexp(end),
+ 'g'
+ );
+ return content.replace(regex, function(all, match) {
+ return '{% ' + tagStart + ' %}' + match + '{% ' + tagEnd + ' %}';
+ });
+}
+
+/**
+ * Replace shortcuts from blocks in a string
+ *
+ * @param {List<TemplateBlock>} engine
+ * @param {String} filePath
+ * @param {String} content
+ * @return {String}
+ */
+function replaceShortcuts(blocks, filePath, content) {
+ var shortcuts = listShortcuts(blocks, filePath);
+ return shortcuts.reduce(applyShortcut, content);
+}
+
+module.exports = replaceShortcuts;
diff --git a/packages/gitbook/lib/templating/themesLoader.js b/packages/gitbook/lib/templating/themesLoader.js
new file mode 100644
index 0000000..bae4c12
--- /dev/null
+++ b/packages/gitbook/lib/templating/themesLoader.js
@@ -0,0 +1,115 @@
+var Immutable = require('immutable');
+var nunjucks = require('nunjucks');
+var fs = require('fs');
+var path = require('path');
+
+var PathUtils = require('../utils/path');
+
+
+var ThemesLoader = nunjucks.Loader.extend({
+ init: function(searchPaths) {
+ this.searchPaths = Immutable.List(searchPaths)
+ .map(path.normalize);
+ },
+
+ /**
+ * Read source of a resolved filepath
+ * @param {String}
+ * @return {Object}
+ */
+ getSource: function(fullpath) {
+ if (!fullpath) return null;
+
+ fullpath = this.resolve(null, fullpath);
+ var templateName = this.getTemplateName(fullpath);
+
+ if(!fullpath) {
+ return null;
+ }
+
+ var src = fs.readFileSync(fullpath, 'utf-8');
+
+ src = '{% do %}var template = template || {}; template.stack = template.stack || []; template.stack.push(template.self); template.self = ' + JSON.stringify(templateName) + '{% enddo %}\n' +
+ src +
+ '\n{% do %}template.self = template.stack.pop();{% enddo %}';
+
+ return {
+ src: src,
+ path: fullpath,
+ noCache: true
+ };
+ },
+
+ /**
+ * Nunjucks calls "isRelative" to determine when to call "resolve".
+ * We handle absolute paths ourselves in ".resolve" so we always return true
+ */
+ isRelative: function() {
+ return true;
+ },
+
+ /**
+ * Get original search path containing a template
+ * @param {String} filepath
+ * @return {String} searchPath
+ */
+ getSearchPath: function(filepath) {
+ return this.searchPaths
+ .sortBy(function(s) {
+ return -s.length;
+ })
+ .find(function(basePath) {
+ return (filepath && filepath.indexOf(basePath) === 0);
+ });
+ },
+
+ /**
+ * Get template name from a filepath
+ * @param {String} filepath
+ * @return {String} name
+ */
+ getTemplateName: function(filepath) {
+ var originalSearchPath = this.getSearchPath(filepath);
+ return originalSearchPath? path.relative(originalSearchPath, filepath) : null;
+ },
+
+ /**
+ * Resolve a template from a current template
+ * @param {String|null} from
+ * @param {String} to
+ * @return {String|null}
+ */
+ resolve: function(from, to) {
+ var searchPaths = this.searchPaths;
+
+ // Relative template like "./test.html"
+ if (PathUtils.isPureRelative(to) && from) {
+ return path.resolve(path.dirname(from), to);
+ }
+
+ // Determine in which search folder we currently are
+ var originalSearchPath = this.getSearchPath(from);
+ var originalFilename = this.getTemplateName(from);
+
+ // If we are including same file from a different search path
+ // Slice the search paths to avoid including from previous ones
+ if (originalFilename == to) {
+ var currentIndex = searchPaths.indexOf(originalSearchPath);
+ searchPaths = searchPaths.slice(currentIndex + 1);
+ }
+
+ // Absolute template to resolve in root folder
+ var resultFolder = searchPaths.find(function(basePath) {
+ var p = path.resolve(basePath, to);
+
+ return (
+ p.indexOf(basePath) === 0
+ && fs.existsSync(p)
+ );
+ });
+ if (!resultFolder) return null;
+ return path.resolve(resultFolder, to);
+ }
+});
+
+module.exports = ThemesLoader;
diff --git a/packages/gitbook/lib/utils/__tests__/git.js b/packages/gitbook/lib/utils/__tests__/git.js
new file mode 100644
index 0000000..abc1ea1
--- /dev/null
+++ b/packages/gitbook/lib/utils/__tests__/git.js
@@ -0,0 +1,57 @@
+var path = require('path');
+var os = require('os');
+
+var Git = require('../git');
+
+describe('Git', function() {
+
+ describe('URL parsing', function() {
+
+ it('should correctly validate git urls', function() {
+ // HTTPS
+ expect(Git.isUrl('git+https://github.com/Hello/world.git')).toBeTruthy();
+
+ // SSH
+ expect(Git.isUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1')).toBeTruthy();
+
+ // Non valid
+ expect(Git.isUrl('https://github.com/Hello/world.git')).toBeFalsy();
+ expect(Git.isUrl('README.md')).toBeFalsy();
+ });
+
+ it('should parse HTTPS urls', function() {
+ var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md');
+
+ expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git');
+ expect(parts.ref).toBe(null);
+ expect(parts.filepath).toBe('test.md');
+ });
+
+ it('should parse HTTPS urls with a reference', function() {
+ var parts = Git.parseUrl('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md#1.0.0');
+
+ expect(parts.host).toBe('https://gist.github.com/69ea4542e4c8967d2fa7.git');
+ expect(parts.ref).toBe('1.0.0');
+ expect(parts.filepath).toBe('test.md');
+ });
+
+ it('should parse SSH urls', function() {
+ var parts = Git.parseUrl('git+git@github.com:GitbookIO/gitbook.git/directory/README.md#e1594cde2c32e4ff48f6c4eff3d3d461743d74e1');
+
+ expect(parts.host).toBe('git@github.com:GitbookIO/gitbook.git');
+ expect(parts.ref).toBe('e1594cde2c32e4ff48f6c4eff3d3d461743d74e1');
+ expect(parts.filepath).toBe('directory/README.md');
+ });
+ });
+
+ describe('Cloning and resolving', function() {
+ it('should clone an HTTPS url', function() {
+ var git = new Git(path.join(os.tmpdir(), 'test-git-'+Date.now()));
+ return git.resolve('git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md')
+ .then(function(filename) {
+ expect(path.extname(filename)).toBe('.md');
+ });
+ });
+ });
+
+});
diff --git a/packages/gitbook/lib/utils/__tests__/location.js b/packages/gitbook/lib/utils/__tests__/location.js
new file mode 100644
index 0000000..822338e
--- /dev/null
+++ b/packages/gitbook/lib/utils/__tests__/location.js
@@ -0,0 +1,100 @@
+var LocationUtils = require('../location');
+
+describe('LocationUtils', function() {
+ it('should correctly test external location', function() {
+ expect(LocationUtils.isExternal('http://google.fr')).toBe(true);
+ expect(LocationUtils.isExternal('https://google.fr')).toBe(true);
+ expect(LocationUtils.isExternal('test.md')).toBe(false);
+ expect(LocationUtils.isExternal('folder/test.md')).toBe(false);
+ expect(LocationUtils.isExternal('/folder/test.md')).toBe(false);
+ expect(LocationUtils.isExternal('data:image/png')).toBe(false);
+ });
+
+ it('should correctly test data:uri location', function() {
+ expect(LocationUtils.isDataURI('data:image/png')).toBe(true);
+ expect(LocationUtils.isDataURI('http://google.fr')).toBe(false);
+ expect(LocationUtils.isDataURI('https://google.fr')).toBe(false);
+ expect(LocationUtils.isDataURI('test.md')).toBe(false);
+ expect(LocationUtils.isDataURI('data.md')).toBe(false);
+ });
+
+ it('should correctly detect anchor location', function() {
+ expect(LocationUtils.isAnchor('#test')).toBe(true);
+ expect(LocationUtils.isAnchor(' #test')).toBe(true);
+ expect(LocationUtils.isAnchor('https://google.fr#test')).toBe(false);
+ expect(LocationUtils.isAnchor('test.md#test')).toBe(false);
+ });
+
+ describe('.relative', function() {
+ it('should resolve to a relative path (same folder)', function() {
+ expect(LocationUtils.relative('links/', 'links/test.md')).toBe('test.md');
+ });
+
+ it('should resolve to a relative path (parent folder)', function() {
+ expect(LocationUtils.relative('links/', 'test.md')).toBe('../test.md');
+ });
+
+ it('should resolve to a relative path (child folder)', function() {
+ expect(LocationUtils.relative('links/', 'links/hello/test.md')).toBe('hello/test.md');
+ });
+ });
+
+ describe('.flatten', function() {
+ it('should remove leading slash', function() {
+ expect(LocationUtils.flatten('/test.md')).toBe('test.md');
+ expect(LocationUtils.flatten('/hello/cool.md')).toBe('hello/cool.md');
+ });
+
+ it('should remove leading slashes', function() {
+ expect(LocationUtils.flatten('///test.md')).toBe('test.md');
+ });
+
+ it('should not break paths', function() {
+ expect(LocationUtils.flatten('hello/cool.md')).toBe('hello/cool.md');
+ });
+ });
+
+ describe('.toAbsolute', function() {
+ it('should correctly transform as absolute', function() {
+ expect(LocationUtils.toAbsolute('http://google.fr')).toBe('http://google.fr');
+ expect(LocationUtils.toAbsolute('test.md', './', './')).toBe('test.md');
+ expect(LocationUtils.toAbsolute('folder/test.md', './', './')).toBe('folder/test.md');
+ });
+
+ it('should correctly handle windows path', function() {
+ expect(LocationUtils.toAbsolute('folder\\test.md', './', './')).toBe('folder/test.md');
+ });
+
+ it('should correctly handle absolute path', function() {
+ expect(LocationUtils.toAbsolute('/test.md', './', './')).toBe('test.md');
+ expect(LocationUtils.toAbsolute('/test.md', 'test', 'test')).toBe('../test.md');
+ expect(LocationUtils.toAbsolute('/sub/test.md', 'test', 'test')).toBe('../sub/test.md');
+ expect(LocationUtils.toAbsolute('/test.png', 'folder', '')).toBe('test.png');
+ });
+
+ it('should correctly handle absolute path (windows)', function() {
+ expect(LocationUtils.toAbsolute('\\test.png', 'folder', '')).toBe('test.png');
+ });
+
+ it('should resolve path starting by "/" in root directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './', './')
+ ).toBe('test/hello.md');
+ });
+
+ it('should resolve path starting by "/" in child directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './hello', './')
+ ).toBe('test/hello.md');
+ });
+
+ it('should resolve path starting by "/" in child directory, with same output directory', function() {
+ expect(
+ LocationUtils.toAbsolute('/test/hello.md', './hello', './hello')
+ ).toBe('../test/hello.md');
+ });
+ });
+
+});
+
+
diff --git a/packages/gitbook/lib/utils/__tests__/path.js b/packages/gitbook/lib/utils/__tests__/path.js
new file mode 100644
index 0000000..22bb016
--- /dev/null
+++ b/packages/gitbook/lib/utils/__tests__/path.js
@@ -0,0 +1,17 @@
+var path = require('path');
+
+describe('Paths', function() {
+ var PathUtils = require('..//path');
+
+ describe('setExtension', function() {
+ it('should correctly change extension of filename', function() {
+ expect(PathUtils.setExtension('test.md', '.html')).toBe('test.html');
+ expect(PathUtils.setExtension('test.md', '.json')).toBe('test.json');
+ });
+
+ it('should correctly change extension of path', function() {
+ expect(PathUtils.setExtension('hello/test.md', '.html')).toBe(path.normalize('hello/test.html'));
+ expect(PathUtils.setExtension('hello/test.md', '.json')).toBe(path.normalize('hello/test.json'));
+ });
+ });
+});
diff --git a/packages/gitbook/lib/utils/command.js b/packages/gitbook/lib/utils/command.js
new file mode 100644
index 0000000..90a556e
--- /dev/null
+++ b/packages/gitbook/lib/utils/command.js
@@ -0,0 +1,118 @@
+var is = require('is');
+var childProcess = require('child_process');
+var spawn = require('spawn-cmd').spawn;
+var Promise = require('./promise');
+
+/**
+ Execute a command
+
+ @param {String} command
+ @param {Object} options
+ @return {Promise}
+*/
+function exec(command, options) {
+ var d = Promise.defer();
+
+ var child = childProcess.exec(command, options, function(err, stdout, stderr) {
+ if (!err) {
+ return d.resolve();
+ }
+
+ err.message = stdout.toString('utf8') + stderr.toString('utf8');
+ d.reject(err);
+ });
+
+ child.stdout.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.stderr.on('data', function (data) {
+ d.notify(data);
+ });
+
+ return d.promise;
+}
+
+/**
+ Spawn an executable
+
+ @param {String} command
+ @param {Array} args
+ @param {Object} options
+ @return {Promise}
+*/
+function spawnCmd(command, args, options) {
+ var d = Promise.defer();
+ var child = spawn(command, args, options);
+
+ child.on('error', function(error) {
+ return d.reject(error);
+ });
+
+ child.stdout.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.stderr.on('data', function (data) {
+ d.notify(data);
+ });
+
+ child.on('close', function(code) {
+ if (code === 0) {
+ d.resolve();
+ } else {
+ d.reject(new Error('Error with command "'+command+'"'));
+ }
+ });
+
+ return d.promise;
+}
+
+/**
+ Transform an option object to a command line string
+
+ @param {String|number} value
+ @param {String}
+*/
+function escapeShellArg(value) {
+ if (is.number(value)) {
+ return value;
+ }
+
+ value = String(value);
+ value = value.replace(/"/g, '\\"');
+
+ return '"' + value + '"';
+}
+
+/**
+ Transform a map of options into a command line arguments string
+
+ @param {Object} options
+ @return {String}
+*/
+function optionsToShellArgs(options) {
+ var result = [];
+
+ for (var key in options) {
+ var value = options[key];
+
+ if (value === null || value === undefined || value === false) {
+ continue;
+ }
+
+ if (is.bool(value)) {
+ result.push(key);
+ } else {
+ result.push(key + '=' + escapeShellArg(value));
+ }
+ }
+
+ return result.join(' ');
+}
+
+module.exports = {
+ exec: exec,
+ spawn: spawnCmd,
+ optionsToShellArgs: optionsToShellArgs
+};
diff --git a/packages/gitbook/lib/utils/error.js b/packages/gitbook/lib/utils/error.js
new file mode 100644
index 0000000..7686779
--- /dev/null
+++ b/packages/gitbook/lib/utils/error.js
@@ -0,0 +1,99 @@
+var is = require('is');
+
+var TypedError = require('error/typed');
+var WrappedError = require('error/wrapped');
+
+
+// Enforce as an Error object, and cleanup message
+function enforce(err) {
+ if (is.string(err)) err = new Error(err);
+ err.message = err.message.replace(/^Error: /, '');
+
+ return err;
+}
+
+// Random error wrappers during parsing/generation
+var ParsingError = WrappedError({
+ message: 'Parsing Error: {origMessage}',
+ type: 'parse'
+});
+var OutputError = WrappedError({
+ message: 'Output Error: {origMessage}',
+ type: 'generate'
+});
+
+// A file does not exists
+var FileNotFoundError = TypedError({
+ type: 'file.not-found',
+ message: 'No "{filename}" file (or is ignored)',
+ filename: null
+});
+
+// A file cannot be parsed
+var FileNotParsableError = TypedError({
+ type: 'file.not-parsable',
+ message: '"{filename}" file cannot be parsed',
+ filename: null
+});
+
+// A file is outside the scope
+var FileOutOfScopeError = TypedError({
+ type: 'file.out-of-scope',
+ message: '"{filename}" not in "{root}"',
+ filename: null,
+ root: null,
+ code: 'EACCESS'
+});
+
+// A file is outside the scope
+var RequireInstallError = TypedError({
+ type: 'install.required',
+ message: '"{cmd}" is not installed.\n{install}',
+ cmd: null,
+ code: 'ENOENT',
+ install: ''
+});
+
+// Error for nunjucks templates
+var TemplateError = WrappedError({
+ message: 'Error compiling template "{filename}": {origMessage}',
+ type: 'template',
+ filename: null
+});
+
+// Error for nunjucks templates
+var PluginError = WrappedError({
+ message: 'Error with plugin "{plugin}": {origMessage}',
+ type: 'plugin',
+ plugin: null
+});
+
+// Error with the book's configuration
+var ConfigurationError = WrappedError({
+ message: 'Error with book\'s configuration: {origMessage}',
+ type: 'configuration'
+});
+
+// Error during ebook generation
+var EbookError = WrappedError({
+ message: 'Error during ebook generation: {origMessage}\n{stdout}',
+ type: 'ebook',
+ stdout: ''
+});
+
+module.exports = {
+ enforce: enforce,
+
+ ParsingError: ParsingError,
+ OutputError: OutputError,
+ RequireInstallError: RequireInstallError,
+
+ FileNotParsableError: FileNotParsableError,
+ FileNotFoundError: FileNotFoundError,
+ FileOutOfScopeError: FileOutOfScopeError,
+
+ TemplateError: TemplateError,
+ PluginError: PluginError,
+ ConfigurationError: ConfigurationError,
+ EbookError: EbookError
+};
diff --git a/packages/gitbook/lib/utils/fs.js b/packages/gitbook/lib/utils/fs.js
new file mode 100644
index 0000000..35839a3
--- /dev/null
+++ b/packages/gitbook/lib/utils/fs.js
@@ -0,0 +1,170 @@
+var fs = require('graceful-fs');
+var mkdirp = require('mkdirp');
+var destroy = require('destroy');
+var rmdir = require('rmdir');
+var tmp = require('tmp');
+var request = require('request');
+var path = require('path');
+var cp = require('cp');
+var cpr = require('cpr');
+
+var Promise = require('./promise');
+
+// Write a stream to a file
+function writeStream(filename, st) {
+ var d = Promise.defer();
+
+ var wstream = fs.createWriteStream(filename);
+ var cleanup = function() {
+ destroy(wstream);
+ wstream.removeAllListeners();
+ };
+
+ wstream.on('finish', function () {
+ cleanup();
+ d.resolve();
+ });
+ wstream.on('error', function (err) {
+ cleanup();
+ d.reject(err);
+ });
+
+ st.on('error', function(err) {
+ cleanup();
+ d.reject(err);
+ });
+
+ st.pipe(wstream);
+
+ return d.promise;
+}
+
+// Return a promise resolved with a boolean
+function fileExists(filename) {
+ var d = Promise.defer();
+
+ fs.exists(filename, function(exists) {
+ d.resolve(exists);
+ });
+
+ return d.promise;
+}
+
+// Generate temporary file
+function genTmpFile(opts) {
+ return Promise.nfcall(tmp.file, opts)
+ .get(0);
+}
+
+// Generate temporary dir
+function genTmpDir(opts) {
+ return Promise.nfcall(tmp.dir, opts)
+ .get(0);
+}
+
+// Download an image
+function download(uri, dest) {
+ return writeStream(dest, request(uri));
+}
+
+// Find a filename available in a folder
+function uniqueFilename(base, filename) {
+ var ext = path.extname(filename);
+ filename = path.resolve(base, filename);
+ filename = path.join(path.dirname(filename), path.basename(filename, ext));
+
+ var _filename = filename+ext;
+
+ var i = 0;
+ while (fs.existsSync(filename)) {
+ _filename = filename + '_' + i + ext;
+ i = i + 1;
+ }
+
+ return Promise(path.relative(base, _filename));
+}
+
+// Create all required folder to create a file
+function ensureFile(filename) {
+ var base = path.dirname(filename);
+ return Promise.nfcall(mkdirp, base);
+}
+
+// Remove a folder
+function rmDir(base) {
+ return Promise.nfcall(rmdir, base, {
+ fs: fs
+ });
+}
+
+/**
+ Assert a file, if it doesn't exist, call "generator"
+
+ @param {String} filePath
+ @param {Function} generator
+ @return {Promise}
+*/
+function assertFile(filePath, generator) {
+ return fileExists(filePath)
+ .then(function(exists) {
+ if (exists) return;
+
+ return generator();
+ });
+}
+
+/**
+ Pick a file, returns the absolute path if exists, undefined otherwise
+
+ @param {String} rootFolder
+ @param {String} fileName
+ @return {String}
+*/
+function pickFile(rootFolder, fileName) {
+ var result = path.join(rootFolder, fileName);
+ if (fs.existsSync(result)) {
+ return result;
+ }
+
+ return undefined;
+}
+
+/**
+ Ensure that a directory exists and is empty
+
+ @param {String} folder
+ @return {Promise}
+*/
+function ensureFolder(rootFolder) {
+ return rmDir(rootFolder)
+ .fail(function() {
+ return Promise();
+ })
+ .then(function() {
+ return Promise.nfcall(mkdirp, rootFolder);
+ });
+}
+
+module.exports = {
+ exists: fileExists,
+ existsSync: fs.existsSync,
+ mkdirp: Promise.nfbind(mkdirp),
+ readFile: Promise.nfbind(fs.readFile),
+ writeFile: Promise.nfbind(fs.writeFile),
+ assertFile: assertFile,
+ pickFile: pickFile,
+ stat: Promise.nfbind(fs.stat),
+ statSync: fs.statSync,
+ readdir: Promise.nfbind(fs.readdir),
+ writeStream: writeStream,
+ readStream: fs.createReadStream,
+ copy: Promise.nfbind(cp),
+ copyDir: Promise.nfbind(cpr),
+ tmpFile: genTmpFile,
+ tmpDir: genTmpDir,
+ download: download,
+ uniqueFilename: uniqueFilename,
+ ensureFile: ensureFile,
+ ensureFolder: ensureFolder,
+ rmDir: rmDir
+};
diff --git a/packages/gitbook/lib/utils/genKey.js b/packages/gitbook/lib/utils/genKey.js
new file mode 100644
index 0000000..0650011
--- /dev/null
+++ b/packages/gitbook/lib/utils/genKey.js
@@ -0,0 +1,13 @@
+var lastKey = 0;
+
+/*
+ Generate a random key
+ @return {String}
+*/
+function generateKey() {
+ lastKey += 1;
+ var str = lastKey.toString(16);
+ return '00000'.slice(str.length) + str;
+}
+
+module.exports = generateKey;
diff --git a/packages/gitbook/lib/utils/git.js b/packages/gitbook/lib/utils/git.js
new file mode 100644
index 0000000..6884b83
--- /dev/null
+++ b/packages/gitbook/lib/utils/git.js
@@ -0,0 +1,133 @@
+var is = require('is');
+var path = require('path');
+var crc = require('crc');
+var URI = require('urijs');
+
+var pathUtil = require('./path');
+var Promise = require('./promise');
+var command = require('./command');
+var fs = require('./fs');
+
+var GIT_PREFIX = 'git+';
+
+function Git() {
+ this.tmpDir;
+ this.cloned = {};
+}
+
+// Return an unique ID for a combinaison host/ref
+Git.prototype.repoID = function(host, ref) {
+ return crc.crc32(host+'#'+(ref || '')).toString(16);
+};
+
+// Allocate a temporary folder for cloning repos in it
+Git.prototype.allocateDir = function() {
+ var that = this;
+
+ if (this.tmpDir) return Promise();
+
+ return fs.tmpDir()
+ .then(function(dir) {
+ that.tmpDir = dir;
+ });
+};
+
+// Clone a git repository if non existant
+Git.prototype.clone = function(host, ref) {
+ var that = this;
+
+ return this.allocateDir()
+
+ // Return or clone the git repo
+ .then(function() {
+ // Unique ID for repo/ref combinaison
+ var repoId = that.repoID(host, ref);
+
+ // Absolute path to the folder
+ var repoPath = path.join(that.tmpDir, repoId);
+
+ if (that.cloned[repoId]) return repoPath;
+
+ // Clone repo
+ return command.exec('git clone '+host+' '+repoPath)
+
+ // Checkout reference if specified
+ .then(function() {
+ that.cloned[repoId] = true;
+
+ if (!ref) return;
+ return command.exec('git checkout '+ref, { cwd: repoPath });
+ })
+ .thenResolve(repoPath);
+ });
+};
+
+// Get file from a git repo
+Git.prototype.resolve = function(giturl) {
+ // Path to a file in a git repo?
+ if (!Git.isUrl(giturl)) {
+ if (this.resolveRoot(giturl)) return Promise(giturl);
+ return Promise(null);
+ }
+ if (is.string(giturl)) giturl = Git.parseUrl(giturl);
+ if (!giturl) return Promise(null);
+
+ // Clone or get from cache
+ return this.clone(giturl.host, giturl.ref)
+ .then(function(repo) {
+ return path.resolve(repo, giturl.filepath);
+ });
+};
+
+// Return root of git repo from a filepath
+Git.prototype.resolveRoot = function(filepath) {
+ var relativeToGit, repoId;
+
+ // No git repo cloned, or file is not in a git repository
+ if (!this.tmpDir || !pathUtil.isInRoot(this.tmpDir, filepath)) return null;
+
+ // Extract first directory (is the repo id)
+ relativeToGit = path.relative(this.tmpDir, filepath);
+ repoId = relativeToGit.split(path.sep)[0];
+ if (!repoId) {
+ return;
+ }
+
+ // Return an absolute file
+ return path.resolve(this.tmpDir, repoId);
+};
+
+// Check if an url is a git dependency url
+Git.isUrl = function(giturl) {
+ return (giturl.indexOf(GIT_PREFIX) === 0);
+};
+
+// Parse and extract infos
+Git.parseUrl = function(giturl) {
+ var ref, uri, fileParts, filepath;
+
+ if (!Git.isUrl(giturl)) return null;
+ giturl = giturl.slice(GIT_PREFIX.length);
+
+ uri = new URI(giturl);
+ ref = uri.fragment() || null;
+ uri.fragment(null);
+
+ // Extract file inside the repo (after the .git)
+ fileParts = uri.path().split('.git');
+ filepath = fileParts.length > 1? fileParts.slice(1).join('.git') : '';
+ if (filepath[0] == '/') {
+ filepath = filepath.slice(1);
+ }
+
+ // Recreate pathname without the real filename
+ uri.path(fileParts[0] + '.git');
+
+ return {
+ host: uri.toString(),
+ ref: ref,
+ filepath: filepath
+ };
+};
+
+module.exports = Git;
diff --git a/packages/gitbook/lib/utils/images.js b/packages/gitbook/lib/utils/images.js
new file mode 100644
index 0000000..6d4b927
--- /dev/null
+++ b/packages/gitbook/lib/utils/images.js
@@ -0,0 +1,60 @@
+var Promise = require('./promise');
+var command = require('./command');
+var fs = require('./fs');
+var error = require('./error');
+
+// Convert a svg file to a pmg
+function convertSVGToPNG(source, dest, options) {
+ if (!fs.existsSync(source)) return Promise.reject(new error.FileNotFoundError({ filename: source }));
+
+ return command.spawn('svgexport', [source, dest])
+ .fail(function(err) {
+ if (err.code == 'ENOENT') {
+ err = error.RequireInstallError({
+ cmd: 'svgexport',
+ install: 'Install it using: "npm install svgexport -g"'
+ });
+ }
+ throw err;
+ })
+ .then(function() {
+ if (fs.existsSync(dest)) return;
+
+ throw new Error('Error converting '+source+' into '+dest);
+ });
+}
+
+// Convert a svg buffer to a png file
+function convertSVGBufferToPNG(buf, dest) {
+ // Create a temporary SVG file to convert
+ return fs.tmpFile({
+ postfix: '.svg'
+ })
+ .then(function(tmpSvg) {
+ return fs.writeFile(tmpSvg, buf)
+ .then(function() {
+ return convertSVGToPNG(tmpSvg, dest);
+ });
+ });
+}
+
+// Converts a inline data: to png file
+function convertInlinePNG(source, dest) {
+ if (!/^data\:image\/png/.test(source)) return Promise.reject(new Error('Source is not a PNG data-uri'));
+
+ var base64data = source.split('data:image/png;base64,')[1];
+ var buf = new Buffer(base64data, 'base64');
+
+ return fs.writeFile(dest, buf)
+ .then(function() {
+ if (fs.existsSync(dest)) return;
+
+ throw new Error('Error converting '+source+' into '+dest);
+ });
+}
+
+module.exports = {
+ convertSVGToPNG: convertSVGToPNG,
+ convertSVGBufferToPNG: convertSVGBufferToPNG,
+ convertInlinePNG: convertInlinePNG
+}; \ No newline at end of file
diff --git a/packages/gitbook/lib/utils/location.js b/packages/gitbook/lib/utils/location.js
new file mode 100644
index 0000000..00d8004
--- /dev/null
+++ b/packages/gitbook/lib/utils/location.js
@@ -0,0 +1,139 @@
+var url = require('url');
+var path = require('path');
+
+// Is the url an external url
+function isExternal(href) {
+ try {
+ return Boolean(url.parse(href).protocol) && !isDataURI(href);
+ } catch(err) {
+ return false;
+ }
+}
+
+// Is the url an iniline data-uri
+function isDataURI(href) {
+ try {
+ return Boolean(url.parse(href).protocol) && (url.parse(href).protocol === 'data:');
+ } catch(err) {
+ return false;
+ }
+}
+
+// Inverse of isExternal
+function isRelative(href) {
+ return !isExternal(href);
+}
+
+// Return true if the link is an achor
+function isAnchor(href) {
+ try {
+ var parsed = url.parse(href);
+ return !!(!parsed.protocol && !parsed.path && parsed.hash);
+ } catch(err) {
+ return false;
+ }
+}
+
+// Normalize a path to be a link
+function normalize(s) {
+ return path.normalize(s).replace(/\\/g, '/');
+}
+
+/**
+ * Flatten a path, it removes the leading "/"
+ *
+ * @param {String} href
+ * @return {String}
+ */
+function flatten(href) {
+ href = normalize(href);
+ if (href[0] == '/') {
+ href = normalize(href.slice(1));
+ }
+
+ return href;
+}
+
+/**
+ * Convert a relative path to absolute
+ *
+ * @param {String} href
+ * @param {String} dir: directory parent of the file currently in rendering process
+ * @param {String} outdir: directory parent from the html output
+ * @return {String}
+ */
+function toAbsolute(_href, dir, outdir) {
+ if (isExternal(_href) || isDataURI(_href)) {
+ return _href;
+ }
+
+ outdir = outdir == undefined? dir : outdir;
+
+ _href = normalize(_href);
+ dir = normalize(dir);
+ outdir = normalize(outdir);
+
+ // Path "_href" inside the base folder
+ var hrefInRoot = normalize(path.join(dir, _href));
+ if (_href[0] == '/') {
+ hrefInRoot = normalize(_href.slice(1));
+ }
+
+ // Make it relative to output
+ _href = path.relative(outdir, hrefInRoot);
+
+ // Normalize windows paths
+ _href = normalize(_href);
+
+ return _href;
+}
+
+/**
+ * Convert an absolute path to a relative path for a specific folder (dir)
+ * ('test/', 'hello.md') -> '../hello.md'
+ *
+ * @param {String} dir: current directory
+ * @param {String} file: absolute path of file
+ * @return {String}
+ */
+function relative(dir, file) {
+ var isDirectory = file.slice(-1) === '/';
+ return normalize(path.relative(dir, file)) + (isDirectory? '/': '');
+}
+
+/**
+ * Convert an absolute path to a relative path for a specific folder (dir)
+ * ('test/test.md', 'hello.md') -> '../hello.md'
+ *
+ * @param {String} baseFile: current file
+ * @param {String} file: absolute path of file
+ * @return {String}
+ */
+function relativeForFile(baseFile, file) {
+ return relative(path.dirname(baseFile), file);
+}
+
+/**
+ * Compare two paths, return true if they are identical
+ * ('README.md', './README.md') -> true
+ *
+ * @param {String} p1: first path
+ * @param {String} p2: second path
+ * @return {Boolean}
+ */
+function areIdenticalPaths(p1, p2) {
+ return normalize(p1) === normalize(p2);
+}
+
+module.exports = {
+ areIdenticalPaths: areIdenticalPaths,
+ isDataURI: isDataURI,
+ isExternal: isExternal,
+ isRelative: isRelative,
+ isAnchor: isAnchor,
+ normalize: normalize,
+ toAbsolute: toAbsolute,
+ relative: relative,
+ relativeForFile: relativeForFile,
+ flatten: flatten
+};
diff --git a/packages/gitbook/lib/utils/logger.js b/packages/gitbook/lib/utils/logger.js
new file mode 100644
index 0000000..6fac92b
--- /dev/null
+++ b/packages/gitbook/lib/utils/logger.js
@@ -0,0 +1,172 @@
+var is = require('is');
+var util = require('util');
+var color = require('bash-color');
+var Immutable = require('immutable');
+
+var LEVELS = Immutable.Map({
+ DEBUG: 0,
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3,
+ DISABLED: 10
+});
+
+var COLORS = Immutable.Map({
+ DEBUG: color.purple,
+ INFO: color.cyan,
+ WARN: color.yellow,
+ ERROR: color.red
+});
+
+function Logger(write, logLevel) {
+ if (!(this instanceof Logger)) return new Logger(write, logLevel);
+
+ this._write = write || function(msg) {
+ if(process.stdout) {
+ process.stdout.write(msg);
+ }
+ };
+ this.lastChar = '\n';
+
+ this.setLevel(logLevel || 'info');
+
+ // Create easy-to-use method like "logger.debug.ln('....')"
+ LEVELS.forEach(function(level, levelKey) {
+ if (levelKey === 'DISABLED') {
+ return;
+ }
+ levelKey = levelKey.toLowerCase();
+
+ this[levelKey] = this.log.bind(this, level);
+ this[levelKey].ln = this.logLn.bind(this, level);
+ this[levelKey].ok = this.ok.bind(this, level);
+ this[levelKey].fail = this.fail.bind(this, level);
+ this[levelKey].promise = this.promise.bind(this, level);
+ }, this);
+}
+
+/**
+ Change minimum level
+
+ @param {String} logLevel
+*/
+Logger.prototype.setLevel = function(logLevel) {
+ if (is.string(logLevel)) {
+ logLevel = logLevel.toUpperCase();
+ logLevel = LEVELS.get(logLevel);
+ }
+
+ this.logLevel = logLevel;
+};
+
+/**
+ Return minimum logging level
+
+ @return {Number}
+*/
+Logger.prototype.getLevel = function(logLevel) {
+ return this.logLevel;
+};
+
+/**
+ Print a simple string
+
+ @param {String}
+*/
+Logger.prototype.write = function(msg) {
+ msg = msg.toString();
+ this.lastChar = msg[msg.length - 1];
+ return this._write(msg);
+};
+
+/**
+ Format a string using the first argument as a printf-like format.
+*/
+Logger.prototype.format = function() {
+ return util.format.apply(util, arguments);
+};
+
+/**
+ Print a line
+
+ @param {String}
+*/
+Logger.prototype.writeLn = function(msg) {
+ return this.write((msg || '')+'\n');
+};
+
+/**
+ Log/Print a message if level is allowed
+
+ @param {Number} level
+*/
+Logger.prototype.log = function(level) {
+ if (level < this.logLevel) return;
+
+ var levelKey = LEVELS.findKey(function(v) {
+ return v === level;
+ });
+ var args = Array.prototype.slice.apply(arguments, [1]);
+ var msg = this.format.apply(this, args);
+
+ if (this.lastChar == '\n') {
+ msg = COLORS.get(levelKey)(levelKey.toLowerCase()+':')+' '+msg;
+ }
+
+ return this.write(msg);
+};
+
+/**
+ Log/Print a line if level is allowed
+*/
+Logger.prototype.logLn = function() {
+ if (this.lastChar != '\n') this.write('\n');
+
+ var args = Array.prototype.slice.apply(arguments);
+ args.push('\n');
+ return this.log.apply(this, args);
+};
+
+/**
+ Log a confirmation [OK]
+*/
+Logger.prototype.ok = function(level) {
+ var args = Array.prototype.slice.apply(arguments, [1]);
+ var msg = this.format.apply(this, args);
+ if (arguments.length > 1) {
+ this.logLn(level, color.green('>> ') + msg.trim().replace(/\n/g, color.green('\n>> ')));
+ } else {
+ this.log(level, color.green('OK'), '\n');
+ }
+};
+
+/**
+ Log a "FAIL"
+*/
+Logger.prototype.fail = function(level) {
+ return this.log(level, color.red('ERROR') + '\n');
+};
+
+/**
+ Log state of a promise
+
+ @param {Number} level
+ @param {Promise}
+ @return {Promise}
+*/
+Logger.prototype.promise = function(level, p) {
+ var that = this;
+
+ return p.
+ then(function(st) {
+ that.ok(level);
+ return st;
+ }, function(err) {
+ that.fail(level);
+ throw err;
+ });
+};
+
+Logger.LEVELS = LEVELS;
+
+module.exports = Logger;
diff --git a/packages/gitbook/lib/utils/mergeDefaults.js b/packages/gitbook/lib/utils/mergeDefaults.js
new file mode 100644
index 0000000..47a374b
--- /dev/null
+++ b/packages/gitbook/lib/utils/mergeDefaults.js
@@ -0,0 +1,16 @@
+var Immutable = require('immutable');
+
+/**
+ * Merge
+ * @param {Object|Map} obj
+ * @param {Object|Map} src
+ * @return {Object}
+ */
+function mergeDefaults(obj, src) {
+ var objValue = Immutable.fromJS(obj);
+ var srcValue = Immutable.fromJS(src);
+
+ return srcValue.mergeDeep(objValue).toJS();
+}
+
+module.exports = mergeDefaults;
diff --git a/packages/gitbook/lib/utils/path.js b/packages/gitbook/lib/utils/path.js
new file mode 100644
index 0000000..26b6005
--- /dev/null
+++ b/packages/gitbook/lib/utils/path.js
@@ -0,0 +1,74 @@
+var path = require('path');
+var error = require('./error');
+
+// Normalize a filename
+function normalizePath(filename) {
+ return path.normalize(filename);
+}
+
+// Return true if file path is inside a folder
+function isInRoot(root, filename) {
+ root = path.normalize(root);
+ filename = path.normalize(filename);
+
+ if (root === '.') {
+ return true;
+ }
+ if (root[root.length - 1] != path.sep) {
+ root = root + path.sep;
+ }
+
+ return (filename.substr(0, root.length) === root);
+}
+
+// Resolve paths in a specific folder
+// Throw error if file is outside this folder
+function resolveInRoot(root) {
+ var input, result;
+ var args = Array.prototype.slice.call(arguments, 1);
+
+ input = args
+ .reduce(function(current, p) {
+ // Handle path relative to book root ("/README.md")
+ if (p[0] == '/' || p[0] == '\\') return p.slice(1);
+
+ return current? path.join(current, p) : path.normalize(p);
+ }, '');
+
+ result = path.resolve(root, input);
+
+ if (!isInRoot(root, result)) {
+ throw new error.FileOutOfScopeError({
+ filename: result,
+ root: root
+ });
+ }
+
+ return result;
+}
+
+// Chnage extension of a file
+function setExtension(filename, ext) {
+ return path.join(
+ path.dirname(filename),
+ path.basename(filename, path.extname(filename)) + ext
+ );
+}
+
+/*
+ Return true if a filename is relative.
+
+ @param {String}
+ @return {Boolean}
+*/
+function isPureRelative(filename) {
+ return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0);
+}
+
+module.exports = {
+ isInRoot: isInRoot,
+ resolveInRoot: resolveInRoot,
+ normalize: normalizePath,
+ setExtension: setExtension,
+ isPureRelative: isPureRelative
+};
diff --git a/packages/gitbook/lib/utils/promise.js b/packages/gitbook/lib/utils/promise.js
new file mode 100644
index 0000000..b5cca4b
--- /dev/null
+++ b/packages/gitbook/lib/utils/promise.js
@@ -0,0 +1,148 @@
+var Q = require('q');
+var Immutable = require('immutable');
+
+// Debugging for long stack traces
+if (process.env.DEBUG || process.env.CI) {
+ Q.longStackSupport = true;
+}
+
+/**
+ * Reduce an array to a promise
+ *
+ * @param {Array|List} arr
+ * @param {Function(value, element, index)}
+ * @return {Promise<Mixed>}
+ */
+function reduce(arr, iter, base) {
+ arr = Immutable.Iterable.isIterable(arr)? arr : Immutable.List(arr);
+
+ return arr.reduce(function(prev, elem, key) {
+ return prev
+ .then(function(val) {
+ return iter(val, elem, key);
+ });
+ }, Q(base));
+}
+
+/**
+ * Iterate over an array using an async iter
+ *
+ * @param {Array|List} arr
+ * @param {Function(value, element, index)}
+ * @return {Promise}
+ */
+function forEach(arr, iter) {
+ return reduce(arr, function(val, el, key) {
+ return iter(el, key);
+ });
+}
+
+/**
+ * Transform an array
+ *
+ * @param {Array|List} arr
+ * @param {Function(value, element, index)}
+ * @return {Promise}
+ */
+function serie(arr, iter, base) {
+ return reduce(arr, function(before, item, key) {
+ return Q(iter(item, key))
+ .then(function(r) {
+ before.push(r);
+ return before;
+ });
+ }, []);
+}
+
+/**
+ * Iter over an array and return first result (not null)
+ *
+ * @param {Array|List} arr
+ * @param {Function(element, index)}
+ * @return {Promise<Mixed>}
+ */
+function some(arr, iter) {
+ arr = Immutable.List(arr);
+
+ return arr.reduce(function(prev, elem, i) {
+ return prev.then(function(val) {
+ if (val) return val;
+
+ return iter(elem, i);
+ });
+ }, Q());
+}
+
+/**
+ * Map an array using an async (promised) iterator
+ *
+ * @param {Array|List} arr
+ * @param {Function(element, index)}
+ * @return {Promise<List>}
+ */
+function mapAsList(arr, iter) {
+ return reduce(arr, function(prev, entry, i) {
+ return Q(iter(entry, i))
+ .then(function(out) {
+ prev.push(out);
+ return prev;
+ });
+ }, []);
+}
+
+/**
+ * Map an array or map
+ *
+ * @param {Array|List|Map|OrderedMap} arr
+ * @param {Function(element, key)}
+ * @return {Promise<List|Map|OrderedMap>}
+ */
+function map(arr, iter) {
+ if (Immutable.Map.isMap(arr)) {
+ var type = 'Map';
+ if (Immutable.OrderedMap.isOrderedMap(arr)) {
+ type = 'OrderedMap';
+ }
+
+ return mapAsList(arr, function(value, key) {
+ return Q(iter(value, key))
+ .then(function(result) {
+ return [key, result];
+ });
+ })
+ .then(function(result) {
+ return Immutable[type](result);
+ });
+ } else {
+ return mapAsList(arr, iter)
+ .then(function(result) {
+ return Immutable.List(result);
+ });
+ }
+}
+
+
+/**
+ * Wrap a function in a promise
+ *
+ * @param {Function} func
+ * @return {Funciton}
+ */
+function wrap(func) {
+ return function() {
+ var args = Array.prototype.slice.call(arguments, 0);
+
+ return Q()
+ .then(function() {
+ return func.apply(null, args);
+ });
+ };
+}
+
+module.exports = Q;
+module.exports.forEach = forEach;
+module.exports.reduce = reduce;
+module.exports.map = map;
+module.exports.serie = serie;
+module.exports.some = some;
+module.exports.wrapfn = wrap;
diff --git a/packages/gitbook/lib/utils/reducedObject.js b/packages/gitbook/lib/utils/reducedObject.js
new file mode 100644
index 0000000..7bcfd5b
--- /dev/null
+++ b/packages/gitbook/lib/utils/reducedObject.js
@@ -0,0 +1,33 @@
+var Immutable = require('immutable');
+
+/**
+ * Reduce the difference between a map and its default version
+ * @param {Map} defaultVersion
+ * @param {Map} currentVersion
+ * @return {Map} The properties of currentVersion that differs from defaultVersion
+ */
+function reducedObject(defaultVersion, currentVersion) {
+ if(defaultVersion === undefined) {
+ return currentVersion;
+ }
+
+ return currentVersion.reduce(function(result, value, key) {
+ var defaultValue = defaultVersion.get(key);
+
+ if (Immutable.Map.isMap(value)) {
+ var diffs = reducedObject(defaultValue, value);
+
+ if (diffs.size > 0) {
+ return result.set(key, diffs);
+ }
+ }
+
+ if (Immutable.is(defaultValue, value)) {
+ return result;
+ }
+
+ return result.set(key, value);
+ }, Immutable.Map());
+}
+
+module.exports = reducedObject;
diff --git a/packages/gitbook/lib/utils/timing.js b/packages/gitbook/lib/utils/timing.js
new file mode 100644
index 0000000..e6b0323
--- /dev/null
+++ b/packages/gitbook/lib/utils/timing.js
@@ -0,0 +1,97 @@
+var Immutable = require('immutable');
+var is = require('is');
+
+var timers = {};
+var startDate = Date.now();
+
+/**
+ Mesure an operation
+
+ @parqm {String} type
+ @param {Promise} p
+ @return {Promise}
+*/
+function measure(type, p) {
+ timers[type] = timers[type] || {
+ type: type,
+ count: 0,
+ total: 0,
+ min: undefined,
+ max: 0
+ };
+
+ var start = Date.now();
+
+ return p
+ .fin(function() {
+ var end = Date.now();
+ var duration = (end - start);
+
+ timers[type].count ++;
+ timers[type].total += duration;
+
+ if (is.undefined(timers[type].min)) {
+ timers[type].min = duration;
+ } else {
+ timers[type].min = Math.min(timers[type].min, duration);
+ }
+
+ timers[type].max = Math.max(timers[type].max, duration);
+ });
+}
+
+/**
+ Return a milliseconds number as a second string
+
+ @param {Number} ms
+ @return {String}
+*/
+function time(ms) {
+ if (ms < 1000) {
+ return (ms.toFixed(0)) + 'ms';
+ }
+
+ return (ms/1000).toFixed(2) + 's';
+}
+
+/**
+ Dump all timers to a logger
+
+ @param {Logger} logger
+*/
+function dump(logger) {
+ var prefix = ' > ';
+ var measured = 0;
+ var totalDuration = Date.now() - startDate;
+
+ // Enable debug logging
+ var logLevel = logger.getLevel();
+ logger.setLevel('debug');
+
+ Immutable.Map(timers)
+ .valueSeq()
+ .sortBy(function(timer) {
+ measured += timer.total;
+ return timer.total;
+ })
+ .forEach(function(timer) {
+ var percent = (timer.total * 100) / totalDuration;
+
+
+ logger.debug.ln((percent.toFixed(1)) + '% of time spent in "' + timer.type + '" (' + timer.count + ' times) :');
+ logger.debug.ln(prefix + 'Total: ' + time(timer.total)+ ' | Average: ' + time(timer.total / timer.count));
+ logger.debug.ln(prefix + 'Min: ' + time(timer.min) + ' | Max: ' + time(timer.max));
+ logger.debug.ln('---------------------------');
+ });
+
+
+ logger.debug.ln(time(totalDuration - measured) + ' spent in non-mesured sections');
+
+ // Rollback to previous level
+ logger.setLevel(logLevel);
+}
+
+module.exports = {
+ measure: measure,
+ dump: dump
+};
diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json
new file mode 100644
index 0000000..b3f5f15
--- /dev/null
+++ b/packages/gitbook/package.json
@@ -0,0 +1,98 @@
+{
+ "name": "gitbook",
+ "version": "3.2.0",
+ "homepage": "https://www.gitbook.com",
+ "description": "Library and cmd utility to generate GitBooks",
+ "main": "lib/index.js",
+ "browser": "./lib/browser.js",
+ "dependencies": {
+ "bash-color": "0.0.4",
+ "cheerio": "0.20.0",
+ "chokidar": "1.5.0",
+ "cp": "0.2.0",
+ "cpr": "1.1.1",
+ "crc": "3.4.0",
+ "destroy": "1.0.4",
+ "direction": "0.1.5",
+ "dom-serializer": "0.1.0",
+ "error": "7.0.2",
+ "escape-html": "^1.0.3",
+ "escape-string-regexp": "1.0.5",
+ "extend": "^3.0.0",
+ "fresh-require": "1.0.3",
+ "front-matter": "^2.1.0",
+ "gitbook-asciidoc": "1.2.2",
+ "gitbook-markdown": "2.0.1",
+ "gitbook-plugin-fontsettings": "2.0.0",
+ "gitbook-plugin-highlight": "2.0.2",
+ "gitbook-plugin-livereload": "0.0.1",
+ "gitbook-plugin-lunr": "1.2.0",
+ "gitbook-plugin-search": "2.2.1",
+ "gitbook-plugin-sharing": "1.0.2",
+ "gitbook-plugin-theme-default": "1.0.5",
+ "github-slugid": "1.0.1",
+ "graceful-fs": "4.1.4",
+ "i18n-t": "1.0.1",
+ "ignore": "3.1.2",
+ "immutable": "^3.8.1",
+ "is": "^3.1.0",
+ "js-yaml": "^3.6.1",
+ "json-schema-defaults": "0.1.1",
+ "jsonschema": "1.1.0",
+ "juice": "2.0.0",
+ "mkdirp": "0.5.1",
+ "moment": "2.13.0",
+ "npm": "3.9.2",
+ "npmi": "2.0.1",
+ "nunjucks": "2.4.2",
+ "nunjucks-do": "1.0.0",
+ "object-path": "^0.9.2",
+ "omit-keys": "^0.1.0",
+ "open": "0.0.5",
+ "q": "1.4.1",
+ "react": "^15.3.1",
+ "react-dom": "^15.3.1",
+ "react-redux": "^4.4.5",
+ "read-installed": "^4.0.3",
+ "redux": "^3.5.2",
+ "request": "2.72.0",
+ "resolve": "1.1.7",
+ "rmdir": "1.2.0",
+ "semver": "5.1.0",
+ "send": "0.13.2",
+ "spawn-cmd": "0.0.2",
+ "tiny-lr": "0.2.1",
+ "tmp": "0.0.28",
+ "urijs": "1.18.0"
+ },
+ "scripts": {
+ "test": "./node_modules/.bin/mocha ./testing/setup.js \"./lib/**/*/__tests__/*.js\" --bail --reporter=list --timeout=10000"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "bin": {
+ "gitbook": "./bin/gitbook.js"
+ },
+ "keywords": [
+ "git",
+ "book",
+ "gitbook"
+ ],
+ "author": "GitBook Inc. <contact@gitbook.com>",
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ },
+ "contributors": [
+ {
+ "name": "Aaron O'Mullan",
+ "email": "aaron@gitbook.com"
+ },
+ {
+ "name": "Samy Pessé",
+ "email": "samy@gitbook.com"
+ }
+ ]
+}
diff --git a/packages/gitbook/testing/setup.js b/packages/gitbook/testing/setup.js
new file mode 100644
index 0000000..1105002
--- /dev/null
+++ b/packages/gitbook/testing/setup.js
@@ -0,0 +1,72 @@
+var is = require('is');
+var path = require('path');
+var fs = require('fs');
+var expect = require('expect');
+var cheerio = require('cheerio');
+
+expect.extend({
+ /**
+ * Check that a file is created in a directory:
+ * expect('myFolder').toHaveFile('hello.md');
+ */
+ toHaveFile: function(fileName) {
+ var filePath = path.join(this.actual, fileName);
+ var exists = fs.existsSync(filePath);
+
+ expect.assert(
+ exists,
+ 'expected %s to have file %s',
+ this.actual,
+ fileName
+ );
+ return this;
+ },
+ toNotHaveFile: function(fileName) {
+ var filePath = path.join(this.actual, fileName);
+ var exists = fs.existsSync(filePath);
+
+ expect.assert(
+ !exists,
+ 'expected %s to not have file %s',
+ this.actual,
+ fileName
+ );
+ return this;
+ },
+
+ /**
+ * Check that a value is defined (not null nor undefined)
+ */
+ toBeDefined: function() {
+ expect.assert(
+ !(is.undefined(this.actual) || is.null(this.actual)),
+ 'expected to be defined'
+ );
+ return this;
+ },
+
+ /**
+ * Check that a value is defined (not null nor undefined)
+ */
+ toNotBeDefined: function() {
+ expect.assert(
+ (is.undefined(this.actual) || is.null(this.actual)),
+ 'expected %s to be not defined',
+ this.actual
+ );
+ return this;
+ },
+
+ /**
+ * Check that a dom element exists in HTML
+ * @param {String} selector
+ */
+ toHaveDOMElement: function(selector) {
+ var $ = cheerio.load(this.actual);
+ var $el = $(selector);
+
+ expect.assert($el.length > 0, 'expected HTML to contains %s', selector);
+ }
+});
+
+global.expect = expect;