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

Issue:
Detect invalid HTML elements
### Motivation

I found that in Svelte 4.0, we had used the following syntax:
```svelte
<head>
  <script>...</script>
</head>

<svelte:head>
   <title>...</title>
</svelte:head>

<div>
</div>
```

But it turns out that in Svelte 5.0 (and officially even before Svelte 5.0, even though it worked :P) this is not valid syntax.

### Description

The idea is to have a list of Svelte tags (known as "Special elements" in the Svelte docs) which are only supported with the `svelte:` syntax, e.g. `head`, `body`, `window`, `document`, `element` and to automatically notify the user if they are incorrect using these tags without the `svelte:` prefix. AFAIK, the linter already blocks components from using lower-case names (`component-name-lowercase`), so I believe that this rule would be exceedingly unlikely to trigger false positives.

### Examples

```svelte
<script>
</script>

<!-- ✓ GOOD -->
<svelte:head>
...
</svelte:head>

<!-- ✗ BAD -->
<head>
...
</head>
```


### Additional comments

_No response_

Pull request description:
feat: add rule `no-deprecated-raw-special-elements`
~Adds a new Svelte 5 specific rule that ensure special elements are used with
`svelte:` prefix. This rule will help on migrating from Svelte 4, as such
elements were supported without the prefix in Svelte 4.~

Adds a new rule that recommends not using raw special elements and fixes to
using the `svelte:` prefix. Raw special elements are deprecated from v5 on.

Closes #913


