Skip to content

Commit fd97894

Browse files
authoredNov 14, 2020
Add autoCloseVoidElements (TroyAlford#163)
* Add autoCloseVoidElements * Update demo & components typing * Update build process for patched acorn * Bump version to 1.28.0
1 parent fae48c8 commit fd97894

15 files changed

+200
-53
lines changed
 

‎README.md

+28-4
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,41 @@ Any `ComponentA`, `ComponentB`, `ComponentC` or `ComponentD` tags in the dynamic
8585

8686
_Note:_ Non-standard tags may throw errors and warnings, but will typically be rendered in a reasonable way.
8787

88+
## Advanced Usage - HTML & Self-Closing Tags
89+
When rendering HTML, standards-adherent editors will render `img`, `hr`, `br`, and other
90+
[void elements](https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#void-elements) with no trailing `/>`. While this is valid HTML, it is _not_ valid JSX. If you wish to opt-in to a more HTML-like parsing style, set the `autoCloseVoidElements` prop to `true`.
91+
92+
### Example:
93+
```jsx
94+
// <hr> has no closing tag, which is valid HTML, but not valid JSX
95+
<JsxParser jsx="<hr><div className='foo'>Foo</div>" />
96+
// Renders: null
97+
98+
// <hr></hr> is invalid HTML, but valid JSX
99+
<JsxParser jsx="<hr></hr><div className='foo'>Foo</div>" />
100+
// Renders: <hr><div class='foo'>Foo</div>
101+
102+
// This is valid HTML, and the `autoCloseVoidElements` prop allows it to render
103+
<JsxParser autoCloseVoidElements jsx="<hr><div className='foo'>Foo</div>" />
104+
// Renders: <hr><div class='foo'>Foo</div>
105+
106+
// This would work in a browser, but will no longer parse with `autoCloseVoidElements`
107+
<JsxParser autoCloseVoidElements jsx="<hr></hr><div className='foo'>Foo</div>" />
108+
// Renders: null
109+
```
110+
88111
## PropTypes / Settings
89112
```javascript
90113
JsxParser.defaultProps = {
91-
// if false, unrecognized elements like <foo> are omitted and reported via onError
92114
allowUnknownElements: true, // by default, allow unrecognized elements
115+
// if false, unrecognized elements like <foo> are omitted and reported via onError
116+
117+
autoCloseVoidElements: false, // by default, unclosed void elements will not parse. See examples
93118

94119
bindings: {}, // by default, do not add any additional bindings
95120

96-
// by default, just removes `on*` attributes (onClick, onChange, etc.)
97-
// values are used as a regex to match property names
98-
blacklistedAttrs: [/^on.+/i],
121+
blacklistedAttrs: [/^on.+/i], // default: removes `on*` attributes (onClick, onChange, etc.)
122+
// any attribute name which matches any of these RegExps will be omitted entirely
99123

100124
blacklistedTags: ['script'], // by default, removes all <script> tags
101125

‎dist/cjs/react-jsx-parser.min.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/cjs/react-jsx-parser.min.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/components/JsxParser.d.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import React from 'react';
1+
import React, { Component, FunctionComponent } from 'react';
22
export declare type TProps = {
33
allowUnknownElements?: boolean;
4+
autoCloseVoidElements?: boolean;
45
bindings?: {
56
[key: string]: unknown;
67
};
78
blacklistedAttrs?: Array<string | RegExp>;
89
blacklistedTags?: string[];
910
className?: string;
10-
components?: Record<string, React.JSXElementConstructor<unknown>>;
11+
components?: Record<string, Component | FunctionComponent>;
1112
componentsOnly?: boolean;
1213
disableFragments?: boolean;
1314
disableKeyGeneration?: boolean;

‎dist/es5/react-jsx-parser.min.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/es5/react-jsx-parser.min.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/umd/react-jsx-parser.min.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/umd/react-jsx-parser.min.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+5-7
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"types": "dist/index.d.ts",
1515
"name": "react-jsx-parser",
1616
"repository": "TroyAlford/react-jsx-parser",
17-
"version": "1.27.0",
17+
"version": "1.28.0",
1818
"dependencies": {
1919
"@types/jsdom": "^16.2.4",
2020
"acorn": "^8.0.4",
@@ -53,6 +53,7 @@
5353
"jest-cli": "^26.5.3",
5454
"jest-environment-jsdom-fourteen": "^1.0.1",
5555
"mkdirp": "^1.0.4",
56+
"patch-package": "^6.2.2",
5657
"react": "^16",
5758
"react-dom": "^16",
5859
"source-map-explorer": "^2.5.0",
@@ -74,17 +75,14 @@
7475
"merge": "^1.2.1"
7576
},
7677
"scripts": {
77-
"build": "yarn types && cross-env NODE_ENV=production webpack",
78+
"build": "yarn patch-package && yarn types && cross-env NODE_ENV=production webpack",
7879
"develop": "NODE_ENV=production concurrently -n build,ts,demo -c green,cyan,yellow \"yarn webpack --watch\" \"yarn types --watch\" \"yarn webpack serve --config ./webpack.demo.js\"",
7980
"types": "tsc -p ./tsconfig.json -d --emitDeclarationOnly",
80-
"postversion": "git push origin HEAD && git push origin HEAD --tags",
8181
"prebuild": "mkdirp ./dist && rm -rf ./dist/*",
82-
"preversion": "yarn test",
8382
"sourcemap": "yarn build && source-map-explorer ./dist/es5/react-jsx-parser.min.js",
84-
"test": "yarn jest",
85-
"version": "yarn build && git add -A ./dist"
83+
"test": "yarn patch-package && yarn jest"
8684
},
87-
"contributors": [
85+
"contributors": [
8886
{
8987
"name": "akucheruk-vareger",
9088
"url": "https://github.com/akucheruk-vareger"

‎patches/acorn-jsx+5.3.1.patch

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
diff --git a/node_modules/acorn-jsx/index.js b/node_modules/acorn-jsx/index.js
2+
index 004e080..aed0558 100644
3+
--- a/node_modules/acorn-jsx/index.js
4+
+++ b/node_modules/acorn-jsx/index.js
5+
@@ -75,7 +75,8 @@ module.exports = function(options) {
6+
return function(Parser) {
7+
return plugin({
8+
allowNamespaces: options.allowNamespaces !== false,
9+
- allowNamespacedObjects: !!options.allowNamespacedObjects
10+
+ allowNamespacedObjects: !!options.allowNamespacedObjects,
11+
+ autoCloseVoidElements: !!options.autoCloseVoidElements,
12+
}, Parser);
13+
};
14+
};
15+
@@ -356,6 +357,13 @@ function plugin(options, Parser) {
16+
node.attributes.push(this.jsx_parseAttribute());
17+
node.selfClosing = this.eat(tt.slash);
18+
this.expect(tok.jsxTagEnd);
19+
+ const VOID_ELEMENTS = [
20+
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
21+
+ 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'
22+
+ ]
23+
+ if (options.autoCloseVoidElements && nodeName && VOID_ELEMENTS.includes(nodeName.name)) {
24+
+ node.selfClosing = true;
25+
+ }
26+
return this.finishNode(node, nodeName ? 'JSXOpeningElement' : 'JSXOpeningFragment');
27+
}
28+

‎source/components/JsxParser.test.tsx

+40-25
Original file line numberDiff line numberDiff line change
@@ -704,31 +704,6 @@ describe('JsxParser Component', () => {
704704
})
705705
})
706706
describe('prop bindings', () => {
707-
test('allows void-element named custom components to take children', () => {
708-
// eslint-disable-next-line react/prop-types
709-
const link = ({ to, children }) => (<a href={to}>{children}</a>)
710-
const { rendered } = render(<JsxParser components={{ link }} jsx={'<link to="/url">Text</link>'} />)
711-
expect(rendered.childNodes[0].nodeName).toEqual('A')
712-
expect(rendered.childNodes[0].textContent).toEqual('Text')
713-
})
714-
test('does not render children for poorly formed void elements', () => {
715-
const { rendered } = render(
716-
<JsxParser
717-
jsx={
718-
'<img src="/foo.png">'
719-
+ '<div class="invalidChild"></div>'
720-
+ '</img>'
721-
}
722-
/>,
723-
)
724-
725-
expect(rendered.childNodes).toHaveLength(1)
726-
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
727-
expect(rendered.childNodes[0].innerHTML).toEqual('')
728-
expect(rendered.childNodes[0].childNodes).toHaveLength(0)
729-
730-
expect(rendered.getElementsByTagName('div')).toHaveLength(0)
731-
})
732707
test('parses childless elements with children = undefined', () => {
733708
const { component } = render(<JsxParser components={{ Custom }} jsx="<Custom />" />)
734709

@@ -1128,6 +1103,46 @@ describe('JsxParser Component', () => {
11281103
)
11291104
expect(html).toEqual('<div class="foo">foo</div>')
11301105
})
1106+
describe('void elements', () => {
1107+
test('void-element named custom components to take children', () => {
1108+
// eslint-disable-next-line react/prop-types
1109+
const link = ({ to, children }) => (<a href={to}>{children}</a>)
1110+
const { rendered } = render(<JsxParser components={{ link }} jsx={'<link to="/url">Text</link>'} />)
1111+
expect(rendered.childNodes[0].nodeName).toEqual('A')
1112+
expect(rendered.childNodes[0].textContent).toEqual('Text')
1113+
})
1114+
})
1115+
describe('self-closing tags', () => {
1116+
test('by default, renders self-closing tags without their children', () => {
1117+
const { rendered } = render(
1118+
<JsxParser showWarnings jsx='<img src="/foo.png"><div class="invalidChild"></div></img>' />,
1119+
)
1120+
1121+
expect(rendered.childNodes).toHaveLength(1)
1122+
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
1123+
expect(rendered.childNodes[0].innerHTML).toEqual('')
1124+
expect(rendered.childNodes[0].childNodes).toHaveLength(0)
1125+
1126+
expect(rendered.getElementsByTagName('div')).toHaveLength(0)
1127+
})
1128+
test('props.autoCloseVoidElements=true auto-closes self-closing tags', () => {
1129+
const { rendered } = render(
1130+
<JsxParser autoCloseVoidElements jsx='<img src="/foo.png"><div>Foo</div>' />,
1131+
)
1132+
1133+
expect(rendered.childNodes).toHaveLength(2)
1134+
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
1135+
expect(rendered.childNodes[0].innerHTML).toEqual('')
1136+
expect(rendered.childNodes[0].childNodes).toHaveLength(0)
1137+
expect(rendered.getElementsByTagName('div')).toHaveLength(1)
1138+
})
1139+
test('props.autoCloseVoidElements=true prevents self-closing tags with closing tags from parsing', () => {
1140+
const { rendered } = render(
1141+
<JsxParser autoCloseVoidElements jsx='<img src="/foo.png"></img><div></div>' />,
1142+
)
1143+
expect(rendered.childNodes).toHaveLength(0)
1144+
})
1145+
})
11311146
test('throws on non-simple literal and global object instance methods', () => {
11321147
// Some of these would normally fail silently, set `onError` forces throw for assertion purposes
11331148
expect(() => render(<JsxParser jsx="{ window.scrollTo() }" onError={e => { throw e }} />)).toThrow()

‎source/components/JsxParser.tsx

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* global JSX */
22
import * as Acorn from 'acorn'
33
import * as AcornJSX from 'acorn-jsx'
4-
import React, { Fragment } from 'react'
4+
import React, { Component, FunctionComponent, Fragment } from 'react'
55
import ATTRIBUTES from '../constants/attributeNames'
66
import { canHaveChildren, canHaveWhitespace } from '../constants/specialTags'
77
import { randomHash } from '../helpers/hash'
@@ -12,11 +12,12 @@ type ParsedJSX = JSX.Element | boolean | string
1212
type ParsedTree = ParsedJSX | ParsedJSX[] | null
1313
export type TProps = {
1414
allowUnknownElements?: boolean,
15+
autoCloseVoidElements?: boolean,
1516
bindings?: { [key: string]: unknown; },
1617
blacklistedAttrs?: Array<string | RegExp>,
1718
blacklistedTags?: string[],
1819
className?: string,
19-
components?: Record<string, React.JSXElementConstructor<unknown>>,
20+
components?: Record<string, Component | FunctionComponent>,
2021
componentsOnly?: boolean,
2122
disableFragments?: boolean,
2223
disableKeyGeneration?: boolean,
@@ -28,14 +29,12 @@ export type TProps = {
2829
renderUnrecognized?: (tagName: string) => JSX.Element | null,
2930
}
3031

31-
const parser = Acorn.Parser.extend(AcornJSX.default())
32-
3332
/* eslint-disable consistent-return */
3433
export default class JsxParser extends React.Component<TProps> {
3534
static displayName = 'JsxParser'
36-
3735
static defaultProps: TProps = {
3836
allowUnknownElements: true,
37+
autoCloseVoidElements: false,
3938
bindings: {},
4039
blacklistedAttrs: [/^on.+/i],
4140
blacklistedTags: ['script'],
@@ -55,6 +54,9 @@ export default class JsxParser extends React.Component<TProps> {
5554
private ParsedChildren: ParsedTree = null
5655

5756
#parseJSX = (jsx: string): JSX.Element | JSX.Element[] | null => {
57+
const parser = Acorn.Parser.extend(AcornJSX.default({
58+
autoCloseVoidElements: this.props.autoCloseVoidElements,
59+
}))
5860
const wrappedJsx = `<root>${jsx}</root>`
5961
let parsed: AcornJSX.Expression[] = []
6062
try {
@@ -68,7 +70,7 @@ export default class JsxParser extends React.Component<TProps> {
6870
if (this.props.renderError) {
6971
return this.props.renderError({ error: String(error) })
7072
}
71-
return []
73+
return null
7274
}
7375

7476
return parsed.map(this.#parseExpression).filter(Boolean)

‎source/demo.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import JsxParser from '../dist/umd/react-jsx-parser.min'
55

66
ReactDOM.render(
77
<JsxParser
8-
jsx="<div className='foo'>bar</div>"
8+
autoCloseVoidElements
9+
jsx={`
10+
<img src="http://placekitten.com/300/500">
11+
<div className="foo">bar</div>
12+
`}
13+
onError={console.error}
14+
showWarnings
915
/>,
1016
document.querySelector('#root'),
1117
)

‎source/types/acorn-jsx.d.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ declare module 'acorn-jsx' {
150150
ExpressionStatement | Identifier | Literal | LogicalExpression | MemberExpression |
151151
ObjectExpression | TemplateElement | TemplateLiteral | UnaryExpression
152152

153-
export default function(): any
153+
interface PluginOptions {
154+
allowNamespacedObjects?: boolean,
155+
allowNamespaces?: boolean,
156+
autoCloseVoidElements?: boolean,
157+
}
158+
export default function(options?: PluginOptions): any
154159
}
155160
/* eslint-enable no-use-before-define */

0 commit comments

Comments
 (0)