diff --git a/docs/rules/index.md b/docs/rules/index.md
index 19f4b12b..1cc264b8 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -12,6 +12,7 @@ description: ESLint Plugin Perfectionist list of rules
| Name | Description | 🔧 |
| :------------------------------------------------------ | :------------------------------------------ | :- |
| [sort-array-includes](/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 |
+| [sort-astro-attributes](/rules/sort-astro-attributes) | enforce sorted union types | 🔧 |
| [sort-classes](/rules/sort-classes) | enforce sorted classes | 🔧 |
| [sort-enums](/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 |
| [sort-exports](/rules/sort-exports) | enforce sorted exports | 🔧 |
diff --git a/docs/rules/sort-astro-attributes.md b/docs/rules/sort-astro-attributes.md
new file mode 100644
index 00000000..9e466652
--- /dev/null
+++ b/docs/rules/sort-astro-attributes.md
@@ -0,0 +1,108 @@
+---
+title: sort-astro-attributes
+description: ESLint Plugin Perfectionist rule which enforce sorted ES class members
+---
+
+# sort-astro-attributes
+
+💼 This rule is enabled in the following [configs](/configs/): `recommended-alphabetical`, `recommended-line-length`, `recommended-natural`.
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+
+
+## 📖 Rule Details
+
+Enforce sorted attributes in Astro elements.
+
+It's **safe**. The rule considers spread elements in an attributes list and does not break component functionality.
+
+## 🔧 Options
+
+This rule accepts an options object with the following properties:
+
+```ts
+interface Options {
+ type?: 'alphabetical' | 'natural' | 'line-length'
+ order?: 'asc' | 'desc'
+ 'ignore-case'?: boolean
+}
+```
+
+### type
+
+(default: `'alphabetical'`)
+
+- `alphabetical` - sort alphabetically.
+- `natural` - sort in natural order.
+- `line-length` - sort by code line length.
+
+### order
+
+(default: `'asc'`)
+
+- `asc` - enforce properties to be in ascending order.
+- `desc` - enforce properties to be in descending order.
+
+### ignore-case
+
+(default: `false`)
+
+Only affects alphabetical and natural sorting. When `true` the rule ignores the case-sensitivity of the order.
+
+## ⚙️ Usage
+
+In order to start using this rule, you need to install additional dependency:
+
+- `astro-eslint-parser`
+
+::: code-group
+
+```json [Legacy Config]
+// .eslintrc
+{
+ "plugins": ["perfectionist"],
+ "rules": {
+ "perfectionist/sort-astro-attributes": [
+ "error",
+ {
+ "type": "natural",
+ "order": "asc"
+ }
+ ]
+ }
+}
+```
+
+```js [Flat Config]
+// eslint.config.js
+import perfectionist from 'eslint-plugin-perfectionist'
+
+export default [
+ {
+ plugins: {
+ perfectionist,
+ },
+ rules: {
+ 'perfectionist/sort-astro-attributes': [
+ 'error',
+ {
+ type: 'natural',
+ order: 'asc',
+ },
+ ],
+ },
+ },
+]
+```
+
+:::
+
+## 🚀 Version
+
+Coming soon.
+
+## 📚 Resources
+
+- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-astro-attributes.ts)
+- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-astro-attributes.test.ts)
diff --git a/index.ts b/index.ts
index 1cae5941..9ff0adc8 100644
--- a/index.ts
+++ b/index.ts
@@ -1,4 +1,5 @@
import sortSvelteAttributes, { RULE_NAME as sortSvelteAttributesName } from './rules/sort-svelte-attributes'
+import sortAstroAttributes, { RULE_NAME as sortAstroAttributesName } from './rules/sort-astro-attributes'
import sortArrayIncludes, { RULE_NAME as sortArrayIncludesName } from './rules/sort-array-includes'
import sortNamedImports, { RULE_NAME as sortNamedImportsName } from './rules/sort-named-imports'
import sortNamedExports, { RULE_NAME as sortNamedExportsName } from './rules/sort-named-exports'
@@ -92,6 +93,7 @@ let createConfigWithOptions = (options: {
},
],
[sortSvelteAttributesName]: ['error'],
+ [sortAstroAttributesName]: ['error'],
[sortNamedExportsName]: ['error'],
[sortNamedImportsName]: ['error'],
[sortObjectTypesName]: ['error'],
@@ -116,6 +118,7 @@ let createConfigWithOptions = (options: {
export default {
rules: {
[sortArrayIncludesName]: sortArrayIncludes,
+ [sortAstroAttributesName]: sortAstroAttributes,
[sortClassesName]: sortClasses,
[sortEnumsName]: sortEnums,
[sortExportsName]: sortExports,
diff --git a/package.json b/package.json
index be262e3f..ae0e7e17 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,9 @@
"./package.json": "./package.json"
},
"peerDependenciesMeta": {
+ "astro-eslint-parser": {
+ "optional": true
+ },
"svelte": {
"optional": true
},
@@ -59,7 +62,9 @@
"optional": true
}
},
+
"peerDependencies": {
+ "astro-eslint-parser": "^0.14.0",
"eslint": ">=8.0.0",
"svelte": ">=3.0.0",
"svelte-eslint-parser": "^0.32.0"
@@ -81,6 +86,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitest/coverage-v8": "^0.33.0",
+ "astro-eslint-parser": "^0.14.0",
"changelogen": "^0.5.4",
"clean-publish": "^4.2.0",
"eslint": "^8.45.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index af217f1d..eeef8845 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,9 @@ devDependencies:
'@vitest/coverage-v8':
specifier: ^0.33.0
version: 0.33.0(vitest@0.33.0)
+ astro-eslint-parser:
+ specifier: ^0.14.0
+ version: 0.14.0
changelogen:
specifier: ^0.5.4
version: 0.5.4
@@ -273,6 +276,10 @@ packages:
'@jridgewell/trace-mapping': 0.3.18
dev: true
+ /@astrojs/compiler@1.6.1:
+ resolution: {integrity: sha512-QZZhV2kOhvyXEfpaGLgHWNPUOlkMVx3Zw+5IlXXU2p448NwtyYt3C6MuS7baVsTlluH3EedAoDBmelt9hPhfiQ==}
+ dev: true
+
/@azat-io/eslint-config-typescript@1.1.1(@typescript-eslint/eslint-plugin@5.62.0)(@typescript-eslint/parser@5.62.0)(eslint-plugin-import@2.27.5)(eslint-plugin-n@16.0.1)(eslint-plugin-perfectionist@1.5.0)(eslint-plugin-prefer-arrow@1.2.3)(eslint-plugin-prefer-let@3.0.1)(eslint-plugin-promise@6.1.1)(eslint-plugin-sonarjs@0.19.0)(eslint-plugin-unicorn@47.0.0)(eslint-plugin-vitest@0.2.6)(eslint@8.45.0):
resolution: {integrity: sha512-OvN3/IgvxZfWwo7LibT3L+9WEUq1NGi5QVKcKj4x9EyNdOJz6T8CfiYHLsDZXBajHaE1X4/7HC/c9H/E3buKBg==}
peerDependencies:
@@ -1561,6 +1568,32 @@ packages:
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
dev: true
+ /astro-eslint-parser@0.14.0:
+ resolution: {integrity: sha512-3F8l1h7+5MNxzDg1cSQxEloalG7fj64K6vOERChUVG7RLnAzSoafADnPQlU8DpMM3WRNfRHSC4NwUCORk/aPrA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ dependencies:
+ '@astrojs/compiler': 1.6.1
+ '@typescript-eslint/scope-manager': 5.62.0
+ '@typescript-eslint/types': 5.62.0
+ astrojs-compiler-sync: 0.3.3(@astrojs/compiler@1.6.1)
+ debug: 4.3.4
+ eslint-visitor-keys: 3.4.1
+ espree: 9.6.1
+ semver: 7.5.4
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /astrojs-compiler-sync@0.3.3(@astrojs/compiler@1.6.1):
+ resolution: {integrity: sha512-LbhchWgsvjvRBb5n5ez8/Q/f9ZKViuox27VxMDOdTUm8MRv9U7phzOiLue5KluqTmC0z1LId4gY2SekvoDrkuw==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ '@astrojs/compiler': '>=0.27.0'
+ dependencies:
+ '@astrojs/compiler': 1.6.1
+ synckit: 0.8.5
+ dev: true
+
/available-typed-arrays@1.0.5:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
diff --git a/readme.md b/readme.md
index beda31ca..427746c9 100644
--- a/readme.md
+++ b/readme.md
@@ -141,6 +141,7 @@ export default [
| Name | Description | 🔧 |
| :------------------------------------------------------------------------------------------------- | :------------------------------------------ | :- |
| [sort-array-includes](https://eslint-plugin-perfectionist.azat.io/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 |
+| [sort-astro-attributes](https://eslint-plugin-perfectionist.azat.io/rules/sort-astro-attributes) | enforce sorted union types | 🔧 |
| [sort-classes](https://eslint-plugin-perfectionist.azat.io/rules/sort-classes) | enforce sorted classes | 🔧 |
| [sort-enums](https://eslint-plugin-perfectionist.azat.io/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 |
| [sort-exports](https://eslint-plugin-perfectionist.azat.io/rules/sort-exports) | enforce sorted exports | 🔧 |
diff --git a/rules/sort-astro-attributes.ts b/rules/sort-astro-attributes.ts
new file mode 100644
index 00000000..85f589fd
--- /dev/null
+++ b/rules/sort-astro-attributes.ts
@@ -0,0 +1,133 @@
+import type { TSESTree } from '@typescript-eslint/types'
+import type { AST } from 'astro-eslint-parser'
+
+import path from 'path'
+
+import type { SortingNode } from '../typings'
+
+import { createEslintRule } from '../utils/create-eslint-rule'
+import { rangeToDiff } from '../utils/range-to-diff'
+import { SortOrder, SortType } from '../typings'
+import { makeFixes } from '../utils/make-fixes'
+import { sortNodes } from '../utils/sort-nodes'
+import { pairwise } from '../utils/pairwise'
+import { complete } from '../utils/complete'
+import { compare } from '../utils/compare'
+
+export const RULE_NAME = 'sort-astro-attributes'
+
+type MESSAGE_ID = 'unexpectedAstroAttributesOrder'
+
+type Options = [
+ Partial<{
+ 'ignore-case': boolean
+ order: SortOrder
+ type: SortType
+ }>,
+]
+
+export default createEslintRule({
+ name: RULE_NAME,
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'enforce sorted union types',
+ recommended: false,
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ type: {
+ enum: [
+ SortType.alphabetical,
+ SortType.natural,
+ SortType['line-length'],
+ ],
+ default: SortType.natural,
+ },
+ order: {
+ enum: [SortOrder.asc, SortOrder.desc],
+ default: SortOrder.asc,
+ },
+ 'ignore-case': {
+ type: 'boolean',
+ default: false,
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ unexpectedAstroAttributesOrder:
+ 'Expected "{{right}}" to come before "{{left}}"',
+ },
+ },
+ defaultOptions: [
+ {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ },
+ ],
+ // @ts-ignore
+ create: context => {
+ if (path.extname(context.getFilename()) !== '.astro') {
+ return {}
+ }
+
+ return {
+ JSXElement: (node: AST.JSXElement) => {
+ let { attributes } = node.openingElement
+
+ if (attributes.length > 1) {
+ let options = complete(context.options.at(0), {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ 'ignore-case': false,
+ })
+
+ let source = context.getSourceCode()
+
+ let parts: SortingNode[][] = attributes.reduce(
+ (accumulator: SortingNode[][], attribute) => {
+ if (attribute.type === 'JSXSpreadAttribute') {
+ accumulator.push([])
+ return accumulator
+ }
+
+ accumulator.at(-1)!.push({
+ size: rangeToDiff(attribute.range),
+ node: attribute as unknown as TSESTree.Node,
+ name:
+ typeof attribute.name.name === 'string'
+ ? attribute.name.name
+ : source.text.slice(...attribute.name.range),
+ })
+
+ return accumulator
+ },
+ [[]],
+ )
+
+ for (let nodes of parts) {
+ pairwise(nodes, (left, right) => {
+ if (compare(left, right, options)) {
+ context.report({
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: left.name,
+ right: right.name,
+ },
+ node: right.node,
+ fix: fixer =>
+ makeFixes(fixer, nodes, sortNodes(nodes, options), source),
+ })
+ }
+ })
+ }
+ }
+ },
+ }
+ },
+})
diff --git a/test/sort-astro-attributes.test.ts b/test/sort-astro-attributes.test.ts
new file mode 100644
index 00000000..4bba147f
--- /dev/null
+++ b/test/sort-astro-attributes.test.ts
@@ -0,0 +1,389 @@
+import { ESLintUtils } from '@typescript-eslint/utils'
+import { describe, it } from 'vitest'
+import { dedent } from 'ts-dedent'
+
+import rule, { RULE_NAME } from '../rules/sort-astro-attributes'
+import { SortOrder, SortType } from '../typings'
+
+describe(RULE_NAME, () => {
+ let ruleTester = new ESLintUtils.RuleTester({
+ // @ts-ignore
+ parser: require.resolve('astro-eslint-parser'),
+ parserOptions: {
+ parser: {
+ ts: '@typescript-eslint/parser',
+ },
+ },
+ })
+
+ describe(`${RULE_NAME}: sorting by alphabetical order`, () => {
+ let type = 'alphabetical-order'
+
+ it(`${RULE_NAME}(${type}): sorts props in astro components`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'partner',
+ right: 'age',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.alphabetical,
+ order: SortOrder.asc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'status',
+ right: 'occupation',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+ })
+
+ describe(`${RULE_NAME}: sorting by natural order`, () => {
+ let type = 'natural-order'
+
+ it(`${RULE_NAME}(${type}): sorts props in astro components`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.natural,
+ order: SortOrder.asc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.natural,
+ order: SortOrder.asc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'partner',
+ right: 'age',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.natural,
+ order: SortOrder.asc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType.natural,
+ order: SortOrder.asc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'status',
+ right: 'occupation',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+ })
+
+ describe(`${RULE_NAME}: sorting by line length`, () => {
+ let type = 'line-length-order'
+
+ it(`${RULE_NAME}(${type}): sorts props in astro components`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType['line-length'],
+ order: SortOrder.desc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType['line-length'],
+ order: SortOrder.desc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'name',
+ right: 'partner',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType['line-length'],
+ order: SortOrder.desc,
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ filename: 'component.astro',
+ code: dedent`
+
+
+ `,
+ output: dedent`
+
+
+ `,
+ options: [
+ {
+ type: SortType['line-length'],
+ order: SortOrder.desc,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedAstroAttributesOrder',
+ data: {
+ left: 'status',
+ right: 'occupation',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+ })
+
+ describe(`${RULE_NAME}: misc`, () => {
+ it(`${RULE_NAME}: works only for .astro files`, () => {
+ ruleTester.run(RULE_NAME, rule, {
+ valid: [
+ dedent`
+
+ `,
+ ],
+ invalid: [],
+ })
+ })
+ })
+})