Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-hairs-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Support translations and generic variants together
34 changes: 23 additions & 11 deletions packages/gitbook/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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'
)}
Expand Down Expand Up @@ -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) =>
Expand All @@ -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 ? (
<>
Expand All @@ -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}
Expand All @@ -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}
Expand Down
149 changes: 149 additions & 0 deletions packages/gitbook/src/components/SpaceLayout/SpaceLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
36 changes: 15 additions & 21 deletions packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 ||
Expand All @@ -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} />
Expand Down Expand Up @@ -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}
Expand All @@ -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) =>
Expand Down Expand Up @@ -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}
</>
}
/>
Expand Down
56 changes: 56 additions & 0 deletions packages/gitbook/src/components/SpaceLayout/categorizeVariants.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading