Skip to content

Commit 35c72fc

Browse files
committed
fix(no-navigation-without-resolve): do not report if there is data-sveltekit-reload or rel="external" in <a>
1 parent 27cf677 commit 35c72fc

15 files changed

+82
-3
lines changed

.changeset/brown-lights-show.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-svelte': patch
3+
---
4+
5+
fix(no-navigation-without-resolve): do not report if there is `data-sveltekit-reload` or `rel="external"` in `<a>`

docs/rules/no-navigation-without-resolve.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ since: 'v3.12.0'
1616

1717
This rule reports navigation using HTML `<a>` tags, SvelteKit's `goto()`, `pushState()` and `replaceState()` functions without resolving a relative URL. All four of these may be used for navigation, with `goto()`, `pushState()` and `replaceState()` being intended solely for internal navigation (i.e. not leaving the site), while `<a>` tags may be used for both internal and external navigation. When using any way of internal navigation, the URL must be resolved using SvelteKit's `resolve()`, otherwise the site may break. For programmatic navigation to external URLs, using `window.location` is advised.
1818

19-
This rule checks all 4 navigation options for the presence of the `resolve()` function call, with an exception for `<a>` links to absolute URLs (and fragment URLs), which are assumed to be used for external navigation and so do not require the `resolve()` function, and for shallow routing functions with an empty string as the path, which keeps the current URL.
19+
This rule checks all 4 navigation options for the presence of the `resolve()` function call, with exceptions for:
20+
21+
- `<a>` links to absolute URLs (and fragment URLs), which are assumed to be used for external navigation and so do not require the `resolve()` function
22+
- `<a>` links that opt out of SvelteKit navigation using `data-sveltekit-reload`
23+
- `<a>` links with `rel="external"` (including multi-token values like `"external nofollow"`), which SvelteKit also treats as full-page navigations
24+
25+
See SvelteKit link options for details: [Link options — data-sveltekit-reload](https://svelte.dev/docs/kit/link-options#data-sveltekit-reload).
2026

2127
<!--eslint-skip-->
2228

@@ -52,6 +58,10 @@ This rule checks all 4 navigation options for the presence of the `resolve()` fu
5258
<!-- ✗ BAD -->
5359
<a href="/foo">Click me!</a>
5460
<a href={'/foo'}>Click me!</a>
61+
62+
<!-- ✓ GOOD (opts out of SPA handling) -->
63+
<a data-sveltekit-reload href="/foo">Click me!</a>
64+
<a rel="external" href="/foo">Click me!</a>
5565
```
5666

5767
## :wrench: Options
@@ -82,6 +92,7 @@ This rule checks all 4 navigation options for the presence of the `resolve()` fu
8292
- [`goto()` documentation](https://svelte.dev/docs/kit/$app-navigation#goto)
8393
- [`pushState()` documentation](https://svelte.dev/docs/kit/$app-navigation#pushState)
8494
- [`replaceState()` documentation](https://svelte.dev/docs/kit/$app-navigation#replaceState)
95+
- [Link options — data-sveltekit-reload](https://svelte.dev/docs/kit/link-options#data-sveltekit-reload)
8596

8697
## :rocket: Version
8798

packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export default createRule('no-navigation-without-resolve', {
9595
) {
9696
return;
9797
}
98+
if (anchorHasSveltekitReload(node) || anchorRelIncludesExternal(node)) {
99+
return;
100+
}
98101
if (
99102
(node.value[0].type === 'SvelteLiteral' &&
100103
!expressionIsAbsolute(new FindVariableContext(context), node.value[0]) &&
@@ -373,3 +376,43 @@ function templateLiteralIsFragment(
373376
function urlValueIsFragment(url: string): boolean {
374377
return url.startsWith('#');
375378
}
379+
380+
function anchorHasSveltekitReload(node: AST.SvelteAttribute): boolean {
381+
const startTag = node.parent;
382+
return startTag.attributes.some((attr): attr is AST.SvelteAttribute => {
383+
return attr.type === 'SvelteAttribute' && attr.key.name === 'data-sveltekit-reload';
384+
});
385+
}
386+
387+
function relTokenListIncludesExternal(value: string): boolean {
388+
return /(?:^|\s)external(?:\s|$)/i.test(value);
389+
}
390+
391+
function anchorRelIncludesExternal(node: AST.SvelteAttribute): boolean {
392+
const startTag = node.parent;
393+
const relAttr = startTag.attributes.find((attr): attr is AST.SvelteAttribute => {
394+
return attr.type === 'SvelteAttribute' && attr.key.name === 'rel';
395+
});
396+
if (!relAttr) return false;
397+
// Handle literal values like rel="external" or rel="external nofollow"
398+
for (const v of relAttr.value) {
399+
if (v.type === 'SvelteLiteral') {
400+
if (relTokenListIncludesExternal(v.value)) return true;
401+
}
402+
if (v.type === 'SvelteMustacheTag') {
403+
// Best-effort: detect simple string literals in mustache, e.g., rel={'external'}
404+
const expr = v.expression;
405+
if (expr.type === 'Literal' && typeof expr.value === 'string') {
406+
if (relTokenListIncludesExternal(expr.value)) return true;
407+
}
408+
if (
409+
expr.type === 'TemplateLiteral' &&
410+
expr.expressions.length === 0 &&
411+
expr.quasis.length === 1
412+
) {
413+
if (relTokenListIncludesExternal(expr.quasis[0].value.raw)) return true;
414+
}
415+
}
416+
}
417+
return false;
418+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
- message: Found a link with a url that isn't resolved.
1+
- message: Unexpected href link without resolve().
22
line: 5
33
column: 9
44
suggestions: null
5-
- message: Found a link with a url that isn't resolved.
5+
- message: Unexpected href link without resolve().
66
line: 6
77
column: 9
88
suggestions: null
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Unexpected href link without resolve().
2+
line: 1
3+
column: 28
4+
suggestions: null
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<a rel={'externals'} href="/foo">Click me!</a>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Unexpected href link without resolve().
2+
line: 1
3+
column: 28
4+
suggestions: null
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<a rel={`externals`} href="/foo">Click me!</a>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- message: Unexpected href link without resolve().
2+
line: 1
3+
column: 26
4+
suggestions: null
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<a rel="externals" href="/foo">Click me!</a>

0 commit comments

Comments
 (0)