2023-09-28 10:08:20 +00:00
import type { ComputedRef , DefineComponent , InjectionKey , PropType } from 'vue'
import { computed , defineComponent , h , inject , onBeforeUnmount , onMounted , provide , ref , resolveComponent } from 'vue'
2023-05-09 17:08:07 +00:00
import type { RouteLocation , RouteLocationRaw } from '#vue-router'
2023-04-07 16:02:47 +00:00
import { hasProtocol , parseQuery , parseURL , withTrailingSlash , withoutTrailingSlash } from 'ufo'
2022-03-14 13:36:32 +00:00
2022-10-17 11:15:29 +00:00
import { preloadRouteComponents } from '../composables/preload'
2022-12-05 10:09:58 +00:00
import { onNuxtReady } from '../composables/ready'
2022-09-14 09:22:03 +00:00
import { navigateTo , useRouter } from '../composables/router'
import { useNuxtApp } from '../nuxt'
2022-12-05 10:09:58 +00:00
import { cancelIdleCallback , requestIdleCallback } from '../compat/idle-callback'
2022-03-14 13:36:32 +00:00
2023-10-18 12:43:42 +00:00
// @ts-expect-error virtual file
import { nuxtLinkDefaults } from '#build/nuxt.config.mjs'
2022-10-14 08:36:03 +00:00
const firstNonUndefined = < T > ( . . . args : ( T | undefined ) [ ] ) = > args . find ( arg = > arg !== undefined )
2022-03-14 13:36:32 +00:00
const DEFAULT_EXTERNAL_REL_ATTRIBUTE = 'noopener noreferrer'
2023-09-28 10:08:20 +00:00
const NuxtLinkDevKeySymbol : InjectionKey < boolean > = Symbol ( 'nuxt-link-dev-key' )
2022-03-14 13:36:32 +00:00
export type NuxtLinkOptions = {
2022-07-07 17:28:23 +00:00
componentName? : string
externalRelAttribute? : string | null
activeClass? : string
exactActiveClass? : string
2022-09-13 20:20:23 +00:00
prefetchedClass? : string
2023-03-07 07:17:42 +00:00
trailingSlash ? : 'append' | 'remove'
2022-03-14 13:36:32 +00:00
}
export type NuxtLinkProps = {
// Routing
2023-05-09 17:08:07 +00:00
to? : RouteLocationRaw
href? : RouteLocationRaw
2022-07-07 17:28:23 +00:00
external? : boolean
replace? : boolean
custom? : boolean
2022-03-14 13:36:32 +00:00
// Attributes
2022-10-13 17:51:26 +00:00
target ? : '_blank' | '_parent' | '_self' | '_top' | ( string & { } ) | null
2022-08-12 17:47:58 +00:00
rel? : string | null
2022-07-07 17:28:23 +00:00
noRel? : boolean
2022-03-14 13:36:32 +00:00
2022-09-13 20:20:23 +00:00
prefetch? : boolean
noPrefetch? : boolean
2022-03-14 13:36:32 +00:00
// Styling
2022-07-07 17:28:23 +00:00
activeClass? : string
exactActiveClass? : string
2022-03-14 13:36:32 +00:00
// Vue Router's `<RouterLink>` additional props
2022-07-07 17:28:23 +00:00
ariaCurrentValue? : string
2022-09-13 20:20:23 +00:00
}
2023-06-07 10:11:24 +00:00
/*! @__NO_SIDE_EFFECTS__ */
2022-03-14 13:36:32 +00:00
export function defineNuxtLink ( options : NuxtLinkOptions ) {
const componentName = options . componentName || 'NuxtLink'
2022-08-12 17:47:58 +00:00
const checkPropConflicts = ( props : NuxtLinkProps , main : keyof NuxtLinkProps , sub : keyof NuxtLinkProps ) : void = > {
2023-08-07 22:03:40 +00:00
if ( import . meta . dev && props [ main ] !== undefined && props [ sub ] !== undefined ) {
2022-03-14 13:36:32 +00:00
console . warn ( ` [ ${ componentName } ] \` ${ main } \` and \` ${ sub } \` cannot be used together. \` ${ sub } \` will be ignored. ` )
}
}
2023-03-07 07:17:42 +00:00
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 )
}
}
2022-03-14 13:36:32 +00:00
return defineComponent ( {
name : componentName ,
props : {
// Routing
to : {
2023-05-09 17:08:07 +00:00
type : [ String , Object ] as PropType < RouteLocationRaw > ,
2022-03-14 13:36:32 +00:00
default : undefined ,
required : false
} ,
href : {
2023-05-09 17:08:07 +00:00
type : [ String , Object ] as PropType < RouteLocationRaw > ,
2022-03-14 13:36:32 +00:00
default : undefined ,
required : false
} ,
// Attributes
target : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
rel : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
noRel : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
} ,
2022-09-13 20:20:23 +00:00
// Prefetching
prefetch : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
} ,
noPrefetch : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
} ,
2022-03-14 13:36:32 +00:00
// Styling
activeClass : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
exactActiveClass : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
2022-09-13 20:20:23 +00:00
prefetchedClass : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
2022-03-14 13:36:32 +00:00
// Vue Router's `<RouterLink>` additional props
replace : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
} ,
ariaCurrentValue : {
type : String as PropType < string > ,
default : undefined ,
required : false
} ,
// Edge cases handling
external : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
} ,
// Slot API
custom : {
type : Boolean as PropType < boolean > ,
default : undefined ,
required : false
}
} ,
setup ( props , { slots } ) {
2022-08-12 17:47:58 +00:00
const router = useRouter ( )
2022-03-14 13:36:32 +00:00
// Resolving `to` value from `to` and `href` props
2022-08-12 17:47:58 +00:00
const to : ComputedRef < string | RouteLocationRaw > = computed ( ( ) = > {
2022-03-14 13:36:32 +00:00
checkPropConflicts ( props , 'to' , 'href' )
2023-03-07 07:17:42 +00:00
const path = props . to || props . href || '' // Defaults to empty string (won't render any `href` attribute)
return resolveTrailingSlashBehavior ( path , router . resolve )
2022-03-14 13:36:32 +00:00
} )
// Resolving link type
const isExternal = computed < boolean > ( ( ) = > {
2022-08-12 17:47:58 +00:00
// External prop is explicitly set
2022-03-14 13:36:32 +00:00
if ( props . external ) {
return true
}
// When `target` prop is set, link is external
if ( props . target && props . target !== '_self' ) {
return true
}
// When `to` is a route object then it's an internal link
if ( typeof to . value === 'object' ) {
return false
}
2023-03-09 18:37:18 +00:00
return to . value === '' || hasProtocol ( to . value , { acceptRelative : true } )
2022-03-14 13:36:32 +00:00
} )
2022-09-13 20:20:23 +00:00
// Prefetching
const prefetched = ref ( false )
2023-08-07 22:03:40 +00:00
const el = import . meta . server ? undefined : ref < HTMLElement | null > ( null )
const elRef = import . meta . server ? undefined : ( ref : any ) = > { el ! . value = props . custom ? ref?.$el?.nextElementSibling : ref?.$el }
2023-03-20 21:46:12 +00:00
2023-08-07 22:03:40 +00:00
if ( import . meta . client ) {
2022-09-13 20:20:23 +00:00
checkPropConflicts ( props , 'prefetch' , 'noPrefetch' )
2023-02-20 20:31:27 +00:00
const shouldPrefetch = props . prefetch !== false && props . noPrefetch !== true && props . target !== '_blank' && ! isSlowConnection ( )
2022-09-13 20:20:23 +00:00
if ( shouldPrefetch ) {
const nuxtApp = useNuxtApp ( )
let idleId : number
2023-03-20 21:46:12 +00:00
let unobserve : ( ( ) = > void ) | null = null
2022-09-13 20:20:23 +00:00
onMounted ( ( ) = > {
2022-12-02 16:13:35 +00:00
const observer = useObserver ( )
2022-12-05 10:09:58 +00:00
onNuxtReady ( ( ) = > {
2022-12-02 16:13:35 +00:00
idleId = requestIdleCallback ( ( ) = > {
if ( el ? . value ? . tagName ) {
2023-05-11 08:37:32 +00:00
unobserve = observer ! . observe ( el . value as HTMLElement , async ( ) = > {
2022-12-02 16:13:35 +00:00
unobserve ? . ( )
unobserve = null
2023-02-20 20:31:27 +00:00
const path = typeof to . value === 'string' ? to.value : router.resolve ( to . value ) . fullPath
2022-12-02 16:13:35 +00:00
await Promise . all ( [
2023-02-20 20:31:27 +00:00
nuxtApp . hooks . callHook ( 'link:prefetch' , path ) . catch ( ( ) = > { } ) ,
2022-12-02 16:13:35 +00:00
! isExternal . value && preloadRouteComponents ( to . value as string , router ) . catch ( ( ) = > { } )
] )
prefetched . value = true
} )
}
} )
2022-12-05 10:09:58 +00:00
} )
2022-09-13 20:20:23 +00:00
} )
onBeforeUnmount ( ( ) = > {
if ( idleId ) { cancelIdleCallback ( idleId ) }
unobserve ? . ( )
unobserve = null
} )
}
}
2023-09-28 10:08:20 +00:00
if ( import . meta . dev && import . meta . server && ! props . custom ) {
const isNuxtLinkChild = inject ( NuxtLinkDevKeySymbol , false )
if ( isNuxtLinkChild ) {
console . log ( '[nuxt] [NuxtLink] You can\'t nest one <a> inside another <a>. This will cause a hydration error on client-side. You can pass the `custom` prop to take full control of the markup.' )
} else {
provide ( NuxtLinkDevKeySymbol , true )
}
}
2022-03-14 13:36:32 +00:00
return ( ) = > {
if ( ! isExternal . value ) {
2023-03-02 09:53:46 +00:00
const routerLinkProps : Record < string , any > = {
2023-03-20 21:46:12 +00:00
ref : elRef ,
2023-03-02 09:53:46 +00:00
to : to.value ,
activeClass : props.activeClass || options . activeClass ,
exactActiveClass : props.exactActiveClass || options . exactActiveClass ,
replace : props.replace ,
ariaCurrentValue : props.ariaCurrentValue ,
custom : props.custom
}
// `custom` API cannot support fallthrough attributes as the slot
// may render fragment or text root nodes (#14897, #19375)
if ( ! props . custom ) {
if ( prefetched . value ) {
routerLinkProps . class = props . prefetchedClass || options . prefetchedClass
}
routerLinkProps . rel = props . rel
}
2022-03-14 13:36:32 +00:00
// Internal link
return h (
resolveComponent ( 'RouterLink' ) ,
2023-03-02 09:53:46 +00:00
routerLinkProps ,
2022-03-14 13:36:32 +00:00
slots . default
)
}
// Resolves `to` value if it's a route location object
2023-04-01 11:12:34 +00:00
// converts `""` to `null` to prevent the attribute from being added as empty (`href=""`)
2022-03-14 13:36:32 +00:00
const href = typeof to . value === 'object' ? router . resolve ( to . value ) ? . href ? ? null : to . value || null
// Resolves `target` value
const target = props . target || null
// Resolves `rel`
checkPropConflicts ( props , 'noRel' , 'rel' )
2022-03-16 10:10:32 +00:00
const rel = ( props . noRel )
2022-03-14 13:36:32 +00:00
? null
// converts `""` to `null` to prevent the attribute from being added as empty (`rel=""`)
2022-03-16 10:10:32 +00:00
: firstNonUndefined < string | null > ( props . rel , options . externalRelAttribute , href ? DEFAULT_EXTERNAL_REL_ATTRIBUTE : '' ) || null
2022-03-14 13:36:32 +00:00
2022-07-07 17:28:23 +00:00
const navigate = ( ) = > navigateTo ( href , { replace : props.replace } )
// https://router.vuejs.org/api/#custom
if ( props . custom ) {
2022-08-12 17:47:58 +00:00
if ( ! slots . default ) {
return null
}
2023-02-27 15:13:14 +00:00
2022-07-07 17:28:23 +00:00
return slots . default ( {
href ,
navigate ,
2023-02-27 15:13:14 +00:00
get route ( ) {
if ( ! href ) { return undefined }
const url = parseURL ( href )
return {
path : url.pathname ,
fullPath : url.pathname ,
get query ( ) { return parseQuery ( url . search ) } ,
hash : url.hash ,
// stub properties for compat with vue-router
params : { } ,
name : undefined ,
matched : [ ] ,
redirectedFrom : undefined ,
meta : { } ,
href
}
} ,
2022-07-07 17:28:23 +00:00
rel ,
target ,
2022-11-09 09:02:11 +00:00
isExternal : isExternal.value ,
2022-07-07 17:28:23 +00:00
isActive : false ,
isExactActive : false
} )
}
2022-10-17 11:15:29 +00:00
return h ( 'a' , { ref : el , href , rel , target } , slots . default ? . ( ) )
2022-03-14 13:36:32 +00:00
}
}
} ) as unknown as DefineComponent < NuxtLinkProps >
}
2023-10-18 12:43:42 +00:00
export default defineNuxtLink ( nuxtLinkDefaults )
2022-09-13 20:20:23 +00:00
// --- Prefetching utils ---
2023-01-10 14:33:21 +00:00
type CallbackFn = ( ) = > void
type ObserveFn = ( element : Element , callback : CallbackFn ) = > ( ) = > void
2022-09-13 20:20:23 +00:00
2023-01-10 14:33:21 +00:00
function useObserver ( ) : { observe : ObserveFn } | undefined {
2023-08-07 22:03:40 +00:00
if ( import . meta . server ) { return }
2022-09-13 20:20:23 +00:00
const nuxtApp = useNuxtApp ( )
if ( nuxtApp . _observer ) {
return nuxtApp . _observer
}
let observer : IntersectionObserver | null = null
2023-01-10 14:33:21 +00:00
2022-09-13 20:20:23 +00:00
const callbacks = new Map < Element , CallbackFn > ( )
2023-01-10 14:33:21 +00:00
const observe : ObserveFn = ( element , callback ) = > {
2022-09-13 20:20:23 +00:00
if ( ! observer ) {
observer = new IntersectionObserver ( ( entries ) = > {
for ( const entry of entries ) {
const callback = callbacks . get ( entry . target )
const isVisible = entry . isIntersecting || entry . intersectionRatio > 0
if ( isVisible && callback ) { callback ( ) }
}
} )
}
callbacks . set ( element , callback )
observer . observe ( element )
return ( ) = > {
callbacks . delete ( element )
observer ! . unobserve ( element )
if ( callbacks . size === 0 ) {
observer ! . disconnect ( )
observer = null
}
}
}
const _observer = nuxtApp . _observer = {
observe
}
return _observer
}
function isSlowConnection ( ) {
2023-08-07 22:03:40 +00:00
if ( import . meta . server ) { return }
2022-09-13 20:20:23 +00:00
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
const cn = ( navigator as any ) . connection as { saveData : boolean , effectiveType : string } | null
if ( cn && ( cn . saveData || /2g/ . test ( cn . effectiveType ) ) ) { return true }
return false
}