diff --git a/.changeset/brown-lights-show.md b/.changeset/brown-lights-show.md new file mode 100644 index 000000000..06d3c1299 --- /dev/null +++ b/.changeset/brown-lights-show.md @@ -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 `` diff --git a/docs/rules/no-navigation-without-resolve.md b/docs/rules/no-navigation-without-resolve.md index 05ea2ad9a..88dec8ac3 100644 --- a/docs/rules/no-navigation-without-resolve.md +++ b/docs/rules/no-navigation-without-resolve.md @@ -16,7 +16,13 @@ since: 'v3.12.0' This rule reports navigation using HTML `` 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 `` 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 `` 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: + +- `` links to absolute URLs (and fragment URLs), which are assumed to be used for external navigation and so do not require the `resolve()` function +- `` links that opt out of SvelteKit navigation using `data-sveltekit-reload` +- `` 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). @@ -52,6 +58,10 @@ This rule checks all 4 navigation options for the presence of the `resolve()` fu Click me! Click me! + + +Click me! +Click me! ``` ## :wrench: Options @@ -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 diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts index 06ad91df8..c418b667a 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts @@ -95,6 +95,9 @@ export default createRule('no-navigation-without-resolve', { ) { return; } + if (anchorHasSveltekitReload(node) || anchorRelIncludesExternal(node)) { + return; + } if ( (node.value[0].type === 'SvelteLiteral' && !expressionIsAbsolute(new FindVariableContext(context), node.value[0]) && @@ -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); +} + +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; + // 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; + } + 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; + } + if ( + expr.type === 'TemplateLiteral' && + expr.expressions.length === 0 && + expr.quasis.length === 1 + ) { + if (relTokenListIncludesExternal(expr.quasis[0].value.raw)) return true; + } + } + } + return false; +} diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-partial-asset01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-partial-asset01-errors.yaml index 7b8b81c68..2d1f90f38 100644 --- a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-partial-asset01-errors.yaml +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-partial-asset01-errors.yaml @@ -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 diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-errors.yaml new file mode 100644 index 000000000..12f99fa21 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-errors.yaml @@ -0,0 +1,4 @@ +- message: Unexpected href link without resolve(). + line: 1 + column: 28 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-input.svelte new file mode 100644 index 000000000..f1f795d6e --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-mustache01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-errors.yaml new file mode 100644 index 000000000..12f99fa21 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-errors.yaml @@ -0,0 +1,4 @@ +- message: Unexpected href link without resolve(). + line: 1 + column: 28 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-input.svelte new file mode 100644 index 000000000..9d129fefb --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external-template01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-errors.yaml new file mode 100644 index 000000000..bc7ce2088 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-errors.yaml @@ -0,0 +1,4 @@ +- message: Unexpected href link without resolve(). + line: 1 + column: 26 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-input.svelte new file mode 100644 index 000000000..33cd93af7 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/link-rel-not-external01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-multi01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-multi01-input.svelte new file mode 100644 index 000000000..bc4be163a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-multi01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-mustache01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-mustache01-input.svelte new file mode 100644 index 000000000..ed7c85630 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-mustache01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-template01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-template01-input.svelte new file mode 100644 index 000000000..a23b5a7cb --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external-template01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external01-input.svelte new file mode 100644 index 000000000..459d1ff97 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-rel-external01-input.svelte @@ -0,0 +1 @@ +Click me! diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-reload01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-reload01-input.svelte new file mode 100644 index 000000000..c9eef28dd --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/valid/link-reload01-input.svelte @@ -0,0 +1 @@ +Click me!