Repository: ember-template-lint/ember-template-lint
Pull request #848 (2019-10-07 14:27:19)

Add `no-whitespace-within-word` rule
If merged, this adds a new rule to prevent the use of whitespace within a word for styling purposes, and should catch the most common offenders. 


Patch:
diff --git a/docs/rule/no-whitespace-within-word.md b/docs/rule/no-whitespace-within-word.md
new file mode 100644
index 00000000..9bf9d5b5
--- /dev/null
+++ b/docs/rule/no-whitespace-within-word.md
@@ -0,0 +1,55 @@
+## no-whitespace-within-word
+
+In practice, the predominant issue raised by inline whitespace styling is that the resultant text 'formatting' is entirely visual in nature; the ability to discern the correct manner in which to read the text, and therefore, to correctly comprehend its meaning, is restricted to sighted users.
+
+Using in-line whitespace word formatting produces results that are explicitly mentioned in [WCAG's list of common sources of web accessibility failures](https://www.w3.org/TR/WCAG20-TECHS/failures.html). Specifically, this common whitespace-within-word-induced web accessibility issue fails to successfully achieve [WCAG Success Criterion 1.3.2: Meaningful Sequence](https://www.w3.org/TR/UNDERSTANDING-WCAG20/content-structure-separation-sequence.html).
+
+The `no-whitespace-within-word` rule operates on the assumption that artifically-spaced English words in rendered text content contain, at a minimum, two word characters fencepost-delimited by three whitespace characters  (`space-char-space-char-space`) so it should be avoided.
+
+### Examples
+
+This rule **forbids** the following:
+
+```hbs
+W e l c o m e
+```
+
+`W`**`&nbsp;`**`e`**`&nbsp;`**`l`**`&nbsp;`**`c`**`&nbsp;`**`o`**`&nbsp;`**`m`**`&nbsp;`**`e`
+
+`Wel c o me`
+
+`Wel`**`&nbsp;`**`c`**`&emsp;`**`o`**`&nbsp;`**`me`
+
+```hbs
+<div>W e l c o m e</div>
+
+<div>Wel c o me</div>
+```
+
+This rule **allows** the following:
+
+`Welcome`
+
+`Yes`**`&nbsp;`**`I`**`&nbsp;`**`am`
+
+`It is possible to get some examples of in-word emph a sis past this rule.`
+
+`However, I do not want a rule that flags annoying false positives for correctly-used single-character words.`
+
+```hbs
+<div>Welcome</div>
+
+<div>Yes&nbsp;I am.</div>
+```
+
+This rule uses the heuristic of letter, whitespace character, letter, whitespace character, letter which makes it a good candidate for most use cases, but not ideal for some languages (such as Japanese).
+
+### Migration
+
+Use CSS to add letter-spacing to a word.
+
+### References
+
+* [F32: Using white space characters to create multiple columns in plain text content](https://www.w3.org/TR/WCAG20-TECHS/failures.html#F32)
+* [WCAG Success Criterion 1.3.2: Meaningful Sequence](https://www.w3.org/TR/UNDERSTANDING-WCAG20/content-structure-separation-sequence.html)
+* [C8: Using CSS letter-spacing to control spacing within a word](https://www.w3.org/WAI/WCAG21/Techniques/css/C8)
diff --git a/docs/rules.md b/docs/rules.md
index 4e67fc31..fc4a9b65 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -43,6 +43,7 @@
 * [no-unnecessary-concat](rule/no-unnecessary-concat.md)
 * [no-unused-block-params](rule/no-unused-block-params.md)
 * [no-whitespace-for-layout](rule/no-whitespace-for-layout.md)
+* [no-whitespace-within-word](rule/no-whitespace-within-word.md)
 * [quotes](rule/quotes.md)
 * [require-iframe-title](rule/require-iframe-title.md)
 * [require-valid-alt-text](rule/require-valid-alt-text.md)
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 77b1c51a..37e01f23 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -47,6 +47,7 @@ module.exports = {
   'no-unnecessary-concat': require('./lint-no-unnecessary-concat'),
   'no-unused-block-params': require('./lint-no-unused-block-params'),
   'no-whitespace-for-layout': require('./lint-no-whitespace-for-layout'),
+  'no-whitespace-within-word': require('./lint-no-whitespace-within-word'),
   quotes: require('./lint-quotes'),
   'require-iframe-title': require('./lint-require-iframe-title'),
   'require-valid-alt-text': require('./lint-require-valid-alt-text'),
diff --git a/lib/rules/lint-no-whitespace-within-word.js b/lib/rules/lint-no-whitespace-within-word.js
new file mode 100644
index 00000000..7b545b98
--- /dev/null
+++ b/lib/rules/lint-no-whitespace-within-word.js
@@ -0,0 +1,143 @@
+'use strict';
+const Rule = require('./base');
+
+const ERROR_MESSAGE = 'Excess whitespace in layout detected.';
+
+const blackList = [
+  '&#32;',
+  ' ',
+  '&#160;',
+  '&nbsp;',
+  '&NonBreakingSpace;',
+  '&#8194;',
+  '&ensp;',
+  '&#8195;',
+  '&emsp;',
+  '&#8196;',
+  '&emsp13;',
+  '&#8197;',
+  '&emsp14;',
+  '&#8199;',
+  '&numsp;',
+  '&#8200;',
+  '&puncsp;',
+  '&#8201;',
+  '&thinsp;',
+  '&ThinSpace;',
+  '&#8202;',
+  '&hairsp;',
+  '&VeryThinSpace;',
+  '&ThickSpace;',
+  '&#8203;',
+  '&ZeroWidthSpace;',
+  '&NegativeVeryThinSpace;',
+  '&NegativeThinSpace;',
+  '&NegativeMediumSpace;',
+  '&NegativeThickSpace;',
+  '&#8204;',
+  '&zwnj;',
+  '&#8205;',
+  '&zwj;',
+  '&#8206;',
+  '&lrm;',
+  '&#8207;',
+  '&rlm;',
+  '&#8287;',
+  '&MediumSpace;',
+  '&ThickSpace;',
+  '&#8288;',
+  '&NoBreak;',
+  '&#8289;',
+  '&ApplyFunction;',
+  '&af;',
+  '&#8290;',
+  '&InvisibleTimes;',
+  '&it;',
+  '&#8291;',
+  '&InvisibleComma;',
+  '&ic;',
+];
+
+function isWhitespace(char) {
+  return blackList.includes(char);
+}
+
+function splitTextByEntity(input) {
+  let result = [];
+
+  for (let i = 0; i < input.length; i++) {
+    let current = input[i];
+
+    if (current === '&') {
+      let possibleEndIndex = input.indexOf(';', i);
+
+      // this is a stand alone `&`
+      if (possibleEndIndex === -1) {
+        result.push(current);
+      }
+
+      // now we know we have an "entity like thing"
+      let possibleEntity = input.substring(i, possibleEndIndex + 1);
+      if (blackList.includes(possibleEntity)) {
+        result.push(possibleEntity);
+        i += possibleEntity.length - 1;
+      } else {
+        result.push(current);
+      }
+    } else {
+      result.push(current);
+    }
+  }
+
+  return result;
+}
+
+// The goal here is to catch alternating non-whitespace/whitespace
+// characters, for example, in 'W e l c o m e'.
+//
+// So the final pattern boils down to this:
+//
+// (whitespace)(non-whitespace)(whitespace)(non-whitespace)(whitespace)
+//
+// Specifically using this "5 alternations" rule since any less than this
+// will return false positives and any more than this should not be
+// necessary in 99.99% of cases
+module.exports = class NoWhitespaceWithinWord extends Rule {
+  visitor() {
+    return {
+      TextNode(node) {
+        let alternationCount = 0;
+        let source = this.sourceForNode(node);
+        let characters = splitTextByEntity(source);
+
+        for (let i = 0; i < characters.length; i++) {
+          let currentChar = characters[i];
+          let previousChar = i > 0 ? characters[i - 1] : undefined;
+
+          if (
+            (isWhitespace(currentChar) && !isWhitespace(previousChar)) ||
+            (!isWhitespace(currentChar) && isWhitespace(previousChar))
+          ) {
+            alternationCount++;
+          } else {
+            alternationCount = 0;
+          }
+
+          if (alternationCount >= 5) {
+            this.log({
+              message: ERROR_MESSAGE,
+              line: node.loc && node.loc.start.line,
+              column: node.loc && node.loc.start.column,
+              source,
+            });
+
+            // no need to keep parsing, we've already reported
+            return;
+          }
+        }
+      },
+    };
+  }
+};
+
+module.exports.ERROR_MESSAGE = ERROR_MESSAGE;


Test patch:
diff --git a/test/unit/rules/lint-no-whitespace-within-word-test.js b/test/unit/rules/lint-no-whitespace-within-word-test.js
new file mode 100644
index 00000000..66d38be9
--- /dev/null
+++ b/test/unit/rules/lint-no-whitespace-within-word-test.js
@@ -0,0 +1,82 @@
+// no-whitespace-within-word-test.js
+
+'use strict';
+
+const generateRuleTests = require('../../helpers/rule-test-harness');
+const ERROR_MESSAGE = require('../../../lib/rules/lint-no-whitespace-within-word').ERROR_MESSAGE;
+
+generateRuleTests({
+  name: 'no-whitespace-within-word',
+  config: true,
+
+  good: [
+    'Welcome',
+    `It is possible to get some examples of in-word emph a sis past this rule.`,
+    `However, I do not want a rule that flags annoying false positives for correctly-used single-character words.`,
+    '<div>Welcome</div>',
+  ],
+
+  bad: [
+    {
+      template: 'W e l c o m e',
+
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 0,
+        source: 'W e l c o m e',
+      },
+    },
+    {
+      template: 'W&nbsp;e&nbsp;l&nbsp;c&nbsp;o&nbsp;m&nbsp;e',
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 0,
+        source: 'W&nbsp;e&nbsp;l&nbsp;c&nbsp;o&nbsp;m&nbsp;e',
+      },
+    },
+    {
+      template: 'Wel c o me',
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 0,
+        source: 'Wel c o me',
+      },
+    },
+    {
+      template: 'Wel&nbsp;c&emsp;o&nbsp;me',
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 0,
+        source: 'Wel&nbsp;c&emsp;o&nbsp;me',
+      },
+    },
+    {
+      template: '<div>W e l c o m e</div>',
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 5,
+        source: 'W e l c o m e',
+      },
+    },
+    {
+      template: '<div>Wel c o me</div>',
+      result: {
+        moduleId: 'layout.hbs',
+        message: ERROR_MESSAGE,
+        line: 1,
+        column: 5,
+        source: 'Wel c o me',
+      },
+    },
+  ],
+});