Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"cout",
"nodownload",
"nofullscreen",
"controlslist"
"controlslist",
"describedby"
]
}
32 changes: 30 additions & 2 deletions docs/rules/sort-attrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@ Examples of **correct** code for this rule:
```ts
//...
"@html-eslint/sort-attrs": ["error", {
"priority": Array<string>
"priority": Array<string | {pattern: string}>
}]
```

#### priority

This option allows you to set an array of attributes keyㄴ.
This option allows you to set an array of attribute names or pattern objects.
When `priority` is defined, the specified attributes are sorted to the front with the highest priority.

Priority items can be:

- Strings: exact attribute name matches
- Objects with `pattern` property: regular expression patterns to match attribute names

The default value of `priority` is `["id", "type", "class", "style"]`.

Examples of **incorrect** code for this rule with the default options (`{ "priority": ["id", "type", "class", "style"] }`).
Expand Down Expand Up @@ -71,3 +76,26 @@ Examples of **correct** code for this rule with the `{ "priority": ["id", "style
```html,correct
<div id="foo" style="color:red" onclick="foo"></div>
```

##### Pattern Support

You can use regular expressions in priority items using the pattern object format:

Examples of **correct** code for this rule with the `{ "priority": ["id", { "pattern": "data-.*" }, "style"] }` option:

```html,correct
<div id="foo" data-test="value" data-custom="attr" style="color:red" onclick="foo"></div>
```

In this example:

- `id` has the highest priority (position 0)
- Any attribute matching `data-.*` pattern has the second priority (position 1)
- `style` has the third priority (position 2)
- All other attributes are sorted alphabetically after the priority items

Examples of **incorrect** code for this rule with the `{ "priority": ["id", { "pattern": "data-.*" }, "style"] }` option:

```html,incorrect
<div style="color:red" data-test="value" id="foo" onclick="foo"></div>
```
68 changes: 61 additions & 7 deletions packages/eslint-plugin/lib/rules/sort-attrs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @import {RuleFixer, RuleModule} from "../types";
*
* @typedef {Object} Option
* @property {string[]} [Option.priority]
* @property {Array<string | {pattern: string}>} [Option.priority]
*/

const { hasTemplate } = require("./utils/node");
Expand Down Expand Up @@ -37,9 +37,23 @@ module.exports = {
priority: {
type: "array",
items: {
type: "string",
uniqueItems: true,
oneOf: [
{
type: "string",
},
{
type: "object",
properties: {
pattern: {
type: "string",
},
},
required: ["pattern"],
additionalProperties: false,
},
],
},
uniqueItems: true,
},
},
additionalProperties: false,
Expand All @@ -55,9 +69,49 @@ module.exports = {
priority: ["id", "type", "class", "style"],
};
/**
* @type {string[]}
* @type {Array<string | {pattern: string, regex: RegExp}>}
*/
const priority = option.priority || [];
const priority = (option.priority || []).map((item) => {
if (item && typeof item === "object" && "pattern" in item) {
return {
...item,
regex: new RegExp(item.pattern, "u"),
Comment on lines +76 to +78
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating RegExp objects during rule execution can impact performance. Consider validating and compiling patterns once during rule initialization or caching compiled regexes to avoid repeated compilation for the same patterns.

Suggested change
return {
...item,
regex: new RegExp(item.pattern, "u"),
let regex = regexCache.get(item.pattern);
if (!regex) {
regex = new RegExp(item.pattern, "u");
regexCache.set(item.pattern, regex);
}
return {
...item,
regex,

Copilot uses AI. Check for mistakes.
};
}
return item;
});

/**
* @param {string} attrName
* @param {string | {pattern: string, regex: RegExp}} priorityItem
* @returns {boolean}
*/
function matchesPriority(attrName, priorityItem) {
if (typeof priorityItem === "string") {
return attrName === priorityItem;
}
if (
priorityItem &&
typeof priorityItem === "object" &&
priorityItem.regex
) {
return priorityItem.regex.test(attrName);
}
return false;
}

/**
* @param {string} attrName
* @returns {number}
*/
function getPriorityIndex(attrName) {
for (let i = 0; i < priority.length; i++) {
if (matchesPriority(attrName, priority[i])) {
return i;
}
}
return -1;
}

/**
* @param {Attribute} attrA
Expand All @@ -68,8 +122,8 @@ module.exports = {
const keyA = attrA.key.value;
const keyB = attrB.key.value;

const keyAReservedValue = priority.indexOf(keyA);
const keyBReservedValue = priority.indexOf(keyB);
const keyAReservedValue = getPriorityIndex(keyA);
const keyBReservedValue = getPriorityIndex(keyB);
if (keyAReservedValue >= 0 && keyBReservedValue >= 0) {
return keyAReservedValue - keyBReservedValue;
} else if (keyAReservedValue >= 0) {
Expand Down
171 changes: 171 additions & 0 deletions packages/eslint-plugin/tests/rules/sort-attrs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ ruleTester.run("sort-attrs", rule, {
},
},
},
// Pattern tests
{
code: '<div id="foo" data-test="value" data-custom="attr" style="color:red" onclick="foo"></div>',
options: [
{
priority: ["id", { pattern: "data-.*" }, "style"],
},
],
},
{
code: '<input id="foo" aria-label="test" aria-describedby="desc" type="text" value="test" />',
options: [
{
priority: ["id", { pattern: "aria-.*" }, "type"],
},
],
},
{
code: '<button id="btn" ng-click="handler" ng-if="visible" class="button" type="submit"></button>',
options: [
{
priority: ["id", { pattern: "ng-.*" }, "class", "type"],
},
],
},
{
code: '<div id="foo" data-test="value" data-custom="attr" aria-label="test" aria-describedby="desc" class="button" onclick="foo"></div>',
options: [
{
priority: [
"id",
{ pattern: "data-.*" },
{ pattern: "aria-.*" },
"class",
],
},
],
},
{
code: `
<button id="nice"
Expand Down Expand Up @@ -272,6 +310,107 @@ ruleTester.run("sort-attrs", rule, {
},
],
},
// Pattern invalid tests
{
code: '<div style="color:red" data-test="value" id="foo" onclick="foo"></div>',
output:
'<div id="foo" data-test="value" style="color:red" onclick="foo"></div>',
options: [
{
priority: ["id", { pattern: "data-.*" }, "style"],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<div data-custom="attr" data-test="value" id="foo" style="color:red" onclick="foo"></div>',
output:
'<div id="foo" data-custom="attr" data-test="value" style="color:red" onclick="foo"></div>',
options: [
{
priority: ["id", { pattern: "data-.*" }, "style"],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<input type="text" aria-describedby="desc" id="foo" aria-label="test" value="test" />',
output:
'<input id="foo" aria-describedby="desc" aria-label="test" type="text" value="test" />',
options: [
{
priority: ["id", { pattern: "aria-.*" }, "type"],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<button class="button" ng-if="visible" id="btn" ng-click="handler" type="submit"></button>',
output:
'<button id="btn" ng-if="visible" ng-click="handler" class="button" type="submit"></button>',
options: [
{
priority: ["id", { pattern: "ng-.*" }, "class", "type"],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<div v-model="data" v-if="show" id="container" v-on:click="handler" class="wrapper"></div>',
output:
'<div id="container" v-model="data" v-if="show" v-on:click="handler" class="wrapper"></div>',
options: [
{
priority: ["id", { pattern: "v-.*" }],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<div data-value="2" custom="test" data-id="1" id="foo" data-name="bar"></div>',
output:
'<div id="foo" data-value="2" data-id="1" data-name="bar" custom="test"></div>',
options: [
{
priority: ["id", { pattern: "data-.*" }],
},
],
errors: [{ messageId: "unsorted" }],
},
// Multiple patterns invalid tests
{
code: '<div aria-label="test" data-custom="attr" id="foo" ng-click="handler" class="button" data-test="value" aria-describedby="desc" onclick="foo"></div>',
output:
'<div id="foo" data-custom="attr" data-test="value" aria-label="test" aria-describedby="desc" class="button" ng-click="handler" onclick="foo"></div>',
options: [
{
priority: [
"id",
{ pattern: "data-.*" },
{ pattern: "aria-.*" },
"class",
],
},
],
errors: [{ messageId: "unsorted" }],
},
{
code: '<input v-model="value" type="text" data-id="123" aria-required="true" id="input" v-if="show" aria-label="Input" data-name="test"></input>',
output:
'<input id="input" data-id="123" data-name="test" aria-required="true" aria-label="Input" v-model="value" v-if="show" type="text"></input>',
options: [
{
priority: [
"id",
{ pattern: "data-.*" },
{ pattern: "aria-.*" },
{ pattern: "v-.*" },
"type",
],
},
],
errors: [{ messageId: "unsorted" }],
},
],
});

Expand All @@ -280,6 +419,27 @@ templateRuleTester.run("[template] sort-attrs", rule, {
{
code: 'html`<input id="foo" type="checkbox" autocomplete="bar" checked />`',
},
{
code: 'html`<div id="foo" data-test="value" data-custom="attr" style="color:red" onclick="foo"></div>`',
options: [
{
priority: ["id", { pattern: "data-.*" }, "style"],
},
],
},
{
code: 'html`<div id="foo" data-test="value" data-custom="attr" aria-label="test" aria-describedby="desc" class="button" onclick="foo"></div>`',
options: [
{
priority: [
"id",
{ pattern: "data-.*" },
{ pattern: "aria-.*" },
"class",
],
},
],
},
],
invalid: [
{
Expand Down Expand Up @@ -346,5 +506,16 @@ templateRuleTester.run("[template] sort-attrs", rule, {
},
],
},
{
code: 'html`<div style="color:red" data-test="value" id="foo" onclick="foo"></div>`',
output:
'html`<div id="foo" data-test="value" style="color:red" onclick="foo"></div>`',
options: [
{
priority: ["id", { pattern: "data-.*" }, "style"],
},
],
errors: [{ messageId: "unsorted" }],
},
],
});