Skip to content

Commit e9cbe4e

Browse files
committed
Extract rule: template-sort-invocations
1 parent 9042859 commit e9cbe4e

4 files changed

Lines changed: 1692 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ rules in templates can be disabled with eslint directives with mustache or html
283283
| [template-self-closing-void-elements](docs/rules/template-self-closing-void-elements.md) | require self-closing on void elements | | 🔧 | |
284284
| [template-simple-modifiers](docs/rules/template-simple-modifiers.md) | require simple modifier syntax | | | |
285285
| [template-simple-unless](docs/rules/template-simple-unless.md) | require simple conditions in unless blocks | | | |
286+
| [template-sort-invocations](docs/rules/template-sort-invocations.md) | require sorted attributes and modifiers | | | |
286287
| [template-splat-attributes-only](docs/rules/template-splat-attributes-only.md) | disallow ...spread other than ...attributes | | | |
287288
| [template-style-concatenation](docs/rules/template-style-concatenation.md) | disallow string concatenation in inline styles | | | |
288289

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ember/template-sort-invocations
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Require sorted attributes and modifiers.
6+
7+
Component attributes and modifiers should be sorted in a consistent order for better readability and maintainability.
8+
9+
## Why use it?
10+
11+
The rule helps you standardize templates:
12+
13+
- Component invocations
14+
- Helper invocations
15+
- Modifier invocations
16+
17+
By sorting things that are order-independent, you can more easily refactor code. In addition, sorting removes style differences, so you can review another person's code more effectively.
18+
19+
> [!TIP]
20+
>
21+
> The `--fix` option for this rule doesn't preserve formatting. You can use `prettier`, [`prettier-plugin-ember-hbs-tag`](https://github.com/ijlee2/prettier-plugin-ember-hbs-tag), and [`prettier-plugin-ember-template-tag`](https://github.com/ember-tooling/prettier-plugin-ember-template-tag) to format templates in `*.hbs`, `hbs` tags, and `<template>` tags, respectively.
22+
23+
## Sorting Order
24+
25+
1. Argument attributes (starting with `@`)
26+
2. Regular attributes
27+
3. `...attributes` splattributes
28+
4. Modifiers
29+
30+
Within each category, attributes are sorted alphabetically.
31+
32+
## Examples
33+
34+
This rule **allows** the following:
35+
36+
```gjs
37+
<template>
38+
<Button
39+
@isDisabled={{true}}
40+
@label='Submit'
41+
class='button'
42+
{{on 'click' @onClick}}
43+
...attributes
44+
/>
45+
</template>
46+
```
47+
48+
## Limitations
49+
50+
It's intended that there are no options for sorting. Alphabetical sort is the simplest for everyone to understand and to apply across different projects. It's also the easiest to maintain.
51+
52+
To better meet your needs, consider creating a plugin for `ember-template-lint`.
53+
54+
## Known issues
55+
56+
1\. If you passed an empty string as an argument's value, it has been replaced with `{{""}}`. Let [`ember-template-lint`](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-unnecessary-curly-strings.md) fix the formatting change.
57+
58+
```diff
59+
- <MyComponent @description={{""}} />
60+
+ <MyComponent @description="" />
61+
```
62+
63+
2\. Comments such as `{{! @glint-expect-error }}` may have shifted. Move them to the correct location.
64+
65+
## References
66+
67+
- [Ember.js Guides - Component Syntax](https://guides.emberjs.com/release/components/component-syntax-and-arguments/)
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/* eslint-disable unicorn/consistent-function-scoping, unicorn/prefer-at */
2+
/** @type {import('eslint').Rule.RuleModule} */
3+
module.exports = {
4+
meta: {
5+
type: 'suggestion',
6+
docs: {
7+
description: 'require sorted attributes and modifiers',
8+
category: 'Best Practices',
9+
recommended: false,
10+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-sort-invocations.md',
11+
templateMode: 'both',
12+
},
13+
fixable: null,
14+
schema: [],
15+
messages: {
16+
attributeOrder: '`{{attributeName}}` must appear after `{{expectedAfter}}`',
17+
modifierOrder: '`{{{{modifierName}}}}` must appear after `{{{{expectedAfter}}}}`',
18+
hashPairOrder: '`{{hashPairName}}` must appear after `{{expectedAfter}}`',
19+
splattributesOrder: '`...attributes` must appear after modifiers',
20+
},
21+
originallyFrom: {
22+
name: 'ember-template-lint',
23+
rule: 'lib/rules/sort-invocations.js',
24+
docs: 'docs/rule/sort-invocations.md',
25+
tests: 'test/unit/rules/sort-invocations-test.js',
26+
},
27+
},
28+
29+
create(context) {
30+
let reported = false;
31+
32+
function getAttributeName(node) {
33+
return node.name;
34+
}
35+
36+
function getAttributePosition(node) {
37+
const name = getAttributeName(node);
38+
39+
if (name.startsWith('@')) {
40+
return 1; // Arguments first
41+
}
42+
43+
if (name === '...attributes') {
44+
return 3; // Splattributes last
45+
}
46+
47+
return 2; // Regular attributes in the middle
48+
}
49+
50+
function getHashPairName(node) {
51+
return node.key;
52+
}
53+
54+
function getModifierName(node) {
55+
if (node.path.type !== 'GlimmerPathExpression') {
56+
return '';
57+
}
58+
59+
return node.path.original;
60+
}
61+
62+
function compareAttributes(a, b) {
63+
const positionA = getAttributePosition(a);
64+
const positionB = getAttributePosition(b);
65+
66+
if (positionA !== positionB) {
67+
return positionA - positionB;
68+
}
69+
70+
const nameA = getAttributeName(a);
71+
const nameB = getAttributeName(b);
72+
73+
return nameA.localeCompare(nameB);
74+
}
75+
76+
function compareHashPairs(a, b) {
77+
const nameA = getHashPairName(a);
78+
const nameB = getHashPairName(b);
79+
80+
return nameA.localeCompare(nameB);
81+
}
82+
83+
function compareModifiers(a, b) {
84+
const nameA = getModifierName(a);
85+
const nameB = getModifierName(b);
86+
87+
if (nameA !== nameB) {
88+
return nameA.localeCompare(nameB);
89+
}
90+
91+
// For 'on' modifiers, sort by event name
92+
if (nameA === 'on' && a.params && b.params && a.params.length > 0 && b.params.length > 0) {
93+
const eventA = a.params[0];
94+
const eventB = b.params[0];
95+
96+
if (eventA.type === 'GlimmerStringLiteral' && eventB.type === 'GlimmerStringLiteral') {
97+
return eventA.value.localeCompare(eventB.value);
98+
}
99+
}
100+
101+
return 0;
102+
}
103+
104+
function getUnsortedAttributeIndex(attributes) {
105+
return attributes.findIndex((attribute, index) => {
106+
if (index === attributes.length - 1) {
107+
return false;
108+
}
109+
110+
return compareAttributes(attribute, attributes[index + 1]) > 0;
111+
});
112+
}
113+
114+
function getUnsortedHashPairIndex(pairs) {
115+
return pairs.findIndex((hashPair, index) => {
116+
if (index === pairs.length - 1) {
117+
return false;
118+
}
119+
120+
return compareHashPairs(hashPair, pairs[index + 1]) > 0;
121+
});
122+
}
123+
124+
function getUnsortedModifierIndex(modifiers) {
125+
return modifiers.findIndex((modifier, index) => {
126+
if (index === modifiers.length - 1) {
127+
return false;
128+
}
129+
130+
return compareModifiers(modifier, modifiers[index + 1]) > 0;
131+
});
132+
}
133+
134+
function canSkipSplattributesLast(node) {
135+
const { attributes, modifiers } = node;
136+
137+
if (!attributes || attributes.length === 0 || !modifiers || modifiers.length === 0) {
138+
return true;
139+
}
140+
141+
const splattributes = attributes.at(-1);
142+
const lastModifier = modifiers.at(-1);
143+
144+
if (!splattributes || splattributes.name !== '...attributes' || !lastModifier) {
145+
return true;
146+
}
147+
148+
// Check that ...attributes appears after the last modifier
149+
const splattributesPosition = splattributes.loc.start;
150+
const lastModifierPosition = lastModifier.loc.start;
151+
152+
if (splattributesPosition.line > lastModifierPosition.line) {
153+
return true;
154+
}
155+
156+
return (
157+
splattributesPosition.line === lastModifierPosition.line &&
158+
splattributesPosition.column > lastModifierPosition.column
159+
);
160+
}
161+
162+
return {
163+
GlimmerElementNode(node) {
164+
const { attributes, modifiers } = node;
165+
166+
if (!reported && attributes && attributes.length > 1) {
167+
const index = getUnsortedAttributeIndex(attributes);
168+
169+
if (index !== -1) {
170+
reported = true;
171+
context.report({
172+
node: attributes[index],
173+
messageId: 'attributeOrder',
174+
data: {
175+
attributeName: getAttributeName(attributes[index]),
176+
expectedAfter: getAttributeName(attributes[index + 1]),
177+
},
178+
});
179+
}
180+
}
181+
182+
if (!reported && modifiers && modifiers.length > 1) {
183+
const index = getUnsortedModifierIndex(modifiers);
184+
185+
if (index !== -1) {
186+
reported = true;
187+
context.report({
188+
node: modifiers[index],
189+
messageId: 'modifierOrder',
190+
data: {
191+
modifierName: getModifierName(modifiers[index]),
192+
expectedAfter: getModifierName(modifiers[index + 1]),
193+
},
194+
});
195+
}
196+
}
197+
198+
if (!reported && !canSkipSplattributesLast(node)) {
199+
const splattributes = attributes.at(-1);
200+
reported = true;
201+
202+
// When ...attributes is the only attribute, report as attributeOrder
203+
// (the ordering issue is that ...attributes should appear after modifiers)
204+
if (attributes.length === 1) {
205+
context.report({
206+
node: splattributes,
207+
messageId: 'attributeOrder',
208+
data: {
209+
attributeName: '...attributes',
210+
expectedAfter: 'modifiers',
211+
},
212+
});
213+
} else {
214+
context.report({
215+
node: splattributes,
216+
messageId: 'splattributesOrder',
217+
});
218+
}
219+
}
220+
},
221+
222+
GlimmerBlockStatement(node) {
223+
if (!reported && node.hash && node.hash.pairs && node.hash.pairs.length > 1) {
224+
const index = getUnsortedHashPairIndex(node.hash.pairs);
225+
226+
if (index !== -1) {
227+
reported = true;
228+
context.report({
229+
node: node.hash.pairs[index],
230+
messageId: 'hashPairOrder',
231+
data: {
232+
hashPairName: getHashPairName(node.hash.pairs[index]),
233+
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
234+
},
235+
});
236+
}
237+
}
238+
},
239+
240+
GlimmerMustacheStatement(node) {
241+
if (!reported && node.hash && node.hash.pairs && node.hash.pairs.length > 1) {
242+
const index = getUnsortedHashPairIndex(node.hash.pairs);
243+
244+
if (index !== -1) {
245+
reported = true;
246+
247+
// Component invocations with a string positional param (e.g. {{component "ui/button" ...}})
248+
// treat hash pairs as component attributes
249+
const isComponentInvocation =
250+
node.path &&
251+
node.path.original === 'component' &&
252+
node.params &&
253+
node.params.length > 0 &&
254+
node.params[0].type === 'GlimmerStringLiteral';
255+
256+
if (isComponentInvocation) {
257+
context.report({
258+
node: node.hash.pairs[index],
259+
messageId: 'attributeOrder',
260+
data: {
261+
attributeName: getHashPairName(node.hash.pairs[index]),
262+
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
263+
},
264+
});
265+
} else {
266+
context.report({
267+
node: node.hash.pairs[index],
268+
messageId: 'hashPairOrder',
269+
data: {
270+
hashPairName: getHashPairName(node.hash.pairs[index]),
271+
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
272+
},
273+
});
274+
}
275+
}
276+
}
277+
},
278+
279+
GlimmerSubExpression(node) {
280+
if (!reported && node.hash && node.hash.pairs && node.hash.pairs.length > 1) {
281+
const index = getUnsortedHashPairIndex(node.hash.pairs);
282+
283+
if (index !== -1) {
284+
reported = true;
285+
context.report({
286+
node: node.hash.pairs[index],
287+
messageId: 'hashPairOrder',
288+
data: {
289+
hashPairName: getHashPairName(node.hash.pairs[index]),
290+
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
291+
},
292+
});
293+
}
294+
}
295+
},
296+
};
297+
},
298+
};
299+
/* eslint-enable unicorn/consistent-function-scoping, unicorn/prefer-at */

0 commit comments

Comments
 (0)