mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 11:52:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8183c786c |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support translations and generic variants together
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve OAuth2 scopes handling in OpenAPI
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
@@ -906,7 +907,10 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With cover',
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover for dark mode',
|
||||
@@ -921,12 +925,18 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover and no TOC',
|
||||
url: 'page-options/page-with-cover-and-no-toc',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
screenshot: {
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
|
||||
@@ -154,6 +154,13 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -83,5 +83,9 @@ export function getOpenAPIContext(args: {
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
locale,
|
||||
hideSummaryAndDescription:
|
||||
'hideSummaryAndDescription' in block.data
|
||||
? Boolean(block.data.hideSummaryAndDescription)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 straight-corners:rounded-none circular-corners:rounded-sm 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 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 straight-corners:rounded-none circular-corners:rounded-md 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 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 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;
|
||||
@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;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
@@ -124,7 +124,8 @@
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace {
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
|
||||
@@ -270,11 +271,11 @@
|
||||
}
|
||||
|
||||
.openapi-schema-enum-value:first-child {
|
||||
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
|
||||
@apply rounded-l ml-0;
|
||||
}
|
||||
|
||||
.openapi-schema-enum-value:last-child {
|
||||
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
|
||||
@apply rounded-r;
|
||||
}
|
||||
|
||||
/* Schema Description */
|
||||
@@ -308,7 +309,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 straight-corners:rounded-none circular-corners:rounded-md 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 text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
/* Authentication */
|
||||
@@ -325,10 +326,6 @@
|
||||
@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;
|
||||
}
|
||||
@@ -338,7 +335,7 @@
|
||||
}
|
||||
|
||||
.openapi-securities-url {
|
||||
@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;
|
||||
@apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors;
|
||||
}
|
||||
|
||||
.openapi-securities-body {
|
||||
@@ -482,7 +479,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 straight-corners:rounded-none circular-corners:rounded-md 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 text-sm leading-none before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
.openapi-path-server {
|
||||
@@ -605,8 +602,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 straight-corners:rounded-none circular-corners:rounded-md leading-none;
|
||||
@apply hover:bg-tint dark: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 leading-none;
|
||||
@apply hover:bg-tint-hover transition-all;
|
||||
}
|
||||
|
||||
.openapi-select:not(.openapi-select-unstyled) > button {
|
||||
@@ -638,7 +635,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 straight-corners:rounded-none circular-corners:rounded-xl;
|
||||
@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 shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
|
||||
}
|
||||
|
||||
@@ -651,7 +648,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 straight-corners:rounded-none circular-corners:rounded-md !outline-none;
|
||||
@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 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;
|
||||
}
|
||||
|
||||
@@ -747,7 +744,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 straight-corners:rounded-none circular-corners:rounded-md;
|
||||
@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;
|
||||
}
|
||||
|
||||
.openapi-tabs-tab[aria-selected="true"] {
|
||||
@@ -818,12 +815,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 dark:hover:bg-tint-hover 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 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 circular-corners:!rounded-md;
|
||||
@apply straight-corners:!rounded-none;
|
||||
}
|
||||
|
||||
.openapi-disclosure-panel {
|
||||
@@ -851,7 +848,7 @@ body:has(.openapi-select-popover) {
|
||||
.openapi-schema-alternatives .openapi-disclosure,
|
||||
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
|
||||
) {
|
||||
@apply rounded-xl straight-corners:rounded-none;
|
||||
@apply rounded-xl;
|
||||
}
|
||||
|
||||
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
|
||||
@@ -1001,8 +998,8 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-path-copy-button {
|
||||
@apply p-1 flex rounded-md straight-corners:rounded-none;
|
||||
@apply hover:bg-tint dark:hover:bg-tint-hover;
|
||||
@apply p-1 flex rounded-md;
|
||||
@apply hover:bg-tint;
|
||||
}
|
||||
|
||||
.openapi-path-copy-button-icon {
|
||||
@@ -1015,4 +1012,8 @@ body:has(.openapi-select-popover) {
|
||||
|
||||
.openapi-path-copy-button-icon svg {
|
||||
@apply text-tint size-4;
|
||||
}
|
||||
|
||||
.openapi-operation-no-summary .openapi-column-preview {
|
||||
@apply pt-0;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -19,12 +18,9 @@ import { TranslationsDropdown } from './SpacesDropdown';
|
||||
export function Header(props: {
|
||||
context: GitBookSiteContext;
|
||||
withTopHeader?: boolean;
|
||||
variants: {
|
||||
generic: SiteSpace[];
|
||||
translations: SiteSpace[];
|
||||
};
|
||||
withVariants?: 'generic' | 'translations';
|
||||
}) {
|
||||
const { context, withTopHeader, variants } = props;
|
||||
const { context, withTopHeader, withVariants } = props;
|
||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||
|
||||
const withSections = Boolean(
|
||||
@@ -95,7 +91,7 @@ export function Header(props: {
|
||||
'theme-bold:text-header-link',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:theme-bold:bg-header-link/3',
|
||||
variants.generic.length > 1
|
||||
withVariants === 'generic'
|
||||
? 'xl:hidden'
|
||||
: 'page-no-toc:hidden lg:hidden'
|
||||
)}
|
||||
@@ -130,7 +126,7 @@ export function Header(props: {
|
||||
>
|
||||
<SearchContainer
|
||||
style={customization.styling.search}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
@@ -154,7 +150,7 @@ export function Header(props: {
|
||||
</div>
|
||||
|
||||
{customization.header.links.length > 0 ||
|
||||
(!withSections && variants.translations.length > 1) ? (
|
||||
(!withSections && withVariants === 'translations') ? (
|
||||
<HeaderLinks>
|
||||
{customization.header.links.length > 0 ? (
|
||||
<>
|
||||
@@ -174,15 +170,11 @@ export function Header(props: {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{!withSections && variants.translations.length > 1 ? (
|
||||
{!withSections && withVariants === 'translations' ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
|
||||
/>
|
||||
) : null}
|
||||
@@ -195,15 +187,11 @@ 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)}>
|
||||
{variants.translations.length > 1 ? (
|
||||
{withVariants === 'translations' ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className="my-2 ml-2 self-start"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -22,6 +23,12 @@ 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,
|
||||
@@ -78,6 +85,7 @@ export async function PageCover(props: {
|
||||
<div
|
||||
id="page-cover"
|
||||
data-full={String(as === 'full')}
|
||||
style={{ height }}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useRef } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import type { ImageSize } from '../utils';
|
||||
import { useCoverPosition } from './useCoverPosition';
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
@@ -18,28 +17,16 @@ interface Images {
|
||||
dark?: ImageAttributes;
|
||||
}
|
||||
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
|
||||
const container = useResizeObserver({
|
||||
// @ts-expect-error wrong types
|
||||
ref: containerRef,
|
||||
});
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
@@ -49,10 +36,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
sizes={imgs.light.sizes}
|
||||
fetchPriority="high"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
className={tcls('h-full', 'w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
@@ -62,10 +48,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
sizes={imgs.dark.sizes}
|
||||
fetchPriority="low"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
className={tcls('h-full', 'w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,6 +113,7 @@ export async function PageHeader(props: {
|
||||
>
|
||||
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
||||
{page.title}
|
||||
{'computed' in page && page.computed?.props.deprecated ? <span className="openapi-deprecated">Deprecated</span> : null}
|
||||
</h1>
|
||||
) : null}
|
||||
{page.description && page.layout.description ? (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './PageBody';
|
||||
export * from './PageCover';
|
||||
export * from './useCoverPosition';
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'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<HTMLDivElement>(null);
|
||||
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { languages } from '@/intl/translations';
|
||||
import { type SiteSpace, TranslationLanguage } from '@gitbook/api';
|
||||
import { categorizeVariants } from './categorizeVariants';
|
||||
|
||||
type FakeSiteSpace = {
|
||||
id: SiteSpace['id'];
|
||||
title: SiteSpace['title'];
|
||||
space: Pick<SiteSpace['space'], 'language'>;
|
||||
};
|
||||
|
||||
function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
|
||||
return {
|
||||
// Only the properties used by categorizeVariants are required for these tests
|
||||
siteSpace: current,
|
||||
siteSpaces: all,
|
||||
} as unknown as Parameters<typeof categorizeVariants>[0];
|
||||
}
|
||||
|
||||
const englishA = {
|
||||
id: 'en-a',
|
||||
title: 'Docs EN A',
|
||||
space: { language: TranslationLanguage.En },
|
||||
};
|
||||
const englishB = {
|
||||
id: 'en-b',
|
||||
title: 'Docs EN B',
|
||||
space: { language: TranslationLanguage.En },
|
||||
};
|
||||
const frenchA = {
|
||||
id: 'fr-a',
|
||||
title: 'Docs FR A',
|
||||
space: { language: TranslationLanguage.Fr },
|
||||
};
|
||||
const frenchB = {
|
||||
id: 'fr-b',
|
||||
title: 'Docs FR B',
|
||||
space: { language: TranslationLanguage.Fr },
|
||||
};
|
||||
const undefinedLanguage = {
|
||||
id: 'undefined',
|
||||
title: 'Docs in Undefined Language',
|
||||
space: { language: undefined },
|
||||
};
|
||||
const unsupportedLanguage = {
|
||||
id: 'unsupported',
|
||||
title: 'Docs in Unsupported Language',
|
||||
space: { language: 'xx' as TranslationLanguage },
|
||||
};
|
||||
|
||||
describe('categorizeVariants', () => {
|
||||
it('returns all spaces as generic and no translations for single-language sites', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
expect(result.translations).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all spaces as generic and no translations for sites with 1 language and an undefined language', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB, undefinedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b', 'undefined']);
|
||||
expect(result.translations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations without remapping titles', () => {
|
||||
const ctx = makeContext(englishA, [englishA, frenchA]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic should only include current language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
|
||||
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'fr-a', title: 'Docs FR A' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations without remapping titles, including unsupported languages', () => {
|
||||
const ctx = makeContext(englishA, [englishA, unsupportedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic should only include current language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
|
||||
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations when there are more than 1 language and an undefined language', () => {
|
||||
const ctx = makeContext(englishA, [englishA, frenchA, undefinedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'fr-a', title: 'Docs FR A' },
|
||||
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates to first space per language and maps titles to language names', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB, frenchA, frenchB]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic includes all current-language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
|
||||
// Distinct languages are ['en','fr'] but initial translations had 4 → remap
|
||||
// After remap: first per language, with title set to language label
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: languages.en.language },
|
||||
{ id: 'fr-a', title: languages.fr.language },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates to first space per language and maps titles to language names, and falls back to original title if no language is found', () => {
|
||||
const ctx = makeContext(englishA, [
|
||||
englishA,
|
||||
englishB,
|
||||
frenchA,
|
||||
frenchB,
|
||||
undefinedLanguage,
|
||||
unsupportedLanguage,
|
||||
]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: languages.en.language },
|
||||
{ id: 'fr-a', title: languages.fr.language },
|
||||
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,11 @@ 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';
|
||||
@@ -25,7 +27,6 @@ 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;
|
||||
@@ -104,7 +105,16 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 1);
|
||||
const variants = categorizeVariants(context);
|
||||
|
||||
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 withFooter =
|
||||
customization.themes.toggeable ||
|
||||
@@ -115,7 +125,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
return (
|
||||
<SpaceLayoutServerContext {...props}>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
|
||||
<NavigationLoader />
|
||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
||||
<AIChat trademark={customization.trademark.enabled} />
|
||||
@@ -155,15 +165,11 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{variants.translations.length > 1 ? (
|
||||
{withVariants === 'translations' ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
/>
|
||||
) : null}
|
||||
@@ -177,7 +183,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
@@ -207,14 +213,14 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
sections={encodeClientSiteSections(context, sections)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
{withVariants === 'generic' && (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={variants.generic}
|
||||
siteSpaces={siteSpaces}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -38,15 +38,38 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{page.emoji || page.icon ? (
|
||||
{page.computed?.props.method ? (
|
||||
<span>
|
||||
<MethodTag method={page.computed.props.method} />
|
||||
{page.title}
|
||||
</span>
|
||||
) : page.emoji || page.icon ? (
|
||||
<span className="flex items-center gap-3">
|
||||
<TOCPageIcon page={page} />
|
||||
{page.title}
|
||||
</span>
|
||||
) : (
|
||||
page.title
|
||||
)}
|
||||
) : page.title}
|
||||
</ToggleableLinkItem>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const MethodTag = ({ method }: { method: string }) => {
|
||||
|
||||
const formattedMethod = (() => {
|
||||
switch (method) {
|
||||
case 'delete':
|
||||
return 'DEL';
|
||||
case 'options':
|
||||
return 'OPT';
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<span className={`openapi-method openapi-method-${method} text-xs!`}>
|
||||
{formattedMethod}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { removeUndefined } from '@/lib/typescript';
|
||||
import type { ContentRef, RevisionPage } from '@gitbook/api';
|
||||
import type { ComputedContentSource, ContentRef, RevisionPage } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
export type ClientTOCPageLink = {
|
||||
@@ -24,6 +24,7 @@ export type ClientTOCPageDocument = {
|
||||
icon?: string;
|
||||
pathnames: string[];
|
||||
descendants?: ClientTOCPage[];
|
||||
computed?: ComputedContentSource
|
||||
};
|
||||
|
||||
export type ClientTOCPageGroup = {
|
||||
@@ -79,6 +80,7 @@ export async function encodeClientTableOfContents(
|
||||
pathnames: getPagePaths(rootPages, page),
|
||||
descendants,
|
||||
type: 'document',
|
||||
computed: 'computed' in page && page.computed ? page.computed : undefined,
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -51,8 +51,6 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ 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);
|
||||
@@ -75,8 +73,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
// We don't want to set isNavigating for same page hash navigation
|
||||
if (pathnameRef.current !== url.pathname && !href.startsWith('#')) {
|
||||
if (pathnameRef.current !== url.pathname) {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setIsNavigating(true);
|
||||
timeoutRef.current = null;
|
||||
@@ -97,7 +94,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the current hash from the URL. The hash is set on navigation clicks **NOT** on hashchange events or on navigation end.
|
||||
* Hook to get the current hash from the URL.
|
||||
* @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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useHash } from './useHash';
|
||||
@@ -12,7 +13,8 @@ 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) {
|
||||
@@ -28,5 +30,5 @@ export function useScrollPage() {
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}, [hash, previousHash]);
|
||||
}, [hash, previousHash, pathname]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const de = {
|
||||
locale: 'de',
|
||||
language: 'Deutsch',
|
||||
flag: '🇩🇪',
|
||||
powered_by_gitbook: 'Bereitgestellt von GitBook',
|
||||
sponsored_via_gitbook: 'Gesponsert von GitBook',
|
||||
switch_to_dark_theme: 'Zum dunklen Modus wechseln',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const en = {
|
||||
locale: 'en',
|
||||
language: 'English',
|
||||
flag: '🇺🇸',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
sponsored_via_gitbook: 'Sponsored via GitBook',
|
||||
switch_to_dark_theme: 'Switch to dark theme',
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { TranslationLanguage } from './types';
|
||||
|
||||
export const es: TranslationLanguage = {
|
||||
locale: 'es',
|
||||
language: 'Español',
|
||||
flag: '🇪🇸',
|
||||
powered_by_gitbook: 'Con tecnología de GitBook',
|
||||
sponsored_via_gitbook: 'Patrocinado por GitBook',
|
||||
switch_to_dark_theme: 'Cambiar a tema oscuro',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const fr = {
|
||||
locale: 'fr',
|
||||
language: 'Français',
|
||||
flag: '🇫🇷',
|
||||
powered_by_gitbook: 'Propulsé par GitBook',
|
||||
sponsored_via_gitbook: 'Sponsorisé via GitBook',
|
||||
switch_to_dark_theme: 'Activer le thème sombre',
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { TranslationLanguage } from './types';
|
||||
|
||||
export const it: TranslationLanguage = {
|
||||
locale: 'it',
|
||||
language: 'Italiano',
|
||||
flag: '🇮🇹',
|
||||
powered_by_gitbook: 'Offerto da GitBook',
|
||||
sponsored_via_gitbook: 'Sponsorizzato tramite GitBook',
|
||||
switch_to_dark_theme: 'Passa al tema scuro',
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { TranslationLanguage } from './types';
|
||||
|
||||
export const ja: TranslationLanguage = {
|
||||
locale: 'ja',
|
||||
language: '日本語',
|
||||
flag: '🇯🇵',
|
||||
powered_by_gitbook: 'GitBook提供',
|
||||
sponsored_via_gitbook: 'GitBookスポンサー',
|
||||
switch_to_dark_theme: 'ダークテーマに切り替え',
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { TranslationLanguage } from './types';
|
||||
|
||||
export const nl: TranslationLanguage = {
|
||||
locale: 'nl',
|
||||
language: 'Nederlands',
|
||||
flag: '🇳🇱',
|
||||
powered_by_gitbook: 'Powered by GitBook',
|
||||
sponsored_via_gitbook: 'Gesponsord door GitBook',
|
||||
switch_to_dark_theme: 'Schakel over naar donkere modus',
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { TranslationLanguage } from './types';
|
||||
|
||||
export const no: TranslationLanguage = {
|
||||
locale: 'no',
|
||||
language: 'Norsk',
|
||||
flag: '🇳🇴',
|
||||
powered_by_gitbook: 'Drevet av GitBook',
|
||||
sponsored_via_gitbook: 'Sponset av GitBook',
|
||||
switch_to_dark_theme: 'Bytt til mørkt tema',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const pt_br = {
|
||||
locale: 'pt-br',
|
||||
language: 'Português (Brasil)',
|
||||
flag: '🇧🇷',
|
||||
powered_by_gitbook: 'Fornecido por GitBook',
|
||||
sponsored_via_gitbook: 'Patrocinado por GitBook',
|
||||
switch_to_dark_theme: 'Mudar para modo escuro',
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const ru = {
|
||||
locale: 'ru',
|
||||
language: 'Русский',
|
||||
flag: '🇷🇺',
|
||||
powered_by_gitbook: 'Работает на GitBook',
|
||||
sponsored_via_gitbook: 'Спонсируется GitBook',
|
||||
switch_to_dark_theme: 'Переключиться на тёмную тему',
|
||||
|
||||
@@ -2,8 +2,6 @@ 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: '切换到深色主题',
|
||||
|
||||
@@ -414,13 +414,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return writeResponseCookies(response, cookies);
|
||||
};
|
||||
|
||||
if (siteRequestURL.pathname.endsWith('/~gitbook/mcp')) {
|
||||
const token = siteRequestURL.searchParams.get('apiToken');
|
||||
if (token) {
|
||||
return withAPIToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
// For https://preview/<siteURL> requests,
|
||||
if (siteRequestURL.hostname === 'preview') {
|
||||
// Do not track page views for preview requests
|
||||
|
||||
@@ -19,7 +19,13 @@ export function OpenAPIOperation(props: {
|
||||
const context = resolveOpenAPIContext(contextInput);
|
||||
|
||||
return (
|
||||
<div className={clsx('openapi-operation', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'openapi-operation',
|
||||
context.hideSummaryAndDescription && 'openapi-operation-no-summary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<OpenAPISummary data={data} context={context} />
|
||||
<div className="openapi-columns">
|
||||
<OpenAPIColumnSpec data={data} context={context} />
|
||||
|
||||
@@ -138,7 +138,7 @@ function getLabelForType(security: OpenAPICustomSecurityScheme, context: OpenAPI
|
||||
|
||||
function OpenAPISchemaOAuth2Flows(props: {
|
||||
context: OpenAPIClientContext;
|
||||
security: OpenAPICustomSecurityScheme & { flows?: OpenAPIV3.OAuth2SecurityScheme['flows'] };
|
||||
security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean };
|
||||
}) {
|
||||
const { context, security } = props;
|
||||
|
||||
@@ -167,7 +167,7 @@ function OpenAPISchemaOAuth2Item(props: {
|
||||
>];
|
||||
name: string;
|
||||
context: OpenAPIClientContext;
|
||||
security: OpenAPICustomSecurityScheme & { flows?: OpenAPIV3.OAuth2SecurityScheme['flows'] };
|
||||
security: OpenAPIV3.OAuth2SecurityScheme & { required?: boolean };
|
||||
}) {
|
||||
const { flow, context, security, name } = props;
|
||||
|
||||
@@ -175,8 +175,7 @@ function OpenAPISchemaOAuth2Item(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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) : [];
|
||||
const scopes = flow.scopes ? Object.entries(flow.scopes) : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function OpenAPIColumnSpec(props: {
|
||||
])}
|
||||
</div>
|
||||
) : null}
|
||||
<OpenAPIOperationDescription operation={operation} context={context} />
|
||||
{!context.hideSummaryAndDescription ? <OpenAPIOperationDescription operation={operation} context={context} /> : null}
|
||||
<OpenAPISpec data={data} context={clientContext} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,15 +24,18 @@ export function OpenAPISummary(props: {
|
||||
|
||||
return (
|
||||
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
|
||||
{(operation.deprecated || operation['x-stability']) && (
|
||||
{(operation.deprecated && !context.hideSummaryAndDescription) ||
|
||||
operation['x-stability'] ? (
|
||||
<div className="openapi-summary-tags">
|
||||
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
|
||||
{operation.deprecated && !context.hideSummaryAndDescription && (
|
||||
<div className="openapi-deprecated">Deprecated</div>
|
||||
)}
|
||||
{operation['x-stability'] && (
|
||||
<OpenAPIStability stability={operation['x-stability']} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{title
|
||||
) : null}
|
||||
{title && !context.hideSummaryAndDescription
|
||||
? context.renderHeading({
|
||||
deprecated: operation.deprecated ?? false,
|
||||
stability: operation['x-stability'],
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface OpenAPIContext extends Omit<OpenAPIClientContext, '$$isClientCo
|
||||
* Specification URL.
|
||||
*/
|
||||
specUrl: string;
|
||||
|
||||
/**
|
||||
* Whether to hide the summary and description of the operation.
|
||||
* @default false
|
||||
*/
|
||||
hideSummaryAndDescription?: boolean;
|
||||
}
|
||||
|
||||
export type OpenAPIUniversalContext = OpenAPIClientContext | OpenAPIContext;
|
||||
|
||||
@@ -158,22 +158,15 @@ function resolveSecurityScopes({
|
||||
securityScheme?: OpenAPIV3.ReferenceObject | OpenAPIV3.SecuritySchemeObject;
|
||||
operationScopes?: string[];
|
||||
}): OpenAPISecurityScope[] | null {
|
||||
if (!operationScopes?.length || !securityScheme || checkIsReference(securityScheme)) {
|
||||
if (
|
||||
!securityScheme ||
|
||||
checkIsReference(securityScheme) ||
|
||||
isOAuthSecurityScheme(securityScheme)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
return operationScopes?.map((scope) => [scope, undefined]) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user