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.
488 lines
16 KiB
488 lines
16 KiB
'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); |
|
} |
|
} |
|
}; |
|
}
|
|
|