refactor!: make the route the single source of truth for the URL hash

Replace the standalone `useData().hash` ref (backed by its own
`hashchange` listener in `initData`) with the `hash` already tracked on
the route, so the current URL is observed in one place and behaves
consistently during SSR.

- `isActive()` no longer reads `location.hash` at call time; it takes
  the current hash as an argument (plus an optional `skipHashCheck`)
  and `matchPath` is now required, making it pure and SSR-safe
- nav bar menus, nav screen menus, and menu links now react to hash-only
  navigation too
- sidebar active-state tracking is reworked on top of the route: items
  without a link are handled, and collapsed groups reliably auto-expand
  when a child link becomes active (including on hash changes)
- prev/next links now properly ignore query strings and hashes when
  deduplicating sidebar candidates (via `normalize`) and when matching
  the current page
- custom `i18nRouting` functions receive the whole current `Route`
  instead of only the hash string; language menu links are resolved
  from the route (en + ru docs updated)
- `useLangs()` / `resolveLocaleLink()` are refactored with
  self-documenting, JSDoc'd options (`linkToCorrespondingPage`,
  `targetLocaleLink`, `currentLocaleLink`), and `resolveLocaleLink`
  gained focused unit tests covering locale-home links, corresponding
  links, cleanUrls, index pages, root-locale switching, and custom
  routing functions
- unavoidable hydration mismatches are silenced with
  `data-allow-mismatch` (viewport-dependent inline styles, per-locale
  alternate links that embed the current query/hash)
- the `Route` interface moved to the shared types so
  `DefaultTheme.I18nRouting` can reference it; the `vitepress` type
  export is unchanged
- misc cleanups: `uniqBy` moved to theme support utils, `smartComputed`
  comparator now called as `(newValue, oldValue)`, import ordering

BREAKING CHANGE: `useData().hash` has been removed. Read the hash from
`useRoute()` instead.

BREAKING CHANGE: custom `themeConfig.i18nRouting` functions now receive
the current `Route` as their second argument instead of the hash
string: `(data, route, targetLocale) => string`. Use `route.hash` for
the previous value; `route.path`, `route.query`, and `route.data` are
available as well.
pull/5315/head
Divyansh Singh 2 months ago
parent 262b78f1ca
commit dcb7a75532

