tinyliquid
Version:
A liquid template engine
769 lines (683 loc) • 20.8 kB
JavaScript
/**
* Parse template
*
* @author Zongmin Lei<leizongmin@gmail.com>
*/
var utils = require('./utils');
var OPCODE = require('./opcode');
var merge = utils.merge;
var ASTStack = utils.ASTStack;
var localsAstNode = utils.localsAstNode;
var isQuoteWrapString = utils.isQuoteWrapString;
var textIndexOf = utils.textIndexOf;
var splitText = utils.splitText;
var stripQuoteWrap = utils.stripQuoteWrap;
var jsonStringify = utils.jsonStringify;
var md5 = utils.md5;
var arrayRemoveEmptyString = utils.arrayRemoveEmptyString;
var genRandomName = utils.genRandomName;
/**
* Parser context object
*
* @param {Object} options
*/
var Context = function (options) {
this.astStack = new ASTStack();
this.tags = options.customTags;
this.raw = '';
this.disableParseTag = false;
this.line = 1;
this.lineStart = 0;
this.position = 0;
this.parseTagStack = [];
this.forItems = [];
this.tablerowItems = [];
this.forItems.test = this.tablerowItems.test = function (name) {
var name = name.split('.')[0];
return this.indexOf(name) === -1 ? false : true;
};
};
/**
* Enable parse tag
*/
Context.prototype.enableParseTag = function () {
var parseTagStack = this.parseTagStack;
if (parseTagStack.length < 1) {
return true;
} else {
return parseTagStack[parseTagStack.length - 1].apply(null, arguments);
}
};
/**
* Get current position
*
* @return {Object}
*/
Context.prototype.getPosition = function () {
return {
line: this.line,
column: this.position - this.lineStart + 2
};
};
/**
* Generate a new AST node
*
* @return {Array}
*/
Context.prototype.astNode = function () {
var pos = this.getPosition();
var ast = [pos.line, pos.column];
for (var i = 0, len = arguments.length; i < len; i++) {
ast.push(arguments[i]);
}
return ast;
};
/**
* Parse template, return AST array
*
* @param {String} tpl
* @param {Object} options
* - {Object} customTags
* @return {Array}
*/
var parser = exports = module.exports = function (tpl, options) {
options =options || {};
var customTags = options.customTags = merge(baseTags, options.customTags);
// parser context
var context = new Context(options);
// compiler version
context.astStack.push(context.astNode(OPCODE.COMPILER_VERSION, 1));
var mainAst = context.astNode(OPCODE.LIST);
var strTmp = '';
function flush () {
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, strTmp));
strTmp = '';
}
for (var i = 0, len = tpl.length; i < len; i++) {
context.position = i;
var c = tpl[i];
if (c === '\n') {
context.line++;
context.lineStart = i;
}
var text = tpl.substr(i, 2);
if (context.disableParseTag) {
// -----------------------------------------------------------------------
// raw
if (text === '{%') {
var e = textIndexOf(tpl, '%}', i);
var body = tpl.slice(i + 2, e).trim();
context.raw = strTmp;
if (e > i && context.enableParseTag(context, body, body)) {
context.disableParseTag = false;
strTmp = '';
context.raw = '';
i = e + 1;
} else {
strTmp += c;
}
} else {
strTmp += c;
}
} else { // ----------------------------------------------------------------
// normal
if (text === '{{') {
var e = textIndexOf(tpl, '}}', i);
if (e > i) {
flush();
context.astStack.push(parseOutput(tpl.slice(i + 2, e).trim(), context));
i = e + 1;
}
} else if (text === '{%') {
var e = textIndexOf(tpl, '%}', i);
if (e > i) {
// optimize: trim left
var e2 = strTmp.lastIndexOf('\n');
if (e2 !== -1) {
if (strTmp.slice(e2 + 1).trim() === '') {
strTmp = strTmp.slice(0, e2 + 1);
}
}
// parse tag
flush();
parseTag(context, tpl.slice(i + 2, e).trim());
i = e + 1;
// optimize: trim right
var e3 = tpl.indexOf('\n', i + 1);
if (e3 !== -1) {
if ((tpl.slice(i + 1, e3 + 1).trim() === '')) {
i = e3;
context.line++;
context.lineStart = i;
}
}
}
} else {
strTmp += c;
}
// -----------------------------------------------------------------------
}
}
flush();
return mainAst.concat(context.astStack.result());
};
// Default parser component
var baseTags = {
'if': function (context, name, body) {
var ast = parseCondition(body, context);
context.astStack.newChild(context.astNode(OPCODE.IF, ast)).newChild(context.astNode(OPCODE.LIST));
},
'unless': function (context, name, body) {
var ast = parseCondition(body, context);
context.astStack.newChild(context.astNode(OPCODE.IF, context.astNode(OPCODE.NOT, ast))).newChild(context.astNode(OPCODE.LIST));
},
'else': function (context, name, body) {
context.astStack.close().newChild(context.astNode(OPCODE.LIST));
},
'endif': function (context, name, body) {
context.astStack.close();
// reset the AST structure
var ast = context.astStack.last();
context.astStack.close();
var reset = function (ast) {
if (ast.length > 6) {
var a = ast.slice(0, 5);
a[5] = reset(context.astNode(OPCODE.IF).concat(ast.slice(5)));
return a;
} else {
return ast;
}
};
var list = context.astStack.last();
if (list) {
list.pop();
list.push(reset(ast));
} else {
context.astStack.list.push(context.astNode(OPCODE.PRINTSTRING, '{% endif %}'));
}
},
'endunless': function (context, name, body) {
context.astStack.close().close();
},
'elseif': function (context, name, body) {
context.astStack.close();
var ast = parseCondition(body, context);
context.astStack.push(ast).newChild(context.astNode(OPCODE.LIST));
},
'elsif': function (context, name, body) {
context.astStack.close();
var ast = parseCondition(body, context);
context.astStack.push(ast).newChild(context.astNode(OPCODE.LIST));
},
'case': function (context, name, body) {
var ast = parseVariables(body, context);
context.astStack.newChild(context.astNode(OPCODE.CASE)).newChild(ast);
},
'when': function (context, name, body) {
context.astStack.close();
var ast = parseWhen(body, context);
context.astStack.push(context.astNode(OPCODE.WHEN, ast)).newChild(context.astNode(OPCODE.LIST));
},
'endcase': function (context, name, body) {
context.astStack.close().close();
},
'for': function (context, name, body) {
var arr = parseFor(body);
var attrs = arr[2];
context.astStack.newChild(context.astNode(OPCODE.FOR, localsAstNode(arr[0], context), arr[1],
attrs.offset, attrs.limit)).newChild(context.astNode(OPCODE.LIST));
context.forItems.push(arr[1]);
},
'endfor': function (context, name, body) {
context.astStack.close().close();
context.forItems.pop();
},
'tablerow': function (context, name, body) {
var arr = parseFor(body);
var attrs = arr[2];
attrs.cols = parseInt(attrs.cols);
if (!(attrs.cols > 1)) attrs.cols = 1;
context.astStack.newChild(context.astNode(OPCODE.TABLEROW, localsAstNode(arr[0], context), arr[1],
attrs.offset, attrs.limit, attrs.cols))
.newChild(context.astNode(OPCODE.LIST));
context.tablerowItems.push(arr[1]);
},
'endtablerow': function (context, name, body) {
context.astStack.close().close();
context.tablerowItems.pop();
},
'assign': function (context, name, body) {
var i = body.indexOf('=');
if (i !== -1) {
var left = body.substr(0, i).trim();
var right = body.substr(i + 1).trim();
var ast = parseVariables(right, context);
context.astStack.push(context.astNode(OPCODE.ASSIGN, left, ast));
}
},
'capture': function (context, name, body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ']));
var name = blocks[0] || genRandomName();
if (!blocks[0]) {
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, 'warning: missing name in {% capture %}'));
}
context.astStack.newChild(context.astNode(OPCODE.CAPTURE, name));
},
'endcapture': function (context, name, body) {
context.astStack.close();
},
'block': function (context, name, body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ']));
var name = blocks[0] || genRandomName();
if (!blocks[0]) {
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, 'warning: missing name in {% block %}'));
}
context.astStack.newChild(context.astNode(OPCODE.BLOCK, name));
},
'endblock': function (context, name, body) {
context.astStack.close();
},
'cycle': function (context, name, body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ', ',']));
blocks = blocks.filter(function (item) {
return item === ',' ? false : true;
});
if (blocks.length > 0) {
var i = blocks[0].indexOf(':');
if (i !== -1) {
var key = blocks[0].substr(0, i);
blocks[0] = blocks[0].substr(i + 1);
if (blocks[0].length < 1) {
blocks.shift();
}
} else {
var key = md5(blocks.join(':')).substr(0, 8);
}
blocks = blocks.map(function (item) {
return localsAstNode(item, context);
});
context.astStack.push(context.astNode(OPCODE.CYCLE, key).concat(blocks));
}
},
'extends': function (context, name, body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ']));
if (blocks.length === 0) {
// syntax error
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, '{% extends ' + body + ' %}'));
return;
}
// get the filename
var bf = blocks[0];
if (bf.substr(0, 2) === '{{') {
// filename is a variable
for (var i = 1; i < blocks.length; i++) {
var b = blocks[i];
bf += b;
if (b.substr(-2) === '}}') {
break;
}
}
filename = parseVariables(bf.slice(2, -2), context);
blocks = blocks.slice(i + 1);
} else {
// filename is a string
filename = stripQuoteWrap(bf);
blocks = blocks.slice(1);
}
context.astStack.push(context.astNode(OPCODE.EXTENDS, filename));
},
'include': function (context, name, body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ']));
var filename, withLocals, parameters;
// support the following pattern:
// {% include xxx %} or {% include "xxx" %}
// {% include {{xx}} %} and with filters: {% include {{xx | yy}} %}
// {% include xxx with yy %}
// {% include xxx a=1 b=2 %}
if (blocks.length === 0) {
// syntax error
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, '{% include ' + body + ' %}'));
return;
} else if (blocks.length === 1 &&
!(blocks[0].substr(0, 2) === '{{' && blocks[0].substr(-2) === '}}')) {
// filename is a string
filename = stripQuoteWrap(blocks[0]).trim();
} else {
if (blocks.length >= 3 && blocks[blocks.length - 2].toLowerCase() === 'with') {
// if include "with" syntax
withLocals = localsAstNode(stripQuoteWrap(blocks[blocks.length - 1]), context);
blocks = blocks.slice(0, -2);
}
// get the filename
var bf = blocks[0];
if (bf.substr(0, 2) === '{{') {
// filename is a variable
for (var i = 1; i < blocks.length; i++) {
var b = blocks[i];
bf += b;
if (b.substr(-2) === '}}') {
break;
}
}
filename = parseVariables(bf.slice(2, -2), context);
blocks = blocks.slice(i + 1);
} else {
// filename is a string
filename = stripQuoteWrap(bf).trim();
blocks = blocks.slice(1);
}
// parse multi-part parameters
if (blocks.length > 0) {
blocks = arrayRemoveEmptyString(splitText(blocks.join(' '), [' ', '=']));
var parts = [];
var pi = 0;
function addPart (i) {
if (i < 0) return;
parts.push(blocks.slice(pi, i + 1).join(''));
pi = i + 1;
}
for (var i = 0; i < blocks.length; i++) {
var b = blocks[i];
if (b === '=') {
addPart(i - 2);
}
}
addPart(i);
parameters = context.astNode(OPCODE.LIST);
//console.log(blocks, parts);
parts.forEach(function (part) {
var i = part.indexOf('=');
if (i !== -1) {
var left = part.substr(0, i).trim();
var right = part.substr(i + 1).trim();
var ast = parseVariables(right, context);
parameters.push(context.astNode(OPCODE.WEAK_ASSIGN, left, ast));
}
});
}
}
context.astStack.push(context.astNode(OPCODE.INCLUDE, filename, withLocals, parameters));
},
'raw': function (context, name, body) {
context.disableParseTag = true;
context.parseTagStack.push(context.tags.endraw);
},
'endraw': function (context, name, body) {
if (name.toLowerCase() === 'endraw') {
context.astStack.push(context.astNode(OPCODE.PRINTSTRING, context.raw));
return true;
} else {
return false;
}
},
'comment': function (context, name, body) {
context.disableParseTag = true;
context.parseTagStack.push(context.tags.endcomment);
},
'endcomment': function (context, name, body) {
if (name.toLowerCase() === 'endcomment') {
context.astStack.push(context.astNode(OPCODE.COMMENT, context.raw));
return true;
} else {
return false;
}
}
};
/**
* Parse "filter"
*
* @param {String} text
* @param {Array} firstArg
* @param {Array} link
* @param {Object} context
* @return {Array}
*/
var parseFilter = parser.parseFilter = function (text, firstArg, link, context) {
text = text.trim();
var i = text.indexOf(':');
if (i === -1) {
var name = text;
var args = [];
} else {
var name = text.slice(0, i);
var args = splitText(text.slice(i + 1).trim(), [',']).filter(function (item) {
return (item !== ',');
});
}
args = args.map(function (item) {
return localsAstNode(item.trim(), context);
});
args.unshift(firstArg);
var ast = context.astNode(OPCODE.FILTER, name).concat(args);
if (link.length > 0) {
return parseFilter(link.shift(), ast, link, context);
} else {
return ast;
}
};
/**
* Parse "condition"
*
* @param {String} body
* @param {Object} context
* @return {Array}
*/
var parseCondition = parser.parseCondition = function (body, context) {
var cond = body.trim();
var blocks = arrayRemoveEmptyString(splitText(cond,
[' ', '===', '&&', '||', '>=', '<=', '==', '!=', '<>', '=', '>', '<', '!']));
var trans = {
'&&': 'and',
'||': 'or',
'>': 'gt',
'<': 'lt',
'=': 'eq',
'==': 'eq',
'===':'ed',
'<>': 'ne',
'!=': 'ne',
'>=': 'ge',
'<=': 'le',
'!': 'not'
};
blocks = blocks.map(function (item) {
return (trans[item] || item);
});
// extract the "and" and "or"
var _blocks = blocks;
blocks = [];
var tmp = [];
var flush = function () {
if (tmp.length > 0) {
blocks.push(tmp);
tmp = [];
}
};
_blocks.forEach(function (item) {
if (item.toLowerCase() === 'and' || item.toLowerCase() === 'or') {
flush();
blocks.push(item.toLowerCase());
} else {
tmp.push(item);
}
});
flush();
// generate condition AST
var condAst = [];
blocks.forEach(function (item) {
if (Array.isArray(item)) {
if (item.length === 1) {
var ast = context.astNode(OPCODE.EXISTS, localsAstNode(item[0], context));
} else if (item.length === 2) {
var code = OPCODE[item[0].toUpperCase()] || OPCODE.DEBUG;
var ast = context.astNode(code, localsAstNode(item[1], context));
} else {
var code = OPCODE[item[1].toUpperCase()] || OPCODE.DEBUG;
var ast = context.astNode(code, localsAstNode(item[0], context), localsAstNode(item[2], context));
}
condAst.push(ast);
} else {
condAst.push(item);
}
});
var mergeCond = function (op) {
var ret = false;
if (blocks.length < 3) return ret;
var _condAst = condAst;
condAst = [];
for (var i = 0, len = _condAst.length; i < len; i++) {
var mid = _condAst[i + 1];
if (typeof(mid) === 'string' && mid.toLowerCase() === op && i + 2 < len) {
var code = OPCODE[op.toUpperCase()] || OPCODE.DEBUG;
condAst.push(context.astNode(code, _condAst[i], _condAst[i + 2]));
i += 2;
ret = true;
} else {
condAst.push(_condAst[i]);
}
}
return ret;
};
// and > or
while (mergeCond('and')) {
// do nothing
}
while (mergeCond('or')) {
// do nothing
}
return condAst[0];
};
/**
* Parse "when"
*
* @param {String} body
* @param {Object} context
* @return {Array}
*/
var parseWhen = parser.parseWhen = function (body, context) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ', 'or']));
blocks = blocks.filter(function (item) {
return item === 'or' ? false : true;
}).map(function (item) {
var ast = localsAstNode(item, context);
if (!Array.isArray(ast)) ast = context.astNode(OPCODE.OBJECT, ast);
return ast;
});
return blocks;
};
/**
* Parse "variables"
* 如: a | call:1,2 | lower
*
* @param {String} text
* @param {Object} context
* @return {Array}
*/
var parseVariables = parser.parseVariables = function (text, context) {
var i = 0;
var filters = [];
while (true) {
var e = textIndexOf(text, '|', i);
if (e === -1) {
break;
} else {
filters.push(text.slice(i, e).trim());
i = e + 1;
}
}
if (filters.length > 0) {
filters.push(text.slice(i).trim());
}
if (filters.length > 1) {
var name = filters.shift();
var astList = parseFilter(filters.shift(), localsAstNode(name, context), filters, context);
} else {
var astList = localsAstNode(text, context);
}
return astList;
};
/**
* Parse "for"
*
* @param {String} body
* @return {Array}
*/
var parseFor = parser.parseFor = function (body) {
var blocks = arrayRemoveEmptyString(splitText(body, [' ']));
var parseAttrs = function (blocks) {
if (blocks.length < 1) return {};
var attrString = blocks.reduce(function (sum, item) {
if (item === ':') return sum;
if (sum.substr(-1) === ':') return sum + item;
return sum + ' ' + item;
});
var attrs = {};
arrayRemoveEmptyString(splitText(attrString, [' ']))
.forEach(function (item) {
var i = item.indexOf(':');
if (i === -1) {
attrs[item.toLowerCase()] = true;
} else {
attrs[item.substr(0, i).toLowerCase()] = item.substr(i + 1);
}
});
return attrs;
};
if (blocks.length >= 3 && blocks[1].toLowerCase() === 'in') {
// normal
var itemName = blocks[0];
var arrayName = blocks[2];
var attrs = parseAttrs(blocks.slice(3));
} else if (blocks.length === 1 ||
(blocks.length > 1 && blocks[1].toLowerCase() !== 'in' && blocks[1].indexOf(':') === -1)) {
// non-standard writing: {% for array %}
var itemName = 'item';
var arrayName = blocks[0];
var attrs = parseAttrs(blocks.slice(1));
}
if (!(attrs.offset > 0)) attrs.offset = 0;
if (!(attrs.limit > 0)) attrs.limit = 0;
return [arrayName, itemName, attrs];
};
/**
* Parse "{{name}}"
*
* @param {String} text
* @param {Object} context
* @return {Array}
*/
var parseOutput = function (text, context) {
var astList = parseVariables(text, context);
if (Array.isArray(astList)) {
if (astList[2] === OPCODE.LOCALS) {
return context.astNode(OPCODE.PRINTLOCALS).concat(astList.slice(3));
} else {
return context.astNode(OPCODE.PRINT, astList);
}
} else {
return context.astNode(OPCODE.PRINTSTRING, astList);
}
};
/**
* Parse "{%tag%}"
*
* @param {Object} context
* @param {String} text
* @return {Array}
*/
var parseTag = function (context, text) {
var i = text.indexOf(' ');
if (i === -1) {
var name = text;
var body = '';
} else {
var name = text.slice(0, i);
var body = text.slice(i + 1).trim();
}
name = name.toLowerCase();
if (typeof(context.tags[name]) === 'function') {
context.tags[name](context, name, body);
} else {
context.astStack.push(context.astNode(OPCODE.UNKNOWN_TAG, name, body));
}
};