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/.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/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/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/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/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, - }; -} 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/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]); } 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: '切换到深色主题', 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]); } /**