diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index 4d8db7f1db..d2ee73ca47 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -1,12 +1,12 @@ import type { ComputedRef, DefineComponent, InjectionKey, PropType } from 'vue' import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent } from 'vue' import type { RouteLocation, RouteLocationRaw } from '#vue-router' -import { hasProtocol, parseQuery, parseURL, withTrailingSlash, withoutTrailingSlash } from 'ufo' +import { hasProtocol, joinURL, parseQuery, parseURL, withTrailingSlash, withoutTrailingSlash } from 'ufo' import { preloadRouteComponents } from '../composables/preload' import { onNuxtReady } from '../composables/ready' import { navigateTo, useRouter } from '../composables/router' -import { useNuxtApp } from '../nuxt' +import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { cancelIdleCallback, requestIdleCallback } from '../compat/idle-callback' // @ts-expect-error virtual file @@ -170,6 +170,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { }, setup (props, { slots }) { const router = useRouter() + const config = useRuntimeConfig() // Resolving `to` value from `to` and `href` props const to: ComputedRef = computed(() => { @@ -180,6 +181,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return resolveTrailingSlashBehavior(path, router.resolve) }) + // Lazily check whether to.value has a protocol + const isProtocolURL = computed(() => typeof to.value === 'string' && hasProtocol(to.value, { acceptRelative: true })) + // Resolving link type const isExternal = computed(() => { // External prop is explicitly set @@ -197,7 +201,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return false } - return to.value === '' || hasProtocol(to.value, { acceptRelative: true }) + return to.value === '' || isProtocolURL.value }) // Prefetching @@ -280,7 +284,11 @@ export function defineNuxtLink (options: NuxtLinkOptions) { // Resolves `to` value if it's a route location object // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) - const href = typeof to.value === 'object' ? router.resolve(to.value)?.href ?? null : to.value || null + const href = typeof to.value === 'object' + ? router.resolve(to.value)?.href ?? null + : (to.value && !props.external && !isProtocolURL.value) + ? resolveTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), router.resolve) as string + : to.value || null // Resolves `target` value const target = props.target || null diff --git a/packages/nuxt/test/nuxt-link.test.ts b/packages/nuxt/test/nuxt-link.test.ts index a3743de40d..6cf247886d 100644 --- a/packages/nuxt/test/nuxt-link.test.ts +++ b/packages/nuxt/test/nuxt-link.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it, vi } from 'vitest' import type { RouteLocation, RouteLocationRaw } from 'vue-router' import type { NuxtLinkOptions, NuxtLinkProps } from '../src/app/components/nuxt-link' import { defineNuxtLink } from '../src/app/components/nuxt-link' +import { useRuntimeConfig } from '../src/app/nuxt' + +// mocks `useRuntimeConfig()` +vi.mock('../src/app/nuxt', () => ({ + useRuntimeConfig: vi.fn(() => ({ + app: { + baseURL: '/' + } + })) +})) // Mocks `h()` vi.mock('vue', async () => { @@ -125,6 +135,40 @@ describe('nuxt-link:propsOrAttributes', () => { it('defaults to `null`', () => { expect(nuxtLink({ to: 'https://nuxtjs.org' }).props.target).toBe(null) }) + + it('prefixes target="_blank" internal links with baseURL', () => { + vi.mocked(useRuntimeConfig).withImplementation(() => { + return { + app: { + baseURL: '/base' + } + } as any + }, () => { + expect(nuxtLink({ to: '/', target: '_blank' }).props.href).toBe('/base') + expect(nuxtLink({ to: '/base', target: '_blank' }).props.href).toBe('/base/base') + expect(nuxtLink({ to: '/to', target: '_blank' }).props.href).toBe('/base/to') + expect(nuxtLink({ to: '/base/to', target: '_blank' }).props.href).toBe('/base/base/to') + expect(nuxtLink({ to: '//base/to', target: '_blank' }).props.href).toBe('//base/to') + expect(nuxtLink({ to: '//to.com/thing', target: '_blank' }).props.href).toBe('//to.com/thing') + expect(nuxtLink({ to: 'https://test.com/to', target: '_blank' }).props.href).toBe('https://test.com/to') + + expect(nuxtLink({ to: '/', target: '_blank' }, { trailingSlash: 'append' }).props.href).toBe('/base/') + expect(nuxtLink({ to: '/base/', target: '_blank' }, { trailingSlash: 'remove' }).props.href).toBe('/base/base') + }) + }) + + it('excludes the baseURL for external links', () => { + vi.mocked(useRuntimeConfig).withImplementation(() => { + return { + app: { + baseURL: '/base' + } + } as any + }, () => { + expect(nuxtLink({ to: 'http://nuxtjs.org/app/about', target: '_blank' }).props.href).toBe('http://nuxtjs.org/app/about') + expect(nuxtLink({ to: '//nuxtjs.org/app/about', target: '_blank' }).props.href).toBe('//nuxtjs.org/app/about') + }) + }) }) describe('rel', () => {