From 8adb2a5d26c4f87458fba006339de3c61609cb13 Mon Sep 17 00:00:00 2001 From: "Robin (Robert) Thomas" Date: Thu, 5 Sep 2024 01:40:32 -0500 Subject: [PATCH] feat: adds no-extra-spacing-text (#215) * add no-extra-spacing-text * Add to doc index * fix lint * Strip trailing spaces * prettier * undo vscode trimTrailingWhitespace * Add trailing space doc --- docs/rules.md | 1 + docs/rules/no-extra-spacing-text.md | 74 +++++++++ packages/eslint-plugin/lib/rules/index.js | 2 + .../lib/rules/no-extra-spacing-text.js | 117 ++++++++++++++ packages/eslint-plugin/lib/types.d.ts | 8 + .../tests/rules/no-extra-spacing-text.test.js | 151 ++++++++++++++++++ 6 files changed, 353 insertions(+) create mode 100644 docs/rules/no-extra-spacing-text.md create mode 100644 packages/eslint-plugin/lib/rules/no-extra-spacing-text.js create mode 100644 packages/eslint-plugin/tests/rules/no-extra-spacing-text.test.js diff --git a/docs/rules.md b/docs/rules.md index aac5a14..becaf25 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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(') | ⭐🔧 | diff --git a/docs/rules/no-extra-spacing-text.md b/docs/rules/no-extra-spacing-text.md new file mode 100644 index 0000000..18ff40e --- /dev/null +++ b/docs/rules/no-extra-spacing-text.md @@ -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 +
+ foo bar +
+``` + +Examples of **correct** code for this rule: + +```html,correct +
+ foo bar +
+``` + +### Options + +This rule has an object option: + +- `"skip"`: skips whitespace-checking within the specified elements. + +```ts +//... +"@html-eslint/element-newline": ["error", { + "skip": Array +}] +``` + +#### 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: + + +```html +
+ Only short whitespace here. + +
    Any    kind    of   whitespace    here!    
+
+``` diff --git a/packages/eslint-plugin/lib/rules/index.js b/packages/eslint-plugin/lib/rules/index.js index cc40ac0..bea5420 100644 --- a/packages/eslint-plugin/lib/rules/index.js +++ b/packages/eslint-plugin/lib/rules/index.js @@ -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"); @@ -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, diff --git a/packages/eslint-plugin/lib/rules/no-extra-spacing-text.js b/packages/eslint-plugin/lib/rules/no-extra-spacing-text.js new file mode 100644 index 0000000..7dc384d --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-extra-spacing-text.js @@ -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} 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` : ` ` + ); + }, + }); + } + } + }, +}; diff --git a/packages/eslint-plugin/lib/types.d.ts b/packages/eslint-plugin/lib/types.d.ts index fd63402..25a25d2 100644 --- a/packages/eslint-plugin/lib/types.d.ts +++ b/packages/eslint-plugin/lib/types.d.ts @@ -267,3 +267,11 @@ export type ChildType = T extends ProgramNode : T extends TagNode ? T["children"][number] : never; + +export type ContentNode = + | CommentNode + | DoctypeNode + | ScriptTagNode + | StyleTagNode + | TagNode + | TextNode; diff --git a/packages/eslint-plugin/tests/rules/no-extra-spacing-text.test.js b/packages/eslint-plugin/tests/rules/no-extra-spacing-text.test.js new file mode 100644 index 0000000..5e9daec --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-extra-spacing-text.test.js @@ -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: `
foo
`, + }, + + { + code: `
foo bar
`, + }, + + { + code: ` +\t
+ \t
+ foo + bar + \t\t
+\t
+`, + }, + + { + code: `
   foo\t\t\tbar   
`, + options: [ + { + skip: [`pre`], + }, + ], + }, + + { + code: ` +
+ Only short whitespace here. + +
    Any    kind    of   whitespace    here!    
+
+`, + options: [ + { + skip: [`pre`], + }, + ], + }, + ], + + invalid: [ + { + code: `foo bar `, + output: `foo bar `, + errors: errorsAt([1, 4, 3], [1, 10, 3]), + }, + + { + code: `
\tfoo \t
`, + output: `
foo
`, + errors: errorsAt([1, 6, 1], [1, 10, 2]), + }, + + { + code: `
foo
`, + output: `
foo
`, + errors: errorsAt([1, 6, 2], [1, 11, 3]), + }, + + { + code: `
foo \n
`, + output: `
foo\n
`, + errors: errorsAt([1, 9, 2, 1]), + }, + + { + code: `
foo\t\n
`, + output: `
foo\n
`, + errors: errorsAt([1, 9, 2, 1]), + }, + + { + code: `
\n\tfoo \n
\n
\n\tbar\t\n
`, + output: `
\n\tfoo\n
\n
\n\tbar\n
`, + errors: errorsAt([2, 5, 3, 1], [3, 7, 4, 1], [5, 5, 6, 1]), + }, + + { + code: ` +
\t\tfoo \t\t
+`, + output: ` +
foo
+`, + errors: errorsAt([2, 10, 3], [2, 16, 3]), + }, + + { + code: ` +
+ foo bar +
+`, + output: ` +
+ foo bar +
+`, + errors: errorsAt([3, 6, 5], [3, 14, 4, 1]), + }, + + { + code: `\n\n
\n\n`, + output: `\n\n
\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] + ), + }, + ], +});