Skip to content

Commit 55444b4

Browse files
authored
Add consistent-template-literal-escape rule (#2866)
1 parent 0bf85e0 commit 55444b4

7 files changed

Lines changed: 326 additions & 0 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Enforce consistent style for escaping `${` in template literals
2+
3+
💼🚫 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config). This rule is _disabled_ in the ☑️ `unopinionated` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
There are multiple ways to escape `${` in a template literal to prevent it from being interpreted as an expression:
11+
12+
- `\${` — escape the dollar sign ✅
13+
- `$\{` — escape the opening brace ❌
14+
- `\$\{` — escape both ❌
15+
16+
This rule enforces escaping the dollar sign (`\${`) for consistency.
17+
18+
## Examples
19+
20+
```js
21+
//
22+
const foo = `$\{a}`;
23+
24+
//
25+
const foo = `\$\{a}`;
26+
27+
//
28+
const foo = `\${a}`;
29+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export default [
6565
| [consistent-empty-array-spread](docs/rules/consistent-empty-array-spread.md) | Prefer consistent types when spreading a ternary in an array literal. || 🔧 | |
6666
| [consistent-existence-index-check](docs/rules/consistent-existence-index-check.md) | Enforce consistent style for element existence checks with `indexOf()`, `lastIndexOf()`, `findIndex()`, and `findLastIndex()`. | ✅ ☑️ | 🔧 | |
6767
| [consistent-function-scoping](docs/rules/consistent-function-scoping.md) | Move function definitions to the highest possible scope. || | |
68+
| [consistent-template-literal-escape](docs/rules/consistent-template-literal-escape.md) | Enforce consistent style for escaping `${` in template literals. || 🔧 | |
6869
| [custom-error-definition](docs/rules/custom-error-definition.md) | Enforce correct `Error` subclassing. | | 🔧 | |
6970
| [empty-brace-spaces](docs/rules/empty-brace-spaces.md) | Enforce no spaces between braces. || 🔧 | |
7071
| [error-message](docs/rules/error-message.md) | Enforce passing a `message` value when creating a built-in error. | ✅ ☑️ | | |
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {replaceTemplateElement} from './fix/index.js';
2+
import {isTaggedTemplateLiteral} from './ast/index.js';
3+
4+
const MESSAGE_ID = 'consistent-template-literal-escape';
5+
const messages = {
6+
[MESSAGE_ID]: 'Use `\\${` instead of `$\\{` to escape in template literals.',
7+
};
8+
9+
/** @param {import('eslint').Rule.RuleContext} context */
10+
const create = context => {
11+
context.on('TemplateElement', node => {
12+
if (isTaggedTemplateLiteral(node.parent, ['String.raw'])) {
13+
return;
14+
}
15+
16+
const {raw} = node.value;
17+
18+
// Match `$\{` or `\$\{` and replace with `\${`.
19+
// The `\\?` makes the leading backslash optional to handle both patterns.
20+
// The lookbehind ensures an even number of preceding backslashes (including zero).
21+
const fixedRaw = raw.replaceAll(
22+
/(?<=(?:^|[^\\])(?:\\\\)*)\\?\$\\{/g,
23+
String.raw`\${`,
24+
);
25+
26+
if (raw !== fixedRaw) {
27+
const problem = {
28+
node,
29+
messageId: MESSAGE_ID,
30+
};
31+
32+
// Only auto-fix untagged templates. Tagged templates may have tag
33+
// functions that read `strings.raw`, where changing the escape style
34+
// would alter runtime behavior.
35+
if (!isTaggedTemplateLiteral(node.parent)) {
36+
problem.fix = fixer => replaceTemplateElement(node, fixedRaw, context, fixer);
37+
}
38+
39+
return problem;
40+
}
41+
});
42+
};
43+
44+
/** @type {import('eslint').Rule.RuleModule} */
45+
const config = {
46+
create,
47+
meta: {
48+
type: 'suggestion',
49+
docs: {
50+
description: 'Enforce consistent style for escaping `${` in template literals.',
51+
recommended: true,
52+
},
53+
fixable: 'code',
54+
messages,
55+
},
56+
};
57+
58+
export default config;

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {default as 'consistent-destructuring'} from './consistent-destructuring.
88
export {default as 'consistent-empty-array-spread'} from './consistent-empty-array-spread.js';
99
export {default as 'consistent-existence-index-check'} from './consistent-existence-index-check.js';
1010
export {default as 'consistent-function-scoping'} from './consistent-function-scoping.js';
11+
export {default as 'consistent-template-literal-escape'} from './consistent-template-literal-escape.js';
1112
export {default as 'custom-error-definition'} from './custom-error-definition.js';
1213
export {default as 'empty-brace-spaces'} from './empty-brace-spaces.js';
1314
export {default as 'error-message'} from './error-message.js';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {getTester} from './utils/test.js';
2+
3+
const {test} = getTester(import.meta);
4+
5+
test.snapshot({
6+
valid: [
7+
// Correct: escape the dollar sign
8+
// eslint-disable-next-line no-template-curly-in-string
9+
'const foo = `\\${a}`',
10+
// No escaping needed
11+
'const foo = `hello`',
12+
'const foo = `$`',
13+
'const foo = `{`',
14+
// Empty template literal
15+
'const foo = ``',
16+
// Actual template expression (not escaped)
17+
// eslint-disable-next-line no-template-curly-in-string
18+
'const foo = `${a}`',
19+
// Template with only expressions
20+
// eslint-disable-next-line no-template-curly-in-string
21+
'const foo = `${a}${b}`',
22+
// String.raw tagged template (skipped)
23+
'const foo = String.raw`$\\{a}`',
24+
// Escaped backslash before \${ (the backslash is escaped, \${ is correct)
25+
// eslint-disable-next-line no-template-curly-in-string
26+
'const foo = `\\\\\\${a}`',
27+
// Regular string (not a template literal)
28+
String.raw`const foo = '$\{a}'`,
29+
],
30+
invalid: [
31+
// Brace escaped instead of dollar: $\{ → \${
32+
'const foo = `$\\{a}`',
33+
// Both escaped: \$\{ → \${
34+
'const foo = `\\$\\{a}`',
35+
// Multiple occurrences
36+
'const foo = `$\\{a} and $\\{b}`',
37+
// Escaped backslash before $\{ (\\$\{ — the $\{ part is still wrong)
38+
'const foo = `\\\\$\\{a}`',
39+
// Both escaped with preceding escaped backslash
40+
'const foo = `\\\\\\$\\{a}`',
41+
// Non-String.raw tagged template (should still be flagged)
42+
'const foo = html`$\\{a}`',
43+
// Bad escape in head element (before expression)
44+
// eslint-disable-next-line no-template-curly-in-string
45+
'const foo = `$\\{a}${expr}`',
46+
// Bad escape in tail element (after expression)
47+
// eslint-disable-next-line no-template-curly-in-string
48+
'const foo = `${expr}$\\{a}`',
49+
// Bad escape in both head and tail elements
50+
// eslint-disable-next-line no-template-curly-in-string
51+
'const foo = `$\\{a}${expr}$\\{b}`',
52+
],
53+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Snapshot report for `test/consistent-template-literal-escape.js`
2+
3+
The actual snapshot is saved in `consistent-template-literal-escape.js.snap`.
4+
5+
Generated by [AVA](https://avajs.dev).
6+
7+
## invalid(1): const foo = `$\{a}`
8+
9+
> Input
10+
11+
`␊
12+
1 | const foo = \`$\\{a}\`␊
13+
`
14+
15+
> Error 1/1
16+
17+
`␊
18+
Message:␊
19+
> 1 | const foo = \`$\\{a}\`␊
20+
| ^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
21+
22+
Output:␊
23+
1 | const foo = \`\\${a}\`␊
24+
`
25+
26+
## invalid(2): const foo = `\$\{a}`
27+
28+
> Input
29+
30+
`␊
31+
1 | const foo = \`\\$\\{a}\`␊
32+
`
33+
34+
> Error 1/1
35+
36+
`␊
37+
Message:␊
38+
> 1 | const foo = \`\\$\\{a}\`␊
39+
| ^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
40+
41+
Output:␊
42+
1 | const foo = \`\\${a}\`␊
43+
`
44+
45+
## invalid(3): const foo = `$\{a} and $\{b}`
46+
47+
> Input
48+
49+
`␊
50+
1 | const foo = \`$\\{a} and $\\{b}\`␊
51+
`
52+
53+
> Error 1/1
54+
55+
`␊
56+
Message:␊
57+
> 1 | const foo = \`$\\{a} and $\\{b}\`␊
58+
| ^^^^^^^^^^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
59+
60+
Output:␊
61+
1 | const foo = \`\\${a} and \\${b}\`␊
62+
`
63+
64+
## invalid(4): const foo = `\\$\{a}`
65+
66+
> Input
67+
68+
`␊
69+
1 | const foo = \`\\\\$\\{a}\`␊
70+
`
71+
72+
> Error 1/1
73+
74+
`␊
75+
Message:␊
76+
> 1 | const foo = \`\\\\$\\{a}\`␊
77+
| ^^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
78+
79+
Output:␊
80+
1 | const foo = \`\\\\\\${a}\`␊
81+
`
82+
83+
## invalid(5): const foo = `\\\$\{a}`
84+
85+
> Input
86+
87+
`␊
88+
1 | const foo = \`\\\\\\$\\{a}\`␊
89+
`
90+
91+
> Error 1/1
92+
93+
`␊
94+
Message:␊
95+
> 1 | const foo = \`\\\\\\$\\{a}\`␊
96+
| ^^^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
97+
98+
Output:␊
99+
1 | const foo = \`\\\\\\${a}\`␊
100+
`
101+
102+
## invalid(6): const foo = html`$\{a}`
103+
104+
> Input
105+
106+
`␊
107+
1 | const foo = html\`$\\{a}\`␊
108+
`
109+
110+
> Error 1/1
111+
112+
`␊
113+
Message:␊
114+
> 1 | const foo = html\`$\\{a}\`␊
115+
| ^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
116+
`
117+
118+
## invalid(7): const foo = `$\{a}${expr}`
119+
120+
> Input
121+
122+
`␊
123+
1 | const foo = \`$\\{a}${expr}\`␊
124+
`
125+
126+
> Error 1/1
127+
128+
`␊
129+
Message:␊
130+
> 1 | const foo = \`$\\{a}${expr}\`␊
131+
| ^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
132+
133+
Output:␊
134+
1 | const foo = \`\\${a}${expr}\`␊
135+
`
136+
137+
## invalid(8): const foo = `${expr}$\{a}`
138+
139+
> Input
140+
141+
`␊
142+
1 | const foo = \`${expr}$\\{a}\`␊
143+
`
144+
145+
> Error 1/1
146+
147+
`␊
148+
Message:␊
149+
> 1 | const foo = \`${expr}$\\{a}\`␊
150+
| ^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
151+
152+
Output:␊
153+
1 | const foo = \`${expr}\\${a}\`␊
154+
`
155+
156+
## invalid(9): const foo = `$\{a}${expr}$\{b}`
157+
158+
> Input
159+
160+
`␊
161+
1 | const foo = \`$\\{a}${expr}$\\{b}\`␊
162+
`
163+
164+
> Error 1/2
165+
166+
`␊
167+
Message:␊
168+
> 1 | const foo = \`$\\{a}${expr}$\\{b}\`␊
169+
| ^^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
170+
171+
Output:␊
172+
1 | const foo = \`\\${a}${expr}$\\{b}\`␊
173+
`
174+
175+
> Error 2/2
176+
177+
`␊
178+
Message:␊
179+
> 1 | const foo = \`$\\{a}${expr}$\\{b}\`␊
180+
| ^^^^^^^ Use \`\\${\` instead of \`$\\{\` to escape in template literals.␊
181+
182+
Output:␊
183+
1 | const foo = \`$\\{a}${expr}\\${b}\`␊
184+
`
607 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)