summaryrefslogtreecommitdiffstats
path: root/packages/gitbook
diff options
context:
space:
mode:
Diffstat (limited to 'packages/gitbook')
-rw-r--r--packages/gitbook/.babelrc3
-rw-r--r--packages/gitbook/.gitignore1
-rw-r--r--packages/gitbook/.npmignore2
-rwxr-xr-xpackages/gitbook/bin/gitbook.js8
-rw-r--r--packages/gitbook/package.json107
-rw-r--r--packages/gitbook/src/__tests__/gitbook.js9
-rw-r--r--packages/gitbook/src/__tests__/init.js16
-rw-r--r--packages/gitbook/src/__tests__/module.js6
-rw-r--r--packages/gitbook/src/api/decodeConfig.js17
-rw-r--r--packages/gitbook/src/api/decodeGlobal.js22
-rw-r--r--packages/gitbook/src/api/decodePage.js34
-rw-r--r--packages/gitbook/src/api/deprecate.js120
-rw-r--r--packages/gitbook/src/api/encodeConfig.js36
-rw-r--r--packages/gitbook/src/api/encodeGlobal.js264
-rw-r--r--packages/gitbook/src/api/encodeNavigation.js64
-rw-r--r--packages/gitbook/src/api/encodePage.js45
-rw-r--r--packages/gitbook/src/api/encodeProgress.js63
-rw-r--r--packages/gitbook/src/api/encodeSummary.js52
-rw-r--r--packages/gitbook/src/api/index.js7
-rw-r--r--packages/gitbook/src/browser.js23
-rw-r--r--packages/gitbook/src/browser/__tests__/render.js4
-rw-r--r--packages/gitbook/src/browser/loadPlugins.js31
-rw-r--r--packages/gitbook/src/browser/render.js103
-rw-r--r--packages/gitbook/src/cli/build.js34
-rw-r--r--packages/gitbook/src/cli/buildEbook.js78
-rw-r--r--packages/gitbook/src/cli/getBook.js23
-rw-r--r--packages/gitbook/src/cli/getOutputFolder.js17
-rw-r--r--packages/gitbook/src/cli/index.js12
-rw-r--r--packages/gitbook/src/cli/init.js17
-rw-r--r--packages/gitbook/src/cli/install.js21
-rw-r--r--packages/gitbook/src/cli/options.js31
-rw-r--r--packages/gitbook/src/cli/parse.js79
-rw-r--r--packages/gitbook/src/cli/serve.js159
-rw-r--r--packages/gitbook/src/cli/server.js127
-rw-r--r--packages/gitbook/src/cli/watch.js46
-rw-r--r--packages/gitbook/src/constants/__tests__/configSchema.js46
-rw-r--r--packages/gitbook/src/constants/configDefault.js6
-rw-r--r--packages/gitbook/src/constants/configFiles.js5
-rw-r--r--packages/gitbook/src/constants/configSchema.js194
-rw-r--r--packages/gitbook/src/constants/defaultBlocks.js5
-rw-r--r--packages/gitbook/src/constants/defaultFilters.js15
-rw-r--r--packages/gitbook/src/constants/defaultPlugins.js31
-rw-r--r--packages/gitbook/src/constants/extsAsciidoc.js4
-rw-r--r--packages/gitbook/src/constants/extsMarkdown.js5
-rw-r--r--packages/gitbook/src/constants/ignoreFiles.js6
-rw-r--r--packages/gitbook/src/constants/pluginAssetsFolder.js2
-rw-r--r--packages/gitbook/src/constants/pluginHooks.js8
-rw-r--r--packages/gitbook/src/constants/pluginPrefix.js5
-rw-r--r--packages/gitbook/src/constants/pluginResources.js6
-rw-r--r--packages/gitbook/src/constants/templatesFolder.js2
-rw-r--r--packages/gitbook/src/constants/themePrefix.js4
-rw-r--r--packages/gitbook/src/fs/__tests__/mock.js81
-rw-r--r--packages/gitbook/src/fs/mock.js95
-rw-r--r--packages/gitbook/src/fs/node.js42
-rw-r--r--packages/gitbook/src/gitbook.js28
-rw-r--r--packages/gitbook/src/index.js9
-rw-r--r--packages/gitbook/src/init.js83
-rw-r--r--packages/gitbook/src/json/encodeFile.js23
-rw-r--r--packages/gitbook/src/json/encodeGlossary.js22
-rw-r--r--packages/gitbook/src/json/encodeGlossaryEntry.js16
-rw-r--r--packages/gitbook/src/json/encodeLanguages.js29
-rw-r--r--packages/gitbook/src/json/encodePage.js41
-rw-r--r--packages/gitbook/src/json/encodeReadme.js18
-rw-r--r--packages/gitbook/src/json/encodeState.js42
-rw-r--r--packages/gitbook/src/json/encodeSummary.js23
-rw-r--r--packages/gitbook/src/json/encodeSummaryArticle.js30
-rw-r--r--packages/gitbook/src/json/encodeSummaryPart.js19
-rw-r--r--packages/gitbook/src/json/index.js10
-rw-r--r--packages/gitbook/src/models/__tests__/config.js89
-rw-r--r--packages/gitbook/src/models/__tests__/glossary.js39
-rw-r--r--packages/gitbook/src/models/__tests__/glossaryEntry.js14
-rw-r--r--packages/gitbook/src/models/__tests__/page.js26
-rw-r--r--packages/gitbook/src/models/__tests__/plugin.js26
-rw-r--r--packages/gitbook/src/models/__tests__/pluginDependency.js80
-rw-r--r--packages/gitbook/src/models/__tests__/summary.js93
-rw-r--r--packages/gitbook/src/models/__tests__/summaryArticle.js52
-rw-r--r--packages/gitbook/src/models/__tests__/summaryPart.js22
-rw-r--r--packages/gitbook/src/models/__tests__/templateBlock.js218
-rw-r--r--packages/gitbook/src/models/__tests__/templateEngine.js51
-rw-r--r--packages/gitbook/src/models/__tests__/uriIndex.js84
-rw-r--r--packages/gitbook/src/models/book.js357
-rw-r--r--packages/gitbook/src/models/config.js181
-rw-r--r--packages/gitbook/src/models/file.js89
-rw-r--r--packages/gitbook/src/models/fs.js300
-rw-r--r--packages/gitbook/src/models/glossary.js109
-rw-r--r--packages/gitbook/src/models/glossaryEntry.js43
-rw-r--r--packages/gitbook/src/models/ignore.js43
-rw-r--r--packages/gitbook/src/models/language.js21
-rw-r--r--packages/gitbook/src/models/languages.js71
-rw-r--r--packages/gitbook/src/models/output.js112
-rw-r--r--packages/gitbook/src/models/page.js69
-rw-r--r--packages/gitbook/src/models/parser.js122
-rw-r--r--packages/gitbook/src/models/plugin.js149
-rw-r--r--packages/gitbook/src/models/pluginDependency.js168
-rw-r--r--packages/gitbook/src/models/readme.js40
-rw-r--r--packages/gitbook/src/models/summary.js228
-rw-r--r--packages/gitbook/src/models/summaryArticle.js189
-rw-r--r--packages/gitbook/src/models/summaryPart.js61
-rw-r--r--packages/gitbook/src/models/templateBlock.js253
-rw-r--r--packages/gitbook/src/models/templateEngine.js133
-rw-r--r--packages/gitbook/src/models/templateShortcut.js73
-rw-r--r--packages/gitbook/src/models/uriIndex.js159
-rw-r--r--packages/gitbook/src/modifiers/config/__tests__/addPlugin.js31
-rw-r--r--packages/gitbook/src/modifiers/config/__tests__/removePlugin.js32
-rw-r--r--packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js27
-rw-r--r--packages/gitbook/src/modifiers/config/addPlugin.js25
-rw-r--r--packages/gitbook/src/modifiers/config/editPlugin.js13
-rw-r--r--packages/gitbook/src/modifiers/config/getPluginConfig.js20
-rw-r--r--packages/gitbook/src/modifiers/config/hasPlugin.js15
-rw-r--r--packages/gitbook/src/modifiers/config/index.js10
-rw-r--r--packages/gitbook/src/modifiers/config/isDefaultPlugin.js14
-rw-r--r--packages/gitbook/src/modifiers/config/removePlugin.js25
-rw-r--r--packages/gitbook/src/modifiers/config/togglePlugin.js31
-rw-r--r--packages/gitbook/src/modifiers/index.js5
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/editArticle.js0
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js43
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js78
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/insertPart.js60
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js45
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js68
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js82
-rw-r--r--packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js53
-rw-r--r--packages/gitbook/src/modifiers/summary/editArticleRef.js17
-rw-r--r--packages/gitbook/src/modifiers/summary/editArticleTitle.js17
-rw-r--r--packages/gitbook/src/modifiers/summary/editPartTitle.js23
-rw-r--r--packages/gitbook/src/modifiers/summary/index.js13
-rw-r--r--packages/gitbook/src/modifiers/summary/indexArticleLevels.js23
-rw-r--r--packages/gitbook/src/modifiers/summary/indexLevels.js17
-rw-r--r--packages/gitbook/src/modifiers/summary/indexPartLevels.js24
-rw-r--r--packages/gitbook/src/modifiers/summary/insertArticle.js49
-rw-r--r--packages/gitbook/src/modifiers/summary/insertPart.js19
-rw-r--r--packages/gitbook/src/modifiers/summary/mergeAtLevel.js75
-rw-r--r--packages/gitbook/src/modifiers/summary/moveArticle.js25
-rw-r--r--packages/gitbook/src/modifiers/summary/moveArticleAfter.js60
-rw-r--r--packages/gitbook/src/modifiers/summary/removeArticle.js37
-rw-r--r--packages/gitbook/src/modifiers/summary/removePart.js15
-rw-r--r--packages/gitbook/src/modifiers/summary/unshiftArticle.js29
-rw-r--r--packages/gitbook/src/output/__tests__/createMock.js38
-rw-r--r--packages/gitbook/src/output/__tests__/ebook.js15
-rw-r--r--packages/gitbook/src/output/__tests__/generateMock.js40
-rw-r--r--packages/gitbook/src/output/__tests__/json.js46
-rw-r--r--packages/gitbook/src/output/__tests__/website.js140
-rw-r--r--packages/gitbook/src/output/callHook.js60
-rw-r--r--packages/gitbook/src/output/callPageHook.js28
-rw-r--r--packages/gitbook/src/output/createTemplateEngine.js48
-rw-r--r--packages/gitbook/src/output/ebook/getConvertOptions.js73
-rw-r--r--packages/gitbook/src/output/ebook/getCoverPath.js30
-rw-r--r--packages/gitbook/src/output/ebook/getPDFTemplate.js36
-rw-r--r--packages/gitbook/src/output/ebook/index.js9
-rw-r--r--packages/gitbook/src/output/ebook/onFinish.js85
-rw-r--r--packages/gitbook/src/output/ebook/onPage.js25
-rw-r--r--packages/gitbook/src/output/ebook/options.js14
-rw-r--r--packages/gitbook/src/output/generateAssets.js26
-rw-r--r--packages/gitbook/src/output/generateBook.js193
-rw-r--r--packages/gitbook/src/output/generatePage.js68
-rw-r--r--packages/gitbook/src/output/generatePages.js36
-rw-r--r--packages/gitbook/src/output/getModifiers.js42
-rw-r--r--packages/gitbook/src/output/helper/index.js2
-rw-r--r--packages/gitbook/src/output/helper/writeFile.js23
-rw-r--r--packages/gitbook/src/output/index.js24
-rw-r--r--packages/gitbook/src/output/json/index.js7
-rw-r--r--packages/gitbook/src/output/json/onFinish.js48
-rw-r--r--packages/gitbook/src/output/json/onPage.js43
-rw-r--r--packages/gitbook/src/output/json/options.js8
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js25
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/annotateText.js45
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js39
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/inlinePng.js24
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js34
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/svgToImg.js24
-rw-r--r--packages/gitbook/src/output/modifiers/__tests__/svgToPng.js32
-rw-r--r--packages/gitbook/src/output/modifiers/addHeadingId.js21
-rw-r--r--packages/gitbook/src/output/modifiers/annotateText.js91
-rw-r--r--packages/gitbook/src/output/modifiers/editHTMLElement.js15
-rw-r--r--packages/gitbook/src/output/modifiers/fetchRemoteImages.js44
-rw-r--r--packages/gitbook/src/output/modifiers/index.js14
-rw-r--r--packages/gitbook/src/output/modifiers/inlineAssets.js29
-rw-r--r--packages/gitbook/src/output/modifiers/inlinePng.js46
-rw-r--r--packages/gitbook/src/output/modifiers/modifyHTML.js25
-rw-r--r--packages/gitbook/src/output/modifiers/resolveImages.js33
-rw-r--r--packages/gitbook/src/output/modifiers/resolveLinks.js30
-rw-r--r--packages/gitbook/src/output/modifiers/svgToImg.js56
-rw-r--r--packages/gitbook/src/output/modifiers/svgToPng.js53
-rw-r--r--packages/gitbook/src/output/prepareAssets.js22
-rw-r--r--packages/gitbook/src/output/preparePages.js35
-rw-r--r--packages/gitbook/src/output/preparePlugins.js36
-rw-r--r--packages/gitbook/src/output/website/copyPluginAssets.js111
-rw-r--r--packages/gitbook/src/output/website/index.js10
-rw-r--r--packages/gitbook/src/output/website/onAsset.js29
-rw-r--r--packages/gitbook/src/output/website/onFinish.js30
-rw-r--r--packages/gitbook/src/output/website/onInit.js15
-rw-r--r--packages/gitbook/src/output/website/onPage.js34
-rw-r--r--packages/gitbook/src/output/website/options.js10
-rw-r--r--packages/gitbook/src/output/website/state.js18
-rw-r--r--packages/gitbook/src/parse/__tests__/listAssets.js29
-rw-r--r--packages/gitbook/src/parse/__tests__/parseBook.js90
-rw-r--r--packages/gitbook/src/parse/__tests__/parseGlossary.js36
-rw-r--r--packages/gitbook/src/parse/__tests__/parseIgnore.js40
-rw-r--r--packages/gitbook/src/parse/__tests__/parsePageFromString.js37
-rw-r--r--packages/gitbook/src/parse/__tests__/parseReadme.js36
-rw-r--r--packages/gitbook/src/parse/__tests__/parseSummary.js34
-rw-r--r--packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js26
-rw-r--r--packages/gitbook/src/parse/findParsableFile.js36
-rw-r--r--packages/gitbook/src/parse/index.js15
-rw-r--r--packages/gitbook/src/parse/listAssets.js43
-rw-r--r--packages/gitbook/src/parse/lookupStructureFile.js20
-rw-r--r--packages/gitbook/src/parse/parseBook.js77
-rw-r--r--packages/gitbook/src/parse/parseConfig.js55
-rw-r--r--packages/gitbook/src/parse/parseGlossary.js26
-rw-r--r--packages/gitbook/src/parse/parseIgnore.js54
-rw-r--r--packages/gitbook/src/parse/parseLanguages.js28
-rw-r--r--packages/gitbook/src/parse/parsePage.js21
-rw-r--r--packages/gitbook/src/parse/parsePageFromString.js22
-rw-r--r--packages/gitbook/src/parse/parsePagesList.js97
-rw-r--r--packages/gitbook/src/parse/parseReadme.js28
-rw-r--r--packages/gitbook/src/parse/parseStructureFile.js67
-rw-r--r--packages/gitbook/src/parse/parseSummary.js44
-rw-r--r--packages/gitbook/src/parse/parseURIIndexFromPages.js44
-rw-r--r--packages/gitbook/src/parse/validateConfig.js31
-rw-r--r--packages/gitbook/src/parse/walkSummary.js34
-rw-r--r--packages/gitbook/src/parsers.js63
-rw-r--r--packages/gitbook/src/plugins/__tests__/findForBook.js19
-rw-r--r--packages/gitbook/src/plugins/__tests__/findInstalled.js25
-rw-r--r--packages/gitbook/src/plugins/__tests__/installPlugin.js42
-rw-r--r--packages/gitbook/src/plugins/__tests__/installPlugins.js37
-rw-r--r--packages/gitbook/src/plugins/__tests__/listDependencies.js39
-rw-r--r--packages/gitbook/src/plugins/__tests__/locateRootFolder.js10
-rw-r--r--packages/gitbook/src/plugins/__tests__/resolveVersion.js22
-rw-r--r--packages/gitbook/src/plugins/__tests__/sortDependencies.js42
-rw-r--r--packages/gitbook/src/plugins/__tests__/validatePlugin.js16
-rw-r--r--packages/gitbook/src/plugins/findForBook.js33
-rw-r--r--packages/gitbook/src/plugins/findInstalled.js81
-rw-r--r--packages/gitbook/src/plugins/index.js8
-rw-r--r--packages/gitbook/src/plugins/installPlugin.js44
-rw-r--r--packages/gitbook/src/plugins/installPlugins.js46
-rw-r--r--packages/gitbook/src/plugins/listBlocks.js21
-rw-r--r--packages/gitbook/src/plugins/listDependencies.js33
-rw-r--r--packages/gitbook/src/plugins/listDepsForBook.js18
-rw-r--r--packages/gitbook/src/plugins/listFilters.js20
-rw-r--r--packages/gitbook/src/plugins/loadForBook.js73
-rw-r--r--packages/gitbook/src/plugins/loadPlugin.js89
-rw-r--r--packages/gitbook/src/plugins/locateRootFolder.js22
-rw-r--r--packages/gitbook/src/plugins/resolveVersion.js70
-rw-r--r--packages/gitbook/src/plugins/sortDependencies.js34
-rw-r--r--packages/gitbook/src/plugins/toNames.js16
-rw-r--r--packages/gitbook/src/plugins/validateConfig.js71
-rw-r--r--packages/gitbook/src/plugins/validatePlugin.js34
-rw-r--r--packages/gitbook/src/templating/__tests__/conrefsLoader.js111
-rw-r--r--packages/gitbook/src/templating/__tests__/include.md1
-rw-r--r--packages/gitbook/src/templating/__tests__/replaceShortcuts.js31
-rw-r--r--packages/gitbook/src/templating/conrefsLoader.js93
-rw-r--r--packages/gitbook/src/templating/index.js7
-rw-r--r--packages/gitbook/src/templating/listShortcuts.js31
-rw-r--r--packages/gitbook/src/templating/render.js40
-rw-r--r--packages/gitbook/src/templating/renderFile.js41
-rw-r--r--packages/gitbook/src/templating/replaceShortcuts.js39
-rw-r--r--packages/gitbook/src/utils/__tests__/git.js55
-rw-r--r--packages/gitbook/src/utils/__tests__/location.js99
-rw-r--r--packages/gitbook/src/utils/__tests__/path.js17
-rw-r--r--packages/gitbook/src/utils/command.js118
-rw-r--r--packages/gitbook/src/utils/error.js99
-rw-r--r--packages/gitbook/src/utils/fs.js170
-rw-r--r--packages/gitbook/src/utils/genKey.js13
-rw-r--r--packages/gitbook/src/utils/git.js158
-rw-r--r--packages/gitbook/src/utils/images.js60
-rw-r--r--packages/gitbook/src/utils/location.js139
-rw-r--r--packages/gitbook/src/utils/logger.js170
-rw-r--r--packages/gitbook/src/utils/mergeDefaults.js16
-rw-r--r--packages/gitbook/src/utils/path.js71
-rw-r--r--packages/gitbook/src/utils/promise.js146
-rw-r--r--packages/gitbook/src/utils/reducedObject.js33
-rw-r--r--packages/gitbook/src/utils/timing.js104
-rw-r--r--packages/gitbook/testing/setup.js73
273 files changed, 13790 insertions, 0 deletions
diff --git a/packages/gitbook/.babelrc b/packages/gitbook/.babelrc
new file mode 100644
index 0000000..5f27bda
--- /dev/null
+++ b/packages/gitbook/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["es2015", "react", "stage-2"]
+}
diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore
new file mode 100644
index 0000000..a65b417
--- /dev/null
+++ b/packages/gitbook/.gitignore
@@ -0,0 +1 @@
+lib
diff --git a/packages/gitbook/.npmignore b/packages/gitbook/.npmignore
new file mode 100644
index 0000000..e04684f
--- /dev/null
+++ b/packages/gitbook/.npmignore
@@ -0,0 +1,2 @@
+src
+!lib
diff --git a/packages/gitbook/bin/gitbook.js b/packages/gitbook/bin/gitbook.js
new file mode 100755
index 0000000..0492d29
--- /dev/null
+++ b/packages/gitbook/bin/gitbook.js
@@ -0,0 +1,8 @@
+#! /usr/bin/env node
+/* eslint-disable no-console,no-var */
+
+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/package.json b/packages/gitbook/package.json
new file mode 100644
index 0000000..0e85179
--- /dev/null
+++ b/packages/gitbook/package.json
@@ -0,0 +1,107 @@
+{
+ "name": "gitbook",
+ "version": "4.0.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-core": "4.0.0",
+ "gitbook-markdown": "1.3.2",
+ "gitbook-plugin-copy-code": "4.0.0",
+ "gitbook-plugin-headings": "4.0.0",
+ "gitbook-plugin-highlight": "4.0.0",
+ "gitbook-plugin-hints": "4.0.0",
+ "gitbook-plugin-livereload": "4.0.0",
+ "gitbook-plugin-lunr": "4.0.0",
+ "gitbook-plugin-search": "4.0.0",
+ "gitbook-plugin-sharing": "4.0.0",
+ "gitbook-plugin-theme-default": "4.0.0",
+ "github-slugid": "1.0.1",
+ "graceful-fs": "4.1.4",
+ "i18n-t": "1.0.1",
+ "ied": "2.3.6",
+ "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.10.9",
+ "nunjucks": "2.5.2",
+ "object-path": "^0.9.2",
+ "omit-keys": "^0.1.0",
+ "open": "0.0.5",
+ "q": "1.4.1",
+ "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 \"./src/**/*/__tests__/*.js\" --bail --reporter=list --timeout=100000 --compilers js:babel-register",
+ "dist": "rm -rf lib/ && babel -d lib/ src/ --source-maps --ignore \"**/*/__tests__/*.js\"",
+ "prepublish": "npm run dist"
+ },
+ "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"
+ }
+ ],
+ "devDependencies": {
+ "babel-cli": "^6.14.0",
+ "babel-preset-es2015": "^6.14.0",
+ "babel-preset-react": "^6.11.1",
+ "babel-preset-stage-2": "^6.13.0",
+ "babel-register": "^6.14.0",
+ "mocha": "^3.0.2"
+ }
+}
diff --git a/packages/gitbook/src/__tests__/gitbook.js b/packages/gitbook/src/__tests__/gitbook.js
new file mode 100644
index 0000000..5292e01
--- /dev/null
+++ b/packages/gitbook/src/__tests__/gitbook.js
@@ -0,0 +1,9 @@
+const gitbook = require('../gitbook');
+
+describe('satisfies', function() {
+
+ it('should return true for *', function() {
+ expect(gitbook.satisfies('*')).toBe(true);
+ });
+
+});
diff --git a/packages/gitbook/src/__tests__/init.js b/packages/gitbook/src/__tests__/init.js
new file mode 100644
index 0000000..d8e5398
--- /dev/null
+++ b/packages/gitbook/src/__tests__/init.js
@@ -0,0 +1,16 @@
+const tmp = require('tmp');
+const initBook = require('../init');
+
+describe('initBook', function() {
+
+ it('should create a README and SUMMARY for empty book', function() {
+ const 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/src/__tests__/module.js b/packages/gitbook/src/__tests__/module.js
new file mode 100644
index 0000000..d9220f5
--- /dev/null
+++ b/packages/gitbook/src/__tests__/module.js
@@ -0,0 +1,6 @@
+
+describe('GitBook', function() {
+ it('should correctly export', function() {
+ require('../');
+ });
+});
diff --git a/packages/gitbook/src/api/decodeConfig.js b/packages/gitbook/src/api/decodeConfig.js
new file mode 100644
index 0000000..0c5ba66
--- /dev/null
+++ b/packages/gitbook/src/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) {
+ const values = result.values;
+
+ delete values.generator;
+ delete values.output;
+
+ return config.updateValues(values);
+}
+
+module.exports = decodeGlobal;
diff --git a/packages/gitbook/src/api/decodeGlobal.js b/packages/gitbook/src/api/decodeGlobal.js
new file mode 100644
index 0000000..c7bbcc7
--- /dev/null
+++ b/packages/gitbook/src/api/decodeGlobal.js
@@ -0,0 +1,22 @@
+const 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} output
+ */
+function decodeGlobal(output, result) {
+ let book = output.getBook();
+ let 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/src/api/decodePage.js b/packages/gitbook/src/api/decodePage.js
new file mode 100644
index 0000000..16e5115
--- /dev/null
+++ b/packages/gitbook/src/api/decodePage.js
@@ -0,0 +1,34 @@
+const Immutable = require('immutable');
+
+/**
+ * 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) {
+ const originalContent = page.getContent();
+
+ // No returned value
+ // Existing content will be used
+ if (!result) {
+ return page;
+ }
+
+ // Update page attributes
+ const newAttributes = Immutable.fromJS(result.attributes);
+ page = page.set('attributes', newAttributes);
+
+ // GitBook 3
+ // Use returned page.content if different from original content
+ if (result.content != originalContent) {
+ page = page.set('content', result.content);
+ }
+
+ return page;
+}
+
+module.exports = decodePage;
diff --git a/packages/gitbook/src/api/deprecate.js b/packages/gitbook/src/api/deprecate.js
new file mode 100644
index 0000000..c781971
--- /dev/null
+++ b/packages/gitbook/src/api/deprecate.js
@@ -0,0 +1,120 @@
+const is = require('is');
+const objectPath = require('object-path');
+
+const logged = {};
+const 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;
+
+ const 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(...args) {
+ logNotice(book, key, msg);
+ return fn.apply(this, args);
+ };
+}
+
+/**
+ * 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) {
+ let store = undefined;
+
+ const prepare = () => {
+ if (!is.undefined(store)) return;
+
+ if (is.fn(value)) store = value();
+ else store = value;
+ };
+
+ const getter = () => {
+ prepare();
+
+ logNotice(book, key, msg);
+ return store;
+ };
+
+ const setter = (v) => {
+ prepare();
+
+ logNotice(book, key, msg);
+ store = v;
+ return store;
+ };
+
+ Object.defineProperty(instance, property, {
+ get: getter,
+ set: setter,
+ enumerable: false,
+ 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');
+ const 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/src/api/encodeConfig.js b/packages/gitbook/src/api/encodeConfig.js
new file mode 100644
index 0000000..cdfc0b7
--- /dev/null
+++ b/packages/gitbook/src/api/encodeConfig.js
@@ -0,0 +1,36 @@
+const objectPath = require('object-path');
+const deprecate = require('./deprecate');
+
+/**
+ * Encode a config object into a JS config api
+ *
+ * @param {Output} output
+ * @param {Config} config
+ * @return {Object}
+ */
+function encodeConfig(output, config) {
+ const result = {
+ values: config.getValues().toJS(),
+
+ get(key, defaultValue) {
+ return objectPath.get(result.values, key, defaultValue);
+ },
+
+ set(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/src/api/encodeGlobal.js b/packages/gitbook/src/api/encodeGlobal.js
new file mode 100644
index 0000000..89db629
--- /dev/null
+++ b/packages/gitbook/src/api/encodeGlobal.js
@@ -0,0 +1,264 @@
+const path = require('path');
+const Promise = require('../utils/promise');
+const PathUtils = require('../utils/path');
+const fs = require('../utils/fs');
+
+const Plugins = require('../plugins');
+const deprecate = require('./deprecate');
+const defaultBlocks = require('../constants/defaultBlocks');
+const gitbook = require('../gitbook');
+const parsers = require('../parsers');
+
+const encodeConfig = require('./encodeConfig');
+const encodeSummary = require('./encodeSummary');
+const encodeNavigation = require('./encodeNavigation');
+const 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) {
+ const book = output.getBook();
+ const bookFS = book.getContentFS();
+ const logger = output.getLogger();
+ const outputFolder = output.getRoot();
+ const plugins = output.getPlugins();
+ const blocks = Plugins.listBlocks(plugins);
+ const urls = output.getURLIndex();
+
+ const result = {
+ log: logger,
+ config: encodeConfig(output, book.getConfig()),
+ summary: encodeSummary(output, book.getSummary()),
+
+ /**
+ * Return absolute path to the root folder of the book
+ * @return {String}
+ */
+ root() {
+ return book.getRoot();
+ },
+
+ /**
+ * Return absolute path to the root folder of the book (for content)
+ * @return {String}
+ */
+ contentRoot() {
+ return book.getContentRoot();
+ },
+
+ /**
+ * Check if the book is a multilingual book.
+ * @return {Boolean}
+ */
+ isMultilingual() {
+ return book.isMultilingual();
+ },
+
+ /**
+ * Check if the book is a language book for a multilingual book.
+ * @return {Boolean}
+ */
+ isLanguageBook() {
+ return book.isLanguageBook();
+ },
+
+ /**
+ * Read a file from the book.
+ * @param {String} fileName
+ * @return {Promise<Buffer>}
+ */
+ readFile(fileName) {
+ return bookFS.read(fileName);
+ },
+
+ /**
+ * Read a file from the book as a string.
+ * @param {String} fileName
+ * @return {Promise<String>}
+ */
+ readFileAsString(fileName) {
+ return bookFS.readAsString(fileName);
+ },
+
+ /**
+ * Resolve a file from the book root.
+ * @param {String} fileName
+ * @return {String}
+ */
+ resolve(fileName) {
+ return path.resolve(book.getContentRoot(), fileName);
+ },
+
+ /**
+ * Resolve a page by it path.
+ * @param {String} filePath
+ * @return {String}
+ */
+ getPageByPath(filePath) {
+ const 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(type, text) {
+ const 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(type, text) {
+ const 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(name, blockData) {
+ const 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() {
+ return outputFolder;
+ },
+
+ /**
+ * Resolve a file from the output root.
+ * @param {String} fileName
+ * @return {String}
+ */
+ resolve(fileName) {
+ return path.resolve(outputFolder, fileName);
+ },
+
+ /**
+ * Convert a filepath into an url
+ * @return {String}
+ */
+ toURL(filePath) {
+ return urls.resolveToURL(filePath);
+ },
+
+ /**
+ * Check that a file exists.
+ * @param {String} fileName
+ * @return {Promise}
+ */
+ hasFile(fileName, content) {
+ return Promise()
+ .then(function() {
+ const 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(fileName, content) {
+ return Promise()
+ .then(function() {
+ const 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(inputFile, outputFile, content) {
+ return Promise()
+ .then(function() {
+ const 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/src/api/encodeNavigation.js b/packages/gitbook/src/api/encodeNavigation.js
new file mode 100644
index 0000000..95ab8e3
--- /dev/null
+++ b/packages/gitbook/src/api/encodeNavigation.js
@@ -0,0 +1,64 @@
+const Immutable = require('immutable');
+
+/**
+ * Encode an article for next/prev
+ *
+ * @param {Map<String:Page>}
+ * @param {Article}
+ * @return {Object}
+ */
+function encodeArticle(pages, article) {
+ const 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) {
+ const book = output.getBook();
+ const pages = output.getPages();
+ const summary = book.getSummary();
+ const articles = summary.getArticlesAsList();
+
+
+ const navigation = articles
+ .map(function(article, i) {
+ const ref = article.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+ const prev = articles.get(i - 1);
+ const 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/src/api/encodePage.js b/packages/gitbook/src/api/encodePage.js
new file mode 100644
index 0000000..7d563cd
--- /dev/null
+++ b/packages/gitbook/src/api/encodePage.js
@@ -0,0 +1,45 @@
+const JSONUtils = require('../json');
+const deprecate = require('./deprecate');
+const 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) {
+ const book = output.getBook();
+ const urls = output.getURLIndex();
+ const summary = book.getSummary();
+ const fs = book.getContentFS();
+ const file = page.getFile();
+
+ // JS Page is based on the JSON output
+ const result = JSONUtils.encodePage(page, summary, urls);
+
+ result.type = file.getType();
+ result.path = file.getPath();
+ result.rawPath = fs.resolve(result.path);
+
+ result.setAttribute = (key, value) => {
+ result.attributes[key] = value;
+ return result;
+ };
+
+ 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/src/api/encodeProgress.js b/packages/gitbook/src/api/encodeProgress.js
new file mode 100644
index 0000000..3224370
--- /dev/null
+++ b/packages/gitbook/src/api/encodeProgress.js
@@ -0,0 +1,63 @@
+const Immutable = require('immutable');
+const encodeNavigation = require('./encodeNavigation');
+
+/**
+ page.progress is a deprecated property from GitBook v2
+
+ @param {Output}
+ @param {Page}
+ @return {Object}
+*/
+function encodeProgress(output, page) {
+ const current = page.getPath();
+ let navigation = encodeNavigation(output);
+ navigation = Immutable.Map(navigation);
+
+ const n = navigation.size;
+ let percent = 0, prevPercent = 0, currentChapter = null;
+ let done = true;
+
+ const 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,
+
+ // Current percent
+ percent,
+
+ // List of chapter with progress
+ chapters,
+
+ // Current chapter
+ current: currentChapter
+ };
+}
+
+module.exports = encodeProgress;
+
diff --git a/packages/gitbook/src/api/encodeSummary.js b/packages/gitbook/src/api/encodeSummary.js
new file mode 100644
index 0000000..323f5d4
--- /dev/null
+++ b/packages/gitbook/src/api/encodeSummary.js
@@ -0,0 +1,52 @@
+const encodeSummaryArticle = require('../json/encodeSummaryArticle');
+
+/**
+ Encode summary to provide an API to plugin
+
+ @param {Output} output
+ @param {Config} config
+ @return {Object}
+*/
+function encodeSummary(output, summary) {
+ const result = {
+
+ /**
+ Iterate over the summary, it stops when the "iter" returns false
+
+ @param {Function} iter
+ */
+ walk(iter) {
+ summary.getArticle(function(article) {
+ const jsonArticle = encodeSummaryArticle(article, false);
+
+ return iter(jsonArticle);
+ });
+ },
+
+ /**
+ Get an article by its level
+
+ @param {String} level
+ @return {Object}
+ */
+ getArticleByLevel(level) {
+ const article = summary.getByLevel(level);
+ return (article ? encodeSummaryArticle(article) : undefined);
+ },
+
+ /**
+ Get an article by its path
+
+ @param {String} level
+ @return {Object}
+ */
+ getArticleByPath(level) {
+ const article = summary.getByPath(level);
+ return (article ? encodeSummaryArticle(article) : undefined);
+ }
+ };
+
+ return result;
+}
+
+module.exports = encodeSummary;
diff --git a/packages/gitbook/src/api/index.js b/packages/gitbook/src/api/index.js
new file mode 100644
index 0000000..3956c62
--- /dev/null
+++ b/packages/gitbook/src/api/index.js
@@ -0,0 +1,7 @@
+
+module.exports = {
+ encodePage: require('./encodePage'),
+ decodePage: require('./decodePage'),
+ encodeGlobal: require('./encodeGlobal'),
+ decodeGlobal: require('./decodeGlobal')
+};
diff --git a/packages/gitbook/src/browser.js b/packages/gitbook/src/browser.js
new file mode 100644
index 0000000..1e7fad2
--- /dev/null
+++ b/packages/gitbook/src/browser.js
@@ -0,0 +1,23 @@
+const 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/src/browser/__tests__/render.js b/packages/gitbook/src/browser/__tests__/render.js
new file mode 100644
index 0000000..799be44
--- /dev/null
+++ b/packages/gitbook/src/browser/__tests__/render.js
@@ -0,0 +1,4 @@
+
+describe('render', () => {
+
+});
diff --git a/packages/gitbook/src/browser/loadPlugins.js b/packages/gitbook/src/browser/loadPlugins.js
new file mode 100644
index 0000000..c9bf7a6
--- /dev/null
+++ b/packages/gitbook/src/browser/loadPlugins.js
@@ -0,0 +1,31 @@
+const path = require('path');
+const timing = require('../utils/timing');
+
+/**
+ * Load all browser plugins.
+ *
+ * @param {OrderedMap<Plugin>} plugins
+ * @param {String} type ('browser', 'ebook')
+ * @return {Array}
+ */
+function loadPlugins(plugins, type) {
+ return timing.measure(
+ 'browser.loadPlugins',
+ () => {
+ return plugins
+ .valueSeq()
+ .filter(plugin => plugin.getPackage().has(type))
+ .map(plugin => {
+ const browserFile = path.resolve(
+ plugin.getPath(),
+ plugin.getPackage().get(type)
+ );
+
+ return require(browserFile);
+ })
+ .toArray();
+ }
+ );
+}
+
+module.exports = loadPlugins;
diff --git a/packages/gitbook/src/browser/render.js b/packages/gitbook/src/browser/render.js
new file mode 100644
index 0000000..86c3dff
--- /dev/null
+++ b/packages/gitbook/src/browser/render.js
@@ -0,0 +1,103 @@
+const ReactDOMServer = require('gitbook-core/lib/server');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const timing = require('../utils/timing');
+const loadPlugins = require('./loadPlugins');
+
+function HTML({head, innerHTML, payload, scripts, bootstrap}) {
+ const attrs = head.htmlAttributes.toComponent();
+
+ return (
+ <html {...attrs}>
+ <head>
+ {head.title.toComponent()}
+ {head.meta.toComponent()}
+ {head.link.toComponent()}
+ {head.style.toComponent()}
+ </head>
+ <body>
+ <div id="content" dangerouslySetInnerHTML={{__html: innerHTML}} />
+ {scripts.map(script => {
+ return <script key={script} src={script} />;
+ })}
+ <script type="application/payload+json" dangerouslySetInnerHTML={{__html: payload}} />
+ <script type="application/javascript" dangerouslySetInnerHTML={{__html: bootstrap}} />
+ {head.script.toComponent()}
+ </body>
+ </html>
+ );
+}
+HTML.propTypes = {
+ head: React.PropTypes.object,
+ innerHTML: React.PropTypes.string,
+ payload: React.PropTypes.string,
+ bootstrap: React.PropTypes.string,
+ scripts: React.PropTypes.arrayOf(React.PropTypes.string)
+};
+
+/**
+ * Get bootstrap code for a role
+ * @param {String} role
+ * @return {String}
+ */
+function getBootstrapCode(role) {
+ return `(function() { require("gitbook-core").bootstrap({ role: "${role}" }) })()`;
+}
+
+/**
+ * Render a view using plugins.
+ *
+ * @param {OrderedMap<String:Plugin>} plugin
+ * @param {Object} initialState
+ * @param {String} type ("ebook" or "browser")
+ * @param {String} role
+ * @return {String} html
+ */
+function render(plugins, initialState, type, role) {
+ return timing.measure(
+ 'browser.render',
+ () => {
+ // Load the plugins
+ const browserPlugins = loadPlugins(plugins, type);
+ const payload = JSON.stringify(initialState);
+ const context = GitBook.createContext(browserPlugins, initialState);
+
+ const currentFile = context.getState().file;
+
+ const scripts = plugins.toList()
+ .filter(plugin => plugin.getPackage().has(type))
+ .map(plugin => {
+ return currentFile.relative('gitbook/plugins/' + plugin.getName() + '.js');
+ })
+ .toArray();
+
+ const el = GitBook.renderWithContext(context, { role });
+
+ // We're done with the context
+ context.deactivate();
+
+ // Render inner body
+ const innerHTML = ReactDOMServer.renderToString(el);
+
+ // Get headers
+ const head = GitBook.Head.rewind();
+
+ // Render whole HTML page
+ const htmlEl = <HTML
+ head={head}
+ innerHTML={innerHTML}
+ payload={payload}
+ bootstrap={getBootstrapCode(role)}
+ scripts={[
+ currentFile.relative('gitbook/core.js')
+ ].concat(scripts)}
+ />;
+
+ const html = ReactDOMServer.renderToStaticMarkup(htmlEl);
+ return html;
+ }
+ );
+}
+
+module.exports = render;
diff --git a/packages/gitbook/src/cli/build.js b/packages/gitbook/src/cli/build.js
new file mode 100644
index 0000000..3f5c937
--- /dev/null
+++ b/packages/gitbook/src/cli/build.js
@@ -0,0 +1,34 @@
+const Parse = require('../parse');
+const Output = require('../output');
+const timing = require('../utils/timing');
+
+const options = require('./options');
+const getBook = require('./getBook');
+const getOutputFolder = require('./getOutputFolder');
+
+
+module.exports = {
+ name: 'build [book] [output]',
+ description: 'build a book',
+ options: [
+ options.log,
+ options.format,
+ options.timing
+ ],
+ exec(args, kwargs) {
+ const book = getBook(args, kwargs);
+ const outputFolder = getOutputFolder(args);
+
+ const 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/src/cli/buildEbook.js b/packages/gitbook/src/cli/buildEbook.js
new file mode 100644
index 0000000..56e63f8
--- /dev/null
+++ b/packages/gitbook/src/cli/buildEbook.js
@@ -0,0 +1,78 @@
+const path = require('path');
+const tmp = require('tmp');
+
+const Promise = require('../utils/promise');
+const fs = require('../utils/fs');
+const Parse = require('../parse');
+const Output = require('../output');
+
+const options = require('./options');
+const getBook = require('./getBook');
+
+
+module.exports = function(format) {
+ return {
+ name: (format + ' [book] [output]'),
+ description: 'build a book into an ebook file',
+ options: [
+ options.log
+ ],
+ exec(args, kwargs) {
+ const extension = '.' + format;
+
+ // Output file will be stored in
+ const outputFile = args[1] || ('book' + extension);
+
+ // Create temporary directory
+ const outputFolder = tmp.dirSync().name;
+
+ const book = getBook(args, kwargs);
+ const logger = book.getLogger();
+ const Generator = Output.getGenerator('ebook');
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ return Output.generate(Generator, resultBook, {
+ root: outputFolder,
+ format
+ });
+ })
+
+ // Extract ebook file
+ .then(function(output) {
+ const book = output.getBook();
+ const languages = book.getLanguages();
+
+ if (book.isMultilingual()) {
+ return Promise.forEach(languages.getList(), function(lang) {
+ const langID = lang.getID();
+
+ const 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/src/cli/getBook.js b/packages/gitbook/src/cli/getBook.js
new file mode 100644
index 0000000..b37e49c
--- /dev/null
+++ b/packages/gitbook/src/cli/getBook.js
@@ -0,0 +1,23 @@
+const path = require('path');
+const Book = require('../models/book');
+const 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) {
+ const input = path.resolve(args[0] || process.cwd());
+ const logLevel = kwargs.log;
+
+ const fs = createNodeFS(input);
+ const book = Book.createForFS(fs);
+
+ return book.setLogLevel(logLevel);
+}
+
+module.exports = getBook;
diff --git a/packages/gitbook/src/cli/getOutputFolder.js b/packages/gitbook/src/cli/getOutputFolder.js
new file mode 100644
index 0000000..94f22da
--- /dev/null
+++ b/packages/gitbook/src/cli/getOutputFolder.js
@@ -0,0 +1,17 @@
+const path = require('path');
+
+/**
+ Return path to output folder
+
+ @param {Array} args
+ @return {String}
+*/
+function getOutputFolder(args) {
+ const bookRoot = path.resolve(args[0] || process.cwd());
+ const defaultOutputRoot = path.join(bookRoot, '_book');
+ const outputFolder = args[1] ? path.resolve(process.cwd(), args[1]) : defaultOutputRoot;
+
+ return outputFolder;
+}
+
+module.exports = getOutputFolder;
diff --git a/packages/gitbook/src/cli/index.js b/packages/gitbook/src/cli/index.js
new file mode 100644
index 0000000..48ad117
--- /dev/null
+++ b/packages/gitbook/src/cli/index.js
@@ -0,0 +1,12 @@
+const 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/src/cli/init.js b/packages/gitbook/src/cli/init.js
new file mode 100644
index 0000000..51d6869
--- /dev/null
+++ b/packages/gitbook/src/cli/init.js
@@ -0,0 +1,17 @@
+const path = require('path');
+
+const options = require('./options');
+const initBook = require('../init');
+
+module.exports = {
+ name: 'init [book]',
+ description: 'setup and create files for chapters',
+ options: [
+ options.log
+ ],
+ exec(args, kwargs) {
+ const bookRoot = path.resolve(process.cwd(), args[0] || './');
+
+ return initBook(bookRoot);
+ }
+};
diff --git a/packages/gitbook/src/cli/install.js b/packages/gitbook/src/cli/install.js
new file mode 100644
index 0000000..6af4013
--- /dev/null
+++ b/packages/gitbook/src/cli/install.js
@@ -0,0 +1,21 @@
+const options = require('./options');
+const getBook = require('./getBook');
+
+const Parse = require('../parse');
+const Plugins = require('../plugins');
+
+module.exports = {
+ name: 'install [book]',
+ description: 'install all plugins dependencies',
+ options: [
+ options.log
+ ],
+ exec(args, kwargs) {
+ const book = getBook(args, kwargs);
+
+ return Parse.parseConfig(book)
+ .then(function(resultBook) {
+ return Plugins.installPlugins(resultBook);
+ });
+ }
+};
diff --git a/packages/gitbook/src/cli/options.js b/packages/gitbook/src/cli/options.js
new file mode 100644
index 0000000..d643f91
--- /dev/null
+++ b/packages/gitbook/src/cli/options.js
@@ -0,0 +1,31 @@
+const Logger = require('../utils/logger');
+
+const logOptions = {
+ name: 'log',
+ description: 'Minimum log level to display',
+ values: Logger.LEVELS
+ .keySeq()
+ .map(function(s) {
+ return s.toLowerCase();
+ }).toJS(),
+ defaults: 'info'
+};
+
+const formatOption = {
+ name: 'format',
+ description: 'Format to build to',
+ values: ['website', 'json', 'ebook'],
+ defaults: 'website'
+};
+
+const timingOption = {
+ name: 'timing',
+ description: 'Print timing debug information',
+ defaults: false
+};
+
+module.exports = {
+ log: logOptions,
+ format: formatOption,
+ timing: timingOption
+};
diff --git a/packages/gitbook/src/cli/parse.js b/packages/gitbook/src/cli/parse.js
new file mode 100644
index 0000000..3d38fe7
--- /dev/null
+++ b/packages/gitbook/src/cli/parse.js
@@ -0,0 +1,79 @@
+const options = require('./options');
+const getBook = require('./getBook');
+
+const Parse = require('../parse');
+
+function printBook(book) {
+ const logger = book.getLogger();
+
+ const config = book.getConfig();
+ const configFile = config.getFile();
+
+ const summary = book.getSummary();
+ const summaryFile = summary.getFile();
+
+ const readme = book.getReadme();
+ const readmeFile = readme.getFile();
+
+ const glossary = book.getGlossary();
+ const 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) {
+ const logger = book.getLogger();
+ const languages = book.getLanguages();
+ const 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(args, kwargs) {
+ const book = getBook(args, kwargs);
+ const logger = book.getLogger();
+
+ return Parse.parseBook(book)
+ .then(function(resultBook) {
+ const rootFolder = book.getRoot();
+ const 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/src/cli/serve.js b/packages/gitbook/src/cli/serve.js
new file mode 100644
index 0000000..6397c2e
--- /dev/null
+++ b/packages/gitbook/src/cli/serve.js
@@ -0,0 +1,159 @@
+/* eslint-disable no-console */
+
+const tinylr = require('tiny-lr');
+const open = require('open');
+
+const Parse = require('../parse');
+const Output = require('../output');
+const ConfigModifier = require('../modifiers').Config;
+
+const Promise = require('../utils/promise');
+
+const options = require('./options');
+const getBook = require('./getBook');
+const getOutputFolder = require('./getOutputFolder');
+const Server = require('./server');
+const watch = require('./watch');
+
+let server, lrServer, lrPath;
+
+function waitForCtrlC() {
+ const d = Promise.defer();
+
+ process.on('SIGINT', function() {
+ d.resolve();
+ });
+
+ return d.promise;
+}
+
+
+function generateBook(args, kwargs) {
+ const port = kwargs.port;
+ const outputFolder = getOutputFolder(args);
+ const book = getBook(args, kwargs);
+ const Generator = Output.getGenerator(kwargs.format);
+ const browser = kwargs['browser'];
+
+ const hasWatch = kwargs['watch'];
+ const hasLiveReloading = kwargs['live'];
+ const 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
+ let 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(args, kwargs) {
+ server = new Server();
+ const hasWatch = kwargs['watch'];
+ const 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/src/cli/server.js b/packages/gitbook/src/cli/server.js
new file mode 100644
index 0000000..c494efc
--- /dev/null
+++ b/packages/gitbook/src/cli/server.js
@@ -0,0 +1,127 @@
+const events = require('events');
+const http = require('http');
+const send = require('send');
+const url = require('url');
+
+const Promise = require('../utils/promise');
+
+class Server extends events.EventEmitter {
+ constructor() {
+ super();
+ this.running = null;
+ this.dir = null;
+ this.port = 0;
+ this.sockets = [];
+ }
+
+ /**
+ * Return true if the server is running
+ * @return {Boolean}
+ */
+ isRunning() {
+ return !!this.running;
+ }
+
+ /**
+ * Stop the server
+ * @return {Promise}
+ */
+ stop() {
+ const that = this;
+ if (!this.isRunning()) return Promise();
+
+ const d = Promise.defer();
+ this.running.close(function(err) {
+ that.running = null;
+ that.emit('state', false);
+
+ if (err) d.reject(err);
+ else d.resolve();
+ });
+
+ for (let i = 0; i < this.sockets.length; i++) {
+ this.sockets[i].destroy();
+ }
+
+ return d.promise;
+ }
+
+ /**
+ * Start the server
+ * @return {Promise}
+ */
+ start(dir, port) {
+ const that = this;
+ let pre = Promise();
+ port = port || 8004;
+
+ if (that.isRunning()) pre = this.stop();
+ return pre
+ .then(function() {
+ const 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() {
+ const 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/src/cli/watch.js b/packages/gitbook/src/cli/watch.js
new file mode 100644
index 0000000..e1d453c
--- /dev/null
+++ b/packages/gitbook/src/cli/watch.js
@@ -0,0 +1,46 @@
+const path = require('path');
+const chokidar = require('chokidar');
+
+const Promise = require('../utils/promise');
+const parsers = require('../parsers');
+
+/**
+ Watch a folder and resolve promise once a file is modified
+
+ @param {String} dir
+ @return {Promise}
+*/
+function watch(dir) {
+ const d = Promise.defer();
+ dir = path.resolve(dir);
+
+ const toWatch = [
+ 'book.json', 'book.js', '_layouts/**'
+ ];
+
+ // Watch all parsable files
+ parsers.extensions.forEach(function(ext) {
+ toWatch.push('**/*' + ext);
+ });
+
+ const 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/src/constants/__tests__/configSchema.js b/packages/gitbook/src/constants/__tests__/configSchema.js
new file mode 100644
index 0000000..df83680
--- /dev/null
+++ b/packages/gitbook/src/constants/__tests__/configSchema.js
@@ -0,0 +1,46 @@
+const jsonschema = require('jsonschema');
+const schema = require('../configSchema');
+
+describe('configSchema', function() {
+
+ function validate(cfg) {
+ const v = new jsonschema.Validator();
+ return v.validate(cfg, schema, {
+ propertyName: 'config'
+ });
+ }
+
+ describe('structure', function() {
+
+ it('should accept dot in filename', function() {
+ const result = validate({
+ structure: {
+ readme: 'book-intro.adoc'
+ }
+ });
+
+ expect(result.errors.length).toBe(0);
+ });
+
+ it('should accept uppercase in filename', function() {
+ const result = validate({
+ structure: {
+ readme: 'BOOK.adoc'
+ }
+ });
+
+ expect(result.errors.length).toBe(0);
+ });
+
+ it('should not accept filepath', function() {
+ const result = validate({
+ structure: {
+ readme: 'folder/myFile.md'
+ }
+ });
+
+ expect(result.errors.length).toBe(1);
+ });
+
+ });
+});
diff --git a/packages/gitbook/src/constants/configDefault.js b/packages/gitbook/src/constants/configDefault.js
new file mode 100644
index 0000000..c384c6c
--- /dev/null
+++ b/packages/gitbook/src/constants/configDefault.js
@@ -0,0 +1,6 @@
+const Immutable = require('immutable');
+const jsonSchemaDefaults = require('json-schema-defaults');
+
+const schema = require('./configSchema');
+
+module.exports = Immutable.fromJS(jsonSchemaDefaults(schema));
diff --git a/packages/gitbook/src/constants/configFiles.js b/packages/gitbook/src/constants/configFiles.js
new file mode 100644
index 0000000..a67fd74
--- /dev/null
+++ b/packages/gitbook/src/constants/configFiles.js
@@ -0,0 +1,5 @@
+// Configuration files to test (sorted)
+module.exports = [
+ 'book.js',
+ 'book.json'
+];
diff --git a/packages/gitbook/src/constants/configSchema.js b/packages/gitbook/src/constants/configSchema.js
new file mode 100644
index 0000000..9aaf8cd
--- /dev/null
+++ b/packages/gitbook/src/constants/configSchema.js
@@ -0,0 +1,194 @@
+const 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/src/constants/defaultBlocks.js b/packages/gitbook/src/constants/defaultBlocks.js
new file mode 100644
index 0000000..d1fe6ff
--- /dev/null
+++ b/packages/gitbook/src/constants/defaultBlocks.js
@@ -0,0 +1,5 @@
+const Immutable = require('immutable');
+
+module.exports = Immutable.Map({
+
+});
diff --git a/packages/gitbook/src/constants/defaultFilters.js b/packages/gitbook/src/constants/defaultFilters.js
new file mode 100644
index 0000000..c9bffe1
--- /dev/null
+++ b/packages/gitbook/src/constants/defaultFilters.js
@@ -0,0 +1,15 @@
+const Immutable = require('immutable');
+const moment = require('moment');
+
+module.exports = Immutable.Map({
+ // Format a date
+ // ex: 'MMMM Do YYYY, h:mm:ss a
+ date(time, format) {
+ return moment(time).format(format);
+ },
+
+ // Relative Time
+ dateFromNow(time) {
+ return moment(time).fromNow();
+ }
+});
diff --git a/packages/gitbook/src/constants/defaultPlugins.js b/packages/gitbook/src/constants/defaultPlugins.js
new file mode 100644
index 0000000..326ad3a
--- /dev/null
+++ b/packages/gitbook/src/constants/defaultPlugins.js
@@ -0,0 +1,31 @@
+const Immutable = require('immutable');
+const PluginDependency = require('../models/pluginDependency');
+
+const pkg = require('../../package.json');
+
+/**
+ * Create a PluginDependency from a dependency of gitbook
+ * @param {String} pluginName
+ * @return {PluginDependency}
+ */
+function createFromDependency(pluginName) {
+ const npmID = PluginDependency.nameToNpmID(pluginName);
+ const 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',
+ 'hints',
+ 'headings',
+ 'copy-code',
+ 'theme-default'
+]).map(createFromDependency);
diff --git a/packages/gitbook/src/constants/extsAsciidoc.js b/packages/gitbook/src/constants/extsAsciidoc.js
new file mode 100644
index 0000000..b2f4ce4
--- /dev/null
+++ b/packages/gitbook/src/constants/extsAsciidoc.js
@@ -0,0 +1,4 @@
+module.exports = [
+ '.adoc',
+ '.asciidoc'
+];
diff --git a/packages/gitbook/src/constants/extsMarkdown.js b/packages/gitbook/src/constants/extsMarkdown.js
new file mode 100644
index 0000000..44bf36b
--- /dev/null
+++ b/packages/gitbook/src/constants/extsMarkdown.js
@@ -0,0 +1,5 @@
+module.exports = [
+ '.md',
+ '.markdown',
+ '.mdown'
+];
diff --git a/packages/gitbook/src/constants/ignoreFiles.js b/packages/gitbook/src/constants/ignoreFiles.js
new file mode 100644
index 0000000..aac225e
--- /dev/null
+++ b/packages/gitbook/src/constants/ignoreFiles.js
@@ -0,0 +1,6 @@
+// Files containing ignore pattner (sorted by priority)
+module.exports = [
+ '.ignore',
+ '.gitignore',
+ '.bookignore'
+];
diff --git a/packages/gitbook/src/constants/pluginAssetsFolder.js b/packages/gitbook/src/constants/pluginAssetsFolder.js
new file mode 100644
index 0000000..cd44722
--- /dev/null
+++ b/packages/gitbook/src/constants/pluginAssetsFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_assets';
diff --git a/packages/gitbook/src/constants/pluginHooks.js b/packages/gitbook/src/constants/pluginHooks.js
new file mode 100644
index 0000000..2d5dcaa
--- /dev/null
+++ b/packages/gitbook/src/constants/pluginHooks.js
@@ -0,0 +1,8 @@
+module.exports = [
+ 'init',
+ 'finish',
+ 'finish:before',
+ 'config',
+ 'page',
+ 'page:before'
+];
diff --git a/packages/gitbook/src/constants/pluginPrefix.js b/packages/gitbook/src/constants/pluginPrefix.js
new file mode 100644
index 0000000..c7f2dd0
--- /dev/null
+++ b/packages/gitbook/src/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/src/constants/pluginResources.js b/packages/gitbook/src/constants/pluginResources.js
new file mode 100644
index 0000000..cc9d134
--- /dev/null
+++ b/packages/gitbook/src/constants/pluginResources.js
@@ -0,0 +1,6 @@
+const Immutable = require('immutable');
+
+module.exports = Immutable.List([
+ 'js',
+ 'css'
+]);
diff --git a/packages/gitbook/src/constants/templatesFolder.js b/packages/gitbook/src/constants/templatesFolder.js
new file mode 100644
index 0000000..aad6a72
--- /dev/null
+++ b/packages/gitbook/src/constants/templatesFolder.js
@@ -0,0 +1,2 @@
+
+module.exports = '_layouts';
diff --git a/packages/gitbook/src/constants/themePrefix.js b/packages/gitbook/src/constants/themePrefix.js
new file mode 100644
index 0000000..621e85c
--- /dev/null
+++ b/packages/gitbook/src/constants/themePrefix.js
@@ -0,0 +1,4 @@
+/*
+ All GitBook themes plugins name start with this prefix once shorted.
+*/
+module.exports = 'theme-';
diff --git a/packages/gitbook/src/fs/__tests__/mock.js b/packages/gitbook/src/fs/__tests__/mock.js
new file mode 100644
index 0000000..7d1ea48
--- /dev/null
+++ b/packages/gitbook/src/fs/__tests__/mock.js
@@ -0,0 +1,81 @@
+const createMockFS = require('../mock');
+
+describe('MockFS', function() {
+ const 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/src/fs/mock.js b/packages/gitbook/src/fs/mock.js
new file mode 100644
index 0000000..611b2ab
--- /dev/null
+++ b/packages/gitbook/src/fs/mock.js
@@ -0,0 +1,95 @@
+const path = require('path');
+const is = require('is');
+const Buffer = require('buffer').Buffer;
+const Immutable = require('immutable');
+
+const FS = require('../models/fs');
+const error = require('../utils/error');
+
+/**
+ * Create a fake filesystem for unit testing GitBook.
+ * @param {Map<String:String|Map>}
+ * @return {FS}
+ */
+function createMockFS(files, root = '') {
+ files = Immutable.fromJS(files);
+ const mtime = new Date();
+
+ function getFile(filePath) {
+ const parts = path.normalize(filePath).split(path.sep);
+ return parts.reduce(function(list, part, i) {
+ if (!list) return null;
+
+ let 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) {
+ const file = getFile(filePath);
+ if (!is.string(file)) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return new Buffer(file, 'utf8');
+ }
+
+ function fsStatFile(filePath) {
+ const file = getFile(filePath);
+ if (!file) {
+ throw error.FileNotFoundError({
+ filename: filePath
+ });
+ }
+
+ return {
+ mtime
+ };
+ }
+
+ function fsReadDir(filePath) {
+ const 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,
+ fsReadFile,
+ fsStatFile,
+ fsReadDir
+ });
+}
+
+module.exports = createMockFS;
diff --git a/packages/gitbook/src/fs/node.js b/packages/gitbook/src/fs/node.js
new file mode 100644
index 0000000..6e28daf
--- /dev/null
+++ b/packages/gitbook/src/fs/node.js
@@ -0,0 +1,42 @@
+const path = require('path');
+const Immutable = require('immutable');
+const fresh = require('fresh-require');
+
+const fs = require('../utils/fs');
+const 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;
+
+ const 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,
+
+ fsExists: fs.exists,
+ fsReadFile: fs.readFile,
+ fsStatFile: fs.stat,
+ fsReadDir,
+ fsLoadObject,
+ fsReadAsStream: fs.readStream
+ });
+};
diff --git a/packages/gitbook/src/gitbook.js b/packages/gitbook/src/gitbook.js
new file mode 100644
index 0000000..5786e68
--- /dev/null
+++ b/packages/gitbook/src/gitbook.js
@@ -0,0 +1,28 @@
+const semver = require('semver');
+const pkg = require('../package.json');
+
+const VERSION = pkg.version;
+const VERSION_STABLE = VERSION.replace(/\-(\S+)/g, '');
+
+const 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,
+ START_TIME
+};
diff --git a/packages/gitbook/src/index.js b/packages/gitbook/src/index.js
new file mode 100644
index 0000000..fc8f254
--- /dev/null
+++ b/packages/gitbook/src/index.js
@@ -0,0 +1,9 @@
+const common = require('./browser');
+
+module.exports = {
+ ...common,
+ initBook: require('./init'),
+ createNodeFS: require('./fs/node'),
+ Output: require('./output'),
+ commands: require('./cli')
+};
diff --git a/packages/gitbook/src/init.js b/packages/gitbook/src/init.js
new file mode 100644
index 0000000..bbd5f90
--- /dev/null
+++ b/packages/gitbook/src/init.js
@@ -0,0 +1,83 @@
+const path = require('path');
+
+const createNodeFS = require('./fs/node');
+const fs = require('./utils/fs');
+const Promise = require('./utils/promise');
+const File = require('./models/file');
+const Readme = require('./models/readme');
+const Book = require('./models/book');
+const 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) {
+ const extension = '.md';
+
+ return fs.mkdirp(rootFolder)
+
+ // Parse the summary and readme
+ .then(function() {
+ const bookFS = createNodeFS(rootFolder);
+ const book = Book.createForFS(bookFS);
+
+ return Parse.parseReadme(book)
+
+ // Setup default readme if doesn't found one
+ .fail(function() {
+ const readmeFile = File.createWithFilepath('README' + extension);
+ const readme = Readme.create(readmeFile);
+ return book.setReadme(readme);
+ });
+ })
+ .then(Parse.parseSummary)
+
+ .then(function(book) {
+ const logger = book.getLogger();
+ const summary = book.getSummary();
+ const summaryFile = summary.getFile();
+ const summaryFilename = summaryFile.getPath() || ('SUMMARY' + extension);
+
+ const articles = summary.getArticlesAsList();
+
+ // Write pages
+ return Promise.forEach(articles, function(article) {
+ const articlePath = article.getPath();
+ const 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() {
+ const 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/src/json/encodeFile.js b/packages/gitbook/src/json/encodeFile.js
new file mode 100644
index 0000000..2295ac1
--- /dev/null
+++ b/packages/gitbook/src/json/encodeFile.js
@@ -0,0 +1,23 @@
+
+/**
+ * Return a JSON representation of a file
+ *
+ * @param {File} file
+ * @param {URIIndex} urls
+ * @return {JSON} json
+ */
+function encodeFileToJson(file, urls) {
+ const filePath = file.getPath();
+ if (!filePath) {
+ return undefined;
+ }
+
+ return {
+ path: filePath,
+ mtime: file.getMTime(),
+ type: file.getType(),
+ url: urls.resolveToURL(filePath)
+ };
+}
+
+module.exports = encodeFileToJson;
diff --git a/packages/gitbook/src/json/encodeGlossary.js b/packages/gitbook/src/json/encodeGlossary.js
new file mode 100644
index 0000000..d82bb62
--- /dev/null
+++ b/packages/gitbook/src/json/encodeGlossary.js
@@ -0,0 +1,22 @@
+const encodeFile = require('./encodeFile');
+const encodeGlossaryEntry = require('./encodeGlossaryEntry');
+
+/**
+ * Encode a glossary to JSON
+ *
+ * @param {Glossary} glossary
+ * @param {URIIndex} urls
+ * @return {JSON} json
+ */
+function encodeGlossary(glossary, urls) {
+ const file = glossary.getFile();
+ const entries = glossary.getEntries();
+
+ return {
+ file: encodeFile(file, urls),
+ entries: entries
+ .map(encodeGlossaryEntry).toJS()
+ };
+}
+
+module.exports = encodeGlossary;
diff --git a/packages/gitbook/src/json/encodeGlossaryEntry.js b/packages/gitbook/src/json/encodeGlossaryEntry.js
new file mode 100644
index 0000000..52e13c3
--- /dev/null
+++ b/packages/gitbook/src/json/encodeGlossaryEntry.js
@@ -0,0 +1,16 @@
+
+/**
+ * Encode a SummaryArticle to JSON
+ *
+ * @param {GlossaryEntry} entry
+ * @return {JSON} json
+ */
+function encodeGlossaryEntry(entry) {
+ return {
+ id: entry.getID(),
+ name: entry.getName(),
+ description: entry.getDescription()
+ };
+}
+
+module.exports = encodeGlossaryEntry;
diff --git a/packages/gitbook/src/json/encodeLanguages.js b/packages/gitbook/src/json/encodeLanguages.js
new file mode 100644
index 0000000..809cfb2
--- /dev/null
+++ b/packages/gitbook/src/json/encodeLanguages.js
@@ -0,0 +1,29 @@
+const encodeFile = require('./encodeFile');
+
+/**
+ * Encode a languages listing to JSON
+ *
+ * @param {Languages} languages
+ * @param {String} currentLanguage
+ * @param {URIIndex} urls
+ * @return {JSON} json
+*/
+function encodeLanguages(languages, currentLanguage, urls) {
+ const file = languages.getFile();
+ const list = languages.getList();
+
+ return {
+ file: encodeFile(file, urls),
+ current: currentLanguage,
+ list: list
+ .valueSeq()
+ .map(function(lang) {
+ return {
+ id: lang.getID(),
+ title: lang.getTitle()
+ };
+ }).toJS()
+ };
+}
+
+module.exports = encodeLanguages;
diff --git a/packages/gitbook/src/json/encodePage.js b/packages/gitbook/src/json/encodePage.js
new file mode 100644
index 0000000..0671721
--- /dev/null
+++ b/packages/gitbook/src/json/encodePage.js
@@ -0,0 +1,41 @@
+const encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ * Return a JSON representation of a page.
+ *
+ * @param {Page} page
+ * @param {Summary} summary
+ * @param {URIIndex} urls
+ * @return {JSON} json
+ */
+function encodePage(page, summary, urls) {
+ const file = page.getFile();
+ const attributes = page.getAttributes();
+ const article = summary.getByPath(file.getPath());
+
+ const result = {
+ content: page.getContent(),
+ dir: page.getDir(),
+ attributes: attributes.toJS()
+ };
+
+ if (article) {
+ result.title = article.getTitle();
+ result.level = article.getLevel();
+ result.depth = article.getDepth();
+
+ const nextArticle = summary.getNextArticle(article);
+ if (nextArticle) {
+ result.next = encodeSummaryArticle(nextArticle, urls, false);
+ }
+
+ const prevArticle = summary.getPrevArticle(article);
+ if (prevArticle) {
+ result.previous = encodeSummaryArticle(prevArticle, urls, false);
+ }
+ }
+
+ return result;
+}
+
+module.exports = encodePage;
diff --git a/packages/gitbook/src/json/encodeReadme.js b/packages/gitbook/src/json/encodeReadme.js
new file mode 100644
index 0000000..dff81cf
--- /dev/null
+++ b/packages/gitbook/src/json/encodeReadme.js
@@ -0,0 +1,18 @@
+const encodeFile = require('./encodeFile');
+
+/**
+ * Encode a readme to JSON.
+ *
+ * @param {Readme} readme
+ * @param {URIIndex} urls
+ * @return {JSON} json
+ */
+function encodeReadme(readme, urls) {
+ const file = readme.getFile();
+
+ return {
+ file: encodeFile(file, urls)
+ };
+}
+
+module.exports = encodeReadme;
diff --git a/packages/gitbook/src/json/encodeState.js b/packages/gitbook/src/json/encodeState.js
new file mode 100644
index 0000000..faac972
--- /dev/null
+++ b/packages/gitbook/src/json/encodeState.js
@@ -0,0 +1,42 @@
+const gitbook = require('../gitbook');
+const encodeSummary = require('./encodeSummary');
+const encodeGlossary = require('./encodeGlossary');
+const encodeReadme = require('./encodeReadme');
+const encodeLanguages = require('./encodeLanguages');
+const encodePage = require('./encodePage');
+const encodeFile = require('./encodeFile');
+
+/**
+ * Encode context to JSON from an output instance.
+ * This JSON representation is used as initial state for the redux store.
+ *
+ * @param {Output} output
+ * @param {Page} page?
+ * @return {JSON}
+ */
+function encodeStateToJSON(output, page) {
+ const book = output.getBook();
+ const urls = output.getURLIndex();
+
+ return {
+ output: {
+ name: output.getGenerator()
+ },
+ gitbook: {
+ version: gitbook.version,
+ time: gitbook.START_TIME
+ },
+
+ summary: encodeSummary(book.getSummary(), urls),
+ glossary: encodeGlossary(book.getGlossary(), urls),
+ readme: encodeReadme(book.getReadme(), urls),
+ config: book.getConfig().getValues().toJS(),
+ languages: book.isMultilingual() ?
+ encodeLanguages(book.getLanguages(), book.getLanguage(), urls) : undefined,
+
+ page: page ? encodePage(page, book.getSummary(), urls) : undefined,
+ file: page ? encodeFile(page.getFile(), urls) : undefined
+ };
+}
+
+module.exports = encodeStateToJSON;
diff --git a/packages/gitbook/src/json/encodeSummary.js b/packages/gitbook/src/json/encodeSummary.js
new file mode 100644
index 0000000..8380379
--- /dev/null
+++ b/packages/gitbook/src/json/encodeSummary.js
@@ -0,0 +1,23 @@
+const encodeFile = require('./encodeFile');
+const encodeSummaryPart = require('./encodeSummaryPart');
+
+/**
+ * Encode a summary to JSON
+ *
+ * @param {Summary} summary
+ * @param {URIIndex} urls
+ * @return {Object}
+ */
+function encodeSummary(summary, urls) {
+ const file = summary.getFile();
+ const parts = summary.getParts();
+
+ return {
+ file: encodeFile(file, urls),
+ parts: parts
+ .map(part => encodeSummaryPart(part, urls))
+ .toJS()
+ };
+}
+
+module.exports = encodeSummary;
diff --git a/packages/gitbook/src/json/encodeSummaryArticle.js b/packages/gitbook/src/json/encodeSummaryArticle.js
new file mode 100644
index 0000000..0fb6368
--- /dev/null
+++ b/packages/gitbook/src/json/encodeSummaryArticle.js
@@ -0,0 +1,30 @@
+
+/**
+ * Encode a SummaryArticle to JSON
+ *
+ * @param {SummaryArticle} article
+ * @param {URIIndex} urls
+ * @param {Boolean} recursive
+ * @return {Object}
+ */
+function encodeSummaryArticle(article, urls, recursive) {
+ let articles = undefined;
+ if (recursive !== false) {
+ articles = article.getArticles()
+ .map(innerArticle => encodeSummaryArticle(innerArticle, urls, recursive))
+ .toJS();
+ }
+
+ return {
+ title: article.getTitle(),
+ level: article.getLevel(),
+ depth: article.getDepth(),
+ anchor: article.getAnchor(),
+ url: urls.resolveToURL(article.getPath() || article.getUrl()),
+ path: article.getPath(),
+ ref: article.getRef(),
+ articles
+ };
+}
+
+module.exports = encodeSummaryArticle;
diff --git a/packages/gitbook/src/json/encodeSummaryPart.js b/packages/gitbook/src/json/encodeSummaryPart.js
new file mode 100644
index 0000000..fbcdc4c
--- /dev/null
+++ b/packages/gitbook/src/json/encodeSummaryPart.js
@@ -0,0 +1,19 @@
+const encodeSummaryArticle = require('./encodeSummaryArticle');
+
+/**
+ * Encode a SummaryPart to JSON.
+ *
+ * @param {SummaryPart} part
+ * @param {URIIndex} urls
+ * @return {JSON} json
+ */
+function encodeSummaryPart(part, urls) {
+ return {
+ title: part.getTitle(),
+ articles: part.getArticles()
+ .map(article => encodeSummaryArticle(article, urls))
+ .toJS()
+ };
+}
+
+module.exports = encodeSummaryPart;
diff --git a/packages/gitbook/src/json/index.js b/packages/gitbook/src/json/index.js
new file mode 100644
index 0000000..49ab195
--- /dev/null
+++ b/packages/gitbook/src/json/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ encodeState: require('./encodeState'),
+ encodeFile: require('./encodeFile'),
+ encodePage: require('./encodePage'),
+ encodeSummary: require('./encodeSummary'),
+ encodeSummaryArticle: require('./encodeSummaryArticle'),
+ encodeReadme: require('./encodeReadme'),
+ encodeLanguages: require('./encodeLanguages')
+};
diff --git a/packages/gitbook/src/models/__tests__/config.js b/packages/gitbook/src/models/__tests__/config.js
new file mode 100644
index 0000000..a865f96
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/config.js
@@ -0,0 +1,89 @@
+const Immutable = require('immutable');
+const Config = require('../config');
+
+describe('Config', function() {
+ const config = Config.createWithValues({
+ hello: {
+ world: 1,
+ test: 'Hello',
+ isFalse: false
+ }
+ });
+
+ describe('getValue', function() {
+ it('must return value as immutable', function() {
+ const value = config.getValue('hello');
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ });
+
+ it('must return deep value', function() {
+ const value = config.getValue('hello.world');
+ expect(value).toBe(1);
+ });
+
+ it('must return default value if non existant', function() {
+ const value = config.getValue('hello.nonExistant', 'defaultValue');
+ expect(value).toBe('defaultValue');
+ });
+
+ it('must not return default value for falsy values', function() {
+ const value = config.getValue('hello.isFalse', 'defaultValue');
+ expect(value).toBe(false);
+ });
+ });
+
+ describe('setValue', function() {
+ it('must set value as immutable', function() {
+ const testConfig = config.setValue('hello', {
+ 'cool': 1
+ });
+ const 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() {
+ const testConfig = config.setValue('hello.world', 2);
+ const hello = testConfig.getValue('hello');
+ const 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() {
+ const _config = Config.createWithValues({
+ gitbook: '3.0.0'
+ });
+
+ const reducedVersion = _config.toReducedVersion();
+
+ expect(reducedVersion.toJS()).toEqual({
+ gitbook: '3.0.0'
+ });
+ });
+
+ it('must only return diffs for deep values', function() {
+ const _config = Config.createWithValues({
+ structure: {
+ readme: 'intro.md'
+ }
+ });
+
+ const reducedVersion = _config.toReducedVersion();
+
+ expect(reducedVersion.toJS()).toEqual({
+ structure: {
+ readme: 'intro.md'
+ }
+ });
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/models/__tests__/glossary.js b/packages/gitbook/src/models/__tests__/glossary.js
new file mode 100644
index 0000000..b50338a
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/glossary.js
@@ -0,0 +1,39 @@
+const File = require('../file');
+const Glossary = require('../glossary');
+const GlossaryEntry = require('../glossaryEntry');
+
+describe('Glossary', function() {
+ const 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() {
+ const entries = glossary.getEntries();
+ expect(entries.size).toBe(2);
+ });
+
+ it('must add entries as GlossaryEntries', function() {
+ const entries = glossary.getEntries();
+ const 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/src/models/__tests__/glossaryEntry.js b/packages/gitbook/src/models/__tests__/glossaryEntry.js
new file mode 100644
index 0000000..66ddab4
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/glossaryEntry.js
@@ -0,0 +1,14 @@
+const GlossaryEntry = require('../glossaryEntry');
+
+describe('GlossaryEntry', function() {
+ describe('getID', function() {
+ it('must return a normalized ID', function() {
+ const entry = new GlossaryEntry({
+ name: 'Hello World'
+ });
+
+ expect(entry.getID()).toBe('hello-world');
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/models/__tests__/page.js b/packages/gitbook/src/models/__tests__/page.js
new file mode 100644
index 0000000..b004121
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/page.js
@@ -0,0 +1,26 @@
+const Immutable = require('immutable');
+const Page = require('../page');
+
+describe('Page', function() {
+
+ describe('toText', function() {
+ it('must not prepend frontmatter if no attributes', function() {
+ const page = (new Page()).merge({
+ content: 'Hello World'
+ });
+
+ expect(page.toText()).toBe('Hello World');
+ });
+
+ it('must prepend frontmatter if attributes', function() {
+ const page = (new 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/src/models/__tests__/plugin.js b/packages/gitbook/src/models/__tests__/plugin.js
new file mode 100644
index 0000000..63cb58c
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/plugin.js
@@ -0,0 +1,26 @@
+describe('Plugin', function() {
+ const Plugin = require('../plugin');
+
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ const plugin = Plugin.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse version', function() {
+ const 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() {
+ const plugin = Plugin.createFromString('hello');
+ expect(plugin.isLoaded()).toBe(false);
+ });
+
+ });
+});
+
diff --git a/packages/gitbook/src/models/__tests__/pluginDependency.js b/packages/gitbook/src/models/__tests__/pluginDependency.js
new file mode 100644
index 0000000..cda0cc2
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/pluginDependency.js
@@ -0,0 +1,80 @@
+const Immutable = require('immutable');
+const PluginDependency = require('../pluginDependency');
+
+describe('PluginDependency', function() {
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ const plugin = PluginDependency.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse state', function() {
+ const plugin = PluginDependency.createFromString('-hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.isEnabled()).toBe(false);
+ });
+
+ describe('Version', function() {
+ it('must parse version', function() {
+ const plugin = PluginDependency.createFromString('hello@1.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('1.0.0');
+ });
+
+ it('must parse semver', function() {
+ const 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() {
+ const 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() {
+ const 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() {
+ const 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() {
+ const arr = Immutable.fromJS([
+ 'hello@1.0.0',
+ {
+ 'name': 'plugin-ga',
+ 'version': 'git+ssh://samy@github.com/GitbookIO/plugin-ga.git'
+ }
+ ]);
+ const 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/src/models/__tests__/summary.js b/packages/gitbook/src/models/__tests__/summary.js
new file mode 100644
index 0000000..49ed9b1
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/summary.js
@@ -0,0 +1,93 @@
+
+describe('Summary', function() {
+ const File = require('../file');
+ const Summary = require('../summary');
+
+ const 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() {
+ const parts = summary.getParts();
+ expect(parts.size).toBe(2);
+ });
+ });
+
+ describe('getByLevel', function() {
+ it('can return a Part', function() {
+ const part = summary.getByLevel('1');
+
+ expect(part).toBeDefined();
+ expect(part.getArticles().size).toBe(4);
+ });
+
+ it('can return a Part (2)', function() {
+ const part = summary.getByLevel('2');
+
+ expect(part).toBeDefined();
+ expect(part.getTitle()).toBe('Test');
+ expect(part.getArticles().size).toBe(0);
+ });
+
+ it('can return an Article', function() {
+ const article = summary.getByLevel('1.1');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+ });
+
+ describe('getByPath', function() {
+ it('return correct article', function() {
+ const article = summary.getByPath('README.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+
+ it('return correct article', function() {
+ const article = summary.getByPath('article.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My Second Article');
+ });
+
+ it('return undefined if not found', function() {
+ const 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/src/models/__tests__/summaryArticle.js b/packages/gitbook/src/models/__tests__/summaryArticle.js
new file mode 100644
index 0000000..506d481
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/summaryArticle.js
@@ -0,0 +1,52 @@
+const SummaryArticle = require('../summaryArticle');
+const File = require('../file');
+
+describe('SummaryArticle', function() {
+ describe('createChildLevel', function() {
+ it('must create the right level', function() {
+ const article = SummaryArticle.create({}, '1.1');
+ expect(article.createChildLevel()).toBe('1.1.1');
+ });
+
+ it('must create the right level when has articles', function() {
+ const 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() {
+ const article = SummaryArticle.create({
+ ref: 'hello.md'
+ }, '1.1');
+ const file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(true);
+ });
+
+ it('must return true when path is not normalized', function() {
+ const article = SummaryArticle.create({
+ ref: '/hello.md'
+ }, '1.1');
+ const file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(true);
+ });
+
+ it('must return false when has anchor', function() {
+ const article = SummaryArticle.create({
+ ref: 'hello.md#world'
+ }, '1.1');
+ const file = File.createWithFilepath('hello.md');
+
+ expect(article.isFile(file)).toBe(false);
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/models/__tests__/summaryPart.js b/packages/gitbook/src/models/__tests__/summaryPart.js
new file mode 100644
index 0000000..fc9e8b5
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/summaryPart.js
@@ -0,0 +1,22 @@
+const SummaryPart = require('../summaryPart');
+
+describe('SummaryPart', function() {
+ describe('createChildLevel', function() {
+ it('must create the right level', function() {
+ const article = SummaryPart.create({}, '1');
+ expect(article.createChildLevel()).toBe('1.1');
+ });
+
+ it('must create the right level when has articles', function() {
+ const article = SummaryPart.create({
+ articles: [
+ {
+ title: 'Test'
+ }
+ ]
+ }, '1');
+ expect(article.createChildLevel()).toBe('1.2');
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/models/__tests__/templateBlock.js b/packages/gitbook/src/models/__tests__/templateBlock.js
new file mode 100644
index 0000000..5db8a80
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/templateBlock.js
@@ -0,0 +1,218 @@
+const nunjucks = require('nunjucks');
+const Immutable = require('immutable');
+const Promise = require('../../utils/promise');
+
+describe('TemplateBlock', function() {
+ const TemplateBlock = require('../templateBlock');
+
+ describe('.create', function() {
+ it('must initialize a simple TemplateBlock from a function', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return { message: 'Hello World' };
+ });
+
+ expect(templateBlock.getName()).toBe('sayhello');
+ expect(templateBlock.getEndTag()).toBe('endsayhello');
+ expect(templateBlock.getBlocks().size).toBe(0);
+ expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension');
+ });
+ });
+
+ describe('.toProps', function() {
+ it('must handle sync method', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return { message: 'Hello World' };
+ });
+
+ return templateBlock.toProps()
+ .then(function(props) {
+ expect(props).toEqual({ message: 'Hello World' });
+ });
+ });
+
+ it('must not fail if return a string', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return 'Hello World';
+ });
+
+ return templateBlock.toProps()
+ .then(function(props) {
+ expect(props).toEqual({ children: 'Hello World' });
+ });
+ });
+ });
+
+ describe('.getShortcuts', function() {
+ it('must return undefined if no shortcuts', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return { message: 'Hello World' };
+ });
+
+ expect(templateBlock.getShortcuts()).toNotExist();
+ });
+
+ it('.must return complete shortcut', function() {
+ const templateBlock = TemplateBlock.create('sayhello', {
+ process(block) {
+ return { message: 'Hello World' };
+ },
+ shortcuts: {
+ parsers: ['markdown'],
+ start: '$',
+ end: '-'
+ }
+ });
+
+ const 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 render children correctly', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return 'Hello';
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ const src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<xblock name="sayhello" props="{}">Hello</xblock>');
+ });
+ });
+
+ it('must handle HTML children', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<p>Hello, World!</p>';
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ const src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<xblock name="sayhello" props="{}"><p>Hello, World!</p></xblock>');
+ });
+ });
+
+ it('must inline props without children', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return {
+ message: block.kwargs.tag + ' ' + block.kwargs.name
+ };
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ const src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<xblock name="sayhello" props="{&quot;message&quot;:&quot;p Samy&quot;}"></xblock>');
+ });
+ });
+
+ it('must accept an async function', function() {
+ const templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return Promise()
+ .delay(1)
+ .then(function() {
+ return {
+ children: 'Hello ' + block.children
+ };
+ });
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ const src = '{% sayhello %}Samy{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<xblock name="sayhello" props="{}">Hello Samy</xblock>');
+ });
+ });
+
+ it('must handle nested blocks', function() {
+ const templateBlock = new TemplateBlock({
+ name: 'yoda',
+ blocks: Immutable.List(['start', 'end']),
+ process(block) {
+ const nested = {};
+
+ block.blocks.forEach(function(blk) {
+ nested[blk.name] = blk.children.trim();
+ });
+
+ return '<p class="yoda">' + nested.end + ' ' + nested.start + '</p>';
+ }
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ const src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<xblock name="yoda" props="{}"><p class="yoda">inverted this sentence should be</p></xblock>');
+ });
+ });
+
+ it('must handle multiple inline blocks', function() {
+ const templateBlock = new TemplateBlock({
+ name: 'math',
+ process(block) {
+ return '<math>' + block.children + '</math>';
+ }
+ });
+
+ // Create a fresh Nunjucks environment
+ const env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ const Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block after replacing shortcuts
+ const src = 'There should be two inline blocks as a result: {% math %}a = b{% endmath %} and {% math %}c = d{% endmath %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('There should be two inline blocks as a result: <xblock name="math" props="{}"><math>a = b</math></xblock> and <xblock name="math" props="{}"><math>c = d</math></xblock>');
+ });
+ });
+ });
+});
diff --git a/packages/gitbook/src/models/__tests__/templateEngine.js b/packages/gitbook/src/models/__tests__/templateEngine.js
new file mode 100644
index 0000000..30cd543
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/templateEngine.js
@@ -0,0 +1,51 @@
+
+describe('TemplateBlock', function() {
+ const TemplateEngine = require('../templateEngine');
+
+ describe('create', function() {
+ it('must initialize with a list of filters', function() {
+ const engine = TemplateEngine.create({
+ filters: {
+ hello(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ const env = engine.toNunjucks();
+ const res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must initialize with a list of globals', function() {
+ const engine = TemplateEngine.create({
+ globals: {
+ hello(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ const env = engine.toNunjucks();
+ const res = env.renderString('{{ hello("Luke") }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must pass context to filters and blocks', function() {
+ const engine = TemplateEngine.create({
+ filters: {
+ hello(name) {
+ return 'Hello ' + name + ' ' + this.lastName + '!';
+ }
+ },
+ context: {
+ lastName: 'Skywalker'
+ }
+ });
+ const env = engine.toNunjucks();
+ const res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke Skywalker!');
+ });
+ });
+});
diff --git a/packages/gitbook/src/models/__tests__/uriIndex.js b/packages/gitbook/src/models/__tests__/uriIndex.js
new file mode 100644
index 0000000..f3be40b
--- /dev/null
+++ b/packages/gitbook/src/models/__tests__/uriIndex.js
@@ -0,0 +1,84 @@
+const URIIndex = require('../uriIndex');
+
+describe('URIIndex', () => {
+ let index;
+
+ before(() => {
+ index = new URIIndex({
+ 'README.md': 'index.html',
+ 'world.md': 'world.html',
+ 'hello/README.md': 'hello/index.html',
+ 'hello/test.md': 'hello/test.html'
+ });
+ });
+
+ describe('.resolve', () => {
+ it('should resolve a basic file path', () => {
+ expect(index.resolve('README.md')).toBe('index.html');
+ });
+
+ it('should resolve a nested file path', () => {
+ expect(index.resolve('hello/test.md')).toBe('hello/test.html');
+ });
+
+ it('should normalize path', () => {
+ expect(index.resolve('./hello//test.md')).toBe('hello/test.html');
+ });
+
+ it('should not fail for non existing entries', () => {
+ expect(index.resolve('notfound.md')).toBe('notfound.md');
+ });
+
+ it('should not fail for absolute url', () => {
+ expect(index.resolve('http://google.fr')).toBe('http://google.fr');
+ });
+
+ it('should preserve hash', () => {
+ expect(index.resolve('hello/test.md#myhash')).toBe('hello/test.html#myhash');
+ });
+ });
+
+ describe('.resolveToURL', () => {
+ it('should resolve a basic file path with directory index', () => {
+ expect(index.resolveToURL('README.md')).toBe('./');
+ });
+
+ it('should resolve a basic file path with directory index', () => {
+ expect(index.resolveToURL('hello/README.md')).toBe('hello/');
+ });
+ });
+
+ describe('.resolveFrom', () => {
+ it('should resolve correctly in same directory', () => {
+ expect(index.resolveFrom('README.md', 'world.md')).toBe('world.html');
+ });
+
+ it('should resolve correctly for a nested path', () => {
+ expect(index.resolveFrom('README.md', 'hello/README.md')).toBe('hello/index.html');
+ });
+
+ it('should resolve correctly for a nested path (2)', () => {
+ expect(index.resolveFrom('hello/README.md', 'test.md')).toBe('test.html');
+ });
+
+ it('should resolve correctly for a nested path (3)', () => {
+ expect(index.resolveFrom('hello/README.md', '../README.md')).toBe('../index.html');
+ });
+
+ it('should preserve hash', () => {
+ expect(index.resolveFrom('README.md', 'hello/README.md#myhash')).toBe('hello/index.html#myhash');
+ });
+
+ it('should not fail for absolute url', () => {
+ expect(index.resolveFrom('README.md', 'http://google.fr')).toBe('http://google.fr');
+ });
+ });
+
+ describe('.append', () => {
+ it('should normalize the filename', () => {
+ const newIndex = index.append('append//sometest.md', 'append/sometest.html');
+ expect(newIndex.resolve('append/sometest.md')).toBe('append/sometest.html');
+ });
+ });
+
+});
diff --git a/packages/gitbook/src/models/book.js b/packages/gitbook/src/models/book.js
new file mode 100644
index 0000000..4668154
--- /dev/null
+++ b/packages/gitbook/src/models/book.js
@@ -0,0 +1,357 @@
+const path = require('path');
+const { Record, OrderedMap } = require('immutable');
+
+const Logger = require('../utils/logger');
+
+const FS = require('./fs');
+const Config = require('./config');
+const Readme = require('./readme');
+const Summary = require('./summary');
+const Glossary = require('./glossary');
+const Languages = require('./languages');
+const Ignore = require('./ignore');
+
+const DEFAULTS = {
+ // Logger for output message
+ logger: new Logger(),
+ // Filesystem binded to the book scope to read files/directories
+ fs: new FS(),
+ // Ignore files parser
+ ignore: new Ignore(),
+ // Structure files
+ config: new Config(),
+ readme: new Readme(),
+ summary: new Summary(),
+ glossary: new Glossary(),
+ languages: new Languages(),
+ // ID of the language for language books
+ language: String(),
+ // List of children, if multilingual (String -> Book)
+ books: new OrderedMap()
+};
+
+class Book extends Record(DEFAULTS) {
+ getLogger() {
+ return this.get('logger');
+ }
+
+ getFS() {
+ return this.get('fs');
+ }
+
+ getIgnore() {
+ return this.get('ignore');
+ }
+
+ getConfig() {
+ return this.get('config');
+ }
+
+ getReadme() {
+ return this.get('readme');
+ }
+
+ getSummary() {
+ return this.get('summary');
+ }
+
+ getGlossary() {
+ return this.get('glossary');
+ }
+
+ getLanguages() {
+ return this.get('languages');
+ }
+
+ getBooks() {
+ return this.get('books');
+ }
+
+ getLanguage() {
+ return this.get('language');
+ }
+
+ /**
+ * Return FS instance to access the content
+ * @return {FS}
+ */
+ getContentFS() {
+ const fs = this.getFS();
+ const config = this.getConfig();
+ const rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ return FS.reduceScope(fs, rootFolder);
+ }
+
+ return fs;
+ }
+
+ /**
+ * Return root of the book
+ *
+ * @return {String}
+ */
+ getRoot() {
+ const fs = this.getFS();
+ return fs.getRoot();
+ }
+
+ /**
+ * Return root for content of the book
+ *
+ * @return {String}
+ */
+ getContentRoot() {
+ const fs = this.getContentFS();
+ return fs.getRoot();
+ }
+
+ /**
+ * Check if a file is ignore (should not being parsed, etc)
+ *
+ * @param {String} ref
+ * @return {Page|undefined}
+ */
+ isFileIgnored(filename) {
+ const ignore = this.getIgnore();
+ const 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}
+ */
+ isContentFileIgnored(filename) {
+ const config = this.getConfig();
+ const 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}
+ */
+ getPage(ref) {
+ return this.getPages().get(ref);
+ }
+
+ /**
+ * Is this book the parent of language's books
+ * @return {Boolean}
+ */
+ isMultilingual() {
+ return (this.getLanguages().getCount() > 0);
+ }
+
+ /**
+ * Return true if book is associated to a language
+ * @return {Boolean}
+ */
+ isLanguageBook() {
+ return Boolean(this.getLanguage());
+ }
+
+ /**
+ * Return a languages book
+ * @param {String} language
+ * @return {Book}
+ */
+ getLanguageBook(language) {
+ const books = this.getBooks();
+ return books.get(language);
+ }
+
+ /**
+ * Add a new language book
+ *
+ * @param {String} language
+ * @param {Book} book
+ * @return {Book}
+ */
+ addLanguageBook(language, book) {
+ let books = this.getBooks();
+ books = books.set(language, book);
+
+ return this.set('books', books);
+ }
+
+ /**
+ * Set the summary for this book
+ *
+ * @param {Summary}
+ * @return {Book}
+ */
+ setSummary(summary) {
+ return this.set('summary', summary);
+ }
+
+ /**
+ * Set the readme for this book
+ *
+ * @param {Readme}
+ * @return {Book}
+ */
+ setReadme(readme) {
+ return this.set('readme', readme);
+ }
+
+ /**
+ * Set the configuration for this book
+ *
+ * @param {Config}
+ * @return {Book}
+ */
+ setConfig(config) {
+ return this.set('config', config);
+ }
+
+ /**
+ * Set the ignore instance for this book
+ *
+ @param {Ignore}
+ * @return {Book}
+ */
+ setIgnore(ignore) {
+ return this.set('ignore', ignore);
+ }
+
+ /**
+ * Change log level
+ *
+ * @param {String} level
+ * @return {Book}
+ */
+ setLogLevel(level) {
+ this.getLogger().setLevel(level);
+ return this;
+ }
+
+ /**
+ * Infers the default extension for files
+ * @return {String}
+ */
+ getDefaultExt() {
+ // Inferring sources
+ const clues = [
+ this.getReadme(),
+ this.getSummary(),
+ this.getGlossary()
+ ];
+
+ // List their extensions
+ const exts = clues.map(function(clue) {
+ const 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}
+ */
+ getDefaultReadmePath(absolute) {
+ const 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}
+ */
+ getDefaultSummaryPath(absolute) {
+ const 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}
+ */
+ getDefaultGlossaryPath(absolute) {
+ const 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}
+ */
+ static createFromParent(parent, language) {
+ const ignore = parent.getIgnore();
+ let 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,
+ ignore,
+
+ language,
+ fs: FS.reduceScope(parent.getContentFS(), language)
+ });
+ }
+
+ /**
+ * Create a book using a filesystem
+ *
+ * @param {FS} fs
+ * @return {Book}
+ */
+ static createForFS(fs) {
+ return new Book({
+ fs
+ });
+ }
+}
+
+module.exports = Book;
diff --git a/packages/gitbook/src/models/config.js b/packages/gitbook/src/models/config.js
new file mode 100644
index 0000000..6a0be5e
--- /dev/null
+++ b/packages/gitbook/src/models/config.js
@@ -0,0 +1,181 @@
+const is = require('is');
+const Immutable = require('immutable');
+
+const File = require('./file');
+const PluginDependency = require('./pluginDependency');
+const configDefault = require('../constants/configDefault');
+const reducedObject = require('../utils/reducedObject');
+
+const 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) {
+ const 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);
+
+ let 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() {
+ const 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) {
+ const 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) {
+ const 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) {
+ let 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,
+ 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/src/models/file.js b/packages/gitbook/src/models/file.js
new file mode 100644
index 0000000..84828ce
--- /dev/null
+++ b/packages/gitbook/src/models/file.js
@@ -0,0 +1,89 @@
+const path = require('path');
+const Immutable = require('immutable');
+
+const parsers = require('../parsers');
+
+const 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() {
+ const 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/src/models/fs.js b/packages/gitbook/src/models/fs.js
new file mode 100644
index 0000000..7afbfbd
--- /dev/null
+++ b/packages/gitbook/src/models/fs.js
@@ -0,0 +1,300 @@
+const path = require('path');
+const Immutable = require('immutable');
+const stream = require('stream');
+
+const File = require('./file');
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const PathUtil = require('../utils/path');
+
+const 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) {
+ const 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(...args) {
+ const rootPath = this.getRoot();
+ let filename = path.join(rootPath, ...args);
+ filename = path.normalize(filename);
+
+ if (!this.isInScope(filename)) {
+ throw error.FileOutOfScopeError({
+ 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) {
+ const that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ const 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) {
+ const that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ const 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) {
+ const that = this;
+ const filepath = that.resolve(filename);
+ const fsReadAsStream = this.get('fsReadAsStream');
+
+ if (fsReadAsStream) {
+ return Promise(fsReadAsStream(filepath));
+ }
+
+ return this.read(filename)
+ .then(function(buf) {
+ const 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) {
+ const that = this;
+
+ return Promise()
+ .then(function() {
+ const filepath = that.resolve(filename);
+ const 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) {
+ const that = this;
+
+ return Promise()
+ .then(function() {
+ const dirpath = that.resolve(dirname);
+ const 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) {
+ const that = this;
+ dirName = dirName || '.';
+
+ return this.readDir(dirName)
+ .then(function(files) {
+ return Promise.reduce(files, function(out, file) {
+ const isDirectory = pathIsFolder(file);
+ const 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) {
+ const that = this;
+ const fsLoadObject = this.get('fsLoadObject');
+
+ return this.exists(filename)
+ .then(function(exists) {
+ if (!exists) {
+ const 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) {
+ const lastChar = filename[filename.length - 1];
+ return lastChar == '/' || lastChar == '\\';
+}
+
+module.exports = FS;
diff --git a/packages/gitbook/src/models/glossary.js b/packages/gitbook/src/models/glossary.js
new file mode 100644
index 0000000..e269b14
--- /dev/null
+++ b/packages/gitbook/src/models/glossary.js
@@ -0,0 +1,109 @@
+const Immutable = require('immutable');
+
+const error = require('../utils/error');
+const File = require('./file');
+const GlossaryEntry = require('./glossaryEntry');
+const parsers = require('../parsers');
+
+const 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) {
+ const entries = this.getEntries();
+ const id = GlossaryEntry.nameToID(name);
+
+ return entries.get(id);
+};
+
+/**
+ Render glossary as text
+
+ @return {Promise<String>}
+*/
+Glossary.prototype.toText = function(parser) {
+ const file = this.getFile();
+ const 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) {
+ const id = entry.getID();
+ let 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) {
+ const entry = new GlossaryEntry({
+ name,
+ 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,
+ entries: Immutable.OrderedMap(entries)
+ });
+};
+
+
+module.exports = Glossary;
diff --git a/packages/gitbook/src/models/glossaryEntry.js b/packages/gitbook/src/models/glossaryEntry.js
new file mode 100644
index 0000000..b36b276
--- /dev/null
+++ b/packages/gitbook/src/models/glossaryEntry.js
@@ -0,0 +1,43 @@
+const Immutable = require('immutable');
+const slug = require('github-slugid');
+
+/*
+ A definition represents an entry in the glossary
+*/
+
+const 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/src/models/ignore.js b/packages/gitbook/src/models/ignore.js
new file mode 100644
index 0000000..547f6b4
--- /dev/null
+++ b/packages/gitbook/src/models/ignore.js
@@ -0,0 +1,43 @@
+const { Record } = require('immutable');
+const IgnoreMutable = require('ignore');
+
+/*
+ Immutable version of node-ignore
+*/
+
+const DEFAULTS = {
+ ignore: new IgnoreMutable()
+};
+
+class Ignore extends Record(DEFAULTS) {
+ getIgnore() {
+ return this.get('ignore');
+ }
+
+ /**
+ * Test if a file is ignored by these rules.
+ * @param {String} filePath
+ * @return {Boolean} isIgnored
+ */
+ isFileIgnored(filename) {
+ const ignore = this.getIgnore();
+ return ignore.filter([filename]).length == 0;
+ }
+
+ /**
+ * Add rules.
+ * @param {String}
+ * @return {Ignore}
+ */
+ add(rule) {
+ const ignore = this.getIgnore();
+ const newIgnore = new IgnoreMutable();
+
+ newIgnore.add(ignore);
+ newIgnore.add(rule);
+
+ return this.set('ignore', newIgnore);
+ }
+}
+
+module.exports = Ignore;
diff --git a/packages/gitbook/src/models/language.js b/packages/gitbook/src/models/language.js
new file mode 100644
index 0000000..1413091
--- /dev/null
+++ b/packages/gitbook/src/models/language.js
@@ -0,0 +1,21 @@
+const path = require('path');
+const Immutable = require('immutable');
+
+const 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/src/models/languages.js b/packages/gitbook/src/models/languages.js
new file mode 100644
index 0000000..9540546
--- /dev/null
+++ b/packages/gitbook/src/models/languages.js
@@ -0,0 +1,71 @@
+const Immutable = require('immutable');
+
+const File = require('./file');
+const Language = require('./language');
+
+const 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) {
+ let list = Immutable.OrderedMap();
+
+ langs.forEach(function(lang) {
+ lang = Language({
+ title: lang.title,
+ path: lang.ref
+ });
+ list = list.set(lang.getID(), lang);
+ });
+
+ return Languages({
+ file,
+ list
+ });
+};
+
+module.exports = Languages;
diff --git a/packages/gitbook/src/models/output.js b/packages/gitbook/src/models/output.js
new file mode 100644
index 0000000..a63be17
--- /dev/null
+++ b/packages/gitbook/src/models/output.js
@@ -0,0 +1,112 @@
+const { Record, OrderedMap, Map, List } = require('immutable');
+
+const Git = require('../utils/git');
+const LocationUtils = require('../utils/location');
+const Book = require('./book');
+const URIIndex = require('./uriIndex');
+
+const DEFAULTS = {
+ book: new Book(),
+ // Name of the generator being used
+ generator: String(),
+ // Map of plugins to use (String -> Plugin)
+ plugins: OrderedMap(),
+ // Map pages to generation (String -> Page)
+ pages: OrderedMap(),
+ // List of file that are not pages in the book (String)
+ assets: List(),
+ // Option for the generation
+ options: Map(),
+ // Internal state for the generation
+ state: Map(),
+ // Index of urls
+ urls: new URIIndex(),
+ // Git repositories manager
+ git: new Git()
+};
+
+class Output extends Record(DEFAULTS) {
+ getBook() {
+ return this.get('book');
+ }
+
+ getGenerator() {
+ return this.get('generator');
+ }
+
+ getPlugins() {
+ return this.get('plugins');
+ }
+
+ getPages() {
+ return this.get('pages');
+ }
+
+ getOptions() {
+ return this.get('options');
+ }
+
+ getAssets() {
+ return this.get('assets');
+ }
+
+ getState() {
+ return this.get('state');
+ }
+
+ getURLIndex() {
+ return this.get('urls');
+ }
+
+ /**
+ * Return a page byt its file path
+ *
+ * @param {String} filePath
+ * @return {Page|undefined}
+ */
+ getPage(filePath) {
+ filePath = LocationUtils.normalize(filePath);
+
+ const pages = this.getPages();
+ return pages.get(filePath);
+ }
+
+ /**
+ * Get root folder for output.
+ * @return {String}
+ */
+ getRoot() {
+ return this.getOptions().get('root');
+ }
+
+ /**
+ * Update state of output
+ *
+ * @param {Map} newState
+ * @return {Output}
+ */
+ setState(newState) {
+ return this.set('state', newState);
+ }
+
+ /**
+ * Update options
+ *
+ * @param {Map} newOptions
+ * @return {Output}
+ */
+ setOptions(newOptions) {
+ return this.set('options', newOptions);
+ }
+
+ /**
+ * Return logegr for this output (same as book)
+ *
+ * @return {Logger}
+ */
+ getLogger() {
+ return this.getBook().getLogger();
+ }
+}
+
+module.exports = Output;
diff --git a/packages/gitbook/src/models/page.js b/packages/gitbook/src/models/page.js
new file mode 100644
index 0000000..e2ab977
--- /dev/null
+++ b/packages/gitbook/src/models/page.js
@@ -0,0 +1,69 @@
+const { Record, Map } = require('immutable');
+const yaml = require('js-yaml');
+
+const File = require('./file');
+
+const DEFAULTS = {
+ file: File(),
+ // Attributes extracted from the YAML header
+ attributes: Map(),
+ // Content of the page
+ content: String(),
+ // Direction of the text
+ dir: String('ltr')
+};
+
+class Page extends Record(DEFAULTS) {
+ getFile() {
+ return this.get('file');
+ }
+
+ getAttributes() {
+ return this.get('attributes');
+ }
+
+ getContent() {
+ return this.get('content');
+ }
+
+ getDir() {
+ return this.get('dir');
+ }
+
+ /**
+ * Return page as text
+ * @return {String}
+ */
+ toText() {
+ const attrs = this.getAttributes();
+ const content = this.getContent();
+
+ if (attrs.size === 0) {
+ return content;
+ }
+
+ const frontMatter = '---\n' + yaml.safeDump(attrs.toJS(), { skipInvalid: true }) + '---\n\n';
+ return (frontMatter + content);
+ }
+
+ /**
+ * Return path of the page
+ * @return {String}
+ */
+ getPath() {
+ return this.getFile().getPath();
+ }
+
+ /**
+ * Create a page for a file
+ * @param {File} file
+ * @return {Page}
+ */
+ static createForFile(file) {
+ return new Page({
+ file
+ });
+ }
+}
+
+module.exports = Page;
diff --git a/packages/gitbook/src/models/parser.js b/packages/gitbook/src/models/parser.js
new file mode 100644
index 0000000..3769dd3
--- /dev/null
+++ b/packages/gitbook/src/models/parser.js
@@ -0,0 +1,122 @@
+const Immutable = require('immutable');
+const Promise = require('../utils/promise');
+
+const 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) {
+ const readme = this.get('readme');
+ return Promise(readme(content));
+};
+
+Parser.prototype.parseSummary = function(content) {
+ const summary = this.get('summary');
+ return Promise(summary(content));
+};
+
+Parser.prototype.parseGlossary = function(content) {
+ const glossary = this.get('glossary');
+ return Promise(glossary(content));
+};
+
+Parser.prototype.preparePage = function(content) {
+ const page = this.get('page');
+ if (!page.prepare) {
+ return Promise(content);
+ }
+
+ return Promise(page.prepare(content));
+};
+
+Parser.prototype.parsePage = function(content) {
+ const page = this.get('page');
+ return Promise(page(content));
+};
+
+Parser.prototype.parseInline = function(content) {
+ const inline = this.get('inline');
+ return Promise(inline(content));
+};
+
+Parser.prototype.parseLanguages = function(content) {
+ const langs = this.get('langs');
+ return Promise(langs(content));
+};
+
+Parser.prototype.parseInline = function(content) {
+ const inline = this.get('inline');
+ return Promise(inline(content));
+};
+
+// TO TEXT
+
+Parser.prototype.renderLanguages = function(content) {
+ const langs = this.get('langs');
+ return Promise(langs.toText(content));
+};
+
+Parser.prototype.renderSummary = function(content) {
+ const summary = this.get('summary');
+ return Promise(summary.toText(content));
+};
+
+Parser.prototype.renderGlossary = function(content) {
+ const 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) {
+ const 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,
+ 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/src/models/plugin.js b/packages/gitbook/src/models/plugin.js
new file mode 100644
index 0000000..f2491f2
--- /dev/null
+++ b/packages/gitbook/src/models/plugin.js
@@ -0,0 +1,149 @@
+const { Record, Map } = require('immutable');
+
+const TemplateBlock = require('./templateBlock');
+const PluginDependency = require('./pluginDependency');
+const THEME_PREFIX = require('../constants/themePrefix');
+
+const DEFAULT_VERSION = '*';
+
+const DEFAULTS = {
+ 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: Map(),
+ // Content of the package itself
+ content: Map()
+};
+
+class Plugin extends Record(DEFAULTS) {
+ getName() {
+ return this.get('name');
+ }
+
+ getPath() {
+ return this.get('path');
+ }
+
+ getVersion() {
+ return this.get('version');
+ }
+
+ getPackage() {
+ return this.get('package');
+ }
+
+ getContent() {
+ return this.get('content');
+ }
+
+ getDepth() {
+ return this.get('depth');
+ }
+
+ getParent() {
+ return this.get('parent');
+ }
+
+ /**
+ * Return the ID on NPM for this plugin
+ * @return {String}
+ */
+ getNpmID() {
+ return PluginDependency.nameToNpmID(this.getName());
+ }
+
+ /**
+ * Check if a plugin is loaded
+ * @return {Boolean}
+ */
+ isLoaded() {
+ return Boolean(this.getPackage().size > 0);
+ }
+
+ /**
+ * Check if a plugin is a theme given its name
+ * @return {Boolean}
+ */
+ isTheme() {
+ const name = this.getName();
+ return (name && name.indexOf(THEME_PREFIX) === 0);
+ }
+
+ /**
+ * Return map of hooks
+ * @return {Map<String:Function>}
+ */
+ getHooks() {
+ return this.getContent().get('hooks') || Map();
+ }
+
+ /**
+ * Return map of filters
+ * @return {Map<String:Function>}
+ */
+ getFilters() {
+ return this.getContent().get('filters');
+ }
+
+ /**
+ * Return map of blocks
+ * @return {Map<String:TemplateBlock>}
+ */
+ getBlocks() {
+ let blocks = this.getContent().get('blocks');
+ blocks = blocks || Map();
+
+ return blocks
+ .map(function(block, blockName) {
+ return TemplateBlock.create(blockName, block);
+ });
+ }
+
+ /**
+ * Return a specific hook
+ * @param {String} name
+ * @return {Function|undefined}
+ */
+ getHook(name) {
+ return this.getHooks().get(name);
+ }
+
+ /**
+ * Create a plugin from a string
+ * @param {String}
+ * @return {Plugin}
+ */
+ static createFromString(s) {
+ const parts = s.split('@');
+ const name = parts[0];
+ const version = parts.slice(1).join('@');
+
+ return new Plugin({
+ name,
+ version: version || DEFAULT_VERSION
+ });
+ }
+
+ /**
+ * Create a plugin from a dependency
+ * @param {PluginDependency}
+ * @return {Plugin}
+ */
+ static createFromDep(dep) {
+ return new Plugin({
+ name: dep.getName(),
+ version: dep.getVersion()
+ });
+ }
+}
+
+Plugin.nameToNpmID = PluginDependency.nameToNpmID;
+
+module.exports = Plugin;
diff --git a/packages/gitbook/src/models/pluginDependency.js b/packages/gitbook/src/models/pluginDependency.js
new file mode 100644
index 0000000..4e5d464
--- /dev/null
+++ b/packages/gitbook/src/models/pluginDependency.js
@@ -0,0 +1,168 @@
+const is = require('is');
+const semver = require('semver');
+const Immutable = require('immutable');
+
+const PREFIX = require('../constants/pluginPrefix');
+const DEFAULT_VERSION = '*';
+
+/*
+ * PluginDependency represents the informations about a plugin
+ * stored in config.plugins
+ */
+const 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,
+ version: version || DEFAULT_VERSION,
+ enabled: Boolean(enabled)
+ });
+};
+
+/**
+ * Create a plugin from a string
+ * @param {String}
+ * @return {Plugin|undefined}
+ */
+PluginDependency.createFromString = function(s) {
+ const parts = s.split('@');
+ let name = parts[0];
+ const version = parts.slice(1).join('@');
+ let enabled = true;
+
+ if (name[0] === '-') {
+ enabled = false;
+ name = name.slice(1);
+ }
+
+ return new PluginDependency({
+ name,
+ version: version || DEFAULT_VERSION,
+ enabled
+ });
+};
+
+/**
+ * Create a PluginDependency from a string
+ * @param {String}
+ * @return {List<PluginDependency>}
+ */
+PluginDependency.listFromString = function(s) {
+ const 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) {
+ let 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/src/models/readme.js b/packages/gitbook/src/models/readme.js
new file mode 100644
index 0000000..0fb52b4
--- /dev/null
+++ b/packages/gitbook/src/models/readme.js
@@ -0,0 +1,40 @@
+const Immutable = require('immutable');
+
+const File = require('./file');
+
+const 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,
+ title: def.title || '',
+ description: def.description || ''
+ });
+};
+
+module.exports = Readme;
diff --git a/packages/gitbook/src/models/summary.js b/packages/gitbook/src/models/summary.js
new file mode 100644
index 0000000..edc202e
--- /dev/null
+++ b/packages/gitbook/src/models/summary.js
@@ -0,0 +1,228 @@
+const is = require('is');
+const Immutable = require('immutable');
+
+const error = require('../utils/error');
+const LocationUtils = require('../utils/location');
+const File = require('./file');
+const SummaryPart = require('./summaryPart');
+const SummaryArticle = require('./summaryArticle');
+const parsers = require('../parsers');
+
+const 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) {
+ const 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) {
+ const 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) {
+ const 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) {
+ const level = is.string(current) ? current : current.getLevel();
+ let 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) {
+ const level = is.string(current) ? current : current.getLevel();
+ let 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
+ const parentLevel = getParentLevel(level);
+ if (!parentLevel) {
+ return null;
+ }
+
+ // Get parent of the position
+ const 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) {
+ const file = this.getFile();
+ const parts = this.getParts();
+
+ const 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() {
+ const 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,
+ parts: new Immutable.List(parts)
+ });
+};
+
+/**
+ Returns parent level of a level
+
+ @param {String} level
+ @return {String}
+*/
+function getParentLevel(level) {
+ const parts = level.split('.');
+ return parts.slice(0, -1).join('.');
+}
+
+module.exports = Summary;
diff --git a/packages/gitbook/src/models/summaryArticle.js b/packages/gitbook/src/models/summaryArticle.js
new file mode 100644
index 0000000..919e6b9
--- /dev/null
+++ b/packages/gitbook/src/models/summaryArticle.js
@@ -0,0 +1,189 @@
+const Immutable = require('immutable');
+
+const location = require('../utils/location');
+
+/*
+ An article represents an entry in the Summary / table of Contents
+*/
+
+const 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;
+ }
+
+ const ref = this.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+ const parts = ref.split('#');
+
+ const 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() {
+ const ref = this.getRef();
+ const parts = ref.split('#');
+
+ const 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() {
+ const level = this.getLevel();
+ const subArticles = this.getArticles();
+ const 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) {
+ const readme = book.getFile ? book : book.getReadme();
+ const 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) {
+ const 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,
+ 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) {
+ const 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/src/models/summaryPart.js b/packages/gitbook/src/models/summaryPart.js
new file mode 100644
index 0000000..0bb5369
--- /dev/null
+++ b/packages/gitbook/src/models/summaryPart.js
@@ -0,0 +1,61 @@
+const Immutable = require('immutable');
+
+const SummaryArticle = require('./summaryArticle');
+
+/*
+ A part represents a section in the Summary / table of Contents
+*/
+
+const 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() {
+ const level = this.getLevel();
+ const subArticles = this.getArticles();
+ const childLevel = level + '.' + (subArticles.size + 1);
+
+ return childLevel;
+};
+
+/**
+ * Create a SummaryPart
+ *
+ * @param {Object} def
+ * @return {SummaryPart}
+ */
+SummaryPart.create = function(def, level) {
+ const 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/src/models/templateBlock.js b/packages/gitbook/src/models/templateBlock.js
new file mode 100644
index 0000000..61c006f
--- /dev/null
+++ b/packages/gitbook/src/models/templateBlock.js
@@ -0,0 +1,253 @@
+const is = require('is');
+const extend = require('extend');
+const { Record, List, Map } = require('immutable');
+const escape = require('escape-html');
+
+const Promise = require('../utils/promise');
+const TemplateShortcut = require('./templateShortcut');
+
+const NODE_ENDARGS = '%%endargs%%';
+const HTML_TAGNAME = 'xblock';
+
+const DEFAULTS = {
+ // 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: List(),
+ // List of shortcuts to replace with this block
+ shortcuts: Map()
+};
+
+class TemplateBlock extends Record(DEFAULTS) {
+ getName() {
+ return this.get('name');
+ }
+
+ getEndTag() {
+ return this.get('end') || ('end' + this.getName());
+ }
+
+ getProcess() {
+ return this.get('process');
+ }
+
+ getBlocks() {
+ return this.get('blocks');
+ }
+
+
+ /**
+ * Return shortcuts associated with this block or undefined
+ * @return {TemplateShortcut|undefined}
+ */
+ getShortcuts() {
+ const shortcuts = this.get('shortcuts');
+ if (shortcuts.size === 0) {
+ return undefined;
+ }
+
+ return TemplateShortcut.createForBlock(this, shortcuts);
+ }
+
+ /**
+ * Return name for the nunjucks extension
+ * @return {String}
+ */
+ getExtensionName() {
+ return 'Block' + this.getName() + 'Extension';
+ }
+
+ /**
+ * Return a nunjucks extension to represents this block
+ * @return {Nunjucks.Extension}
+ */
+ toNunjucksExt(mainContext = {}) {
+ const that = this;
+ const name = this.getName();
+ const endTag = this.getEndTag();
+ const blocks = this.getBlocks().toJS();
+
+ function Ext() {
+ this.tags = [name];
+
+ this.parse = (parser, nodes) => {
+ let lastBlockName = null;
+ let lastBlockArgs = null;
+ const allBlocks = blocks.concat([endTag]);
+
+ // Parse first block
+ const tok = parser.nextToken();
+ lastBlockArgs = parser.parseSignature(null, true);
+ parser.advanceAfterBlockEnd(tok.value);
+
+ const args = new nodes.NodeList();
+ const bodies = [];
+ const blockNamesNode = new nodes.Array(tok.lineno, tok.colno);
+ const blockArgCounts = new nodes.Array(tok.lineno, tok.colno);
+
+ // Parse while we found "end<block>"
+ do {
+ // Read body
+ const currentBody = parser.parseUntilBlocks(...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 = (context, ...fnArgs) => {
+ let args;
+ const blocks = [];
+ let bodies = [];
+
+ // Extract callback
+ const callback = fnArgs.pop();
+
+ // Detect end of arguments
+ const endArgIndex = fnArgs.indexOf(NODE_ENDARGS);
+
+ // Extract arguments and bodies
+ args = fnArgs.slice(0, endArgIndex);
+ bodies = fnArgs.slice(endArgIndex + 1);
+
+ // Extract block counts
+ const blockArgCounts = args.pop();
+ const blockNames = args.pop();
+
+ // Recreate list of blocks
+ blockNames.forEach((blkName, i) => {
+ const countArgs = blockArgCounts[i];
+ const blockBody = bodies.shift();
+
+ const blockArgs = countArgs > 0 ? args.slice(0, countArgs) : [];
+ args = args.slice(countArgs);
+ const blockKwargs = extractKwargs(blockArgs);
+
+ blocks.push({
+ name: blkName,
+ children: blockBody(),
+ args: blockArgs,
+ kwargs: blockKwargs
+ });
+ });
+
+ const mainBlock = blocks.shift();
+ mainBlock.blocks = blocks;
+
+ Promise()
+ .then(function() {
+ const ctx = extend({
+ ctx: context
+ }, mainContext);
+
+ return that.toProps(mainBlock, ctx);
+ })
+ .then(function(props) {
+ return that.toHTML(props);
+ })
+ .nodeify(callback);
+ };
+ }
+
+ return Ext;
+ }
+
+ /**
+ * Apply a block an return the props
+ *
+ * @param {Object} inner
+ * @param {Object} context
+ * @return {Promise<Props>}
+ */
+ toProps(inner, context) {
+ const processFn = this.getProcess();
+
+ inner = inner || {};
+ inner.args = inner.args || [];
+ inner.kwargs = inner.kwargs || {};
+ inner.blocks = inner.blocks || [];
+
+ return Promise()
+ .then(() => processFn.call(context, inner))
+ .then(props => {
+ if (is.string(props)) {
+ return { children: props };
+ }
+
+ return props;
+ });
+ }
+
+ /**
+ * Convert a block props to HTML. This HTML is then being
+ * parsed by gitbook-core during rendering, and binded to the right react components.
+ *
+ * @param {Object} props
+ * @return {String}
+ */
+ toHTML(props) {
+ const { children, ...innerProps } = props;
+ const payload = escape(JSON.stringify(innerProps));
+
+ return (
+ `<${HTML_TAGNAME} name="${this.name}" props="${payload}">${children || ''}</${HTML_TAGNAME}>`
+ );
+ }
+
+ /**
+ * Create a template block from a function or an object
+ * @param {String} blockName
+ * @param {Object} block
+ * @return {TemplateBlock}
+ */
+ static create(blockName, block) {
+ if (is.fn(block)) {
+ block = new 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) {
+ const last = args[args.length - 1];
+ return (is.object(last) && last.__keywords) ? args.pop() : {};
+}
+
+module.exports = TemplateBlock;
diff --git a/packages/gitbook/src/models/templateEngine.js b/packages/gitbook/src/models/templateEngine.js
new file mode 100644
index 0000000..0d0dcb6
--- /dev/null
+++ b/packages/gitbook/src/models/templateEngine.js
@@ -0,0 +1,133 @@
+const nunjucks = require('nunjucks');
+const { Record, Map, List } = require('immutable');
+
+const DEFAULTS = {
+ // List of {TemplateBlock}
+ blocks: List(),
+ // Map of Extension
+ extensions: Map(),
+ // Map of filters: {String} name -> {Function} fn
+ filters: Map(),
+ // Map of globals: {String} name -> {Mixed}
+ globals: Map(),
+ // Context for filters / blocks
+ context: Object(),
+ // Nunjucks loader
+ loader: nunjucks.FileSystemLoader('views')
+};
+
+class TemplateEngine extends Record(DEFAULTS) {
+ getBlocks() {
+ return this.get('blocks');
+ }
+
+ getGlobals() {
+ return this.get('globals');
+ }
+
+ getFilters() {
+ return this.get('filters');
+ }
+
+ getShortcuts() {
+ return this.get('shortcuts');
+ }
+
+ getLoader() {
+ return this.get('loader');
+ }
+
+ getContext() {
+ return this.get('context');
+ }
+
+ getExtensions() {
+ return this.get('extensions');
+ }
+
+ /**
+ * Return a block by its name (or undefined).
+ * @param {String} name
+ * @return {TemplateBlock} block?
+ */
+ getBlock(name) {
+ const blocks = this.getBlocks();
+ return blocks.find(function(block) {
+ return block.getName() === name;
+ });
+ }
+
+ /**
+ * Return a nunjucks environment from this configuration
+ * @return {Nunjucks.Environment} env
+ */
+ toNunjucks() {
+ const loader = this.getLoader();
+ const blocks = this.getBlocks();
+ const filters = this.getFilters();
+ const globals = this.getGlobals();
+ const extensions = this.getExtensions();
+ const context = this.getContext();
+
+ const 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) {
+ const extName = block.getExtensionName();
+ const Ext = block.toNunjucksExt(context);
+
+ env.addExtension(extName, new Ext());
+ });
+
+ // Add globals
+ globals.forEach(function(globalValue, globalName) {
+ env.addGlobal(globalName, globalValue);
+ });
+
+ // Add other extensions
+ extensions.forEach(function(ext, extName) {
+ env.addExtension(extName, ext);
+ });
+
+ return env;
+ }
+
+ /**
+ * Create a template engine.
+ * @param {Object} def
+ * @return {TemplateEngine} engine
+ */
+ static create(def) {
+ return new TemplateEngine({
+ blocks: List(def.blocks || []),
+ extensions: Map(def.extensions || {}),
+ filters: Map(def.filters || {}),
+ globals: Map(def.globals || {}),
+ context: def.context,
+ loader: def.loader
+ });
+ }
+}
+
+module.exports = TemplateEngine;
diff --git a/packages/gitbook/src/models/templateShortcut.js b/packages/gitbook/src/models/templateShortcut.js
new file mode 100644
index 0000000..b6e1ed9
--- /dev/null
+++ b/packages/gitbook/src/models/templateShortcut.js
@@ -0,0 +1,73 @@
+const Immutable = require('immutable');
+const is = require('is');
+
+/*
+ A TemplateShortcut is defined in plugin's template blocks
+ to replace content with a templating block using delimiters.
+*/
+const 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();
+ }
+
+ const 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/src/models/uriIndex.js b/packages/gitbook/src/models/uriIndex.js
new file mode 100644
index 0000000..eecdc54
--- /dev/null
+++ b/packages/gitbook/src/models/uriIndex.js
@@ -0,0 +1,159 @@
+const path = require('path');
+const url = require('url');
+const { Record, Map } = require('immutable');
+const LocationUtils = require('../utils/location');
+
+/*
+ The URIIndex stores a map of filename to url.
+ To resolve urls for each article.
+ */
+
+const DEFAULTS = {
+ uris: Map(),
+ directoryIndex: Boolean(true)
+};
+
+/**
+ * Modify an url path while preserving the hash
+ * @param {String} input
+ * @param {Function<String>} transform
+ * @return {String} output
+ */
+function transformURLPath(input, transform) {
+ // Split anchor
+ const parsed = url.parse(input);
+ input = parsed.pathname || '';
+
+ input = transform(input);
+
+ // Add back anchor
+ input = input + (parsed.hash || '');
+
+ return input;
+}
+
+class URIIndex extends Record(DEFAULTS) {
+ constructor(index) {
+ super({
+ uris: Map(index)
+ .mapKeys(key => LocationUtils.normalize(key))
+ });
+ }
+
+ /**
+ * Append a file to the index
+ * @param {String} filePath
+ * @param {String} url
+ * @return {URIIndex}
+ */
+ append(filePath, uri) {
+ const { uris } = this;
+ filePath = LocationUtils.normalize(filePath);
+
+ return this.merge({
+ uris: uris.set(filePath, uri)
+ });
+ }
+
+ /**
+ * Resolve an absolute file path to an url.
+ *
+ * @param {String} filePath
+ * @return {String} url
+ */
+ resolve(filePath) {
+ if (LocationUtils.isExternal(filePath)) {
+ return filePath;
+ }
+
+ return transformURLPath(filePath, (href) => {
+ const { uris } = this;
+ href = LocationUtils.normalize(href);
+
+ return uris.get(href, href);
+ });
+ }
+
+ /**
+ * Resolve a filename to an url, considering that the link to "filePath"
+ * in the file "originPath".
+ *
+ * For example if we are generating doc/README.md and we have a link "/READNE.md":
+ * index.resolveFrom('doc/README.md', '/README.md') === '../index.html'
+ *
+ * @param {String} originPath
+ * @param {String} filePath
+ * @return {String} url
+ */
+ resolveFrom(originPath, filePath) {
+ if (LocationUtils.isExternal(filePath)) {
+ return filePath;
+ }
+
+ const originURL = this.resolve(originPath);
+ const originDir = path.dirname(originPath);
+ const originOutDir = path.dirname(originURL);
+
+ return transformURLPath(filePath, (href) => {
+ if (!href) {
+ return href;
+ }
+ // Calcul absolute path for this
+ href = LocationUtils.toAbsolute(href, originDir, '.');
+
+ // Resolve file
+ href = this.resolve(href);
+
+ // Convert back to relative
+ href = LocationUtils.relative(originOutDir, href);
+
+ return href;
+ });
+ }
+
+ /**
+ * Normalize an url
+ * @param {String} uri
+ * @return {String} uri
+ */
+ normalizeURL(uri) {
+ const { directoryIndex } = this;
+
+ if (!directoryIndex || LocationUtils.isExternal(uri)) {
+ return uri;
+ }
+
+ return transformURLPath(uri, (pathname) => {
+ if (path.basename(pathname) == 'index.html') {
+ pathname = path.dirname(pathname) + '/';
+ }
+
+ return pathname;
+ });
+ }
+
+ /**
+ * Resolve an entry to an url
+ * @param {String} filePath
+ * @return {String}
+ */
+ resolveToURL(filePath) {
+ const uri = this.resolve(filePath);
+ return this.normalizeURL(uri);
+ }
+
+ /**
+ * Resolve an entry to an url
+ *
+ * @param {String} originPath
+ * @param {String} filePath
+ * @return {String} url
+ */
+ resolveToURLFrom(originPath, filePath) {
+ const uri = this.resolveFrom(originPath, filePath);
+ return this.normalizeURL(uri);
+ }
+
+}
+
+module.exports = URIIndex;
diff --git a/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js b/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js
new file mode 100644
index 0000000..65fd8f9
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/__tests__/addPlugin.js
@@ -0,0 +1,31 @@
+const addPlugin = require('../addPlugin');
+const Config = require('../../../models/config');
+
+describe('addPlugin', function() {
+ const config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should have correct state of dependencies', function() {
+ const disabledDep = config.getPluginDependency('disabled');
+
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+
+ it('should add the plugin to the list', function() {
+ const newConfig = addPlugin(config, 'test');
+
+ const testDep = newConfig.getPluginDependency('test');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeTruthy();
+
+ const disabledDep = newConfig.getPluginDependency('disabled');
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+});
+
diff --git a/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js b/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js
new file mode 100644
index 0000000..5450b30
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/__tests__/removePlugin.js
@@ -0,0 +1,32 @@
+const removePlugin = require('../removePlugin');
+const Config = require('../../../models/config');
+
+describe('removePlugin', function() {
+ const config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should remove the plugin from the list', function() {
+ const newConfig = removePlugin(config, 'hello');
+
+ const testDep = newConfig.getPluginDependency('hello');
+ expect(testDep).toNotBeDefined();
+ });
+
+ it('should remove the disabled plugin from the list', function() {
+ const newConfig = removePlugin(config, 'disabled');
+
+ const testDep = newConfig.getPluginDependency('disabled');
+ expect(testDep).toNotBeDefined();
+ });
+
+ it('should disable default plugin', function() {
+ const newConfig = removePlugin(config, 'search');
+
+ const disabledDep = newConfig.getPluginDependency('search');
+ expect(disabledDep).toBeDefined();
+ expect(disabledDep.getVersion()).toEqual('*');
+ expect(disabledDep.isEnabled()).toBeFalsy();
+ });
+});
+
diff --git a/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js b/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js
new file mode 100644
index 0000000..6d23ae0
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/__tests__/togglePlugin.js
@@ -0,0 +1,27 @@
+const togglePlugin = require('../togglePlugin');
+const Config = require('../../../models/config');
+
+describe('togglePlugin', function() {
+ const config = Config.createWithValues({
+ plugins: ['hello', 'world', '-disabled']
+ });
+
+ it('should enable plugin', function() {
+ const newConfig = togglePlugin(config, 'disabled');
+
+ const testDep = newConfig.getPluginDependency('disabled');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeTruthy();
+ });
+
+ it('should disable plugin', function() {
+ const newConfig = togglePlugin(config, 'world');
+
+ const testDep = newConfig.getPluginDependency('world');
+ expect(testDep).toBeDefined();
+ expect(testDep.getVersion()).toEqual('*');
+ expect(testDep.isEnabled()).toBeFalsy();
+ });
+});
+
diff --git a/packages/gitbook/src/modifiers/config/addPlugin.js b/packages/gitbook/src/modifiers/config/addPlugin.js
new file mode 100644
index 0000000..e9ed259
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/addPlugin.js
@@ -0,0 +1,25 @@
+const PluginDependency = require('../../models/pluginDependency');
+const togglePlugin = require('./togglePlugin');
+const 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);
+ }
+
+ let deps = config.getPluginDependencies();
+ const dep = PluginDependency.create(pluginName, version);
+
+ deps = deps.push(dep);
+ return config.setPluginDependencies(deps);
+}
+
+module.exports = addPlugin;
diff --git a/packages/gitbook/src/modifiers/config/editPlugin.js b/packages/gitbook/src/modifiers/config/editPlugin.js
new file mode 100644
index 0000000..dd7fd11
--- /dev/null
+++ b/packages/gitbook/src/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/src/modifiers/config/getPluginConfig.js b/packages/gitbook/src/modifiers/config/getPluginConfig.js
new file mode 100644
index 0000000..ed7d6ea
--- /dev/null
+++ b/packages/gitbook/src/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) {
+ const pluginsConfig = config.getValues().get('pluginsConfig');
+ if (pluginsConfig === undefined) {
+ return {};
+ }
+ const pluginConf = pluginsConfig.get(pluginName);
+ if (pluginConf === undefined) {
+ return {};
+ } else {
+ return pluginConf.toJS();
+ }
+}
+
+module.exports = getPluginConfig;
diff --git a/packages/gitbook/src/modifiers/config/hasPlugin.js b/packages/gitbook/src/modifiers/config/hasPlugin.js
new file mode 100644
index 0000000..9aab4f2
--- /dev/null
+++ b/packages/gitbook/src/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/src/modifiers/config/index.js b/packages/gitbook/src/modifiers/config/index.js
new file mode 100644
index 0000000..b3de0b0
--- /dev/null
+++ b/packages/gitbook/src/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/src/modifiers/config/isDefaultPlugin.js b/packages/gitbook/src/modifiers/config/isDefaultPlugin.js
new file mode 100644
index 0000000..096e21a
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/isDefaultPlugin.js
@@ -0,0 +1,14 @@
+const DEFAULT_PLUGINS = require('../../constants/defaultPlugins');
+const 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/src/modifiers/config/removePlugin.js b/packages/gitbook/src/modifiers/config/removePlugin.js
new file mode 100644
index 0000000..c80ab84
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/removePlugin.js
@@ -0,0 +1,25 @@
+const togglePlugin = require('./togglePlugin');
+const isDefaultPlugin = require('./isDefaultPlugin');
+
+/**
+ * Remove a plugin from a book's configuration
+ * @param {Config} config
+ * @param {String} plugin
+ * @return {Config}
+ */
+function removePlugin(config, pluginName) {
+ let 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/src/modifiers/config/togglePlugin.js b/packages/gitbook/src/modifiers/config/togglePlugin.js
new file mode 100644
index 0000000..12a6dec
--- /dev/null
+++ b/packages/gitbook/src/modifiers/config/togglePlugin.js
@@ -0,0 +1,31 @@
+const PluginDependency = require('../../models/pluginDependency');
+const hasPlugin = require('./hasPlugin');
+const 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) {
+ let 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/src/modifiers/index.js b/packages/gitbook/src/modifiers/index.js
new file mode 100644
index 0000000..ad24604
--- /dev/null
+++ b/packages/gitbook/src/modifiers/index.js
@@ -0,0 +1,5 @@
+
+module.exports = {
+ Summary: require('./summary'),
+ Config: require('./config')
+};
diff --git a/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/editArticle.js
diff --git a/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js b/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js
new file mode 100644
index 0000000..aa14a34
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/editPartTitle.js
@@ -0,0 +1,43 @@
+const Summary = require('../../../models/summary');
+const File = require('../../../models/file');
+
+describe('editPartTitle', function() {
+ const editPartTitle = require('../editPartTitle');
+ const 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() {
+ const newSummary = editPartTitle(summary, 0, 'Hello World');
+ const part = newSummary.getPart(0);
+
+ expect(part.getTitle()).toBe('Hello World');
+ });
+
+ it('should correctly set title of second part', function() {
+ const newSummary = editPartTitle(summary, 1, 'Hello');
+ const part = newSummary.getPart(1);
+
+ expect(part.getTitle()).toBe('Hello');
+ });
+
+ it('should not fail if part doesn\'t exist', function() {
+ const newSummary = editPartTitle(summary, 3, 'Hello');
+ expect(newSummary.getParts().size).toBe(2);
+ });
+});
+
diff --git a/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js
new file mode 100644
index 0000000..d5ae9bc
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/insertArticle.js
@@ -0,0 +1,78 @@
+const Summary = require('../../../models/summary');
+const SummaryArticle = require('../../../models/summaryArticle');
+const File = require('../../../models/file');
+
+describe('insertArticle', function() {
+ const insertArticle = require('../insertArticle');
+ const 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() {
+ const article = SummaryArticle.create({
+ title: 'Inserted'
+ }, 'fake.level');
+
+ const newSummary = insertArticle(summary, article, '2.1.1');
+
+ const inserted = newSummary.getByLevel('2.1.1');
+ const 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() {
+ const article = SummaryArticle.create({
+ title: 'Inserted'
+ }, 'fake.level');
+
+ const newSummary = insertArticle(summary, article, '2.2');
+
+ const inserted = newSummary.getByLevel('2.2');
+ const 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/src/modifiers/summary/__tests__/insertPart.js b/packages/gitbook/src/modifiers/summary/__tests__/insertPart.js
new file mode 100644
index 0000000..5112931
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/insertPart.js
@@ -0,0 +1,60 @@
+const Summary = require('../../../models/summary');
+const SummaryPart = require('../../../models/summaryPart');
+
+const File = require('../../../models/file');
+
+describe('insertPart', function() {
+ const insertPart = require('../insertPart');
+ const 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() {
+ const part = SummaryPart.create({
+ title: 'Inserted'
+ }, 'meaningless.level');
+
+ const newSummary = insertPart(summary, part, 1);
+
+ const inserted = newSummary.getPart(1);
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(newSummary.getParts().count()).toBe(3);
+
+ const 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() {
+ const part = SummaryPart.create({
+ title: 'Inserted'
+ }, 'meaningless.level');
+
+ const newSummary = insertPart(summary, part, 2);
+
+ const inserted = newSummary.getPart(2);
+ expect(inserted.getTitle()).toBe('Inserted');
+ expect(newSummary.getParts().count()).toBe(3);
+ });
+});
diff --git a/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js b/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js
new file mode 100644
index 0000000..e0d4a62
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/mergeAtLevel.js
@@ -0,0 +1,45 @@
+const Immutable = require('immutable');
+const Summary = require('../../../models/summary');
+const File = require('../../../models/file');
+
+describe('mergeAtLevel', function() {
+ const mergeAtLevel = require('../mergeAtLevel');
+ const 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() {
+ const beforeChildren = summary.getByLevel('1').getArticles();
+ const newSummary = mergeAtLevel(summary, '1', {title: 'Part O'});
+ const 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() {
+ const beforePath = summary.getByLevel('1.2').getPath();
+ const newSummary = mergeAtLevel(summary, '1.2', {title: 'Renamed article'});
+ const 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/src/modifiers/summary/__tests__/moveArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js
new file mode 100644
index 0000000..a7d111b
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/moveArticle.js
@@ -0,0 +1,68 @@
+const Immutable = require('immutable');
+const Summary = require('../../../models/summary');
+const File = require('../../../models/file');
+
+describe('moveArticle', function() {
+ const moveArticle = require('../moveArticle');
+ const 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() {
+ const newSummary = moveArticle(summary, '2.1', '2.1');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('should move an article to an previous level', function() {
+ const newSummary = moveArticle(summary, '2.2', '2.1');
+ const moved = newSummary.getByLevel('2.1');
+ const 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() {
+ const newSummary = moveArticle(summary, '2.1', '2.2');
+ const moved = newSummary.getByLevel('2.1');
+ const other = newSummary.getByLevel('2.2');
+
+ expect(moved.getTitle()).toBe('2.2');
+ expect(other.getTitle()).toBe('2.1');
+ });
+});
diff --git a/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js b/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js
new file mode 100644
index 0000000..446d8a4
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/moveArticleAfter.js
@@ -0,0 +1,82 @@
+const Immutable = require('immutable');
+const Summary = require('../../../models/summary');
+const File = require('../../../models/file');
+
+describe('moveArticleAfter', function() {
+ const moveArticleAfter = require('../moveArticleAfter');
+ const 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() {
+ const newSummary = moveArticleAfter(summary, '2.1', '2.1');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('moving after previous one should be invariant too', function() {
+ const newSummary = moveArticleAfter(summary, '2.1', '2.0');
+
+ expect(Immutable.is(summary, newSummary)).toBe(true);
+ });
+
+ it('should move an article after a previous level', function() {
+ const newSummary = moveArticleAfter(summary, '2.2', '2.0');
+ const 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() {
+ const newSummary = moveArticleAfter(summary, '2.1.1', '2.0');
+ const 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() {
+ const newSummary = moveArticleAfter(summary, '2.1', '2.2');
+ const 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/src/modifiers/summary/__tests__/removeArticle.js b/packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js
new file mode 100644
index 0000000..14587ca
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/__tests__/removeArticle.js
@@ -0,0 +1,53 @@
+const Summary = require('../../../models/summary');
+const File = require('../../../models/file');
+
+describe('removeArticle', function() {
+ const removeArticle = require('../removeArticle');
+ const 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() {
+ const newSummary = removeArticle(summary, '2.1.1');
+
+ const removed = newSummary.getByLevel('2.1.1');
+ const nextOne = newSummary.getByLevel('2.1.2');
+
+ expect(removed.getTitle()).toBe('2.1.2');
+ expect(nextOne).toBe(null);
+ });
+});
diff --git a/packages/gitbook/src/modifiers/summary/editArticleRef.js b/packages/gitbook/src/modifiers/summary/editArticleRef.js
new file mode 100644
index 0000000..c5c1868
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/editArticleRef.js
@@ -0,0 +1,17 @@
+const 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/src/modifiers/summary/editArticleTitle.js b/packages/gitbook/src/modifiers/summary/editArticleTitle.js
new file mode 100644
index 0000000..f55c97e
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/editArticleTitle.js
@@ -0,0 +1,17 @@
+const 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/src/modifiers/summary/editPartTitle.js b/packages/gitbook/src/modifiers/summary/editPartTitle.js
new file mode 100644
index 0000000..ace7058
--- /dev/null
+++ b/packages/gitbook/src/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) {
+ let parts = summary.getParts();
+
+ let 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/src/modifiers/summary/index.js b/packages/gitbook/src/modifiers/summary/index.js
new file mode 100644
index 0000000..f91fdb6
--- /dev/null
+++ b/packages/gitbook/src/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/src/modifiers/summary/indexArticleLevels.js b/packages/gitbook/src/modifiers/summary/indexArticleLevels.js
new file mode 100644
index 0000000..03c26c7
--- /dev/null
+++ b/packages/gitbook/src/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();
+ let articles = article.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return article.merge({
+ level: baseLevel,
+ articles
+ });
+}
+
+module.exports = indexArticleLevels;
diff --git a/packages/gitbook/src/modifiers/summary/indexLevels.js b/packages/gitbook/src/modifiers/summary/indexLevels.js
new file mode 100644
index 0000000..deb76da
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/indexLevels.js
@@ -0,0 +1,17 @@
+const indexPartLevels = require('./indexPartLevels');
+
+/**
+ Index all levels in the summary
+
+ @param {Summary}
+ @return {Summary}
+*/
+function indexLevels(summary) {
+ let parts = summary.getParts();
+ parts = parts.map(indexPartLevels);
+
+ return summary.set('parts', parts);
+}
+
+
+module.exports = indexLevels;
diff --git a/packages/gitbook/src/modifiers/summary/indexPartLevels.js b/packages/gitbook/src/modifiers/summary/indexPartLevels.js
new file mode 100644
index 0000000..6e48778
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/indexPartLevels.js
@@ -0,0 +1,24 @@
+const indexArticleLevels = require('./indexArticleLevels');
+
+/**
+ Index levels in a part
+
+ @param {Part}
+ @param {Number} index
+ @return {Part}
+*/
+function indexPartLevels(part, index) {
+ const baseLevel = String(index + 1);
+ let articles = part.getArticles();
+
+ articles = articles.map(function(inner, i) {
+ return indexArticleLevels(inner, baseLevel + '.' + (i + 1));
+ });
+
+ return part.merge({
+ level: baseLevel,
+ articles
+ });
+}
+
+module.exports = indexPartLevels;
diff --git a/packages/gitbook/src/modifiers/summary/insertArticle.js b/packages/gitbook/src/modifiers/summary/insertArticle.js
new file mode 100644
index 0000000..537f548
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/insertArticle.js
@@ -0,0 +1,49 @@
+const is = require('is');
+const SummaryArticle = require('../../models/summaryArticle');
+const mergeAtLevel = require('./mergeAtLevel');
+const 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();
+
+ let parent = summary.getParent(level);
+ if (!parent) {
+ return summary;
+ }
+
+ // Find the index to insert at
+ let articles = parent.getArticles();
+ const 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) {
+ const arr = level.split('.').map(function(char) {
+ return parseInt(char, 10);
+ });
+ return arr[arr.length - 1] - 1;
+}
+
+module.exports = insertArticle;
diff --git a/packages/gitbook/src/modifiers/summary/insertPart.js b/packages/gitbook/src/modifiers/summary/insertPart.js
new file mode 100644
index 0000000..ea99f89
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/insertPart.js
@@ -0,0 +1,19 @@
+const SummaryPart = require('../../models/summaryPart');
+const 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);
+
+ const parts = summary.getParts().insert(index, part);
+ return indexLevels(summary.set('parts', parts));
+}
+
+module.exports = insertPart;
diff --git a/packages/gitbook/src/modifiers/summary/mergeAtLevel.js b/packages/gitbook/src/modifiers/summary/mergeAtLevel.js
new file mode 100644
index 0000000..ea01763
--- /dev/null
+++ b/packages/gitbook/src/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) {
+ const 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
+ const 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) {
+ let 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) {
+ const levelParts = level.split('.');
+ const partIndex = Number(levelParts[0]) - 1;
+
+ let parts = summary.getParts();
+ let part = parts.get(partIndex);
+ if (!part) {
+ return summary;
+ }
+
+ const 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/src/modifiers/summary/moveArticle.js b/packages/gitbook/src/modifiers/summary/moveArticle.js
new file mode 100644
index 0000000..29d4748
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/moveArticle.js
@@ -0,0 +1,25 @@
+const is = require('is');
+const removeArticle = require('./removeArticle');
+const 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
+ const originLevel = is.string(origin) ? origin : origin.getLevel();
+ const targetLevel = is.string(target) ? target : target.getLevel();
+ const article = summary.getByLevel(originLevel);
+
+ // Remove first
+ const removed = removeArticle(summary, originLevel);
+ return insertArticle(removed, article, targetLevel);
+}
+
+module.exports = moveArticle;
diff --git a/packages/gitbook/src/modifiers/summary/moveArticleAfter.js b/packages/gitbook/src/modifiers/summary/moveArticleAfter.js
new file mode 100644
index 0000000..a1ed28f
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/moveArticleAfter.js
@@ -0,0 +1,60 @@
+const is = require('is');
+const removeArticle = require('./removeArticle');
+const 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
+ const originLevel = is.string(origin) ? origin : origin.getLevel();
+ const afterTargetLevel = is.string(afterTarget) ? afterTarget : afterTarget.getLevel();
+ const article = summary.getByLevel(originLevel);
+
+ const targetLevel = increment(afterTargetLevel);
+
+ if (targetLevel < origin) {
+ // Remove first
+ const removed = removeArticle(summary, originLevel);
+ // Insert then
+ return insertArticle(removed, article, targetLevel);
+ } else {
+ // Insert right after first
+ const 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/src/modifiers/summary/removeArticle.js b/packages/gitbook/src/modifiers/summary/removeArticle.js
new file mode 100644
index 0000000..0c4cd33
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/removeArticle.js
@@ -0,0 +1,37 @@
+const is = require('is');
+const mergeAtLevel = require('./mergeAtLevel');
+const 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();
+
+ let parent = summary.getParent(level);
+
+ let articles = parent.getArticles();
+ // Find the index to remove
+ const 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/src/modifiers/summary/removePart.js b/packages/gitbook/src/modifiers/summary/removePart.js
new file mode 100644
index 0000000..30502dc
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/removePart.js
@@ -0,0 +1,15 @@
+const indexLevels = require('./indexLevels');
+
+/**
+ Remove a part at given index
+
+ @param {Summary} summary
+ @param {Number|} index
+ @return {Summary}
+*/
+function removePart(summary, index) {
+ const parts = summary.getParts().remove(index);
+ return indexLevels(summary.set('parts', parts));
+}
+
+module.exports = removePart;
diff --git a/packages/gitbook/src/modifiers/summary/unshiftArticle.js b/packages/gitbook/src/modifiers/summary/unshiftArticle.js
new file mode 100644
index 0000000..c5810f0
--- /dev/null
+++ b/packages/gitbook/src/modifiers/summary/unshiftArticle.js
@@ -0,0 +1,29 @@
+const SummaryArticle = require('../../models/summaryArticle');
+const SummaryPart = require('../../models/summaryPart');
+
+const 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);
+
+ let parts = summary.getParts();
+ let part = parts.get(0) || SummaryPart();
+
+ let 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/src/output/__tests__/createMock.js b/packages/gitbook/src/output/__tests__/createMock.js
new file mode 100644
index 0000000..09b93da
--- /dev/null
+++ b/packages/gitbook/src/output/__tests__/createMock.js
@@ -0,0 +1,38 @@
+const Immutable = require('immutable');
+
+const Output = require('../../models/output');
+const Book = require('../../models/book');
+const parseBook = require('../../parse/parseBook');
+const createMockFS = require('../../fs/mock');
+const 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) {
+ const fs = createMockFS(files);
+ let book = Book.createForFS(fs);
+ const 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,
+ state,
+ generator: generator.name
+ });
+ })
+ .then(preparePlugins);
+}
+
+module.exports = createMockOutput;
diff --git a/packages/gitbook/src/output/__tests__/ebook.js b/packages/gitbook/src/output/__tests__/ebook.js
new file mode 100644
index 0000000..8b7096c
--- /dev/null
+++ b/packages/gitbook/src/output/__tests__/ebook.js
@@ -0,0 +1,15 @@
+const generateMock = require('./generateMock');
+const 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/src/output/__tests__/generateMock.js b/packages/gitbook/src/output/__tests__/generateMock.js
new file mode 100644
index 0000000..6ae1de2
--- /dev/null
+++ b/packages/gitbook/src/output/__tests__/generateMock.js
@@ -0,0 +1,40 @@
+const tmp = require('tmp');
+
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+const parseBook = require('../../parse/parseBook');
+const 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) {
+ const fs = createMockFS(files);
+ let book = Book.createForFS(fs);
+ let dir;
+
+ try {
+ dir = tmp.dirSync();
+ } catch (err) {
+ throw err;
+ }
+
+ book = book.setLogLevel('disabled');
+
+ return parseBook(book)
+ .then((resultBook) => {
+ return generateBook(Generator, resultBook, {
+ root: dir.name
+ });
+ })
+ .thenResolve(dir.name);
+}
+
+module.exports = generateMock;
diff --git a/packages/gitbook/src/output/__tests__/json.js b/packages/gitbook/src/output/__tests__/json.js
new file mode 100644
index 0000000..d4992ec
--- /dev/null
+++ b/packages/gitbook/src/output/__tests__/json.js
@@ -0,0 +1,46 @@
+const generateMock = require('./generateMock');
+const 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/src/output/__tests__/website.js b/packages/gitbook/src/output/__tests__/website.js
new file mode 100644
index 0000000..4c10f1e
--- /dev/null
+++ b/packages/gitbook/src/output/__tests__/website.js
@@ -0,0 +1,140 @@
+const generateMock = require('./generateMock');
+const WebsiteGenerator = require('../website');
+
+describe('WebsiteGenerator', () => {
+
+ it('should generate an index.html', () => {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then((folder) => {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ it('should generate an index.html for custom README', () => {
+ return generateMock(WebsiteGenerator, {
+ 'CustomReadme.md': 'Hello World',
+ 'book.json': '{ "structure": { "readme": "CustomReadme.md" } }'
+ })
+ .then((folder) => {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toNotHaveFile('CustomReadme.html');
+ });
+ });
+
+ describe('Glossary', () => {
+ let folder;
+
+ before(() => {
+ 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((_folder) => {
+ folder = _folder;
+ });
+ });
+
+ it('should generate a GLOSSARY.html', () => {
+ expect(folder).toHaveFile('GLOSSARY.html');
+ });
+
+ it('should accept a custom glossary file', () => {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'book.json': '{ "structure": { "glossary": "custom.md" } }',
+ 'custom.md': '# Glossary\n\n## Hello\n\nHello World'
+ })
+ .then((result) => {
+ expect(result).toHaveFile('custom.html');
+ expect(result).toNotHaveFile('GLOSSARY.html');
+ });
+ });
+ });
+
+
+ it('should copy asset files', () => {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'myJsFile.js': 'var a = "test";',
+ 'folder': {
+ 'AnotherAssetFile.md': '# Even md'
+ }
+ })
+ .then((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', () => {
+ return generateMock(WebsiteGenerator, {
+ 'README.adoc': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ it('should generate an HTML file for each articles', () => {
+ 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', () => {
+ 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((folder) => {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toNotHaveFile('page.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ it('should generate a multilingual book', () => {
+ return generateMock(WebsiteGenerator, {
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ })
+ .then((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/core.js');
+ expect(folder).toNotHaveFile('en/gitbook/core.js');
+
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
diff --git a/packages/gitbook/src/output/callHook.js b/packages/gitbook/src/output/callHook.js
new file mode 100644
index 0000000..34c16ab
--- /dev/null
+++ b/packages/gitbook/src/output/callHook.js
@@ -0,0 +1,60 @@
+const Promise = require('../utils/promise');
+const timing = require('../utils/timing');
+const Api = require('../api');
+
+function defaultGetArgument() {
+ return undefined;
+}
+
+function defaultHandleResult(output, result) {
+ return output;
+}
+
+/**
+ * Call a "global" hook for an output. Hooks are functions exported by plugins.
+ *
+ * @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;
+
+ const logger = output.getLogger();
+ const plugins = output.getPlugins();
+
+ logger.debug.ln('calling hook "' + name + '"');
+
+ // Create the JS context for plugins
+ const 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) {
+ const 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/src/output/callPageHook.js b/packages/gitbook/src/output/callPageHook.js
new file mode 100644
index 0000000..0c7adfa
--- /dev/null
+++ b/packages/gitbook/src/output/callPageHook.js
@@ -0,0 +1,28 @@
+const Api = require('../api');
+const 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/src/output/createTemplateEngine.js b/packages/gitbook/src/output/createTemplateEngine.js
new file mode 100644
index 0000000..f405f36
--- /dev/null
+++ b/packages/gitbook/src/output/createTemplateEngine.js
@@ -0,0 +1,48 @@
+const Templating = require('../templating');
+const TemplateEngine = require('../models/templateEngine');
+
+const Api = require('../api');
+const Plugins = require('../plugins');
+
+const defaultBlocks = require('../constants/defaultBlocks');
+const defaultFilters = require('../constants/defaultFilters');
+
+/**
+ * Create template engine for an output.
+ * It adds default filters/blocks, then add the ones from plugins.
+ *
+ * This template engine is used to compile pages.
+ *
+ * @param {Output} output
+ * @return {TemplateEngine}
+ */
+function createTemplateEngine(output) {
+ const { git } = output;
+ const plugins = output.getPlugins();
+ const book = output.getBook();
+ const rootFolder = book.getContentRoot();
+ const logger = book.getLogger();
+
+ let filters = Plugins.listFilters(plugins);
+ let blocks = Plugins.listBlocks(plugins);
+
+ // Extend with default
+ blocks = defaultBlocks.merge(blocks);
+ filters = defaultFilters.merge(filters);
+
+ // Create loader
+ const transformFn = Templating.replaceShortcuts.bind(null, blocks);
+ const loader = new Templating.ConrefsLoader(rootFolder, transformFn, logger, git);
+
+ // Create API context
+ const context = Api.encodeGlobal(output);
+
+ return new TemplateEngine({
+ filters,
+ blocks,
+ loader,
+ context
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/packages/gitbook/src/output/ebook/getConvertOptions.js b/packages/gitbook/src/output/ebook/getConvertOptions.js
new file mode 100644
index 0000000..b37c68e
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/getConvertOptions.js
@@ -0,0 +1,73 @@
+const extend = require('extend');
+
+const Promise = require('../../utils/promise');
+const getPDFTemplate = require('./getPDFTemplate');
+const getCoverPath = require('./getCoverPath');
+
+/**
+ Generate options for ebook-convert
+
+ @param {Output}
+ @return {Promise<Object>}
+*/
+function getConvertOptions(output) {
+ const options = output.getOptions();
+ const format = options.get('format');
+
+ const book = output.getBook();
+ const config = book.getConfig();
+
+ return Promise()
+ .then(function() {
+ const coverPath = getCoverPath(output);
+ let 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) {
+ const 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/src/output/ebook/getCoverPath.js b/packages/gitbook/src/output/ebook/getCoverPath.js
new file mode 100644
index 0000000..cf18c8d
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/getCoverPath.js
@@ -0,0 +1,30 @@
+const path = require('path');
+const fs = require('../../utils/fs');
+
+/**
+ Resolve path to cover file to use
+
+ @param {Output}
+ @return {String}
+*/
+function getCoverPath(output) {
+ const outputRoot = output.getRoot();
+ const book = output.getBook();
+ const config = book.getConfig();
+ const coverName = config.getValue('cover', 'cover.jpg');
+
+ // Resolve to absolute
+ let 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/src/output/ebook/getPDFTemplate.js b/packages/gitbook/src/output/ebook/getPDFTemplate.js
new file mode 100644
index 0000000..53c7a82
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/getPDFTemplate.js
@@ -0,0 +1,36 @@
+const juice = require('juice');
+
+const JSONUtils = require('../../json');
+const render = require('../../browser/render');
+const Promise = require('../../utils/promise');
+
+/**
+ * Generate PDF header/footer templates
+ *
+ * @param {Output} output
+ * @param {String} type ("footer" or "header")
+ * @return {String} html
+ */
+function getPDFTemplate(output, type) {
+ const outputRoot = output.getRoot();
+ const plugins = output.getPlugins();
+
+ // Generate initial state
+ const initialState = JSONUtils.encodeState(output);
+ initialState.page = {
+ num: '_PAGENUM_',
+ title: '_SECTION_'
+ };
+
+ // Render the theme
+ const html = render(plugins, initialState, 'ebook', `pdf:${type}`);
+
+ // Inline CSS
+ return Promise.nfcall(juice.juiceResources, html, {
+ webResources: {
+ relativeTo: outputRoot
+ }
+ });
+}
+
+module.exports = getPDFTemplate;
diff --git a/packages/gitbook/src/output/ebook/index.js b/packages/gitbook/src/output/ebook/index.js
new file mode 100644
index 0000000..c5c07c2
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/index.js
@@ -0,0 +1,9 @@
+const extend = require('extend');
+const WebsiteGenerator = require('../website');
+
+module.exports = extend({}, WebsiteGenerator, {
+ name: 'ebook',
+ Options: require('./options'),
+ onPage: require('./onPage'),
+ onFinish: require('./onFinish')
+});
diff --git a/packages/gitbook/src/output/ebook/onFinish.js b/packages/gitbook/src/output/ebook/onFinish.js
new file mode 100644
index 0000000..7db757f
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/onFinish.js
@@ -0,0 +1,85 @@
+const path = require('path');
+
+const JSONUtils = require('../../json');
+const Promise = require('../../utils/promise');
+const error = require('../../utils/error');
+const command = require('../../utils/command');
+const writeFile = require('../helper/writeFile');
+const render = require('../../browser/render');
+
+const getConvertOptions = require('./getConvertOptions');
+const SUMMARY_FILE = 'SUMMARY.html';
+
+/**
+ * Write the SUMMARY.html
+ *
+ * @param {Output} output
+ * @return {Output} output
+ */
+function writeSummary(output) {
+ const plugins = output.getPlugins();
+
+ // Generate initial state
+ const initialState = JSONUtils.encodeState(output);
+
+ // Render using React
+ const html = render(plugins, initialState, 'ebook', 'ebook:summary');
+
+ return writeFile(output, SUMMARY_FILE, html);
+}
+
+/**
+ * Generate the ebook file as "index.pdf"
+ *
+ * @param {Output} output
+ * @return {Output} output
+ */
+function runEbookConvert(output) {
+ const logger = output.getLogger();
+ const options = output.getOptions();
+ const format = options.get('format');
+ const outputFolder = output.getRoot();
+
+ if (!format) {
+ return Promise(output);
+ }
+
+ return getConvertOptions(output)
+ .then(function(options) {
+ const 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} output
+ * @return {Output} output
+ */
+function onFinish(output) {
+ return writeSummary(output)
+ .then(runEbookConvert);
+}
+
+module.exports = onFinish;
diff --git a/packages/gitbook/src/output/ebook/onPage.js b/packages/gitbook/src/output/ebook/onPage.js
new file mode 100644
index 0000000..a7c2137
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/onPage.js
@@ -0,0 +1,25 @@
+const WebsiteGenerator = require('../website');
+const Modifiers = require('../modifiers');
+
+/**
+ * Write a page for ebook output. It renders it just as the website generator
+ * except that it inline assets.
+ *
+ * @param {Output} output
+ * @param {Output} output
+ */
+function onPage(output, page) {
+ const 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/src/output/ebook/options.js b/packages/gitbook/src/output/ebook/options.js
new file mode 100644
index 0000000..d192fd2
--- /dev/null
+++ b/packages/gitbook/src/output/ebook/options.js
@@ -0,0 +1,14 @@
+const Immutable = require('immutable');
+
+const 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/src/output/generateAssets.js b/packages/gitbook/src/output/generateAssets.js
new file mode 100644
index 0000000..f926492
--- /dev/null
+++ b/packages/gitbook/src/output/generateAssets.js
@@ -0,0 +1,26 @@
+const Promise = require('../utils/promise');
+
+/**
+ * Output all assets using a generator
+ *
+ * @param {Generator} generator
+ * @param {Output} output
+ * @return {Promise<Output>}
+ */
+function generateAssets(generator, output) {
+ const assets = output.getAssets();
+ const 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/src/output/generateBook.js b/packages/gitbook/src/output/generateBook.js
new file mode 100644
index 0000000..0e2c230
--- /dev/null
+++ b/packages/gitbook/src/output/generateBook.js
@@ -0,0 +1,193 @@
+const path = require('path');
+const Immutable = require('immutable');
+
+const Output = require('../models/output');
+const Promise = require('../utils/promise');
+const fs = require('../utils/fs');
+
+const callHook = require('./callHook');
+const preparePlugins = require('./preparePlugins');
+const preparePages = require('./preparePages');
+const prepareAssets = require('./prepareAssets');
+const generateAssets = require('./generateAssets');
+const 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) {
+ const book = output.getBook();
+ const config = book.getConfig();
+ const values = config.getValues();
+
+ return values.toJS();
+ },
+ function(output, result) {
+ let book = output.getBook();
+ let 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((output) => {
+ if (!generator.onInit) {
+ return output;
+ }
+
+ return generator.onInit(output);
+ })
+
+ .then(generateAssets.bind(null, generator))
+ .then(generatePages.bind(null, generator))
+
+ .tap((output) => {
+ const book = output.getBook();
+
+ if (!book.isMultilingual()) {
+ return;
+ }
+
+ const logger = book.getLogger();
+ const books = book.getBooks();
+ const outputRoot = output.getRoot();
+ const plugins = output.getPlugins();
+ const state = output.getState();
+ const options = output.getOptions();
+
+ return Promise.forEach(books, function(langBook) {
+ // Inherits plugins list, options and state
+ const langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage()));
+ const langOutput = new Output({
+ book: langBook,
+ options: langOptions,
+ state,
+ generator: generator.name,
+ 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((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);
+ const state = generator.State ? generator.State({}) : Immutable.Map();
+ const start = Date.now();
+
+ return Promise(
+ new Output({
+ book,
+ options,
+ state,
+ generator: generator.name
+ })
+ )
+
+ // Cleanup output folder
+ .then((output) => {
+ const logger = output.getLogger();
+ const rootFolder = output.getRoot();
+
+ logger.debug.ln('cleanup folder "' + rootFolder + '"');
+ return fs.ensureFolder(rootFolder)
+ .thenResolve(output);
+ })
+
+ .then(output => processOutput(generator, output))
+
+ // Log duration and end message
+ .then((output) => {
+ const logger = output.getLogger();
+ const end = Date.now();
+ const 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/src/output/generatePage.js b/packages/gitbook/src/output/generatePage.js
new file mode 100644
index 0000000..7375f1d
--- /dev/null
+++ b/packages/gitbook/src/output/generatePage.js
@@ -0,0 +1,68 @@
+const path = require('path');
+
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const timing = require('../utils/timing');
+
+const Templating = require('../templating');
+const JSONUtils = require('../json');
+const createTemplateEngine = require('./createTemplateEngine');
+const callPageHook = require('./callPageHook');
+
+/**
+ * Prepare and generate HTML for a page
+ *
+ * @param {Output} output
+ * @param {Page} page
+ * @return {Promise<Page>}
+ */
+function generatePage(output, page) {
+ const book = output.getBook();
+ const engine = createTemplateEngine(output);
+
+ return timing.measure(
+ 'page.generate',
+ Promise(page)
+ .then(function(resultPage) {
+ const file = resultPage.getFile();
+ const filePath = file.getPath();
+ const parser = file.getParser();
+ const context = JSONUtils.encodeState(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((currentPage) => {
+ return parser.preparePage(currentPage.getContent());
+ })
+
+ // Render templating syntax
+ .then((content) => {
+ const absoluteFilePath = path.join(book.getContentRoot(), filePath);
+ return Templating.render(engine, absoluteFilePath, content, context);
+ })
+
+ // Parse with markdown/asciidoc parser
+ .then((content) => parser.parsePage(content))
+
+ // Return new page
+ .then(({content}) => {
+ return resultPage.set('content', content);
+ })
+
+ // Call final hook
+ .then((currentPage) => {
+ return callPageHook('page', output, currentPage);
+ });
+ })
+ );
+}
+
+module.exports = generatePage;
diff --git a/packages/gitbook/src/output/generatePages.js b/packages/gitbook/src/output/generatePages.js
new file mode 100644
index 0000000..21b6610
--- /dev/null
+++ b/packages/gitbook/src/output/generatePages.js
@@ -0,0 +1,36 @@
+const Promise = require('../utils/promise');
+const generatePage = require('./generatePage');
+
+/**
+ Output all pages using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generatePages(generator, output) {
+ const pages = output.getPages();
+ const logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onPage) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(pages, function(out, page) {
+ const 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/src/output/getModifiers.js b/packages/gitbook/src/output/getModifiers.js
new file mode 100644
index 0000000..3007b02
--- /dev/null
+++ b/packages/gitbook/src/output/getModifiers.js
@@ -0,0 +1,42 @@
+const Modifiers = require('./modifiers');
+
+/**
+ * Return default modifier to prepare a page for
+ * rendering.
+ *
+ * @return {Array<Modifier>}
+ */
+function getModifiers(output, page) {
+ const book = output.getBook();
+ const glossary = book.getGlossary();
+ const file = page.getFile();
+
+ // Map of urls
+ const urls = output.getURLIndex();
+
+ // Glossary entries
+ const entries = glossary.getEntries();
+ const glossaryFile = glossary.getFile();
+ const glossaryFilename = urls.resolveToURL(glossaryFile.getPath());
+
+ // Current file path
+ const currentFilePath = file.getPath();
+
+ 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,
+ (filePath => urls.resolveToURLFrom(currentFilePath, filePath))
+ )
+ ];
+}
+
+module.exports = getModifiers;
diff --git a/packages/gitbook/src/output/helper/index.js b/packages/gitbook/src/output/helper/index.js
new file mode 100644
index 0000000..f8bc109
--- /dev/null
+++ b/packages/gitbook/src/output/helper/index.js
@@ -0,0 +1,2 @@
+
+module.exports = {};
diff --git a/packages/gitbook/src/output/helper/writeFile.js b/packages/gitbook/src/output/helper/writeFile.js
new file mode 100644
index 0000000..01a8e68
--- /dev/null
+++ b/packages/gitbook/src/output/helper/writeFile.js
@@ -0,0 +1,23 @@
+const path = require('path');
+const 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) {
+ const 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/src/output/index.js b/packages/gitbook/src/output/index.js
new file mode 100644
index 0000000..574b3df
--- /dev/null
+++ b/packages/gitbook/src/output/index.js
@@ -0,0 +1,24 @@
+const Immutable = require('immutable');
+
+const 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
+};
diff --git a/packages/gitbook/src/output/json/index.js b/packages/gitbook/src/output/json/index.js
new file mode 100644
index 0000000..361da06
--- /dev/null
+++ b/packages/gitbook/src/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/src/output/json/onFinish.js b/packages/gitbook/src/output/json/onFinish.js
new file mode 100644
index 0000000..24f5159
--- /dev/null
+++ b/packages/gitbook/src/output/json/onFinish.js
@@ -0,0 +1,48 @@
+const path = require('path');
+
+const Promise = require('../../utils/promise');
+const fs = require('../../utils/fs');
+const JSONUtils = require('../../json');
+
+/**
+ * Finish the generation
+ *
+ * @param {Output}
+ * @return {Output}
+ */
+function onFinish(output) {
+ const book = output.getBook();
+ const outputRoot = output.getRoot();
+ const urls = output.getURLIndex();
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ // Get main language
+ const languages = book.getLanguages();
+ const mainLanguage = languages.getDefaultLanguage();
+
+ // Read the main JSON
+ return fs.readFile(path.resolve(outputRoot, mainLanguage.getID(), 'README.json'), 'utf8')
+
+ // Extend the JSON
+ .then(function(content) {
+ const json = JSON.parse(content);
+
+ json.languages = JSONUtils.encodeLanguages(languages, urls);
+
+ 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/src/output/json/onPage.js b/packages/gitbook/src/output/json/onPage.js
new file mode 100644
index 0000000..f31fadc
--- /dev/null
+++ b/packages/gitbook/src/output/json/onPage.js
@@ -0,0 +1,43 @@
+const JSONUtils = require('../../json');
+const PathUtils = require('../../utils/path');
+const Modifiers = require('../modifiers');
+const writeFile = require('../helper/writeFile');
+const getModifiers = require('../getModifiers');
+
+const JSON_VERSION = '3';
+
+/**
+ * Write a page as a json file
+ *
+ * @param {Output} output
+ * @param {Page} page
+ */
+function onPage(output, page) {
+ const file = page.getFile();
+ const readme = output.getBook().getReadme().getFile();
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the JSON
+ const json = JSONUtils.encodeState(output, resultPage);
+
+ // Delete some private properties
+ delete json.config;
+
+ // Specify JSON output version
+ json.version = JSON_VERSION;
+
+ // File path in the output folder
+ let 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/src/output/json/options.js b/packages/gitbook/src/output/json/options.js
new file mode 100644
index 0000000..2a9de0e
--- /dev/null
+++ b/packages/gitbook/src/output/json/options.js
@@ -0,0 +1,8 @@
+const Immutable = require('immutable');
+
+const Options = Immutable.Record({
+ // Root folder for the output
+ root: String()
+});
+
+module.exports = Options;
diff --git a/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js
new file mode 100644
index 0000000..4d77e75
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/addHeadingId.js
@@ -0,0 +1,25 @@
+const cheerio = require('cheerio');
+const addHeadingId = require('../addHeadingId');
+
+describe('addHeadingId', function() {
+ it('should add an ID if none', function() {
+ const $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>');
+
+ return addHeadingId($)
+ .then(function() {
+ const html = $.html();
+ expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>');
+ });
+ });
+
+ it('should not change existing IDs', function() {
+ const $ = cheerio.load('<h1 id="awesome">Hello World</h1>');
+
+ return addHeadingId($)
+ .then(function() {
+ const html = $.html();
+ expect(html).toBe('<h1 id="awesome">Hello World</h1>');
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/output/modifiers/__tests__/annotateText.js b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js
new file mode 100644
index 0000000..28a5cc5
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/annotateText.js
@@ -0,0 +1,45 @@
+const Immutable = require('immutable');
+const cheerio = require('cheerio');
+const GlossaryEntry = require('../../../models/glossaryEntry');
+const annotateText = require('../annotateText');
+
+describe('annotateText', function() {
+ const entries = Immutable.List([
+ GlossaryEntry({ name: 'Word' }),
+ GlossaryEntry({ name: 'Multiple Words' })
+ ]);
+
+ it('should annotate text', function() {
+ const $ = cheerio.load('<p>This is a word, and multiple words</p>');
+
+ annotateText(entries, 'GLOSSARY.md', $);
+
+ const links = $('a');
+ expect(links.length).toBe(2);
+
+ const word = $(links.get(0));
+ expect(word.attr('href')).toBe('/GLOSSARY.md#word');
+ expect(word.text()).toBe('word');
+ expect(word.hasClass('glossary-term')).toBeTruthy();
+
+ const 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() {
+ const $ = 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() {
+ const $ = 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/src/output/modifiers/__tests__/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js
new file mode 100644
index 0000000..9145cae
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/fetchRemoteImages.js
@@ -0,0 +1,39 @@
+const cheerio = require('cheerio');
+const tmp = require('tmp');
+const path = require('path');
+
+const URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png';
+
+describe('fetchRemoteImages', function() {
+ let dir;
+ const fetchRemoteImages = require('../fetchRemoteImages');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should download image file', function() {
+ const $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'index.html', $)
+ .then(function() {
+ const $img = $('img');
+ const src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+
+ it('should download image file and replace with relative path', function() {
+ const $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'test/index.html', $)
+ .then(function() {
+ const $img = $('img');
+ const src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(path.join('test', src));
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js
new file mode 100644
index 0000000..fd031b0
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/inlinePng.js
@@ -0,0 +1,24 @@
+const cheerio = require('cheerio');
+const tmp = require('tmp');
+const inlinePng = require('../inlinePng');
+
+describe('inlinePng', function() {
+ let dir;
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write an inline PNG using data URI as a file', function() {
+ const $ = 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() {
+ const $img = $('img');
+ const src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js
new file mode 100644
index 0000000..d11a31f
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/resolveLinks.js
@@ -0,0 +1,34 @@
+const cheerio = require('cheerio');
+const resolveLinks = require('../resolveLinks');
+
+describe('resolveLinks', () => {
+ function resolveFileBasic(href) {
+ return 'fakeDir/' + href;
+ }
+
+ it('should resolve path using resolver', () => {
+ const TEST = '<p>This is a <a href="test/cool.md"></a></p>';
+ const $ = cheerio.load(TEST);
+
+ return resolveLinks(resolveFileBasic, $)
+ .then(function() {
+ const link = $('a');
+ expect(link.attr('href')).toBe('fakeDir/test/cool.md');
+ });
+ });
+
+ describe('External link', () => {
+ const TEST = '<p>This is a <a href="http://www.github.com">external link</a></p>';
+
+ it('should have target="_blank" attribute', () => {
+ const $ = cheerio.load(TEST);
+
+ return resolveLinks(resolveFileBasic, $)
+ .then(function() {
+ const link = $('a');
+ expect(link.attr('target')).toBe('_blank');
+ });
+ });
+ });
+
+});
diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js
new file mode 100644
index 0000000..4bdab59
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/svgToImg.js
@@ -0,0 +1,24 @@
+const cheerio = require('cheerio');
+const tmp = require('tmp');
+
+describe('svgToImg', function() {
+ let dir;
+ const svgToImg = require('../svgToImg');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write svg as a file', function() {
+ const $ = 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() {
+ const $img = $('img');
+ const src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js
new file mode 100644
index 0000000..0a12938
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/__tests__/svgToPng.js
@@ -0,0 +1,32 @@
+const cheerio = require('cheerio');
+const tmp = require('tmp');
+const path = require('path');
+
+const svgToImg = require('../svgToImg');
+const svgToPng = require('../svgToPng');
+
+describe('svgToPng', function() {
+ let dir;
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ it('should write svg as png file', function() {
+ const $ = 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>');
+ const fileName = 'index.html';
+
+ return svgToImg(dir.name, fileName, $)
+ .then(function() {
+ return svgToPng(dir.name, fileName, $);
+ })
+ .then(function() {
+ const $img = $('img');
+ const src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ expect(path.extname(src)).toBe('.png');
+ });
+ });
+});
+
diff --git a/packages/gitbook/src/output/modifiers/addHeadingId.js b/packages/gitbook/src/output/modifiers/addHeadingId.js
new file mode 100644
index 0000000..e528b9d
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/addHeadingId.js
@@ -0,0 +1,21 @@
+const slug = require('github-slugid');
+const 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/src/output/modifiers/annotateText.js b/packages/gitbook/src/output/modifiers/annotateText.js
new file mode 100644
index 0000000..36ee4e9
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/annotateText.js
@@ -0,0 +1,91 @@
+const escape = require('escape-html');
+
+// Selector to ignore
+const 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() {
+ let node = this.firstChild, val, new_val;
+ // Elements to be removed at the end.
+ const 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) {
+ const entryId = entry.getID();
+ const name = entry.getName();
+ const description = entry.getDescription();
+ const searchRegex = new RegExp('\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi');
+
+ $('*').each(function() {
+ const $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/src/output/modifiers/editHTMLElement.js b/packages/gitbook/src/output/modifiers/editHTMLElement.js
new file mode 100644
index 0000000..d0d2b19
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/editHTMLElement.js
@@ -0,0 +1,15 @@
+const Promise = require('../../utils/promise');
+
+/**
+ Edit all elements matching a selector
+*/
+function editHTMLElement($, selector, fn) {
+ const $elements = $(selector);
+
+ return Promise.forEach($elements, function(el) {
+ const $el = $(el);
+ return fn($el);
+ });
+}
+
+module.exports = editHTMLElement;
diff --git a/packages/gitbook/src/output/modifiers/fetchRemoteImages.js b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js
new file mode 100644
index 0000000..f022093
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/fetchRemoteImages.js
@@ -0,0 +1,44 @@
+const path = require('path');
+const crc = require('crc');
+
+const editHTMLElement = require('./editHTMLElement');
+const fs = require('../../utils/fs');
+const LocationUtils = require('../../utils/location');
+
+/**
+ * Fetch all remote images
+ *
+ * @param {String} rootFolder
+ * @param {String} currentFile
+ * @param {HTMLDom} $
+ * @return {Promise}
+ */
+function fetchRemoteImages(rootFolder, currentFile, $) {
+ const currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ let src = $img.attr('src');
+ const extension = path.extname(src);
+
+ if (!LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ const hash = crc.crc32(src).toString(16);
+ const fileName = hash + extension;
+ const 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/src/output/modifiers/index.js b/packages/gitbook/src/output/modifiers/index.js
new file mode 100644
index 0000000..5f290f6
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/index.js
@@ -0,0 +1,14 @@
+
+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')
+};
diff --git a/packages/gitbook/src/output/modifiers/inlineAssets.js b/packages/gitbook/src/output/modifiers/inlineAssets.js
new file mode 100644
index 0000000..4541fcc
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/inlineAssets.js
@@ -0,0 +1,29 @@
+const svgToImg = require('./svgToImg');
+const svgToPng = require('./svgToPng');
+const inlinePng = require('./inlinePng');
+const resolveImages = require('./resolveImages');
+const fetchRemoteImages = require('./fetchRemoteImages');
+
+const 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/src/output/modifiers/inlinePng.js b/packages/gitbook/src/output/modifiers/inlinePng.js
new file mode 100644
index 0000000..bf14e4f
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/inlinePng.js
@@ -0,0 +1,46 @@
+const crc = require('crc');
+const path = require('path');
+
+const imagesUtil = require('../../utils/images');
+const fs = require('../../utils/fs');
+const LocationUtils = require('../../utils/location');
+
+const editHTMLElement = require('./editHTMLElement');
+
+/**
+ * Convert all inline PNG images to PNG file
+ *
+ * @param {String} rootFolder
+ * @param {HTMLDom} $
+ * @return {Promise}
+ */
+function inlinePng(rootFolder, currentFile, $) {
+ const currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ const src = $img.attr('src');
+ if (!LocationUtils.isDataURI(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ const hash = crc.crc32(src).toString(16);
+ let fileName = hash + '.png';
+
+ // Result file path
+ const 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/src/output/modifiers/modifyHTML.js b/packages/gitbook/src/output/modifiers/modifyHTML.js
new file mode 100644
index 0000000..64abd07
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/modifyHTML.js
@@ -0,0 +1,25 @@
+const cheerio = require('cheerio');
+const Promise = require('../../utils/promise');
+
+/**
+ * Apply a list of operations to a page and
+ * output the new page.
+ *
+ * @param {Page} page
+ * @param {List|Array<Transformation>} operations
+ * @return {Promise<Page>} page
+ */
+function modifyHTML(page, operations) {
+ const html = page.getContent();
+ const $ = cheerio.load(html);
+
+ return Promise.forEach(operations, function(op) {
+ return op($);
+ })
+ .then(function() {
+ const resultHTML = $.html();
+ return page.set('content', resultHTML);
+ });
+}
+
+module.exports = modifyHTML;
diff --git a/packages/gitbook/src/output/modifiers/resolveImages.js b/packages/gitbook/src/output/modifiers/resolveImages.js
new file mode 100644
index 0000000..c647fde
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/resolveImages.js
@@ -0,0 +1,33 @@
+const path = require('path');
+
+const LocationUtils = require('../../utils/location');
+const editHTMLElement = require('./editHTMLElement');
+
+/**
+ * Resolve all HTML images:
+ * - /test.png in hello -> ../test.html
+ *
+ * @param {String} currentFile
+ * @param {HTMLDom} $
+ */
+function resolveImages(currentFile, $) {
+ const currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ let 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/src/output/modifiers/resolveLinks.js b/packages/gitbook/src/output/modifiers/resolveLinks.js
new file mode 100644
index 0000000..ca81ccb
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/resolveLinks.js
@@ -0,0 +1,30 @@
+const LocationUtils = require('../../utils/location');
+const editHTMLElement = require('./editHTMLElement');
+
+/**
+ * Resolve all HTML links:
+ * - /test.md in hello -> ../test.html
+ *
+ * @param {Function(String) -> String} resolveURL
+ * @param {HTMLDom} $
+ */
+function resolveLinks(resolveURL, $) {
+ return editHTMLElement($, 'a', function($a) {
+ let href = $a.attr('href');
+
+ // Don't change a tag without href
+ if (!href) {
+ return;
+ }
+
+ if (LocationUtils.isExternal(href)) {
+ $a.attr('target', '_blank');
+ return;
+ }
+
+ href = resolveURL(href);
+ $a.attr('href', href);
+ });
+}
+
+module.exports = resolveLinks;
diff --git a/packages/gitbook/src/output/modifiers/svgToImg.js b/packages/gitbook/src/output/modifiers/svgToImg.js
new file mode 100644
index 0000000..ac37d07
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/svgToImg.js
@@ -0,0 +1,56 @@
+const path = require('path');
+const crc = require('crc');
+const domSerializer = require('dom-serializer');
+
+const editHTMLElement = require('./editHTMLElement');
+const fs = require('../../utils/fs');
+const 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, $) {
+ const currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'svg', function($svg) {
+ const content = '<?xml version="1.0" encoding="UTF-8"?>' +
+ renderDOM($, $svg);
+
+ // We avoid generating twice the same PNG
+ const hash = crc.crc32(content).toString(16);
+ const fileName = hash + '.svg';
+ const 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() {
+ const src = LocationUtils.relative(currentDirectory, fileName);
+ $svg.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = svgToImg;
diff --git a/packages/gitbook/src/output/modifiers/svgToPng.js b/packages/gitbook/src/output/modifiers/svgToPng.js
new file mode 100644
index 0000000..ad3f31f
--- /dev/null
+++ b/packages/gitbook/src/output/modifiers/svgToPng.js
@@ -0,0 +1,53 @@
+const crc = require('crc');
+const path = require('path');
+
+const imagesUtil = require('../../utils/images');
+const fs = require('../../utils/fs');
+const LocationUtils = require('../../utils/location');
+
+const editHTMLElement = require('./editHTMLElement');
+
+/**
+ Convert all SVG images to PNG
+
+ @param {String} rootFolder
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function svgToPng(rootFolder, currentFile, $) {
+ const currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ let 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
+ const hash = crc.crc32(src).toString(16);
+ let fileName = hash + '.png';
+
+ // Input file path
+ const inputPath = path.join(rootFolder, src);
+
+ // Result file path
+ const 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/src/output/prepareAssets.js b/packages/gitbook/src/output/prepareAssets.js
new file mode 100644
index 0000000..2851b01
--- /dev/null
+++ b/packages/gitbook/src/output/prepareAssets.js
@@ -0,0 +1,22 @@
+const Parse = require('../parse');
+
+/**
+ * List all assets in the book.
+ *
+ * @param {Output} output
+ * @return {Promise<Output>} output
+ */
+function prepareAssets(output) {
+ const book = output.getBook();
+ const pages = output.getPages();
+ const 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/src/output/preparePages.js b/packages/gitbook/src/output/preparePages.js
new file mode 100644
index 0000000..0cf1412
--- /dev/null
+++ b/packages/gitbook/src/output/preparePages.js
@@ -0,0 +1,35 @@
+const Parse = require('../parse');
+const Promise = require('../utils/promise');
+const parseURIIndexFromPages = require('../parse/parseURIIndexFromPages');
+
+/**
+ * List and parse all pages, then create the urls mapping.
+ *
+ * @param {Output}
+ * @return {Promise<Output>}
+ */
+function preparePages(output) {
+ const book = output.getBook();
+ const logger = book.getLogger();
+ const readme = book.getReadme();
+
+ if (book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ return Parse.parsePagesList(book)
+ .then((pages) => {
+ logger.info.ln('found', pages.size, 'pages');
+ let urls = parseURIIndexFromPages(pages);
+
+ // Readme should always generate an index.html
+ urls = urls.append(readme.getFile().getPath(), 'index.html');
+
+ return output.merge({
+ pages,
+ urls
+ });
+ });
+}
+
+module.exports = preparePages;
diff --git a/packages/gitbook/src/output/preparePlugins.js b/packages/gitbook/src/output/preparePlugins.js
new file mode 100644
index 0000000..c84bade
--- /dev/null
+++ b/packages/gitbook/src/output/preparePlugins.js
@@ -0,0 +1,36 @@
+const Plugins = require('../plugins');
+const Promise = require('../utils/promise');
+
+/**
+ * Load and setup plugins
+ *
+ * @param {Output}
+ * @return {Promise<Output>}
+ */
+function preparePlugins(output) {
+ const 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
+ });
+ });
+ });
+}
+
+module.exports = preparePlugins;
diff --git a/packages/gitbook/src/output/website/copyPluginAssets.js b/packages/gitbook/src/output/website/copyPluginAssets.js
new file mode 100644
index 0000000..fe75377
--- /dev/null
+++ b/packages/gitbook/src/output/website/copyPluginAssets.js
@@ -0,0 +1,111 @@
+const path = require('path');
+
+const ASSET_FOLDER = require('../../constants/pluginAssetsFolder');
+const Promise = require('../../utils/promise');
+const fs = require('../../utils/fs');
+
+/**
+ * Copy all assets from plugins.
+ * Assets are files stored in a "_assets" of the plugin.
+ *
+ * @param {Output}
+ * @return {Promise}
+ */
+function copyPluginAssets(output) {
+ const 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);
+ }
+
+ const plugins = output.getPlugins();
+
+ return Promise.forEach(plugins, (plugin) => {
+ return copyAssets(output, plugin)
+ .then(() => copyBrowserJS(output, plugin));
+ })
+ .then(() => copyCoreJS(output))
+ .thenResolve(output);
+}
+
+/**
+ * Copy assets from a plugin
+ *
+ * @param {Plugin}
+ * @return {Promise}
+ */
+function copyAssets(output, plugin) {
+ const logger = output.getLogger();
+ const pluginRoot = plugin.getPath();
+ const options = output.getOptions();
+
+ const outputRoot = options.get('root');
+ const prefix = options.get('prefix');
+
+ const assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix);
+ const assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getName());
+
+ if (!fs.existsSync(assetFolder)) {
+ return Promise();
+ }
+
+ logger.debug.ln('copy assets from plugin', assetFolder);
+ return fs.copyDir(
+ assetFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+/**
+ * Copy JS file for the plugin
+ *
+ * @param {Plugin}
+ * @return {Promise}
+ */
+function copyBrowserJS(output, plugin) {
+ const logger = output.getLogger();
+ const pluginRoot = plugin.getPath();
+ const options = output.getOptions();
+ const outputRoot = options.get('root');
+
+ let browserFile = plugin.getPackage().get('browser');
+
+ if (!browserFile) {
+ return Promise();
+ }
+
+ browserFile = path.join(pluginRoot, browserFile);
+ const outputFile = path.join(outputRoot, 'gitbook/plugins', plugin.getName() + '.js');
+
+ logger.debug.ln('copy browser JS file from plugin', browserFile);
+ return fs.ensureFile(outputFile)
+ .then(() => fs.copy(browserFile, outputFile));
+}
+
+/**
+ * Copy JS file for gitbook-core
+ *
+ * @param {Plugin}
+ * @return {Promise}
+ */
+function copyCoreJS(output) {
+ const logger = output.getLogger();
+ const options = output.getOptions();
+ const outputRoot = options.get('root');
+
+ const inputFile = require.resolve('gitbook-core/dist/gitbook.core.min.js');
+ const outputFile = path.join(outputRoot, 'gitbook/core.js');
+
+ logger.debug.ln('copy JS for gitbook-core');
+ return fs.ensureFile(outputFile)
+ .then(() => fs.copy(inputFile, outputFile));
+}
+
+module.exports = copyPluginAssets;
diff --git a/packages/gitbook/src/output/website/index.js b/packages/gitbook/src/output/website/index.js
new file mode 100644
index 0000000..c6031e1
--- /dev/null
+++ b/packages/gitbook/src/output/website/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ name: 'website',
+ State: require('./state'),
+ Options: require('./options'),
+ onInit: require('./onInit'),
+ onFinish: require('./onFinish'),
+ onPage: require('./onPage'),
+ onAsset: require('./onAsset')
+};
diff --git a/packages/gitbook/src/output/website/onAsset.js b/packages/gitbook/src/output/website/onAsset.js
new file mode 100644
index 0000000..b72c47d
--- /dev/null
+++ b/packages/gitbook/src/output/website/onAsset.js
@@ -0,0 +1,29 @@
+const path = require('path');
+const fs = require('../../utils/fs');
+
+/**
+ * Copy an asset from the book to the output folder.
+ *
+ * @param {Output} output
+ * @param {Page} page
+ * @return {Output} output
+ */
+function onAsset(output, asset) {
+ const book = output.getBook();
+ const options = output.getOptions();
+ const bookFS = book.getContentFS();
+
+ const outputFolder = options.get('root');
+ const 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/src/output/website/onFinish.js b/packages/gitbook/src/output/website/onFinish.js
new file mode 100644
index 0000000..6efeed8
--- /dev/null
+++ b/packages/gitbook/src/output/website/onFinish.js
@@ -0,0 +1,30 @@
+const JSONUtils = require('../../json');
+const Promise = require('../../utils/promise');
+const writeFile = require('../helper/writeFile');
+const render = require('../../browser/render');
+
+/**
+ * Finish the generation, write the languages index.
+ *
+ * @param {Output}
+ * @return {Output}
+ */
+function onFinish(output) {
+ const book = output.getBook();
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ const plugins = output.getPlugins();
+
+ // Generate initial state
+ const initialState = JSONUtils.encodeState(output);
+
+ // Render using React
+ const html = render(plugins, initialState, 'browser', 'website:languages');
+
+ return writeFile(output, 'index.html', html);
+}
+
+module.exports = onFinish;
diff --git a/packages/gitbook/src/output/website/onInit.js b/packages/gitbook/src/output/website/onInit.js
new file mode 100644
index 0000000..b13c719
--- /dev/null
+++ b/packages/gitbook/src/output/website/onInit.js
@@ -0,0 +1,15 @@
+const Promise = require('../../utils/promise');
+const copyPluginAssets = require('./copyPluginAssets');
+
+/**
+ * Initialize the generator
+ *
+ * @param {Output}
+ * @return {Output}
+ */
+function onInit(output) {
+ return Promise(output)
+ .then(copyPluginAssets);
+}
+
+module.exports = onInit;
diff --git a/packages/gitbook/src/output/website/onPage.js b/packages/gitbook/src/output/website/onPage.js
new file mode 100644
index 0000000..90eec63
--- /dev/null
+++ b/packages/gitbook/src/output/website/onPage.js
@@ -0,0 +1,34 @@
+const JSONUtils = require('../../json');
+const Modifiers = require('../modifiers');
+const writeFile = require('../helper/writeFile');
+const getModifiers = require('../getModifiers');
+const render = require('../../browser/render');
+
+/**
+ * Generate a page using react and the plugins.
+ *
+ * @param {Output} output
+ * @param {Page} page
+ */
+function onPage(output, page) {
+ const file = page.getFile();
+ const plugins = output.getPlugins();
+ const urls = output.getURLIndex();
+
+ // Output file path
+ const filePath = urls.resolve(file.getPath());
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the context
+ const initialState = JSONUtils.encodeState(output, resultPage);
+
+ // Render the theme
+ const html = render(plugins, initialState, 'browser', 'website:body');
+
+ // Write it to the disk
+ return writeFile(output, filePath, html);
+ });
+}
+
+module.exports = onPage;
diff --git a/packages/gitbook/src/output/website/options.js b/packages/gitbook/src/output/website/options.js
new file mode 100644
index 0000000..3bcbd9a
--- /dev/null
+++ b/packages/gitbook/src/output/website/options.js
@@ -0,0 +1,10 @@
+const Immutable = require('immutable');
+
+const Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+ // Prefix for generation
+ prefix: String('website')
+});
+
+module.exports = Options;
diff --git a/packages/gitbook/src/output/website/state.js b/packages/gitbook/src/output/website/state.js
new file mode 100644
index 0000000..2adb9ed
--- /dev/null
+++ b/packages/gitbook/src/output/website/state.js
@@ -0,0 +1,18 @@
+const I18n = require('i18n-t');
+const Immutable = require('immutable');
+
+const 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/src/parse/__tests__/listAssets.js b/packages/gitbook/src/parse/__tests__/listAssets.js
new file mode 100644
index 0000000..102aed9
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/listAssets.js
@@ -0,0 +1,29 @@
+const Immutable = require('immutable');
+
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+const listAssets = require('../listAssets');
+const parseGlossary = require('../parseGlossary');
+
+describe('listAssets', function() {
+ it('should not list glossary as asset', function() {
+ const fs = createMockFS({
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello',
+ 'assetFile.js': '',
+ 'assets': {
+ 'file.js': ''
+ }
+ });
+ const 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/src/parse/__tests__/parseBook.js b/packages/gitbook/src/parse/__tests__/parseBook.js
new file mode 100644
index 0000000..d5de25c
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseBook.js
@@ -0,0 +1,90 @@
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+
+describe('parseBook', function() {
+ const parseBook = require('../parseBook');
+
+ it('should parse multilingual book', function() {
+ const fs = createMockFS({
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ });
+ const book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ const languages = resultBook.getLanguages();
+ const 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() {
+ const 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'
+ }
+ });
+ const book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ const books = resultBook.getBooks();
+
+ expect(resultBook.isMultilingual()).toBe(true);
+ expect(books.size).toBe(2);
+
+ const en = books.get('en');
+ const fr = books.get('fr');
+
+ const enConfig = en.getConfig();
+ const 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() {
+ const 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'
+ }
+ });
+ const book = Book.createForFS(fs);
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ const readme = resultBook.getReadme();
+ const summary = resultBook.getSummary();
+ const 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/src/parse/__tests__/parseGlossary.js b/packages/gitbook/src/parse/__tests__/parseGlossary.js
new file mode 100644
index 0000000..ba2e407
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseGlossary.js
@@ -0,0 +1,36 @@
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+
+describe('parseGlossary', function() {
+ const parseGlossary = require('../parseGlossary');
+
+ it('should parse glossary if exists', function() {
+ const fs = createMockFS({
+ 'GLOSSARY.md': '# Glossary\n\n## Hello\nDescription for hello'
+ });
+ const book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ const glossary = resultBook.getGlossary();
+ const file = glossary.getFile();
+ const entries = glossary.getEntries();
+
+ expect(file.exists()).toBeTruthy();
+ expect(entries.size).toBe(1);
+ });
+ });
+
+ it('should not fail if doesn\'t exist', function() {
+ const fs = createMockFS({});
+ const book = Book.createForFS(fs);
+
+ return parseGlossary(book)
+ .then(function(resultBook) {
+ const glossary = resultBook.getGlossary();
+ const file = glossary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/gitbook/src/parse/__tests__/parseIgnore.js b/packages/gitbook/src/parse/__tests__/parseIgnore.js
new file mode 100644
index 0000000..b1bd43c
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseIgnore.js
@@ -0,0 +1,40 @@
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+
+describe('parseIgnore', function() {
+ const parseIgnore = require('../parseIgnore');
+ const 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() {
+ const 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/src/parse/__tests__/parsePageFromString.js b/packages/gitbook/src/parse/__tests__/parsePageFromString.js
new file mode 100644
index 0000000..13bc544
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parsePageFromString.js
@@ -0,0 +1,37 @@
+const parsePageFromString = require('../parsePageFromString');
+const Page = require('../../models/page');
+
+describe('parsePageFromString', function() {
+ const page = new Page();
+
+ it('should parse YAML frontmatter', function() {
+ const CONTENT = '---\nhello: true\nworld: "cool"\n---\n# Hello World\n';
+ const newPage = parsePageFromString(page, CONTENT);
+
+ expect(newPage.getDir()).toBe('ltr');
+ expect(newPage.getContent()).toBe('# Hello World\n');
+
+ const 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() {
+ const CONTENT = 'Hello World';
+ const 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() {
+ const CONTENT = 'مرحبا بالعالم';
+ const newPage = parsePageFromString(page, CONTENT);
+
+ expect(newPage.getDir()).toBe('rtl');
+ expect(newPage.getContent()).toBe('مرحبا بالعالم');
+ expect(newPage.getAttributes().size).toBe(0);
+ });
+});
diff --git a/packages/gitbook/src/parse/__tests__/parseReadme.js b/packages/gitbook/src/parse/__tests__/parseReadme.js
new file mode 100644
index 0000000..45ecfa3
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseReadme.js
@@ -0,0 +1,36 @@
+const Promise = require('../../utils/promise');
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+
+describe('parseReadme', function() {
+ const parseReadme = require('../parseReadme');
+
+ it('should parse summary if exists', function() {
+ const fs = createMockFS({
+ 'README.md': '# Hello\n\nAnd here is the description.'
+ });
+ const book = Book.createForFS(fs);
+
+ return parseReadme(book)
+ .then(function(resultBook) {
+ const readme = resultBook.getReadme();
+ const 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() {
+ const fs = createMockFS({});
+ const 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/src/parse/__tests__/parseSummary.js b/packages/gitbook/src/parse/__tests__/parseSummary.js
new file mode 100644
index 0000000..8b86c45
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseSummary.js
@@ -0,0 +1,34 @@
+const Book = require('../../models/book');
+const createMockFS = require('../../fs/mock');
+
+describe('parseSummary', function() {
+ const parseSummary = require('../parseSummary');
+
+ it('should parse summary if exists', function() {
+ const fs = createMockFS({
+ 'SUMMARY.md': '# Summary\n\n* [Hello](hello.md)'
+ });
+ const book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ const summary = resultBook.getSummary();
+ const file = summary.getFile();
+
+ expect(file.exists()).toBeTruthy();
+ });
+ });
+
+ it('should not fail if doesn\'t exist', function() {
+ const fs = createMockFS({});
+ const book = Book.createForFS(fs);
+
+ return parseSummary(book)
+ .then(function(resultBook) {
+ const summary = resultBook.getSummary();
+ const file = summary.getFile();
+
+ expect(file.exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js b/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js
new file mode 100644
index 0000000..755b225
--- /dev/null
+++ b/packages/gitbook/src/parse/__tests__/parseURIIndexFromPages.js
@@ -0,0 +1,26 @@
+const { OrderedMap } = require('immutable');
+
+const parseURIIndexFromPages = require('../parseURIIndexFromPages');
+const Page = require('../../models/page');
+
+describe('parseURIIndexFromPages', () => {
+
+ it('should map file to html', () => {
+ const pages = OrderedMap({
+ 'page.md': new Page()
+ });
+ const urls = parseURIIndexFromPages(pages);
+
+ expect(urls.resolve('page.md')).toBe('page.html');
+ });
+
+ it('should map README to folder', () => {
+ const pages = OrderedMap({
+ 'hello/README.md': new Page()
+ });
+ const urls = parseURIIndexFromPages(pages);
+
+ expect(urls.resolveToURL('hello/README.md')).toBe('hello/');
+ });
+
+});
diff --git a/packages/gitbook/src/parse/findParsableFile.js b/packages/gitbook/src/parse/findParsableFile.js
new file mode 100644
index 0000000..c30dbbd
--- /dev/null
+++ b/packages/gitbook/src/parse/findParsableFile.js
@@ -0,0 +1,36 @@
+const path = require('path');
+
+const Promise = require('../utils/promise');
+const 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) {
+ const fs = book.getContentFS();
+ const ext = path.extname(filename);
+ const basename = path.basename(filename, ext);
+ const basedir = path.dirname(filename);
+
+ // Ordered list of extensions to test
+ const exts = parsers.extensions;
+
+ return Promise.some(exts, function(ext) {
+ const 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/src/parse/index.js b/packages/gitbook/src/parse/index.js
new file mode 100644
index 0000000..1f73946
--- /dev/null
+++ b/packages/gitbook/src/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/src/parse/listAssets.js b/packages/gitbook/src/parse/listAssets.js
new file mode 100644
index 0000000..91699df
--- /dev/null
+++ b/packages/gitbook/src/parse/listAssets.js
@@ -0,0 +1,43 @@
+const 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
+ * @return {Promise<List<String>>} assets
+ */
+function listAssets(book, pages) {
+ const fs = book.getContentFS();
+
+ const summary = book.getSummary();
+ const summaryFile = summary.getFile().getPath();
+
+ const glossary = book.getGlossary();
+ const glossaryFile = glossary.getFile().getPath();
+
+ const langs = book.getLanguages();
+ const langsFile = langs.getFile().getPath();
+
+ const config = book.getConfig();
+ const 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/src/parse/lookupStructureFile.js b/packages/gitbook/src/parse/lookupStructureFile.js
new file mode 100644
index 0000000..e54a769
--- /dev/null
+++ b/packages/gitbook/src/parse/lookupStructureFile.js
@@ -0,0 +1,20 @@
+const 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) {
+ const config = book.getConfig();
+
+ const fileToSearch = config.getValue(['structure', type]);
+
+ return findParsableFile(book, fileToSearch);
+}
+
+module.exports = lookupStructureFile;
diff --git a/packages/gitbook/src/parse/parseBook.js b/packages/gitbook/src/parse/parseBook.js
new file mode 100644
index 0000000..e5c1784
--- /dev/null
+++ b/packages/gitbook/src/parse/parseBook.js
@@ -0,0 +1,77 @@
+const Promise = require('../utils/promise');
+const timing = require('../utils/timing');
+const Book = require('../models/book');
+
+const parseIgnore = require('./parseIgnore');
+const parseConfig = require('./parseConfig');
+const parseGlossary = require('./parseGlossary');
+const parseSummary = require('./parseSummary');
+const parseReadme = require('./parseReadme');
+const 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) {
+ const languages = book.getLanguages();
+ const langList = languages.getList();
+
+ return Promise.reduce(langList, function(currentBook, lang) {
+ const langID = lang.getID();
+ const child = Book.createFromParent(currentBook, langID);
+ let 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/src/parse/parseConfig.js b/packages/gitbook/src/parse/parseConfig.js
new file mode 100644
index 0000000..cd27426
--- /dev/null
+++ b/packages/gitbook/src/parse/parseConfig.js
@@ -0,0 +1,55 @@
+const Promise = require('../utils/promise');
+
+const validateConfig = require('./validateConfig');
+const CONFIG_FILES = require('../constants/configFiles');
+
+/**
+ Parse configuration from "book.json" or "book.js"
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseConfig(book) {
+ const fs = book.getFS();
+ let 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,
+ values: cfg
+ };
+ });
+ })
+ .fail(function(err) {
+ if (err.code != 'MODULE_NOT_FOUND') throw (err);
+ else return Promise(false);
+ });
+ })
+
+ .then(function(result) {
+ let 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/src/parse/parseGlossary.js b/packages/gitbook/src/parse/parseGlossary.js
new file mode 100644
index 0000000..052985b
--- /dev/null
+++ b/packages/gitbook/src/parse/parseGlossary.js
@@ -0,0 +1,26 @@
+const parseStructureFile = require('./parseStructureFile');
+const Glossary = require('../models/glossary');
+
+/**
+ Parse glossary
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseGlossary(book) {
+ const 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());
+
+ const glossary = Glossary.createFromEntries(file, entries);
+ return book.set('glossary', glossary);
+ });
+}
+
+module.exports = parseGlossary;
diff --git a/packages/gitbook/src/parse/parseIgnore.js b/packages/gitbook/src/parse/parseIgnore.js
new file mode 100644
index 0000000..a42805b
--- /dev/null
+++ b/packages/gitbook/src/parse/parseIgnore.js
@@ -0,0 +1,54 @@
+const Promise = require('../utils/promise');
+const IGNORE_FILES = require('../constants/ignoreFiles');
+
+const 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} book
+ * @return {Book} book
+ */
+function parseIgnore(book) {
+ if (book.isLanguageBook()) {
+ return Promise.reject(new Error('Ignore files could be parsed for language books'));
+ }
+
+ const fs = book.getFS();
+ let ignore = book.getIgnore();
+
+ ignore = ignore.add(DEFAULT_IGNORES);
+
+ return Promise.serie(IGNORE_FILES, (filename) => {
+ return fs.readAsString(filename)
+ .then(
+ (content) => {
+ ignore = ignore.add(content.toString().split(/\r?\n/));
+ },
+ (err) => {
+ return Promise();
+ }
+ );
+ })
+
+ .then(() => {
+ return book.setIgnore(ignore);
+ });
+}
+
+module.exports = parseIgnore;
diff --git a/packages/gitbook/src/parse/parseLanguages.js b/packages/gitbook/src/parse/parseLanguages.js
new file mode 100644
index 0000000..1b28930
--- /dev/null
+++ b/packages/gitbook/src/parse/parseLanguages.js
@@ -0,0 +1,28 @@
+const parseStructureFile = require('./parseStructureFile');
+const Languages = require('../models/languages');
+
+/**
+ Parse languages list from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseLanguages(book) {
+ const logger = book.getLogger();
+
+ return parseStructureFile(book, 'langs')
+ .spread(function(file, result) {
+ if (!file) {
+ return book;
+ }
+
+ const 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/src/parse/parsePage.js b/packages/gitbook/src/parse/parsePage.js
new file mode 100644
index 0000000..72f9ddf
--- /dev/null
+++ b/packages/gitbook/src/parse/parsePage.js
@@ -0,0 +1,21 @@
+const 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) {
+ const fs = book.getContentFS();
+ const file = page.getFile();
+
+ return fs.readAsString(file.getPath())
+ .then(function(content) {
+ return parsePageFromString(page, content);
+ });
+}
+
+
+module.exports = parsePage;
diff --git a/packages/gitbook/src/parse/parsePageFromString.js b/packages/gitbook/src/parse/parsePageFromString.js
new file mode 100644
index 0000000..2e4a598
--- /dev/null
+++ b/packages/gitbook/src/parse/parsePageFromString.js
@@ -0,0 +1,22 @@
+const Immutable = require('immutable');
+const fm = require('front-matter');
+const direction = require('direction');
+
+/**
+ * Parse a page, its content and the YAMl header
+ *
+ * @param {Page} page
+ * @return {Page}
+ */
+function parsePageFromString(page, content) {
+ const 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/src/parse/parsePagesList.js b/packages/gitbook/src/parse/parsePagesList.js
new file mode 100644
index 0000000..89a1a4f
--- /dev/null
+++ b/packages/gitbook/src/parse/parsePagesList.js
@@ -0,0 +1,97 @@
+const Immutable = require('immutable');
+
+const timing = require('../utils/timing');
+const Page = require('../models/page');
+const walkSummary = require('./walkSummary');
+const parsePage = require('./parsePage');
+
+
+/**
+ * Parse a page from a path
+ *
+ * @param {Book} book
+ * @param {String} filePath
+ * @return {Page?}
+ */
+function parseFilePage(book, filePath) {
+ const fs = book.getContentFS();
+
+ return fs.statFile(filePath)
+ .then(
+ function(file) {
+ const page = Page.createForFile(file);
+ return parsePage(book, page);
+ },
+ function(err) {
+ // file doesn't exist
+ return null;
+ }
+ )
+ .fail(function(err) {
+ const logger = book.getLogger();
+ logger.error.ln('error while parsing page "' + filePath + '":');
+ throw err;
+ });
+}
+
+
+/**
+ * Parse all pages from a book as an OrderedMap
+ *
+ * @param {Book} book
+ * @return {Promise<OrderedMap<Page>>}
+ */
+function parsePagesList(book) {
+ const summary = book.getSummary();
+ const glossary = book.getGlossary();
+ let map = Immutable.OrderedMap();
+
+ // Parse pages from summary
+ return timing.measure(
+ 'parse.listPages',
+ walkSummary(summary, function(article) {
+ if (!article.isPage()) return;
+
+ const filepath = article.getPath();
+
+ // Is the page ignored?
+ if (book.isContentFileIgnored(filepath)) return;
+
+ return parseFilePage(book, filepath)
+ .then(function(page) {
+ // file doesn't exist
+ if (!page) {
+ return;
+ }
+
+ map = map.set(filepath, page);
+ });
+ })
+ )
+
+ // Parse glossary
+ .then(function() {
+ const file = glossary.getFile();
+
+ if (!file.exists()) {
+ return;
+ }
+
+ return parseFilePage(book, file.getPath())
+ .then(function(page) {
+ // file doesn't exist
+ if (!page) {
+ return;
+ }
+
+ map = map.set(file.getPath(), page);
+ });
+ })
+
+ .then(function() {
+ return map;
+ });
+}
+
+
+module.exports = parsePagesList;
diff --git a/packages/gitbook/src/parse/parseReadme.js b/packages/gitbook/src/parse/parseReadme.js
new file mode 100644
index 0000000..82f8f19
--- /dev/null
+++ b/packages/gitbook/src/parse/parseReadme.js
@@ -0,0 +1,28 @@
+const parseStructureFile = require('./parseStructureFile');
+const Readme = require('../models/readme');
+
+const error = require('../utils/error');
+
+/**
+ Parse readme from book
+
+ @param {Book} book
+ @return {Promise<Book>}
+*/
+function parseReadme(book) {
+ const 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());
+
+ const readme = Readme.create(file, result);
+ return book.set('readme', readme);
+ });
+}
+
+module.exports = parseReadme;
diff --git a/packages/gitbook/src/parse/parseStructureFile.js b/packages/gitbook/src/parse/parseStructureFile.js
new file mode 100644
index 0000000..951da96
--- /dev/null
+++ b/packages/gitbook/src/parse/parseStructureFile.js
@@ -0,0 +1,67 @@
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const 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) {
+ const filepath = file.getPath();
+ const 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) {
+ const 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/src/parse/parseSummary.js b/packages/gitbook/src/parse/parseSummary.js
new file mode 100644
index 0000000..9488341
--- /dev/null
+++ b/packages/gitbook/src/parse/parseSummary.js
@@ -0,0 +1,44 @@
+const parseStructureFile = require('./parseStructureFile');
+const Summary = require('../models/summary');
+const 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) {
+ const readme = book.getReadme();
+ const logger = book.getLogger();
+ const readmeFile = readme.getFile();
+
+ return parseStructureFile(book, 'summary')
+ .spread(function(file, result) {
+ let 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
+ const 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/src/parse/parseURIIndexFromPages.js b/packages/gitbook/src/parse/parseURIIndexFromPages.js
new file mode 100644
index 0000000..645d083
--- /dev/null
+++ b/packages/gitbook/src/parse/parseURIIndexFromPages.js
@@ -0,0 +1,44 @@
+const path = require('path');
+const PathUtils = require('../utils/path');
+const LocationUtils = require('../utils/location');
+const URIIndex = require('../models/uriIndex');
+
+const OUTPUT_EXTENSION = '.html';
+
+/**
+ * Convert a filePath (absolute) to an url (without hostname).
+ * It returns an absolute path.
+ *
+ * "README.md" -> "/index.html"
+ * "test/hello.md" -> "test/hello.html"
+ * "test/README.md" -> "test/index.html"
+ *
+ * @param {Output} output
+ * @param {String} filePath
+ * @return {String}
+ */
+function fileToURL(filePath) {
+ if (
+ path.basename(filePath, path.extname(filePath)) == 'README'
+ ) {
+ filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION);
+ } else {
+ filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+/**
+ * Parse a set of pages into an URIIndex.
+ * Each pages is added as an entry in the index.
+ *
+ * @param {OrderedMap<Page>} pages
+ * @return {URIIndex} index
+ */
+function parseURIIndexFromPages(pages) {
+ const urls = pages.map((page, filePath) => fileToURL(filePath));
+ return new URIIndex(urls);
+}
+
+module.exports = parseURIIndexFromPages;
diff --git a/packages/gitbook/src/parse/validateConfig.js b/packages/gitbook/src/parse/validateConfig.js
new file mode 100644
index 0000000..e766fae
--- /dev/null
+++ b/packages/gitbook/src/parse/validateConfig.js
@@ -0,0 +1,31 @@
+const jsonschema = require('jsonschema');
+const jsonSchemaDefaults = require('json-schema-defaults');
+
+const schema = require('../constants/configSchema');
+const error = require('../utils/error');
+const 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) {
+ const v = new jsonschema.Validator();
+ const 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
+ const defaults = jsonSchemaDefaults(schema);
+ return mergeDefaults(bookJson, defaults);
+}
+
+module.exports = validateConfig;
diff --git a/packages/gitbook/src/parse/walkSummary.js b/packages/gitbook/src/parse/walkSummary.js
new file mode 100644
index 0000000..47feb1f
--- /dev/null
+++ b/packages/gitbook/src/parse/walkSummary.js
@@ -0,0 +1,34 @@
+const 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) {
+ const parts = summary.getParts();
+
+ return Promise.forEach(parts, function(part) {
+ return walkArticles(part.getArticles(), fn);
+ });
+}
+
+module.exports = walkSummary;
diff --git a/packages/gitbook/src/parsers.js b/packages/gitbook/src/parsers.js
new file mode 100644
index 0000000..62c3776
--- /dev/null
+++ b/packages/gitbook/src/parsers.js
@@ -0,0 +1,63 @@
+const path = require('path');
+const Immutable = require('immutable');
+
+const markdownParser = require('gitbook-markdown');
+const asciidocParser = require('gitbook-asciidoc');
+
+const EXTENSIONS_MARKDOWN = require('./constants/extsMarkdown');
+const EXTENSIONS_ASCIIDOC = require('./constants/extsAsciidoc');
+const Parser = require('./models/parser');
+
+// This list is ordered by priority of parsers to use
+const 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
+const extensions = parsers
+ .map(function(parser) {
+ return parser.getExtensions();
+ })
+ .flatten();
+
+module.exports = {
+ extensions,
+ get: getParser,
+ getByExt: getParserByExt,
+ getForFile: getParserForFile
+};
diff --git a/packages/gitbook/src/plugins/__tests__/findForBook.js b/packages/gitbook/src/plugins/__tests__/findForBook.js
new file mode 100644
index 0000000..0d12aa1
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/findForBook.js
@@ -0,0 +1,19 @@
+const path = require('path');
+
+const Book = require('../../models/book');
+const createNodeFS = require('../../fs/node');
+const findForBook = require('../findForBook');
+
+describe('findForBook', () => {
+ const fs = createNodeFS(
+ path.resolve(__dirname, '../../..')
+ );
+ const book = Book.createForFS(fs);
+
+ it('should list default plugins', () => {
+ return findForBook(book)
+ .then((plugins) => {
+ expect(plugins.has('theme-default')).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/gitbook/src/plugins/__tests__/findInstalled.js b/packages/gitbook/src/plugins/__tests__/findInstalled.js
new file mode 100644
index 0000000..e787761
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/findInstalled.js
@@ -0,0 +1,25 @@
+const path = require('path');
+const Immutable = require('immutable');
+
+describe('findInstalled', function() {
+ const findInstalled = require('../findInstalled');
+
+ it('must list default plugins for gitbook directory', function() {
+ // Read gitbook-plugins from package.json
+ const pkg = require(path.resolve(__dirname, '../../../package.json'));
+ const 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('highlight')).toBe(true);
+ expect(plugins.has('search')).toBe(true);
+ });
+ });
+
+});
diff --git a/packages/gitbook/src/plugins/__tests__/installPlugin.js b/packages/gitbook/src/plugins/__tests__/installPlugin.js
new file mode 100644
index 0000000..97f1475
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/installPlugin.js
@@ -0,0 +1,42 @@
+const tmp = require('tmp');
+
+const PluginDependency = require('../../models/pluginDependency');
+const Book = require('../../models/book');
+const NodeFS = require('../../fs/node');
+const installPlugin = require('../installPlugin');
+
+const Parse = require('../../parse');
+
+describe('installPlugin', () => {
+ let book, dir;
+
+ before(() => {
+ dir = tmp.dirSync({ unsafeCleanup: true });
+ const fs = NodeFS(dir.name);
+ const baseBook = Book.createForFS(fs)
+ .setLogLevel('disabled');
+
+ return Parse.parseConfig(baseBook)
+ .then((_book) => {
+ book = _book;
+ });
+ });
+
+ after(() => {
+ dir.removeCallback();
+ });
+
+ it('must install a plugin from NPM', () => {
+ const dep = PluginDependency.createFromString('ga');
+ return installPlugin(book, dep)
+ .then(() => {
+ expect(dir.name).toHaveFile('node_modules/gitbook-plugin-ga/package.json');
+ expect(dir.name).toNotHaveFile('package.json');
+ });
+ });
+
+ it('must install a specific version of a plugin', () => {
+ const dep = PluginDependency.createFromString('ga@0.2.1');
+ return installPlugin(book, dep);
+ });
+});
diff --git a/packages/gitbook/src/plugins/__tests__/installPlugins.js b/packages/gitbook/src/plugins/__tests__/installPlugins.js
new file mode 100644
index 0000000..26f135d
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/installPlugins.js
@@ -0,0 +1,37 @@
+const tmp = require('tmp');
+
+const Book = require('../../models/book');
+const MockFS = require('../../fs/mock');
+const installPlugins = require('../installPlugins');
+
+const Parse = require('../../parse');
+
+describe('installPlugins', () => {
+ let book, dir;
+
+ before(() => {
+ dir = tmp.dirSync({ unsafeCleanup: true });
+
+ const fs = MockFS({
+ 'book.json': JSON.stringify({ plugins: ['ga', 'sitemap' ]})
+ }, dir.name);
+ const baseBook = Book.createForFS(fs)
+ .setLogLevel('disabled');
+
+ return Parse.parseConfig(baseBook)
+ .then((_book) => {
+ book = _book;
+ });
+ });
+
+ after(() => {
+ dir.removeCallback();
+ });
+
+ it('must install all plugins from NPM', () => {
+ return installPlugins(book)
+ .then(function(n) {
+ expect(n).toBe(2);
+ });
+ });
+});
diff --git a/packages/gitbook/src/plugins/__tests__/listDependencies.js b/packages/gitbook/src/plugins/__tests__/listDependencies.js
new file mode 100644
index 0000000..002f0e9
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/listDependencies.js
@@ -0,0 +1,39 @@
+const PluginDependency = require('../../models/pluginDependency');
+const listDependencies = require('../listDependencies');
+const toNames = require('../toNames');
+
+describe('listDependencies', () => {
+ it('must list default', () => {
+ const deps = PluginDependency.listFromString('ga,great');
+ const plugins = listDependencies(deps);
+ const names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga', 'great', 'highlight', 'search', 'lunr',
+ 'sharing', 'hints', 'headings', 'copy-code', 'theme-default'
+ ]);
+ });
+
+ it('must list from array with -', () => {
+ const deps = PluginDependency.listFromString('ga,-great');
+ const plugins = listDependencies(deps);
+ const names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga', 'highlight', 'search', 'lunr',
+ 'sharing', 'hints', 'headings',
+ 'copy-code', 'theme-default'
+ ]);
+ });
+
+ it('must remove default plugins using -', () => {
+ const deps = PluginDependency.listFromString('ga,-search');
+ const plugins = listDependencies(deps);
+ const names = toNames(plugins);
+
+ expect(names).toEqual([
+ 'ga', 'highlight', 'lunr', 'sharing',
+ 'hints', 'headings', 'copy-code', 'theme-default'
+ ]);
+ });
+});
diff --git a/packages/gitbook/src/plugins/__tests__/locateRootFolder.js b/packages/gitbook/src/plugins/__tests__/locateRootFolder.js
new file mode 100644
index 0000000..54e095b
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/locateRootFolder.js
@@ -0,0 +1,10 @@
+const path = require('path');
+const 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/src/plugins/__tests__/resolveVersion.js b/packages/gitbook/src/plugins/__tests__/resolveVersion.js
new file mode 100644
index 0000000..949d078
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/resolveVersion.js
@@ -0,0 +1,22 @@
+const PluginDependency = require('../../models/pluginDependency');
+const resolveVersion = require('../resolveVersion');
+
+describe('resolveVersion', function() {
+ it('must skip resolving and return non-semver versions', function() {
+ const 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() {
+ const 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/src/plugins/__tests__/sortDependencies.js b/packages/gitbook/src/plugins/__tests__/sortDependencies.js
new file mode 100644
index 0000000..a08d59d
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/sortDependencies.js
@@ -0,0 +1,42 @@
+const PluginDependency = require('../../models/pluginDependency');
+const sortDependencies = require('../sortDependencies');
+const toNames = require('../toNames');
+
+describe('sortDependencies', function() {
+ it('must load themes after plugins', function() {
+ const allPlugins = PluginDependency.listFromArray([
+ 'hello',
+ 'theme-test',
+ 'world'
+ ]);
+
+ const sorted = sortDependencies(allPlugins);
+ const names = toNames(sorted);
+
+ expect(names).toEqual([
+ 'hello',
+ 'world',
+ 'theme-test'
+ ]);
+ });
+
+ it('must keep order of themes', function() {
+ const allPlugins = PluginDependency.listFromArray([
+ 'theme-test',
+ 'theme-test1',
+ 'hello',
+ 'theme-test2',
+ 'world'
+ ]);
+ const sorted = sortDependencies(allPlugins);
+ const names = toNames(sorted);
+
+ expect(names).toEqual([
+ 'hello',
+ 'world',
+ 'theme-test',
+ 'theme-test1',
+ 'theme-test2'
+ ]);
+ });
+});
diff --git a/packages/gitbook/src/plugins/__tests__/validatePlugin.js b/packages/gitbook/src/plugins/__tests__/validatePlugin.js
new file mode 100644
index 0000000..a2bd23b
--- /dev/null
+++ b/packages/gitbook/src/plugins/__tests__/validatePlugin.js
@@ -0,0 +1,16 @@
+const Promise = require('../../utils/promise');
+const Plugin = require('../../models/plugin');
+const validatePlugin = require('../validatePlugin');
+
+describe('validatePlugin', function() {
+ it('must not validate a not loaded plugin', function() {
+ const 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/src/plugins/findForBook.js b/packages/gitbook/src/plugins/findForBook.js
new file mode 100644
index 0000000..8668d1d
--- /dev/null
+++ b/packages/gitbook/src/plugins/findForBook.js
@@ -0,0 +1,33 @@
+const { List, OrderedMap } = require('immutable');
+
+const Promise = require('../utils/promise');
+const timing = require('../utils/timing');
+const findInstalled = require('./findInstalled');
+const 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 List(results)
+ .reduce(function(out, result) {
+ return out.merge(result);
+ }, OrderedMap());
+ })
+ );
+}
+
+module.exports = findForBook;
diff --git a/packages/gitbook/src/plugins/findInstalled.js b/packages/gitbook/src/plugins/findInstalled.js
new file mode 100644
index 0000000..fb690c2
--- /dev/null
+++ b/packages/gitbook/src/plugins/findInstalled.js
@@ -0,0 +1,81 @@
+const { OrderedMap } = require('immutable');
+const path = require('path');
+
+const Promise = require('../utils/promise');
+const fs = require('../utils/fs');
+const Plugin = require('../models/plugin');
+const PREFIX = require('../constants/pluginPrefix');
+
+/**
+ * Validate if a package name is a GitBook plugin
+ *
+ * @return {Boolean}
+ */
+function validateId(name) {
+ return name && name.indexOf(PREFIX) === 0;
+}
+
+/**
+ * Read details about a node module.
+ * @param {String} modulePath
+ * @param {Number} depth
+ * @param {String} parent
+ * @return {Plugin} plugin
+ */
+function readModule(modulePath, depth, parent) {
+ const pkg = require(path.join(modulePath, 'package.json'));
+ const pluginName = pkg.name.slice(PREFIX.length);
+
+ return new Plugin({
+ name: pluginName,
+ version: pkg.version,
+ path: modulePath,
+ depth,
+ parent
+ });
+}
+
+/**
+ * List all packages installed inside a folder
+ *
+ * @param {String} folder
+ * @param {Number} depth
+ * @param {String} parent
+ * @return {Promise<OrderedMap<String:Plugin>>} plugins
+ */
+function findInstalled(folder, depth = 0, parent = null) {
+ // When tetsing with mock-fs
+ if (!folder) {
+ return Promise(OrderedMap());
+ }
+
+ // Search for gitbook-plugins in node_modules folder
+ const node_modules = path.join(folder, 'node_modules');
+
+ // List all folders in node_modules
+ return fs.readdir(node_modules)
+ .fail(() => {
+ return Promise([]);
+ })
+ .then((modules) => {
+ return Promise.reduce(modules, (results, moduleName) => {
+ // Not a gitbook-plugin
+ if (!validateId(moduleName)) {
+ return results;
+ }
+
+ // Read gitbook-plugin package details
+ const moduleFolder = path.join(node_modules, moduleName);
+ const plugin = readModule(moduleFolder, depth, parent);
+
+ results = results.set(plugin.getName(), plugin);
+
+ return findInstalled(moduleFolder, depth + 1, plugin.getName())
+ .then((innerModules) => {
+ return results.merge(innerModules);
+ });
+ }, OrderedMap());
+ });
+}
+
+module.exports = findInstalled;
diff --git a/packages/gitbook/src/plugins/index.js b/packages/gitbook/src/plugins/index.js
new file mode 100644
index 0000000..bdc3b05
--- /dev/null
+++ b/packages/gitbook/src/plugins/index.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ loadForBook: require('./loadForBook'),
+ validateConfig: require('./validateConfig'),
+ installPlugins: require('./installPlugins'),
+ listBlocks: require('./listBlocks'),
+ listFilters: require('./listFilters')
+};
diff --git a/packages/gitbook/src/plugins/installPlugin.js b/packages/gitbook/src/plugins/installPlugin.js
new file mode 100644
index 0000000..9834d05
--- /dev/null
+++ b/packages/gitbook/src/plugins/installPlugin.js
@@ -0,0 +1,44 @@
+const resolve = require('resolve');
+
+const { exec } = require('../utils/command');
+const resolveVersion = require('./resolveVersion');
+
+/**
+ * Install a plugin for a book
+ *
+ * @param {Book} book
+ * @param {PluginDependency} plugin
+ * @return {Promise}
+ */
+function installPlugin(book, plugin) {
+ const logger = book.getLogger();
+
+ const installFolder = book.getRoot();
+ const name = plugin.getName();
+ const requirement = plugin.getVersion();
+
+ logger.info.ln('');
+ logger.info.ln('installing plugin "' + name + '"');
+
+ const installerBin = resolve.sync('ied/lib/cmd.js');
+
+ // 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 + ') with version', version);
+
+ const npmID = plugin.getNpmID();
+ const command = `${installerBin} install ${npmID}@${version}`;
+
+ return exec(command, { cwd: installFolder });
+ })
+ .then(function() {
+ logger.info.ok('plugin "' + name + '" installed with success');
+ });
+}
+
+module.exports = installPlugin;
diff --git a/packages/gitbook/src/plugins/installPlugins.js b/packages/gitbook/src/plugins/installPlugins.js
new file mode 100644
index 0000000..9d2520f
--- /dev/null
+++ b/packages/gitbook/src/plugins/installPlugins.js
@@ -0,0 +1,46 @@
+const DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+const Promise = require('../utils/promise');
+const installPlugin = require('./installPlugin');
+
+/**
+ * Install plugin requirements for a book
+ *
+ * @param {Book} book
+ * @return {Promise<Number>} count
+ */
+function installPlugins(book) {
+ const logger = book.getLogger();
+ const config = book.getConfig();
+ let plugins = config.getPluginDependencies();
+
+ // Remove default plugins
+ // (only if version is same as installed)
+ plugins = plugins.filterNot(function(plugin) {
+ const 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(0);
+ }
+
+ logger.info.ln('installing', plugins.size, 'plugins from registry');
+
+ return Promise.forEach(plugins, function(plugin) {
+ return installPlugin(book, plugin);
+ })
+ .thenResolve(plugins.size);
+}
+
+module.exports = installPlugins;
diff --git a/packages/gitbook/src/plugins/listBlocks.js b/packages/gitbook/src/plugins/listBlocks.js
new file mode 100644
index 0000000..a2b04f5
--- /dev/null
+++ b/packages/gitbook/src/plugins/listBlocks.js
@@ -0,0 +1,21 @@
+const { Map } = require('immutable');
+
+/**
+ * List blocks from a list of plugins
+ *
+ * @param {OrderedMap<String:Plugin>}
+ * @return {Map<String:TemplateBlock>}
+ */
+function listBlocks(plugins) {
+ return plugins
+ .reverse()
+ .reduce(
+ (result, plugin) => {
+ const blocks = plugin.getBlocks();
+ return result.merge(blocks);
+ },
+ Map()
+ );
+}
+
+module.exports = listBlocks;
diff --git a/packages/gitbook/src/plugins/listDependencies.js b/packages/gitbook/src/plugins/listDependencies.js
new file mode 100644
index 0000000..3930ae7
--- /dev/null
+++ b/packages/gitbook/src/plugins/listDependencies.js
@@ -0,0 +1,33 @@
+const DEFAULT_PLUGINS = require('../constants/defaultPlugins');
+const 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 -)
+ const 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/src/plugins/listDepsForBook.js b/packages/gitbook/src/plugins/listDepsForBook.js
new file mode 100644
index 0000000..81f619d
--- /dev/null
+++ b/packages/gitbook/src/plugins/listDepsForBook.js
@@ -0,0 +1,18 @@
+const 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} book
+ * @return {List<PluginDependency>} dependencies
+ */
+function listDepsForBook(book) {
+ const config = book.getConfig();
+ const plugins = config.getPluginDependencies();
+
+ return listDependencies(plugins);
+}
+
+module.exports = listDepsForBook;
diff --git a/packages/gitbook/src/plugins/listFilters.js b/packages/gitbook/src/plugins/listFilters.js
new file mode 100644
index 0000000..57d5c29
--- /dev/null
+++ b/packages/gitbook/src/plugins/listFilters.js
@@ -0,0 +1,20 @@
+const { Map } = require('immutable');
+
+/**
+ * List filters from a list of plugins
+ *
+ * @param {OrderedMap<String:Plugin>} plugins
+ * @return {Map<String:Function>} filters
+ */
+function listFilters(plugins) {
+ return plugins
+ .reverse()
+ .reduce(
+ (result, plugin) => {
+ return result.merge(plugin.getFilters());
+ },
+ Map()
+ );
+}
+
+module.exports = listFilters;
diff --git a/packages/gitbook/src/plugins/loadForBook.js b/packages/gitbook/src/plugins/loadForBook.js
new file mode 100644
index 0000000..0baa78e
--- /dev/null
+++ b/packages/gitbook/src/plugins/loadForBook.js
@@ -0,0 +1,73 @@
+const Immutable = require('immutable');
+
+const Promise = require('../utils/promise');
+const listDepsForBook = require('./listDepsForBook');
+const findForBook = require('./findForBook');
+const loadPlugin = require('./loadPlugin');
+
+
+/**
+ * Load all plugins in a book
+ *
+ * @param {Book}
+ * @return {Promise<Map<String:Plugin>}
+ */
+function loadForBook(book) {
+ const logger = book.getLogger();
+
+ // List the dependencies
+ const requirements = listDepsForBook(book);
+
+ // List all plugins installed in the book
+ return findForBook(book)
+ .then(function(installedMap) {
+ const missing = [];
+ let plugins = requirements.reduce(function(result, dep) {
+ const name = dep.getName();
+ const installed = installedMap.get(name);
+
+ if (installed) {
+ const 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/src/plugins/loadPlugin.js b/packages/gitbook/src/plugins/loadPlugin.js
new file mode 100644
index 0000000..167587a
--- /dev/null
+++ b/packages/gitbook/src/plugins/loadPlugin.js
@@ -0,0 +1,89 @@
+const path = require('path');
+const resolve = require('resolve');
+const Immutable = require('immutable');
+
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const timing = require('../utils/timing');
+
+const 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) {
+ const logger = book.getLogger();
+
+ const name = plugin.getName();
+ let pkgPath = plugin.getPath();
+
+ // Try loading plugins from different location
+ let p = Promise()
+ .then(function() {
+ let packageContent;
+ let packageMain;
+ let content;
+
+ // Locate plugin and load package.json
+ try {
+ const 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 {
+ const 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/src/plugins/locateRootFolder.js b/packages/gitbook/src/plugins/locateRootFolder.js
new file mode 100644
index 0000000..64e06a8
--- /dev/null
+++ b/packages/gitbook/src/plugins/locateRootFolder.js
@@ -0,0 +1,22 @@
+const path = require('path');
+const resolve = require('resolve');
+
+const 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() {
+ const firstDefaultPlugin = DEFAULT_PLUGINS.first();
+ const pluginPath = resolve.sync(firstDefaultPlugin.getNpmID() + '/package.json', {
+ basedir: __dirname
+ });
+ const nodeModules = path.resolve(pluginPath, '../../..');
+
+ return nodeModules;
+}
+
+module.exports = locateRootFolder;
diff --git a/packages/gitbook/src/plugins/resolveVersion.js b/packages/gitbook/src/plugins/resolveVersion.js
new file mode 100644
index 0000000..a241c23
--- /dev/null
+++ b/packages/gitbook/src/plugins/resolveVersion.js
@@ -0,0 +1,70 @@
+const npm = require('npm');
+const semver = require('semver');
+const { Map } = require('immutable');
+
+const Promise = require('../utils/promise');
+const Plugin = require('../models/plugin');
+const gitbook = require('../gitbook');
+
+let 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) {
+ const npmId = Plugin.nameToNpmID(plugin.getName());
+ const 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 = Map(versions).entrySeq();
+
+ const 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/src/plugins/sortDependencies.js b/packages/gitbook/src/plugins/sortDependencies.js
new file mode 100644
index 0000000..2adfa20
--- /dev/null
+++ b/packages/gitbook/src/plugins/sortDependencies.js
@@ -0,0 +1,34 @@
+const Immutable = require('immutable');
+
+const THEME_PREFIX = require('../constants/themePrefix');
+
+const TYPE_PLUGIN = 'plugin';
+const TYPE_THEME = 'theme';
+
+
+/**
+ * Returns the type of a plugin given its name
+ * @param {Plugin} plugin
+ * @return {String}
+ */
+function pluginType(plugin) {
+ const 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) {
+ const byTypes = plugins.groupBy(pluginType);
+
+ return byTypes.get(TYPE_PLUGIN, Immutable.List())
+ .concat(byTypes.get(TYPE_THEME, Immutable.List()));
+}
+
+module.exports = sortDependencies;
diff --git a/packages/gitbook/src/plugins/toNames.js b/packages/gitbook/src/plugins/toNames.js
new file mode 100644
index 0000000..422a24d
--- /dev/null
+++ b/packages/gitbook/src/plugins/toNames.js
@@ -0,0 +1,16 @@
+
+/**
+ * Return list of plugin names. This method is only 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/src/plugins/validateConfig.js b/packages/gitbook/src/plugins/validateConfig.js
new file mode 100644
index 0000000..82a2507
--- /dev/null
+++ b/packages/gitbook/src/plugins/validateConfig.js
@@ -0,0 +1,71 @@
+const Immutable = require('immutable');
+const jsonschema = require('jsonschema');
+const jsonSchemaDefaults = require('json-schema-defaults');
+
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const 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) {
+ let config = book.getConfig();
+ const packageInfos = plugin.getPackage();
+
+ const configKey = [
+ 'pluginsConfig',
+ plugin.getName()
+ ].join('.');
+
+ let pluginConfig = config.getValue(configKey, {}).toJS();
+
+ const schema = (packageInfos.get('gitbook') || Immutable.Map()).toJS();
+ if (!schema) return book;
+
+ // Normalize schema
+ schema.id = '/' + configKey;
+ schema.type = 'object';
+
+ // Validate and throw if invalid
+ const v = new jsonschema.Validator();
+ const 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
+ const 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/src/plugins/validatePlugin.js b/packages/gitbook/src/plugins/validatePlugin.js
new file mode 100644
index 0000000..cc9ac7b
--- /dev/null
+++ b/packages/gitbook/src/plugins/validatePlugin.js
@@ -0,0 +1,34 @@
+const gitbook = require('../gitbook');
+
+const Promise = require('../utils/promise');
+
+/**
+ * Validate a plugin
+ *
+ * @param {Plugin}
+ * @return {Promise<Plugin>}
+ */
+function validatePlugin(plugin) {
+ const packageInfos = plugin.getPackage();
+
+ const 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() + '"'));
+ }
+
+ const 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/src/templating/__tests__/conrefsLoader.js b/packages/gitbook/src/templating/__tests__/conrefsLoader.js
new file mode 100644
index 0000000..1b8e92f
--- /dev/null
+++ b/packages/gitbook/src/templating/__tests__/conrefsLoader.js
@@ -0,0 +1,111 @@
+const path = require('path');
+
+const TemplateEngine = require('../../models/templateEngine');
+const renderTemplate = require('../render');
+const ConrefsLoader = require('../conrefsLoader');
+
+describe('ConrefsLoader', () => {
+ const dirName = __dirname + '/';
+ const fileName = path.join(dirName, 'test.md');
+
+ describe('Git', () => {
+ let engine;
+
+ before(() => {
+ engine = new TemplateEngine({
+ loader: new ConrefsLoader(dirName)
+ });
+ });
+
+ it('should include content from git', () => {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test.md" %}')
+ .then((out) => {
+ expect(out).toBe('Hello from git');
+ });
+ });
+
+ it('should handle deep inclusion (1)', () => {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test2.md" %}')
+ .then((out) => {
+ expect(out).toBe('First Hello. Hello from git');
+ });
+ });
+
+ it('should handle deep inclusion (2)', () => {
+ return renderTemplate(engine, fileName, '{% include "git+https://gist.github.com/69ea4542e4c8967d2fa7.git/test3.md" %}')
+ .then((out) => {
+ expect(out).toBe('First Hello. Hello from git');
+ });
+ });
+ });
+
+ describe('Local', () => {
+ let engine;
+
+ before(() => {
+ engine = new TemplateEngine({
+ loader: new ConrefsLoader(dirName)
+ });
+ });
+
+ describe('Relative', () => {
+ it('should resolve basic relative filepath', () => {
+ return renderTemplate(engine, fileName, '{% include "include.md" %}')
+ .then((out) => {
+ expect(out).toBe('Hello World');
+ });
+ });
+
+ it('should resolve basic parent filepath', () => {
+ return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "../include.md" %}')
+ .then((out) => {
+ expect(out).toBe('Hello World');
+ });
+ });
+ });
+
+ describe('Absolute', function() {
+ it('should resolve absolute filepath', () => {
+ return renderTemplate(engine, fileName, '{% include "/include.md" %}')
+ .then((out) => {
+ expect(out).toBe('Hello World');
+ });
+ });
+
+ it('should resolve absolute filepath when in a directory', () => {
+ return renderTemplate(engine, path.join(dirName, 'hello/test.md'), '{% include "/include.md" %}')
+ .then((out) => {
+ expect(out).toBe('Hello World');
+ });
+ });
+ });
+
+ });
+
+ describe('transform', () => {
+ 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';
+ }
+
+ let engine;
+
+ before(() => {
+ engine = new TemplateEngine({
+ loader: new ConrefsLoader(dirName, transform)
+ });
+ });
+
+ it('should transform included content', () => {
+ return renderTemplate(engine, fileName, '{% include "include.md" %}')
+ .then((out) => {
+ expect(out).toBe('test-Hello World-endtest');
+ });
+ });
+ });
+});
diff --git a/packages/gitbook/src/templating/__tests__/include.md b/packages/gitbook/src/templating/__tests__/include.md
new file mode 100644
index 0000000..5e1c309
--- /dev/null
+++ b/packages/gitbook/src/templating/__tests__/include.md
@@ -0,0 +1 @@
+Hello World \ No newline at end of file
diff --git a/packages/gitbook/src/templating/__tests__/replaceShortcuts.js b/packages/gitbook/src/templating/__tests__/replaceShortcuts.js
new file mode 100644
index 0000000..1126f91
--- /dev/null
+++ b/packages/gitbook/src/templating/__tests__/replaceShortcuts.js
@@ -0,0 +1,31 @@
+const Immutable = require('immutable');
+
+const TemplateBlock = require('../../models/templateBlock');
+const replaceShortcuts = require('../replaceShortcuts');
+
+describe('replaceShortcuts', function() {
+ const blocks = Immutable.List([
+ TemplateBlock.create('math', {
+ shortcuts: {
+ start: '$$',
+ end: '$$',
+ parsers: ['markdown']
+ }
+ })
+ ]);
+
+ it('should correctly replace inline matches by block', function() {
+ const content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$');
+ expect(content).toBe('Hello {% math %}a = b{% endmath %}');
+ });
+
+ it('should correctly replace multiple inline matches by block', function() {
+ const content = replaceShortcuts(blocks, 'test.md', 'Hello $$a = b$$ and $$c = d$$');
+ expect(content).toBe('Hello {% math %}a = b{% endmath %} and {% math %}c = d{% endmath %}');
+ });
+
+ it('should correctly replace block matches', function() {
+ const 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/src/templating/conrefsLoader.js b/packages/gitbook/src/templating/conrefsLoader.js
new file mode 100644
index 0000000..3660d17
--- /dev/null
+++ b/packages/gitbook/src/templating/conrefsLoader.js
@@ -0,0 +1,93 @@
+const path = require('path');
+const nunjucks = require('nunjucks');
+
+const fs = require('../utils/fs');
+const LocationUtils = require('../utils/location');
+const PathUtils = require('../utils/path');
+const Git = require('../utils/git');
+
+
+/**
+ * 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)
+ */
+const ConrefsLoader = nunjucks.Loader.extend({
+ async: true,
+
+ init(rootFolder, transformFn, logger, git = new Git()) {
+ this.rootFolder = rootFolder;
+ this.transformFn = transformFn;
+ this.logger = logger;
+ this.git = git;
+ },
+
+ getSource(sourceURL, callback) {
+ const 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(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)
+ const fromRelative = path.relative(this.rootFolder, from);
+
+ // Resolve "to" to a filepath relative to rootFolder
+ const 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
+ const 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(filename) {
+ return LocationUtils.isRelative(filename);
+ }
+});
+
+module.exports = ConrefsLoader;
diff --git a/packages/gitbook/src/templating/index.js b/packages/gitbook/src/templating/index.js
new file mode 100644
index 0000000..5189eac
--- /dev/null
+++ b/packages/gitbook/src/templating/index.js
@@ -0,0 +1,7 @@
+
+module.exports = {
+ render: require('./render'),
+ renderFile: require('./renderFile'),
+ replaceShortcuts: require('./replaceShortcuts'),
+ ConrefsLoader: require('./conrefsLoader')
+};
diff --git a/packages/gitbook/src/templating/listShortcuts.js b/packages/gitbook/src/templating/listShortcuts.js
new file mode 100644
index 0000000..099b709
--- /dev/null
+++ b/packages/gitbook/src/templating/listShortcuts.js
@@ -0,0 +1,31 @@
+const { List } = require('immutable');
+const 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>} shortcuts
+ */
+function listShortcuts(blocks, filePath) {
+ const parser = parsers.getForFile(filePath);
+
+ if (!parser) {
+ return 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/src/templating/render.js b/packages/gitbook/src/templating/render.js
new file mode 100644
index 0000000..945d6dc
--- /dev/null
+++ b/packages/gitbook/src/templating/render.js
@@ -0,0 +1,40 @@
+const Promise = require('../utils/promise');
+const timing = require('../utils/timing');
+const 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<String>}
+ */
+function renderTemplate(engine, filePath, content, context) {
+ context = context || {};
+
+ // Mutable objects to contains all blocks requiring post-processing
+ const blocks = {};
+
+ // Create nunjucks environment
+ const 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
+ }
+ )
+ );
+}
+
+module.exports = renderTemplate;
diff --git a/packages/gitbook/src/templating/renderFile.js b/packages/gitbook/src/templating/renderFile.js
new file mode 100644
index 0000000..a2463f8
--- /dev/null
+++ b/packages/gitbook/src/templating/renderFile.js
@@ -0,0 +1,41 @@
+const Promise = require('../utils/promise');
+const error = require('../utils/error');
+const render = require('./render');
+
+/**
+ * Render a template
+ *
+ * @param {TemplateEngine} engine
+ * @param {String} filePath
+ * @param {Object} context
+ * @return {Promise<TemplateOutput>}
+ */
+function renderTemplateFile(engine, filePath, context) {
+ const loader = engine.getLoader();
+
+ // Resolve the filePath
+ const resolvedFilePath = loader.resolve(null, filePath);
+
+ return Promise()
+ .then(function() {
+ if (!loader.async) {
+ return loader.getSource(resolvedFilePath);
+ }
+
+ const 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/src/templating/replaceShortcuts.js b/packages/gitbook/src/templating/replaceShortcuts.js
new file mode 100644
index 0000000..25f598f
--- /dev/null
+++ b/packages/gitbook/src/templating/replaceShortcuts.js
@@ -0,0 +1,39 @@
+const escapeStringRegexp = require('escape-string-regexp');
+const listShortcuts = require('./listShortcuts');
+
+/**
+ * Apply a shortcut of block to a template
+ * @param {String} content
+ * @param {Shortcut} shortcut
+ * @return {String}
+ */
+function applyShortcut(content, shortcut) {
+ const start = shortcut.getStart();
+ const end = shortcut.getEnd();
+
+ const tagStart = shortcut.getStartTag();
+ const tagEnd = shortcut.getEndTag();
+
+ const 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) {
+ const shortcuts = listShortcuts(blocks, filePath);
+ return shortcuts.reduce(applyShortcut, content);
+}
+
+module.exports = replaceShortcuts;
diff --git a/packages/gitbook/src/utils/__tests__/git.js b/packages/gitbook/src/utils/__tests__/git.js
new file mode 100644
index 0000000..29be4a1
--- /dev/null
+++ b/packages/gitbook/src/utils/__tests__/git.js
@@ -0,0 +1,55 @@
+const path = require('path');
+const Git = require('../git');
+
+describe('Git', () => {
+
+ describe('URL parsing', () => {
+
+ it('should correctly validate git urls', () => {
+ // 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', () => {
+ const 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', () => {
+ const 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', () => {
+ const 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', () => {
+ it('should clone an HTTPS url', () => {
+ const git = new Git();
+ 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/src/utils/__tests__/location.js b/packages/gitbook/src/utils/__tests__/location.js
new file mode 100644
index 0000000..a565adb
--- /dev/null
+++ b/packages/gitbook/src/utils/__tests__/location.js
@@ -0,0 +1,99 @@
+const 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/src/utils/__tests__/path.js b/packages/gitbook/src/utils/__tests__/path.js
new file mode 100644
index 0000000..1f8a1d3
--- /dev/null
+++ b/packages/gitbook/src/utils/__tests__/path.js
@@ -0,0 +1,17 @@
+const path = require('path');
+
+describe('Paths', function() {
+ const 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/src/utils/command.js b/packages/gitbook/src/utils/command.js
new file mode 100644
index 0000000..5533ca8
--- /dev/null
+++ b/packages/gitbook/src/utils/command.js
@@ -0,0 +1,118 @@
+const is = require('is');
+const childProcess = require('child_process');
+const spawn = require('spawn-cmd').spawn;
+const Promise = require('./promise');
+
+/**
+ * Execute a command
+ *
+ * @param {String} command
+ * @param {Object} options
+ * @return {Promise}
+ */
+function exec(command, options) {
+ const d = Promise.defer();
+
+ const 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) {
+ const d = Promise.defer();
+ const 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) {
+ const result = [];
+
+ for (const key in options) {
+ const 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,
+ spawn: spawnCmd,
+ optionsToShellArgs
+};
diff --git a/packages/gitbook/src/utils/error.js b/packages/gitbook/src/utils/error.js
new file mode 100644
index 0000000..925b5ff
--- /dev/null
+++ b/packages/gitbook/src/utils/error.js
@@ -0,0 +1,99 @@
+const is = require('is');
+
+const TypedError = require('error/typed');
+const 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
+const ParsingError = WrappedError({
+ message: 'Parsing Error: {origMessage}',
+ type: 'parse'
+});
+const OutputError = WrappedError({
+ message: 'Output Error: {origMessage}',
+ type: 'generate'
+});
+
+// A file does not exists
+const FileNotFoundError = TypedError({
+ type: 'file.not-found',
+ message: 'No "{filename}" file (or is ignored)',
+ filename: null
+});
+
+// A file cannot be parsed
+const FileNotParsableError = TypedError({
+ type: 'file.not-parsable',
+ message: '"{filename}" file cannot be parsed',
+ filename: null
+});
+
+// A file is outside the scope
+const FileOutOfScopeError = TypedError({
+ type: 'file.out-of-scope',
+ message: '"{filename}" not in "{root}"',
+ filename: null,
+ root: null,
+ code: 'EACCESS'
+});
+
+// A file is outside the scope
+const RequireInstallError = TypedError({
+ type: 'install.required',
+ message: '"{cmd}" is not installed.\n{install}',
+ cmd: null,
+ code: 'ENOENT',
+ install: ''
+});
+
+// Error for nunjucks templates
+const TemplateError = WrappedError({
+ message: 'Error compiling template "{filename}": {origMessage}',
+ type: 'template',
+ filename: null
+});
+
+// Error for nunjucks templates
+const PluginError = WrappedError({
+ message: 'Error with plugin "{plugin}": {origMessage}',
+ type: 'plugin',
+ plugin: null
+});
+
+// Error with the book's configuration
+const ConfigurationError = WrappedError({
+ message: 'Error with book\'s configuration: {origMessage}',
+ type: 'configuration'
+});
+
+// Error during ebook generation
+const EbookError = WrappedError({
+ message: 'Error during ebook generation: {origMessage}\n{stdout}',
+ type: 'ebook',
+ stdout: ''
+});
+
+module.exports = {
+ enforce,
+
+ ParsingError,
+ OutputError,
+ RequireInstallError,
+
+ FileNotParsableError,
+ FileNotFoundError,
+ FileOutOfScopeError,
+
+ TemplateError,
+ PluginError,
+ ConfigurationError,
+ EbookError
+};
diff --git a/packages/gitbook/src/utils/fs.js b/packages/gitbook/src/utils/fs.js
new file mode 100644
index 0000000..17b2ebb
--- /dev/null
+++ b/packages/gitbook/src/utils/fs.js
@@ -0,0 +1,170 @@
+const fs = require('graceful-fs');
+const mkdirp = require('mkdirp');
+const destroy = require('destroy');
+const rmdir = require('rmdir');
+const tmp = require('tmp');
+const request = require('request');
+const path = require('path');
+const cp = require('cp');
+const cpr = require('cpr');
+
+const Promise = require('./promise');
+
+// Write a stream to a file
+function writeStream(filename, st) {
+ const d = Promise.defer();
+
+ const wstream = fs.createWriteStream(filename);
+ const 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) {
+ const 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) {
+ const ext = path.extname(filename);
+ filename = path.resolve(base, filename);
+ filename = path.join(path.dirname(filename), path.basename(filename, ext));
+
+ let _filename = filename + ext;
+
+ let 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) {
+ const base = path.dirname(filename);
+ return Promise.nfcall(mkdirp, base);
+}
+
+// Remove a folder
+function rmDir(base) {
+ return Promise.nfcall(rmdir, base, {
+ 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) {
+ const 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,
+ pickFile,
+ stat: Promise.nfbind(fs.stat),
+ statSync: fs.statSync,
+ readdir: Promise.nfbind(fs.readdir),
+ writeStream,
+ readStream: fs.createReadStream,
+ copy: Promise.nfbind(cp),
+ copyDir: Promise.nfbind(cpr),
+ tmpFile: genTmpFile,
+ tmpDir: genTmpDir,
+ download,
+ uniqueFilename,
+ ensureFile,
+ ensureFolder,
+ rmDir
+};
diff --git a/packages/gitbook/src/utils/genKey.js b/packages/gitbook/src/utils/genKey.js
new file mode 100644
index 0000000..e4982f4
--- /dev/null
+++ b/packages/gitbook/src/utils/genKey.js
@@ -0,0 +1,13 @@
+let lastKey = 0;
+
+/*
+ Generate a random key
+ @return {String}
+*/
+function generateKey() {
+ lastKey += 1;
+ const str = lastKey.toString(16);
+ return '00000'.slice(str.length) + str;
+}
+
+module.exports = generateKey;
diff --git a/packages/gitbook/src/utils/git.js b/packages/gitbook/src/utils/git.js
new file mode 100644
index 0000000..2b2a3e3
--- /dev/null
+++ b/packages/gitbook/src/utils/git.js
@@ -0,0 +1,158 @@
+const is = require('is');
+const path = require('path');
+const crc = require('crc');
+const URI = require('urijs');
+
+const pathUtil = require('./path');
+const Promise = require('./promise');
+const command = require('./command');
+const fs = require('./fs');
+
+const GIT_PREFIX = 'git+';
+
+class Git {
+ constructor() {
+ this.tmpDir = null;
+ this.cloned = {};
+ }
+
+ // Return an unique ID for a combinaison host/ref
+ repoID(host, ref) {
+ return crc.crc32(host + '#' + (ref || '')).toString(16);
+ }
+
+ // Allocate a temporary folder for cloning repos in it
+ allocateDir() {
+ const that = this;
+
+ if (this.tmpDir) {
+ return Promise();
+ }
+
+ return fs.tmpDir()
+ .then(function(dir) {
+ that.tmpDir = dir;
+ });
+ }
+
+ /**
+ * Clone a git repository if non existant
+ * @param {String} host: url of the git repository
+ * @param {String} ref: branch/commit/tag to checkout
+ * @return {Promise<String>} repoPath
+ */
+ clone(host, ref) {
+ const that = this;
+
+ return this.allocateDir()
+
+ // Return or clone the git repo
+ .then(function() {
+ // Unique ID for repo/ref combinaison
+ const repoId = that.repoID(host, ref);
+
+ // Absolute path to the folder
+ const 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);
+ });
+ }
+
+ /**
+ * Resole a git url, clone the repo and return the path to the right file.
+ * @param {String} giturl
+ * @return {Promise<String>} filePath
+ */
+ resolve(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
+ * @param {String} filePath
+ * @return {String} repoPath
+ */
+ resolveRoot(filepath) {
+ // 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)
+ const relativeToGit = path.relative(this.tmpDir, filepath);
+ const 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
+ * @param {String} giturl
+ * @return {Boolean} isUrl
+ */
+ static isUrl(giturl) {
+ return (giturl.indexOf(GIT_PREFIX) === 0);
+ }
+
+ /**
+ * Parse and extract infos
+ * @param {String} giturl
+ * @return {Object} { host, ref, filepath }
+ */
+ static parseUrl(giturl) {
+ if (!Git.isUrl(giturl)) {
+ return null;
+ }
+ giturl = giturl.slice(GIT_PREFIX.length);
+
+ const uri = new URI(giturl);
+ const ref = uri.fragment() || null;
+ uri.fragment(null);
+
+ // Extract file inside the repo (after the .git)
+ const fileParts = uri.path().split('.git');
+ let 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,
+ filepath
+ };
+ }
+
+}
+
+module.exports = Git;
diff --git a/packages/gitbook/src/utils/images.js b/packages/gitbook/src/utils/images.js
new file mode 100644
index 0000000..808be63
--- /dev/null
+++ b/packages/gitbook/src/utils/images.js
@@ -0,0 +1,60 @@
+const Promise = require('./promise');
+const command = require('./command');
+const fs = require('./fs');
+const 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'));
+
+ const base64data = source.split('data:image/png;base64,')[1];
+ const 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,
+ convertSVGBufferToPNG,
+ convertInlinePNG
+};
diff --git a/packages/gitbook/src/utils/location.js b/packages/gitbook/src/utils/location.js
new file mode 100644
index 0000000..6dc41ba
--- /dev/null
+++ b/packages/gitbook/src/utils/location.js
@@ -0,0 +1,139 @@
+const url = require('url');
+const 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 {
+ const 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
+ let 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) {
+ const 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,
+ isDataURI,
+ isExternal,
+ isRelative,
+ isAnchor,
+ normalize,
+ toAbsolute,
+ relative,
+ relativeForFile,
+ flatten
+};
diff --git a/packages/gitbook/src/utils/logger.js b/packages/gitbook/src/utils/logger.js
new file mode 100644
index 0000000..25f8517
--- /dev/null
+++ b/packages/gitbook/src/utils/logger.js
@@ -0,0 +1,170 @@
+const is = require('is');
+const util = require('util');
+const color = require('bash-color');
+const Immutable = require('immutable');
+
+const LEVELS = Immutable.Map({
+ DEBUG: 0,
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3,
+ DISABLED: 10
+});
+
+const 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(...args) {
+ return util.format(...args);
+};
+
+/**
+ 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, ...args) {
+ if (level < this.logLevel) return;
+
+ const levelKey = LEVELS.findKey(function(v) {
+ return v === level;
+ });
+ let msg = this.format(...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(...args) {
+ if (this.lastChar != '\n') this.write('\n');
+
+ args.push('\n');
+ return this.log(...args);
+};
+
+/**
+ Log a confirmation [OK]
+*/
+Logger.prototype.ok = function(level, ...args) {
+ const msg = this.format(...args);
+
+ if (args.length > 0) {
+ 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) {
+ const 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/src/utils/mergeDefaults.js b/packages/gitbook/src/utils/mergeDefaults.js
new file mode 100644
index 0000000..b2e8c3d
--- /dev/null
+++ b/packages/gitbook/src/utils/mergeDefaults.js
@@ -0,0 +1,16 @@
+const Immutable = require('immutable');
+
+/**
+ * Merge
+ * @param {Object|Map} obj
+ * @param {Object|Map} src
+ * @return {Object}
+ */
+function mergeDefaults(obj, src) {
+ const objValue = Immutable.fromJS(obj);
+ const srcValue = Immutable.fromJS(src);
+
+ return srcValue.mergeDeep(objValue).toJS();
+}
+
+module.exports = mergeDefaults;
diff --git a/packages/gitbook/src/utils/path.js b/packages/gitbook/src/utils/path.js
new file mode 100644
index 0000000..01c2cbf
--- /dev/null
+++ b/packages/gitbook/src/utils/path.js
@@ -0,0 +1,71 @@
+const path = require('path');
+const 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, ...args) {
+ const 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);
+ }, '');
+
+ const result = path.resolve(root, input);
+
+ if (!isInRoot(root, result)) {
+ throw new error.FileOutOfScopeError({
+ filename: result,
+ 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,
+ resolveInRoot,
+ normalize: normalizePath,
+ setExtension,
+ isPureRelative
+};
diff --git a/packages/gitbook/src/utils/promise.js b/packages/gitbook/src/utils/promise.js
new file mode 100644
index 0000000..8cbbd47
--- /dev/null
+++ b/packages/gitbook/src/utils/promise.js
@@ -0,0 +1,146 @@
+const Q = require('q');
+const 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)) {
+ let 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(...args) {
+ return Q()
+ .then(function() {
+ return func(...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/src/utils/reducedObject.js b/packages/gitbook/src/utils/reducedObject.js
new file mode 100644
index 0000000..196a72c
--- /dev/null
+++ b/packages/gitbook/src/utils/reducedObject.js
@@ -0,0 +1,33 @@
+const 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) {
+ const defaultValue = defaultVersion.get(key);
+
+ if (Immutable.Map.isMap(value)) {
+ const 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/src/utils/timing.js b/packages/gitbook/src/utils/timing.js
new file mode 100644
index 0000000..38ffd00
--- /dev/null
+++ b/packages/gitbook/src/utils/timing.js
@@ -0,0 +1,104 @@
+const Immutable = require('immutable');
+const is = require('is');
+const Promise = require('./promise');
+
+const timers = {};
+const startDate = Date.now();
+
+/**
+ * Mesure an operation
+ *
+ * @param {String} type
+ * @param {Promise|Function} p
+ * @return {Promise|Mixed} result
+ */
+function measure(type, p) {
+ timers[type] = timers[type] || {
+ type,
+ count: 0,
+ total: 0,
+ min: undefined,
+ max: 0
+ };
+
+ const start = Date.now();
+
+ const after = () => {
+ const end = Date.now();
+ const 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);
+ };
+
+ if (Promise.isPromise(p)) {
+ return p.fin(after);
+ }
+
+ const result = p();
+ after();
+
+ return result;
+}
+
+/**
+ * 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) {
+ const prefix = ' > ';
+ let measured = 0;
+ const totalDuration = Date.now() - startDate;
+
+ // Enable debug logging
+ const logLevel = logger.getLevel();
+ logger.setLevel('debug');
+
+ Immutable.Map(timers)
+ .valueSeq()
+ .sortBy(function(timer) {
+ measured += timer.total;
+ return timer.total;
+ })
+ .forEach(function(timer) {
+ const 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,
+ dump
+};
diff --git a/packages/gitbook/testing/setup.js b/packages/gitbook/testing/setup.js
new file mode 100644
index 0000000..ee1485e
--- /dev/null
+++ b/packages/gitbook/testing/setup.js
@@ -0,0 +1,73 @@
+const is = require('is');
+const path = require('path');
+const fs = require('fs');
+const expect = require('expect');
+const cheerio = require('cheerio');
+
+expect.extend({
+
+ /**
+ * Check that a file is created in a directory:
+ * expect('myFolder').toHaveFile('hello.md');
+ */
+ toHaveFile(fileName) {
+ const filePath = path.join(this.actual, fileName);
+ const exists = fs.existsSync(filePath);
+
+ expect.assert(
+ exists,
+ 'expected %s to have file %s',
+ this.actual,
+ fileName
+ );
+ return this;
+ },
+ toNotHaveFile(fileName) {
+ const filePath = path.join(this.actual, fileName);
+ const 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() {
+ 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() {
+ 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(selector) {
+ const $ = cheerio.load(this.actual);
+ const $el = $(selector);
+
+ expect.assert($el.length > 0, 'expected HTML to contains %s', selector);
+ }
+});
+
+global.expect = expect;