You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
790 lines
18 KiB
790 lines
18 KiB
'use strict';
|
|
|
|
var Lexer = require('./lexer');
|
|
var nodes = require('./nodes');
|
|
var utils = require('./utils');
|
|
var filters = require('./filters');
|
|
var path = require('path');
|
|
var constantinople = require('constantinople');
|
|
var parseJSExpression = require('character-parser').parseMax;
|
|
var extname = path.extname;
|
|
|
|
/**
|
|
* Initialize `Parser` with the given input `str` and `filename`.
|
|
*
|
|
* @param {String} str
|
|
* @param {String} filename
|
|
* @param {Object} options
|
|
* @api public
|
|
*/
|
|
|
|
var Parser = exports = module.exports = function Parser(str, filename, options){
|
|
//Strip any UTF-8 BOM off of the start of `str`, if it exists.
|
|
this.input = str.replace(/^\uFEFF/, '');
|
|
this.lexer = new Lexer(this.input, filename);
|
|
this.filename = filename;
|
|
this.blocks = {};
|
|
this.mixins = {};
|
|
this.options = options;
|
|
this.contexts = [this];
|
|
this.inMixin = false;
|
|
};
|
|
|
|
/**
|
|
* Parser prototype.
|
|
*/
|
|
|
|
Parser.prototype = {
|
|
|
|
/**
|
|
* Save original constructor
|
|
*/
|
|
|
|
constructor: Parser,
|
|
|
|
/**
|
|
* Push `parser` onto the context stack,
|
|
* or pop and return a `Parser`.
|
|
*/
|
|
|
|
context: function(parser){
|
|
if (parser) {
|
|
this.contexts.push(parser);
|
|
} else {
|
|
return this.contexts.pop();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Return the next token object.
|
|
*
|
|
* @return {Object}
|
|
* @api private
|
|
*/
|
|
|
|
advance: function(){
|
|
return this.lexer.advance();
|
|
},
|
|
|
|
/**
|
|
* Skip `n` tokens.
|
|
*
|
|
* @param {Number} n
|
|
* @api private
|
|
*/
|
|
|
|
skip: function(n){
|
|
while (n--) this.advance();
|
|
},
|
|
|
|
/**
|
|
* Single token lookahead.
|
|
*
|
|
* @return {Object}
|
|
* @api private
|
|
*/
|
|
|
|
peek: function() {
|
|
return this.lookahead(1);
|
|
},
|
|
|
|
/**
|
|
* Return lexer lineno.
|
|
*
|
|
* @return {Number}
|
|
* @api private
|
|
*/
|
|
|
|
line: function() {
|
|
return this.lexer.lineno;
|
|
},
|
|
|
|
/**
|
|
* `n` token lookahead.
|
|
*
|
|
* @param {Number} n
|
|
* @return {Object}
|
|
* @api private
|
|
*/
|
|
|
|
lookahead: function(n){
|
|
return this.lexer.lookahead(n);
|
|
},
|
|
|
|
/**
|
|
* Parse input returning a string of js for evaluation.
|
|
*
|
|
* @return {String}
|
|
* @api public
|
|
*/
|
|
|
|
parse: function(){
|
|
var block = new nodes.Block, parser;
|
|
block.line = 0;
|
|
block.filename = this.filename;
|
|
|
|
while ('eos' != this.peek().type) {
|
|
if ('newline' == this.peek().type) {
|
|
this.advance();
|
|
} else {
|
|
var next = this.peek();
|
|
var expr = this.parseExpr();
|
|
expr.filename = expr.filename || this.filename;
|
|
expr.line = next.line;
|
|
block.push(expr);
|
|
}
|
|
}
|
|
|
|
if (parser = this.extending) {
|
|
this.context(parser);
|
|
var ast = parser.parse();
|
|
this.context();
|
|
|
|
// hoist mixins
|
|
for (var name in this.mixins)
|
|
ast.unshift(this.mixins[name]);
|
|
return ast;
|
|
}
|
|
|
|
return block;
|
|
},
|
|
|
|
/**
|
|
* Expect the given type, or throw an exception.
|
|
*
|
|
* @param {String} type
|
|
* @api private
|
|
*/
|
|
|
|
expect: function(type){
|
|
if (this.peek().type === type) {
|
|
return this.advance();
|
|
} else {
|
|
throw new Error('expected "' + type + '", but got "' + this.peek().type + '"');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Accept the given `type`.
|
|
*
|
|
* @param {String} type
|
|
* @api private
|
|
*/
|
|
|
|
accept: function(type){
|
|
if (this.peek().type === type) {
|
|
return this.advance();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* tag
|
|
* | doctype
|
|
* | mixin
|
|
* | include
|
|
* | filter
|
|
* | comment
|
|
* | text
|
|
* | each
|
|
* | code
|
|
* | yield
|
|
* | id
|
|
* | class
|
|
* | interpolation
|
|
*/
|
|
|
|
parseExpr: function(){
|
|
switch (this.peek().type) {
|
|
case 'tag':
|
|
return this.parseTag();
|
|
case 'mixin':
|
|
return this.parseMixin();
|
|
case 'block':
|
|
return this.parseBlock();
|
|
case 'mixin-block':
|
|
return this.parseMixinBlock();
|
|
case 'case':
|
|
return this.parseCase();
|
|
case 'when':
|
|
return this.parseWhen();
|
|
case 'default':
|
|
return this.parseDefault();
|
|
case 'extends':
|
|
return this.parseExtends();
|
|
case 'include':
|
|
return this.parseInclude();
|
|
case 'doctype':
|
|
return this.parseDoctype();
|
|
case 'filter':
|
|
return this.parseFilter();
|
|
case 'comment':
|
|
return this.parseComment();
|
|
case 'text':
|
|
return this.parseText();
|
|
case 'each':
|
|
return this.parseEach();
|
|
case 'code':
|
|
return this.parseCode();
|
|
case 'call':
|
|
return this.parseCall();
|
|
case 'interpolation':
|
|
return this.parseInterpolation();
|
|
case 'yield':
|
|
this.advance();
|
|
var block = new nodes.Block;
|
|
block.yield = true;
|
|
return block;
|
|
case 'id':
|
|
case 'class':
|
|
var tok = this.advance();
|
|
this.lexer.defer(this.lexer.tok('tag', 'div'));
|
|
this.lexer.defer(tok);
|
|
return this.parseExpr();
|
|
default:
|
|
throw new Error('unexpected token "' + this.peek().type + '"');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Text
|
|
*/
|
|
|
|
parseText: function(){
|
|
var tok = this.expect('text');
|
|
var tokens = this.parseTextWithInlineTags(tok.val);
|
|
if (tokens.length === 1) return tokens[0];
|
|
var node = new nodes.Block;
|
|
for (var i = 0; i < tokens.length; i++) {
|
|
node.push(tokens[i]);
|
|
};
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* ':' expr
|
|
* | block
|
|
*/
|
|
|
|
parseBlockExpansion: function(){
|
|
if (':' == this.peek().type) {
|
|
this.advance();
|
|
return new nodes.Block(this.parseExpr());
|
|
} else {
|
|
return this.block();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* case
|
|
*/
|
|
|
|
parseCase: function(){
|
|
var val = this.expect('case').val;
|
|
var node = new nodes.Case(val);
|
|
node.line = this.line();
|
|
node.block = this.block();
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* when
|
|
*/
|
|
|
|
parseWhen: function(){
|
|
var val = this.expect('when').val
|
|
return new nodes.Case.When(val, this.parseBlockExpansion());
|
|
},
|
|
|
|
/**
|
|
* default
|
|
*/
|
|
|
|
parseDefault: function(){
|
|
this.expect('default');
|
|
return new nodes.Case.When('default', this.parseBlockExpansion());
|
|
},
|
|
|
|
/**
|
|
* code
|
|
*/
|
|
|
|
parseCode: function(){
|
|
var tok = this.expect('code');
|
|
var node = new nodes.Code(tok.val, tok.buffer, tok.escape);
|
|
var block;
|
|
var i = 1;
|
|
node.line = this.line();
|
|
while (this.lookahead(i) && 'newline' == this.lookahead(i).type) ++i;
|
|
block = 'indent' == this.lookahead(i).type;
|
|
if (block) {
|
|
this.skip(i-1);
|
|
node.block = this.block();
|
|
}
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* comment
|
|
*/
|
|
|
|
parseComment: function(){
|
|
var tok = this.expect('comment');
|
|
var node;
|
|
|
|
if ('indent' == this.peek().type) {
|
|
this.lexer.pipeless = true;
|
|
node = new nodes.BlockComment(tok.val, this.parseTextBlock(), tok.buffer);
|
|
this.lexer.pipeless = false;
|
|
} else {
|
|
node = new nodes.Comment(tok.val, tok.buffer);
|
|
}
|
|
|
|
node.line = this.line();
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* doctype
|
|
*/
|
|
|
|
parseDoctype: function(){
|
|
var tok = this.expect('doctype');
|
|
var node = new nodes.Doctype(tok.val);
|
|
node.line = this.line();
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* filter attrs? text-block
|
|
*/
|
|
|
|
parseFilter: function(){
|
|
var tok = this.expect('filter');
|
|
var attrs = this.accept('attrs');
|
|
var block;
|
|
|
|
if ('indent' == this.peek().type) {
|
|
this.lexer.pipeless = true;
|
|
block = this.parseTextBlock();
|
|
this.lexer.pipeless = false;
|
|
} else {
|
|
block = new nodes.Block;
|
|
}
|
|
|
|
var options = {};
|
|
if (attrs) {
|
|
attrs.attrs.forEach(function (attribute) {
|
|
options[attribute.name] = constantinople.toConstant(attribute.val);
|
|
});
|
|
}
|
|
|
|
var node = new nodes.Filter(tok.val, block, options);
|
|
node.line = this.line();
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* each block
|
|
*/
|
|
|
|
parseEach: function(){
|
|
var tok = this.expect('each');
|
|
var node = new nodes.Each(tok.code, tok.val, tok.key);
|
|
node.line = this.line();
|
|
node.block = this.block();
|
|
if (this.peek().type == 'code' && this.peek().val == 'else') {
|
|
this.advance();
|
|
node.alternative = this.block();
|
|
}
|
|
return node;
|
|
},
|
|
|
|
/**
|
|
* Resolves a path relative to the template for use in
|
|
* includes and extends
|
|
*
|
|
* @param {String} path
|
|
* @param {String} purpose Used in error messages.
|
|
* @return {String}
|
|
* @api private
|
|
*/
|
|
|
|
resolvePath: function (path, purpose) {
|
|
var p = require('path');
|
|
var dirname = p.dirname;
|
|
var basename = p.basename;
|
|
var join = p.join;
|
|
|
|
if (path[0] !== '/' && !this.filename)
|
|
throw new Error('the "filename" option is required to use "' + purpose + '" with "relative" paths');
|
|
|
|
if (path[0] === '/' && !this.options.basedir)
|
|
throw new Error('the "basedir" option is required to use "' + purpose + '" with "absolute" paths');
|
|
|
|
path = join(path[0] === '/' ? this.options.basedir : dirname(this.filename), path);
|
|
|
|
if (basename(path).indexOf('.') === -1) path += '.jade';
|
|
|
|
return path;
|
|
},
|
|
|
|
/**
|
|
* 'extends' name
|
|
*/
|
|
|
|
parseExtends: function(){
|
|
var fs = require('fs');
|
|
|
|
var path = this.resolvePath(this.expect('extends').val.trim(), 'extends');
|
|
if ('.jade' != path.substr(-5)) path += '.jade';
|
|
|
|
var str = fs.readFileSync(path, 'utf8');
|
|
var parser = new this.constructor(str, path, this.options);
|
|
|
|
parser.blocks = this.blocks;
|
|
parser.contexts = this.contexts;
|
|
this.extending = parser;
|
|
|
|
// TODO: null node
|
|
return new nodes.Literal('');
|
|
},
|
|
|
|
/**
|
|
* 'block' name block
|
|
*/
|
|
|
|
parseBlock: function(){
|
|
var block = this.expect('block');
|
|
var mode = block.mode;
|
|
var name = block.val.trim();
|
|
|
|
block = 'indent' == this.peek().type
|
|
? this.block()
|
|
: new nodes.Block(new nodes.Literal(''));
|
|
|
|
var prev = this.blocks[name] || {prepended: [], appended: []}
|
|
if (prev.mode === 'replace') return this.blocks[name] = prev;
|
|
|
|
var allNodes = prev.prepended.concat(block.nodes).concat(prev.appended);
|
|
|
|
switch (mode) {
|
|
case 'append':
|
|
prev.appended = prev.parser === this ?
|
|
prev.appended.concat(block.nodes) :
|
|
block.nodes.concat(prev.appended);
|
|
break;
|
|
case 'prepend':
|
|
prev.prepended = prev.parser === this ?
|
|
block.nodes.concat(prev.prepended) :
|
|
prev.prepended.concat(block.nodes);
|
|
break;
|
|
}
|
|
block.nodes = allNodes;
|
|
block.appended = prev.appended;
|
|
block.prepended = prev.prepended;
|
|
block.mode = mode;
|
|
block.parser = this;
|
|
|
|
return this.blocks[name] = block;
|
|
},
|
|
|
|
parseMixinBlock: function () {
|
|
var block = this.expect('mixin-block');
|
|
if (!this.inMixin) {
|
|
throw new Error('Anonymous blocks are not allowed unless they are part of a mixin.');
|
|
}
|
|
return new nodes.MixinBlock();
|
|
},
|
|
|
|
/**
|
|
* include block?
|
|
*/
|
|
|
|
parseInclude: function(){
|
|
var fs = require('fs');
|
|
var tok = this.expect('include');
|
|
|
|
var path = this.resolvePath(tok.val.trim(), 'include');
|
|
|
|
// has-filter
|
|
if (tok.filter) {
|
|
var str = fs.readFileSync(path, 'utf8').replace(/\r/g, '');
|
|
str = filters(tok.filter, str, { filename: path });
|
|
return new nodes.Literal(str);
|
|
}
|
|
|
|
// non-jade
|
|
if ('.jade' != path.substr(-5)) {
|
|
var str = fs.readFileSync(path, 'utf8').replace(/\r/g, '');
|
|
return new nodes.Literal(str);
|
|
}
|
|
|
|
var str = fs.readFileSync(path, 'utf8');
|
|
var parser = new this.constructor(str, path, this.options);
|
|
parser.blocks = utils.merge({}, this.blocks);
|
|
|
|
parser.mixins = this.mixins;
|
|
|
|
this.context(parser);
|
|
var ast = parser.parse();
|
|
this.context();
|
|
ast.filename = path;
|
|
|
|
if ('indent' == this.peek().type) {
|
|
ast.includeBlock().push(this.block());
|
|
}
|
|
|
|
return ast;
|
|
},
|
|
|
|
/**
|
|
* call ident block
|
|
*/
|
|
|
|
parseCall: function(){
|
|
var tok = this.expect('call');
|
|
var name = tok.val;
|
|
var args = tok.args;
|
|
var mixin = new nodes.Mixin(name, args, new nodes.Block, true);
|
|
|
|
this.tag(mixin);
|
|
if (mixin.code) {
|
|
mixin.block.push(mixin.code);
|
|
mixin.code = null;
|
|
}
|
|
if (mixin.block.isEmpty()) mixin.block = null;
|
|
return mixin;
|
|
},
|
|
|
|
/**
|
|
* mixin block
|
|
*/
|
|
|
|
parseMixin: function(){
|
|
var tok = this.expect('mixin');
|
|
var name = tok.val;
|
|
var args = tok.args;
|
|
var mixin;
|
|
|
|
// definition
|
|
if ('indent' == this.peek().type) {
|
|
this.inMixin = true;
|
|
mixin = new nodes.Mixin(name, args, this.block(), false);
|
|
this.mixins[name] = mixin;
|
|
this.inMixin = false;
|
|
return mixin;
|
|
// call
|
|
} else {
|
|
return new nodes.Mixin(name, args, null, true);
|
|
}
|
|
},
|
|
|
|
parseTextWithInlineTags: function (str) {
|
|
var line = this.line();
|
|
|
|
var match = /(\\)?#\[((?:.|\n)*)$/.exec(str);
|
|
if (match) {
|
|
if (match[1]) { // escape
|
|
var text = new nodes.Text(str.substr(0, match.index) + '#[');
|
|
text.line = line;
|
|
var rest = this.parseTextWithInlineTags(match[2]);
|
|
if (rest[0].type === 'Text') {
|
|
text.val += rest[0].val;
|
|
rest.shift();
|
|
}
|
|
return [text].concat(rest);
|
|
} else {
|
|
var text = new nodes.Text(str.substr(0, match.index));
|
|
text.line = line;
|
|
var buffer = [text];
|
|
var rest = match[2];
|
|
var range = parseJSExpression(rest);
|
|
var inner = new Parser(range.src, this.filename, this.options);
|
|
buffer.push(inner.parse());
|
|
return buffer.concat(this.parseTextWithInlineTags(rest.substr(range.end + 1)));
|
|
}
|
|
} else {
|
|
var text = new nodes.Text(str);
|
|
text.line = line;
|
|
return [text];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* indent (text | newline)* outdent
|
|
*/
|
|
|
|
parseTextBlock: function(){
|
|
var block = new nodes.Block;
|
|
block.line = this.line();
|
|
var spaces = this.expect('indent').val;
|
|
if (null == this._spaces) this._spaces = spaces;
|
|
var indent = Array(spaces - this._spaces + 1).join(' ');
|
|
while ('outdent' != this.peek().type) {
|
|
switch (this.peek().type) {
|
|
case 'newline':
|
|
this.advance();
|
|
break;
|
|
case 'indent':
|
|
this.parseTextBlock(true).nodes.forEach(function(node){
|
|
block.push(node);
|
|
});
|
|
break;
|
|
default:
|
|
var texts = this.parseTextWithInlineTags(indent + this.advance().val);
|
|
texts.forEach(function (text) {
|
|
block.push(text);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (spaces == this._spaces) this._spaces = null;
|
|
this.expect('outdent');
|
|
|
|
return block;
|
|
},
|
|
|
|
/**
|
|
* indent expr* outdent
|
|
*/
|
|
|
|
block: function(){
|
|
var block = new nodes.Block;
|
|
block.line = this.line();
|
|
block.filename = this.filename;
|
|
this.expect('indent');
|
|
while ('outdent' != this.peek().type) {
|
|
if ('newline' == this.peek().type) {
|
|
this.advance();
|
|
} else {
|
|
var expr = this.parseExpr();
|
|
expr.filename = this.filename;
|
|
block.push(expr);
|
|
}
|
|
}
|
|
this.expect('outdent');
|
|
return block;
|
|
},
|
|
|
|
/**
|
|
* interpolation (attrs | class | id)* (text | code | ':')? newline* block?
|
|
*/
|
|
|
|
parseInterpolation: function(){
|
|
var tok = this.advance();
|
|
var tag = new nodes.Tag(tok.val);
|
|
tag.buffer = true;
|
|
return this.tag(tag);
|
|
},
|
|
|
|
/**
|
|
* tag (attrs | class | id)* (text | code | ':')? newline* block?
|
|
*/
|
|
|
|
parseTag: function(){
|
|
var tok = this.advance();
|
|
var tag = new nodes.Tag(tok.val);
|
|
|
|
tag.selfClosing = tok.selfClosing;
|
|
|
|
return this.tag(tag);
|
|
},
|
|
|
|
/**
|
|
* Parse tag.
|
|
*/
|
|
|
|
tag: function(tag){
|
|
tag.line = this.line();
|
|
|
|
var seenAttrs = false;
|
|
// (attrs | class | id)*
|
|
out:
|
|
while (true) {
|
|
switch (this.peek().type) {
|
|
case 'id':
|
|
case 'class':
|
|
var tok = this.advance();
|
|
tag.setAttribute(tok.type, "'" + tok.val + "'");
|
|
continue;
|
|
case 'attrs':
|
|
if (seenAttrs) {
|
|
console.warn('You should not have jade tags with multiple attributes.');
|
|
}
|
|
seenAttrs = true;
|
|
var tok = this.advance();
|
|
var attrs = tok.attrs;
|
|
|
|
if (tok.selfClosing) tag.selfClosing = true;
|
|
|
|
for (var i = 0; i < attrs.length; i++) {
|
|
tag.setAttribute(attrs[i].name, attrs[i].val, attrs[i].escaped);
|
|
}
|
|
continue;
|
|
case '&attributes':
|
|
var tok = this.advance();
|
|
tag.addAttributes(tok.val);
|
|
break;
|
|
default:
|
|
break out;
|
|
}
|
|
}
|
|
|
|
// check immediate '.'
|
|
if ('dot' == this.peek().type) {
|
|
tag.textOnly = true;
|
|
this.advance();
|
|
}
|
|
|
|
if (tag.selfClosing
|
|
&& ['newline', 'outdent', 'eos'].indexOf(this.peek().type) === -1
|
|
&& (this.peek().type !== 'text' || /^\s*$/.text(this.peek().val))) {
|
|
throw new Error(name + ' is self closing and should not have content.');
|
|
}
|
|
|
|
// (text | code | ':')?
|
|
switch (this.peek().type) {
|
|
case 'text':
|
|
tag.block.push(this.parseText());
|
|
break;
|
|
case 'code':
|
|
tag.code = this.parseCode();
|
|
break;
|
|
case ':':
|
|
this.advance();
|
|
tag.block = new nodes.Block;
|
|
tag.block.push(this.parseExpr());
|
|
break;
|
|
case 'newline':
|
|
case 'indent':
|
|
case 'outdent':
|
|
case 'eos':
|
|
break;
|
|
default:
|
|
throw new Error('Unexpected token `' + this.peek().type + '` expected `text`, `code`, `:`, `newline` or `eos`')
|
|
}
|
|
|
|
// newline*
|
|
while ('newline' == this.peek().type) this.advance();
|
|
|
|
// block?
|
|
if ('indent' == this.peek().type) {
|
|
if (tag.textOnly) {
|
|
this.lexer.pipeless = true;
|
|
tag.block = this.parseTextBlock();
|
|
this.lexer.pipeless = false;
|
|
} else {
|
|
var block = this.block();
|
|
if (tag.block) {
|
|
for (var i = 0, len = block.nodes.length; i < len; ++i) {
|
|
tag.block.push(block.nodes[i]);
|
|
}
|
|
} else {
|
|
tag.block = block;
|
|
}
|
|
}
|
|
}
|
|
|
|
return tag;
|
|
}
|
|
};
|