Compare commits

...

8 Commits

Author SHA1 Message Date
Claude d55764e136 changeset 2026-07-20 00:15:58 +00:00
Claude 901f0268cc Fix overlapping search highlights for adjacent multi-word matches
Multi-word search queries highlight each word independently, so a phrase
like "maria db" against "MariaDB" produced two touching highlight spans
whose negative margins and rounded corners overlapped into a doubled pill.
Coalesce contiguous matched parts into a single highlight span.

The pure matching logic is extracted to HighlightQuery.utils.ts so it can
be unit-tested without pulling in the React JSX runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4jiy83BeKXZjEH4Jqwn3Z
2026-07-20 00:15:58 +00:00
spastorelli 0dee4155a2 Add consent flow env vars to next config (#4413) 2026-07-17 15:16:46 +00:00
spastorelli 4d7c01587e Add Sites OAuth consent screen (#4405) 2026-07-17 15:58:27 +02:00
Greg Bergé 6083a88845 Expose the current page context to integration block webframes (#4411)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:06:38 +02:00
Nolann B. bf6a7af72b Defer search index loading until search is opened (#4407) 2026-07-16 21:53:24 +02:00
Utku Ufuk 703e654a37 Split the default-scope site search into two parallel API requests (#4395) 2026-07-16 15:23:30 +03:00
conico974 1b571aeaa9 Fix scroll behavior for navigation (#4406) 2026-07-16 14:21:01 +02:00
39 changed files with 1216 additions and 175 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search results rendering adjacent matched words from a multi-word query (e.g. "maria db" matching "MariaDB") as two overlapping highlight pills instead of one.
+5
View File
@@ -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.
+6
View File
@@ -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
-5
View File
@@ -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',
+2
View File
@@ -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,
@@ -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>
);
}
@@ -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;
}
}
@@ -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.
*/
@@ -1,4 +1,5 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import { matchString } from './HighlightQuery.utils';
/**
* Match a string against a query and render the matching text in bold.
@@ -44,45 +45,3 @@ export function HighlightQuery(props: {
</span>
);
}
interface TextMatch {
text: string;
match?: string;
}
function matchString(text: string, query: string): TextMatch[] {
const words = splitQuery(query);
const initialParts = [{ text }];
return words.reduce((parts, word) => matchWordInParts(parts, word), initialParts);
}
function matchWordInParts(parts: TextMatch[], word: string): TextMatch[] {
return parts.reduce((result, part) => {
if (part.match) {
result.push(part);
return result;
}
const { text } = part;
const index = text.toLowerCase().indexOf(word);
if (index >= 0) {
const before = text.slice(0, index);
const inner = text.slice(index, index + word.length);
const after = text.slice(index + word.length);
if (before.length > 0) result.push({ text: before });
if (inner.length > 0) result.push({ text: inner, match: word });
if (after.length > 0) result.push({ text: after });
return result;
}
result.push({ text });
return result;
}, [] as TextMatch[]);
}
function splitQuery(text: string): string[] {
return text.toLowerCase().split(' ');
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { matchString } from './HighlightQuery.utils';
describe('matchString', () => {
it('returns the whole text as a single non-match when nothing matches', () => {
expect(matchString('MariaDB Cloud', 'postgres')).toEqual([{ text: 'MariaDB Cloud' }]);
});
it('highlights a single matching word', () => {
expect(matchString('MariaDB Cloud', 'cloud')).toEqual([
{ text: 'MariaDB ' },
{ text: 'Cloud', match: 'cloud' },
]);
});
it('coalesces adjacent word matches into one highlight span', () => {
// "maria db" matches "MariaDB" as two touching tokens; they must render
// as a single highlight, not two overlapping pills.
expect(matchString('MariaDB Cloud', 'maria db cloud')).toEqual([
{ text: 'MariaDB', match: 'mariadb' },
{ text: ' ' },
{ text: 'Cloud', match: 'cloud' },
]);
});
it('keeps non-adjacent matches as separate spans', () => {
expect(matchString('MariaDB is a cloud database', 'maria cloud')).toEqual([
{ text: 'Maria', match: 'maria' },
{ text: 'DB is a ' },
{ text: 'cloud', match: 'cloud' },
{ text: ' database' },
]);
});
});
@@ -0,0 +1,65 @@
export interface TextMatch {
text: string;
match?: string;
}
/**
* Split `text` into consecutive parts, flagging the ones that match `query`.
*/
export function matchString(text: string, query: string): TextMatch[] {
const words = splitQuery(query);
const initialParts = [{ text }];
const parts = words.reduce((parts, word) => matchWordInParts(parts, word), initialParts);
return coalesceAdjacentMatches(parts);
}
function matchWordInParts(parts: TextMatch[], word: string): TextMatch[] {
return parts.reduce((result, part) => {
if (part.match) {
result.push(part);
return result;
}
const { text } = part;
const index = text.toLowerCase().indexOf(word);
if (index >= 0) {
const before = text.slice(0, index);
const inner = text.slice(index, index + word.length);
const after = text.slice(index + word.length);
if (before.length > 0) result.push({ text: before });
if (inner.length > 0) result.push({ text: inner, match: word });
if (after.length > 0) result.push({ text: after });
return result;
}
result.push({ text });
return result;
}, [] as TextMatch[]);
}
/**
* Multi-word queries match each word independently, so a phrase like "maria db"
* against "MariaDB" produces two touching match parts. Rendered as separate
* spans their negative margins and rounded corners overlap into a doubled pill,
* so contiguous matches are merged into one highlight span.
*/
function coalesceAdjacentMatches(parts: TextMatch[]): TextMatch[] {
return parts.reduce((result, part) => {
const previous = result[result.length - 1];
if (part.match && previous?.match) {
previous.text += part.text;
previous.match += part.match;
return result;
}
result.push({ ...part });
return result;
}, [] as TextMatch[]);
}
function splitQuery(text: string): string[] {
return text.toLowerCase().split(' ');
}
@@ -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)
@@ -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;
}
+12 -1
View File
@@ -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),
},
{},
{
+6 -1
View File
@@ -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
View File
@@ -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;
}
+34 -4
View File
@@ -36,6 +36,7 @@ import {
getPreviewRequestIdentifier,
isPreviewRequest,
} from '@/lib/preview';
import { shouldRenderSiteOAuthConsent } from '@/lib/site-oauth/flag';
import {
type ResponseCookies,
getPathScopedCookieName,
@@ -194,10 +195,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 +551,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);
};
@@ -731,6 +756,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) {
@@ -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?.(),
]);
}
/**
+19
View File
@@ -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