Skip to content

Commit 55c0b03

Browse files
authored
Support translations and generic variants together (#3772)
1 parent 3e548e4 commit 55c0b03

File tree

16 files changed

+270
-32
lines changed

16 files changed

+270
-32
lines changed

.changeset/rich-hairs-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Support translations and generic variants together

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
33
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
44
import { getSpaceLanguage, t } from '@/intl/server';
55
import { tcls } from '@/lib/tailwind';
6+
import type { SiteSpace } from '@gitbook/api';
67
import { SearchContainer } from '../Search';
78
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
89
import { HeaderLink } from './HeaderLink';
@@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown';
1819
export function Header(props: {
1920
context: GitBookSiteContext;
2021
withTopHeader?: boolean;
21-
withVariants?: 'generic' | 'translations';
22+
variants: {
23+
generic: SiteSpace[];
24+
translations: SiteSpace[];
25+
};
2226
}) {
23-
const { context, withTopHeader, withVariants } = props;
27+
const { context, withTopHeader, variants } = props;
2428
const { siteSpace, siteSpaces, sections, customization } = context;
2529

2630
const withSections = Boolean(
@@ -91,7 +95,7 @@ export function Header(props: {
9195
'theme-bold:text-header-link',
9296
'hover:bg-tint-hover',
9397
'hover:theme-bold:bg-header-link/3',
94-
withVariants === 'generic'
98+
variants.generic.length > 1
9599
? 'xl:hidden'
96100
: 'page-no-toc:hidden lg:hidden'
97101
)}
@@ -126,7 +130,7 @@ export function Header(props: {
126130
>
127131
<SearchContainer
128132
style={customization.styling.search}
129-
withVariants={withVariants === 'generic'}
133+
withVariants={variants.generic.length > 1}
130134
withSiteVariants={
131135
sections?.list.some(
132136
(s) =>
@@ -150,7 +154,7 @@ export function Header(props: {
150154
</div>
151155

152156
{customization.header.links.length > 0 ||
153-
(!withSections && withVariants === 'translations') ? (
157+
(!withSections && variants.translations.length > 1) ? (
154158
<HeaderLinks>
155159
{customization.header.links.length > 0 ? (
156160
<>
@@ -170,11 +174,15 @@ export function Header(props: {
170174
/>
171175
</>
172176
) : null}
173-
{!withSections && withVariants === 'translations' ? (
177+
{!withSections && variants.translations.length > 1 ? (
174178
<TranslationsDropdown
175179
context={context}
176-
siteSpace={siteSpace}
177-
siteSpaces={siteSpaces}
180+
siteSpace={
181+
variants.translations.find(
182+
(space) => space.id === siteSpace.id
183+
) ?? siteSpace
184+
}
185+
siteSpaces={variants.translations}
178186
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
179187
/>
180188
) : null}
@@ -187,11 +195,15 @@ export function Header(props: {
187195
{sections && withSections ? (
188196
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
189197
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
190-
{withVariants === 'translations' ? (
198+
{variants.translations.length > 1 ? (
191199
<TranslationsDropdown
192200
context={context}
193-
siteSpace={siteSpace}
194-
siteSpaces={siteSpaces}
201+
siteSpace={
202+
variants.translations.find(
203+
(space) => space.id === siteSpace.id
204+
) ?? siteSpace
205+
}
206+
siteSpaces={variants.translations}
195207
className="my-2 ml-2 self-start"
196208
/>
197209
) : null}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { languages } from '@/intl/translations';
3+
import { type SiteSpace, TranslationLanguage } from '@gitbook/api';
4+
import { categorizeVariants } from './categorizeVariants';
5+
6+
type FakeSiteSpace = {
7+
id: SiteSpace['id'];
8+
title: SiteSpace['title'];
9+
space: Pick<SiteSpace['space'], 'language'>;
10+
};
11+
12+
function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
13+
return {
14+
// Only the properties used by categorizeVariants are required for these tests
15+
siteSpace: current,
16+
siteSpaces: all,
17+
} as unknown as Parameters<typeof categorizeVariants>[0];
18+
}
19+
20+
const englishA = {
21+
id: 'en-a',
22+
title: 'Docs EN A',
23+
space: { language: TranslationLanguage.En },
24+
};
25+
const englishB = {
26+
id: 'en-b',
27+
title: 'Docs EN B',
28+
space: { language: TranslationLanguage.En },
29+
};
30+
const frenchA = {
31+
id: 'fr-a',
32+
title: 'Docs FR A',
33+
space: { language: TranslationLanguage.Fr },
34+
};
35+
const frenchB = {
36+
id: 'fr-b',
37+
title: 'Docs FR B',
38+
space: { language: TranslationLanguage.Fr },
39+
};
40+
const undefinedLanguage = {
41+
id: 'undefined',
42+
title: 'Docs in Undefined Language',
43+
space: { language: undefined },
44+
};
45+
const unsupportedLanguage = {
46+
id: 'unsupported',
47+
title: 'Docs in Unsupported Language',
48+
space: { language: 'xx' as TranslationLanguage },
49+
};
50+
51+
describe('categorizeVariants', () => {
52+
it('returns all spaces as generic and no translations for single-language sites', () => {
53+
const ctx = makeContext(englishA, [englishA, englishB]);
54+
55+
const result = categorizeVariants(ctx);
56+
57+
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
58+
expect(result.translations).toEqual([]);
59+
});
60+
61+
it('returns all spaces as generic and no translations for sites with 1 language and an undefined language', () => {
62+
const ctx = makeContext(englishA, [englishA, englishB, undefinedLanguage]);
63+
64+
const result = categorizeVariants(ctx);
65+
66+
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b', 'undefined']);
67+
expect(result.translations).toEqual([]);
68+
});
69+
70+
it('keeps one-per-language translations without remapping titles', () => {
71+
const ctx = makeContext(englishA, [englishA, frenchA]);
72+
73+
const result = categorizeVariants(ctx);
74+
75+
// Generic should only include current language variants when multi-language
76+
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
77+
78+
// With exactly 1 per language, translations length equals number of languages → no remap
79+
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
80+
{ id: 'en-a', title: 'Docs EN A' },
81+
{ id: 'fr-a', title: 'Docs FR A' },
82+
]);
83+
});
84+
85+
it('keeps one-per-language translations without remapping titles, including unsupported languages', () => {
86+
const ctx = makeContext(englishA, [englishA, unsupportedLanguage]);
87+
88+
const result = categorizeVariants(ctx);
89+
90+
// Generic should only include current language variants when multi-language
91+
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
92+
93+
// With exactly 1 per language, translations length equals number of languages → no remap
94+
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
95+
{ id: 'en-a', title: 'Docs EN A' },
96+
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
97+
]);
98+
});
99+
100+
it('keeps one-per-language translations when there are more than 1 language and an undefined language', () => {
101+
const ctx = makeContext(englishA, [englishA, frenchA, undefinedLanguage]);
102+
103+
const result = categorizeVariants(ctx);
104+
105+
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
106+
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
107+
{ id: 'en-a', title: 'Docs EN A' },
108+
{ id: 'fr-a', title: 'Docs FR A' },
109+
{ id: 'undefined', title: 'Docs in Undefined Language' },
110+
]);
111+
});
112+
113+
it('deduplicates to first space per language and maps titles to language names', () => {
114+
const ctx = makeContext(englishA, [englishA, englishB, frenchA, frenchB]);
115+
116+
const result = categorizeVariants(ctx);
117+
118+
// Generic includes all current-language variants when multi-language
119+
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
120+
121+
// Distinct languages are ['en','fr'] but initial translations had 4 → remap
122+
// After remap: first per language, with title set to language label
123+
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
124+
{ id: 'en-a', title: languages.en.language },
125+
{ id: 'fr-a', title: languages.fr.language },
126+
]);
127+
});
128+
129+
it('deduplicates to first space per language and maps titles to language names, and falls back to original title if no language is found', () => {
130+
const ctx = makeContext(englishA, [
131+
englishA,
132+
englishB,
133+
frenchA,
134+
frenchB,
135+
undefinedLanguage,
136+
unsupportedLanguage,
137+
]);
138+
139+
const result = categorizeVariants(ctx);
140+
141+
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
142+
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
143+
{ id: 'en-a', title: languages.en.language },
144+
{ id: 'fr-a', title: languages.fr.language },
145+
{ id: 'undefined', title: 'Docs in Undefined Language' },
146+
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
147+
]);
148+
});
149+
});

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ import { Footer } from '@/components/Footer';
1010
import { Header, HeaderLogo } from '@/components/Header';
1111
import { TableOfContents } from '@/components/TableOfContents';
1212
import { CONTAINER_STYLE } from '@/components/layout';
13-
import { tcls } from '@/lib/tailwind';
14-
15-
import { getSpaceLanguage } from '@/intl/server';
1613
import type { VisitorAuthClaims } from '@/lib/adaptive';
1714
import { GITBOOK_APP_URL } from '@/lib/env';
15+
import { tcls } from '@/lib/tailwind';
1816
import { AIChatProvider } from '../AI';
1917
import type { RenderAIMessageOptions } from '../AI';
2018
import { AIChat } from '../AIChat';
@@ -27,6 +25,7 @@ import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
2725
import { CurrentContentProvider } from '../hooks';
2826
import { NavigationLoader } from '../primitives/NavigationLoader';
2927
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
28+
import { categorizeVariants } from './categorizeVariants';
3029

3130
type SpaceLayoutProps = {
3231
context: GitBookSiteContext;
@@ -105,16 +104,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
105104
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
106105

107106
const withSections = Boolean(sections && sections.list.length > 1);
108-
109-
const currentLanguage = getSpaceLanguage(context);
110-
const withVariants: 'generic' | 'translations' | undefined =
111-
siteSpaces.length > 1
112-
? siteSpaces.some(
113-
(space) => space.space.language && space.space.language !== currentLanguage.locale
114-
)
115-
? 'translations'
116-
: 'generic'
117-
: undefined;
107+
const variants = categorizeVariants(context);
118108

119109
const withFooter =
120110
customization.themes.toggeable ||
@@ -125,7 +115,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
125115
return (
126116
<SpaceLayoutServerContext {...props}>
127117
<Announcement context={context} />
128-
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
118+
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
129119
<NavigationLoader />
130120
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
131121
<AIChat trademark={customization.trademark.enabled} />
@@ -165,11 +155,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
165155
)}
166156
>
167157
<HeaderLogo context={context} />
168-
{withVariants === 'translations' ? (
158+
{variants.translations.length > 1 ? (
169159
<TranslationsDropdown
170160
context={context}
171-
siteSpace={siteSpace}
172-
siteSpaces={siteSpaces}
161+
siteSpace={
162+
variants.translations.find(
163+
(space) => space.id === siteSpace.id
164+
) ?? siteSpace
165+
}
166+
siteSpaces={variants.translations}
173167
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
174168
/>
175169
) : null}
@@ -183,7 +177,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
183177
<div className="flex gap-2">
184178
<SearchContainer
185179
style={CustomizationSearchStyle.Subtle}
186-
withVariants={withVariants === 'generic'}
180+
withVariants={variants.generic.length > 1}
187181
withSiteVariants={
188182
sections?.list.some(
189183
(s) =>
@@ -213,14 +207,14 @@ export function SpaceLayout(props: SpaceLayoutProps) {
213207
sections={encodeClientSiteSections(context, sections)}
214208
/>
215209
)}
216-
{withVariants === 'generic' && (
210+
{variants.generic.length > 1 ? (
217211
<SpacesDropdown
218212
context={context}
219213
siteSpace={siteSpace}
220-
siteSpaces={siteSpaces}
214+
siteSpaces={variants.generic}
221215
className="w-full px-3 py-2"
222216
/>
223-
)}
217+
) : null}
224218
</>
225219
}
226220
/>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { languages } from '@/intl/translations';
2+
import type { GitBookSiteContext } from '@/lib/context';
3+
4+
/**
5+
* Categorize the variants of the space into generic and translation variants.
6+
*/
7+
export function categorizeVariants(context: GitBookSiteContext) {
8+
const { siteSpace, siteSpaces } = context;
9+
const currentLanguage = siteSpace.space.language;
10+
11+
// Get all languages of the variants.
12+
const variantLanguages = [...new Set(siteSpaces.map((space) => space.space.language))];
13+
14+
// We only show the language picker if there are at least 2 distinct languages, excluding undefined.
15+
const isMultiLanguage =
16+
variantLanguages.filter((language) => language !== undefined).length > 1;
17+
18+
// Generic variants are all spaces that have the same language as the current (can also be undefined).
19+
const genericVariants = isMultiLanguage
20+
? siteSpaces.filter(
21+
(space) => space === siteSpace || space.space.language === currentLanguage
22+
)
23+
: siteSpaces;
24+
25+
// Translation variants are all spaces that have a different language than the current.
26+
let translationVariants = isMultiLanguage
27+
? siteSpaces.filter(
28+
(space) => space === siteSpace || space.space.language !== currentLanguage
29+
)
30+
: [];
31+
32+
// If there is exactly 1 variant per language, we will use them as-is.
33+
// Otherwise, we will create a translation dropdown with the first space of each language.
34+
if (variantLanguages.length !== translationVariants.length) {
35+
translationVariants = variantLanguages
36+
// Get the first space of each language.
37+
.map((variantLanguage) =>
38+
translationVariants.find((space) => space.space.language === variantLanguage)
39+
)
40+
// Filter out unmatched languages.
41+
.filter((space) => space !== undefined)
42+
// Transform the title to include the language name if we have a translation. Otherwise, use the original title.
43+
.map((space) => {
44+
const language = languages[space.space.language as keyof typeof languages];
45+
return {
46+
...space,
47+
title: language ? language.language : space.title,
48+
};
49+
});
50+
}
51+
52+
return {
53+
generic: genericVariants,
54+
translations: translationVariants,
55+
};
56+
}

0 commit comments

Comments
 (0)