From 3aea3026488f7d3ce354a30c8a2b6c3addd14768 Mon Sep 17 00:00:00 2001
From: Viktor Renkema <49148610+viktorrenkema@users.noreply.github.com>
Date: Thu, 30 Oct 2025 10:24:22 +0100
Subject: [PATCH 1/4] Revert "Support cover image heights and positioning
changes" (#3771)
---
packages/gitbook/e2e/customers.spec.ts | 10 +-
packages/gitbook/e2e/internal.spec.ts | 16 +--
packages/gitbook/e2e/util.ts | 7 --
.../src/components/PageBody/PageCover.tsx | 8 --
.../components/PageBody/PageCoverImage.tsx | 43 ++++---
.../src/components/PageBody/coverHeight.ts | 25 ----
.../gitbook/src/components/PageBody/index.ts | 1 -
.../components/PageBody/useCoverPosition.ts | 109 ------------------
8 files changed, 37 insertions(+), 182 deletions(-)
delete mode 100644 packages/gitbook/src/components/PageBody/coverHeight.ts
delete mode 100644 packages/gitbook/src/components/PageBody/useCoverPosition.ts
diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts
index 3d79d0984..f0e18037b 100644
--- a/packages/gitbook/e2e/customers.spec.ts
+++ b/packages/gitbook/e2e/customers.spec.ts
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
- {
- name: 'docs.gradient.network',
- contentBaseURL: 'https://docs.gradient.network',
- tests: [{ name: 'Home', url: '/' }],
- },
+ // {
+ // name: 'docs.gradient.network',
+ // contentBaseURL: 'https://docs.gradient.network',
+ // tests: [{ name: 'Home', url: '/' }],
+ // },
// {
// name: 'mygate-network.gitbook.io',
// contentBaseURL: 'https://mygate-network.gitbook.io',
diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts
index 8ea1a361a..34088e136 100644
--- a/packages/gitbook/e2e/internal.spec.ts
+++ b/packages/gitbook/e2e/internal.spec.ts
@@ -33,7 +33,6 @@ import {
headerLinks,
runTestCases,
waitForCookiesDialog,
- waitForCoverImages,
waitForNotFound,
} from './util';
@@ -907,10 +906,7 @@ const testCases: TestsCase[] = [
{
name: 'With cover',
url: 'page-options/page-with-cover',
- run: async (page) => {
- await waitForCookiesDialog(page);
- await waitForCoverImages(page);
- },
+ run: waitForCookiesDialog,
},
{
name: 'With cover for dark mode',
@@ -925,18 +921,12 @@ const testCases: TestsCase[] = [
{
name: 'With hero cover',
url: 'page-options/page-with-hero-cover',
- run: async (page) => {
- await waitForCookiesDialog(page);
- await waitForCoverImages(page);
- },
+ run: waitForCookiesDialog,
},
{
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
- run: async (page) => {
- await waitForCookiesDialog(page);
- await waitForCoverImages(page);
- },
+ run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts
index 6b62dea8c..c2b3a9300 100644
--- a/packages/gitbook/e2e/util.ts
+++ b/packages/gitbook/e2e/util.ts
@@ -154,13 +154,6 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
-export async function waitForCoverImages(page: Page) {
- // Wait for cover images to exist (not the shimmer placeholder)
- await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
- timeout: 10_000,
- });
-}
-
/**
* Transform test cases into Playwright tests and run it.
*/
diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx
index 947e09280..062331051 100644
--- a/packages/gitbook/src/components/PageBody/PageCover.tsx
+++ b/packages/gitbook/src/components/PageBody/PageCover.tsx
@@ -8,7 +8,6 @@ import { tcls } from '@/lib/tailwind';
import { assert } from 'ts-essentials';
import { PageCoverImage } from './PageCoverImage';
-import { getCoverHeight } from './coverHeight';
import defaultPageCoverSVG from './default-page-cover.svg';
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
@@ -23,12 +22,6 @@ export async function PageCover(props: {
context: GitBookSiteContext;
}) {
const { as, page, cover, context } = props;
- const height = getCoverHeight(cover);
-
- if (!height) {
- return null;
- }
-
const [resolved, resolvedDark] = await Promise.all([
cover.ref ? resolveContentRef(cover.ref, context) : null,
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
@@ -85,7 +78,6 @@ export async function PageCover(props: {
- );
- }
+function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) {
+ // When the size of the image hasn't been determined, we fallback to the center position
+ if (!img.size || y === 0) return '50%';
+ const ratio =
+ container.height && container.width
+ ? Math.max(container.width / img.size.width, container.height / img.size.height)
+ : 1;
+ const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height;
+ const top =
+ container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y;
+ return `${top}px`;
+}
+
+export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
+ const containerRef = useRef(null);
+
+ const container = useResizeObserver({
+ // @ts-expect-error wrong types
+ ref: containerRef,
+ });
return (
@@ -36,9 +49,10 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
sizes={imgs.light.sizes}
fetchPriority="high"
alt="Page cover"
- className={tcls('h-full', 'w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
+ className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
style={{
- objectPosition: `50% ${objectPositionY}%`,
+ aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
+ objectPosition: `50% ${getTop(container, y, imgs.light)}`,
}}
/>
{imgs.dark && (
@@ -48,9 +62,10 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
sizes={imgs.dark.sizes}
fetchPriority="low"
alt="Page cover"
- className={tcls('h-full', 'w-full', 'object-cover', 'dark:inline', 'hidden')}
+ className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
style={{
- objectPosition: `50% ${objectPositionY}%`,
+ aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
+ objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
}}
/>
)}
diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts
deleted file mode 100644
index e4f227770..000000000
--- a/packages/gitbook/src/components/PageBody/coverHeight.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { RevisionPageDocumentCover } from '@gitbook/api';
-
-export const DEFAULT_COVER_HEIGHT = 240;
-export const MIN_COVER_HEIGHT = 10;
-export const MAX_COVER_HEIGHT = 700;
-
-// Normalize and clamp the cover height between the minimum and maximum heights
-function clampCoverHeight(height: number | null | undefined): number {
- if (typeof height !== 'number' || Number.isNaN(height)) {
- return DEFAULT_COVER_HEIGHT;
- }
-
- return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height));
-}
-
-export function getCoverHeight(
- cover: RevisionPageDocumentCover | null | undefined
-): number | undefined {
- // Cover (and thus height) is not defined
- if (!cover) {
- return undefined;
- }
-
- return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT);
-}
diff --git a/packages/gitbook/src/components/PageBody/index.ts b/packages/gitbook/src/components/PageBody/index.ts
index 74b91d616..651c1bdb0 100644
--- a/packages/gitbook/src/components/PageBody/index.ts
+++ b/packages/gitbook/src/components/PageBody/index.ts
@@ -1,3 +1,2 @@
export * from './PageBody';
export * from './PageCover';
-export * from './useCoverPosition';
diff --git a/packages/gitbook/src/components/PageBody/useCoverPosition.ts b/packages/gitbook/src/components/PageBody/useCoverPosition.ts
deleted file mode 100644
index 7d749f48b..000000000
--- a/packages/gitbook/src/components/PageBody/useCoverPosition.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-'use client';
-import { useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { useResizeObserver } from 'usehooks-ts';
-
-interface ImageSize {
- width: number;
- height: number;
-}
-
-interface ImageAttributes {
- src: string;
- srcSet?: string;
- sizes?: string;
- width?: number;
- height?: number;
- size?: ImageSize;
-}
-
-interface Images {
- light: ImageAttributes;
- dark?: ImageAttributes;
-}
-
-/**
- * Hook to calculate the object position Y percentage for a cover image
- * based on the y offset, image dimensions, and container dimensions.
- */
-export function useCoverPosition(imgs: Images, y: number) {
- const containerRef = useRef(null);
- const [loadedDimensions, setLoadedDimensions] = useState(null);
- const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
-
- const container = useResizeObserver({
- // @ts-expect-error wrong types
- ref: containerRef,
- });
-
- // Load original image dimensions if not provided in `imgs`
- useLayoutEffect(() => {
- // Check if we have dimensions from either light or dark image
- const hasDimensions = imgs.light.size || imgs.dark?.size;
-
- if (hasDimensions) {
- return; // Already have dimensions
- }
-
- setIsLoading(true);
-
- // Load the original image (using src, not srcSet) to get true dimensions
- // Use dark image if available, otherwise fall back to light
- const imageToLoad = imgs.dark || imgs.light;
- const img = new Image();
- img.onload = () => {
- setLoadedDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- setIsLoading(false);
- };
- img.onerror = () => {
- // If image fails to load, use a fallback
- setIsLoading(false);
- };
- img.src = imageToLoad.src;
- }, [imgs.light, imgs.dark]);
-
- // Use provided dimensions or fall back to loaded dimensions
- // Check light first, then dark, then loaded dimensions
- const imageDimensions = imgs.light.size ?? imgs.dark?.size ?? loadedDimensions;
-
- // Calculate ratio and dimensions similar to useCoverPosition hook
- const ratio =
- imageDimensions && container.height && container.width
- ? Math.max(
- container.width / imageDimensions.width,
- container.height / imageDimensions.height
- )
- : 1;
- const safeRatio = ratio || 1;
-
- const scaledHeight =
- imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
- const maxOffset =
- scaledHeight && container.height
- ? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
- : 0;
-
- // Parse the position between the allowed min/max
- const objectPositionY = useMemo(() => {
- if (!container.height || !imageDimensions) {
- return 50;
- }
-
- const scaled = imageDimensions.height * safeRatio;
- if (scaled <= container.height || maxOffset === 0) {
- return 50;
- }
-
- const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
- const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
- return relative * 100;
- }, [container.height, imageDimensions, maxOffset, safeRatio, y]);
-
- return {
- containerRef,
- objectPositionY,
- isLoading: !imageDimensions || isLoading,
- };
-}
From 3e548e41863efa569bea8ed2d4e11dfa928bdbaf Mon Sep 17 00:00:00 2001
From: conico974
Date: Thu, 30 Oct 2025 14:33:26 +0100
Subject: [PATCH 2/4] Initialize hash on mount and improve navigation handling
(#3774)
---
packages/gitbook/src/components/hooks/useHash.tsx | 7 +++++--
packages/gitbook/src/components/hooks/useScrollPage.ts | 6 ++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/packages/gitbook/src/components/hooks/useHash.tsx b/packages/gitbook/src/components/hooks/useHash.tsx
index aff0358e6..fecbb9d7e 100644
--- a/packages/gitbook/src/components/hooks/useHash.tsx
+++ b/packages/gitbook/src/components/hooks/useHash.tsx
@@ -51,6 +51,8 @@ export const NavigationStatusProvider: React.FC = ({ ch
// Cleanup timeout on unmount
React.useEffect(() => {
+ // Initialize hash on mount - It could be null on SSR rehydration
+ setHash(getHash());
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
@@ -73,7 +75,8 @@ export const NavigationStatusProvider: React.FC = ({ ch
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
- if (pathnameRef.current !== url.pathname) {
+ // We don't want to set isNavigating for same page hash navigation
+ if (pathnameRef.current !== url.pathname && !href.startsWith('#')) {
timeoutRef.current = window.setTimeout(() => {
setIsNavigating(true);
timeoutRef.current = null;
@@ -94,7 +97,7 @@ export const NavigationStatusProvider: React.FC = ({ ch
};
/**
- * Hook to get the current hash from the URL.
+ * Hook to get the current hash from the URL. The hash is set on navigation clicks **NOT** on hashchange events or on navigation end.
* @see https://github.com/vercel/next.js/discussions/49465
* We use a different hack than this one, because for same page link it don't work
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
diff --git a/packages/gitbook/src/components/hooks/useScrollPage.ts b/packages/gitbook/src/components/hooks/useScrollPage.ts
index d56619502..a63159fb5 100644
--- a/packages/gitbook/src/components/hooks/useScrollPage.ts
+++ b/packages/gitbook/src/components/hooks/useScrollPage.ts
@@ -1,6 +1,5 @@
'use client';
-import { usePathname } from 'next/navigation';
import React from 'react';
import { useHash } from './useHash';
@@ -13,8 +12,7 @@ import { usePrevious } from './usePrevious';
export function useScrollPage() {
const hash = useHash();
const previousHash = usePrevious(hash);
- const pathname = usePathname();
- // biome-ignore lint/correctness/useExhaustiveDependencies: pathname should trigger it.
+
React.useEffect(() => {
if (hash) {
if (previousHash !== undefined && previousHash !== hash) {
@@ -30,5 +28,5 @@ export function useScrollPage() {
}
window.scrollTo(0, 0);
- }, [hash, previousHash, pathname]);
+ }, [hash, previousHash]);
}
From 55c0b03a4126c2b62dda8d7602f0875fb8a730a6 Mon Sep 17 00:00:00 2001
From: Zeno Kapitein
Date: Thu, 30 Oct 2025 15:03:50 +0100
Subject: [PATCH 3/4] 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: '切换到深色主题',
From 2c3066e3ae128cc1c37e7af738f6a4bad7d928b3 Mon Sep 17 00:00:00 2001
From: "Nolann B." <100787331+nolannbiron@users.noreply.github.com>
Date: Thu, 30 Oct 2025 15:46:29 +0100
Subject: [PATCH 4/4] Improve OAuth2 scopes handling in OpenAPI (#3775)
---
.changeset/sunny-cobras-feel.md | 6 +++
.../components/DocumentView/OpenAPI/style.css | 40 ++++++++++---------
.../react-openapi/src/OpenAPISecurities.tsx | 7 ++--
.../src/resolveOpenAPIOperation.ts | 19 ++++++---
4 files changed, 45 insertions(+), 27 deletions(-)
create mode 100644 .changeset/sunny-cobras-feel.md
diff --git a/.changeset/sunny-cobras-feel.md b/.changeset/sunny-cobras-feel.md
new file mode 100644
index 000000000..6d94fc5e9
--- /dev/null
+++ b/.changeset/sunny-cobras-feel.md
@@ -0,0 +1,6 @@
+---
+'@gitbook/react-openapi': patch
+'gitbook': patch
+---
+
+Improve OAuth2 scopes handling in OpenAPI
diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
index 5fac6a4f3..521a1a6c4 100644
--- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
+++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css
@@ -34,7 +34,7 @@
.openapi-deprecated,
.openapi-stability {
- @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -72,7 +72,7 @@
}
.openapi-markdown code {
- @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -95,7 +95,7 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
- @apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
+ @apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get,
@@ -270,11 +270,11 @@
}
.openapi-schema-enum-value:first-child {
- @apply rounded-l ml-0;
+ @apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
}
.openapi-schema-enum-value:last-child {
- @apply rounded-r;
+ @apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
}
/* Schema Description */
@@ -308,7 +308,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
- @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
+ @apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -325,6 +325,10 @@
@apply prose *:!prose-sm *:text-tint;
}
+.openapi-securities-oauth-content {
+ @apply flex flex-col gap-1 mt-1;
+}
+
.openapi-securities-oauth-content.openapi-markdown code {
@apply text-xs;
}
@@ -334,7 +338,7 @@
}
.openapi-securities-url {
- @apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors;
+ @apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -478,7 +482,7 @@
}
.openapi-path-variable {
- @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:content-none! after:!content-none;
+ @apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
@@ -601,8 +605,8 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
- @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded leading-none;
- @apply hover:bg-tint-hover transition-all;
+ @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
+ @apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
.openapi-select:not(.openapi-select-unstyled) > button {
@@ -634,7 +638,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
- @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
+ @apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -647,7 +651,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
- @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded !outline-none;
+ @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
@@ -743,7 +747,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
- @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded;
+ @apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -814,12 +818,12 @@ body:has(.openapi-select-popover) {
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
- @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
+ @apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
.openapi-schemas-disclosure .openapi-disclosure-panel {
- @apply straight-corners:!rounded-none;
+ @apply straight-corners:!rounded-none circular-corners:!rounded-md;
}
.openapi-disclosure-panel {
@@ -847,7 +851,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
- @apply rounded-xl;
+ @apply rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -997,8 +1001,8 @@ body:has(.openapi-select-popover) {
}
.openapi-path-copy-button {
- @apply p-1 flex rounded-md;
- @apply hover:bg-tint;
+ @apply p-1 flex rounded-md straight-corners:rounded-none;
+ @apply hover:bg-tint dark:hover:bg-tint-hover;
}
.openapi-path-copy-button-icon {
diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx
index 72d27bd38..f4808e69c 100644
--- a/packages/react-openapi/src/OpenAPISecurities.tsx
+++ b/packages/react-openapi/src/OpenAPISecurities.tsx
@@ -138,7 +138,7 @@ function getLabelForType(security: OpenAPICustomSecurityScheme, context: OpenAPI
function OpenAPISchemaOAuth2Flows(props: {
context: OpenAPIClientContext;
- security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean };
+ security: OpenAPICustomSecurityScheme & { flows?: OpenAPIV3.OAuth2SecurityScheme['flows'] };
}) {
const { context, security } = props;
@@ -167,7 +167,7 @@ function OpenAPISchemaOAuth2Item(props: {
>];
name: string;
context: OpenAPIClientContext;
- security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean };
+ security: OpenAPICustomSecurityScheme & { flows?: OpenAPIV3.OAuth2SecurityScheme['flows'] };
}) {
const { flow, context, security, name } = props;
@@ -175,7 +175,8 @@ function OpenAPISchemaOAuth2Item(props: {
return null;
}
- const scopes = flow.scopes ? Object.entries(flow.scopes) : [];
+ // If the security scheme has scopes, we don't need to display the scopes from the flow
+ const scopes = !security.scopes?.length && flow.scopes ? Object.entries(flow.scopes) : [];
return (
diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts
index 2711fb9d1..9a7c1567d 100644
--- a/packages/react-openapi/src/resolveOpenAPIOperation.ts
+++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts
@@ -158,15 +158,22 @@ function resolveSecurityScopes({
securityScheme?: OpenAPIV3.ReferenceObject | OpenAPIV3.SecuritySchemeObject;
operationScopes?: string[];
}): OpenAPISecurityScope[] | null {
- if (
- !securityScheme ||
- checkIsReference(securityScheme) ||
- isOAuthSecurityScheme(securityScheme)
- ) {
+ if (!operationScopes?.length || !securityScheme || checkIsReference(securityScheme)) {
return null;
}
- return operationScopes?.map((scope) => [scope, undefined]) || [];
+ // If the security scheme is an OAuth or OpenID Connect security scheme, we first check if the operation scopes are defined in the security scheme
+ if (isOAuthSecurityScheme(securityScheme)) {
+ const flows = securityScheme.flows ? Object.entries(securityScheme.flows) : [];
+
+ return flows.flatMap(([_, flow]) => {
+ return Object.entries(flow.scopes ?? {}).filter(([scope]) =>
+ operationScopes.includes(scope)
+ );
+ });
+ }
+
+ return operationScopes.map((scope) => [scope, undefined]);
}
/**