From dcb7a75532c5472060ec379d25a5fafbc7932637 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:09:41 +0530 Subject: [PATCH] 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. --- .../theme-default/composables/langs.test.ts | 174 ++++++++++++------ .../theme-default/support/sidebar.test.ts | 10 +- docs/en/reference/default-theme-config.md | 8 +- docs/ru/reference/default-theme-config.md | 8 +- src/client/app/components/Content.ts | 2 +- src/client/app/composables/head.ts | 2 +- src/client/app/data.ts | 22 +-- src/client/app/router.ts | 10 +- src/client/index.ts | 4 +- .../theme-default/components/VPCarbonAds.vue | 6 +- src/client/theme-default/components/VPDoc.vue | 1 - .../components/VPHomeContent.vue | 1 + .../components/VPLocalNavOutlineDropdown.vue | 3 +- .../theme-default/components/VPMenuLink.vue | 15 +- .../components/VPNavBarExtra.vue | 5 +- .../components/VPNavBarMenuGroup.vue | 16 +- .../components/VPNavBarMenuLink.vue | 15 +- .../components/VPNavBarTranslations.vue | 5 +- .../components/VPNavScreenMenuGroupLink.vue | 15 +- .../components/VPNavScreenMenuLink.vue | 15 +- .../components/VPNavScreenTranslations.vue | 5 +- .../theme-default/composables/flyout.ts | 2 +- src/client/theme-default/composables/langs.ts | 95 ++++++---- .../theme-default/composables/layout.ts | 2 +- .../theme-default/composables/prev-next.ts | 25 +-- .../theme-default/composables/sidebar.ts | 59 +++--- .../theme-default/support/reactivity.ts | 8 +- src/client/theme-default/support/sidebar.ts | 16 +- src/client/theme-default/support/utils.ts | 8 + src/shared/shared.ts | 19 +- types/default-theme.d.ts | 4 +- types/shared.d.ts | 18 +- 32 files changed, 357 insertions(+), 241 deletions(-) diff --git a/__tests__/unit/client/theme-default/composables/langs.test.ts b/__tests__/unit/client/theme-default/composables/langs.test.ts index cf9760c1..39fe021c 100644 --- a/__tests__/unit/client/theme-default/composables/langs.test.ts +++ b/__tests__/unit/client/theme-default/composables/langs.test.ts @@ -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', - cleanUrls = false, - hash = '#install' +// `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, + 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 -} -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', () => { - expect( - resolveLocaleLink(createData({}), createRoute(), 'fr', '/fr/', '/', true) - ).toBe('/fr/guide/getting-started.html#install') - }) - - test('resolves clean index links with the default router', () => { - expect( - resolveLocaleLink( - createData({}, 'en/guide/index.md', true, '#intro'), - createRoute('?query', '#intro'), - 'fr', - '/fr/', - '/en/', - true - ) - ).toBe('/fr/guide/?query#intro') - }) + 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('keeps locale root links when i18n routing is disabled', () => { - expect( - resolveLocaleLink( - createData({ i18nRouting: false }), - createRoute(), - 'fr', - '/fr/', - '/', - true - ) - ).toBe('/fr/#install') - }) + test('preserves query and hash', () => { + expect( + resolve('/guide/getting-started.md?a=1#install', { + linkToCorrespondingPage: false + }) + ).toBe('/fr/?a=1#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('ignores custom i18n routing functions', () => { + expect( + resolve('/guide/getting-started.md', { + linkToCorrespondingPage: false, + themeConfig: { i18nRouting: () => '/custom/' } + }) + ).toBe('/fr/') + }) }) - expect( - resolveLocaleLink(data, createRoute(), 'fr', '/fr/', '/', true) - ).toBe('/fr/mapped/guide/getting-started.md#install') + 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' + ) + }) + + 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') + }) + }) }) }) diff --git a/__tests__/unit/client/theme-default/support/sidebar.test.ts b/__tests__/unit/client/theme-default/support/sidebar.test.ts index 7232757c..c5a4d44f 100644 --- a/__tests__/unit/client/theme-default/support/sidebar.test.ts +++ b/__tests__/unit/client/theme-default/support/sidebar.test.ts @@ -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) }) }) }) diff --git a/docs/en/reference/default-theme-config.md b/docs/en/reference/default-theme-config.md index cc4ffd0e..02c221ec 100644 --- a/docs/en/reference/default-theme-config.md +++ b/docs/en/reference/default-theme-config.md @@ -25,23 +25,23 @@ export default { ## i18nRouting -- Type: `boolean | ((data: VitePressData, hash: string, targetLocale: string) => string)` +- Type: `boolean | ((data: VitePressData, 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}` } } }) diff --git a/docs/ru/reference/default-theme-config.md b/docs/ru/reference/default-theme-config.md index 796b5bb4..afd17954 100644 --- a/docs/ru/reference/default-theme-config.md +++ b/docs/ru/reference/default-theme-config.md @@ -25,23 +25,23 @@ export default { ## i18nRouting -- Тип: `boolean | ((data: VitePressData, hash: string, targetLocale: string) => string)` +- Тип: `boolean | ((data: VitePressData, 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}` } } }) diff --git a/src/client/app/components/Content.ts b/src/client/app/components/Content.ts index 12456451..d21ed2aa 100644 --- a/src/client/app/components/Content.ts +++ b/src/client/app/components/Content.ts @@ -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( diff --git a/src/client/app/composables/head.ts b/src/client/app/composables/head.ts index 13fb9bb0..2b63d5cf 100644 --- a/src/client/app/composables/head.ts +++ b/src/client/app/composables/head.ts @@ -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) { let isFirstUpdate = true diff --git a/src/client/app/data.ts b/src/client/app/data.ts index 89297dba..c612dbb7 100644 --- a/src/client/app/data.ts +++ b/src/client/app/data.ts @@ -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 = 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 } } diff --git a/src/client/app/router.ts b/src/client/app/router.ts index 5d3d86f1..818f46fb 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -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. diff --git a/src/client/index.ts b/src/client/index.ts index 47074c43..8b0bc15e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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' diff --git a/src/client/theme-default/components/VPCarbonAds.vue b/src/client/theme-default/components/VPCarbonAds.vue index b8fc77a2..ca034ccb 100644 --- a/src/client/theme-default/components/VPCarbonAds.vue +++ b/src/client/theme-default/components/VPCarbonAds.vue @@ -1,10 +1,10 @@ diff --git a/src/client/theme-default/components/VPNavBarExtra.vue b/src/client/theme-default/components/VPNavBarExtra.vue index 7201623b..1ddde9b9 100644 --- a/src/client/theme-default/components/VPNavBarExtra.vue +++ b/src/client/theme-default/components/VPNavBarExtra.vue @@ -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" /> diff --git a/src/client/theme-default/components/VPNavBarMenuGroup.vue b/src/client/theme-default/components/VPNavBarMenuGroup.vue index 4d014a49..61004f3f 100644 --- a/src/client/theme-default/components/VPNavBarMenuGroup.vue +++ b/src/client/theme-default/components/VPNavBarMenuGroup.vue @@ -1,19 +1,24 @@