@html-eslint/eslint-plugin
Version:
ESLint plugin for HTML
178 lines (167 loc) • 4.97 kB
JavaScript
/**
* @import {
* ScriptTag,
* StyleTag,
* Tag
* } from "@html-eslint/types"
* @import {RuleModule} from "../types"
* @typedef {Object} Option
* @property {"always" | "never"} [Option.selfClosing]
* @property {string[]} [Option.selfClosingCustomPatterns]
*/
const { RULE_CATEGORY, VOID_ELEMENTS } = require("../constants");
const { createVisitors } = require("./utils/visitors");
const { getRuleUrl } = require("./utils/rule");
const { getNameOf } = require("./utils/node");
const VOID_ELEMENTS_SET = new Set(VOID_ELEMENTS);
const MESSAGE_IDS = {
MISSING: "missing",
MISSING_SELF: "missingSelf",
UNEXPECTED: "unexpected",
};
/** @type {RuleModule<[Option]>} */
module.exports = {
meta: {
type: "code",
docs: {
description: "Require closing tags.",
category: RULE_CATEGORY.BEST_PRACTICE,
recommended: true,
url: getRuleUrl("require-closing-tags"),
},
fixable: true,
schema: [
{
type: "object",
properties: {
selfClosing: {
enum: ["always", "never"],
},
selfClosingCustomPatterns: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
],
messages: {
[MESSAGE_IDS.MISSING]: "Missing closing tag for {{tag}}.",
[MESSAGE_IDS.MISSING_SELF]: "Missing self closing tag for {{tag}}",
[MESSAGE_IDS.UNEXPECTED]: "Unexpected self closing tag for {{tag}}.",
},
},
create(context) {
/** @type {string[]} */
const foreignContext = [];
const shouldSelfCloseVoid =
context.options && context.options.length
? context.options[0].selfClosing === "always"
: false;
/** @type {string[]} */
const selfClosingCustomPatternsOption =
(context.options &&
context.options.length &&
context.options[0].selfClosingCustomPatterns) ||
[];
const selfClosingCustomPatterns = selfClosingCustomPatternsOption.map(
(i) => new RegExp(i)
);
/** @param {Tag | ScriptTag | StyleTag} node */
function checkClosing(node) {
const name = getNameOf(node);
if (!node.close) {
context.report({
node: node,
data: {
tag: name,
},
messageId: MESSAGE_IDS.MISSING,
});
}
}
/**
* @param {Tag} node
* @param {boolean} shouldSelfClose
* @param {boolean} fixable
*/
function checkVoidElement(node, shouldSelfClose, fixable) {
const hasSelfClose = node.openEnd.value === "/>";
if (shouldSelfClose && !hasSelfClose) {
context.report({
node: node.openEnd,
data: {
tag: node.name,
},
messageId: MESSAGE_IDS.MISSING_SELF,
fix(fixer) {
if (!fixable) {
return null;
}
const fixes = [];
fixes.push(fixer.replaceText(node.openEnd, " />"));
if (node.close) fixes.push(fixer.remove(node.close));
return fixes;
},
});
}
if (!shouldSelfClose && hasSelfClose) {
context.report({
node: node.openEnd,
data: {
tag: node.name,
},
messageId: MESSAGE_IDS.UNEXPECTED,
fix(fixer) {
if (!fixable) {
return null;
}
return fixer.replaceText(node.openEnd, ">");
},
});
}
}
return createVisitors(context, {
Tag(node) {
const isVoidElement = VOID_ELEMENTS_SET.has(node.name);
const isSelfClosingCustomElement = !!selfClosingCustomPatterns.some(
(i) => node.name.match(i)
);
const isForeign = foreignContext.length > 0;
const shouldSelfCloseCustom =
isSelfClosingCustomElement && !node.children.length;
const shouldSelfCloseForeign = node.selfClosing;
const shouldSelfClose =
(isVoidElement && shouldSelfCloseVoid) ||
(isSelfClosingCustomElement && shouldSelfCloseCustom) ||
(isForeign && shouldSelfCloseForeign);
const canSelfClose =
isVoidElement || isSelfClosingCustomElement || isForeign;
if (node.selfClosing || canSelfClose) {
checkVoidElement(node, shouldSelfClose, canSelfClose);
} else if (node.openEnd.value !== "/>") {
checkClosing(node);
}
if (["svg", "math"].includes(node.name)) foreignContext.push(node.name);
},
/** @param {Tag} node */
"Tag:exit"(node) {
if (node.name === foreignContext[foreignContext.length - 1]) {
foreignContext.pop();
}
},
ScriptTag(node) {
if (!node.close) {
checkClosing(node);
}
},
StyleTag(node) {
if (!node.close) {
checkClosing(node);
}
},
});
},
};