Skip to content

Commit 8ac04f7

Browse files
authored
Merge branch 'master' into autofix/simple-unless
2 parents cf122e9 + b705850 commit 8ac04f7

18 files changed

Lines changed: 1257 additions & 85 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,12 @@ rules in templates can be disabled with eslint directives with mustache or html
256256
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
257257
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
258258
| [template-no-passed-in-event-handlers](docs/rules/template-no-passed-in-event-handlers.md) | disallow passing event handlers directly as component arguments | | | |
259-
| [template-no-positional-data-test-selectors](docs/rules/template-no-positional-data-test-selectors.md) | disallow positional data-test-* params in curly invocations | | | |
259+
| [template-no-positional-data-test-selectors](docs/rules/template-no-positional-data-test-selectors.md) | disallow positional data-test-* params in curly invocations | | 🔧 | |
260260
| [template-no-potential-path-strings](docs/rules/template-no-potential-path-strings.md) | disallow potential path strings in attribute values | | | |
261-
| [template-no-redundant-fn](docs/rules/template-no-redundant-fn.md) | disallow unnecessary usage of (fn) helper | | | |
261+
| [template-no-redundant-fn](docs/rules/template-no-redundant-fn.md) | disallow unnecessary usage of (fn) helper | | 🔧 | |
262262
| [template-no-restricted-invocations](docs/rules/template-no-restricted-invocations.md) | disallow certain components, helpers or modifiers from being used | | | |
263263
| [template-no-splattributes-with-class](docs/rules/template-no-splattributes-with-class.md) | disallow splattributes with class attribute | | | |
264-
| [template-no-this-in-template-only-components](docs/rules/template-no-this-in-template-only-components.md) | disallow this in template-only components (gjs/gts) | | 🔧 | |
264+
| [template-no-this-in-template-only-components](docs/rules/template-no-this-in-template-only-components.md) | disallow this in template-only components | | 🔧 | |
265265
| [template-no-trailing-spaces](docs/rules/template-no-trailing-spaces.md) | disallow trailing whitespace at the end of lines in templates | | 🔧 | |
266266
| [template-no-unavailable-this](docs/rules/template-no-unavailable-this.md) | disallow `this` in templates that are not inside a class or function | | | |
267267
| [template-no-unnecessary-component-helper](docs/rules/template-no-unnecessary-component-helper.md) | disallow unnecessary component helper | | 🔧 | |
@@ -284,7 +284,7 @@ rules in templates can be disabled with eslint directives with mustache or html
284284
| [template-self-closing-void-elements](docs/rules/template-self-closing-void-elements.md) | require self-closing on void elements | | 🔧 | |
285285
| [template-simple-modifiers](docs/rules/template-simple-modifiers.md) | require simple modifier syntax | | | |
286286
| [template-simple-unless](docs/rules/template-simple-unless.md) | require simple conditions in unless blocks | | 🔧 | |
287-
| [template-sort-invocations](docs/rules/template-sort-invocations.md) | require sorted attributes and modifiers | | | |
287+
| [template-sort-invocations](docs/rules/template-sort-invocations.md) | require sorted attributes and modifiers | | 🔧 | |
288288
| [template-splat-attributes-only](docs/rules/template-splat-attributes-only.md) | disallow ...spread other than ...attributes | | | |
289289
| [template-style-concatenation](docs/rules/template-style-concatenation.md) | disallow string concatenation in inline styles | | | |
290290

docs/rules/template-no-positional-data-test-selectors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-no-positional-data-test-selectors
22

3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
35
<!-- end auto-generated rule header -->
46

57
## Motivation

docs/rules/template-no-redundant-fn.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-no-redundant-fn
22

3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
35
<!-- end auto-generated rule header -->
46

57
The `fn` helper can be used to bind arguments to another function. Using it without any arguments is redundant because then the inner function could just be used directly.

docs/rules/template-sort-invocations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-sort-invocations
22

3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
35
<!-- end auto-generated rule header -->
46

57
## Why use it?

