Skip to content

Commit 5f4c440

Browse files
mmkalsindresorhus
andauthored
Add template-indent rule (#1478)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent 3e2a4e2 commit 5f4c440

File tree

9 files changed

+933
-40
lines changed

9 files changed

+933
-40
lines changed

configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ module.exports = {
9696
'unicorn/require-number-to-fixed-digits-argument': 'error',
9797
'unicorn/require-post-message-target-origin': 'error',
9898
'unicorn/string-content': 'off',
99+
'unicorn/template-indent': 'warn',
99100
'unicorn/throw-new-error': 'error',
100101
...require('./conflicting-rules.js').rules,
101102
},

docs/rules/template-indent.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Fix whitespace-insensitive template indentation
2+
3+
[Tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates) often look ugly/jarring because their indentation doesn't match the code they're found in. In many cases, whitespace is insignificant, or a library like [strip-indent](https://www.npmjs.com/package/strip-indent) is used to remove the margin. See [proposal-string-dedent](https://github.com/tc39/proposal-string-dedent) (stage 1 at the time of writing) for a proposal on fixing this in JavaScript.
4+
5+
This rule will automatically fix the indentation of multiline string templates, to keep them in alignment with the code they are found in. A configurable whitelist is used to ensure no whitespace-sensitive strings are edited.
6+
7+
## Fail
8+
9+
```js
10+
function foo() {
11+
const sqlQuery = sql`
12+
select *
13+
from students
14+
where first_name = ${x}
15+
and last_name = ${y}
16+
`;
17+
18+
const gqlQuery = gql`
19+
query user(id: 5) {
20+
firstName
21+
lastName
22+
}
23+
`;
24+
25+
const html = /* HTML */ `
26+
<div>
27+
<span>hello</span>
28+
</div>
29+
`;
30+
}
31+
```
32+
33+
## Pass
34+
35+
The above will auto-fix to:
36+
37+
```js
38+
function foo() {
39+
const sqlQuery = sql`
40+
select *
41+
from students
42+
where first_name = ${x}
43+
and last_name = ${y}
44+
`;
45+
46+
const gqlQuery = gql`
47+
query user(id: 5) {
48+
firstName
49+
lastName
50+
}
51+
`;
52+
53+
const html = /* HTML */ `
54+
<div>
55+
<span>hello</span>
56+
</div>
57+
`;
58+
}
59+
```
60+
61+
Under the hood, [strip-indent](https://npmjs.com/package/strip-indent) is used to determine how the template "should" look. Then a common indent is added to each line based on the margin of the line the template started at. This rule will *not* alter the relative whitespace between significant lines, it will only shift the content right or left so that it aligns sensibly with the surrounding code.
62+
63+
## Options
64+
65+
The rule accepts lists of `tags`, `functions`, `selectors` and `comments` to match template literals. `tags` are tagged template literal identifiers, functions are names of utility functions like `stripIndent`, selectors can be any [ESLint selector](https://eslint.org/docs/developer-guide/selectors), and comments are `/* block-commented */` strings.
66+
67+
Default configuration:
68+
69+
```js
70+
{
71+
'unicorn/template-indent': [
72+
'warn',
73+
{
74+
tags: [
75+
'outdent',
76+
'dedent',
77+
'gql',
78+
'sql',
79+
'html',
80+
'styled'
81+
],
82+
functions: [
83+
'dedent',
84+
'stripIndent'
85+
],
86+
selectors: [],
87+
comments: [
88+
'HTML',
89+
'indent'
90+
]
91+
}
92+
]
93+
}
94+
```
95+
96+
You can use a selector for custom use-cases, like indenting *all* template literals, even those without template tags or function callers:
97+
98+
```js
99+
{
100+
'unicorn/template-indent': [
101+
'warn',
102+
{
103+
tags: [],
104+
functions: [],
105+
selectors: [
106+
'TemplateLiteral'
107+
]
108+
}
109+
]
110+
}
111+
```
112+
113+
Indentation will be done with tabs or spaces depending on the line of code that the template literal starts at. You can override this by supplying an `indent`, which should be either a number (of spaces) or a string consisting only of whitespace characters:
114+
115+
```js
116+
{
117+
'unicorn/template-indent': [
118+
'warn', {
119+
indent: 8,
120+
}
121+
]
122+
}
123+
```
124+
125+
```js
126+
{
127+
'unicorn/template-indent': [
128+
'warn',
129+
{
130+
indent: '\t\t'
131+
}
132+
]
133+
}
134+
```

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,16 @@
4343
"clean-regexp": "^1.0.0",
4444
"eslint-template-visitor": "^2.3.2",
4545
"eslint-utils": "^3.0.0",
46+
"esquery": "^1.4.0",
47+
"indent-string": "4",
4648
"is-builtin-module": "^3.1.0",
4749
"lodash": "^4.17.21",
4850
"pluralize": "^8.0.0",
4951
"read-pkg-up": "^7.0.1",
5052
"regexp-tree": "^0.1.23",
5153
"safe-regex": "^2.1.1",
52-
"semver": "^7.3.5"
54+
"semver": "^7.3.5",
55+
"strip-indent": "^3.0.0"
5356
},
5457
"devDependencies": {
5558
"@babel/code-frame": "^7.14.5",

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ Configure it in `package.json`.
125125
"unicorn/require-number-to-fixed-digits-argument": "error",
126126
"unicorn/require-post-message-target-origin": "error",
127127
"unicorn/string-content": "off",
128+
"unicorn/template-indent": "warn",
128129
"unicorn/throw-new-error": "error"
129130
},
130131
"overrides": [
@@ -245,6 +246,7 @@ Each rule has emojis denoting:
245246
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
246247
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. || | 💡 |
247248
| [string-content](docs/rules/string-content.md) | Enforce better string content. | | 🔧 | 💡 |
249+
| [template-indent](docs/rules/template-indent.md) | Fix whitespace-insensitive template indentation. | | 🔧 | |
248250
| [throw-new-error](docs/rules/throw-new-error.md) | Require `new` when throwing an error. || 🔧 | |
249251

250252
<!-- RULES_TABLE_END -->

rules/template-indent.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
'use strict';
2+
const stripIndent = require('strip-indent');
3+
const indentString = require('indent-string');
4+
const esquery = require('esquery');
5+
const {replaceTemplateElement} = require('./fix/index.js');
6+
7+
const MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE = 'template-indent';
8+
const messages = {
9+
[MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE]: 'Templates should be properly indented.',
10+
};
11+
12+
/** @param {import('eslint').Rule.RuleContext} context */
13+
const create = context => {
14+
const sourceCode = context.getSourceCode();
15+
const options = {
16+
tags: ['outdent', 'dedent', 'gql', 'sql', 'html', 'styled'],
17+
functions: ['dedent', 'stripIndent'],
18+
selectors: [],
19+
comments: ['HTML', 'indent'],
20+
...context.options[0],
21+
};
22+
23+
options.comments = options.comments.map(comment => comment.toLowerCase());
24+
25+
const selectors = [
26+
...options.tags.map(tag => `TaggedTemplateExpression[tag.name="${tag}"] > .quasi`),
27+
...options.functions.map(fn => `CallExpression[callee.name="${fn}"] > .arguments`),
28+
...options.selectors,
29+
];
30+
31+
/** @param {import('@babel/core').types.TemplateLiteral} node */
32+
const indentTemplateLiteralNode = node => {
33+
const delimiter = '__PLACEHOLDER__' + Math.random();
34+
const joined = node.quasis
35+
.map(quasi => {
36+
const untrimmedText = sourceCode.getText(quasi);
37+
return untrimmedText.slice(1, quasi.tail ? -1 : -2);
38+
})
39+
.join(delimiter);
40+
41+
const eolMatch = joined.match(/\r?\n/);
42+
if (!eolMatch) {
43+
return;
44+
}
45+
46+
const eol = eolMatch[0];
47+
48+
const startLine = sourceCode.lines[node.loc.start.line - 1];
49+
const marginMatch = startLine.match(/^(\s*)\S/);
50+
const parentMargin = marginMatch ? marginMatch[1] : '';
51+
52+
let indent;
53+
if (typeof options.indent === 'string') {
54+
indent = options.indent;
55+
} else if (typeof options.indent === 'number') {
56+
indent = ' '.repeat(options.indent);
57+
} else {
58+
const tabs = parentMargin.startsWith('\t');
59+
indent = tabs ? '\t' : ' ';
60+
}
61+
62+
const dedented = stripIndent(joined);
63+
const fixed
64+
= eol
65+
+ indentString(dedented.trim(), 1, {indent: parentMargin + indent})
66+
+ eol
67+
+ parentMargin;
68+
69+
if (fixed === joined) {
70+
return;
71+
}
72+
73+
context.report({
74+
node,
75+
messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
76+
fix: fixer => fixed
77+
.split(delimiter)
78+
.map((replacement, index) => replaceTemplateElement(fixer, node.quasis[index], replacement)),
79+
});
80+
};
81+
82+
return {
83+
/** @param {import('@babel/core').types.TemplateLiteral} node */
84+
TemplateLiteral: node => {
85+
if (options.comments.length > 0) {
86+
const previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
87+
if (previousToken && previousToken.type === 'Block' && options.comments.includes(previousToken.value.trim().toLowerCase())) {
88+
indentTemplateLiteralNode(node);
89+
return;
90+
}
91+
}
92+
93+
const ancestry = context.getAncestors().reverse();
94+
const shouldIndent = selectors.some(selector => esquery.matches(node, esquery.parse(selector), ancestry));
95+
96+
if (shouldIndent) {
97+
indentTemplateLiteralNode(node);
98+
}
99+
},
100+
};
101+
};
102+
103+
/** @type {import('json-schema').JSONSchema7[]} */
104+
const schema = [
105+
{
106+
type: 'object',
107+
properties: {
108+
indent: {
109+
oneOf: [
110+
{
111+
type: 'string',
112+
pattern: /^\s+$/.source,
113+
},
114+
{
115+
type: 'integer',
116+
minimum: 1,
117+
},
118+
],
119+
},
120+
tags: {
121+
type: 'array',
122+
uniqueItems: true,
123+
items: {
124+
type: 'string',
125+
},
126+
},
127+
functions: {
128+
type: 'array',
129+
uniqueItems: true,
130+
items: {
131+
type: 'string',
132+
},
133+
},
134+
selectors: {
135+
type: 'array',
136+
uniqueItems: true,
137+
items: {
138+
type: 'string',
139+
},
140+
},
141+
comments: {
142+
type: 'array',
143+
uniqueItems: true,
144+
items: {
145+
type: 'string',
146+
},
147+
},
148+
},
149+
additionalProperties: false,
150+
},
151+
];
152+
153+
module.exports = {
154+
create,
155+
meta: {
156+
type: 'suggestion',
157+
docs: {
158+
description: 'Fix whitespace-insensitive template indentation.',
159+
},
160+
fixable: 'code',
161+
schema,
162+
messages,
163+
},
164+
};

test/consistent-function-scoping.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,12 @@ test({
218218
`,
219219
// Functions that could be extracted are conservatively ignored due to JSX masking references
220220
outdent`
221-
function Foo() {
222-
function Bar () {
223-
return <div />
224-
}
225-
return <div>{ Bar() }</div>
221+
function Foo() {
222+
function Bar () {
223+
return <div />
226224
}
225+
return <div>{ Bar() }</div>
226+
}
227227
`,
228228
outdent`
229229
function foo() {

0 commit comments

Comments
 (0)