489 lines
16 KiB
JavaScript
489 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const XHTMLEntities = require('./xhtml');
|
|
|
|
const hexNumber = /^[\da-fA-F]+$/;
|
|
const decimalNumber = /^\d+$/;
|
|
|
|
// The map to `acorn-jsx` tokens from `acorn` namespace objects.
|
|
const acornJsxMap = new WeakMap();
|
|
|
|
// Get the original tokens for the given `acorn` namespace object.
|
|
function getJsxTokens(acorn) {
|
|
acorn = acorn.Parser.acorn || acorn;
|
|
let acornJsx = acornJsxMap.get(acorn);
|
|
if (!acornJsx) {
|
|
const tt = acorn.tokTypes;
|
|
const TokContext = acorn.TokContext;
|
|
const TokenType = acorn.TokenType;
|
|
const tc_oTag = new TokContext('<tag', false);
|
|
const tc_cTag = new TokContext('</tag', false);
|
|
const tc_expr = new TokContext('<tag>...</tag>', true, true);
|
|
const tokContexts = {
|
|
tc_oTag: tc_oTag,
|
|
tc_cTag: tc_cTag,
|
|
tc_expr: tc_expr
|
|
};
|
|
const tokTypes = {
|
|
jsxName: new TokenType('jsxName'),
|
|
jsxText: new TokenType('jsxText', {beforeExpr: true}),
|
|
jsxTagStart: new TokenType('jsxTagStart', {startsExpr: true}),
|
|
jsxTagEnd: new TokenType('jsxTagEnd')
|
|
};
|
|
|
|
tokTypes.jsxTagStart.updateContext = function() {
|
|
this.context.push(tc_expr); // treat as beginning of JSX expression
|
|
this.context.push(tc_oTag); // start opening tag context
|
|
this.exprAllowed = false;
|
|
};
|
|
tokTypes.jsxTagEnd.updateContext = function(prevType) {
|
|
let out = this.context.pop();
|
|
if (out === tc_oTag && prevType === tt.slash || out === tc_cTag) {
|
|
this.context.pop();
|
|
this.exprAllowed = this.curContext() === tc_expr;
|
|
} else {
|
|
this.exprAllowed = true;
|
|
}
|
|
};
|
|
|
|
acornJsx = { tokContexts: tokContexts, tokTypes: tokTypes };
|
|
acornJsxMap.set(acorn, acornJsx);
|
|
}
|
|
|
|
return acornJsx;
|
|
}
|
|
|
|
// Transforms JSX element name to string.
|
|
|
|
function getQualifiedJSXName(object) {
|
|
if (!object)
|
|
return object;
|
|
|
|
if (object.type === 'JSXIdentifier')
|
|
return object.name;
|
|
|
|
if (object.type === 'JSXNamespacedName')
|
|
return object.namespace.name + ':' + object.name.name;
|
|
|
|
if (object.type === 'JSXMemberExpression')
|
|
return getQualifiedJSXName(object.object) + '.' +
|
|
getQualifiedJSXName(object.property);
|
|
}
|
|
|
|
module.exports = function(options) {
|
|
options = options || {};
|
|
return function(Parser) {
|
|
return plugin({
|
|
allowNamespaces: options.allowNamespaces !== false,
|
|
allowNamespacedObjects: !!options.allowNamespacedObjects
|
|
}, Parser);
|
|
};
|
|
};
|
|
|
|
// This is `tokTypes` of the peer dep.
|
|
// This can be different instances from the actual `tokTypes` this plugin uses.
|
|
Object.defineProperty(module.exports, "tokTypes", {
|
|
get: function get_tokTypes() {
|
|
return getJsxTokens(require("acorn")).tokTypes;
|
|
},
|
|
configurable: true,
|
|
enumerable: true
|
|
});
|
|
|
|
function plugin(options, Parser) {
|
|
const acorn = Parser.acorn || require("acorn");
|
|
const acornJsx = getJsxTokens(acorn);
|
|
const tt = acorn.tokTypes;
|
|
const tok = acornJsx.tokTypes;
|
|
const tokContexts = acorn.tokContexts;
|
|
const tc_oTag = acornJsx.tokContexts.tc_oTag;
|
|
const tc_cTag = acornJsx.tokContexts.tc_cTag;
|
|
const tc_expr = acornJsx.tokContexts.tc_expr;
|
|
const isNewLine = acorn.isNewLine;
|
|
const isIdentifierStart = acorn.isIdentifierStart;
|
|
const isIdentifierChar = acorn.isIdentifierChar;
|
|
|
|
return class extends Parser {
|
|
// Expose actual `tokTypes` and `tokContexts` to other plugins.
|
|
static get acornJsx() {
|
|
return acornJsx;
|
|
}
|
|
|
|
// Reads inline JSX contents token.
|
|
jsx_readToken() {
|
|
let out = '', chunkStart = this.pos;
|
|
for (;;) {
|
|
if (this.pos >= this.input.length)
|
|
this.raise(this.start, 'Unterminated JSX contents');
|
|
let ch = this.input.charCodeAt(this.pos);
|
|
|
|
switch (ch) {
|
|
case 60: // '<'
|
|
case 123: // '{'
|
|
if (this.pos === this.start) {
|
|
if (ch === 60 && this.exprAllowed) {
|
|
++this.pos;
|
|
return this.finishToken(tok.jsxTagStart);
|
|
}
|
|
return this.getTokenFromCode(ch);
|
|
}
|
|
out += this.input.slice(chunkStart, this.pos);
|
|
return this.finishToken(tok.jsxText, out);
|
|
|
|
case 38: // '&'
|
|
out += this.input.slice(chunkStart, this.pos);
|
|
out += this.jsx_readEntity();
|
|
chunkStart = this.pos;
|
|
break;
|
|
|
|
case 62: // '>'
|
|
case 125: // '}'
|
|
this.raise(
|
|
this.pos,
|
|
"Unexpected token `" + this.input[this.pos] + "`. Did you mean `" +
|
|
(ch === 62 ? ">" : "}") + "` or " + "`{\"" + this.input[this.pos] + "\"}" + "`?"
|
|
);
|
|
|
|
default:
|
|
if (isNewLine(ch)) {
|
|
out += this.input.slice(chunkStart, this.pos);
|
|
out += this.jsx_readNewLine(true);
|
|
chunkStart = this.pos;
|
|
} else {
|
|
++this.pos;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
jsx_readNewLine(normalizeCRLF) {
|
|
let ch = this.input.charCodeAt(this.pos);
|
|
let out;
|
|
++this.pos;
|
|
if (ch === 13 && this.input.charCodeAt(this.pos) === 10) {
|
|
++this.pos;
|
|
out = normalizeCRLF ? '\n' : '\r\n';
|
|
} else {
|
|
out = String.fromCharCode(ch);
|
|
}
|
|
if (this.options.locations) {
|
|
++this.curLine;
|
|
this.lineStart = this.pos;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
jsx_readString(quote) {
|
|
let out = '', chunkStart = ++this.pos;
|
|
for (;;) {
|
|
if (this.pos >= this.input.length)
|
|
this.raise(this.start, 'Unterminated string constant');
|
|
let ch = this.input.charCodeAt(this.pos);
|
|
if (ch === quote) break;
|
|
if (ch === 38) { // '&'
|
|
out += this.input.slice(chunkStart, this.pos);
|
|
out += this.jsx_readEntity();
|
|
chunkStart = this.pos;
|
|
} else if (isNewLine(ch)) {
|
|
out += this.input.slice(chunkStart, this.pos);
|
|
out += this.jsx_readNewLine(false);
|
|
chunkStart = this.pos;
|
|
} else {
|
|
++this.pos;
|
|
}
|
|
}
|
|
out += this.input.slice(chunkStart, this.pos++);
|
|
return this.finishToken(tt.string, out);
|
|
}
|
|
|
|
jsx_readEntity() {
|
|
let str = '', count = 0, entity;
|
|
let ch = this.input[this.pos];
|
|
if (ch !== '&')
|
|
this.raise(this.pos, 'Entity must start with an ampersand');
|
|
let startPos = ++this.pos;
|
|
while (this.pos < this.input.length && count++ < 10) {
|
|
ch = this.input[this.pos++];
|
|
if (ch === ';') {
|
|
if (str[0] === '#') {
|
|
if (str[1] === 'x') {
|
|
str = str.substr(2);
|
|
if (hexNumber.test(str))
|
|
entity = String.fromCharCode(parseInt(str, 16));
|
|
} else {
|
|
str = str.substr(1);
|
|
if (decimalNumber.test(str))
|
|
entity = String.fromCharCode(parseInt(str, 10));
|
|
}
|
|
} else {
|
|
entity = XHTMLEntities[str];
|
|
}
|
|
break;
|
|
}
|
|
str += ch;
|
|
}
|
|
if (!entity) {
|
|
this.pos = startPos;
|
|
return '&';
|
|
}
|
|
return entity;
|
|
}
|
|
|
|
// Read a JSX identifier (valid tag or attribute name).
|
|
//
|
|
// Optimized version since JSX identifiers can't contain
|
|
// escape characters and so can be read as single slice.
|
|
// Also assumes that first character was already checked
|
|
// by isIdentifierStart in readToken.
|
|
|
|
jsx_readWord() {
|
|
let ch, start = this.pos;
|
|
do {
|
|
ch = this.input.charCodeAt(++this.pos);
|
|
} while (isIdentifierChar(ch) || ch === 45); // '-'
|
|
return this.finishToken(tok.jsxName, this.input.slice(start, this.pos));
|
|
}
|
|
|
|
// Parse next token as JSX identifier
|
|
|
|
jsx_parseIdentifier() {
|
|
let node = this.startNode();
|
|
if (this.type === tok.jsxName)
|
|
node.name = this.value;
|
|
else if (this.type.keyword)
|
|
node.name = this.type.keyword;
|
|
else
|
|
this.unexpected();
|
|
this.next();
|
|
return this.finishNode(node, 'JSXIdentifier');
|
|
}
|
|
|
|
// Parse namespaced identifier.
|
|
|
|
jsx_parseNamespacedName() {
|
|
let startPos = this.start, startLoc = this.startLoc;
|
|
let name = this.jsx_parseIdentifier();
|
|
if (!options.allowNamespaces || !this.eat(tt.colon)) return name;
|
|
var node = this.startNodeAt(startPos, startLoc);
|
|
node.namespace = name;
|
|
node.name = this.jsx_parseIdentifier();
|
|
return this.finishNode(node, 'JSXNamespacedName');
|
|
}
|
|
|
|
// Parses element name in any form - namespaced, member
|
|
// or single identifier.
|
|
|
|
jsx_parseElementName() {
|
|
if (this.type === tok.jsxTagEnd) return '';
|
|
let startPos = this.start, startLoc = this.startLoc;
|
|
let node = this.jsx_parseNamespacedName();
|
|
if (this.type === tt.dot && node.type === 'JSXNamespacedName' && !options.allowNamespacedObjects) {
|
|
this.unexpected();
|
|
}
|
|
while (this.eat(tt.dot)) {
|
|
let newNode = this.startNodeAt(startPos, startLoc);
|
|
newNode.object = node;
|
|
newNode.property = this.jsx_parseIdentifier();
|
|
node = this.finishNode(newNode, 'JSXMemberExpression');
|
|
}
|
|
return node;
|
|
}
|
|
|
|
// Parses any type of JSX attribute value.
|
|
|
|
jsx_parseAttributeValue() {
|
|
switch (this.type) {
|
|
case tt.braceL:
|
|
let node = this.jsx_parseExpressionContainer();
|
|
if (node.expression.type === 'JSXEmptyExpression')
|
|
this.raise(node.start, 'JSX attributes must only be assigned a non-empty expression');
|
|
return node;
|
|
|
|
case tok.jsxTagStart:
|
|
case tt.string:
|
|
return this.parseExprAtom();
|
|
|
|
default:
|
|
this.raise(this.start, 'JSX value should be either an expression or a quoted JSX text');
|
|
}
|
|
}
|
|
|
|
// JSXEmptyExpression is unique type since it doesn't actually parse anything,
|
|
// and so it should start at the end of last read token (left brace) and finish
|
|
// at the beginning of the next one (right brace).
|
|
|
|
jsx_parseEmptyExpression() {
|
|
let node = this.startNodeAt(this.lastTokEnd, this.lastTokEndLoc);
|
|
return this.finishNodeAt(node, 'JSXEmptyExpression', this.start, this.startLoc);
|
|
}
|
|
|
|
// Parses JSX expression enclosed into curly brackets.
|
|
|
|
jsx_parseExpressionContainer() {
|
|
let node = this.startNode();
|
|
this.next();
|
|
node.expression = this.type === tt.braceR
|
|
? this.jsx_parseEmptyExpression()
|
|
: this.parseExpression();
|
|
this.expect(tt.braceR);
|
|
return this.finishNode(node, 'JSXExpressionContainer');
|
|
}
|
|
|
|
// Parses following JSX attribute name-value pair.
|
|
|
|
jsx_parseAttribute() {
|
|
let node = this.startNode();
|
|
if (this.eat(tt.braceL)) {
|
|
this.expect(tt.ellipsis);
|
|
node.argument = this.parseMaybeAssign();
|
|
this.expect(tt.braceR);
|
|
return this.finishNode(node, 'JSXSpreadAttribute');
|
|
}
|
|
node.name = this.jsx_parseNamespacedName();
|
|
node.value = this.eat(tt.eq) ? this.jsx_parseAttributeValue() : null;
|
|
return this.finishNode(node, 'JSXAttribute');
|
|
}
|
|
|
|
// Parses JSX opening tag starting after '<'.
|
|
|
|
jsx_parseOpeningElementAt(startPos, startLoc) {
|
|
let node = this.startNodeAt(startPos, startLoc);
|
|
node.attributes = [];
|
|
let nodeName = this.jsx_parseElementName();
|
|
if (nodeName) node.name = nodeName;
|
|
while (this.type !== tt.slash && this.type !== tok.jsxTagEnd)
|
|
node.attributes.push(this.jsx_parseAttribute());
|
|
node.selfClosing = this.eat(tt.slash);
|
|
this.expect(tok.jsxTagEnd);
|
|
return this.finishNode(node, nodeName ? 'JSXOpeningElement' : 'JSXOpeningFragment');
|
|
}
|
|
|
|
// Parses JSX closing tag starting after '</'.
|
|
|
|
jsx_parseClosingElementAt(startPos, startLoc) {
|
|
let node = this.startNodeAt(startPos, startLoc);
|
|
let nodeName = this.jsx_parseElementName();
|
|
if (nodeName) node.name = nodeName;
|
|
this.expect(tok.jsxTagEnd);
|
|
return this.finishNode(node, nodeName ? 'JSXClosingElement' : 'JSXClosingFragment');
|
|
}
|
|
|
|
// Parses entire JSX element, including it's opening tag
|
|
// (starting after '<'), attributes, contents and closing tag.
|
|
|
|
jsx_parseElementAt(startPos, startLoc) {
|
|
let node = this.startNodeAt(startPos, startLoc);
|
|
let children = [];
|
|
let openingElement = this.jsx_parseOpeningElementAt(startPos, startLoc);
|
|
let closingElement = null;
|
|
|
|
if (!openingElement.selfClosing) {
|
|
contents: for (;;) {
|
|
switch (this.type) {
|
|
case tok.jsxTagStart:
|
|
startPos = this.start; startLoc = this.startLoc;
|
|
this.next();
|
|
if (this.eat(tt.slash)) {
|
|
closingElement = this.jsx_parseClosingElementAt(startPos, startLoc);
|
|
break contents;
|
|
}
|
|
children.push(this.jsx_parseElementAt(startPos, startLoc));
|
|
break;
|
|
|
|
case tok.jsxText:
|
|
children.push(this.parseExprAtom());
|
|
break;
|
|
|
|
case tt.braceL:
|
|
children.push(this.jsx_parseExpressionContainer());
|
|
break;
|
|
|
|
default:
|
|
this.unexpected();
|
|
}
|
|
}
|
|
if (getQualifiedJSXName(closingElement.name) !== getQualifiedJSXName(openingElement.name)) {
|
|
this.raise(
|
|
closingElement.start,
|
|
'Expected corresponding JSX closing tag for <' + getQualifiedJSXName(openingElement.name) + '>');
|
|
}
|
|
}
|
|
let fragmentOrElement = openingElement.name ? 'Element' : 'Fragment';
|
|
|
|
node['opening' + fragmentOrElement] = openingElement;
|
|
node['closing' + fragmentOrElement] = closingElement;
|
|
node.children = children;
|
|
if (this.type === tt.relational && this.value === "<") {
|
|
this.raise(this.start, "Adjacent JSX elements must be wrapped in an enclosing tag");
|
|
}
|
|
return this.finishNode(node, 'JSX' + fragmentOrElement);
|
|
}
|
|
|
|
// Parse JSX text
|
|
|
|
jsx_parseText() {
|
|
let node = this.parseLiteral(this.value);
|
|
node.type = "JSXText";
|
|
return node;
|
|
}
|
|
|
|
// Parses entire JSX element from current position.
|
|
|
|
jsx_parseElement() {
|
|
let startPos = this.start, startLoc = this.startLoc;
|
|
this.next();
|
|
return this.jsx_parseElementAt(startPos, startLoc);
|
|
}
|
|
|
|
parseExprAtom(refShortHandDefaultPos) {
|
|
if (this.type === tok.jsxText)
|
|
return this.jsx_parseText();
|
|
else if (this.type === tok.jsxTagStart)
|
|
return this.jsx_parseElement();
|
|
else
|
|
return super.parseExprAtom(refShortHandDefaultPos);
|
|
}
|
|
|
|
readToken(code) {
|
|
let context = this.curContext();
|
|
|
|
if (context === tc_expr) return this.jsx_readToken();
|
|
|
|
if (context === tc_oTag || context === tc_cTag) {
|
|
if (isIdentifierStart(code)) return this.jsx_readWord();
|
|
|
|
if (code == 62) {
|
|
++this.pos;
|
|
return this.finishToken(tok.jsxTagEnd);
|
|
}
|
|
|
|
if ((code === 34 || code === 39) && context == tc_oTag)
|
|
return this.jsx_readString(code);
|
|
}
|
|
|
|
if (code === 60 && this.exprAllowed && this.input.charCodeAt(this.pos + 1) !== 33) {
|
|
++this.pos;
|
|
return this.finishToken(tok.jsxTagStart);
|
|
}
|
|
return super.readToken(code);
|
|
}
|
|
|
|
updateContext(prevType) {
|
|
if (this.type == tt.braceL) {
|
|
var curContext = this.curContext();
|
|
if (curContext == tc_oTag) this.context.push(tokContexts.b_expr);
|
|
else if (curContext == tc_expr) this.context.push(tokContexts.b_tmpl);
|
|
else super.updateContext(prevType);
|
|
this.exprAllowed = true;
|
|
} else if (this.type === tt.slash && prevType === tok.jsxTagStart) {
|
|
this.context.length -= 2; // do not consider JSX expr -> JSX open tag -> ... anymore
|
|
this.context.push(tc_cTag); // reconsider as closing tag context
|
|
this.exprAllowed = false;
|
|
} else {
|
|
return super.updateContext(prevType);
|
|
}
|
|
}
|
|
};
|
|
}
|