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, ``) + } + }, + }) + } + + 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"))