Repository: yannickcr/eslint-plugin-react
Pull request #681 (2016-07-15 02:02:01)

Rule for requiring HTML entities to be escaped
I will also add in some documentation around this, but wanted to get this out there to make sure I was on the right track

I ran this on our codebase and it caught a bunch of errors so it appears to work

pfhayes: Any thoughts on this rule? Happy to make any recommended updates.

We've been running this in development and have already gotten a lot of value out of it. It has caught several errors around mistyped closing tags

cc @yannickcr 
ljharb: @pfhayes please rebase so as to remove merge commits from the PR :-)
pfhayes: I've squashed this into a single commit
lencioni: This looks good. Thanks for your contribution!

Patch:
diff --git a/README.md b/README.md
index a696ff08..376c9229 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 00000000..2040d2c0
--- /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
+<MyComponent
+  name="name"
+  type="string"
+  foo="bar">  {/* oops! */}
+  x="y">
+  Body Text
+</MyComponent>
+```
+
+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
+<MyComponent>{'Text'}}</MyComponent>
+```
+
+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
+<MyComponent
+  a="b">  {/* oops! */}
+  c="d"
+  Intended body text
+</MyComponent>
+```
+
+The preferred way to include one of these characters is to use the HTML escape code.
+
+- `>` can be replaced with `&gt;`
+- `"` can be replaced with `&quot;`, `&ldquo;` or `&rdquo;`
+- `'` can be replaced with `&apos;`, `&lsquo;` or `&rsquo;`
+- `}` can be replaced with `&#125;`
+
+Alternatively, you can include the literal character inside a subexpression
+(such as `<div>{'>'}</div>`.
+
+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
+<div> > </div>
+```
+
+The following patterns are not considered warnings:
+
+```jsx
+<div> &gt; </div>
+```
+
+```jsx
+<div> {'>'} </div>
+```
diff --git a/index.js b/index.js
index a0ac8bf0..ff5d9d9e 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 00000000..3548cb43
--- /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.');
+          }
+        }
+      }
+    };
+  }
+};


Test patch:
diff --git a/tests/lib/rules/no-unescaped-entities.js b/tests/lib/rules/no-unescaped-entities.js
new file mode 100644
index 00000000..098dd4f7
--- /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 (',
+        '      <div/>',
+        '    );',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>Here is some text!</div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>I&rsquo;ve escaped some entities: &gt; &lt; &amp;</div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>first line is ok',
+        '    so is second',
+        '    and here are some escaped entities: &gt; &lt; &amp;</div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>{">" + "<" + "&" + \'"\'}</div>;',
+        '  },',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions
+    }
+  ],
+
+  invalid: [
+    {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>></div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions,
+      errors: [{message: 'HTML entities must be escaped.'}]
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>first line is ok',
+        '    so is second',
+        '    and here are some bad entities: ></div>',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions,
+      errors: [{message: 'HTML entities must be escaped.'}]
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>\'</div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions,
+      errors: [{message: 'HTML entities must be escaped.'}]
+    }, {
+      code: [
+        'var Hello = React.createClass({',
+        '  render: function() {',
+        '    return <div>Multiple errors: \'>></div>;',
+        '  }',
+        '});'
+      ].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 <div>{"Unbalanced braces"}}</div>;',
+        '  }',
+        '});'
+      ].join('\n'),
+      parserOptions: parserOptions,
+      errors: [{message: 'HTML entities must be escaped.'}]
+    }
+  ]
+});