Skip to content

Commit

Permalink
feat: adds no-extra-spacing-text (#215)
Browse files Browse the repository at this point in the history
* add no-extra-spacing-text

* Add to doc index

* fix lint

* Strip trailing spaces

* prettier

* undo vscode trimTrailingWhitespace

* Add trailing space doc
  • Loading branch information
RobertAKARobin authored Sep 5, 2024
1 parent 402d7fa commit 8adb2a5
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
| [indent](rules/indent) | Enforce consistent indentation | ⭐🔧 |
| [lowercase](rules/lowercase) | Enforce to use lowercase for tag and attribute names. | 🔧 |
| [no-extra-spacing-attrs](rules/no-extra-spacing-attrs) | Disallow an extra spacing around attributes | ⭐🔧 |
| [no-extra-spacing-text](rules/no-extra-spacing-text) | Disallow extra spacing in text | 🔧 |
| [no-multiple-empty-lines](rules/no-multiple-empty-lines) | Disallow multiple empty lines | 🔧 |
| [no-trailing-spaces](rules/no-trailing-spaces) | Disallow trailing whitespace at the end of lines | 🔧 |
| [quotes](rules/quotes) | Enforce consistent quoting attributes with double(") or single(') | ⭐🔧 |
Expand Down
74 changes: 74 additions & 0 deletions docs/rules/no-extra-spacing-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# no-extra-spacing-text

This rule disallows multiple consecutive spaces or tabs in text and comments.

## How to use

```js,.eslintrc.js
module.exports = {
rules: {
"@html-eslint/no-extra-spacing-text": "error",
},
};
```

## Rule Details

[Whitespace in HTML is largely ignored](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace), so the purpose of this rule is to prevent unnecessary whitespace in text, such as:

- Tab characters
- Sequences of more than 1 whitespace character
- Whitespace at the end of a line

When used with `--fix`, the rule will replace invalid whitespace with a single space.

Note:

- This rule ignores whitespace at the start of lines in order to not conflict with indentation rules. See [@html-eslint/indent](./indent).
- This rule strips whitespace from the end of lines, as does [@html-eslint/no-trailing-spaces](./no-trailing-spaces).
- This rule does **not** affect whitespace around attributes. See [@html-eslint/no-extra-spacing-attrs](./no-extra-spacing-attrs).

Examples of **incorrect** code for this rule:

```html,incorrect
<div foo = " bar " >
foo bar
</div>
```

Examples of **correct** code for this rule:

```html,correct
<div foo="bar">
foo bar
</div>
```

### Options

This rule has an object option:

- `"skip"`: skips whitespace-checking within the specified elements.

```ts
//...
"@html-eslint/element-newline": ["error", {
"skip": Array<string>
}]
```

#### skip

You can specify a list of tag names in the `skip` option.
Whitespace-checking is not performed on children of the specified tags.

Examples of **correct** code for the `{ "skip": ["pre"] }` option:

<!-- prettier-ignore -->
```html
<div>
Only short whitespace here.

<pre> Any kind of whitespace here! </pre>
</div>
```
2 changes: 2 additions & 0 deletions packages/eslint-plugin/lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const noDuplicateId = require("./no-duplicate-id");
const noInlineStyles = require("./no-inline-styles");
const noMultipleH1 = require("./no-multiple-h1");
const noExtraSpacingAttrs = require("./no-extra-spacing-attrs");
const noExtraSpacingText = require("./no-extra-spacing-text");
const attrsNewline = require("./attrs-newline");
const elementNewLine = require("./element-newline");
const noSkipHeadingLevels = require("./no-skip-heading-levels");
Expand Down Expand Up @@ -46,6 +47,7 @@ module.exports = {
"no-inline-styles": noInlineStyles,
"no-multiple-h1": noMultipleH1,
"no-extra-spacing-attrs": noExtraSpacingAttrs,
"no-extra-spacing-text": noExtraSpacingText,
"attrs-newline": attrsNewline,
"element-newline": elementNewLine,
"no-skip-heading-levels": noSkipHeadingLevels,
Expand Down
117 changes: 117 additions & 0 deletions packages/eslint-plugin/lib/rules/no-extra-spacing-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @typedef { import("../types").RuleModule } RuleModule
* @typedef { import("../types").ProgramNode } ProgramNode
* @typedef { import("es-html-parser").CommentContentNode } CommentContentNode
* @typedef { import("../types").ContentNode } ContentNode
* @typedef { import("../types").TextNode } TextNode
*/

const { RULE_CATEGORY } = require("../constants");

const MESSAGE_IDS = {
UNEXPECTED: "unexpected",
};

/**
* @type {RuleModule}
*/
module.exports = {
meta: {
type: "code",

docs: {
description: "Disallow unnecessary consecutive spaces",
category: RULE_CATEGORY.BEST_PRACTICE,
recommended: false,
},

fixable: true,
schema: [
{
type: "object",
properties: {
skip: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
],
messages: {
[MESSAGE_IDS.UNEXPECTED]:
"Tabs and/or multiple consecutive spaces not allowed here",
},
},

create(context) {
const options = context.options[0] || {};
const skipTags = options.skip || [];
const sourceCode = context.getSourceCode();

/**
* @param {Array<ContentNode>} siblings
*/
function checkSiblings(siblings) {
for (
let length = siblings.length, index = 0;
index < length;
index += 1
) {
const node = siblings[index];

if (node.type === `Tag` && skipTags.includes(node.name) === false) {
checkSiblings(node.children);
} else if (node.type === `Text`) {
stripConsecutiveSpaces(node);
} else if (node.type === `Comment`) {
stripConsecutiveSpaces(node.value);
}
}
}

return {
Program(node) {
// @ts-ignore
checkSiblings(node.body);
},
};

/**
* @param {TextNode | CommentContentNode} node
*/
function stripConsecutiveSpaces(node) {
const text = node.value;
const matcher = /(^|[^\n \t])([ \t]+\n|\t[\t ]*|[ \t]{2,})/g;

// eslint-disable-next-line no-constant-condition
while (true) {
const offender = matcher.exec(text);
if (offender === null) {
break;
}

const space = offender[2];
const indexStart = node.range[0] + matcher.lastIndex - space.length;
const indexEnd = indexStart + space.length;

context.report({
node: node,
loc: {
start: sourceCode.getLocFromIndex(indexStart),
end: sourceCode.getLocFromIndex(indexEnd),
},
messageId: MESSAGE_IDS.UNEXPECTED,
fix(fixer) {
return fixer.replaceTextRange(
[indexStart, indexEnd],
space.endsWith(`\n`) ? `\n` : ` `
);
},
});
}
}
},
};
8 changes: 8 additions & 0 deletions packages/eslint-plugin/lib/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,11 @@ export type ChildType<T extends BaseNode> = T extends ProgramNode
: T extends TagNode
? T["children"][number]
: never;

export type ContentNode =
| CommentNode
| DoctypeNode
| ScriptTagNode
| StyleTagNode
| TagNode
| TextNode;
151 changes: 151 additions & 0 deletions packages/eslint-plugin/tests/rules/no-extra-spacing-text.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
const createRuleTester = require("../rule-tester");
const rule = require("../../lib/rules/no-extra-spacing-text");

function errorsAt(...positions) {
return positions.map((input) => {
const [line, column, length] = input;
if (input.length === 3) {
return {
messageId: `unexpected`,
line,
column,
endLine: line,
endColumn: column + length,
};
} else {
const [line, column, endLine, endColumn] = input;
return {
messageId: `unexpected`,
line,
column,
endLine,
endColumn,
};
}
});
}

const ruleTester = createRuleTester();

ruleTester.run("no-extra-spacing-text", rule, {
valid: [
{
code: `<div> foo </div>`,
},

{
code: `<div> foo bar </div>`,
},

{
code: `
\t <div>
\t <div>
foo
bar
\t\t </div>
\t </div>
`,
},

{
code: `<pre> foo\t\t\tbar </pre><script> const foo = 'bar' </script><style> .foo { bar } </style>`,
options: [
{
skip: [`pre`],
},
],
},

{
code: `
<div foo = "bar">
Only short whitespace here.
<pre> Any kind of whitespace here! </pre>
</div>
`,
options: [
{
skip: [`pre`],
},
],
},
],

invalid: [
{
code: `foo bar `,
output: `foo bar `,
errors: errorsAt([1, 4, 3], [1, 10, 3]),
},

{
code: `<div>\tfoo \t</div>`,
output: `<div> foo </div>`,
errors: errorsAt([1, 6, 1], [1, 10, 2]),
},

{
code: `<div> foo </div>`,
output: `<div> foo </div>`,
errors: errorsAt([1, 6, 2], [1, 11, 3]),
},

{
code: `<div>foo \n</div>`,
output: `<div>foo\n</div>`,
errors: errorsAt([1, 9, 2, 1]),
},

{
code: `<div>foo\t\n</div>`,
output: `<div>foo\n</div>`,
errors: errorsAt([1, 9, 2, 1]),
},

{
code: `<div>\n\tfoo \n</div> \n<div>\n\tbar\t\n</div>`,
output: `<div>\n\tfoo\n</div>\n<div>\n\tbar\n</div>`,
errors: errorsAt([2, 5, 3, 1], [3, 7, 4, 1], [5, 5, 6, 1]),
},

{
code: `
<div> \t\tfoo \t\t</div>
`,
output: `
<div> foo </div>
`,
errors: errorsAt([2, 10, 3], [2, 16, 3]),
},

{
code: `
<div>
foo bar
</div>
`,
output: `
<div>
foo bar
</div>
`,
errors: errorsAt([3, 6, 5], [3, 14, 4, 1]),
},

{
code: `\n\n <div> <a> <!-- foo bar --> </a> </div>\n\n`,
output: `\n\n <div> <a> <!-- foo bar --> </a> </div>\n\n`,
errors: errorsAt(
[3, 10, 3],
[3, 16, 3],
[3, 23, 3],
[3, 29, 3],
[3, 35, 3],
[3, 41, 3],
[3, 48, 3]
),
},
],
});

0 comments on commit 8adb2a5

Please sign in to comment.