@ -3,13 +3,34 @@ import type { Route, VitePressData } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { ref } from 'vue' import { ref } from 'vue'
function createData( // `currentPage` is the current page's relative path (like
themeConfig: DefaultTheme.Config, // `route.data.relativePath`, but with a leading slash), plus any query and
relativePath = 'guide/getting-started.md', // hash of the current URL.
cleanUrls = false, function resolve(
hash = '#install' currentPage: string,
{
themeConfig = {},
cleanUrls = false,
targetLocale = 'fr',
targetLocaleLink = '/fr/',
currentLocaleLink = '/',
linkToCorrespondingPage = true
}: {
themeConfig?: DefaultTheme.Config
cleanUrls?: boolean
targetLocale?: string
targetLocaleLink?: string
currentLocaleLink?: string
linkToCorrespondingPage?: boolean
} = {}
) { ) {
return { const { pathname, search, hash } = new URL(currentPage, 'http://a.com')
const route = {
data: { relativePath: pathname.slice(1) },
query: search,
hash
} as Route
const data = {
site: ref({ site: ref({
cleanUrls, cleanUrls,
locales: { locales: {
@ -18,61 +39,108 @@ function createData(
}, },
themeConfig themeConfig
}), }),
page: ref({ relativePath }), theme: ref(themeConfig)
theme: ref(themeConfig),
hash: ref(hash)
} as unknown as VitePressData<DefaultTheme.Config> } as unknown as VitePressData<DefaultTheme.Config>
}
function createRoute(query = '', hash = '#install') { return resolveLocaleLink(data, route, {
return { targetLocale,
query, targetLocaleLink,
hash currentLocaleLink,
} as unknown as Route linkToCorrespondingPage
})
} }
describe('client/theme-default/composables/langs', () => { describe('client/theme-default/composables/langs', () => {
test('resolves corresponding links with the default router', () => { describe('resolveLocaleLink', () => {
expect( describe('locale home links (linkToCorrespondingPage: false)', () => {
resolveLocaleLink(createData({}), createRoute(), 'fr', '/fr/', '/', true) test('links to the target locale home', () => {
).toBe('/fr/guide/getting-started.html#install') expect(
}) resolve('/guide/getting-started.md', {
linkToCorrespondingPage: false
test('resolves clean index links with the default router', () => { })
expect( ).toBe('/fr/')
resolveLocaleLink( })
createData({}, 'en/guide/index.md', true, '#intro'),
createRoute('?query', '#intro'),
'fr',
'/fr/',
'/en/',
true
)
).toBe('/fr/guide/?query#intro')
})
test('keeps locale root links when i18n routing is disabled', () => { test('preserves query and hash', () => {
expect( expect(
resolveLocaleLink( resolve('/guide/getting-started.md?a=1#install', {
createData({ i18nRouting: false }), linkToCorrespondingPage: false
createRoute(), })
'fr', ).toBe('/fr/?a=1#install')
'/fr/', })
'/',
true
)
).toBe('/fr/#install')
})
test('uses custom i18n routing functions for corresponding links', () => { test('ignores custom i18n routing functions', () => {
const data = createData({ expect(
i18nRouting(data, hash, targetLocale) { resolve('/guide/getting-started.md', {
return `${data.site.value.locales[targetLocale].link}mapped/${data.page.value.relativePath}${hash}` linkToCorrespondingPage: false,
} themeConfig: { i18nRouting: () => '/custom/' }
})
).toBe('/fr/')
})
}) })
expect( describe('corresponding page links (linkToCorrespondingPage: true)', () => {
resolveLocaleLink(data, createRoute(), 'fr', '/fr/', '/', true) test('rewrites the current page path into the target locale', () => {
).toBe('/fr/mapped/guide/getting-started.md#install') expect(resolve('/guide/getting-started.md#install')).toBe(
'/fr/guide/getting-started.html#install'
)
})
test('drops the .html extension when clean URLs are enabled', () => {
expect(
resolve('/guide/getting-started.md#install', { cleanUrls: true })
).toBe('/fr/guide/getting-started#install')
})
test('resolves index pages to directory links', () => {
expect(resolve('/guide/index.md')).toBe('/fr/guide/')
expect(resolve('/guide/index.md', { cleanUrls: true })).toBe(
'/fr/guide/'
)
})
test('resolves the site root page to the target locale home', () => {
expect(resolve('/index.md')).toBe('/fr/')
})
test('strips the current locale prefix before rewriting', () => {
expect(
resolve('/en/guide/index.md?query#intro', {
currentLocaleLink: '/en/',
cleanUrls: true
})
).toBe('/fr/guide/?query#intro')
})
test('rewrites into the root locale', () => {
expect(
resolve('/fr/guide/getting-started.md#install', {
targetLocale: 'root',
targetLocaleLink: '/',
currentLocaleLink: '/fr/'
})
).toBe('/guide/getting-started.html#install')
})
test('links to the target locale home when i18n routing is disabled', () => {
expect(
resolve('/guide/getting-started.md#install', {
themeConfig: { i18nRouting: false }
})
).toBe('/fr/#install')
})
test('delegates to custom i18n routing functions', () => {
expect(
resolve('/guide/getting-started.md#install', {
themeConfig: {
i18nRouting(data, route, targetLocale) {
return `${data.site.value.locales[targetLocale].link}mapped/${route.data.relativePath}${route.hash}`
}
}
})
).toBe('/fr/mapped/guide/getting-started.md#install')
})
})
}) })
}) })

@ -188,8 +188,8 @@ describe('client/theme-default/support/sidebar', () => {
] ]
} }
expect(hasActiveLink('active-1', item)).toBe(true) expect(hasActiveLink('active-1', '', item)).toBe(true)
expect(hasActiveLink('inactive', item)).toBe(false) expect(hasActiveLink('inactive', '', item)).toBe(false)
}) })
test('checks `SidebarItem[]`', () => { test('checks `SidebarItem[]`', () => {
@ -210,9 +210,9 @@ describe('client/theme-default/support/sidebar', () => {
} }
] ]
expect(hasActiveLink('active-1', item)).toBe(true) expect(hasActiveLink('active-1', '', item)).toBe(true)
expect(hasActiveLink('active-3', item)).toBe(true) expect(hasActiveLink('active-3', '', item)).toBe(true)
expect(hasActiveLink('inactive', item)).toBe(false) expect(hasActiveLink('inactive', '', item)).toBe(false)
}) })
}) })
}) })

