Skip to content

Commit 4e6c681

Browse files
authored
feat: added the no-goto-without-base rule (#679)
1 parent d4303f5 commit 4e6c681

20 files changed

+295
-0
lines changed

.changeset/shaggy-dryers-smoke.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-svelte": minor
3+
---
4+
5+
feat: added the no-goto-without-base rule

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,14 @@ These rules extend the rules provided by ESLint itself, or other plugins to work
394394
| [svelte/no-inner-declarations](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-inner-declarations/) | disallow variable or `function` declarations in nested blocks | :star: |
395395
| [svelte/no-trailing-spaces](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-trailing-spaces/) | disallow trailing whitespace at the end of lines | :wrench: |
396396

397+
## SvelteKit
398+
399+
These rules relate to SvelteKit and its best Practices.
400+
401+
| Rule ID | Description | |
402+
|:--------|:------------|:---|
403+
| [svelte/no-goto-without-base](https://sveltejs.github.io/eslint-plugin-svelte/rules/no-goto-without-base/) | disallow using goto() without the base path | |
404+
397405
## Experimental
398406

399407
:warning: These rules are considered experimental and may change or be removed in the future:

docs-svelte-kit/src/lib/eslint/scripts/linter.js

+5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export const categories = [
3232
classes: 'svelte-category',
3333
rules: []
3434
},
35+
{
36+
title: 'SvelteKit',
37+
classes: 'svelte-category',
38+
rules: []
39+
},
3540
{
3641
title: 'Experimental',
3742
classes: 'svelte-category',

docs-svelte-kit/src/lib/utils.js

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const categories = [
1818
'Best Practices',
1919
'Stylistic Issues',
2020
'Extension Rules',
21+
'SvelteKit',
2122
'Experimental',
2223
'System'
2324
];

docs/rules.md

+8
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ These rules extend the rules provided by ESLint itself, or other plugins to work
101101
| [svelte/no-inner-declarations](./rules/no-inner-declarations.md) | disallow variable or `function` declarations in nested blocks | :star: |
102102
| [svelte/no-trailing-spaces](./rules/no-trailing-spaces.md) | disallow trailing whitespace at the end of lines | :wrench: |
103103

104+
## SvelteKit
105+
106+
These rules relate to SvelteKit and its best Practices.
107+
108+
| Rule ID | Description | |
109+
| :------------------------------------------------------------- | :------------------------------------------ | :-- |
110+
| [svelte/no-goto-without-base](./rules/no-goto-without-base.md) | disallow using goto() without the base path | |
111+
104112
## Experimental
105113

106114
:warning: These rules are considered experimental and may change or be removed in the future:

docs/rules/no-goto-without-base.md

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
pageClass: 'rule-details'
3+
sidebarDepth: 0
4+
title: 'svelte/no-goto-without-base'
5+
description: 'disallow using goto() without the base path'
6+
---
7+
8+
# svelte/no-goto-without-base
9+
10+
> disallow using goto() without the base path
11+
12+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> **_This rule has not been released yet._** </badge>
13+
14+
## :book: Rule Details
15+
16+
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).
17+
18+
<ESLintCodeBlock>
19+
20+
<!--eslint-skip-->
21+
22+
```svelte
23+
<script>
24+
/* eslint svelte/no-goto-without-base: "error" */
25+
26+
import { goto } from '$app/navigation';
27+
import { base } from '$app/paths';
28+
import { base as baseAlias } from '$app/paths';
29+
30+
// ✓ GOOD
31+
goto(base + '/foo/');
32+
goto(`${base}/foo/`);
33+
34+
goto(baseAlias + '/foo/');
35+
goto(`${baseAlias}/foo/`);
36+
37+
goto('https://localhost/foo/');
38+
39+
// ✗ BAD
40+
goto('/foo');
41+
42+
goto('/foo/' + base);
43+
goto(`/foo/${base}`);
44+
</script>
45+
```
46+
47+
</ESLintCodeBlock>
48+
49+
## :wrench: Options
50+
51+
Nothing.
52+
53+
## :books: Further Reading
54+
55+
- [`goto()` documentation](https://kit.svelte.dev/docs/modules#$app-navigation-goto)
56+
- [`base` documentation](https://kit.svelte.dev/docs/modules#$app-paths-base)
57+
58+
## :mag: Implementation
59+
60+
- [Rule source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/src/rules/no-goto-without-base.ts)
61+
- [Test source](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/tests/src/rules/no-goto-without-base.ts)

src/rules/no-goto-without-base.ts

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { TSESTree } from '@typescript-eslint/types';
2+
import { createRule } from '../utils';
3+
import { ReferenceTracker } from '@eslint-community/eslint-utils';
4+
import { getSourceCode } from '../utils/compat';
5+
import { findVariable } from '../utils/ast-utils';
6+
import type { RuleContext } from '../types';
7+
8+
export default createRule('no-goto-without-base', {
9+
meta: {
10+
docs: {
11+
description: 'disallow using goto() without the base path',
12+
category: 'SvelteKit',
13+
recommended: false
14+
},
15+
schema: [],
16+
messages: {
17+
isNotPrefixedWithBasePath:
18+
"Found a goto() call with a url that isn't prefixed with the base path."
19+
},
20+
type: 'suggestion'
21+
},
22+
create(context) {
23+
return {
24+
Program() {
25+
const referenceTracker = new ReferenceTracker(
26+
getSourceCode(context).scopeManager.globalScope!
27+
);
28+
const basePathNames = extractBasePathReferences(referenceTracker, context);
29+
for (const gotoCall of extractGotoReferences(referenceTracker)) {
30+
if (gotoCall.arguments.length < 1) {
31+
continue;
32+
}
33+
const path = gotoCall.arguments[0];
34+
switch (path.type) {
35+
case 'BinaryExpression':
36+
checkBinaryExpression(context, path, basePathNames);
37+
break;
38+
case 'Literal':
39+
checkLiteral(context, path);
40+
break;
41+
case 'TemplateLiteral':
42+
checkTemplateLiteral(context, path, basePathNames);
43+
break;
44+
default:
45+
context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' });
46+
}
47+
}
48+
}
49+
};
50+
}
51+
});
52+
53+
function checkBinaryExpression(
54+
context: RuleContext,
55+
path: TSESTree.BinaryExpression,
56+
basePathNames: Set<TSESTree.Identifier>
57+
): void {
58+
if (path.left.type !== 'Identifier' || !basePathNames.has(path.left)) {
59+
context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' });
60+
}
61+
}
62+
63+
function checkTemplateLiteral(
64+
context: RuleContext,
65+
path: TSESTree.TemplateLiteral,
66+
basePathNames: Set<TSESTree.Identifier>
67+
): void {
68+
const startingIdentifier = extractStartingIdentifier(path);
69+
if (startingIdentifier === undefined || !basePathNames.has(startingIdentifier)) {
70+
context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' });
71+
}
72+
}
73+
74+
function checkLiteral(context: RuleContext, path: TSESTree.Literal): void {
75+
const absolutePathRegex = /^(?:[+a-z]+:)?\/\//i;
76+
if (!absolutePathRegex.test(path.value?.toString() ?? '')) {
77+
context.report({ loc: path.loc, messageId: 'isNotPrefixedWithBasePath' });
78+
}
79+
}
80+
81+
function extractStartingIdentifier(
82+
templateLiteral: TSESTree.TemplateLiteral
83+
): TSESTree.Identifier | undefined {
84+
const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) =>
85+
a.range[0] < b.range[0] ? -1 : 1
86+
);
87+
for (const part of literalParts) {
88+
if (part.type === 'TemplateElement' && part.value.raw === '') {
89+
// Skip empty quasi in the begining
90+
continue;
91+
}
92+
if (part.type === 'Identifier') {
93+
return part;
94+
}
95+
return undefined;
96+
}
97+
return undefined;
98+
}
99+
100+
function extractGotoReferences(referenceTracker: ReferenceTracker): TSESTree.CallExpression[] {
101+
return Array.from(
102+
referenceTracker.iterateEsmReferences({
103+
'$app/navigation': {
104+
[ReferenceTracker.ESM]: true,
105+
goto: {
106+
[ReferenceTracker.CALL]: true
107+
}
108+
}
109+
}),
110+
({ node }) => node
111+
);
112+
}
113+
114+
function extractBasePathReferences(
115+
referenceTracker: ReferenceTracker,
116+
context: RuleContext
117+
): Set<TSESTree.Identifier> {
118+
const set = new Set<TSESTree.Identifier>();
119+
for (const { node } of referenceTracker.iterateEsmReferences({
120+
'$app/paths': {
121+
[ReferenceTracker.ESM]: true,
122+
base: {
123+
[ReferenceTracker.READ]: true
124+
}
125+
}
126+
})) {
127+
const variable = findVariable(context, (node as TSESTree.ImportSpecifier).local);
128+
if (!variable) continue;
129+
for (const reference of variable.references) {
130+
if (reference.identifier.type === 'Identifier') set.add(reference.identifier);
131+
}
132+
}
133+
return set;
134+
}

src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type RuleCategory =
4141
| 'Best Practices'
4242
| 'Stylistic Issues'
4343
| 'Extension Rules'
44+
| 'SvelteKit'
4445
| 'Experimental'
4546
| 'System';
4647

src/utils/rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import noDupeUseDirectives from '../rules/no-dupe-use-directives';
2727
import noDynamicSlotName from '../rules/no-dynamic-slot-name';
2828
import noExportLoadInSvelteModuleInKitPages from '../rules/no-export-load-in-svelte-module-in-kit-pages';
2929
import noExtraReactiveCurlies from '../rules/no-extra-reactive-curlies';
30+
import noGotoWithoutBase from '../rules/no-goto-without-base';
3031
import noIgnoredUnsubscribe from '../rules/no-ignored-unsubscribe';
3132
import noImmutableReactiveStatements from '../rules/no-immutable-reactive-statements';
3233
import noInlineStyles from '../rules/no-inline-styles';
@@ -90,6 +91,7 @@ export const rules = [
9091
noDynamicSlotName,
9192
noExportLoadInSvelteModuleInKitPages,
9293
noExtraReactiveCurlies,
94+
noGotoWithoutBase,
9395
noIgnoredUnsubscribe,
9496
noImmutableReactiveStatements,
9597
noInlineStyles,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Found a goto() call with a url that isn't prefixed with the base path.
2+
line: 4
3+
column: 8
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import { goto as alias } from '$app/navigation';
3+
4+
alias('/foo');
5+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- message: Found a goto() call with a url that isn't prefixed with the base path.
2+
line: 6
3+
column: 7
4+
suggestions: null
5+
- message: Found a goto() call with a url that isn't prefixed with the base path.
6+
line: 7
7+
column: 7
8+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
import { base } from '$app/paths';
3+
import { goto } from '$app/navigation';
4+
5+
// eslint-disable-next-line prefer-template -- Testing both variants
6+
goto('/foo/' + base);
7+
goto(`/foo/${base}`);
8+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Found a goto() call with a url that isn't prefixed with the base path.
2+
line: 4
3+
column: 7
4+
suggestions: null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import { goto } from '$app/navigation';
3+
4+
goto('/foo');
5+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script>
2+
import { goto } from '$app/navigation';
3+
4+
goto('http://localhost/foo/');
5+
goto('https://localhost/foo/');
6+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
import { base as alias } from '$app/paths';
3+
import { goto } from '$app/navigation';
4+
5+
// eslint-disable-next-line prefer-template -- Testing both variants
6+
goto(alias + '/foo/');
7+
goto(`${alias}/foo/`);
8+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
import { base } from '$app/paths';
3+
import { goto } from '$app/navigation';
4+
5+
// eslint-disable-next-line prefer-template -- Testing both variants
6+
goto(base + '/foo/');
7+
goto(`${base}/foo/`);
8+
</script>
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { RuleTester } from '../../utils/eslint-compat';
2+
import rule from '../../../src/rules/no-goto-without-base';
3+
import { loadTestCases } from '../../utils/utils';
4+
5+
const tester = new RuleTester({
6+
languageOptions: {
7+
ecmaVersion: 2020,
8+
sourceType: 'module'
9+
}
10+
});
11+
12+
tester.run('no-goto-without-base', rule as any, loadTestCases('no-goto-without-base'));

tools/render-rules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const categories = [
77
'Best Practices',
88
'Stylistic Issues',
99
'Extension Rules',
10+
'SvelteKit',
1011
'Experimental',
1112
'System'
1213
] as const;
@@ -18,6 +19,7 @@ const descriptions: Record<(typeof categories)[number], string> = {
1819
'Stylistic Issues': 'These rules relate to style guidelines, and are therefore quite subjective:',
1920
'Extension Rules':
2021
'These rules extend the rules provided by ESLint itself, or other plugins to work well in Svelte:',
22+
SvelteKit: 'These rules relate to SvelteKit and its best Practices.',
2123
Experimental:
2224
':warning: These rules are considered experimental and may change or be removed in the future:',
2325
System: 'These rules relate to this plugin works:'

0 commit comments

Comments
 (0)