From 55c0b03a4126c2b62dda8d7602f0875fb8a730a6 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Thu, 30 Oct 2025 15:03:50 +0100 Subject: [PATCH] Support translations and generic variants together (#3772) --- .changeset/rich-hairs-check.md | 5 + .../gitbook/src/components/Header/Header.tsx | 34 ++-- .../SpaceLayout/SpaceLayout.test.ts | 149 ++++++++++++++++++ .../components/SpaceLayout/SpaceLayout.tsx | 36 ++--- .../SpaceLayout/categorizeVariants.ts | 56 +++++++ packages/gitbook/src/intl/translations/de.ts | 2 + packages/gitbook/src/intl/translations/en.ts | 2 + packages/gitbook/src/intl/translations/es.ts | 2 + packages/gitbook/src/intl/translations/fr.ts | 2 + packages/gitbook/src/intl/translations/it.ts | 2 + packages/gitbook/src/intl/translations/ja.ts | 2 + packages/gitbook/src/intl/translations/nl.ts | 2 + packages/gitbook/src/intl/translations/no.ts | 2 + .../gitbook/src/intl/translations/pt-br.ts | 2 + packages/gitbook/src/intl/translations/ru.ts | 2 + packages/gitbook/src/intl/translations/zh.ts | 2 + 16 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 .changeset/rich-hairs-check.md create mode 100644 packages/gitbook/src/components/SpaceLayout/SpaceLayout.test.ts create mode 100644 packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts diff --git a/.changeset/rich-hairs-check.md b/.changeset/rich-hairs-check.md new file mode 100644 index 000000000..f12f76ba5 --- /dev/null +++ b/.changeset/rich-hairs-check.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Support translations and generic variants together diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index bbdc3e8a8..9253aa504 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { getSpaceLanguage, t } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; +import type { SiteSpace } from '@gitbook/api'; import { SearchContainer } from '../Search'; import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections'; import { HeaderLink } from './HeaderLink'; @@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown'; export function Header(props: { context: GitBookSiteContext; withTopHeader?: boolean; - withVariants?: 'generic' | 'translations'; + variants: { + generic: SiteSpace[]; + translations: SiteSpace[]; + }; }) { - const { context, withTopHeader, withVariants } = props; + const { context, withTopHeader, variants } = props; const { siteSpace, siteSpaces, sections, customization } = context; const withSections = Boolean( @@ -91,7 +95,7 @@ export function Header(props: { 'theme-bold:text-header-link', 'hover:bg-tint-hover', 'hover:theme-bold:bg-header-link/3', - withVariants === 'generic' + variants.generic.length > 1 ? 'xl:hidden' : 'page-no-toc:hidden lg:hidden' )} @@ -126,7 +130,7 @@ export function Header(props: { > 1} withSiteVariants={ sections?.list.some( (s) => @@ -150,7 +154,7 @@ export function Header(props: { {customization.header.links.length > 0 || - (!withSections && withVariants === 'translations') ? ( + (!withSections && variants.translations.length > 1) ? ( {customization.header.links.length > 0 ? ( <> @@ -170,11 +174,15 @@ export function Header(props: { /> ) : null} - {!withSections && withVariants === 'translations' ? ( + {!withSections && variants.translations.length > 1 ? ( space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3" /> ) : null} @@ -187,11 +195,15 @@ export function Header(props: { {sections && withSections ? (
- {withVariants === 'translations' ? ( + {variants.translations.length > 1 ? ( space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} className="my-2 ml-2 self-start" /> ) : null} diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.test.ts b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.test.ts new file mode 100644 index 000000000..1b210ac66 --- /dev/null +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'bun:test'; +import { languages } from '@/intl/translations'; +import { type SiteSpace, TranslationLanguage } from '@gitbook/api'; +import { categorizeVariants } from './categorizeVariants'; + +type FakeSiteSpace = { + id: SiteSpace['id']; + title: SiteSpace['title']; + space: Pick; +}; + +function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) { + return { + // Only the properties used by categorizeVariants are required for these tests + siteSpace: current, + siteSpaces: all, + } as unknown as Parameters[0]; +} + +const englishA = { + id: 'en-a', + title: 'Docs EN A', + space: { language: TranslationLanguage.En }, +}; +const englishB = { + id: 'en-b', + title: 'Docs EN B', + space: { language: TranslationLanguage.En }, +}; +const frenchA = { + id: 'fr-a', + title: 'Docs FR A', + space: { language: TranslationLanguage.Fr }, +}; +const frenchB = { + id: 'fr-b', + title: 'Docs FR B', + space: { language: TranslationLanguage.Fr }, +}; +const undefinedLanguage = { + id: 'undefined', + title: 'Docs in Undefined Language', + space: { language: undefined }, +}; +const unsupportedLanguage = { + id: 'unsupported', + title: 'Docs in Unsupported Language', + space: { language: 'xx' as TranslationLanguage }, +}; + +describe('categorizeVariants', () => { + it('returns all spaces as generic and no translations for single-language sites', () => { + const ctx = makeContext(englishA, [englishA, englishB]); + + const result = categorizeVariants(ctx); + + expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']); + expect(result.translations).toEqual([]); + }); + + it('returns all spaces as generic and no translations for sites with 1 language and an undefined language', () => { + const ctx = makeContext(englishA, [englishA, englishB, undefinedLanguage]); + + const result = categorizeVariants(ctx); + + expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b', 'undefined']); + expect(result.translations).toEqual([]); + }); + + it('keeps one-per-language translations without remapping titles', () => { + const ctx = makeContext(englishA, [englishA, frenchA]); + + const result = categorizeVariants(ctx); + + // Generic should only include current language variants when multi-language + expect(result.generic.map((s) => s.id)).toEqual(['en-a']); + + // With exactly 1 per language, translations length equals number of languages → no remap + expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([ + { id: 'en-a', title: 'Docs EN A' }, + { id: 'fr-a', title: 'Docs FR A' }, + ]); + }); + + it('keeps one-per-language translations without remapping titles, including unsupported languages', () => { + const ctx = makeContext(englishA, [englishA, unsupportedLanguage]); + + const result = categorizeVariants(ctx); + + // Generic should only include current language variants when multi-language + expect(result.generic.map((s) => s.id)).toEqual(['en-a']); + + // With exactly 1 per language, translations length equals number of languages → no remap + expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([ + { id: 'en-a', title: 'Docs EN A' }, + { id: 'unsupported', title: 'Docs in Unsupported Language' }, + ]); + }); + + it('keeps one-per-language translations when there are more than 1 language and an undefined language', () => { + const ctx = makeContext(englishA, [englishA, frenchA, undefinedLanguage]); + + const result = categorizeVariants(ctx); + + expect(result.generic.map((s) => s.id)).toEqual(['en-a']); + expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([ + { id: 'en-a', title: 'Docs EN A' }, + { id: 'fr-a', title: 'Docs FR A' }, + { id: 'undefined', title: 'Docs in Undefined Language' }, + ]); + }); + + it('deduplicates to first space per language and maps titles to language names', () => { + const ctx = makeContext(englishA, [englishA, englishB, frenchA, frenchB]); + + const result = categorizeVariants(ctx); + + // Generic includes all current-language variants when multi-language + expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']); + + // Distinct languages are ['en','fr'] but initial translations had 4 → remap + // After remap: first per language, with title set to language label + expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([ + { id: 'en-a', title: languages.en.language }, + { id: 'fr-a', title: languages.fr.language }, + ]); + }); + + it('deduplicates to first space per language and maps titles to language names, and falls back to original title if no language is found', () => { + const ctx = makeContext(englishA, [ + englishA, + englishB, + frenchA, + frenchB, + undefinedLanguage, + unsupportedLanguage, + ]); + + const result = categorizeVariants(ctx); + + expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']); + expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([ + { id: 'en-a', title: languages.en.language }, + { id: 'fr-a', title: languages.fr.language }, + { id: 'undefined', title: 'Docs in Undefined Language' }, + { id: 'unsupported', title: 'Docs in Unsupported Language' }, + ]); + }); +}); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 0a555f51c..97b437022 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -10,11 +10,9 @@ import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; import { TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; -import { tcls } from '@/lib/tailwind'; - -import { getSpaceLanguage } from '@/intl/server'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { GITBOOK_APP_URL } from '@/lib/env'; +import { tcls } from '@/lib/tailwind'; import { AIChatProvider } from '../AI'; import type { RenderAIMessageOptions } from '../AI'; import { AIChat } from '../AIChat'; @@ -27,6 +25,7 @@ import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { CurrentContentProvider } from '../hooks'; import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutContextProvider } from './SpaceLayoutContext'; +import { categorizeVariants } from './categorizeVariants'; type SpaceLayoutProps = { context: GitBookSiteContext; @@ -105,16 +104,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None; const withSections = Boolean(sections && sections.list.length > 1); - - const currentLanguage = getSpaceLanguage(context); - const withVariants: 'generic' | 'translations' | undefined = - siteSpaces.length > 1 - ? siteSpaces.some( - (space) => space.space.language && space.space.language !== currentLanguage.locale - ) - ? 'translations' - : 'generic' - : undefined; + const variants = categorizeVariants(context); const withFooter = customization.themes.toggeable || @@ -125,7 +115,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { return ( -
+
{customization.ai?.mode === CustomizationAIMode.Assistant ? ( @@ -165,11 +155,15 @@ export function SpaceLayout(props: SpaceLayoutProps) { )} > - {withVariants === 'translations' ? ( + {variants.translations.length > 1 ? ( space.id === siteSpace.id + ) ?? siteSpace + } + siteSpaces={variants.translations} className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden" /> ) : null} @@ -183,7 +177,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
1} withSiteVariants={ sections?.list.some( (s) => @@ -213,14 +207,14 @@ export function SpaceLayout(props: SpaceLayoutProps) { sections={encodeClientSiteSections(context, sections)} /> )} - {withVariants === 'generic' && ( + {variants.generic.length > 1 ? ( - )} + ) : null} } /> diff --git a/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts b/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts new file mode 100644 index 000000000..94df27104 --- /dev/null +++ b/packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts @@ -0,0 +1,56 @@ +import { languages } from '@/intl/translations'; +import type { GitBookSiteContext } from '@/lib/context'; + +/** + * Categorize the variants of the space into generic and translation variants. + */ +export function categorizeVariants(context: GitBookSiteContext) { + const { siteSpace, siteSpaces } = context; + const currentLanguage = siteSpace.space.language; + + // Get all languages of the variants. + const variantLanguages = [...new Set(siteSpaces.map((space) => space.space.language))]; + + // We only show the language picker if there are at least 2 distinct languages, excluding undefined. + const isMultiLanguage = + variantLanguages.filter((language) => language !== undefined).length > 1; + + // Generic variants are all spaces that have the same language as the current (can also be undefined). + const genericVariants = isMultiLanguage + ? siteSpaces.filter( + (space) => space === siteSpace || space.space.language === currentLanguage + ) + : siteSpaces; + + // Translation variants are all spaces that have a different language than the current. + let translationVariants = isMultiLanguage + ? siteSpaces.filter( + (space) => space === siteSpace || space.space.language !== currentLanguage + ) + : []; + + // If there is exactly 1 variant per language, we will use them as-is. + // Otherwise, we will create a translation dropdown with the first space of each language. + if (variantLanguages.length !== translationVariants.length) { + translationVariants = variantLanguages + // Get the first space of each language. + .map((variantLanguage) => + translationVariants.find((space) => space.space.language === variantLanguage) + ) + // Filter out unmatched languages. + .filter((space) => space !== undefined) + // Transform the title to include the language name if we have a translation. Otherwise, use the original title. + .map((space) => { + const language = languages[space.space.language as keyof typeof languages]; + return { + ...space, + title: language ? language.language : space.title, + }; + }); + } + + return { + generic: genericVariants, + translations: translationVariants, + }; +} diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index c3b48024f..4a7860470 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -1,5 +1,7 @@ export const de = { locale: 'de', + language: 'Deutsch', + flag: '🇩🇪', powered_by_gitbook: 'Bereitgestellt von GitBook', sponsored_via_gitbook: 'Gesponsert von GitBook', switch_to_dark_theme: 'Zum dunklen Modus wechseln', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index c45fae880..d4e81af67 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -1,5 +1,7 @@ export const en = { locale: 'en', + language: 'English', + flag: '🇺🇸', powered_by_gitbook: 'Powered by GitBook', sponsored_via_gitbook: 'Sponsored via GitBook', switch_to_dark_theme: 'Switch to dark theme', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index b7544a69a..e3769eabd 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const es: TranslationLanguage = { locale: 'es', + language: 'Español', + flag: '🇪🇸', powered_by_gitbook: 'Con tecnología de GitBook', sponsored_via_gitbook: 'Patrocinado por GitBook', switch_to_dark_theme: 'Cambiar a tema oscuro', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 606c1e779..63caac0ab 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -1,5 +1,7 @@ export const fr = { locale: 'fr', + language: 'Français', + flag: '🇫🇷', powered_by_gitbook: 'Propulsé par GitBook', sponsored_via_gitbook: 'Sponsorisé via GitBook', switch_to_dark_theme: 'Activer le thème sombre', diff --git a/packages/gitbook/src/intl/translations/it.ts b/packages/gitbook/src/intl/translations/it.ts index 24e2309bb..504c1b213 100644 --- a/packages/gitbook/src/intl/translations/it.ts +++ b/packages/gitbook/src/intl/translations/it.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const it: TranslationLanguage = { locale: 'it', + language: 'Italiano', + flag: '🇮🇹', powered_by_gitbook: 'Offerto da GitBook', sponsored_via_gitbook: 'Sponsorizzato tramite GitBook', switch_to_dark_theme: 'Passa al tema scuro', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 5ae091c59..7a0ab127c 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const ja: TranslationLanguage = { locale: 'ja', + language: '日本語', + flag: '🇯🇵', powered_by_gitbook: 'GitBook提供', sponsored_via_gitbook: 'GitBookスポンサー', switch_to_dark_theme: 'ダークテーマに切り替え', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index ee5a1713f..07790cdee 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const nl: TranslationLanguage = { locale: 'nl', + language: 'Nederlands', + flag: '🇳🇱', powered_by_gitbook: 'Powered by GitBook', sponsored_via_gitbook: 'Gesponsord door GitBook', switch_to_dark_theme: 'Schakel over naar donkere modus', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index cda4278a0..a53bdedc1 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const no: TranslationLanguage = { locale: 'no', + language: 'Norsk', + flag: '🇳🇴', powered_by_gitbook: 'Drevet av GitBook', sponsored_via_gitbook: 'Sponset av GitBook', switch_to_dark_theme: 'Bytt til mørkt tema', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 55cc8e3ab..1e711c78f 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -1,5 +1,7 @@ export const pt_br = { locale: 'pt-br', + language: 'Português (Brasil)', + flag: '🇧🇷', powered_by_gitbook: 'Fornecido por GitBook', sponsored_via_gitbook: 'Patrocinado por GitBook', switch_to_dark_theme: 'Mudar para modo escuro', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index ad0807299..ccaa8e4b8 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -1,5 +1,7 @@ export const ru = { locale: 'ru', + language: 'Русский', + flag: '🇷🇺', powered_by_gitbook: 'Работает на GitBook', sponsored_via_gitbook: 'Спонсируется GitBook', switch_to_dark_theme: 'Переключиться на тёмную тему', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 4f96e247e..5021702f7 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -2,6 +2,8 @@ import type { TranslationLanguage } from './types'; export const zh: TranslationLanguage = { locale: 'zh', + language: '中文', + flag: '🇨🇳', powered_by_gitbook: '由 GitBook 提供支持', sponsored_via_gitbook: '通过 GitBook 赞助', switch_to_dark_theme: '切换到深色主题',