diff options
Diffstat (limited to 'lib/template.js')
-rw-r--r-- | lib/template.js | 394 |
1 files changed, 394 insertions, 0 deletions
diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 0000000..28b11c6 --- /dev/null +++ b/lib/template.js @@ -0,0 +1,394 @@ +var _ = require("lodash"); +var Q = require("q"); +var path = require("path"); +var nunjucks = require("nunjucks"); + +var git = require("./utils/git"); +var stringUtils = require("./utils/string"); +var fs = require("./utils/fs"); +var batch = require("./utils/batch"); +var pkg = require("../package.json"); + + +// The loader should handle relative and git url +var BookLoader = nunjucks.Loader.extend({ + async: true, + + init: function(book) { + this.book = book; + }, + + getSource: function(fileurl, callback) { + var that = this; + + git.resolveFile(fileurl) + .then(function(filepath) { + // Is local file + if (!filepath) filepath = path.resolve(that.book.root, fileurl); + else that.book.log.debug.ln("resolve from git", fileurl, "to", filepath) + + // Read file from absolute path + return fs.readFile(filepath) + .then(function(source) { + return { + src: source.toString(), + path: filepath + } + }); + }) + .nodeify(callback); + }, + + resolve: function(from, to) { + return path.resolve(path.dirname(from), to); + } +}); + + +var TemplateEngine = function(book) { + this.book = book; + this.log = this.book.log; + + // Nunjucks env + this.env = new nunjucks.Environment( + new BookLoader(book), + { + // Escaping is done after by the markdown parser + autoescape: false, + + // Tags + tags: { + blockStart: '{%', + blockEnd: '%}', + variableStart: '{{', + variableEnd: '}}', + commentStart: '{###', + commentEnd: '###}' + } + } + ); + + // List of tags shortcuts + this.shortcuts = []; + + // Map of blocks + this.blocks = {}; + + // Bind methods + _.bindAll(this); + + // Default block "html" that return html not parsed + this.addBlock("html", { + process: _.identity + }); +}; + +// Process a block in a context +TemplateEngine.prototype.processBlock = function(blk) { + if (_.isString(blk)) blk = { body: blk }; + + blk = _.defaults(blk, { + parse: false, + post: undefined + }); + blk.id = _.uniqueId("blk"); + + var toAdd = (!blk.parse) || (blk.post != undefined); + + // Add to global map + if (toAdd) this.blocks[blk.id] = blk; + + //Parsable block, just return it + if (blk.parse) { + return blk.body; + } + + // Return it as a macro + return "%+%"+blk.id+"%+%"; +}; + +// Replace blocks by body after processing +// This is done to avoid that markdown processer parse the block content +TemplateEngine.prototype.replaceBlocks = function(content) { + var that = this; + + return content.replace(/\%\+\%([\s\S]+?)\%\+\%/g, function(match, key) { + var blk = that.blocks[key]; + if (!blk) return match; + + var body = blk.body; + + return body; + }); +}; + +// Bind a function to a context +TemplateEngine.prototype.bindContext = function(func) { + var that = this; + + return function() { + var ctx = { + ctx: this.ctx, + book: that.book, + generator: that.book.options.generator + }; + + return func.apply(ctx, arguments); + }; +}; + +// Add filter +TemplateEngine.prototype.addFilter = function(filterName, func) { + try { + this.env.getFilter(filterName); + this.log.warn.ln("conflict in filters, '"+filterName+"' is already set"); + return false; + } catch(e) {} + + this.log.debug.ln("add filter '"+filterName+"'"); + this.env.addFilter(filterName, this.bindContext(function() { + var ctx = this; + var args = Array.prototype.slice.apply(arguments); + var callback = _.last(args); + + Q() + .then(function() { + return func.apply(ctx, args.slice(0, -1)); + }) + .nodeify(callback); + }), true); + return true; +}; + +// Add a block +TemplateEngine.prototype.addBlock = function(name, block) { + var that = this; + + block = _.defaults(block || {}, { + shortcuts: [], + end: "end"+name, + process: _.identity, + blocks: [] + }); + + var extName = 'Block'+name+'Extension'; + if (this.env.getExtension(extName)) { + this.log.warn.ln("conflict in blocks, '"+name+"' is already defined"); + return false; + } + + this.log.debug.ln("add block '"+name+"'"); + + var Ext = function () { + this.tags = [name]; + + this.parse = function(parser, nodes, lexer) { + var body = null; + var lastBlockName = null; + var lastBlockArgs = null; + var allBlocks = block.blocks.concat([block.end]); + var subbodies = {}; + + var tok = parser.nextToken(); + var args = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(tok.value); + + while (1) { + // Read body + var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks); + + // Handle body with previous block name and args + if (lastBlockName) { + subbodies[lastBlockName] = subbodies[lastBlockName] || []; + subbodies[lastBlockName].push({ + body: currentBody, + args: lastBlockArgs + }); + } else { + body = currentBody; + } + + // Read new block + lastBlockName = parser.peekToken().value; + if (lastBlockName == block.end) { + break; + } + + // Parse signature and move to the end of the block + lastBlockArgs = parser.parseSignature(null, true); + parser.advanceAfterBlockEnd(lastBlockName); + } + parser.advanceAfterBlockEnd(); + + var bodies = [body]; + _.each(block.blocks, function(blockName) { + subbodies[blockName] = subbodies[blockName] || []; + if (subbodies[blockName].length == 0) { + subbodies[blockName].push({ + args: new nodes.NodeList(), + body: new nodes.NodeList() + }); + } + + bodies.push(subbodies[blockName][0].body); + }); + + return new nodes.CallExtensionAsync(this, 'run', args, bodies); + }; + + this.run = function(context) { + var args = Array.prototype.slice.call(arguments, 1); + var callback = args.pop(); + + // Extract blocks + var blocks = args + .concat([]) + .slice(-block.blocks.length); + + // Eliminate blocks from list + if (block.blocks.length > 0) args = args.slice(0, -block.blocks.length); + + // Extract main body and kwargs + var body = args.pop(); + var kwargs = args.pop() || {}; + + // Extract blocks body + var _blocks = _.map(block.blocks, function(blockName, i){ + return { + name: blockName, + body: blocks[i]() + }; + }); + + var func = that.bindContext(block.process); + + Q() + .then(function() { + return func.call(context, { + body: body(), + args: args, + kwargs: kwargs, + blocks: _blocks + }); + }) + + // process the block returned + .then(that.processBlock) + .nodeify(callback) + }; + }; + + + // Add the Extension + this.env.addExtension(extName, new Ext()); + + // Add shortcuts + if (!_.isArray(block.shortcuts)) block.shortcuts = [block.shortcuts]; + _.each(block.shortcuts, function(shortcut) { + this.log.debug.ln("add template shortcut from '"+shortcut.start+"' to block '"+name+"' for parsers ", shortcut.parsers); + this.shortcuts.push({ + parsers: shortcut.parsers, + start: shortcut.start, + end: shortcut.end, + tag: { + start: name, + end: block.end + } + }); + }, this); +}; + +// Apply a shortcut to a string +TemplateEngine.prototype._applyShortcut = function(parser, content, shortcut) { + if (!_.contains(shortcut.parsers, parser)) return content; + var regex = new RegExp( + stringUtils.escapeRegex(shortcut.start) + "([\\s\\S]*?[^\\$])" + stringUtils.escapeRegex(shortcut.end), + 'g' + ); + return content.replace(regex, function(all, match) { + return "{% "+shortcut.tag.start+" %}"+ match + "{% "+shortcut.tag.end+" %}"; + }); +}; + +// Render a string from the book +TemplateEngine.prototype.renderString = function(content, context, options) { + var context = _.extend({}, context, { + // Variables from book.json + book: this.book.options.variables, + + // infos about gitbook + gitbook: { + version: pkg.version, + generator: this.book.options.generator + } + }); + options = _.defaults(options || {}, { + path: null, + type: null + }); + if (options.path) options.path = this.book.resolve(options.path); + + // Replace shortcuts + content = _.reduce(this.shortcuts, _.partial(this._applyShortcut.bind(this), options.type), content); + + return Q.nfcall(this.env.renderString.bind(this.env), content, context, options); +}; + +// Render a file from the book +TemplateEngine.prototype.renderFile = function(filename, options) { + var that = this, context; + + return that.book.readFile(filename) + .then(function(content) { + return that.renderString(content, {}, { + path: filename + }); + }); +}; + +// Render a page from the book +TemplateEngine.prototype.renderPage = function(page) { + var that = this, context; + + return that.book.statFile(page.path) + .then(function(stat) { + context = { + // infos about the file + file: { + path: page.path, + mtime: stat.mtime + } + }; + + return that.renderString(page.content, context, { + path: page.path, + type: page.type + }); + }); +}; + +// Post process content +TemplateEngine.prototype.postProcess = function(content) { + var that = this; + + return Q(content) + .then(that.replaceBlocks) + .then(function(_content) { + return batch.execEach(that.blocks, { + max: 20, + fn: function(blk, blkId) { + return Q() + .then(function() { + if (!blk.post) return Q(); + return blk.post(); + }) + .then(function() { + delete that.blocks[blkId]; + }); + } + }) + .thenResolve(_content); + }); +}; + +module.exports = TemplateEngine; |