From eef57427f4861939ff27f07d71587e68118064e2 Mon Sep 17 00:00:00 2001 From: T <6601329+cookesan@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:00:54 -0300 Subject: [PATCH] feat: allow custom i18n routing (#5239) --- .../theme-default/composables/langs.test.ts | 69 +++++++++++++++++++ docs/en/reference/default-theme-config.md | 20 +++++- src/client/app/data.ts | 38 +--------- src/client/index.ts | 2 +- src/client/theme-default/composables/langs.ts | 45 +++++++++--- src/shared/shared.ts | 1 + types/default-theme.d.ts | 14 +++- types/shared.d.ts | 34 +++++++++ 8 files changed, 173 insertions(+), 50 deletions(-) create mode 100644 __tests__/unit/client/theme-default/composables/langs.test.ts diff --git a/__tests__/unit/client/theme-default/composables/langs.test.ts b/__tests__/unit/client/theme-default/composables/langs.test.ts new file mode 100644 index 000000000..565a4a238 --- /dev/null +++ b/__tests__/unit/client/theme-default/composables/langs.test.ts @@ -0,0 +1,69 @@ +import { ref } from 'vue' +import type { VitePressData } from 'vitepress' +import type { DefaultTheme } from 'vitepress/theme' +import { resolveLocaleLink } from 'client/theme-default/composables/langs' + +function createData( + themeConfig: DefaultTheme.Config, + relativePath = 'guide/getting-started.md', + cleanUrls = false, + hash = '#install' +) { + return { + site: ref({ + cleanUrls, + locales: { + root: { label: 'English', lang: 'en-US' }, + fr: { label: 'Français', lang: 'fr-FR', link: '/fr/' } + }, + themeConfig + }), + page: ref({ relativePath }), + theme: ref(themeConfig), + hash: ref(hash) + } as unknown as VitePressData +} + +describe('client/theme-default/composables/langs', () => { + test('resolves corresponding links with the default router', () => { + expect(resolveLocaleLink(createData({}), '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'), + 'fr', + '/fr/', + '/en/', + true + ) + ).toBe('/fr/guide/#intro') + }) + + test('keeps locale root links when i18n routing is disabled', () => { + expect( + resolveLocaleLink( + createData({ i18nRouting: false }), + 'fr', + '/fr/', + '/', + true + ) + ).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}` + } + }) + + expect(resolveLocaleLink(data, 'fr', '/fr/', '/', true)).toBe( + '/fr/mapped/guide/getting-started.md#install' + ) + }) +}) diff --git a/docs/en/reference/default-theme-config.md b/docs/en/reference/default-theme-config.md index 085965526..b515cb45d 100644 --- a/docs/en/reference/default-theme-config.md +++ b/docs/en/reference/default-theme-config.md @@ -25,10 +25,28 @@ export default { ## i18nRouting -- Type: `boolean` +- Type: `boolean | ((data: VitePressData, hash: string, 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. + +```ts +import { defineConfig } from 'vitepress' + +export default defineConfig({ + themeConfig: { + i18nRouting(data, hash, targetLocale) { + const target = data.site.value.locales[targetLocale] + const targetLink = + target.link || (targetLocale === 'root' ? '/' : `/${targetLocale}/`) + + return `${targetLink}${data.page.value.relativePath.replace(/\.md$/, '')}${hash}` + } + } +}) +``` + ## logo - Type: `ThemeableImage` diff --git a/src/client/app/data.ts b/src/client/app/data.ts index 72fb78311..89297dba3 100644 --- a/src/client/app/data.ts +++ b/src/client/app/data.ts @@ -15,45 +15,13 @@ import { createTitle, inBrowser, resolveSiteDataByRoute, - type PageData, - type SiteData + type SiteData, + type VitePressData } from '../shared' import type { Route } from './router' export const dataSymbol: InjectionKey = Symbol() - -export interface VitePressData { - /** - * Site-level metadata - */ - site: Ref> - /** - * themeConfig from .vitepress/config.js - */ - theme: Ref - /** - * Page-level metadata - */ - page: Ref - /** - * page frontmatter data - */ - frontmatter: Ref - /** - * dynamic route params - */ - params: Ref - title: Ref - description: Ref - lang: Ref - dir: Ref - localeIndex: Ref - isDark: Ref - /** - * Current location hash - */ - hash: Ref -} +export type { VitePressData } from '../shared' // site data is a singleton export const siteDataRef: Ref = shallowRef( diff --git a/src/client/index.ts b/src/client/index.ts index cd7442e9f..6112e8664 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,7 +2,7 @@ // so the user can do `import { useRoute, useData } from 'vitepress'` // generic types -export type { VitePressData } from './app/data' +export type { VitePressData } from './shared' export type { Route, Router } from './app/router' // theme types diff --git a/src/client/theme-default/composables/langs.ts b/src/client/theme-default/composables/langs.ts index 625f1175b..9c10a1e99 100644 --- a/src/client/theme-default/composables/langs.ts +++ b/src/client/theme-default/composables/langs.ts @@ -1,9 +1,12 @@ import { computed } from 'vue' +import type { DefaultTheme } from 'vitepress/theme' +import type { VitePressData } from '../../app/data' import { ensureStartingSlash } from '../support/utils' import { useData } from './data' export function useLangs({ correspondingLink = false } = {}) { - const { site, localeIndex, page, theme, hash } = useData() + const data = useData() + const { site, localeIndex } = data const currentLang = computed(() => ({ label: site.value.locales[localeIndex.value]?.label, link: @@ -17,15 +20,13 @@ export function useLangs({ correspondingLink = false } = {}) { ? [] : { text: value.label, - link: - normalizeLink( - value.link || (key === 'root' ? '/' : `/${key}/`), - theme.value.i18nRouting !== false && correspondingLink, - page.value.relativePath.slice( - currentLang.value.link.length - 1 - ), - !site.value.cleanUrls - ) + hash.value, + link: resolveLocaleLink( + data, + key, + value.link || (key === 'root' ? '/' : `/${key}/`), + currentLang.value.link, + correspondingLink + ), lang: value.lang, dir: value.dir } @@ -35,6 +36,30 @@ export function useLangs({ correspondingLink = false } = {}) { return { localeLinks, currentLang } } +export function resolveLocaleLink( + data: VitePressData, + targetLocale: string, + targetLink: string, + currentLink: string, + correspondingLink: boolean +) { + const { site, page, theme, hash } = data + const i18nRouting = theme.value.i18nRouting + + if (correspondingLink && typeof i18nRouting === 'function') { + return i18nRouting(data, hash.value, targetLocale) + } + + return ( + normalizeLink( + targetLink, + i18nRouting !== false && correspondingLink, + page.value.relativePath.slice(currentLink.length - 1), + !site.value.cleanUrls + ) + hash.value + ) +} + function normalizeLink( link: string, addPath: boolean, diff --git a/src/shared/shared.ts b/src/shared/shared.ts index dade94d29..c73019e6a 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -16,6 +16,7 @@ export type { PageData, PageDataPayload, SiteData, + VitePressData, SSGContext, AdditionalConfig, AdditionalConfigDict, diff --git a/types/default-theme.d.ts b/types/default-theme.d.ts index 7f648ca03..ae3c2c71e 100644 --- a/types/default-theme.d.ts +++ b/types/default-theme.d.ts @@ -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 } from './shared.js' +import type { Header, PageData, VitePressData } from './shared.js' export namespace DefaultTheme { export interface Config { @@ -136,11 +136,13 @@ export namespace DefaultTheme { carbonAds?: CarbonAdsOptions /** - * Changing locale when current url is `/foo` will redirect to `/locale/foo`. + * Changing locale when current url is `/foo` redirects to `/locale/foo`. + * Set to `false` to disable this behavior, or provide a function to + * customize the target locale link. * * @default true */ - i18nRouting?: boolean + i18nRouting?: boolean | I18nRouting /** * Show external link icon in Markdown links. @@ -155,6 +157,12 @@ export namespace DefaultTheme { notFound?: NotFoundOptions } + export type I18nRouting = ( + data: VitePressData, + hash: string, + targetLocale: string + ) => string + // nav ----------------------------------------------------------------------- export type NavItem = NavItemComponent | NavItemWithLink | NavItemWithChildren diff --git a/types/shared.d.ts b/types/shared.d.ts index 1af93217d..ab45c50ad 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -1,5 +1,6 @@ // types shared between server and client import type { UseDarkOptions } from '@vueuse/core' +import type { Ref } from 'vue' import type { SSRContext } from 'vue/server-renderer' export type { DefaultTheme } from './default-theme.js' @@ -146,6 +147,39 @@ export interface SiteData { AdditionalConfigDict | AdditionalConfigLoader } +export interface VitePressData { + /** + * Site-level metadata + */ + site: Ref> + /** + * themeConfig from .vitepress/config.js + */ + theme: Ref + /** + * Page-level metadata + */ + page: Ref + /** + * page frontmatter data + */ + frontmatter: Ref + /** + * dynamic route params + */ + params: Ref + title: Ref + description: Ref + lang: Ref + dir: Ref + localeIndex: Ref + isDark: Ref + /** + * Current location hash + */ + hash: Ref +} + export type HeadConfig = [string, Record] | [string, Record, string]