``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
/* global JSX */
|
|
import * as Acorn from 'acorn'
|
|
import * as AcornJSX from 'acorn-jsx'
|
|
import React, { Fragment, ComponentType, ExoticComponent } from 'react'
|
|
import ATTRIBUTES from '../constants/attributeNames'
|
|
import { canHaveChildren, canHaveWhitespace } from '../constants/specialTags'
|
|
import { randomHash } from '../helpers/hash'
|
|
import { parseStyle } from '../helpers/parseStyle'
|
|
import { resolvePath } from '../helpers/resolvePath'
|
|
|
|
type ParsedJSX = JSX.Element | boolean | string
|
|
type ParsedTree = ParsedJSX | ParsedJSX[] | null
|
|
export type TProps = {
|
|
allowUnknownElements?: boolean,
|
|
autoCloseVoidElements?: boolean,
|
|
bindings?: { [key: string]: unknown; },
|
|
blacklistedAttrs?: Array<string | RegExp>,
|
|
blacklistedTags?: string[],
|
|
className?: string,
|
|
components?: Record<string, ComponentType | ExoticComponent>,
|
|
componentsOnly?: boolean,
|
|
disableFragments?: boolean,
|
|
disableKeyGeneration?: boolean,
|
|
jsx?: string,
|
|
onError?: (error: Error) => void,
|
|
showWarnings?: boolean,
|
|
renderError?: (props: { error: string }) => JSX.Element | null,
|
|
renderInWrapper?: boolean,
|
|
renderUnrecognized?: (tagName: string) => JSX.Element | null,
|
|
}
|
|
type Scope = Record<string, any>
|
|
|
|
/* eslint-disable consistent-return */
|
|
export default class JsxParser extends React.Component<TProps> {
|
|
static displayName = 'JsxParser'
|
|
static defaultProps: TProps = {
|
|
allowUnknownElements: true,
|
|
autoCloseVoidElements: false,
|
|
bindings: {},
|
|
blacklistedAttrs: [/^on.+/i],
|
|
blacklistedTags: ['script'],
|
|
className: '',
|
|
components: {},
|
|
componentsOnly: false,
|
|
disableFragments: false,
|
|
disableKeyGeneration: false,
|
|
jsx: '',
|
|
onError: () => { },
|
|
showWarnings: false,
|
|
renderError: undefined,
|
|
renderInWrapper: true,
|
|
renderUnrecognized: () => null,
|
|
}
|
|
|
|
private ParsedChildren: ParsedTree = null
|
|
|
|
#parseJSX = (jsx: string): JSX.Element | JSX.Element[] | null => {
|
|
const parser = Acorn.Parser.extend(AcornJSX.default({
|
|
autoCloseVoidElements: this.props.autoCloseVoidElements,
|
|
}))
|
|
const wrappedJsx = `<root>${jsx}</root>`
|
|
let parsed: AcornJSX.Expression[] = []
|
|
try {
|
|
// @ts-ignore - AcornJsx doesn't have typescript typings
|
|
parsed = parser.parse(wrappedJsx, { ecmaVersion: 'latest' })
|
|
// @ts-ignore - AcornJsx doesn't have typescript typings
|
|
parsed = parsed.body[0].expression.children || []
|
|
} catch (error) {
|
|
if (this.props.showWarnings) console.warn(error) // eslint-disable-line no-console
|
|
if (this.props.onError) this.props.onError(error)
|
|
if (this.props.renderError) {
|
|
return this.props.renderError({ error: String(error) })
|
|
}
|
|
return null
|
|
}
|
|
|
|
return parsed.map(p => this.#parseExpression(p)).filter(Boolean)
|
|
}
|
|
|
|
#parseExpression = (expression: AcornJSX.Expression, scope?: Scope): any => {
|
|
switch (expression.type) {
|
|
case 'JSXAttribute':
|
|
if (expression.value === null) return true
|
|
return this.#parseExpression(expression.value, scope)
|
|
case 'JSXElement':
|
|
case 'JSXFragment':
|
|
return this.#parseElement(expression, scope)
|
|
case 'JSXExpressionContainer':
|
|
return this.#parseExpression(expression.expression, scope)
|
|
case 'JSXText':
|
|
const key = this.props.disableKeyGeneration ? undefined : randomHash()
|
|
return this.props.disableFragments
|
|
? expression.value
|
|
: <Fragment key={key}>{expression.value}</Fragment>
|
|
case 'ArrayExpression':
|
|
return expression.elements.map(ele => this.#parseExpression(ele, scope)) as ParsedTree
|
|
case 'BinaryExpression':
|
|
/* eslint-disable eqeqeq,max-len */
|
|
switch (expression.operator) {
|
|
case '-': return this.#parseExpression(expression.left) - this.#parseExpression(expression.right)
|
|
case '!=': return this.#parseExpression(expression.left) != this.#parseExpression(expression.right)
|
|
case '!==': return this.#parseExpression(expression.left) !== this.#parseExpression(expression.right)
|
|
case '*': return this.#parseExpression(expression.left) * this.#parseExpression(expression.right)
|
|
case '**': return this.#parseExpression(expression.left) ** this.#parseExpression(expression.right)
|
|
case '/': return this.#parseExpression(expression.left) / this.#parseExpression(expression.right)
|
|
case '%': return this.#parseExpression(expression.left) % this.#parseExpression(expression.right)
|
|
case '+': return this.#parseExpression(expression.left) + this.#parseExpression(expression.right)
|
|
case '<': return this.#parseExpression(expression.left) < this.#parseExpression(expression.right)
|
|
case '<=': return this.#parseExpression(expression.left) <= this.#parseExpression(expression.right)
|
|
case '==': return this.#parseExpression(expression.left) == this.#parseExpression(expression.right)
|
|
case '===': return this.#parseExpression(expression.left) === this.#parseExpression(expression.right)
|
|
case '>': return this.#parseExpression(expression.left) > this.#parseExpression(expression.right)
|
|
case '>=': return this.#parseExpression(expression.left) >= this.#parseExpression(expression.right)
|
|
/* eslint-enable eqeqeq,max-len */
|
|
}
|
|
return undefined
|
|
case 'CallExpression':
|
|
const parsedCallee = this.#parseExpression(expression.callee)
|
|
if (parsedCallee === undefined) {
|
|
this.props.onError!(new Error(`The expression '${expression.callee}' could not be resolved, resulting in an undefined return value.`))
|
|
return undefined
|
|
}
|
|
return parsedCallee(...expression.arguments.map(
|
|
arg => this.#parseExpression(arg, expression.callee),
|
|
))
|
|
case 'ConditionalExpression':
|
|
return this.#parseExpression(expression.test)
|
|
? this.#parseExpression(expression.consequent)
|
|
: this.#parseExpression(expression.alternate)
|
|
case 'ExpressionStatement':
|
|
return this.#parseExpression(expression.expression)
|
|
case 'Identifier':
|
|
if (scope && expression.name in scope) {
|
|
return scope[expression.name]
|
|
}
|
|
return (this.props.bindings || {})[expression.name]
|
|
|
|
case 'Literal':
|
|
return expression.value
|
|
case 'LogicalExpression':
|
|
const left = this.#parseExpression(expression.left)
|
|
if (expression.operator === '||' && left) return left
|
|
if ((expression.operator === '&&' && left) || (expression.operator === '||' && !left)) {
|
|
return this.#parseExpression(expression.right)
|
|
}
|
|
return false
|
|
case 'MemberExpression':
|
|
return this.#parseMemberExpression(expression, scope)
|
|
case 'ObjectExpression':
|
|
const object: Record<string, any> = {}
|
|
expression.properties.forEach(prop => {
|
|
object[prop.key.name! || prop.key.value!] = this.#parseExpression(prop.value)
|
|
})
|
|
return object
|
|
case 'TemplateElement':
|
|
return expression.value.cooked
|
|
case 'TemplateLiteral':
|
|
return [...expression.expressions, ...expression.quasis]
|
|
.sort((a, b) => {
|
|
if (a.start < b.start) return -1
|
|
return 1
|
|
})
|
|
.map(item => this.#parseExpression(item))
|
|
.join('')
|
|
case 'UnaryExpression':
|
|
switch (expression.operator) {
|
|
case '+': return expression.argument.value
|
|
case '-': return -expression.argument.value
|
|
case '!': return !expression.argument.value
|
|
}
|
|
return undefined
|
|
case 'ArrowFunctionExpression':
|
|
if (expression.async || expression.generator) {
|
|
this.props.onError?.(new Error('Async and generator arrow functions are not supported.'))
|
|
}
|
|
return (...args: any[]) : any => {
|
|
const functionScope: Record<string, any> = {}
|
|
expression.params.forEach((param, idx) => {
|
|
functionScope[param.name] = args[idx]
|
|
})
|
|
return this.#parseExpression(expression.body, functionScope)
|
|
}
|
|
}
|
|
}
|
|
|
|
#parseMemberExpression = (expression: AcornJSX.MemberExpression, scope?: Scope): any => {
|
|
// eslint-disable-next-line prefer-destructuring
|
|
let { object } = expression
|
|
const path = [expression.property?.name ?? JSON.parse(expression.property?.raw ?? '""')]
|
|
|
|
if (expression.object.type !== 'Literal') {
|
|
while (object && ['MemberExpression', 'Literal'].includes(object?.type)) {
|
|
const { property } = (object as AcornJSX.MemberExpression)
|
|
if ((object as AcornJSX.MemberExpression).computed) {
|
|
path.unshift(this.#parseExpression(property!, scope))
|
|
} else {
|
|
path.unshift(property?.name ?? JSON.parse(property?.raw ?? '""'))
|
|
}
|
|
|
|
object = (object as AcornJSX.MemberExpression).object
|
|
}
|
|
}
|
|
|
|
const target = this.#parseExpression(object, scope)
|
|
try {
|
|
let parent = target
|
|
const member = path.reduce((value, next) => {
|
|
parent = value
|
|
return value[next]
|
|
}, target)
|
|
if (typeof member === 'function') return member.bind(parent)
|
|
|
|
return member
|
|
} catch {
|
|
const name = (object as AcornJSX.MemberExpression)?.name || 'unknown'
|
|
this.props.onError!(new Error(`Unable to parse ${name}["${path.join('"]["')}"]}`))
|
|
}
|
|
}
|
|
|
|
#parseName = (element: AcornJSX.JSXIdentifier | AcornJSX.JSXMemberExpression): string => {
|
|
if (element.type === 'JSXIdentifier') { return element.name }
|
|
return `${this.#parseName(element.object)}.${this.#parseName(element.property)}`
|
|
}
|
|
|
|
#parseElement = (
|
|
element: AcornJSX.JSXElement | AcornJSX.JSXFragment,
|
|
scope?: Scope,
|
|
): JSX.Element | JSX.Element[] | null => {
|
|
const { allowUnknownElements, components, componentsOnly, onError } = this.props
|
|
const { children: childNodes = [] } = element
|
|
const openingTag = element.type === 'JSXElement'
|
|
? element.openingElement
|
|
: element.openingFragment
|
|
const { attributes = [] } = openingTag
|
|
const name = element.type === 'JSXElement'
|
|
? this.#parseName(openingTag.name)
|
|
: ''
|
|
|
|
const blacklistedAttrs = (this.props.blacklistedAttrs || [])
|
|
.map(attr => (attr instanceof RegExp ? attr : new RegExp(attr, 'i')))
|
|
const blacklistedTags = (this.props.blacklistedTags || [])
|
|
.map(tag => tag.trim().toLowerCase()).filter(Boolean)
|
|
|
|
if (/^(html|head|body)$/i.test(name)) {
|
|
return childNodes.map(c => this.#parseElement(c, scope)) as JSX.Element[]
|
|
}
|
|
const tagName = name.trim().toLowerCase()
|
|
if (blacklistedTags.indexOf(tagName) !== -1) {
|
|
onError!(new Error(`The tag <${name}> is blacklisted, and will not be rendered.`))
|
|
return null
|
|
}
|
|
|
|
if (name !== '' && !resolvePath(components, name)) {
|
|
if (componentsOnly) {
|
|
onError!(new Error(`The component <${name}> is unrecognized, and will not be rendered.`))
|
|
return this.props.renderUnrecognized!(name)
|
|
}
|
|
|
|
if (!allowUnknownElements && document.createElement(name) instanceof HTMLUnknownElement) {
|
|
onError!(new Error(`The tag <${name}> is unrecognized in this browser, and will not be rendered.`))
|
|
return this.props.renderUnrecognized!(name)
|
|
}
|
|
}
|
|
|
|
let children
|
|
const component = element.type === 'JSXElement'
|
|
? resolvePath(components, name)
|
|
: Fragment
|
|
|
|
if (component || canHaveChildren(name)) {
|
|
children = childNodes.map(node => this.#parseExpression(node, scope))
|
|
if (!component && !canHaveWhitespace(name)) {
|
|
children = children.filter(child => (
|
|
typeof child !== 'string' || !/^\s*$/.test(child)
|
|
))
|
|
}
|
|
|
|
if (children.length === 0) {
|
|
children = undefined
|
|
} else if (children.length === 1) {
|
|
[children] = children
|
|
} else if (children.length > 1 && !this.props.disableKeyGeneration) {
|
|
// Add `key` to any child that is a react element (by checking if it has `.type`) if one
|
|
// does not already exist.
|
|
children = children.map((child, key) => (
|
|
(child?.type && !child?.key) ? { ...child, key: child.key || key } : child
|
|
))
|
|
}
|
|
}
|
|
|
|
const props: { [key: string]: any } = {
|
|
key: this.props.disableKeyGeneration ? undefined : randomHash(),
|
|
}
|
|
attributes.forEach( // eslint-disable-next-line max-len
|
|
(expr: AcornJSX.JSXAttribute | AcornJSX.JSXAttributeExpression | AcornJSX.JSXSpreadAttribute) => {
|
|
if (expr.type === 'JSXAttribute') {
|
|
const rawName = expr.name.name
|
|
const attributeName = ATTRIBUTES[rawName] || rawName
|
|
// if the value is null, this is an implicitly "true" prop, such as readOnly
|
|
const value = this.#parseExpression(expr, scope)
|
|
|
|
const matches = blacklistedAttrs.filter(re => re.test(attributeName))
|
|
if (matches.length === 0) {
|
|
props[attributeName] = value
|
|
}
|
|
} else if (
|
|
(expr.type === 'JSXSpreadAttribute' && expr.argument.type === 'Identifier')
|
|
|| expr.argument!.type === 'MemberExpression'
|
|
) {
|
|
const value = this.#parseExpression(expr.argument!, scope)
|
|
if (typeof value === 'object') {
|
|
Object.keys(value).forEach(rawName => {
|
|
const attributeName: string = ATTRIBUTES[rawName] || rawName
|
|
const matches = blacklistedAttrs.filter(re => re.test(attributeName))
|
|
if (matches.length === 0) {
|
|
props[attributeName] = value[rawName]
|
|
}
|
|
})
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
if (typeof props.style === 'string') {
|
|
props.style = parseStyle(props.style)
|
|
}
|
|
const lowerName = name.toLowerCase()
|
|
if (lowerName === 'option') {
|
|
children = children.props.children
|
|
}
|
|
|
|
return React.createElement(component || lowerName, props, children)
|
|
}
|
|
|
|
render = (): JSX.Element => {
|
|
const jsx = (this.props.jsx || '').trim().replace(/<!DOCTYPE([^>]*)>/g, '')
|
|
this.ParsedChildren = this.#parseJSX(jsx)
|
|
const className = [...new Set(['jsx-parser', ...String(this.props.className).split(' ')])]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
return (
|
|
this.props.renderInWrapper
|
|
? <div className={className}>{this.ParsedChildren}</div>
|
|
: <>{this.ParsedChildren}</>
|
|
)
|
|
}
|
|
}
|
|
/* eslint-enable consistent-return */
|