summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/gitbook-core/.babelrc3
-rw-r--r--packages/gitbook-core/.gitignore1
-rw-r--r--packages/gitbook-core/.npmignore3
-rw-r--r--packages/gitbook-core/package.json51
-rw-r--r--packages/gitbook-core/src/actions/TYPES.js16
-rw-r--r--packages/gitbook-core/src/actions/components.js37
-rw-r--r--packages/gitbook-core/src/actions/history.js188
-rw-r--r--packages/gitbook-core/src/actions/i18n.js33
-rw-r--r--packages/gitbook-core/src/components/Backdrop.js56
-rw-r--r--packages/gitbook-core/src/components/Button.js22
-rw-r--r--packages/gitbook-core/src/components/ButtonGroup.js23
-rw-r--r--packages/gitbook-core/src/components/ContextProvider.js34
-rw-r--r--packages/gitbook-core/src/components/Dropdown.js126
-rw-r--r--packages/gitbook-core/src/components/HTMLContent.js77
-rw-r--r--packages/gitbook-core/src/components/HotKeys.js59
-rw-r--r--packages/gitbook-core/src/components/I18nProvider.js28
-rw-r--r--packages/gitbook-core/src/components/Icon.js28
-rw-r--r--packages/gitbook-core/src/components/Import.js48
-rw-r--r--packages/gitbook-core/src/components/InjectedComponent.js117
-rw-r--r--packages/gitbook-core/src/components/Link.js37
-rw-r--r--packages/gitbook-core/src/components/PJAXWrapper.js102
-rw-r--r--packages/gitbook-core/src/components/Panel.js22
-rw-r--r--packages/gitbook-core/src/components/Tooltipped.js44
-rw-r--r--packages/gitbook-core/src/index.js73
-rw-r--r--packages/gitbook-core/src/lib/bootstrap.js29
-rw-r--r--packages/gitbook-core/src/lib/composeReducer.js16
-rw-r--r--packages/gitbook-core/src/lib/connect.js70
-rw-r--r--packages/gitbook-core/src/lib/createContext.js76
-rw-r--r--packages/gitbook-core/src/lib/createPlugin.js27
-rw-r--r--packages/gitbook-core/src/lib/createReducer.js27
-rw-r--r--packages/gitbook-core/src/lib/getPayload.js19
-rw-r--r--packages/gitbook-core/src/lib/renderWithContext.js55
-rw-r--r--packages/gitbook-core/src/models/Context.js58
-rw-r--r--packages/gitbook-core/src/models/File.js54
-rw-r--r--packages/gitbook-core/src/models/Language.js12
-rw-r--r--packages/gitbook-core/src/models/Languages.js40
-rw-r--r--packages/gitbook-core/src/models/Location.js78
-rw-r--r--packages/gitbook-core/src/models/Page.js24
-rw-r--r--packages/gitbook-core/src/models/Plugin.js21
-rw-r--r--packages/gitbook-core/src/models/Readme.js21
-rw-r--r--packages/gitbook-core/src/models/SummaryArticle.js32
-rw-r--r--packages/gitbook-core/src/models/SummaryPart.js17
-rw-r--r--packages/gitbook-core/src/propTypes/Context.js11
-rw-r--r--packages/gitbook-core/src/propTypes/File.js13
-rw-r--r--packages/gitbook-core/src/propTypes/History.js11
-rw-r--r--packages/gitbook-core/src/propTypes/Language.js7
-rw-r--r--packages/gitbook-core/src/propTypes/Languages.js12
-rw-r--r--packages/gitbook-core/src/propTypes/Location.js12
-rw-r--r--packages/gitbook-core/src/propTypes/Page.js16
-rw-r--r--packages/gitbook-core/src/propTypes/Readme.js11
-rw-r--r--packages/gitbook-core/src/propTypes/Summary.js14
-rw-r--r--packages/gitbook-core/src/propTypes/SummaryArticle.js22
-rw-r--r--packages/gitbook-core/src/propTypes/SummaryPart.js14
-rw-r--r--packages/gitbook-core/src/propTypes/i18n.js10
-rw-r--r--packages/gitbook-core/src/propTypes/index.js19
-rw-r--r--packages/gitbook-core/src/reducers/components.js20
-rw-r--r--packages/gitbook-core/src/reducers/config.js15
-rw-r--r--packages/gitbook-core/src/reducers/file.js16
-rw-r--r--packages/gitbook-core/src/reducers/history.js82
-rw-r--r--packages/gitbook-core/src/reducers/i18n.js27
-rw-r--r--packages/gitbook-core/src/reducers/index.js15
-rw-r--r--packages/gitbook-core/src/reducers/languages.js12
-rw-r--r--packages/gitbook-core/src/reducers/page.js16
-rw-r--r--packages/gitbook-core/src/reducers/readme.js5
-rw-r--r--packages/gitbook-core/src/reducers/summary.js28
-rw-r--r--packages/gitbook-core/src/server.js2
-rw-r--r--packages/gitbook-plugin-copy-code/.gitignore31
-rw-r--r--packages/gitbook-plugin-copy-code/.npmignore2
-rw-r--r--packages/gitbook-plugin-copy-code/_assets/website/button.css27
-rw-r--r--packages/gitbook-plugin-copy-code/index.js10
-rw-r--r--packages/gitbook-plugin-copy-code/package.json29
-rw-r--r--packages/gitbook-plugin-copy-code/src/index.js82
-rw-r--r--packages/gitbook-plugin-headings/.gitignore31
-rw-r--r--packages/gitbook-plugin-headings/.npmignore2
-rw-r--r--packages/gitbook-plugin-headings/_assets/website/headings.css41
-rw-r--r--packages/gitbook-plugin-headings/index.js10
-rw-r--r--packages/gitbook-plugin-headings/package.json38
-rw-r--r--packages/gitbook-plugin-headings/src/index.js60
-rw-r--r--packages/gitbook-plugin-highlight/.gitignore31
-rw-r--r--packages/gitbook-plugin-highlight/.npmignore2
-rw-r--r--packages/gitbook-plugin-highlight/_assets/website/white.css92
-rw-r--r--packages/gitbook-plugin-highlight/index.js10
-rw-r--r--packages/gitbook-plugin-highlight/package.json29
-rw-r--r--packages/gitbook-plugin-highlight/src/ALIASES.js10
-rw-r--r--packages/gitbook-plugin-highlight/src/CodeBlock.js55
-rw-r--r--packages/gitbook-plugin-highlight/src/getLanguage.js34
-rw-r--r--packages/gitbook-plugin-highlight/src/index.js9
-rw-r--r--packages/gitbook-plugin-hints/.gitignore31
-rw-r--r--packages/gitbook-plugin-hints/.npmignore2
-rw-r--r--packages/gitbook-plugin-hints/README.md41
-rw-r--r--packages/gitbook-plugin-hints/_assets/website/plugin.css43
-rw-r--r--packages/gitbook-plugin-hints/index.js12
-rw-r--r--packages/gitbook-plugin-hints/package.json29
-rw-r--r--packages/gitbook-plugin-hints/src/index.js45
-rw-r--r--packages/gitbook-plugin-livereload/.gitignore31
-rw-r--r--packages/gitbook-plugin-livereload/LICENSE201
-rw-r--r--packages/gitbook-plugin-livereload/README.md3
-rw-r--r--packages/gitbook-plugin-livereload/package.json29
-rw-r--r--packages/gitbook-plugin-livereload/src/index.js18
-rw-r--r--packages/gitbook-plugin-lunr/.gitignore31
-rw-r--r--packages/gitbook-plugin-lunr/.npmignore2
-rw-r--r--packages/gitbook-plugin-lunr/index.js99
-rw-r--r--packages/gitbook-plugin-lunr/package.json44
-rw-r--r--packages/gitbook-plugin-lunr/src/actions.js43
-rw-r--r--packages/gitbook-plugin-lunr/src/index.js28
-rw-r--r--packages/gitbook-plugin-lunr/src/reducer.js31
-rw-r--r--packages/gitbook-plugin-search/.gitignore1
-rw-r--r--packages/gitbook-plugin-search/.npmignore1
-rw-r--r--packages/gitbook-plugin-search/README.md41
-rw-r--r--packages/gitbook-plugin-search/index.js4
-rw-r--r--packages/gitbook-plugin-search/package.json31
-rw-r--r--packages/gitbook-plugin-search/src/actions/search.js121
-rw-r--r--packages/gitbook-plugin-search/src/actions/types.js8
-rw-r--r--packages/gitbook-plugin-search/src/components/Input.js73
-rw-r--r--packages/gitbook-plugin-search/src/components/Results.js80
-rw-r--r--packages/gitbook-plugin-search/src/index.js33
-rw-r--r--packages/gitbook-plugin-search/src/models/Result.js20
-rw-r--r--packages/gitbook-plugin-search/src/reducers/index.js3
-rw-r--r--packages/gitbook-plugin-search/src/reducers/search.js56
-rw-r--r--packages/gitbook-plugin-sharing/.gitignore31
-rw-r--r--packages/gitbook-plugin-sharing/.npmignore2
-rw-r--r--packages/gitbook-plugin-sharing/README.md38
-rw-r--r--packages/gitbook-plugin-sharing/index.js10
-rw-r--r--packages/gitbook-plugin-sharing/package.json77
-rw-r--r--packages/gitbook-plugin-sharing/src/SITES.js72
-rw-r--r--packages/gitbook-plugin-sharing/src/components/ShareButton.js47
-rw-r--r--packages/gitbook-plugin-sharing/src/components/SharingButtons.js63
-rw-r--r--packages/gitbook-plugin-sharing/src/components/SiteButton.js29
-rw-r--r--packages/gitbook-plugin-sharing/src/index.js9
-rw-r--r--packages/gitbook-plugin-sharing/src/optionsShape.js20
-rw-r--r--packages/gitbook-plugin-sharing/src/shapes/options.js19
-rw-r--r--packages/gitbook-plugin-sharing/src/shapes/site.js13
-rw-r--r--packages/gitbook-plugin-theme-default/.gitignore1
-rw-r--r--packages/gitbook-plugin-theme-default/.npmignore1
-rw-r--r--packages/gitbook-plugin-theme-default/index.js3
-rw-r--r--packages/gitbook-plugin-theme-default/less/Body.less9
-rw-r--r--packages/gitbook-plugin-theme-default/less/Button.less22
-rw-r--r--packages/gitbook-plugin-theme-default/less/Dropdown.less56
-rw-r--r--packages/gitbook-plugin-theme-default/less/LoadingBar.less30
-rw-r--r--packages/gitbook-plugin-theme-default/less/Page.less16
-rw-r--r--packages/gitbook-plugin-theme-default/less/Panel.less7
-rw-r--r--packages/gitbook-plugin-theme-default/less/Search.less38
-rw-r--r--packages/gitbook-plugin-theme-default/less/Sidebar.less29
-rw-r--r--packages/gitbook-plugin-theme-default/less/Summary.less51
-rw-r--r--packages/gitbook-plugin-theme-default/less/Toolbar.less27
-rw-r--r--packages/gitbook-plugin-theme-default/less/Tooltipped.less100
-rw-r--r--packages/gitbook-plugin-theme-default/less/main.less50
-rw-r--r--packages/gitbook-plugin-theme-default/less/mixins.less15
-rw-r--r--packages/gitbook-plugin-theme-default/less/reset.less396
-rw-r--r--packages/gitbook-plugin-theme-default/less/variables.less55
-rw-r--r--packages/gitbook-plugin-theme-default/package.json80
-rwxr-xr-xpackages/gitbook-plugin-theme-default/prepublish.sh11
-rw-r--r--packages/gitbook-plugin-theme-default/src/actions/sidebar.js13
-rw-r--r--packages/gitbook-plugin-theme-default/src/actions/types.js4
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Body.js121
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/LoadingBar.js124
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Page.js30
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Sidebar.js25
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Summary.js111
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Theme.js57
-rw-r--r--packages/gitbook-plugin-theme-default/src/components/Toolbar.js43
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ar.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/bn.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ca.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/cs.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/de.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/el.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/en.json21
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/es.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/fa.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/fi.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/fr.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/he.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/index.js30
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/it.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ja.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ko.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/nl.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/no.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/pl.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/pt.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ro.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/ru.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/sv.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/tr.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/uk.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/vi.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json20
-rw-r--r--packages/gitbook-plugin-theme-default/src/index.js14
-rw-r--r--packages/gitbook-plugin-theme-default/src/reducers/index.js5
-rw-r--r--packages/gitbook-plugin-theme-default/src/reducers/sidebar.js18
-rw-r--r--packages/gitbook-plugin/CONTRIBUTING.md11
-rw-r--r--packages/gitbook-plugin/README.md1
-rw-r--r--packages/gitbook-plugin/package.json39
-rw-r--r--packages/gitbook-plugin/src/cli.js84
-rw-r--r--packages/gitbook-plugin/src/compile.js41
-rw-r--r--packages/gitbook-plugin/src/create.js61
-rw-r--r--packages/gitbook-plugin/src/index.js0
-rw-r--r--packages/gitbook-plugin/template/.eslintignore2
-rw-r--r--packages/gitbook-plugin/template/.eslintrc3
-rw-r--r--packages/gitbook-plugin/template/.gitignore31
-rw-r--r--packages/gitbook-plugin/template/.npmignore2
-rw-r--r--packages/gitbook-plugin/template/index.js10
-rw-r--r--packages/gitbook-plugin/template/src/index.js11
-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
478 files changed, 20833 insertions, 0 deletions
diff --git a/packages/gitbook-core/.babelrc b/packages/gitbook-core/.babelrc
new file mode 100644
index 0000000..5f27bda
--- /dev/null
+++ b/packages/gitbook-core/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["es2015", "react", "stage-2"]
+}
diff --git a/packages/gitbook-core/.gitignore b/packages/gitbook-core/.gitignore
new file mode 100644
index 0000000..eed58d7
--- /dev/null
+++ b/packages/gitbook-core/.gitignore
@@ -0,0 +1 @@
+gitbook.core.min.js
diff --git a/packages/gitbook-core/.npmignore b/packages/gitbook-core/.npmignore
new file mode 100644
index 0000000..b9cde8e
--- /dev/null
+++ b/packages/gitbook-core/.npmignore
@@ -0,0 +1,3 @@
+src
+!lib
+!dist
diff --git a/packages/gitbook-core/package.json b/packages/gitbook-core/package.json
new file mode 100644
index 0000000..182e6e3
--- /dev/null
+++ b/packages/gitbook-core/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "gitbook-core",
+ "version": "4.0.0",
+ "description": "Core for GitBook plugins API",
+ "main": "./lib/index.js",
+ "dependencies": {
+ "bluebird": "^3.4.6",
+ "classnames": "^2.2.5",
+ "entities": "^1.1.1",
+ "history": "^4.3.0",
+ "html-tags": "^1.1.1",
+ "immutable": "^3.8.1",
+ "mousetrap": "1.6.0",
+ "react": "15.4.1",
+ "react-addons-css-transition-group": "15.4.1",
+ "react-dom": "15.4.1",
+ "react-helmet": "^3.1.0",
+ "react-immutable-proptypes": "^2.1.0",
+ "react-intl": "^2.1.5",
+ "react-redux": "^4.4.5",
+ "react-safe-html": "0.4.0",
+ "redux": "^3.5.2",
+ "redux-thunk": "^2.1.0",
+ "reflexbox": "^2.2.2",
+ "whatwg-fetch": "^1.0.0"
+ },
+ "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",
+ "browserify": "^13.1.0",
+ "envify": "^3.4.1",
+ "uglify-js": "^2.7.3"
+ },
+ "scripts": {
+ "dist-lib": "rm -rf lib/ && babel -d lib/ src/",
+ "dist-standalone": "mkdir -p dist && browserify -r ./lib/index.js:gitbook-core -r react -r react-dom ./lib/index.js | uglifyjs -c > ./dist/gitbook.core.min.js",
+ "dist": "npm run dist-lib && npm run dist-standalone",
+ "prepublish": "npm run dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "author": "GitBook Inc. <contact@gitbook.com>",
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-core/src/actions/TYPES.js b/packages/gitbook-core/src/actions/TYPES.js
new file mode 100644
index 0000000..9876057
--- /dev/null
+++ b/packages/gitbook-core/src/actions/TYPES.js
@@ -0,0 +1,16 @@
+
+module.exports = {
+ // Components
+ REGISTER_COMPONENT: 'components/register',
+ UNREGISTER_COMPONENT: 'components/unregister',
+ // Navigation
+ HISTORY_ACTIVATE: 'history/activate',
+ HISTORY_DEACTIVATE: 'history/deactivate',
+ HISTORY_LISTEN: 'history/listen',
+ HISTORY_UPDATE: 'history/update',
+ PAGE_FETCH_START: 'history/fetch:start',
+ PAGE_FETCH_END: 'history/fetch:end',
+ PAGE_FETCH_ERROR: 'history/fetch:error',
+ // i18n
+ I18N_REGISTER_LOCALE: 'i18n/register:locale'
+};
diff --git a/packages/gitbook-core/src/actions/components.js b/packages/gitbook-core/src/actions/components.js
new file mode 100644
index 0000000..f21c382
--- /dev/null
+++ b/packages/gitbook-core/src/actions/components.js
@@ -0,0 +1,37 @@
+const ACTION_TYPES = require('./TYPES');
+
+/**
+ * Find all components matching a descriptor
+ * @param {List<ComponentDescriptor>} state
+ * @param {String} matching.role
+ */
+function findMatchingComponents(state, matching) {
+ return state
+ .filter(({descriptor}) => {
+ if (matching.role && matching.role !== descriptor.role) {
+ return false;
+ }
+
+ return true;
+ })
+ .map(component => component.Component);
+}
+
+/**
+ * Register a new component
+ * @param {React.Class} Component
+ * @param {Descriptor} descriptor
+ * @return {Action}
+ */
+function registerComponent(Component, descriptor) {
+ return {
+ type: ACTION_TYPES.REGISTER_COMPONENT,
+ Component,
+ descriptor
+ };
+}
+
+module.exports = {
+ findMatchingComponents,
+ registerComponent
+};
diff --git a/packages/gitbook-core/src/actions/history.js b/packages/gitbook-core/src/actions/history.js
new file mode 100644
index 0000000..1c33f4a
--- /dev/null
+++ b/packages/gitbook-core/src/actions/history.js
@@ -0,0 +1,188 @@
+const ACTION_TYPES = require('./TYPES');
+const getPayload = require('../lib/getPayload');
+const Location = require('../models/Location');
+
+const SUPPORTED = (
+ typeof window !== 'undefined' &&
+ window.history && window.history.pushState && window.history.replaceState &&
+ // pushState isn't reliable on iOS until 5.
+ !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]\D|WebApps\/.+CFNetwork)/)
+);
+
+/**
+ * Initialize the history
+ */
+function activate() {
+ return (dispatch, getState) => {
+ dispatch({
+ type: ACTION_TYPES.HISTORY_ACTIVATE,
+ listener: (location) => {
+ location = Location.fromNative(location);
+ const prevLocation = getState().history.location;
+
+ // Fetch page if required
+ if (!prevLocation || location.pathname !== prevLocation.pathname) {
+ dispatch(fetchPage(location.pathname));
+ }
+
+ // Signal location to listener
+ dispatch(emit());
+
+ // Update the location
+ dispatch({
+ type: ACTION_TYPES.HISTORY_UPDATE,
+ location
+ });
+ }
+ });
+
+ // Trigger for existing listeners
+ dispatch(emit());
+ };
+}
+
+/**
+ * Emit current location
+ * @param {List|Array<Function>} to?
+ */
+function emit(to) {
+ return (dispatch, getState) => {
+ const { listeners, client } = getState().history;
+
+ if (!client) {
+ return;
+ }
+
+ const location = Location.fromNative(client.location);
+
+ to = to || listeners;
+
+ to.forEach(handler => {
+ handler(location, dispatch, getState);
+ });
+ };
+}
+
+/**
+ * Cleanup the history
+ */
+function deactivate() {
+ return { type: ACTION_TYPES.HISTORY_DEACTIVATE };
+}
+
+/**
+ * Push a new url into the history
+ * @param {String|Location} location
+ * @return {Action} action
+ */
+function push(location) {
+ return (dispatch, getState) => {
+ const { client } = getState().history;
+ location = Location.fromNative(location);
+
+ if (SUPPORTED) {
+ client.push(location.toNative());
+ } else {
+ redirect(location.toString());
+ }
+ };
+}
+
+/**
+ * Replace current state in history
+ * @param {String|Location} location
+ * @return {Action} action
+ */
+function replace(location) {
+ return (dispatch, getState) => {
+ const { client } = getState().history;
+ location = Location.fromNative(location);
+
+ if (SUPPORTED) {
+ client.replace(location.toNative());
+ } else {
+ redirect(location.toString());
+ }
+ };
+}
+
+/**
+ * Hard redirection
+ * @param {String} uri
+ * @return {Action} action
+ */
+function redirect(uri) {
+ return () => {
+ window.location.href = uri;
+ };
+}
+
+/**
+ * Listen to url change
+ * @param {Function} listener
+ * @return {Action} action
+ */
+function listen(listener) {
+ return (dispatch, getState) => {
+ dispatch({ type: ACTION_TYPES.HISTORY_LISTEN, listener });
+
+ // Trigger for existing listeners
+ dispatch(emit([ listener ]));
+ };
+}
+
+/**
+ * Fetch a new page and update the store accordingly
+ * @param {String} pathname
+ * @return {Action} action
+ */
+function fetchPage(pathname) {
+ return (dispatch, getState) => {
+ dispatch({ type: ACTION_TYPES.PAGE_FETCH_START });
+
+ window.fetch(pathname, {
+ credentials: 'include'
+ })
+ .then(
+ response => {
+ return response.text();
+ }
+ )
+ .then(
+ html => {
+ const payload = getPayload(html);
+
+ if (!payload) {
+ throw new Error('No payload found in page');
+ }
+
+ dispatch({ type: ACTION_TYPES.PAGE_FETCH_END, payload });
+ }
+ )
+ .catch(
+ error => {
+ // dispatch(redirect(pathname));
+ dispatch({ type: ACTION_TYPES.PAGE_FETCH_ERROR, error });
+ }
+ );
+ };
+}
+
+/**
+ * Fetch a new article
+ * @param {SummaryArticle} article
+ * @return {Action} action
+ */
+function fetchArticle(article) {
+ return fetchPage(article.path);
+}
+
+module.exports = {
+ activate,
+ deactivate,
+ listen,
+ push,
+ replace,
+ fetchPage,
+ fetchArticle
+};
diff --git a/packages/gitbook-core/src/actions/i18n.js b/packages/gitbook-core/src/actions/i18n.js
new file mode 100644
index 0000000..115c5a1
--- /dev/null
+++ b/packages/gitbook-core/src/actions/i18n.js
@@ -0,0 +1,33 @@
+const ACTION_TYPES = require('./TYPES');
+
+/**
+ * Register messages for a locale
+ * @param {String} locale
+ * @param {Map<String:String>} messages
+ * @return {Action}
+ */
+function registerLocale(locale, messages) {
+ return { type: ACTION_TYPES.I18N_REGISTER_LOCALE, locale, messages };
+}
+
+/**
+ * Register multiple locales
+ * @param {Map<String:Object>} locales
+ * @return {Action}
+ */
+function registerLocales(locales) {
+ return (dispatch) => {
+ for (const locale in locales) {
+ if (!locales.hasOwnProperty(locale)) {
+ continue;
+ }
+
+ dispatch(registerLocale(locale, locales[locale]));
+ }
+ };
+}
+
+module.exports = {
+ registerLocale,
+ registerLocales
+};
diff --git a/packages/gitbook-core/src/components/Backdrop.js b/packages/gitbook-core/src/components/Backdrop.js
new file mode 100644
index 0000000..7b34b0d
--- /dev/null
+++ b/packages/gitbook-core/src/components/Backdrop.js
@@ -0,0 +1,56 @@
+const React = require('react');
+const HotKeys = require('./HotKeys');
+
+/**
+ * Backdrop for modals, dropdown, etc. that covers the whole screen
+ * and handles click and pressing escape.
+ *
+ * <Backdrop onClose={onCloseModal} />
+ */
+const Backdrop = React.createClass({
+ propTypes: {
+ // Callback when backdrop is closed
+ onClose: React.PropTypes.func.isRequired,
+ // Z-index for the backdrop
+ zIndex: React.PropTypes.number,
+ children: React.PropTypes.node
+ },
+
+ getDefaultProps() {
+ return {
+ zIndex: 200
+ };
+ },
+
+ onClick(event) {
+ const { onClose } = this.props;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ onClose();
+ },
+
+ render() {
+ const { zIndex, children, onClose } = this.props;
+ const style = {
+ zIndex,
+ position: 'fixed',
+ top: 0,
+ right: 0,
+ width: '100%',
+ height: '100%'
+ };
+ const keyMap = {
+ 'escape': onClose
+ };
+
+ return (
+ <HotKeys keyMap={keyMap}>
+ <div style={style} onClick={this.onClick}>{children}</div>
+ </HotKeys>
+ );
+ }
+});
+
+module.exports = Backdrop;
diff --git a/packages/gitbook-core/src/components/Button.js b/packages/gitbook-core/src/components/Button.js
new file mode 100644
index 0000000..4d929b8
--- /dev/null
+++ b/packages/gitbook-core/src/components/Button.js
@@ -0,0 +1,22 @@
+const React = require('react');
+const classNames = require('classnames');
+
+const Button = React.createClass({
+ propTypes: {
+ active: React.PropTypes.bool,
+ className: React.PropTypes.string,
+ children: React.PropTypes.node,
+ onClick: React.PropTypes.func
+ },
+
+ render() {
+ const { children, active, onClick } = this.props;
+ const className = classNames('GitBook-Button', this.props.className, {
+ active
+ });
+
+ return <button className={className} onClick={onClick}>{children}</button>;
+ }
+});
+
+module.exports = Button;
diff --git a/packages/gitbook-core/src/components/ButtonGroup.js b/packages/gitbook-core/src/components/ButtonGroup.js
new file mode 100644
index 0000000..4c20b68
--- /dev/null
+++ b/packages/gitbook-core/src/components/ButtonGroup.js
@@ -0,0 +1,23 @@
+const React = require('react');
+const classNames = require('classnames');
+
+const ButtonGroup = React.createClass({
+ propTypes: {
+ className: React.PropTypes.string,
+ children: React.PropTypes.node,
+ onClick: React.PropTypes.func
+ },
+
+ render() {
+ let { className, children } = this.props;
+
+ className = classNames(
+ 'GitBook-ButtonGroup',
+ className
+ );
+
+ return <div className={className}>{children}</div>;
+ }
+});
+
+module.exports = ButtonGroup;
diff --git a/packages/gitbook-core/src/components/ContextProvider.js b/packages/gitbook-core/src/components/ContextProvider.js
new file mode 100644
index 0000000..96a44e3
--- /dev/null
+++ b/packages/gitbook-core/src/components/ContextProvider.js
@@ -0,0 +1,34 @@
+const React = require('react');
+const { Provider } = require('react-redux');
+
+const ContextShape = require('../propTypes/Context');
+
+/**
+ * React component to provide a GitBook context to children components.
+ */
+
+const ContextProvider = React.createClass({
+ propTypes: {
+ context: ContextShape.isRequired,
+ children: React.PropTypes.node
+ },
+
+ childContextTypes: {
+ gitbook: ContextShape
+ },
+
+ getChildContext() {
+ const { context } = this.props;
+
+ return {
+ gitbook: context
+ };
+ },
+
+ render() {
+ const { context, children } = this.props;
+ return <Provider store={context.store}>{children}</Provider>;
+ }
+});
+
+module.exports = ContextProvider;
diff --git a/packages/gitbook-core/src/components/Dropdown.js b/packages/gitbook-core/src/components/Dropdown.js
new file mode 100644
index 0000000..83a377f
--- /dev/null
+++ b/packages/gitbook-core/src/components/Dropdown.js
@@ -0,0 +1,126 @@
+const React = require('react');
+const classNames = require('classnames');
+
+/**
+ * Dropdown to display a menu
+ *
+ * <Dropdown.Container>
+ *
+ * <Button />
+ *
+ * <Dropdown.Menu>
+ * <Dropdown.Item href={...}> ... </Dropdown.Item>
+ * <Dropdown.Item onClick={...}> ... </Dropdown.Item>
+ * </Dropdown.Menu>
+ * </Dropdown.Container>
+ */
+
+const DropdownContainer = React.createClass({
+ propTypes: {
+ className: React.PropTypes.string,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ let { className, children } = this.props;
+
+ className = classNames(
+ 'GitBook-Dropdown',
+ className
+ );
+
+ return (
+ <div className={className}>
+ {children}
+ </div>
+ );
+ }
+});
+
+/**
+ * A dropdown item which can contains informations.
+ */
+const DropdownItem = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const { children } = this.props;
+
+ return (
+ <div className="GitBook-DropdownItem">
+ {children}
+ </div>
+ );
+ }
+});
+
+
+/**
+ * A dropdown item, which is always a link.
+ */
+const DropdownItemLink = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node,
+ onClick: React.PropTypes.func,
+ href: React.PropTypes.string
+ },
+
+ onClick(event) {
+ const { onClick, href } = this.props;
+
+ if (href) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (onClick) {
+ onClick();
+ }
+ },
+
+ render() {
+ const { children, href, ...otherProps } = this.props;
+
+ return (
+ <a {...otherProps} className="GitBook-DropdownItemLink" href={href || '#'} onClick={this.onClick} >
+ {children}
+ </a>
+ );
+ }
+});
+
+
+/**
+ * A DropdownMenu to display DropdownItems. Must be inside a
+ * DropdownContainer.
+ */
+const DropdownMenu = React.createClass({
+ propTypes: {
+ className: React.PropTypes.string,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ let { className, children } = this.props;
+ className = classNames('GitBook-DropdownMenu', className);
+
+ return (
+ <div className={className}>
+ {children}
+ </div>
+ );
+ }
+});
+
+const Dropdown = {
+ Item: DropdownItem,
+ ItemLink: DropdownItemLink,
+ Menu: DropdownMenu,
+ Container: DropdownContainer
+};
+
+module.exports = Dropdown;
diff --git a/packages/gitbook-core/src/components/HTMLContent.js b/packages/gitbook-core/src/components/HTMLContent.js
new file mode 100644
index 0000000..9d15398
--- /dev/null
+++ b/packages/gitbook-core/src/components/HTMLContent.js
@@ -0,0 +1,77 @@
+const React = require('react');
+const ReactSafeHtml = require('react-safe-html');
+const { DOMProperty } = require('react-dom/lib/ReactInjection');
+const htmlTags = require('html-tags');
+const entities = require('entities');
+
+const { InjectedComponent } = require('./InjectedComponent');
+
+DOMProperty.injectDOMPropertyConfig({
+ Properties: {
+ align: DOMProperty.MUST_USE_ATTRIBUTE
+ },
+ isCustomAttribute: (attributeName) => {
+ return attributeName === 'align';
+ }
+});
+
+/*
+ HTMLContent is a container for the page HTML that parse the content and
+ render the right block.
+ All html elements can be extended using the injected component.
+ */
+
+function inject(injectedProps, Component) {
+ return (props) => {
+ const cleanProps = {
+ ...props,
+ className: props['class']
+ };
+ delete cleanProps['class'];
+
+ return (
+ <InjectedComponent {...injectedProps(cleanProps)}>
+ <Component {...cleanProps} />
+ </InjectedComponent>
+ );
+ };
+}
+
+const COMPONENTS = {
+ // Templating blocks are exported as <xblock name="youtube" props="{}" />
+ 'xblock': inject(
+ ({name, props}) => {
+ props = entities.decodeHTML(props);
+ return {
+ matching: { role: `block:${name}` },
+ props: JSON.parse(props)
+ };
+ },
+ props => <div {...props} />
+ )
+};
+
+htmlTags.forEach(tag => {
+ COMPONENTS[tag] = inject(
+ props => {
+ return {
+ matching: { role: `html:${tag}` },
+ props
+ };
+ },
+ props => React.createElement(tag, props)
+ );
+});
+
+const HTMLContent = React.createClass({
+ propTypes: {
+ html: React.PropTypes.string.isRequired
+ },
+
+ render() {
+ const { html } = this.props;
+ return <ReactSafeHtml html={html} components={COMPONENTS} />;
+ }
+});
+
+module.exports = HTMLContent;
diff --git a/packages/gitbook-core/src/components/HotKeys.js b/packages/gitbook-core/src/components/HotKeys.js
new file mode 100644
index 0000000..e2a8154
--- /dev/null
+++ b/packages/gitbook-core/src/components/HotKeys.js
@@ -0,0 +1,59 @@
+const React = require('react');
+const Mousetrap = require('mousetrap');
+const { Map } = require('immutable');
+
+/**
+ * Defines hotkeys globally when this component is mounted.
+ *
+ * keyMap = {
+ * 'escape': (e) => quit()
+ * 'mod+s': (e) => save()
+ * }
+ *
+ * <HotKeys keyMap={keyMap}>
+ * < ... />
+ * </HotKeys>
+ */
+
+const HotKeys = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node.isRequired,
+ keyMap: React.PropTypes.objectOf(React.PropTypes.func)
+ },
+
+ getDefaultProps() {
+ return { keyMap: {} };
+ },
+
+ updateBindings(keyMap) {
+ Map(keyMap).forEach((handler, key) => {
+ Mousetrap.bind(key, handler);
+ });
+ },
+
+ clearBindings(keyMap) {
+ Map(keyMap).forEach((handler, key) => {
+ Mousetrap.unbind(key, handler);
+ });
+ },
+
+ componentDidMount() {
+ this.updateBindings(this.props.keyMap);
+ },
+
+ componentDidUpdate(prevProps) {
+ this.clearBindings(prevProps.keyMap);
+ this.updateBindings(this.props.keyMap);
+ },
+
+ componentWillUnmount() {
+ this.clearBindings(this.props.keyMap);
+ },
+
+ render() {
+ // Simply render the only child
+ return React.Children.only(this.props.children);
+ }
+});
+
+module.exports = HotKeys;
diff --git a/packages/gitbook-core/src/components/I18nProvider.js b/packages/gitbook-core/src/components/I18nProvider.js
new file mode 100644
index 0000000..b6b2d0f
--- /dev/null
+++ b/packages/gitbook-core/src/components/I18nProvider.js
@@ -0,0 +1,28 @@
+const { Map } = require('immutable');
+const React = require('react');
+const intl = require('react-intl');
+const ReactRedux = require('react-redux');
+
+const I18nProvider = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node,
+ messages: React.PropTypes.object
+ },
+
+ render() {
+ let { messages } = this.props;
+ messages = messages.get('en', Map()).toJS();
+
+ return (
+ <intl.IntlProvider locale={'en'} messages={messages}>
+ {this.props.children}
+ </intl.IntlProvider>
+ );
+ }
+});
+
+const mapStateToProps = state => {
+ return { messages: state.i18n.messages };
+};
+
+module.exports = ReactRedux.connect(mapStateToProps)(I18nProvider);
diff --git a/packages/gitbook-core/src/components/Icon.js b/packages/gitbook-core/src/components/Icon.js
new file mode 100644
index 0000000..5f2c751
--- /dev/null
+++ b/packages/gitbook-core/src/components/Icon.js
@@ -0,0 +1,28 @@
+const React = require('react');
+
+const Icon = React.createClass({
+ propTypes: {
+ id: React.PropTypes.string,
+ type: React.PropTypes.string,
+ className: React.PropTypes.string
+ },
+
+ getDefaultProps() {
+ return {
+ type: 'fa'
+ };
+ },
+
+ render() {
+ const { id, type } = this.props;
+ let { className } = this.props;
+
+ if (id) {
+ className = 'GitBook-Icon ' + type + ' ' + type + '-' + id;
+ }
+
+ return <i className={className}/>;
+ }
+});
+
+module.exports = Icon;
diff --git a/packages/gitbook-core/src/components/Import.js b/packages/gitbook-core/src/components/Import.js
new file mode 100644
index 0000000..68318b9
--- /dev/null
+++ b/packages/gitbook-core/src/components/Import.js
@@ -0,0 +1,48 @@
+const React = require('react');
+const Head = require('react-helmet');
+const ReactRedux = require('react-redux');
+
+/**
+ * Resolve a file url to a relative url in current state
+ * @param {String} href
+ * @param {State} state
+ * @return {String}
+ */
+function resolveForCurrentFile(href, state) {
+ const { file } = state;
+ return file.relative(href);
+}
+
+const ImportLink = ReactRedux.connect((state, {rel, href}) => {
+ href = resolveForCurrentFile(href, state);
+
+ return {
+ link: [
+ {
+ rel,
+ href
+ }
+ ]
+ };
+})(Head);
+
+const ImportScript = ReactRedux.connect((state, {type, src}) => {
+ src = resolveForCurrentFile(src, state);
+
+ return {
+ script: [
+ {
+ type,
+ src
+ }
+ ]
+ };
+})(Head);
+
+const ImportCSS = props => <ImportLink rel="stylesheet" {...props} />;
+
+module.exports = {
+ ImportLink,
+ ImportScript,
+ ImportCSS
+};
diff --git a/packages/gitbook-core/src/components/InjectedComponent.js b/packages/gitbook-core/src/components/InjectedComponent.js
new file mode 100644
index 0000000..097edaf
--- /dev/null
+++ b/packages/gitbook-core/src/components/InjectedComponent.js
@@ -0,0 +1,117 @@
+const React = require('react');
+const ReactRedux = require('react-redux');
+const { List } = require('immutable');
+
+const { findMatchingComponents } = require('../actions/components');
+
+/*
+ Public: InjectedComponent makes it easy to include a set of dynamically registered
+ components inside of your React render method. Rather than explicitly render
+ an array of buttons, for example, you can use InjectedComponentSet:
+
+ ```js
+ <InjectedComponentSet className="message-actions"
+ matching={{role: 'ThreadActionButton'}}
+ props={{ a: 1 }}>
+ ```
+
+ InjectedComponentSet will look up components registered for the location you provide,
+ render them inside a {Flexbox} and pass them `exposedProps`. By default, all injected
+ children are rendered inside {UnsafeComponent} wrappers to prevent third-party code
+ from throwing exceptions that break React renders.
+
+ InjectedComponentSet monitors the ComponentStore for changes. If a new component
+ is registered into the location you provide, InjectedComponentSet will re-render.
+ If no matching components is found, the InjectedComponent renders an empty span.
+ */
+
+const Injection = React.createClass({
+ propTypes: {
+ component: React.PropTypes.func,
+ props: React.PropTypes.object,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const Comp = this.props.component;
+ const { props, children } = this.props;
+
+ // TODO: try to render with an error handling for unsafe component
+ return <Comp {...props}>{children}</Comp>;
+ }
+});
+
+const InjectedComponentSet = React.createClass({
+ propTypes: {
+ components: React.PropTypes.oneOfType([
+ React.PropTypes.arrayOf(React.PropTypes.func),
+ React.PropTypes.instanceOf(List)
+ ]).isRequired,
+ props: React.PropTypes.object,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const { components, props, children, ...divProps } = this.props;
+
+ const inner = components.map((Comp, i) => <Injection key={i} component={Comp} props={props} />);
+
+ return (
+ <div {...divProps}>
+ {children}
+ {inner}
+ </div>
+ );
+ }
+});
+
+/**
+ * Render only the first component matching
+ */
+const InjectedComponent = React.createClass({
+ propTypes: {
+ components: React.PropTypes.oneOfType([
+ React.PropTypes.arrayOf(React.PropTypes.func),
+ React.PropTypes.instanceOf(List)
+ ]).isRequired,
+ props: React.PropTypes.object,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ let { components, props, children } = this.props;
+
+ if (!children) {
+ children = null;
+ } else {
+ children = React.Children.only(children);
+ }
+
+ return components.reduce((inner, Comp) => {
+ return (
+ <Injection component={Comp} props={props}>
+ {inner}
+ </Injection>
+ );
+ }, children);
+ }
+});
+
+/**
+ * Map Redux state to InjectedComponentSet's props
+ */
+function mapStateToProps(state, props) {
+ const { components } = state;
+ const { matching } = props;
+
+ return {
+ components: findMatchingComponents(components, matching)
+ };
+}
+
+const connect = ReactRedux.connect(mapStateToProps);
+
+module.exports = {
+ InjectedComponent: connect(InjectedComponent),
+ InjectedComponentSet: connect(InjectedComponentSet)
+};
diff --git a/packages/gitbook-core/src/components/Link.js b/packages/gitbook-core/src/components/Link.js
new file mode 100644
index 0000000..ab364bb
--- /dev/null
+++ b/packages/gitbook-core/src/components/Link.js
@@ -0,0 +1,37 @@
+const React = require('react');
+const ReactRedux = require('react-redux');
+
+const File = require('../models/File');
+const SummaryArticle = require('../models/SummaryArticle');
+const SummaryArticleShape = require('../propTypes/SummaryArticle');
+const FileShape = require('../propTypes/File');
+
+const Link = React.createClass({
+ propTypes: {
+ currentFile: FileShape,
+ children: React.PropTypes.node,
+
+ // Destination of the link
+ to: React.PropTypes.oneOfType([
+ React.PropTypes.string,
+ SummaryArticleShape,
+ FileShape
+ ])
+ },
+
+ render() {
+ const { currentFile, to, children, ...props } = this.props;
+ let href = to;
+
+ if (SummaryArticle.is(to) || File.is(to)) {
+ href = to.url;
+ }
+
+ href = currentFile.relative(href);
+ return <a href={href} {...props}>{children}</a>;
+ }
+});
+
+module.exports = ReactRedux.connect(state => {
+ return { currentFile: state.file };
+})(Link);
diff --git a/packages/gitbook-core/src/components/PJAXWrapper.js b/packages/gitbook-core/src/components/PJAXWrapper.js
new file mode 100644
index 0000000..6ed0697
--- /dev/null
+++ b/packages/gitbook-core/src/components/PJAXWrapper.js
@@ -0,0 +1,102 @@
+const React = require('react');
+const ReactRedux = require('react-redux');
+const History = require('../actions/history');
+
+/**
+ * Check if an element is inside a link
+ * @param {DOMElement} el
+ * @param {String} name
+ * @return {DOMElement|undefined
+ */
+function findParentByTagName(el, name) {
+ while (el && el !== document) {
+ if (el.tagName && el.tagName.toUpperCase() === name) {
+ return el;
+ }
+ el = el.parentNode;
+ }
+
+ return false;
+}
+
+/**
+ * Internal: Return the `href` component of given URL object with the hash
+ * portion removed.
+ *
+ * @param {Location|HTMLAnchorElement} location
+ * @return {String}
+ */
+function stripHash(location) {
+ return location.href.replace(/#.*/, '');
+}
+
+/**
+ * Test if a click event should be handled,
+ * return the new url if it's a normal lcick
+ */
+function getHrefForEvent(event) {
+ const link = findParentByTagName(event.target, 'A');
+
+ if (!link)
+ return;
+
+ // Middle click, cmd click, and ctrl click should open
+ // links in a new tab as normal.
+ if (event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
+ return;
+
+ // Ignore cross origin links
+ if (location.protocol !== link.protocol || location.hostname !== link.hostname)
+ return;
+
+ // Ignore case when a hash is being tacked on the current URL
+ if (link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location))
+ return;
+
+ // Ignore event with default prevented
+ if (event.defaultPrevented)
+ return;
+
+ // Explicitly ignored
+ if (link.getAttribute('data-nopjax'))
+ return;
+
+ return link.pathname;
+}
+
+/*
+ Wrapper to bind all navigation events to fetch pages.
+ */
+
+const PJAXWrapper = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node,
+ dispatch: React.PropTypes.func
+ },
+
+ onClick(event) {
+ const { dispatch } = this.props;
+ const href = getHrefForEvent(event);
+
+ if (!href) {
+ return;
+ }
+
+ event.preventDefault();
+ dispatch(History.push(href));
+ },
+
+ componentDidMount() {
+ document.addEventListener('click', this.onClick, false);
+ },
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.onClick, false);
+ },
+
+ render() {
+ return React.Children.only(this.props.children);
+ }
+});
+
+module.exports = ReactRedux.connect()(PJAXWrapper);
diff --git a/packages/gitbook-core/src/components/Panel.js b/packages/gitbook-core/src/components/Panel.js
new file mode 100644
index 0000000..694cc29
--- /dev/null
+++ b/packages/gitbook-core/src/components/Panel.js
@@ -0,0 +1,22 @@
+const React = require('react');
+const classNames = require('classnames');
+
+const Panel = React.createClass({
+ propTypes: {
+ className: React.PropTypes.string,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ let { className, children } = this.props;
+ className = classNames('GitBook-Panel', className);
+
+ return (
+ <div className={className}>
+ {children}
+ </div>
+ );
+ }
+});
+
+module.exports = Panel;
diff --git a/packages/gitbook-core/src/components/Tooltipped.js b/packages/gitbook-core/src/components/Tooltipped.js
new file mode 100644
index 0000000..4d297fd
--- /dev/null
+++ b/packages/gitbook-core/src/components/Tooltipped.js
@@ -0,0 +1,44 @@
+const React = require('react');
+const classNames = require('classnames');
+
+const POSITIONS = {
+ BOTTOM_RIGHT: 'e',
+ BOTTOM_LEFT: 'w',
+ TOP_LEFT: 'nw',
+ TOP_RIGHT: 'ne',
+ BOTTOM: '',
+ TOP: 'n'
+};
+
+const Tooltipped = React.createClass({
+ propTypes: {
+ title: React.PropTypes.string.isRequired,
+ position: React.PropTypes.string,
+ open: React.PropTypes.bool,
+ children: React.PropTypes.node
+ },
+
+ statics: {
+ POSITIONS
+ },
+
+ render() {
+ const { title, position, open, children } = this.props;
+
+ const className = classNames(
+ 'GitBook-Tooltipped',
+ position ? 'Tooltipped-' + position : '',
+ {
+ 'Tooltipped-o': open
+ }
+ );
+
+ return (
+ <div className={className} aria-label={title}>
+ {children}
+ </div>
+ );
+ }
+});
+
+module.exports = Tooltipped;
diff --git a/packages/gitbook-core/src/index.js b/packages/gitbook-core/src/index.js
new file mode 100644
index 0000000..3f0120c
--- /dev/null
+++ b/packages/gitbook-core/src/index.js
@@ -0,0 +1,73 @@
+require('whatwg-fetch');
+
+const React = require('react');
+const ReactCSSTransitionGroup = require('react-addons-css-transition-group');
+const Immutable = require('immutable');
+const Head = require('react-helmet');
+const Promise = require('bluebird');
+const { Provider } = require('react-redux');
+const { Flex, Box } = require('reflexbox');
+
+const { InjectedComponent, InjectedComponentSet } = require('./components/InjectedComponent');
+const { ImportLink, ImportScript, ImportCSS } = require('./components/Import');
+const HTMLContent = require('./components/HTMLContent');
+const Link = require('./components/Link');
+const Icon = require('./components/Icon');
+const HotKeys = require('./components/HotKeys');
+const Button = require('./components/Button');
+const ButtonGroup = require('./components/ButtonGroup');
+const Dropdown = require('./components/Dropdown');
+const Panel = require('./components/Panel');
+const Backdrop = require('./components/Backdrop');
+const Tooltipped = require('./components/Tooltipped');
+const I18nProvider = require('./components/I18nProvider');
+
+const ACTIONS = require('./actions/TYPES');
+
+const PropTypes = require('./propTypes');
+const connect = require('./lib/connect');
+const createPlugin = require('./lib/createPlugin');
+const createReducer = require('./lib/createReducer');
+const createContext = require('./lib/createContext');
+const composeReducer = require('./lib/composeReducer');
+const bootstrap = require('./lib/bootstrap');
+const renderWithContext = require('./lib/renderWithContext');
+
+module.exports = {
+ ACTIONS,
+ bootstrap,
+ renderWithContext,
+ connect,
+ createPlugin,
+ createReducer,
+ createContext,
+ composeReducer,
+ // React Components
+ I18nProvider,
+ InjectedComponent,
+ InjectedComponentSet,
+ HTMLContent,
+ Head,
+ Panel,
+ Provider,
+ ImportLink,
+ ImportScript,
+ ImportCSS,
+ FlexLayout: Flex,
+ FlexBox: Box,
+ Link,
+ Icon,
+ HotKeys,
+ Button,
+ ButtonGroup,
+ Dropdown,
+ Backdrop,
+ Tooltipped,
+ // Utilities
+ PropTypes,
+ // Librairies
+ React,
+ ReactCSSTransitionGroup,
+ Immutable,
+ Promise
+};
diff --git a/packages/gitbook-core/src/lib/bootstrap.js b/packages/gitbook-core/src/lib/bootstrap.js
new file mode 100644
index 0000000..f3c99b7
--- /dev/null
+++ b/packages/gitbook-core/src/lib/bootstrap.js
@@ -0,0 +1,29 @@
+const ReactDOM = require('react-dom');
+
+const getPayload = require('./getPayload');
+const createContext = require('./createContext');
+const renderWithContext = require('./renderWithContext');
+
+/**
+ * Bootstrap GitBook on the browser (this function should not be called on the server side).
+ * @param {Object} matching
+ */
+function bootstrap(matching) {
+ const initialState = getPayload(window.document);
+ const plugins = window.gitbookPlugins;
+
+ const mountNode = document.getElementById('content');
+
+ // Create the redux store
+ const context = createContext(plugins, initialState);
+
+ window.gitbookContext = context;
+
+ // Render with the store
+ const el = renderWithContext(context, matching);
+
+ ReactDOM.render(el, mountNode);
+}
+
+
+module.exports = bootstrap;
diff --git a/packages/gitbook-core/src/lib/composeReducer.js b/packages/gitbook-core/src/lib/composeReducer.js
new file mode 100644
index 0000000..fa2a589
--- /dev/null
+++ b/packages/gitbook-core/src/lib/composeReducer.js
@@ -0,0 +1,16 @@
+
+/**
+ * Compose multiple reducers into one
+ * @param {Function} reducers
+ * @return {Function}
+ */
+function composeReducer(...reducers) {
+ return (state, action) => {
+ return reducers.reduce(
+ (newState, reducer) => reducer(newState, action),
+ state
+ );
+ };
+}
+
+module.exports = composeReducer;
diff --git a/packages/gitbook-core/src/lib/connect.js b/packages/gitbook-core/src/lib/connect.js
new file mode 100644
index 0000000..a34299d
--- /dev/null
+++ b/packages/gitbook-core/src/lib/connect.js
@@ -0,0 +1,70 @@
+const React = require('react');
+const ReactRedux = require('react-redux');
+const { injectIntl } = require('react-intl');
+
+const ContextShape = require('../propTypes/Context');
+
+/**
+ * Use the GitBook context provided by ContextProvider to map actions to props
+ * @param {ReactComponent} Component
+ * @param {Function} mapActionsToProps
+ * @return {ReactComponent}
+ */
+function connectToActions(Component, mapActionsToProps) {
+ if (!mapActionsToProps) {
+ return Component;
+ }
+
+ return React.createClass({
+ displayName: `ConnectActions(${Component.displayName})`,
+ propTypes: {
+ children: React.PropTypes.node
+ },
+
+ contextTypes: {
+ gitbook: ContextShape.isRequired
+ },
+
+ render() {
+ const { gitbook } = this.context;
+ const { children, ...props } = this.props;
+ const { actions, store } = gitbook;
+
+ const actionsProps = mapActionsToProps(actions, store.dispatch);
+
+ return <Component {...props} {...actionsProps}>{children}</Component>;
+ }
+ });
+}
+
+/**
+ * Connect to i18n
+ * @param {ReactComponent} Component
+ * @return {ReactComponent}
+ */
+function connectToI18n(Component) {
+ return injectIntl(({intl, children, ...props}) => {
+ const i18n = {
+ t: (id, values) => intl.formatMessage({ id }, values)
+ };
+
+ return <Component {...props} i18n={i18n}>{children}</Component>;
+ });
+}
+
+/**
+ * Connect a component to the GitBook context (store and actions).
+ *
+ * @param {ReactComponent} Component
+ * @param {Function} mapStateToProps
+ * @return {ReactComponent}
+ */
+function connect(Component, mapStateToProps, mapActionsToProps) {
+ Component = ReactRedux.connect(mapStateToProps)(Component);
+ Component = connectToI18n(Component);
+ Component = connectToActions(Component, mapActionsToProps);
+
+ return Component;
+}
+
+module.exports = connect;
diff --git a/packages/gitbook-core/src/lib/createContext.js b/packages/gitbook-core/src/lib/createContext.js
new file mode 100644
index 0000000..ba0c7e1
--- /dev/null
+++ b/packages/gitbook-core/src/lib/createContext.js
@@ -0,0 +1,76 @@
+/* eslint-disable no-console */
+const Redux = require('redux');
+const ReduxThunk = require('redux-thunk').default;
+
+const Plugin = require('../models/Plugin');
+const Context = require('../models/Context');
+const coreReducers = require('../reducers');
+const composeReducer = require('./composeReducer');
+
+const Components = require('../actions/components');
+const I18n = require('../actions/i18n');
+const History = require('../actions/history');
+
+const isBrowser = (typeof window !== 'undefined');
+
+/**
+ * The core plugin defines the defualt behaviour of GitBook and provides
+ * actions to other plugins.
+ * @type {Plugin}
+ */
+const corePlugin = new Plugin({
+ reduce: coreReducers,
+ actions: {
+ Components, I18n, History
+ }
+});
+
+/**
+ * Create a new context containing redux store from an initial state and a list of plugins.
+ * Each plugin entry is the result of {createPlugin}.
+ *
+ * @param {Array<Plugin>} plugins
+ * @param {Object} initialState
+ * @return {Context} context
+ */
+function createContext(plugins, initialState) {
+ plugins = [corePlugin].concat(plugins);
+
+ // Compose the reducer from core with plugins
+ const pluginReducers = plugins.map(plugin => plugin.reduce);
+ const reducer = composeReducer(...pluginReducers);
+
+ // Get actions from all plugins
+ const actions = plugins.reduce((accu, plugin) => {
+ return Object.assign(accu, plugin.actions);
+ }, {});
+
+ // Create thunk middleware which include actions
+ const thunk = ReduxThunk.withExtraArgument(actions);
+
+ // Create the redux store
+ const store = Redux.createStore(
+ (state, action) => {
+ if (isBrowser) {
+ console.log('[store]', action.type);
+ }
+ return reducer(state, action);
+ },
+ initialState,
+ Redux.compose(Redux.applyMiddleware(thunk))
+ );
+
+ // Create the context
+ const context = new Context({
+ store,
+ plugins,
+ actions
+ });
+
+ // Initialize the plugins
+ context.activate();
+
+ return context;
+}
+
+module.exports = createContext;
diff --git a/packages/gitbook-core/src/lib/createPlugin.js b/packages/gitbook-core/src/lib/createPlugin.js
new file mode 100644
index 0000000..cb5d2be
--- /dev/null
+++ b/packages/gitbook-core/src/lib/createPlugin.js
@@ -0,0 +1,27 @@
+const Plugin = require('../models/Plugin');
+
+/**
+ * Create a plugin to extend the state and the views.
+ *
+ * @param {Function(dispatch, state)} plugin.init
+ * @param {Function(state, action)} plugin.reduce
+ * @param {Object} plugin.actions
+ * @return {Plugin}
+ */
+function createPlugin({ activate, deactivate, reduce, actions }) {
+ const plugin = new Plugin({
+ activate,
+ deactivate,
+ reduce,
+ actions
+ });
+
+ if (typeof window !== 'undefined') {
+ window.gitbookPlugins = window.gitbookPlugins || [];
+ window.gitbookPlugins.push(plugin);
+ }
+
+ return plugin;
+}
+
+module.exports = createPlugin;
diff --git a/packages/gitbook-core/src/lib/createReducer.js b/packages/gitbook-core/src/lib/createReducer.js
new file mode 100644
index 0000000..2ebecfb
--- /dev/null
+++ b/packages/gitbook-core/src/lib/createReducer.js
@@ -0,0 +1,27 @@
+
+/**
+ * Helper to create a reducer that extend the store.
+ *
+ * @param {String} property
+ * @param {Function(state, action): state} reduce
+ * @return {Function(state, action): state}
+ */
+function createReducer(name, reduce) {
+ return (state, action) => {
+ const value = state[name];
+ const newValue = reduce(value, action);
+
+ if (newValue === value) {
+ return state;
+ }
+
+ const newState = {
+ ...state,
+ [name]: newValue
+ };
+
+ return newState;
+ };
+}
+
+module.exports = createReducer;
diff --git a/packages/gitbook-core/src/lib/getPayload.js b/packages/gitbook-core/src/lib/getPayload.js
new file mode 100644
index 0000000..2d54b9e
--- /dev/null
+++ b/packages/gitbook-core/src/lib/getPayload.js
@@ -0,0 +1,19 @@
+
+/**
+ * Get the payload for a GitBook page
+ * @param {String|DOMDocument} html
+ * @return {Object}
+ */
+function getPayload(html) {
+ if (typeof html === 'string') {
+ const parser = new DOMParser();
+ html = parser.parseFromString(html, 'text/html');
+ }
+
+ const script = html.querySelector('script[type="application/payload+json"]');
+ const payload = JSON.parse(script.innerHTML);
+
+ return payload;
+}
+
+module.exports = getPayload;
diff --git a/packages/gitbook-core/src/lib/renderWithContext.js b/packages/gitbook-core/src/lib/renderWithContext.js
new file mode 100644
index 0000000..dc7e1f2
--- /dev/null
+++ b/packages/gitbook-core/src/lib/renderWithContext.js
@@ -0,0 +1,55 @@
+const React = require('react');
+
+const { InjectedComponent } = require('../components/InjectedComponent');
+const PJAXWrapper = require('../components/PJAXWrapper');
+const I18nProvider = require('../components/I18nProvider');
+const ContextProvider = require('../components/ContextProvider');
+const History = require('../actions/history');
+const contextShape = require('../propTypes/context');
+
+const GitBookApplication = React.createClass({
+ propTypes: {
+ context: contextShape,
+ matching: React.PropTypes.object
+ },
+
+ componentDidMount() {
+ const { context } = this.props;
+ context.dispatch(History.activate());
+ },
+
+ componentWillUnmount() {
+ const { context } = this.props;
+ context.dispatch(History.deactivate());
+ },
+
+ render() {
+ const { context, matching } = this.props;
+
+ return (
+ <ContextProvider context={context}>
+ <PJAXWrapper>
+ <I18nProvider>
+ <InjectedComponent matching={matching} />
+ </I18nProvider>
+ </PJAXWrapper>
+ </ContextProvider>
+ );
+ }
+});
+
+
+/**
+ * Render the application for a GitBook context.
+ *
+ * @param {GitBookContext} context
+ * @param {Object} matching
+ * @return {React.Element} element
+ */
+function renderWithContext(context, matching) {
+ return (
+ <GitBookApplication context={context} matching={matching} />
+ );
+}
+
+module.exports = renderWithContext;
diff --git a/packages/gitbook-core/src/models/Context.js b/packages/gitbook-core/src/models/Context.js
new file mode 100644
index 0000000..f4b0d4c
--- /dev/null
+++ b/packages/gitbook-core/src/models/Context.js
@@ -0,0 +1,58 @@
+const { Record, List } = require('immutable');
+
+const DEFAULTS = {
+ store: null,
+ actions: {},
+ plugins: List()
+};
+
+class Context extends Record(DEFAULTS) {
+ constructor(...args) {
+ super(...args);
+
+ this.dispatch = this.dispatch.bind(this);
+ this.getState = this.getState.bind(this);
+ }
+
+ /**
+ * Return current state
+ * @return {Object}
+ */
+ getState() {
+ const { store } = this;
+ return store.getState();
+ }
+
+ /**
+ * Dispatch an action
+ * @param {Action} action
+ */
+ dispatch(action) {
+ const { store } = this;
+ return store.dispatch(action);
+ }
+
+ /**
+ * Deactivate the context, cleanup resources from plugins.
+ */
+ deactivate() {
+ const { plugins, actions } = this;
+
+ plugins.forEach(plugin => {
+ plugin.deactivate(this.dispatch, this.getState, actions);
+ });
+ }
+
+ /**
+ * Activate the context and the plugins.
+ */
+ activate() {
+ const { plugins, actions } = this;
+
+ plugins.forEach(plugin => {
+ plugin.activate(this.dispatch, this.getState, actions);
+ });
+ }
+}
+
+module.exports = Context;
diff --git a/packages/gitbook-core/src/models/File.js b/packages/gitbook-core/src/models/File.js
new file mode 100644
index 0000000..efc4f11
--- /dev/null
+++ b/packages/gitbook-core/src/models/File.js
@@ -0,0 +1,54 @@
+const path = require('path');
+const { Record } = require('immutable');
+
+const DEFAULTS = {
+ type: '',
+ mtime: new Date(),
+ path: '',
+ url: ''
+};
+
+class File extends Record(DEFAULTS) {
+ constructor(file = {}) {
+ if (typeof file === 'string') {
+ file = { path: file, url: file };
+ }
+
+ super({
+ ...file,
+ mtime: new Date(file.mtime)
+ });
+ }
+
+ /**
+ * @param {String} to Absolute path
+ * @return {String} The same path, but relative to this file
+ */
+ relative(to) {
+ return path.relative(
+ path.dirname(this.path),
+ to
+ ) || './';
+ }
+
+ /**
+ * Return true if file is an instance of File
+ * @param {Mixed} file
+ * @return {Boolean} isFile
+ */
+ static is(file) {
+ return (file instanceof File);
+ }
+
+ /**
+ * Create a file instance
+ * @param {Mixed|File} file
+ * @return {File} file
+ */
+ static create(file) {
+ return File.is(file) ?
+ file : new File(file);
+ }
+}
+
+module.exports = File;
diff --git a/packages/gitbook-core/src/models/Language.js b/packages/gitbook-core/src/models/Language.js
new file mode 100644
index 0000000..20fc237
--- /dev/null
+++ b/packages/gitbook-core/src/models/Language.js
@@ -0,0 +1,12 @@
+const { Record } = require('immutable');
+
+const DEFAULTS = {
+ id: null,
+ title: null
+};
+
+class Language extends Record(DEFAULTS) {
+
+}
+
+module.exports = Language;
diff --git a/packages/gitbook-core/src/models/Languages.js b/packages/gitbook-core/src/models/Languages.js
new file mode 100644
index 0000000..b698d14
--- /dev/null
+++ b/packages/gitbook-core/src/models/Languages.js
@@ -0,0 +1,40 @@
+const { Record, List } = require('immutable');
+const Language = require('./Language');
+const File = require('./File');
+
+const DEFAULTS = {
+ current: String(),
+ file: new File(),
+ list: List()
+};
+
+class Languages extends Record(DEFAULTS) {
+ constructor(spec = {}) {
+ super({
+ ...spec,
+ file: File.create(spec.file),
+ list: List(spec.list).map(lang => new Language(lang))
+ });
+ }
+
+ /**
+ * Return true if file is an instance of Languages
+ * @param {Mixed} langs
+ * @return {Boolean}
+ */
+ static is(langs) {
+ return (langs instanceof Languages);
+ }
+
+ /**
+ * Create a Languages instance
+ * @param {Mixed|Languages} langs
+ * @return {Languages}
+ */
+ static create(langs) {
+ return Languages.is(langs) ?
+ langs : new Languages(langs);
+ }
+}
+
+module.exports = Languages;
diff --git a/packages/gitbook-core/src/models/Location.js b/packages/gitbook-core/src/models/Location.js
new file mode 100644
index 0000000..cdfea2d
--- /dev/null
+++ b/packages/gitbook-core/src/models/Location.js
@@ -0,0 +1,78 @@
+const { Record, Map } = require('immutable');
+const querystring = require('querystring');
+
+const DEFAULTS = {
+ pathname: String(''),
+ // Hash without the #
+ hash: String(''),
+ // If query is a non empty map
+ query: Map()
+};
+
+class Location extends Record(DEFAULTS) {
+
+ /**
+ * Return search query as a string
+ * @return {String}
+ */
+ get search() {
+ const { query } = this;
+ return query.size === 0 ?
+ '' :
+ '?' + querystring.stringify(query.toJS());
+ }
+
+ /**
+ * Convert this location to a string.
+ * @return {String}
+ */
+ toString() {
+
+ }
+
+ /**
+ * Convert this immutable instance to an object
+ * for "history".
+ * @return {Object}
+ */
+ toNative() {
+ return {
+ pathname: this.pathname,
+ hash: this.hash ? `#${this.hash}` : '',
+ search: this.search
+ };
+ }
+
+ /**
+ * Convert an instance from "history" to Location.
+ * @param {Object|String} location
+ * @return {Location}
+ */
+ static fromNative(location) {
+ if (typeof location === 'string') {
+ location = { pathname: location };
+ }
+
+ const pathname = location.pathname;
+ let hash = location.hash || '';
+ let search = location.search || '';
+ let query = location.query;
+
+ hash = hash[0] === '#' ? hash.slice(1) : hash;
+ search = search[0] === '?' ? search.slice(1) : search;
+
+ if (query) {
+ query = Map(query);
+ } else {
+ query = Map(querystring.parse(search));
+ }
+
+ return new Location({
+ pathname,
+ hash,
+ query
+ });
+ }
+}
+
+module.exports = Location;
diff --git a/packages/gitbook-core/src/models/Page.js b/packages/gitbook-core/src/models/Page.js
new file mode 100644
index 0000000..e3c4a96
--- /dev/null
+++ b/packages/gitbook-core/src/models/Page.js
@@ -0,0 +1,24 @@
+const { Record, Map, fromJS } = require('immutable');
+
+const DEFAULTS = {
+ title: '',
+ content: '',
+ dir: 'ltr',
+ depth: 1,
+ level: '',
+ previous: null,
+ next: null,
+ attributes: Map()
+};
+
+class Page extends Record(DEFAULTS) {
+ static create(state) {
+ return state instanceof Page ?
+ state : new Page({
+ ...state,
+ attributes: fromJS(state.attributes)
+ });
+ }
+}
+
+module.exports = Page;
diff --git a/packages/gitbook-core/src/models/Plugin.js b/packages/gitbook-core/src/models/Plugin.js
new file mode 100644
index 0000000..07b1976
--- /dev/null
+++ b/packages/gitbook-core/src/models/Plugin.js
@@ -0,0 +1,21 @@
+const { Record } = require('immutable');
+
+const DEFAULTS = {
+ activate: ((dispatch, getState) => {}),
+ deactivate: ((dispatch, getState) => {}),
+ reduce: ((state, action) => state),
+ actions: {}
+};
+
+class Plugin extends Record(DEFAULTS) {
+ constructor(plugin) {
+ super({
+ activate: plugin.activate || DEFAULTS.activate,
+ deactivate: plugin.deactivate || DEFAULTS.deactivate,
+ reduce: plugin.reduce || DEFAULTS.reduce,
+ actions: plugin.actions || DEFAULTS.actions
+ });
+ }
+}
+
+module.exports = Plugin;
diff --git a/packages/gitbook-core/src/models/Readme.js b/packages/gitbook-core/src/models/Readme.js
new file mode 100644
index 0000000..f275ca2
--- /dev/null
+++ b/packages/gitbook-core/src/models/Readme.js
@@ -0,0 +1,21 @@
+const { Record } = require('immutable');
+const File = require('./File');
+
+const DEFAULTS = {
+ file: new File()
+};
+
+class Readme extends Record(DEFAULTS) {
+ constructor(state = {}) {
+ super({
+ file: File.create(state.file)
+ });
+ }
+
+ static create(state) {
+ return state instanceof Readme ?
+ state : new Readme(state);
+ }
+}
+
+module.exports = Readme;
diff --git a/packages/gitbook-core/src/models/SummaryArticle.js b/packages/gitbook-core/src/models/SummaryArticle.js
new file mode 100644
index 0000000..3651c8a
--- /dev/null
+++ b/packages/gitbook-core/src/models/SummaryArticle.js
@@ -0,0 +1,32 @@
+const { Record, List } = require('immutable');
+
+const DEFAULTS = {
+ title: '',
+ depth: 0,
+ path: '',
+ url: '',
+ ref: '',
+ level: '',
+ articles: List()
+};
+
+class SummaryArticle extends Record(DEFAULTS) {
+ constructor(article) {
+ super({
+ ...article,
+ articles: (new List(article.articles))
+ .map(art => new SummaryArticle(art))
+ });
+ }
+
+ /**
+ * Return true if article is an instance of SummaryArticle
+ * @param {Mixed} article
+ * @return {Boolean}
+ */
+ static is(article) {
+ return (article instanceof SummaryArticle);
+ }
+}
+
+module.exports = SummaryArticle;
diff --git a/packages/gitbook-core/src/models/SummaryPart.js b/packages/gitbook-core/src/models/SummaryPart.js
new file mode 100644
index 0000000..89c76d4
--- /dev/null
+++ b/packages/gitbook-core/src/models/SummaryPart.js
@@ -0,0 +1,17 @@
+const { Record, List } = require('immutable');
+const SummaryArticle = require('./SummaryArticle');
+
+class SummaryPart extends Record({
+ title: '',
+ articles: List()
+}) {
+ constructor(state) {
+ super({
+ ...state,
+ articles: (new List(state.articles))
+ .map(article => new SummaryArticle(article))
+ });
+ }
+}
+
+module.exports = SummaryPart;
diff --git a/packages/gitbook-core/src/propTypes/Context.js b/packages/gitbook-core/src/propTypes/Context.js
new file mode 100644
index 0000000..dd6d010
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Context.js
@@ -0,0 +1,11 @@
+const React = require('react');
+const {
+ object,
+ shape
+} = React.PropTypes;
+
+
+module.exports = shape({
+ store: object,
+ actions: object
+});
diff --git a/packages/gitbook-core/src/propTypes/File.js b/packages/gitbook-core/src/propTypes/File.js
new file mode 100644
index 0000000..fb7bc06
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/File.js
@@ -0,0 +1,13 @@
+const React = require('react');
+const {
+ oneOf,
+ string,
+ instanceOf,
+ shape
+} = React.PropTypes;
+
+module.exports = shape({
+ mtime: instanceOf(Date).isRequired,
+ path: string.isRequired,
+ type: oneOf(['', 'markdown', 'asciidoc']).isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/History.js b/packages/gitbook-core/src/propTypes/History.js
new file mode 100644
index 0000000..1b59ea0
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/History.js
@@ -0,0 +1,11 @@
+const React = require('react');
+const locationShape = require('./Location');
+const {
+ bool,
+ shape
+} = React.PropTypes;
+
+module.exports = shape({
+ loading: bool,
+ location: locationShape
+});
diff --git a/packages/gitbook-core/src/propTypes/Language.js b/packages/gitbook-core/src/propTypes/Language.js
new file mode 100644
index 0000000..eea37a7
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Language.js
@@ -0,0 +1,7 @@
+const React = require('react');
+const { string, shape } = React.PropTypes;
+
+module.exports = shape({
+ id: string.isRequired,
+ title: string.isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/Languages.js b/packages/gitbook-core/src/propTypes/Languages.js
new file mode 100644
index 0000000..076aec5
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Languages.js
@@ -0,0 +1,12 @@
+const React = require('react');
+const { listOf } = require('react-immutable-proptypes');
+const { shape, string } = React.PropTypes;
+
+const fileShape = require('./File');
+const languageShape = require('./Language');
+
+module.exports = shape({
+ current: string.isRequired,
+ file: fileShape.isRequired,
+ list: listOf(languageShape).isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/Location.js b/packages/gitbook-core/src/propTypes/Location.js
new file mode 100644
index 0000000..13e0a34
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Location.js
@@ -0,0 +1,12 @@
+const React = require('react');
+const { map } = require('react-immutable-proptypes');
+const {
+ string,
+ shape
+} = React.PropTypes;
+
+module.exports = shape({
+ pathname: string,
+ hash: string,
+ query: map
+});
diff --git a/packages/gitbook-core/src/propTypes/Page.js b/packages/gitbook-core/src/propTypes/Page.js
new file mode 100644
index 0000000..c589f54
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Page.js
@@ -0,0 +1,16 @@
+const React = require('react');
+const {
+ oneOf,
+ string,
+ number,
+ shape
+} = React.PropTypes;
+
+
+module.exports = shape({
+ title: string.isRequired,
+ content: string.isRequired,
+ level: string.isRequired,
+ depth: number.isRequired,
+ dir: oneOf(['ltr', 'rtl']).isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/Readme.js b/packages/gitbook-core/src/propTypes/Readme.js
new file mode 100644
index 0000000..8414f05
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Readme.js
@@ -0,0 +1,11 @@
+const React = require('react');
+
+const {
+ shape
+} = React.PropTypes;
+
+const File = require('./File');
+
+module.exports = shape({
+ file: File.isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/Summary.js b/packages/gitbook-core/src/propTypes/Summary.js
new file mode 100644
index 0000000..f97e66c
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/Summary.js
@@ -0,0 +1,14 @@
+const React = require('react');
+const { listOf } = require('react-immutable-proptypes');
+
+const {
+ shape
+} = React.PropTypes;
+
+const File = require('./File');
+const Part = require('./SummaryPart');
+
+module.exports = shape({
+ file: File.isRequired,
+ parts: listOf(Part).isRequired
+});
diff --git a/packages/gitbook-core/src/propTypes/SummaryArticle.js b/packages/gitbook-core/src/propTypes/SummaryArticle.js
new file mode 100644
index 0000000..c93bdd9
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/SummaryArticle.js
@@ -0,0 +1,22 @@
+/* eslint-disable no-use-before-define */
+
+const React = require('react');
+const { list } = require('react-immutable-proptypes');
+
+const {
+ string,
+ number,
+ shape
+} = React.PropTypes;
+
+const Article = shape({
+ title: string.isRequired,
+ depth: number.isRequired,
+ path: string,
+ url: string,
+ ref: string,
+ level: string,
+ articles: list
+});
+
+module.exports = Article;
diff --git a/packages/gitbook-core/src/propTypes/SummaryPart.js b/packages/gitbook-core/src/propTypes/SummaryPart.js
new file mode 100644
index 0000000..769ddd1
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/SummaryPart.js
@@ -0,0 +1,14 @@
+const React = require('react');
+const { listOf } = require('react-immutable-proptypes');
+
+const {
+ string,
+ shape
+} = React.PropTypes;
+
+const Article = require('./SummaryArticle');
+
+module.exports = shape({
+ title: string.isRequired,
+ articles: listOf(Article)
+});
diff --git a/packages/gitbook-core/src/propTypes/i18n.js b/packages/gitbook-core/src/propTypes/i18n.js
new file mode 100644
index 0000000..372a240
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/i18n.js
@@ -0,0 +1,10 @@
+const React = require('react');
+const {
+ func,
+ shape
+} = React.PropTypes;
+
+
+module.exports = shape({
+ t: func
+});
diff --git a/packages/gitbook-core/src/propTypes/index.js b/packages/gitbook-core/src/propTypes/index.js
new file mode 100644
index 0000000..f56b78c
--- /dev/null
+++ b/packages/gitbook-core/src/propTypes/index.js
@@ -0,0 +1,19 @@
+const React = require('react');
+const ImmutablePropTypes = require('react-immutable-proptypes');
+
+module.exports = {
+ ...ImmutablePropTypes,
+ dispatch: React.PropTypes.func,
+ I18n: require('./i18n'),
+ Context: require('./Context'),
+ Page: require('./Page'),
+ File: require('./File'),
+ History: require('./History'),
+ Language: require('./Language'),
+ Languages: require('./Languages'),
+ Location: require('./Location'),
+ Readme: require('./Readme'),
+ Summary: require('./Summary'),
+ SummaryPart: require('./SummaryPart'),
+ SummaryArticle: require('./SummaryArticle')
+};
diff --git a/packages/gitbook-core/src/reducers/components.js b/packages/gitbook-core/src/reducers/components.js
new file mode 100644
index 0000000..948a3ac
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/components.js
@@ -0,0 +1,20 @@
+const { List } = require('immutable');
+const ACTION_TYPES = require('../actions/TYPES');
+
+function reduceComponents(state, action) {
+ state = state || List();
+ switch (action.type) {
+
+ case ACTION_TYPES.REGISTER_COMPONENT:
+ return state.push({
+ Component: action.Component,
+ descriptor: action.descriptor
+ });
+
+ default:
+ return state;
+
+ }
+}
+
+module.exports = reduceComponents;
diff --git a/packages/gitbook-core/src/reducers/config.js b/packages/gitbook-core/src/reducers/config.js
new file mode 100644
index 0000000..a49c602
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/config.js
@@ -0,0 +1,15 @@
+const { fromJS } = require('immutable');
+const ACTION_TYPES = require('../actions/TYPES');
+
+module.exports = (state, action) => {
+ state = fromJS(state);
+ switch (action.type) {
+
+ case ACTION_TYPES.PAGE_FETCH_END:
+ return fromJS(action.payload.config);
+
+ default:
+ return state;
+
+ }
+};
diff --git a/packages/gitbook-core/src/reducers/file.js b/packages/gitbook-core/src/reducers/file.js
new file mode 100644
index 0000000..82b0f42
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/file.js
@@ -0,0 +1,16 @@
+const ACTION_TYPES = require('../actions/TYPES');
+const File = require('../models/File');
+
+module.exports = (state, action) => {
+ state = File.create(state);
+
+ switch (action.type) {
+
+ case ACTION_TYPES.PAGE_FETCH_END:
+ return state.merge(action.payload.file);
+
+ default:
+ return state;
+
+ }
+};
diff --git a/packages/gitbook-core/src/reducers/history.js b/packages/gitbook-core/src/reducers/history.js
new file mode 100644
index 0000000..be8fe42
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/history.js
@@ -0,0 +1,82 @@
+const { Record, List } = require('immutable');
+const { createBrowserHistory, createMemoryHistory } = require('history');
+const ACTION_TYPES = require('../actions/TYPES');
+const Location = require('../models/Location');
+
+const isServerSide = (typeof window === 'undefined');
+
+const HistoryState = Record({
+ // Current location
+ location: new Location(),
+ // Are we loading a new page
+ loading: Boolean(false),
+ // Did we fail loading a page?
+ error: null,
+ // Listener for history changes
+ listeners: List(),
+ // Function to call to stop listening
+ unlisten: null,
+ // HistoryJS instance
+ client: null
+});
+
+function reduceHistory(state, action) {
+ state = state || HistoryState();
+ switch (action.type) {
+
+ case ACTION_TYPES.PAGE_FETCH_START:
+ return state.merge({
+ loading: true
+ });
+
+ case ACTION_TYPES.PAGE_FETCH_END:
+ return state.merge({
+ loading: false
+ });
+
+ case ACTION_TYPES.PAGE_FETCH_ERROR:
+ return state.merge({
+ loading: false,
+ error: action.error
+ });
+
+ case ACTION_TYPES.HISTORY_ACTIVATE:
+ const client = isServerSide ? createMemoryHistory() : createBrowserHistory();
+ const unlisten = client.listen(action.listener);
+
+ // We can't use .merge since it convert history to an immutable
+ const newState = state
+ // TODO: we should find a way to have the correct location on server side
+ .set('location', isServerSide ? new Location() : Location.fromNative(window.location))
+ .set('client', client)
+ .set('unlisten', unlisten);
+
+ return newState;
+
+ case ACTION_TYPES.HISTORY_DEACTIVATE:
+ if (state.unlisten) {
+ state.unlisten();
+ }
+
+ return state.merge({
+ client: null,
+ unlisten: null
+ });
+
+ case ACTION_TYPES.HISTORY_UPDATE:
+ return state.merge({
+ location: action.location
+ });
+
+ case ACTION_TYPES.HISTORY_LISTEN:
+ return state.merge({
+ listeners: state.listeners.push(action.listener)
+ });
+
+ default:
+ return state;
+
+ }
+}
+
+module.exports = reduceHistory;
diff --git a/packages/gitbook-core/src/reducers/i18n.js b/packages/gitbook-core/src/reducers/i18n.js
new file mode 100644
index 0000000..4ffd129
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/i18n.js
@@ -0,0 +1,27 @@
+const { Record, Map } = require('immutable');
+const ACTION_TYPES = require('../actions/TYPES');
+
+const I18nState = Record({
+ locale: 'en',
+ // Map of locale -> Map<String:String>
+ messages: Map()
+});
+
+function reduceI18n(state, action) {
+ state = state || I18nState();
+ switch (action.type) {
+
+ case ACTION_TYPES.I18N_REGISTER_LOCALE:
+ return state.merge({
+ messages: state.messages.set(action.locale,
+ state.messages.get(action.locale, Map()).merge(action.messages)
+ )
+ });
+
+ default:
+ return state;
+
+ }
+}
+
+module.exports = reduceI18n;
diff --git a/packages/gitbook-core/src/reducers/index.js b/packages/gitbook-core/src/reducers/index.js
new file mode 100644
index 0000000..a211d3b
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/index.js
@@ -0,0 +1,15 @@
+const composeReducer = require('../lib/composeReducer');
+const createReducer = require('../lib/createReducer');
+
+module.exports = composeReducer(
+ createReducer('components', require('./components')),
+ createReducer('history', require('./history')),
+ createReducer('i18n', require('./i18n')),
+ // GitBook JSON
+ createReducer('config', require('./config')),
+ createReducer('file', require('./file')),
+ createReducer('page', require('./page')),
+ createReducer('summary', require('./summary')),
+ createReducer('readme', require('./readme')),
+ createReducer('languages', require('./languages'))
+);
diff --git a/packages/gitbook-core/src/reducers/languages.js b/packages/gitbook-core/src/reducers/languages.js
new file mode 100644
index 0000000..0ec2ae4
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/languages.js
@@ -0,0 +1,12 @@
+const Languages = require('../models/Languages');
+
+module.exports = (state, action) => {
+ state = Languages.create(state);
+
+ switch (action.type) {
+
+ default:
+ return state;
+
+ }
+};
diff --git a/packages/gitbook-core/src/reducers/page.js b/packages/gitbook-core/src/reducers/page.js
new file mode 100644
index 0000000..9b94d1e
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/page.js
@@ -0,0 +1,16 @@
+const ACTION_TYPES = require('../actions/TYPES');
+const Page = require('../models/Page');
+
+module.exports = (state, action) => {
+ state = Page.create(state);
+
+ switch (action.type) {
+
+ case ACTION_TYPES.PAGE_FETCH_END:
+ return state.merge(action.payload.page);
+
+ default:
+ return state;
+
+ }
+};
diff --git a/packages/gitbook-core/src/reducers/readme.js b/packages/gitbook-core/src/reducers/readme.js
new file mode 100644
index 0000000..9e8656a
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/readme.js
@@ -0,0 +1,5 @@
+const Readme = require('../models/Readme');
+
+module.exports = (state, action) => {
+ return Readme.create(state);
+};
diff --git a/packages/gitbook-core/src/reducers/summary.js b/packages/gitbook-core/src/reducers/summary.js
new file mode 100644
index 0000000..60568ef
--- /dev/null
+++ b/packages/gitbook-core/src/reducers/summary.js
@@ -0,0 +1,28 @@
+const { Record, List } = require('immutable');
+
+const File = require('../models/File');
+const SummaryPart = require('../models/SummaryPart');
+
+
+class SummaryState extends Record({
+ file: new File(),
+ parts: List()
+}) {
+ constructor(state = {}) {
+ super({
+ ...state,
+ file: new File(state.file),
+ parts: (new List(state.parts))
+ .map(article => new SummaryPart(article))
+ });
+ }
+
+ static create(state) {
+ return state instanceof SummaryState ?
+ state : new SummaryState(state);
+ }
+}
+
+module.exports = (state, action) => {
+ return SummaryState.create(state);
+};
diff --git a/packages/gitbook-core/src/server.js b/packages/gitbook-core/src/server.js
new file mode 100644
index 0000000..0363aa0
--- /dev/null
+++ b/packages/gitbook-core/src/server.js
@@ -0,0 +1,2 @@
+const ReactDOMServer = require('react-dom/server');
+module.exports = ReactDOMServer;
diff --git a/packages/gitbook-plugin-copy-code/.gitignore b/packages/gitbook-plugin-copy-code/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-copy-code/.npmignore b/packages/gitbook-plugin-copy-code/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin-copy-code/_assets/website/button.css b/packages/gitbook-plugin-copy-code/_assets/website/button.css
new file mode 100644
index 0000000..2fd034e
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/_assets/website/button.css
@@ -0,0 +1,27 @@
+.CodeBlockWithCopy-Container {
+ position: relative;
+}
+
+.CodeBlockWithCopy-Button {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ padding: 3px 6px;
+ margin: 0px;
+ text-transform: uppercase;
+ border-radius: 3px;
+ line-height: 1em;
+ font-size: 12px;
+ border: 1px solid rgba(0,0,0, 0.1);
+ color: rgba(0,0,0, 0.4);
+ cursor: pointer;
+ display: none;
+}
+
+.CodeBlockWithCopy-Container:hover .CodeBlockWithCopy-Button {
+ display: block;
+}
+
+.CodeBlockWithCopy-Button:hover {
+ border-color: rgba(0,0,0, 0.2);
+}
diff --git a/packages/gitbook-plugin-copy-code/index.js b/packages/gitbook-plugin-copy-code/index.js
new file mode 100644
index 0000000..e542ae8
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ blocks: {
+
+ },
+
+ hooks: {
+
+ }
+};
diff --git a/packages/gitbook-plugin-copy-code/package.json b/packages/gitbook-plugin-copy-code/package.json
new file mode 100644
index 0000000..b25ca43
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "gitbook-plugin-copy-code",
+ "description": "Button to copy code blocks",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "copy-to-clipboard": "^3.0.5",
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-copy-code/src/index.js b/packages/gitbook-plugin-copy-code/src/index.js
new file mode 100644
index 0000000..73d46c6
--- /dev/null
+++ b/packages/gitbook-plugin-copy-code/src/index.js
@@ -0,0 +1,82 @@
+const copy = require('copy-to-clipboard');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const COPIED_TIMEOUT = 1000;
+
+/**
+ * Get children as text
+ * @param {React.Children} children
+ * @return {String}
+ */
+function getChildrenToText(children) {
+ return React.Children.map(children, child => {
+ if (typeof child === 'string') {
+ return child;
+ } else {
+ return child.props.children ?
+ getChildrenToText(child.props.children) : '';
+ }
+ }).join('');
+}
+
+let CodeBlockWithCopy = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node,
+ i18n: GitBook.PropTypes.I18n
+ },
+
+ getInitialState() {
+ return {
+ copied: false
+ };
+ },
+
+ onClick(event) {
+ const { children } = this.props;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const text = getChildrenToText(children);
+ copy(text);
+
+ this.setState({ copied: true }, () => {
+ this.timeout = setTimeout(() => {
+ this.setState({
+ copied: false
+ });
+ }, COPIED_TIMEOUT);
+ });
+ },
+
+ componentWillUnmount() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ },
+
+ render() {
+ const { children, i18n } = this.props;
+ const { copied } = this.state;
+
+ return (
+ <div className="CodeBlockWithCopy-Container">
+ <GitBook.ImportCSS href="gitbook/copy-code/button.css" />
+
+ {children}
+ <span className="CodeBlockWithCopy-Button" onClick={this.onClick}>
+ {copied ? i18n.t('COPIED') : i18n.t('COPY')}
+ </span>
+ </div>
+ );
+ }
+});
+
+CodeBlockWithCopy = GitBook.connect(CodeBlockWithCopy);
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ dispatch(Components.registerComponent(CodeBlockWithCopy, { role: 'html:pre' }));
+ }
+});
diff --git a/packages/gitbook-plugin-headings/.gitignore b/packages/gitbook-plugin-headings/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-headings/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-headings/.npmignore b/packages/gitbook-plugin-headings/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin-headings/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin-headings/_assets/website/headings.css b/packages/gitbook-plugin-headings/_assets/website/headings.css
new file mode 100644
index 0000000..1ef0d64
--- /dev/null
+++ b/packages/gitbook-plugin-headings/_assets/website/headings.css
@@ -0,0 +1,41 @@
+
+.Headings-Container {
+ position: relative;
+ margin-left: -30px;
+ padding-left: 30px;
+}
+
+/* Left anchors rules */
+.Headings-Container > .Headings-Anchor-Left {
+ position: absolute;
+ left: 5px;
+ top: 50%;
+ transform: translateY(-50%);
+ opacity: 0;
+ color: inherit;
+}
+
+/* Right anchors rules */
+.Headings-Container > .Headings-Anchor-Right {
+ padding-left: 5px;
+ opacity: 0;
+ color: inherit;
+}
+
+.Headings-Container.Headings-Right > h1,
+.Headings-Container.Headings-Right > h2,
+.Headings-Container.Headings-Right > h3,
+.Headings-Container.Headings-Right > h4,
+.Headings-Container.Headings-Right > h5,
+.Headings-Container.Headings-Right > h6 {
+ display: inline-block;
+ margin-right: 5px;
+}
+
+/* Display on hover */
+.Headings-Container:hover > .Headings-Anchor-Left,
+.Headings-Container > .Headings-Anchor-Left:focus,
+.Headings-Container:hover > .Headings-Anchor-Right,
+.Headings-Container > .Headings-Anchor-Right:focus {
+ opacity: 1;
+}
diff --git a/packages/gitbook-plugin-headings/index.js b/packages/gitbook-plugin-headings/index.js
new file mode 100644
index 0000000..e542ae8
--- /dev/null
+++ b/packages/gitbook-plugin-headings/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ blocks: {
+
+ },
+
+ hooks: {
+
+ }
+};
diff --git a/packages/gitbook-plugin-headings/package.json b/packages/gitbook-plugin-headings/package.json
new file mode 100644
index 0000000..55a06f6
--- /dev/null
+++ b/packages/gitbook-plugin-headings/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "gitbook-plugin-headings",
+ "description": "Automatically add anchors to headings",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "classnames": "^2.2.5",
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ },
+ "gitbook": {
+ "properties": {
+ "position": {
+ "type": "string",
+ "title": "Position of anchors",
+ "default": "left"
+ }
+ }
+ }
+}
diff --git a/packages/gitbook-plugin-headings/src/index.js b/packages/gitbook-plugin-headings/src/index.js
new file mode 100644
index 0000000..b023e2e
--- /dev/null
+++ b/packages/gitbook-plugin-headings/src/index.js
@@ -0,0 +1,60 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+const classNames = require('classnames');
+
+function mapStateToProps({ config }) {
+ return {
+ position: config.getIn(['pluginsConfig', 'headings', 'position'], 'left')
+ };
+}
+
+let Heading = React.createClass({
+ propTypes: {
+ id: React.PropTypes.string.isRequired,
+ children: React.PropTypes.node.isRequired,
+ position: React.PropTypes.string.isRequired
+ },
+
+ render() {
+ const { position, children, id } = this.props;
+ const className = classNames('Headings-Container', {
+ 'Headings-Right': (position !== 'left')
+ });
+
+ return (
+ <div className={className}>
+ <GitBook.ImportCSS href="gitbook/headings/headings.css" />
+
+ {position == 'left' ?
+ <a className="Headings-Anchor-Left" href={`#${id}`}>
+ <i className="fa fa-link" />
+ </a>
+ : null}
+
+ {children}
+
+ {position != 'left' ?
+ <a className="Headings-Anchor-Right" href={`#${id}`}>
+ <i className="fa fa-link" />
+ </a>
+ : null}
+ </div>
+ );
+ }
+});
+
+Heading = GitBook.connect(Heading, mapStateToProps);
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ // Attach component to titles
+ dispatch(Components.registerComponent(Heading, { role: 'html:h1' }));
+ dispatch(Components.registerComponent(Heading, { role: 'html:h2' }));
+ dispatch(Components.registerComponent(Heading, { role: 'html:h3' }));
+ dispatch(Components.registerComponent(Heading, { role: 'html:h4' }));
+ dispatch(Components.registerComponent(Heading, { role: 'html:h5' }));
+ dispatch(Components.registerComponent(Heading, { role: 'html:h6' }));
+ },
+ deactivate: (dispatch, getState) => {},
+ reduce: (state, action) => state
+});
diff --git a/packages/gitbook-plugin-highlight/.gitignore b/packages/gitbook-plugin-highlight/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-highlight/.npmignore b/packages/gitbook-plugin-highlight/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin-highlight/_assets/website/white.css b/packages/gitbook-plugin-highlight/_assets/website/white.css
new file mode 100644
index 0000000..d59f1d4
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/_assets/website/white.css
@@ -0,0 +1,92 @@
+/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
+
+/* Tomorrow Comment */
+.hljs-comment,
+.hljs-title {
+ color: #8e908c;
+}
+
+/* Tomorrow Red */
+.hljs-variable,
+.hljs-attribute,
+.hljs-tag,
+.hljs-regexp,
+.hljs-deletion,
+.ruby .hljs-constant,
+.xml .hljs-tag .hljs-title,
+.xml .hljs-pi,
+.xml .hljs-doctype,
+.html .hljs-doctype,
+.css .hljs-id,
+.css .hljs-class,
+.css .hljs-pseudo {
+ color: #c82829;
+}
+
+/* Tomorrow Orange */
+.hljs-number,
+.hljs-preprocessor,
+.hljs-pragma,
+.hljs-built_in,
+.hljs-literal,
+.hljs-params,
+.hljs-constant {
+ color: #f5871f;
+}
+
+/* Tomorrow Yellow */
+.ruby .hljs-class .hljs-title,
+.css .hljs-rules .hljs-attribute {
+ color: #eab700;
+}
+
+/* Tomorrow Green */
+.hljs-string,
+.hljs-value,
+.hljs-inheritance,
+.hljs-header,
+.hljs-addition,
+.ruby .hljs-symbol,
+.xml .hljs-cdata {
+ color: #718c00;
+}
+
+/* Tomorrow Aqua */
+.css .hljs-hexcolor {
+ color: #3e999f;
+}
+
+/* Tomorrow Blue */
+.hljs-function,
+.python .hljs-decorator,
+.python .hljs-title,
+.ruby .hljs-function .hljs-title,
+.ruby .hljs-title .hljs-keyword,
+.perl .hljs-sub,
+.javascript .hljs-title,
+.coffeescript .hljs-title {
+ color: #4271ae;
+}
+
+/* Tomorrow Purple */
+.hljs-keyword,
+.javascript .hljs-function {
+ color: #8959a8;
+}
+
+.hljs {
+ display: block;
+ background: white;
+ color: #4d4d4c;
+ padding: 0.5em;
+}
+
+.coffeescript .javascript,
+.javascript .xml,
+.tex .hljs-formula,
+.xml .javascript,
+.xml .vbscript,
+.xml .css,
+.xml .hljs-cdata {
+ opacity: 0.5;
+}
diff --git a/packages/gitbook-plugin-highlight/index.js b/packages/gitbook-plugin-highlight/index.js
new file mode 100644
index 0000000..e542ae8
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ blocks: {
+
+ },
+
+ hooks: {
+
+ }
+};
diff --git a/packages/gitbook-plugin-highlight/package.json b/packages/gitbook-plugin-highlight/package.json
new file mode 100644
index 0000000..ce8b8d6
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "gitbook-plugin-highlight",
+ "description": "Syntax highlighter for Gitbook",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "gitbook-core": "4.0.0",
+ "highlight.js": "9.7.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-highlight/src/ALIASES.js b/packages/gitbook-plugin-highlight/src/ALIASES.js
new file mode 100644
index 0000000..799efef
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/src/ALIASES.js
@@ -0,0 +1,10 @@
+
+const ALIASES = {
+ 'py': 'python',
+ 'js': 'javascript',
+ 'json': 'javascript',
+ 'rb': 'ruby',
+ 'csharp': 'cs'
+};
+
+module.exports = ALIASES;
diff --git a/packages/gitbook-plugin-highlight/src/CodeBlock.js b/packages/gitbook-plugin-highlight/src/CodeBlock.js
new file mode 100644
index 0000000..a556d36
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/src/CodeBlock.js
@@ -0,0 +1,55 @@
+const hljs = require('highlight.js');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const getLanguage = require('./getLanguage');
+
+/**
+ * Get children as text
+ * @param {React.Children} children
+ * @return {String}
+ */
+function getChildrenToText(children) {
+ return React.Children.map(children, child => {
+ if (typeof child === 'string') {
+ return child;
+ } else {
+ return child.props.children ?
+ getChildrenToText(child.props.children) : '';
+ }
+ }).join('');
+}
+
+const CodeBlock = React.createClass({
+ propTypes: {
+ children: React.PropTypes.node,
+ className: React.PropTypes.string
+ },
+
+ render() {
+ const { children, className } = this.props;
+ const content = getChildrenToText(children);
+ const lang = getLanguage(className || '');
+
+ const includeCSS = <GitBook.ImportCSS href="gitbook/highlight/white.css" />;
+
+ try {
+ const html = hljs.highlight(lang, content).value;
+ return (
+ <code>
+ {includeCSS}
+ <span dangerouslySetInnerHTML={{__html: html}} />
+ </code>
+ );
+ } catch (e) {
+ return (
+ <code>
+ {includeCSS}
+ {content}
+ </code>
+ );
+ }
+ }
+});
+
+module.exports = CodeBlock;
diff --git a/packages/gitbook-plugin-highlight/src/getLanguage.js b/packages/gitbook-plugin-highlight/src/getLanguage.js
new file mode 100644
index 0000000..7a1bf8e
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/src/getLanguage.js
@@ -0,0 +1,34 @@
+const GitBook = require('gitbook-core');
+const { List } = GitBook.Immutable;
+
+const ALIASES = require('./ALIASES');
+
+/**
+ * Return language for a code blocks from a list of class names
+ *
+ * @param {String} className
+ * @return {String}
+ */
+function getLanguage(className) {
+ const lang = List(className.split(' '))
+ .map(function(cl) {
+ // Markdown
+ if (cl.search('lang-') === 0) {
+ return cl.slice('lang-'.length);
+ }
+
+ // Asciidoc
+ if (cl.search('language-') === 0) {
+ return cl.slice('language-'.length);
+ }
+
+ return null;
+ })
+ .find(function(cl) {
+ return Boolean(cl);
+ });
+
+ return ALIASES[lang] || lang;
+}
+
+module.exports = getLanguage;
diff --git a/packages/gitbook-plugin-highlight/src/index.js b/packages/gitbook-plugin-highlight/src/index.js
new file mode 100644
index 0000000..3f17c42
--- /dev/null
+++ b/packages/gitbook-plugin-highlight/src/index.js
@@ -0,0 +1,9 @@
+const GitBook = require('gitbook-core');
+const CodeBlock = require('./CodeBlock');
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ dispatch(Components.registerComponent(CodeBlock, { role: 'html:code' }));
+ },
+ reduce: (state, action) => state
+});
diff --git a/packages/gitbook-plugin-hints/.gitignore b/packages/gitbook-plugin-hints/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-hints/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-hints/.npmignore b/packages/gitbook-plugin-hints/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin-hints/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin-hints/README.md b/packages/gitbook-plugin-hints/README.md
new file mode 100644
index 0000000..9952b97
--- /dev/null
+++ b/packages/gitbook-plugin-hints/README.md
@@ -0,0 +1,41 @@
+Styled hint blocks in your docs
+==============
+
+This plugins requires gitbook `>=4.0.0`.
+
+### Install
+
+Add the below to your `book.json` file, then run `gitbook install` :
+
+```json
+{
+ "plugins": ["hints"]
+}
+```
+
+### Usage
+
+You can now provide hints in various ways using the `hint` tag.
+
+```markdown
+{% hint style='info' %}
+Important info: this note needs to be highlighted
+{% endhint %}
+```
+
+##### Styles
+
+Available styles are:
+
+- `info` (default)
+- `tip`
+- `danger`
+- `warning`
+
+##### Custom Icons
+
+```markdown
+{% hint style='info' icon="mail" %}
+Important info: this note needs to be highlighted
+{% endhint %}
+```
diff --git a/packages/gitbook-plugin-hints/_assets/website/plugin.css b/packages/gitbook-plugin-hints/_assets/website/plugin.css
new file mode 100644
index 0000000..343201b
--- /dev/null
+++ b/packages/gitbook-plugin-hints/_assets/website/plugin.css
@@ -0,0 +1,43 @@
+.HintAlert {
+ padding: 10px;
+ border-radius: 3px;
+ display: flex;
+ margin-bottom: 1.275em;
+}
+
+.HintAlert-Icon {
+ flex: 0;
+ padding: 10px 20px;
+ font-size: 24px;
+}
+
+.HintAlert-Content {
+ flex: auto;
+ padding: 10px;
+ padding-left: 0px;
+}
+
+/* Styles */
+.HintAlert-Style-info, .HintAlert-Style-tip {
+ color: #31708f;
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+}
+
+.HintAlert-Style-success {
+ color: #3c763d;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.HintAlert-Style-danger {
+ color: #a94442;
+ background-color: #f2dede;
+ border-color: #ebccd1;
+}
+
+.HintAlert-Style-warning {
+ color: #8a6d3b;
+ background-color: #fcf8e3;
+ border-color: #faebcc;
+}
diff --git a/packages/gitbook-plugin-hints/index.js b/packages/gitbook-plugin-hints/index.js
new file mode 100644
index 0000000..c762232
--- /dev/null
+++ b/packages/gitbook-plugin-hints/index.js
@@ -0,0 +1,12 @@
+
+module.exports = {
+ blocks: {
+ hint: ({ kwargs, children }) => {
+ return {
+ children,
+ style: kwargs.style || 'info',
+ icon: kwargs.icon
+ };
+ }
+ }
+};
diff --git a/packages/gitbook-plugin-hints/package.json b/packages/gitbook-plugin-hints/package.json
new file mode 100644
index 0000000..3afad4d
--- /dev/null
+++ b/packages/gitbook-plugin-hints/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "gitbook-plugin-hints",
+ "description": "Defines four types of styled hint blocks: info, danger, tip, working.",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "classnames": "^2.2.5",
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=4.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitBookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitBookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitBookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-hints/src/index.js b/packages/gitbook-plugin-hints/src/index.js
new file mode 100644
index 0000000..2ee8a1f
--- /dev/null
+++ b/packages/gitbook-plugin-hints/src/index.js
@@ -0,0 +1,45 @@
+const classNames = require('classnames');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const STYLE_TO_ICON = {
+ info: 'info-circle',
+ tip: 'question',
+ success: 'check-circle',
+ danger: 'exclamation-circle',
+ warning: 'exclamation-triangle'
+};
+
+const HintAlert = React.createClass({
+ propTypes: {
+ icon: React.PropTypes.string,
+ style: React.PropTypes.string,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const { children, style, icon } = this.props;
+ const className = classNames(
+ 'HintAlert', `HintAlert-Style-${style}`,
+ 'alert', `alert-${style}`
+ );
+
+ return (
+ <div className={className}>
+ <GitBook.ImportCSS href="gitbook/hints/plugin.css" />
+ <div className="HintAlert-Icon">
+ <GitBook.Icon id={icon || STYLE_TO_ICON[style]} />
+ </div>
+ <div className="HintAlert-Content">
+ {children}
+ </div>
+ </div>
+ );
+ }
+});
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ dispatch(Components.registerComponent(HintAlert, { role: 'block:hint' }));
+ }
+});
diff --git a/packages/gitbook-plugin-livereload/.gitignore b/packages/gitbook-plugin-livereload/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-livereload/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-livereload/LICENSE b/packages/gitbook-plugin-livereload/LICENSE
new file mode 100644
index 0000000..ad410e1
--- /dev/null
+++ b/packages/gitbook-plugin-livereload/LICENSE
@@ -0,0 +1,201 @@
+Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License. \ No newline at end of file
diff --git a/packages/gitbook-plugin-livereload/README.md b/packages/gitbook-plugin-livereload/README.md
new file mode 100644
index 0000000..e2d6f83
--- /dev/null
+++ b/packages/gitbook-plugin-livereload/README.md
@@ -0,0 +1,3 @@
+# `gitbook-plugin-livereload`
+
+See [GitBook](https://github.com/GitbookIO/gitbook) for more information.
diff --git a/packages/gitbook-plugin-livereload/package.json b/packages/gitbook-plugin-livereload/package.json
new file mode 100644
index 0000000..97df231
--- /dev/null
+++ b/packages/gitbook-plugin-livereload/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "gitbook-plugin-livereload",
+ "description": "Live reloading for your gitbook",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "engines": {
+ "gitbook": "*"
+ },
+ "dependencies": {
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "license": "Apache 2",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-livereload/src/index.js b/packages/gitbook-plugin-livereload/src/index.js
new file mode 100644
index 0000000..e73f12d
--- /dev/null
+++ b/packages/gitbook-plugin-livereload/src/index.js
@@ -0,0 +1,18 @@
+const GitBook = require('gitbook-core');
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const newEl = document.createElement('script');
+ const firstScriptTag = document.getElementsByTagName('script')[0];
+
+ if (firstScriptTag) {
+ newEl.async = 1;
+ newEl.src = '//' + window.location.hostname + ':35729/livereload.js';
+ firstScriptTag.parentNode.insertBefore(newEl, firstScriptTag);
+ }
+ }
+});
diff --git a/packages/gitbook-plugin-lunr/.gitignore b/packages/gitbook-plugin-lunr/.gitignore
new file mode 100644
index 0000000..7c6f0eb
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets
diff --git a/packages/gitbook-plugin-lunr/.npmignore b/packages/gitbook-plugin-lunr/.npmignore
new file mode 100644
index 0000000..7bc36b7
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets
diff --git a/packages/gitbook-plugin-lunr/index.js b/packages/gitbook-plugin-lunr/index.js
new file mode 100644
index 0000000..bdde8f6
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/index.js
@@ -0,0 +1,99 @@
+/* eslint-disable no-var, object-shorthand */
+var lunr = require('lunr');
+var Entities = require('html-entities').AllHtmlEntities;
+
+var Html = new Entities();
+
+var searchIndex;
+
+// Called with the `this` context provided by Gitbook
+function getSearchIndex(context) {
+ if (!searchIndex) {
+ // Create search index
+ var ignoreSpecialCharacters = (
+ context.config.get('pluginsConfig.lunr.ignoreSpecialCharacters')
+ || context.config.get('lunr.ignoreSpecialCharacters')
+ );
+
+ searchIndex = lunr(function() {
+ this.ref('url');
+
+ this.field('title', { boost: 10 });
+ this.field('keywords', { boost: 15 });
+ this.field('body');
+
+ if (!ignoreSpecialCharacters) {
+ // Don't trim non words characters (to allow search such as "C++")
+ this.pipeline.remove(lunr.trimmer);
+ }
+ });
+ }
+ return searchIndex;
+}
+
+// Map of Lunr ref to document
+var documentsStore = {};
+
+var searchIndexEnabled = true;
+var indexSize = 0;
+
+module.exports = {
+ hooks: {
+ // Index each page
+ 'page': function(page) {
+ const search = page.attributes.search;
+
+ if (this.output.name != 'website' || !searchIndexEnabled || search === false) {
+ return page;
+ }
+
+ var text, maxIndexSize;
+ maxIndexSize = this.config.get('pluginsConfig.lunr.maxIndexSize') || this.config.get('lunr.maxIndexSize');
+
+ this.log.debug.ln('index page', page.path);
+
+ text = page.content;
+ // Decode HTML
+ text = Html.decode(text);
+ // Strip HTML tags
+ text = text.replace(/(<([^>]+)>)/ig, '');
+
+ indexSize = indexSize + text.length;
+ if (indexSize > maxIndexSize) {
+ this.log.warn.ln('search index is too big, indexing is now disabled');
+ searchIndexEnabled = false;
+ return page;
+ }
+
+ var keywords = [];
+ if (search) {
+ keywords = search.keywords || [];
+ }
+
+ // Add to index
+ var doc = {
+ url: this.output.toURL(page.path),
+ title: page.title,
+ summary: page.description,
+ keywords: keywords.join(' '),
+ body: text
+ };
+
+ documentsStore[doc.url] = doc;
+ getSearchIndex(this).add(doc);
+
+ return page;
+ },
+
+ // Write index to disk
+ 'finish': function() {
+ if (this.output.name != 'website') return;
+
+ this.log.debug.ln('write search index');
+ return this.output.writeFile('search_index.json', JSON.stringify({
+ index: getSearchIndex(this),
+ store: documentsStore
+ }));
+ }
+ }
+};
diff --git a/packages/gitbook-plugin-lunr/package.json b/packages/gitbook-plugin-lunr/package.json
new file mode 100644
index 0000000..6a26f2d
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "gitbook-plugin-lunr",
+ "description": "Static and local index for search in GitBook",
+ "main": "index.js",
+ "browser": "./_assets/theme.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "gitbook-core": "4.0.0",
+ "html-entities": "1.2.0",
+ "lunr": "0.5.12"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "gitbook": {
+ "properties": {
+ "maxIndexSize": {
+ "type": "number",
+ "title": "Limit size for the index",
+ "default": 1000000
+ },
+ "ignoreSpecialCharacters": {
+ "type": "boolean",
+ "title": "Ignore special characters in words",
+ "default": false
+ }
+ }
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/theme.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitBookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitBookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitBookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-lunr/src/actions.js b/packages/gitbook-plugin-lunr/src/actions.js
new file mode 100644
index 0000000..765fa2e
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/src/actions.js
@@ -0,0 +1,43 @@
+const GitBook = require('gitbook-core');
+
+const TYPES = {
+ LOAD: 'lunr/load'
+};
+const INDEX_FILENAME = 'search_index.json';
+
+/**
+ * Load an index set
+ * @param {JSON} json
+ * @return {Action}
+ */
+function load(json) {
+ return { type: TYPES.LOAD, json };
+}
+
+/**
+ * Fetch an index
+ * @return {Action}
+ */
+function fetch() {
+ return (dispatch, getState) => {
+ const { lunr, file } = getState();
+ const { idx } = lunr;
+ const filePath = file.relative(INDEX_FILENAME);
+
+ if (idx) {
+ return GitBook.Promise.resolve();
+ }
+
+ return GitBook.Promise.resolve()
+ .then(() => {
+ return window.fetch(filePath);
+ })
+ .then(response => response.json())
+ .then(json => dispatch(load(json)));
+ };
+}
+
+module.exports = {
+ TYPES,
+ fetch
+};
diff --git a/packages/gitbook-plugin-lunr/src/index.js b/packages/gitbook-plugin-lunr/src/index.js
new file mode 100644
index 0000000..1135f51
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/src/index.js
@@ -0,0 +1,28 @@
+const GitBook = require('gitbook-core');
+const reduce = require('./reducer');
+const actions = require('./actions');
+
+/**
+ * Search in the local index
+ * @param {String} query
+ * @return {Promise<List>}
+ */
+function searchHandler(query, dispatch, getState) {
+ // Fetch the index if non loaded
+ return dispatch(actions.fetch())
+
+ // Execute the search
+ .then(() => {
+ const { idx, store } = getState().lunr;
+ const results = idx.search(query);
+
+ return results.map(({ref}) => store.get(ref).toJS());
+ });
+}
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Search }) => {
+ dispatch(Search.registerHandler('lunr', searchHandler));
+ },
+ reduce
+});
diff --git a/packages/gitbook-plugin-lunr/src/reducer.js b/packages/gitbook-plugin-lunr/src/reducer.js
new file mode 100644
index 0000000..7e317c4
--- /dev/null
+++ b/packages/gitbook-plugin-lunr/src/reducer.js
@@ -0,0 +1,31 @@
+const lunr = require('lunr');
+const GitBook = require('gitbook-core');
+const { Record } = GitBook.Immutable;
+
+const { TYPES } = require('./actions');
+
+/*
+ We store the lunr index an the document index in the store.
+ */
+
+const LunrState = Record({
+ idx: null,
+ store: {}
+});
+
+module.exports = GitBook.createReducer('lunr', (state, action) => {
+ state = state || LunrState();
+
+ switch (action.type) {
+
+ case TYPES.LOAD:
+ return state
+ .set('idx', lunr.Index.load(action.json.index))
+ .merge({
+ store: action.json.store
+ });
+
+ default:
+ return state;
+ }
+});
diff --git a/packages/gitbook-plugin-search/.gitignore b/packages/gitbook-plugin-search/.gitignore
new file mode 100644
index 0000000..dfd90dc
--- /dev/null
+++ b/packages/gitbook-plugin-search/.gitignore
@@ -0,0 +1 @@
+_assets
diff --git a/packages/gitbook-plugin-search/.npmignore b/packages/gitbook-plugin-search/.npmignore
new file mode 100644
index 0000000..75e0923
--- /dev/null
+++ b/packages/gitbook-plugin-search/.npmignore
@@ -0,0 +1 @@
+!_assets
diff --git a/packages/gitbook-plugin-search/README.md b/packages/gitbook-plugin-search/README.md
new file mode 100644
index 0000000..f667e4c
--- /dev/null
+++ b/packages/gitbook-plugin-search/README.md
@@ -0,0 +1,41 @@
+# plugin-search
+
+This plugin is the interface used by all the search plugins (`plugin-lunr`, `plugin-algolia`, etc.)
+
+## Registering a Search handler
+
+Your plugin must register as a Search handler during its `activate` method:
+
+
+``` js
+GitBook.createPlugin({
+ activate: (dispatch, getState, { Search }) => {
+ dispatch(Search.registerHandler('my-plugin-name', searchHandler));
+ },
+ reduce
+})
+
+/**
+ * Search against a query
+ * @param {String} query
+ * @return {Promise<List<Result>>}
+ */
+function searchHandler(query, dispatch, getState) {
+ ...
+}
+```
+
+Your search handler must return a List of result-shaped objects. A result object has the following shape:
+
+``` js
+result = {
+ title: string, // The title of the resource, as displayed in the list of results.
+
+ url: string, // The URL to access the matched resource.
+
+ body: string // (optional) The context of the matched text (can be a sentence
+ // containing matching words). It will be displayed near the result.
+}
+```
+
+
diff --git a/packages/gitbook-plugin-search/index.js b/packages/gitbook-plugin-search/index.js
new file mode 100644
index 0000000..5803889
--- /dev/null
+++ b/packages/gitbook-plugin-search/index.js
@@ -0,0 +1,4 @@
+
+module.exports = {
+
+};
diff --git a/packages/gitbook-plugin-search/package.json b/packages/gitbook-plugin-search/package.json
new file mode 100644
index 0000000..ef4ae9e
--- /dev/null
+++ b/packages/gitbook-plugin-search/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "gitbook-plugin-search",
+ "description": "Search integration in GitBook",
+ "main": "index.js",
+ "browser": "./_assets/theme.js",
+ "version": "4.0.0",
+ "dependencies": {
+ "gitbook-core": "4.0.0",
+ "react": "^15.4.1"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0",
+ "react-highlighter": "^0.3.3"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/theme.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin-search/src/actions/search.js b/packages/gitbook-plugin-search/src/actions/search.js
new file mode 100644
index 0000000..24151c6
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/actions/search.js
@@ -0,0 +1,121 @@
+const { Promise, Immutable } = require('gitbook-core');
+const { List } = Immutable;
+
+const TYPES = require('./types');
+const Result = require('../models/Result');
+
+/*
+ Search workflow:
+
+ 1. Typing in the search input
+ 2. Trigger an update of the url
+ 3. An update of the url, trigger an update of search results
+ */
+
+/**
+ * Start a search query
+ * @param {String} q
+ * @return {Action}
+ */
+function query(q) {
+ return (dispatch, getState, { History }) => {
+ const searchState = getState().search;
+ const currentQuery = searchState.query;
+
+ const queryString = q ? { q } : {};
+
+ if (currentQuery && q) {
+ dispatch(History.replace({ query: queryString }));
+ } else {
+ dispatch(History.push({ query: queryString }));
+ }
+ };
+}
+
+/**
+ * Update results for a query
+ * @param {String} q
+ * @return {Action}
+ */
+function handleQuery(q) {
+ if (!q) {
+ return clear();
+ }
+
+ return (dispatch, getState, actions) => {
+ const { handlers } = getState().search;
+
+ dispatch({ type: TYPES.START, query: q });
+
+ return Promise.reduce(
+ handlers.toArray(),
+ (results, handler) => {
+ return Promise.resolve(handler(q, dispatch, getState, actions))
+ .then(handlerResults => {
+ return handlerResults.map(result => new Result(result));
+ })
+ .then(handlerResults => results.concat(handlerResults));
+ },
+ List()
+ )
+ .then(
+ results => {
+ dispatch({ type: TYPES.END, query: q, results });
+ }
+ );
+ };
+}
+
+/**
+ * Refresh current search (when handlers have changed)
+ * @return {Action}
+ */
+function refresh() {
+ return (dispatch, getState) => {
+ const q = getState().search.query;
+ if (q) {
+ dispatch(handleQuery(q));
+ }
+ };
+}
+
+/**
+ * Clear the whole search
+ * @return {Action}
+ */
+function clear() {
+ return { type: TYPES.CLEAR };
+}
+
+/**
+ * Register a search handler
+ * @param {String} name
+ * @param {Function} handler
+ * @return {Action}
+ */
+function registerHandler(name, handler) {
+ return (dispatch) => {
+ dispatch({ type: TYPES.REGISTER_HANDLER, name, handler });
+ dispatch(refresh());
+ };
+}
+
+/**
+ * Unregister a search handler
+ * @param {String} name
+ * @return {Action}
+ */
+function unregisterHandler(name) {
+ return (dispatch) => {
+ dispatch({ type: TYPES.UNREGISTER_HANDLER, name });
+ dispatch(refresh());
+ };
+}
+
+module.exports = {
+ clear,
+ query,
+ handleQuery,
+ registerHandler,
+ unregisterHandler
+};
diff --git a/packages/gitbook-plugin-search/src/actions/types.js b/packages/gitbook-plugin-search/src/actions/types.js
new file mode 100644
index 0000000..3cd1a89
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/actions/types.js
@@ -0,0 +1,8 @@
+
+module.exports = {
+ CLEAR: 'search/clear',
+ REGISTER_HANDLER: 'search/handlers/register',
+ UNREGISTER_HANDLER: 'search/handlers/unregister',
+ START: 'search/start',
+ END: 'search/end'
+};
diff --git a/packages/gitbook-plugin-search/src/components/Input.js b/packages/gitbook-plugin-search/src/components/Input.js
new file mode 100644
index 0000000..216a5d2
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/components/Input.js
@@ -0,0 +1,73 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const search = require('../actions/search');
+
+const ESCAPE = 27;
+
+const SearchInput = React.createClass({
+ propTypes: {
+ query: React.PropTypes.string,
+ i18n: GitBook.PropTypes.I18n,
+ dispatch: GitBook.PropTypes.dispatch
+ },
+
+ onChange(event) {
+ const { dispatch } = this.props;
+ const { value } = event.currentTarget;
+
+ dispatch(search.query(value));
+ },
+
+ /**
+ * On Escape key down, clear the search field
+ */
+ onKeyDown(e) {
+ const { query } = this.props;
+ if (e.keyCode == ESCAPE && query != '') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.clearSearch();
+ }
+ },
+
+ clearSearch() {
+ this.props.dispatch(search.query(''));
+ },
+
+ render() {
+ const { i18n, query } = this.props;
+
+ let clear;
+ if (query != '') {
+ clear = (
+ <span className="Search-Clear"
+ onClick={this.clearSearch}>
+ ✕
+ </span>
+ );
+ // clear = <GitBook.Icon id="x" onClick={this.clearSearch}/>;
+ }
+
+ return (
+ <div className="Search-Input">
+ <input
+ type="text"
+ onKeyDown={this.onKeyDown}
+ value={query}
+ placeholder={i18n.t('SEARCH_PLACEHOLDER')}
+ onChange={this.onChange}
+ />
+
+ { clear }
+ </div>
+ );
+ }
+});
+
+const mapStateToProps = state => {
+ const { query } = state.search;
+ return { query };
+};
+
+module.exports = GitBook.connect(SearchInput, mapStateToProps);
diff --git a/packages/gitbook-plugin-search/src/components/Results.js b/packages/gitbook-plugin-search/src/components/Results.js
new file mode 100644
index 0000000..16a8cbd
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/components/Results.js
@@ -0,0 +1,80 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+const Highlight = require('react-highlighter');
+
+const MAX_DESCRIPTION_SIZE = 500;
+
+const Result = React.createClass({
+ propTypes: {
+ result: React.PropTypes.object,
+ query: React.PropTypes.string
+ },
+
+ render() {
+ const { result, query } = this.props;
+
+ let summary = result.body.trim();
+ if (summary.length > MAX_DESCRIPTION_SIZE) {
+ summary = summary.slice(0, MAX_DESCRIPTION_SIZE).trim() + '...';
+ }
+
+ return (
+ <div className="Search-ResultContainer">
+ <GitBook.InjectedComponent matching={{ role: 'search:result' }} props={{ result, query }}>
+ <div className="Search-Result">
+ <h3>
+ <GitBook.Link to={result.url}>{result.title}</GitBook.Link>
+ </h3>
+ <p>
+ <Highlight
+ matchElement="span"
+ matchClass="Search-MatchSpan"
+ search={query}>
+ {summary}
+ </Highlight>
+ </p>
+ </div>
+ </GitBook.InjectedComponent>
+ </div>
+ );
+ }
+});
+
+const SearchResults = React.createClass({
+ propTypes: {
+ i18n: GitBook.PropTypes.I18n,
+ results: GitBook.PropTypes.list,
+ query: React.PropTypes.string,
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const { i18n, query, results, children } = this.props;
+
+ if (!query) {
+ return React.Children.only(children);
+ }
+
+ return (
+ <div className="Search-ResultsContainer">
+ <GitBook.InjectedComponent matching={{ role: 'search:results' }} props={{ results, query }}>
+ <div className="Search-Results">
+ <h1>{i18n.t('SEARCH_RESULTS_TITLE', { query, count: results.size })}</h1>
+ <div className="Search-Results">
+ {results.map((result, i) => {
+ return <Result key={i} result={result} query={query} />;
+ })}
+ </div>
+ </div>
+ </GitBook.InjectedComponent>
+ </div>
+ );
+ }
+});
+
+const mapStateToProps = (state) => {
+ const { results, query } = state.search;
+ return { results, query };
+};
+
+module.exports = GitBook.connect(SearchResults, mapStateToProps);
diff --git a/packages/gitbook-plugin-search/src/index.js b/packages/gitbook-plugin-search/src/index.js
new file mode 100644
index 0000000..f8c59aa
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/index.js
@@ -0,0 +1,33 @@
+const GitBook = require('gitbook-core');
+
+const SearchInput = require('./components/Input');
+const SearchResults = require('./components/Results');
+const reducers = require('./reducers');
+const Search = require('./actions/search');
+
+/**
+ * Url of the page changed, we update the search according to this.
+ * @param {GitBook.Location} location
+ * @param {Function} dispatch
+ */
+const onLocationChange = (location, dispatch) => {
+ const { query } = location;
+ const q = query.get('q');
+
+ dispatch(Search.handleQuery(q));
+};
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { History, Components }) => {
+ // Register the navigation handler
+ dispatch(History.listen(onLocationChange));
+
+ // Register components
+ dispatch(Components.registerComponent(SearchInput, { role: 'search:container:input' }));
+ dispatch(Components.registerComponent(SearchResults, { role: 'search:container:results' }));
+ },
+ reduce: reducers,
+ actions: {
+ Search
+ }
+});
diff --git a/packages/gitbook-plugin-search/src/models/Result.js b/packages/gitbook-plugin-search/src/models/Result.js
new file mode 100644
index 0000000..0012b2b
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/models/Result.js
@@ -0,0 +1,20 @@
+const GitBook = require('gitbook-core');
+const { Record } = GitBook.Immutable;
+
+const DEFAULTS = {
+ url: String(''),
+ title: String(''),
+ body: String('')
+};
+
+class Result extends Record(DEFAULTS) {
+ constructor(spec) {
+ if (!spec.url || !spec.title) {
+ throw new Error('"url" and "title" are required to create a search result');
+ }
+
+ super(spec);
+ }
+}
+
+module.exports = Result;
diff --git a/packages/gitbook-plugin-search/src/reducers/index.js b/packages/gitbook-plugin-search/src/reducers/index.js
new file mode 100644
index 0000000..bfce2bd
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/reducers/index.js
@@ -0,0 +1,3 @@
+const GitBook = require('gitbook-core');
+
+module.exports = GitBook.createReducer('search', require('./search'));
diff --git a/packages/gitbook-plugin-search/src/reducers/search.js b/packages/gitbook-plugin-search/src/reducers/search.js
new file mode 100644
index 0000000..b960a77
--- /dev/null
+++ b/packages/gitbook-plugin-search/src/reducers/search.js
@@ -0,0 +1,56 @@
+const GitBook = require('gitbook-core');
+const { Record, List, OrderedMap } = GitBook.Immutable;
+
+const TYPES = require('../actions/types');
+
+const SearchState = Record({
+ // Is the search being processed
+ loading: Boolean(false),
+ // Current query
+ query: String(''),
+ // Current list of results
+ results: List(),
+ // Search handlers
+ handlers: OrderedMap()
+});
+
+module.exports = (state = SearchState(), action) => {
+ switch (action.type) {
+
+ case TYPES.CLEAR:
+ return state.merge({
+ loading: false,
+ query: '',
+ results: List()
+ });
+
+ case TYPES.START:
+ return state.merge({
+ loading: true,
+ query: action.query
+ });
+
+ case TYPES.END:
+ if (action.query !== state.query) {
+ return state;
+ }
+
+ return state.merge({
+ loading: false,
+ results: action.results
+ });
+
+ case TYPES.REGISTER_HANDLER:
+ return state.merge({
+ handlers: state.handlers.set(action.name, action.handler)
+ });
+
+ case TYPES.UNREGISTER_HANDLER:
+ return state.merge({
+ handlers: state.handlers.remove(action.name)
+ });
+
+ default:
+ return state;
+ }
+};
diff --git a/packages/gitbook-plugin-sharing/.gitignore b/packages/gitbook-plugin-sharing/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin-sharing/.npmignore b/packages/gitbook-plugin-sharing/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin-sharing/README.md b/packages/gitbook-plugin-sharing/README.md
new file mode 100644
index 0000000..28ae0d4
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/README.md
@@ -0,0 +1,38 @@
+# plugin-sharing
+
+This plugin adds sharing buttons in the GitBook website toolbar to share book on social networks.
+
+### Disable this plugin
+
+This is a default plugin and it can be disabled using a `book.json` configuration:
+
+```
+{
+ plugins: ["-sharing"]
+}
+```
+
+### Configuration
+
+This plugin can be configured in the `book.json`:
+
+Default configuration is:
+
+```js
+{
+ "pluginsConfig": {
+ "sharing": {
+ "facebook": true,
+ "twitter": true,
+ "google": false,
+ "weibo": false,
+ "instapaper": false,
+ "vk": false,
+ "all": [
+ "facebook", "google", "twitter",
+ "weibo", "instapaper"
+ ]
+ }
+ }
+}
+```
diff --git a/packages/gitbook-plugin-sharing/index.js b/packages/gitbook-plugin-sharing/index.js
new file mode 100644
index 0000000..e542ae8
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ blocks: {
+
+ },
+
+ hooks: {
+
+ }
+};
diff --git a/packages/gitbook-plugin-sharing/package.json b/packages/gitbook-plugin-sharing/package.json
new file mode 100644
index 0000000..b0540e8
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "gitbook-plugin-sharing",
+ "description": "Sharing buttons in the toolbar",
+ "main": "index.js",
+ "browser": "./_assets/plugin.js",
+ "version": "4.0.0",
+ "gitbook": {
+ "properties": {
+ "facebook": {
+ "type": "boolean",
+ "default": true,
+ "title": "Facebook"
+ },
+ "twitter": {
+ "type": "boolean",
+ "default": true,
+ "title": "Twitter"
+ },
+ "google": {
+ "type": "boolean",
+ "default": false,
+ "title": "Google"
+ },
+ "weibo": {
+ "type": "boolean",
+ "default": false,
+ "description": "Weibo"
+ },
+ "instapaper": {
+ "type": "boolean",
+ "default": false,
+ "description": "Instapaper"
+ },
+ "vk": {
+ "type": "boolean",
+ "default": false,
+ "description": "VK"
+ },
+ "all": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "facebook",
+ "google",
+ "twitter",
+ "weibo",
+ "instapaper"
+ ],
+ "uniqueItems": true
+ }
+ }
+ },
+ "dependencies": {
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "gitbook-plugin": "4.0.0"
+ },
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "scripts": {
+ "build-js": "gitbook-plugin build ./src/index.js ./_assets/plugin.js",
+ "prepublish": "npm run build-js"
+ },
+ "homepage": "https://github.com/GitbookIO/gitbook",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ },
+ "license": "Apache-2.0"
+}
diff --git a/packages/gitbook-plugin-sharing/src/SITES.js b/packages/gitbook-plugin-sharing/src/SITES.js
new file mode 100644
index 0000000..86eae74
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/SITES.js
@@ -0,0 +1,72 @@
+// All the sharing platforms
+const SITES = {
+
+ // One sharing platform
+ 'facebook': {
+ // Displayed name
+ label: 'Facebook',
+
+ // Font-awesome icon id
+ icon: 'facebook',
+
+ /**
+ * Share a page on this platform
+ * @param {String} url The url to share
+ * @param {String} title The title of the url page
+ */
+ onShare(url, title) {
+ url = encodeURIComponent(url);
+ window.open(`http://www.facebook.com/sharer/sharer.php?s=100&p[url]=${url}`);
+ }
+ },
+
+ 'twitter': {
+ label: 'Twitter',
+ icon: 'twitter',
+ onShare(url, title) {
+ const status = encodeURIComponent(title + ' ' + url);
+ window.open(`http://twitter.com/home?status=${status}`);
+ }
+ },
+
+ 'google': {
+ label: 'Google+',
+ icon: 'google-plus',
+ onShare(url, title) {
+ url = encodeURIComponent(url);
+ window.open(`https://plus.google.com/share?url=${url}`);
+ }
+ },
+
+ 'weibo': {
+ label: 'Weibo',
+ icon: 'weibo',
+ onShare(url, title) {
+ url = encodeURIComponent(url);
+ title = encodeURIComponent(title);
+ window.open(`http://service.weibo.com/share/share.php?content=utf-8&url=${url}&title=${title}`);
+ }
+ },
+
+ 'instapaper': {
+ label: 'Instapaper',
+ icon: 'instapaper',
+ onShare(url, title) {
+ url = encodeURIComponent(url);
+ window.open(`http://www.instapaper.com/text?u=${url}`);
+ }
+ },
+
+ 'vk': {
+ label: 'VK',
+ icon: 'vk',
+ onShare(url, title) {
+ url = encodeURIComponent(url);
+ window.open(`http://vkontakte.ru/share.php?url=${url}`);
+ }
+ }
+};
+
+SITES.ALL = Object.keys(SITES);
+
+module.exports = SITES;
diff --git a/packages/gitbook-plugin-sharing/src/components/ShareButton.js b/packages/gitbook-plugin-sharing/src/components/ShareButton.js
new file mode 100644
index 0000000..8983423
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/components/ShareButton.js
@@ -0,0 +1,47 @@
+const GitBook = require('gitbook-core');
+const { React, Dropdown, Backdrop } = GitBook;
+
+const SITES = require('../SITES');
+
+// Share button with dropdown list of sites
+const ShareButton = React.createClass({
+ propTypes: {
+ siteIds: React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
+ onShare: React.PropTypes.func.isRequired
+ },
+
+ getInitialState() {
+ return { open: false };
+ },
+
+ onToggle() {
+ const { open } = this.state;
+ this.setState({ open: !open });
+ },
+
+ render() {
+ const { siteIds, onShare } = this.props;
+ const { open } = this.state;
+
+ return (
+ <Dropdown.Container>
+ {open ? <Backdrop onClose={this.onToggle} /> : null}
+
+ <GitBook.Button onClick={this.onToggle}>
+ <GitBook.Icon id="share-alt" />
+ </GitBook.Button>
+
+ {open ? (
+ <Dropdown.Menu>
+ {siteIds.map((id) => (
+ <Dropdown.ItemLink onClick={() => onShare(SITES[id])} key={id}>
+ {SITES[id].label}
+ </Dropdown.ItemLink>
+ ))}
+ </Dropdown.Menu>) : null}
+ </Dropdown.Container>
+ );
+ }
+});
+
+module.exports = ShareButton;
diff --git a/packages/gitbook-plugin-sharing/src/components/SharingButtons.js b/packages/gitbook-plugin-sharing/src/components/SharingButtons.js
new file mode 100644
index 0000000..4f5ada9
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/components/SharingButtons.js
@@ -0,0 +1,63 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const SITES = require('../SITES');
+const optionsShape = require('../shapes/options');
+const SiteButton = require('./SiteButton');
+const ShareButton = require('./ShareButton');
+
+/**
+ * Displays the group of sharing buttons
+ */
+const SharingButtons = React.createClass({
+ propTypes: {
+ options: optionsShape.isRequired,
+ page: GitBook.PropTypes.Page.isRequired
+ },
+
+ onShare(site) {
+ site.onShare(location.href, this.props.page.title);
+ },
+
+ render() {
+ const { options } = this.props;
+
+ // Highlighted sites
+ const mainButtons = SITES
+ .ALL
+ .filter(id => options[id])
+ .map(id => <SiteButton key={id} onShare={this.onShare} site={SITES[id]} />);
+
+ // Other sites
+ let shareButton = undefined;
+ if (options.all.length > 0) {
+ shareButton = (
+ <ShareButton siteIds={options.all}
+ onShare={this.onShare} />
+ );
+ }
+
+ return (
+ <GitBook.ButtonGroup>
+ { mainButtons }
+ { shareButton }
+ </GitBook.ButtonGroup>
+ );
+ }
+});
+
+function mapStateToProps(state) {
+ let options = state.config.getIn(['pluginsConfig', 'sharing']);
+ if (options) {
+ options = options.toJS();
+ } else {
+ options = { all: [] };
+ }
+
+ return {
+ page: state.page,
+ options
+ };
+}
+
+module.exports = GitBook.connect(SharingButtons, mapStateToProps);
diff --git a/packages/gitbook-plugin-sharing/src/components/SiteButton.js b/packages/gitbook-plugin-sharing/src/components/SiteButton.js
new file mode 100644
index 0000000..e03720d
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/components/SiteButton.js
@@ -0,0 +1,29 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const siteShape = require('../shapes/site');
+
+// An individual site sharing button
+const SiteButton = React.createClass({
+ propTypes: {
+ site: siteShape.isRequired,
+ onShare: React.PropTypes.func.isRequired
+ },
+
+ onClick(e) {
+ e.preventDefault();
+ this.props.onShare(this.props.site);
+ },
+
+ render() {
+ const { site } = this.props;
+
+ return (
+ <GitBook.Button onClick={this.onClick}>
+ <GitBook.Icon id={site.icon}/>
+ </GitBook.Button>
+ );
+ }
+});
+
+module.exports = SiteButton;
diff --git a/packages/gitbook-plugin-sharing/src/index.js b/packages/gitbook-plugin-sharing/src/index.js
new file mode 100644
index 0000000..174adfc
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/index.js
@@ -0,0 +1,9 @@
+const GitBook = require('gitbook-core');
+const SharingButtons = require('./components/SharingButtons');
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState, { Components }) => {
+ // Dispatch initialization actions
+ dispatch(Components.registerComponent(SharingButtons, { role: 'toolbar:buttons:right' }));
+ }
+});
diff --git a/packages/gitbook-plugin-sharing/src/optionsShape.js b/packages/gitbook-plugin-sharing/src/optionsShape.js
new file mode 100644
index 0000000..dd51016
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/optionsShape.js
@@ -0,0 +1,20 @@
+const {
+ bool,
+ arrayOf,
+ oneOf,
+ shape
+} = require('gitbook-core').React.PropTypes;
+
+const { ALL } = require('./SITES');
+
+const optionsShape = shape({
+ facebook: bool,
+ twitter: bool,
+ google: bool,
+ weibo: bool,
+ instapaper: bool,
+ vk: bool,
+ all: arrayOf(oneOf(ALL)).isRequired
+});
+
+module.exports = optionsShape;
diff --git a/packages/gitbook-plugin-sharing/src/shapes/options.js b/packages/gitbook-plugin-sharing/src/shapes/options.js
new file mode 100644
index 0000000..885feb6
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/shapes/options.js
@@ -0,0 +1,19 @@
+const {
+ bool,
+ arrayOf,
+ oneOf,
+ shape
+} = require('gitbook-core').React.PropTypes;
+const { ALL } = require('../SITES');
+
+const optionsShape = shape({
+ facebook: bool,
+ twitter: bool,
+ google: bool,
+ weibo: bool,
+ instapaper: bool,
+ vk: bool,
+ all: arrayOf(oneOf(ALL)).isRequired
+});
+
+module.exports = optionsShape;
diff --git a/packages/gitbook-plugin-sharing/src/shapes/site.js b/packages/gitbook-plugin-sharing/src/shapes/site.js
new file mode 100644
index 0000000..2227429
--- /dev/null
+++ b/packages/gitbook-plugin-sharing/src/shapes/site.js
@@ -0,0 +1,13 @@
+const {
+ string,
+ func,
+ shape
+} = require('gitbook-core').React.PropTypes;
+
+const siteShape = shape({
+ label: string.isRequired,
+ icon: string.isRequired,
+ onShare: func.isRequired
+});
+
+module.exports = siteShape;
diff --git a/packages/gitbook-plugin-theme-default/.gitignore b/packages/gitbook-plugin-theme-default/.gitignore
new file mode 100644
index 0000000..dfd90dc
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/.gitignore
@@ -0,0 +1 @@
+_assets
diff --git a/packages/gitbook-plugin-theme-default/.npmignore b/packages/gitbook-plugin-theme-default/.npmignore
new file mode 100644
index 0000000..75e0923
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/.npmignore
@@ -0,0 +1 @@
+!_assets
diff --git a/packages/gitbook-plugin-theme-default/index.js b/packages/gitbook-plugin-theme-default/index.js
new file mode 100644
index 0000000..f4d6253
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/index.js
@@ -0,0 +1,3 @@
+module.exports = {
+
+};
diff --git a/packages/gitbook-plugin-theme-default/less/Body.less b/packages/gitbook-plugin-theme-default/less/Body.less
new file mode 100644
index 0000000..4bc33db
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Body.less
@@ -0,0 +1,9 @@
+.Body-Flex {
+ .flex(1 0 auto);
+}
+
+.Body {
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Button.less b/packages/gitbook-plugin-theme-default/less/Button.less
new file mode 100644
index 0000000..336d16e
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Button.less
@@ -0,0 +1,22 @@
+.GitBook-Button {
+ border: 0;
+ background-color: transparent;
+ background: @button-background;
+ color: @button-color;
+ text-align: center;
+ line-height: @line-height-base;
+ outline: none;
+ padding: @button-padding;
+
+ &:hover {
+ color: @button-hover-color;
+ }
+
+ &:focus, &:hover {
+ outline: none;
+ }
+}
+
+.GitBook-ButtonGroup {
+ display: inline-block;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Dropdown.less b/packages/gitbook-plugin-theme-default/less/Dropdown.less
new file mode 100644
index 0000000..2c341e4
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Dropdown.less
@@ -0,0 +1,56 @@
+.GitBook-Dropdown {
+ display: inline-block;
+ position: relative;
+}
+
+.GitBook-DropdownMenu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: 300;
+ border: 1px solid @dropdown-border-color;
+ margin: 5px;
+ margin-top: 0px;
+ border-radius: 3px;
+ background: @dropdown-background;
+
+ &:before {
+ content: " ";
+ width: 0;
+ height: 0;
+ border-left: @dropdown-arrow-width solid transparent;
+ border-right: @dropdown-arrow-width solid transparent;
+ border-bottom: @dropdown-arrow-width solid @dropdown-border-color;
+ position: absolute;
+ top: -@dropdown-arrow-width;
+ right: 10px;
+ }
+
+ &:after {
+ content: " ";
+ width: 0;
+ height: 0;
+ border-left: (@dropdown-arrow-width - 1) solid transparent;
+ border-right: (@dropdown-arrow-width - 1) solid transparent;
+ border-bottom: (@dropdown-arrow-width - 1) solid @dropdown-background;
+ position: absolute;
+ top: -(@dropdown-arrow-width - 1);
+ right: 11px;
+ }
+}
+
+.GitBook-DropdownItem {
+ padding: @dropdown-padding-v @dropdown-padding-h;
+}
+
+.GitBook-DropdownItemLink {
+ width: 100%;
+ display: inline-block;
+ padding: @dropdown-padding-v @dropdown-padding-h;
+ text-align: center;
+ color: @dropdown-color;
+
+ &:hover {
+ color: @dropdown-hover-color;
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/LoadingBar.less b/packages/gitbook-plugin-theme-default/less/LoadingBar.less
new file mode 100644
index 0000000..1fca2ea
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/LoadingBar.less
@@ -0,0 +1,30 @@
+.LoadingBar {
+ pointer-events: none;
+ transition: 400ms linear all;
+
+ .LoadingBar-Bar {
+ background: @color-primary;
+ height: 2px;
+
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 10000;
+ display: none;
+ width: 100%;
+ border-radius: 0 1px 1px 0;
+ transition: width 350ms;
+ }
+
+ .LoadingBar-Shadow {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 70px;
+ height: 2px;
+ border-radius: 50%;
+ opacity: .45;
+ box-shadow: @color-primary 1px 0 6px 1px;
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Page.less b/packages/gitbook-plugin-theme-default/less/Page.less
new file mode 100644
index 0000000..6011533
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Page.less
@@ -0,0 +1,16 @@
+.PageContainer {
+ position: relative;
+ outline: none;
+ width: 100%;
+ max-width: @page-width;
+ margin: 0px auto;
+ padding: 20px 15px 40px 15px;
+ font-size: @page-font-size;
+ .gitbook-markdown(@md-color: @page-color, @md-line-height: @page-line-height);
+ overflow: visible;
+
+ .glossary-term {
+ cursor: help;
+ text-decoration: underline;
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Panel.less b/packages/gitbook-plugin-theme-default/less/Panel.less
new file mode 100644
index 0000000..507396c
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Panel.less
@@ -0,0 +1,7 @@
+.GitBook-Panel {
+ border: 2px solid #f5f5f5;
+ padding: 10px;
+ background: #fafafa;
+ border-radius: 2px;
+ margin-top: 20px;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Search.less b/packages/gitbook-plugin-theme-default/less/Search.less
new file mode 100644
index 0000000..faa871f
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Search.less
@@ -0,0 +1,38 @@
+.Search-Input {
+ padding: 6px;
+ background: transparent;
+ transition: top 0.5s ease;
+ background: #fff;
+ border-bottom: 1px solid @sidebar-border-color;
+ border-top: 1px solid @sidebar-border-color;
+ margin-bottom: 10px;
+
+ // Move top to hide top border
+ margin-top: -1px;
+
+ input, input:focus, input:hover {
+ width: 90%; // 10% room for clear input X
+ background: transparent;
+ border: 1px solid transparent;
+ box-shadow: none;
+ outline: none;
+ line-height: 22px;
+ padding: 7px 7px;
+ color: inherit;
+ }
+}
+
+.Search-Clear {
+ width: 10%;
+ display: inline-block;
+ text-align: center;
+ font-size: 14px;
+ line-height: 22px;
+ color: @search-clear-color;
+ cursor: pointer;
+}
+
+
+.Search-MatchSpan {
+ background: @search-highlight-color;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Sidebar.less b/packages/gitbook-plugin-theme-default/less/Sidebar.less
new file mode 100644
index 0000000..1689b9f
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Sidebar.less
@@ -0,0 +1,29 @@
+.Sidebar-Flex {
+ .flex(0 0 @sidebar-width);
+
+ &.Layout-enter {
+ margin-left: -@sidebar-width;
+
+ &.Layout-enter-active {
+ margin-left: 0;
+ transition: margin-left 250ms ease-in-out;
+ }
+ }
+
+ &.Layout-leave {
+ margin-left: 0;
+
+ &.Layout-leave-active {
+ margin-left: -@sidebar-width;
+ transition: margin-left 250ms ease-in-out;
+ }
+ }
+}
+
+.Sidebar {
+ height: 100%;
+ background: @sidebar-background;
+ background: rgb(250, 250, 250);
+ border-right: 1px solid @sidebar-border-color;
+ overflow-y: auto;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Summary.less b/packages/gitbook-plugin-theme-default/less/Summary.less
new file mode 100644
index 0000000..1e1e8ba
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Summary.less
@@ -0,0 +1,51 @@
+.Summary {
+
+}
+
+.SummaryPart {
+
+}
+
+.SummaryPart-Title {
+ margin: 0px;
+ padding: 2*@summary-article-padding-v @summary-article-padding-h;
+ text-transform: uppercase;
+ color: @summary-header-color;
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+.SummaryArticles {
+ list-style: none;
+ margin: 0px;
+ padding: 0px;
+}
+
+.SummaryArticle {
+ list-style: none;
+
+ a, span {
+ display: block;
+ padding: @summary-article-padding-v @summary-article-padding-h;
+ border-bottom: none;
+ color: @summary-article-color;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ position: relative;
+ text-decoration: none;
+ outline: none;
+ }
+
+ a:hover {
+ text-decoration: none;
+ color: @summary-article-hover-color;
+ }
+
+ &.active, &.active:hover {
+ a {
+ color: @summary-article-active-color;
+ background: @summary-article-active-background;
+ }
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Toolbar.less b/packages/gitbook-plugin-theme-default/less/Toolbar.less
new file mode 100644
index 0000000..8c59d96
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Toolbar.less
@@ -0,0 +1,27 @@
+.Toolbar {
+ .Toolbar-Title {
+ padding: 0px 20px;
+ margin: 0;
+ font-size: 20px;
+ font-weight: 200;
+ text-align: center;
+ line-height: 50px;
+ opacity: 0;
+ .transition(~"opacity ease .2s");
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: @button-hover-color;
+
+ a, a:hover {
+ text-decoration: none;
+ color: inherit;
+ }
+ }
+
+ &:hover {
+ .Toolbar-Title {
+ opacity: 1;
+ }
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/Tooltipped.less b/packages/gitbook-plugin-theme-default/less/Tooltipped.less
new file mode 100644
index 0000000..126daab
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/Tooltipped.less
@@ -0,0 +1,100 @@
+.GitBook-Tooltipped {
+ display: inline-block;
+ position: relative;
+
+ &:hover, &.Tooltipped-o {
+ &:after {
+ line-height: 1em;
+ background: @tooltip-background;
+ border-radius: @tooltip-radius;
+ bottom: auto;
+ top: ~"calc(100% + 10px)";
+ color: @tooltip-color;
+ content: attr(aria-label);
+ display: block;
+ left: 50%;
+ padding: 5px 5px;
+ position: absolute;
+ white-space: nowrap;
+ z-index: @zindex-tooltip;
+ font-size: 13px;
+ text-transform: none;
+ font-weight: @font-size-base;
+ pointer-events: none;
+ transform: translateX(-50%);
+ }
+
+ &:before {
+ border: solid;
+ border-color: @tooltip-background transparent;
+ bottom:auto;
+ top: ~"calc(100% + 5px)";
+ border-width: 0px 5px 5px 5px;
+ content: "";
+ display: block;
+ left: 50%;
+ position: absolute;
+ z-index: @zindex-tooltip+1;
+ transform: translateX(-50%);
+ }
+ }
+
+ .north() {
+ &:after {
+ top: auto;
+ bottom: ~"calc(100% + 10px)";
+ transform: translateX(0%);
+ }
+ &:before {
+ top: auto;
+ border-width: 5px 5px 0px 5px;
+ bottom: ~"calc(100% + 5px)";
+ transform: translateX(0%);
+ }
+ }
+ .west() {
+ &:after {
+ left: auto;
+ right: 5px;
+ transform: translateX(0%);
+ }
+ &:before {
+ left: auto;
+ right: 10px;
+ transform: translateX(0%);
+ }
+ }
+ .east() {
+ &:after {
+ right: auto;
+ left: 5px;
+ transform: translateX(0%);
+ }
+ &:before {
+ right: auto;
+ left: 10px;
+ transform: translateX(0%);
+ }
+ }
+
+ &.Tooltipped-e {
+ .east()
+ }
+
+ &.Tooltipped-n {
+ .north();
+ }
+
+ &.Tooltipped-ne {
+ .north();
+ .east();
+ }
+
+ &.Tooltipped-nw {
+ .north();
+ .west();
+ }
+ &.Tooltipped-sw, &.Tooltipped-w {
+ .west();
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/less/main.less b/packages/gitbook-plugin-theme-default/less/main.less
new file mode 100644
index 0000000..d3c0dd5
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/main.less
@@ -0,0 +1,50 @@
+@import "../node_modules/preboot/less/preboot.less";
+@import "../node_modules/gitbook-markdown-css/less/mixin.less";
+@import "../node_modules/font-awesome/less/font-awesome.less";
+
+@import "mixins.less";
+@import "reset.less";
+@import "variables.less";
+
+@import "Button.less";
+@import "Sidebar.less";
+@import "Summary.less";
+@import "Page.less";
+@import "Toolbar.less";
+@import "Search.less";
+@import "Body.less";
+@import "Dropdown.less";
+@import "LoadingBar.less";
+@import "Tooltipped.less";
+@import "Panel.less";
+
+* {
+ .box-sizing(border-box);
+ -webkit-overflow-scrolling: touch;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-text-size-adjust: none;
+ -webkit-touch-callout: none;
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ text-decoration: none;
+}
+
+html, body {
+ margin: 0px;
+ height: 100%;
+}
+
+html {
+ font-size: 62.5%;
+}
+
+body {
+ text-rendering: optimizeLegibility;
+ font-smoothing: antialiased;
+ font-family: @font-family-base;
+ font-size: @font-size-base;
+ letter-spacing: .2px;
+ .text-adjust(100%);
+}
diff --git a/packages/gitbook-plugin-theme-default/less/mixins.less b/packages/gitbook-plugin-theme-default/less/mixins.less
new file mode 100644
index 0000000..e4308b9
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/mixins.less
@@ -0,0 +1,15 @@
+.text-adjust(@value) {
+ text-size-adjust: @value;
+ -ms-text-size-adjust: @value;
+ -webkit-text-size-adjust: @value;
+}
+
+// The 'flex' shorthand
+// - applies to: flex items
+// <positive-number>, initial, auto, or none
+.flex(@columns: initial) {
+ -webkit-flex: @columns;
+ -moz-flex: @columns;
+ -ms-flex: @columns;
+ flex: @columns;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/reset.less b/packages/gitbook-plugin-theme-default/less/reset.less
new file mode 100644
index 0000000..a9c6f52
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/reset.less
@@ -0,0 +1,396 @@
+/*! normalize.css v2.1.0 | MIT License | git.io/normalize */
+
+/* ==========================================================================
+ HTML5 display definitions
+ ========================================================================== */
+
+/**
+ * Correct `block` display not defined in IE 8/9.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/**
+ * Correct `inline-block` display not defined in IE 8/9.
+ */
+
+audio,
+canvas,
+video {
+ display: inline-block;
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+[hidden] {
+ display: none;
+}
+
+/* ==========================================================================
+ Base
+ ========================================================================== */
+
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS text size adjust after orientation change, without disabling
+ * user zoom.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+ -ms-text-size-adjust: 100%; /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Links
+ ========================================================================== */
+
+/**
+ * Address `outline` inconsistency between Chrome and other browsers.
+ */
+
+a:focus {
+ outline: thin dotted;
+}
+
+/**
+ * Improve readability when focused and also mouse hovered in all browsers.
+ */
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* ==========================================================================
+ Typography
+ ========================================================================== */
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari 5, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9, Safari 5, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
+ */
+
+b,
+strong {
+ font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari 5 and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+
+hr {
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+ height: 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+/**
+ * Correct font family set oddly in Safari 5 and Chrome.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, serif;
+ font-size: 1em;
+}
+
+/**
+ * Improve readability of pre-formatted text in all browsers.
+ */
+
+pre {
+ white-space: pre-wrap;
+}
+
+/**
+ * Set consistent quote types.
+ */
+
+q {
+ quotes: "\201C" "\201D" "\2018" "\2019";
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* ==========================================================================
+ Embedded content
+ ========================================================================== */
+
+/**
+ * Remove border when inside `a` element in IE 8/9.
+ */
+
+img {
+ border: 0;
+}
+
+/**
+ * Correct overflow displayed oddly in IE 9.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* ==========================================================================
+ Figures
+ ========================================================================== */
+
+/**
+ * Address margin not present in IE 8/9 and Safari 5.
+ */
+
+figure {
+ margin: 0;
+}
+
+/* ==========================================================================
+ Forms
+ ========================================================================== */
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * 1. Correct font family not being inherited in all browsers.
+ * 2. Correct font size not being inherited in all browsers.
+ * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome.
+ */
+
+button,
+input,
+select,
+textarea {
+ font-family: inherit; /* 1 */
+ font-size: 100%; /* 2 */
+ margin: 0; /* 3 */
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+button,
+input {
+ line-height: normal;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+.
+ * Correct `select` style inheritance in Firefox 4+ and Opera.
+ */
+
+button,
+select {
+ text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+ cursor: pointer; /* 3 */
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+
+/**
+ * 1. Address box sizing set to `content-box` in IE 8/9.
+ * 2. Remove excess padding in IE 8/9.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
+ * (include `-moz` to future-proof).
+ */
+
+input[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box; /* 2 */
+ box-sizing: content-box;
+}
+
+/**
+ * Remove inner padding and search cancel button in Safari 5 and Chrome
+ * on OS X.
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/**
+ * 1. Remove default vertical scrollbar in IE 8/9.
+ * 2. Improve readability and alignment in all browsers.
+ */
+
+textarea {
+ overflow: auto; /* 1 */
+ vertical-align: top; /* 2 */
+}
+
+/* ==========================================================================
+ Tables
+ ========================================================================== */
+
+/**
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
diff --git a/packages/gitbook-plugin-theme-default/less/variables.less b/packages/gitbook-plugin-theme-default/less/variables.less
new file mode 100644
index 0000000..5c6842d
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/less/variables.less
@@ -0,0 +1,55 @@
+// Colors
+@color-primary: hsl(207, 100%, 50%); // rgb(44, 106, 254);
+// Fonts
+@font-family-serif: Georgia, serif;
+@font-family-sans: "Helvetica Neue", Helvetica, Arial, sans-serif;
+@font-family-base: @font-family-sans;
+// Font sizes
+@font-size-base: 14px;
+@font-size-large: ceil(@font-size-base * 1.25); // ~18px
+@font-size-small: ceil(@font-size-base * 0.85); // ~12px
+@line-height-base: 1.428571429; // 20/14
+@line-height-computed: floor(@font-size-base * @line-height-base);
+// Sidebar
+@sidebar-background: rgb(250, 250, 250);
+@sidebar-border-color: rgba(0, 0, 0, 0.0666667);
+@sidebar-width: 300px;
+// Summary
+@summary-header-color: #939da3;
+@summary-article-padding-v: 10px;
+@summary-article-padding-h: 15px;
+@summary-article-color: hsl(207, 15%, 25%);
+@summary-article-hover-color: @color-primary;
+@summary-article-active-color: @summary-article-color;
+@summary-article-active-background: #f5f5f5;
+// Page
+@page-width: 800px;
+@page-color: #333333;
+@page-line-height: 1.7;
+@page-font-size: 16px;
+// Button
+@button-padding: 19px;
+@button-background: transparent;
+@button-color: #bbb;
+@button-hover-color: #a1a1a1;
+// Dropdown
+@dropdown-padding-v: 10px;
+@dropdown-padding-h: 15px;
+@dropdown-arrow-width: 8px;
+@dropdown-border-color: #e5e5e5;
+@dropdown-color: @button-color;
+@dropdown-hover-color: @button-hover-color;
+@dropdown-background: #fff;
+// Tooltip
+@tooltip-background: rgba(0,0,0,.8);
+@tooltip-radius: 3px;
+@tooltip-color: #fff;
+// Search
+@search-highlight-color: rgba(255, 220, 0, 0.4);
+@search-clear-color: @button-color;
+// Font awesome
+@path-assets: '.';
+@path-fonts: '@{path-assets}/fonts';
+@fa-font-path: '@{path-fonts}/fontawesome';
+// Z-indexes
+@zindex-tooltip: 300;
diff --git a/packages/gitbook-plugin-theme-default/package.json b/packages/gitbook-plugin-theme-default/package.json
new file mode 100644
index 0000000..7c44305
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "gitbook-plugin-theme-default",
+ "description": "Default theme for GitBook",
+ "main": "./index.js",
+ "browser": "./_assets/theme.js",
+ "version": "4.0.0",
+ "engines": {
+ "gitbook": ">=3.0.0"
+ },
+ "dependencies": {
+ "debounce": "^1.0.0",
+ "gitbook-core": "4.0.0"
+ },
+ "devDependencies": {
+ "classnames": "^2.2.5",
+ "font-awesome": "^4.6.3",
+ "gitbook-markdown-css": "^1.0.1",
+ "gitbook-plugin": "4.0.0",
+ "less": "^2.7.1",
+ "less-plugin-clean-css": "^1.5.1",
+ "preboot": "git+https://github.com/mdo/preboot.git#4aab4edd85f076d50609cbe28e4fe66cc0771701"
+ },
+ "scripts": {
+ "prepublish": "./prepublish.sh"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "author": "GitBook Inc. <contact@gitbook.com>",
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ },
+ "contributors": [
+ {
+ "name": "Samy Pessé",
+ "email": "samy@gitbook.com"
+ }
+ ],
+ "gitbook": {
+ "properties": {
+ "styles": {
+ "type": "object",
+ "title": "Custom Stylesheets",
+ "properties": {
+ "website": {
+ "title": "Stylesheet for website output",
+ "default": "styles/website.css"
+ },
+ "pdf": {
+ "title": "Stylesheet for PDF output",
+ "default": "styles/pdf.css"
+ },
+ "epub": {
+ "title": "Stylesheet for ePub output",
+ "default": "styles/epub.css"
+ },
+ "mobi": {
+ "title": "Stylesheet for Mobi output",
+ "default": "styles/mobi.css"
+ },
+ "ebook": {
+ "title": "Stylesheet for ebook outputs (PDF, ePub, Mobi)",
+ "default": "styles/ebook.css"
+ },
+ "print": {
+ "title": "Stylesheet to replace default ebook css",
+ "default": "styles/print.css"
+ }
+ }
+ },
+ "showLevel": {
+ "type": "boolean",
+ "title": "Show level indicator in TOC",
+ "default": false
+ }
+ }
+ }
+}
diff --git a/packages/gitbook-plugin-theme-default/prepublish.sh b/packages/gitbook-plugin-theme-default/prepublish.sh
new file mode 100755
index 0000000..458df9b
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/prepublish.sh
@@ -0,0 +1,11 @@
+#! /bin/bash
+#
+# Compile LESS To CSS
+lessc -clean-css ./less/main.less ./_assets/website/theme.css
+
+# Compile JS
+gitbook-plugin build ./src/index.js ./_assets/theme.js
+
+# Copy fonts
+mkdir -p _assets/website/fonts
+cp -R node_modules/font-awesome/fonts/ _assets/website/fonts/fontawesome/
diff --git a/packages/gitbook-plugin-theme-default/src/actions/sidebar.js b/packages/gitbook-plugin-theme-default/src/actions/sidebar.js
new file mode 100644
index 0000000..52f8422
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/actions/sidebar.js
@@ -0,0 +1,13 @@
+const ActionTypes = require('./types');
+
+/**
+ * Toggle the sidebar
+ * @return {Action}
+ */
+function toggle() {
+ return { type: ActionTypes.TOGGLE_SIDEBAR };
+}
+
+module.exports = {
+ toggle
+};
diff --git a/packages/gitbook-plugin-theme-default/src/actions/types.js b/packages/gitbook-plugin-theme-default/src/actions/types.js
new file mode 100644
index 0000000..9f8a80f
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/actions/types.js
@@ -0,0 +1,4 @@
+
+module.exports = {
+ TOGGLE_SIDEBAR: 'theme-default/sidebar/toggle'
+};
diff --git a/packages/gitbook-plugin-theme-default/src/components/Body.js b/packages/gitbook-plugin-theme-default/src/components/Body.js
new file mode 100644
index 0000000..c61a2f3
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Body.js
@@ -0,0 +1,121 @@
+const debounce = require('debounce');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const Page = require('./Page');
+const Toolbar = require('./Toolbar');
+
+const HEADINGS_SELECTOR = 'h1[id],h2[id],h3[id],h4[id]';
+
+/**
+ * Get offset of an element relative to a parent container.
+ * @param {DOMElement} container
+ * @param {DOMElement} element
+ * @return {Number} offset
+ */
+function getOffset(container, element, type = 'Top') {
+ const parent = element.parentElement;
+ let base = 0;
+
+ if (parent != container) {
+ base = getOffset(container, parent, type);
+ }
+
+ return base + element[`offset${type}`];
+}
+
+/**
+ * Find the current heading anchor for a scroll position.
+ * @param {DOMElement} container
+ * @param {Number} top
+ * @return {String}
+ */
+function getHeadingID(container, top) {
+ let id;
+ const headings = container.querySelectorAll(HEADINGS_SELECTOR);
+
+ headings.forEach(heading => {
+ if (id) {
+ return;
+ }
+
+ const offset = getOffset(container, heading);
+
+ if (offset > top) {
+ id = heading.getAttribute('id');
+ }
+ });
+
+ return id;
+}
+
+const Body = React.createClass({
+ propTypes: {
+ page: GitBook.PropTypes.Page,
+ readme: GitBook.PropTypes.Readme,
+ history: GitBook.PropTypes.History,
+ updateURI: React.PropTypes.func
+ },
+
+ getInitialState() {
+ this.debouncedOnScroll = debounce(this.onScroll, 300);
+ return {};
+ },
+
+ /**
+ * User is scrolling the page, update the location with current section's ID.
+ */
+ onScroll() {
+ const { scrollContainer } = this;
+ const { history, updateURI } = this.props;
+ const { location } = history;
+
+ // Find the id matching the current scroll position
+ const hash = getHeadingID(scrollContainer, scrollContainer.scrollTop);
+
+ // Update url if changed
+ if (hash !== location.hash) {
+ updateURI(location.merge({ hash }));
+ }
+ },
+
+ /**
+ * Component has been updated with a new location,
+ * scroll to the right anchor.
+ */
+ componentDidUpdate() {
+
+ },
+
+ render() {
+ const { page, readme } = this.props;
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'body:wrapper' }}>
+ <div
+ className="Body page-wrapper"
+ onScroll={this.debouncedOnScroll}
+ ref={div => this.scrollContainer = div}
+ >
+ <GitBook.InjectedComponent matching={{ role: 'toolbar:wrapper' }}>
+ <Toolbar title={page.title} readme={readme} />
+ </GitBook.InjectedComponent>
+ <GitBook.InjectedComponent matching={{ role: 'page:wrapper' }}>
+ <Page page={page} />
+ </GitBook.InjectedComponent>
+ </div>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+
+module.exports = GitBook.connect(Body,
+ () => {
+ return {};
+ },
+ ({ History }, dispatch) => {
+ return {
+ updateURI: (location) => dispatch(History.replace(location))
+ };
+ }
+);
diff --git a/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js b/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js
new file mode 100644
index 0000000..11e1ddb
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/LoadingBar.js
@@ -0,0 +1,124 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+/**
+ * Displays a progress bar (YouTube-like) at the top of container
+ * Based on https://github.com/lonelyclick/react-loading-bar/blob/master/src/Loading.jsx
+ */
+const LoadingBar = React.createClass({
+ propTypes: {
+ show: React.PropTypes.bool
+ },
+
+ getDefaultProps() {
+ return {
+ show: false
+ };
+ },
+
+ getInitialState() {
+ return {
+ size: 0,
+ disappearDelayHide: false, // when dispappear, first transition then display none
+ percent: 0,
+ appearDelayWidth: 0 // when appear, first display block then transition width
+ };
+ },
+
+ componentWillReceiveProps(nextProps) {
+ const { show } = nextProps;
+
+ if (show) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ },
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return true; // !shallowEqual(nextState, this.state)
+ },
+
+ show() {
+ let { size, percent } = this.state;
+
+ const appearDelayWidth = size === 0;
+ percent = calculatePercent(percent);
+
+ this.setState({
+ size: ++size,
+ appearDelayWidth,
+ percent
+ });
+
+ if (appearDelayWidth) {
+ setTimeout(() => {
+ this.setState({
+ appearDelayWidth: false
+ });
+ });
+ }
+ },
+
+ hide() {
+ let { size } = this.state;
+
+ if (--size < 0) {
+ this.setState({ size: 0 });
+ return;
+ }
+
+ this.setState({
+ size: 0,
+ disappearDelayHide: true,
+ percent: 1
+ });
+
+ setTimeout(() => {
+ this.setState({
+ disappearDelayHide: false,
+ percent: 0
+ });
+ }, 500);
+ },
+
+ getBarStyle() {
+ const { disappearDelayHide, appearDelayWidth, percent } = this.state;
+
+ return {
+ width: appearDelayWidth ? 0 : percent * 100 + '%',
+ display: disappearDelayHide || percent > 0 ? 'block' : 'none'
+ };
+ },
+
+ getShadowStyle() {
+ const { percent, disappearDelayHide } = this.state;
+
+ return {
+ display: disappearDelayHide || percent > 0 ? 'block' : 'none'
+ };
+ },
+
+ render() {
+ return (
+ <div className="LoadingBar">
+ <div className="LoadingBar-Bar" style={this.getBarStyle()}>
+ <div className="LoadingBar-Shadow"
+ style={this.getShadowStyle()}>
+ </div>
+ </div>
+ </div>
+ );
+ }
+});
+
+function calculatePercent(percent) {
+ percent = percent || 0;
+
+ // How much of remaining bar we advance
+ const progress = 0.1 + Math.random() * 0.3;
+
+ return percent + progress * (1 - percent);
+}
+
+module.exports = LoadingBar;
diff --git a/packages/gitbook-plugin-theme-default/src/components/Page.js b/packages/gitbook-plugin-theme-default/src/components/Page.js
new file mode 100644
index 0000000..cbce704
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Page.js
@@ -0,0 +1,30 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const Page = React.createClass({
+ propTypes: {
+ page: GitBook.PropTypes.Page
+ },
+
+ render() {
+ const { page } = this.props;
+
+ return (
+ <div className="PageContainer">
+ <GitBook.InjectedComponent matching={{ role: 'search:container:results' }} props={this.props}>
+ <div className="Page">
+ <GitBook.InjectedComponentSet matching={{ role: 'page:header' }} props={this.props} />
+
+ <GitBook.InjectedComponent matching={{ role: 'page:container' }} props={this.props}>
+ <GitBook.HTMLContent html={page.content} />
+ </GitBook.InjectedComponent>
+
+ <GitBook.InjectedComponentSet matching={{ role: 'page:footer' }} props={this.props} />
+ </div>
+ </GitBook.InjectedComponent>
+ </div>
+ );
+ }
+});
+
+module.exports = Page;
diff --git a/packages/gitbook-plugin-theme-default/src/components/Sidebar.js b/packages/gitbook-plugin-theme-default/src/components/Sidebar.js
new file mode 100644
index 0000000..ab628df
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Sidebar.js
@@ -0,0 +1,25 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const Summary = require('./Summary');
+
+const Sidebar = React.createClass({
+ propTypes: {
+ summary: GitBook.PropTypes.Summary
+ },
+
+ render() {
+ const { summary } = this.props;
+
+ return (
+ <div className="Sidebar-Flex">
+ <div className="Sidebar book-summary">
+ <GitBook.InjectedComponent matching={{ role: 'search:container:input' }} />
+ <Summary summary={summary} />
+ </div>
+ </div>
+ );
+ }
+});
+
+module.exports = Sidebar;
diff --git a/packages/gitbook-plugin-theme-default/src/components/Summary.js b/packages/gitbook-plugin-theme-default/src/components/Summary.js
new file mode 100644
index 0000000..ef6ab3f
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Summary.js
@@ -0,0 +1,111 @@
+const classNames = require('classnames');
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+let SummaryArticle = React.createClass({
+ propTypes: {
+ active: React.PropTypes.bool,
+ article: GitBook.PropTypes.SummaryArticle
+ },
+
+ render() {
+ const { article, active } = this.props;
+ const className = classNames('SummaryArticle', {
+ active
+ });
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'summary:article' }} props={this.props}>
+ <li className={className}>
+ {article.ref ?
+ <GitBook.Link to={article}>{article.title}</GitBook.Link>
+ : <span>{article.title}</span>}
+ </li>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+SummaryArticle = GitBook.connect(SummaryArticle, ({page}, {article}) => {
+ return {
+ active: page.level === article.level
+ };
+});
+
+const SummaryArticles = React.createClass({
+ propTypes: {
+ articles: GitBook.PropTypes.listOf(GitBook.PropTypes.SummaryArticle)
+ },
+
+ render() {
+ const { articles } = this.props;
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'summary:articles' }} props={this.props}>
+ <ul className="SummaryArticles">
+ {articles.map(article => <SummaryArticle key={article.level} article={article} />)}
+ </ul>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+
+const SummaryPart = React.createClass({
+ propTypes: {
+ part: GitBook.PropTypes.SummaryPart
+ },
+
+ render() {
+ const { part } = this.props;
+ const { title, articles } = part;
+
+ const titleEL = title ? <h2 className="SummaryPart-Title">{title}</h2> : null;
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'summary:part' }} props={this.props}>
+ <div className="SummaryPart">
+ {titleEL}
+ <SummaryArticles articles={articles} />
+ </div>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+
+const SummaryParts = React.createClass({
+ propTypes: {
+ parts: GitBook.PropTypes.listOf(GitBook.PropTypes.SummaryPart)
+ },
+
+ render() {
+ const { parts } = this.props;
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'summary:parts' }} props={this.props}>
+ <div className="SummaryParts">
+ {parts.map((part, i) => <SummaryPart key={i} part={part} />)}
+ </div>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+
+const Summary = React.createClass({
+ propTypes: {
+ summary: GitBook.PropTypes.Summary
+ },
+
+ render() {
+ const { summary } = this.props;
+ const { parts } = summary;
+
+ return (
+ <GitBook.InjectedComponent matching={{ role: 'summary:container' }} props={this.props}>
+ <div className="Summary book-summary">
+ <SummaryParts parts={parts} />
+ </div>
+ </GitBook.InjectedComponent>
+ );
+ }
+});
+
+module.exports = Summary;
diff --git a/packages/gitbook-plugin-theme-default/src/components/Theme.js b/packages/gitbook-plugin-theme-default/src/components/Theme.js
new file mode 100644
index 0000000..b323fc4
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Theme.js
@@ -0,0 +1,57 @@
+const GitBook = require('gitbook-core');
+const { React, ReactCSSTransitionGroup } = GitBook;
+
+const Sidebar = require('./Sidebar');
+const Body = require('./Body');
+const LoadingBar = require('./LoadingBar');
+
+const Theme = React.createClass({
+ propTypes: {
+ // State
+ page: GitBook.PropTypes.Page,
+ summary: GitBook.PropTypes.Summary,
+ readme: GitBook.PropTypes.Readme,
+ history: GitBook.PropTypes.History,
+ sidebar: React.PropTypes.object,
+ // Other props
+ children: React.PropTypes.node
+ },
+
+ render() {
+ const { page, summary, children, sidebar, readme, history } = this.props;
+
+ return (
+ <GitBook.FlexLayout column className="GitBook book">
+ <LoadingBar show={history.loading} />
+ <GitBook.Head
+ title={page.title}
+ titleTemplate="%s - GitBook" />
+ <GitBook.ImportCSS href="gitbook/theme-default/theme.css" />
+
+ <GitBook.FlexBox>
+ <ReactCSSTransitionGroup
+ component={GitBook.FlexLayout}
+ transitionName="Layout"
+ transitionEnterTimeout={300}
+ transitionLeaveTimeout={300}>
+ {sidebar.open ? (
+ <Sidebar key={0} summary={summary} />
+ ) : null}
+ <div key={1} className="Body-Flex">
+ <Body
+ page={page}
+ readme={readme}
+ history={history}
+ />
+ </div>
+ </ReactCSSTransitionGroup>
+ </GitBook.FlexBox>
+ {children}
+ </GitBook.FlexLayout>
+ );
+ }
+});
+
+module.exports = GitBook.connect(Theme, ({page, summary, sidebar, readme, history}) => {
+ return { page, summary, sidebar, readme, history };
+});
diff --git a/packages/gitbook-plugin-theme-default/src/components/Toolbar.js b/packages/gitbook-plugin-theme-default/src/components/Toolbar.js
new file mode 100644
index 0000000..d426a40
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/components/Toolbar.js
@@ -0,0 +1,43 @@
+const GitBook = require('gitbook-core');
+const { React } = GitBook;
+
+const sidebar = require('../actions/sidebar');
+
+const Toolbar = React.createClass({
+ propTypes: {
+ title: React.PropTypes.string.isRequired,
+ dispatch: React.PropTypes.func,
+ readme: GitBook.PropTypes.Readme
+ },
+
+ onToggle() {
+ const { dispatch } = this.props;
+ dispatch(sidebar.toggle());
+ },
+
+ render() {
+ const { title, readme } = this.props;
+
+ return (
+ <GitBook.FlexLayout className="Toolbar">
+ <GitBook.FlexBox className="Toolbar-left">
+ <GitBook.InjectedComponentSet align="flex-end" matching={{ role: 'toolbar:buttons:left' }}>
+ <GitBook.Button onClick={this.onToggle}>
+ <GitBook.Icon id="align-justify" />
+ </GitBook.Button>
+ </GitBook.InjectedComponentSet>
+ </GitBook.FlexBox>
+ <GitBook.FlexBox auto>
+ <h1 className="Toolbar-Title">
+ <GitBook.Link to={readme.file}>{title}</GitBook.Link>
+ </h1>
+ </GitBook.FlexBox>
+ <GitBook.FlexBox className="Toolbar-right">
+ <GitBook.InjectedComponentSet align="flex-end" matching={{ role: 'toolbar:buttons:right' }} />
+ </GitBook.FlexBox>
+ </GitBook.FlexLayout>
+ );
+ }
+});
+
+module.exports = GitBook.connect(Toolbar);
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ar.json b/packages/gitbook-plugin-theme-default/src/i18n/ar.json
new file mode 100644
index 0000000..f652c1a
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ar.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "اختيار اللغة",
+ "GLOSSARY": "قاموس مصطلحات",
+ "GLOSSARY_INDEX": "مؤشر المصطلحات",
+ "GLOSSARY_OPEN": "قاموس مصطلحات",
+ "GITBOOK_LINK": "نشرت مع GitBook",
+ "SUMMARY": "جدول المحتويات",
+ "SUMMARY_INTRODUCTION": "مقدمة",
+ "SUMMARY_TOGGLE": "جدول المحتويات",
+ "SEARCH_TOGGLE": "بحث",
+ "SEARCH_PLACEHOLDER": "اكتب للبحث",
+ "FONTSETTINGS_TOGGLE": "إعدادات الخط",
+ "SHARE_TOGGLE": "حصة",
+ "SHARE_ON": "على {{platform}} حصة",
+ "FONTSETTINGS_WHITE": "أبيض",
+ "FONTSETTINGS_SEPIA": "بني داكن",
+ "FONTSETTINGS_NIGHT": "ليل",
+ "FONTSETTINGS_SANS": "بلا",
+ "FONTSETTINGS_SERIF": "الرقيق"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/bn.json b/packages/gitbook-plugin-theme-default/src/i18n/bn.json
new file mode 100644
index 0000000..24baec3
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/bn.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "ভাষা নির্বাচন করুন",
+ "GLOSSARY": "গ্লোসারি",
+ "GLOSSARY_INDEX": "ইন্ডেক্স",
+ "GLOSSARY_OPEN": "গ্লোসারি",
+ "GITBOOK_LINK": "গিটবুকের মাধ্যমে প্রকাশিত",
+ "SUMMARY": "সূচিপত্র",
+ "SUMMARY_INTRODUCTION": "সূচনা",
+ "SUMMARY_TOGGLE": "সূচিপত্র",
+ "SEARCH_TOGGLE": "অনুসন্ধান",
+ "SEARCH_PLACEHOLDER": "অনুসন্ধান",
+ "FONTSETTINGS_TOGGLE": "ফন্ট সেটিংস",
+ "SHARE_TOGGLE": "শেয়ার",
+ "SHARE_ON": "{{platform}}-এ শেয়ার",
+ "FONTSETTINGS_WHITE": "সাদা",
+ "FONTSETTINGS_SEPIA": "সেপিয়া",
+ "FONTSETTINGS_NIGHT": "রাত",
+ "FONTSETTINGS_SANS": "স্যান্স",
+ "FONTSETTINGS_SERIF": "শেরিফ"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ca.json b/packages/gitbook-plugin-theme-default/src/i18n/ca.json
new file mode 100644
index 0000000..d26edb6
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ca.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Selecciona un idioma",
+ "GLOSSARY": "Glossari",
+ "GLOSSARY_INDEX": "Índex",
+ "GLOSSARY_OPEN": "Glossari",
+ "GITBOOK_LINK": "Publicat amb GitBook",
+ "SUMMARY": "Taula de contingut",
+ "SUMMARY_INTRODUCTION": "Introducció",
+ "SUMMARY_TOGGLE": "Taula de contingut",
+ "SEARCH_TOGGLE": "Cercar",
+ "SEARCH_PLACEHOLDER": "Escriu per cercar",
+ "FONTSETTINGS_TOGGLE": "Configuració de font",
+ "SHARE_TOGGLE": "Compartir",
+ "SHARE_ON": "Compartir en {{platform}}",
+ "FONTSETTINGS_WHITE": "Clar",
+ "FONTSETTINGS_SEPIA": "Sèpia",
+ "FONTSETTINGS_NIGHT": "Nit",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/cs.json b/packages/gitbook-plugin-theme-default/src/i18n/cs.json
new file mode 100644
index 0000000..b2e19c0
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/cs.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Zvolte jazyk",
+ "GLOSSARY": "Slovníček",
+ "GLOSSARY_INDEX": "Rejstřík",
+ "GLOSSARY_OPEN": "Slovníček",
+ "GITBOOK_LINK": "Publikováno pomocí GitBook",
+ "SUMMARY": "Obsah",
+ "SUMMARY_INTRODUCTION": "Úvod",
+ "SUMMARY_TOGGLE": "Obsah",
+ "SEARCH_TOGGLE": "Hledání",
+ "SEARCH_PLACEHOLDER": "Vyhledat",
+ "FONTSETTINGS_TOGGLE": "Nastavení písma",
+ "SHARE_TOGGLE": "Sdílet",
+ "SHARE_ON": "Sdílet na {{platform}}",
+ "FONTSETTINGS_WHITE": "Bílá",
+ "FONTSETTINGS_SEPIA": "Sépie",
+ "FONTSETTINGS_NIGHT": "Noc",
+ "FONTSETTINGS_SANS": "Bezpatkové",
+ "FONTSETTINGS_SERIF": "Patkové"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/de.json b/packages/gitbook-plugin-theme-default/src/i18n/de.json
new file mode 100644
index 0000000..b51732e
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/de.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Sprache auswählen",
+ "GLOSSARY": "Glossar",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Glossar",
+ "GITBOOK_LINK": "Veröffentlicht mit GitBook",
+ "SUMMARY": "Inhaltsverzeichnis",
+ "SUMMARY_INTRODUCTION": "Einleitung",
+ "SUMMARY_TOGGLE": "Inhaltsverzeichnis",
+ "SEARCH_TOGGLE": "Suche",
+ "SEARCH_PLACEHOLDER": "Suchbegriff eingeben",
+ "FONTSETTINGS_TOGGLE": "Schrifteinstellungen",
+ "SHARE_TOGGLE": "Teilen",
+ "SHARE_ON": "Auf {{platform}} teilen",
+ "FONTSETTINGS_WHITE": "Hell",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Nacht",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+} \ No newline at end of file
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/el.json b/packages/gitbook-plugin-theme-default/src/i18n/el.json
new file mode 100644
index 0000000..5198e60
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/el.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Επιλογή γλώσσας",
+ "GLOSSARY": "Γλωσσάρι",
+ "GLOSSARY_INDEX": "Ευρετήριο",
+ "GLOSSARY_OPEN": "Γλωσσάρι",
+ "GITBOOK_LINK": "Δημοσιεύτηκε με το GitBook",
+ "SUMMARY": "Πίνακας Περιεχομένων",
+ "SUMMARY_INTRODUCTION": "Εισαγωγή",
+ "SUMMARY_TOGGLE": "Πίνακας Περιεχομένων",
+ "SEARCH_TOGGLE": "Αναζήτηση",
+ "SEARCH_PLACEHOLDER": "Αναζήτηση για ...",
+ "FONTSETTINGS_TOGGLE": "Επιλογές γραμματοσειράς",
+ "SHARE_TOGGLE": "Κοινοποίηση",
+ "SHARE_ON": "Κοινοποίηση σε {{platform}}",
+ "FONTSETTINGS_WHITE": "Λευκό",
+ "FONTSETTINGS_SEPIA": "Καστανόχρους",
+ "FONTSETTINGS_NIGHT": "Βραδινό",
+ "FONTSETTINGS_SANS": "Χωρίς πατούρες",
+ "FONTSETTINGS_SERIF": "Με πατούρες"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/en.json b/packages/gitbook-plugin-theme-default/src/i18n/en.json
new file mode 100644
index 0000000..b6504d3
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/en.json
@@ -0,0 +1,21 @@
+{
+ "LANGS_CHOOSE": "Choose a language",
+ "GLOSSARY": "Glossary",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Glossary",
+ "GITBOOK_LINK": "Published with GitBook",
+ "SUMMARY": "Table of Contents",
+ "SUMMARY_INTRODUCTION": "Introduction",
+ "SUMMARY_TOGGLE": "Table of Contents",
+ "SEARCH_TOGGLE": "Search",
+ "SEARCH_PLACEHOLDER": "Type to search",
+ "SEARCH_RESULTS_TITLE": "{count, plural, =0 {No results} one {1 result} other {{count} results}} matching \"{query}\"",
+ "FONTSETTINGS_TOGGLE": "Font Settings",
+ "SHARE_TOGGLE": "Share",
+ "SHARE_ON": "Share on {{platform}}",
+ "FONTSETTINGS_WHITE": "White",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Night",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/es.json b/packages/gitbook-plugin-theme-default/src/i18n/es.json
new file mode 100644
index 0000000..36159be
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/es.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Selecciona un idioma",
+ "GLOSSARY": "Glosario",
+ "GLOSSARY_INDEX": "Índice",
+ "GLOSSARY_OPEN": "Glosario",
+ "GITBOOK_LINK": "Publicado con GitBook",
+ "SUMMARY": "Tabla de contenido",
+ "SUMMARY_INTRODUCTION": "Introducción",
+ "SUMMARY_TOGGLE": "Tabla de contenido",
+ "SEARCH_TOGGLE": "Buscar",
+ "SEARCH_PLACEHOLDER": "Escribe para buscar",
+ "FONTSETTINGS_TOGGLE": "Configuración de fuente",
+ "SHARE_TOGGLE": "Compartir",
+ "SHARE_ON": "Compartir en {{platform}}",
+ "FONTSETTINGS_WHITE": "Claro",
+ "FONTSETTINGS_SEPIA": "Sépia",
+ "FONTSETTINGS_NIGHT": "Noche",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fa.json b/packages/gitbook-plugin-theme-default/src/i18n/fa.json
new file mode 100644
index 0000000..56ded4f
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/fa.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "انتخاب زبان",
+ "GLOSSARY": "واژه‌نامه",
+ "GLOSSARY_INDEX": "فهرست واژه‌ها",
+ "GLOSSARY_OPEN": "واژه‌نامه",
+ "GITBOOK_LINK": "انتشار یافته توسط GitBook",
+ "SUMMARY": "فهرست مطالب",
+ "SUMMARY_INTRODUCTION": "مقدمه",
+ "SUMMARY_TOGGLE": "فهرست مطالب",
+ "SEARCH_TOGGLE": "جستجو",
+ "SEARCH_PLACEHOLDER": "چیزی برای جستجو بنویسید",
+ "FONTSETTINGS_TOGGLE": "تنظیمات فونت",
+ "SHARE_TOGGLE": "اشتراک",
+ "SHARE_ON": "در {{platform}} به اشتراک بگذارید",
+ "FONTSETTINGS_WHITE": "سفید",
+ "FONTSETTINGS_SEPIA": "سپیا",
+ "FONTSETTINGS_NIGHT": "شب",
+ "FONTSETTINGS_SANS": "سنس",
+ "FONTSETTINGS_SERIF": "سریف"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fi.json b/packages/gitbook-plugin-theme-default/src/i18n/fi.json
new file mode 100644
index 0000000..a8476ca
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/fi.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Valitse kieli",
+ "GLOSSARY": "Sanasto",
+ "GLOSSARY_INDEX": "Hakemisto",
+ "GLOSSARY_OPEN": "Sanasto",
+ "GITBOOK_LINK": "Julkaistu GitBookilla",
+ "SUMMARY": "Sisällysluettelo",
+ "SUMMARY_INTRODUCTION": "Johdanto",
+ "SUMMARY_TOGGLE": "Sisällysluettelu",
+ "SEARCH_TOGGLE": "Etsi",
+ "SEARCH_PLACEHOLDER": "Kirjoita hakusana",
+ "FONTSETTINGS_TOGGLE": "Fonttivalinnat",
+ "SHARE_TOGGLE": "Jaa",
+ "SHARE_ON": "Jaa {{platform}}ssa",
+ "FONTSETTINGS_WHITE": "Valkoinen",
+ "FONTSETTINGS_SEPIA": "Seepia",
+ "FONTSETTINGS_NIGHT": "Yö",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/fr.json b/packages/gitbook-plugin-theme-default/src/i18n/fr.json
new file mode 100644
index 0000000..8cc10e2
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/fr.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Choisissez une langue",
+ "GLOSSARY": "Glossaire",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Glossaire",
+ "GITBOOK_LINK": "Publié avec GitBook",
+ "SUMMARY": "Table des matières",
+ "SUMMARY_INTRODUCTION": "Introduction",
+ "SUMMARY_TOGGLE": "Table des matières",
+ "SEARCH_TOGGLE": "Recherche",
+ "SEARCH_PLACEHOLDER": "Tapez pour rechercher",
+ "FONTSETTINGS_TOGGLE": "Paramètres de Police",
+ "SHARE_TOGGLE": "Partage",
+ "SHARE_ON": "Partager sur {{platform}}",
+ "FONTSETTINGS_WHITE": "Clair",
+ "FONTSETTINGS_SEPIA": "Sépia",
+ "FONTSETTINGS_NIGHT": "Nuit",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+} \ No newline at end of file
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/he.json b/packages/gitbook-plugin-theme-default/src/i18n/he.json
new file mode 100644
index 0000000..353d3b5
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/he.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "בחר שפה",
+ "GLOSSARY": "מונחים",
+ "GLOSSARY_INDEX": "מפתח",
+ "GLOSSARY_OPEN": "מונחים",
+ "GITBOOK_LINK": "הוצאה לאור באמצעות גיט-בוק GITBOOK",
+ "SUMMARY": "תוכן העניינים",
+ "SUMMARY_INTRODUCTION": "הוראות",
+ "SUMMARY_TOGGLE": "תקציר",
+ "SEARCH_TOGGLE": "חיפוש",
+ "SEARCH_PLACEHOLDER": "סוג החיפוש",
+ "FONTSETTINGS_TOGGLE": "הגדרת אותיות",
+ "SHARE_TOGGLE": "שתף",
+ "SHARE_ON": "{{platform}} שתף ב",
+ "FONTSETTINGS_WHITE": "בהיר",
+ "FONTSETTINGS_SEPIA": "חום כהה",
+ "FONTSETTINGS_NIGHT": "מצב לילה",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/index.js b/packages/gitbook-plugin-theme-default/src/i18n/index.js
new file mode 100644
index 0000000..d09de1b
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/index.js
@@ -0,0 +1,30 @@
+
+module.exports = {
+ ar: require('./ar'),
+ bn: require('./bn'),
+ ca: require('./ca'),
+ cs: require('./cs'),
+ de: require('./de'),
+ el: require('./el'),
+ en: require('./en'),
+ es: require('./es'),
+ fa: require('./fa'),
+ fi: require('./fi'),
+ fr: require('./fr'),
+ he: require('./he'),
+ it: require('./it'),
+ ja: require('./ja'),
+ ko: require('./ko'),
+ nl: require('./nl'),
+ no: require('./no'),
+ pl: require('./pl'),
+ pt: require('./pt'),
+ ro: require('./ro'),
+ ru: require('./ru'),
+ sv: require('./sv'),
+ tr: require('./tr'),
+ uk: require('./uk'),
+ vi: require('./vi'),
+ 'zh-hans': require('./zh-hans'),
+ 'zh-tw': require('./zh-tw'),
+};
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/it.json b/packages/gitbook-plugin-theme-default/src/i18n/it.json
new file mode 100644
index 0000000..3f5e95d
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/it.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Scegli una lingua",
+ "GLOSSARY": "Glossario",
+ "GLOSSARY_INDEX": "Indice",
+ "GLOSSARY_OPEN": "Glossario",
+ "GITBOOK_LINK": "Pubblicato con GitBook",
+ "SUMMARY": "Sommario",
+ "SUMMARY_INTRODUCTION": "Introduzione",
+ "SUMMARY_TOGGLE": "Sommario",
+ "SEARCH_TOGGLE": "Cerca",
+ "SEARCH_PLACEHOLDER": "Scrivi per cercare",
+ "FONTSETTINGS_TOGGLE": "Impostazioni dei caratteri",
+ "SHARE_TOGGLE": "Condividi",
+ "SHARE_ON": "Condividi su {{platform}}",
+ "FONTSETTINGS_WHITE": "Bianco",
+ "FONTSETTINGS_SEPIA": "Seppia",
+ "FONTSETTINGS_NIGHT": "Notte",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+} \ No newline at end of file
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ja.json b/packages/gitbook-plugin-theme-default/src/i18n/ja.json
new file mode 100644
index 0000000..b1afd02
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ja.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "言語を選択",
+ "GLOSSARY": "用語集",
+ "GLOSSARY_INDEX": "索引",
+ "GLOSSARY_OPEN": "用語集",
+ "GITBOOK_LINK": "GitBookで公開 ",
+ "SUMMARY": "目次",
+ "SUMMARY_INTRODUCTION": "はじめに",
+ "SUMMARY_TOGGLE": "目次",
+ "SEARCH_TOGGLE": "検索",
+ "SEARCH_PLACEHOLDER": "検索すると入力",
+ "FONTSETTINGS_TOGGLE": "フォント設定",
+ "SHARE_TOGGLE": "シェア",
+ "SHARE_ON": "{{platform}}でシェア",
+ "FONTSETTINGS_WHITE": "白",
+ "FONTSETTINGS_SEPIA": "セピア",
+ "FONTSETTINGS_NIGHT": "夜",
+ "FONTSETTINGS_SANS": "ゴシック体",
+ "FONTSETTINGS_SERIF": "明朝体"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ko.json b/packages/gitbook-plugin-theme-default/src/i18n/ko.json
new file mode 100644
index 0000000..5015a93
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ko.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "언어를 선택하세요",
+ "GLOSSARY": "어휘",
+ "GLOSSARY_INDEX": "색인",
+ "GLOSSARY_OPEN": "어휘",
+ "GITBOOK_LINK": "GitBook에 게시",
+ "SUMMARY": "차례",
+ "SUMMARY_INTRODUCTION": "소개",
+ "SUMMARY_TOGGLE": "차례",
+ "SEARCH_TOGGLE": "검색",
+ "SEARCH_PLACEHOLDER": "검색어 입력",
+ "FONTSETTINGS_TOGGLE": "글꼴 설정",
+ "SHARE_TOGGLE": "공유",
+ "SHARE_ON": "{{platform}}에 공유",
+ "FONTSETTINGS_WHITE": "화이트",
+ "FONTSETTINGS_SEPIA": "세피아",
+ "FONTSETTINGS_NIGHT": "나이트",
+ "FONTSETTINGS_SANS": "고딕",
+ "FONTSETTINGS_SERIF": "명조"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/nl.json b/packages/gitbook-plugin-theme-default/src/i18n/nl.json
new file mode 100644
index 0000000..da4f59e
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/nl.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Kies een taal",
+ "GLOSSARY": "Begrippenlijst",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Begrippenlijst",
+ "GITBOOK_LINK": "Gepubliceerd met GitBook",
+ "SUMMARY": "Inhoudsopgave",
+ "SUMMARY_INTRODUCTION": "Inleiding",
+ "SUMMARY_TOGGLE": "Inhoudsopgave",
+ "SEARCH_TOGGLE": "Zoeken",
+ "SEARCH_PLACEHOLDER": "Zoeken",
+ "FONTSETTINGS_TOGGLE": "Lettertype instellingen",
+ "SHARE_TOGGLE": "Delen",
+ "SHARE_ON": "Delen op {{platform}}",
+ "FONTSETTINGS_WHITE": "Wit",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Zwart",
+ "FONTSETTINGS_SANS": "Schreefloos",
+ "FONTSETTINGS_SERIF": "Schreef"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/no.json b/packages/gitbook-plugin-theme-default/src/i18n/no.json
new file mode 100644
index 0000000..1ed6236
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/no.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Velg språk",
+ "GLOSSARY": "Register",
+ "GLOSSARY_INDEX": "Indeks",
+ "GLOSSARY_OPEN": "Register",
+ "GITBOOK_LINK": "Publisert med GitBook",
+ "SUMMARY": "Innholdsfortegnelse",
+ "SUMMARY_INTRODUCTION": "Innledning",
+ "SUMMARY_TOGGLE": "Innholdsfortegnelse",
+ "SEARCH_TOGGLE": "Søk",
+ "SEARCH_PLACEHOLDER": "Skriv inn søkeord",
+ "FONTSETTINGS_TOGGLE": "Tekstinnstillinger",
+ "SHARE_TOGGLE": "Del",
+ "SHARE_ON": "Del på {{platform}}",
+ "FONTSETTINGS_WHITE": "Lys",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Mørk",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/pl.json b/packages/gitbook-plugin-theme-default/src/i18n/pl.json
new file mode 100644
index 0000000..4f009fc
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/pl.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Wybierz język",
+ "GLOSSARY": "Glosariusz",
+ "GLOSSARY_INDEX": "Indeks",
+ "GLOSSARY_OPEN": "Glosariusz",
+ "GITBOOK_LINK": "Opublikowano dzięki GitBook",
+ "SUMMARY": "Spis treści",
+ "SUMMARY_INTRODUCTION": "Wstęp",
+ "SUMMARY_TOGGLE": "Spis treści",
+ "SEARCH_TOGGLE": "Szukaj",
+ "SEARCH_PLACEHOLDER": "Wpisz szukaną frazę",
+ "FONTSETTINGS_TOGGLE": "Ustawienia czcionki",
+ "SHARE_TOGGLE": "Udostępnij",
+ "SHARE_ON": "Udostępnij na {{platform}}",
+ "FONTSETTINGS_WHITE": "Jasny",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Noc",
+ "FONTSETTINGS_SANS": "Bezszeryfowa",
+ "FONTSETTINGS_SERIF": "Szeryfowa"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/pt.json b/packages/gitbook-plugin-theme-default/src/i18n/pt.json
new file mode 100644
index 0000000..9d6bde0
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/pt.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Escolher sua língua",
+ "GLOSSARY": "Glossário",
+ "GLOSSARY_INDEX": "Índice",
+ "GLOSSARY_OPEN": "Glossário",
+ "GITBOOK_LINK": "Publicado com GitBook",
+ "SUMMARY": "Tabela de conteúdos",
+ "SUMMARY_INTRODUCTION": "Introdução",
+ "SUMMARY_TOGGLE": "Tabela de conteúdos",
+ "SEARCH_TOGGLE": "Pesquise",
+ "SEARCH_PLACEHOLDER": "Escreva para pesquisar",
+ "FONTSETTINGS_TOGGLE": "Configurações de fonte",
+ "SHARE_TOGGLE": "Compartilhar",
+ "SHARE_ON": "Compartilhar no {{platform}}",
+ "FONTSETTINGS_WHITE": "Claro",
+ "FONTSETTINGS_SEPIA": "Sépia",
+ "FONTSETTINGS_NIGHT": "Noite",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ro.json b/packages/gitbook-plugin-theme-default/src/i18n/ro.json
new file mode 100644
index 0000000..24295a4
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ro.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Alege o limba",
+ "GLOSSARY": "Glosar",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Glosar",
+ "GITBOOK_LINK": "Publicata cu GitBook",
+ "SUMMARY": "Cuprins",
+ "SUMMARY_INTRODUCTION": "Introducere",
+ "SUMMARY_TOGGLE": "Cuprins",
+ "SEARCH_TOGGLE": "Cauta",
+ "SEARCH_PLACEHOLDER": "Ce cauti",
+ "FONTSETTINGS_TOGGLE": "Setari de font",
+ "SHARE_TOGGLE": "Distribuie",
+ "SHARE_ON": "Distribuie pe {{platform}}",
+ "FONTSETTINGS_WHITE": "Alb",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Noapte",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/ru.json b/packages/gitbook-plugin-theme-default/src/i18n/ru.json
new file mode 100644
index 0000000..9e6b9dd
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/ru.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Выберите язык",
+ "GLOSSARY": "Алфавитный указатель",
+ "GLOSSARY_INDEX": "Алфавитный указатель",
+ "GLOSSARY_OPEN": "Алфавитный указатель",
+ "GITBOOK_LINK": "Опубликовано с помощью GitBook",
+ "SUMMARY": "Содержание",
+ "SUMMARY_INTRODUCTION": "Введение",
+ "SUMMARY_TOGGLE": "Содержание",
+ "SEARCH_TOGGLE": "Поиск",
+ "SEARCH_PLACEHOLDER": "Введите условия поиска",
+ "FONTSETTINGS_TOGGLE": "Шрифт",
+ "SHARE_TOGGLE": "Поделиться",
+ "SHARE_ON": "Поделиться в {{platform}}",
+ "FONTSETTINGS_WHITE": "Светлый",
+ "FONTSETTINGS_SEPIA": "Сепия",
+ "FONTSETTINGS_NIGHT": "Тёмный",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+} \ No newline at end of file
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/sv.json b/packages/gitbook-plugin-theme-default/src/i18n/sv.json
new file mode 100644
index 0000000..2e2f6ac
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/sv.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Välj språk",
+ "GLOSSARY": "Gloslista",
+ "GLOSSARY_INDEX": "Index",
+ "GLOSSARY_OPEN": "Gloslista",
+ "GITBOOK_LINK": "Publicera med GitBook",
+ "SUMMARY": "Innehållsförteckning",
+ "SUMMARY_INTRODUCTION": "Inledning",
+ "SUMMARY_TOGGLE": "Innehållsförteckning",
+ "SEARCH_TOGGLE": "Sök",
+ "SEARCH_PLACEHOLDER": "Skriv sökord",
+ "FONTSETTINGS_TOGGLE": "Textinställningar",
+ "SHARE_TOGGLE": "Dela",
+ "SHARE_ON": "Dela på {{platform}}",
+ "FONTSETTINGS_WHITE": "Ljus",
+ "FONTSETTINGS_SEPIA": "Sepia",
+ "FONTSETTINGS_NIGHT": "Mörk",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/tr.json b/packages/gitbook-plugin-theme-default/src/i18n/tr.json
new file mode 100644
index 0000000..d92d5a2
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/tr.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Dil seçimi",
+ "GLOSSARY": "Sözlük",
+ "GLOSSARY_INDEX": "Dizin",
+ "GLOSSARY_OPEN": "Sözlük",
+ "GITBOOK_LINK": "GitBook ile yayınla",
+ "SUMMARY": "İçindekiler",
+ "SUMMARY_INTRODUCTION": "Giriş",
+ "SUMMARY_TOGGLE": "İçindekiler",
+ "SEARCH_TOGGLE": "Arama",
+ "SEARCH_PLACEHOLDER": "Aramak istediğiniz",
+ "FONTSETTINGS_TOGGLE": "Font Ayarları",
+ "SHARE_TOGGLE": "Paylaş",
+ "SHARE_ON": "{{platform}} ile paylaş",
+ "FONTSETTINGS_WHITE": "Beyaz",
+ "FONTSETTINGS_SEPIA": "Sepya",
+ "FONTSETTINGS_NIGHT": "Karanlık",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/uk.json b/packages/gitbook-plugin-theme-default/src/i18n/uk.json
new file mode 100644
index 0000000..a582d6c
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/uk.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Виберіть мову",
+ "GLOSSARY": "Алфавітний покажчик",
+ "GLOSSARY_INDEX": "Алфавітний покажчик",
+ "GLOSSARY_OPEN": "Алфавітний покажчик",
+ "GITBOOK_LINK": "Опубліковано за допомогою GitBook",
+ "SUMMARY": "Зміст",
+ "SUMMARY_INTRODUCTION": "Вступ",
+ "SUMMARY_TOGGLE": "Зміст",
+ "SEARCH_TOGGLE": "Пошук",
+ "SEARCH_PLACEHOLDER": "Введіть для пошуку",
+ "FONTSETTINGS_TOGGLE": "Шрифт",
+ "SHARE_TOGGLE": "Поділитися",
+ "SHARE_ON": "Поділитися в {{platform}}",
+ "FONTSETTINGS_WHITE": "Світлий",
+ "FONTSETTINGS_SEPIA": "Сепія",
+ "FONTSETTINGS_NIGHT": "Темний",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+} \ No newline at end of file
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/vi.json b/packages/gitbook-plugin-theme-default/src/i18n/vi.json
new file mode 100644
index 0000000..0addb8e
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/vi.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "Lựa chọn ngôn ngữ",
+ "GLOSSARY": "Chú giải",
+ "GLOSSARY_INDEX": "Chỉ mục",
+ "GLOSSARY_OPEN": "Chú giải",
+ "GITBOOK_LINK": "Xuất bản với GitBook",
+ "SUMMARY": "Mục Lục",
+ "SUMMARY_INTRODUCTION": "Giới thiệu",
+ "SUMMARY_TOGGLE": "Mục Lục",
+ "SEARCH_TOGGLE": "Tìm kiếm",
+ "SEARCH_PLACEHOLDER": "Nhập thông tin cần tìm",
+ "FONTSETTINGS_TOGGLE": "Tùy chỉnh phông chữ",
+ "SHARE_TOGGLE": "Chia sẻ",
+ "SHARE_ON": "Chia sẻ trên {{platform}}",
+ "FONTSETTINGS_WHITE": "Sáng",
+ "FONTSETTINGS_SEPIA": "Vàng nâu",
+ "FONTSETTINGS_NIGHT": "Tối",
+ "FONTSETTINGS_SANS": "Sans",
+ "FONTSETTINGS_SERIF": "Serif"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json b/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json
new file mode 100644
index 0000000..8aa372c
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/zh-hans.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "选择一种语言",
+ "GLOSSARY": "术语表",
+ "GLOSSARY_INDEX": "索引",
+ "GLOSSARY_OPEN": "术语表",
+ "GITBOOK_LINK": "本书使用 GitBook 发布",
+ "SUMMARY": "目录",
+ "SUMMARY_INTRODUCTION": "介绍",
+ "SUMMARY_TOGGLE": "目录",
+ "SEARCH_TOGGLE": "搜索",
+ "SEARCH_PLACEHOLDER": "输入并搜索",
+ "FONTSETTINGS_TOGGLE": "字体设置",
+ "SHARE_TOGGLE": "分享",
+ "SHARE_ON": "分享到 {{platform}}",
+ "FONTSETTINGS_WHITE": "白色",
+ "FONTSETTINGS_SEPIA": "棕褐色",
+ "FONTSETTINGS_NIGHT": "夜间",
+ "FONTSETTINGS_SANS": "无衬线体",
+ "FONTSETTINGS_SERIF": "衬线体"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json b/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json
new file mode 100644
index 0000000..d5ff1ad
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/i18n/zh-tw.json
@@ -0,0 +1,20 @@
+{
+ "LANGS_CHOOSE": "選擇一種語言",
+ "GLOSSARY": "術語表",
+ "GLOSSARY_INDEX": "索引",
+ "GLOSSARY_OPEN": "術語表",
+ "GITBOOK_LINK": "本書使用 GitBook 釋出",
+ "SUMMARY": "目錄",
+ "SUMMARY_INTRODUCTION": "介紹",
+ "SUMMARY_TOGGLE": "目錄",
+ "SEARCH_TOGGLE": "搜尋",
+ "SEARCH_PLACEHOLDER": "輸入並搜尋",
+ "FONTSETTINGS_TOGGLE": "字型設定",
+ "SHARE_TOGGLE": "分享",
+ "SHARE_ON": "分享到 {{platform}}",
+ "FONTSETTINGS_WHITE": "白色",
+ "FONTSETTINGS_SEPIA": "棕褐色",
+ "FONTSETTINGS_NIGHT": "夜間",
+ "FONTSETTINGS_SANS": "無襯線體",
+ "FONTSETTINGS_SERIF": "襯線體"
+}
diff --git a/packages/gitbook-plugin-theme-default/src/index.js b/packages/gitbook-plugin-theme-default/src/index.js
new file mode 100644
index 0000000..ad96175
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/index.js
@@ -0,0 +1,14 @@
+const GitBook = require('gitbook-core');
+
+const Theme = require('./components/Theme');
+const reduceState = require('./reducers');
+const locales = require('./i18n');
+
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, state, { Components, I18n }) => {
+ dispatch(Components.registerComponent(Theme, { role: 'website:body' }));
+ dispatch(I18n.registerLocales(locales));
+ },
+ reduce: reduceState
+});
diff --git a/packages/gitbook-plugin-theme-default/src/reducers/index.js b/packages/gitbook-plugin-theme-default/src/reducers/index.js
new file mode 100644
index 0000000..ac53d3a
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/reducers/index.js
@@ -0,0 +1,5 @@
+const GitBook = require('gitbook-core');
+
+module.exports = GitBook.composeReducer(
+ GitBook.createReducer('sidebar', require('./sidebar'))
+);
diff --git a/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js b/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js
new file mode 100644
index 0000000..eef68d4
--- /dev/null
+++ b/packages/gitbook-plugin-theme-default/src/reducers/sidebar.js
@@ -0,0 +1,18 @@
+const GitBook = require('gitbook-core');
+const { Record } = GitBook.Immutable;
+const ActionTypes = require('../actions/types');
+
+const SidebarState = Record({
+ open: true
+});
+
+function reduceSidebar(state = SidebarState(), action) {
+ switch (action.type) {
+ case ActionTypes.TOGGLE_SIDEBAR:
+ return state.set('open', !state.get('open'));
+ default:
+ return state;
+ }
+}
+
+module.exports = reduceSidebar;
diff --git a/packages/gitbook-plugin/CONTRIBUTING.md b/packages/gitbook-plugin/CONTRIBUTING.md
new file mode 100644
index 0000000..19119b6
--- /dev/null
+++ b/packages/gitbook-plugin/CONTRIBUTING.md
@@ -0,0 +1,11 @@
+Compile the CLI using:
+
+```
+npm run dist
+```
+
+Then run the CLI in `lib/`:
+
+```
+./lib/cli.js
+```
diff --git a/packages/gitbook-plugin/README.md b/packages/gitbook-plugin/README.md
new file mode 100644
index 0000000..b2dab88
--- /dev/null
+++ b/packages/gitbook-plugin/README.md
@@ -0,0 +1 @@
+For instructions on how to create plugins, see [GitBook: Create a plugin](toolchain.gitbook.com/api/).
diff --git a/packages/gitbook-plugin/package.json b/packages/gitbook-plugin/package.json
new file mode 100644
index 0000000..9cdc2f8
--- /dev/null
+++ b/packages/gitbook-plugin/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "gitbook-plugin",
+ "version": "4.0.0",
+ "description": "CLI for compiling and testing plugins",
+ "main": "./lib/index.js",
+ "dependencies": {
+ "babel-preset-es2015": "^6.14.0",
+ "babel-preset-react": "^6.11.1",
+ "babelify": "^7.3.0",
+ "browserify": "^13.1.0",
+ "commander": "^2.9.0",
+ "fs-extra": "^0.30.0",
+ "inquirer": "^1.1.3",
+ "q": "^1.4.1",
+ "winston": "^2.2.0"
+ },
+ "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"
+ },
+ "bin": {
+ "gitbook-plugin": "./lib/cli.js"
+ },
+ "scripts": {
+ "dist": "rm -rf lib/ && babel -d lib/ src/ && chmod +x ./lib/cli.js",
+ "prepublish": "npm run dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/GitbookIO/gitbook.git"
+ },
+ "author": "GitBook Inc. <contact@gitbook.com>",
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/GitbookIO/gitbook/issues"
+ }
+}
diff --git a/packages/gitbook-plugin/src/cli.js b/packages/gitbook-plugin/src/cli.js
new file mode 100644
index 0000000..06e421d
--- /dev/null
+++ b/packages/gitbook-plugin/src/cli.js
@@ -0,0 +1,84 @@
+#! /usr/bin/env node
+
+const program = require('commander');
+const path = require('path');
+const winston = require('winston');
+const inquirer = require('inquirer');
+
+const pkg = require('../package.json');
+const compile = require('./compile');
+const create = require('./create');
+
+const resolve = (input => path.resolve(process.cwd(), input));
+
+program.version(pkg.version);
+winston.cli();
+
+program
+ .command('build [input] [output]')
+ .description('build a browser plugin')
+ .action(function(input, output, options) {
+ compile(resolve(input), resolve(output))
+ .then(
+ () => winston.info('Plugin compiled successfully'),
+ (err) => winston.error('Error: ', err)
+ );
+ });
+
+program
+ .command('create [output]')
+ .description('create a new plugin')
+ .action(function(output, options) {
+ inquirer.prompt([
+ {
+ name: 'title',
+ message: 'Title (as displayed on GitBook.com):'
+ },
+ {
+ name: 'name',
+ message: 'Name (unique identifier for the plugin):'
+ },
+ {
+ name: 'desc',
+ message: 'Description:'
+ },
+ {
+ name: 'github',
+ message: 'GitHub repository URL:'
+ },
+ {
+ name: 'categories',
+ message: 'Categories (as displayed on GitBook.com):',
+ type: 'checkbox',
+ choices: [
+ 'analytics',
+ 'search',
+ 'content',
+ 'structure',
+ 'social',
+ 'visual'
+ ]
+ }
+ ])
+ .then(answers => {
+ output = resolve(output || answers.name);
+ return create(output, answers);
+ })
+ .then(
+ () => winston.info(`Plugin created successfully in "${output}"`),
+ (err) => winston.error('Error: ', err)
+ );
+ });
+
+program
+ .command('test [plugin]')
+ .description('test specs for a plugin')
+ .action(function(plugin, options) {
+
+ });
+
+
+program.parse(process.argv);
+
+// Display help if no arguments
+if (!program.args.length) program.help();
diff --git a/packages/gitbook-plugin/src/compile.js b/packages/gitbook-plugin/src/compile.js
new file mode 100644
index 0000000..61c8777
--- /dev/null
+++ b/packages/gitbook-plugin/src/compile.js
@@ -0,0 +1,41 @@
+const fs = require('fs-extra');
+const Promise = require('q');
+const browserify = require('browserify');
+const babelify = require('babelify');
+
+/**
+ * Compile a plugin to work with "gitbook-core" in the browser.
+ * @param {String} inputFile
+ * @param {String} outputFile
+ * @return {Promise}
+ */
+function compilePlugin(inputFile, outputFile) {
+ const d = Promise.defer();
+ const b = browserify({
+ standalone: 'GitBookPlugin'
+ });
+
+ b.add(inputFile);
+ b.external('react');
+ b.external('react-dom');
+ b.external('gitbook-core');
+ b.transform(babelify, {
+ presets: [
+ require('babel-preset-es2015'),
+ require('babel-preset-react')
+ ]
+ });
+
+ fs.ensureFileSync(outputFile);
+
+ const output = fs.createWriteStream(outputFile);
+
+ b.bundle()
+ .pipe(output)
+ .on('error', (err) => d.reject(err))
+ .on('end', () => d.resolve());
+
+ return d.promise;
+}
+
+module.exports = compilePlugin;
diff --git a/packages/gitbook-plugin/src/create.js b/packages/gitbook-plugin/src/create.js
new file mode 100644
index 0000000..31edb85
--- /dev/null
+++ b/packages/gitbook-plugin/src/create.js
@@ -0,0 +1,61 @@
+const fs = require('fs-extra');
+const path = require('path');
+const GITBOOK_VERSION = require('../package.json').version;
+
+const TEMPLATE_DIR = path.resolve(__dirname, '../template');
+
+/**
+ * Create a new plugin
+ * @param {String} outputDir
+ * @param {String} spec.title
+ * @param {String} spec.name
+ * @param {String} spec.desc
+ * @param {Array} spec.keywords
+ */
+function create(outputDir, spec) {
+ const pkg = {
+ 'title': `${spec.title}`,
+ 'name': `gitbook-plugin-${spec.name}`,
+ 'description': `${spec.desc}`,
+ 'version': '0.0.0',
+ 'main': 'index.js',
+ 'browser': './_assets/plugin.js',
+ 'ebook': './_assets/plugin.js',
+ 'dependencies': {
+ 'gitbook-core': '^' + GITBOOK_VERSION
+ },
+ 'devDependencies': {
+ 'gitbook-plugin': '^' + GITBOOK_VERSION,
+ 'eslint': '3.7.1',
+ 'eslint-config-gitbook': '1.4.0'
+ },
+ 'engines': {
+ 'gitbook': '>=4.0.0-alpha.0'
+ },
+ 'scripts': {
+ 'lint': 'eslint ./',
+ 'build-website': 'gitbook-plugin build ./src/index.js ./_assets/plugin.js',
+ 'prepublish': 'npm run build-website',
+ 'test': 'gitbook-plugin test && npm run lint'
+ },
+ 'homepage': `${spec.github}`,
+ 'keywords': spec.categories.map(category => `gitbook:${category}`),
+ 'repository': {
+ 'type': 'git',
+ 'url': `${spec.github}.git`
+ },
+ 'bugs': {
+ 'url': `${spec.github}/issues`
+ }
+ };
+
+ fs.copySync(TEMPLATE_DIR, outputDir, {
+ clobber: true
+ });
+
+ fs.outputJsonSync(path.resolve(outputDir, 'package.json'), pkg, {
+ spaces: 2
+ });
+}
+
+module.exports = create;
diff --git a/packages/gitbook-plugin/src/index.js b/packages/gitbook-plugin/src/index.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/gitbook-plugin/src/index.js
diff --git a/packages/gitbook-plugin/template/.eslintignore b/packages/gitbook-plugin/template/.eslintignore
new file mode 100644
index 0000000..1d35cda
--- /dev/null
+++ b/packages/gitbook-plugin/template/.eslintignore
@@ -0,0 +1,2 @@
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin/template/.eslintrc b/packages/gitbook-plugin/template/.eslintrc
new file mode 100644
index 0000000..90359b2
--- /dev/null
+++ b/packages/gitbook-plugin/template/.eslintrc
@@ -0,0 +1,3 @@
+{
+ "extends": "gitbook/plugin"
+}
diff --git a/packages/gitbook-plugin/template/.gitignore b/packages/gitbook-plugin/template/.gitignore
new file mode 100644
index 0000000..ef47881
--- /dev/null
+++ b/packages/gitbook-plugin/template/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# Deployed apps should consider commenting this line out:
+# see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git
+node_modules
+
+# vim swapfile
+*.swp
+
+# Plugin assets
+_assets/plugin.js
diff --git a/packages/gitbook-plugin/template/.npmignore b/packages/gitbook-plugin/template/.npmignore
new file mode 100644
index 0000000..a0e53cf
--- /dev/null
+++ b/packages/gitbook-plugin/template/.npmignore
@@ -0,0 +1,2 @@
+# Publish assets on NPM
+!_assets/plugin.js
diff --git a/packages/gitbook-plugin/template/index.js b/packages/gitbook-plugin/template/index.js
new file mode 100644
index 0000000..e542ae8
--- /dev/null
+++ b/packages/gitbook-plugin/template/index.js
@@ -0,0 +1,10 @@
+
+module.exports = {
+ blocks: {
+
+ },
+
+ hooks: {
+
+ }
+};
diff --git a/packages/gitbook-plugin/template/src/index.js b/packages/gitbook-plugin/template/src/index.js
new file mode 100644
index 0000000..0fe8869
--- /dev/null
+++ b/packages/gitbook-plugin/template/src/index.js
@@ -0,0 +1,11 @@
+const GitBook = require('gitbook-core');
+
+module.exports = GitBook.createPlugin({
+ activate: (dispatch, getState) => {
+ // Dispatch initialization actions
+ },
+ deactivate: (dispatch, getState) => {
+ // Dispatch cleanup actions
+ },
+ reduce: (state, action) => state
+});
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;