mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): support trailingSlashBehavior
in defineNuxtLink
(#19458)
This commit is contained in:
parent
b9e6980a62
commit
3a73f42d1c
@ -115,6 +115,7 @@ defineNuxtLink({
|
||||
activeClass?: string;
|
||||
exactActiveClass?: string;
|
||||
prefetchedClass?: string;
|
||||
trailingSlash?: 'append' | 'remove'
|
||||
}) => Component
|
||||
```
|
||||
|
||||
@ -123,6 +124,7 @@ defineNuxtLink({
|
||||
- **activeClass**: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/#linkactiveclass). Defaults to Vue Router's default (`"router-link-active"`)
|
||||
- **exactActiveClass**: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/#linkexactactiveclass). Defaults to Vue Router's default (`"router-link-exact-active"`)
|
||||
- **prefetchedClass**: A default class to apply to links that have been prefetched.
|
||||
- **trailingSlash**: An option to either add or remove trailing slashes in the `href`. If unset or not matching the valid values `append` or `remove`, it will be ignored. **This option is currently only available on the [Edge Channel](/docs/guide/going-further/edge-channel/).** <!-- stabilityedge -->
|
||||
|
||||
::LinkExample{link="/docs/examples/routing/nuxt-link"}
|
||||
::
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { PropType, DefineComponent, ComputedRef } from 'vue'
|
||||
import { defineComponent, h, ref, resolveComponent, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { hasProtocol, parseQuery, parseURL } from 'ufo'
|
||||
import type { RouteLocation, RouteLocationRaw } from 'vue-router'
|
||||
import { hasProtocol, parseQuery, parseURL, withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
||||
|
||||
import { preloadRouteComponents } from '../composables/preload'
|
||||
import { onNuxtReady } from '../composables/ready'
|
||||
@ -19,6 +19,7 @@ export type NuxtLinkOptions = {
|
||||
activeClass?: string
|
||||
exactActiveClass?: string
|
||||
prefetchedClass?: string
|
||||
trailingSlash?: 'append' | 'remove'
|
||||
}
|
||||
|
||||
export type NuxtLinkProps = {
|
||||
@ -53,6 +54,27 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
console.warn(`[${componentName}] \`${main}\` and \`${sub}\` cannot be used together. \`${sub}\` will be ignored.`)
|
||||
}
|
||||
}
|
||||
const resolveTrailingSlashBehavior = (
|
||||
to: RouteLocationRaw,
|
||||
resolve: (to: RouteLocationRaw) => RouteLocation & { href?: string }
|
||||
): RouteLocationRaw | RouteLocation => {
|
||||
if (!to || (options.trailingSlash !== 'append' && options.trailingSlash !== 'remove')) {
|
||||
return to
|
||||
}
|
||||
|
||||
const normalizeTrailingSlash = options.trailingSlash === 'append' ? withTrailingSlash : withoutTrailingSlash
|
||||
if (typeof to === 'string') {
|
||||
return normalizeTrailingSlash(to, true)
|
||||
}
|
||||
|
||||
const path = 'path' in to ? to.path : resolve(to).path
|
||||
|
||||
return {
|
||||
...to,
|
||||
name: undefined, // named routes would otherwise always override trailing slash behavior
|
||||
path: normalizeTrailingSlash(path, true)
|
||||
}
|
||||
}
|
||||
|
||||
return defineComponent({
|
||||
name: componentName,
|
||||
@ -148,7 +170,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
||||
const to: ComputedRef<string | RouteLocationRaw> = computed(() => {
|
||||
checkPropConflicts(props, 'to', 'href')
|
||||
|
||||
return props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute)
|
||||
const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute)
|
||||
|
||||
return resolveTrailingSlashBehavior(path, router.resolve)
|
||||
})
|
||||
|
||||
// Resolving link type
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { expect, describe, it, vi } from 'vitest'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
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'
|
||||
|
||||
@ -15,7 +15,20 @@ vi.mock('vue', async () => {
|
||||
|
||||
// Mocks Nuxt `useRouter()`
|
||||
vi.mock('../src/app/composables/router', () => ({
|
||||
useRouter: () => ({ resolve: ({ to }: { to: string }) => ({ href: to }) })
|
||||
useRouter: () => ({
|
||||
resolve: (route: string | RouteLocation & { to?: string }): Partial<RouteLocation> & { href?: string } => {
|
||||
if (typeof route === 'string') {
|
||||
return { href: route, path: route }
|
||||
}
|
||||
return route.to
|
||||
? { href: route.to }
|
||||
: {
|
||||
path: route.path || `/${route.name?.toString()}` || undefined,
|
||||
query: route.query || undefined,
|
||||
hash: route.hash || undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// Helpers for test visibility
|
||||
@ -25,9 +38,9 @@ const INTERNAL = 'RouterLink'
|
||||
// Renders a `<NuxtLink />`
|
||||
const nuxtLink = (
|
||||
props: NuxtLinkProps = {},
|
||||
NuxtLinkOptions: Partial<NuxtLinkOptions> = {}
|
||||
nuxtLinkOptions: Partial<NuxtLinkOptions> = {}
|
||||
): { type: string, props: Record<string, unknown>, slots: unknown } => {
|
||||
const component = defineNuxtLink({ componentName: 'NuxtLink', ...NuxtLinkOptions })
|
||||
const component = defineNuxtLink({ componentName: 'NuxtLink', ...nuxtLinkOptions })
|
||||
|
||||
const [type, _props, slots] = (component.setup as unknown as (props: NuxtLinkProps, context: { slots: Record<string, () => unknown> }) =>
|
||||
() => [string, Record<string, unknown>, unknown])(props, { slots: { default: () => null } })()
|
||||
@ -199,5 +212,29 @@ describe('nuxt-link:propsOrAttributes', () => {
|
||||
expect(nuxtLink({ to: '/to', ariaCurrentValue: 'step' }).props.ariaCurrentValue).toBe('step')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trailingSlashBehavior', () => {
|
||||
it('append slash', () => {
|
||||
const appendSlashOptions: NuxtLinkOptions = { trailingSlash: 'append' }
|
||||
|
||||
expect(nuxtLink({ to: '/to' }, appendSlashOptions).props.to).toEqual('/to/')
|
||||
expect(nuxtLink({ to: '/to/' }, appendSlashOptions).props.to).toEqual('/to/')
|
||||
expect(nuxtLink({ to: { name: 'to' } }, appendSlashOptions).props.to).toHaveProperty('path', '/to/')
|
||||
expect(nuxtLink({ to: { path: '/to' } }, appendSlashOptions).props.to).toHaveProperty('path', '/to/')
|
||||
expect(nuxtLink({ href: '/to' }, appendSlashOptions).props.to).toEqual('/to/')
|
||||
expect(nuxtLink({ to: '/to?param=1' }, appendSlashOptions).props.to).toEqual('/to/?param=1')
|
||||
})
|
||||
|
||||
it('remove slash', () => {
|
||||
const removeSlashOptions: NuxtLinkOptions = { trailingSlash: 'remove' }
|
||||
|
||||
expect(nuxtLink({ to: '/to' }, removeSlashOptions).props.to).toEqual('/to')
|
||||
expect(nuxtLink({ to: '/to/' }, removeSlashOptions).props.to).toEqual('/to')
|
||||
expect(nuxtLink({ to: { name: 'to' } }, removeSlashOptions).props.to).toHaveProperty('path', '/to')
|
||||
expect(nuxtLink({ to: { path: '/to/' } }, removeSlashOptions).props.to).toHaveProperty('path', '/to')
|
||||
expect(nuxtLink({ href: '/to/' }, removeSlashOptions).props.to).toEqual('/to')
|
||||
expect(nuxtLink({ to: '/to/?param=1' }, removeSlashOptions).props.to).toEqual('/to?param=1')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -282,6 +282,78 @@ describe('pages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nuxt links', () => {
|
||||
it('handles trailing slashes', async () => {
|
||||
const html = await $fetch('/nuxt-link/trailing-slash')
|
||||
const data: Record<string, string[]> = {}
|
||||
for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) {
|
||||
data[selector] = []
|
||||
for (const match of html.matchAll(new RegExp(`href="([^"]*)"[^>]*class="[^"]*\\b${selector}\\b`, 'g'))) {
|
||||
data[selector].push(match[1])
|
||||
}
|
||||
}
|
||||
expect(data).toMatchInlineSnapshot(`
|
||||
{
|
||||
"link-with-trailing-slash": [
|
||||
"/",
|
||||
"/nuxt-link/trailing-slash/",
|
||||
"/nuxt-link/trailing-slash/",
|
||||
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash/",
|
||||
"/nuxt-link/trailing-slash/?with-state=true",
|
||||
"/nuxt-link/trailing-slash/?without-state=true",
|
||||
],
|
||||
"link-without-trailing-slash": [
|
||||
"/",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash?with-state=true",
|
||||
"/nuxt-link/trailing-slash?without-state=true",
|
||||
],
|
||||
"nuxt-link": [
|
||||
"/",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash/",
|
||||
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash?with-state=true",
|
||||
"/nuxt-link/trailing-slash?without-state=true",
|
||||
],
|
||||
"router-link": [
|
||||
"/",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash/",
|
||||
"/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other",
|
||||
"/nuxt-link/trailing-slash",
|
||||
"/nuxt-link/trailing-slash?with-state=true",
|
||||
"/nuxt-link/trailing-slash?without-state=true",
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('preserves route state', async () => {
|
||||
const page = await createPage('/nuxt-link/trailing-slash')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
for (const selector of ['nuxt-link', 'router-link', 'link-with-trailing-slash', 'link-without-trailing-slash']) {
|
||||
await page.locator(`.${selector}[href*=with-state]`).click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.getByTestId('window-state').innerText()).toContain('bar')
|
||||
|
||||
await page.locator(`.${selector}[href*=without-state]`).click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(await page.getByTestId('window-state').innerText()).not.toContain('bar')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('head tags', () => {
|
||||
it('should render tags', async () => {
|
||||
const headHtml = await $fetch('/head')
|
||||
|
@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
|
||||
|
||||
it('default server bundle size', async () => {
|
||||
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||
expect(stats.server.totalBytes).toBeLessThan(93000)
|
||||
expect(stats.server.totalBytes).toBeLessThan(94000)
|
||||
|
||||
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
||||
expect(modules.totalBytes).toBeLessThan(2722000)
|
||||
|
73
test/fixtures/basic/pages/nuxt-link/trailing-slash.vue
vendored
Normal file
73
test/fixtures/basic/pages/nuxt-link/trailing-slash.vue
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
const LinkWithTrailingSlash = defineNuxtLink({
|
||||
trailingSlash: 'append'
|
||||
})
|
||||
const LinkWithoutTrailingSlash = defineNuxtLink({
|
||||
trailingSlash: 'remove'
|
||||
})
|
||||
const links = [
|
||||
'/',
|
||||
'/nuxt-link/trailing-slash',
|
||||
'/nuxt-link/trailing-slash/',
|
||||
'/nuxt-link/trailing-slash?test=true&thing=other/thing#thing-other',
|
||||
'/nuxt-link/trailing-slash/?test=true&thing=other/thing#thing-other',
|
||||
{ name: 'nuxt-link-trailing-slash' },
|
||||
{ query: { 'with-state': 'true' }, state: { foo: 'bar' } },
|
||||
{ query: { 'without-state': 'true' } }
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const windowState = computed(() => {
|
||||
console.log(route.fullPath)
|
||||
return process.client ? window.history.state.foo : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="window-state">
|
||||
<ClientOnly>
|
||||
{{ windowState }}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="(link, index) in links" :key="index">
|
||||
<LinkWithTrailingSlash :to="link" class="link-with-trailing-slash">
|
||||
<LinkWithTrailingSlash v-slot="{ href }" custom :to="link">
|
||||
{{ href }}
|
||||
</LinkWithTrailingSlash>
|
||||
</LinkWithTrailingSlash>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li v-for="(link, index) in links" :key="index">
|
||||
<LinkWithoutTrailingSlash :to="link" class="link-without-trailing-slash">
|
||||
<LinkWithoutTrailingSlash v-slot="{ href }" custom :to="link">
|
||||
{{ href }}
|
||||
</LinkWithoutTrailingSlash>
|
||||
</LinkWithoutTrailingSlash>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li v-for="(link, index) in links" :key="index">
|
||||
<NuxtLink :to="link" class="nuxt-link">
|
||||
<NuxtLink v-slot="{ href }" custom :to="link">
|
||||
{{ href }}
|
||||
</NuxtLink>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li v-for="(link, index) in links" :key="index">
|
||||
<RouterLink :to="link" class="router-link">
|
||||
<RouterLink v-slot="{ href }" custom :to="link">
|
||||
{{ href }}
|
||||
</RouterLink>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
Loading…
Reference in New Issue
Block a user