diff --git a/README.md b/README.md
index a696ff080f..376c92296f 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ Finally, enable all of the rules that you would like to use. Use [our preset](#
* [react/no-render-return-value](docs/rules/no-render-return-value.md): Prevent usage of the return value of `React.render`
* [react/no-set-state](docs/rules/no-set-state.md): Prevent usage of `setState`
* [react/no-string-refs](docs/rules/no-string-refs.md): Prevent using string references in `ref` attribute.
+* [react/no-unescaped-entities](docs/rules/no-unescaped-entities.md): Prevent invalid characters from appearing in markup
* [react/no-unknown-property](docs/rules/no-unknown-property.md): Prevent usage of unknown DOM property (fixable)
* [react/no-unused-prop-types](docs/rules/no-unused-prop-types.md): Prevent definitions of unused prop types
* [react/prefer-es6-class](docs/rules/prefer-es6-class.md): Enforce ES5 or ES6 class for React Components
diff --git a/docs/rules/no-unescaped-entities.md b/docs/rules/no-unescaped-entities.md
new file mode 100644
index 0000000000..2040d2c07c
--- /dev/null
+++ b/docs/rules/no-unescaped-entities.md
@@ -0,0 +1,70 @@
+# Prevent invalid characters from appearing in markup (no-unescaped-entities)
+
+This rule prevents characters that you may have meant as JSX escape characters
+from being accidentally injected as a text node in JSX statements.
+
+For example, if one were to misplace their closing `>` in a tag:
+
+```jsx
+ {/* oops! */}
+ x="y">
+ Body Text
+
+```
+
+The body text of this would render as `x="y"> Body Text`, which is probably not
+what was intended. This rule requires that these special characters are
+escaped if they appear in the body of a tag.
+
+Another example is when one accidentally includes an extra closing brace.
+
+```jsx
+{'Text'}}
+```
+
+The extra brace will be rendered, and the body text will be `Text}`.
+
+This rule will also check for `"` and `'`, which might be accidentally included
+when the closing `>` is in the wrong place.
+
+```jsx
+ {/* oops! */}
+ c="d"
+ Intended body text
+
+```
+
+The preferred way to include one of these characters is to use the HTML escape code.
+
+- `>` can be replaced with `>`
+- `"` can be replaced with `"`, `“` or `”`
+- `'` can be replaced with `'`, `‘` or `’`
+- `}` can be replaced with `}`
+
+Alternatively, you can include the literal character inside a subexpression
+(such as `
{'>'}
`.
+
+The characters `<` and `{` should also be escaped, but they are not checked by this
+rule because it is a syntax error to include those tokens inside of a tag.
+
+## Rule Details
+
+The following patterns are considered warnings:
+
+```jsx
+ >
+```
+
+The following patterns are not considered warnings:
+
+```jsx
+ >
+```
+
+```jsx
+ {'>'}
+```
diff --git a/index.js b/index.js
index a0ac8bf0b1..ff5d9d9eef 100644
--- a/index.js
+++ b/index.js
@@ -21,6 +21,7 @@ var rules = {
'no-did-mount-set-state': require('./lib/rules/no-did-mount-set-state'),
'no-did-update-set-state': require('./lib/rules/no-did-update-set-state'),
'no-render-return-value': require('./lib/rules/no-render-return-value'),
+ 'no-unescaped-entities': require('./lib/rules/no-unescaped-entities'),
'react-in-jsx-scope': require('./lib/rules/react-in-jsx-scope'),
'jsx-uses-vars': require('./lib/rules/jsx-uses-vars'),
'jsx-handler-names': require('./lib/rules/jsx-handler-names'),
diff --git a/lib/rules/no-unescaped-entities.js b/lib/rules/no-unescaped-entities.js
new file mode 100644
index 0000000000..3548cb43ed
--- /dev/null
+++ b/lib/rules/no-unescaped-entities.js
@@ -0,0 +1,80 @@
+/**
+ * @fileoverview HTML special characters should be escaped.
+ * @author Patrick Hayes
+ */
+'use strict';
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+// NOTE: '<' and '{' are also problematic characters, but they do not need
+// to be included here because it is a syntax error when these characters are
+// included accidentally.
+var DEFAULTS = ['>', '"', '\'', '}'];
+
+module.exports = {
+ meta: {
+ docs: {
+ description: 'Detect unescaped HTML entities, which might represent malformed tags',
+ category: 'Possible Errors',
+ recommended: false
+ },
+ schema: [{
+ type: 'object',
+ properties: {
+ forbid: {
+ type: 'array',
+ items: {
+ type: 'string'
+ }
+ }
+ },
+ additionalProperties: false
+ }]
+ },
+
+ create: function(context) {
+ function isInvalidEntity(node) {
+ var configuration = context.options[0] || {};
+ var entities = configuration.forbid || DEFAULTS;
+
+ // HTML entites are already escaped in node.value (as well as node.raw),
+ // so pull the raw text from context.getSourceCode()
+ for (var i = node.loc.start.line; i <= node.loc.end.line; i++) {
+ var rawLine = context.getSourceCode().lines[i - 1];
+ var start = 0;
+ var end = rawLine.length;
+ if (i === node.loc.start.line) {
+ start = node.loc.start.column;
+ }
+ if (i === node.loc.end.line) {
+ end = node.loc.end.column;
+ }
+ rawLine = rawLine.substring(start, end);
+ for (var j = 0; j < entities.length; j++) {
+ for (var index = 0; index < rawLine.length; index++) {
+ var c = rawLine[index];
+ if (c === entities[j]) {
+ context.report({
+ loc: {line: i, column: start + index},
+ message: 'HTML entities must be escaped.',
+ node: node
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ Literal: function(node) {
+ if (node.type === 'Literal' && node.parent.type === 'JSXElement') {
+ if (isInvalidEntity(node)) {
+ context.report(node, 'HTML entities must be escaped.');
+ }
+ }
+ }
+ };
+ }
+};
diff --git a/tests/lib/rules/no-unescaped-entities.js b/tests/lib/rules/no-unescaped-entities.js
new file mode 100644
index 0000000000..098dd4f76d
--- /dev/null
+++ b/tests/lib/rules/no-unescaped-entities.js
@@ -0,0 +1,138 @@
+/**
+ * @fileoverview Tests for no-unescaped-entities
+ * @author Patrick Hayes
+ */
+'use strict';
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+var rule = require('../../../lib/rules/no-unescaped-entities');
+var RuleTester = require('eslint').RuleTester;
+var parserOptions = {
+ ecmaFeatures: {
+ jsx: true
+ }
+};
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+var ruleTester = new RuleTester();
+ruleTester.run('no-unescaped-entities', rule, {
+
+ valid: [
+ {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return (',
+ ' ',
+ ' );',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return Here is some text!
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return I’ve escaped some entities: > < &
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return first line is ok',
+ ' so is second',
+ ' and here are some escaped entities: > < &
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return {">" + "<" + "&" + \'"\'}
;',
+ ' },',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions
+ }
+ ],
+
+ invalid: [
+ {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return >
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions,
+ errors: [{message: 'HTML entities must be escaped.'}]
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return first line is ok',
+ ' so is second',
+ ' and here are some bad entities: >
',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions,
+ errors: [{message: 'HTML entities must be escaped.'}]
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return \'
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions,
+ errors: [{message: 'HTML entities must be escaped.'}]
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return Multiple errors: \'>>
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions,
+ errors: [
+ {message: 'HTML entities must be escaped.'},
+ {message: 'HTML entities must be escaped.'},
+ {message: 'HTML entities must be escaped.'}
+ ]
+ }, {
+ code: [
+ 'var Hello = React.createClass({',
+ ' render: function() {',
+ ' return {"Unbalanced braces"}}
;',
+ ' }',
+ '});'
+ ].join('\n'),
+ parserOptions: parserOptions,
+ errors: [{message: 'HTML entities must be escaped.'}]
+ }
+ ]
+});