Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: expressions scopes and member expressions #276

Merged
merged 2 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions source/components/JsxParser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,42 @@ describe('JsxParser Component', () => {
expect(node.childNodes[0].textContent).toEqual(bindings.array[bindings.index].of)
expect(instance.ParsedChildren[0].props.foo).toEqual(bindings.array[bindings.index].of)
})
it('can evaluate a[b]', () => {
const { node } = render(
<JsxParser
bindings={{ items: { 0: 'hello', 1: 'world' }, arr: [0, 1] }}
jsx="{items[arr[0]]}"
/>,
)
expect(node.innerHTML).toMatch('hello')
})
it('handles optional chaining', () => {
const { node } = render(
<JsxParser
bindings={{ foo: { bar: 'baz' }, baz: undefined }}
jsx="{foo?.bar} {baz?.bar}"
/>,
)
expect(node.innerHTML).toMatch('baz')
})
it('optional short-cut', () => {
const { node } = render(
<JsxParser
bindings={{ foo: { bar: { baz: 'baz' } }, foo2: undefined }}
jsx="{foo?.bar.baz} {foo2?.bar.baz}"
/>,
)
expect(node.innerHTML).toMatch('baz')
})
it('optional function call', () => {
const { node } = render(
<JsxParser
bindings={{ foo: { bar: () => 'baz' }, foo2: undefined }}
jsx="{foo?.bar()} {foo2?.bar()}"
/>,
)
expect(node.innerHTML).toMatch('baz')
})
/* eslint-enable dot-notation,no-useless-concat */
})
})
Expand Down Expand Up @@ -1264,5 +1300,15 @@ describe('JsxParser Component', () => {
)
expect(node.outerHTML).toEqual('<p>from-container</p>')
})

it('supports math with scope', () => {
const { node } = render(<JsxParser jsx="{[1, 2, 3].map(num => num * 2)}" />)
expect(node.innerHTML).toEqual('246')
})

it('supports conditional with scope', () => {
const { node } = render(<JsxParser jsx="{[1, 2, 3].map(num => num == 1 || num == 3 ? num : -1)}" />)
expect(node.innerHTML).toEqual('1-13')
})
})
})
117 changes: 69 additions & 48 deletions source/components/JsxParser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export type TProps = {
}
type Scope = Record<string, any>

class NullishShortCircuit extends Error {
constructor(message = 'Nullish value encountered') {
super(message)
this.name = 'NullishShortCircuit'
}
}

/* eslint-disable consistent-return */
export default class JsxParser extends React.Component<TProps> {
static displayName = 'JsxParser'
Expand Down Expand Up @@ -94,40 +101,45 @@ export default class JsxParser extends React.Component<TProps> {
case 'ArrayExpression':
return expression.elements.map(ele => this.#parseExpression(ele, scope)) as ParsedTree
case 'BinaryExpression':
const binaryLeft = this.#parseExpression(expression.left, scope)
const binaryRight = this.#parseExpression(expression.right, scope)
/* 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)
case '-': return binaryLeft - binaryRight
case '!=': return binaryLeft != binaryRight
case '!==': return binaryLeft !== binaryRight
case '*': return binaryLeft * binaryRight
case '**': return binaryLeft ** binaryRight
case '/': return binaryLeft / binaryRight
case '%': return binaryLeft % binaryRight
case '+': return binaryLeft + binaryRight
case '<': return binaryLeft < binaryRight
case '<=': return binaryLeft <= binaryRight
case '==': return binaryLeft == binaryRight
case '===': return binaryLeft === binaryRight
case '>': return binaryLeft > binaryRight
case '>=': return binaryLeft >= binaryRight
/* eslint-enable eqeqeq,max-len */
}
return undefined
case 'CallExpression':
const parsedCallee = this.#parseExpression(expression.callee)
const parsedCallee = this.#parseExpression(expression.callee, scope)
if (parsedCallee === undefined) {
if (expression.optional) {
throw new NullishShortCircuit()
}
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)
return this.#parseExpression(expression.test, scope)
? this.#parseExpression(expression.consequent, scope)
: this.#parseExpression(expression.alternate, scope)
case 'ExpressionStatement':
return this.#parseExpression(expression.expression)
return this.#parseExpression(expression.expression, scope)
case 'Identifier':
if (scope && expression.name in scope) {
return scope[expression.name]
Expand All @@ -137,18 +149,20 @@ export default class JsxParser extends React.Component<TProps> {
case 'Literal':
return expression.value
case 'LogicalExpression':
const left = this.#parseExpression(expression.left)
const left = this.#parseExpression(expression.left, scope)
if (expression.operator === '||' && left) return left
if ((expression.operator === '&&' && left) || (expression.operator === '||' && !left)) {
return this.#parseExpression(expression.right)
return this.#parseExpression(expression.right, scope)
}
return false
case 'MemberExpression':
return this.#parseMemberExpression(expression, scope)
case 'ChainExpression':
return this.#parseChainExpression(expression, scope)
case 'ObjectExpression':
const object: Record<string, any> = {}
expression.properties.forEach(prop => {
object[prop.key.name! || prop.key.value!] = this.#parseExpression(prop.value)
object[prop.key.name! || prop.key.value!] = this.#parseExpression(prop.value, scope)
})
return object
case 'TemplateElement':
Expand All @@ -159,7 +173,7 @@ export default class JsxParser extends React.Component<TProps> {
if (a.start < b.start) return -1
return 1
})
.map(item => this.#parseExpression(item))
.map(item => this.#parseExpression(item, scope))
.join('')
case 'UnaryExpression':
switch (expression.operator) {
Expand All @@ -179,41 +193,48 @@ export default class JsxParser extends React.Component<TProps> {
})
return this.#parseExpression(expression.body, functionScope)
}
default:
this.props.onError!(new Error(`The expression type '${expression.type}' is not supported.`))
return undefined
}
}

#parseChainExpression = (expression: AcornJSX.ChainExpression, scope?: Scope): any => {
try {
return this.#parseExpression(expression.expression, scope)
} catch (error) {
if (error instanceof NullishShortCircuit) return undefined
throw error
}
}

#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 ?? '""')]
const object = this.#parseExpression(expression.object, scope)

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 ?? '""'))
}
let property