Patch:
diff --git a/.changeset/lazy-eyes-wait.md b/.changeset/lazy-eyes-wait.md
new file mode 100644
index 0000000..74f2c86
--- /dev/null
+++ b/.changeset/lazy-eyes-wait.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-svelte': minor
+---
+
+Added new `no-deprecated-raw-special-elements` rule
diff --git a/README.md b/README.md
index 9682cbf..7c56952 100644
--- a/README.md
+++ b/README.md
@@ -380,6 +380,7 @@ These rules relate to possible syntax or logic errors in Svelte code:
 | Rule ID | Description |    |
 |:--------|:------------|:---|
 | [svelte/infinite-reactive-loop](https://sveltejs.github.io/eslint-plugin-svelte/rules/infinite-reactive-loop/) | Svelte runtime prevents calling the same reactive statement twice in a microtask. But between different microtask, it doesn't prevent. |  |
+| [svelte/no-deprecated-raw-special-elements](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-deprecated-raw-special-elements/) | Recommends not using raw special elements in Svelte versions previous to 5. | :wrench: |
 | [svelte/no-dom-manipulating](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dom-manipulating/) | disallow DOM manipulating |  |
 | [svelte/no-dupe-else-if-blocks](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dupe-else-if-blocks/) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: |
 | [svelte/no-dupe-on-directives](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dupe-on-directives/) | disallow duplicate `on:` directives |  |
diff --git a/docs/rules.md b/docs/rules.md
index fb067e5..29701ff 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -17,6 +17,7 @@ These rules relate to possible syntax or logic errors in Svelte code:
 | Rule ID                                                                                                        | Description                                                                                                                            |                |
 | :------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | :------------- |
 | [svelte/infinite-reactive-loop](./rules/infinite-reactive-loop.md)                                             | Svelte runtime prevents calling the same reactive statement twice in a microtask. But between different microtask, it doesn't prevent. |                |
+| [svelte/no-deprecated-raw-special-elements](./rules/no-deprecated-raw-special-elements.md)                     | Recommends not using raw special elements in Svelte versions previous to 5.                                                            | :wrench:       |
 | [svelte/no-dom-manipulating](./rules/no-dom-manipulating.md)                                                   | disallow DOM manipulating                                                                                                              |                |
 | [svelte/no-dupe-else-if-blocks](./rules/no-dupe-else-if-blocks.md)                                             | disallow duplicate conditions in `{#if}` / `{:else if}` chains                                                                         | :star:         |
 | [svelte/no-dupe-on-directives](./rules/no-dupe-on-directives.md)                                               | disallow duplicate `on:` directives                                                                                                    |                |
diff --git a/docs/rules/no-deprecated-raw-special-elements.md b/docs/rules/no-deprecated-raw-special-elements.md
new file mode 100644
index 0000000..4bf863a
--- /dev/null
+++ b/docs/rules/no-deprecated-raw-special-elements.md
@@ -0,0 +1,52 @@
+---
+pageClass: 'rule-details'
+sidebarDepth: 0
+title: 'svelte/no-deprecated-raw-special-elements'
+description: 'Recommends not using raw special elements in Svelte versions previous to 5.'
+---
+
+# svelte/no-deprecated-raw-special-elements
+
+> Recommends not using raw special elements in Svelte versions previous to 5.
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
+- :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
+
+This rule reports the usage of `head`, `body`, `window`, `document`, `element` and `options` HTML elements. These elements were valid in in versions proior to 5, but since Svelte 5 they must be used with `svelte:`.
+
+<ESLintCodeBlock fix>
+
+<!--eslint-skip-->
+
+```svelte
+<script>
+  /* eslint svelte/no-deprecated-raw-special-elements: "error" */
+</script>
+
+<!-- ✓ GOOD -->
+<svelte:head>
+  <title>Valid</title>
+</svelte:head>
+
+<!-- ✗ BAD -->
+<head>
+  <title>Invalid</title>
+</head>
+```
+
+</ESLintCodeBlock>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- See special elements section in [Svelte docs](https://svelte.dev/docs/svelte/svelte-window)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/no-deprecated-raw-special-elements.ts)
+- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/no-deprecated-raw-special-elements.ts)
diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts
index 8e747b6..8b7cbe8 100644
--- a/packages/eslint-plugin-svelte/src/rule-types.ts
+++ b/packages/eslint-plugin-svelte/src/rule-types.ts
@@ -104,6 +104,11 @@ export interface RuleOptions {
    * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-at-html-tags/
    */
   'svelte/no-at-html-tags'?: Linter.RuleEntry<[]>
+  /**
+   * Recommends not using raw special elements in Svelte versions previous to 5.
+   * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-deprecated-raw-special-elements/
+   */
+  'svelte/no-deprecated-raw-special-elements'?: Linter.RuleEntry<[]>
   /**
    * disallow DOM manipulating
    * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/no-dom-manipulating/
diff --git a/packages/eslint-plugin-svelte/src/rules/no-deprecated-raw-special-elements.ts b/packages/eslint-plugin-svelte/src/rules/no-deprecated-raw-special-elements.ts
new file mode 100644
index 0000000..d47ef72
--- /dev/null
+++ b/packages/eslint-plugin-svelte/src/rules/no-deprecated-raw-special-elements.ts
@@ -0,0 +1,47 @@
+import type { AST } from 'svelte-eslint-parser';
+import { createRule } from '../utils';
+
+const INVALID_HTML_ELEMENTS = ['head', 'body', 'window', 'document', 'element', 'options'];
+const VALID_PREFIX = 'svelte:';
+
+export default createRule('no-deprecated-raw-special-elements', {
+	meta: {
+		docs: {
+			description: 'Recommends not using raw special elements in Svelte versions previous to 5.',
+			category: 'Possible Errors',
+			// TODO: Switch to recommended in the major version
+			recommended: false
+		},
+		schema: [],
+		messages: {
+			deprecatedElement:
+				'Special {{name}} element is deprecated in v5, use svelte:{{name}} instead.'
+		},
+		type: 'problem', // 'problem', or 'layout',
+		fixable: 'code'
+	},
+	create(context) {
+		return {
+			'SvelteElement[kind="html"]'(node: AST.SvelteHTMLElement) {
+				const { name } = node.name;
+				if (INVALID_HTML_ELEMENTS.includes(name)) {
+					context.report({
+						node,
+						messageId: 'deprecatedElement',
+						data: { name },
+						*fix(fixer) {
+							const { endTag } = node;
+							yield fixer.insertTextBeforeRange([node.range[0] + 1, node.range[1]], VALID_PREFIX);
+							if (endTag) {
+								yield fixer.insertTextBeforeRange(
+									[endTag.range[0] + 2, endTag.range[1]],
+									VALID_PREFIX
+								);
+							}
+						}
+					});
+				}
+			}
+		};
+	}
+});
diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts
index 5bb5a08..862db8b 100644
--- a/packages/eslint-plugin-svelte/src/utils/rules.ts
+++ b/packages/eslint-plugin-svelte/src/utils/rules.ts
@@ -20,6 +20,7 @@ import maxAttributesPerLine from '../rules/max-attributes-per-line';
 import mustacheSpacing from '../rules/mustache-spacing';
 import noAtDebugTags from '../rules/no-at-debug-tags';
 import noAtHtmlTags from '../rules/no-at-html-tags';
+import noDeprecatedRawSpecialElements from '../rules/no-deprecated-raw-special-elements';
 import noDomManipulating from '../rules/no-dom-manipulating';
 import noDupeElseIfBlocks from '../rules/no-dupe-else-if-blocks';
 import noDupeOnDirectives from '../rules/no-dupe-on-directives';
@@ -87,6 +88,7 @@ export const rules = [
 	mustacheSpacing,
 	noAtDebugTags,
 	noAtHtmlTags,
+	noDeprecatedRawSpecialElements,
 	noDomManipulating,
 	noDupeElseIfBlocks,
 	noDupeOnDirectives,


Test patch:
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-errors.yaml
new file mode 100644
index 0000000..2acb009
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-errors.yaml
@@ -0,0 +1,24 @@
+- message: Special head element is deprecated in v5, use svelte:head instead.
+  line: 1
+  column: 1
+  suggestions: null
+- message: Special body element is deprecated in v5, use svelte:body instead.
+  line: 2
+  column: 1
+  suggestions: null
+- message: Special window element is deprecated in v5, use svelte:window instead.
+  line: 3
+  column: 1
+  suggestions: null
+- message: Special document element is deprecated in v5, use svelte:document instead.
+  line: 4
+  column: 1
+  suggestions: null
+- message: Special element element is deprecated in v5, use svelte:element instead.
+  line: 5
+  column: 1
+  suggestions: null
+- message: Special options element is deprecated in v5, use svelte:options instead.
+  line: 6
+  column: 1
+  suggestions: null
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-input.svelte
new file mode 100644
index 0000000..0856e15
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-input.svelte
@@ -0,0 +1,6 @@
+<head></head>
+<body></body>
+<window></window>
+<document></document>
+<element this={{}}></element>
+<options></options>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-output.svelte
new file mode 100644
index 0000000..507eea7
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/invalid/test01-output.svelte
@@ -0,0 +1,6 @@
+<svelte:head></svelte:head>
+<svelte:body></svelte:body>
+<svelte:window></svelte:window>
+<svelte:document></svelte:document>
+<svelte:element this={{}}></svelte:element>
+<svelte:options></svelte:options>
diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/valid/test01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/valid/test01-input.svelte
new file mode 100644
index 0000000..ea0dfc2
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-deprecated-raw-special-elements/valid/test01-input.svelte
@@ -0,0 +1,8 @@
+<svelte:options />
+
+<svelte:body />
+<svelte:document />
+<svelte:element this={{}}></svelte:element>
+<svelte:head></svelte:head>
+
+<svelte:window />
diff --git a/packages/eslint-plugin-svelte/tests/src/rules/no-deprecated-raw-special-elements.ts b/packages/eslint-plugin-svelte/tests/src/rules/no-deprecated-raw-special-elements.ts
new file mode 100644
index 0000000..dbe2fe1
--- /dev/null
+++ b/packages/eslint-plugin-svelte/tests/src/rules/no-deprecated-raw-special-elements.ts
@@ -0,0 +1,12 @@
+import { RuleTester } from '../../utils/eslint-compat';
+import rule from '../../../src/rules/no-deprecated-raw-special-elements';
+import { loadTestCases } from '../../utils/utils';
+
+const tester = new RuleTester({
+	languageOptions: {
+		ecmaVersion: 2020,
+		sourceType: 'module'
+	}
+});
+
+tester.run('no-deprecated-raw-special-elements', rule as any, loadTestCases('no-deprecated-raw-special-elements'));