diff options
Diffstat (limited to 'lib/output2')
-rw-r--r-- | lib/output2/assets-inliner.js | 140 | ||||
-rw-r--r-- | lib/output2/base.js | 309 | ||||
-rw-r--r-- | lib/output2/conrefs.js | 67 | ||||
-rw-r--r-- | lib/output2/ebook.js | 193 | ||||
-rw-r--r-- | lib/output2/folder.js | 152 | ||||
-rw-r--r-- | lib/output2/json.js | 47 | ||||
-rw-r--r-- | lib/output2/website/index.js | 225 | ||||
-rw-r--r-- | lib/output2/website/templateEnv.js | 95 | ||||
-rw-r--r-- | lib/output2/website/themeLoader.js | 127 |
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; |