eslint-plugin-san
Version:
Official ESLint plugin for San
211 lines (194 loc) • 7.76 kB
JavaScript
/**
* @author Toru Nagashima
* @copyright 2016 Toru Nagashima. All rights reserved.
* See LICENSE file in root directory for full license.
*/
'use strict';
/* eslint-disable */
// ------------------------------------------------------------------------------
// 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: 'San.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: ['strongly-recommended'],
url: 'https://ecomfe.github.io/eslint-plugin-san/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, `></${node.rawName}>`)
const elementPart = sourceCode.text.slice(node.range[0], close.range[0]);
return fixer.replaceText(node, `${elementPart}></${node.rawName}>`);
}
});
}
}
},
{
Program(node) {
hasInvalidEOF = utils.hasInvalidEOF(node);
}
}
);
}
};