object = (object as AcornJSX.MemberExpression).object
}
if (expression.computed) {
property = this.#parseExpression(expression.property, scope)
} else if (expression.property.type === 'Identifier') {
property = expression.property.name
} else {
this.props.onError!(new Error('Only simple MemberExpressions are supported.'))
return undefined
}

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)
if (object === null || object === undefined) {
if (expression.optional) throw new NullishShortCircuit()
}

return member
} catch {
const name = (object as AcornJSX.MemberExpression)?.name || 'unknown'
this.props.onError!(new Error(`Unable to parse ${name}["${path.join('"]["')}"]}`))
let member
try {
member = object[property]
} catch (error) {
this.props.onError!(new Error(`The property '${property}' could not be resolved on the object '${object}'.`))
return undefined
}
if (typeof member === 'function') return member.bind(object)
return member
}

#parseName = (element: AcornJSX.JSXIdentifier | AcornJSX.JSXMemberExpression): string => {
Expand Down
16 changes: 11 additions & 5 deletions source/types/acorn-jsx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ declare module 'acorn-jsx' {
type: 'CallExpression';
arguments: Expression[];
callee: Expression;
optional: boolean;
}

export interface ConditionalExpression extends BaseExpression {
Expand Down Expand Up @@ -122,10 +123,15 @@ declare module 'acorn-jsx' {
export interface MemberExpression extends BaseExpression {
type: 'MemberExpression';
computed: boolean;
optional: boolean;
name?: string;
object: Literal | MemberExpression;
property?: MemberExpression;
raw?: string;
object: Expression;
property: Expression;
}

export interface ChainExpression extends BaseExpression {
type: 'ChainExpression';
expression: MemberExpression | CallExpression;
}

export interface ObjectExpression extends BaseExpression {
Expand Down Expand Up @@ -155,9 +161,9 @@ declare module 'acorn-jsx' {

export type Expression =
JSXAttribute | JSXAttributeExpression | JSXElement | JSXExpressionContainer |
JSXSpreadAttribute | JSXFragment | JSXText |
JSXSpreadAttribute | JSXFragment | JSXText | ChainExpression | MemberExpression |
ArrayExpression | BinaryExpression | CallExpression | ConditionalExpression |
ExpressionStatement | Identifier | Literal | LogicalExpression | MemberExpression |
ExpressionStatement | Identifier | Literal | LogicalExpression |
ObjectExpression | TemplateElement | TemplateLiteral | UnaryExpression |
ArrowFunctionExpression

Expand Down