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

AcornJSX Refactor #19

Merged
merged 5 commits into from
Jul 2, 2017
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
592 changes: 535 additions & 57 deletions lib/react-jsx-parser.min.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
"url": "[email protected]:TroyAlford/react-jsx-parser.git"
},
"dependencies": {
"babel-plugin-transform-react-remove-prop-types": "^0.4.6",
"acorn-jsx": "^4.0.1",
"react": "^15.6.1"
},
"devDependencies": {
"babel-cli": "^6.22.2",
"babel-loader": "^7.1.0",
"babel-plugin-transform-object-rest-spread": "^6.22.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.6",
"babel-preset-es2015": "^6.22.0",
"babel-preset-jest": "^20.0.3",
"babel-preset-react": "^6.22.0",
Expand Down
205 changes: 93 additions & 112 deletions source/components/JsxParser.js
Original file line number Diff line number Diff line change
@@ -1,139 +1,116 @@
import { Parser } from 'acorn-jsx'
import React, { Component } from 'react'
import camelCase from '../helpers/camelCase'
import parseStyle from '../helpers/parseStyle'
import hasDoctype from '../helpers/hasDoctype'

import ATTRIBUTES from '../constants/attributeNames'
import NODE_TYPES from '../constants/nodeTypes'
import { canHaveChildren, canHaveWhitespace } from '../constants/specialTags'

const parser = new DOMParser()

const warnParseErrors = (doc) => {
const errors = Array.from(doc.documentElement.childNodes)
// eslint-disable-next-line no-console
console.warn(`Unable to parse jsx. Found ${errors.length} error(s):`)

const warn = (node, indent) => {
if (node.childNodes.length) {
Array.from(node.childNodes)
.forEach(n => warn(n, indent.concat(' ')))
}

// eslint-disable-next-line no-console
console.warn(`${indent}==> ${node.nodeValue}`)
}

errors.forEach(e => warn(e, ' '))
}
const parserOptions = { plugins: { jsx: true } }

export default class JsxParser extends Component {
constructor(props) {
super(props)
this.parseJSX.bind(this)
this.parseNode.bind(this)
this.parseElement = this.parseElement.bind(this)
this.parseExpression = this.parseExpression.bind(this)
this.parseJSX = this.parseJSX.bind(this)
this.handleNewProps = this.handleNewProps.bind(this)

this.ParsedChildren = this.parseJSX(props.jsx || '')
this.handleNewProps(props)
}

componentWillReceiveProps(props) {
this.ParsedChildren = this.parseJSX(props.jsx || '')
this.handleNewProps(props)
}

parseJSX(rawJSX) {
if (!rawJSX || typeof rawJSX !== 'string') return []

const jsx = this.props.blacklistedTags.reduce((raw, tag) =>
raw.replace(new RegExp(`(</?)${tag}`, 'ig'), '$1REMOVE')
, rawJSX).trim()

const wrappedJsx = hasDoctype(jsx) ? jsx : `<!DOCTYPE html>\n<html><body>${jsx}</body></html>`

const doc = parser.parseFromString(wrappedJsx, 'application/xhtml+xml')
handleNewProps(props) {
this.blacklistedTags = (props.blacklistedTags || [])
.map(tag => tag.trim().toLowerCase()).filter(Boolean)
this.blacklistedAttrs = (props.blacklistedAttrs || [])
.map(attr => (attr instanceof RegExp ? attr : new RegExp(attr, 'i')))

if (!doc) return []

Array.from(doc.getElementsByTagName('REMOVE')).forEach(tag =>
tag.parentNode.removeChild(tag)
)
const jsx = (props.jsx || '').trim().replace(/<!DOCTYPE([^>]*)>/g, '')
this.ParsedChildren = this.parseJSX(jsx)
}

const body = doc.getElementsByTagName('body')[0]
if (!body || body.nodeName.toLowerCase() === 'parseerror') {
if (this.props.showWarnings) warnParseErrors(doc)
parseJSX(rawJSX) {
const wrappedJsx = `<root>${rawJSX}</root>`
let parsed = []
try {
parsed = (new Parser(parserOptions, wrappedJsx)).parse()
parsed = parsed.body[0].expression.children || []
} catch (error) {
// eslint-disable-next-line no-console
if (this.props.showWarnings) console.warn(error)
return []
}

return this.parseNode(body.childNodes || [], this.props.components)
return parsed.map(this.parseExpression).filter(Boolean)
}
parseNode(node, components = {}, key) {
if (node instanceof NodeList || Array.isArray(node)) {
return Array.from(node) // handle nodeList or []
.map((child, index) => this.parseNode(child, components, index))
.filter(Boolean) // remove falsy nodes
}

if (node.nodeType === NODE_TYPES.TEXT) {
// Text node. Collapse whitespace and return it as a String.
return ('textContent' in node ? node.textContent : node.nodeValue || '')
.replace(/[\r\n\t\f\v]/g, '')
.replace(/\s{2,}/g, ' ')
} else if (node.nodeType === NODE_TYPES.ELEMENT) {
// Element node. Parse its Attributes and Children, then call createElement
let children
if (canHaveChildren(node.nodeName)) {
children = this.parseNode(node.childNodes, components)
if (!canHaveWhitespace(node.nodeName)) {
children = children.filter(child =>
typeof child !== 'string' || !child.match(/^\s*$/)
)
}
}

return React.createElement(
components[node.nodeName] || node.nodeName,
{
...this.props.bindings || {},
...this.parseAttrs(node.attributes, key),
},
children,
)
}

if (this.props.showWarnings) {
// eslint-disable-next-line no-console
console.warn(`JsxParser encountered a(n) ${NODE_TYPES[node.nodeType]} node, and discarded it.`)
parseExpression(expression, key) {
/* eslint-disable no-case-declarations */
const value = expression.value

switch (expression.type) {
case 'JSXElement':
return this.parseElement(expression, key)
case 'JSXText':
return (value || '').replace(/\s+/g, ' ')
case 'JSXAttribute':
if (expression.value === null) return true
return this.parseExpression(expression.value)

case 'ArrayExpression':
return expression.elements.map(this.parseExpression)
case 'ObjectExpression':
const object = {}
expression.properties.forEach((prop) => {
object[prop.key.name] = this.parseExpression(prop.value)
})
return object
case 'JsxExpressionContainer':
return this.parseExpression(expression.expression)
case 'Literal':
return value

default:
return undefined
}
return null
}

parseAttrs(attrs, key) {
if (!attrs || !attrs.length) return { key }
parseElement(element, key) {
const { bindings = {}, components = {} } = this.props
const { children = [], openingElement: { attributes, name: { name } } } = element

const blacklist = this.props.blacklistedAttrs
if (/^(html|head|body)$/i.test(name)) return children.map(c => this.parseElement(c))

return Array.from(attrs)
.filter(attr =>
!blacklist.map(mask =>
// If any mask matches, it will return a non-null value
attr.name.match(new RegExp(mask, 'gi'))
).filter(match => match !== null).length
)
.reduce((current, attr) => {
let { name, value } = attr
if (value === '') value = true

if (name.match(/^on/i)) {
value = new Function(value) // eslint-disable-line no-new-func
} else if (name === 'style') {
value = parseStyle(value)
if (this.blacklistedTags.indexOf(name.trim().toLowerCase()) !== -1) return undefined
let parsedChildren
if (canHaveChildren(name)) {
parsedChildren = children.map(this.parseExpression)
if (!canHaveWhitespace(name)) {
parsedChildren = parsedChildren.filter(child =>
typeof child !== 'string' || !/^\s*$/.test(child)
)
}
}

name = ATTRIBUTES[name.toLowerCase()] || camelCase(name)
const attrs = { key, ...bindings }
attributes.forEach((expr) => {
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)

return {
...current,
[name]: value,
}
}, { key })
const matches = this.blacklistedAttrs.filter(re => re.test(attributeName))
if (matches.length === 0) attrs[attributeName] = value
})

if (typeof attrs.style === 'string') {
attrs.style = parseStyle(attrs.style)
}

return React.createElement(components[name] || name, attrs, parsedChildren)
}

render() {
Expand All @@ -147,22 +124,26 @@ export default class JsxParser extends Component {

JsxParser.defaultProps = {
bindings: {},
blacklistedAttrs: ['on[a-z]*'],
blacklistedAttrs: [/^on.+/i],
blacklistedTags: ['script'],
components: [],
jsx: '',
showWarnings: false,
}

if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV !== 'production') {
/* eslint-disable react/no-unused-prop-types*/
// eslint-disable-next-line global-require,import/no-extraneous-dependencies
const PropTypes = require('prop-types')
JsxParser.propTypes = {
bindings: PropTypes.shape({}),
blacklistedAttrs: PropTypes.arrayOf(PropTypes.string),
blacklistedTags: PropTypes.arrayOf(PropTypes.string),
components: PropTypes.shape({}),
jsx: PropTypes.string,
blacklistedAttrs: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(RegExp),
])),
blacklistedTags: PropTypes.arrayOf(PropTypes.string),
components: PropTypes.shape({}),
jsx: PropTypes.string,

showWarnings: PropTypes.bool,
}
Expand Down
17 changes: 15 additions & 2 deletions source/components/JsxParser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import JsxParser from './JsxParser'

jest.unmock('acorn-jsx')
jest.unmock('./JsxParser')

// eslint-disable-next-line react/prefer-stateless-function
Expand Down Expand Up @@ -233,7 +234,6 @@ describe('JsxParser Component', () => {
)

expect(component.ParsedChildren).toHaveLength(2)
// The
expect(component.ParsedChildren[0].props).toEqual({
foo: 'Foo', // from `bindings`
bar: 'Baz', // from jsx attributes (takes precedence)
Expand Down Expand Up @@ -363,6 +363,19 @@ describe('JsxParser Component', () => {
expect(rendered.childNodes).toHaveLength(2)
})

it('handles implicit boolean props correctly', () => {
const { component } = render(
<JsxParser
components={{ Custom }}
jsx="<Custom shouldBeTrue shouldBeFalse={false} />"
/>
)

expect(component.ParsedChildren).toHaveLength(1)
expect(component.ParsedChildren[0].props.shouldBeTrue).toBeTruthy()
expect(component.ParsedChildren[0].props.shouldBeFalse).not.toBeTruthy()
})

it('does not render children for poorly formed void elements', () => {
const { rendered } = render(
<JsxParser
Expand Down Expand Up @@ -418,7 +431,7 @@ describe('JsxParser Component', () => {
expect(rendered.getElementsByTagName('h1')[1].textContent).toEqual('Lorem')
})

it('does work when DOCTYPE and html is already added', () => {
it('skips over DOCTYPE, html, head, and div if found', () => {
const { rendered } = render(
<JsxParser
jsx={'<!DOCTYPE html><html><head></head><body><h1>Test</h1><p>Another Text</p></body></html>'}
Expand Down
30 changes: 0 additions & 30 deletions source/constants/nodeTypes.js

This file was deleted.

7 changes: 0 additions & 7 deletions source/helpers/hasDoctype.js

This file was deleted.

41 changes: 0 additions & 41 deletions source/helpers/hasDoctype.test.js

This file was deleted.

Loading