summaryrefslogtreecommitdiffstats
path: root/packages/gitbook/src/output
diff options
context:
space:
mode:
authorSamy Pessé <samypesse@gmail.com>2016-12-22 10:18:38 +0100
committerGitHub <noreply@github.com>2016-12-22 10:18:38 +0100
commit194ebc3da9641ff96f083f9d8ab43c2d27944f9a (patch)
treec50988f32ccf18df93ae7ab40be78e9459642818 /packages/gitbook/src/output
parent64ccb6b00b4b63fa0e516d4e35351275b34f8c07 (diff)
parent16af264360e48e8a833e9efa9ab8d194574dbc70 (diff)
downloadgitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.zip
gitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.tar.gz
gitbook-194ebc3da9641ff96f083f9d8ab43c2d27944f9a.tar.bz2
Merge pull request #1543 from GitbookIO/dream
React for rendering website with plugins
Diffstat (limited to 'packages/gitbook/src/output')
-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
57 files changed, 2237 insertions, 0 deletions
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;