UNPKG

@html-eslint/eslint-plugin

Version:
178 lines (167 loc) 4.97 kB
/** * @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); } }, }); }, };