diff --git a/.changeset/shaggy-dryers-smoke.md b/.changeset/shaggy-dryers-smoke.md new file mode 100644 index 000000000..44312f9b8 --- /dev/null +++ b/.changeset/shaggy-dryers-smoke.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-svelte": minor +--- + +feat: added the no-goto-without-base rule diff --git a/README.md b/README.md index 51fdef1d2..ebd15a809 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,14 @@ These rules extend the rules provided by ESLint itself, or other plugins to work | [svelte/no-inner-declarations](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-inner-declarations/) | disallow variable or `function` declarations in nested blocks | :star: | | [svelte/no-trailing-spaces](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-trailing-spaces/) | disallow trailing whitespace at the end of lines | :wrench: | +## SvelteKit + +These rules relate to SvelteKit and its best Practices. + +| Rule ID | Description | | +|:--------|:------------|:---| +| [svelte/no-goto-without-base](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-goto-without-base/) | disallow using goto() without the base path | | + ## Experimental :warning: These rules are considered experimental and may change or be removed in the future: diff --git a/docs-svelte-kit/src/lib/eslint/scripts/linter.js b/docs-svelte-kit/src/lib/eslint/scripts/linter.js index a47058017..f361a50fe 100644 --- a/docs-svelte-kit/src/lib/eslint/scripts/linter.js +++ b/docs-svelte-kit/src/lib/eslint/scripts/linter.js @@ -32,6 +32,11 @@ export const categories = [ classes: 'svelte-category', rules: [] }, + { + title: 'SvelteKit', + classes: 'svelte-category', + rules: [] + }, { title: 'Experimental', classes: 'svelte-category', diff --git a/docs-svelte-kit/src/lib/utils.js b/docs-svelte-kit/src/lib/utils.js index 8ef89992b..ce28beab4 100644 --- a/docs-svelte-kit/src/lib/utils.js +++ b/docs-svelte-kit/src/lib/utils.js @@ -18,6 +18,7 @@ const categories = [ 'Best Practices', 'Stylistic Issues', 'Extension Rules', + 'SvelteKit', 'Experimental', 'System' ]; diff --git a/docs/rules.md b/docs/rules.md index 257cb4f93..e932ded0a 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -101,6 +101,14 @@ These rules extend the rules provided by ESLint itself, or other plugins to work | [svelte/no-inner-declarations](./rules/no-inner-declarations.md) | disallow variable or `function` declarations in nested blocks | :star: | | [svelte/no-trailing-spaces](./rules/no-trailing-spaces.md) | disallow trailing whitespace at the end of lines | :wrench: | +## SvelteKit + +These rules relate to SvelteKit and its best Practices. + +| Rule ID | Description | | +| :------------------------------------------------------------- | :------------------------------------------ | :-- | +| [svelte/no-goto-without-base](./rules/no-goto-without-base.md) | disallow using goto() without the base path | | + ## Experimental :warning: These rules are considered experimental and may change or be removed in the future: diff --git a/docs/rules/no-goto-without-base.md b/docs/rules/no-goto-without-base.md new file mode 100644 index 000000000..e1d0de774 --- /dev/null +++ b/docs/rules/no-goto-without-base.md @@ -0,0 +1,61 @@ +--- +pageClass: 'rule-details' +sidebarDepth: 0 +title: 'svelte/no-goto-without-base' +description: 'disallow using goto() without the base path' +--- + +# svelte/no-goto-without-base + +> disallow using goto() without the base path + +- :exclamation: **_This rule has not been released yet._** + +## :book: Rule Details + +This rule reports navigation using SvelteKit's `goto()` function without prefixing a relative URL with the base path. If a non-prefixed relative URL is used for navigation, the `goto` function navigates away from the base path, which is usually not what you wanted to do (for external URLs, `window.location = url` should be used instead). + + + + + +```svelte + +``` + + + +## :wrench: Options + +Nothing. + +## :books: Further Reading + +- [`goto()` documentation](https://kit.svelte.dev/docs/modules#$app-navigation-goto) +- [`base` documentation](https://kit.svelte.dev/docs/modules#$app-paths-base) + +## :mag: Implementation + +- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/src/rules/no-goto-without-base.ts) +- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/tests/src/rules/no-goto-without-base.ts) diff --git a/src/rules/no-goto-without-base.ts b/src/rules/no-goto-without-base.ts new file mode 100644 index 000000000..9154dc264 --- /dev/null +++ b/src/rules/no-goto-without-base.ts @@ -0,0 +1,134 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import { createRule } from '../utils'; +import { ReferenceTracker } from '@eslint-community/eslint-utils'; +import { getSourceCode } from '../utils/compat'; +import { findVariable } from '../utils/ast-utils'; +import type { RuleContext } from '../types'; + +export default createRule('no-goto-without-base', { + meta: { + docs: { + description: 'disallow using goto() without the base path', + category: 'SvelteKit', + recommended: false + }, + schema: [], + messages: { + isNotPrefixedWithBasePath: + "Found a goto() call with a url that isn't prefixed with the base path." + }, + type: 'suggestion' + }, + create(context) { + return { + Program() { + const referenceTracker = new ReferenceTracker( + getSourceCode(context).scopeManager.globalScope! + ); + const basePathNames = extractBasePathReferences(referenceTracker, context); + for (const gotoCall of extractGotoReferences(referenceTracker)) { + if (gotoCall.arguments.length < 1) { + continue; + } + const path = gotoCall.arguments[0]; + switch (path.type) { + case 'BinaryExpression': + checkBinaryExpression(context, path, basePathNames); + break; + case 'Literal': + checkLiteral(context, path); + break; + case 'TemplateLiteral': + checkTemplateLiteral(context, path, basePathNames); + break; + default: + context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' }); + } + } + } + }; + } +}); + +function checkBinaryExpression( + context: RuleContext, + path: TSESTree.BinaryExpression, + basePathNames: Set +): void { + if (path.left.type !== 'Identifier' || !basePathNames.has(path.left)) { + context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' }); + } +} + +function checkTemplateLiteral( + context: RuleContext, + path: TSESTree.TemplateLiteral, + basePathNames: Set +): void { + const startingIdentifier = extractStartingIdentifier(path); + if (startingIdentifier === undefined || !basePathNames.has(startingIdentifier)) { + context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' }); + } +} + +function checkLiteral(context: RuleContext, path: TSESTree.Literal): void { + const absolutePathRegex = /^(?:[+a-z]+:)?\/\//i; + if (!absolutePathRegex.test(path.value?.toString() ?? '')) { + context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' }); + } +} + +function extractStartingIdentifier( + templateLiteral: TSESTree.TemplateLiteral +): TSESTree.Identifier | 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 === 'Identifier') { + return part; + } + return undefined; + } + return undefined; +} + +function extractGotoReferences(referenceTracker: ReferenceTracker): TSESTree.CallExpression[] { + return Array.from( + referenceTracker.iterateEsmReferences({ + '$app/navigation': { + [ReferenceTracker.ESM]: true, + goto: { + [ReferenceTracker.CALL]: true + } + } + }), + ({ node }) => node + ); +} + +function extractBasePathReferences( + referenceTracker: ReferenceTracker, + context: RuleContext +): Set { + const set = new Set(); + for (const { node } of referenceTracker.iterateEsmReferences({ + '$app/paths': { + [ReferenceTracker.ESM]: true, + base: { + [ReferenceTracker.READ]: true + } + } + })) { + const variable = findVariable(context, (node as TSESTree.ImportSpecifier).local); + if (!variable) continue; + for (const reference of variable.references) { + if (reference.identifier.type === 'Identifier') set.add(reference.identifier); + } + } + return set; +} diff --git a/src/types.ts b/src/types.ts index 64591ae7b..ab313fbb6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,7 @@ export type RuleCategory = | 'Best Practices' | 'Stylistic Issues' | 'Extension Rules' + | 'SvelteKit' | 'Experimental' | 'System'; diff --git a/src/utils/rules.ts b/src/utils/rules.ts index 7a7713e45..cb19a67a3 100644 --- a/src/utils/rules.ts +++ b/src/utils/rules.ts @@ -27,6 +27,7 @@ import noDupeUseDirectives from '../rules/no-dupe-use-directives'; import noDynamicSlotName from '../rules/no-dynamic-slot-name'; import noExportLoadInSvelteModuleInKitPages from '../rules/no-export-load-in-svelte-module-in-kit-pages'; import noExtraReactiveCurlies from '../rules/no-extra-reactive-curlies'; +import noGotoWithoutBase from '../rules/no-goto-without-base'; import noIgnoredUnsubscribe from '../rules/no-ignored-unsubscribe'; import noImmutableReactiveStatements from '../rules/no-immutable-reactive-statements'; import noInlineStyles from '../rules/no-inline-styles'; @@ -90,6 +91,7 @@ export const rules = [ noDynamicSlotName, noExportLoadInSvelteModuleInKitPages, noExtraReactiveCurlies, + noGotoWithoutBase, noIgnoredUnsubscribe, noImmutableReactiveStatements, noInlineStyles, diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-errors.yaml b/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-errors.yaml new file mode 100644 index 000000000..cc35de50a --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-errors.yaml @@ -0,0 +1,4 @@ +- message: Found a goto() call with a url that isn't prefixed with the base path. + line: 4 + column: 8 + suggestions: null diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-input.svelte b/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-input.svelte new file mode 100644 index 000000000..00f19edec --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/aliased-goto01-input.svelte @@ -0,0 +1,5 @@ + diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-errors.yaml b/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-errors.yaml new file mode 100644 index 000000000..ed8b9578e --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-errors.yaml @@ -0,0 +1,8 @@ +- message: Found a goto() call with a url that isn't prefixed with the base path. + line: 6 + column: 7 + suggestions: null +- message: Found a goto() call with a url that isn't prefixed with the base path. + line: 7 + column: 7 + suggestions: null diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-input.svelte b/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-input.svelte new file mode 100644 index 000000000..1aeff3aec --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/base-not-prefixed01-input.svelte @@ -0,0 +1,8 @@ + diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-errors.yaml b/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-errors.yaml new file mode 100644 index 000000000..658fcb47d --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-errors.yaml @@ -0,0 +1,4 @@ +- message: Found a goto() call with a url that isn't prefixed with the base path. + line: 4 + column: 7 + suggestions: null diff --git a/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-input.svelte b/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-input.svelte new file mode 100644 index 000000000..6f011fe2d --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/invalid/no-base01-input.svelte @@ -0,0 +1,5 @@ + diff --git a/tests/fixtures/rules/no-goto-without-base/valid/absolute-uri01-input.svelte b/tests/fixtures/rules/no-goto-without-base/valid/absolute-uri01-input.svelte new file mode 100644 index 000000000..87ee8abf3 --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/valid/absolute-uri01-input.svelte @@ -0,0 +1,6 @@ + diff --git a/tests/fixtures/rules/no-goto-without-base/valid/base-aliased01-input.svelte b/tests/fixtures/rules/no-goto-without-base/valid/base-aliased01-input.svelte new file mode 100644 index 000000000..b11e55f92 --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/valid/base-aliased01-input.svelte @@ -0,0 +1,8 @@ + diff --git a/tests/fixtures/rules/no-goto-without-base/valid/base-prefixed01-input.svelte b/tests/fixtures/rules/no-goto-without-base/valid/base-prefixed01-input.svelte new file mode 100644 index 000000000..cd7177deb --- /dev/null +++ b/tests/fixtures/rules/no-goto-without-base/valid/base-prefixed01-input.svelte @@ -0,0 +1,8 @@ + diff --git a/tests/src/rules/no-goto-without-base.ts b/tests/src/rules/no-goto-without-base.ts new file mode 100644 index 000000000..1fadf4174 --- /dev/null +++ b/tests/src/rules/no-goto-without-base.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '../../utils/eslint-compat'; +import rule from '../../../src/rules/no-goto-without-base'; +import { loadTestCases } from '../../utils/utils'; + +const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}); + +tester.run('no-goto-without-base', rule as any, loadTestCases('no-goto-without-base')); diff --git a/tools/render-rules.ts b/tools/render-rules.ts index c6209b8a9..8e8466bd7 100644 --- a/tools/render-rules.ts +++ b/tools/render-rules.ts @@ -7,6 +7,7 @@ const categories = [ 'Best Practices', 'Stylistic Issues', 'Extension Rules', + 'SvelteKit', 'Experimental', 'System' ] as const; @@ -18,6 +19,7 @@ const descriptions: Record<(typeof categories)[number], string> = { 'Stylistic Issues': 'These rules relate to style guidelines, and are therefore quite subjective:', 'Extension Rules': 'These rules extend the rules provided by ESLint itself, or other plugins to work well in Svelte:', + SvelteKit: 'These rules relate to SvelteKit and its best Practices.', Experimental: ':warning: These rules are considered experimental and may change or be removed in the future:', System: 'These rules relate to this plugin works:'