diff --git a/.changeset/moody-seas-kick.md b/.changeset/moody-seas-kick.md new file mode 100644 index 000000000..aebc93044 --- /dev/null +++ b/.changeset/moody-seas-kick.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-svelte": minor +--- + +feat: added the no-unused-class-name rule diff --git a/.eslintignore b/.eslintignore index 06758f473..9382feba5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,6 +8,8 @@ /prettier-playground /tests/fixtures/rules/indent/invalid/ts /tests/fixtures/rules/indent/invalid/ts-v5 +/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte +/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte /tests/fixtures/rules/valid-compile/invalid/ts /tests/fixtures/rules/valid-compile/valid/babel /tests/fixtures/rules/valid-compile/valid/ts diff --git a/README.md b/README.md index f512560a2..a1f1b1038 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-immutable-reactive-statements](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-immutable-reactive-statements/) | disallow reactive statements that don't reference reactive values. | | | [svelte/no-reactive-functions](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-functions/) | it's not necessary to define functions in reactive statements | :bulb: | | [svelte/no-reactive-literals](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-reactive-literals/) | don't assign literal values in reactive statements | :bulb: | +| [svelte/no-unused-class-name](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-class-name/) | disallow the use of a class in the template without a corresponding style | | | [svelte/no-unused-svelte-ignore](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-unused-svelte-ignore/) | disallow unused svelte-ignore comments | :star: | | [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :wrench: | | [svelte/prefer-destructured-store-props](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-destructured-store-props/) | destructure values from object stores for better change tracking & fewer redraws | :bulb: | diff --git a/docs/rules.md b/docs/rules.md index 644183301..d35419cad 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -56,6 +56,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-immutable-reactive-statements](./rules/no-immutable-reactive-statements.md) | disallow reactive statements that don't reference reactive values. | | | [svelte/no-reactive-functions](./rules/no-reactive-functions.md) | it's not necessary to define functions in reactive statements | :bulb: | | [svelte/no-reactive-literals](./rules/no-reactive-literals.md) | don't assign literal values in reactive statements | :bulb: | +| [svelte/no-unused-class-name](./rules/no-unused-class-name.md) | disallow the use of a class in the template without a corresponding style | | | [svelte/no-unused-svelte-ignore](./rules/no-unused-svelte-ignore.md) | disallow unused svelte-ignore comments | :star: | | [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: | | [svelte/prefer-destructured-store-props](./rules/prefer-destructured-store-props.md) | destructure values from object stores for better change tracking & fewer redraws | :bulb: | diff --git a/docs/rules/no-unused-class-name.md b/docs/rules/no-unused-class-name.md new file mode 100644 index 000000000..7e02863a2 --- /dev/null +++ b/docs/rules/no-unused-class-name.md @@ -0,0 +1,61 @@ +--- +pageClass: "rule-details" +sidebarDepth: 0 +title: "svelte/no-unused-class-name" +description: "disallow the use of a class in the template without a corresponding style" +--- + +# svelte/no-unused-class-name + +> disallow the use of a class in the template without a corresponding style + +- :exclamation: **_This rule has not been released yet._** + +## :book: Rule Details + +This rule is aimed at reducing unused classes in the HTML template. While `svelte-check` will produce the `css-unused-selector` if your ` +``` + + + +## :wrench: Options + +Nothing. + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/src/rules/no-unused-class-name.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/tests/src/rules/no-unused-class-name.ts) diff --git a/package.json b/package.json index 264cce58c..e83924ab2 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "postcss": "^8.4.5", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.0.11", "svelte-eslint-parser": "^0.31.0" }, "devDependencies": { diff --git a/src/rules/no-unused-class-name.ts b/src/rules/no-unused-class-name.ts new file mode 100644 index 000000000..541f54a05 --- /dev/null +++ b/src/rules/no-unused-class-name.ts @@ -0,0 +1,120 @@ +import { createRule } from "../utils" +import type { + SourceLocation, + SvelteAttribute, + SvelteDirective, + SvelteShorthandAttribute, + SvelteSpecialDirective, + SvelteSpreadAttribute, + SvelteStyleDirective, +} from "svelte-eslint-parser/lib/ast" +import type { AnyNode } from "postcss" +import { + default as selectorParser, + type Node as SelectorNode, +} from "postcss-selector-parser" + +export default createRule("no-unused-class-name", { + meta: { + docs: { + description: + "disallow the use of a class in the template without a corresponding style", + category: "Best Practices", + recommended: false, + }, + schema: [], + messages: {}, + type: "suggestion", + }, + create(context) { + const classesUsedInTemplate: Record = {} + + return { + SvelteElement(node) { + if (node.kind !== "html") { + return + } + const classes = node.startTag.attributes.flatMap(findClassesInAttribute) + for (const className of classes) { + classesUsedInTemplate[className] = node.startTag.loc + } + }, + "Program:exit"() { + const styleContext = context.parserServices.getStyleContext() + if (["parse-error", "unknown-lang"].includes(styleContext.status)) { + return + } + const classesUsedInStyle = + styleContext.sourceAst != null + ? findClassesInPostCSSNode(styleContext.sourceAst) + : [] + for (const className in classesUsedInTemplate) { + if (!classesUsedInStyle.includes(className)) { + context.report({ + loc: classesUsedInTemplate[className], + message: `Unused class "${className}".`, + }) + } + } + }, + } + }, +}) + +/** + * Extract all class names used in a HTML element attribute. + */ +function findClassesInAttribute( + attribute: + | SvelteAttribute + | SvelteShorthandAttribute + | SvelteSpreadAttribute + | SvelteDirective + | SvelteStyleDirective + | SvelteSpecialDirective, +): string[] { + if (attribute.type === "SvelteAttribute" && attribute.key.name === "class") { + return attribute.value.flatMap((value) => + value.type === "SvelteLiteral" ? value.value.trim().split(/\s+/u) : [], + ) + } + if (attribute.type === "SvelteDirective" && attribute.kind === "Class") { + return [attribute.key.name.name] + } + return [] +} + +/** + * Extract all class names used in a PostCSS node. + */ +function findClassesInPostCSSNode(node: AnyNode): string[] { + if (node.type === "rule") { + let classes = node.nodes.flatMap(findClassesInPostCSSNode) + const processor = selectorParser() + classes = classes.concat( + findClassesInSelector(processor.astSync(node.selector)), + ) + return classes + } + if (node.type === "root" || node.type === "atrule") { + return node.nodes.flatMap(findClassesInPostCSSNode) + } + return [] +} + +/** + * Extract all class names used in a PostCSS selector. + */ +function findClassesInSelector(node: SelectorNode): string[] { + if (node.type === "class") { + return [node.value] + } + if ( + node.type === "pseudo" || + node.type === "root" || + node.type === "selector" + ) { + return node.nodes.flatMap(findClassesInSelector) + } + return [] +} diff --git a/src/utils/rules.ts b/src/utils/rules.ts index 3e4141f65..647c69c6c 100644 --- a/src/utils/rules.ts +++ b/src/utils/rules.ts @@ -41,6 +41,7 @@ import noStoreAsync from "../rules/no-store-async" import noTargetBlank from "../rules/no-target-blank" import noTrailingSpaces from "../rules/no-trailing-spaces" import noUnknownStyleDirectiveProperty from "../rules/no-unknown-style-directive-property" +import noUnusedClassName from "../rules/no-unused-class-name" import noUnusedSvelteIgnore from "../rules/no-unused-svelte-ignore" import noUselessMustaches from "../rules/no-useless-mustaches" import preferClassDirective from "../rules/prefer-class-directive" @@ -101,6 +102,7 @@ export const rules = [ noTargetBlank, noTrailingSpaces, noUnknownStyleDirectiveProperty, + noUnusedClassName, noUnusedSvelteIgnore, noUselessMustaches, preferClassDirective, diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml new file mode 100644 index 000000000..4f97ff145 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-errors.yaml @@ -0,0 +1,8 @@ +- message: Unused class "first". + line: 1 + column: 1 + suggestions: null +- message: Unused class "second". + line: 3 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte new file mode 100644 index 000000000..0a52a4226 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/class-directive01-input.svelte @@ -0,0 +1,3 @@ +
Hello
+ +World! diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml new file mode 100644 index 000000000..67d002d3a --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-errors.yaml @@ -0,0 +1,12 @@ +- message: Unused class "div-class-two". + line: 2 + column: 1 + suggestions: null +- message: Unused class "span-class-two". + line: 4 + column: 1 + suggestions: null +- message: Unused class "span-class-three". + line: 4 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte new file mode 100644 index 000000000..5a9a1c58a --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiline-class-names01-input.svelte @@ -0,0 +1,19 @@ + +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml new file mode 100644 index 000000000..5408f7e8b --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-errors.yaml @@ -0,0 +1,12 @@ +- message: Unused class "div-class-two". + line: 1 + column: 1 + suggestions: null +- message: Unused class "span-class-two". + line: 3 + column: 1 + suggestions: null +- message: Unused class "span-class-three". + line: 3 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte new file mode 100644 index 000000000..d10cb63b2 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/multiple-class-names01-input.svelte @@ -0,0 +1,13 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml new file mode 100644 index 000000000..b51081d84 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-errors.yaml @@ -0,0 +1,8 @@ +- message: Unused class "div-class". + line: 1 + column: 1 + suggestions: null +- message: Unused class "span-class". + line: 3 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte new file mode 100644 index 000000000..de285fe09 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/same-name-id01-input.svelte @@ -0,0 +1,13 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml new file mode 100644 index 000000000..b51081d84 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-errors.yaml @@ -0,0 +1,8 @@ +- message: Unused class "div-class". + line: 1 + column: 1 + suggestions: null +- message: Unused class "span-class". + line: 3 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte new file mode 100644 index 000000000..a486966cf --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/unused-class-name01-input.svelte @@ -0,0 +1,3 @@ +
Hello
+ +World! diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml new file mode 100644 index 000000000..b51081d84 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-errors.yaml @@ -0,0 +1,8 @@ +- message: Unused class "div-class". + line: 1 + column: 1 + suggestions: null +- message: Unused class "span-class". + line: 3 + column: 1 + suggestions: null diff --git a/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte new file mode 100644 index 000000000..829cc3ed3 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/invalid/used-unrelated-class-name01-input.svelte @@ -0,0 +1,9 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte new file mode 100644 index 000000000..a535d1b47 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/adjacent-sibling-combinator01-input.svelte @@ -0,0 +1,9 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte new file mode 100644 index 000000000..66f147ea9 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/child-combinator01-input.svelte @@ -0,0 +1,9 @@ +
+
Hello
+
+ + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte new file mode 100644 index 000000000..28dcb26a8 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/descendant-combinator01-input.svelte @@ -0,0 +1,9 @@ +
+
Hello
+
+ + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte new file mode 100644 index 000000000..9532972bd --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/general-sibling-combinator01-input.svelte @@ -0,0 +1,9 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte new file mode 100644 index 000000000..a3184dbd2 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/invalid-style01-input.svelte @@ -0,0 +1,18 @@ +
+
Hello
+ + World! +
+ + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte new file mode 100644 index 000000000..f4b324327 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/multiple-class-names01-input.svelte @@ -0,0 +1,21 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte new file mode 100644 index 000000000..f9297796e --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/no-class-name01-input.svelte @@ -0,0 +1,3 @@ +
Hello
+ +World! diff --git a/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte new file mode 100644 index 000000000..7b5aec359 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-classes01-input.svelte @@ -0,0 +1,14 @@ +
Hello
+ +World + +! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte new file mode 100644 index 000000000..881e0aec6 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/pseudo-elements01-input.svelte @@ -0,0 +1,12 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte new file mode 100644 index 000000000..45bbe8605 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/scss-class-name01-input.svelte @@ -0,0 +1,18 @@ +
+
Hello
+ + World! +
+ + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte new file mode 100644 index 000000000..e5b42f52a --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/selector-list01-input.svelte @@ -0,0 +1,10 @@ +
Hello
+ +World! + + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte new file mode 100644 index 000000000..43e1850c4 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/unknown-lang01-input.svelte @@ -0,0 +1,18 @@ +
+
Hello
+ + World! +
+ + diff --git a/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte b/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte new file mode 100644 index 000000000..3f4f103e0 --- /dev/null +++ b/tests/fixtures/rules/no-unused-class-name/valid/used-class-name01-input.svelte @@ -0,0 +1,13 @@ +
Hello
+ +World! + + diff --git a/tests/src/rules/no-unused-class-name.ts b/tests/src/rules/no-unused-class-name.ts new file mode 100644 index 000000000..0920a419c --- /dev/null +++ b/tests/src/rules/no-unused-class-name.ts @@ -0,0 +1,16 @@ +import { RuleTester } from "eslint" +import rule from "../../../src/rules/no-unused-class-name" +import { loadTestCases } from "../../utils/utils" + +const tester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + }, +}) + +tester.run( + "no-unused-class-name", + rule as any, + loadTestCases("no-unused-class-name"), +)