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:'