Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-lights-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': patch
---

fix(no-navigation-without-resolve): do not report if there is `data-sveltekit-reload` or `rel="external"` in `<a>`
13 changes: 12 additions & 1 deletion docs/rules/no-navigation-without-resolve.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ since: 'v3.12.0'

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.

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.
This rule checks all 4 navigation options for the presence of the `resolve()` function call, with exceptions 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
- `<a>` links that opt out of SvelteKit navigation using `data-sveltekit-reload`
- `<a>` links with `rel="external"` (including multi-token values like `"external nofollow"`), which SvelteKit also treats as full-page navigations

See SvelteKit link options for details: [Link options — data-sveltekit-reload](https://svelte.dev/docs/kit/link-options#data-sveltekit-reload).

<!--eslint-skip-->

Expand Down Expand Up @@ -52,6 +58,10 @@ This rule checks all 4 navigation options for the presence of the `resolve()` fu
<!-- ✗ BAD -->
<a href="/foo">Click me!</a>
<a href={'/foo'}>Click me!</a>

<!-- ✓ GOOD (opts out of SPA handling) -->
<a data-sveltekit-reload href="/foo">Click me!</a>
<a rel="external" href="/foo">Click me!</a>
```

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

## :rocket: Version

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export default createRule('no-navigation-without-resolve', {
) {
return;
}
if (anchorHasSveltekitReload(node) || anchorRelIncludesExternal(node)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add this to the previous if?

return;
}
if (
(node.value[0].type === 'SvelteLiteral' &&
!expressionIsAbsolute(new FindVariableContext(context), node.value[0]) &&
Expand Down Expand Up @@ -373,3 +376,43 @@ function templateLiteralIsFragment(
function urlValueIsFragment(url: string): boolean {
return url.startsWith('#');
}

function anchorHasSveltekitReload(node: AST.SvelteAttribute): boolean {
const startTag = node.parent;
return startTag.attributes.some((attr): attr is AST.SvelteAttribute => {
return attr.type === 'SvelteAttribute' && attr.key.name === 'data-sveltekit-reload';
});
}

function relTokenListIncludesExternal(value: string): boolean {
return /(?:^|\s)external(?:\s|$)/i.test(value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return /(?:^|\s)external(?:\s|$)/i.test(value);
return value.split(" ").some((part) => part === "external");

}

function anchorRelIncludesExternal(node: AST.SvelteAttribute): boolean {
const startTag = node.parent;
const relAttr = startTag.attributes.find((attr): attr is AST.SvelteAttribute => {
return attr.type === 'SvelteAttribute' && attr.key.name === 'rel';
});
if (!relAttr) return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!relAttr) return false;
if (relAttr === undefined) return false;

// Handle literal values like rel="external" or rel="external nofollow"
for (const v of relAttr.value) {
if (v.type === 'SvelteLiteral') {
if (relTokenListIncludesExternal(v.value)) return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge with parent if

}
if (v.type === 'SvelteMustacheTag') {
// Best-effort: detect simple string literals in mustache, e.g., rel={'external'}
const expr = v.expression;
if (expr.type === 'Literal' && typeof expr.value === 'string') {
if (relTokenListIncludesExternal(expr.value)) return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge with parent if

}
if (
expr.type === 'TemplateLiteral' &&
expr.expressions.length === 0 &&
expr.quasis.length === 1
) {
if (relTokenListIncludesExternal(expr.quasis[0].value.raw)) return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge with parent if

}
}
}
return false;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
- message: Found a link with a url that isn't resolved.
- message: Unexpected href link without resolve().
line: 5
column: 9
suggestions: null
- message: Found a link with a url that isn't resolved.
- message: Unexpected href link without resolve().
line: 6
column: 9
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 1
column: 28
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel={'externals'} href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 1
column: 28
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel={`externals`} href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 1
column: 26
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel="externals" href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel="external nofollow" href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel={'external'} href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel={`external`} href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a rel="external" href="/foo">Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a data-sveltekit-reload href="/foo">Click me!</a>
Loading