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

Add autoCloseVoidElements #163

Merged
merged 4 commits into from Nov 14, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 28 additions & 4 deletions README.md
Expand Up @@ -85,17 +85,41 @@ Any `ComponentA`, `ComponentB`, `ComponentC` or `ComponentD` tags in the dynamic

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

## Advanced Usage - HTML & Self-Closing Tags
When rendering HTML, standards-adherent editors will render `img`, `hr`, `br`, and other
[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`.

### Example:
```jsx
// <hr> has no closing tag, which is valid HTML, but not valid JSX
<JsxParser jsx="<hr><div className='foo'>Foo</div>" />
// Renders: null

// <hr></hr> is invalid HTML, but valid JSX
<JsxParser jsx="<hr></hr><div className='foo'>Foo</div>" />
// Renders: <hr><div class='foo'>Foo</div>

// This is valid HTML, and the `autoCloseVoidElements` prop allows it to render
<JsxParser autoCloseVoidElements jsx="<hr><div className='foo'>Foo</div>" />
// Renders: <hr><div class='foo'>Foo</div>

// This would work in a browser, but will no longer parse with `autoCloseVoidElements`
<JsxParser autoCloseVoidElements jsx="<hr></hr><div className='foo'>Foo</div>" />
// Renders: null
```

## PropTypes / Settings
```javascript
JsxParser.defaultProps = {
// if false, unrecognized elements like <foo> are omitted and reported via onError
allowUnknownElements: true, // by default, allow unrecognized elements
// if false, unrecognized elements like <foo> are omitted and reported via onError

autoCloseVoidElements: false, // by default, unclosed void elements will not parse. See examples

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion dist/cjs/react-jsx-parser.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/cjs/react-jsx-parser.min.js.map

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions dist/components/JsxParser.d.ts
@@ -1,13 +1,14 @@
import React from 'react';
import React, { Component, FunctionComponent } from 'react';
export declare type TProps = {
allowUnknownElements?: boolean;
autoCloseVoidElements?: boolean;
bindings?: {
[key: string]: unknown;
};
blacklistedAttrs?: Array<string | RegExp>;
blacklistedTags?: string[];
className?: string;
components?: Record<string, React.JSXElementConstructor<unknown>>;
components?: Record<string, Component | FunctionComponent>;
componentsOnly?: boolean;
disableFragments?: boolean;
disableKeyGeneration?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion dist/es5/react-jsx-parser.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/es5/react-jsx-parser.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/umd/react-jsx-parser.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/umd/react-jsx-parser.min.js.map

Large diffs are not rendered by default.

12 changes: 5 additions & 7 deletions package.json
Expand Up @@ -14,7 +14,7 @@
"types": "dist/index.d.ts",
"name": "react-jsx-parser",
"repository": "TroyAlford/react-jsx-parser",
"version": "1.27.0",
"version": "1.28.0",
"dependencies": {
"@types/jsdom": "^16.2.4",
"acorn": "^8.0.4",
Expand Down Expand Up @@ -53,6 +53,7 @@
"jest-cli": "^26.5.3",
"jest-environment-jsdom-fourteen": "^1.0.1",
"mkdirp": "^1.0.4",
"patch-package": "^6.2.2",
"react": "^16",
"react-dom": "^16",
"source-map-explorer": "^2.5.0",
Expand All @@ -74,17 +75,14 @@
"merge": "^1.2.1"
},
"scripts": {
"build": "yarn types && cross-env NODE_ENV=production webpack",
"build": "yarn patch-package && yarn types && cross-env NODE_ENV=production webpack",
"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\"",
"types": "tsc -p ./tsconfig.json -d --emitDeclarationOnly",
"postversion": "git push origin HEAD && git push origin HEAD --tags",
"prebuild": "mkdirp ./dist && rm -rf ./dist/*",
"preversion": "yarn test",
"sourcemap": "yarn build && source-map-explorer ./dist/es5/react-jsx-parser.min.js",
"test": "yarn jest",
"version": "yarn build && git add -A ./dist"
"test": "yarn patch-package && yarn jest"
},
"contributors": [
"contributors": [
{
"name": "akucheruk-vareger",
"url": "https://github.com/akucheruk-vareger"
Expand Down
28 changes: 28 additions & 0 deletions patches/acorn-jsx+5.3.1.patch
@@ -0,0 +1,28 @@
diff --git a/node_modules/acorn-jsx/index.js b/node_modules/acorn-jsx/index.js
index 004e080..aed0558 100644
--- a/node_modules/acorn-jsx/index.js
+++ b/node_modules/acorn-jsx/index.js
@@ -75,7 +75,8 @@ module.exports = function(options) {
return function(Parser) {
return plugin({
allowNamespaces: options.allowNamespaces !== false,
- allowNamespacedObjects: !!options.allowNamespacedObjects
+ allowNamespacedObjects: !!options.allowNamespacedObjects,
+ autoCloseVoidElements: !!options.autoCloseVoidElements,
}, Parser);
};
};
@@ -356,6 +357,13 @@ function plugin(options, Parser) {
node.attributes.push(this.jsx_parseAttribute());
node.selfClosing = this.eat(tt.slash);
this.expect(tok.jsxTagEnd);
+ const VOID_ELEMENTS = [
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
+ 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'
+ ]
+ if (options.autoCloseVoidElements && nodeName && VOID_ELEMENTS.includes(nodeName.name)) {
+ node.selfClosing = true;
+ }
return this.finishNode(node, nodeName ? 'JSXOpeningElement' : 'JSXOpeningFragment');
}

65 changes: 40 additions & 25 deletions source/components/JsxParser.test.tsx
Expand Up @@ -704,31 +704,6 @@ describe('JsxParser Component', () => {
})
})
describe('prop bindings', () => {
test('allows void-element named custom components to take children', () => {
// eslint-disable-next-line react/prop-types
const link = ({ to, children }) => (<a href={to}>{children}</a>)
const { rendered } = render(<JsxParser components={{ link }} jsx={'<link to="/url">Text</link>'} />)
expect(rendered.childNodes[0].nodeName).toEqual('A')
expect(rendered.childNodes[0].textContent).toEqual('Text')
})
test('does not render children for poorly formed void elements', () => {
const { rendered } = render(
<JsxParser
jsx={
'<img src="/foo.png">'
+ '<div class="invalidChild"></div>'
+ '</img>'
}
/>,
)

expect(rendered.childNodes).toHaveLength(1)
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
expect(rendered.childNodes[0].innerHTML).toEqual('')
expect(rendered.childNodes[0].childNodes).toHaveLength(0)

expect(rendered.getElementsByTagName('div')).toHaveLength(0)
})
test('parses childless elements with children = undefined', () => {
const { component } = render(<JsxParser components={{ Custom }} jsx="<Custom />" />)

Expand Down Expand Up @@ -1128,6 +1103,46 @@ describe('JsxParser Component', () => {
)
expect(html).toEqual('<div class="foo">foo</div>')
})
describe('void elements', () => {
test('void-element named custom components to take children', () => {
// eslint-disable-next-line react/prop-types
const link = ({ to, children }) => (<a href={to}>{children}</a>)
const { rendered } = render(<JsxParser components={{ link }} jsx={'<link to="/url">Text</link>'} />)
expect(rendered.childNodes[0].nodeName).toEqual('A')
expect(rendered.childNodes[0].textContent).toEqual('Text')
})
})
describe('self-closing tags', () => {
test('by default, renders self-closing tags without their children', () => {
const { rendered } = render(
<JsxParser showWarnings jsx='<img src="/foo.png"><div class="invalidChild"></div></img>' />,
)

expect(rendered.childNodes).toHaveLength(1)
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
expect(rendered.childNodes[0].innerHTML).toEqual('')
expect(rendered.childNodes[0].childNodes).toHaveLength(0)

expect(rendered.getElementsByTagName('div')).toHaveLength(0)
})
test('props.autoCloseVoidElements=true auto-closes self-closing tags', () => {
const { rendered } = render(
<JsxParser autoCloseVoidElements jsx='<img src="/foo.png"><div>Foo</div>' />,
)

expect(rendered.childNodes).toHaveLength(2)
expect(rendered.getElementsByTagName('img')).toHaveLength(1)
expect(rendered.childNodes[0].innerHTML).toEqual('')
expect(rendered.childNodes[0].childNodes).toHaveLength(0)
expect(rendered.getElementsByTagName('div')).toHaveLength(1)
})
test('props.autoCloseVoidElements=true prevents self-closing tags with closing tags from parsing', () => {
const { rendered } = render(
<JsxParser autoCloseVoidElements jsx='<img src="/foo.png"></img><div></div>' />,
)
expect(rendered.childNodes).toHaveLength(0)
})
})
test('throws on non-simple literal and global object instance methods', () => {
// Some of these would normally fail silently, set `onError` forces throw for assertion purposes
expect(() => render(<JsxParser jsx="{ window.scrollTo() }" onError={e => { throw e }} />)).toThrow()
Expand Down
14 changes: 8 additions & 6 deletions source/components/JsxParser.tsx
@@ -1,7 +1,7 @@
/* global JSX */
import * as Acorn from 'acorn'
import * as AcornJSX from 'acorn-jsx'
import React, { Fragment } from 'react'
import React, { Component, FunctionComponent, Fragment } from 'react'
import ATTRIBUTES from '../constants/attributeNames'
import { canHaveChildren, canHaveWhitespace } from '../constants/specialTags'
import { randomHash } from '../helpers/hash'
Expand All @@ -12,11 +12,12 @@ 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, React.JSXElementConstructor<unknown>>,
components?: Record<string, Component | FunctionComponent>,
componentsOnly?: boolean,
disableFragments?: boolean,
disableKeyGeneration?: boolean,
Expand All @@ -28,14 +29,12 @@ export type TProps = {
renderUnrecognized?: (tagName: string) => JSX.Element | null,
}

const parser = Acorn.Parser.extend(AcornJSX.default())

/* 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'],
Expand All @@ -55,6 +54,9 @@ export default class JsxParser extends React.Component<TProps> {
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 {
Expand All @@ -68,7 +70,7 @@ export default class JsxParser extends React.Component<TProps> {
if (this.props.renderError) {
return this.props.renderError({ error: String(error) })
}
return []
return null
}

return parsed.map(this.#parseExpression).filter(Boolean)
Expand Down
8 changes: 7 additions & 1 deletion source/demo.tsx
Expand Up @@ -5,7 +5,13 @@ import JsxParser from '../dist/umd/react-jsx-parser.min'

ReactDOM.render(
<JsxParser
jsx="<div className='foo'>bar</div>"
autoCloseVoidElements
jsx={`
<img src="http://placekitten.com/300/500">
<div className="foo">bar</div>
`}
onError={console.error}
showWarnings
/>,
document.querySelector('#root'),
)
7 changes: 6 additions & 1 deletion source/types/acorn-jsx.d.ts
Expand Up @@ -150,6 +150,11 @@ declare module 'acorn-jsx' {
ExpressionStatement | Identifier | Literal | LogicalExpression | MemberExpression |
ObjectExpression | TemplateElement | TemplateLiteral | UnaryExpression

export default function(): any
interface PluginOptions {
allowNamespacedObjects?: boolean,
allowNamespaces?: boolean,
autoCloseVoidElements?: boolean,
}
export default function(options?: PluginOptions): any
}
/* eslint-enable no-use-before-define */