mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +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;
|
activeClass?: string;
|
||||||
exactActiveClass?: string;
|
exactActiveClass?: string;
|
||||||
prefetchedClass?: string;
|
prefetchedClass?: string;
|
||||||
|
trailingSlash?: 'append' | 'remove'
|
||||||
}) => Component
|
}) => 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"`)
|
- **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"`)
|
- **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.
|
- **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"}
|
::LinkExample{link="/docs/examples/routing/nuxt-link"}
|
||||||
::
|
::
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import type { PropType, DefineComponent, ComputedRef } from 'vue'
|
import type { PropType, DefineComponent, ComputedRef } from 'vue'
|
||||||
import { defineComponent, h, ref, resolveComponent, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { defineComponent, h, ref, resolveComponent, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocation, RouteLocationRaw } from 'vue-router'
|
||||||
import { hasProtocol, parseQuery, parseURL } from 'ufo'
|
import { hasProtocol, parseQuery, parseURL, withoutTrailingSlash, withTrailingSlash } from 'ufo'
|
||||||
|
|
||||||
import { preloadRouteComponents } from '../composables/preload'
|
import { preloadRouteComponents } from '../composables/preload'
|
||||||
import { onNuxtReady } from '../composables/ready'
|
import { onNuxtReady } from '../composables/ready'
|
||||||
@ -19,6 +19,7 @@ export type NuxtLinkOptions = {
|
|||||||
activeClass?: string
|
activeClass?: string
|
||||||
exactActiveClass?: string
|
exactActiveClass?: string
|
||||||
prefetchedClass?: string
|
prefetchedClass?: string
|
||||||
|
trailingSlash?: 'append' | 'remove'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NuxtLinkProps = {
|
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.`)
|
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({
|
return defineComponent({
|
||||||
name: componentName,
|
name: componentName,
|
||||||
@ -148,7 +170,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
|
|||||||
const to: ComputedRef<string | RouteLocationRaw> = computed(() => {
|
const to: ComputedRef<string | RouteLocationRaw> = computed(() => {
|
||||||
checkPropConflicts(props, 'to', 'href')
|
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
|
// Resolving link type
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { expect, describe, it, vi } from 'vitest'
|
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 type { NuxtLinkOptions, NuxtLinkProps } from '../src/app/components/nuxt-link'
|
||||||
import { defineNuxtLink } 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()`
|
// Mocks Nuxt `useRouter()`
|
||||||
vi.mock('../src/app/composables/router', () => ({
|
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
|
// Helpers for test visibility
|
||||||
@ -25,9 +38,9 @@ const INTERNAL = 'RouterLink'
|
|||||||
// Renders a `<NuxtLink />`
|
// Renders a `<NuxtLink />`
|
||||||
const nuxtLink = (
|
const nuxtLink = (
|
||||||
props: NuxtLinkProps = {},
|
props: NuxtLinkProps = {},
|
||||||
NuxtLinkOptions: Partial<NuxtLinkOptions> = {}
|
nuxtLinkOptions: Partial<NuxtLinkOptions> = {}
|
||||||
): { type: string, props: Record<string, unknown>, slots: unknown } => {
|
): { 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> }) =>
|
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 } })()
|
() => [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')
|
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', () => {
|
describe('head tags', () => {
|
||||||
it('should render tags', async () => {
|
it('should render tags', async () => {
|
||||||
const headHtml = await $fetch('/head')
|
const headHtml = await $fetch('/head')
|
||||||
|
@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
|
|||||||
|
|
||||||
it('default server bundle size', async () => {
|
it('default server bundle size', async () => {
|
||||||
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
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)
|
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
||||||
expect(modules.totalBytes).toBeLessThan(2722000)
|
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