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 { ref } from 'vue'
function createData(
themeConfig: DefaultTheme.Config,
relativePath = 'guide/getting-started.md',
// `currentPage` is the current page's relative path (like
// `route.data.relativePath`, but with a leading slash), plus any query and
// hash of the current URL.
function resolve(
currentPage: string,
{
themeConfig = {},
cleanUrls = false,
hash = '#install'
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({
cleanUrls,
locales: {
@ -18,61 +39,108 @@ function createData(
},
themeConfig
}),
page: ref({ relativePath }),
theme: ref(themeConfig),
hash: ref(hash)
theme: ref(themeConfig)
} as unknown as VitePressData<DefaultTheme.Config>
}
function createRoute(query = '', hash = '#install') {
return {
query,
hash
} as unknown as Route
return resolveLocaleLink(data, route, {
targetLocale,
targetLocaleLink,
currentLocaleLink,
linkToCorrespondingPage
})
}
describe('client/theme-default/composables/langs', () => {
test('resolves corresponding links with the default router', () => {
describe('resolveLocaleLink', () => {
describe('locale home links (linkToCorrespondingPage: false)', () => {
test('links to the target locale home', () => {
expect(
resolve('/guide/getting-started.md', {
linkToCorrespondingPage: false
})
).toBe('/fr/')
})
test('preserves query and hash', () => {
expect(
resolveLocaleLink(createData({}), createRoute(), 'fr', '/fr/', '/', true)
).toBe('/fr/guide/getting-started.html#install')
resolve('/guide/getting-started.md?a=1#install', {
linkToCorrespondingPage: false
})
).toBe('/fr/?a=1#install')
})
test('resolves clean index links with the default router', () => {
test('ignores custom i18n routing functions', () => {
expect(
resolveLocaleLink(
createData({}, 'en/guide/index.md', true, '#intro'),
createRoute('?query', '#intro'),
'fr',
'/fr/',
'/en/',
true
resolve('/guide/getting-started.md', {
linkToCorrespondingPage: false,
themeConfig: { i18nRouting: () => '/custom/' }
})
).toBe('/fr/')
})
})
describe('corresponding page links (linkToCorrespondingPage: true)', () => {
test('rewrites the current page path into the target locale', () => {
expect(resolve('/guide/getting-started.md#install')).toBe(
'/fr/guide/getting-started.html#install'
)
).toBe('/fr/guide/?query#intro')
})
test('keeps locale root links when i18n routing is disabled', () => {
test('drops the .html extension when clean URLs are enabled', () => {
expect(
resolveLocaleLink(
createData({ i18nRouting: false }),
createRoute(),
'fr',
'/fr/',
'/',
true
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/'
)
).toBe('/fr/#install')
})
test('uses custom i18n routing functions for corresponding links', () => {
const data = createData({
i18nRouting(data, hash, targetLocale) {
return `${data.site.value.locales[targetLocale].link}mapped/${data.page.value.relativePath}${hash}`
}
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(
resolveLocaleLink(data, createRoute(), 'fr', '/fr/', '/', true)
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('inactive', item)).toBe(false)
expect(hasActiveLink('active-1', '', item)).toBe(true)
expect(hasActiveLink('inactive', '', item)).toBe(false)
})
test('checks `SidebarItem[]`', () => {
@ -210,9 +210,9 @@ describe('client/theme-default/support/sidebar', () => {
}
]
expect(hasActiveLink('active-1', item)).toBe(true)
expect(hasActiveLink('active-3', item)).toBe(true)
expect(hasActiveLink('inactive', item)).toBe(false)
expect(hasActiveLink('active-1', '', item)).toBe(true)
expect(hasActiveLink('active-3', '', item)).toBe(true)
expect(hasActiveLink('inactive', '', item)).toBe(false)
})
})
})

@ -25,23 +25,23 @@ export default {
## 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`.
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
import { defineConfig } from 'vitepress'
export default defineConfig({
themeConfig: {
i18nRouting(data, hash, targetLocale) {
i18nRouting(data, route, targetLocale) {
const target = data.site.value.locales[targetLocale]
const targetLink =
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
- Тип: `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`.
Установите для `themeConfig.i18nRouting` функцию, чтобы настроить ссылки для переключения локали. Эта функция получает текущие данные VitePress, текущий хеш и ключ целевой локали, а затем возвращает ссылку для перехода на неё.
Установите для `themeConfig.i18nRouting` функцию, чтобы настроить ссылку локали. Эта функция получает текущие данные VitePress, текущий маршрут и ключ целевой локали, а затем возвращает целевую ссылку.
```ts
import { defineConfig } from 'vitepress'
export default defineConfig({
themeConfig: {
i18nRouting(data, hash, targetLocale) {
i18nRouting(data, route, targetLocale) {
const target = data.site.value.locales[targetLocale]
const targetLink =
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' }
},
setup(props) {
const route = useRoute()
const { frontmatter, site } = useData()
const route = useRoute()
watch(frontmatter, runCbs, { deep: true, flush: 'post' })
return () =>
h(

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

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

@ -1,18 +1,10 @@
import type { Component, InjectionKey } 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 { siteDataRef } from './data'
import { inBrowser, withBase } from './utils'
export interface Route {
path: string
hash: string
query: string
data: PageData
component: Component | null
}
export interface Router {
/**
* Current route.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,14 +1,24 @@
import { computed } from 'vue'
import type { DefaultTheme } from 'vitepress/theme'
import type { VitePressData } from '../../app/data'
import { useRoute, type Route } from '../../app/router'
import { computed } from 'vue'
import { useRoute } from '../../app/router'
import type { Route, VitePressData } from '../../shared'
import { ensureStartingSlash } from '../support/utils'
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 route = useRoute()
const { site, localeIndex } = data
const currentLang = computed(() => ({
label: site.value.locales[localeIndex.value]?.label,
link:
@ -22,43 +32,64 @@ export function useLangs({ correspondingLink = false } = {}) {
? []
: {
text: value.label,
link: resolveLocaleLink(
data,
route,
key,
link: resolveLocaleLink(data, route, {
targetLocale: key,
targetLocaleLink:
value.link || (key === 'root' ? '/' : `/${key}/`),
currentLang.value.link,
correspondingLink
),
currentLocaleLink: currentLang.value.link,
linkToCorrespondingPage
}),
lang: value.lang,
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(
data: VitePressData<DefaultTheme.Config>,
route: Route,
targetLocale: string,
targetLink: string,
currentLink: string,
correspondingLink: boolean
{
targetLocale,
targetLocaleLink,
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
if (correspondingLink && typeof i18nRouting === 'function') {
return i18nRouting(data, route.hash, targetLocale)
if (linkToCorrespondingPage && typeof i18nRouting === 'function') {
return i18nRouting(data, route, targetLocale)
}
return (
normalizeLink(
targetLink,
i18nRouting !== false && correspondingLink,
page.value.relativePath.slice(currentLink.length - 1),
targetLocaleLink,
i18nRouting !== false && linkToCorrespondingPage,
route.data.relativePath.slice(currentLocaleLink.length - 1),
!site.value.cleanUrls
) +
route.query +
@ -67,17 +98,17 @@ export function resolveLocaleLink(
}
function normalizeLink(
link: string,
addPath: boolean,
path: string,
addExt: boolean
localeLink: string,
appendPagePath: boolean,
pagePath: string,
addHtmlExt: boolean
) {
return addPath
? link.replace(/\/$/, '') +
return appendPagePath
? localeLink.replace(/\/$/, '') +
ensureStartingSlash(
path
pagePath
.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) {
const { frontmatter, page, theme } = useData()
const { theme, page, frontmatter } = useData()
watch(
() => [page.value.relativePath, theme.value.sidebar] as const,

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

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

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

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

@ -1,7 +1,7 @@
import type { Options as _MiniSearchOptions } from 'minisearch'
import type { DocSearchProps } from './docsearch.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 interface Config {
@ -159,7 +159,7 @@ export namespace DefaultTheme {
export type I18nRouting = (
data: VitePressData<Config>,
hash: string,
route: Route,
targetLocale: string
) => string

18
types/shared.d.ts vendored

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

Loading…
Cancel
Save