Skip to content

Commit 54d0d68

Browse files
authored
feat: add svelte/html-self-closing (#190)
* feat: add `svelte/html-self-closing` Moved to separate PR from #186 * chore: requested changes * fix: test not passing, lint, edit rule docs * chore(html-self-closing): move options from html object, move utils to ast-utils
1 parent bc23974 commit 54d0d68

33 files changed

+441
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
297297
| [svelte/first-attribute-linebreak](https://ota-meshi.github.io/eslint-plugin-svelte/rules/first-attribute-linebreak/) | enforce the location of first attribute | :wrench: |
298298
| [svelte/html-closing-bracket-spacing](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-closing-bracket-spacing/) | require or disallow a space before tag's closing brackets | :wrench: |
299299
| [svelte/html-quotes](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-quotes/) | enforce quotes style of HTML attributes | :wrench: |
300+
| [svelte/html-self-closing](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-self-closing/) | enforce self-closing style | :wrench: |
300301
| [svelte/indent](https://ota-meshi.github.io/eslint-plugin-svelte/rules/indent/) | enforce consistent indentation | :wrench: |
301302
| [svelte/max-attributes-per-line](https://ota-meshi.github.io/eslint-plugin-svelte/rules/max-attributes-per-line/) | enforce the maximum number of attributes per line | :wrench: |
302303
| [svelte/mustache-spacing](https://ota-meshi.github.io/eslint-plugin-svelte/rules/mustache-spacing/) | enforce unified spacing in mustache | :wrench: |

docs/rules.md

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
5757
| [svelte/first-attribute-linebreak](./rules/first-attribute-linebreak.md) | enforce the location of first attribute | :wrench: |
5858
| [svelte/html-closing-bracket-spacing](./rules/html-closing-bracket-spacing.md) | require or disallow a space before tag's closing brackets | :wrench: |
5959
| [svelte/html-quotes](./rules/html-quotes.md) | enforce quotes style of HTML attributes | :wrench: |
60+
| [svelte/html-self-closing](./rules/html-self-closing.md) | enforce self-closing style | :wrench: |
6061
| [svelte/indent](./rules/indent.md) | enforce consistent indentation | :wrench: |
6162
| [svelte/max-attributes-per-line](./rules/max-attributes-per-line.md) | enforce the maximum number of attributes per line | :wrench: |
6263
| [svelte/mustache-spacing](./rules/mustache-spacing.md) | enforce unified spacing in mustache | :wrench: |

docs/rules/html-self-closing.md

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "svelte/html-self-closing"
5+
description: "enforce self-closing style"
6+
---
7+
8+
# svelte/html-self-closing
9+
10+
> enforce self-closing style
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+
You can choose either two styles for elements without content
18+
19+
- always: `<div />`
20+
- never: `<div></div>`
21+
22+
<ESLintCodeBlock fix>
23+
24+
<!-- prettier-ignore-start -->
25+
<!--eslint-skip-->
26+
27+
```svelte
28+
<script>
29+
/* eslint svelte/html-self-closing: "error" */
30+
</script>
31+
32+
<!-- ✓ GOOD -->
33+
<div />
34+
<p>Hello</p>
35+
<div><div /></div>
36+
<img />
37+
<svelte:head />
38+
39+
<!-- ✗ BAD -->
40+
<div></div>
41+
<p> </p>
42+
<div><div></div></div>
43+
<img>
44+
<svelte:head></svelte:head>
45+
```
46+
47+
<!-- prettier-ignore-end -->
48+
49+
</ESLintCodeBlock>
50+
51+
## :wrench: Options
52+
53+
```jsonc
54+
{
55+
"svelte/html-self-closing": [
56+
"error",
57+
{
58+
"void": "always", // or "always" or "ignore"
59+
"normal": "always", // or "never" or "ignore"
60+
"component": "always", // or "never" or "ignore"
61+
"svelte": "always" // or "never" or "ignore"
62+
}
63+
]
64+
}
65+
```
66+
67+
- `void` (`"always"` by default)... Style of HTML void elements
68+
- `component` (`"always"` by default)... Style of svelte components
69+
- `svelte` (`"always"` by default)... Style of svelte special elements (`<svelte:head>`, `<svelte:self>`)
70+
- `normal` (`"always"` by default)... Style of other elements
71+
72+
Every option can be set to
73+
- "always" (`<div />`)
74+
- "never" (`<div></div>`)
75+
- "ignore" (either `<div />` or `<div></div>`)
76+
77+
## :mag: Implementation
78+
79+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/html-self-closing.ts)
80+
- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/html-self-closing.ts)

src/configs/prettier.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export = {
99
"svelte/first-attribute-linebreak": "off",
1010
"svelte/html-closing-bracket-spacing": "off",
1111
"svelte/html-quotes": "off",
12+
"svelte/html-self-closing": "off",
1213
"svelte/indent": "off",
1314
"svelte/max-attributes-per-line": "off",
1415
"svelte/mustache-spacing": "off",

src/rules/html-self-closing.ts

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { AST } from "svelte-eslint-parser"
2+
import { createRule } from "../utils"
3+
import { getNodeName, isVoidHtmlElement } from "../utils/ast-utils"
4+
5+
const TYPE_MESSAGES = {
6+
normal: "HTML elements",
7+
void: "HTML void elements",
8+
component: "Svelte custom components",
9+
svelte: "Svelte special elements",
10+
}
11+
12+
type ElementTypes = "normal" | "void" | "component" | "svelte"
13+
14+
export default createRule("html-self-closing", {
15+
meta: {
16+
docs: {
17+
description: "enforce self-closing style",
18+
category: "Stylistic Issues",
19+
recommended: false,
20+
conflictWithPrettier: true,
21+
},
22+
type: "layout",
23+
fixable: "code",
24+
messages: {
25+
requireClosing: "Require self-closing on {{type}}.",
26+
disallowClosing: "Disallow self-closing on {{type}}.",
27+
},
28+
schema: [
29+
{
30+
type: "object",
31+
properties: {
32+
void: {
33+
enum: ["never", "always", "ignore"],
34+
},
35+
normal: {
36+
enum: ["never", "always", "ignore"],
37+
},
38+
component: {
39+
enum: ["never", "always", "ignore"],
40+
},
41+
svelte: {
42+
enum: ["never", "always", "ignore"],
43+
},
44+
},
45+
additionalProperties: false,
46+
},
47+
],
48+
},
49+
create(ctx) {
50+
const options = {
51+
void: "always",
52+
normal: "always",
53+
component: "always",
54+
svelte: "always",
55+
...ctx.options?.[0],
56+
}
57+
58+
/**
59+
* Get SvelteElement type.
60+
* If element is custom component "component" is returned
61+
* If element is svelte special element such as svelte:self "svelte" is returned
62+
* If element is void element "void" is returned
63+
* otherwise "normal" is returned
64+
*/
65+
function getElementType(node: AST.SvelteElement): ElementTypes {
66+
if (node.kind === "component") return "component"
67+
if (node.kind === "special") return "svelte"
68+
if (isVoidHtmlElement(node)) return "void"
69+
return "normal"
70+
}
71+
72+
/**
73+
* Returns true if element has no children, or has only whitespace text
74+
*/
75+
function isElementEmpty(node: AST.SvelteElement): boolean {
76+
if (node.children.length <= 0) return true
77+
78+
for (const child of node.children) {
79+
if (child.type !== "SvelteText") return false
80+
if (!/^\s*$/.test(child.value)) return false
81+
}
82+
83+
return true
84+
}
85+
86+
/**
87+
* Report
88+
*/
89+
function report(node: AST.SvelteElement, close: boolean) {
90+
const elementType = getElementType(node)
91+
92+
ctx.report({
93+
node,
94+
messageId: close ? "requireClosing" : "disallowClosing",
95+
data: {
96+
type: TYPE_MESSAGES[elementType],
97+
},
98+
*fix(fixer) {
99+
if (close) {
100+
for (const child of node.children) {
101+
yield fixer.removeRange(child.range)
102+
}
103+
104+
yield fixer.insertTextBeforeRange(
105+
[node.startTag.range[1] - 1, node.startTag.range[1]],
106+
"/",
107+
)
108+
109+
if (node.endTag) yield fixer.removeRange(node.endTag.range)
110+
} else {
111+
yield fixer.removeRange([
112+
node.startTag.range[1] - 2,
113+
node.startTag.range[1] - 1,
114+
])
115+
116+
if (!isVoidHtmlElement(node))
117+
yield fixer.insertTextAfter(node, `</${getNodeName(node)}>`)
118+
}
119+
},
120+
})
121+
}
122+
123+
return {
124+
SvelteElement(node: AST.SvelteElement) {
125+
if (!isElementEmpty(node)) return
126+
127+
const elementType = getElementType(node)
128+
129+
const elementTypeOptions = options[elementType]
130+
if (elementTypeOptions === "ignore") return
131+
const shouldBeClosed = elementTypeOptions === "always"
132+
133+
if (shouldBeClosed && !node.startTag.selfClosing) {
134+
report(node, true)
135+
} else if (!shouldBeClosed && node.startTag.selfClosing) {
136+
report(node, false)
137+
}
138+
},
139+
}
140+
},
141+
})

src/utils/ast-utils.ts

+28
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type * as ESTree from "estree"
33
import type { AST as SvAST } from "svelte-eslint-parser"
44
import * as eslintUtils from "eslint-utils"
55
import type { Scope } from "eslint"
6+
import voidElements from "./void-elements"
67

78
/**
89
* Checks whether or not the tokens of two given nodes are same.
@@ -494,3 +495,30 @@ function getAttributeValueRangeTokens(
494495
lastToken: tokens.closeToken,
495496
}
496497
}
498+
499+
/**
500+
* Returns name of SvelteElement
501+
*/
502+
export function getNodeName(node: SvAST.SvelteElement): string {
503+
if ("name" in node.name) {
504+
return node.name.name
505+
}
506+
let object = ""
507+
let currentObject = node.name.object
508+
while ("object" in currentObject) {
509+
object = `${currentObject.property.name}.${object}`
510+
currentObject = currentObject.object
511+
}
512+
if ("name" in currentObject) {
513+
object = `${currentObject.name}.${object}`
514+
}
515+
return object + node.name.property.name
516+
}
517+
518+
/**
519+
* Returns true if element is known void element
520+
* {@link https://developer.mozilla.org/en-US/docs/Glossary/Empty_element}
521+
*/
522+
export function isVoidHtmlElement(node: SvAST.SvelteElement): boolean {
523+
return voidElements.includes(getNodeName(node))
524+
}

src/utils/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import commentDirective from "../rules/comment-directive"
44
import firstAttributeLinebreak from "../rules/first-attribute-linebreak"
55
import htmlClosingBracketSpacing from "../rules/html-closing-bracket-spacing"
66
import htmlQuotes from "../rules/html-quotes"
7+
import htmlSelfClosing from "../rules/html-self-closing"
78
import indent from "../rules/indent"
89
import maxAttributesPerLine from "../rules/max-attributes-per-line"
910
import mustacheSpacing from "../rules/mustache-spacing"
@@ -40,6 +41,7 @@ export const rules = [
4041
firstAttributeLinebreak,
4142
htmlClosingBracketSpacing,
4243
htmlQuotes,
44+
htmlSelfClosing,
4345
indent,
4446
maxAttributesPerLine,
4547
mustacheSpacing,

src/utils/void-elements.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const voidElements = [
2+
"area",
3+
"base",
4+
"br",
5+
"col",
6+
"embed",
7+
"hr",
8+
"img",
9+
"input",
10+
"keygen",
11+
"link",
12+
"menuitem",
13+
"meta",
14+
"param",
15+
"source",
16+
"track",
17+
"wbr",
18+
]
19+
20+
export default voidElements
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": [
3+
{
4+
"component": "never"
5+
}
6+
]
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
{
3+
"message": "Disallow self-closing on Svelte custom components.",
4+
"line": 3,
5+
"column": 3
6+
},
7+
{
8+
"message": "Disallow self-closing on Svelte custom components.",
9+
"line": 4,
10+
"column": 3
11+
}
12+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!-- prettier-ignore -->
2+
<div>
3+
<CustomElement />
4+
<I.Am.A.Foo />
5+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!-- prettier-ignore -->
2+
<div>
3+
<CustomElement ></CustomElement>
4+
<I.Am.A.Foo ></I.Am.A.Foo>
5+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": [
3+
{
4+
"normal": "ignore"
5+
}
6+
]
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{
3+
"message": "Require self-closing on HTML void elements.",
4+
"line": 5,
5+
"column": 3
6+
}
7+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!-- prettier-ignore -->
2+
<div>
3+
<div />
4+
<div></div>
5+
<img>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!-- prettier-ignore -->
2+
<div>
3+
<div />
4+
<div></div>
5+
<img/>
6+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": [
3+
{
4+
"normal": "never"
5+
}
6+
]
7+
}

0 commit comments

Comments
 (0)