@ -25,23 +25,23 @@ export default {
## i18nRouting ## i18nRouting
- Type: `boolean | ((data: VitePressData<DefaultTheme.Config>, hash: string, targetLocale: string) => string)` - Type: `boolean | ((data: VitePressData<DefaultTheme.Config>, route: Route, targetLocale: string) => string)`
Changing locale to say `zh` will change the URL from `/foo` (or `/en/foo/`) to `/zh/foo`. You can disable this behavior by setting `themeConfig.i18nRouting` to `false`. Changing locale to say `zh` will change the URL from `/foo` (or `/en/foo/`) to `/zh/foo`. You can disable this behavior by setting `themeConfig.i18nRouting` to `false`.
Set `themeConfig.i18nRouting` to a function to customize the locale link. The function receives the current VitePress data, the current hash, and the target locale key, and returns the target link. Set `themeConfig.i18nRouting` to a function to customize the locale link. The function receives the current VitePress data, the current route, and the target locale key, and returns the target link.
```ts ```ts
import { defineConfig } from 'vitepress' import { defineConfig } from 'vitepress'
export default defineConfig({ export default defineConfig({
themeConfig: { themeConfig: {
i18nRouting(data, hash, targetLocale) { i18nRouting(data, route, targetLocale) {
const target = data.site.value.locales[targetLocale] const target = data.site.value.locales[targetLocale]
const targetLink = const targetLink =
target.link || (targetLocale === 'root' ? '/' : `/${targetLocale}/`) target.link || (targetLocale === 'root' ? '/' : `/${targetLocale}/`)
return `${targetLink}${data.page.value.relativePath.replace(/\.md$/, '')}${hash}` return `${targetLink}${route.data.relativePath.replace(/\.md$/, '')}${route.hash}`
} }
} }
}) })

@ -25,23 +25,23 @@ export default {
## i18nRouting ## i18nRouting
- Тип: `boolean | ((data: VitePressData<DefaultTheme.Config>, hash: string, targetLocale: string) => string)` - Тип: `boolean | ((data: VitePressData<DefaultTheme.Config>, route: Route, targetLocale: string) => string)`
При смене локали на `ru` URL изменится с `/foo` (или `/en/foo/`) на `/ru/foo`. Вы можете отключить это поведение, установив для параметра `themeConfig.i18nRouting` значение `false`. При смене локали на `ru` URL изменится с `/foo` (или `/en/foo/`) на `/ru/foo`. Вы можете отключить это поведение, установив для параметра `themeConfig.i18nRouting` значение `false`.
Установите для `themeConfig.i18nRouting` функцию, чтобы настроить ссылки для переключения локали. Эта функция получает текущие данные VitePress, текущий хеш и ключ целевой локали, а затем возвращает ссылку для перехода на неё. Установите для `themeConfig.i18nRouting` функцию, чтобы настроить ссылку локали. Эта функция получает текущие данные VitePress, текущий маршрут и ключ целевой локали, а затем возвращает целевую ссылку.
```ts ```ts
import { defineConfig } from 'vitepress' import { defineConfig } from 'vitepress'
export default defineConfig({ export default defineConfig({
themeConfig: { themeConfig: {
i18nRouting(data, hash, targetLocale) { i18nRouting(data, route, targetLocale) {
const target = data.site.value.locales[targetLocale] const target = data.site.value.locales[targetLocale]
const targetLink = const targetLink =
target.link || (targetLocale === 'root' ? '/' : `/${targetLocale}/`) target.link || (targetLocale === 'root' ? '/' : `/${targetLocale}/`)
return `${targetLink}${data.page.value.relativePath.replace(/\.md$/, '')}${hash}` return `${targetLink}${route.data.relativePath.replace(/\.md$/, '')}${route.hash}`
} }
} }
}) })

@ -10,8 +10,8 @@ export const Content = defineComponent({
as: { type: [Object, String], default: 'div' } as: { type: [Object, String], default: 'div' }
}, },
setup(props) { setup(props) {
const route = useRoute()
const { frontmatter, site } = useData() const { frontmatter, site } = useData()
const route = useRoute()
watch(frontmatter, runCbs, { deep: true, flush: 'post' }) watch(frontmatter, runCbs, { deep: true, flush: 'post' })
return () => return () =>
h( h(

@ -3,9 +3,9 @@ import {
createTitle, createTitle,
mergeHead, mergeHead,
type HeadConfig, type HeadConfig,
type Route,
type SiteData type SiteData
} from '../../shared' } from '../../shared'
import type { Route } from '../router'
export function useUpdateHead(route: Route, siteDataByRouteRef: Ref<SiteData>) { export function useUpdateHead(route: Route, siteDataByRouteRef: Ref<SiteData>) {
let isFirstUpdate = true let isFirstUpdate = true

@ -6,19 +6,17 @@ import {
readonly, readonly,
ref, ref,
shallowRef, shallowRef,
watch,
type InjectionKey, type InjectionKey,
type Ref type Ref
} from 'vue' } from 'vue'
import { import {
APPEARANCE_KEY, APPEARANCE_KEY,
createTitle, createTitle,
inBrowser,
resolveSiteDataByRoute, resolveSiteDataByRoute,
type Route,
type SiteData, type SiteData,
type VitePressData type VitePressData
} from '../shared' } from '../shared'
import type { Route } from './router'
export const dataSymbol: InjectionKey<VitePressData> = Symbol() export const dataSymbol: InjectionKey<VitePressData> = Symbol()
export type { VitePressData } from '../shared' export type { VitePressData } from '../shared'
@ -48,21 +46,6 @@ export function initData(route: Route): VitePressData {
}) })
: ref(false) : ref(false)
const hashRef = ref(inBrowser ? location.hash : '')
if (inBrowser) {
window.addEventListener('hashchange', () => {
hashRef.value = location.hash
})
}
watch(
() => route.data,
() => {
hashRef.value = inBrowser ? location.hash : ''
}
)
return { return {
site, site,
theme: computed(() => site.value.themeConfig), theme: computed(() => site.value.themeConfig),
@ -76,8 +59,7 @@ export function initData(route: Route): VitePressData {
description: computed( description: computed(
() => route.data.description || site.value.description () => route.data.description || site.value.description
), ),
isDark, isDark
hash: computed(() => hashRef.value)
} }
} }

@ -1,18 +1,10 @@
import type { Component, InjectionKey } from 'vue' import type { Component, InjectionKey } from 'vue'
import { inject, markRaw, nextTick, reactive, readonly } from 'vue' import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload } from '../shared' import type { Awaitable, PageData, PageDataPayload, Route } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared' import { notFoundPageData, treatAsHtml } from '../shared'
import { siteDataRef } from './data' import { siteDataRef } from './data'
import { inBrowser, withBase } from './utils' import { inBrowser, withBase } from './utils'
export interface Route {
path: string
hash: string
query: string
data: PageData
component: Component | null
}
export interface Router { export interface Router {
/** /**
* Current route. * Current route.

@ -2,8 +2,8 @@
// so the user can do `import { useRoute, useData } from 'vitepress'` // so the user can do `import { useRoute, useData } from 'vitepress'`
// generic types // generic types
export type { Route, Router } from './app/router' export type { Router } from './app/router'
export type { VitePressData } from './shared' export type { Route, VitePressData } from './shared'
// theme types // theme types
export type { EnhanceAppContext, Theme } from './app/theme' export type { EnhanceAppContext, Theme } from './app/theme'

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { useAside } from '../composables/aside' import { useAside } from '../composables/aside'
import { useData } from '../composables/data'
const { page } = useData() const route = useRoute()
const props = defineProps<{ const props = defineProps<{
carbonAds: DefaultTheme.CarbonAdsOptions carbonAds: DefaultTheme.CarbonAdsOptions
}>() }>()
@ -32,7 +32,7 @@ function init() {
} }
} }
watch(() => page.value.relativePath, () => { watch(() => route.data.relativePath, () => {
if (isInitialized && isAsideEnabled.value) { if (isInitialized && isAsideEnabled.value) {
;(window as any)._carbonads?.refresh() ;(window as any)._carbonads?.refresh()
} }

@ -7,7 +7,6 @@ import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue' import VPDocFooter from './VPDocFooter.vue'
const { theme } = useData() const { theme } = useData()
const route = useRoute() const route = useRoute()
const { hasSidebar, hasAside, leftAside } = useLayout() const { hasSidebar, hasAside, leftAside } = useLayout()

@ -11,6 +11,7 @@ const { width: vw } = useWindowSize({
<div <div
class="vp-doc container" class="vp-doc container"
:style="vw ? { '--vp-offset': `calc(50% - ${vw / 2}px)` } : {}" :style="vw ? { '--vp-offset': `calc(50% - ${vw / 2}px)` } : {}"
data-allow-mismatch="style"
> >
<slot /> <slot />
</div> </div>

@ -65,9 +65,10 @@ function scrollToTop() {
<template> <template>
<div <div
ref="main"
class="VPLocalNavOutlineDropdown" class="VPLocalNavOutlineDropdown"
:style="{ '--vp-vh': vh + 'px' }" :style="{ '--vp-vh': vh + 'px' }"
ref="main" data-allow-mismatch="style"
> >
<button @click="toggle" :class="{ open }" v-if="headers.length > 0"> <button @click="toggle" :class="{ open }" v-if="headers.length > 0">
<span class="menu-text">{{ resolveTitle(theme) }}</span> <span class="menu-text">{{ resolveTitle(theme) }}</span>

@ -1,8 +1,8 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink"> <script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink">
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue' import { computed } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { useData } from '../composables/data'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
const props = defineProps<{ const props = defineProps<{
@ -10,21 +10,22 @@ const props = defineProps<{
rel?: string rel?: string
}>() }>()
const { page } = useData() const route = useRoute()
const href = computed(() => const href = computed(() =>
typeof props.item.link === 'function' typeof props.item.link === 'function'
? props.item.link(page.value) ? props.item.link(route.data)
: props.item.link : props.item.link
) )
const isActiveLink = computed(() => const isActiveLink = computed(() => {
isActive( return isActive(
page.value.relativePath, route.data.relativePath,
route.hash,
props.item.activeMatch || href.value, props.item.activeMatch || href.value,
!!props.item.activeMatch !!props.item.activeMatch
) )
) })
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
</script> </script>

@ -8,7 +8,9 @@ import VPSocialLinks from './VPSocialLinks.vue'
import VPSwitchAppearance from './VPSwitchAppearance.vue' import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site, theme } = useData() const { site, theme } = useData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true }) const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
const hasExtraContent = computed( const hasExtraContent = computed(
() => () =>
@ -38,6 +40,7 @@ const hasExtraContent = computed(
:hreflang="locale.lang" :hreflang="locale.lang"
rel="alternate" rel="alternate"
:dir="locale.dir" :dir="locale.dir"
data-allow-mismatch="attribute"
/> />
</template> </template>
</div> </div>

@ -1,19 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue' import { computed } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { useData } from '../composables/data'
import VPFlyout from './VPFlyout.vue' import VPFlyout from './VPFlyout.vue'
const props = defineProps<{ const props = defineProps<{
item: DefaultTheme.NavItemWithChildren item: DefaultTheme.NavItemWithChildren
}>() }>()
const { page } = useData() const route = useRoute()
const isActiveGroup = computed(() => { const isActiveGroup = computed(() => {
if (props.item.activeMatch) { if (props.item.activeMatch) {
return isActive(page.value.relativePath, props.item.activeMatch, true) return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch,
true
)
} }
return isChildActive(props.item) return isChildActive(props.item)
}) })
@ -24,11 +29,12 @@ function isChildActive(navItem: DefaultTheme.NavItem): boolean {
if ('link' in navItem) { if ('link' in navItem) {
const href = const href =
typeof navItem.link === 'function' typeof navItem.link === 'function'
? navItem.link(page.value) ? navItem.link(route.data)
: navItem.link : navItem.link
return isActive( return isActive(
page.value.relativePath, route.data.relativePath,
route.hash,
navItem.activeMatch || href, navItem.activeMatch || href,
!!navItem.activeMatch !!navItem.activeMatch
) )

@ -1,29 +1,30 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue' import { computed } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { useData } from '../composables/data'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
const props = defineProps<{ const props = defineProps<{
item: DefaultTheme.NavItemWithLink item: DefaultTheme.NavItemWithLink
}>() }>()
const { page } = useData() const route = useRoute()
const href = computed(() => const href = computed(() =>
typeof props.item.link === 'function' typeof props.item.link === 'function'
? props.item.link(page.value) ? props.item.link(route.data)
: props.item.link : props.item.link
) )
const isActiveLink = computed(() => const isActiveLink = computed(() => {
isActive( return isActive(
page.value.relativePath, route.data.relativePath,
route.hash,
props.item.activeMatch || href.value, props.item.activeMatch || href.value,
!!props.item.activeMatch !!props.item.activeMatch
) )
) })
</script> </script>
<template> <template>

@ -5,7 +5,9 @@ import VPFlyout from './VPFlyout.vue'
import VPMenuLink from './VPMenuLink.vue' import VPMenuLink from './VPMenuLink.vue'
const { theme } = useData() const { theme } = useData()
const { localeLinks, currentLang } = useLangs({ correspondingLink: true }) const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
</script> </script>
<template> <template>
@ -26,6 +28,7 @@ const { localeLinks, currentLang } = useLangs({ correspondingLink: true })
:hreflang="locale.lang" :hreflang="locale.lang"
rel="alternate" rel="alternate"
:dir="locale.dir" :dir="locale.dir"
data-allow-mismatch="attribute"
/> />
</template> </template>
</div> </div>

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { useData } from '../composables/data'
import { navInjectionKey } from '../composables/nav' import { navInjectionKey } from '../composables/nav'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
@ -10,21 +10,22 @@ const props = defineProps<{
item: DefaultTheme.NavItemWithLink item: DefaultTheme.NavItemWithLink
}>() }>()
const { page } = useData() const route = useRoute()
const href = computed(() => const href = computed(() =>
typeof props.item.link === 'function' typeof props.item.link === 'function'
? props.item.link(page.value) ? props.item.link(route.data)
: props.item.link : props.item.link
) )
const isActiveLink = computed(() => const isActiveLink = computed(() => {
isActive( return isActive(
page.value.relativePath, route.data.relativePath,
route.hash,
props.item.activeMatch || href.value, props.item.activeMatch || href.value,
!!props.item.activeMatch !!props.item.activeMatch
) )
) })
const { closeScreen } = inject(navInjectionKey)! const { closeScreen } = inject(navInjectionKey)!
</script> </script>

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { useData } from '../composables/data'
import { navInjectionKey } from '../composables/nav' import { navInjectionKey } from '../composables/nav'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
@ -10,21 +10,22 @@ const props = defineProps<{
item: DefaultTheme.NavItemWithLink item: DefaultTheme.NavItemWithLink
}>() }>()
const { page } = useData() const route = useRoute()
const href = computed(() => const href = computed(() =>
typeof props.item.link === 'function' typeof props.item.link === 'function'
? props.item.link(page.value) ? props.item.link(route.data)
: props.item.link : props.item.link
) )
const isActiveLink = computed(() => const isActiveLink = computed(() => {
isActive( return isActive(
page.value.relativePath, route.data.relativePath,
route.hash,
props.item.activeMatch || href.value, props.item.activeMatch || href.value,
!!props.item.activeMatch !!props.item.activeMatch
) )
) })
const { closeScreen } = inject(navInjectionKey)! const { closeScreen } = inject(navInjectionKey)!
</script> </script>

@ -3,7 +3,9 @@ import { ref } from 'vue'
import { useLangs } from '../composables/langs' import { useLangs } from '../composables/langs'
import VPLink from './VPLink.vue' import VPLink from './VPLink.vue'
const { localeLinks, currentLang } = useLangs({ correspondingLink: true }) const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
const isOpen = ref(false) const isOpen = ref(false)
function toggle() { function toggle() {
@ -33,6 +35,7 @@ function toggle() {
:hreflang="locale.lang" :hreflang="locale.lang"
rel="alternate" rel="alternate"
:dir="locale.dir" :dir="locale.dir"
data-allow-mismatch="attribute"
> >
{{ locale.text }} {{ locale.text }}
</VPLink> </VPLink>

@ -1,5 +1,5 @@
import { inBrowser } from 'vitepress'
import { onUnmounted, readonly, type Ref, ref, watch } from 'vue' import { onUnmounted, readonly, type Ref, ref, watch } from 'vue'
import { inBrowser } from '../../shared'
interface UseFlyoutOptions { interface UseFlyoutOptions {
el: Ref<HTMLElement | undefined> el: Ref<HTMLElement | undefined>

@ -1,14 +1,24 @@
import { computed } from 'vue'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import type { VitePressData } from '../../app/data' import { computed } from 'vue'
import { useRoute, type Route } from '../../app/router' import { useRoute } from '../../app/router'
import type { Route, VitePressData } from '../../shared'
import { ensureStartingSlash } from '../support/utils' import { ensureStartingSlash } from '../support/utils'
import { useData } from './data' import { useData } from './data'
export function useLangs({ correspondingLink = false } = {}) { export function useLangs({
linkToCorrespondingPage = false
}: {
/**
* Link each entry of the translations menu to the current page's
* equivalent in that locale (resolved by `resolveLocaleLink`) instead of
* that locale's home page.
*/
linkToCorrespondingPage?: boolean
} = {}) {
const data = useData() const data = useData()
const route = useRoute() const route = useRoute()
const { site, localeIndex } = data const { site, localeIndex } = data
const currentLang = computed(() => ({ const currentLang = computed(() => ({
label: site.value.locales[localeIndex.value]?.label, label: site.value.locales[localeIndex.value]?.label,
link: link:
@ -22,43 +32,64 @@ export function useLangs({ correspondingLink = false } = {}) {
? [] ? []
: { : {
text: value.label, text: value.label,
link: resolveLocaleLink( link: resolveLocaleLink(data, route, {
data, targetLocale: key,
route, targetLocaleLink:
key, value.link || (key === 'root' ? '/' : `/${key}/`),
value.link || (key === 'root' ? '/' : `/${key}/`), currentLocaleLink: currentLang.value.link,
currentLang.value.link, linkToCorrespondingPage
correspondingLink }),
),
lang: value.lang, lang: value.lang,
dir: value.dir dir: value.dir
} }
) )
) )
return { localeLinks, currentLang } return { currentLang, localeLinks }
} }
/**
* Resolves the link used for switching from the current page to
* `targetLocale`. Without `linkToCorrespondingPage`, this is simply the home
* of the target locale. With it, the current page's path is rewritten into
* the target locale (honoring `cleanUrls`) unless
* `themeConfig.i18nRouting` is `false` (the locale home is used instead) or
* a function (which then fully controls the resolution).
*
* The current query and hash are carried over, except when a custom
* `i18nRouting` function is used.
*/
export function resolveLocaleLink( export function resolveLocaleLink(
data: VitePressData<DefaultTheme.Config>, data: VitePressData<DefaultTheme.Config>,
route: Route, route: Route,
targetLocale: string, {
targetLink: string, targetLocale,
currentLink: string, targetLocaleLink,
correspondingLink: boolean currentLocaleLink,
linkToCorrespondingPage
}: {
/** Key of the target locale in `site.locales`, e.g. `'fr'` or `'root'`. */
targetLocale: string
/** Home link of the target locale, e.g. `'/fr/'`. */
targetLocaleLink: string
/** Home link of the locale the current page is in, e.g. `'/'`. */
currentLocaleLink: string
/** Link to the current page's equivalent instead of the locale home. */
linkToCorrespondingPage: boolean
}
) { ) {
const { site, page, theme } = data const { site, theme } = data
const i18nRouting = theme.value.i18nRouting const i18nRouting = theme.value.i18nRouting
if (correspondingLink && typeof i18nRouting === 'function') { if (linkToCorrespondingPage && typeof i18nRouting === 'function') {
return i18nRouting(data, route.hash, targetLocale) return i18nRouting(data, route, targetLocale)
} }
return ( return (
normalizeLink( normalizeLink(
targetLink, targetLocaleLink,
i18nRouting !== false && correspondingLink, i18nRouting !== false && linkToCorrespondingPage,
page.value.relativePath.slice(currentLink.length - 1), route.data.relativePath.slice(currentLocaleLink.length - 1),
!site.value.cleanUrls !site.value.cleanUrls
) + ) +
route.query + route.query +
@ -67,17 +98,17 @@ export function resolveLocaleLink(
} }
function normalizeLink( function normalizeLink(
link: string, localeLink: string,
addPath: boolean, appendPagePath: boolean,
path: string, pagePath: string,
addExt: boolean addHtmlExt: boolean
) { ) {
return addPath return appendPagePath
? link.replace(/\/$/, '') + ? localeLink.replace(/\/$/, '') +
ensureStartingSlash( ensureStartingSlash(
path pagePath
.replace(/(^|\/)index\.md$/, '$1') .replace(/(^|\/)index\.md$/, '$1')
.replace(/\.md$/, addExt ? '.html' : '') .replace(/\.md$/, addHtmlExt ? '.html' : '')
) )
: link : localeLink
} }

@ -74,7 +74,7 @@ interface RegisterWatchersOptions {
} }
export function registerWatchers({ closeSidebar }: RegisterWatchersOptions) { export function registerWatchers({ closeSidebar }: RegisterWatchersOptions) {
const { frontmatter, page, theme } = useData() const { theme, page, frontmatter } = useData()
watch( watch(
() => [page.value.relativePath, theme.value.sidebar] as const, () => [page.value.relativePath, theme.value.sidebar] as const,

@ -1,20 +1,24 @@
import { computed } from 'vue' import { computed } from 'vue'
import { isActive } from '../../shared' import { isActive, normalize } from '../../shared'
import { getFlatSideBarLinks, getSidebar } from '../support/sidebar' import { getFlatSideBarLinks, getSidebar } from '../support/sidebar'
import { uniqBy } from '../support/utils'
import { useData } from './data' import { useData } from './data'
export function usePrevNext() { export function usePrevNext() {
const { page, theme, frontmatter } = useData() const { theme, page, frontmatter } = useData()
return computed(() => { return computed<{
prev?: { text?: string; link?: string; target?: string; rel?: string }
next?: { text?: string; link?: string; target?: string; rel?: string }
}>(() => {
const sidebar = getSidebar(theme.value.sidebar, page.value.relativePath) const sidebar = getSidebar(theme.value.sidebar, page.value.relativePath)
const links = getFlatSideBarLinks(sidebar) const links = getFlatSideBarLinks(sidebar)
// ignore inner-page links with hashes // ignore inner-page links with hashes
const candidates = uniqBy(links, (link) => link.link.replace(/[?#].*$/, '')) const candidates = uniqBy(links, (link) => normalize(link.link))
const index = candidates.findIndex((link) => { const index = candidates.findIndex((link) => {
return isActive(page.value.relativePath, link.link) return isActive(page.value.relativePath, '', link.link, false, true)
}) })
const hidePrev = const hidePrev =
@ -74,17 +78,6 @@ export function usePrevNext() {
? frontmatter.value.next.rel ? frontmatter.value.next.rel
: undefined) ?? candidates[index + 1]?.rel : undefined) ?? candidates[index + 1]?.rel
} }
} as {
prev?: { text?: string; link?: string; target?: string; rel?: string }
next?: { text?: string; link?: string; target?: string; rel?: string }
} }
}) })
} }
function uniqBy<T>(array: T[], keyFn: (item: T) => any): T[] {
const seen = new Set()
return array.filter((item) => {
const k = keyFn(item)
return seen.has(k) ? false : seen.add(k)
})
}

@ -1,17 +1,17 @@
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme' import type { DefaultTheme } from 'vitepress/theme'
import { import {
computed, computed,
nextTick,
onMounted, onMounted,
onUnmounted, onUnmounted,
ref, ref,
watch, watch,
watchEffect, watchEffect,
watchPostEffect,
type ComputedRef type ComputedRef
} from 'vue' } from 'vue'
import { isActive } from '../../shared' import { isActive } from '../../shared'
import { hasActiveLink as containsActiveLink } from '../support/sidebar' import { hasActiveLink as containsActiveLink } from '../support/sidebar'
import { useData } from './data'
const isOpen = ref(false) const isOpen = ref(false)
@ -68,7 +68,7 @@ export function useSidebarControl() {
export function useSidebarItemControl( export function useSidebarItemControl(
item: ComputedRef<DefaultTheme.SidebarItem> item: ComputedRef<DefaultTheme.SidebarItem>
) { ) {
const { page, hash } = useData() const route = useRoute()
const collapsed = ref(false) const collapsed = ref(false)
@ -81,22 +81,39 @@ export function useSidebarItemControl(
}) })
const isActiveLink = ref(false) const isActiveLink = ref(false)
const updateIsActiveLink = () => { const hasActiveLink = ref(false)
isActiveLink.value = isActive(page.value.relativePath, item.value.link)
} function updateActiveLink(): void {
if (item.value.link) {
watch([page, item, hash], updateIsActiveLink) isActiveLink.value = isActive(
onMounted(updateIsActiveLink) route.data.relativePath,
route.hash,
const hasActiveLink = computed(() => { item.value.link
)
} else {
isActiveLink.value = false
}
if (isActiveLink.value) { if (isActiveLink.value) {
return true hasActiveLink.value = true
nextTick(() => (collapsed.value = false))
return
}
if (!item.value.items) {
hasActiveLink.value = false
return
}
hasActiveLink.value = containsActiveLink(
route.data.relativePath,
route.hash,
item.value.items
)
if (hasActiveLink.value) {
nextTick(() => (collapsed.value = false))
} }
}
return item.value.items watch([item, route], updateActiveLink)
? containsActiveLink(page.value.relativePath, item.value.items) onMounted(updateActiveLink)
: false
})
const hasChildren = computed(() => { const hasChildren = computed(() => {
return !!(item.value.items && item.value.items.length) return !!(item.value.items && item.value.items.length)
@ -106,11 +123,7 @@ export function useSidebarItemControl(
collapsed.value = !!(collapsible.value && item.value.collapsed) collapsed.value = !!(collapsible.value && item.value.collapsed)
}) })
watchPostEffect(() => { function toggle(): void {
;(isActiveLink.value || hasActiveLink.value) && (collapsed.value = false)
})
function toggle() {
if (collapsible.value) { if (collapsible.value) {
collapsed.value = !collapsed.value collapsed.value = !collapsed.value
} }
@ -120,8 +133,8 @@ export function useSidebarItemControl(
collapsed, collapsed,
collapsible, collapsible,
isLink, isLink,
isActiveLink, isActiveLink: isActiveLink as ComputedRef<boolean>,
hasActiveLink, hasActiveLink: hasActiveLink as ComputedRef<boolean>,
hasChildren, hasChildren,
toggle toggle
} }

@ -1,13 +1,13 @@
import { type ComputedRef, computed } from 'vue' import { computed, type ComputedRef } from 'vue'
export function smartComputed<T>( export function smartComputed<T>(
getter: () => T, getter: () => T,
comparator = (oldValue: T, newValue: T) => comparator = (newValue: T, oldValue: T) =>
JSON.stringify(oldValue) === JSON.stringify(newValue) JSON.stringify(newValue) === JSON.stringify(oldValue)
): ComputedRef<T> { ): ComputedRef<T> {
return computed((oldValue) => { return computed((oldValue) => {
const newValue = getter() const newValue = getter()
return oldValue === undefined || !comparator(oldValue, newValue) return oldValue === undefined || !comparator(newValue, oldValue)
? newValue ? newValue
: oldValue : oldValue
}) })

@ -99,17 +99,19 @@ export function getFlatSideBarLinks(sidebar: SidebarItem[]): SidebarLink[] {
*/ */
export function hasActiveLink( export function hasActiveLink(
path: string, path: string,
hash: string,
items: SidebarItem | SidebarItem[] items: SidebarItem | SidebarItem[]
): boolean { ): boolean {
if (Array.isArray(items)) { if (Array.isArray(items)) {
return items.some((item) => hasActiveLink(path, item)) return items.some((item) => hasActiveLink(path, hash, item))
} }
if (items.link && isActive(path, hash, items.link)) {
return isActive(path, items.link) return true
? true }
: items.items if (items.items) {
? hasActiveLink(path, items.items) return hasActiveLink(path, hash, items.items)
: false }
return false
} }
function addBase(items: SidebarItem[], _base?: string): SidebarItem[] { function addBase(items: SidebarItem[], _base?: string): SidebarItem[] {

@ -58,3 +58,11 @@ export function normalizeLink(url: string): string {
return withBase(normalizedPath) return withBase(normalizedPath)
} }
export function uniqBy<T>(array: T[], keyFn: (item: T) => any): T[] {
const seen = new Set()
return array.filter((item) => {
const k = keyFn(item)
return seen.has(k) ? false : seen.add(k)
})
}

@ -18,6 +18,7 @@ export type {
MarkdownEnv, MarkdownEnv,
PageData, PageData,
PageDataPayload, PageDataPayload,
Route,
SiteData, SiteData,
SSGContext, SSGContext,
VitePressData VitePressData
@ -48,13 +49,11 @@ export const notFoundPageData: PageData = {
export function isActive( export function isActive(
currentPath: string, currentPath: string,
matchPath?: string, currentHash: string,
asRegex: boolean = false matchPath: string,
asRegex: boolean = false,
skipHashCheck: boolean = false
): boolean { ): boolean {
if (matchPath === undefined) {
return false
}
currentPath = normalize(`/${currentPath}`) currentPath = normalize(`/${currentPath}`)
if (asRegex) { if (asRegex) {
@ -65,10 +64,14 @@ export function isActive(
return false return false
} }
if (skipHashCheck) {
return true
}
const hashMatch = matchPath.match(HASH_WITHOUT_FRAGMENT_RE) const hashMatch = matchPath.match(HASH_WITHOUT_FRAGMENT_RE)
if (hashMatch) { if (hashMatch) {
return (inBrowser ? location.hash : '') === hashMatch[0] return currentHash === hashMatch[0]
} }
return true return true
@ -93,7 +96,7 @@ export function getLocaleForPath(
(key) => (key) =>
key !== 'root' && key !== 'root' &&
!isExternal(key) && !isExternal(key) &&
isActive(relativePath, `^/${key}/`, true) isActive(relativePath, '', `^/${key}/`, true)
) || 'root' ) || 'root'
) )
} }

@ -1,7 +1,7 @@
import type { Options as _MiniSearchOptions } from 'minisearch' import type { Options as _MiniSearchOptions } from 'minisearch'
import type { DocSearchProps } from './docsearch.js' import type { DocSearchProps } from './docsearch.js'
import type { LocalSearchTranslations } from './local-search.js' import type { LocalSearchTranslations } from './local-search.js'
import type { Header, PageData, VitePressData } from './shared.js' import type { Header, PageData, Route, VitePressData } from './shared.js'
export namespace DefaultTheme { export namespace DefaultTheme {
export interface Config { export interface Config {
@ -159,7 +159,7 @@ export namespace DefaultTheme {
export type I18nRouting = ( export type I18nRouting = (
data: VitePressData<Config>, data: VitePressData<Config>,
hash: string, route: Route,
targetLocale: string targetLocale: string
) => string ) => string

18
types/shared.d.ts vendored

@ -1,6 +1,6 @@
// types shared between server and client // types shared between server and client
import type { UseDarkOptions } from '@vueuse/core' import type { UseDarkOptions } from '@vueuse/core'
import type { Ref } from 'vue' import type { Component, Ref } from 'vue'
import type { SSRContext } from 'vue/server-renderer' import type { SSRContext } from 'vue/server-renderer'
export type { DefaultTheme } from './default-theme.js' export type { DefaultTheme } from './default-theme.js'
@ -149,7 +149,7 @@ export interface SiteData<ThemeConfig = any> {
export interface VitePressData<T = any> { export interface VitePressData<T = any> {
/** /**
* Site-level metadata * site-level metadata
*/ */
site: Ref<SiteData<T>> site: Ref<SiteData<T>>
/** /**
@ -157,7 +157,7 @@ export interface VitePressData<T = any> {
*/ */
theme: Ref<T> theme: Ref<T>
/** /**
* Page-level metadata * page-level metadata
*/ */
page: Ref<PageData> page: Ref<PageData>
/** /**
@ -174,10 +174,14 @@ export interface VitePressData<T = any> {
dir: Ref<string> dir: Ref<string>
localeIndex: Ref<string> localeIndex: Ref<string>
isDark: Ref<boolean> isDark: Ref<boolean>
/** }
* Current location hash
*/ export interface Route {
hash: Ref<string> path: string
hash: string
query: string
data: PageData
component: Component | null
} }
export type HeadConfig = export type HeadConfig =

Loading…
Cancel
Save