Skip to content

Commit d0f6512

Browse files
kimtaejin3ljharb
authored andcommitted
[New] jsx-closing-tag-location: add line-aligned option
1 parent 51d342b commit d0f6512

File tree

4 files changed

+234
-11
lines changed

4 files changed

+234
-11
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
55

66
## Unreleased
77

8+
### Added
9+
10+
* [`jsx-closing-tag-location`]: add `line-aligned` option ([#3777] @kimtaejin3)
11+
812
### Fixed
913

1014
* [`prop-types`]: fix `className` missing in prop validation false negative ([#3749] @akulsr0)
1115

16+
[#3777]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3777
1217
[#3749]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3749
1318

1419
## [7.34.3] - 2024.06.18

docs/rules/jsx-closing-tag-location.md

+77-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,82 @@ Examples of **correct** code for this rule:
3535
<Hello>marklar</Hello>
3636
```
3737

38+
## Rule Options
39+
40+
There is one way to configure this rule.
41+
42+
The configuration is a string shortcut corresponding to the `location` values specified below. If omitted, it defaults to `"tag-aligned"`.
43+
44+
```js
45+
"react/jsx-closing-tag-location": <enabled> // -> [<enabled>, "tag-aligned"]
46+
"react/jsx-closing-tag-location": [<enabled>, "<location>"]
47+
```
48+
49+
### `location`
50+
51+
Enforced location for the closing tag.
52+
53+
- `tag-aligned`: must be aligned with the opening tag.
54+
- `line-aligned`: must be aligned with the line containing the opening tag.
55+
56+
Defaults to `tag-aligned`.
57+
58+
For backward compatibility, you may pass an object `{ "location": <location> }` that is equivalent to the first string shortcut form.
59+
60+
Examples of **incorrect** code for this rule:
61+
62+
```jsx
63+
// 'jsx-closing-tag-location': 1
64+
// 'jsx-closing-tag-location': [1, 'tag-aligned']
65+
// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}]
66+
<Say
67+
firstName="John"
68+
lastName="Smith">
69+
Hello
70+
</Say>;
71+
72+
// 'jsx-closing-tag-location': [1, 'tag-aligned']
73+
// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}]
74+
const App = <Bar>
75+
Foo
76+
</Bar>;
77+
78+
79+
// 'jsx-closing-tag-location': [1, 'line-aligned']
80+
// 'jsx-closing-tag-location': [1, {"location":'line-aligned'}]
81+
const App = <Bar>
82+
Foo
83+
</Bar>;
84+
85+
86+
```
87+
88+
Examples of **correct** code for this rule:
89+
90+
```jsx
91+
// 'jsx-closing-tag-location': 1
92+
// 'jsx-closing-tag-location': [1, 'tag-aligned']
93+
// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}]
94+
<Say
95+
firstName="John"
96+
lastName="Smith">
97+
Hello
98+
</Say>;
99+
100+
// 'jsx-closing-tag-location': [1, 'tag-aligned']
101+
// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}]
102+
const App = <Bar>
103+
Foo
104+
</Bar>;
105+
106+
// 'jsx-closing-tag-location': [1, 'line-aligned']
107+
// 'jsx-closing-tag-location': [1, {"location":'line-aligned'}]
108+
const App = <Bar>
109+
Foo
110+
</Bar>;
111+
112+
```
113+
38114
## When Not To Use It
39115

40-
If you do not care about closing tag JSX alignment then you can disable this rule.
116+
If you do not care about closing tag JSX alignment then you can disable this rule.

lib/rules/jsx-closing-tag-location.js

+60-3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
'use strict';
77

88
const repeat = require('string.prototype.repeat');
9+
const has = require('hasown');
910

1011
const astUtil = require('../util/ast');
1112
const docsUrl = require('../util/docsUrl');
13+
const getSourceCode = require('../util/eslint').getSourceCode;
1214
const report = require('../util/report');
1315

1416
// ------------------------------------------------------------------------------
@@ -18,6 +20,14 @@ const report = require('../util/report');
1820
const messages = {
1921
onOwnLine: 'Closing tag of a multiline JSX expression must be on its own line.',
2022
matchIndent: 'Expected closing tag to match indentation of opening.',
23+
alignWithOpening: 'Expected closing tag to be aligned with the line containing the opening tag',
24+
};
25+
26+
const defaultOption = 'tag-aligned';
27+
28+
const optionMessageMap = {
29+
'tag-aligned': 'matchIndent',
30+
'line-aligned': 'alignWithOpening',
2131
};
2232

2333
/** @type {import('eslint').Rule.RuleModule} */
@@ -31,31 +41,78 @@ module.exports = {
3141
},
3242
fixable: 'whitespace',
3343
messages,
44+
schema: [{
45+
anyOf: [
46+
{
47+
enum: ['tag-aligned', 'line-aligned'],
48+
},
49+
{
50+
type: 'object',
51+
properties: {
52+
location: {
53+
enum: ['tag-aligned', 'line-aligned'],
54+
},
55+
},
56+
additionalProperties: false,
57+
},
58+
],
59+
}],
3460
},
3561

3662
create(context) {
63+
const config = context.options[0];
64+
let option = defaultOption;
65+
66+
if (typeof config === 'string') {
67+
option = config;
68+
} else if (typeof config === 'object') {
69+
if (has(config, 'location')) {
70+
option = config.location;
71+
}
72+
}
73+
74+
function getIndentation(openingStartOfLine, opening) {
75+
if (option === 'line-aligned') return openingStartOfLine.column + 1;
76+
if (option === 'tag-aligned') return opening.loc.start.column + 1;
77+
}
78+
3779
function handleClosingElement(node) {
3880
if (!node.parent) {
3981
return;
4082
}
83+
const sourceCode = getSourceCode(context);
4184

4285
const opening = node.parent.openingElement || node.parent.openingFragment;
86+
const openingLoc = sourceCode.getFirstToken(opening).loc.start;
87+
const openingLine = sourceCode.lines[openingLoc.line - 1];
88+
89+
const openingStartOfLine = {
90+
column: /^\s*/.exec(openingLine)[0].length,
91+
line: openingLoc.line,
92+
};
93+
4394
if (opening.loc.start.line === node.loc.start.line) {
4495
return;
4596
}
4697

47-
if (opening.loc.start.column === node.loc.start.column) {
98+
if (opening.loc.start.column === node.loc.start.column && option === 'tag-aligned') {
99+
return;
100+
}
101+
102+
if (openingStartOfLine.column === node.loc.start.column && option === 'line-aligned') {
48103
return;
49104
}
50105

51106
const messageId = astUtil.isNodeFirstInLine(context, node)
52-
? 'matchIndent'
107+
? optionMessageMap[option]
53108
: 'onOwnLine';
109+
54110
report(context, messages[messageId], messageId, {
55111
node,
56112
loc: node.loc,
57113
fix(fixer) {
58-
const indent = repeat(' ', opening.loc.start.column);
114+
const indent = repeat(' ', getIndentation(openingStartOfLine, opening));
115+
59116
if (astUtil.isNodeFirstInLine(context, node)) {
60117
return fixer.replaceTextRange(
61118
[node.range[0] - node.loc.start.column, node.range[0]],

tests/lib/rules/jsx-closing-tag-location.js

+92-7
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,80 @@ const parserOptions = {
2929
const ruleTester = new RuleTester({ parserOptions });
3030
ruleTester.run('jsx-closing-tag-location', rule, {
3131
valid: parsers.all([
32+
{
33+
code: `
34+
const foo = () => {
35+
return <App>
36+
bar</App>
37+
}
38+
`,
39+
options: [{ location: 'line-aligned' }],
40+
},
41+
{
42+
code: `
43+
const foo = () => {
44+
return <App>
45+
bar</App>
46+
}
47+
`,
48+
},
49+
{
50+
code: `
51+
const foo = () => {
52+
return <App>
53+
bar
54+
</App>
55+
}
56+
`,
57+
options: ['line-aligned'],
58+
},
59+
{
60+
code: `
61+
const foo = <App>
62+
bar
63+
</App>
64+
`,
65+
options: ['line-aligned'],
66+
},
67+
{
68+
code: `
69+
const x = <App>
70+
foo
71+
</App>
72+
`,
73+
},
74+
{
75+
code: `
76+
const foo =
77+
<App>
78+
bar
79+
</App>
80+
`,
81+
options: ['line-aligned'],
82+
},
83+
{
84+
code: `
85+
const foo =
86+
<App>
87+
bar
88+
</App>
89+
`,
90+
},
3291
{
3392
code: `
3493
<App>
3594
foo
3695
</App>
3796
`,
3897
},
98+
{
99+
code: `
100+
<App>
101+
foo
102+
</App>
103+
`,
104+
options: ['line-aligned'],
105+
},
39106
{
40107
code: `
41108
<App>foo</App>
@@ -95,20 +162,38 @@ ruleTester.run('jsx-closing-tag-location', rule, {
95162
foo
96163
</>
97164
`,
98-
errors: [{ messageId: 'matchIndent' }],
165+
errors: [{ messageId: 'matchIndent' }], // here
99166
},
100167
{
101168
code: `
102-
<>
103-
foo</>
169+
const x = () => {
170+
return <App>
171+
foo</App>
172+
}
104173
`,
105-
features: ['fragment', 'no-ts-old'], // TODO: FIXME: remove no-ts-old and fix
106174
output: `
107-
<>
108-
foo
109-
</>
175+
const x = () => {
176+
return <App>
177+
foo
178+
</App>
179+
}
110180
`,
111181
errors: [{ messageId: 'onOwnLine' }],
182+
options: ['line-aligned'],
183+
},
184+
{
185+
code: `
186+
const x = <App>
187+
foo
188+
</App>
189+
`,
190+
output: `
191+
const x = <App>
192+
foo
193+
</App>
194+
`,
195+
errors: [{ messageId: 'alignWithOpening' }],
196+
options: ['line-aligned'],
112197
},
113198
]),
114199
});

0 commit comments

Comments
 (0)