diff --git a/README.md b/README.md
index 339c54c9e..452dc1880 100644
--- a/README.md
+++ b/README.md
@@ -294,6 +294,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
| [svelte/first-attribute-linebreak](https://ota-meshi.github.io/eslint-plugin-svelte/rules/first-attribute-linebreak/) | enforce the location of first attribute | :wrench: |
| [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: |
| [svelte/html-quotes](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-quotes/) | enforce quotes style of HTML attributes | :wrench: |
+| [svelte/html-self-closing](https://ota-meshi.github.io/eslint-plugin-svelte/rules/html-self-closing/) | enforce self-closing style | :wrench: |
| [svelte/indent](https://ota-meshi.github.io/eslint-plugin-svelte/rules/indent/) | enforce consistent indentation | :wrench: |
| [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: |
| [svelte/mustache-spacing](https://ota-meshi.github.io/eslint-plugin-svelte/rules/mustache-spacing/) | enforce unified spacing in mustache | :wrench: |
diff --git a/docs/rules.md b/docs/rules.md
index 900cdcc55..a80220047 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -54,6 +54,7 @@ These rules relate to style guidelines, and are therefore quite subjective:
| [svelte/first-attribute-linebreak](./rules/first-attribute-linebreak.md) | enforce the location of first attribute | :wrench: |
| [svelte/html-closing-bracket-spacing](./rules/html-closing-bracket-spacing.md) | require or disallow a space before tag's closing brackets | :wrench: |
| [svelte/html-quotes](./rules/html-quotes.md) | enforce quotes style of HTML attributes | :wrench: |
+| [svelte/html-self-closing](./rules/html-self-closing.md) | enforce self-closing style | :wrench: |
| [svelte/indent](./rules/indent.md) | enforce consistent indentation | :wrench: |
| [svelte/max-attributes-per-line](./rules/max-attributes-per-line.md) | enforce the maximum number of attributes per line | :wrench: |
| [svelte/mustache-spacing](./rules/mustache-spacing.md) | enforce unified spacing in mustache | :wrench: |
diff --git a/docs/rules/html-self-closing.md b/docs/rules/html-self-closing.md
new file mode 100644
index 000000000..45878662a
--- /dev/null
+++ b/docs/rules/html-self-closing.md
@@ -0,0 +1,80 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "svelte/html-self-closing"
+description: "enforce self-closing style"
+---
+
+# svelte/html-self-closing
+
+> enforce self-closing style
+
+- :exclamation: **_This rule has not been released yet._**
+- :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.
+
+## :book: Rule Details
+
+You can choose either two styles for elements without content
+
+- always: `
`
+- never: ``
+
+
+
+
+
+
+```svelte
+
+
+
+
+Hello
+
+
+
+
+
+
+
+
+
+
+```
+
+
+
+
+
+## :wrench: Options
+
+```jsonc
+{
+ "svelte/html-self-closing": [
+ "error",
+ {
+ "void": "always", // or "always" or "ignore"
+ "normal": "always", // or "never" or "ignore"
+ "component": "always", // or "never" or "ignore"
+ "svelte": "always" // or "never" or "ignore"
+ }
+ ]
+}
+```
+
+- `void` (`"always"` by default)... Style of HTML void elements
+- `component` (`"always"` by default)... Style of svelte components
+- `svelte` (`"always"` by default)... Style of svelte special elements (``, ``)
+- `normal` (`"always"` by default)... Style of other elements
+
+Every option can be set to
+- "always" (``)
+- "never" (``)
+- "ignore" (either `` or ``)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/html-self-closing.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/html-self-closing.ts)
diff --git a/src/configs/prettier.ts b/src/configs/prettier.ts
index 756aba288..20ea0c6a7 100644
--- a/src/configs/prettier.ts
+++ b/src/configs/prettier.ts
@@ -9,6 +9,7 @@ export = {
"svelte/first-attribute-linebreak": "off",
"svelte/html-closing-bracket-spacing": "off",
"svelte/html-quotes": "off",
+ "svelte/html-self-closing": "off",
"svelte/indent": "off",
"svelte/max-attributes-per-line": "off",
"svelte/mustache-spacing": "off",
diff --git a/src/rules/html-self-closing.ts b/src/rules/html-self-closing.ts
new file mode 100644
index 000000000..6e10d65e2
--- /dev/null
+++ b/src/rules/html-self-closing.ts
@@ -0,0 +1,141 @@
+import type { AST } from "svelte-eslint-parser"
+import { createRule } from "../utils"
+import { getNodeName, isVoidHtmlElement } from "../utils/ast-utils"
+
+const TYPE_MESSAGES = {
+ normal: "HTML elements",
+ void: "HTML void elements",
+ component: "Svelte custom components",
+ svelte: "Svelte special elements",
+}
+
+type ElementTypes = "normal" | "void" | "component" | "svelte"
+
+export default createRule("html-self-closing", {
+ meta: {
+ docs: {
+ description: "enforce self-closing style",
+ category: "Stylistic Issues",
+ recommended: false,
+ conflictWithPrettier: true,
+ },
+ type: "layout",
+ fixable: "code",
+ messages: {
+ requireClosing: "Require self-closing on {{type}}.",
+ disallowClosing: "Disallow self-closing on {{type}}.",
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ void: {
+ enum: ["never", "always", "ignore"],
+ },
+ normal: {
+ enum: ["never", "always", "ignore"],
+ },
+ component: {
+ enum: ["never", "always", "ignore"],
+ },
+ svelte: {
+ enum: ["never", "always", "ignore"],
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+ create(ctx) {
+ const options = {
+ void: "always",
+ normal: "always",
+ component: "always",
+ svelte: "always",
+ ...ctx.options?.[0],
+ }
+
+ /**
+ * Get SvelteElement type.
+ * If element is custom component "component" is returned
+ * If element is svelte special element such as svelte:self "svelte" is returned
+ * If element is void element "void" is returned
+ * otherwise "normal" is returned
+ */
+ function getElementType(node: AST.SvelteElement): ElementTypes {
+ if (node.kind === "component") return "component"
+ if (node.kind === "special") return "svelte"
+ if (isVoidHtmlElement(node)) return "void"
+ return "normal"
+ }
+
+ /**
+ * Returns true if element has no children, or has only whitespace text
+ */
+ function isElementEmpty(node: AST.SvelteElement): boolean {
+ if (node.children.length <= 0) return true
+
+ for (const child of node.children) {
+ if (child.type !== "SvelteText") return false
+ if (!/^\s*$/.test(child.value)) return false
+ }
+
+ return true
+ }
+
+ /**
+ * Report
+ */
+ function report(node: AST.SvelteElement, close: boolean) {
+ const elementType = getElementType(node)
+
+ ctx.report({
+ node,
+ messageId: close ? "requireClosing" : "disallowClosing",
+ data: {
+ type: TYPE_MESSAGES[elementType],
+ },
+ *fix(fixer) {
+ if (close) {
+ for (const child of node.children) {
+ yield fixer.removeRange(child.range)
+ }
+
+ yield fixer.insertTextBeforeRange(
+ [node.startTag.range[1] - 1, node.startTag.range[1]],
+ "/",
+ )
+
+ if (node.endTag) yield fixer.removeRange(node.endTag.range)
+ } else {
+ yield fixer.removeRange([
+ node.startTag.range[1] - 2,
+ node.startTag.range[1] - 1,
+ ])
+
+ if (!isVoidHtmlElement(node))
+ yield fixer.insertTextAfter(node, `${getNodeName(node)}>`)
+ }
+ },
+ })
+ }
+
+ return {
+ SvelteElement(node: AST.SvelteElement) {
+ if (!isElementEmpty(node)) return
+
+ const elementType = getElementType(node)
+
+ const elementTypeOptions = options[elementType]
+ if (elementTypeOptions === "ignore") return
+ const shouldBeClosed = elementTypeOptions === "always"
+
+ if (shouldBeClosed && !node.startTag.selfClosing) {
+ report(node, true)
+ } else if (!shouldBeClosed && node.startTag.selfClosing) {
+ report(node, false)
+ }
+ },
+ }
+ },
+})
diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts
index 8981ca3bb..112e4ff7a 100644
--- a/src/utils/ast-utils.ts
+++ b/src/utils/ast-utils.ts
@@ -3,6 +3,7 @@ import type * as ESTree from "estree"
import type { AST as SvAST } from "svelte-eslint-parser"
import * as eslintUtils from "eslint-utils"
import type { Scope } from "eslint"
+import voidElements from "./void-elements"
/**
* Checks whether or not the tokens of two given nodes are same.
@@ -437,3 +438,30 @@ function getAttributeValueRangeTokens(
lastToken: tokens.closeToken,
}
}
+
+/**
+ * Returns name of SvelteElement
+ */
+export function getNodeName(node: SvAST.SvelteElement): string {
+ if ("name" in node.name) {
+ return node.name.name
+ }
+ let object = ""
+ let currentObject = node.name.object
+ while ("object" in currentObject) {
+ object = `${currentObject.property.name}.${object}`
+ currentObject = currentObject.object
+ }
+ if ("name" in currentObject) {
+ object = `${currentObject.name}.${object}`
+ }
+ return object + node.name.property.name
+}
+
+/**
+ * Returns true if element is known void element
+ * {@link https://developer.mozilla.org/en-US/docs/Glossary/Empty_element}
+ */
+export function isVoidHtmlElement(node: SvAST.SvelteElement): boolean {
+ return voidElements.includes(getNodeName(node))
+}
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index 8a2b068fc..41351bfc3 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -4,6 +4,7 @@ import commentDirective from "../rules/comment-directive"
import firstAttributeLinebreak from "../rules/first-attribute-linebreak"
import htmlClosingBracketSpacing from "../rules/html-closing-bracket-spacing"
import htmlQuotes from "../rules/html-quotes"
+import htmlSelfClosing from "../rules/html-self-closing"
import indent from "../rules/indent"
import maxAttributesPerLine from "../rules/max-attributes-per-line"
import mustacheSpacing from "../rules/mustache-spacing"
@@ -36,6 +37,7 @@ export const rules = [
firstAttributeLinebreak,
htmlClosingBracketSpacing,
htmlQuotes,
+ htmlSelfClosing,
indent,
maxAttributesPerLine,
mustacheSpacing,
diff --git a/src/utils/void-elements.ts b/src/utils/void-elements.ts
new file mode 100644
index 000000000..4df980dc6
--- /dev/null
+++ b/src/utils/void-elements.ts
@@ -0,0 +1,20 @@
+const voidElements = [
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "keygen",
+ "link",
+ "menuitem",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr",
+]
+
+export default voidElements
diff --git a/tests/fixtures/rules/html-self-closing/invalid/component-never/_config.json b/tests/fixtures/rules/html-self-closing/invalid/component-never/_config.json
new file mode 100644
index 000000000..7a7c92aa6
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/component-never/_config.json
@@ -0,0 +1,7 @@
+{
+ "options": [
+ {
+ "component": "never"
+ }
+ ]
+}
diff --git a/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-errors.json b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-errors.json
new file mode 100644
index 000000000..a7d505baf
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-errors.json
@@ -0,0 +1,12 @@
+[
+ {
+ "message": "Disallow self-closing on Svelte custom components.",
+ "line": 3,
+ "column": 3
+ },
+ {
+ "message": "Disallow self-closing on Svelte custom components.",
+ "line": 4,
+ "column": 3
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-input.svelte
new file mode 100644
index 000000000..61b1ad263
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-input.svelte
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-output.svelte
new file mode 100644
index 000000000..fcabea330
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/component-never/component-never-output.svelte
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/_config.json b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/_config.json
new file mode 100644
index 000000000..18bfad55d
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/_config.json
@@ -0,0 +1,7 @@
+{
+ "options": [
+ {
+ "normal": "ignore"
+ }
+ ]
+}
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-errors.json b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-errors.json
new file mode 100644
index 000000000..c14a98697
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-errors.json
@@ -0,0 +1,7 @@
+[
+ {
+ "message": "Require self-closing on HTML void elements.",
+ "line": 5,
+ "column": 3
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-input.svelte
new file mode 100644
index 000000000..14f69ea78
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-input.svelte
@@ -0,0 +1,6 @@
+
+
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-output.svelte
new file mode 100644
index 000000000..eedf2bb1f
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-ignore/normal-any-output.svelte
@@ -0,0 +1,6 @@
+
+
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-never/_config.json b/tests/fixtures/rules/html-self-closing/invalid/normal-never/_config.json
new file mode 100644
index 000000000..0bf73a739
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-never/_config.json
@@ -0,0 +1,7 @@
+{
+ "options": [
+ {
+ "normal": "never"
+ }
+ ]
+}
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-errors.json b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-errors.json
new file mode 100644
index 000000000..1d62f7723
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-errors.json
@@ -0,0 +1,7 @@
+[
+ {
+ "message": "Disallow self-closing on HTML elements.",
+ "line": 3,
+ "column": 3
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-input.svelte
new file mode 100644
index 000000000..5c8a2cf01
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-input.svelte
@@ -0,0 +1,4 @@
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-output.svelte
new file mode 100644
index 000000000..a45145e01
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/normal-never/component-never-output.svelte
@@ -0,0 +1,4 @@
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/svelte-never/_config.json b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/_config.json
new file mode 100644
index 000000000..697ddfa81
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/_config.json
@@ -0,0 +1,7 @@
+{
+ "options": [
+ {
+ "svelte": "never"
+ }
+ ]
+}
diff --git a/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-errors.json b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-errors.json
new file mode 100644
index 000000000..6531399aa
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-errors.json
@@ -0,0 +1,7 @@
+[
+ {
+ "message": "Disallow self-closing on Svelte special elements.",
+ "line": 2,
+ "column": 1
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-input.svelte
new file mode 100644
index 000000000..47943f3a6
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-input.svelte
@@ -0,0 +1,2 @@
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-output.svelte
new file mode 100644
index 000000000..161b1f0a1
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/svelte-never/svelte-never-output.svelte
@@ -0,0 +1,2 @@
+
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/test01-errors.json b/tests/fixtures/rules/html-self-closing/invalid/test01-errors.json
new file mode 100644
index 000000000..7ab1a841f
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/test01-errors.json
@@ -0,0 +1,17 @@
+[
+ {
+ "message": "Require self-closing on HTML elements.",
+ "line": 3,
+ "column": 3
+ },
+ {
+ "message": "Require self-closing on Svelte custom components.",
+ "line": 4,
+ "column": 3
+ },
+ {
+ "message": "Require self-closing on HTML void elements.",
+ "line": 5,
+ "column": 3
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/test01-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/test01-input.svelte
new file mode 100644
index 000000000..b8d7b8051
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/test01-input.svelte
@@ -0,0 +1,6 @@
+
+
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/test01-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/test01-output.svelte
new file mode 100644
index 000000000..d8a1286c5
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/test01-output.svelte
@@ -0,0 +1,6 @@
+
+
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/void-never/_config.json b/tests/fixtures/rules/html-self-closing/invalid/void-never/_config.json
new file mode 100644
index 000000000..f395b7ad8
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/void-never/_config.json
@@ -0,0 +1,7 @@
+{
+ "options": [
+ {
+ "void": "never"
+ }
+ ]
+}
diff --git a/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-errors.json b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-errors.json
new file mode 100644
index 000000000..3b61e0b15
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-errors.json
@@ -0,0 +1,7 @@
+[
+ {
+ "message": "Disallow self-closing on HTML void elements.",
+ "line": 3,
+ "column": 3
+ }
+]
diff --git a/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-input.svelte b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-input.svelte
new file mode 100644
index 000000000..42ea6dbf7
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-input.svelte
@@ -0,0 +1,4 @@
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-output.svelte b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-output.svelte
new file mode 100644
index 000000000..713cf9f72
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/invalid/void-never/void-never-output.svelte
@@ -0,0 +1,4 @@
+
+
+
![]()
+
diff --git a/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte b/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte
new file mode 100644
index 000000000..55bbb8893
--- /dev/null
+++ b/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte
@@ -0,0 +1,9 @@
+
+
+
hello
+
![]()
+ {#if true}
+
+ {/if}
+
+
diff --git a/tests/src/rules/html-self-closing.ts b/tests/src/rules/html-self-closing.ts
new file mode 100644
index 000000000..6890e9087
--- /dev/null
+++ b/tests/src/rules/html-self-closing.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from "eslint"
+import rule from "../../../src/rules/html-self-closing"
+import { loadTestCases } from "../../utils/utils"
+
+const tester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+})
+
+tester.run("html-self-closing", rule as any, loadTestCases("html-self-closing"))