Skip to content

Commit f71806b

Browse files
Merge pull request #2452 from NullVoxPopuli/nvp/template-lint-extract-rule-template-modifier-name-case
Extract rule: template-modifier-name-case
2 parents 292c12d + 3612eeb commit f71806b

4 files changed

Lines changed: 264 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ rules in templates can be disabled with eslint directives with mustache or html
403403
| [template-block-indentation](docs/rules/template-block-indentation.md) | enforce consistent indentation for block statements and their children | | | |
404404
| [template-eol-last](docs/rules/template-eol-last.md) | require or disallow newline at the end of template files | | 🔧 | |
405405
| [template-linebreak-style](docs/rules/template-linebreak-style.md) | enforce consistent linebreaks in templates | | 🔧 | |
406+
| [template-modifier-name-case](docs/rules/template-modifier-name-case.md) | require dasherized names for modifiers | | 🔧 | |
406407
| [template-no-only-default-slot](docs/rules/template-no-only-default-slot.md) | disallow using only the default slot | | 🔧 | |
407408

408409
### Testing
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ember/template-modifier-name-case
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
> **HBS Only**: This rule applies to classic `.hbs` template files only (loose mode). It is not relevant for `gjs`/`gts` files (strict mode), where these patterns cannot occur.
6+
7+
<!-- end auto-generated rule header -->
8+
9+
Requires dasherized names for modifiers.
10+
11+
Modifiers should use dasherized names when being invoked, not camelCase. This is a stylistic rule that will prevent you from using camelCase modifiers, requiring you to use dasherized modifier names instead.
12+
13+
## Examples
14+
15+
This rule **forbids** the following:
16+
17+
```gjs
18+
<template><div {{didInsert}}></div></template>
19+
```
20+
21+
```gjs
22+
<template><div {{onFocus}}></div></template>
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```gjs
28+
<template><div {{did-insert}}></div></template>
29+
```
30+
31+
```gjs
32+
<template><div {{on-focus}}></div></template>
33+
```
34+
35+
## See Also
36+
37+
- [named-functions-in-promises](named-functions-in-promises.md)
38+
39+
## References
40+
41+
- [Template syntax guide - Modifiers](https://guides.emberjs.com/release/components/template-syntax/#toc_modifiers)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/* eslint-disable unicorn/consistent-function-scoping */
2+
3+
const SIMPLE_DASHERIZE_REGEXP = /[A-Z]/g;
4+
const ALPHA = /[A-Za-z]/;
5+
6+
function dasherize(key) {
7+
return key
8+
.replaceAll(SIMPLE_DASHERIZE_REGEXP, (char, index) => {
9+
if (index === 0 || !ALPHA.test(key[index - 1])) {
10+
return char.toLowerCase();
11+
}
12+
return `-${char.toLowerCase()}`;
13+
})
14+
.replaceAll('::', '/');
15+
}
16+
17+
/** @type {import('eslint').Rule.RuleModule} */
18+
module.exports = {
19+
meta: {
20+
type: 'suggestion',
21+
docs: {
22+
description: 'require dasherized names for modifiers',
23+
category: 'Stylistic Issues',
24+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-modifier-name-case.md',
25+
templateMode: 'loose',
26+
},
27+
fixable: 'code',
28+
schema: [],
29+
messages: {
30+
dasherized:
31+
'Use dasherized names for modifier invocation. Please replace `{{modifierName}}` with `{{dasherizedName}}`.',
32+
},
33+
originallyFrom: {
34+
name: 'ember-template-lint',
35+
rule: 'lib/rules/modifier-name-case.js',
36+
docs: 'docs/rule/modifier-name-case.md',
37+
tests: 'test/unit/rules/modifier-name-case-test.js',
38+
},
39+
},
40+
41+
create(context) {
42+
const filename = context.filename ?? context.getFilename();
43+
if (!filename.endsWith('.hbs')) {
44+
return {};
45+
}
46+
47+
function isModifierHelper(node) {
48+
return (
49+
node.path && node.path.type === 'GlimmerPathExpression' && node.path.original === 'modifier'
50+
);
51+
}
52+
53+
return {
54+
GlimmerElementModifierStatement(node) {
55+
const modifierName = node.path?.original;
56+
57+
if (typeof modifierName === 'string' && modifierName !== dasherize(modifierName)) {
58+
const dasherizedName = dasherize(modifierName);
59+
context.report({
60+
node,
61+
messageId: 'dasherized',
62+
data: { modifierName, dasherizedName },
63+
fix(fixer) {
64+
return fixer.replaceTextRange(node.path.range, dasherizedName);
65+
},
66+
});
67+
}
68+
},
69+
70+
GlimmerSubExpression(node) {
71+
if (!isModifierHelper(node)) {
72+
return;
73+
}
74+
75+
const nameParam = node.params?.[0];
76+
77+
if (nameParam && nameParam.type === 'GlimmerStringLiteral') {
78+
const modifierName = nameParam.value;
79+
80+
if (typeof modifierName === 'string' && modifierName !== dasherize(modifierName)) {
81+
const dasherizedName = dasherize(modifierName);
82+
context.report({
83+
node: nameParam,
84+
messageId: 'dasherized',
85+
data: { modifierName, dasherizedName },
86+
fix(fixer) {
87+
return fixer.replaceTextRange(nameParam.range, `"${dasherizedName}"`);
88+
},
89+
});
90+
}
91+
}
92+
},
93+
94+
GlimmerMustacheStatement(node) {
95+
if (!isModifierHelper(node)) {
96+
return;
97+
}
98+
99+
const nameParam = node.params?.[0];
100+
101+
if (nameParam && nameParam.type === 'GlimmerStringLiteral') {
102+
const modifierName = nameParam.value;
103+
104+
if (typeof modifierName === 'string' && modifierName !== dasherize(modifierName)) {
105+
const dasherizedName = dasherize(modifierName);
106+
context.report({
107+
node: nameParam,
108+
messageId: 'dasherized',
109+
data: { modifierName, dasherizedName },
110+
fix(fixer) {
111+
return fixer.replaceTextRange(nameParam.range, `"${dasherizedName}"`);
112+
},
113+
});
114+
}
115+
}
116+
},
117+
};
118+
},
119+
};
120+
/* eslint-enable unicorn/consistent-function-scoping */
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
const rule = require('../../../lib/rules/template-modifier-name-case');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const ruleTester = new RuleTester({
5+
parser: require.resolve('ember-eslint-parser'),
6+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
7+
});
8+
9+
ruleTester.run('template-modifier-name-case', rule, {
10+
// Rule is HBS-only: hyphenated identifiers are not valid JS, so camelCase
11+
// modifier names in .gjs/.gts files are intentional and must not be flagged.
12+
valid: [
13+
'<template><div {{did-insert}}></div></template>',
14+
'<template><div {{did-update}}></div></template>',
15+
'<template><div {{on-click}}></div></template>',
16+
'<template><div {{(modifier "did-insert")}}></div></template>',
17+
'<template><div {{(modifier "on-click")}}></div></template>',
18+
'<template><div {{did-insert "something"}}></div></template>',
19+
'<template><div {{did-insert action=something}}></div></template>',
20+
'<template><button {{on "click" somethingAmazing}}></button></template>',
21+
'<template><button onclick={{do-a-thing "foo"}}></button></template>',
22+
'<template><button onclick={{doAThing "foo"}}></button></template>',
23+
'<template><a href="#" onclick={{amazingActionThing "foo"}} {{did-insert}}></a></template>',
24+
'<template><div didInsert></div></template>',
25+
'<template><div {{(modifier "foo-bar")}}></div></template>',
26+
'<template><div {{(if this.foo (modifier "foo-bar"))}}></div></template>',
27+
'<template><div {{(modifier this.fooBar)}}></div></template>',
28+
// camelCase modifiers in GJS are not flagged — hyphenated names are invalid JS identifiers
29+
'<template><div {{didInsert}}></div></template>',
30+
'<template><div {{doSomething}}></div></template>',
31+
'<template><div {{fooBar}}></div></template>',
32+
'<template><div {{FooBar}}></div></template>',
33+
'<template><div {{(modifier "fooBar")}}></div></template>',
34+
],
35+
invalid: [],
36+
});
37+
38+
const hbsRuleTester = new RuleTester({
39+
parser: require.resolve('ember-eslint-parser/hbs'),
40+
parserOptions: {
41+
ecmaVersion: 2022,
42+
sourceType: 'module',
43+
},
44+
});
45+
46+
hbsRuleTester.run('template-modifier-name-case', rule, {
47+
valid: [
48+
{ filename: 'test.hbs', code: '<div {{did-insert}}></div>' },
49+
{ filename: 'test.hbs', code: '<div {{did-insert "something"}}></div>' },
50+
{ filename: 'test.hbs', code: '<div {{did-insert action=something}}></div>' },
51+
{ filename: 'test.hbs', code: '<button {{on "click" somethingAmazing}}></button>' },
52+
{ filename: 'test.hbs', code: '<button onclick={{do-a-thing "foo"}}></button>' },
53+
{ filename: 'test.hbs', code: '<button onclick={{doAThing "foo"}}></button>' },
54+
{
55+
filename: 'test.hbs',
56+
code: '<a href="#" onclick={{amazingActionThing "foo"}} {{did-insert}}></a>',
57+
},
58+
{ filename: 'test.hbs', code: '<div didInsert></div>' },
59+
{ filename: 'test.hbs', code: '<div {{(modifier "foo-bar")}}></div>' },
60+
{ filename: 'test.hbs', code: '<div {{(if this.foo (modifier "foo-bar"))}}></div>' },
61+
{ filename: 'test.hbs', code: '<div {{(modifier this.fooBar)}}></div>' },
62+
],
63+
invalid: [
64+
{
65+
filename: 'test.hbs',
66+
code: '<div {{didInsert}}></div>',
67+
output: '<div {{did-insert}}></div>',
68+
errors: [{ messageId: 'dasherized' }],
69+
},
70+
{
71+
filename: 'test.hbs',
72+
code: '<div class="monkey" {{didInsert "something" with="somethingElse"}}></div>',
73+
output: '<div class="monkey" {{did-insert "something" with="somethingElse"}}></div>',
74+
errors: [{ messageId: 'dasherized' }],
75+
},
76+
// PascalCase: index-0 guard prevents leading dash
77+
{
78+
filename: 'test.hbs',
79+
code: '<div {{FooBar}}></div>',
80+
output: '<div {{foo-bar}}></div>',
81+
errors: [{ messageId: 'dasherized' }],
82+
},
83+
{
84+
filename: 'test.hbs',
85+
code: '<a href="#" onclick={{amazingActionThing "foo"}} {{doSomething}}></a>',
86+
output: '<a href="#" onclick={{amazingActionThing "foo"}} {{do-something}}></a>',
87+
errors: [{ messageId: 'dasherized' }],
88+
},
89+
{
90+
filename: 'test.hbs',
91+
code: '<div {{(modifier "fooBar")}}></div>',
92+
output: '<div {{(modifier "foo-bar")}}></div>',
93+
errors: [{ messageId: 'dasherized' }],
94+
},
95+
{
96+
filename: 'test.hbs',
97+
code: '<div {{(if this.foo (modifier "fooBar"))}}></div>',
98+
output: '<div {{(if this.foo (modifier "foo-bar"))}}></div>',
99+
errors: [{ messageId: 'dasherized' }],
100+
},
101+
],
102+
});

0 commit comments

Comments
 (0)