Repository: sveltejs/eslint-plugin-svelte (ts)

Issue:
More configuration options for `html-self-closing`
### Description

I would like to be able to configure `html-self-closing` so that it aways produces most compact, yet correct tags. That would mean preferring `void` > `self-closing` > `separate closing tag`, but only within spec limitations.

I was able to accomplish that for HTML/Svelte with `{ void: 'never', normal: 'never' }` settings (example output: `<img><div></div><Component/>`), but now it changes SVG tags from `<path/>` to `<path></path>`.



Pull request description:
feat: add config option for foreign elements in `svelte/html-self-closing` rule
This PR adds separate configuration option for foreign (SVG and MathML) elements to `svelte/html-self-closing` rule.

According to HTML spec (https://html.spec.whatwg.org/multipage/syntax.html#elements-2):
> Raw text, escapable raw text, and normal elements have a start tag to indicate where they begin, and an end tag to indicate where they end. The start and end tags of certain normal elements can be omitted, as described below in the section on optional tags. Those that cannot be omitted must not be omitted. Void elements only have a start tag; end tags must not be specified for void elements. Foreign elements must either have a start tag and an end tag, or a start tag that is marked as self-closing, in which case they must not have an end tag.

This means that `<div/>` is invalid, while `<path/>` (which is a SVG element) is OK. This configuration option would allow users to take advantage of the terser SVG syntax, while still using correct HTML syntax for normal elements (like `div`).

Closes #837.

Patch:
diff --git a/.changeset/rotten-news-swim.md b/.changeset/rotten-news-swim.md
new file mode 100644
index 0000000..3371852
--- /dev/null
+++ b/.changeset/rotten-news-swim.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': minor
+---
+
+feat: add config option for foreign elements in `svelte/html-self-closing` rule
diff --git a/docs/rules/html-self-closing.md b/docs/rules/html-self-closing.md
index c7d268b..5f9cc36 100644
--- a/docs/rules/html-self-closing.md
+++ b/docs/rules/html-self-closing.md
@@ -70,6 +70,7 @@ config object:
     {
       "void": "always", // or "never" or "ignore"
       "normal": "always", // or "never" or "ignore"
+      "foreign": "always", // or "never" or "ignore"
       "component": "always", // or "never" or "ignore"
       "svelte": "always" // or "never" or "ignore"
     }
@@ -86,6 +87,7 @@ presets:
 config object:
 
 - `void` (`"always"` in default preset)... Style of HTML void elements
+- `foreign` (`"always"` in default preset)... Style of foreign elements (SVG and MathML)
 - `component` (`"always"` in default preset)... Style of svelte components
 - `svelte` (`"always"` in default preset)... Style of svelte special elements (`<svelte:head>`, `<svelte:self>`)
 - `normal` (`"always"` in default preset)... Style of other elements
diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts
index 77d3c4c..c6b683f 100644
--- a/packages/eslint-plugin-svelte/src/rule-types.ts
+++ b/packages/eslint-plugin-svelte/src/rule-types.ts
@@ -379,6 +379,7 @@ type SvelteHtmlQuotes = []|[{
 type SvelteHtmlSelfClosing = []|[({
   void?: ("never" | "always" | "ignore")
   normal?: ("never" | "always" | "ignore")
+  foreign?: ("never" | "always" | "ignore")
   component?: ("never" | "always" | "ignore")
   svelte?: ("never" | "always" | "ignore")
 } | ("all" | "html" | "none"))]
diff --git a/packages/eslint-plugin-svelte/src/rules/html-self-closing.ts b/packages/eslint-plugin-svelte/src/rules/html-self-closing.ts
index d4bf01b..a929bdd 100644
--- a/packages/eslint-plugin-svelte/src/rules/html-self-closing.ts
+++ b/packages/eslint-plugin-svelte/src/rules/html-self-closing.ts
@@ -1,16 +1,17 @@
 import type { AST } from 'svelte-eslint-parser';
 import { createRule } from '../utils';
-import { getNodeName, isVoidHtmlElement } from '../utils/ast-utils';
+import { getNodeName, isVoidHtmlElement, isForeignElement } from '../utils/ast-utils';
 import { getSourceCode } from '../utils/compat';
 
 const TYPE_MESSAGES = {
 	normal: 'HTML elements',
 	void: 'HTML void elements',
+	foreign: 'foreign (SVG or MathML) elements',
 	component: 'Svelte custom components',
 	svelte: 'Svelte special elements'
 };
 
-type ElementTypes = 'normal' | 'void' | 'component' | 'svelte';
+type ElementTypes = 'normal' | 'void' | 'foreign' | 'component' | 'svelte';
 
 export default createRule('html-self-closing', {
 	meta: {
@@ -37,6 +38,9 @@ export default createRule('html-self-closing', {
 							normal: {
 								enum: ['never', 'always', 'ignore']
 							},
+							foreign: {
+								enum: ['never', 'always', 'ignore']
+							},
 							component: {
 								enum: ['never', 'always', 'ignore']
 							},
@@ -57,6 +61,7 @@ export default createRule('html-self-closing', {
 		let options = {
 			void: 'always',
 			normal: 'always',
+			foreign: 'always',
 			component: 'always',
 			svelte: 'always'
 		};
@@ -67,6 +72,7 @@ export default createRule('html-self-closing', {
 				options = {
 					void: 'never',
 					normal: 'never',
+					foreign: 'never',
 					component: 'never',
 					svelte: 'never'
 				};
@@ -75,6 +81,7 @@ export default createRule('html-self-closing', {
 				options = {
 					void: 'always',
 					normal: 'never',
+					foreign: 'always',
 					component: 'never',
 					svelte: 'always'
 				};
@@ -101,6 +108,7 @@ export default createRule('html-self-closing', {
 			if (node.kind === 'component') return 'component';
 			if (node.kind === 'special') return 'svelte';
 			if (isVoidHtmlElement(node)) return 'void';
+			if (isForeignElement(node)) return 'foreign';
 			return 'normal';
 		}
 
diff --git a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts
index 4011020..212e910 100644
--- a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts
+++ b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts
@@ -3,7 +3,7 @@ import type { TSESTree } from '@typescript-eslint/types';
 import type { Scope, Variable } from '@typescript-eslint/scope-manager';
 import type { AST as SvAST } from 'svelte-eslint-parser';
 import * as eslintUtils from '@eslint-community/eslint-utils';
-import voidElements from './void-elements';
+import { voidElements, svgElements, mathmlElements } from './element-types';
 import { getSourceCode } from './compat';
 
 /**
@@ -560,6 +560,13 @@ export function isVoidHtmlElement(node: SvAST.SvelteElement): boolean {
 	return voidElements.includes(getNodeName(node));
 }
 
+/**
+ * Returns true if element is known foreign (SVG or MathML) element
+ */
+export function isForeignElement(node: SvAST.SvelteElement): boolean {
+	return svgElements.includes(getNodeName(node)) || mathmlElements.includes(getNodeName(node));
+}
+
 /** Checks whether the given identifier node is used as an expression. */
 export function isExpressionIdentifier(node: TSESTree.Identifier): boolean {
 	const parent = node.parent;
diff --git a/packages/eslint-plugin-svelte/src/utils/element-types.ts b/packages/eslint-plugin-svelte/src/utils/element-types.ts
new file mode 100644
index 0000000..398afd6
--- /dev/null
+++ b/packages/eslint-plugin-svelte/src/utils/element-types.ts
@@ -0,0 +1,140 @@
+export const voidElements = [
+	'area',
+	'base',
+	'br',
+	'col',
+	'embed',
+	'hr',
+	'img',
+	'input',
+	'keygen',
+	'link',
+	'menuitem',
+	'meta',
+	'param',
+	'source',
+	'track',
+	'wbr'
+];
+
+export const svgElements = [
+	'altGlyph',
+	'altGlyphDef',
+	'altGlyphItem',
+	'animate',
+	'animateColor',
+	'animateMotion',
+	'animateTransform',
+	'circle',
+	'clipPath',
+	'color-profile',
+	'cursor',
+	'defs',
+	'desc',
+	'discard',
+	'ellipse',
+	'feBlend',
+	'feColorMatrix',
+	'feComponentTransfer',
+	'feComposite',
+	'feConvolveMatrix',
+	'feDiffuseLighting',
+	'feDisplacementMap',
+	'feDistantLight',
+	'feDropShadow',
+	'feFlood',
+	'feFuncA',
+	'feFuncB',
+	'feFuncG',
+	'feFuncR',
+	'feGaussianBlur',
+	'feImage',
+	'feMerge',
+	'feMergeNode',
+	'feMorphology',
+	'feOffset',
+	'fePointLight',
+	'feSpecularLighting',
+	'feSpotLight',
+	'feTile',
+	'feTurbulence',
+	'filter',
+	'font',
+	'font-face',
+	'font-face-format',
+	'font-face-name',
+	'font-face-src',
+	'font-face-uri',
+	'foreignObject',
+	'g',
+	'glyph',
+	'glyphRef',
+	'hatch',
+	'hatchpath',
+	'hkern',
+	'image',
+	'line',
+	'linearGradient',
+	'marker',
+	'mask',
+	'mesh',
+	'meshgradient',
+	'meshpatch',
+	'meshrow',
+	'metadata',
+	'missing-glyph',
+	'mpath',
+	'path',
+	'pattern',
+	'polygon',
+	'polyline',
+	'radialGradient',
+	'rect',
+	'set',
+	'solidcolor',
+	'stop',
+	'svg',
+	'switch',
+	'symbol',
+	'text',
+	'textPath',
+	'tref',
+	'tspan',
+	'unknown',
+	'use',
+	'view',
+	'vkern'
+];
+
+export const mathmlElements = [
+	'annotation',
+	'annotation-xml',
+	'maction',
+	'math',
+	'merror',
+	'mfrac',
+	'mi',
+	'mmultiscripts',
+	'mn',
+	'mo',
+	'mover',
+	'mpadded',
+	'mphantom',
+	'mprescripts',
+	'mroot',
+	'mrow',
+	'ms',
+	'mspace',
+	'msqrt',
+	'mstyle',
+	'msub',
+	'msubsup',
+	'msup',
+	'mtable',
+	'mtd',
+	'mtext',
+	'mtr',
+	'munder',
+	'munderover',
+	'semantics'
+];
diff --git a/packages/eslint-plugin-svelte/src/utils/void-elements.ts b/packages/eslint-plugin-svelte/src/utils/void-elements.ts
deleted file mode 100644
index 8e490d1..0000000
--- a/packages/eslint-plugin-svelte/src/utils/void-elements.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-const voidElements = [
-	'area',
-	'base',
-	'br',
-	'col',
-	'embed',
-	'hr',
-	'img',
-	'input',
-	'keygen',
-	'link',
-	'menuitem',
-	'meta',
-	'param',
-	'source',
-	'track',
-	'wbr'
-];
-
-export default voidElements;


Test patch:
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/_config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/_config.json
new file mode 100644
index 0000000..6081aab
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/_config.json
@@ -0,0 +1,7 @@
+{
+	"options": [
+		{
+			"foreign": "never"
+		}
+	]
+}
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-errors.yaml
new file mode 100644
index 0000000..a2e0ece
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-errors.yaml
@@ -0,0 +1,8 @@
+- message: Disallow self-closing on foreign (SVG or MathML) elements.
+  line: 2
+  column: 12
+  suggestions: null
+- message: Disallow self-closing on foreign (SVG or MathML) elements.
+  line: 3
+  column: 13
+  suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-input.svelte
new file mode 100644
index 0000000..2e6a960
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-input.svelte
@@ -0,0 +1,3 @@
+<!-- prettier-ignore -->
+<svg><path /></svg>
+<math><msup /></math>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-output.svelte
new file mode 100644
index 0000000..76223b7
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/foreign-never/svelte-never-output.svelte
@@ -0,0 +1,3 @@
+<!-- prettier-ignore -->
+<svg><path ></path></svg>
+<math><msup ></msup></math>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-errors.yaml
index b288399..ed068bc 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-errors.yaml
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-errors.yaml
@@ -10,7 +10,15 @@
   line: 5
   column: 18
   suggestions: null
+- message: Require self-closing on foreign (SVG or MathML) elements.
+  line: 6
+  column: 13
+  suggestions: null
+- message: Require self-closing on foreign (SVG or MathML) elements.
+  line: 7
+  column: 14
+  suggestions: null
 - message: Require self-closing on Svelte special elements.
-  line: 8
+  line: 10
   column: 13
   suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-input.svelte
index 35dc286..e9b5f17 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-input.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-input.svelte
@@ -3,6 +3,8 @@
   <div />
   <img>
   <TestComponent />
+  <svg><path></path></svg>
+  <math><msup></msup></math>
 </div>
 <!-- prettier-ignore -->
 <svelte:head></svelte:head>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-output.svelte
index c934246..d11d92b 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-output.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/html/preset-html-output.svelte
@@ -3,6 +3,8 @@
   <div ></div>
   <img/>
   <TestComponent ></TestComponent>
+  <svg><path/></svg>
+  <math><msup/></math>
 </div>
 <!-- prettier-ignore -->
 <svelte:head/>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-errors.yaml
index b15a063..f12f4c3 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-errors.yaml
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-errors.yaml
@@ -10,7 +10,15 @@
   line: 5
   column: 8
   suggestions: null
+- message: Disallow self-closing on foreign (SVG or MathML) elements.
+  line: 6
+  column: 14
+  suggestions: null
+- message: Disallow self-closing on foreign (SVG or MathML) elements.
+  line: 7
+  column: 15
+  suggestions: null
 - message: Disallow self-closing on Svelte special elements.
-  line: 8
+  line: 10
   column: 14
   suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-input.svelte
index 61675d7..5a6369c 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-input.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-input.svelte
@@ -3,6 +3,8 @@
   <div />
   <TestComponent />
   <img />
+  <svg><path /></svg>
+  <math><msup /></math>
 </div>
 <!-- prettier-ignore -->
 <svelte:head />
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-output.svelte
index 42d0a6c..4246e6c 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-output.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/invalid/presets/none/preset-none-output.svelte
@@ -3,6 +3,8 @@
   <div ></div>
   <TestComponent ></TestComponent>
   <img >
+  <svg><path ></path></svg>
+  <math><msup ></msup></math>
 </div>
 <!-- prettier-ignore -->
 <svelte:head ></svelte:head>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte
index c974189..082bc47 100644
--- a/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/html-self-closing/valid/test01-input.svelte
@@ -2,6 +2,8 @@
 	<div />
 	<div>hello</div>
 	<img />
+	<svg><path /></svg>
+	<math><msup /></math>
 	{#if true}
 		<svelte:self />
 	{/if}