mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-05 12:54:22 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9c139d032 | |||
| 25eceeb60e | |||
| 1eb763f9c5 | |||
| db176ba0ea | |||
| 03bbacf319 | |||
| 484cc11627 | |||
| 0dee4155a2 | |||
| 4d7c01587e | |||
| 6083a88845 | |||
| bf6a7af72b | |||
| 703e654a37 | |||
| 1b571aeaa9 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix vertically off-center icon next to breadcrumb text.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add missing link reference to OpenAPI models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add an "On this page" table of contents on OpenAPI models pages. Each model in a grouped/multi-model "Models" section is now listed as its own section, matching operations and webhooks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
|
||||
@@ -53,6 +53,7 @@ runs:
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
@@ -68,6 +69,8 @@ runs:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }}
|
||||
# Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default.
|
||||
GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload the DO worker
|
||||
|
||||
@@ -55,6 +55,7 @@ runs:
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
@@ -84,6 +85,8 @@ runs:
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
GITBOOK_RUNTIME: vercel
|
||||
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
|
||||
# Enable the sites OAuth consent screen everywhere but production, matching the OAuth server default.
|
||||
GITBOOK_SITE_OAUTH_CONSENT_ENABLED: ${{ inputs.environment != 'production' && 'true' || 'false' }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
shell: bash
|
||||
|
||||
@@ -282,11 +282,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://vimeo.com',
|
||||
tests: [{ name: 'Home', url: '/legal' }],
|
||||
},
|
||||
{
|
||||
name: 'help.platipomiru.com',
|
||||
contentBaseURL: 'https://help.platipomiru.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'help.aikido.dev',
|
||||
contentBaseURL: 'https://help.aikido.dev',
|
||||
|
||||
@@ -46,6 +46,8 @@ const nextConfig = {
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET,
|
||||
GITBOOK_SITE_OAUTH_CONSENT_ENABLED: process.env.GITBOOK_SITE_OAUTH_CONSENT_ENABLED,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout/CustomizationRootLayout';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
|
||||
/**
|
||||
* Layout for the sites OAuth consent screen.
|
||||
*/
|
||||
export default async function Layout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ params: Promise<RouteLayoutParams> }>) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout context={context} forcedTheme={forcedTheme}>
|
||||
{children}
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getDynamicSiteContext,
|
||||
getSiteURLDataFromParams,
|
||||
} from '@/app/utils';
|
||||
import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import {
|
||||
SiteOAuthConsentError,
|
||||
isSitesOAuthConsentEnabled,
|
||||
startSiteOAuthConsent,
|
||||
} from '@/lib/site-oauth';
|
||||
import { getVisitorToken } from '@/lib/visitors';
|
||||
|
||||
// The consent screen depends on the request (visitor, one-time interaction) and must never be cached.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type PageParams = RouteLayoutParams & { siteId: string };
|
||||
|
||||
/**
|
||||
* Render the sites OAuth consent screen for a post-login authorize resume.
|
||||
*/
|
||||
export default async function Page(props: {
|
||||
params: Promise<PageParams>;
|
||||
searchParams: Promise<{ gb_oauth_state?: string }>;
|
||||
}) {
|
||||
if (!isSitesOAuthConsentEnabled()) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const { siteId } = params;
|
||||
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const siteBasePath = withTrailingSlash(
|
||||
withLeadingSlash(getSiteURLDataFromParams(params).siteBasePath)
|
||||
);
|
||||
const authorizeURL = new URL(
|
||||
`${siteBasePath}~gitbook/oauth2/v1/${siteId}/authorize`,
|
||||
context.linker.toAbsoluteURL('/')
|
||||
);
|
||||
const visitorToken = getVisitorToken({
|
||||
cookies: (await cookies()).getAll(),
|
||||
headers: await headers(),
|
||||
url: authorizeURL,
|
||||
});
|
||||
const jwtToken = visitorToken?.token;
|
||||
const interactionId = searchParams.gb_oauth_state;
|
||||
|
||||
if (!interactionId || !jwtToken) {
|
||||
return <ConsentError />;
|
||||
}
|
||||
|
||||
try {
|
||||
const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken });
|
||||
return <ConsentScreen siteId={siteId} siteTitle={context.site.title} consent={consent} />;
|
||||
} catch (error) {
|
||||
if (error instanceof SiteOAuthConsentError) {
|
||||
return <ConsentError />;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -6,6 +6,7 @@ import { type GitBookLinker, createLinker } from '@/lib/links';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import type { WebframePageContext } from './adaptive';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
@@ -17,18 +18,20 @@ export type WebframeLinkerData = Pick<
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
|
||||
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
|
||||
* access them).
|
||||
* the current page, navigation to other pages, and adaptive visitor claims (only when the
|
||||
* integration is allowed to access them).
|
||||
*/
|
||||
export function ContentKitWithClientContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext> & {
|
||||
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
|
||||
canAccessVisitorClaims: boolean;
|
||||
/** Current page to inject into the webframe, or `null` when unknown. */
|
||||
page: WebframePageContext | null;
|
||||
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
|
||||
linkerData: WebframeLinkerData;
|
||||
}
|
||||
) {
|
||||
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
|
||||
const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
@@ -56,6 +59,7 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
getVisitorContext: canAccessVisitorClaims
|
||||
? () => ({ visitor: visitorClaims?.visitor ?? null })
|
||||
: undefined,
|
||||
getPageContext: page ? () => ({ page }) : undefined,
|
||||
navigate: ({ path, anchor }) => {
|
||||
// Resolve the requested path relative to the site root so a webframe can navigate
|
||||
// to any section or space within the site (and nowhere outside it).
|
||||
@@ -63,7 +67,7 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
navigateTo(linker.toPathInSite(path) + suffix);
|
||||
},
|
||||
}),
|
||||
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
|
||||
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
|
||||
);
|
||||
|
||||
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ContentKitWithClientContext,
|
||||
type WebframeLinkerData,
|
||||
} from './ContentKitWithClientContext';
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { fetchSafeIntegrationUI } from './render';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
@@ -77,8 +77,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||
// visitor claims when the integration is allowed them.
|
||||
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
|
||||
const page = getWebframePageContext(context.contentContext);
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
|
||||
// exposes the current page, plus visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
@@ -106,6 +109,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
page={page}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
|
||||
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
|
||||
const webframe: ContentKitWebFrame = {
|
||||
type: 'webframe',
|
||||
@@ -38,3 +40,47 @@ describe('integrationBlockContainsWebframe', () => {
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebframePageContext', () => {
|
||||
it('returns null when the context has no page', () => {
|
||||
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
|
||||
expect(getWebframePageContext(context)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the page path relative to the site root, including the section slug', () => {
|
||||
const context = {
|
||||
page: {
|
||||
id: 'page-1',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
slug: 'getting-started',
|
||||
},
|
||||
// Site served at /docs, with the page's space mounted under the `api` section.
|
||||
linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }),
|
||||
} as unknown as GitBookAnyContext;
|
||||
|
||||
expect(getWebframePageContext(context)).toEqual({
|
||||
id: 'page-1',
|
||||
path: 'api/guides/getting-started',
|
||||
title: 'Getting started',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the path unprefixed when the space is served at the site root', () => {
|
||||
const context = {
|
||||
page: {
|
||||
id: 'page-2',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
slug: 'getting-started',
|
||||
},
|
||||
linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }),
|
||||
} as unknown as GitBookAnyContext;
|
||||
|
||||
expect(getWebframePageContext(context)).toEqual({
|
||||
id: 'page-2',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type {
|
||||
ContentKitDescendantElement,
|
||||
ContentKitRenderOutput,
|
||||
@@ -7,9 +8,19 @@ import type {
|
||||
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Current page exposed to a webframe through the client-only webframe state.
|
||||
*/
|
||||
export type WebframePageContext = {
|
||||
id: string;
|
||||
/** Path of the page relative to the site root (includes the section and variant). */
|
||||
path: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an integration block's output contains a webframe that can consume client-only context
|
||||
* (navigation and/or visitor claims).
|
||||
* (navigation, visitor claims and/or the current page).
|
||||
*/
|
||||
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||
if (output.type === 'complete') {
|
||||
@@ -19,6 +30,31 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput)
|
||||
return doesContentKitElementContainWebframe(output.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current page to expose to a webframe, or `null` when it is unknown
|
||||
* (e.g. a non-page context, or reusable content resolved from another source).
|
||||
*
|
||||
* The exposed `path` is resolved relative to the site root — so it carries the section and
|
||||
* variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a
|
||||
* path. A webframe can pass `page.path` straight back to the navigate action.
|
||||
*/
|
||||
export function getWebframePageContext(
|
||||
contentContext: GitBookAnyContext
|
||||
): WebframePageContext | null {
|
||||
if (!('page' in contentContext) || !contentContext.page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { linker } = contentContext;
|
||||
const { id, path, title } = contentContext.page;
|
||||
|
||||
return {
|
||||
id,
|
||||
path: linker.toRelativePathInSite(linker.toPathInSpace(path)),
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a ContentKit element tree contains a webframe element.
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,7 @@ export function getOpenAPIContext(args: {
|
||||
check: <Icon icon="check" />,
|
||||
lock: <Icon icon="lock" />,
|
||||
mcp: <Icon icon="mcp" />,
|
||||
hashtag: <Icon icon="hashtag" />,
|
||||
},
|
||||
renderCodeBlock: (codeProps) => (
|
||||
<PlainCodeBlock
|
||||
|
||||
@@ -865,6 +865,30 @@ body:has(.openapi-select-popover) {
|
||||
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
|
||||
}
|
||||
|
||||
/* Model name + its hover hash-link, inline. Carries the anchor id / deep-link scroll target. */
|
||||
.openapi-schemas-model-title {
|
||||
@apply flex items-center gap-2 min-w-0;
|
||||
/* Offset by the trigger's p-5 so a deep-link lands the model's top edge, not its title, below the header. */
|
||||
scroll-margin-top: calc(var(--content-scroll-margin) + 1.25rem);
|
||||
}
|
||||
|
||||
.openapi-schemas-model-title-name {
|
||||
@apply min-w-0 truncate;
|
||||
}
|
||||
|
||||
.openapi-schemas-anchor-link {
|
||||
@apply shrink-0 flex items-center opacity-0 transition-opacity;
|
||||
}
|
||||
|
||||
.openapi-schemas-disclosure:hover .openapi-schemas-anchor-link,
|
||||
.openapi-schemas-anchor-link:focus-visible {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.openapi-schemas-anchor-link svg {
|
||||
@apply size-3 text-tint-subtle hover:text-tint-strong;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -965,7 +989,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
}
|
||||
|
||||
.openapi-disclosure-trigger[aria-expanded="true"] svg {
|
||||
.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg {
|
||||
@apply rotate-45;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ export function BreadcrumbItemDropdown(props: {
|
||||
const content = (
|
||||
<>
|
||||
{emoji || icon ? (
|
||||
<PageIcon page={{ emoji, icon }} style="mr-1 inline size-[1em] shrink-0" />
|
||||
<PageIcon
|
||||
page={{ emoji, icon }}
|
||||
style="mr-1 inline size-[1em] shrink-0 align-middle"
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
@@ -10,13 +11,16 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button, Popover } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { SearchFrame } from './SearchFrame';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchController } from './useSearchController';
|
||||
|
||||
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface SearchContainerProps extends SearchBaseProps {
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
@@ -113,8 +117,20 @@ export function SearchContainer({
|
||||
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
|
||||
const isSearchOpen = Boolean(visible && (state?.open ?? false));
|
||||
const shouldFillHeight = Boolean(query || showAsk);
|
||||
|
||||
// The SideSheet always renders its children (it hides them with CSS, unlike the desktop
|
||||
// Popover which mounts its content on open). Mounting the frame only after the first open
|
||||
// keeps the dynamic SearchFrame chunk off the mobile startup path, while leaving it mounted
|
||||
// afterwards so the sheet's exit animation isn't cut short.
|
||||
const [wasSearchOpened, setWasSearchOpened] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (isSearchOpen) {
|
||||
setWasSearchOpened(true);
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
const shouldShowSearchFrame = usesSideSheet
|
||||
? Boolean(state?.open || state?.query || withAI)
|
||||
? Boolean(state?.open || state?.query || wasSearchOpened)
|
||||
: Boolean(state?.query || withAI);
|
||||
const scopeControlNode =
|
||||
searchProps.withVariants || searchProps.withSections ? (
|
||||
|
||||
@@ -14,7 +14,7 @@ function localPage(id: string, title = id): LocalPageResult {
|
||||
};
|
||||
}
|
||||
|
||||
function remotePage(id: string, title = id): OrderedComputedResult {
|
||||
function remotePage(id: string, title = id, score = 0): OrderedComputedResult {
|
||||
return {
|
||||
type: 'page',
|
||||
id: `remote-${id}`,
|
||||
@@ -22,7 +22,7 @@ function remotePage(id: string, title = id): OrderedComputedResult {
|
||||
spaceId: 'space',
|
||||
title,
|
||||
href: `/${id}`,
|
||||
score: 0,
|
||||
score,
|
||||
breadcrumbs: [{ label: 'Remote' }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ function mergePinnedRemoteResult(
|
||||
|
||||
/**
|
||||
* Merge local (FlexSearch) and remote (API) search results using
|
||||
* Reciprocal Rank Fusion (RRF), while preserving the API order for the first
|
||||
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
|
||||
* three remote results.
|
||||
*
|
||||
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
|
||||
|
||||
@@ -37,7 +37,12 @@ export type ComputedRecordResult = BaseComputedResult & {
|
||||
|
||||
export type SearchSiteContentScope =
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| {
|
||||
mode: 'current';
|
||||
siteSpaceId: string;
|
||||
/** Restrict the search to the current site space alone, or to the other site spaces in the scope. */
|
||||
restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces';
|
||||
}
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
|
||||
export interface SearchSiteContentRequest {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
|
||||
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
|
||||
*
|
||||
* Loading strategy: the download starts as early as possible (server-rendered
|
||||
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
|
||||
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
|
||||
* consumer actually needs the pages.
|
||||
*/
|
||||
|
||||
export interface SiteIndexBreadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Raw entry from the `~gitbook/site-index` JSON response */
|
||||
export interface SiteIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
/** BCP-47 language code emitted by the index route, absent when no language is set. */
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: SiteIndexBreadcrumb[];
|
||||
}
|
||||
|
||||
let siteIndexText: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Start (or reuse) the single-flight download of the raw index. Errors clear the
|
||||
* cache so the next consumer retries.
|
||||
*/
|
||||
export function prefetchSiteIndex(indexURL: string): void {
|
||||
fetchSiteIndexText(indexURL).catch(() => {
|
||||
// Ignored: consumers surface errors when they actually read the index.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parsed index pages. Parses per call (cheap for the rare second
|
||||
* consumer) so the parsed object graph is never retained at module scope.
|
||||
*/
|
||||
export async function fetchSiteIndex(
|
||||
indexURL: string
|
||||
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
|
||||
return JSON.parse(await fetchSiteIndexText(indexURL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached raw text (several MB for large sites) once a consumer has
|
||||
* turned it into a longer-lived form. Purely a memory release: a later consumer
|
||||
* re-fetches, hitting the HTTP cache.
|
||||
*/
|
||||
export function releaseSiteIndex(): void {
|
||||
siteIndexText = null;
|
||||
}
|
||||
|
||||
function fetchSiteIndexText(indexURL: string): Promise<string> {
|
||||
if (!siteIndexText) {
|
||||
siteIndexText = fetch(indexURL).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
|
||||
siteIndexText.catch(() => {
|
||||
siteIndexText = null;
|
||||
});
|
||||
}
|
||||
|
||||
return siteIndexText;
|
||||
}
|
||||
@@ -1,27 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Document, type DocumentValue } from 'flexsearch';
|
||||
import type { Document, DocumentValue } from 'flexsearch';
|
||||
import React from 'react';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Raw entry from the `~gitbook/index` JSON response */
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
/** BCP-47 language code emitted by the index route, absent when no language is set. */
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
import {
|
||||
type SiteIndexBreadcrumb,
|
||||
type SiteIndexPage,
|
||||
fetchSiteIndex,
|
||||
prefetchSiteIndex,
|
||||
releaseSiteIndex,
|
||||
} from './site-index';
|
||||
|
||||
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
|
||||
interface IndexPage {
|
||||
@@ -41,7 +28,7 @@ export interface LocalPageResult {
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
breadcrumbs?: SiteIndexBreadcrumb[];
|
||||
}
|
||||
|
||||
type LocalSearchState = {
|
||||
@@ -58,13 +45,16 @@ const cachedIndexes = new Map<string, Document<IndexPage>>();
|
||||
// Keyed by page id, shared across all language groups.
|
||||
const cachedPageData = new Map<
|
||||
string,
|
||||
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] }
|
||||
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
|
||||
>();
|
||||
|
||||
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
||||
|
||||
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
|
||||
const index = new Document<IndexPage>({
|
||||
function buildLangIndex(
|
||||
DocumentCtor: typeof import('flexsearch').Document,
|
||||
pages: SiteIndexPage[]
|
||||
): Document<IndexPage> {
|
||||
const index = new DocumentCtor<IndexPage>({
|
||||
document: {
|
||||
id: 'id',
|
||||
index: ['title', 'description'],
|
||||
@@ -110,15 +100,14 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
}
|
||||
|
||||
pendingFetch = (async () => {
|
||||
const response = await fetch(indexURL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: { version: 1; pages: RawIndexPage[] } = await response.json();
|
||||
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
|
||||
const [{ Document }, data] = await Promise.all([
|
||||
import('flexsearch'),
|
||||
fetchSiteIndex(indexURL),
|
||||
]);
|
||||
|
||||
// Group pages by their `lang` value (empty string for pages without one)
|
||||
const pagesByLang = new Map<string, RawIndexPage[]>();
|
||||
const pagesByLang = new Map<string, SiteIndexPage[]>();
|
||||
for (const page of data.pages) {
|
||||
const key = page.lang ?? '';
|
||||
const bucket = pagesByLang.get(key);
|
||||
@@ -131,9 +120,11 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
|
||||
// Build one FlexSearch Document per language group
|
||||
for (const [lang, pages] of pagesByLang) {
|
||||
cachedIndexes.set(lang, buildLangIndex(pages));
|
||||
cachedIndexes.set(lang, buildLangIndex(Document, pages));
|
||||
}
|
||||
|
||||
releaseSiteIndex();
|
||||
|
||||
return cachedIndexes;
|
||||
})();
|
||||
|
||||
@@ -156,8 +147,10 @@ export function useLocalSearchResults(props: {
|
||||
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
||||
filterSiteSpaceIds?: string[];
|
||||
disabled?: boolean;
|
||||
/** Whether the search surface is open. */
|
||||
open: boolean;
|
||||
}): LocalSearchState {
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false } = props;
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
|
||||
|
||||
const [state, setState] = React.useState<LocalSearchState>({
|
||||
results: [],
|
||||
@@ -168,6 +161,8 @@ export function useLocalSearchResults(props: {
|
||||
// Track whether the indexes are loaded so the search effect re-runs after load
|
||||
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
|
||||
|
||||
const active = open || Boolean(query);
|
||||
|
||||
// Load the indexes once
|
||||
React.useEffect(() => {
|
||||
if (cachedIndexes.size > 0) {
|
||||
@@ -175,6 +170,12 @@ export function useLocalSearchResults(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchSiteIndex(indexURL);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
||||
|
||||
@@ -194,7 +195,7 @@ export function useLocalSearchResults(props: {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [indexURL]);
|
||||
}, [indexURL, active]);
|
||||
|
||||
// Perform instant local search whenever query, lang, or index readiness changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -200,6 +200,7 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(state?.query || withAI),
|
||||
open: Boolean(state?.open),
|
||||
query: normalizedQuery,
|
||||
siteSpaceId: siteSpace.id,
|
||||
siteSpaceIds,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createRecommendedQuestionResult,
|
||||
getEmptySearchResults,
|
||||
} from './empty-search-results';
|
||||
import type { OrderedComputedResult } from './search-types';
|
||||
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
|
||||
import { streamRecommendedQuestions } from './server-actions';
|
||||
|
||||
import { useAI } from '@/components/AI';
|
||||
@@ -28,6 +28,9 @@ export type ResultType =
|
||||
|
||||
export type { LocalPageResult, MergedPageResult };
|
||||
|
||||
// Score multiplier for current site space results when combined with those from other site spaces
|
||||
const CURRENT_SITE_SPACE_SCORE_MULTIPLIER = 2;
|
||||
|
||||
// Small helper extracted for unit testing of scope → local filter mapping
|
||||
// computeFilterSiteSpaceIds is imported from './filter' for testability
|
||||
|
||||
@@ -42,6 +45,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
|
||||
export function useSearchResults(props: {
|
||||
asEmbeddable?: boolean;
|
||||
disabled: boolean;
|
||||
/** Whether the search surface is open. Gates building the local search index. */
|
||||
open: boolean;
|
||||
query: string;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
@@ -49,7 +54,7 @@ export function useSearchResults(props: {
|
||||
suggestions?: string[];
|
||||
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
||||
searchURL: string;
|
||||
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */
|
||||
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
|
||||
indexURL: string;
|
||||
/** BCP-47 language code of the current site space, used to filter local search results. */
|
||||
lang?: string;
|
||||
@@ -59,6 +64,7 @@ export function useSearchResults(props: {
|
||||
const {
|
||||
asEmbeddable,
|
||||
disabled,
|
||||
open,
|
||||
query,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
@@ -82,14 +88,16 @@ export function useSearchResults(props: {
|
||||
indexURL,
|
||||
lang,
|
||||
disabled,
|
||||
open,
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
otherSpacesResults: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
}>({ results: [], otherSpacesResults: [], fetching: false, error: false });
|
||||
|
||||
// Track the current in-flight fetch so it can be aborted imperatively
|
||||
// when the user navigates away before the request completes.
|
||||
@@ -105,7 +113,12 @@ export function useSearchResults(props: {
|
||||
}
|
||||
if (!query) {
|
||||
if (!withAI) {
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,11 +129,21 @@ export function useSearchResults(props: {
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -133,7 +156,12 @@ export function useSearchResults(props: {
|
||||
suggestions.forEach((question) => {
|
||||
questions.add(question);
|
||||
});
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,7 +187,12 @@ export function useSearchResults(props: {
|
||||
|
||||
if (!cancelled) {
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -171,67 +204,121 @@ export function useSearchResults(props: {
|
||||
}
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await (() => {
|
||||
const fetchSearch = (
|
||||
scope: Parameters<typeof fetchSearchResults>[1]
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(
|
||||
searchURL,
|
||||
scope,
|
||||
query,
|
||||
abortController.signal,
|
||||
asEmbeddable
|
||||
);
|
||||
const fetchSearch = (
|
||||
scope: Parameters<typeof fetchSearchResults>[1]
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
|
||||
|
||||
try {
|
||||
// Each scope resolves to a primary search request and, for the default scope
|
||||
// on a multi-section site, a secondary request for the other site spaces
|
||||
const { resultsPromise, otherSpacesResultsPromise } = ((): {
|
||||
resultsPromise: Promise<OrderedComputedResult[]>;
|
||||
otherSpacesResultsPromise?: Promise<OrderedComputedResult[]>;
|
||||
} => {
|
||||
switch (scope) {
|
||||
case 'all':
|
||||
// Search all content on the site
|
||||
return fetchSearch({ mode: 'all' });
|
||||
return { resultsPromise: fetchSearch({ mode: 'all' }) };
|
||||
case 'default':
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return fetchSearch({ mode: 'current', siteSpaceId });
|
||||
// Search the current section's variant + matched/default variant for other sections.
|
||||
// Without sections, the scope resolves to the current site space alone, so a
|
||||
// second request restricted to the other site spaces would be redundant.
|
||||
if (!withSections) {
|
||||
return {
|
||||
resultsPromise: fetchSearch({ mode: 'current', siteSpaceId }),
|
||||
};
|
||||
}
|
||||
|
||||
// Split into two parallel requests so the (smaller, faster) current site
|
||||
// space results can be shown while the other site spaces are still being searched.
|
||||
return {
|
||||
resultsPromise: fetchSearch({
|
||||
mode: 'current',
|
||||
siteSpaceId,
|
||||
restrictTo: 'currentSiteSpace',
|
||||
}),
|
||||
otherSpacesResultsPromise: fetchSearch({
|
||||
mode: 'current',
|
||||
siteSpaceId,
|
||||
restrictTo: 'otherSiteSpaces',
|
||||
}),
|
||||
};
|
||||
case 'extended':
|
||||
// Search all variants of the current section
|
||||
return fetchSearch({ mode: 'specific', siteSpaceIds });
|
||||
return {
|
||||
resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }),
|
||||
};
|
||||
case 'current':
|
||||
// Search only the current section's current variant
|
||||
return fetchSearch({ mode: 'specific', siteSpaceIds: [siteSpaceId] });
|
||||
return {
|
||||
resultsPromise: fetchSearch({
|
||||
mode: 'specific',
|
||||
siteSpaceIds: [siteSpaceId],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
assertNever(scope);
|
||||
}
|
||||
})();
|
||||
|
||||
// Render each result set as soon as its response arrives; a failed
|
||||
// request reports an error without discarding the other result set.
|
||||
let tracked = false;
|
||||
const onResults =
|
||||
(key: 'results' | 'otherSpacesResults') =>
|
||||
(results: OrderedComputedResult[]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState((prev) => ({ ...prev, [key]: results }));
|
||||
|
||||
if (!tracked) {
|
||||
tracked = true;
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
}
|
||||
};
|
||||
const onError = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
resultsPromise.then(onResults('results'), onError),
|
||||
otherSpacesResultsPromise?.then(onResults('otherSpacesResults'), onError),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// One time when this one returns undefined is when it cannot find the server action and returns the html from the page.
|
||||
// In that case, we want to avoid being stuck in a loading state, but it is an error.
|
||||
// We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience.
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({ results, fetching: false, error: false });
|
||||
|
||||
trackEvent({
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
});
|
||||
setRemoteState((prev) => ({ ...prev, fetching: false }));
|
||||
} catch {
|
||||
// If there is an error, we need to catch it to avoid infinite loading state.
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
@@ -258,6 +345,7 @@ export function useSearchResults(props: {
|
||||
suggestions,
|
||||
searchURL,
|
||||
asEmbeddable,
|
||||
withSections,
|
||||
]);
|
||||
|
||||
const abort = React.useCallback(() => {
|
||||
@@ -284,10 +372,23 @@ export function useSearchResults(props: {
|
||||
});
|
||||
}
|
||||
|
||||
const merged = reciprocalRankFusion(localResults, remoteState.results, query);
|
||||
|
||||
return merged;
|
||||
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
|
||||
return reciprocalRankFusion(
|
||||
localResults,
|
||||
remoteState.otherSpacesResults.length > 0
|
||||
? combineRemoteResults(remoteState.results, remoteState.otherSpacesResults)
|
||||
: remoteState.results,
|
||||
query
|
||||
);
|
||||
}, [
|
||||
localResults,
|
||||
remoteState.results,
|
||||
remoteState.otherSpacesResults,
|
||||
query,
|
||||
withAI,
|
||||
siteSpaceId,
|
||||
suggestions,
|
||||
recentQueries,
|
||||
]);
|
||||
|
||||
return {
|
||||
results,
|
||||
@@ -302,10 +403,7 @@ export function useSearchResults(props: {
|
||||
*/
|
||||
async function fetchSearchResults(
|
||||
searchURL: string,
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] },
|
||||
scope: SearchSiteContentScope,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
asEmbeddable?: boolean
|
||||
@@ -327,3 +425,16 @@ async function fetchSearchResults(
|
||||
|
||||
return response.json() as Promise<OrderedComputedResult[]>;
|
||||
}
|
||||
|
||||
function combineRemoteResults(
|
||||
remoteResultsCurrentSpace: OrderedComputedResult[],
|
||||
remoteResultsOtherSpaces: OrderedComputedResult[]
|
||||
): OrderedComputedResult[] {
|
||||
return [
|
||||
...remoteResultsCurrentSpace.map((result) => ({
|
||||
...result,
|
||||
score: result.score * CURRENT_SITE_SPACE_SCORE_MULTIPLIER,
|
||||
})),
|
||||
...remoteResultsOtherSpaces,
|
||||
].sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,13 @@ export async function SiteLayout(props: {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
|
||||
// We also preload the site index
|
||||
// Start the search-index download from the HTML itself. `crossOrigin` must match the
|
||||
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
|
||||
// index downloads twice — the omission was exactly that bug before.
|
||||
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
|
||||
as: 'fetch',
|
||||
type: 'application/json',
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { Checkbox } from '@/components/primitives/Checkbox';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { type SubmitConsentInput, submitSiteOAuthConsent } from './actions';
|
||||
|
||||
/**
|
||||
* Site's OAuth consent form to present to the user the client's information requesting access to the site's MCP.
|
||||
*/
|
||||
export function ConsentForm(props: {
|
||||
siteId: string;
|
||||
consentSessionId: string;
|
||||
/** Whether the OAuth server recognizes the client as verified. */
|
||||
verified: boolean;
|
||||
}) {
|
||||
const { siteId, consentSessionId, verified } = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string>();
|
||||
const [trusted, setTrusted] = useState(false);
|
||||
|
||||
// Unverified clients can only be approved once the visitor explicitly acknowledges they trust
|
||||
// the app. The OAuth server re-checks this, so it can't be bypassed by tampering with the client.
|
||||
const canApprove = verified || trusted;
|
||||
|
||||
const decide = (decision: SubmitConsentInput['decision']) => {
|
||||
setError(undefined);
|
||||
startTransition(async () => {
|
||||
const result = await submitSiteOAuthConsent({
|
||||
siteId,
|
||||
consentSessionId,
|
||||
decision,
|
||||
trusted,
|
||||
});
|
||||
if ('redirectURL' in result) {
|
||||
// Full-page navigation to the client's (external) redirect URI.
|
||||
window.location.href = result.redirectURL;
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger-strong text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||
{verified ? (
|
||||
<span />
|
||||
) : (
|
||||
<label
|
||||
htmlFor="site-oauth-trusted"
|
||||
className="flex items-center gap-2 text-sm text-tint"
|
||||
>
|
||||
<Checkbox
|
||||
id="site-oauth-trusted"
|
||||
checked={trusted}
|
||||
onCheckedChange={(value) => setTrusted(value === true)}
|
||||
/>
|
||||
<span>I recognize and trust this client</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={tcls('ms-auto flex gap-2')}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="xmark"
|
||||
disabled={isPending}
|
||||
onClick={() => decide('deny')}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={isPending || !canApprove}
|
||||
onClick={() => decide('approve')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { StyledLink } from '@/components/primitives/StyledLink';
|
||||
import type { SiteOAuthConsentStart } from '@/lib/site-oauth';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ConsentForm } from './ConsentForm';
|
||||
|
||||
/**
|
||||
* Consent screen shown to a visitor when an MCP client requests authorization to a published site.
|
||||
*/
|
||||
export function ConsentScreen(props: {
|
||||
siteId: string;
|
||||
siteTitle: string;
|
||||
consent: SiteOAuthConsentStart;
|
||||
}) {
|
||||
const { siteId, siteTitle, consent } = props;
|
||||
const { client, redirectUri, consentSessionId } = consent;
|
||||
const redirectParts = parseRedirectURI(redirectUri);
|
||||
|
||||
return (
|
||||
<ConsentCard>
|
||||
<div className="flex flex-col gap-6 p-6 sm:p-8">
|
||||
{/* Client identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
{client.logoUri ? (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-corners:rounded-lg straight-corners:rounded-none object-contain"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-corners:rounded-lg straight-corners:rounded-none bg-tint-subtle text-tint">
|
||||
<Icon icon="key" className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="font-semibold text-tint-strong">{client.name}</h1>
|
||||
<ClientTrustBadge verified={client.verified} />
|
||||
</div>
|
||||
{client.uri ? (
|
||||
<StyledLink
|
||||
href={client.uri}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-tint"
|
||||
>
|
||||
Website
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</StyledLink>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request statement — toned down, with the client and site names emphasized. */}
|
||||
<p className="text-base text-tint leading-snug">
|
||||
<span className="font-semibold text-tint-strong">{client.name}</span> wants to
|
||||
access <span className="font-semibold text-tint-strong">{siteTitle} MCP</span>{' '}
|
||||
on your behalf.
|
||||
</p>
|
||||
|
||||
{/* Redirect URI, shown in full with the destination host emphasized. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-tint">
|
||||
After approving, an authorization code will be sent to:
|
||||
</span>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex items-center gap-2.5',
|
||||
'rounded-corners:rounded-md straight-corners:rounded-none',
|
||||
'border border-tint-subtle bg-tint-subtle px-3 py-2'
|
||||
)}
|
||||
>
|
||||
<Icon icon="link" className="size-4 shrink-0 text-tint" />
|
||||
<code className="break-all font-mono text-sm">
|
||||
{redirectParts ? (
|
||||
<>
|
||||
<span className="text-tint">{redirectParts.prefix}</span>
|
||||
<span className="font-semibold text-tint-strong">
|
||||
{redirectParts.host}
|
||||
</span>
|
||||
<span className="text-tint">{redirectParts.rest}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-tint-strong">{redirectUri}</span>
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.verified ? null : (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-3',
|
||||
'rounded-corners:rounded-md straight-corners:rounded-none',
|
||||
'bg-warning p-3 text-sm text-warning-strong'
|
||||
)}
|
||||
>
|
||||
<Icon icon="triangle-exclamation" className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
GitBook has not verified this client
|
||||
</span>
|
||||
<span>
|
||||
Only approve if you recognize this application and trust it with
|
||||
access to {siteTitle}.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: trust acknowledgement + decision */}
|
||||
<div className="border-tint-subtle border-t p-4 sm:px-8">
|
||||
<ConsentForm
|
||||
siteId={siteId}
|
||||
consentSessionId={consentSessionId}
|
||||
verified={client.verified}
|
||||
/>
|
||||
</div>
|
||||
</ConsentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered, branded card shell shared by the consent screen and its error state.
|
||||
*/
|
||||
function ConsentCard(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-tint-subtle p-4">
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full max-w-lg',
|
||||
'flex flex-col',
|
||||
'rounded-corners:rounded-lg straight-corners:rounded-none',
|
||||
'border border-tint-subtle bg-tint-base',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state shown when the consent flow cannot be started (e.g. a refreshed or expired link).
|
||||
*/
|
||||
export function ConsentError(props: { title?: string; message?: string }) {
|
||||
const {
|
||||
title = 'This authorization link has expired',
|
||||
message = 'Please start the sign-in again from the application.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ConsentCard>
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<span className="flex size-12 items-center justify-center rounded-corners:rounded-full straight-corners:rounded-none bg-danger text-danger-strong">
|
||||
<Icon icon="circle-exclamation" className="size-6" />
|
||||
</span>
|
||||
<h1 className="font-semibold text-lg text-tint-strong">{title}</h1>
|
||||
<p className="text-tint">{message}</p>
|
||||
</div>
|
||||
</ConsentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a redirect URI so the destination host (the trust-relevant part) can be emphasized while
|
||||
* the scheme and path are shown muted. Returns null if the URI can't be parsed.
|
||||
*/
|
||||
function parseRedirectURI(uri: string): { prefix: string; host: string; rest: string } | null {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return {
|
||||
prefix: `${url.protocol}//`,
|
||||
host: url.host,
|
||||
rest: `${url.pathname}${url.search}${url.hash}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline verified/unverified indicator shown next to the client name.
|
||||
*/
|
||||
function ClientTrustBadge(props: { verified: boolean }) {
|
||||
const { verified } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={tcls(
|
||||
'inline-flex items-center gap-1 font-medium text-xs',
|
||||
verified ? 'text-success-strong' : 'text-warning-strong'
|
||||
)}
|
||||
>
|
||||
<Icon icon={verified ? 'circle-check' : 'triangle-exclamation'} className="size-3" />
|
||||
{verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use server';
|
||||
|
||||
import { type SiteOAuthConsentDecision, submitSiteOAuthConsentDecision } from '@/lib/site-oauth';
|
||||
|
||||
export type SubmitConsentInput = {
|
||||
siteId: string;
|
||||
consentSessionId: string;
|
||||
decision: SiteOAuthConsentDecision;
|
||||
trusted: boolean;
|
||||
};
|
||||
|
||||
export type SubmitConsentResult = { redirectURL: string } | { error: string };
|
||||
|
||||
/**
|
||||
* Server action to submit the consent decision to the sites OAuth server's `consent/decision` endpoint.
|
||||
*/
|
||||
export async function submitSiteOAuthConsent(
|
||||
input: SubmitConsentInput
|
||||
): Promise<SubmitConsentResult> {
|
||||
const { siteId, consentSessionId, decision, trusted } = input;
|
||||
|
||||
if (!siteId || !consentSessionId || (decision !== 'approve' && decision !== 'deny')) {
|
||||
return { error: 'Invalid request. Please start again from the application.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { redirectURL } = await submitSiteOAuthConsentDecision({
|
||||
siteId,
|
||||
consentSessionId,
|
||||
decision,
|
||||
trusted,
|
||||
});
|
||||
return { redirectURL };
|
||||
} catch (_error) {
|
||||
return {
|
||||
error: 'We could not complete the authorization. The request may have expired — please start again from the application.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ConsentScreen, ConsentError } from './ConsentScreen';
|
||||
@@ -11,6 +11,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { fetchSiteIndex } from '../Search/site-index';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import {
|
||||
useSiteAdaptiveAuthLoginHref,
|
||||
@@ -229,34 +230,21 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
|
||||
) : null;
|
||||
}
|
||||
|
||||
/** Minimal shape of an entry in the `~gitbook/site-index` response. */
|
||||
type IndexPage = {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the pages whose path is closest to the one that 404'd.
|
||||
*
|
||||
* Rather than asking the server (which would mean an extra request per 404), this reuses the
|
||||
* search index served at `~gitbook/site-index` — already preloaded and CDN-cached on every page —
|
||||
* so it's a cache hit, not an origin request. The ranking is a lighter, client-side cousin of
|
||||
* `getSimilarPages` (which the Markdown 404 runs server-side from the full page tree).
|
||||
* search index served at `~gitbook/site-index` — preloaded on every page and shared with
|
||||
* instant search via the module cache in `site-index.ts`, so at most one request is made.
|
||||
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
|
||||
* runs server-side from the full page tree).
|
||||
*/
|
||||
async function getRelatedPages(
|
||||
indexURL: string,
|
||||
requestedPath: string,
|
||||
siteSpaceId: string | null
|
||||
): Promise<RelatedPage[]> {
|
||||
const response = await fetch(indexURL);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const { pages } = (await response.json()) as { pages: IndexPage[] };
|
||||
const { pages } = await fetchSiteIndex(indexURL);
|
||||
|
||||
return pages
|
||||
.filter((page) => !siteSpaceId || page.siteSpaceId === siteSpaceId)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useScrollListener } from './useScrollListener';
|
||||
|
||||
/**
|
||||
* Track the scroll position and overflow amount of a scrollable container,
|
||||
* keeping them in sync with scroll events, resizes, and content changes.
|
||||
*/
|
||||
export function useScrollOverflow(
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
) {
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
const [scrollSize, setScrollSize] = React.useState(0);
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollDimension =
|
||||
orientation === 'horizontal' ? container.scrollWidth : container.scrollHeight;
|
||||
const clientDimension =
|
||||
orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
|
||||
|
||||
setScrollSize(Math.max(scrollDimension - clientDimension - 1, 0));
|
||||
setScrollPosition(
|
||||
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
|
||||
);
|
||||
}, [orientation, containerRef]);
|
||||
|
||||
useScrollListener(measure, containerRef);
|
||||
|
||||
// Measure synchronously on mount (and when the container/orientation changes), so
|
||||
// initial overflow is detected without waiting for a resize/scroll event. Subsequent
|
||||
// content changes are picked up by the observers below instead of re-measuring on
|
||||
// every render.
|
||||
React.useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Children can overflow (or stop overflowing) without the container itself
|
||||
// changing size, so we observe the direct children in addition to the container,
|
||||
// and re-register observers as children are added/removed.
|
||||
let frame: number | null = null;
|
||||
const scheduleMeasure = () => {
|
||||
if (frame !== null) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
measure();
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(scheduleMeasure);
|
||||
ro.observe(container);
|
||||
for (const child of Array.from(container.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
// Only re-register direct children with the resize observer; descendants
|
||||
// deeper in the tree are covered by their parent's resize/mutation handling.
|
||||
if (mutation.target !== container) {
|
||||
continue;
|
||||
}
|
||||
for (const node of Array.from(mutation.addedNodes)) {
|
||||
if (node instanceof Element) {
|
||||
ro.observe(node);
|
||||
}
|
||||
}
|
||||
for (const node of Array.from(mutation.removedNodes)) {
|
||||
if (node instanceof Element) {
|
||||
ro.unobserve(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleMeasure();
|
||||
});
|
||||
// Also watch descendants (subtree/characterData) so text/content changes deeper in
|
||||
// the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure.
|
||||
mo.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [measure, containerRef]);
|
||||
|
||||
return { scrollPosition, scrollSize };
|
||||
}
|
||||
@@ -85,7 +85,9 @@ function scrollToHash(hash: string) {
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
// Looks like there is a bug when using smooth scroll on navigation between pages.
|
||||
// The browser does not scroll, probably some kind of browser optimization.
|
||||
behavior: 'instant',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { useScrollListener } from '../hooks/useScrollListener';
|
||||
import { useScrollOverflow } from '../hooks/useScrollOverflow';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
/**
|
||||
@@ -56,50 +56,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
const [scrollSize, setScrollSize] = React.useState(0);
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
useScrollListener(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScrollSize(
|
||||
orientation === 'horizontal'
|
||||
? container.scrollWidth - container.clientWidth - 1
|
||||
: container.scrollHeight - container.clientHeight - 1
|
||||
);
|
||||
|
||||
setScrollPosition(
|
||||
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
|
||||
);
|
||||
}, containerRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update max scroll position using resize observer
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry) {
|
||||
setScrollSize(
|
||||
orientation === 'horizontal'
|
||||
? entry.target.scrollWidth - entry.target.clientWidth - 1
|
||||
: entry.target.scrollHeight - entry.target.clientHeight - 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(container);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, [orientation]);
|
||||
const { scrollPosition, scrollSize } = useScrollOverflow(orientation, containerRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
MAX_API_TOKEN_COOKIE_LENGTH,
|
||||
getAPITokenFromCookies,
|
||||
getAPITokenResponseCookies,
|
||||
} from './api-token-cookie';
|
||||
|
||||
const cookieName = 'gitbook-api-token~test';
|
||||
const options = {
|
||||
httpOnly: true,
|
||||
sameSite: 'none' as const,
|
||||
secure: true,
|
||||
maxAge: 60 * 60,
|
||||
};
|
||||
|
||||
describe('API token cookies', () => {
|
||||
it('keeps tokens at the cookie limit in one cookie', () => {
|
||||
const apiToken = 'a'.repeat(4_000);
|
||||
|
||||
expect(getAPITokenResponseCookies({ cookies: [], cookieName, apiToken, options })).toEqual([
|
||||
{ name: cookieName, value: apiToken, options },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits and reconstructs oversized tokens', () => {
|
||||
const apiToken = `${'a'.repeat(4_000)}b`;
|
||||
const cookies = getAPITokenResponseCookies({ cookies: [], cookieName, apiToken, options });
|
||||
|
||||
expect(cookies).toEqual([
|
||||
{ name: cookieName, value: 'chunks:2', options },
|
||||
{ name: `${cookieName}-0`, value: 'a'.repeat(4_000), options },
|
||||
{ name: `${cookieName}-1`, value: 'b', options },
|
||||
]);
|
||||
expect(getAPITokenFromCookies(cookies, cookieName)).toBe(apiToken);
|
||||
});
|
||||
|
||||
it('rejects tokens that would exceed the response cookie header limit', () => {
|
||||
expect(() =>
|
||||
getAPITokenResponseCookies({
|
||||
cookies: [],
|
||||
cookieName,
|
||||
apiToken: 'a'.repeat(MAX_API_TOKEN_COOKIE_LENGTH + 1),
|
||||
options,
|
||||
})
|
||||
).toThrow(`API token exceeds the ${MAX_API_TOKEN_COOKIE_LENGTH}-character cookie limit`);
|
||||
});
|
||||
|
||||
it('reads legacy single-cookie tokens', () => {
|
||||
expect(
|
||||
getAPITokenFromCookies([{ name: cookieName, value: 'legacy-token' }], cookieName)
|
||||
).toBe('legacy-token');
|
||||
});
|
||||
|
||||
it('does not return a partial token when a chunk is missing', () => {
|
||||
expect(
|
||||
getAPITokenFromCookies(
|
||||
[
|
||||
{ name: cookieName, value: 'chunks:2' },
|
||||
{ name: `${cookieName}-0`, value: 'first' },
|
||||
],
|
||||
cookieName
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not return a token when the first chunk is missing', () => {
|
||||
expect(
|
||||
getAPITokenFromCookies(
|
||||
[
|
||||
{ name: cookieName, value: 'chunks:2' },
|
||||
{ name: `${cookieName}-1`, value: 'second' },
|
||||
],
|
||||
cookieName
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the base cookie value when its chunk count is unknown', () => {
|
||||
expect(
|
||||
getAPITokenFromCookies(
|
||||
[
|
||||
{ name: cookieName, value: '2' },
|
||||
{ name: `${cookieName}-0`, value: 'first' },
|
||||
{ name: `${cookieName}-1`, value: 'second' },
|
||||
],
|
||||
cookieName
|
||||
)
|
||||
).toBe('2');
|
||||
expect(getAPITokenFromCookies([{ name: cookieName, value: 'chunks:02' }], cookieName)).toBe(
|
||||
'chunks:02'
|
||||
);
|
||||
});
|
||||
|
||||
it('expires chunks no longer needed by a replacement token', () => {
|
||||
const oldCookies = [
|
||||
{ name: cookieName, value: 'chunks:3' },
|
||||
{ name: `${cookieName}-0`, value: 'first' },
|
||||
{ name: `${cookieName}-1`, value: 'second' },
|
||||
{ name: `${cookieName}-2`, value: 'third' },
|
||||
];
|
||||
|
||||
expect(
|
||||
getAPITokenResponseCookies({
|
||||
cookies: oldCookies,
|
||||
cookieName,
|
||||
apiToken: 'replacement',
|
||||
options,
|
||||
})
|
||||
).toEqual([
|
||||
{ name: cookieName, value: 'replacement', options },
|
||||
{ name: `${cookieName}-0`, value: '', options: { ...options, maxAge: 0 } },
|
||||
{ name: `${cookieName}-1`, value: '', options: { ...options, maxAge: 0 } },
|
||||
{ name: `${cookieName}-2`, value: '', options: { ...options, maxAge: 0 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not attempt to expire an unbounded number of forged chunks', () => {
|
||||
expect(
|
||||
getAPITokenResponseCookies({
|
||||
cookies: [{ name: cookieName, value: 'chunks:999999999' }],
|
||||
cookieName,
|
||||
apiToken: 'replacement',
|
||||
options,
|
||||
})
|
||||
).toEqual([{ name: cookieName, value: 'replacement', options }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ResponseCookie, ResponseCookies } from './visitors';
|
||||
|
||||
const COOKIE_CHUNK_SIZE = 4_000;
|
||||
const MAX_COOKIE_CHUNKS = 3;
|
||||
export const MAX_API_TOKEN_COOKIE_LENGTH = COOKIE_CHUNK_SIZE * MAX_COOKIE_CHUNKS;
|
||||
|
||||
type RequestCookie = Pick<ResponseCookie, 'name' | 'value'>;
|
||||
|
||||
/**
|
||||
*
|
||||
* Retrieves the API token from the provided cookies, handling both single-cookie and chunked representations.
|
||||
* We need to split the token into multiple cookies if it exceeds the size limit of a single cookie (4,000 characters).
|
||||
* Some sites go over that limit which then cause an infinite redirect loop.
|
||||
*/
|
||||
export function getAPITokenFromCookies(
|
||||
cookies: readonly RequestCookie[],
|
||||
cookieName: string
|
||||
): string | undefined {
|
||||
const cookie = cookies.find(({ name }) => name === cookieName);
|
||||
if (!cookie) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunkCount = parseChunkCount(cookie.value);
|
||||
// A base cookie without a recognized chunk marker is always a token value.
|
||||
if (chunkCount === undefined) {
|
||||
return cookie.value;
|
||||
}
|
||||
|
||||
if (chunkCount > MAX_COOKIE_CHUNKS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunks = new Map<number, string>();
|
||||
const chunkNamePrefix = `${cookieName}-`;
|
||||
for (const { name, value } of cookies) {
|
||||
if (!name.startsWith(chunkNamePrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexValue = name.slice(chunkNamePrefix.length);
|
||||
const index = Number(indexValue);
|
||||
if (/^\d+$/.test(indexValue) && Number.isSafeInteger(index) && index >= 0) {
|
||||
chunks.set(index, value);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't have all the chunks, so we can't reconstruct the token.
|
||||
// We consider this a missing token
|
||||
if (chunks.size < chunkCount) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenChunks: string[] = [];
|
||||
for (let index = 0; index < chunkCount; index++) {
|
||||
const chunk = chunks.get(index);
|
||||
if (chunk === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
tokenChunks.push(chunk);
|
||||
}
|
||||
|
||||
return tokenChunks.join('');
|
||||
}
|
||||
|
||||
export function getAPITokenResponseCookies(input: {
|
||||
cookies: readonly RequestCookie[];
|
||||
cookieName: string;
|
||||
apiToken: string;
|
||||
options: NonNullable<ResponseCookie['options']>;
|
||||
}): ResponseCookies {
|
||||
const { cookies, cookieName, apiToken, options } = input;
|
||||
if (apiToken.length > MAX_API_TOKEN_COOKIE_LENGTH) {
|
||||
throw new APITokenCookieTooLargeError();
|
||||
}
|
||||
|
||||
const chunks = splitIntoCookieChunks(apiToken);
|
||||
const previousChunkCount = getPreviousChunkCount(cookies, cookieName);
|
||||
const responseCookies: ResponseCookies =
|
||||
chunks.length === 1
|
||||
? [{ name: cookieName, value: apiToken, options }]
|
||||
: [
|
||||
{ name: cookieName, value: `chunks:${chunks.length}`, options },
|
||||
...chunks.map((value, index) => ({
|
||||
name: `${cookieName}-${index}`,
|
||||
value,
|
||||
options,
|
||||
})),
|
||||
];
|
||||
|
||||
const firstChunkToExpire = chunks.length === 1 ? 0 : chunks.length;
|
||||
for (let index = firstChunkToExpire; index < previousChunkCount; index++) {
|
||||
responseCookies.push({
|
||||
name: `${cookieName}-${index}`,
|
||||
value: '',
|
||||
options: { ...options, maxAge: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
return responseCookies;
|
||||
}
|
||||
|
||||
function splitIntoCookieChunks(value: string): string[] {
|
||||
if (value.length <= COOKIE_CHUNK_SIZE) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (let start = 0; start < value.length; start += COOKIE_CHUNK_SIZE) {
|
||||
chunks.push(value.slice(start, start + COOKIE_CHUNK_SIZE));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function getPreviousChunkCount(cookies: readonly RequestCookie[], cookieName: string): number {
|
||||
const cookie = cookies.find(({ name }) => name === cookieName);
|
||||
const chunkCount = cookie ? parseChunkCount(cookie.value) : undefined;
|
||||
return chunkCount && chunkCount <= MAX_COOKIE_CHUNKS ? chunkCount : 0;
|
||||
}
|
||||
|
||||
function parseChunkCount(value: string): number | undefined {
|
||||
// This regex matches the format "chunks:N" where N is a number between 2 and 9 or any number with two or more digits.
|
||||
const match = /^chunks:([2-9]|[1-9]\d+)$/.exec(value);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const count = Number(match[1]);
|
||||
return Number.isSafeInteger(count) ? count : undefined;
|
||||
}
|
||||
|
||||
export class APITokenCookieTooLargeError extends Error {
|
||||
constructor() {
|
||||
super(`API token exceeds the ${MAX_API_TOKEN_COOKIE_LENGTH}-character cookie limit`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GitBookAPI,
|
||||
type HttpResponse,
|
||||
type RenderIntegrationUI,
|
||||
type SiteSearchScope,
|
||||
} from '@gitbook/api';
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
import { parse as parseCacheControl } from '@tusbar/cache-control';
|
||||
@@ -785,7 +786,17 @@ const searchSiteContent = cache(
|
||||
siteId,
|
||||
{
|
||||
query,
|
||||
...scope,
|
||||
...(scope.mode === 'current' && scope.restrictTo
|
||||
? {
|
||||
// `restrictTo` only exists in the newer `scope` request shape,
|
||||
// and the published @gitbook/api types don't include it yet.
|
||||
scope: {
|
||||
mode: 'default',
|
||||
currentSiteSpace: scope.siteSpaceId,
|
||||
restrictTo: scope.restrictTo,
|
||||
} as SiteSearchScope,
|
||||
}
|
||||
: scope),
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -178,7 +178,12 @@ export interface GitBookDataFetcher {
|
||||
query: string;
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| {
|
||||
mode: 'current';
|
||||
siteSpaceId: string;
|
||||
/** Restrict the search to the current site space alone, or to the other site spaces in the scope. */
|
||||
restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces';
|
||||
}
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
/** Cache bust to ensure the search results are fresh when the space is updated. */
|
||||
cacheBust?: string;
|
||||
|
||||
@@ -8,7 +8,11 @@ mock.module('./openapi/resolveOpenAPIOperationBlock', () => ({
|
||||
}));
|
||||
|
||||
mock.module('./openapi/resolveOpenAPISchemasBlock', () => ({
|
||||
resolveOpenAPISchemasBlock: async () => ({ data: null }),
|
||||
resolveOpenAPISchemasBlock: async ({ block }: { block: { data: { schemas?: string[] } } }) => ({
|
||||
data: {
|
||||
schemas: (block.data.schemas ?? []).map((name) => ({ name, schema: {} })),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getDocumentSections } = await import('./document-sections');
|
||||
@@ -225,4 +229,61 @@ describe('getDocumentSections', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts a single-schema openapi-schemas block as one section', async () => {
|
||||
const document: JSONDocument = {
|
||||
object: 'document',
|
||||
data: { schemaVersion: 2 },
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'openapi-schemas',
|
||||
data: { schemas: ['User'], grouped: false },
|
||||
meta: { id: 'models-block' },
|
||||
isVoid: false,
|
||||
nodes: [],
|
||||
} as unknown as JSONDocument['nodes'][number],
|
||||
],
|
||||
};
|
||||
|
||||
const sections = await getDocumentSections(context, document);
|
||||
|
||||
expect(
|
||||
sections.map((section) => ({
|
||||
id: section.id,
|
||||
depth: section.depth,
|
||||
title: reactNodeToText(section.title),
|
||||
}))
|
||||
).toEqual([{ id: 'models-block', depth: 1, title: 'The User object' }]);
|
||||
});
|
||||
|
||||
it('extracts one section per model for grouped/multi-schema openapi-schemas blocks', async () => {
|
||||
const document: JSONDocument = {
|
||||
object: 'document',
|
||||
data: { schemaVersion: 2 },
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'openapi-schemas',
|
||||
data: { schemas: ['User', 'Pet Store'], grouped: true },
|
||||
meta: { id: 'models-block' },
|
||||
isVoid: false,
|
||||
nodes: [],
|
||||
} as unknown as JSONDocument['nodes'][number],
|
||||
],
|
||||
};
|
||||
|
||||
const sections = await getDocumentSections(context, document);
|
||||
|
||||
expect(
|
||||
sections.map((section) => ({
|
||||
id: section.id,
|
||||
depth: section.depth,
|
||||
title: reactNodeToText(section.title),
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'user', depth: 1, title: 'User' },
|
||||
{ id: 'pet-store', depth: 1, title: 'Pet Store' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
import { getOpenAPISchemaAnchorId } from '@gitbook/openapi-parser';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { getDataOrNull } from './data';
|
||||
@@ -142,31 +143,30 @@ async function getSectionsFromNodes(
|
||||
continue;
|
||||
}
|
||||
case 'openapi-schemas': {
|
||||
const id = block.meta?.id;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
if (block.data.grouped || block.data.schemas.length !== 1) {
|
||||
// Skip grouped schemas, they are not sections
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data } = await resolveOpenAPISchemasBlock({
|
||||
block,
|
||||
context,
|
||||
});
|
||||
const schema = data?.schemas[0];
|
||||
if (schema) {
|
||||
sections.push(
|
||||
withTags(
|
||||
{
|
||||
id,
|
||||
title: `The ${schema.name} object`,
|
||||
depth: 1,
|
||||
},
|
||||
tags
|
||||
)
|
||||
);
|
||||
|
||||
// A lone model anchors on the block's own id, like an operation.
|
||||
if (!block.data.grouped && block.data.schemas.length === 1) {
|
||||
const id = block.meta?.id;
|
||||
const schema = data?.schemas[0];
|
||||
if (id && schema) {
|
||||
sections.push(
|
||||
withTags({ id, title: `The ${schema.name} object`, depth: 1 }, tags)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grouped/multiple schemas each render as a deep-linkable model, keyed by name slug.
|
||||
for (const schema of data?.schemas ?? []) {
|
||||
const id = getOpenAPISchemaAnchorId(schema.name);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
sections.push(withTags({ id, title: schema.name, depth: 1 }, tags));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
+8
@@ -132,6 +132,14 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
||||
*/
|
||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
||||
|
||||
/**
|
||||
* Shared secret used to sign server-to-server requests to the sites OAuth server consent endpoints.
|
||||
* This must match the sites OAuth provider signing secret (`functionsConfig.sitesOAuth.signingSecret`
|
||||
* in gitbook-x); it is a dedicated secret and must not be confused with `GITBOOK_SECRET`.
|
||||
*/
|
||||
export const GITBOOK_SITE_OAUTH_SIGNING_SECRET =
|
||||
process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET ?? null;
|
||||
|
||||
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
|
||||
if (!enumValues.includes(value as T)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Whether GBO should render the sites OAuth consent screen (instead of forwarding the post-login
|
||||
* resume to the OAuth server). This must be coordinated with the OAuth server so GBO renders consent
|
||||
* exactly when the server expects it.
|
||||
*
|
||||
* Kept in its own module (free of `node:crypto`/`server-only`) so it can be imported from the edge
|
||||
* middleware. It is never imported into a client bundle.
|
||||
*/
|
||||
export function isSitesOAuthConsentEnabled(): boolean {
|
||||
const override = process.env.GITBOOK_SITE_OAUTH_CONSENT_ENABLED;
|
||||
if (override !== undefined) {
|
||||
return override === 'true';
|
||||
}
|
||||
return process.env.NODE_ENV === 'development';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interaction id the OAuth server puts on the post-login resume URL. Its presence marks a resume
|
||||
* that GBO should render consent for (rather than forward to the OAuth server).
|
||||
*/
|
||||
export const SITE_OAUTH_STATE_PARAM = 'gb_oauth_state';
|
||||
|
||||
/**
|
||||
* Whether GBO should render the consent screen for a request hitting the
|
||||
* `~gitbook/oauth2/v1/:siteId/authorize` forwarder, rather than forwarding it to the OAuth server.
|
||||
*
|
||||
* Render only when consent is enabled AND this is a post-login resume (carries the interaction id);
|
||||
* everything else forwards, preserving the legacy behavior.
|
||||
*/
|
||||
export function shouldRenderSiteOAuthConsent(searchParams: URLSearchParams): boolean {
|
||||
return isSitesOAuthConsentEnabled() && searchParams.has(SITE_OAUTH_STATE_PARAM);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import { GITBOOK_OAUTH_SERVER_URL, GITBOOK_SITE_OAUTH_SIGNING_SECRET } from '@/lib/env';
|
||||
|
||||
export {
|
||||
SITE_OAUTH_STATE_PARAM,
|
||||
isSitesOAuthConsentEnabled,
|
||||
shouldRenderSiteOAuthConsent,
|
||||
} from './flag';
|
||||
|
||||
/**
|
||||
* Details about the OAuth client requesting authorization, as returned by the OAuth server. The
|
||||
* `name` and `uri` are client-supplied and must be treated as untrusted when rendered.
|
||||
*/
|
||||
export type SiteOAuthConsentClient = {
|
||||
name: string;
|
||||
uri?: string;
|
||||
logoUri?: string;
|
||||
verified: boolean;
|
||||
verifiedName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of a successful `consent/start` call: everything GBO needs to render the consent screen.
|
||||
*/
|
||||
export type SiteOAuthConsentStart = {
|
||||
consentSessionId: string;
|
||||
client: SiteOAuthConsentClient;
|
||||
scopes: string[];
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A visitor's decision on a consent request.
|
||||
*/
|
||||
export type SiteOAuthConsentDecision = 'approve' | 'deny';
|
||||
|
||||
/**
|
||||
* Error thrown when a server-to-server call to the OAuth server consent endpoint fails. It carries
|
||||
* the upstream HTTP status so callers can distinguish an expired/consumed session (400) from an
|
||||
* authentication problem (401).
|
||||
*/
|
||||
export class SiteOAuthConsentError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'SiteOAuthConsentError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a consent session with the OAuth server for a post-login authorize resume.
|
||||
*
|
||||
* This is single-use: it consumes the pending interaction session on the OAuth server, so it must be
|
||||
* called exactly once per consent render.
|
||||
*/
|
||||
export async function startSiteOAuthConsent(args: {
|
||||
siteId: string;
|
||||
interactionId: string;
|
||||
jwtToken: string;
|
||||
}): Promise<SiteOAuthConsentStart> {
|
||||
const { siteId, interactionId, jwtToken } = args;
|
||||
return postToConsentEndpoint<SiteOAuthConsentStart>(siteId, 'consent/start', {
|
||||
interactionId,
|
||||
jwtToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the visitor's decision to the OAuth server and get back the absolute URL to send the
|
||||
* visitor's browser to (the client's redirect URI with an auth code on approve, or an access_denied
|
||||
* redirect on deny).
|
||||
*/
|
||||
export async function submitSiteOAuthConsentDecision(args: {
|
||||
siteId: string;
|
||||
consentSessionId: string;
|
||||
decision: SiteOAuthConsentDecision;
|
||||
trusted: boolean;
|
||||
}): Promise<{ redirectURL: string }> {
|
||||
const { siteId, consentSessionId, decision, trusted } = args;
|
||||
return postToConsentEndpoint<{ redirectURL: string }>(siteId, 'consent/decision', {
|
||||
consentSessionId,
|
||||
decision,
|
||||
// Only forward the trust acknowledgement when the visitor actually gave it; the server
|
||||
// re-checks and rejects an unverified client approved without it.
|
||||
...(trusted ? { trusted: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and POST a JSON body to a site OAuth server consent endpoint, returning the parsed response.
|
||||
*
|
||||
* The shared-secret signature matches the OAuth server's schem and is sent as the
|
||||
* `x-gitbook-signature` / `x-gitbook-timestamp` headers.
|
||||
*/
|
||||
async function postToConsentEndpoint<T>(
|
||||
siteId: string,
|
||||
endpoint: 'consent/start' | 'consent/decision',
|
||||
body: unknown
|
||||
): Promise<T> {
|
||||
if (!GITBOOK_SITE_OAUTH_SIGNING_SECRET) {
|
||||
throw new SiteOAuthConsentError('Missing sites OAuth signing secret', 500);
|
||||
}
|
||||
|
||||
const rawBody = JSON.stringify(body);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = createHmac('sha256', GITBOOK_SITE_OAUTH_SIGNING_SECRET)
|
||||
.update(`${siteId}:${timestamp}:${rawBody}`)
|
||||
.digest('hex');
|
||||
|
||||
const url = new URL(GITBOOK_OAUTH_SERVER_URL);
|
||||
url.pathname += `/${encodeURIComponent(siteId)}/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-gitbook-signature': signature,
|
||||
'x-gitbook-timestamp': String(timestamp),
|
||||
},
|
||||
body: rawBody,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new SiteOAuthConsentError(
|
||||
`OAuth server ${endpoint} responded with ${response.status}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
|
||||
import {
|
||||
MAX_API_TOKEN_COOKIE_LENGTH,
|
||||
getAPITokenFromCookies,
|
||||
getAPITokenResponseCookies,
|
||||
} from '@/lib/api-token-cookie';
|
||||
import type { SiteURLData } from '@/lib/context';
|
||||
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { validateSerializedCustomization } from '@/lib/customization';
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
getPreviewRequestIdentifier,
|
||||
isPreviewRequest,
|
||||
} from '@/lib/preview';
|
||||
import { shouldRenderSiteOAuthConsent } from '@/lib/site-oauth/flag';
|
||||
import {
|
||||
type ResponseCookies,
|
||||
getPathScopedCookieName,
|
||||
@@ -194,10 +200,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
|
||||
if (siteOAuthAuthorizeMatch) {
|
||||
const siteId = siteOAuthAuthorizeMatch.pathname.groups.siteId;
|
||||
const siteOAuthAuthorizeURL = new URL(oauthServerURL);
|
||||
siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`;
|
||||
siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', '');
|
||||
return NextResponse.redirect(siteOAuthAuthorizeURL.toString());
|
||||
|
||||
// When the consent flow is enabled, GBO renders the consent screen for the post-login resume
|
||||
// (recognized by the `gb_oauth_state` interaction id the OAuth server puts on the resume URL)
|
||||
// instead of forwarding. We fall through to the normal site routing, which rewrites the
|
||||
// request to the `~gitbook/oauth2/v1/[siteId]/authorize` route that renders consent.
|
||||
//
|
||||
// Otherwise we forward to the OAuth server exactly as before (legacy path).
|
||||
if (!shouldRenderSiteOAuthConsent(siteRequestURL.searchParams)) {
|
||||
const siteOAuthAuthorizeURL = new URL(oauthServerURL);
|
||||
siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`;
|
||||
siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', '');
|
||||
return NextResponse.redirect(siteOAuthAuthorizeURL.toString());
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -541,6 +556,21 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
response.headers.set('cache-control', 'public, max-age=0, must-revalidate');
|
||||
}
|
||||
|
||||
// The sites OAuth consent screen carries a security decision: lock it down so it can't be
|
||||
// framed, cached, or leak the client's redirect URI via the Referer header.
|
||||
if (pathname.match(/^~gitbook\/oauth2\/v1\/[^/]+\/authorize$/)) {
|
||||
response.headers.set(
|
||||
'content-security-policy',
|
||||
getContentSecurityPolicy().replace(
|
||||
/frame-ancestors[^;]*;/,
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
);
|
||||
response.headers.set('x-frame-options', 'DENY');
|
||||
response.headers.set('referrer-policy', 'no-referrer');
|
||||
response.headers.set('cache-control', 'no-store');
|
||||
}
|
||||
|
||||
return writeResponseCookies(response, cookies);
|
||||
};
|
||||
|
||||
@@ -621,22 +651,31 @@ async function serveWithQueryAPIToken(input: {
|
||||
// If found, we redirect to the same URL but with the token in the cookie
|
||||
const queryAPIToken = requestURL.searchParams.get('token');
|
||||
if (queryAPIToken) {
|
||||
if (queryAPIToken.length > MAX_API_TOKEN_COOKIE_LENGTH) {
|
||||
return new Response('API token is too large', {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
requestURL.searchParams.delete('token');
|
||||
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [
|
||||
{
|
||||
name: cookieName,
|
||||
value: queryAPIToken,
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURL.toString()),
|
||||
getAPITokenResponseCookies({
|
||||
cookies: requestCookies.getAll(),
|
||||
cookieName,
|
||||
apiToken: queryAPIToken,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const apiToken = requestCookies.get(cookieName)?.value;
|
||||
const apiToken = getAPITokenFromCookies(requestCookies.getAll(), cookieName);
|
||||
|
||||
return serve(apiToken ?? null);
|
||||
}
|
||||
@@ -731,6 +770,11 @@ function encodePathInSiteContent(
|
||||
return { pathname };
|
||||
}
|
||||
|
||||
// The sites OAuth consent screen is rendered dynamically per request (client details, visitor).
|
||||
if (pathname.match(/^~gitbook\/oauth2\/v1\/[^/]+\/authorize$/)) {
|
||||
return { pathname, routeType: 'dynamic' };
|
||||
}
|
||||
|
||||
// If the pathname is a RSS feed (/.../rss.xml), we rewrite it to ~gitbook/rss/:pathname
|
||||
const rssMatch = pathname.match(RSS_PATH_REGEX);
|
||||
if (rssMatch) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getOpenAPISchemaAnchorId } from './schemas';
|
||||
|
||||
describe('getOpenAPISchemaAnchorId', () => {
|
||||
it('lowercases a simple name', () => {
|
||||
expect(getOpenAPISchemaAnchorId('User')).toBe('user');
|
||||
});
|
||||
|
||||
it('replaces non-alphanumeric runs with a single dash', () => {
|
||||
expect(getOpenAPISchemaAnchorId('Pet Store')).toBe('pet-store');
|
||||
expect(getOpenAPISchemaAnchorId('User.Profile_v2')).toBe('user-profile-v2');
|
||||
});
|
||||
|
||||
it('trims leading and trailing dashes', () => {
|
||||
expect(getOpenAPISchemaAnchorId('#User#')).toBe('user');
|
||||
expect(getOpenAPISchemaAnchorId(' spaced ')).toBe('spaced');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,19 @@ import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
|
||||
import { shouldIgnoreEntity } from './helpers/shouldIgnoreEntity';
|
||||
import type { OpenAPISchema } from './types';
|
||||
|
||||
/**
|
||||
* Build a stable, readable anchor id for a model/schema name (e.g. `User` -> `user`).
|
||||
* Shared between the rendered disclosure and the page's table of contents so their ids match.
|
||||
* Names are usually unique within a spec; a page heading could in theory slug to the same value.
|
||||
*/
|
||||
export function getOpenAPISchemaAnchorId(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract selected schemas from the OpenAPI document.
|
||||
*/
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
};
|
||||
}, [renderer, sendMessage]);
|
||||
|
||||
// Send data and client-only context (visitor claims) as state to the webframe.
|
||||
// Send data and client-only context (visitor claims, current page) as state to the webframe.
|
||||
React.useEffect(() => {
|
||||
const abort = { cancelled: false };
|
||||
sendWebframeState({
|
||||
@@ -231,10 +231,14 @@ function resolveWebframeState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the optional client-only contexts (visitor claims) to merge into the webframe state.
|
||||
* Resolve the optional client-only contexts (visitor claims, current page)
|
||||
* to merge into the webframe state.
|
||||
*/
|
||||
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
|
||||
return await Promise.all([clientContext?.getVisitorContext?.()]);
|
||||
return await Promise.all([
|
||||
clientContext?.getVisitorContext?.(),
|
||||
clientContext?.getPageContext?.(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,16 @@ export type ContentKitRenderUpdate = Partial<
|
||||
Pick<RequestRenderIntegrationUI, 'action' | 'props' | 'state'>
|
||||
>;
|
||||
|
||||
/**
|
||||
* The current page exposed to a webframe through the client-only webframe state.
|
||||
*/
|
||||
export type ContentKitWebframePage = {
|
||||
id: string;
|
||||
/** Path of the page relative to the site root. */
|
||||
path: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ContentKitClientContextData = {
|
||||
/**
|
||||
* Client-only visitor claims, merged into the webframe state.
|
||||
@@ -28,6 +38,15 @@ export type ContentKitClientContextData = {
|
||||
| undefined
|
||||
| Promise<Record<string, unknown> | null | undefined>;
|
||||
|
||||
/**
|
||||
* Client-only current-page context, merged into the webframe state.
|
||||
*/
|
||||
getPageContext?: () =>
|
||||
| { page: ContentKitWebframePage }
|
||||
| null
|
||||
| undefined
|
||||
| Promise<{ page: ContentKitWebframePage } | null | undefined>;
|
||||
|
||||
/**
|
||||
* Navigate the host page to another page, in response to a webframe `@webframe.navigate`
|
||||
* action. The destination is addressed by `path` (resolved against the site base path); the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import clsx from 'classnames';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
|
||||
|
||||
/**
|
||||
@@ -14,10 +14,36 @@ export function OpenAPIDisclosure(props: {
|
||||
label: string | ((isExpanded: boolean) => string);
|
||||
className?: string;
|
||||
defaultExpanded?: boolean;
|
||||
/**
|
||||
* Anchor id used as a deep-link target. When set, the disclosure expands and scrolls the
|
||||
* element carrying this id into view once the URL hash matches it.
|
||||
*/
|
||||
id?: string;
|
||||
}): React.JSX.Element {
|
||||
const { icon, header, label, children, className, defaultExpanded = false } = props;
|
||||
const { icon, header, label, children, className, defaultExpanded = false, id } = props;
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
// Expand and scroll into view when the URL hash points to this disclosure.
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openFromHash = () => {
|
||||
if (window.location.hash.slice(1) !== id) {
|
||||
return;
|
||||
}
|
||||
setIsExpanded(true);
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||
});
|
||||
};
|
||||
|
||||
openFromHash();
|
||||
window.addEventListener('hashchange', openFromHash);
|
||||
return () => window.removeEventListener('hashchange', openFromHash);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
className={clsx('openapi-disclosure', className)}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface OpenAPIClientContext {
|
||||
check: React.ReactNode;
|
||||
lock: React.ReactNode;
|
||||
mcp: React.ReactNode;
|
||||
hashtag: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,20 +8,45 @@ import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
||||
import { Section } from '../StaticSection';
|
||||
import type { OpenAPIClientContext } from '../context';
|
||||
import { getDisclosureLabel } from '../getDisclosureLabel';
|
||||
import { tString } from '../translate';
|
||||
|
||||
export function OpenAPISchemaItem(props: {
|
||||
id?: string;
|
||||
name: string;
|
||||
schema: OpenAPIV3.SchemaObject;
|
||||
context: OpenAPIClientContext;
|
||||
}) {
|
||||
const { schema, context, name } = props;
|
||||
const { schema, context, name, id } = props;
|
||||
|
||||
return (
|
||||
<OpenAPIDisclosure
|
||||
className="openapi-schemas-disclosure"
|
||||
key={name}
|
||||
id={id}
|
||||
icon={context.icons.plus}
|
||||
header={name}
|
||||
header={
|
||||
id ? (
|
||||
<span id={id} className="openapi-schemas-model-title">
|
||||
<span className="openapi-schemas-model-title-name">{name}</span>
|
||||
<a
|
||||
href={`#${id}`}
|
||||
className="openapi-schemas-anchor-link"
|
||||
aria-label={tString(context.translation, 'direct_link_to_model', name)}
|
||||
// Keep the click from toggling the disclosure or scrolling; just set the URL.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.history.pushState(null, '', `#${id}`);
|
||||
}}
|
||||
>
|
||||
{context.icons.hashtag}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
name
|
||||
)
|
||||
}
|
||||
label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })}
|
||||
defaultExpanded={context.expandAllModelSections}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getOpenAPISchemaAnchorId } from '@gitbook/openapi-parser';
|
||||
import clsx from 'classnames';
|
||||
import { OpenAPIExample } from '../OpenAPIExample';
|
||||
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
|
||||
@@ -41,7 +42,9 @@ export function OpenAPISchemas(props: {
|
||||
const title = `The ${firstSchema.name} object`;
|
||||
return (
|
||||
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
|
||||
<div className="openapi-summary" id={context.id}>
|
||||
{/* The heading rendered below already carries the anchor id; a second id here would
|
||||
win the fragment target and scroll to the wrong margin. */}
|
||||
<div className="openapi-summary">
|
||||
{context.renderHeading({
|
||||
title,
|
||||
deprecated: Boolean(firstSchema.schema.deprecated),
|
||||
@@ -88,6 +91,7 @@ export function OpenAPISchemas(props: {
|
||||
return (
|
||||
<OpenAPISchemaItem
|
||||
key={name}
|
||||
id={getOpenAPISchemaAnchorId(name)}
|
||||
name={name}
|
||||
context={clientContext}
|
||||
schema={schema}
|
||||
|
||||
@@ -45,4 +45,5 @@ export const de = {
|
||||
or: 'oder',
|
||||
and: 'und',
|
||||
possible_values: 'Mögliche Werte',
|
||||
direct_link_to_model: 'Direktlink zu ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const en = {
|
||||
properties: 'Properties',
|
||||
or: 'or',
|
||||
and: 'and',
|
||||
direct_link_to_model: 'Direct link to ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const es = {
|
||||
or: 'o',
|
||||
and: 'y',
|
||||
possible_values: 'Valores posibles',
|
||||
direct_link_to_model: 'Enlace directo a ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const fr = {
|
||||
or: 'ou',
|
||||
and: 'et',
|
||||
possible_values: 'Valeurs possibles',
|
||||
direct_link_to_model: 'Lien direct vers ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const ja = {
|
||||
or: 'または',
|
||||
and: 'および',
|
||||
possible_values: '可能な値',
|
||||
direct_link_to_model: '${1} への直接リンク',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const nl = {
|
||||
or: 'of',
|
||||
and: 'en',
|
||||
possible_values: 'Mogelijke waarden',
|
||||
direct_link_to_model: 'Directe link naar ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const no = {
|
||||
or: 'eller',
|
||||
and: 'og',
|
||||
possible_values: 'Mulige verdier',
|
||||
direct_link_to_model: 'Direktelenke til ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const pt_br = {
|
||||
or: 'ou',
|
||||
and: 'e',
|
||||
possible_values: 'Valores possíveis',
|
||||
direct_link_to_model: 'Link direto para ${1}',
|
||||
};
|
||||
|
||||
@@ -45,4 +45,5 @@ export const zh = {
|
||||
or: '或',
|
||||
and: '和',
|
||||
possible_values: '可能的值',
|
||||
direct_link_to_model: '${1} 的直接链接',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user