blade
Version:
Blade - HTML Template Compiler, inspired by Jade & Haml
681 lines (668 loc) • 24.2 kB
JavaScript
/** Blade Compiler
(c) Copyright 2012. Blake Miner. All rights reserved.
https://github.com/bminer/node-blade
http://www.blakeminer.com/
See the full license here:
https://raw.github.com/bminer/node-blade/master/LICENSE.txt
*/
var path = require('path'),
parser = require('./parser'),
runtime = require('./runtime'),
bladeutil = require('./util'),
doctypes = require('./doctypes'),
selfClosingTags = require('./self-closing-tags'),
filters = require('./filters');
module.exports = Compiler;
function Compiler(string, opts) {
this.string = string;
this.options = opts || {};
//Copy all this crap into compiler options object
var copy = {
"doctypes": doctypes,
"filters": filters
};
//Merge doctypes, filters, and selfClosingTags with those passed into `options`
for(var i in copy)
{
this.options[i] = this.options[i] || {};
for(var j in copy[i])
if(this.options[i][j] == null)
this.options[i][j] = copy[i][j];
}
//Self-closing tags
this.options.selfClosingTags = this.options.selfClosingTags || selfClosingTags;
//Store special options
if(typeof this.options.filename == "string")
{
this.options.filename = path.resolve(this.options.filename);
this.options.basedir = path.resolve(this.options.basedir || process.cwd() );
this.options.reldir = path.dirname(path.relative(
this.options.basedir, this.options.filename) );
//Hide full paths for templates compiled by the Blade middleware
if(this.options.middleware)
{
this.options.filename = path.relative(this.options.basedir,
this.options.filename);
delete this.options.basedir;
}
this.includes = 0;
}
else
this.includes = -1;
if(this.options.cache)
this.options.minify = true;
if(this.options.includeSource == null && process.env.NODE_ENV == "development")
this.options.includeSource = true;
this.dependencies = [];
//Name of reserved variable in template
this.options.templateNamespace = this.options.templateNamespace || "__";
}
/* static */ Compiler.parse = function(string) {
return parser.parse(string);
}
Compiler.doctypes = doctypes;
Compiler.selfClosingTags = selfClosingTags;
Compiler.filters = filters;
Compiler.prototype.compile = function(cb) {
var ns = this.options.templateNamespace;
//Update options in runtime environment for "includes"
runtime.compileOptions = this.options;
if(this.options.debug)
console.log("Compiling:\n" + this.string +
"\n---------------------------------------------");
try {
this.ast = Compiler.parse(this.string);
if(this.options.debug)
console.log("AST:\n", require('util').inspect(this.ast, false, 15, true),
"\n---------------------------------------------");
} catch(e) {
if(this.options.filename)
e.filename = this.options.filename;
e.message = "Parser error: " + e.message;
e.source = this.string;
return cb(runtime.rethrow(e) );
}
try {
//Convert to JS function
this.buf = "";
this.blockDeclarations = false;
this._pushOff(ns + ' = ' + ns + ' || [];' + //Define ns as an array
ns + '.r = ' + ns + '.r || blade.Runtime;' + //Define ns.r to point to the runtime
'if(!' + ns + '.func) ' + ns + '.func = {},' + //Define ns.func to hold all functions
ns + '.blocks = {},' + //Define ns.blocks to hold all blocks
ns + '.chunk = {};' + //Define ns.chunk to hold all functions chunks
ns + '.locals = locals || {};'); //Store all locals, too
var baseRelStart = this.buf.length;
//Expose the filename no matter what; this is needed for branch labels if a
//"live page update engine" is used
this._pushOff(this.options.filename ? ns + ".filename = " +
JSON.stringify(this.options.filename) + ";" : "");
//Only include error handling and source code, if needed
if(this.options.minify !== true && this.options.includeSource)
this._pushOff(ns + '.source = ' + JSON.stringify(this.string) + ";");
this._pushOff("\ntry {");
//Now compile the template
this._pushOff('with(' + ns + '.locals) {');
for(var i = 0; i < this.ast.doctypes.length; i++)
this._compileDoctype(this.ast.doctypes[i]);
for(var i = 0; i < this.ast.nodes.length; i++)
this._compileNode(this.ast.nodes[i]);
//Close brace for `with`
this._pushOff("}");
//Close main `try` and insert `catch`
if(this.options.minify !== true)
this._pushOff("} catch(e){return cb(" + ns + ".r.rethrow(e, " + ns + ") );}");
else
this._pushOff("} catch(e){return cb(e);}");
//End of template callback
this._pushOff('if(!' + ns + '.inc) ' + ns + '.r.done(' + ns + ');');
if(this.blockDeclarations)
this._pushOff(ns + '.bd = 1;');
this._pushOff('cb(null, ' + ns + '.join(""), ' + ns + ');');
//Add base and rel properties to buffer if includes were used
if(this.includes > 0)
{
var tempBuf = this.buf.substr(baseRelStart);
this.buf = this.buf.substr(0, baseRelStart);
if(this.options.basedir)
this._pushOff(ns + ".base=" + JSON.stringify(this.options.basedir) + ";");
this._pushOff(ns + ".rel=" + JSON.stringify(this.options.reldir) + ";");
this.buf += tempBuf;
}
if(this.options.debug)
console.log("Template:\n" + this.buf +
"\n---------------------------------------------");
//Wrapper only accepts `locals` and `cb`
var wrapper = function(locals, cb) {
var info = [];
info.r = runtime;
wrapper.template(locals, cb, info);
}
try {
//The actual template exepects `locals`, `runtime`, and `cb`
if(this.options.debug)
console.log("Compiling template...");
wrapper.template = new Function("locals", "cb", ns, this.buf);
if(this.options.debug)
console.log("Template compiled successfully!");
wrapper.filename = this.options.filename;
wrapper.minify = this.options.minify;
wrapper.dependencies = this.dependencies;
wrapper.reldir = this.options.reldir;
wrapper.unknownDependencies = this.unknownDependencies === true;
wrapper.toString = function() {
//Try to use uglify-js
return bladeutil.uglify(this.template.toString(), this.minify);
};
} catch(e) {
/* Note: The error object generated when calling `new Function(...)` does not
contain any useful information about where the error occurred. No line
number. No offset. Nothing.
This is not only a Node.js limitation, it is a Google V8 limitation.
I don't like this. If you don't like this, go complain:
Node.js issue: https://github.com/joyent/node/issues/2734
Google V8 issue: http://code.google.com/p/v8/issues/detail?id=1914
*/
e.message = "An error occurred while generating a function from the" +
" compiled template. This could be a problem with Blade, or it could be a" +
" syntax issue with the JavaScript code in your template: " + e.message;
throw e;
}
} catch(e) {
if(this.options.filename)
e.filename = this.options.filename;
e.message = "Compile error: " + e.message;
e.source = this.string;
return cb(runtime.rethrow(e) );
}
cb(null, wrapper);
}
Compiler.prototype.addDoctype = function(name, value) {
this.options.doctypes[name] = value;
};
Compiler.prototype.addFilter = function(name, filter) {
this.options.filters[name] = filter;
};
Compiler.prototype._push = function(str) {
if(this.inPush)
this.buf += "+" + str;
else
{
this.buf += this.options.templateNamespace + ".push(" + str;
this.inPush = true;
}
};
Compiler.prototype._pushOff = function(str) {
if(this.inPush)
{
this.buf += ");" + str;
this.inPush = false;
}
else
this.buf += str;
}
Compiler.prototype._compileDoctype = function(doctype) {
var ns = this.options.templateNamespace;
if(this.options.doctypes[doctype])
this._push(JSON.stringify(this.options.doctypes[doctype]) );
else
this._push(JSON.stringify('<!DOCTYPE ' + doctype + '>') );
}
Compiler.prototype._compileNode = function(node) {
var ns = this.options.templateNamespace;
if(this.options.minify !== true && node.line != null && (this.lastNode == null ||
this.lastNode.type != "code" || this.lastNode.children.length == 0 ||
node.type != "code") && node.type != "foreach_else")
{
this._pushOff(ns + ".line=" + node.line + "," + ns + ".col=" + node.col + ";");
if(node.type != "code")
this.lastNode = node;
}
if(this.prependNewline && node.type != "text" && node.type != "filtered_text")
this.prependNewline = false;
switch(node.type)
{
case 'tag':
var attrs = node.attributes;
//id attribute
if(node.id)
{
/* If the tag doesn't have an "id" attribute, add it now; otherwise,
only add it if the attribute is 'code'.
That is, `div(id="foo")` always takes precedence over `div#foo`
*/
if(!attrs.id || attrs.id.text == "")
attrs.id = {'escape': false, 'text': node.id};
else if(attrs.id.code)
attrs.id.code = "(" + attrs.id.code + ") || " + JSON.stringify(node.id);
}
//class attribute
if(node.classes.length > 0)
{
/* If the tag doesn't have a "class" attribute, add it now; otherwise,
if the "class" attribute is text, just append to it now; otherwise,
append the classes at runtime
*/
if(!attrs['class'])
attrs['class'] = {'escape': false, 'text': node.classes.join(" ")};
else if(attrs['class'].text)
attrs['class'].text += (attrs['class'].text.length > 0 ? " " : "") +
node.classes.join(" ");
else
attrs['class'].append = node.classes.join(" "); //append classes at runtime
}
//event handlers
var numEventHandlers = 0,
eventHandlerID;
for(var i = 0; i < node.children.length; i++)
if(node.children[i].type == "event_handler")
{
//This is the first event handler, so we have some setup work to do...
if(numEventHandlers++ == 0)
{
//Add id attribute, if necessary
if(!attrs.id)
{
eventHandlerID = "'blade_'+" + ns + ".r.ueid";
attrs.id = {
"escape": false,
"code": eventHandlerID + "++"
};
}
else
eventHandlerID = attrs.id.text ? JSON.stringify(attrs.id.text) :
attrs.id.code;
}
if(node.children[i].event_handler.length == 0) continue;
//Setup the event handler
this._pushOff(ns + ".r.bind(" + JSON.stringify(node.children[i].events.toLowerCase() ) +
"," + eventHandlerID + ",function(e){" +
node.children[i].event_handler + "\n}," + ns +
(numEventHandlers > 1 ? ",1" : "") + ");");
//Add event attributes
var events = node.children[i].events.split(" ");
for(var j = 0; j < events.length; j++)
attrs["on" + events[j].toLowerCase() ] = {
"escape": false,
"text": "return blade.Runtime.trigger(this,arguments);"
};
}
//start tag
this._push((node.prependSpace ? "' <" : "'<") + node.name + "'");
//attributes
var varAttrs = "";
for(var i in attrs)
{
//interpolate text attributes
if(attrs[i].text)
{
var stringified = JSON.stringify(attrs[i].text),
interpolated = bladeutil.interpolate(stringified, ns);
//check to see if this text attribute needs to be interpolated
if(interpolated != stringified)
{
delete attrs[i].text;
attrs[i].code = interpolated;
}
}
//take care of text attributes here
if(attrs[i].text != null)
{
if(attrs[i].escape)
this._push("' " + i + "=" + bladeutil.quote(
JSON.stringify(runtime.escape(attrs[i].text)) ) + "'");
else
this._push("' " + i + "=" + bladeutil.quote(
JSON.stringify(attrs[i].text) ) + "'");
}
//take care of code attributes here
else
varAttrs += "," + JSON.stringify(i) + ":{v:" + attrs[i].code +
(attrs[i].escape ? ",e:1" : "") +
(i == "class" && attrs[i].append ?
",a:" + JSON.stringify(attrs[i].append): "") + "}";
}
if(varAttrs.length > 0)
this._pushOff(ns + ".r.attrs({" + varAttrs.substr(1) + "}, " + ns + ");");
//child nodes and end tag
if(this.options.selfClosingTags.indexOf(node.name) >= 0)
{
this._push("'/>'");
if(node.children.length > numEventHandlers)
{
var e = new Error("Self-closing tag <" + node.name +
"/> may not contain any children.");
e.line = node.line, e.column = node.col;
throw e;
}
}
else
{
this._push("'>'");
for(var i = 0; i < node.children.length; i++)
if(node.children[i].type != "event_handler")
this._compileNode(node.children[i]);
this._push("'</" + node.name + ">'");
}
if(node.appendSpace)
this._push("' '");
break;
case 'event_handler':
var e = new Error("Event handlers may only be defined for a tag.");
e.line = node.line, e.column = node.col;
throw e;
break;
case 'text':
//Ensure we prepend a newline if the last node was a text node.
if(this.prependNewline)
node.text = "\n" + node.text;
//Interpolate #{stuff}
var interpolated = bladeutil.interpolate(JSON.stringify(node.text), ns);
//Optionally escape the text
if(node.escape)
{
//If no string interpolation was used in this node, we can just escape it now
if(interpolated == JSON.stringify(node.text) )
this._push(JSON.stringify(runtime.escape(node.text) ) );
//Otherwise, we'll escape it at runtime
else
this._push(ns + ".r.escape(" + interpolated + ")");
}
else
this._push(interpolated);
this.prependNewline = true;
break;
case 'code_output':
/* This is a text block that contains code, which should be outputed into
the view. If the code ends in a semicolon, we just remove it for
convenience. The extra semicolon can break things. :) */
if(node.code_output.charAt(node.code_output.length - 1) == ";")
node.code_output = node.code_output.substr(0, node.code_output.length - 1);
var parens = node.code_output.indexOf(",") >= 0;
if(node.escape)
this._push(ns + ".r.escape(" + (parens ? "(" : "") + node.code_output +
"\n)" + (parens ? ")" : "") );
else
this._push((parens ? "(" : "") + node.code_output + "\n" +
(parens ? ")" : "") );
break;
case 'filtered_text':
if(typeof this.options.filters[node.name] != "function")
{
var e = new Error("Invalid filter name: " + node.name);
e.line = node.line, e.column = node.col;
throw e;
}
var output = this.options.filters[node.name](node.filtered_text, {
'minify': this.options.minify,
'compress': this.options.minify,
'filename': this.options.filename
});
//Ensure we prepend a newline if the last node was a text node.
if(this.prependNewline)
output = "\n" + output;
if(this.options.filters[node.name].interpolate === false)
this._push(JSON.stringify(output) );
else
this._push(bladeutil.interpolate(JSON.stringify(output), ns) );
this.prependNewline = true;
break;
case 'comment':
if(!node.hidden)
{
this._push("'<!--'");
var start = this.buf.length; //Keep track of where the comment begins
this._push(JSON.stringify(node.comment) );
if(node.children) //unparsed comments do not have children attribute
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
//Escape all instances of "<!--" and "-->"
var comment = this.buf.substr(start)
.replace(/<!--/g, "<!-!-")
.replace(/-->/g, "-!->");
this.buf = this.buf.substr(0, start) + comment;
//Now end the comment
this._push("'-->'");
}
//if node.hidden, we ignore it entirely
break;
case 'conditional_comment':
this._push("'<!--['");
this._push(JSON.stringify(node.comment) );
this._push("']>'");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._push("'<![endif]-->'");
break;
case 'code':
/* A line of code, which does not output into the view.
If the node has children, we use the curly brace to enclose them;
otherwise, we terminate the code with a semicolon */
if(node.multiline)
{
var e = new Error("Code statements spanning mulitple lines " +
"must be indented underneath a line of code.");
e.line = node.line, e.column = node.col;
throw e;
}
this._pushOff(node.code + "\n");
if(node.children.length > 0)
{
//Process mutli-line code statements first
for(var i = 0; i < node.children.length; i++)
{
if(node.children[i].type == "code" && node.children[i].multiline)
{
this._pushOff(node.children[i].code + "\n");
if(node.children[i].children.length > 0)
{
var e = new Error("Code statements spanning mulitple lines " +
"cannot have child nodes.\nPlease check to ensure " +
"nothing is indented underneath the `_` line of code.");
e.line = node.children[i].line, e.column = node.children[i].col;
throw e;
}
}
else
break;
}
//Now process all other child nodes
var insertCurlyBrace = i < node.children.length;
if(insertCurlyBrace)
this._pushOff("{");
for(; i < node.children.length; i++)
this._compileNode(node.children[i]);
//Now we end the code statement with a curly brace or semicolon
if(insertCurlyBrace)
this._pushOff("}");
else
this._pushOff(";");
}
else
this._pushOff(";");
break;
case 'include':
if(this.includes == -1)
{
var e = new Error("Includes will not work unless the `filename` property" +
" is passed to the compiler.");
e.line = node.line, e.column = node.col;
throw e;
}
this.includes++;
var exposedVarList = "";
if(node.exposing)
for(var i = 0; i < node.exposing.length; i++)
exposedVarList += "," + JSON.stringify(node.exposing[i]) + "," + node.exposing[i];
if(node.code)
{
this._pushOff(ns + ".r.include(" + node.code + "," + ns + exposedVarList + ");");
this.unknownDependencies = true;
}
else if(node.filename.length > 0)
{
this._pushOff(ns + ".r.include(" + JSON.stringify(node.filename) + "," +
ns + exposedVarList + ");");
//Add to list of dependencies
if(this.dependencies.indexOf(node.filename) < 0)
this.dependencies.push(node.filename);
}
else
{
var e = new Error("Invalid include statement: You must specify a filename");
e.line = node.line, e.column = node.col;
throw e;
}
break;
case 'block':
this.blockDeclarations = true;
var paramStr = node.parameters == null ? "" : "," + node.parameters.join(",");
this._pushOff(ns + ".r.blockDef(" + JSON.stringify(node.name) + ", " + ns +
", function(" + ns + paramStr + ") {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("});");
break;
case 'render':
this._pushOff(ns + ".r.blockRender('" + node.behavior.charAt(0) + "', " +
JSON.stringify(node.name) + ", " + ns +
(node.arguments.length > 0 ? ", " + node.arguments : "") + ");");
break;
case 'append':
case 'prepend':
if(node.parameters != null)
{
var e = new Error("Parameters are not permitted for `" +
node.type + " block`.");
e.line = node.line, e.column = node.col;
throw e;
}
case 'replace':
var paramStr = node.parameters == null ? "" : "," + node.parameters.join(",");
this._pushOff(ns + ".r.blockMod('" + node.type.charAt(0) + "', " +
JSON.stringify(node.name) + ", " + ns + ", " +
"function(" + ns + paramStr + ") {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("});");
break;
case 'function':
var paramStr = node.parameters == null ? "" : "," + node.parameters.join(",");
this._pushOff(ns + ".r.func(" + JSON.stringify(node.name) + ",function(" +
ns + paramStr + ") {");
/* If the first child node is a tag, ensure that its attributes are modified
to allow a user to pass in an id or classes when the function is called */
if(node.children.length > 0 && node.children[0].type == "tag")
{
var attrs = node.children[0].attributes;
modifyAttribute('id');
modifyAttribute('class');
function modifyAttribute(attrName) {
//If the attribute does not exist, simply add it as code; no need to escape
if(attrs[attrName] == null)
attrs[attrName] = {'escape': false, 'code': "this." + (attrName == "class" ? "classes" : attrName) };
//Merge passed-in classes with the class attribute
else if(attrName == "class")
{
if(attrs[attrName].code)
attrs[attrName].code = "(this.classes || []).concat(" + attrs[attrName].code + ")";
else
{
attrs[attrName].code = "(this.classes || []).concat(" +
bladeutil.interpolate(JSON.stringify(attrs[attrName].text) ) + ")";
delete attrs[attrName].text;
}
}
//Replace the attribute with the passed-in attribute value
else if(attrs[attrName].code)
attrs[attrName].code = "this." + attrName + '||' + attrs[attrName].code;
else
{
attrs[attrName].code = "this." + attrName + '||' +
bladeutil.interpolate(JSON.stringify(attrs[attrName].text) );
delete attrs[attrName].text;
}
}
}
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
//The buffer length before the function call is returned by the function
this._pushOff("}," + ns + ");");
break;
case 'call':
//The buffer length before the function call is returned by the function
if(node.output)
this._pushOff(node.output.to + (node.output.append ? "+=" : "=") + ns +
".r.capture(" + ns + "," + ns + ".length,");
if(node.arguments != "")
node.arguments = "," + node.arguments;
this._pushOff(ns + ".r.call(" + JSON.stringify(node.name) + ",{" +
(node.id ? "id:" + JSON.stringify(node.id) + "," : "") +
(node.classes.length > 0 ? "classes:" +
JSON.stringify(node.classes) + "," : "") +
"}," + ns + node.arguments + (node.output ? ") );" : ");") );
break;
case 'chunk':
console.warn("Blade chunks are now deprecated. Please fix " +
(this.options.filename ? this.options.filename + ":" : "line ") +
node.line + ":" + node.col);
var paramStr = node.parameters == null ? "" : node.parameters.join(",");
this._pushOff(ns + ".r.chunk(" + JSON.stringify(node.name) + ",function(" +
paramStr + ") {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("}," + ns + ");");
break;
case 'isolate':
this._pushOff(ns + ".r.isolate(function() {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("}," + ns + ");");
break;
case 'constant':
this._pushOff(ns + ".r.constant(" + node.line + ",function() {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("}," + ns + ");");
break;
case 'preserve':
if(!node.preserved)
node.preserved = "[]";
this._pushOff(ns + ".r.preserve(" + node.line + ",(" + node.preserved + ")||[],function() {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("}," + ns + ");");
break;
case 'foreach':
this._pushOff(ns + ".r.foreach(" + ns + "," + node.cursor + ",function(" +
(node.itemAlias ? node.itemAlias : "") + ") {");
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("});");
break;
case 'foreach_else':
if(!this.lastNode || this.lastNode.type != "foreach")
{
var e = new Error("No matching foreach list block. You cannot put a foreach else block here!");
e.line = node.line, e.column = node.col;
throw e;
}
//Remove trailing ");" and add elseFunc argument to the Runtime.foreach(...) call.
this.buf = this.buf.substr(0, this.buf.length - 2) + ",function() {";
for(var i = 0; i < node.children.length; i++)
this._compileNode(node.children[i]);
this._pushOff("});");
break;
case 'blank_line':
//Ignore these lines
break;
default:
var e = new Error("Unknown node type: " + node.type);
e.line = node.line, e.column = node.col;
throw e;
break;
}
if(this.prependNewline && node.type != "text" && node.type != "filtered_text")
this.prependNewline = false;
this.lastNode = node;
}