diff --git a/docs/rules.md b/docs/rules.md index e282f93f..f9112d2f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -64,6 +64,7 @@ | [id-naming-convention](rules/id-naming-convention) | Enforce consistent naming id attributes | | | [indent](rules/indent) | Enforce consistent indentation | ⭐🔧 | | [lowercase](rules/lowercase) | Enforce to use lowercase for tag and attribute names. | 🔧 | +| [max-element-depth](rules/max-element-depth) | Enforce element maximum depth | | | [no-extra-spacing-attrs](rules/no-extra-spacing-attrs) | Disallow an extra spacing around attributes | ⭐🔧 | | [no-multiple-empty-lines](rules/no-multiple-empty-lines) | Disallow multiple empty lines | 🔧 | | [no-trailing-spaces](rules/no-trailing-spaces) | Disallow trailing whitespace at the end of lines | 🔧 | diff --git a/docs/rules/max-element-depth.md b/docs/rules/max-element-depth.md new file mode 100644 index 00000000..f36c06b6 --- /dev/null +++ b/docs/rules/max-element-depth.md @@ -0,0 +1,57 @@ +# max-element-depth + +This rule enforces element maximum depth. + +## Why? + +Deeply nested HTML structures can be difficult to read and understand, making the code harder to maintain. A flatter structure is more intuitive for developers, reducing the likelihood of errors and improving collaboration. + +Deep nesting can increase the complexity of rendering for browsers. The browser's layout engine needs to compute styles and positions for all elements, and deeply nested structures can slow down this process, especially on resource-constrained devices. + +## How to use + +```js,.eslintrc.js +module.exports = { + rules: { + "@html-eslint/max-element-depth": "error", + }, +}; +``` + +## Rule Details + +### Options + +This rule has an object option: + +- `"max"`: Maximum element depth to allow. + +```ts +"@html-eslint/element-newline": ["error", { + "max": number +}] +``` + +Examples of **incorrect** code for this rule with the `{"max": 2}` option: + +```html,incorrect +
+
+
+
+
+
+``` + +Examples of **correct** code for this rule with the `{"max": 2}` option: + +```html,correct +
+
+
+
+``` + +## Further Reading + +- [Avoid an excessive DOM size](https://developer.chrome.com/docs/lighthouse/performance/dom-size) diff --git a/packages/eslint-plugin/lib/rules/index.js b/packages/eslint-plugin/lib/rules/index.js index 4b9d7cd3..4ebeda59 100644 --- a/packages/eslint-plugin/lib/rules/index.js +++ b/packages/eslint-plugin/lib/rules/index.js @@ -43,6 +43,7 @@ const requireFormMethod = require("./require-form-method"); const noHeadingInsideButton = require("./no-heading-inside-button"); const noInvalidRole = require("./no-invalid-role"); const noNestedInteractive = require("./no-nested-interactive"); +const maxElementDepth = require("./max-element-depth"); module.exports = { "require-lang": requireLang, @@ -90,4 +91,5 @@ module.exports = { "sort-attrs": sortAttrs, "prefer-https": preferHttps, "require-input-label": requireInputLabel, + "max-element-depth": maxElementDepth, }; diff --git a/packages/eslint-plugin/lib/rules/max-element-depth.js b/packages/eslint-plugin/lib/rules/max-element-depth.js new file mode 100644 index 00000000..40f67d65 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/max-element-depth.js @@ -0,0 +1,96 @@ +/** + * @typedef { import("../types").RuleModule } RuleModule + * @typedef { import("../types").Tag } Tag + * @typedef { import("../types").StyleTag } StyleTag + * @typedef { import("../types").ScriptTag } ScriptTag + */ + +const { RULE_CATEGORY } = require("../constants"); +const { createVisitors } = require("./utils/visitors"); + +const MESSAGE_IDS = { + MAX_DEPTH_EXCEEDED: "maxDepthExceeded", +}; + +/** + * @type {RuleModule} + */ +module.exports = { + meta: { + type: "code", + + docs: { + description: "Enforce element maximum depth", + category: RULE_CATEGORY.STYLE, + recommended: false, + }, + + fixable: null, + schema: [ + { + type: "object", + properties: { + max: { + type: "integer", + minimum: 1, + default: 32, + }, + }, + required: ["max"], + additionalProperties: false, + }, + ], + messages: { + [MESSAGE_IDS.MAX_DEPTH_EXCEEDED]: + "Expected the depth of nested elements to be <= {{needed}}, but found {{found}}", + }, + }, + + create(context) { + const maxDepth = + context.options && + context.options[0] && + typeof context.options[0].max === "number" + ? context.options[0].max + : 32; + + let depth = 0; + + function resetDepth() { + depth = 0; + } + + /** + * + * @param {Tag | ScriptTag | StyleTag} node + */ + function increaseDepth(node) { + depth++; + if (depth > maxDepth) { + context.report({ + node, + messageId: MESSAGE_IDS.MAX_DEPTH_EXCEEDED, + data: { + needed: maxDepth, + found: String(depth), + }, + }); + } + } + + function decreaseDepth() { + depth--; + } + + return createVisitors(context, { + Document: resetDepth, + "Document:exit": resetDepth, + Tag: increaseDepth, + "Tag:exit": decreaseDepth, + ScriptTag: increaseDepth, + "ScriptTag:exit": decreaseDepth, + StyleTag: increaseDepth, + "StyleTag:exit": decreaseDepth, + }); + }, +}; diff --git a/packages/eslint-plugin/tests/rules/max-element-depth.test.js b/packages/eslint-plugin/tests/rules/max-element-depth.test.js new file mode 100644 index 00000000..68a04bb6 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/max-element-depth.test.js @@ -0,0 +1,238 @@ +const createRuleTester = require("../rule-tester"); +const rule = require("../../lib/rules/max-element-depth"); + +const ruleTester = createRuleTester(); +const templateRuleTester = createRuleTester("espree"); + +ruleTester.run("max-element-depth", rule, { + valid: [ + { + code: `
`, + }, + { + code: `
`, + }, + { + code: `
`, + options: [ + { + max: 2, + }, + ], + }, + { + code: ` +
+
+`, + options: [ + { + max: 2, + }, + ], + }, + ], + invalid: [ + { + code: `
`, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + { + code: ` +
+
+
+`, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + { + code: ` +
+
+
+
+ `, + options: [ + { + max: 3, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 3, but found 4", + }, + { + message: + "Expected the depth of nested elements to be <= 3, but found 4", + }, + ], + }, + ], +}); + +templateRuleTester.run("[template] max-element-depth", rule, { + valid: [ + { + code: `html\`
\``, + }, + { + code: `html\`
\``, + }, + { + code: `html\`
\``, + options: [ + { + max: 2, + }, + ], + }, + { + code: `html\` +
+
\`; + `, + options: [ + { + max: 2, + }, + ], + }, + { + code: ` + html\` +
+
+ \${html\`
\`} +
+
\` + `, + options: [ + { + max: 2, + }, + ], + }, + ], + invalid: [ + { + code: `html\`
\``, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + { + code: `html\` +
+
+
\`; + `, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + { + code: `html\` +
+
+
+
\` + `, + options: [ + { + max: 3, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 3, but found 4", + }, + { + message: + "Expected the depth of nested elements to be <= 3, but found 4", + }, + ], + }, + { + code: ` + html\` +
+
+ \${html\`
\`} +
+
\` + `, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + { + code: ` + html\` +
+
+ \${html\`
\`} + \${html\`
\`} + \${html\`
\`} +
+
\` + `, + options: [ + { + max: 2, + }, + ], + errors: [ + { + message: + "Expected the depth of nested elements to be <= 2, but found 3", + }, + ], + }, + ], +});