From a02cd0e5ce6c3b6d647851a0e57a7ef4e4d6da34 Mon Sep 17 00:00:00 2001 From: Peter Stenger Date: Wed, 2 Jul 2025 13:33:33 -0500 Subject: [PATCH 1/3] Initial commit --- docs/rules.md | 1 + docs/rules/group-attrs.md | 148 +++++++++++ .../eslint-plugin/lib/rules/group-attrs.js | 232 +++++++++++++++++ packages/eslint-plugin/lib/rules/index.js | 2 + .../tests/rules/group-attrs.test.js | 244 ++++++++++++++++++ 5 files changed, 627 insertions(+) create mode 100644 docs/rules/group-attrs.md create mode 100644 packages/eslint-plugin/lib/rules/group-attrs.js create mode 100644 packages/eslint-plugin/tests/rules/group-attrs.test.js diff --git a/docs/rules.md b/docs/rules.md index bd657d78..52996b1c 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -68,6 +68,7 @@ | -------------------------------------------------------- | ----------------------------------------------------------------- | ---- | | [attrs-newline](rules/attrs-newline) | Enforce newline between attributes | ⭐🔧 | | [element-newline](rules/element-newline) | Enforce newline between elements. | ⭐🔧 | +| [group-attrs](rules/group-attrs) | Enforce grouping and ordering of related attributes | 🔧 | | [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. | 🔧 | diff --git a/docs/rules/group-attrs.md b/docs/rules/group-attrs.md new file mode 100644 index 00000000..1b365ea5 --- /dev/null +++ b/docs/rules/group-attrs.md @@ -0,0 +1,148 @@ +# group-attrs + +Enforce grouping and ordering of related attributes. + +## How to use + +```js,.eslintrc.js +module.exports = { + rules: { + "@html-eslint/group-attrs": "error", + }, +}; +``` + +## Rule Details + +This rule ensures that related attributes are placed next to each other when multiple attributes from the same group are present on an element. It also ensures that attributes are in the correct order within their group. + +If only some attributes from a group are present (partial groups), they are still grouped together. For example, if you have a group `["left", "top", "right", "bottom"]` and only `left` and `top` are present, they should be adjacent to each other. + +Examples of **incorrect** code for this rule: + +```html + + + + +Image + + + + + +
+ + +
+ + +
+``` + +Examples of **correct** code for this rule: + +```html + + + + +Image + + + + + +
+ + +
+ + +
+ + + + + + +``` + +### Options + +This rule accepts an options object with the following properties: + +- `groups`: Array of attribute groups that should be kept together and in order. + +```ts +//... +"@html-eslint/group-attrs": ["error", { + "groups": Array> +}] +``` + +#### groups + +Default: + +```js +[ + ["min", "max"], + ["minlength", "maxlength"], + ["width", "height"], + ["aria-labelledby", "aria-describedby"], + ["data-min", "data-max"], + ["left", "top", "right", "bottom"] +] +``` + +Defines arrays of attribute names that should be grouped together when present. + +Example configuration: + +```js,.eslintrc.js +module.exports = { + rules: { + "@html-eslint/group-attrs": [ + "error", + { + groups: [ + ["min", "max"], + ["data-start", "data-end"], + ["x", "y"], + ["left", "top", "right", "bottom"] + ] + } + ], + }, +}; +``` + +With this configuration: + +```html + +
+ + +
+ + + + + + + + +
+ + +
+``` + +## When Not To Use It + +You might want to disable this rule if: + +- You have `@html-eslint/sort-attrs` enabled +- You have specific semantic groupings that don't align with the default groups \ No newline at end of file diff --git a/packages/eslint-plugin/lib/rules/group-attrs.js b/packages/eslint-plugin/lib/rules/group-attrs.js new file mode 100644 index 00000000..01c988db --- /dev/null +++ b/packages/eslint-plugin/lib/rules/group-attrs.js @@ -0,0 +1,232 @@ +/** + * @import {Attribute} from "@html-eslint/types"; + * @import {RuleModule} from "../types"; + * + * @typedef {Object} Option + * @property {string[][]} [Option.groups] - Array of attribute groups that should be kept together and in order + */ + +const { RULE_CATEGORY } = require("../constants"); +const { createVisitors } = require("./utils/visitors"); +const { getRuleUrl } = require("./utils/rule"); +const { getSourceCode } = require("./utils/source-code"); + +const MESSAGE_IDS = { + NOT_GROUPED: "notGrouped", + WRONG_ORDER: "wrongOrder", +}; + +/** + * Default attribute groups that are commonly related. + * When multiple attributes from the same group are present, + * they should be placed next to each other in the specified order. + */ +const DEFAULT_GROUPS = [ + ["min", "max"], + ["minlength", "maxlength"], + ["width", "height"], + ["aria-labelledby", "aria-describedby"], + ["data-min", "data-max"], + ["left", "top", "right", "bottom"], +]; + +/** + * @type {RuleModule<[Option]>} + */ +module.exports = { + meta: { + type: "code", + + docs: { + description: "Enforce grouping and ordering of related attributes", + category: RULE_CATEGORY.STYLE, + recommended: false, + url: getRuleUrl("group-attrs"), + }, + + fixable: "code", + schema: [ + { + type: "object", + properties: { + groups: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + minItems: 2, + }, + }, + }, + additionalProperties: false, + }, + ], + messages: { + [MESSAGE_IDS.NOT_GROUPED]: + "Related attributes should be grouped together: {{attrs}} (found separated by: {{separator}})", + [MESSAGE_IDS.WRONG_ORDER]: + "Related attributes should be in the correct order: {{attrs}} (expected order: {{expectedOrder}})", + }, + }, + + create(context) { + const options = context.options[0] || {}; + const groups = options.groups || DEFAULT_GROUPS; + + /** + * @param {Attribute[]} attributes + */ + function checkAttributes(attributes) { + if (attributes.length <= 1) { + return; + } + + for (const group of groups) { + /** @type {string[]} */ + const foundAttrs = []; + /** @type {number[]} */ + const indices = []; + + // Find which attributes from this group are present + for (let i = 0; i < attributes.length; i++) { + const attr = attributes[i]; + if (group.includes(attr.key.value)) { + foundAttrs.push(attr.key.value); + indices.push(i); + } + } + + // If we have multiple attributes from this group, check violations + if (foundAttrs.length > 1) { + const firstIndex = indices[0]; + const lastIndex = indices[indices.length - 1]; + + // Check if indices are consecutive + const isConsecutive = indices.every((index, i) => + i === 0 || index === indices[i - 1] + 1 + ); + + if (!isConsecutive) { + // Grouping violation: attributes are not together + const separatorAttrs = []; + for (let i = firstIndex + 1; i < lastIndex; i++) { + if (!indices.includes(i)) { + separatorAttrs.push(attributes[i].key.value); + } + } + + context.report({ + loc: { + start: attributes[firstIndex].loc.start, + end: attributes[lastIndex].loc.end, + }, + messageId: MESSAGE_IDS.NOT_GROUPED, + data: { + attrs: foundAttrs.join(", "), + separator: separatorAttrs.join(", "), + }, + fix(fixer) { + return fixAttributeOrder(fixer, attributes, indices, group, foundAttrs); + }, + }); + return; // Only report one violation at a time + } else { + // Check ordering violation: attributes are together but in wrong order + const expectedOrder = group.filter(attr => foundAttrs.includes(attr)); + const isCorrectOrder = foundAttrs.every((attr, i) => attr === expectedOrder[i]); + + if (!isCorrectOrder) { + context.report({ + loc: { + start: attributes[firstIndex].loc.start, + end: attributes[lastIndex].loc.end, + }, + messageId: MESSAGE_IDS.WRONG_ORDER, + data: { + attrs: foundAttrs.join(", "), + expectedOrder: expectedOrder.join(", "), + }, + fix(fixer) { + return fixAttributeOrder(fixer, attributes, indices, group, foundAttrs); + }, + }); + return; // Only report one violation at a time + } + } + } + } + } + + /** + * Fix attribute order by grouping and sorting them correctly + * @param {*} fixer + * @param {Attribute[]} attributes + * @param {number[]} indices + * @param {string[]} group + * @param {string[]} foundAttrs + */ + function fixAttributeOrder(fixer, attributes, indices, group, foundAttrs) { + const sourceCode = getSourceCode(context); + const source = sourceCode.getText(); + + // Get the attributes that need to be reordered + const attrsToReorder = indices.map(i => attributes[i]); + + // Sort them according to the group order + const expectedOrder = group.filter(attr => foundAttrs.includes(attr)); + attrsToReorder.sort((a, b) => { + const aIndex = expectedOrder.indexOf(a.key.value); + const bIndex = expectedOrder.indexOf(b.key.value); + return aIndex - bIndex; + }); + + // Create a fixed version of all attributes by building the new arrangement + const fixed = []; + let groupInserted = false; + const targetInsertIndex = Math.min(...indices); // Insert grouped attrs at position of first occurrence + + for (let i = 0; i < attributes.length; i++) { + if (i === targetInsertIndex && !groupInserted) { + // Insert the grouped/reordered attributes at the position of first occurrence + fixed.push(...attrsToReorder); + groupInserted = true; + } else if (!indices.includes(i)) { + // Only add non-grouped attributes + fixed.push(attributes[i]); + } + // Skip attributes that are part of the group (they're already inserted above) + } + + // Build the replacement text + let result = ""; + for (let i = 0; i < fixed.length; i++) { + const attr = fixed[i]; + result += source.slice(attr.range[0], attr.range[1]); + + // Add spacing between attributes + if (i < fixed.length - 1) { + result += " "; + } + } + + return fixer.replaceTextRange( + [attributes[0].range[0], attributes[attributes.length - 1].range[1]], + result + ); + } + + return createVisitors(context, { + Tag(node) { + checkAttributes(node.attributes); + }, + ScriptTag(node) { + checkAttributes(node.attributes); + }, + StyleTag(node) { + checkAttributes(node.attributes); + }, + }); + }, +}; diff --git a/packages/eslint-plugin/lib/rules/index.js b/packages/eslint-plugin/lib/rules/index.js index 1a3113c0..9d8929a6 100644 --- a/packages/eslint-plugin/lib/rules/index.js +++ b/packages/eslint-plugin/lib/rules/index.js @@ -51,6 +51,7 @@ const noDuplicateClass = require("./no-duplicate-class"); const noEmptyHeadings = require("./no-empty-headings"); const noInvalidEntity = require("./no-invalid-entity"); const noDuplicateInHead = require("./no-duplicate-in-head"); +const groupAttrs = require("./group-attrs"); // import new rule here ↑ // DO NOT REMOVE THIS COMMENT @@ -108,6 +109,7 @@ const rules = { "no-empty-headings": noEmptyHeadings, "no-invalid-entity": noInvalidEntity, "no-duplicate-in-head": noDuplicateInHead, + "group-attrs": groupAttrs, // export new rule here ↑ // DO NOT REMOVE THIS COMMENT }; diff --git a/packages/eslint-plugin/tests/rules/group-attrs.test.js b/packages/eslint-plugin/tests/rules/group-attrs.test.js new file mode 100644 index 00000000..f7097d4a --- /dev/null +++ b/packages/eslint-plugin/tests/rules/group-attrs.test.js @@ -0,0 +1,244 @@ +const createRuleTester = require("../rule-tester"); +const rule = require("../../lib/rules/group-attrs"); + +const ruleTester = createRuleTester(); +const templateRuleTester = createRuleTester("espree"); + +ruleTester.run("group-attrs", rule, { + valid: [ + // Basic valid cases + { + code: '', + }, + { + code: '
', + }, + { + code: ' @@ -44,10 +44,10 @@ Examples of **correct** code for this rule: ```html - + -Image +Image @@ -62,10 +62,10 @@ Examples of **correct** code for this rule:
- + - + ``` ### Options @@ -88,12 +88,12 @@ Default: ```js [ ["min", "max"], - ["minlength", "maxlength"], + ["minlength", "maxlength"], ["width", "height"], ["aria-labelledby", "aria-describedby"], ["data-min", "data-max"], - ["left", "top", "right", "bottom"] -] + ["left", "top", "right", "bottom"], +]; ``` Defines arrays of attribute names that should be grouped together when present. @@ -145,4 +145,4 @@ With this configuration: You might want to disable this rule if: - You have `@html-eslint/sort-attrs` enabled -- You have specific semantic groupings that don't align with the default groups \ No newline at end of file +- You have specific semantic groupings that don't align with the default groups diff --git a/packages/eslint-plugin/lib/rules/group-attrs.js b/packages/eslint-plugin/lib/rules/group-attrs.js index 01c988db..d81d9cf9 100644 --- a/packages/eslint-plugin/lib/rules/group-attrs.js +++ b/packages/eslint-plugin/lib/rules/group-attrs.js @@ -23,10 +23,57 @@ const MESSAGE_IDS = { */ const DEFAULT_GROUPS = [ ["min", "max"], + ["low", "high"], + ["src", "srcset", "alt"], + ["cols", "rows"], + ["type", "name", "value"], + ["shadowrootclonable", "shadowrootdelegatesfocus", "shadowrootmode"], + ["formaction", "formmethod", "formtarget"], ["minlength", "maxlength"], + ["aria-valuemin", "aria-valuenow", "aria-valuemax", "aria-valuetext"], + ["aria-rowindex", "aria-colindex"], + ["aria-rowspan", "aria-colspan"], + ["aria-rowcount", "aria-colcount"], ["width", "height"], - ["aria-labelledby", "aria-describedby"], - ["data-min", "data-max"], + ["aria-label", "aria-labelledby", "aria-describedby"], + ["aria-expanded", "aria-haspopup"], + // Group remaining ARIA attributes + [ + "role", + "aria-activedescendant", + "aria-atomic", + "aria-autocomplete", + "aria-busy", + "aria-checked", + "aria-controls", + "aria-current", + "aria-details", + "aria-disabled", + "aria-dropeffect", + "aria-errormessage", + "aria-flowto", + "aria-grabbed", + "aria-hidden", + "aria-invalid", + "aria-keyshortcuts", + "aria-level", + "aria-live", + "aria-modal", + "aria-multiline", + "aria-multiselectable", + "aria-orientation", + "aria-owns", + "aria-placeholder", + "aria-posinset", + "aria-pressed", + "aria-readonly", + "aria-relevant", + "aria-required", + "aria-roledescription", + "aria-selected", + "aria-setsize", + "aria-sort", + ], ["left", "top", "right", "bottom"], ]; @@ -64,7 +111,7 @@ module.exports = { }, ], messages: { - [MESSAGE_IDS.NOT_GROUPED]: + [MESSAGE_IDS.NOT_GROUPED]: "Related attributes should be grouped together: {{attrs}} (found separated by: {{separator}})", [MESSAGE_IDS.WRONG_ORDER]: "Related attributes should be in the correct order: {{attrs}} (expected order: {{expectedOrder}})", @@ -88,7 +135,7 @@ module.exports = { const foundAttrs = []; /** @type {number[]} */ const indices = []; - + // Find which attributes from this group are present for (let i = 0; i < attributes.length; i++) { const attr = attributes[i]; @@ -97,17 +144,17 @@ module.exports = { indices.push(i); } } - + // If we have multiple attributes from this group, check violations if (foundAttrs.length > 1) { const firstIndex = indices[0]; const lastIndex = indices[indices.length - 1]; - + // Check if indices are consecutive - const isConsecutive = indices.every((index, i) => - i === 0 || index === indices[i - 1] + 1 + const isConsecutive = indices.every( + (index, i) => i === 0 || index === indices[i - 1] + 1 ); - + if (!isConsecutive) { // Grouping violation: attributes are not together const separatorAttrs = []; @@ -116,7 +163,7 @@ module.exports = { separatorAttrs.push(attributes[i].key.value); } } - + context.report({ loc: { start: attributes[firstIndex].loc.start, @@ -128,15 +175,25 @@ module.exports = { separator: separatorAttrs.join(", "), }, fix(fixer) { - return fixAttributeOrder(fixer, attributes, indices, group, foundAttrs); + return fixAttributeOrder( + fixer, + attributes, + indices, + group, + foundAttrs + ); }, }); return; // Only report one violation at a time } else { // Check ordering violation: attributes are together but in wrong order - const expectedOrder = group.filter(attr => foundAttrs.includes(attr)); - const isCorrectOrder = foundAttrs.every((attr, i) => attr === expectedOrder[i]); - + const expectedOrder = group.filter((attr) => + foundAttrs.includes(attr) + ); + const isCorrectOrder = foundAttrs.every( + (attr, i) => attr === expectedOrder[i] + ); + if (!isCorrectOrder) { context.report({ loc: { @@ -149,7 +206,13 @@ module.exports = { expectedOrder: expectedOrder.join(", "), }, fix(fixer) { - return fixAttributeOrder(fixer, attributes, indices, group, foundAttrs); + return fixAttributeOrder( + fixer, + attributes, + indices, + group, + foundAttrs + ); }, }); return; // Only report one violation at a time @@ -170,47 +233,47 @@ module.exports = { function fixAttributeOrder(fixer, attributes, indices, group, foundAttrs) { const sourceCode = getSourceCode(context); const source = sourceCode.getText(); - + // Get the attributes that need to be reordered - const attrsToReorder = indices.map(i => attributes[i]); - + const attrsToReorder = indices.map((i) => attributes[i]); + // Sort them according to the group order - const expectedOrder = group.filter(attr => foundAttrs.includes(attr)); + const expectedOrder = group.filter((attr) => foundAttrs.includes(attr)); attrsToReorder.sort((a, b) => { const aIndex = expectedOrder.indexOf(a.key.value); const bIndex = expectedOrder.indexOf(b.key.value); return aIndex - bIndex; }); - - // Create a fixed version of all attributes by building the new arrangement - const fixed = []; - let groupInserted = false; - const targetInsertIndex = Math.min(...indices); // Insert grouped attrs at position of first occurrence - - for (let i = 0; i < attributes.length; i++) { - if (i === targetInsertIndex && !groupInserted) { - // Insert the grouped/reordered attributes at the position of first occurrence - fixed.push(...attrsToReorder); - groupInserted = true; - } else if (!indices.includes(i)) { - // Only add non-grouped attributes - fixed.push(attributes[i]); - } - // Skip attributes that are part of the group (they're already inserted above) - } - + + // Create a fixed version of all attributes by building the new arrangement + const fixed = []; + let groupInserted = false; + const targetInsertIndex = Math.min(...indices); // Insert grouped attrs at position of first occurrence + + for (let i = 0; i < attributes.length; i++) { + if (i === targetInsertIndex && !groupInserted) { + // Insert the grouped/reordered attributes at the position of first occurrence + fixed.push(...attrsToReorder); + groupInserted = true; + } else if (!indices.includes(i)) { + // Only add non-grouped attributes + fixed.push(attributes[i]); + } + // Skip attributes that are part of the group (they're already inserted above) + } + // Build the replacement text let result = ""; for (let i = 0; i < fixed.length; i++) { const attr = fixed[i]; result += source.slice(attr.range[0], attr.range[1]); - + // Add spacing between attributes if (i < fixed.length - 1) { result += " "; } } - + return fixer.replaceTextRange( [attributes[0].range[0], attributes[attributes.length - 1].range[1]], result diff --git a/packages/eslint-plugin/tests/rules/group-attrs.test.js b/packages/eslint-plugin/tests/rules/group-attrs.test.js index f7097d4a..d02ce6ec 100644 --- a/packages/eslint-plugin/tests/rules/group-attrs.test.js +++ b/packages/eslint-plugin/tests/rules/group-attrs.test.js @@ -16,7 +16,7 @@ ruleTester.run("group-attrs", rule, { { code: '