summaryrefslogtreecommitdiffstats
path: root/lib/output
diff options
context:
space:
mode:
Diffstat (limited to 'lib/output')
-rw-r--r--lib/output/__tests__/ebook.js16
-rw-r--r--lib/output/__tests__/json.js29
-rw-r--r--lib/output/__tests__/website.js71
-rw-r--r--lib/output/assets-inliner.js140
-rw-r--r--lib/output/base.js309
-rw-r--r--lib/output/callHook.js60
-rw-r--r--lib/output/callPageHook.js28
-rw-r--r--lib/output/conrefs.js67
-rw-r--r--lib/output/createTemplateEngine.js44
-rw-r--r--lib/output/ebook.js193
-rw-r--r--lib/output/ebook/getConvertOptions.js73
-rw-r--r--lib/output/ebook/getCoverPath.js30
-rw-r--r--lib/output/ebook/getPDFTemplate.js42
-rw-r--r--lib/output/ebook/index.js9
-rw-r--r--lib/output/ebook/onFinish.js90
-rw-r--r--lib/output/ebook/onPage.js24
-rw-r--r--lib/output/ebook/options.js17
-rw-r--r--lib/output/folder.js152
-rw-r--r--lib/output/generateAssets.js26
-rw-r--r--lib/output/generateBook.js181
-rw-r--r--lib/output/generateMock.js35
-rw-r--r--lib/output/generatePage.js71
-rw-r--r--lib/output/generatePages.js36
-rw-r--r--lib/output/getModifiers.js68
-rw-r--r--lib/output/helper/fileToOutput.js32
-rw-r--r--lib/output/helper/fileToURL.js31
-rw-r--r--lib/output/helper/index.js2
-rw-r--r--lib/output/helper/resolveFileToUrl.js27
-rw-r--r--lib/output/helper/writeFile.js23
-rw-r--r--lib/output/index.js24
-rw-r--r--lib/output/json.js47
-rw-r--r--lib/output/json/index.js6
-rw-r--r--lib/output/json/onFinish.js32
-rw-r--r--lib/output/json/onPage.js43
-rw-r--r--lib/output/json/options.js8
-rw-r--r--lib/output/modifiers/__tests__/addHeadingId.js29
-rw-r--r--lib/output/modifiers/__tests__/annotateText.js49
-rw-r--r--lib/output/modifiers/__tests__/fetchRemoteImages.js40
-rw-r--r--lib/output/modifiers/__tests__/highlightCode.js63
-rw-r--r--lib/output/modifiers/__tests__/resolveLinks.js71
-rw-r--r--lib/output/modifiers/__tests__/svgToImg.js25
-rw-r--r--lib/output/modifiers/__tests__/svgToPng.js32
-rw-r--r--lib/output/modifiers/addHeadingId.js23
-rw-r--r--lib/output/modifiers/annotateText.js94
-rw-r--r--lib/output/modifiers/editHTMLElement.js15
-rw-r--r--lib/output/modifiers/fetchRemoteImages.js44
-rw-r--r--lib/output/modifiers/highlightCode.js56
-rw-r--r--lib/output/modifiers/index.js15
-rw-r--r--lib/output/modifiers/inlineAssets.js27
-rw-r--r--lib/output/modifiers/modifyHTML.js25
-rw-r--r--lib/output/modifiers/resolveImages.js33
-rw-r--r--lib/output/modifiers/resolveLinks.js38
-rw-r--r--lib/output/modifiers/svgToImg.js56
-rw-r--r--lib/output/modifiers/svgToPng.js53
-rw-r--r--lib/output/prepareAssets.js22
-rw-r--r--lib/output/preparePages.js21
-rw-r--r--lib/output/preparePlugins.js36
-rw-r--r--lib/output/website/copyPluginAssets.js115
-rw-r--r--lib/output/website/createTemplateEngine.js118
-rw-r--r--lib/output/website/index.js232
-rw-r--r--lib/output/website/listSearchPaths.js23
-rw-r--r--lib/output/website/onAsset.js27
-rw-r--r--lib/output/website/onFinish.js35
-rw-r--r--lib/output/website/onInit.js18
-rw-r--r--lib/output/website/onPage.js72
-rw-r--r--lib/output/website/options.js14
-rw-r--r--lib/output/website/prepareI18n.js30
-rw-r--r--lib/output/website/state.js12
-rw-r--r--lib/output/website/templateEnv.js95
-rw-r--r--lib/output/website/themeLoader.js127
70 files changed, 2518 insertions, 1353 deletions
diff --git a/lib/output/__tests__/ebook.js b/lib/output/__tests__/ebook.js
new file mode 100644
index 0000000..dabf360
--- /dev/null
+++ b/lib/output/__tests__/ebook.js
@@ -0,0 +1,16 @@
+var generateMock = require('../generateMock');
+var EbookGenerator = require('../ebook');
+
+describe('EbookGenerator', function() {
+
+ pit('should generate a SUMMARY.html', function() {
+ return generateMock(EbookGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('SUMMARY.html');
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/lib/output/__tests__/json.js b/lib/output/__tests__/json.js
new file mode 100644
index 0000000..94a0362
--- /dev/null
+++ b/lib/output/__tests__/json.js
@@ -0,0 +1,29 @@
+var generateMock = require('../generateMock');
+var JSONGenerator = require('../json');
+
+describe('JSONGenerator', function() {
+
+ pit('should generate a README.json', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ });
+ });
+
+ pit('should generate a json file for each articles', function() {
+ return generateMock(JSONGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('README.json');
+ expect(folder).toHaveFile('test/page.json');
+ });
+ });
+});
+
diff --git a/lib/output/__tests__/website.js b/lib/output/__tests__/website.js
new file mode 100644
index 0000000..6b949a4
--- /dev/null
+++ b/lib/output/__tests__/website.js
@@ -0,0 +1,71 @@
+var generateMock = require('../generateMock');
+var WebsiteGenerator = require('../website');
+
+describe('WebsiteGenerator', function() {
+
+ pit('should generate an index.html', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World'
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+
+ pit('should generate an HTML file for each articles', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ pit('should not generate file if entry file doesn\'t exist', function() {
+ return generateMock(WebsiteGenerator, {
+ 'README.md': 'Hello World',
+ 'SUMMARY.md': '# Summary\n\n* [Page 1](page.md)\n* [Page 2](test/page.md)',
+ 'test': {
+ 'page.md': 'Hello 2'
+ }
+ })
+ .then(function(folder) {
+ expect(folder).toHaveFile('index.html');
+ expect(folder).not.toHaveFile('page.html');
+ expect(folder).toHaveFile('test/page.html');
+ });
+ });
+
+ pit('should generate a multilingual book', function() {
+ return generateMock(WebsiteGenerator, {
+ 'LANGS.md': '# Languages\n\n* [en](en)\n* [fr](fr)',
+ 'en': {
+ 'README.md': 'Hello'
+ },
+ 'fr': {
+ 'README.md': 'Bonjour'
+ }
+ })
+ .then(function(folder) {
+ // It should generate languages
+ expect(folder).toHaveFile('en/index.html');
+ expect(folder).toHaveFile('fr/index.html');
+
+ // Should not copy languages as assets
+ expect(folder).not.toHaveFile('en/README.md');
+ expect(folder).not.toHaveFile('fr/README.md');
+
+ // Should copy assets only once
+ expect(folder).toHaveFile('gitbook/style.css');
+ expect(folder).not.toHaveFile('en/gitbook/style.css');
+
+ expect(folder).toHaveFile('index.html');
+ });
+ });
+});
+
diff --git a/lib/output/assets-inliner.js b/lib/output/assets-inliner.js
deleted file mode 100644
index 6f1f02d..0000000
--- a/lib/output/assets-inliner.js
+++ /dev/null
@@ -1,140 +0,0 @@
-var util = require('util');
-var path = require('path');
-var crc = require('crc');
-
-var FolderOutput = require('./folder')();
-var Promise = require('../utils/promise');
-var fs = require('../utils/fs');
-var imagesUtil = require('../utils/images');
-var location = require('../utils/location');
-
-var DEFAULT_ASSETS_FOLDER = 'assets';
-
-/*
-Mixin to inline all the assets in a book:
- - Outline <svg> tags
- - Download remote images
- - Convert .svg images as png
-*/
-
-module.exports = function assetsInliner(Base) {
- Base = Base || FolderOutput;
-
- function AssetsInliner() {
- Base.apply(this, arguments);
-
- // Map of svg already converted
- this.svgs = {};
- this.inlineSvgs = {};
-
- // Map of images already downloaded
- this.downloaded = {};
- }
- util.inherits(AssetsInliner, Base);
-
- // Output a SVG buffer as a file
- AssetsInliner.prototype.onOutputSVG = function(page, svg) {
- this.log.debug.ln('output svg from', page.path);
-
- // Convert svg buffer to a png file
- return this.convertSVGBuffer(svg)
-
- // Return relative path from the page
- .then(function(filename) {
- return page.relative('/' + filename);
- });
- };
-
-
- // Output an image as a file
- AssetsInliner.prototype.onOutputImage = function(page, src) {
- var that = this;
-
- return Promise()
-
- // Download file if external
- .then(function() {
- if (!location.isExternal(src)) return;
-
- return that.downloadAsset(src)
- .then(function(_asset) {
- src = '/' + _asset;
- });
-
- })
- .then(function() {
- // Resolve src to a relative filepath to the book's root
- src = page.resolveLocal(src);
-
- // Already a PNG/JPG/.. ?
- if (path.extname(src).toLowerCase() != '.svg') {
- return src;
- }
-
- // Convert SVG to PNG
- return that.convertSVGFile(that.resolve(src));
- })
-
- // Return relative path from the page
- .then(function(filename) {
- return page.relative(filename);
- });
- };
-
- // Download an asset if not already download; returns the output file
- AssetsInliner.prototype.downloadAsset = function(src) {
- if (this.downloaded[src]) return Promise(this.downloaded[src]);
-
- var that = this;
- var ext = path.extname(src);
- var hash = crc.crc32(src).toString(16);
-
- // Create new file
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + ext)
- .then(function(filename) {
- that.downloaded[src] = filename;
-
- that.log.debug.ln('downloading asset', src);
- return fs.download(src, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- // Convert a .svg into an .png
- // Return the output filename for the .png
- AssetsInliner.prototype.convertSVGFile = function(src) {
- if (this.svgs[src]) return Promise(this.svgs[src]);
-
- var that = this;
- var hash = crc.crc32(src).toString(16);
-
- // Create new file
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
- .then(function(filename) {
- that.svgs[src] = filename;
-
- return imagesUtil.convertSVGToPNG(src, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- // Convert an inline svg into an .png
- // Return the output filename for the .png
- AssetsInliner.prototype.convertSVGBuffer = function(buf) {
- var that = this;
- var hash = crc.crc32(buf).toString(16);
-
- // Already converted?
- if (this.inlineSvgs[hash]) return Promise(this.inlineSvgs[hash]);
-
- return this.createNewFile(DEFAULT_ASSETS_FOLDER, hash + '.png')
- .then(function(filename) {
- that.inlineSvgs[hash] = filename;
-
- return imagesUtil.convertSVGBufferToPNG(buf, that.resolve(filename))
- .thenResolve(filename);
- });
- };
-
- return AssetsInliner;
-};
diff --git a/lib/output/base.js b/lib/output/base.js
deleted file mode 100644
index 868b85b..0000000
--- a/lib/output/base.js
+++ /dev/null
@@ -1,309 +0,0 @@
-var _ = require('lodash');
-var Ignore = require('ignore');
-var path = require('path');
-
-var Promise = require('../utils/promise');
-var pathUtil = require('../utils/path');
-var location = require('../utils/location');
-var error = require('../utils/error');
-var PluginsManager = require('../plugins');
-var TemplateEngine = require('../template');
-var gitbook = require('../gitbook');
-
-/*
-Output is like a stream interface for a parsed book
-to output "something".
-
-The process is mostly on the behavior of "onPage" and "onAsset"
-*/
-
-function Output(book, opts, parent) {
- _.bindAll(this);
- this.parent = parent;
-
- this.opts = _.defaults({}, opts || {}, {
- directoryIndex: true
- });
-
- this.book = book;
- book.output = this;
- this.log = this.book.log;
-
- // Create plugins manager
- this.plugins = new PluginsManager(this.book);
-
- // Create template engine
- this.template = new TemplateEngine(this);
-
- // Files to ignore in output
- this.ignore = Ignore();
-
- // Hack to inherits from rules of the book
- this.ignore.add(this.book.ignore);
-}
-
-// Default name for generator
-Output.prototype.name = 'base';
-
-// Default extension for output
-Output.prototype.defaultExtension = '.html';
-
-// Start the generation, for a parsed book
-Output.prototype.generate = function() {
- var that = this;
- var isMultilingual = this.book.isMultilingual();
-
- return Promise()
-
- // Load all plugins
- .then(function() {
- return that.plugins.loadAll()
- .then(function() {
- that.template.addFilters(that.plugins.getFilters());
- that.template.addBlocks(that.plugins.getBlocks());
- });
- })
-
- // Transform the configuration
- .then(function() {
- return that.plugins.hook('config', that.book.config.dump())
- .then(function(cfg) {
- that.book.config.replace(cfg);
- });
- })
-
- // Initialize the generation
- .then(function() {
- return that.plugins.hook('init');
- })
- .then(function() {
- that.log.info.ln('preparing the generation');
- return that.prepare();
- })
-
- // Process all files
- .then(function() {
- that.log.debug.ln('listing files');
- return that.book.fs.listAllFiles(that.book.root);
- })
-
- // We want to process assets first, then pages
- // Since pages can have logic based on existance of assets
- .then(function(files) {
- // Split into pages/assets
- var byTypes = _.chain(files)
- .filter(that.ignore.createFilter())
-
- // Ignore file present in a language book
- .filter(function(filename) {
- return !(isMultilingual && that.book.isInLanguageBook(filename));
- })
-
- .groupBy(function(filename) {
- return (that.book.hasPage(filename)? 'page' : 'asset');
- })
-
- .value();
-
- return Promise.serie(byTypes.asset, function(filename) {
- that.log.debug.ln('copy asset', filename);
- return that.onAsset(filename);
- })
- .then(function() {
- return Promise.serie(byTypes.page, function(filename) {
- that.log.debug.ln('process page', filename);
- return that.onPage(that.book.getPage(filename));
- });
- });
- })
-
- // Generate sub-books
- .then(function() {
- if (!that.book.isMultilingual()) return;
-
- return Promise.serie(that.book.books, function(subbook) {
- that.log.info.ln('');
- that.log.info.ln('start generation of language "' + path.relative(that.book.root, subbook.root) + '"');
-
- var out = that.onLanguageBook(subbook);
- return out.generate();
- });
- })
-
- // Finish the generation
- .then(function() {
- return that.plugins.hook('finish:before');
- })
- .then(function() {
- that.log.debug.ln('finishing the generation');
- return that.finish();
- })
- .then(function() {
- return that.plugins.hook('finish');
- })
-
- .then(function() {
- if (!that.book.isLanguageBook()) that.log.info.ln('');
- that.log.info.ok('generation finished with success!');
- });
-};
-
-// Prepare the generation
-Output.prototype.prepare = function() {
- this.ignore.addPattern(_.compact([
- '.gitignore',
- '.ignore',
- '.bookignore',
- 'node_modules',
- '_layouts',
-
- // The configuration file should not be copied in the output
- '/' + this.book.config.path,
-
- // Structure file to ignore
- '/' + this.book.summary.path,
- '/' + this.book.langs.path
- ]));
-};
-
-// Write a page (parsable file), ex: markdown, etc
-Output.prototype.onPage = function(page) {
- return page.toHTML(this);
-};
-
-// Copy an asset file (non-parsable), ex: images, etc
-Output.prototype.onAsset = function(filename) {
-
-};
-
-// Finish the generation
-Output.prototype.finish = function() {
-
-};
-
-// Resolve an HTML link
-Output.prototype.onRelativeLink = function(currentPage, href) {
- var to = currentPage.followPage(href);
-
- // Replace by an .html link
- if (to) {
- href = to.path;
-
- // Change README path to be "index.html"
- if (href == this.book.readme.path) {
- href = 'index.html';
- }
-
- // Recalcul as relative link
- href = currentPage.relative(href);
-
- // Replace .md by .html
- href = this.toURL(href);
- }
-
- return href;
-};
-
-// Output a SVG buffer as a file
-Output.prototype.onOutputSVG = function(page, svg) {
- return null;
-};
-
-// Output an image as a file
-// Normalize the relative link
-Output.prototype.onOutputImage = function(page, imgFile) {
- if (location.isExternal(imgFile)) {
- return imgFile;
- }
-
- imgFile = page.resolveLocal(imgFile);
- return page.relative(imgFile);
-};
-
-// Read a template by its source URL
-Output.prototype.onGetTemplate = function(sourceUrl) {
- throw new Error('template not found '+sourceUrl);
-};
-
-// Generate a source URL for a template
-Output.prototype.onResolveTemplate = function(from, to) {
- return path.resolve(path.dirname(from), to);
-};
-
-// Prepare output for a language book
-Output.prototype.onLanguageBook = function(book) {
- return new this.constructor(book, this.opts, this);
-};
-
-
-// ---- Utilities ----
-
-// Return conetxt for the output itself
-Output.prototype.getSelfContext = function() {
- return {
- name: this.name
- };
-};
-
-// Return a default context for templates
-Output.prototype.getContext = function() {
- var ctx = _.extend(
- {
- output: this.getSelfContext()
- },
- this.book.getContext(),
- (this.book.isLanguageBook()? this.book.parent: this.book).langs.getContext(),
- this.book.readme.getContext(),
- this.book.summary.getContext(),
- this.book.glossary.getContext(),
- this.book.config.getContext(),
- gitbook.getContext()
- );
-
- // Deprecated fields
- error.deprecateField(ctx.gitbook, 'generator', this.name, '"gitbook.generator" property is deprecated, use "output.name" instead');
-
- return ctx;
-};
-
-// Resolve a file path in the context of a specific page
-// Result is an "absolute path relative to the output folder"
-Output.prototype.resolveForPage = function(page, href) {
- if (_.isString(page)) page = this.book.getPage(page);
-
- href = page.relative(href);
- return this.onRelativeLink(page, href);
-};
-
-// Filename for output
-// READMEs are replaced by index.html
-// /test/README.md -> /test/index.html
-Output.prototype.outputPath = function(filename, ext) {
- ext = ext || this.defaultExtension;
- var output = filename;
-
- if (
- path.basename(filename, path.extname(filename)) == 'README' ||
- output == this.book.readme.path
- ) {
- output = path.join(path.dirname(output), 'index'+ext);
- } else {
- output = pathUtil.setExtension(output, ext);
- }
-
- return output;
-};
-
-// Filename for output
-// /test/index.html -> /test/
-Output.prototype.toURL = function(filename, ext) {
- var href = this.outputPath(filename, ext);
-
- if (path.basename(href) == 'index.html' && this.opts.directoryIndex) {
- href = path.dirname(href) + '/';
- }
-
- return location.normalize(href);
-};
-
-module.exports = Output;
diff --git a/lib/output/callHook.js b/lib/output/callHook.js
new file mode 100644
index 0000000..4914e52
--- /dev/null
+++ b/lib/output/callHook.js
@@ -0,0 +1,60 @@
+var Promise = require('../utils/promise');
+var timing = require('../utils/timing');
+var Api = require('../api');
+
+function defaultGetArgument() {
+ return undefined;
+}
+
+function defaultHandleResult(output, result) {
+ return output;
+}
+
+/**
+ Call a "global" hook for an output
+
+ @param {String} name
+ @param {Function(Output) -> Mixed} getArgument
+ @param {Function(Output, result) -> Output} handleResult
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function callHook(name, getArgument, handleResult, output) {
+ getArgument = getArgument || defaultGetArgument;
+ handleResult = handleResult || defaultHandleResult;
+
+ var logger = output.getLogger();
+ var plugins = output.getPlugins();
+
+ logger.debug.ln('calling hook "' + name + '"');
+
+ // Create the JS context for plugins
+ var context = Api.encodeGlobal(output);
+
+ return timing.measure(
+ 'call.hook.' + name,
+
+ // Get the arguments
+ Promise(getArgument(output))
+
+ // Call the hooks in serie
+ .then(function(arg) {
+ return Promise.reduce(plugins, function(prev, plugin) {
+ var hook = plugin.getHook(name);
+ if (!hook) {
+ return prev;
+ }
+
+ return hook.call(context, prev);
+ }, arg);
+ })
+
+ // Handle final result
+ .then(function(result) {
+ output = Api.decodeGlobal(output, context);
+ return handleResult(output, result);
+ })
+ );
+}
+
+module.exports = callHook;
diff --git a/lib/output/callPageHook.js b/lib/output/callPageHook.js
new file mode 100644
index 0000000..c66cef0
--- /dev/null
+++ b/lib/output/callPageHook.js
@@ -0,0 +1,28 @@
+var Api = require('../api');
+var callHook = require('./callHook');
+
+/**
+ Call a hook for a specific page
+
+ @param {String} name
+ @param {Output} output
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function callPageHook(name, output, page) {
+ return callHook(
+ name,
+
+ function(out) {
+ return Api.encodePage(out, page);
+ },
+
+ function(out, result) {
+ return Api.decodePage(out, page, result);
+ },
+
+ output
+ );
+}
+
+module.exports = callPageHook;
diff --git a/lib/output/conrefs.js b/lib/output/conrefs.js
deleted file mode 100644
index e58f836..0000000
--- a/lib/output/conrefs.js
+++ /dev/null
@@ -1,67 +0,0 @@
-var path = require('path');
-var util = require('util');
-
-var folderOutput = require('./folder');
-var Git = require('../utils/git');
-var fs = require('../utils/fs');
-var pathUtil = require('../utils/path');
-var location = require('../utils/location');
-
-/*
-Mixin for output to resolve git conrefs
-*/
-
-module.exports = function conrefsLoader(Base) {
- Base = folderOutput(Base);
-
- function ConrefsLoader() {
- Base.apply(this, arguments);
-
- this.git = new Git();
- }
- util.inherits(ConrefsLoader, Base);
-
- // Read a template by its source URL
- ConrefsLoader.prototype.onGetTemplate = function(sourceURL) {
- var that = this;
-
- return this.git.resolve(sourceURL)
- .then(function(filepath) {
- // Is local file
- if (!filepath) {
- filepath = that.book.resolve(sourceURL);
- } else {
- that.book.log.debug.ln('resolve from git', sourceURL, 'to', filepath);
- }
-
- // Read file from absolute path
- return fs.readFile(filepath)
- .then(function(source) {
- return {
- src: source.toString('utf8'),
- path: filepath
- };
- });
- });
- };
-
- // Generate a source URL for a template
- ConrefsLoader.prototype.onResolveTemplate = function(from, to) {
- // If origin is in the book, we enforce result file to be in the book
- if (this.book.isInBook(from)) {
- var href = location.toAbsolute(to, path.dirname(from), '');
- return this.book.resolve(href);
- }
-
- // If origin is in a git repository, we resolve file in the git repository
- var gitRoot = this.git.resolveRoot(from);
- if (gitRoot) {
- return pathUtil.resolveInRoot(gitRoot, to);
- }
-
- // If origin is not in the book (include from a git content ref)
- return path.resolve(path.dirname(from), to);
- };
-
- return ConrefsLoader;
-};
diff --git a/lib/output/createTemplateEngine.js b/lib/output/createTemplateEngine.js
new file mode 100644
index 0000000..37b3c27
--- /dev/null
+++ b/lib/output/createTemplateEngine.js
@@ -0,0 +1,44 @@
+var Templating = require('../templating');
+var TemplateEngine = require('../models/templateEngine');
+
+var Api = require('../api');
+var Plugins = require('../plugins');
+
+var defaultBlocks = require('../constants/defaultBlocks');
+var defaultFilters = require('../constants/defaultFilters');
+
+/**
+ Create template engine for an output.
+ It adds default filters/blocks, then add the ones from plugins
+
+ @param {Output} output
+ @return {TemplateEngine}
+*/
+function createTemplateEngine(output) {
+ var plugins = output.getPlugins();
+ var book = output.getBook();
+ var rootFolder = book.getContentRoot();
+ var logger = book.getLogger();
+
+ var filters = Plugins.listFilters(plugins);
+ var blocks = Plugins.listBlocks(plugins);
+
+ // Extend with default
+ blocks = defaultBlocks.merge(blocks);
+ filters = defaultFilters.merge(filters);
+
+ // Create loader
+ var loader = new Templating.ConrefsLoader(rootFolder, logger);
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+ return new TemplateEngine({
+ filters: filters,
+ blocks: blocks,
+ loader: loader,
+ context: context
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/lib/output/ebook.js b/lib/output/ebook.js
deleted file mode 100644
index 2b8fac9..0000000
--- a/lib/output/ebook.js
+++ /dev/null
@@ -1,193 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-var juice = require('juice');
-
-var command = require('../utils/command');
-var fs = require('../utils/fs');
-var Promise = require('../utils/promise');
-var error = require('../utils/error');
-var WebsiteOutput = require('./website');
-var assetsInliner = require('./assets-inliner');
-
-function _EbookOutput() {
- WebsiteOutput.apply(this, arguments);
-
- // ebook-convert does not support link like "./"
- this.opts.directoryIndex = false;
-}
-util.inherits(_EbookOutput, WebsiteOutput);
-
-var EbookOutput = assetsInliner(_EbookOutput);
-
-EbookOutput.prototype.name = 'ebook';
-
-// Return context for templating
-// Incldue type of ebbook generated
-EbookOutput.prototype.getSelfContext = function() {
- var ctx = EbookOutput.super_.prototype.getSelfContext.apply(this);
- ctx.format = this.opts.format;
-
- return ctx;
-};
-
-// Finish generation, create ebook using ebook-convert
-EbookOutput.prototype.finish = function() {
- var that = this;
- if (that.book.isMultilingual()) {
- return EbookOutput.super_.prototype.finish.apply(that);
- }
-
- return Promise()
- .then(function() {
- return EbookOutput.super_.prototype.finish.apply(that);
- })
-
- // Generate SUMMARY.html
- .then(function() {
- return that.render('summary', 'SUMMARY.html', that.getContext());
- })
-
- // Start ebook-convert
- .then(function() {
- return that.ebookConvertOption();
- })
-
- .then(function(options) {
- if (!that.opts.format) return;
-
- var cmd = [
- 'ebook-convert',
- that.resolve('SUMMARY.html'),
- that.resolve('index.'+that.opts.format),
- command.optionsToShellArgs(options)
- ].join(' ');
-
- return command.exec(cmd)
- .progress(function(data) {
- that.book.log.debug(data);
- })
- .fail(function(err) {
- if (err.code == 127) {
- throw error.RequireInstallError({
- cmd: 'ebook-convert',
- install: 'Install it from Calibre: https://calibre-ebook.com'
- });
- }
-
- throw error.EbookError(err);
- });
- });
-};
-
-// Generate header/footer for PDF
-EbookOutput.prototype.getPDFTemplate = function(tpl) {
- var that = this;
- var context = _.extend(
- {
- // Nunjucks context mapping to ebook-convert templating
- page: {
- num: '_PAGENUM_',
- title: '_TITLE_',
- section: '_SECTION_'
- }
- },
- this.getContext()
- );
-
- return this.renderAsString('pdf_'+tpl, context)
-
- // Inline css, include css relative to the output folder
- .then(function(output) {
- return Promise.nfcall(juice.juiceResources, output, {
- webResources: {
- relativeTo: that.root()
- }
- });
- });
-};
-
-// Locate the cover file to use
-// Use configuration or search a "cover.jpg" file
-// For multi-lingual book, it can use the one from the main book
-EbookOutput.prototype.locateCover = function() {
- var cover = this.book.config.get('cover', 'cover.jpg');
-
- // Resolve to absolute
- cover = this.resolve(cover);
-
- // Cover doesn't exist and multilingual?
- if (!fs.existsSync(cover)) {
- if (this.parent) return this.parent.locateCover();
- else return undefined;
- }
-
- return cover;
-};
-
-// Generate options for ebook-convert
-EbookOutput.prototype.ebookConvertOption = function() {
- var that = this;
-
- var options = {
- '--cover': this.locateCover(),
- '--title': that.book.config.get('title'),
- '--comments': that.book.config.get('description'),
- '--isbn': that.book.config.get('isbn'),
- '--authors': that.book.config.get('author'),
- '--language': that.book.config.get('language'),
- '--book-producer': 'GitBook',
- '--publisher': 'GitBook',
- '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
- '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
- '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
- '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
- '--no-chapters-in-toc': true,
- '--max-levels': '1',
- '--breadth-first': true
- };
-
- if (that.opts.format == 'epub') {
- options = _.extend(options, {
- '--dont-split-on-page-breaks': true
- });
- }
-
- if (that.opts.format != 'pdf') return Promise(options);
-
- var pdfOptions = that.book.config.get('pdf');
-
- options = _.extend(options, {
- '--chapter-mark': String(pdfOptions.chapterMark),
- '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
- '--margin-left': String(pdfOptions.margin.left),
- '--margin-right': String(pdfOptions.margin.right),
- '--margin-top': String(pdfOptions.margin.top),
- '--margin-bottom': String(pdfOptions.margin.bottom),
- '--pdf-default-font-size': String(pdfOptions.fontSize),
- '--pdf-mono-font-size': String(pdfOptions.fontSize),
- '--paper-size': String(pdfOptions.paperSize),
- '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
- '--pdf-header-template': that.getPDFTemplate('header'),
- '--pdf-footer-template': that.getPDFTemplate('footer'),
- '--pdf-sans-family': String(pdfOptions.fontFamily)
- });
-
- return that.getPDFTemplate('header')
- .then(function(tpl) {
- options['--pdf-header-template'] = tpl;
-
- return that.getPDFTemplate('footer');
- })
- .then(function(tpl) {
- options['--pdf-footer-template'] = tpl;
-
- return options;
- });
-};
-
-// Don't write multi-lingual index for wbook
-EbookOutput.prototype.outputMultilingualIndex = function() {
-
-};
-
-module.exports = EbookOutput;
diff --git a/lib/output/ebook/getConvertOptions.js b/lib/output/ebook/getConvertOptions.js
new file mode 100644
index 0000000..bc80493
--- /dev/null
+++ b/lib/output/ebook/getConvertOptions.js
@@ -0,0 +1,73 @@
+var extend = require('extend');
+
+var Promise = require('../../utils/promise');
+var getPDFTemplate = require('./getPDFTemplate');
+var getCoverPath = require('./getCoverPath');
+
+/**
+ Generate options for ebook-convert
+
+ @param {Output}
+ @return {Promise<Object>}
+*/
+function getConvertOptions(output) {
+ var options = output.getOptions();
+ var format = options.get('format');
+
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ return Promise()
+ .then(function() {
+ var coverPath = getCoverPath(output);
+ var options = {
+ '--cover': coverPath,
+ '--title': config.getValue('title'),
+ '--comments': config.getValue('description'),
+ '--isbn': config.getValue('isbn'),
+ '--authors': config.getValue('author'),
+ '--language': book.getLanguage() || config.getValue('language'),
+ '--book-producer': 'GitBook',
+ '--publisher': 'GitBook',
+ '--chapter': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter \')]',
+ '--level1-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-1 \')]',
+ '--level2-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-2 \')]',
+ '--level3-toc': 'descendant-or-self::*[contains(concat(\' \', normalize-space(@class), \' \'), \' book-chapter-3 \')]',
+ '--max-levels': '1',
+ '--no-chapters-in-toc': true,
+ '--breadth-first': true,
+ '--dont-split-on-page-breaks': format === 'epub'? true : undefined
+ };
+
+ if (format !== 'pdf') {
+ return options;
+ }
+
+ return Promise.all([
+ getPDFTemplate(output, 'header'),
+ getPDFTemplate(output, 'footer')
+ ])
+ .spread(function(headerTpl, footerTpl) {
+ var pdfOptions = config.getValue('pdf').toJS();
+
+ return options = extend(options, {
+ '--chapter-mark': String(pdfOptions.chapterMark),
+ '--page-breaks-before': String(pdfOptions.pageBreaksBefore),
+ '--margin-left': String(pdfOptions.margin.left),
+ '--margin-right': String(pdfOptions.margin.right),
+ '--margin-top': String(pdfOptions.margin.top),
+ '--margin-bottom': String(pdfOptions.margin.bottom),
+ '--pdf-default-font-size': String(pdfOptions.fontSize),
+ '--pdf-mono-font-size': String(pdfOptions.fontSize),
+ '--paper-size': String(pdfOptions.paperSize),
+ '--pdf-page-numbers': Boolean(pdfOptions.pageNumbers),
+ '--pdf-sans-family': String(pdfOptions.fontFamily),
+ '--pdf-header-template': headerTpl,
+ '--pdf-footer-template': footerTpl
+ });
+ });
+ });
+}
+
+
+module.exports = getConvertOptions;
diff --git a/lib/output/ebook/getCoverPath.js b/lib/output/ebook/getCoverPath.js
new file mode 100644
index 0000000..c2192d4
--- /dev/null
+++ b/lib/output/ebook/getCoverPath.js
@@ -0,0 +1,30 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Resolve path to cover file to use
+
+ @param {Output}
+ @return {String}
+*/
+function getCoverPath(output) {
+ var outputRoot = output.getRoot();
+ var book = output.getBook();
+ var config = book.getConfig();
+ var cover = config.getValue('cover', 'cover.jpg');
+
+ // Resolve to absolute
+ cover = fs.pickFile(outputRoot, cover);
+ if (cover) {
+ return cover;
+ }
+
+ // Multilingual? try parent folder
+ if (book.isLanguageBook()) {
+ cover = fs.pickFile(path.join(outputRoot, '..'), cover);
+ }
+
+ return cover;
+}
+
+module.exports = getCoverPath;
diff --git a/lib/output/ebook/getPDFTemplate.js b/lib/output/ebook/getPDFTemplate.js
new file mode 100644
index 0000000..f7a450d
--- /dev/null
+++ b/lib/output/ebook/getPDFTemplate.js
@@ -0,0 +1,42 @@
+var juice = require('juice');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+
+
+/**
+ Generate PDF header/footer templates
+
+ @param {Output} output
+ @param {String} type
+ @return {String}
+*/
+function getPDFTemplate(output, type) {
+ var filePath = 'pdf_' + type + '.html';
+ var outputRoot = output.getRoot();
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+
+ // Generate context
+ var context = JSONUtils.encodeOutput(output);
+ context.page = {
+ num: '_PAGENUM_',
+ title: '_TITLE_',
+ section: '_SECTION_'
+ };
+
+ // Render the theme
+ return Templating.renderFile(engine, 'ebook/' + filePath, context)
+
+ // Inline css and assets
+ .then(function(html) {
+ return Promise.nfcall(juice.juiceResources, html, {
+ webResources: {
+ relativeTo: outputRoot
+ }
+ });
+ });
+}
+
+module.exports = getPDFTemplate;
diff --git a/lib/output/ebook/index.js b/lib/output/ebook/index.js
new file mode 100644
index 0000000..786a10a
--- /dev/null
+++ b/lib/output/ebook/index.js
@@ -0,0 +1,9 @@
+var extend = require('extend');
+var WebsiteGenerator = require('../website');
+
+module.exports = extend({}, WebsiteGenerator, {
+ name: 'ebook',
+ Options: require('./options'),
+ onPage: require('./onPage'),
+ onFinish: require('./onFinish')
+});
diff --git a/lib/output/ebook/onFinish.js b/lib/output/ebook/onFinish.js
new file mode 100644
index 0000000..17a8e5e
--- /dev/null
+++ b/lib/output/ebook/onFinish.js
@@ -0,0 +1,90 @@
+var path = require('path');
+
+var WebsiteGenerator = require('../website');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var Promise = require('../../utils/promise');
+var error = require('../../utils/error');
+var command = require('../../utils/command');
+var writeFile = require('../helper/writeFile');
+
+var getConvertOptions = require('./getConvertOptions');
+
+/**
+ Write the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function writeSummary(output) {
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ var filePath = 'SUMMARY.html';
+ var engine = WebsiteGenerator.createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/SUMMARY.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+}
+
+/**
+ Generate the ebook file as "index.pdf"
+
+ @param {Output}
+ @return {Output}
+*/
+function runEbookConvert(output) {
+ var logger = output.getLogger();
+ var options = output.getOptions();
+ var format = options.get('format');
+ var outputFolder = output.getRoot();
+
+ if (!format) {
+ return Promise(output);
+ }
+
+ return getConvertOptions(output)
+ .then(function(options) {
+ var cmd = [
+ 'ebook-convert',
+ path.resolve(outputFolder, 'SUMMARY.html'),
+ path.resolve(outputFolder, 'index.' + format),
+ command.optionsToShellArgs(options)
+ ].join(' ');
+
+ return command.exec(cmd)
+ .progress(function(data) {
+ logger.debug(data);
+ })
+ .fail(function(err) {
+ if (err.code == 127) {
+ throw error.RequireInstallError({
+ cmd: 'ebook-convert',
+ install: 'Install it from Calibre: https://calibre-ebook.com'
+ });
+ }
+
+ throw error.EbookError(err);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Finish the generation, generates the SUMMARY.html
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ return writeSummary(output)
+ .then(runEbookConvert);
+}
+
+module.exports = onFinish;
diff --git a/lib/output/ebook/onPage.js b/lib/output/ebook/onPage.js
new file mode 100644
index 0000000..21fd34c
--- /dev/null
+++ b/lib/output/ebook/onPage.js
@@ -0,0 +1,24 @@
+var WebsiteGenerator = require('../website');
+var Modifiers = require('../modifiers');
+
+/**
+ Write a page for ebook output
+
+ @param {Output} output
+ @param {Output}
+*/
+function onPage(output, page) {
+ var options = output.getOptions();
+
+ // Inline assets
+ return Modifiers.modifyHTML(page, [
+ Modifiers.inlineAssets(options.get('root'))
+ ])
+
+ // Write page using website generator
+ .then(function(resultPage) {
+ return WebsiteGenerator.onPage(output, resultPage);
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/ebook/options.js b/lib/output/ebook/options.js
new file mode 100644
index 0000000..ea7b8b4
--- /dev/null
+++ b/lib/output/ebook/options.js
@@ -0,0 +1,17 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('ebook'),
+
+ // Format to generate using ebook-convert
+ format: String(),
+
+ // Force use of absolute urls ("index.html" instead of "/")
+ directoryIndex: Boolean(false)
+});
+
+module.exports = Options;
diff --git a/lib/output/folder.js b/lib/output/folder.js
deleted file mode 100644
index 8303ed2..0000000
--- a/lib/output/folder.js
+++ /dev/null
@@ -1,152 +0,0 @@
-var _ = require('lodash');
-var util = require('util');
-var path = require('path');
-
-var Output = require('./base');
-var fs = require('../utils/fs');
-var pathUtil = require('../utils/path');
-var Promise = require('../utils/promise');
-
-/*
-This output requires the native fs module to output
-book as a directory (mapping assets and pages)
-*/
-
-module.exports = function folderOutput(Base) {
- Base = Base || Output;
-
- function FolderOutput() {
- Base.apply(this, arguments);
-
- this.opts.root = path.resolve(this.opts.root || this.book.resolve('_book'));
- }
- util.inherits(FolderOutput, Base);
-
- // Copy an asset file (non-parsable), ex: images, etc
- FolderOutput.prototype.onAsset = function(filename) {
- return this.copyFile(
- this.book.resolve(filename),
- filename
- );
- };
-
- // Prepare the generation by creating the output folder
- FolderOutput.prototype.prepare = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return FolderOutput.super_.prototype.prepare.apply(that);
- })
-
- // Cleanup output folder
- .then(function() {
- that.log.debug.ln('removing previous output directory');
- return fs.rmDir(that.root())
- .fail(function() {
- return Promise();
- });
- })
-
- // Create output folder
- .then(function() {
- that.log.debug.ln('creating output directory');
- return fs.mkdirp(that.root());
- })
-
- // Add output folder to ignored files
- .then(function() {
- that.ignore.addPattern([
- path.relative(that.book.root, that.root())
- ]);
- });
- };
-
- // Prepare output for a language book
- FolderOutput.prototype.onLanguageBook = function(book) {
- return new this.constructor(book, _.extend({}, this.opts, {
-
- // Language output should be output in sub-directory of output
- root: path.resolve(this.root(), book.language)
- }), this);
- };
-
- // ----- Utility methods -----
-
- // Return path to the root folder
- FolderOutput.prototype.root = function() {
- return this.opts.root;
- };
-
- // Resolve a file in the output directory
- FolderOutput.prototype.resolve = function(filename) {
- return pathUtil.resolveInRoot.apply(null, [this.root()].concat(_.toArray(arguments)));
- };
-
- // Copy a file to the output
- FolderOutput.prototype.copyFile = function(from, to) {
- var that = this;
-
- return Promise()
- .then(function() {
- to = that.resolve(to);
- var folder = path.dirname(to);
-
- // Ensure folder exists
- return fs.mkdirp(folder);
- })
- .then(function() {
- return fs.copy(from, to);
- });
- };
-
- // Write a file/buffer to the output folder
- FolderOutput.prototype.writeFile = function(filename, buf) {
- var that = this;
-
- return Promise()
- .then(function() {
- filename = that.resolve(filename);
- var folder = path.dirname(filename);
-
- // Ensure folder exists
- return fs.mkdirp(folder);
- })
-
- // Write the file
- .then(function() {
- return fs.writeFile(filename, buf);
- });
- };
-
- // Return true if a file exists in the output folder
- FolderOutput.prototype.hasFile = function(filename) {
- var that = this;
-
- return Promise()
- .then(function() {
- return fs.exists(that.resolve(filename));
- });
- };
-
- // Create a new unique file
- // Returns its filename
- FolderOutput.prototype.createNewFile = function(base, filename) {
- var that = this;
-
- if (!filename) {
- filename = path.basename(filename);
- base = path.dirname(base);
- }
-
- return fs.uniqueFilename(this.resolve(base), filename)
- .then(function(out) {
- out = path.join(base, out);
-
- return fs.ensure(that.resolve(out))
- .thenResolve(out);
- });
- };
-
- return FolderOutput;
-};
diff --git a/lib/output/generateAssets.js b/lib/output/generateAssets.js
new file mode 100644
index 0000000..7a6e104
--- /dev/null
+++ b/lib/output/generateAssets.js
@@ -0,0 +1,26 @@
+var Promise = require('../utils/promise');
+
+/**
+ Output all assets using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generateAssets(generator, output) {
+ var assets = output.getAssets();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onAsset) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(assets, function(out, assetFile) {
+ logger.debug.ln('copy asset "' + assetFile + '"');
+
+ return generator.onAsset(out, assetFile);
+ }, output);
+}
+
+module.exports = generateAssets;
diff --git a/lib/output/generateBook.js b/lib/output/generateBook.js
new file mode 100644
index 0000000..6fcade0
--- /dev/null
+++ b/lib/output/generateBook.js
@@ -0,0 +1,181 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Output = require('../models/output');
+var Config = require('../models/config');
+var Promise = require('../utils/promise');
+
+var callHook = require('./callHook');
+var preparePlugins = require('./preparePlugins');
+var preparePages = require('./preparePages');
+var prepareAssets = require('./prepareAssets');
+var generateAssets = require('./generateAssets');
+var generatePages = require('./generatePages');
+
+/**
+ Process an output to generate the book
+
+ @param {Generator} generator
+ @param {Output} output
+
+ @return {Promise<Output>}
+*/
+function processOutput(generator, startOutput) {
+ return Promise(startOutput)
+ .then(preparePlugins)
+ .then(preparePages)
+ .then(prepareAssets)
+
+ .then(
+ callHook.bind(null,
+ 'config',
+ function(output) {
+ var book = output.getBook();
+ var config = book.getConfig();
+ var values = config.getValues();
+
+ return values.toJS();
+ },
+ function(output, result) {
+ var book = output.getBook();
+ var config = book.getConfig();
+
+ config = Config.updateValues(config, result);
+ book = book.set('config', config);
+ return output.set('book', book);
+ }
+ )
+ )
+
+ .then(
+ callHook.bind(null,
+ 'init',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onInit) {
+ return output;
+ }
+
+ return generator.onInit(output);
+ })
+
+ .then(generateAssets.bind(null, generator))
+ .then(generatePages.bind(null, generator))
+
+ .tap(function(output) {
+ var book = output.getBook();
+
+ if (!book.isMultilingual()) {
+ return;
+ }
+
+ var books = book.getBooks();
+ var outputRoot = output.getRoot();
+ var plugins = output.getPlugins();
+ var state = output.getState();
+ var options = output.getOptions();
+
+ return Promise.forEach(books, function(langBook) {
+ // Inherits plugins list, options and state
+ var langOptions = options.set('root', path.join(outputRoot, langBook.getLanguage()));
+ var langOutput = new Output({
+ book: langBook,
+ options: langOptions,
+ state: state,
+ generator: generator.name,
+ plugins: plugins
+ });
+
+ return processOutput(generator, langOutput);
+ });
+ })
+
+ .then(callHook.bind(null,
+ 'finish:before',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ )
+
+ .then(function(output) {
+ if (!generator.onFinish) {
+ return output;
+ }
+
+ return generator.onFinish(output);
+ })
+
+ .then(callHook.bind(null,
+ 'finish',
+ function(output) {
+ return {};
+ },
+ function(output) {
+ return output;
+ }
+ )
+ );
+}
+
+/**
+ Generate a book using a generator.
+
+ The overall process is:
+ 1. List and load plugins for this book
+ 2. Call hook "config"
+ 3. Call hook "init"
+ 4. Initialize generator
+ 5. List all assets and pages
+ 6. Copy all assets to output
+ 7. Generate all pages
+ 8. Call hook "finish:before"
+ 9. Finish generation
+ 10. Call hook "finish"
+
+
+ @param {Generator} generator
+ @param {Book} book
+ @param {Object} options
+
+ @return {Promise<Output>}
+*/
+function generateBook(generator, book, options) {
+ options = generator.Options(options);
+ var state = generator.State? generator.State({}) : Immutable.Map();
+ var start = Date.now();
+
+ return Promise(
+ new Output({
+ book: book,
+ options: options,
+ state: state,
+ generator: generator.name
+ })
+ )
+ .then(processOutput.bind(null, generator))
+
+ // Log duration and end message
+ .then(function(output) {
+ var logger = output.getLogger();
+ var end = Date.now();
+ var duration = (end - start)/1000;
+
+ logger.info.ok('generation finished with success in ' + duration.toFixed(1) + 's !');
+
+ return output;
+ });
+}
+
+module.exports = generateBook;
diff --git a/lib/output/generateMock.js b/lib/output/generateMock.js
new file mode 100644
index 0000000..47d29dc
--- /dev/null
+++ b/lib/output/generateMock.js
@@ -0,0 +1,35 @@
+var tmp = require('tmp');
+
+var Book = require('../models/book');
+var createMockFS = require('../fs/mock');
+var parseBook = require('../parse/parseBook');
+var generateBook = require('./generateBook');
+
+
+/**
+ Generate a book using JSON generator
+ And returns the path to the output dir.
+
+ FOR TESTING PURPOSE ONLY
+
+ @param {Generator}
+ @param {Map<String:String|Map>} files
+ @return {Promise<String>}
+*/
+function generateMock(Generator, files) {
+ var fs = createMockFS(files);
+ var book = Book.createForFS(fs);
+ var dir = tmp.dirSync();
+
+ book = book.setLogLevel('disabled');
+
+ return parseBook(book)
+ .then(function(resultBook) {
+ return generateBook(Generator, resultBook, {
+ root: dir.name
+ });
+ })
+ .thenResolve(dir.name);
+}
+
+module.exports = generateMock;
diff --git a/lib/output/generatePage.js b/lib/output/generatePage.js
new file mode 100644
index 0000000..a93d4b0
--- /dev/null
+++ b/lib/output/generatePage.js
@@ -0,0 +1,71 @@
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var timing = require('../utils/timing');
+
+var Parse = require('../parse');
+var Templating = require('../templating');
+var JSONUtils = require('../json');
+var createTemplateEngine = require('./createTemplateEngine');
+var callPageHook = require('./callPageHook');
+
+/**
+ Prepare and generate HTML for a page
+
+ @param {Output} output
+ @param {Page} page
+ @return {Promise<Page>}
+*/
+function generatePage(output, page) {
+ var book = output.getBook();
+ var engine = createTemplateEngine(output);
+
+ return timing.measure(
+ 'page.generate',
+ Parse.parsePage(book, page)
+ .then(function(resultPage) {
+ var file = resultPage.getFile();
+ var filePath = file.getPath();
+ var parser = file.getParser();
+ var context = JSONUtils.encodeBookWithPage(book, resultPage);
+
+ if (!parser) {
+ return Promise.reject(error.FileNotParsableError({
+ filename: filePath
+ }));
+ }
+
+ // Call hook "page:before"
+ return callPageHook('page:before', output, resultPage)
+
+ // Escape code blocks with raw tags
+ .then(function(currentPage) {
+ return parser.page.prepare(currentPage.getContent());
+ })
+
+ // Render templating syntax
+ .then(function(content) {
+ return Templating.render(engine, filePath, content, context);
+ })
+
+ // Render page using parser (markdown -> HTML)
+ .then(parser.page).get('content')
+
+ // Post processing for templating syntax
+ .then(function(content) {
+ return Templating.postRender(engine, content);
+ })
+
+ // Return new page
+ .then(function(content) {
+ return resultPage.set('content', content);
+ })
+
+ // Call final hook
+ .then(function(currentPage) {
+ return callPageHook('page', output, currentPage);
+ });
+ })
+ );
+}
+
+module.exports = generatePage;
diff --git a/lib/output/generatePages.js b/lib/output/generatePages.js
new file mode 100644
index 0000000..73c5c09
--- /dev/null
+++ b/lib/output/generatePages.js
@@ -0,0 +1,36 @@
+var Promise = require('../utils/promise');
+var generatePage = require('./generatePage');
+
+/**
+ Output all pages using a generator
+
+ @param {Generator} generator
+ @param {Output} output
+ @return {Promise<Output>}
+*/
+function generatePages(generator, output) {
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ // Is generator ignoring assets?
+ if (!generator.onPage) {
+ return Promise(output);
+ }
+
+ return Promise.reduce(pages, function(out, page) {
+ var file = page.getFile();
+
+ logger.debug.ln('generate page "' + file.getPath() + '"');
+
+ return generatePage(out, page)
+ .then(function(resultPage) {
+ return generator.onPage(out, resultPage);
+ })
+ .fail(function(err) {
+ logger.error.ln('error while generating page "' + file.getPath() + '":');
+ throw err;
+ });
+ }, output);
+}
+
+module.exports = generatePages;
diff --git a/lib/output/getModifiers.js b/lib/output/getModifiers.js
new file mode 100644
index 0000000..e649df6
--- /dev/null
+++ b/lib/output/getModifiers.js
@@ -0,0 +1,68 @@
+var Modifiers = require('./modifiers');
+var resolveFileToURL = require('./helper/resolveFileToURL');
+var Api = require('../api');
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+var defaultBlocks = require('../constants/defaultBlocks');
+
+var CODEBLOCK = 'code';
+
+/**
+ Return default modifier to prepare a page for
+ rendering.
+
+ @return {Array<Modifier>}
+*/
+function getModifiers(output, page) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+ var glossary = book.getGlossary();
+ var entries = glossary.getEntries();
+ var file = page.getFile();
+
+ // Current file path
+ var currentFilePath = file.getPath();
+
+ // Get TemplateBlock for highlighting
+ var blocks = Plugins.listBlocks(plugins);
+ var code = blocks.get(CODEBLOCK) || defaultBlocks.get(CODEBLOCK);
+
+ // Current context
+ var context = Api.encodeGlobal(output);
+
+ return [
+ // Normalize IDs on headings
+ Modifiers.addHeadingId,
+
+ // Resolve links (.md -> .html)
+ Modifiers.resolveLinks.bind(null,
+ currentFilePath,
+ resolveFileToURL.bind(null, output)
+ ),
+
+ // Resolve images
+ Modifiers.resolveImages.bind(null, currentFilePath),
+
+ // Annotate text with glossary entries
+ Modifiers.annotateText.bind(null, entries),
+
+ // Highlight code blocks using "code" block
+ Modifiers.highlightCode.bind(null, function(lang, source) {
+ return Promise(code.applyBlock({
+ body: source,
+ kwargs: {
+ language: lang
+ }
+ }, context))
+ .then(function(result) {
+ if (result.html === false) {
+ return { text: result.body };
+ } else {
+ return { html: result.body };
+ }
+ });
+ })
+ ];
+}
+
+module.exports = getModifiers;
diff --git a/lib/output/helper/fileToOutput.js b/lib/output/helper/fileToOutput.js
new file mode 100644
index 0000000..9673162
--- /dev/null
+++ b/lib/output/helper/fileToOutput.js
@@ -0,0 +1,32 @@
+var path = require('path');
+
+var PathUtils = require('../../utils/path');
+var LocationUtils = require('../../utils/location');
+
+var OUTPUT_EXTENSION = '.html';
+
+/**
+ Convert a filePath (absolute) to a filename for output
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function fileToOutput(output, filePath) {
+ var book = output.getBook();
+ var readme = book.getReadme();
+ var fileReadme = readme.getFile();
+
+ if (
+ path.basename(filePath, path.extname(filePath)) == 'README' ||
+ (fileReadme.exists() && filePath == fileReadme.getPath())
+ ) {
+ filePath = path.join(path.dirname(filePath), 'index' + OUTPUT_EXTENSION);
+ } else {
+ filePath = PathUtils.setExtension(filePath, OUTPUT_EXTENSION);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToOutput;
diff --git a/lib/output/helper/fileToURL.js b/lib/output/helper/fileToURL.js
new file mode 100644
index 0000000..44ad2d8
--- /dev/null
+++ b/lib/output/helper/fileToURL.js
@@ -0,0 +1,31 @@
+var path = require('path');
+var LocationUtils = require('../../utils/location');
+
+var fileToOutput = require('./fileToOutput');
+
+/**
+ Convert a filePath (absolute) to an url (without hostname).
+ It returns an absolute path.
+
+ "README.md" -> "/"
+ "test/hello.md" -> "test/hello.html"
+ "test/README.md" -> "test/"
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function fileToURL(output, filePath) {
+ var options = output.getOptions();
+ var directoryIndex = options.get('directoryIndex');
+
+ filePath = fileToOutput(output, filePath);
+
+ if (directoryIndex && path.basename(filePath) == 'index.html') {
+ filePath = path.dirname(filePath) + '/';
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = fileToURL;
diff --git a/lib/output/helper/index.js b/lib/output/helper/index.js
new file mode 100644
index 0000000..f8bc109
--- /dev/null
+++ b/lib/output/helper/index.js
@@ -0,0 +1,2 @@
+
+module.exports = {};
diff --git a/lib/output/helper/resolveFileToUrl.js b/lib/output/helper/resolveFileToUrl.js
new file mode 100644
index 0000000..3dba8f7
--- /dev/null
+++ b/lib/output/helper/resolveFileToUrl.js
@@ -0,0 +1,27 @@
+var LocationUtils = require('../../utils/location');
+
+var fileToURL = require('./fileToURL');
+
+/**
+ Resolve an absolute path (extracted from a link)
+
+ @param {Output} output
+ @param {String} filePath
+ @return {String}
+*/
+function resolveFileToURL(output, filePath) {
+ // Convert /test.png -> test.png
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+
+ var pages = output.getPages();
+ var page = pages.get(filePath);
+
+ // if file is a page, return correct .html url
+ if (page) {
+ filePath = fileToURL(output, filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+}
+
+module.exports = resolveFileToURL;
diff --git a/lib/output/helper/writeFile.js b/lib/output/helper/writeFile.js
new file mode 100644
index 0000000..a6d4645
--- /dev/null
+++ b/lib/output/helper/writeFile.js
@@ -0,0 +1,23 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Write a file to the output folder
+
+ @param {Output} output
+ @param {String} filePath
+ @param {Buffer|String} content
+ @return {Promise}
+*/
+function writeFile(output, filePath, content) {
+ var rootFolder = output.getRoot();
+ filePath = path.join(rootFolder, filePath);
+
+ return fs.ensureFile(filePath)
+ .then(function() {
+ return fs.writeFile(filePath, content);
+ })
+ .thenResolve(output);
+}
+
+module.exports = writeFile;
diff --git a/lib/output/index.js b/lib/output/index.js
new file mode 100644
index 0000000..9b8ec17
--- /dev/null
+++ b/lib/output/index.js
@@ -0,0 +1,24 @@
+var Immutable = require('immutable');
+
+var generators = Immutable.List([
+ require('./json'),
+ require('./website'),
+ require('./ebook')
+]);
+
+/**
+ Return a specific generator by its name
+
+ @param {String}
+ @return {Generator}
+*/
+function getGenerator(name) {
+ return generators.find(function(generator) {
+ return generator.name == name;
+ });
+}
+
+module.exports = {
+ generate: require('./generateBook'),
+ getGenerator: getGenerator
+};
diff --git a/lib/output/json.js b/lib/output/json.js
deleted file mode 100644
index 7061141..0000000
--- a/lib/output/json.js
+++ /dev/null
@@ -1,47 +0,0 @@
-var conrefsLoader = require('./conrefs');
-
-var JSONOutput = conrefsLoader();
-
-JSONOutput.prototype.name = 'json';
-
-// Don't copy asset on JSON output
-JSONOutput.prototype.onAsset = function(filename) {};
-
-// Write a page (parsable file)
-JSONOutput.prototype.onPage = function(page) {
- var that = this;
-
- // Parse the page
- return page.toHTML(this)
-
- // Write as json
- .then(function() {
- var json = page.getOutputContext(that);
-
- // Delete some private properties
- delete json.config;
-
- // Specify JSON output version
- json.version = '3';
-
- return that.writeFile(
- page.withExtension('.json'),
- JSON.stringify(json, null, 4)
- );
- });
-};
-
-// At the end of generation, generate README.json for multilingual books
-JSONOutput.prototype.finish = function() {
- if (!this.book.isMultilingual()) return;
-
- // Copy README.json from main book
- var mainLanguage = this.book.langs.getDefault().id;
- return this.copyFile(
- this.resolve(mainLanguage, 'README.json'),
- 'README.json'
- );
-};
-
-
-module.exports = JSONOutput;
diff --git a/lib/output/json/index.js b/lib/output/json/index.js
new file mode 100644
index 0000000..e24c127
--- /dev/null
+++ b/lib/output/json/index.js
@@ -0,0 +1,6 @@
+
+module.exports = {
+ name: 'json',
+ Options: require('./options'),
+ onPage: require('./onPage')
+};
diff --git a/lib/output/json/onFinish.js b/lib/output/json/onFinish.js
new file mode 100644
index 0000000..ff336a2
--- /dev/null
+++ b/lib/output/json/onFinish.js
@@ -0,0 +1,32 @@
+var path = require('path');
+
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+
+/**
+ Finish the generation
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var outputRoot = output.getRoot();
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ // Get main language
+ var languages = book.getLanguages();
+ var mainLanguage = languages.getDefaultLanguage();
+
+ // Copy README.json from it
+ return fs.copy(
+ path.resolve(outputRoot, mainLanguage.getID(), 'README.json'),
+ path.resolve(outputRoot, 'README.json')
+ )
+ .thenResolve(output);
+}
+
+module.exports = onFinish;
diff --git a/lib/output/json/onPage.js b/lib/output/json/onPage.js
new file mode 100644
index 0000000..fece540
--- /dev/null
+++ b/lib/output/json/onPage.js
@@ -0,0 +1,43 @@
+var JSONUtils = require('../../json');
+var PathUtils = require('../../utils/path');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+
+var JSON_VERSION = '3';
+
+/**
+ Write a page as a json file
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onPage(output, page) {
+ var file = page.getFile();
+ var readme = output.getBook().getReadme().getFile();
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the JSON
+ var json = JSONUtils.encodeBookWithPage(output.getBook(), resultPage);
+
+ // Delete some private properties
+ delete json.config;
+
+ // Specify JSON output version
+ json.version = JSON_VERSION;
+
+ // File path in the output folder
+ var filePath = file.getPath() == readme.getPath()? 'README.json' : file.getPath();
+ filePath = PathUtils.setExtension(filePath, '.json');
+
+ // Write it to the disk
+ return writeFile(
+ output,
+ filePath,
+ JSON.stringify(json, null, 4)
+ );
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/json/options.js b/lib/output/json/options.js
new file mode 100644
index 0000000..79167b1
--- /dev/null
+++ b/lib/output/json/options.js
@@ -0,0 +1,8 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String()
+});
+
+module.exports = Options;
diff --git a/lib/output/modifiers/__tests__/addHeadingId.js b/lib/output/modifiers/__tests__/addHeadingId.js
new file mode 100644
index 0000000..7277440
--- /dev/null
+++ b/lib/output/modifiers/__tests__/addHeadingId.js
@@ -0,0 +1,29 @@
+jest.autoMockOff();
+
+var cheerio = require('cheerio');
+
+describe('addHeadingId', function() {
+ var addHeadingId = require('../addHeadingId');
+
+ pit('should add an ID if none', function() {
+ var $ = cheerio.load('<h1>Hello World</h1><h2>Cool !!</h2>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="hello-world">Hello World</h1><h2 id="cool-">Cool !!</h2>');
+ });
+ });
+
+ pit('should not change existing IDs', function() {
+ var $ = cheerio.load('<h1 id="awesome">Hello World</h1>');
+
+ return addHeadingId($)
+ .then(function() {
+ var html = $.html();
+ expect(html).toBe('<h1 id="awesome">Hello World</h1>');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/annotateText.js b/lib/output/modifiers/__tests__/annotateText.js
new file mode 100644
index 0000000..15d4c30
--- /dev/null
+++ b/lib/output/modifiers/__tests__/annotateText.js
@@ -0,0 +1,49 @@
+jest.autoMockOff();
+
+var Immutable = require('immutable');
+var cheerio = require('cheerio');
+var GlossaryEntry = require('../../../models/glossaryEntry');
+
+describe('annotateText', function() {
+ var annotateText = require('../annotateText');
+
+ var entries = Immutable.List([
+ GlossaryEntry({ name: 'Word' }),
+ GlossaryEntry({ name: 'Multiple Words' })
+ ]);
+
+ it('should annotate text', function() {
+ var $ = cheerio.load('<p>This is a word, and multiple words</p>');
+
+ annotateText(entries, $);
+
+ var links = $('a');
+ expect(links.length).toBe(2);
+
+ var word = $(links.get(0));
+ expect(word.attr('href')).toBe('/GLOSSARY.md#word');
+ expect(word.text()).toBe('word');
+ expect(word.hasClass('glossary-term')).toBeTruthy();
+
+ var words = $(links.get(1));
+ expect(words.attr('href')).toBe('/GLOSSARY.md#multiple-words');
+ expect(words.text()).toBe('multiple words');
+ expect(words.hasClass('glossary-term')).toBeTruthy();
+ });
+
+ it('should not annotate scripts', function() {
+ var $ = cheerio.load('<script>This is a word, and multiple words</script>');
+
+ annotateText(entries, $);
+ expect($('a').length).toBe(0);
+ });
+
+ it('should not annotate when has class "no-glossary"', function() {
+ var $ = cheerio.load('<p class="no-glossary">This is a word, and multiple words</p>');
+
+ annotateText(entries, $);
+ expect($('a').length).toBe(0);
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/fetchRemoteImages.js b/lib/output/modifiers/__tests__/fetchRemoteImages.js
new file mode 100644
index 0000000..f5610a2
--- /dev/null
+++ b/lib/output/modifiers/__tests__/fetchRemoteImages.js
@@ -0,0 +1,40 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+var URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png';
+
+describe('fetchRemoteImages', function() {
+ var dir;
+ var fetchRemoteImages = require('../fetchRemoteImages');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should download image file', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+
+ pit('should download image file and replace with relative path', function() {
+ var $ = cheerio.load('<img src="' + URL + '" />');
+
+ return fetchRemoteImages(dir.name, 'test/index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(path.join('test', src));
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/highlightCode.js b/lib/output/modifiers/__tests__/highlightCode.js
new file mode 100644
index 0000000..bd7d422
--- /dev/null
+++ b/lib/output/modifiers/__tests__/highlightCode.js
@@ -0,0 +1,63 @@
+jest.autoMockOff();
+
+var cheerio = require('cheerio');
+var Promise = require('../../../utils/promise');
+
+describe('highlightCode', function() {
+ var highlightCode = require('../highlightCode');
+
+ function doHighlight(lang, code) {
+ return {
+ text: '' + (lang || '') + '$' + code
+ };
+ }
+
+ function doHighlightAsync(lang, code) {
+ return Promise()
+ .then(function() {
+ return doHighlight(lang, code);
+ });
+ }
+
+ pit('should call it for normal code element', function() {
+ var $ = cheerio.load('<p>This is a <code>test</code></p>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('$test');
+ });
+ });
+
+ pit('should call it for markdown code block', function() {
+ var $ = cheerio.load('<pre><code class="lang-js">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('js$test');
+ });
+ });
+
+ pit('should call it for asciidoc code block', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlight, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+
+ pit('should accept async highlighter', function() {
+ var $ = cheerio.load('<pre><code class="language-python">test</code></pre>');
+
+ return highlightCode(doHighlightAsync, $)
+ .then(function() {
+ var $code = $('code');
+ expect($code.text()).toBe('python$test');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/resolveLinks.js b/lib/output/modifiers/__tests__/resolveLinks.js
new file mode 100644
index 0000000..3d50d80
--- /dev/null
+++ b/lib/output/modifiers/__tests__/resolveLinks.js
@@ -0,0 +1,71 @@
+jest.autoMockOff();
+
+var path = require('path');
+var cheerio = require('cheerio');
+
+describe('resolveLinks', function() {
+ var resolveLinks = require('../resolveLinks');
+
+ function resolveFileBasic(href) {
+ return href;
+ }
+
+ function resolveFileCustom(href) {
+ if (path.extname(href) == '.md') {
+ return href.slice(0, -3) + '.html';
+ }
+
+ return href;
+ }
+
+ describe('Absolute path', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a></p>';
+
+ pit('should resolve path starting by "/" in root directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('test/cool.md');
+ });
+ });
+
+ pit('should resolve path starting by "/" in child directory', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afolder/hello.md', resolveFileBasic, $)
+ .then(function() {
+ var link = $('a');
+ expect(link.attr('href')).toBe('../test/cool.md');
+ });
+ });
+ });
+
+ describe('Custom Resolver', function() {
+ var TEST = '<p>This is a <a href="/test/cool.md"></a> <a href="afile.png"></a></p>';
+
+ pit('should resolve path correctly for absolute path', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('test/cool.html');
+ });
+ });
+
+ pit('should resolve path correctly for absolute path (2)', function() {
+ var $ = cheerio.load(TEST);
+
+ return resolveLinks('afodler/hello.md', resolveFileCustom, $)
+ .then(function() {
+ var link = $('a').first();
+ expect(link.attr('href')).toBe('../test/cool.html');
+ });
+ });
+ });
+
+});
+
+
diff --git a/lib/output/modifiers/__tests__/svgToImg.js b/lib/output/modifiers/__tests__/svgToImg.js
new file mode 100644
index 0000000..793395e
--- /dev/null
+++ b/lib/output/modifiers/__tests__/svgToImg.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+
+describe('svgToImg', function() {
+ var dir;
+ var svgToImg = require('../svgToImg');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should write svg as a file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+
+ return svgToImg(dir.name, 'index.html', $)
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/__tests__/svgToPng.js b/lib/output/modifiers/__tests__/svgToPng.js
new file mode 100644
index 0000000..163d72e
--- /dev/null
+++ b/lib/output/modifiers/__tests__/svgToPng.js
@@ -0,0 +1,32 @@
+var cheerio = require('cheerio');
+var tmp = require('tmp');
+var path = require('path');
+
+describe('svgToPng', function() {
+ var dir;
+ var svgToImg = require('../svgToImg');
+ var svgToPng = require('../svgToPng');
+
+ beforeEach(function() {
+ dir = tmp.dirSync();
+ });
+
+ pit('should write svg as png file', function() {
+ var $ = cheerio.load('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" version="1.1"><rect width="200" height="100" stroke="black" stroke-width="6" fill="green"/></svg>');
+ var fileName = 'index.html';
+
+ return svgToImg(dir.name, fileName, $)
+ .then(function() {
+ return svgToPng(dir.name, fileName, $);
+ })
+ .then(function() {
+ var $img = $('img');
+ var src = $img.attr('src');
+
+ expect(dir.name).toHaveFile(src);
+ expect(path.extname(src)).toBe('.png');
+ });
+ });
+});
+
+
diff --git a/lib/output/modifiers/addHeadingId.js b/lib/output/modifiers/addHeadingId.js
new file mode 100644
index 0000000..e2e2720
--- /dev/null
+++ b/lib/output/modifiers/addHeadingId.js
@@ -0,0 +1,23 @@
+var slug = require('github-slugid');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Add ID to an heading
+
+ @param {HTMLElement} heading
+*/
+function addId(heading) {
+ if (heading.attr('id')) return;
+ heading.attr('id', slug(heading.text()));
+}
+
+/**
+ Add ID to all headings
+
+ @param {HTMLDom} $
+*/
+function addHeadingId($) {
+ return editHTMLElement($, 'h1,h2,h3,h4,h5,h6', addId);
+}
+
+module.exports = addHeadingId;
diff --git a/lib/output/modifiers/annotateText.js b/lib/output/modifiers/annotateText.js
new file mode 100644
index 0000000..d8443cf
--- /dev/null
+++ b/lib/output/modifiers/annotateText.js
@@ -0,0 +1,94 @@
+var escape = require('escape-html');
+
+// Selector to ignore
+var ANNOTATION_IGNORE = '.no-glossary,code,pre,a,script,h1,h2,h3,h4,h5,h6';
+
+function pregQuote( str ) {
+ return (str+'').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
+}
+
+function replaceText($, el, search, replace, text_only ) {
+ return $(el).each(function(){
+ var node = this.firstChild,
+ val,
+ new_val,
+
+ // Elements to be removed at the end.
+ remove = [];
+
+ // Only continue if firstChild exists.
+ if ( node ) {
+
+ // Loop over all childNodes.
+ while (node) {
+
+ // Only process text nodes.
+ if ( node.nodeType === 3 ) {
+
+ // The original node value.
+ val = node.nodeValue;
+
+ // The new value.
+ new_val = val.replace( search, replace );
+
+ // Only replace text if the new value is actually different!
+ if ( new_val !== val ) {
+
+ if ( !text_only && /</.test( new_val ) ) {
+ // The new value contains HTML, set it in a slower but far more
+ // robust way.
+ $(node).before( new_val );
+
+ // Don't remove the node yet, or the loop will lose its place.
+ remove.push( node );
+ } else {
+ // The new value contains no HTML, so it can be set in this
+ // very fast, simple way.
+ node.nodeValue = new_val;
+ }
+ }
+ }
+
+ node = node.nextSibling;
+ }
+ }
+
+ // Time to remove those elements!
+ if (remove.length) $(remove).remove();
+ });
+}
+
+/**
+ Annotate text using a list of GlossaryEntry
+
+ @param {List<GlossaryEntry>}
+ @param {HTMLDom} $
+*/
+function annotateText(entries, $) {
+ entries.forEach(function(entry) {
+ var entryId = entry.getID();
+ var name = entry.getName();
+ var description = entry.getDescription();
+
+ var searchRegex = new RegExp( '\\b(' + pregQuote(name.toLowerCase()) + ')\\b' , 'gi' );
+
+ $('*').each(function() {
+ var $this = $(this);
+
+ if (
+ $this.is(ANNOTATION_IGNORE) ||
+ $this.parents(ANNOTATION_IGNORE).length > 0
+ ) return;
+
+ replaceText($, this, searchRegex, function(match) {
+ return '<a href="/GLOSSARY.md#' + entryId + '" '
+ + 'class="glossary-term" title="' + escape(description) + '">'
+ + match
+ + '</a>';
+ });
+ });
+
+ });
+}
+
+module.exports = annotateText;
diff --git a/lib/output/modifiers/editHTMLElement.js b/lib/output/modifiers/editHTMLElement.js
new file mode 100644
index 0000000..755598e
--- /dev/null
+++ b/lib/output/modifiers/editHTMLElement.js
@@ -0,0 +1,15 @@
+var Promise = require('../../utils/promise');
+
+/**
+ Edit all elements matching a selector
+*/
+function editHTMLElement($, selector, fn) {
+ var $elements = $(selector);
+
+ return Promise.forEach($elements, function(el) {
+ var $el = $(el);
+ return fn($el);
+ });
+}
+
+module.exports = editHTMLElement;
diff --git a/lib/output/modifiers/fetchRemoteImages.js b/lib/output/modifiers/fetchRemoteImages.js
new file mode 100644
index 0000000..ef868b9
--- /dev/null
+++ b/lib/output/modifiers/fetchRemoteImages.js
@@ -0,0 +1,44 @@
+var path = require('path');
+var crc = require('crc');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Fetch all remote images
+
+ @param {String} rootFolder
+ @param {String} currentFile
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function fetchRemoteImages(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ var extension = path.extname(src);
+
+ if (!LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + extension;
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return fs.download(src, filePath);
+ })
+ .then(function() {
+ // Convert to relative
+ src = LocationUtils.relative(currentDirectory, fileName);
+
+ $img.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = fetchRemoteImages;
diff --git a/lib/output/modifiers/highlightCode.js b/lib/output/modifiers/highlightCode.js
new file mode 100644
index 0000000..dcd9d24
--- /dev/null
+++ b/lib/output/modifiers/highlightCode.js
@@ -0,0 +1,56 @@
+var is = require('is');
+var Promise = require('../../utils/promise');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Return language for a code blocks from a list of class names
+
+ @param {Array<String>}
+ @return {String}
+*/
+function getLanguageForClass(classNames) {
+ return classNames
+ .map(function(cl) {
+ // Markdown
+ if (cl.search('lang-') === 0) {
+ return cl.slice('lang-'.length);
+ }
+
+ // Asciidoc
+ if (cl.search('language-') === 0) {
+ return cl.slice('language-'.length);
+ }
+
+ return null;
+ })
+ .find(function(cl) {
+ return Boolean(cl);
+ });
+}
+
+
+/**
+ Highlight all code elements
+
+ @param {Function(lang, body) -> String} highlight
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function highlightCode(highlight, $) {
+ return editHTMLElement($, 'code', function($code) {
+ var classNames = ($code.attr('class') || '').split(' ');
+ var lang = getLanguageForClass(classNames);
+ var source = $code.text();
+
+ return Promise(highlight(lang, source))
+ .then(function(r) {
+ if (is.string(r.html)) {
+ $code.html(r.html);
+ } else {
+ $code.text(r.text);
+ }
+ });
+ });
+}
+
+module.exports = highlightCode;
diff --git a/lib/output/modifiers/index.js b/lib/output/modifiers/index.js
new file mode 100644
index 0000000..f1daa2b
--- /dev/null
+++ b/lib/output/modifiers/index.js
@@ -0,0 +1,15 @@
+
+module.exports = {
+ modifyHTML: require('./modifyHTML'),
+ inlineAssets: require('./inlineAssets'),
+
+ // HTML transformations
+ addHeadingId: require('./addHeadingId'),
+ svgToImg: require('./svgToImg'),
+ fetchRemoteImages: require('./fetchRemoteImages'),
+ svgToPng: require('./svgToPng'),
+ resolveLinks: require('./resolveLinks'),
+ resolveImages: require('./resolveImages'),
+ annotateText: require('./annotateText'),
+ highlightCode: require('./highlightCode')
+};
diff --git a/lib/output/modifiers/inlineAssets.js b/lib/output/modifiers/inlineAssets.js
new file mode 100644
index 0000000..9f19fd7
--- /dev/null
+++ b/lib/output/modifiers/inlineAssets.js
@@ -0,0 +1,27 @@
+var svgToImg = require('./svgToImg');
+var svgToPng = require('./svgToPng');
+var resolveImages = require('./resolveImages');
+var fetchRemoteImages = require('./fetchRemoteImages');
+
+var Promise = require('../../utils/promise');
+
+/**
+ Inline all assets in a page
+
+ @param {String} rootFolder
+*/
+function inlineAssets(rootFolder, currentFile) {
+ return function($) {
+ return Promise()
+
+ // Resolving images and fetching external images should be
+ // done before svg conversion
+ .then(resolveImages.bind(null, currentFile))
+ .then(fetchRemoteImages.bind(null, rootFolder, currentFile))
+
+ .then(svgToImg.bind(null, rootFolder, currentFile))
+ .then(svgToPng.bind(null, rootFolder, currentFile));
+ };
+}
+
+module.exports = inlineAssets;
diff --git a/lib/output/modifiers/modifyHTML.js b/lib/output/modifiers/modifyHTML.js
new file mode 100644
index 0000000..0fcf994
--- /dev/null
+++ b/lib/output/modifiers/modifyHTML.js
@@ -0,0 +1,25 @@
+var cheerio = require('cheerio');
+var Promise = require('../../utils/promise');
+
+/**
+ Apply a list of operations to a page and
+ output the new page.
+
+ @param {Page}
+ @param {List|Array<Transformation>}
+ @return {Promise<Page>}
+*/
+function modifyHTML(page, operations) {
+ var html = page.getContent();
+ var $ = cheerio.load(html);
+
+ return Promise.forEach(operations, function(op) {
+ op($);
+ })
+ .then(function() {
+ var resultHTML = $.html();
+ return page.set('content', resultHTML);
+ });
+}
+
+module.exports = modifyHTML;
diff --git a/lib/output/modifiers/resolveImages.js b/lib/output/modifiers/resolveImages.js
new file mode 100644
index 0000000..e401cf5
--- /dev/null
+++ b/lib/output/modifiers/resolveImages.js
@@ -0,0 +1,33 @@
+var path = require('path');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML images:
+ - /test.png in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {HTMLDom} $
+*/
+function resolveImages(currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+
+ if (LocationUtils.isExternal(src)) {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // Convert back to relative
+ src = LocationUtils.relative(currentDirectory, src);
+
+ $img.attr('src', src);
+ });
+}
+
+module.exports = resolveImages;
diff --git a/lib/output/modifiers/resolveLinks.js b/lib/output/modifiers/resolveLinks.js
new file mode 100644
index 0000000..bf3fd10
--- /dev/null
+++ b/lib/output/modifiers/resolveLinks.js
@@ -0,0 +1,38 @@
+var path = require('path');
+
+var LocationUtils = require('../../utils/location');
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Resolve all HTML links:
+ - /test.md in hello -> ../test.html
+
+ @param {String} currentFile
+ @param {Function(String) -> String} resolveFile
+ @param {HTMLDom} $
+*/
+function resolveLinks(currentFile, resolveFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'a', function($a) {
+ var href = $a.attr('href');
+
+ if (LocationUtils.isExternal(href)) {
+ $a.attr('_target', 'blank');
+ return;
+ }
+
+ // Calcul absolute path for this
+ href = LocationUtils.toAbsolute(href, currentDirectory, '.');
+
+ // Resolve file
+ href = resolveFile(href);
+
+ // Convert back to relative
+ href = LocationUtils.relative(currentDirectory, href);
+
+ $a.attr('href', href);
+ });
+}
+
+module.exports = resolveLinks;
diff --git a/lib/output/modifiers/svgToImg.js b/lib/output/modifiers/svgToImg.js
new file mode 100644
index 0000000..f31b06d
--- /dev/null
+++ b/lib/output/modifiers/svgToImg.js
@@ -0,0 +1,56 @@
+var path = require('path');
+var crc = require('crc');
+var domSerializer = require('dom-serializer');
+
+var editHTMLElement = require('./editHTMLElement');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+/**
+ Render a cheerio DOM as html
+
+ @param {HTMLDom} $
+ @param {HTMLElement} dom
+ @param {Object}
+ @return {String}
+*/
+function renderDOM($, dom, options) {
+ if (!dom && $._root && $._root.children) {
+ dom = $._root.children;
+ }
+ options = options|| dom.options || $._options;
+ return domSerializer(dom, options);
+}
+
+/**
+ Replace SVG tag by IMG
+
+ @param {String} baseFolder
+ @param {HTMLDom} $
+*/
+function svgToImg(baseFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'svg', function($svg) {
+ var content = '<?xml version="1.0" encoding="UTF-8"?>' +
+ renderDOM($, $svg);
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(content).toString(16);
+ var fileName = hash + '.svg';
+ var filePath = path.join(baseFolder, fileName);
+
+ // Write the svg to the file
+ return fs.assertFile(filePath, function() {
+ return fs.writeFile(filePath, content, 'utf8');
+ })
+
+ // Return as image
+ .then(function() {
+ var src = LocationUtils.relative(currentDirectory, fileName);
+ $svg.replaceWith('<img src="' + src + '" />');
+ });
+ });
+}
+
+module.exports = svgToImg;
diff --git a/lib/output/modifiers/svgToPng.js b/lib/output/modifiers/svgToPng.js
new file mode 100644
index 0000000..1093106
--- /dev/null
+++ b/lib/output/modifiers/svgToPng.js
@@ -0,0 +1,53 @@
+var crc = require('crc');
+var path = require('path');
+
+var imagesUtil = require('../../utils/images');
+var fs = require('../../utils/fs');
+var LocationUtils = require('../../utils/location');
+
+var editHTMLElement = require('./editHTMLElement');
+
+/**
+ Convert all SVG images to PNG
+
+ @param {String} rootFolder
+ @param {HTMLDom} $
+ @return {Promise}
+*/
+function svgToPng(rootFolder, currentFile, $) {
+ var currentDirectory = path.dirname(currentFile);
+
+ return editHTMLElement($, 'img', function($img) {
+ var src = $img.attr('src');
+ if (path.extname(src) !== '.svg') {
+ return;
+ }
+
+ // Calcul absolute path for this
+ src = LocationUtils.toAbsolute(src, currentDirectory, '.');
+
+ // We avoid generating twice the same PNG
+ var hash = crc.crc32(src).toString(16);
+ var fileName = hash + '.png';
+
+ // Input file path
+ var inputPath = path.join(rootFolder, src);
+
+ // Result file path
+ var filePath = path.join(rootFolder, fileName);
+
+ return fs.assertFile(filePath, function() {
+ return imagesUtil.convertSVGToPNG(inputPath, filePath);
+ })
+ .then(function() {
+ // Convert filename to a relative filename
+ fileName = LocationUtils.relative(currentDirectory, fileName);
+
+ // Replace src
+ $img.attr('src', fileName);
+ });
+ });
+}
+
+
+module.exports = svgToPng;
diff --git a/lib/output/prepareAssets.js b/lib/output/prepareAssets.js
new file mode 100644
index 0000000..ae9b55a
--- /dev/null
+++ b/lib/output/prepareAssets.js
@@ -0,0 +1,22 @@
+var Parse = require('../parse');
+
+/**
+ List all assets in the book
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareAssets(output) {
+ var book = output.getBook();
+ var pages = output.getPages();
+ var logger = output.getLogger();
+
+ return Parse.listAssets(book, pages)
+ .then(function(assets) {
+ logger.info.ln('found', assets.size, 'asset files');
+
+ return output.set('assets', assets);
+ });
+}
+
+module.exports = prepareAssets;
diff --git a/lib/output/preparePages.js b/lib/output/preparePages.js
new file mode 100644
index 0000000..8ad5f8c
--- /dev/null
+++ b/lib/output/preparePages.js
@@ -0,0 +1,21 @@
+var Parse = require('../parse');
+
+/**
+ List and prepare all pages
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function preparePages(output) {
+ var book = output.getBook();
+ var logger = book.getLogger();
+
+ return Parse.parsePagesList(book)
+ .then(function(pages) {
+ logger.info.ln('found', pages.size, 'pages');
+
+ return output.set('pages', pages);
+ });
+}
+
+module.exports = preparePages;
diff --git a/lib/output/preparePlugins.js b/lib/output/preparePlugins.js
new file mode 100644
index 0000000..54837ed
--- /dev/null
+++ b/lib/output/preparePlugins.js
@@ -0,0 +1,36 @@
+var Plugins = require('../plugins');
+var Promise = require('../utils/promise');
+
+/**
+ Load and setup plugins
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function preparePlugins(output) {
+ var book = output.getBook();
+
+ return Promise()
+
+ // Only load plugins for main book
+ .then(function() {
+ if (book.isLanguageBook()) {
+ return output.getPlugins();
+ } else {
+ return Plugins.loadForBook(book);
+ }
+ })
+
+ // Update book's configuration using the plugins
+ .then(function(plugins) {
+ return Plugins.validateConfig(book, plugins)
+ .then(function(newBook) {
+ return output.merge({
+ book: newBook,
+ plugins: plugins
+ });
+ });
+ });
+}
+
+module.exports = preparePlugins;
diff --git a/lib/output/website/copyPluginAssets.js b/lib/output/website/copyPluginAssets.js
new file mode 100644
index 0000000..9dc876f
--- /dev/null
+++ b/lib/output/website/copyPluginAssets.js
@@ -0,0 +1,115 @@
+var path = require('path');
+
+var ASSET_FOLDER = require('../../constants/pluginAssetsFolder');
+var Promise = require('../../utils/promise');
+var fs = require('../../utils/fs');
+
+/**
+ Copy all assets from plugins.
+ Assets are files stored in "_assets"
+ nd resources declared in the plugin itself.
+
+ @param {Output}
+ @return {Promise}
+*/
+function copyPluginAssets(output) {
+ var book = output.getBook();
+
+ // Don't copy plugins assets for language book
+ // It'll be resolved to the parent folder
+ if (book.isLanguageBook()) {
+ return Promise(output);
+ }
+
+ var plugins = output.getPlugins()
+
+ // We reverse the order of plugins to copy
+ // so that first plugins can replace assets from other plugins.
+ .reverse();
+
+ return Promise.forEach(plugins, function(plugin) {
+ return copyAssets(output, plugin)
+ .then(function() {
+ return copyResources(output, plugin);
+ });
+ })
+ .thenResolve(output);
+}
+
+/**
+ Copy assets from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyAssets(output, plugin) {
+ var logger = output.getLogger();
+ var pluginRoot = plugin.getPath();
+ var options = output.getOptions();
+
+ var outputRoot = options.get('root');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook');
+ var prefix = options.get('prefix');
+
+ var assetFolder = path.join(pluginRoot, ASSET_FOLDER, prefix);
+
+ if (!fs.existsSync(assetFolder)) {
+ return Promise();
+ }
+
+ logger.debug.ln('copy assets from theme', assetFolder);
+ return fs.copyDir(
+ assetFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+/**
+ Copy resources from a plugin
+
+ @param {Plugin}
+ @return {Promise}
+*/
+function copyResources(output, plugin) {
+ var logger = output.getLogger();
+
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+ var outputRoot = options.get('root');
+
+ var pluginRoot = plugin.getPath();
+ var resources = plugin.getResources(prefix);
+
+ var assetsFolder = resources.get('assets');
+ var assetOutputFolder = path.join(outputRoot, 'gitbook', plugin.getNpmID());
+
+ if (!assetsFolder) {
+ return Promise();
+ }
+
+ // Resolve assets folder
+ assetsFolder = path.resolve(pluginRoot, assetsFolder);
+ if (!fs.existsSync(assetsFolder)) {
+ logger.warn.ln('assets folder for plugin "' + plugin.getName() + '" doesn\'t exist');
+ return Promise();
+ }
+
+ logger.debug.ln('copy resources from plugin', assetsFolder);
+
+ return fs.copyDir(
+ assetsFolder,
+ assetOutputFolder,
+ {
+ deleteFirst: false,
+ overwrite: true,
+ confirm: true
+ }
+ );
+}
+
+module.exports = copyPluginAssets;
diff --git a/lib/output/website/createTemplateEngine.js b/lib/output/website/createTemplateEngine.js
new file mode 100644
index 0000000..334ec13
--- /dev/null
+++ b/lib/output/website/createTemplateEngine.js
@@ -0,0 +1,118 @@
+var path = require('path');
+var nunjucks = require('nunjucks');
+var DoExtension = require('nunjucks-do')(nunjucks);
+
+var Api = require('../../api');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var fs = require('../../utils/fs');
+var PathUtils = require('../../utils/path');
+var TemplateEngine = require('../../models/templateEngine');
+var templatesFolder = require('../../constants/templatesFolder');
+var defaultFilters = require('../../constants/defaultFilters');
+var Templating = require('../../templating');
+var listSearchPaths = require('./listSearchPaths');
+
+var fileToURL = require('../helper/fileToURL');
+var resolveFileToURL = require('../helper/resolveFileToURL');
+
+/**
+ Directory for a theme with the templates
+*/
+function templateFolder(dir) {
+ return path.join(dir, templatesFolder);
+}
+
+/**
+ Create templating engine to render themes
+
+ @param {Output} output
+ @param {String} currentFile
+ @return {TemplateEngine}
+*/
+function createTemplateEngine(output, currentFile) {
+ var book = output.getBook();
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var config = book.getConfig();
+ var summary = book.getSummary();
+ var outputFolder = output.getRoot();
+
+ // Search paths for templates
+ var searchPaths = listSearchPaths(output);
+ var tplSearchPaths = searchPaths.map(templateFolder);
+
+ // Create loader
+ var loader = new Templating.ThemesLoader(tplSearchPaths);
+
+ // Get languages
+ var language = config.get('language');
+
+ // Create API context
+ var context = Api.encodeGlobal(output);
+
+ return TemplateEngine.create({
+ loader: loader,
+
+ context: context,
+
+ filters: defaultFilters.merge({
+ /**
+ Translate a sentence
+ */
+ t: function t(s) {
+ return i18n.t(language, s);
+ },
+
+ /**
+ Resolve an absolute file path into a
+ relative path.
+ it also resolve pages
+ */
+ resolveFile: function(filePath) {
+ filePath = resolveFileToURL(output, filePath);
+ return LocationUtils.relativeForFile(currentFile, filePath);
+ },
+
+ resolveAsset: function(filePath) {
+ filePath = LocationUtils.toAbsolute(filePath, '', '');
+ filePath = path.join('gitbook', filePath);
+ filePath = LocationUtils.relativeForFile(currentFile, filePath);
+
+ // Use assets from parent if language book
+ if (book.isLanguageBook()) {
+ filePath = path.join('../', filePath);
+ }
+
+ return LocationUtils.normalize(filePath);
+ },
+
+ /**
+ Check if a file exists
+ */
+ fileExists: function(fileName) {
+ var filePath = PathUtils.resolveInRoot(outputFolder, fileName);
+ return fs.existsSync(filePath);
+ },
+
+ contentURL: function(filePath) {
+ return fileToURL(output, filePath);
+ },
+
+ /**
+ Return an article by its path
+ */
+ getArticleByPath: function(s) {
+ var article = summary.getByPath(s);
+ if (!article) return undefined;
+ return JSONUtils.encodeSummaryArticle(article);
+ }
+ }),
+
+ extensions: {
+ 'DoExtension': new DoExtension()
+ }
+ });
+}
+
+module.exports = createTemplateEngine;
diff --git a/lib/output/website/index.js b/lib/output/website/index.js
index 0a8618c..7818a28 100644
--- a/lib/output/website/index.js
+++ b/lib/output/website/index.js
@@ -1,225 +1,11 @@
-var _ = require('lodash');
-var path = require('path');
-var util = require('util');
-var I18n = require('i18n-t');
-var Promise = require('../../utils/promise');
-var location = require('../../utils/location');
-var fs = require('../../utils/fs');
-var conrefsLoader = require('../conrefs');
-var Output = require('../base');
-var setupTemplateEnv = require('./templateEnv');
-
-function _WebsiteOutput() {
- Output.apply(this, arguments);
-
- // Nunjucks environment
- this.env;
-
- // Plugin instance for the main theme
- this.theme;
-
- // Plugin instance for the default theme
- this.defaultTheme;
-
- // Resources loaded from plugins
- this.resources;
-
- // i18n for themes
- this.i18n = new I18n();
-}
-util.inherits(_WebsiteOutput, Output);
-
-var WebsiteOutput = conrefsLoader(_WebsiteOutput);
-
-// Name of the generator
-// It's being used as a prefix for templates
-WebsiteOutput.prototype.name = 'website';
-
-// Load and setup the theme
-WebsiteOutput.prototype.prepare = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return WebsiteOutput.super_.prototype.prepare.apply(that);
- })
-
- .then(function() {
- // This list is ordered to give priority to templates in the book
- var searchPaths = _.pluck(that.plugins.list(), 'root');
-
- // The book itself can contains a "_layouts" folder
- searchPaths.unshift(that.book.root);
-
- // Load i18n
- _.each(searchPaths.concat().reverse(), function(searchPath) {
- var i18nRoot = path.resolve(searchPath, '_i18n');
-
- if (!fs.existsSync(i18nRoot)) return;
- that.i18n.load(i18nRoot);
- });
-
- that.searchPaths = searchPaths;
- })
-
- // Copy assets from themes before copying files from book
- .then(function() {
- if (that.book.isLanguageBook()) return;
-
- // Assets from the book are already copied
- // Copy assets from plugins (start with default plugins)
- return Promise.serie(that.plugins.list().reverse(), function(plugin) {
- // Copy assets only if exists (don't fail otherwise)
- var assetFolder = path.join(plugin.root, '_assets', that.name);
- if (!fs.existsSync(assetFolder)) return;
-
- that.log.debug.ln('copy assets from theme', assetFolder);
- return fs.copyDir(
- assetFolder,
- that.resolve('gitbook'),
- {
- deleteFirst: false,
- overwrite: true,
- confirm: true
- }
- );
- });
- })
-
- // Load resources for plugins
- .then(function() {
- return that.plugins.getResources(that.name)
- .then(function(resources) {
- that.resources = resources;
- });
- });
-};
-
-// Write a page (parsable file)
-WebsiteOutput.prototype.onPage = function(page) {
- var that = this;
-
- // Parse the page
- return page.toHTML(this)
-
- // Render the page template with the same context as the json output
- .then(function() {
- return that.render('page', that.outputPath(page.path), page.getOutputContext(that));
- });
-};
-
-// Finish generation, create ebook using ebook-convert
-WebsiteOutput.prototype.finish = function() {
- var that = this;
-
- return Promise()
- .then(function() {
- return WebsiteOutput.super_.prototype.finish.apply(that);
- })
-
- // Copy assets from plugins
- .then(function() {
- if (that.book.isLanguageBook()) return;
- return that.plugins.copyResources(that.name, that.resolve('gitbook'));
- })
-
- // Generate homepage to select languages
- .then(function() {
- if (!that.book.isMultilingual()) return;
- return that.outputMultilingualIndex();
- });
-};
-
-// ----- Utilities ----
-
-// Write multi-languages index
-WebsiteOutput.prototype.outputMultilingualIndex = function() {
- var that = this;
-
- return that.render('languages', 'index.html', that.getContext());
-};
-
-/*
- Render a template as an HTML string
- Templates are stored in `_layouts` folders
-
-
- @param {String} tpl: template name (ex: "page")
- @param {String} outputFile: filename to write, relative to the output
- @param {Object} context: context for the page
- @return {Promise}
-*/
-WebsiteOutput.prototype.renderAsString = function(tpl, context) {
- // Calcul template name
- var filename = this.templateName(tpl);
-
- context = _.extend(context, {
- plugins: {
- resources: this.resources
- },
-
- options: this.opts
- });
-
- // Create environment
- var env = setupTemplateEnv(this, context);
-
- return Promise.nfcall(env.render.bind(env), filename, context);
+module.exports = {
+ name: 'website',
+ State: require('./state'),
+ Options: require('./options'),
+ onInit: require('./onInit'),
+ onFinish: require('./onFinish'),
+ onPage: require('./onPage'),
+ onAsset: require('./onAsset'),
+ createTemplateEngine: require('./createTemplateEngine')
};
-
-/*
- Render a template using nunjucks
- Templates are stored in `_layouts` folders
-
-
- @param {String} tpl: template name (ex: "page")
- @param {String} outputFile: filename to write, relative to the output
- @param {Object} context: context for the page
- @return {Promise}
-*/
-WebsiteOutput.prototype.render = function(tpl, outputFile, context) {
- var that = this;
-
- // Calcul relative path to the root
- var outputDirName = path.dirname(outputFile);
- var basePath = location.normalize(path.relative(outputDirName, './'));
-
- // Setup complete context
- context = _.extend(context, {
- basePath: basePath,
-
- template: {
- getJSContext: function() {
- return {
- page: _.omit(context.page, 'content'),
- config: context.config,
- file: context.file,
- gitbook: context.gitbook,
- basePath: basePath,
- book: {
- language: context.book.language
- }
- };
- }
- }
- });
-
- return this.renderAsString(tpl, context)
- .then(function(html) {
- return that.writeFile(
- outputFile,
- html
- );
- });
-};
-
-// Return a complete name for a template
-WebsiteOutput.prototype.templateName = function(name) {
- return path.join(this.name, name+'.html');
-};
-
-module.exports = WebsiteOutput;
-
-
-
diff --git a/lib/output/website/listSearchPaths.js b/lib/output/website/listSearchPaths.js
new file mode 100644
index 0000000..c45f39c
--- /dev/null
+++ b/lib/output/website/listSearchPaths.js
@@ -0,0 +1,23 @@
+
+/**
+ List search paths for templates / i18n, etc
+
+ @param {Output} output
+ @return {List<String>}
+*/
+function listSearchPaths(output) {
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+
+ var searchPaths = plugins
+ .valueSeq()
+ .map(function(plugin) {
+ return plugin.getPath();
+ })
+ .toList();
+
+ return searchPaths.unshift(book.getContentRoot());
+}
+
+
+module.exports = listSearchPaths;
diff --git a/lib/output/website/onAsset.js b/lib/output/website/onAsset.js
new file mode 100644
index 0000000..17b6ba7
--- /dev/null
+++ b/lib/output/website/onAsset.js
@@ -0,0 +1,27 @@
+var path = require('path');
+var fs = require('../../utils/fs');
+
+/**
+ Copy an asset to the output folder
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onAsset(output, asset) {
+ var book = output.getBook();
+ var options = output.getOptions();
+
+ var rootFolder = book.getContentRoot();
+ var outputFolder = options.get('root');
+
+ var filePath = path.resolve(rootFolder, asset);
+ var outputPath = path.resolve(outputFolder, asset);
+
+ return fs.ensureFile(outputPath)
+ .then(function() {
+ return fs.copy(filePath, outputPath);
+ })
+ .thenResolve(output);
+}
+
+module.exports = onAsset;
diff --git a/lib/output/website/onFinish.js b/lib/output/website/onFinish.js
new file mode 100644
index 0000000..e3560e2
--- /dev/null
+++ b/lib/output/website/onFinish.js
@@ -0,0 +1,35 @@
+var Promise = require('../../utils/promise');
+var JSONUtils = require('../../json');
+var Templating = require('../../templating');
+var writeFile = require('../helper/writeFile');
+var createTemplateEngine = require('./createTemplateEngine');
+
+/**
+ Finish the generation, write the languages index
+
+ @param {Output}
+ @return {Output}
+*/
+function onFinish(output) {
+ var book = output.getBook();
+ var options = output.getOptions();
+ var prefix = options.get('prefix');
+
+ if (!book.isMultilingual()) {
+ return Promise(output);
+ }
+
+ var filePath = 'index.html';
+ var engine = createTemplateEngine(output, filePath);
+ var context = JSONUtils.encodeOutput(output);
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/languages.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+}
+
+module.exports = onFinish;
diff --git a/lib/output/website/onInit.js b/lib/output/website/onInit.js
new file mode 100644
index 0000000..979a90d
--- /dev/null
+++ b/lib/output/website/onInit.js
@@ -0,0 +1,18 @@
+var Promise = require('../../utils/promise');
+
+var copyPluginAssets = require('./copyPluginAssets');
+var prepareI18n = require('./prepareI18n');
+
+/**
+ Initialize the generator
+
+ @param {Output}
+ @return {Output}
+*/
+function onInit(output) {
+ return Promise(output)
+ .then(prepareI18n)
+ .then(copyPluginAssets);
+}
+
+module.exports = onInit;
diff --git a/lib/output/website/onPage.js b/lib/output/website/onPage.js
new file mode 100644
index 0000000..64b4e04
--- /dev/null
+++ b/lib/output/website/onPage.js
@@ -0,0 +1,72 @@
+var path = require('path');
+var omit = require('omit-keys');
+
+var Templating = require('../../templating');
+var Plugins = require('../../plugins');
+var JSONUtils = require('../../json');
+var LocationUtils = require('../../utils/location');
+var Modifiers = require('../modifiers');
+var writeFile = require('../helper/writeFile');
+var getModifiers = require('../getModifiers');
+var createTemplateEngine = require('./createTemplateEngine');
+var fileToOutput = require('../helper/fileToOutput');
+
+/**
+ Write a page as a json file
+
+ @param {Output} output
+ @param {Page} page
+*/
+function onPage(output, page) {
+ var options = output.getOptions();
+ var file = page.getFile();
+ var prefix = options.get('prefix');
+ var book = output.getBook();
+ var plugins = output.getPlugins();
+
+ var engine = createTemplateEngine(output, page.getPath());
+
+ // Output file path
+ var filePath = fileToOutput(output, file.getPath());
+
+ // Calcul relative path to the root
+ var outputDirName = path.dirname(filePath);
+ var basePath = LocationUtils.normalize(path.relative(outputDirName, './'));
+
+ return Modifiers.modifyHTML(page, getModifiers(output, page))
+ .then(function(resultPage) {
+ // Generate the context
+ var context = JSONUtils.encodeBookWithPage(output.getBook(), resultPage);
+ context.plugins = {
+ resources: Plugins.listResources(plugins, prefix).toJS()
+ };
+
+ context.template = {
+ getJSContext: function() {
+ return {
+ page: omit(context.page, 'content'),
+ config: context.config,
+ file: context.file,
+ gitbook: context.gitbook,
+ basePath: basePath,
+ book: {
+ language: book.getLanguage()
+ }
+ };
+ }
+ };
+
+ // We should probabbly move it to "template" or a "site" namespace
+ context.basePath = basePath;
+
+ // Render the theme
+ return Templating.renderFile(engine, prefix + '/page.html', context)
+
+ // Write it to the disk
+ .then(function(html) {
+ return writeFile(output, filePath, html);
+ });
+ });
+}
+
+module.exports = onPage;
diff --git a/lib/output/website/options.js b/lib/output/website/options.js
new file mode 100644
index 0000000..ac9cdad
--- /dev/null
+++ b/lib/output/website/options.js
@@ -0,0 +1,14 @@
+var Immutable = require('immutable');
+
+var Options = Immutable.Record({
+ // Root folder for the output
+ root: String(),
+
+ // Prefix for generation
+ prefix: String('website'),
+
+ // Use directory index url instead of "index.html"
+ directoryIndex: Boolean(true)
+});
+
+module.exports = Options;
diff --git a/lib/output/website/prepareI18n.js b/lib/output/website/prepareI18n.js
new file mode 100644
index 0000000..b57d178
--- /dev/null
+++ b/lib/output/website/prepareI18n.js
@@ -0,0 +1,30 @@
+var path = require('path');
+
+var fs = require('../../utils/fs');
+var Promise = require('../../utils/promise');
+var listSearchPaths = require('./listSearchPaths');
+
+/**
+ Prepare i18n, load translations from plugins and book
+
+ @param {Output}
+ @return {Promise<Output>}
+*/
+function prepareI18n(output) {
+ var state = output.getState();
+ var i18n = state.getI18n();
+ var searchPaths = listSearchPaths(output);
+
+ searchPaths
+ .reverse()
+ .forEach(function(searchPath) {
+ var i18nRoot = path.resolve(searchPath, '_i18n');
+
+ if (!fs.existsSync(i18nRoot)) return;
+ i18n.load(i18nRoot);
+ });
+
+ return Promise(output);
+}
+
+module.exports = prepareI18n;
diff --git a/lib/output/website/state.js b/lib/output/website/state.js
new file mode 100644
index 0000000..99e7f04
--- /dev/null
+++ b/lib/output/website/state.js
@@ -0,0 +1,12 @@
+var I18n = require('i18n-t');
+var Immutable = require('immutable');
+
+var GeneratorState = Immutable.Record({
+ i18n: I18n()
+});
+
+GeneratorState.prototype.getI18n = function() {
+ return this.get('i18n');
+};
+
+module.exports = GeneratorState;
diff --git a/lib/output/website/templateEnv.js b/lib/output/website/templateEnv.js
deleted file mode 100644
index d385108..0000000
--- a/lib/output/website/templateEnv.js
+++ /dev/null
@@ -1,95 +0,0 @@
-var _ = require('lodash');
-var nunjucks = require('nunjucks');
-var path = require('path');
-var fs = require('fs');
-var DoExtension = require('nunjucks-do')(nunjucks);
-
-
-var location = require('../../utils/location');
-var defaultFilters = require('../../template/filters');
-
-var ThemeLoader = require('./themeLoader');
-
-// Directory for a theme with the templates
-function templatesPath(dir) {
- return path.join(dir, '_layouts');
-}
-
-/*
- Create and setup at Nunjucks template environment
-
- @return {Nunjucks.Environment}
-*/
-function setupTemplateEnv(output, context) {
- context = _.defaults(context || {}, {
- // Required by ThemeLoader
- template: {}
- });
-
- var loader = new ThemeLoader(
- _.map(output.searchPaths, templatesPath)
- );
- var env = new nunjucks.Environment(loader);
-
- env.addExtension('DoExtension', new DoExtension());
-
- // Add context as global
- _.each(context, function(value, key) {
- env.addGlobal(key, value);
- });
-
- // Add GitBook default filters
- _.each(defaultFilters, function(fn, filter) {
- env.addFilter(filter, fn);
- });
-
- // Translate using _i18n locales
- env.addFilter('t', function t(s) {
- return output.i18n.t(output.book.config.get('language'), s);
- });
-
- // Transform an absolute path into a relative path
- // using this.ctx.page.path
- env.addFilter('resolveFile', function resolveFile(href) {
- return location.normalize(output.resolveForPage(context.file.path, href));
- });
-
- // Test if a file exists
- env.addFilter('fileExists', function fileExists(href) {
- return fs.existsSync(output.resolve(href));
- });
-
- // Transform a '.md' into a '.html' (README -> index)
- env.addFilter('contentURL', function contentURL(s) {
- return output.toURL(s);
- });
-
- // Get an article using its path
- env.addFilter('getArticleByPath', function getArticleByPath(s) {
- var article = output.book.summary.getArticle(s);
- if (!article) return undefined;
-
- return article.getContext();
- });
-
- // Relase path to an asset
- env.addFilter('resolveAsset', function resolveAsset(href) {
- href = path.join('gitbook', href);
-
- // Resolve for current file
- if (context.file) {
- href = output.resolveForPage(context.file.path, '/' + href);
- }
-
- // Use assets from parent
- if (output.book.isLanguageBook()) {
- href = path.join('../', href);
- }
-
- return location.normalize(href);
- });
-
- return env;
-}
-
-module.exports = setupTemplateEnv;
diff --git a/lib/output/website/themeLoader.js b/lib/output/website/themeLoader.js
deleted file mode 100644
index 774a39e..0000000
--- a/lib/output/website/themeLoader.js
+++ /dev/null
@@ -1,127 +0,0 @@
-var _ = require('lodash');
-var fs = require('fs');
-var path = require('path');
-var nunjucks = require('nunjucks');
-
-/*
- Nunjucks loader similar to FileSystemLoader, but avoid infinite looping
-*/
-
-/*
- Return true if a filename is relative.
-*/
-function isRelative(filename) {
- return (filename.indexOf('./') === 0 || filename.indexOf('../') === 0);
-}
-
-var ThemeLoader = nunjucks.Loader.extend({
- init: function(searchPaths) {
- this.searchPaths = _.map(searchPaths, path.normalize);
- },
-
- /*
- Read source of a resolved filepath
-
- @param {String}
- @return {Object}
- */
- getSource: function(fullpath) {
- if (!fullpath) return null;
-
- fullpath = this.resolve(null, fullpath);
- var templateName = this.getTemplateName(fullpath);
-
- if(!fullpath) {
- return null;
- }
-
- var src = fs.readFileSync(fullpath, 'utf-8');
-
- src = '{% do %}var template = template || {}; template.stack = template.stack || []; template.stack.push(template.self); template.self = ' + JSON.stringify(templateName) + '{% enddo %}\n' +
- src +
- '\n{% do %}template.self = template.stack.pop();{% enddo %}';
-
- return {
- src: src,
- path: fullpath,
- noCache: true
- };
- },
-
- /*
- Nunjucks calls "isRelative" to determine when to call "resolve".
- We handle absolute paths ourselves in ".resolve" so we always return true
- */
- isRelative: function() {
- return true;
- },
-
- /*
- Get original search path containing a template
-
- @param {String} filepath
- @return {String} searchPath
- */
- getSearchPath: function(filepath) {
- return _.chain(this.searchPaths)
- .sortBy(function(s) {
- return -s.length;
- })
- .find(function(basePath) {
- return (filepath && filepath.indexOf(basePath) === 0);
- })
- .value();
- },
-
- /*
- Get template name from a filepath
-
- @param {String} filepath
- @return {String} name
- */
- getTemplateName: function(filepath) {
- var originalSearchPath = this.getSearchPath(filepath);
- return originalSearchPath? path.relative(originalSearchPath, filepath) : null;
- },
-
- /*
- Resolve a template from a current template
-
- @param {String|null} from
- @param {String} to
- @return {String|null}
- */
- resolve: function(from, to) {
- var searchPaths = this.searchPaths;
-
- // Relative template like "./test.html"
- if (isRelative(to) && from) {
- return path.resolve(path.dirname(from), to);
- }
-
- // Determine in which search folder we currently are
- var originalSearchPath = this.getSearchPath(from);
- var originalFilename = this.getTemplateName(from);
-
- // If we are including same file from a different search path
- // Slice the search paths to avoid including from previous ones
- if (originalFilename == to) {
- var currentIndex = searchPaths.indexOf(originalSearchPath);
- searchPaths = searchPaths.slice(currentIndex + 1);
- }
-
- // Absolute template to resolve in root folder
- var resultFolder = _.find(searchPaths, function(basePath) {
- var p = path.resolve(basePath, to);
-
- return (
- p.indexOf(basePath) === 0
- && fs.existsSync(p)
- );
- });
- if (!resultFolder) return null;
- return path.resolve(resultFolder, to);
- }
-});
-
-module.exports = ThemeLoader;