diff --git a/README.md b/README.md index e77a809efe..1a13ff5d99 100644 --- a/README.md +++ b/README.md @@ -34,47 +34,47 @@ You should also specify settings that will be shared across all the plugin rules ```json5 { - "settings": { - "react": { - "createClass": "createReactClass", // Regex for Component Factory to use, - // default to "createReactClass" - "pragma": "React", // Pragma to use, default to "React" - "fragment": "Fragment", // Fragment to use (may be a property of ), default to "Fragment" - "version": "detect", // React version. "detect" automatically picks the version you have installed. - // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. - // Defaults to the "defaultVersion" setting and warns if missing, and to "detect" in the future - "defaultVersion": "", // Default React version to use when the version you have installed cannot be detected. - // If not provided, defaults to the latest React version. - "flowVersion": "0.53" // Flow version + settings: { + react: { + createClass: 'createReactClass', // Regex for Component Factory to use, + // default to "createReactClass" + pragma: 'React', // Pragma to use, default to "React" + fragment: 'Fragment', // Fragment to use (may be a property of ), default to "Fragment" + version: 'detect', // React version. "detect" automatically picks the version you have installed. + // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. + // Defaults to the "defaultVersion" setting and warns if missing, and to "detect" in the future + defaultVersion: '', // Default React version to use when the version you have installed cannot be detected. + // If not provided, defaults to the latest React version. + flowVersion: '0.53', // Flow version }, - "propWrapperFunctions": [ - // The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped. - "forbidExtraProps", - {"property": "freeze", "object": "Object"}, - {"property": "myFavoriteWrapper"}, - // for rules that check exact prop wrappers - {"property": "forbidExtraProps", "exact": true} + propWrapperFunctions: [ + // The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped. + 'forbidExtraProps', + { property: 'freeze', object: 'Object' }, + { property: 'myFavoriteWrapper' }, + // for rules that check exact prop wrappers + { property: 'forbidExtraProps', exact: true }, ], - "componentWrapperFunctions": [ - // The name of any function used to wrap components, e.g. Mobx `observer` function. If this isn't set, components wrapped by these functions will be skipped. - "observer", // `property` - {"property": "styled"}, // `object` is optional - {"property": "observer", "object": "Mobx"}, - {"property": "observer", "object": ""} // sets `object` to whatever value `settings.react.pragma` is set to + componentWrapperFunctions: [ + // The name of any function used to wrap components, e.g. Mobx `observer` function. If this isn't set, components wrapped by these functions will be skipped. + 'observer', // `property` + { property: 'styled' }, // `object` is optional + { property: 'observer', object: 'Mobx' }, + { property: 'observer', object: '' }, // sets `object` to whatever value `settings.react.pragma` is set to ], - "formComponents": [ + formComponents: [ // Components used as alternatives to
for forms, eg. - "CustomForm", - {"name": "SimpleForm", "formAttribute": "endpoint"}, - {"name": "Form", "formAttribute": ["registerEndpoint", "loginEndpoint"]}, // allows specifying multiple properties if necessary + 'CustomForm', + { name: 'SimpleForm', formAttribute: 'endpoint' }, + { name: 'Form', formAttribute: ['registerEndpoint', 'loginEndpoint'] }, // allows specifying multiple properties if necessary ], - "linkComponents": [ + linkComponents: [ // Components used as alternatives to for linking, eg. - "Hyperlink", - {"name": "MyLink", "linkAttribute": "to"}, - {"name": "Link", "linkAttribute": ["to", "href"]}, // allows specifying multiple properties if necessary - ] - } + 'Hyperlink', + { name: 'MyLink', linkAttribute: 'to' }, + { name: 'Link', linkAttribute: ['to', 'href'] }, // allows specifying multiple properties if necessary + ], + }, } ``` @@ -84,9 +84,7 @@ Add "react" to the plugins section. ```json { - "plugins": [ - "react" - ] + "plugins": ["react"] } ``` @@ -136,9 +134,7 @@ This pairs well with the `eslint:all` rule. ```json { - "plugins": [ - "react" - ], + "plugins": ["react"], "extends": ["eslint:all", "plugin:react/all"] } ``` @@ -205,6 +201,7 @@ Refer to the [official docs](https://eslint.org/docs/latest/user-guide/configuri The schema of the `settings.react` object would be identical to that of what's already described above in the legacy config section. + ### Flat Configs This plugin exports 3 flat configs: @@ -375,6 +372,7 @@ module.exports = [ | [no-unused-prop-types](docs/rules/no-unused-prop-types.md) | Disallow definitions of unused propTypes | | | | | | | [no-unused-state](docs/rules/no-unused-state.md) | Disallow definitions of unused state | | | | | | | [no-will-update-set-state](docs/rules/no-will-update-set-state.md) | Disallow usage of setState in componentWillUpdate | | | | | | +| [padding-lines-between-tags](docs/rules/padding-lines-between-tags.md) | Enforce no padding lines between tags for React Components | | | πŸ”§ | | | | [prefer-es6-class](docs/rules/prefer-es6-class.md) | Enforce ES5 or ES6 class for React Components | | | | | | | [prefer-exact-props](docs/rules/prefer-exact-props.md) | Prefer exact proptype definitions | | | | | | | [prefer-read-only-props](docs/rules/prefer-read-only-props.md) | Enforce that props are read-only | | | πŸ”§ | | | @@ -407,15 +405,11 @@ module.exports = [ [npm-url]: https://npmjs.org/package/eslint-plugin-react [npm-image]: https://img.shields.io/npm/v/eslint-plugin-react.svg - [status-url]: https://github.com/jsx-eslint/eslint-plugin-react/pulse [status-image]: https://img.shields.io/github/last-commit/jsx-eslint/eslint-plugin-react.svg - [tidelift-url]: https://tidelift.com/subscription/pkg/npm-eslint-plugin-react?utm_source=npm-eslint-plugin-react&utm_medium=referral&utm_campaign=readme [tidelift-image]: https://tidelift.com/badges/package/npm/eslint-plugin-react?style=flat - [package-url]: https://npmjs.org/package/eslint-plugin-react [npm-version-svg]: https://versionbadg.es/jsx-eslint/eslint-plugin-react.svg - [actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/jsx-eslint/eslint-plugin-react [actions-url]: https://github.com/jsx-eslint/eslint-plugin-react/actions diff --git a/docs/rules/padding-lines-between-tags.md b/docs/rules/padding-lines-between-tags.md new file mode 100644 index 0000000000..42e2c0067c --- /dev/null +++ b/docs/rules/padding-lines-between-tags.md @@ -0,0 +1,152 @@ +# Enforce no padding lines between tags for React Components (`react/padding-lines-between-tags`) + +πŸ”§ This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Require or disallow newlines between sibling tags in React. + +## Rule Details Options + +```json +{ + "padding-line-between-tags": [ + "error", + [{ "blankLine": "always", "prev": "*", "next": "*" }] + ] +} +``` + +This rule requires blank lines between each sibling HTML tag by default. + +A configuration is an object which has 3 properties; `blankLine`, `prev` and `next`. For example, `{ blankLine: "always", prev: "br", next: "div" }` means β€œone or more blank lines are required between a `br` tag and a `div` tag.” You can supply any number of configurations. If a tag pair matches multiple configurations, the last matched configuration will be used. + +- `blankLine` is one of the following: + - `always` requires one or more blank lines. + - `never` disallows blank lines. + - `consistent` requires or disallows a blank line based on the first sibling element. +- `prev` any tag name without brackets. +- `next` any tag name without brackets. + +### Disallow blank lines between all tags + +`{ blankLine: 'never', prev: '*', next: '*' }` + + + +```react + +
+
+
+
+
+
+ +``` + + + +### Require newlines after `
` + +`{ blankLine: 'always', prev: 'br', next: '*' }` + + + +```react + +
+
    +
  • +
  • +
    + +
  • +
  • +
+
+
+``` + +
+ +### Require newlines between `
` and `` + +`{ blankLine: 'always', prev: 'br', next: 'img' }` + + + +```react + +
+
    +
  • +
  • +
  • +
  • +
    + +
  • +
  • +
+
+
+``` + +```react [Fixed] + +
+
    +
  • +
  • + +
  • +
  • +
    + + +
  • +
  • +
+
+
+``` + +
+ +### Require consistent newlines + +`{ blankLine: 'consistent', prev: '*', next: '*' }` + + + +```react + +
+
    +
  • +
  • +
  • +
+ +
+ +
+
+ +``` + + + +## When Not To Use It + +If you are not using React. diff --git a/lib/rules/index.js b/lib/rules/index.js index 11a4475ba2..2b17db2401 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -89,6 +89,7 @@ module.exports = { 'no-unused-state': require('./no-unused-state'), 'no-object-type-as-default-prop': require('./no-object-type-as-default-prop'), 'no-will-update-set-state': require('./no-will-update-set-state'), + 'padding-lines-between-tags': require('./padding-lines-between-tags'), 'prefer-es6-class': require('./prefer-es6-class'), 'prefer-exact-props': require('./prefer-exact-props'), 'prefer-read-only-props': require('./prefer-read-only-props'), diff --git a/lib/rules/padding-lines-between-tags.js b/lib/rules/padding-lines-between-tags.js new file mode 100644 index 0000000000..c3fbc2583a --- /dev/null +++ b/lib/rules/padding-lines-between-tags.js @@ -0,0 +1,189 @@ +/** + * @fileoverview Enforce no padding lines between tags for React Components + * @author Alankar Anand + * Based on https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/padding-line-between-tags.js + * https://github.com/jsx-eslint/eslint-plugin-react/issues/3554 + */ + +'use strict'; + +const docsUrl = require('../util/docsUrl'); + +/** + * Split the source code into multiple lines based on the line delimiters. + * Copied from padding-line-between-blocks + * @param {string} text Source code as a string. + * @returns {string[]} Array of source code lines. + */ +const messages = { + never: 'Unexpected blank line before this tag.', + always: 'Expected blank line before this tag.', +}; + +function splitLines(text) { + return text.split(/\r\n|[\r\n\u2028\u2029]/gu); +} + +function insertNewLine(context, tag, sibling, lineDifference) { + const endTag = tag.closingElement || tag.openingElement; + + if (lineDifference === 1) { + context.report({ + messageId: 'always', + loc: sibling && sibling.loc, + // @ts-ignore + fix(fixer) { + return fixer.insertTextAfter(tag, '\n'); + }, + }); + } else if (lineDifference === 0) { + context.report({ + messageId: 'always', + loc: sibling && sibling.loc, + // @ts-ignore + fix(fixer) { + const lastSpaces = /** @type {RegExpExecArray} */ ( + /^\s*/.exec(context.getSourceCode().lines[endTag.loc.start.line - 1]) + )[0]; + + return fixer.insertTextAfter(endTag, `\n\n${lastSpaces}`); + }, + }); + } +} + +function removeExcessLines(context, endTag, sibling, lineDifference) { + if (lineDifference > 1) { + let hasOnlyTextBetween = true; + for ( + let i = endTag.loc && endTag.loc.start.line; + i < sibling.loc.start.line - 1 && hasOnlyTextBetween; + i++ + ) { + hasOnlyTextBetween = !/^\s*$/.test(context.getSourceCode().lines[i]); + } + if (!hasOnlyTextBetween) { + context.report({ + messageId: 'never', + loc: sibling && sibling.loc, + // @ts-ignore + fix(fixer) { + const start = endTag.range[1]; + const end = sibling.range[0]; + const paddingText = context.getSourceCode().text.slice(start, end); + const textBetween = splitLines(paddingText); + let newTextBetween = `\n${textBetween.pop()}`; + for (let i = textBetween.length - 1; i >= 0; i--) { + if (!/^\s*$/.test(textBetween[i])) { + newTextBetween = `${i === 0 ? '' : '\n'}${ + textBetween[i] + }${newTextBetween}`; + } + } + return fixer.replaceTextRange([start, end], `${newTextBetween}`); + }, + }); + } + } +} + +function checkNewLine(context, configureList) { + const firstConsistentBlankLines = new Map(); + + const reverseConfigureList = [...configureList].reverse(); + + return (node) => { + if (!node.parent.parent) { + return; + } + + const endTag = node.closingElement || node.openingElement; + + if (!node.parent.children) { + return; + } + const lowerSiblings = node.parent.children + .filter( + (element) => element.type === 'JSXElement' && element.range !== node.range + ) + .filter((sibling) => sibling.range[0] - endTag.range[1] >= 0); + + if (lowerSiblings.length === 0) { + return; + } + const closestSibling = lowerSiblings[0]; + + const lineDifference = closestSibling.loc.start.line - endTag.loc.end.line; + + const configure = reverseConfigureList.find( + (config) => (config.prev === '*' + || node.openingElement.name.name === config.prev) + && (config.next === '*' + || closestSibling.openingElement.name.name === config.next) + ); + + if (!configure) { + return; + } + + let blankLine = configure.blankLine; + + if (blankLine === 'consistent') { + const firstConsistentBlankLine = firstConsistentBlankLines.get( + node.parent + ); + if (firstConsistentBlankLine == null) { + firstConsistentBlankLines.set( + node.parent, + lineDifference > 1 ? 'always' : 'never' + ); + return; + } + blankLine = firstConsistentBlankLine; + } + + if (blankLine === 'always') { + insertNewLine(context, node, closestSibling, lineDifference); + } else { + removeExcessLines(context, endTag, closestSibling, lineDifference); + } + }; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + docs: { + description: 'Enforce no padding lines between tags for React Components', + category: 'Stylistic Issues', + recommended: false, + url: docsUrl('padding-lines-between-tags'), + }, + fixable: 'code', + messages, + schema: [ + { + type: 'array', + items: { + type: 'object', + properties: { + blankLine: { enum: ['always', 'never', 'consistent'] }, + prev: { type: 'string' }, + next: { type: 'string' }, + }, + additionalProperties: false, + required: ['blankLine', 'prev', 'next'], + }, + }, + ], + }, + + create(context) { + const configureList = context.options[0] || [ + { blankLine: 'always', prev: '*', next: '*' }, + ]; + return { + JSXElement: checkNewLine(context, configureList), + }; + }, +};