|
|
'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; } };
|