summaryrefslogtreecommitdiffstats
path: root/lib/output2
diff options
context:
space:
mode:
Diffstat (limited to 'lib/output2')
-rw-r--r--lib/output2/assets-inliner.js140
-rw-r--r--lib/output2/base.js309
-rw-r--r--lib/output2/conrefs.js67
-rw-r--r--lib/output2/ebook.js193
-rw-r--r--lib/output2/folder.js152
-rw-r--r--lib/output2/json.js47
-rw-r--r--lib/output2/website/index.js225
-rw-r--r--lib/output2/website/templateEnv.js95
-rw-r--r--lib/output2/website/themeLoader.js127
9 files changed, 1355 insertions, 0 deletions
diff --git a/lib/output2/assets-inliner.js b/lib/output2/assets-inliner.js
new file mode 100644
index 0000000..6f1f02d
--- /dev/null
+++ b/lib/output2/assets-inliner.js
@@ -0,0 +1,140 @@
+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/output2/base.js b/lib/output2/base.js
new file mode 100644
index 0000000..868b85b
--- /dev/null
+++ b/lib/output2/base.js
@@ -0,0 +1,309 @@
+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/output2/conrefs.js b/lib/output2/conrefs.js
new file mode 100644
index 0000000..e58f836
--- /dev/null
+++ b/lib/output2/conrefs.js
@@ -0,0 +1,67 @@
+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/output2/ebook.js b/lib/output2/ebook.js
new file mode 100644
index 0000000..2b8fac9
--- /dev/null
+++ b/lib/output2/ebook.js
@@ -0,0 +1,193 @@
+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/output2/folder.js b/lib/output2/folder.js
new file mode 100644
index 0000000..8303ed2
--- /dev/null
+++ b/lib/output2/folder.js
@@ -0,0 +1,152 @@
+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/output2/json.js b/lib/output2/json.js
new file mode 100644
index 0000000..7061141
--- /dev/null
+++ b/lib/output2/json.js
@@ -0,0 +1,47 @@
+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/output2/website/index.js b/lib/output2/website/index.js
new file mode 100644
index 0000000..0a8618c
--- /dev/null
+++ b/lib/output2/website/index.js
@@ -0,0 +1,225 @@
+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);
+};
+
+/*
+ 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/output2/website/templateEnv.js b/lib/output2/website/templateEnv.js
new file mode 100644
index 0000000..d385108
--- /dev/null
+++ b/lib/output2/website/templateEnv.js
@@ -0,0 +1,95 @@
+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/output2/website/themeLoader.js b/lib/output2/website/themeLoader.js
new file mode 100644
index 0000000..774a39e
--- /dev/null
+++ b/lib/output2/website/themeLoader.js
@@ -0,0 +1,127 @@
+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;