lib/rules/template-no-positional-data-test-selectors.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ function checkNode(node, context) {
5050
context.report({
5151
node,
5252
messageId: 'noPositionalDataTest',
53+
fix(fixer) {
54+
// Convert the positional param to a hash pair by appending =true.
55+
// e.g. `data-test-foo` becomes `data-test-foo=true`
56+
const sourceCode = context.sourceCode;
57+
const paramText = sourceCode.getText(param);
58+
return fixer.replaceText(param, `${paramText}=true`);
59+
},
5360
});
5461
return;
5562
}
@@ -66,7 +73,7 @@ module.exports = {
6673
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-positional-data-test-selectors.md',
6774
templateMode: 'both',
6875
},
69-
fixable: null,
76+
fixable: 'code',
7077
schema: [],
7178
messages: {
7279
noPositionalDataTest:

lib/rules/template-no-quoteless-attributes.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,27 @@ module.exports = {
2121
},
2222
},
2323
create(context) {
24+
const sourceCode = context.sourceCode;
2425
return {
2526
GlimmerAttrNode(node) {
26-
// Check if attribute has text value without quotes
27-
if (node.value?.type === 'GlimmerTextNode' && !/^["']/.test(node.value.chars)) {
28-
const sourceCode = context.sourceCode;
29-
const attrText = sourceCode.getText(node);
27+
if (node.value?.type !== 'GlimmerTextNode') {
28+
return;
29+
}
3030

31-
// If value looks unquoted (no = or =value without quotes)
32-
if (/=\s*[^"'{]/.test(attrText)) {
33-
const type = node.name?.startsWith('@') ? 'Argument' : 'Attribute';
34-
context.report({
35-
node,
36-
messageId: 'missing',
37-
data: { type, name: node.name },
38-
fix(fixer) {
39-
const valueText = node.value.chars;
40-
const replacementText = `${node.name}="${valueText}"`;
41-
return fixer.replaceText(node, replacementText);
42-
},
43-
});
44-
}
31+
const valueSource = sourceCode.getText(node.value);
32+
if (valueSource.length === 0 || valueSource[0] === '"' || valueSource[0] === "'") {
33+
return;
4534
}
35+
36+
const type = node.name?.startsWith('@') ? 'Argument' : 'Attribute';
37+
context.report({
38+
node,
39+
messageId: 'missing',
40+
data: { type, name: node.name },
41+
fix(fixer) {
42+
return fixer.replaceText(node, `${node.name}="${node.value.chars}"`);
43+
},
44+
});
4645
},
4746
};
4847
},

lib/rules/template-no-redundant-fn.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module.exports = {
88
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-redundant-fn.md',
99
templateMode: 'both',
1010
},
11-
fixable: null,
11+
fixable: 'code',
1212
schema: [],
1313
messages: {
1414
redundant:
@@ -66,6 +66,13 @@ module.exports = {
6666
data: {
6767
suggestion: paramText,
6868
},
69+
fix(fixer) {
70+
if (node.type === 'GlimmerMustacheStatement') {
71+
return fixer.replaceTextRange(node.range, `{{${paramText}}}`);
72+
}
73+
// GlimmerSubExpression: (fn this.handleClick) → this.handleClick
74+
return fixer.replaceTextRange(node.range, paramText);
75+
},
6976
});
7077
}
7178
}

lib/rules/template-no-this-in-template-only-components.js

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,68 @@
1+
const { existsSync } = require('node:fs');
2+
const path = require('node:path');
3+
4+
const VALID_COMPONENT_EXTENSIONS = ['.js', '.ts'];
5+
const GLIMMER_SCRIPT_EXTENSIONS = ['.gjs', '.gts'];
6+
7+
function componentClassExists(pathWithoutExtension) {
8+
return VALID_COMPONENT_EXTENSIONS.some((ext) => existsSync(pathWithoutExtension + ext));
9+
}
10+
11+
// Port of ember-template-lint's no-this-in-template-only-components#isTemplateOnlyComponent.
12+
// Returns true when the template has no backing component class file on disk.
13+
function isTemplateOnlyComponent(templateFilePath) {
14+
// .gjs/.gts files always have JS context, so they cannot be template-only.
15+
if (GLIMMER_SCRIPT_EXTENSIONS.some((ext) => templateFilePath.endsWith(ext))) {
16+
return false;
17+
}
18+
19+
const allSegments = path.normalize(templateFilePath).split(path.sep);
20+
21+
const appIndex = allSegments.findIndex((seg) => seg === 'app' || seg === 'addon');
22+
if (appIndex === -1) {
23+
// No app/addon directory found — cannot determine layout, don't flag.
24+
return false;
25+
}
26+
// Preserve everything before `app`/`addon` as a prefix so we can rebuild
27+
// absolute class file paths (upstream uses relative paths, which only
28+
// works when the cwd happens to contain `app/`).
29+
const prefix = allSegments.slice(0, appIndex).join(path.sep);
30+
const segments = allSegments.slice(appIndex);
31+
32+
if (segments[1] === 'templates') {
33+
if (segments[2] === 'components') {
34+
// Classic structure: app/templates/components/foo.hbs => app/components/foo.{js,ts}
35+
const moduleName = path.basename(templateFilePath, '.hbs');
36+
const classFilePath = path.join(
37+
prefix,
38+
segments[0],
39+
'components',
40+
...segments.slice(3, -1),
41+
moduleName
42+
);
43+
return !componentClassExists(classFilePath);
44+
}
45+
// Route template — always has a backing controller/route context.
46+
return false;
47+
}
48+
49+
if (segments[1] === 'components') {
50+
// Co-located structure: app/components/foo.hbs => app/components/foo.{js,ts}
51+
const moduleName = path.basename(templateFilePath, '.hbs');
52+
const classFilePath = path.join(path.dirname(templateFilePath), moduleName);
53+
return !componentClassExists(classFilePath);
54+
}
55+
56+
// Unknown layout (e.g. pods) — assume template-only.
57+
return true;
58+
}
59+
160
/** @type {import('eslint').Rule.RuleModule} */
261
module.exports = {
362
meta: {
463
type: 'suggestion',
564
docs: {
6-
description: 'disallow this in template-only components (gjs/gts)',
65+
description: 'disallow this in template-only components',
766
category: 'Best Practices',
867
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-this-in-template-only-components.md',
968
templateMode: 'both',
@@ -40,22 +99,34 @@ module.exports = {
4099
'isDestroyed',
41100
]);
42101

102+
const filename = context.filename;
103+
const isHbs = filename.endsWith('.hbs');
104+
105+
// For .hbs files, check the filesystem for a companion class file.
106+
// If one exists, this is NOT a template-only component — skip entirely.
107+
if (isHbs && !isTemplateOnlyComponent(filename)) {
108+
return {};
109+
}
110+
43111
return {
44112
GlimmerPathExpression(node) {
45-
// Only flag template-only components, not class components.
46-
// Walk ancestors to check if the <template> is inside a class body.
47-
const sourceCode = context.sourceCode;
48-
const ancestors = sourceCode.getAncestors
49-
? sourceCode.getAncestors(node)
50-
: context.getAncestors();
51-
const isInsideClass = ancestors.some(
52-
(ancestor) => ancestor.type === 'ClassBody' || ancestor.type === 'ClassDeclaration'
53-
);
54-
if (isInsideClass) {
55-
return;
113+
// For .gjs/.gts files: walk ancestors to check if <template> is inside a class body.
114+
// If so, the component has a backing class — skip.
115+
// .hbs files are already handled above via the filesystem check.
116+
if (!isHbs) {
117+
const sourceCode = context.sourceCode;
118+
const ancestors = sourceCode.getAncestors
119+
? sourceCode.getAncestors(node)
120+
: context.getAncestors();
121+
const isInsideClass = ancestors.some(
122+
(ancestor) => ancestor.type === 'ClassBody' || ancestor.type === 'ClassDeclaration'
123+
);
124+
if (isInsideClass) {
125+
return;
126+
}
56127
}
57128

58-
// In gjs/gts files with <template> tags, check for this.* usage
129+
// Check for this.* usage
59130
if (node.head?.type === 'ThisHead' && node.tail?.length > 0) {
60131
const original = node.original;
61132
const firstPart = node.tail[0];

lib/rules/template-sort-invocations.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ module.exports = {
99
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-sort-invocations.md',
1010
templateMode: 'both',
1111
},
12-
fixable: null,
12+
fixable: 'code',
1313
schema: [],
1414
messages: {
1515
attributeOrder: '`{{attributeName}}` must appear after `{{expectedAfter}}`',
@@ -26,6 +26,24 @@ module.exports = {
2626
},
2727

2828
create(context) {
29+
const sourceCode = context.sourceCode;
30+
31+
// Glimmer attribute and modifier nodes can have trailing whitespace
32+
// absorbed into their range, so we strip the trailing whitespace from
33+
// each side and re-append it at the end of the swapped output.
34+
function createSwapFix(fixer, nodeA, nodeB) {
35+
const rawA = sourceCode.getText(nodeA);
36+
const rawB = sourceCode.getText(nodeB);
37+
const contentA = rawA.trimEnd();
38+
const contentB = rawB.trimEnd();
39+
const separator = sourceCode.text.slice(nodeA.range[0] + contentA.length, nodeB.range[0]);
40+
const trailing = rawB.slice(contentB.length);
41+
return fixer.replaceTextRange(
42+
[nodeA.range[0], nodeB.range[1]],
43+
contentB + separator + contentA + trailing
44+
);
45+
}
46+
2947
function getAttributeName(node) {
3048
return node.name;
3149
}
@@ -171,6 +189,9 @@ module.exports = {
171189
attributeName: getAttributeName(attributes[index]),
172190
expectedAfter: getAttributeName(attributes[index + 1]),
173191
},
192+
fix(fixer) {
193+
return createSwapFix(fixer, attributes[index], attributes[index + 1]);
194+
},
174195
});
175196
}
176197
}
@@ -186,13 +207,28 @@ module.exports = {
186207
modifierName: getModifierName(modifiers[index]),
187208
expectedAfter: getModifierName(modifiers[index + 1]),
188209
},
210+
fix(fixer) {
211+
return createSwapFix(fixer, modifiers[index], modifiers[index + 1]);
212+
},
189213
});
190214
}
191215
}
192216

193217
if (!canSkipSplattributesLast(node)) {
194218
const splattributes = attributes.at(-1);
195219

220+
// Swap ...attributes past the first modifier that appears after it;
221+
// ESLint's fix loop continues until splattributes is fully sorted.
222+
// canSkipSplattributesLast guarantees at least one such modifier exists.
223+
const firstModifierAfter = modifiers.find(
224+
(mod) =>
225+
mod.loc.start.line > splattributes.loc.start.line ||
226+
(mod.loc.start.line === splattributes.loc.start.line &&
227+
mod.loc.start.column > splattributes.loc.start.column)
228+
);
229+
230+
const splatFixFn = (fixer) => createSwapFix(fixer, splattributes, firstModifierAfter);
231+
196232
// When ...attributes is the only attribute, report as attributeOrder
197233
// (the ordering issue is that ...attributes should appear after modifiers)
198234
if (attributes.length === 1) {
@@ -203,11 +239,13 @@ module.exports = {
203239
attributeName: '...attributes',
204240
expectedAfter: 'modifiers',
205241
},
242+
fix: splatFixFn,
206243
});
207244
} else {
208245
context.report({
209246
node: splattributes,
210247
messageId: 'splattributesOrder',
248+
fix: splatFixFn,
211249
});
212250
}
213251
}
@@ -225,6 +263,9 @@ module.exports = {
225263
hashPairName: getHashPairName(node.hash.pairs[index]),
226264
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
227265
},
266+
fix(fixer) {
267+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
268+
},
228269
});
229270
}
230271
}
@@ -244,6 +285,10 @@ module.exports = {
244285
node.params.length > 0 &&
245286
node.params[0].type === 'GlimmerStringLiteral';
246287

288+
const fixFn = function (fixer) {
289+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
290+
};
291+
247292
if (isComponentInvocation) {
248293
context.report({
249294
node: node.hash.pairs[index],
@@ -252,6 +297,7 @@ module.exports = {
252297
attributeName: getHashPairName(node.hash.pairs[index]),
253298
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
254299
},
300+
fix: fixFn,
255301
});
256302
} else {
257303
context.report({
@@ -261,6 +307,7 @@ module.exports = {
261307
hashPairName: getHashPairName(node.hash.pairs[index]),
262308
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
263309
},
310+
fix: fixFn,
264311
});
265312
}
266313
}
@@ -279,6 +326,9 @@ module.exports = {
279326
hashPairName: getHashPairName(node.hash.pairs[index]),
280327
expectedAfter: getHashPairName(node.hash.pairs[index + 1]),
281328
},
329+
fix(fixer) {
330+
return createSwapFix(fixer, node.hash.pairs[index], node.hash.pairs[index + 1]);
331+
},
282332
});
283333
}
284334
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Fixture for template-no-this-in-template-only-components rule tests.
2+
// Only needs to exist on disk; the rule's filesystem check uses fs.existsSync.
3+
module.exports = {};

0 commit comments

Comments
 (0)