Support translations and generic variants together (#3772)

This commit is contained in:
Zeno Kapitein
2025-10-30 15:03:50 +01:00
committed by GitHub
parent 3e548e4186
commit 55c0b03a41
16 changed files with 270 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support translations and generic variants together
@@ -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: {
>
<SearchContainer
style={customization.styling.search}
withVariants={withVariants === 'generic'}
withVariants={variants.generic.length > 1}
withSiteVariants={
sections?.list.some(
(s) =>
@@ -150,7 +154,7 @@ export function Header(props: {
</div>
{customization.header.links.length > 0 ||
(!withSections && withVariants === 'translations') ? (
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.length > 0 ? (
<>
@@ -170,11 +174,15 @@ export function Header(props: {
/>
</>
) : null}
{!withSections && withVariants === 'translations' ? (
{!withSections && variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
siteSpace={
variants.translations.find(
(space) => 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 ? (
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
{withVariants === 'translations' ? (
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
siteSpace={
variants.translations.find(
(space) => space.id === siteSpace.id
) ?? siteSpace
}
siteSpaces={variants.translations}
className="my-2 ml-2 self-start"
/>
) : null}
@@ -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<SiteSpace['space'], 'language'>;
};
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<typeof categorizeVariants>[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' },
]);
});
});
@@ -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 (
<SpaceLayoutServerContext {...props}>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
@@ -165,11 +155,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
)}
>
<HeaderLogo context={context} />
{withVariants === 'translations' ? (
{variants.translations.length > 1 ? (
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
siteSpace={
variants.translations.find(
(space) => 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) {
<div className="flex gap-2">
<SearchContainer
style={CustomizationSearchStyle.Subtle}
withVariants={withVariants === 'generic'}
withVariants={variants.generic.length > 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 ? (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
siteSpaces={variants.generic}
className="w-full px-3 py-2"
/>
)}
) : null}
</>
}
/>
@@ -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,
};
}
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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: 'ダークテーマに切り替え',
@@ -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',
@@ -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',
@@ -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',
@@ -1,5 +1,7 @@
export const ru = {
locale: 'ru',
language: 'Русский',
flag: '🇷🇺',
powered_by_gitbook: 'Работает на GitBook',
sponsored_via_gitbook: 'Спонсируется GitBook',
switch_to_dark_theme: 'Переключиться на тёмную тему',
@@ -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: '切换到深色主题',