diff --git a/docs/rules/no-restricted-tags.md b/docs/rules/no-restricted-tags.md new file mode 100644 index 00000000..73f23cb7 --- /dev/null +++ b/docs/rules/no-restricted-tags.md @@ -0,0 +1,118 @@ +# no-restricted-tags + +This rule disallows the use of specified tags. + +## How to use + +```js,.eslintrc.js +module.exports = { + rules: { + "@html-eslint/no-restricted-tags": [ + "error", + { + tagPatterns: ["^div$", "^span$"], + message: "Use semantic elements instead of div and span.", + }, + ], + }, +}; +``` + +## Rule Details + +This rule allows you to specify tags that you don't want to use in your application. + +### Options + +This rule takes an array of option objects, where the `tagPatterns` are specified. + +- `tagPatterns`: An array of strings representing regular expression patterns. It disallows tag names that match any of the patterns. +- `message` (optional): A custom error message to be shown when the rule is triggered. + +```js +module.exports = { + rules: { + "@html-eslint/no-restricted-tags": [ + "error", + { + tagPatterns: ["^div$", "^span$"], + message: "Use semantic elements instead of div and span.", + }, + { + tagPatterns: ["h[1-6]"], + message: "Heading tags are not allowed in this context.", + }, + { + tagPatterns: [".*-.*"], + message: "Custom elements should follow naming conventions.", + }, + ], + }, +}; +``` + +Examples of **incorrect** code for this rule with the option below: + +```json +{ + "tagPatterns": ["^div$", "^span$"], + "message": "Use semantic elements instead of generic containers" +} +``` + +```html,incorrect +
Content
+Text +``` + +Examples of **correct** code for this rule with the option above: + +```html,correct +
Content
+

Text

+
Content
+``` + +Examples of **incorrect** code for this rule with regex patterns: + +```json +{ + "tagPatterns": ["h[1-6]"], + "message": "Heading tags are restricted" +} +``` + +```html,incorrect +

Title

+

Subtitle

+

Section

+``` + +Examples of **incorrect** code for this rule with custom element patterns: + +```json +{ + "tagPatterns": [".*-.*"], + "message": "Custom elements should follow naming conventions" +} +``` + +```html,incorrect + + +``` + +Examples of **incorrect** code for this rule with deprecated tags: + +```json +{ + "tagPatterns": ["font|center|marquee"], + "message": "Deprecated HTML tags are not allowed" +} +``` + +```html,incorrect +Old style text +
Centered content
+Scrolling text +``` diff --git a/packages/eslint-plugin/lib/rules/index.js b/packages/eslint-plugin/lib/rules/index.js index 7712a3c3..fe0422b5 100644 --- a/packages/eslint-plugin/lib/rules/index.js +++ b/packages/eslint-plugin/lib/rules/index.js @@ -52,6 +52,7 @@ const noEmptyHeadings = require("./no-empty-headings"); const noInvalidEntity = require("./no-invalid-entity"); const noDuplicateInHead = require("./no-duplicate-in-head"); const noIneffectiveAttrs = require("./no-ineffective-attrs"); +const noRestrictedTags = require("./no-restricted-tags"); // import new rule here ↑ // DO NOT REMOVE THIS COMMENT @@ -110,6 +111,7 @@ const rules = { "no-invalid-entity": noInvalidEntity, "no-duplicate-in-head": noDuplicateInHead, "no-ineffective-attrs": noIneffectiveAttrs, + "no-restricted-tags": noRestrictedTags, // export new rule here ↑ // DO NOT REMOVE THIS COMMENT }; diff --git a/packages/eslint-plugin/lib/rules/no-restricted-tags.js b/packages/eslint-plugin/lib/rules/no-restricted-tags.js new file mode 100644 index 00000000..ea684dd4 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-restricted-tags.js @@ -0,0 +1,138 @@ +/** + * @import {StyleTag, Tag, ScriptTag} from "@html-eslint/types"; + * @import {RuleModule} from "../types"; + * @typedef {{tagPatterns: string[], message?: string}[]} Options + * + */ + +const { NODE_TYPES } = require("@html-eslint/parser"); +const { RULE_CATEGORY } = require("../constants"); +const { createVisitors } = require("./utils/visitors"); +const { getRuleUrl } = require("./utils/rule"); + +const MESSAGE_IDS = { + RESTRICTED: "restricted", +}; + +/** + * @type {RuleModule} + */ +module.exports = { + meta: { + type: "code", + + docs: { + description: "Disallow specified tags", + category: RULE_CATEGORY.BEST_PRACTICE, + recommended: false, + url: getRuleUrl("no-restricted-tags"), + }, + + fixable: null, + schema: { + type: "array", + + items: { + type: "object", + required: ["tagPatterns"], + properties: { + tagPatterns: { + type: "array", + items: { + type: "string", + }, + }, + message: { + type: "string", + }, + }, + additionalProperties: false, + }, + }, + messages: { + [MESSAGE_IDS.RESTRICTED]: "'{{tag}}' tag is restricted from being used.", + }, + }, + + create(context) { + /** + * @type {Options} + */ + const options = context.options; + const checkers = options.map((option) => new PatternChecker(option)); + + /** + * @param {Tag | StyleTag | ScriptTag} node + */ + function check(node) { + const tagName = + node.type === NODE_TYPES.Tag + ? node.name + : node.type === NODE_TYPES.ScriptTag + ? "script" + : "style"; + + const matched = checkers.find((checker) => checker.test(tagName)); + + if (!matched) { + return; + } + + /** + * @type {{node: Tag | StyleTag | ScriptTag, message: string, messageId?: string}} + */ + const result = { + node: node, + message: "", + }; + + const customMessage = matched.getMessage(); + + if (customMessage) { + result.message = customMessage; + } else { + result.messageId = MESSAGE_IDS.RESTRICTED; + } + + context.report({ + ...result, + data: { tag: tagName }, + }); + } + + return createVisitors(context, { + Tag: check, + StyleTag: check, + ScriptTag: check, + }); + }, +}; + +class PatternChecker { + /** + * @param {Options[number]} option + */ + constructor(option) { + this.option = option; + this.tagRegExps = option.tagPatterns.map( + (pattern) => new RegExp(pattern, "u") + ); + this.message = option.message; + } + + /** + * @param {string} tagName + * @returns {boolean} + */ + test(tagName) { + const result = this.tagRegExps.some((exp) => exp.test(tagName)); + return result; + } + + /** + * @returns {string} + */ + getMessage() { + return this.message || ""; + } +} diff --git a/packages/eslint-plugin/tests/rules/no-restricted-tags.test.js b/packages/eslint-plugin/tests/rules/no-restricted-tags.test.js new file mode 100644 index 00000000..f57ee53e --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-restricted-tags.test.js @@ -0,0 +1,271 @@ +const createRuleTester = require("../rule-tester"); +const rule = require("../../lib/rules/no-restricted-tags"); + +const ruleTester = createRuleTester(); +const templateRuleTester = createRuleTester("espree"); + +ruleTester.run("no-restricted-tags", rule, { + valid: [ + { + code: `
`, + options: [ + { + tagPatterns: ["span"], + }, + ], + }, + { + code: `

