feat(nuxt): support trailingSlashBehavior in defineNuxtLink (#19458)

This commit is contained in:
Alex Korytskyi 2023-03-07 09:17:42 +02:00 committed by GitHub
parent b9e6980a62
commit 3a73f42d1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 216 additions and 8 deletions

View File

@ -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"}
:: ::

View File

@ -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

View File

@ -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')
})
})
}) })
}) })

View File

@ -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&amp;thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&amp;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&amp;thing=other/thing#thing-other",
"/nuxt-link/trailing-slash?test=true&amp;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&amp;thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&amp;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&amp;thing=other/thing#thing-other",
"/nuxt-link/trailing-slash/?test=true&amp;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')

View File

@ -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)

View 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>