diff --git a/docs/rules/README.md b/docs/rules/README.md index f7a2e01d5..b1c72665d 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -117,6 +117,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi |:--------|:------------|:---| | [vue/attribute-hyphenation](./attribute-hyphenation.md) | enforce attribute naming style on custom components in template | :wrench: | | [vue/component-definition-name-casing](./component-definition-name-casing.md) | enforce specific casing for component definition name | :wrench: | +| [vue/first-attribute-linebreak](./first-attribute-linebreak.md) | enforce the location of first attribute | :wrench: | | [vue/html-closing-bracket-newline](./html-closing-bracket-newline.md) | require or disallow a line break before tag's closing brackets | :wrench: | | [vue/html-closing-bracket-spacing](./html-closing-bracket-spacing.md) | require or disallow a space before tag's closing brackets | :wrench: | | [vue/html-end-tags](./html-end-tags.md) | enforce end tag style | :wrench: | @@ -228,6 +229,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi |:--------|:------------|:---| | [vue/attribute-hyphenation](./attribute-hyphenation.md) | enforce attribute naming style on custom components in template | :wrench: | | [vue/component-definition-name-casing](./component-definition-name-casing.md) | enforce specific casing for component definition name | :wrench: | +| [vue/first-attribute-linebreak](./first-attribute-linebreak.md) | enforce the location of first attribute | :wrench: | | [vue/html-closing-bracket-newline](./html-closing-bracket-newline.md) | require or disallow a line break before tag's closing brackets | :wrench: | | [vue/html-closing-bracket-spacing](./html-closing-bracket-spacing.md) | require or disallow a space before tag's closing brackets | :wrench: | | [vue/html-end-tags](./html-end-tags.md) | enforce end tag style | :wrench: | diff --git a/docs/rules/first-attribute-linebreak.md b/docs/rules/first-attribute-linebreak.md new file mode 100644 index 000000000..83aa4eda9 --- /dev/null +++ b/docs/rules/first-attribute-linebreak.md @@ -0,0 +1,164 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/first-attribute-linebreak +description: enforce the location of first attribute +--- +# vue/first-attribute-linebreak + +> enforce the location of first attribute + +- :exclamation: ***This rule has not been released yet.*** +- :gear: This rule is included in all of `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`. +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule aims to enforce a consistent location for the first attribute. + + + +```vue + +``` + + + +## :wrench: Options + +```json +{ + "vue/first-attribute-linebreak": ["error", { + "singleline": "ignore", + "multiline": "below" + }] +} +``` + +- `singleline` ... The location of the first attribute when the attributes on single line. Default is `"ignore"`. + - `"below"` ... Requires a newline before the first attribute. + - `"beside"` ... Disallows a newline before the first attribute. + - `"ignore"` ... Ignores attribute checking. +- `multiline` ... The location of the first attribute when the attributes span multiple lines. Default is `"below"`. + - `"below"` ... Requires a newline before the first attribute. + - `"beside"` ... Disallows a newline before the first attribute. + - `"ignore"` ... Ignores attribute checking. + +### `"singleline": "beside"` + + + +```vue + +``` + + + +### `"singleline": "below"` + + + +```vue + +``` + + + +### `"multiline": "beside"` + + + +```vue + +``` + + + +### `"multiline": "below"` + + + +```vue + +``` + + + +## :couple: Related Rules + +- [vue/max-attributes-per-line](./max-attributes-per-line.md) + +## :books: Further Reading + +- [Style guide - Multi attribute elements](https://v3.vuejs.org/style-guide/#multi-attribute-elements-strongly-recommended) + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/first-attribute-linebreak.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/first-attribute-linebreak.js) diff --git a/docs/rules/max-attributes-per-line.md b/docs/rules/max-attributes-per-line.md index dac9d2d72..a537eef33 100644 --- a/docs/rules/max-attributes-per-line.md +++ b/docs/rules/max-attributes-per-line.md @@ -58,21 +58,17 @@ There is a configurable number of attributes that are acceptable in one-line cas { "vue/max-attributes-per-line": ["error", { "singleline": { - "max": 1, - "allowFirstLine": true + "max": 1 }, "multiline": { - "max": 1, - "allowFirstLine": false + "max": 1 } }] } ``` - `singleline.max` (`number`) ... The number of maximum attributes per line when the opening tag is in a single line. Default is `1`. -- `singleline.allowFirstLine` (`boolean`) ... If `true`, it allows attributes on the same line as that tag name. Default is `true`. - `multiline.max` (`number`) ... The max number of attributes per line when the opening tag is in multiple lines. Default is `1`. This can be `{ multiline: 1 }` instead of `{ multiline: { max: 1 }}` if you don't configure `allowFirstLine` property. -- `multiline.allowFirstLine` (`boolean`) ... If `true`, it allows attributes on the same line as that tag name. Default is `false`. ### `"singleline": 3` @@ -90,24 +86,6 @@ There is a configurable number of attributes that are acceptable in one-line cas -### `"singleline": 1, "allowFirstLine": false` - - - -```vue - -``` - - - ### `"multiline": 2` @@ -130,21 +108,9 @@ There is a configurable number of attributes that are acceptable in one-line cas -### `"multiline": 1, "allowFirstLine": true` - - - -```vue - -``` +## :couple: Related Rules - +- [vue/first-attribute-linebreak](./first-attribute-linebreak.md) ## :books: Further Reading diff --git a/lib/configs/no-layout-rules.js b/lib/configs/no-layout-rules.js index b7d33e0c1..73d857e54 100644 --- a/lib/configs/no-layout-rules.js +++ b/lib/configs/no-layout-rules.js @@ -15,6 +15,7 @@ module.exports = { 'vue/comma-spacing': 'off', 'vue/comma-style': 'off', 'vue/dot-location': 'off', + 'vue/first-attribute-linebreak': 'off', 'vue/func-call-spacing': 'off', 'vue/html-closing-bracket-newline': 'off', 'vue/html-closing-bracket-spacing': 'off', diff --git a/lib/configs/strongly-recommended.js b/lib/configs/strongly-recommended.js index 0d3d41246..d03984029 100644 --- a/lib/configs/strongly-recommended.js +++ b/lib/configs/strongly-recommended.js @@ -8,6 +8,7 @@ module.exports = { rules: { 'vue/attribute-hyphenation': 'warn', 'vue/component-definition-name-casing': 'warn', + 'vue/first-attribute-linebreak': 'warn', 'vue/html-closing-bracket-newline': 'warn', 'vue/html-closing-bracket-spacing': 'warn', 'vue/html-end-tags': 'warn', diff --git a/lib/configs/vue3-strongly-recommended.js b/lib/configs/vue3-strongly-recommended.js index 9adfcd7c7..5b816c957 100644 --- a/lib/configs/vue3-strongly-recommended.js +++ b/lib/configs/vue3-strongly-recommended.js @@ -8,6 +8,7 @@ module.exports = { rules: { 'vue/attribute-hyphenation': 'warn', 'vue/component-definition-name-casing': 'warn', + 'vue/first-attribute-linebreak': 'warn', 'vue/html-closing-bracket-newline': 'warn', 'vue/html-closing-bracket-spacing': 'warn', 'vue/html-end-tags': 'warn', diff --git a/lib/index.js b/lib/index.js index 1fe6e4eb3..bd51932a6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -29,6 +29,7 @@ module.exports = { 'dot-notation': require('./rules/dot-notation'), eqeqeq: require('./rules/eqeqeq'), 'experimental-script-setup-vars': require('./rules/experimental-script-setup-vars'), + 'first-attribute-linebreak': require('./rules/first-attribute-linebreak'), 'func-call-spacing': require('./rules/func-call-spacing'), 'html-button-has-type': require('./rules/html-button-has-type'), 'html-closing-bracket-newline': require('./rules/html-closing-bracket-newline'), diff --git a/lib/rules/first-attribute-linebreak.js b/lib/rules/first-attribute-linebreak.js new file mode 100644 index 000000000..25f893fd2 --- /dev/null +++ b/lib/rules/first-attribute-linebreak.js @@ -0,0 +1,98 @@ +/** + * @fileoverview Enforce the location of first attribute + * @author Yosuke Ota + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ +const utils = require('../utils') + +module.exports = { + meta: { + type: 'layout', + docs: { + description: 'enforce the location of first attribute', + categories: ['vue3-strongly-recommended', 'strongly-recommended'], + url: 'https://eslint.vuejs.org/rules/first-attribute-linebreak.html' + }, + fixable: 'whitespace', // or "code" or "whitespace" + schema: [ + { + type: 'object', + properties: { + multiline: { enum: ['below', 'beside', 'ignore'] }, + singleline: { enum: ['below', 'beside', 'ignore'] } + }, + additionalProperties: false + } + ], + messages: { + expected: 'Expected a linebreak before this attribute.', + unexpected: 'Expected no linebreak before this attribute.' + } + }, + /** @param {RuleContext} context */ + create(context) { + /** @type {"below" | "beside" | "ignore"} */ + const singleline = + (context.options[0] && context.options[0].singleline) || 'ignore' + /** @type {"below" | "beside" | "ignore"} */ + const multiline = + (context.options[0] && context.options[0].multiline) || 'below' + + const template = + context.parserServices.getTemplateBodyTokenStore && + context.parserServices.getTemplateBodyTokenStore() + + /** + * Report attribute + * @param {VAttribute | VDirective} firstAttribute + * @param { "below" | "beside"} location + */ + function report(firstAttribute, location) { + context.report({ + node: firstAttribute, + messageId: location === 'beside' ? 'unexpected' : 'expected', + fix(fixer) { + const prevToken = template.getTokenBefore(firstAttribute, { + includeComments: true + }) + return fixer.replaceTextRange( + [prevToken.range[1], firstAttribute.range[0]], + location === 'beside' ? ' ' : '\n' + ) + } + }) + } + + return utils.defineTemplateBodyVisitor(context, { + VStartTag(node) { + const firstAttribute = node.attributes[0] + if (!firstAttribute) return + + const lastAttribute = node.attributes[node.attributes.length - 1] + + const location = + firstAttribute.loc.start.line === lastAttribute.loc.end.line + ? singleline + : multiline + if (location === 'ignore') { + return + } + + if (location === 'beside') { + if (node.loc.start.line === firstAttribute.loc.start.line) { + return + } + } else { + if (node.loc.start.line < firstAttribute.loc.start.line) { + return + } + } + report(firstAttribute, location) + } + }) + } +} diff --git a/lib/rules/max-attributes-per-line.js b/lib/rules/max-attributes-per-line.js index c21604a61..d0eb40fb3 100644 --- a/lib/rules/max-attributes-per-line.js +++ b/lib/rules/max-attributes-per-line.js @@ -34,9 +34,6 @@ module.exports = { max: { type: 'number', minimum: 1 - }, - allowFirstLine: { - type: 'boolean' } }, additionalProperties: false @@ -55,9 +52,6 @@ module.exports = { max: { type: 'number', minimum: 1 - }, - allowFirstLine: { - type: 'boolean' } }, additionalProperties: false @@ -75,8 +69,6 @@ module.exports = { const configuration = parseOptions(context.options[0]) const multilineMaximum = configuration.multiline const singlelinemMaximum = configuration.singleline - const canHaveSinglelineFirstLine = configuration.singlelineAllowFirstLine - const canHaveMultilineFirstLine = configuration.multilineAllowFirstLine const template = context.parserServices.getTemplateBodyTokenStore && context.parserServices.getTemplateBodyTokenStore() @@ -88,26 +80,12 @@ module.exports = { if (!numberOfAttributes) return if (utils.isSingleLine(node)) { - if ( - !canHaveSinglelineFirstLine && - node.attributes[0].loc.start.line === node.loc.start.line - ) { - showErrors([node.attributes[0]]) - } - if (numberOfAttributes > singlelinemMaximum) { showErrors(node.attributes.slice(singlelinemMaximum)) } } if (!utils.isSingleLine(node)) { - if ( - !canHaveMultilineFirstLine && - node.attributes[0].loc.start.line === node.loc.start.line - ) { - showErrors([node.attributes[0]]) - } - groupAttrsByLine(node.attributes) .filter((attrs) => attrs.length > multilineMaximum) .forEach((attrs) => showErrors(attrs.splice(multilineMaximum))) @@ -124,9 +102,7 @@ module.exports = { function parseOptions(options) { const defaults = { singleline: 1, - singlelineAllowFirstLine: true, - multiline: 1, - multilineAllowFirstLine: false + multiline: 1 } if (options) { @@ -136,11 +112,6 @@ module.exports = { if (typeof options.singleline.max === 'number') { defaults.singleline = options.singleline.max } - - if (typeof options.singleline.allowFirstLine === 'boolean') { - defaults.singlelineAllowFirstLine = - options.singleline.allowFirstLine - } } if (options.multiline) { @@ -150,11 +121,6 @@ module.exports = { if (typeof options.multiline.max === 'number') { defaults.multiline = options.multiline.max } - - if (typeof options.multiline.allowFirstLine === 'boolean') { - defaults.multilineAllowFirstLine = - options.multiline.allowFirstLine - } } } } diff --git a/tests/lib/rules/first-attribute-linebreak.js b/tests/lib/rules/first-attribute-linebreak.js new file mode 100644 index 000000000..61cbb2ed9 --- /dev/null +++ b/tests/lib/rules/first-attribute-linebreak.js @@ -0,0 +1,278 @@ +/** + * @fileoverview Enforce the location of first attribute + * @author Yosuke Ota + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/first-attribute-linebreak') + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 2015 } +}) + +ruleTester.run('first-attribute-linebreak', rule, { + valid: [ + { + code: ` + ` + }, + { + code: ` + ` + }, + { + code: ` + `, + options: [{ singleline: 'ignore', multiline: 'ignore' }] + }, + { + code: ` + `, + options: [{ singleline: 'beside', multiline: 'ignore' }] + }, + { + code: ` + `, + options: [{ singleline: 'ignore', multiline: 'beside' }] + }, + { + code: ` + `, + options: [{ singleline: 'below', multiline: 'ignore' }] + }, + { + code: ` + `, + options: [{ singleline: 'ignore', multiline: 'below' }] + } + ], + + invalid: [ + { + code: ` + `, + output: ` + `, + errors: [ + { + message: 'Expected a linebreak before this attribute.', + line: 8, + column: 20 + } + ] + }, + { + code: ` + `, + output: ` + `, + options: [{ singleline: 'beside', multiline: 'beside' }], + errors: [ + { + message: 'Expected no linebreak before this attribute.', + line: 4, + column: 11 + }, + { + message: 'Expected no linebreak before this attribute.', + line: 13, + column: 11 + } + ] + }, + { + code: ` + `, + output: ` + `, + options: [{ singleline: 'below', multiline: 'below' }], + errors: [ + { + message: 'Expected a linebreak before this attribute.', + line: 8, + column: 20 + }, + { + message: 'Expected a linebreak before this attribute.', + line: 15, + column: 20 + } + ] + } + ] +}) diff --git a/tests/lib/rules/max-attributes-per-line.js b/tests/lib/rules/max-attributes-per-line.js index aedc6d6d8..9e14495a2 100644 --- a/tests/lib/rules/max-attributes-per-line.js +++ b/tests/lib/rules/max-attributes-per-line.js @@ -32,14 +32,6 @@ ruleTester.run('max-attributes-per-line', rule, { job="Vet" >` }, - { - code: ``, - options: [{ multiline: { allowFirstLine: true } }] - }, { code: ``, - options: [{ singleline: 1, multiline: { max: 1, allowFirstLine: false } }] - }, - { - code: ``, - options: [{ singleline: 3, multiline: { max: 1, allowFirstLine: false } }] - }, - { - code: ``, - options: [{ singleline: 3, multiline: { max: 1, allowFirstLine: true } }] - }, - { - code: ``, - options: [{ singleline: 3, multiline: { max: 1, allowFirstLine: false } }] - }, - { - code: ``, - options: [{ singleline: 3, multiline: { max: 2, allowFirstLine: false } }] + ` } ], @@ -131,26 +99,6 @@ v-if="something">`, v-bind:age="user.age">`, errors: ["'v-bind:age' should be on a new line."] }, - { - code: ``, - output: ``, - errors: [ - { - message: "'job' should be on a new line.", - type: 'VAttribute', - line: 1 - } - ] - }, { code: ``, options: [{ singleline: { max: 2 } }], @@ -164,70 +112,6 @@ job="Vet">`, } ] }, - { - code: ``, - options: [ - { singleline: 1, multiline: { max: 1, allowFirstLine: false } } - ], - output: ``, - errors: [ - { - message: "'age' should be on a new line.", - type: 'VAttribute', - line: 1 - }, - { - message: "'job' should be on a new line.", - type: 'VAttribute', - line: 1 - } - ] - }, - { - code: ``, - options: [ - { singleline: 3, multiline: { max: 1, allowFirstLine: false } } - ], - output: ``, - errors: [ - { - message: "'name' should be on a new line.", - type: 'VAttribute', - line: 1 - } - ] - }, - { - code: ``, - options: [ - { singleline: 3, multiline: { max: 1, allowFirstLine: false } } - ], - output: ``, - errors: [ - { - message: "'age' should be on a new line.", - type: 'VAttribute', - line: 2 - } - ] - }, { code: ``, - options: [ - { singleline: 3, multiline: { max: 2, allowFirstLine: false } } - ], - output: ``, - errors: [ - { - message: "'petname' should be on a new line.", - type: 'VAttribute', - line: 3 - } - ] - }, - { - code: ``, - options: [ - { singleline: 3, multiline: { max: 2, allowFirstLine: false } } - ], - output: ``, - errors: [ - { - message: "'petname' should be on a new line.", - type: 'VAttribute', - line: 3 - }, - { - message: "'extra' should be on a new line.", - type: 'VAttribute', - line: 3 - } - ] - }, - { - code: ``, - options: [{ singleline: { allowFirstLine: false } }], - output: ``, - errors: [ - { - message: "'name' should be on a new line.", - type: 'VAttribute', - line: 1 - } - ] } ] })