content

`, + options: [ + { + tagPatterns: ["div"], + }, + ], + }, + { + code: `
`, + options: [ + { + tagPatterns: ["footer"], + }, + ], + }, + ], + invalid: [ + { + code: `
`, + options: [ + { + tagPatterns: ["div"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "div", + }, + }, + ], + }, + { + code: `text`, + options: [ + { + tagPatterns: ["span"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "span", + }, + }, + ], + }, + { + code: ``, + options: [ + { + tagPatterns: ["script"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "script", + }, + }, + ], + }, + { + code: ``, + options: [ + { + tagPatterns: ["style"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "style", + }, + }, + ], + }, + // regex patterns + { + code: `
`, + options: [ + { + tagPatterns: ["^div$"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "div", + }, + }, + ], + }, + { + code: ` `, + options: [ + { + tagPatterns: [".*-.*"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "custom-element", + }, + }, + { + messageId: "restricted", + data: { + tag: "another-custom", + }, + }, + ], + }, + { + code: `

Title

Subtitle

Section

`, + options: [ + { + tagPatterns: ["h[1-6]"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "h1", + }, + }, + { + messageId: "restricted", + data: { + tag: "h2", + }, + }, + { + messageId: "restricted", + data: { + tag: "h3", + }, + }, + ], + }, + // custom message + { + code: `
`, + options: [ + { + tagPatterns: ["div"], + message: "Do not use div tags, use semantic elements instead", + }, + ], + errors: [ + { + message: "Do not use div tags, use semantic elements instead", + }, + ], + }, + { + code: `old style`, + options: [ + { + tagPatterns: ["font|center|marquee"], + message: "Deprecated HTML tags are not allowed", + }, + ], + errors: [ + { + message: "Deprecated HTML tags are not allowed", + }, + ], + }, + // multiple patterns + { + code: `
`, + options: [ + { + tagPatterns: ["div", "span"], + }, + { + tagPatterns: [".*-.*"], + message: "Custom elements should follow naming conventions", + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "div", + }, + }, + { + messageId: "restricted", + data: { + tag: "span", + }, + }, + { + message: "Custom elements should follow naming conventions", + }, + ], + }, + ], +}); + +templateRuleTester.run("[template] no-restricted-tags", rule, { + valid: [ + { + code: `html\`

content

\``, + options: [ + { + tagPatterns: ["div"], + }, + ], + }, + ], + invalid: [ + { + code: `html\`
content
\``, + options: [ + { + tagPatterns: ["div"], + message: "Do not use div in templates", + }, + ], + errors: [ + { + message: "Do not use div in templates", + }, + ], + }, + { + code: `html\`\``, + options: [ + { + tagPatterns: [".*-.*"], + }, + ], + errors: [ + { + messageId: "restricted", + data: { + tag: "custom-element", + }, + }, + ], + }, + ], +});