Skip to content

Commit b0373be

Browse files
authored
feat(html-closing-bracket-new-line): add rule (#870)
Adds the `svelte/html-closing-bracket-newline` rule, which enforces that HTML tags must have a newline (or not) after the closing bracket. This rule is inspired by `vue/html-closing-bracket-newline`, and is implemented ensuring what's discussed in #590. Closes #590.
1 parent edf99d3 commit b0373be

32 files changed

+445
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
441441
|:--------|:------------|:---|
442442
| [svelte/derived-has-same-inputs-outputs](https://sveltejs.github.io/eslint-plugin-svelte/rules/derived-has-same-inputs-outputs/) | derived store should use same variable names between values and callback | |
443443
| [svelte/first-attribute-linebreak](https://sveltejs.github.io/eslint-plugin-svelte/rules/first-attribute-linebreak/) | enforce the location of first attribute | :wrench: |
444+
| [svelte/html-closing-bracket-new-line](https://sveltejs.github.io/eslint-plugin-svelte/rules/html-closing-bracket-new-line/) | Require or disallow a line break before tag's closing brackets | :wrench: |
444445
| [svelte/html-closing-bracket-spacing](https://sveltejs.github.io/eslint-plugin-svelte/rules/html-closing-bracket-spacing/) | require or disallow a space before tag's closing brackets | :wrench: |
445446
| [svelte/html-quotes](https://sveltejs.github.io/eslint-plugin-svelte/rules/html-quotes/) | enforce quotes style of HTML attributes | :wrench: |
446447
| [svelte/html-self-closing](https://sveltejs.github.io/eslint-plugin-svelte/rules/html-self-closing/) | enforce self-closing style | :wrench: |

docs/rules.md

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
7878
| :------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | :------- |
7979
| [svelte/derived-has-same-inputs-outputs](./rules/derived-has-same-inputs-outputs.md) | derived store should use same variable names between values and callback | |
8080
| [svelte/first-attribute-linebreak](./rules/first-attribute-linebreak.md) | enforce the location of first attribute | :wrench: |
81+
| [svelte/html-closing-bracket-new-line](./rules/html-closing-bracket-new-line.md) | Require or disallow a line break before tag's closing brackets | :wrench: |
8182
| [svelte/html-closing-bracket-spacing](./rules/html-closing-bracket-spacing.md) | require or disallow a space before tag's closing brackets | :wrench: |
8283
| [svelte/html-quotes](./rules/html-quotes.md) | enforce quotes style of HTML attributes | :wrench: |
8384
| [svelte/html-self-closing](./rules/html-self-closing.md) | enforce self-closing style | :wrench: |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
pageClass: 'rule-details'
3+
sidebarDepth: 0
4+
title: 'svelte/html-closing-bracket-new-line'
5+
description: "Require or disallow a line break before tag's closing brackets"
6+
---
7+
8+
# svelte/html-closing-bracket-new-line
9+
10+
> Require or disallow a line break before tag's closing brackets
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
14+
15+
## :book: Rule Details
16+
17+
This rule enforces a line break (or no line break) before tag's closing brackets, which can also be configured to be enforced on self-closing tags.
18+
19+
<ESLintCodeBlock fix>
20+
21+
<!-- prettier-ignore-start -->
22+
<!--eslint-skip-->
23+
24+
```svelte
25+
<script>
26+
/* eslint svelte/brackets-same-line: "error" */
27+
</script>
28+
29+
<!-- ✓ GOOD -->
30+
<div></div>
31+
<div
32+
multiline
33+
>
34+
Children
35+
</div>
36+
37+
<SelfClosing />
38+
<SelfClosing
39+
multiline
40+
/>
41+
42+
<!-- ✗ BAD -->
43+
44+
<div
45+
></div>
46+
<div
47+
multiline>
48+
Children
49+
</div>
50+
51+
<SelfClosing
52+
/>
53+
<SelfClosing
54+
multiline/>
55+
```
56+
57+
<!-- prettier-ignore-end -->
58+
59+
</ESLintCodeBlock>
60+
61+
## :wrench: Options
62+
63+
```jsonc
64+
{
65+
"svelte/brackets-same-line": [
66+
"error",
67+
{
68+
"singleline": "never", // ["never", "always"]
69+
"multiline": "always", // ["never", "always"]
70+
"selfClosingTag": {
71+
"singleline": "never", // ["never", "always"]
72+
"multiline": "always" // ["never", "always"]
73+
}
74+
}
75+
]
76+
}
77+
```
78+
79+
- `singleline`: (`"never"` by default) Configuration for single-line elements. It's a single-line element if the element does not have attributes or the last attribute is on the same line as the opening bracket.
80+
- `multiline`: (`"always"` by default) Configuration for multi-line elements. It's a multi-line element if the last attribute is not on the same line of the opening bracket.
81+
- `selfClosingTag.singleline`: Configuration for single-line self closing elements.
82+
- `selfClosingTag.multiline`: Configuration for multi-line self closing elements.
83+
84+
The `selfClosing` is optional, and by default it will use the same configuration as `singleline` and `multiline`, respectively.
85+
86+
## :mag: Implementation
87+
88+
- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/html-closing-bracket-new-line.ts)
89+
- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/html-closing-bracket-new-line.ts)

packages/eslint-plugin-svelte/src/configs/flat/prettier.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const config: Linter.Config[] = [
1010
rules: {
1111
// eslint-plugin-svelte rules
1212
'svelte/first-attribute-linebreak': 'off',
13+
'svelte/html-closing-bracket-new-line': 'off',
1314
'svelte/html-closing-bracket-spacing': 'off',
1415
'svelte/html-quotes': 'off',
1516
'svelte/html-self-closing': 'off',

packages/eslint-plugin-svelte/src/configs/prettier.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const config: Linter.LegacyConfig = {
1010
rules: {
1111
// eslint-plugin-svelte rules
1212
'svelte/first-attribute-linebreak': 'off',
13+
'svelte/html-closing-bracket-new-line': 'off',
1314
'svelte/html-closing-bracket-spacing': 'off',
1415
'svelte/html-quotes': 'off',
1516
'svelte/html-self-closing': 'off',

packages/eslint-plugin-svelte/src/rule-types.ts

+14
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export interface RuleOptions {
5454
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/first-attribute-linebreak/
5555
*/
5656
'svelte/first-attribute-linebreak'?: Linter.RuleEntry<SvelteFirstAttributeLinebreak>
57+
/**
58+
* Require or disallow a line break before tag's closing brackets
59+
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/html-closing-bracket-new-line/
60+
*/
61+
'svelte/html-closing-bracket-new-line'?: Linter.RuleEntry<SvelteHtmlClosingBracketNewLine>
5762
/**
5863
* require or disallow a space before tag's closing brackets
5964
* @see https://sveltejs.github.io/eslint-plugin-svelte/rules/html-closing-bracket-spacing/
@@ -366,6 +371,15 @@ type SvelteFirstAttributeLinebreak = []|[{
366371
multiline?: ("below" | "beside")
367372
singleline?: ("below" | "beside")
368373
}]
374+
// ----- svelte/html-closing-bracket-new-line -----
375+
type SvelteHtmlClosingBracketNewLine = []|[{
376+
singleline?: ("always" | "never")
377+
multiline?: ("always" | "never")
378+
selfClosingTag?: {
379+
singleline?: ("always" | "never")
380+
multiline?: ("always" | "never")
381+
}
382+
}]
369383
// ----- svelte/html-closing-bracket-spacing -----
370384
type SvelteHtmlClosingBracketSpacing = []|[{
371385
startTag?: ("always" | "never" | "ignore")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { AST } from 'svelte-eslint-parser';
2+
import { createRule } from '../utils';
3+
import { getSourceCode } from '../utils/compat';
4+
import type { SourceCode } from '../types';
5+
6+
type ExpectedNode = AST.SvelteStartTag | AST.SvelteEndTag;
7+
type OptionValue = 'always' | 'never';
8+
type RuleOptions = {
9+
singleline: OptionValue;
10+
multiline: OptionValue;
11+
selfClosingTag?: Omit<RuleOptions, 'selfClosingTag'>;
12+
};
13+
14+
function getPhrase(lineBreaks: number) {
15+
switch (lineBreaks) {
16+
case 0: {
17+
return 'no line breaks';
18+
}
19+
case 1: {
20+
return '1 line break';
21+
}
22+
default: {
23+
return `${lineBreaks} line breaks`;
24+
}
25+
}
26+
}
27+
28+
function getExpectedLineBreaks(
29+
node: ExpectedNode,
30+
options: RuleOptions,
31+
type: keyof Omit<RuleOptions, 'selfClosingTag'>
32+
) {
33+
const isSelfClosingTag = node.type === 'SvelteStartTag' && node.selfClosing;
34+
if (isSelfClosingTag && options.selfClosingTag && options.selfClosingTag[type]) {
35+
return options.selfClosingTag[type] === 'always' ? 1 : 0;
36+
}
37+
38+
return options[type] === 'always' ? 1 : 0;
39+
}
40+
41+
type NodeData = {
42+
actualLineBreaks: number;
43+
expectedLineBreaks: number;
44+
startToken: AST.Token;
45+
endToken: AST.Token;
46+
};
47+
48+
function getSelfClosingData(
49+
sourceCode: SourceCode,
50+
node: AST.SvelteStartTag,
51+
options: RuleOptions
52+
): NodeData | null {
53+
const tokens = sourceCode.getTokens(node);
54+
const closingToken = tokens[tokens.length - 2];
55+
if (closingToken.value !== '/') {
56+
return null;
57+
}
58+
59+
const prevToken = sourceCode.getTokenBefore(closingToken)!;
60+
const type = node.loc.start.line === prevToken.loc.end.line ? 'singleline' : 'multiline';
61+
62+
const expectedLineBreaks = getExpectedLineBreaks(node, options, type);
63+
const actualLineBreaks = closingToken.loc.start.line - prevToken.loc.end.line;
64+
65+
return { actualLineBreaks, expectedLineBreaks, startToken: prevToken, endToken: closingToken };
66+
}
67+
68+
function getNodeData(
69+
sourceCode: SourceCode,
70+
node: ExpectedNode,
71+
options: RuleOptions
72+
): NodeData | null {
73+
const closingToken = sourceCode.getLastToken(node);
74+
if (closingToken.value !== '>') {
75+
return null;
76+
}
77+
78+
const prevToken = sourceCode.getTokenBefore(closingToken)!;
79+
const type = node.loc.start.line === prevToken.loc.end.line ? 'singleline' : 'multiline';
80+
81+
const expectedLineBreaks = getExpectedLineBreaks(node, options, type);
82+
const actualLineBreaks = closingToken.loc.start.line - prevToken.loc.end.line;
83+
84+
return { actualLineBreaks, expectedLineBreaks, startToken: prevToken, endToken: closingToken };
85+
}
86+
87+
export default createRule('html-closing-bracket-new-line', {
88+
meta: {
89+
docs: {
90+
description: "Require or disallow a line break before tag's closing brackets",
91+
category: 'Stylistic Issues',
92+
recommended: false,
93+
conflictWithPrettier: true
94+
},
95+
schema: [
96+
{
97+
type: 'object',
98+
properties: {
99+
singleline: { enum: ['always', 'never'] },
100+
multiline: { enum: ['always', 'never'] },
101+
selfClosingTag: {
102+
type: 'object',
103+
properties: {
104+
singleline: { enum: ['always', 'never'] },
105+
multiline: { enum: ['always', 'never'] }
106+
},
107+
additionalProperties: false,
108+
minProperties: 1
109+
}
110+
},
111+
additionalProperties: false
112+
}
113+
],
114+
messages: {
115+
expectedBeforeClosingBracket:
116+
'Expected {{expected}} before closing bracket, but {{actual}} found.'
117+
},
118+
fixable: 'code',
119+
type: 'suggestion'
120+
},
121+
create(context) {
122+
const options: RuleOptions = context.options[0] ?? {};
123+
options.singleline ??= 'never';
124+
options.multiline ??= 'always';
125+
126+
const sourceCode = getSourceCode(context);
127+
128+
return {
129+
'SvelteStartTag, SvelteEndTag'(node: ExpectedNode) {
130+
const data =
131+
node.type === 'SvelteStartTag' && node.selfClosing
132+
? getSelfClosingData(sourceCode, node, options)
133+
: getNodeData(sourceCode, node, options);
134+
if (!data) {
135+
return;
136+
}
137+
138+
const { actualLineBreaks, expectedLineBreaks, startToken, endToken } = data;
139+
if (actualLineBreaks !== expectedLineBreaks) {
140+
// For SvelteEndTag, does not make sense to add a line break, so we only fix if there are extra line breaks
141+
if (node.type === 'SvelteEndTag' && expectedLineBreaks !== 0) {
142+
return;
143+
}
144+
145+
context.report({
146+
node,
147+
loc: { start: startToken.loc.end, end: endToken.loc.start },
148+
messageId: 'expectedBeforeClosingBracket',
149+
data: {
150+
expected: getPhrase(expectedLineBreaks),
151+
actual: getPhrase(actualLineBreaks)
152+
},
153+
fix(fixer) {
154+
const range: AST.Range = [startToken.range[1], endToken.range[0]];
155+
const text = '\n'.repeat(expectedLineBreaks);
156+
return fixer.replaceTextRange(range, text);
157+
}
158+
});
159+
}
160+
}
161+
};
162+
}
163+
});

packages/eslint-plugin-svelte/src/utils/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import derivedHasSameInputsOutputs from '../rules/derived-has-same-inputs-output
1010
import experimentalRequireSlotTypes from '../rules/experimental-require-slot-types';
1111
import experimentalRequireStrictEvents from '../rules/experimental-require-strict-events';
1212
import firstAttributeLinebreak from '../rules/first-attribute-linebreak';
13+
import htmlClosingBracketNewLine from '../rules/html-closing-bracket-new-line';
1314
import htmlClosingBracketSpacing from '../rules/html-closing-bracket-spacing';
1415
import htmlQuotes from '../rules/html-quotes';
1516
import htmlSelfClosing from '../rules/html-self-closing';
@@ -76,6 +77,7 @@ export const rules = [
7677
experimentalRequireSlotTypes,
7778
experimentalRequireStrictEvents,
7879
firstAttributeLinebreak,
80+
htmlClosingBracketNewLine,
7981
htmlClosingBracketSpacing,
8082
htmlQuotes,
8183
htmlSelfClosing,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"options": [{ "multiline": "never" }]
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- message: Expected no line breaks before closing bracket, but 1 line break found.
2+
line: 2
3+
column: 12
4+
suggestions: null
5+
- message: Expected no line breaks before closing bracket, but 1 line break found.
6+
line: 7
7+
column: 12
8+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<div
2+
class="foo"
3+
></div>
4+
<div
5+
class="bar"></div>
6+
<div
7+
class="bar"
8+
>
9+
Children
10+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div
2+
class="foo"></div>
3+
<div
4+
class="bar"></div>
5+
<div
6+
class="bar">
7+
Children
8+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"options": [{ "selfClosingTag": { "singleline": "always", "multiline": "always" } }]
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- message: Expected 1 line break before closing bracket, but no line breaks found.
2+
line: 1
3+
column: 18
4+
suggestions: null
5+
- message: Expected 1 line break before closing bracket, but 2 line breaks found.
6+
line: 6
7+
column: 12
8+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Custom foo="bar" />
2+
<Custom
3+
foo="bar"
4+
/>
5+
<Custom
6+
foo="bar"
7+
8+
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Custom foo="bar"
2+
/>
3+
<Custom
4+
foo="bar"
5+
/>
6+
<Custom
7+
foo="bar"
8+
/>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"options": [{ "selfClosingTag": { "singleline": "never", "multiline": "never" } }]
3+
}

0 commit comments

Comments
 (0)