From 43bf1b7f68fcbc3f40a1d3e9655821e8aaad2e52 Mon Sep 17 00:00:00 2001 From: Andrew Pollack Date: Thu, 3 Apr 2025 18:17:50 -0700 Subject: [PATCH 01/14] docs: update valid-each-key, require-each-key, no-at-html-tags, prefer-class-directive further reading links (#1185) --- docs/rules/no-at-html-tags.md | 2 +- docs/rules/prefer-class-directive.md | 2 +- docs/rules/require-each-key.md | 2 +- docs/rules/valid-each-key.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rules/no-at-html-tags.md b/docs/rules/no-at-html-tags.md index bdec6d96a..aba37a4bb 100644 --- a/docs/rules/no-at-html-tags.md +++ b/docs/rules/no-at-html-tags.md @@ -40,7 +40,7 @@ If you are certain the content passed to `{@html}` is sanitized HTML you can dis ## :books: Further Reading -- [Svelte - Tutorial > 1. Introduction / HTML tags](https://svelte.dev/tutorial/html-tags) +- [Svelte - Tutorial > Basic Svelte / Introduction / HTML tags](https://svelte.dev/tutorial/svelte/html-tags) ## :rocket: Version diff --git a/docs/rules/prefer-class-directive.md b/docs/rules/prefer-class-directive.md index 5b5c6a165..3eb3ed69d 100644 --- a/docs/rules/prefer-class-directive.md +++ b/docs/rules/prefer-class-directive.md @@ -62,7 +62,7 @@ You cannot enforce this style by using [prettier-plugin-svelte]. That is, this r ## :books: Further Reading -- [Svelte - Tutorial > 13. Classes / The class directive](https://svelte.dev/tutorial/classes) +- [Svelte - Tutorial > Basic Svelte / Classes and styles / The class atribute](https://svelte.dev/tutorial/svelte/classes) ## :rocket: Version diff --git a/docs/rules/require-each-key.md b/docs/rules/require-each-key.md index cd0069cb1..0daa7ec84 100644 --- a/docs/rules/require-each-key.md +++ b/docs/rules/require-each-key.md @@ -44,7 +44,7 @@ Nothing. ## :books: Further Reading -- [Svelte - Tutorial > 4. Logic / Keyed each blocks](https://svelte.dev/tutorial/svelte/keyed-each-blocks) +- [Svelte - Tutorial > Basic Svelte / Logic / Keyed each blocks](https://svelte.dev/tutorial/svelte/keyed-each-blocks) ## :rocket: Version diff --git a/docs/rules/valid-each-key.md b/docs/rules/valid-each-key.md index 5e6aa5795..2515751ba 100644 --- a/docs/rules/valid-each-key.md +++ b/docs/rules/valid-each-key.md @@ -53,7 +53,7 @@ Nothing. ## :books: Further Reading -- [Svelte - Tutorial > 4. Logic / Keyed each blocks](https://svelte.dev/tutorial/keyed-each-blocks) +- [Svelte - Tutorial > Basic Svelte / Logic / Keyed each blocks](https://svelte.dev/tutorial/svelte/keyed-each-blocks) ## :rocket: Version From ad528c9b9136825cf2003206df105cc87153bdc6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 5 Apr 2025 02:43:08 +0000 Subject: [PATCH 02/14] chore(deps): update dependency eslint to ~9.24.0 (#1190) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 254352fc5..76316e218 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/parser": "^8.16.0", "c8": "^10.1.2", "env-cmd": "^10.1.0", - "eslint": "~9.23.0", + "eslint": "~9.24.0", "eslint-config-prettier": "^10.0.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-eslint-plugin": "^6.3.2", From 924d4aff5d6f78de6f0b2207248e3a1a79f640d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 02:44:18 +0000 Subject: [PATCH 03/14] fix(deps): update dependency known-css-properties to ^0.36.0 (#1193) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/eslint-plugin-svelte/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-svelte/package.json b/packages/eslint-plugin-svelte/package.json index ad41dd414..eb49cb21b 100644 --- a/packages/eslint-plugin-svelte/package.json +++ b/packages/eslint-plugin-svelte/package.json @@ -59,7 +59,7 @@ "@eslint-community/eslint-utils": "^4.4.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", - "known-css-properties": "^0.35.0", + "known-css-properties": "^0.36.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", From a69409e059ddd1a2f4cb6c88b7ced1d282cdf61a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:05:10 +0000 Subject: [PATCH 04/14] chore(deps): update dependency eslint to ~9.25.0 (#1200) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 76316e218..f323d1d63 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/parser": "^8.16.0", "c8": "^10.1.2", "env-cmd": "^10.1.0", - "eslint": "~9.24.0", + "eslint": "~9.25.0", "eslint-config-prettier": "^10.0.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-eslint-plugin": "^6.3.2", From 73f23ae0321aa4cb87e700f8478b140059e18c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Wed, 30 Apr 2025 08:52:16 +0200 Subject: [PATCH 05/14] feat: added the `require-event-prefix` rule (#1069) --- .changeset/rich-dogs-design.md | 5 + README.md | 1 + docs/rules.md | 1 + docs/rules/require-event-prefix.md | 71 ++++++++++ .../eslint-plugin-svelte/src/rule-types.ts | 9 ++ .../src/rules/require-event-prefix.ts | 123 ++++++++++++++++++ .../eslint-plugin-svelte/src/utils/rules.ts | 2 + .../src/utils/ts-utils/index.ts | 45 +++++++ .../invalid/_requirements.json | 3 + .../invalid/checkAsyncFunctions/_config.json | 3 + .../checkAsyncFunctions/_requirements.json | 3 + .../async-arrow01-errors.yaml | 4 + .../async-arrow01-input.svelte | 9 ++ .../checkAsyncFunctions/async01-errors.yaml | 4 + .../checkAsyncFunctions/async01-input.svelte | 9 ++ .../invalid/no-prefix-arrow01-errors.yaml | 4 + .../invalid/no-prefix-arrow01-input.svelte | 9 ++ .../no-prefix-inline-type01-errors.yaml | 4 + .../no-prefix-inline-type01-input.svelte | 5 + .../invalid/no-prefix01-errors.yaml | 4 + .../invalid/no-prefix01-input.svelte | 9 ++ .../valid/_requirements.json | 3 + .../valid/any01-input.svelte | 9 ++ .../valid/async01-input.svelte | 9 ++ .../valid/non-function01-input.svelte | 9 ++ .../valid/with-prefix01-input.svelte | 9 ++ .../tests/src/rules/require-event-prefix.ts | 12 ++ 27 files changed, 378 insertions(+) create mode 100644 .changeset/rich-dogs-design.md create mode 100644 docs/rules/require-event-prefix.md create mode 100644 packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/_requirements.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_config.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_requirements.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/_requirements.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/any01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/async01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/non-function01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/with-prefix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts diff --git a/.changeset/rich-dogs-design.md b/.changeset/rich-dogs-design.md new file mode 100644 index 000000000..0ef7fbf2c --- /dev/null +++ b/.changeset/rich-dogs-design.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat: added the `require-event-prefix` rule diff --git a/README.md b/README.md index c379a3d82..e24bf7983 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ These rules relate to style guidelines, and are therefore quite subjective: | [svelte/no-spaces-around-equal-signs-in-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-spaces-around-equal-signs-in-attribute/) | disallow spaces around equal signs in attribute | :wrench: | | [svelte/prefer-class-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-class-directive/) | require class directives instead of ternary expressions | :wrench: | | [svelte/prefer-style-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/) | require style directives instead of style attribute | :wrench: | +| [svelte/require-event-prefix](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-prefix/) | require component event names to start with "on" | | | [svelte/shorthand-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/shorthand-attribute/) | enforce use of shorthand syntax in attribute | :wrench: | | [svelte/shorthand-directive](https://sveltejs.github.io/eslint-plugin-svelte/rules/shorthand-directive/) | enforce use of shorthand syntax in directives | :wrench: | | [svelte/sort-attributes](https://sveltejs.github.io/eslint-plugin-svelte/rules/sort-attributes/) | enforce order of attributes | :wrench: | diff --git a/docs/rules.md b/docs/rules.md index df2ca5ec4..1d4088cd5 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -94,6 +94,7 @@ These rules relate to style guidelines, and are therefore quite subjective: | [svelte/no-spaces-around-equal-signs-in-attribute](./rules/no-spaces-around-equal-signs-in-attribute.md) | disallow spaces around equal signs in attribute | :wrench: | | [svelte/prefer-class-directive](./rules/prefer-class-directive.md) | require class directives instead of ternary expressions | :wrench: | | [svelte/prefer-style-directive](./rules/prefer-style-directive.md) | require style directives instead of style attribute | :wrench: | +| [svelte/require-event-prefix](./rules/require-event-prefix.md) | require component event names to start with "on" | | | [svelte/shorthand-attribute](./rules/shorthand-attribute.md) | enforce use of shorthand syntax in attribute | :wrench: | | [svelte/shorthand-directive](./rules/shorthand-directive.md) | enforce use of shorthand syntax in directives | :wrench: | | [svelte/sort-attributes](./rules/sort-attributes.md) | enforce order of attributes | :wrench: | diff --git a/docs/rules/require-event-prefix.md b/docs/rules/require-event-prefix.md new file mode 100644 index 000000000..556509c6e --- /dev/null +++ b/docs/rules/require-event-prefix.md @@ -0,0 +1,71 @@ +--- +pageClass: 'rule-details' +sidebarDepth: 0 +title: 'svelte/require-event-prefix' +description: 'require component event names to start with "on"' +--- + +# svelte/require-event-prefix + +> require component event names to start with "on" + +- :exclamation: **_This rule has not been released yet._** + +## :book: Rule Details + +Starting with Svelte 5, component events are just component props that are functions and so can be called like any function. Events for HTML elements all have their name begin with "on" (e.g. `onclick`). This rule enforces that all component events (i.e. function props) also begin with "on". + + + +```svelte + +``` + +```svelte + +``` + +## :wrench: Options + +```json +{ + "svelte/require-event-prefix": [ + "error", + { + "checkAsyncFunctions": false + } + ] +} +``` + +- `checkAsyncFunctions` ... Whether to also report asychronous function properties. Default `false`. + +## :books: Further Reading + +- [Svelte docs on events in version 5](https://svelte.dev/docs/svelte/v5-migration-guide#Event-changes) + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts) diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts index 7b5cf238c..2150cfc21 100644 --- a/packages/eslint-plugin-svelte/src/rule-types.ts +++ b/packages/eslint-plugin-svelte/src/rule-types.ts @@ -316,6 +316,11 @@ export interface RuleOptions { * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/ */ 'svelte/require-event-dispatcher-types'?: Linter.RuleEntry<[]> + /** + * require component event names to start with "on" + * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-prefix/ + */ + 'svelte/require-event-prefix'?: Linter.RuleEntry /** * require style attributes that can be optimized * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/ @@ -554,6 +559,10 @@ type SveltePreferConst = []|[{ ignoreReadBeforeAssign?: boolean excludedRunes?: string[] }] +// ----- svelte/require-event-prefix ----- +type SvelteRequireEventPrefix = []|[{ + checkAsyncFunctions?: boolean +}] // ----- svelte/shorthand-attribute ----- type SvelteShorthandAttribute = []|[{ prefer?: ("always" | "never") diff --git a/packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts b/packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts new file mode 100644 index 000000000..ed3c3ad08 --- /dev/null +++ b/packages/eslint-plugin-svelte/src/rules/require-event-prefix.ts @@ -0,0 +1,123 @@ +import { createRule } from '../utils/index.js'; +import { + type TSTools, + getTypeScriptTools, + isMethodSymbol, + isPropertySignatureKind, + isFunctionTypeKind, + isMethodSignatureKind, + isTypeReferenceKind, + isIdentifierKind +} from '../utils/ts-utils/index.js'; +import type { Symbol, Type } from 'typescript'; +import type { CallExpression } from 'estree'; + +export default createRule('require-event-prefix', { + meta: { + docs: { + description: 'require component event names to start with "on"', + category: 'Stylistic Issues', + conflictWithPrettier: false, + recommended: false + }, + schema: [ + { + type: 'object', + properties: { + checkAsyncFunctions: { + type: 'boolean' + } + }, + additionalProperties: false + } + ], + messages: { + nonPrefixedFunction: 'Component event name must start with "on".' + }, + type: 'suggestion', + conditions: [ + { + svelteVersions: ['5'], + svelteFileTypes: ['.svelte'] + } + ] + }, + create(context) { + const tsTools = getTypeScriptTools(context); + if (!tsTools) { + return {}; + } + + const checkAsyncFunctions = context.options[0]?.checkAsyncFunctions ?? false; + + return { + CallExpression(node) { + const propsType = getPropsType(node, tsTools); + if (propsType === undefined) { + return; + } + for (const property of propsType.getProperties()) { + if ( + isFunctionLike(property, tsTools) && + !property.getName().startsWith('on') && + (checkAsyncFunctions || !isFunctionAsync(property, tsTools)) + ) { + const declarationTsNode = property.getDeclarations()?.[0]; + const declarationEstreeNode = + declarationTsNode !== undefined + ? tsTools.service.tsNodeToESTreeNodeMap.get(declarationTsNode) + : undefined; + context.report({ + node: declarationEstreeNode ?? node, + messageId: 'nonPrefixedFunction' + }); + } + } + } + }; + } +}); + +function getPropsType(node: CallExpression, tsTools: TSTools): Type | undefined { + if ( + node.callee.type !== 'Identifier' || + node.callee.name !== '$props' || + node.parent.type !== 'VariableDeclarator' + ) { + return undefined; + } + + const tsNode = tsTools.service.esTreeNodeToTSNodeMap.get(node.parent.id); + if (tsNode === undefined) { + return undefined; + } + + return tsTools.service.program.getTypeChecker().getTypeAtLocation(tsNode); +} + +function isFunctionLike(functionSymbol: Symbol, tsTools: TSTools): boolean { + return ( + isMethodSymbol(functionSymbol, tsTools.ts) || + (functionSymbol.valueDeclaration !== undefined && + isPropertySignatureKind(functionSymbol.valueDeclaration, tsTools.ts) && + functionSymbol.valueDeclaration.type !== undefined && + isFunctionTypeKind(functionSymbol.valueDeclaration.type, tsTools.ts)) + ); +} + +function isFunctionAsync(functionSymbol: Symbol, tsTools: TSTools): boolean { + return ( + functionSymbol.getDeclarations()?.some((declaration) => { + if (!isMethodSignatureKind(declaration, tsTools.ts)) { + return false; + } + if (declaration.type === undefined || !isTypeReferenceKind(declaration.type, tsTools.ts)) { + return false; + } + return ( + isIdentifierKind(declaration.type.typeName, tsTools.ts) && + declaration.type.typeName.escapedText === 'Promise' + ); + }) ?? false + ); +} diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts index 3151d17f1..825edea9e 100644 --- a/packages/eslint-plugin-svelte/src/utils/rules.ts +++ b/packages/eslint-plugin-svelte/src/utils/rules.ts @@ -62,6 +62,7 @@ import preferDestructuredStoreProps from '../rules/prefer-destructured-store-pro import preferStyleDirective from '../rules/prefer-style-directive.js'; import requireEachKey from '../rules/require-each-key.js'; import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js'; +import requireEventPrefix from '../rules/require-event-prefix.js'; import requireOptimizedStyleAttribute from '../rules/require-optimized-style-attribute.js'; import requireStoreCallbacksUseSetParam from '../rules/require-store-callbacks-use-set-param.js'; import requireStoreReactiveAccess from '../rules/require-store-reactive-access.js'; @@ -137,6 +138,7 @@ export const rules = [ preferStyleDirective, requireEachKey, requireEventDispatcherTypes, + requireEventPrefix, requireOptimizedStyleAttribute, requireStoreCallbacksUseSetParam, requireStoreReactiveAccess, diff --git a/packages/eslint-plugin-svelte/src/utils/ts-utils/index.ts b/packages/eslint-plugin-svelte/src/utils/ts-utils/index.ts index f386698b5..00762becc 100644 --- a/packages/eslint-plugin-svelte/src/utils/ts-utils/index.ts +++ b/packages/eslint-plugin-svelte/src/utils/ts-utils/index.ts @@ -306,3 +306,48 @@ export function getTypeOfPropertyOfType( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- getTypeOfPropertyOfType is an internal API of TS. return (checker as any).getTypeOfPropertyOfType(type, name); } + +/** + * Check whether the given symbol is a method type or not. + */ +export function isMethodSymbol(type: TS.Symbol, ts: TypeScript): boolean { + return (type.getFlags() & ts.SymbolFlags.Method) !== 0; +} + +/** + * Check whether the given node is a property signature kind or not. + */ +export function isPropertySignatureKind( + node: TS.Node, + ts: TypeScript +): node is TS.PropertySignature { + return node.kind === ts.SyntaxKind.PropertySignature; +} + +/** + * Check whether the given node is a function type kind or not. + */ +export function isFunctionTypeKind(node: TS.Node, ts: TypeScript): node is TS.FunctionTypeNode { + return node.kind === ts.SyntaxKind.FunctionType; +} + +/** + * Check whether the given node is a method signature kind or not. + */ +export function isMethodSignatureKind(node: TS.Node, ts: TypeScript): node is TS.MethodSignature { + return node.kind === ts.SyntaxKind.MethodSignature; +} + +/** + * Check whether the given node is a type reference kind or not. + */ +export function isTypeReferenceKind(node: TS.Node, ts: TypeScript): node is TS.TypeReferenceNode { + return node.kind === ts.SyntaxKind.TypeReference; +} + +/** + * Check whether the given node is an identifier kind or not. + */ +export function isIdentifierKind(node: TS.Node, ts: TypeScript): node is TS.Identifier { + return node.kind === ts.SyntaxKind.Identifier; +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/_requirements.json new file mode 100644 index 000000000..498661308 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_config.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_config.json new file mode 100644 index 000000000..a0f52ed6e --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_config.json @@ -0,0 +1,3 @@ +{ + "options": [{ "checkAsyncFunctions": true }] +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_requirements.json new file mode 100644 index 000000000..498661308 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-errors.yaml new file mode 100644 index 000000000..4c26d4e62 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-errors.yaml @@ -0,0 +1,4 @@ +- message: Component event name must start with "on". + line: 3 + column: 5 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-input.svelte new file mode 100644 index 000000000..0b0502984 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async-arrow01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-errors.yaml new file mode 100644 index 000000000..4c26d4e62 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-errors.yaml @@ -0,0 +1,4 @@ +- message: Component event name must start with "on". + line: 3 + column: 5 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-input.svelte new file mode 100644 index 000000000..4f6f3ee49 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/checkAsyncFunctions/async01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-errors.yaml new file mode 100644 index 000000000..4c26d4e62 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-errors.yaml @@ -0,0 +1,4 @@ +- message: Component event name must start with "on". + line: 3 + column: 5 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-input.svelte new file mode 100644 index 000000000..7874e51da --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-arrow01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-errors.yaml new file mode 100644 index 000000000..affa6e169 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-errors.yaml @@ -0,0 +1,4 @@ +- message: Component event name must start with "on". + line: 2 + column: 21 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-input.svelte new file mode 100644 index 000000000..3fc3c616b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix-inline-type01-input.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-errors.yaml new file mode 100644 index 000000000..4c26d4e62 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-errors.yaml @@ -0,0 +1,4 @@ +- message: Component event name must start with "on". + line: 3 + column: 5 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-input.svelte new file mode 100644 index 000000000..036a1d65b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/invalid/no-prefix01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/_requirements.json new file mode 100644 index 000000000..498661308 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/any01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/any01-input.svelte new file mode 100644 index 000000000..deeb82509 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/any01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/async01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/async01-input.svelte new file mode 100644 index 000000000..4f6f3ee49 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/async01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/non-function01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/non-function01-input.svelte new file mode 100644 index 000000000..d23e387a1 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/non-function01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/with-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/with-prefix01-input.svelte new file mode 100644 index 000000000..e3396af33 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/require-event-prefix/valid/with-prefix01-input.svelte @@ -0,0 +1,9 @@ + diff --git a/packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts b/packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts new file mode 100644 index 000000000..7818735b5 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/src/rules/require-event-prefix.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '../../utils/eslint-compat.js'; +import rule from '../../../src/rules/require-event-prefix.js'; +import { loadTestCases } from '../../utils/utils.js'; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}); + +tester.run('require-event-prefix', rule as any, loadTestCases('require-event-prefix')); From 87c74feef892cb7e04e5709e66dfeda4e27ec820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Wed, 30 Apr 2025 09:03:29 +0200 Subject: [PATCH 06/14] feat(consistent-selector-style): added support for dynamic classes and IDs (#1148) Co-authored-by: Yosuke Ota --- .changeset/two-hats-ask.md | 5 + .../src/rules/consistent-selector-style.ts | 101 +++++++-- .../src/rules/no-navigation-without-base.ts | 84 +------ .../src/utils/expression-affixes.ts | 209 ++++++++++++++++++ .../class-dynamic-prefix01-input.svelte | 33 +++ .../class-dynamic-suffix01-input.svelte | 34 +++ .../class-dynamic-universal01-input.svelte | 23 ++ .../id-dynamic-prefix01-input.svelte | 27 +++ .../id-dynamic-suffix01-input.svelte | 27 +++ .../id-dynamic-universal01-input.svelte | 23 ++ 10 files changed, 465 insertions(+), 101 deletions(-) create mode 100644 .changeset/two-hats-ask.md create mode 100644 packages/eslint-plugin-svelte/src/utils/expression-affixes.ts create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte diff --git a/.changeset/two-hats-ask.md b/.changeset/two-hats-ask.md new file mode 100644 index 000000000..8d0295c45 --- /dev/null +++ b/.changeset/two-hats-ask.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat(consistent-selector-style): added support for dynamic classes and IDs diff --git a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts index 93f2a2cba..558181465 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -6,9 +6,21 @@ import type { Node as SelectorNode, Tag as SelectorTag } from 'postcss-selector-parser'; +import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast'; import { findClassesInAttribute } from '../utils/ast-utils.js'; +import { + extractExpressionPrefixLiteral, + extractExpressionSuffixLiteral +} from '../utils/expression-affixes.js'; import { createRule } from '../utils/index.js'; +interface Selections { + exact: Map; + // [prefix, suffix] + affixes: Map<[string | null, string | null], AST.SvelteHTMLElement[]>; + universalSelector: boolean; +} + export default createRule('consistent-selector-style', { meta: { docs: { @@ -62,9 +74,24 @@ export default createRule('consistent-selector-style', { const style = context.options[0]?.style ?? ['type', 'id', 'class']; const whitelistedClasses: string[] = []; - const classSelections: Map = new Map(); - const idSelections: Map = new Map(); - const typeSelections: Map = new Map(); + + const selections: { + class: Selections; + id: Selections; + type: Map; + } = { + class: { + exact: new Map(), + affixes: new Map(), + universalSelector: false + }, + id: { + exact: new Map(), + affixes: new Map(), + universalSelector: false + }, + type: new Map() + }; /** * Checks selectors in a given PostCSS node @@ -109,10 +136,10 @@ export default createRule('consistent-selector-style', { * Checks a class selector */ function checkClassSelector(node: SelectorClass): void { - if (whitelistedClasses.includes(node.value)) { + if (selections.class.universalSelector || whitelistedClasses.includes(node.value)) { return; } - const selection = classSelections.get(node.value) ?? []; + const selection = matchSelection(selections.class, node.value); for (const styleValue of style) { if (styleValue === 'class') { return; @@ -124,7 +151,7 @@ export default createRule('consistent-selector-style', { }); return; } - if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) { + if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) { context.report({ messageId: 'classShouldBeType', loc: styleSelectorNodeLoc(node) as AST.SourceLocation @@ -138,7 +165,10 @@ export default createRule('consistent-selector-style', { * Checks an ID selector */ function checkIdSelector(node: SelectorIdentifier): void { - const selection = idSelections.get(node.value) ?? []; + if (selections.id.universalSelector) { + return; + } + const selection = matchSelection(selections.id, node.value); for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -150,7 +180,7 @@ export default createRule('consistent-selector-style', { if (styleValue === 'id') { return; } - if (styleValue === 'type' && canUseTypeSelector(selection, typeSelections)) { + if (styleValue === 'type' && canUseTypeSelector(selection, selections.type)) { context.report({ messageId: 'idShouldBeType', loc: styleSelectorNodeLoc(node) as AST.SourceLocation @@ -164,7 +194,7 @@ export default createRule('consistent-selector-style', { * Checks a type selector */ function checkTypeSelector(node: SelectorTag): void { - const selection = typeSelections.get(node.value) ?? []; + const selection = selections.type.get(node.value) ?? []; for (const styleValue of style) { if (styleValue === 'class') { context.report({ @@ -191,21 +221,39 @@ export default createRule('consistent-selector-style', { if (node.kind !== 'html') { return; } - addToArrayMap(typeSelections, node.name.name, node); - const classes = node.startTag.attributes.flatMap(findClassesInAttribute); - for (const className of classes) { - addToArrayMap(classSelections, className, node); - } + addToArrayMap(selections.type, node.name.name, node); for (const attribute of node.startTag.attributes) { if (attribute.type === 'SvelteDirective' && attribute.kind === 'Class') { whitelistedClasses.push(attribute.key.name.name); } - if (attribute.type !== 'SvelteAttribute' || attribute.key.name !== 'id') { + for (const className of findClassesInAttribute(attribute)) { + addToArrayMap(selections.class.exact, className, node); + } + if (attribute.type !== 'SvelteAttribute') { continue; } for (const value of attribute.value) { - if (value.type === 'SvelteLiteral') { - addToArrayMap(idSelections, value.value, node); + if (attribute.key.name === 'class' && value.type === 'SvelteMustacheTag') { + const prefix = extractExpressionPrefixLiteral(context, value.expression); + const suffix = extractExpressionSuffixLiteral(context, value.expression); + if (prefix === null && suffix === null) { + selections.class.universalSelector = true; + } else { + addToArrayMap(selections.class.affixes, [prefix, suffix], node); + } + } + if (attribute.key.name === 'id') { + if (value.type === 'SvelteLiteral') { + addToArrayMap(selections.id.exact, value.value, node); + } else if (value.type === 'SvelteMustacheTag') { + const prefix = extractExpressionPrefixLiteral(context, value.expression); + const suffix = extractExpressionSuffixLiteral(context, value.expression); + if (prefix === null && suffix === null) { + selections.id.universalSelector = true; + } else { + addToArrayMap(selections.id.affixes, [prefix, suffix], node); + } + } } } } @@ -227,14 +275,27 @@ export default createRule('consistent-selector-style', { /** * Helper function to add a value to a Map of arrays */ -function addToArrayMap( - map: Map, - key: string, +function addToArrayMap( + map: Map, + key: T, value: AST.SvelteHTMLElement ): void { map.set(key, (map.get(key) ?? []).concat(value)); } +/** + * Finds all nodes in selections that could be matched by key + */ +function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] { + const selection = selections.exact.get(key) ?? []; + selections.affixes.forEach((nodes, [prefix, suffix]) => { + if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) { + selection.push(...nodes); + } + }); + return selection; +} + /** * Checks whether a given selection could be obtained using an ID selector */ diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts index 00f73d02c..094f432dd 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts @@ -2,6 +2,7 @@ import type { TSESTree } from '@typescript-eslint/types'; import { createRule } from '../utils/index.js'; import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { findVariable } from '../utils/ast-utils.js'; +import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js'; import type { RuleContext } from '../types.js'; import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; @@ -221,87 +222,8 @@ function expressionStartsWithBase( url: TSESTree.Expression, basePathNames: Set ): boolean { - switch (url.type) { - case 'BinaryExpression': - return binaryExpressionStartsWithBase(context, url, basePathNames); - case 'Identifier': - return variableStartsWithBase(context, url, basePathNames); - case 'MemberExpression': - return memberExpressionStartsWithBase(url, basePathNames); - case 'TemplateLiteral': - return templateLiteralStartsWithBase(context, url, basePathNames); - default: - return false; - } -} - -function binaryExpressionStartsWithBase( - context: RuleContext, - url: TSESTree.BinaryExpression, - basePathNames: Set -): boolean { - return ( - url.left.type !== 'PrivateIdentifier' && - expressionStartsWithBase(context, url.left, basePathNames) - ); -} - -function memberExpressionStartsWithBase( - url: TSESTree.MemberExpression, - basePathNames: Set -): boolean { - return url.property.type === 'Identifier' && basePathNames.has(url.property); -} - -function variableStartsWithBase( - context: RuleContext, - url: TSESTree.Identifier, - basePathNames: Set -): boolean { - if (basePathNames.has(url)) { - return true; - } - const variable = findVariable(context, url); - if ( - variable === null || - variable.identifiers.length !== 1 || - variable.identifiers[0].parent.type !== 'VariableDeclarator' || - variable.identifiers[0].parent.init === null - ) { - return false; - } - return expressionStartsWithBase(context, variable.identifiers[0].parent.init, basePathNames); -} - -function templateLiteralStartsWithBase( - context: RuleContext, - url: TSESTree.TemplateLiteral, - basePathNames: Set -): boolean { - const startingIdentifier = extractLiteralStartingExpression(url); - return ( - startingIdentifier !== undefined && - expressionStartsWithBase(context, startingIdentifier, basePathNames) - ); -} - -function extractLiteralStartingExpression( - templateLiteral: TSESTree.TemplateLiteral -): TSESTree.Expression | undefined { - const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) => - a.range[0] < b.range[0] ? -1 : 1 - ); - for (const part of literalParts) { - if (part.type === 'TemplateElement' && part.value.raw === '') { - // Skip empty quasi in the begining - continue; - } - if (part.type !== 'TemplateElement') { - return part; - } - return undefined; - } - return undefined; + const prefixVariable = extractExpressionPrefixVariable(context, url); + return prefixVariable !== null && basePathNames.has(prefixVariable); } function expressionIsEmpty(url: TSESTree.Expression): boolean { diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts new file mode 100644 index 000000000..6c960746b --- /dev/null +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -0,0 +1,209 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { findVariable } from './ast-utils.js'; +import type { RuleContext } from '../types.js'; +import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; + +// Variable prefix extraction + +export function extractExpressionPrefixVariable( + context: RuleContext, + expression: TSESTree.Expression +): TSESTree.Identifier | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionPrefixVariable(context, expression); + case 'Identifier': + return extractVariablePrefixVariable(context, expression); + case 'MemberExpression': + return extractMemberExpressionPrefixVariable(expression); + case 'TemplateLiteral': + return extractTemplateLiteralPrefixVariable(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionPrefixVariable( + context: RuleContext, + expression: TSESTree.BinaryExpression +): TSESTree.Identifier | null { + return expression.left.type !== 'PrivateIdentifier' + ? extractExpressionPrefixVariable(context, expression.left) + : null; +} + +function extractVariablePrefixVariable( + context: RuleContext, + expression: TSESTree.Identifier +): TSESTree.Identifier | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return expression; + } + return ( + extractExpressionPrefixVariable(context, variable.identifiers[0].parent.init) ?? expression + ); +} + +function extractMemberExpressionPrefixVariable( + expression: TSESTree.MemberExpression +): TSESTree.Identifier | null { + return expression.property.type === 'Identifier' ? expression.property : null; +} + +function extractTemplateLiteralPrefixVariable( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): TSESTree.Identifier | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement' && part.value.raw === '') { + // Skip empty quasi in the begining + continue; + } + if (part.type !== 'TemplateElement') { + return extractExpressionPrefixVariable(context, part); + } + return null; + } + return null; +} + +// Literal prefix extraction + +export function extractExpressionPrefixLiteral( + context: RuleContext, + expression: SvelteLiteral | TSESTree.Node +): string | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionPrefixLiteral(context, expression); + case 'Identifier': + return extractVariablePrefixLiteral(context, expression); + case 'Literal': + return typeof expression.value === 'string' ? expression.value : null; + case 'SvelteLiteral': + return expression.value; + case 'TemplateLiteral': + return extractTemplateLiteralPrefixLiteral(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionPrefixLiteral( + context: RuleContext, + expression: TSESTree.BinaryExpression +): string | null { + return expression.left.type !== 'PrivateIdentifier' + ? extractExpressionPrefixLiteral(context, expression.left) + : null; +} + +function extractVariablePrefixLiteral( + context: RuleContext, + expression: TSESTree.Identifier +): string | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return extractExpressionPrefixLiteral(context, variable.identifiers[0].parent.init); +} + +function extractTemplateLiteralPrefixLiteral( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): string | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return extractExpressionPrefixLiteral(context, part); + } + return null; +} + +// Literal suffix extraction + +export function extractExpressionSuffixLiteral( + context: RuleContext, + expression: SvelteLiteral | TSESTree.Node +): string | null { + switch (expression.type) { + case 'BinaryExpression': + return extractBinaryExpressionSuffixLiteral(context, expression); + case 'Identifier': + return extractVariableSuffixLiteral(context, expression); + case 'Literal': + return typeof expression.value === 'string' ? expression.value : null; + case 'SvelteLiteral': + return expression.value; + case 'TemplateLiteral': + return extractTemplateLiteralSuffixLiteral(context, expression); + default: + return null; + } +} + +function extractBinaryExpressionSuffixLiteral( + context: RuleContext, + expression: TSESTree.BinaryExpression +): string | null { + return extractExpressionSuffixLiteral(context, expression.right); +} + +function extractVariableSuffixLiteral( + context: RuleContext, + expression: TSESTree.Identifier +): string | null { + const variable = findVariable(context, expression); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return extractExpressionSuffixLiteral(context, variable.identifiers[0].parent.init); +} + +function extractTemplateLiteralSuffixLiteral( + context: RuleContext, + expression: TSESTree.TemplateLiteral +): string | null { + const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts.reverse()) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return extractExpressionSuffixLiteral(context, part); + } + return null; +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte new file mode 100644 index 000000000..d3437cb3b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-prefix01-input.svelte @@ -0,0 +1,33 @@ + + +Click me! + +Click me two! + +Click me two! + +Click me three! + +Click me three! + +Click me four! + +Click me four! + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte new file mode 100644 index 000000000..5d6b127a3 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-suffix01-input.svelte @@ -0,0 +1,34 @@ + + +Click me! + +Click me two! + +Click me two! + +Click me three! + +Click me three! + + +Click me four! + +Click me four! + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte new file mode 100644 index 000000000..72ea834f2 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/class-dynamic-universal01-input.svelte @@ -0,0 +1,23 @@ + + +Click me! + +Click me two! + +Click me two! + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte new file mode 100644 index 000000000..3e6743a71 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-prefix01-input.svelte @@ -0,0 +1,27 @@ + + +Click me! + +Click me two! + +Click me three! + +Click me four! + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte new file mode 100644 index 000000000..dc701edaf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-suffix01-input.svelte @@ -0,0 +1,27 @@ + + +Click me! + +Click me two! + +Click me three! + +Click me four! + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte new file mode 100644 index 000000000..457936530 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/id-dynamic-universal01-input.svelte @@ -0,0 +1,23 @@ + + +Click me! + +Click me two! + +Click me two! + + From 2c74993e3427c30fd6af6c3189843a7093e2200a Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Wed, 30 Apr 2025 16:06:12 +0900 Subject: [PATCH 07/14] chore: run update --- packages/eslint-plugin-svelte/src/meta.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-svelte/src/meta.ts b/packages/eslint-plugin-svelte/src/meta.ts index 32472e659..e36fbda62 100644 --- a/packages/eslint-plugin-svelte/src/meta.ts +++ b/packages/eslint-plugin-svelte/src/meta.ts @@ -1,5 +1,5 @@ // IMPORTANT! // This file has been automatically generated, // in order to update its content execute "pnpm run update" -export const name = 'eslint-plugin-svelte'; -export const version = '3.5.1'; +export const name = 'eslint-plugin-svelte' as const; +export const version = '3.5.1' as const; From 3ddbd83068a8a82ed5bfe638483c7dd7839e681a Mon Sep 17 00:00:00 2001 From: Yuichiro Yamashita Date: Wed, 30 Apr 2025 16:16:12 +0900 Subject: [PATCH 08/14] feat: add `prefer-writable-derived` rule (#1170) --- .changeset/ready-views-burn.md | 5 + README.md | 1 + docs/rules.md | 1 + docs/rules/prefer-writable-derived.md | 60 +++++++ .../src/configs/flat/recommended.ts | 1 + .../eslint-plugin-svelte/src/rule-types.ts | 5 + .../src/rules/prefer-writable-derived.ts | 147 ++++++++++++++++++ .../eslint-plugin-svelte/src/utils/rules.ts | 2 + .../invalid/_requirements.json | 3 + .../invalid/basic1-errors.yaml | 15 ++ .../invalid/basic1-input.svelte | 10 ++ .../invalid/basic2-errors.yaml | 15 ++ .../invalid/basic2-input.svelte | 10 ++ .../invalid/effect-pre1-errors.yaml | 15 ++ .../invalid/effect-pre1-input.svelte | 10 ++ .../invalid/effect-pre2-errors.yaml | 15 ++ .../invalid/effect-pre2-input.svelte | 10 ++ .../invalid/multiple-reassign1-errors.yaml | 19 +++ .../invalid/multiple-reassign1-input.svelte | 14 ++ .../invalid/multiple-reassign2-errors.yaml | 19 +++ .../invalid/multiple-reassign2-input.svelte | 14 ++ .../invalid/multiple-reassign3-errors.yaml | 38 +++++ .../invalid/multiple-reassign3-input.svelte | 14 ++ .../valid/_requirements.json | 3 + .../valid/condition1-input.svelte | 13 ++ .../valid/condition2-input.svelte | 17 ++ .../src/rules/prefer-writable-derived.ts | 12 ++ 27 files changed, 488 insertions(+) create mode 100644 .changeset/ready-views-burn.md create mode 100644 docs/rules/prefer-writable-derived.md create mode 100644 packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts diff --git a/.changeset/ready-views-burn.md b/.changeset/ready-views-burn.md new file mode 100644 index 000000000..a5f11d84b --- /dev/null +++ b/.changeset/ready-views-burn.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': minor +--- + +feat: add `prefer-writable-derived` rule diff --git a/README.md b/README.md index e24bf7983..edc472b33 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-useless-mustaches](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-useless-mustaches/) | disallow unnecessary mustache interpolations | :star::wrench: | | [svelte/prefer-const](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-const/) | Require `const` declarations for variables that are never reassigned after declared | :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: | +| [svelte/prefer-writable-derived](https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-writable-derived/) | Prefer using writable $derived instead of $state and $effect | :star::bulb: | | [svelte/require-each-key](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/) | require keyed `{#each}` block | :star: | | [svelte/require-event-dispatcher-types](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-event-dispatcher-types/) | require type parameters for `createEventDispatcher` | :star: | | [svelte/require-optimized-style-attribute](https://sveltejs.github.io/eslint-plugin-svelte/rules/require-optimized-style-attribute/) | require style attributes that can be optimized | | diff --git a/docs/rules.md b/docs/rules.md index 1d4088cd5..75dd5ed5e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -67,6 +67,7 @@ These rules relate to better ways of doing things to help you avoid problems: | [svelte/no-useless-mustaches](./rules/no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :star::wrench: | | [svelte/prefer-const](./rules/prefer-const.md) | Require `const` declarations for variables that are never reassigned after declared | :wrench: | | [svelte/prefer-destructured-store-props](./rules/prefer-destructured-store-props.md) | destructure values from object stores for better change tracking & fewer redraws | :bulb: | +| [svelte/prefer-writable-derived](./rules/prefer-writable-derived.md) | Prefer using writable $derived instead of $state and $effect | :star::bulb: | | [svelte/require-each-key](./rules/require-each-key.md) | require keyed `{#each}` block | :star: | | [svelte/require-event-dispatcher-types](./rules/require-event-dispatcher-types.md) | require type parameters for `createEventDispatcher` | :star: | | [svelte/require-optimized-style-attribute](./rules/require-optimized-style-attribute.md) | require style attributes that can be optimized | | diff --git a/docs/rules/prefer-writable-derived.md b/docs/rules/prefer-writable-derived.md new file mode 100644 index 000000000..830bec2aa --- /dev/null +++ b/docs/rules/prefer-writable-derived.md @@ -0,0 +1,60 @@ +--- +pageClass: 'rule-details' +sidebarDepth: 0 +title: 'svelte/prefer-writable-derived' +description: 'Prefer using writable $derived instead of $state and $effect' +--- + +# svelte/prefer-writable-derived + +> Prefer using writable $derived instead of $state and $effect + +- :exclamation: **_This rule has not been released yet._** +- :gear: This rule is included in `"plugin:svelte/recommended"`. +- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). + +## :book: Rule Details + +This rule reports when you use a combination of `$state` and `$effect` to create a derived value that can be written to. It encourages using the more concise and clearer `$derived` syntax instead. + + + +```svelte + +``` + +The rule specifically looks for patterns where: + +1. You initialize a variable with `$state()` +2. You then use `$effect()` or `$effect.pre()` to assign a new value to that same variable +3. The effect function contains only a single assignment statement + +When this pattern is detected, the rule suggests refactoring to use `$derived()` instead, which provides the same functionality in a more concise way. + +## :wrench: Options + +Nothing. + +- This rule has no options. + +## :books: Further Reading + +- [Svelte Documentation on Reactivity Primitives](https://svelte.dev/docs/svelte-components#script-2-assignments-are-reactive) +- [Svelte RFC for Reactivity Primitives](https://github.com/sveltejs/rfcs/blob/rfc-better-primitives/text/0000-better-primitives.md) + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts) diff --git a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts index b1b2d51ad..7a08befc8 100644 --- a/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts +++ b/packages/eslint-plugin-svelte/src/configs/flat/recommended.ts @@ -37,6 +37,7 @@ const config: Linter.Config[] = [ 'svelte/no-unused-svelte-ignore': 'error', 'svelte/no-useless-children-snippet': 'error', 'svelte/no-useless-mustaches': 'error', + 'svelte/prefer-writable-derived': 'error', 'svelte/require-each-key': 'error', 'svelte/require-event-dispatcher-types': 'error', 'svelte/require-store-reactive-access': 'error', diff --git a/packages/eslint-plugin-svelte/src/rule-types.ts b/packages/eslint-plugin-svelte/src/rule-types.ts index 2150cfc21..ac9f2a94f 100644 --- a/packages/eslint-plugin-svelte/src/rule-types.ts +++ b/packages/eslint-plugin-svelte/src/rule-types.ts @@ -306,6 +306,11 @@ export interface RuleOptions { * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-style-directive/ */ 'svelte/prefer-style-directive'?: Linter.RuleEntry<[]> + /** + * Prefer using writable $derived instead of $state and $effect + * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/prefer-writable-derived/ + */ + 'svelte/prefer-writable-derived'?: Linter.RuleEntry<[]> /** * require keyed `{#each}` block * @see https://sveltejs.github.io/eslint-plugin-svelte/rules/require-each-key/ diff --git a/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts b/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts new file mode 100644 index 000000000..c7f859dbd --- /dev/null +++ b/packages/eslint-plugin-svelte/src/rules/prefer-writable-derived.ts @@ -0,0 +1,147 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { createRule } from '../utils/index.js'; +import { getScope } from '../utils/ast-utils.js'; +import { VERSION as SVELTE_VERSION } from 'svelte/compiler'; +import semver from 'semver'; + +// Writable derived were introduced in Svelte 5.25.0 +const shouldRun = semver.satisfies(SVELTE_VERSION, '>=5.25.0'); + +type ValidFunctionType = TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression; +type ValidFunction = ValidFunctionType & { + body: TSESTree.BlockStatement; +}; + +type ValidAssignmentExpression = TSESTree.AssignmentExpression & { + operator: '='; + left: TSESTree.Identifier; +}; + +type ValidExpressionStatement = TSESTree.ExpressionStatement & { + expression: ValidAssignmentExpression; +}; + +function isEffectOrEffectPre(node: TSESTree.CallExpression) { + if (node.callee.type === 'Identifier') { + return node.callee.name === '$effect'; + } + if (node.callee.type === 'MemberExpression') { + return ( + node.callee.object.type === 'Identifier' && + node.callee.object.name === '$effect' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'pre' + ); + } + + return false; +} + +function isValidFunctionArgument(argument: TSESTree.Node): argument is ValidFunction { + if ( + (argument.type !== 'FunctionExpression' && argument.type !== 'ArrowFunctionExpression') || + argument.params.length !== 0 + ) { + return false; + } + + if (argument.body.type !== 'BlockStatement') { + return false; + } + + return argument.body.body.length === 1; +} + +function isValidAssignment(statement: TSESTree.Statement): statement is ValidExpressionStatement { + if (statement.type !== 'ExpressionStatement') return false; + + const { expression } = statement; + return ( + expression.type === 'AssignmentExpression' && + expression.operator === '=' && + expression.left.type === 'Identifier' + ); +} + +function isStateVariable(init: TSESTree.Expression | null): init is TSESTree.CallExpression { + return ( + init?.type === 'CallExpression' && + init.callee.type === 'Identifier' && + init.callee.name === '$state' + ); +} + +export default createRule('prefer-writable-derived', { + meta: { + docs: { + description: 'Prefer using writable $derived instead of $state and $effect', + category: 'Best Practices', + recommended: true + }, + schema: [], + messages: { + unexpected: 'Prefer using writable $derived instead of $state and $effect', + suggestRewrite: 'Rewrite $state and $effect to $derived' + }, + type: 'suggestion', + conditions: [ + { + svelteVersions: ['5'], + runes: [true, 'undetermined'] + } + ], + hasSuggestions: true + }, + create(context) { + if (!shouldRun) { + return {}; + } + return { + CallExpression: (node: TSESTree.CallExpression) => { + if (!isEffectOrEffectPre(node) || node.arguments.length !== 1) { + return; + } + + const argument = node.arguments[0]; + if (!isValidFunctionArgument(argument)) { + return; + } + + const statement = argument.body.body[0]; + if (!isValidAssignment(statement)) { + return; + } + + const { left, right } = statement.expression; + const scope = getScope(context, statement); + const reference = scope.references.find( + (ref) => ref.identifier.type === 'Identifier' && ref.identifier.name === left.name + ); + + const def = reference?.resolved?.defs?.[0]; + if (!def || def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') { + return; + } + + const { init } = def.node; + if (!isStateVariable(init)) { + return; + } + + context.report({ + node: def.node, + messageId: 'unexpected', + suggest: [ + { + messageId: 'suggestRewrite', + fix: (fixer) => { + const rightCode = context.sourceCode.getText(right); + return [fixer.replaceText(init, `$derived(${rightCode})`), fixer.remove(node)]; + } + } + ] + }); + } + }; + } +}); diff --git a/packages/eslint-plugin-svelte/src/utils/rules.ts b/packages/eslint-plugin-svelte/src/utils/rules.ts index 825edea9e..e79cd969a 100644 --- a/packages/eslint-plugin-svelte/src/utils/rules.ts +++ b/packages/eslint-plugin-svelte/src/utils/rules.ts @@ -60,6 +60,7 @@ import preferClassDirective from '../rules/prefer-class-directive.js'; import preferConst from '../rules/prefer-const.js'; import preferDestructuredStoreProps from '../rules/prefer-destructured-store-props.js'; import preferStyleDirective from '../rules/prefer-style-directive.js'; +import preferWritableDerived from '../rules/prefer-writable-derived.js'; import requireEachKey from '../rules/require-each-key.js'; import requireEventDispatcherTypes from '../rules/require-event-dispatcher-types.js'; import requireEventPrefix from '../rules/require-event-prefix.js'; @@ -136,6 +137,7 @@ export const rules = [ preferConst, preferDestructuredStoreProps, preferStyleDirective, + preferWritableDerived, requireEachKey, requireEventDispatcherTypes, requireEventPrefix, diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json new file mode 100644 index 000000000..0192b1098 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0-0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml new file mode 100644 index 000000000..600473adf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte new file mode 100644 index 000000000..51523f5c3 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic1-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml new file mode 100644 index 000000000..b89390b86 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte new file mode 100644 index 000000000..e8b6769c6 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/basic2-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml new file mode 100644 index 000000000..600473adf --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte new file mode 100644 index 000000000..fdafad808 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre1-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml new file mode 100644 index 000000000..b89390b86 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-errors.yaml @@ -0,0 +1,15 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte new file mode 100644 index 000000000..ee0e91b2b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/effect-pre2-input.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml new file mode 100644 index 000000000..f443ce89a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-errors.yaml @@ -0,0 +1,19 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte new file mode 100644 index 000000000..52fce48ad --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign1-input.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml new file mode 100644 index 000000000..d77136489 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-errors.yaml @@ -0,0 +1,19 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + { + newAlbumName = value; + }} + /> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte new file mode 100644 index 000000000..9f42e657a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign2-input.svelte @@ -0,0 +1,14 @@ + + + { + newAlbumName = value; + }} +/> diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml new file mode 100644 index 000000000..0118d5e29 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-errors.yaml @@ -0,0 +1,38 @@ +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + +- message: Prefer using writable $derived instead of $state and $effect + line: 4 + column: 6 + suggestions: + - desc: Rewrite $state and $effect to $derived + messageId: suggestRewrite + output: | + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte new file mode 100644 index 000000000..bb6169e60 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/invalid/multiple-reassign3-input.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json new file mode 100644 index 000000000..0192b1098 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/_requirements.json @@ -0,0 +1,3 @@ +{ + "svelte": ">=5.0.0-0" +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte new file mode 100644 index 000000000..57bd65848 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition1-input.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte new file mode 100644 index 000000000..9083881f1 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/prefer-writable-derived/valid/condition2-input.svelte @@ -0,0 +1,17 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts b/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts new file mode 100644 index 000000000..80e9065a7 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/src/rules/prefer-writable-derived.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '../../utils/eslint-compat.js'; +import rule from '../../../src/rules/prefer-writable-derived.js'; +import { loadTestCases } from '../../utils/utils.js'; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}); + +tester.run('prefer-writable-derived', rule as any, loadTestCases('prefer-writable-derived')); From 9e7b4c529b5c07244af588c4a2fa39841990b495 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:42:26 +0900 Subject: [PATCH 09/14] chore(deps): update dependency eslint-plugin-node-dependencies to ^0.13.0 (#1204) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f323d1d63..78154bc82 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "eslint-plugin-markdown": "^5.1.0", "eslint-plugin-mdx": "^3.1.5", "eslint-plugin-n": "^17.14.0", - "eslint-plugin-node-dependencies": "^0.12.0", + "eslint-plugin-node-dependencies": "^0.13.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-regexp": "^2.7.0", "eslint-plugin-yml": "^1.15.0", From 070126af0f2aafd2b0b3147457270a3c88a4785e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:54:10 +0900 Subject: [PATCH 10/14] chore(deps): update dependency typescript to ~5.8.0 (#1112) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: yosuke ota --- package.json | 2 +- packages/eslint-plugin-svelte/src/rules/block-lang.ts | 8 ++++---- .../src/rules/consistent-selector-style.ts | 3 +-- .../src/rules/no-navigation-without-base.ts | 6 +++--- .../eslint-plugin-svelte/src/utils/expression-affixes.ts | 6 +++--- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 78154bc82..8d92ed05d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "prettier": "^3.4.1", "prettier-plugin-svelte": "^3.3.2", "rimraf": "^6.0.1", - "typescript": "~5.7.2", + "typescript": "~5.8.0", "typescript-eslint": "^8.16.0" }, "publishConfig": { diff --git a/packages/eslint-plugin-svelte/src/rules/block-lang.ts b/packages/eslint-plugin-svelte/src/rules/block-lang.ts index 39a95ca9e..1a673b3ec 100644 --- a/packages/eslint-plugin-svelte/src/rules/block-lang.ts +++ b/packages/eslint-plugin-svelte/src/rules/block-lang.ts @@ -1,6 +1,6 @@ import { createRule } from '../utils/index.js'; import { findAttribute, getLangValue } from '../utils/ast-utils.js'; -import type { SvelteScriptElement, SvelteStyleElement } from 'svelte-eslint-parser/lib/ast'; +import type { AST } from 'svelte-eslint-parser'; import type { SuggestionReportDescriptor, SourceCode } from '../types.js'; export default createRule('block-lang', { @@ -68,13 +68,13 @@ export default createRule('block-lang', { const allowedScriptLangs: (string | null)[] = Array.isArray(scriptOption) ? scriptOption : [scriptOption]; - const scriptNodes: SvelteScriptElement[] = []; + const scriptNodes: AST.SvelteScriptElement[] = []; const styleOption: string | null | (string | null)[] = context.options[0]?.style ?? null; const allowedStyleLangs: (string | null)[] = Array.isArray(styleOption) ? styleOption : [styleOption]; - const styleNodes: SvelteStyleElement[] = []; + const styleNodes: AST.SvelteStyleElement[] = []; return { SvelteScriptElement(node) { @@ -153,7 +153,7 @@ function buildAddLangSuggestions( function buildReplaceLangSuggestions( langs: (string | null)[], - node: SvelteScriptElement | SvelteStyleElement + node: AST.SvelteScriptElement | AST.SvelteStyleElement ): SuggestionReportDescriptor[] { const tagName = node.name.name; const langAttribute = findAttribute(node, 'lang'); diff --git a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts index 558181465..1f64ee0da 100644 --- a/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts +++ b/packages/eslint-plugin-svelte/src/rules/consistent-selector-style.ts @@ -6,7 +6,6 @@ import type { Node as SelectorNode, Tag as SelectorTag } from 'postcss-selector-parser'; -import type { SvelteHTMLElement } from 'svelte-eslint-parser/lib/ast'; import { findClassesInAttribute } from '../utils/ast-utils.js'; import { extractExpressionPrefixLiteral, @@ -286,7 +285,7 @@ function addToArrayMap( /** * Finds all nodes in selections that could be matched by key */ -function matchSelection(selections: Selections, key: string): SvelteHTMLElement[] { +function matchSelection(selections: Selections, key: string): AST.SvelteHTMLElement[] { const selection = selections.exact.get(key) ?? []; selections.affixes.forEach((nodes, [prefix, suffix]) => { if ((prefix === null || key.startsWith(prefix)) && (suffix === null || key.endsWith(suffix))) { diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts index 094f432dd..2bebe6d16 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts @@ -4,7 +4,7 @@ import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { findVariable } from '../utils/ast-utils.js'; import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js'; import type { RuleContext } from '../types.js'; -import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; +import type { AST } from 'svelte-eslint-parser'; export default createRule('no-navigation-without-base', { meta: { @@ -236,7 +236,7 @@ function expressionIsEmpty(url: TSESTree.Expression): boolean { ); } -function expressionIsAbsolute(url: SvelteLiteral | TSESTree.Expression): boolean { +function expressionIsAbsolute(url: AST.SvelteLiteral | TSESTree.Expression): boolean { switch (url.type) { case 'BinaryExpression': return binaryExpressionIsAbsolute(url); @@ -269,7 +269,7 @@ function urlValueIsAbsolute(url: string): boolean { return url.includes('://'); } -function expressionIsFragment(url: SvelteLiteral | TSESTree.Expression): boolean { +function expressionIsFragment(url: AST.SvelteLiteral | TSESTree.Expression): boolean { switch (url.type) { case 'BinaryExpression': return binaryExpressionIsFragment(url); diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts index 6c960746b..53b6562bc 100644 --- a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -1,7 +1,7 @@ import type { TSESTree } from '@typescript-eslint/types'; import { findVariable } from './ast-utils.js'; import type { RuleContext } from '../types.js'; -import type { SvelteLiteral } from 'svelte-eslint-parser/lib/ast'; +import type { AST } from 'svelte-eslint-parser'; // Variable prefix extraction @@ -80,7 +80,7 @@ function extractTemplateLiteralPrefixVariable( export function extractExpressionPrefixLiteral( context: RuleContext, - expression: SvelteLiteral | TSESTree.Node + expression: AST.SvelteLiteral | TSESTree.Node ): string | null { switch (expression.type) { case 'BinaryExpression': @@ -147,7 +147,7 @@ function extractTemplateLiteralPrefixLiteral( export function extractExpressionSuffixLiteral( context: RuleContext, - expression: SvelteLiteral | TSESTree.Node + expression: AST.SvelteLiteral | TSESTree.Node ): string | null { switch (expression.type) { case 'BinaryExpression': From c2b11a2c0607bc65ede156e265b3834ca18b65ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 1 May 2025 15:40:59 +0000 Subject: [PATCH 11/14] chore(deps): update dependency eslint-plugin-node-dependencies to v1 (#1209) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d92ed05d..f4f40fefa 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "eslint-plugin-markdown": "^5.1.0", "eslint-plugin-mdx": "^3.1.5", "eslint-plugin-n": "^17.14.0", - "eslint-plugin-node-dependencies": "^0.13.0", + "eslint-plugin-node-dependencies": "^1.0.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-regexp": "^2.7.0", "eslint-plugin-yml": "^1.15.0", From f2b7870cddd2660340089e41924419cfd19b868e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 2 May 2025 22:38:28 +0000 Subject: [PATCH 12/14] chore(deps): update dependency npm-run-all2 to v8 (#1211) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4f40fefa..83ad59593 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-regexp": "^2.7.0", "eslint-plugin-yml": "^1.15.0", - "npm-run-all2": "^7.0.1", + "npm-run-all2": "^8.0.0", "prettier": "^3.4.1", "prettier-plugin-svelte": "^3.3.2", "rimraf": "^6.0.1", From 0cb141b32b03a2048b815537102b0bd9f666c5c3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 3 May 2025 03:15:22 +0000 Subject: [PATCH 13/14] chore(deps): update dependency eslint to ~9.26.0 (#1212) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 83ad59593..1b4433bda 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/parser": "^8.16.0", "c8": "^10.1.2", "env-cmd": "^10.1.0", - "eslint": "~9.25.0", + "eslint": "~9.26.0", "eslint-config-prettier": "^10.0.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-eslint-plugin": "^6.3.2", From 9ed51a7497a320335b5cf2641a34c76bc9bd160c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 May 2025 04:02:24 +0000 Subject: [PATCH 14/14] chore(deps): update dependency @types/eslint-scope to v8 (#1214) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docs-svelte-kit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-svelte-kit/package.json b/docs-svelte-kit/package.json index 6ea86fb29..6137c3995 100644 --- a/docs-svelte-kit/package.json +++ b/docs-svelte-kit/package.json @@ -25,7 +25,7 @@ "@types/babel__core": "^7.20.5", "@types/cross-spawn": "^6.0.6", "@types/escape-html": "^1.0.4", - "@types/eslint-scope": "^3.7.7", + "@types/eslint-scope": "^8.0.0", "@types/eslint-visitor-keys": "^3.3.2", "@types/less": "^3.0.7", "@types/markdown-it": "^14.1.2",