/** * @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_MESSAGES = Object.freeze({ NORMAL: 'HTML elements', VOID: 'HTML void elements', COMPONENT: 'Vue.js custom components', SVG: 'SVG elements', MATH: 'MathML elements', UNKNOWN: 'unknown elements' }) /** * @typedef {object} Options * @property {'always' | 'never'} NORMAL * @property {'always' | 'never'} VOID * @property {'always' | 'never'} COMPONENT * @property {'always' | 'never'} SVG * @property {'always' | 'never'} MATH * @property {null} UNKNOWN */ /** * Normalize the given options. * @param {any} options The raw options object. * @returns {Options} Normalized options. */ function parseOptions(options) { return { NORMAL: (options && options.html && options.html.normal) || 'always', VOID: (options && options.html && options.html.void) || 'never', COMPONENT: (options && options.html && options.html.component) || 'always', SVG: (options && options.svg) || 'always', MATH: (options && options.math) || 'always', UNKNOWN: null } } /** * Get the elementType of the given element. * @param {VElement} node The element node to get. * @returns {keyof Options} The elementType of the element. */ function getElementType(node) { if (utils.isCustomComponent(node)) { return 'COMPONENT' } if (utils.isHtmlElementNode(node)) { if (utils.isHtmlVoidElementName(node.name)) { return 'VOID' } return 'NORMAL' } if (utils.isSvgElementNode(node)) { return 'SVG' } if (utils.isMathMLElementNode(node)) { return 'MATH' } return 'UNKNOWN' } /** * 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() === '' } // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ module.exports = { meta: { type: 'layout', docs: { description: 'enforce self-closing style', categories: ['vue3-strongly-recommended', 'strongly-recommended'], url: 'https://eslint.vuejs.org/rules/html-self-closing.html' }, 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 } }, /** @param {RuleContext} context */ create(context) { const sourceCode = context.getSourceCode() const options = parseOptions(context.options[0]) let hasInvalidEOF = false return utils.defineTemplateBodyVisitor( context, { VElement(node) { if (hasInvalidEOF) { return } 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: ELEMENT_TYPE_MESSAGES[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: ELEMENT_TYPE_MESSAGES[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 === 'VOID') { return fixer.replaceText(close, '>') } // If only `close` is targeted for replacement, it conflicts with `component-name-in-template-casing`, // so replace the entire element. // return fixer.replaceText(close, `>`) const elementPart = sourceCode.text.slice( node.range[0], close.range[0] ) return fixer.replaceText( node, `${elementPart}>` ) } }) } } }, { Program(node) { hasInvalidEOF = utils.hasInvalidEOF(node) } } ) } }