diff --git a/docs/rules/html-self-closing.md b/docs/rules/html-self-closing.md new file mode 100644 index 000000000..c7ac3822f --- /dev/null +++ b/docs/rules/html-self-closing.md @@ -0,0 +1,67 @@ +# Enforce self-closing style (html-self-closing) + +In Vue.js template, we can use either two styles for elements which don't have their content. + +1. `` +2. `` (self-closing) + +Self-closing is simple and shorter, but it's not supported in raw HTML. +This rule helps you to unify the self-closing style. + +## Rule Details + +This rule has options which specify self-closing style for each context. + +```json +{ + "html-self-closing": ["error", { + "html": { + "normal": "never", + "void": "never", + "component": "always" + }, + "svg": "always", + "math": "always" + }] +} +``` + +- `html.normal` (`"never"` by default) ... The style of well-known HTML elements except void elements. +- `html.void` (`"never"` by default) ... The style of well-known HTML void elements. +- `html.component` (`"always"` by default) ... The style of Vue.js custom components. +- `svg`(`"always"` by default) .... The style of well-known SVG elements. +- `math`(`"always"` by default) .... The style of well-known MathML elements. + +Every option can be set to one of the following values: + +- `"always"` ... Require self-closing at elements which don't have their content. +- `"never"` ... Disallow self-closing. +- `"any"` ... Don't enforce self-closing style. + +---- + +:-1: Examples of **incorrect** code for this rule: + +```html +/*eslint html-self-closing: "error"*/ + + +``` + +:+1: Examples of **correct** code for this rule: + +```html +/*eslint html-self-closing: "error"*/ + + +``` diff --git a/lib/rules/html-end-tags.js b/lib/rules/html-end-tags.js index 4238cca1d..511351095 100644 --- a/lib/rules/html-end-tags.js +++ b/lib/rules/html-end-tags.js @@ -25,7 +25,7 @@ function create (context) { utils.registerTemplateBodyVisitor(context, { VElement (node) { const name = node.name - const isVoid = utils.isVoidElementName(name) + const isVoid = utils.isHtmlVoidElementName(name) const hasEndTag = node.endTag != null if (isVoid && hasEndTag) { diff --git a/lib/rules/html-no-self-closing.js b/lib/rules/html-no-self-closing.js index 02e74b505..b85ab12fd 100644 --- a/lib/rules/html-no-self-closing.js +++ b/lib/rules/html-no-self-closing.js @@ -57,7 +57,7 @@ module.exports = { description: 'disallow self-closing elements.', category: 'Best Practices', recommended: false, - replacedBy: [] + replacedBy: ['html-self-closing-style'] }, deprecated: true, fixable: 'code', diff --git a/lib/rules/html-self-closing.js b/lib/rules/html-self-closing.js new file mode 100644 index 000000000..7dc2bb28b --- /dev/null +++ b/lib/rules/html-self-closing.js @@ -0,0 +1,179 @@ +/** + * @author Toru Nagashima + * @copyright 2016 Toru Nagashima. All rights reserved. + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const utils = require('../utils') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * These strings wil be displayed in error messages. + */ +const ELEMENT_TYPE = Object.freeze({ + NORMAL: 'HTML elements', + VOID: 'HTML void elements', + COMPONENT: 'Vue.js custom components', + SVG: 'SVG elements', + MATH: 'MathML elements' +}) + +/** + * Normalize the given options. + * @param {Object|undefined} options The raw options object. + * @returns {Object} Normalized options. + */ +function parseOptions (options) { + return { + [ELEMENT_TYPE.NORMAL]: (options && options.html && options.html.normal) || 'never', + [ELEMENT_TYPE.VOID]: (options && options.html && options.html.void) || 'never', + [ELEMENT_TYPE.COMPONENT]: (options && options.html && options.html.component) || 'always', + [ELEMENT_TYPE.SVG]: (options && options.svg) || 'always', + [ELEMENT_TYPE.MATH]: (options && options.math) || 'always' + } +} + +/** + * Get the elementType of the given element. + * @param {VElement} node The element node to get. + * @returns {string} The elementType of the element. + */ +function getElementType (node) { + if (utils.isCustomComponent(node)) { + return ELEMENT_TYPE.COMPONENT + } + if (utils.isHtmlElementNode(node)) { + if (utils.isHtmlVoidElementName(node.name)) { + return ELEMENT_TYPE.VOID + } + return ELEMENT_TYPE.NORMAL + } + if (utils.isSvgElementNode(node)) { + return ELEMENT_TYPE.SVG + } + if (utils.isMathMLElementNode(node)) { + return ELEMENT_TYPE.MATH + } + return 'unknown elements' +} + +/** + * Check whether the given element is empty or not. + * This ignores whitespaces, doesn't ignore comments. + * @param {VElement} node The element node to check. + * @param {SourceCode} sourceCode The source code object of the current context. + * @returns {boolean} `true` if the element is empty. + */ +function isEmpty (node, sourceCode) { + const start = node.startTag.range[1] + const end = (node.endTag != null) ? node.endTag.range[0] : node.range[1] + + return sourceCode.text.slice(start, end).trim() === '' +} + +/** + * Creates AST event handlers for html-self-closing. + * + * @param {RuleContext} context - The rule context. + * @returns {object} AST event handlers. + */ +function create (context) { + const sourceCode = context.getSourceCode() + const options = parseOptions(context.options[0]) + + utils.registerTemplateBodyVisitor(context, { + 'VElement' (node) { + const elementType = getElementType(node) + const mode = options[elementType] + + if (mode === 'always' && !node.startTag.selfClosing && isEmpty(node, sourceCode)) { + context.report({ + node, + loc: node.loc, + message: 'Require self-closing on {{elementType}} (<{{name}}>).', + data: { elementType, name: node.rawName }, + fix: (fixer) => { + const tokens = context.parserServices.getTemplateBodyTokenStore() + const close = tokens.getLastToken(node.startTag) + if (close.type !== 'HTMLTagClose') { + return null + } + return fixer.replaceTextRange([close.range[0], node.range[1]], '/>') + } + }) + } + + if (mode === 'never' && node.startTag.selfClosing) { + context.report({ + node, + loc: node.loc, + message: 'Disallow self-closing on {{elementType}} (<{{name}}/>).', + data: { elementType, name: node.rawName }, + fix: (fixer) => { + const tokens = context.parserServices.getTemplateBodyTokenStore() + const close = tokens.getLastToken(node.startTag) + if (close.type !== 'HTMLSelfClosingTagClose') { + return null + } + if (elementType === ELEMENT_TYPE.VOID) { + return fixer.replaceText(close, '>') + } + return fixer.replaceText(close, `>`) + } + }) + } + } + }) + + return {} +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + create, + meta: { + docs: { + description: 'enforce self-closing style.', + category: 'Stylistic Issues', + recommended: false + }, + fixable: 'code', + schema: { + definitions: { + optionValue: { + enum: ['always', 'never', 'any'] + } + }, + type: 'array', + items: [{ + type: 'object', + properties: { + html: { + type: 'object', + properties: { + normal: { $ref: '#/definitions/optionValue' }, + void: { $ref: '#/definitions/optionValue' }, + component: { $ref: '#/definitions/optionValue' } + }, + additionalProperties: false + }, + svg: { $ref: '#/definitions/optionValue' }, + math: { $ref: '#/definitions/optionValue' } + }, + additionalProperties: false + }], + maxItems: 1 + } + } +} diff --git a/lib/utils/index.js b/lib/utils/index.js index 1e741703b..36036d602 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -183,7 +183,7 @@ module.exports = { assert(node && node.type === 'VElement') return ( - !(this.isKnownHtmlElementNode(node) || this.isSvgElementNode(node) || this.isMathMLElementNode(node)) || + (this.isHtmlElementNode(node) && !this.isHtmlWellKnownElementName(node.name)) || this.hasAttribute(node, 'is') || this.hasDirective(node, 'bind', 'is') ) @@ -194,10 +194,10 @@ module.exports = { * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the node is a HTML element. */ - isKnownHtmlElementNode (node) { + isHtmlElementNode (node) { assert(node && node.type === 'VElement') - return node.namespace === vueEslintParser.AST.NS.HTML && HTML_ELEMENT_NAMES.has(node.name.toLowerCase()) + return node.namespace === vueEslintParser.AST.NS.HTML }, /** @@ -222,12 +222,23 @@ module.exports = { return node.namespace === vueEslintParser.AST.NS.MathML }, + /** + * Check whether the given name is an well-known element or not. + * @param {string} name The name to check. + * @returns {boolean} `true` if the name is an well-known element name. + */ + isHtmlWellKnownElementName (name) { + assert(typeof name === 'string') + + return HTML_ELEMENT_NAMES.has(name.toLowerCase()) + }, + /** * Check whether the given name is a void element name or not. * @param {string} name The name to check. * @returns {boolean} `true` if the name is a void element name. */ - isVoidElementName (name) { + isHtmlVoidElementName (name) { assert(typeof name === 'string') return VOID_ELEMENT_NAMES.has(name.toLowerCase()) diff --git a/tests/lib/rules/html-self-closing.js b/tests/lib/rules/html-self-closing.js new file mode 100644 index 000000000..49ee3c43f --- /dev/null +++ b/tests/lib/rules/html-self-closing.js @@ -0,0 +1,292 @@ +/** + * @author Toru Nagashima + * @copyright 2016 Toru Nagashima. All rights reserved. + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/html-self-closing') +const RuleTester = require('eslint').RuleTester + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const tester = new RuleTester({ + parser: 'vue-eslint-parser' +}) + +const ALL_CODE = `` + +const anyWith = (opts) => Object.assign( + { + svg: 'any', + math: 'any' + }, + opts, + { + html: Object.assign( + { + normal: 'any', + void: 'any', + component: 'any' + }, + opts.html || {} + ) + } +) + +tester.run('html-self-closing', rule, { + valid: [ + // default + '', + '', + '', + '', + '', + + // Don't error if there are comments in their content. + { + code: '', + output: null, + options: [{ html: { normal: 'always' }}] + } + + // other cases are in `invalid` tests. + ], + invalid: [ + // default + { + code: '', + output: '', + errors: ['Disallow self-closing on HTML elements (
).'] + }, + { + code: '', + output: '', + errors: ['Disallow self-closing on HTML void elements ().'] + }, + { + code: '', + output: '', + errors: ['Require self-closing on Vue.js custom components ().'] + }, + { + code: '', + output: '', + errors: ['Require self-closing on SVG elements ().'] + }, + { + code: '', + output: '', + errors: ['Require self-closing on MathML elements ().'] + }, + + // others + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { normal: 'always' }})], + errors: [ + { message: 'Require self-closing on HTML elements (
).', line: 2 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { normal: 'never' }})], + errors: [ + { message: 'Disallow self-closing on HTML elements (
).', line: 3 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { void: 'always' }})], + errors: [ + { message: 'Require self-closing on HTML void elements ().', line: 4 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { void: 'never' }})], + errors: [ + { message: 'Disallow self-closing on HTML void elements ().', line: 5 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { component: 'always' }})], + errors: [ + { message: 'Require self-closing on Vue.js custom components ().', line: 6 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ html: { component: 'never' }})], + errors: [ + { message: 'Disallow self-closing on Vue.js custom components ().', line: 7 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ svg: 'always' })], + errors: [ + { message: 'Require self-closing on SVG elements ().', line: 8 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ svg: 'never' })], + errors: [ + { message: 'Disallow self-closing on SVG elements ().', line: 9 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ math: 'always' })], + errors: [ + { message: 'Require self-closing on MathML elements ().', line: 10 } + ] + }, + { + code: ALL_CODE, + output: ``, + options: [anyWith({ math: 'never' })], + errors: [ + { message: 'Disallow self-closing on MathML elements ().', line: 11 } + ] + } + ] +})