Compare commits

..

12 Commits

Author SHA1 Message Date
Nolann Biron d25cc924e4 Gate AIChatProvider 2026-07-16 10:58:03 +02:00
Nolann Biron 68e50d68e5 revert link + re-add optimizePackage 2026-07-16 10:13:07 +02:00
Nolann Biron 46b43c4372 prefetch TOC on hover 2026-07-16 09:58:10 +02:00
Nolann Biron f8fd5e2bb4 revert some changes 2026-07-16 09:30:39 +02:00
Nolann Biron 9ab6cfb878 quick review 2026-07-16 00:16:17 +02:00
Nolann Biron c9a637e0d9 fix openapi styling 2026-07-15 23:03:23 +02:00
Nolann Biron c16ab0a2ce fixes 2026-07-15 22:27:53 +02:00
Nolann Biron fe20392d21 changeset 2026-07-15 21:15:42 +02:00
Nolann Biron 8a81d77151 Trim client bundle via optimizePackageImports and a modern browserslist
Enable Next.js optimizePackageImports for motion, @gitbook/icons and react-aria so barrel imports tree-shake to only the used entrypoints. Raise the browserslist target to Chrome 93 / Edge 93 / Firefox 92 / Safari 15.4 so modern syntax (Array.prototype.at, Object.hasOwn, etc.) is no longer down-compiled and polyfilled across the whole client bundle.
2026-07-15 21:15:23 +02:00
Nolann Biron 55ac341cc9 Lazy-load OpenAPI and ContentKit stylesheets
The OpenAPI/Scalar stylesheet (~148KB raw, unpurgeable) and the ContentKit stylesheet were statically imported by the block dispatcher, so they shipped in every page's render-blocking CSS regardless of whether the page used those blocks. They now load on demand only when an OpenAPI or integration block renders, mirroring the existing KaTeX CSS pattern.
2026-07-15 21:15:23 +02:00
Nolann Biron 2f5b0a7f8a Lazy-load the admin toolbar for anonymous visitors
The admin toolbar (and the Framer Motion it pulls in) shipped to every visitor and only decided client-side whether to render, so anonymous readers downloaded and executed it just to render nothing. It is now split behind a lightweight gate that dynamically imports the full toolbar only for editor/change-request/revision views and authenticated organization members.
2026-07-15 21:15:23 +02:00
Nolann Biron 99f751c060 Defer search index loading until search is opened
Every page previously preloaded and fetched the whole-site search index (~441KB) plus FlexSearch on load, even when search was never used. The index and FlexSearch now load on first search intent (open/query), the results UI is code-split, and the index payload dedupes repeated breadcrumbs into a shared table (version 2, backward-compatible with version 1).
2026-07-15 21:15:23 +02:00
80 changed files with 1436 additions and 2761 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix vertically off-center icon next to breadcrumb text.
-7
View File
@@ -1,7 +0,0 @@
---
"@gitbook/openapi-parser": patch
"@gitbook/react-openapi": patch
"gitbook": patch
---
Add missing link reference to OpenAPI models
@@ -1,5 +0,0 @@
---
"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.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Reduce the JavaScript and CSS loaded on published site pages: the search index and its UI now load only when search is opened, and the admin toolbar and OpenAPI/ContentKit styles are no longer shipped to every visitor.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
-5
View File
@@ -1,5 +0,0 @@
---
"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
@@ -1,6 +0,0 @@
---
"@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,7 +53,6 @@ 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
@@ -69,8 +68,6 @@ 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,7 +55,6 @@ 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
@@ -85,8 +84,6 @@ 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,6 +282,11 @@ 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',
+10 -2
View File
@@ -37,6 +37,16 @@ const nextConfig = {
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
// Tree-shake barrel imports from these packages so only the used entrypoints ship
// in the client bundle (notably `motion`, which is otherwise pulled in wholesale).
optimizePackageImports: [
'motion',
'@gitbook/icons',
'react-aria',
'react-aria-components',
'react-stately',
],
},
env: {
@@ -46,8 +56,6 @@ 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,
+1 -3
View File
@@ -138,9 +138,7 @@
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit"
},
"browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
],
"browserslist": ["chrome >= 93, edge >= 93, firefox >= 92, safari >= 15.4, not dead"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
@@ -1,20 +0,0 @@
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>
);
}
@@ -1,67 +0,0 @@
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;
}
}
@@ -0,0 +1,616 @@
'use client';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AIChatReference } from './references';
import { serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import {
AIChatControllerContext,
type AIChatEvent,
getDefaultAIChatMessageActivity,
globalAIChatState as globalState,
updateAIChatMessageActivity,
} from './useAIChat';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
'type'
>;
// The assistant's tools and controls pull in zod (~270KB chunk); load them on demand so the
// provider itself stays light and the chunk is only fetched on AI-enabled sites.
function importAITooling() {
return Promise.all([import('./tools'), import('./controls/ConfirmControl')]);
}
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/**
* Provide the controller to interact with the AI chat.
*/
export function AIChatProvider(props: {
renderMessageOptions?: RenderAIMessageOptions;
children: React.ReactNode;
}) {
const { renderMessageOptions, children } = props;
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
const navigateToPageTool = useNavigateToPageTool();
// Warm the tools/controls chunk in the background so the first message doesn't pay its
// download latency. Only runs on AI-enabled sites since the provider is gated.
React.useEffect(() => {
void importAITooling();
}, []);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]);
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
// Stream a message with the AI backend
const streamResponse = React.useCallback(
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
globalState.setState((state) => {
return {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
...state.messages,
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const [{ getTools }] = await importAITooling();
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
try {
const result = await toolDef.execute(event.toolCall.input);
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
} catch (error) {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
error: error instanceof Error ? error.message : 'Unknown error',
},
summary: {
icon: 'bomb',
text: 'An error occurred while executing the tool',
},
},
});
}
};
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const [{ getTools }, { ConfirmControlDef, ConfirmControlOutputSchema }] =
await importAITooling();
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
},
});
// Process streaming response
for await (const data of stream) {
if (!data) continue;
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
const event = data.event;
switch (event.type) {
case 'response_finish': {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
responding: false,
error: false,
}));
break;
}
case 'response_followup_suggestion': {
globalState.setState((state) => ({
...state,
followUpSuggestions: [
...state.followUpSuggestions,
...event.suggestions,
],
}));
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
icon: confirmation.icon,
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
break;
}
toolToExecute = event;
break;
}
}
// Update the assistant message with streamed content
globalState.setState((state) => ({
...state,
messages: [
...state.messages.slice(0, -1),
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
}
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
// Turn settled: send the next queued follow-up (oldest first). Held back while a
// control is pending, since posting would throw; it flushes after that resolves.
const { queuedMessages, control: activeControl } = globalState.getState();
const [next, ...rest] = queuedMessages;
if (next !== undefined && !activeControl) {
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
postMessageRef.current?.({ message: next });
}
}
} catch (error) {
console.error('Error streaming AI response', error);
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
messageContextRef.current,
renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
if (responding) {
globalState.setState((state) => ({
...state,
queuedMessages: [...state.queuedMessages, input.message],
}));
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message && references.length === 0) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
}));
return;
}
trackEvent({ type: 'ask_question', query: input.message });
// Add user message and placeholder for AI response
globalState.setState((state) => {
return {
...state,
messages: [
...state.messages,
{
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
postMessageRef.current = onPostMessage;
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
const onCancelQueuedMessage = React.useCallback((index: number) => {
globalState.setState((state) =>
index < 0 || index >= state.queuedMessages.length
? state
: {
...state,
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
}
);
}, []);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
followUpSuggestions: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
references: [],
queuedMessages: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
cancelQueuedMessage: onCancelQueuedMessage,
on: onEvent,
};
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onCancelQueuedMessage,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
{children}
</AIChatControllerContext.Provider>
);
}
+24 -595
View File
@@ -2,28 +2,10 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { AIMessageRole, AIMessageStepPhase, type AIStreamResponse } from '@gitbook/api';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import type { AIChatReference } from './references';
export type AIChatMessage = {
role: AIMessageRole;
@@ -145,8 +127,6 @@ type AIChatEventData<T extends AIChatEvent['type']> = Omit<
'type'
>;
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
@@ -175,10 +155,10 @@ export type AIChatController = {
) => () => void;
};
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
export const AIChatControllerContext = React.createContext<AIChatController | null>(null);
// Global state store for AI chat
const globalState = zustand.create<AIChatState>(() => {
export const globalAIChatState = zustand.create<AIChatState>(() => {
return {
opened: false,
responseId: null,
@@ -200,576 +180,28 @@ const globalState = zustand.create<AIChatState>(() => {
* Get the current state of the AI chat.
*/
export function useAIChatState(): AIChatState {
const state = zustand.useStore(globalState);
const state = zustand.useStore(globalAIChatState);
return state;
}
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/**
* Provide the controller to interact with the AI chat.
* Inert controller returned when no AIChatProvider is mounted (AI chat disabled for the site).
* Lets always-mounted consumers (search, page actions, …) call the hook unconditionally without
* pulling the chat runtime into their bundle or throwing at render time.
*/
export function AIChatProvider(props: {
renderMessageOptions?: RenderAIMessageOptions;
children: React.ReactNode;
}) {
const { renderMessageOptions, children } = props;
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
const navigateToPageTool = useNavigateToPageTool();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]);
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
// Stream a message with the AI backend
const streamResponse = React.useCallback(
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
globalState.setState((state) => {
return {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
...state.messages,
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
try {
const result = await toolDef.execute(event.toolCall.input);
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
} catch (error) {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
error: error instanceof Error ? error.message : 'Unknown error',
},
summary: {
icon: 'bomb',
text: 'An error occurred while executing the tool',
},
},
});
}
};
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
},
});
// Process streaming response
for await (const data of stream) {
if (!data) continue;
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
const event = data.event;
switch (event.type) {
case 'response_finish': {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
responding: false,
error: false,
}));
break;
}
case 'response_followup_suggestion': {
globalState.setState((state) => ({
...state,
followUpSuggestions: [
...state.followUpSuggestions,
...event.suggestions,
],
}));
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
icon: confirmation.icon,
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
break;
}
toolToExecute = event;
break;
}
}
// Update the assistant message with streamed content
globalState.setState((state) => ({
...state,
messages: [
...state.messages.slice(0, -1),
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
}
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
// Turn settled: send the next queued follow-up (oldest first). Held back while a
// control is pending, since posting would throw; it flushes after that resolves.
const { queuedMessages, control: activeControl } = globalState.getState();
const [next, ...rest] = queuedMessages;
if (next !== undefined && !activeControl) {
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
postMessageRef.current?.({ message: next });
}
}
} catch (error) {
console.error('Error streaming AI response', error);
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
messageContextRef.current,
renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
if (responding) {
globalState.setState((state) => ({
...state,
queuedMessages: [...state.queuedMessages, input.message],
}));
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message && references.length === 0) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
}));
return;
}
trackEvent({ type: 'ask_question', query: input.message });
// Add user message and placeholder for AI response
globalState.setState((state) => {
return {
...state,
messages: [
...state.messages,
{
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
postMessageRef.current = onPostMessage;
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
const onCancelQueuedMessage = React.useCallback((index: number) => {
globalState.setState((state) =>
index < 0 || index >= state.queuedMessages.length
? state
: {
...state,
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
}
);
}, []);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
followUpSuggestions: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
references: [],
queuedMessages: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
cancelQueuedMessage: onCancelQueuedMessage,
on: onEvent,
};
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onCancelQueuedMessage,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
{children}
</AIChatControllerContext.Provider>
);
}
const NOOP_AI_CHAT_CONTROLLER: AIChatController = {
open: () => {},
close: () => {},
postMessage: () => {},
clear: () => {},
addReference: (ref) => ref.id,
removeReference: () => {},
clearReferences: () => {},
focus: () => {},
setDraft: () => {},
cancelQueuedMessage: () => {},
on: () => () => {},
};
/**
* Get the controller to interact with the AI chat.
@@ -777,10 +209,7 @@ export function AIChatProvider(props: {
*/
export function useAIChatController(): AIChatController {
const controller = React.useContext(AIChatControllerContext);
if (!controller) {
throw new Error('useAIChatController must be used within an AIChatProvider');
}
return controller;
return controller ?? NOOP_AI_CHAT_CONTROLLER;
}
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
@@ -823,7 +252,7 @@ function getLatestAssistantMessage(messages: AIChatMessage[]) {
return null;
}
function updateAIChatMessageActivity(
export function updateAIChatMessageActivity(
activity: AIChatMessageActivity,
event: AIStreamResponse
): AIChatMessageActivity {
@@ -849,7 +278,7 @@ function updateAIChatMessageActivity(
}
}
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
export function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
return {
currentPhase: undefined,
toolCount: 0,
@@ -6,19 +6,32 @@ import type { AIToolDefinition } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { NavigationStatusContext } from '../hooks';
import { normalizePathname, resolveNavigationTarget } from './navigation';
import { resolveAINavigationLink } from './server-actions';
const NavigateToPageInputSchema = z.object({
url: z
.string()
.describe(
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
),
});
// Hand-written JSON Schema: this hook runs in the always-mounted provider, so pulling zod +
// zod-to-json-schema here would keep the entire zod chunk eager for every visitor.
const NAVIGATE_TO_PAGE_INPUT_SCHEMA = {
type: 'object',
properties: {
url: {
type: 'string',
description:
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).',
},
},
required: ['url'],
additionalProperties: false,
} as AIToolDefinition['inputSchema'];
function parseNavigateToPageInput(input: unknown): { url: string } {
const url = (input as { url?: unknown } | null | undefined)?.url;
if (typeof url !== 'string') {
throw new Error('Invalid input for navigateToPage: expected { url: string }');
}
return { url };
}
/**
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
@@ -70,12 +83,10 @@ export function useNavigateToPageTool(): GitBookIntegrationTool {
name: 'navigateToPage',
description:
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
inputSchema: zodToJsonSchema(
NavigateToPageInputSchema as any
) as AIToolDefinition['inputSchema'],
inputSchema: NAVIGATE_TO_PAGE_INPUT_SCHEMA,
execute: async (input) => {
const { router, language, onNavigationClick } = ref.current;
const { url } = NavigateToPageInputSchema.parse(input);
const { url } = parseNavigateToPageInput(input);
// The assistant references pages using the stable content-ref scheme
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
@@ -4,6 +4,7 @@ import fnv1a from '@sindresorhus/fnv1a';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { t, tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -23,6 +24,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
const language = useLanguage();
const chatController = useAIChatController();
if (!isAIChatEnabled(config.aiMode)) {
return null;
}
const onClick = () => {
const text = content.trim();
if (!text) {
@@ -1,377 +1,32 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import dynamic from 'next/dynamic';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
import type { AdminToolbarClientProps } from './types';
// Loaded on demand so its Framer Motion + toolbar UI never ship in the main client chunk.
// Anonymous public visitors — who can never see the toolbar — pay nothing.
const AdminToolbarFull = dynamic(
() => import('./AdminToolbarFull').then((mod) => mod.AdminToolbarFull),
{ ssr: false }
);
/**
* Lightweight gate deciding whether the admin toolbar can appear for this viewer, before
* loading any of its heavy UI. It renders for editor contexts (change request / prior revision)
* and for authenticated members of the organization owning the site; for everyone else it renders
* nothing and the full toolbar bundle is never requested.
*/
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const { context } = props;
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
closeSession,
closePersistent,
shouldAutoExpand,
};
const isEditorContext =
Boolean(context.changeRequest) || context.revisionId !== context.space.revision;
const isOrgMember = visitor?.organizationId === context.organizationId;
if (hidden) {
if (!isEditorContext && !isOrgMember) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
return null;
}
/**
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
*/
export function ToolbarControlsWrapper(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsProvider value={value}>
<IframeWrapper>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open published/live site */}
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
return <AdminToolbarFull {...props} />;
}
@@ -0,0 +1,382 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
/**
* The full toolbar UI. Pulls in Framer Motion and every toolbar variant, so it is only
* loaded (via a dynamic import in AdminToolbarClient) for viewers who can actually see it —
* never for anonymous public visitors.
*/
export function AdminToolbarFull(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
closeSession,
closePersistent,
shouldAutoExpand,
};
if (hidden) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
return null;
}
/**
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
*/
export function ToolbarControlsWrapper(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsProvider value={value}>
<IframeWrapper>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open published/live site */}
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
}
@@ -0,0 +1,9 @@
'use client';
import { createLazyStylesheet } from '../createLazyStylesheet';
/**
* Lazy-loads the ContentKit stylesheet so it only downloads on pages that actually render
* an integration block, instead of shipping in every page's CSS chunk.
*/
export default createLazyStylesheet(() => import('./contentkit.css'));
@@ -6,7 +6,6 @@ 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>>;
@@ -18,20 +17,18 @@ export type WebframeLinkerData = Pick<
/**
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
* the current page, navigation to other pages, and adaptive visitor claims (only when the
* integration is allowed to access them).
* 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, page, linkerData, ...contentKitProps } = props;
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
@@ -59,7 +56,6 @@ 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).
@@ -67,7 +63,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + suffix);
},
}),
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -2,19 +2,22 @@ import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import React from 'react';
import type { BlockProps } from '../Block';
import './contentkit.css';
import type { GitBookLinker } from '@/lib/links';
import type { BlockProps } from '../Block';
import {
ContentKitWithClientContext,
type WebframeLinkerData,
} from './ContentKitWithClientContext';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { integrationBlockContainsWebframe } from './adaptive';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
// Lazy so the ContentKit CSS is only fetched on pages that render an integration block.
const ContentKitStyles = React.lazy(() => import('./ContentKitStyles'));
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
const { block, context, style } = props;
@@ -77,11 +80,8 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// 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.
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
// visitor claims when the integration is allowed them.
const useClientContext = containsWebframe;
const contentKitProps = {
@@ -105,11 +105,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
<ContentKitStyles />
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
@@ -1,9 +1,7 @@
import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { integrationBlockContainsWebframe } from './adaptive';
const webframe: ContentKitWebFrame = {
type: 'webframe',
@@ -40,47 +38,3 @@ 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,4 +1,3 @@
import type { GitBookAnyContext } from '@/lib/context';
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
@@ -8,19 +7,9 @@ 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, visitor claims and/or the current page).
* (navigation and/or visitor claims).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
@@ -30,31 +19,6 @@ 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.
*/
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi block or an openapi-operation block.
@@ -14,6 +14,7 @@ export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlo
const { style } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPIOperationBody {...props} />
</div>
);
@@ -4,7 +4,7 @@ import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi-schemas block.
@@ -13,6 +13,7 @@ export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
const { style } = props;
return (
<div className={tcls('flex w-full', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPISchemasBody {...props} />
</div>
);
@@ -0,0 +1,9 @@
'use client';
import { createLazyStylesheet } from '../createLazyStylesheet';
/**
* Lazy-loads the OpenAPI/Scalar stylesheet. Kept out of the static import graph so the
* ~148KB Scalar CSS only downloads on pages that actually render an OpenAPI block.
*/
export default createLazyStylesheet(() => import('./style.css'));
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi block or an openapi-webhook block.
@@ -14,6 +14,7 @@ export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
const { style } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPIWebhookBody {...props} />
</div>
);
@@ -1,13 +1,13 @@
import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
import React from 'react';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import './style.css';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import type { GitBookAnyContext } from '@/lib/context';
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
@@ -17,6 +17,12 @@ import type {
OpenAPIWebhookBlock,
} from '@/lib/openapi/types';
/**
* Lazy loader for the OpenAPI/Scalar stylesheet, rendered by each OpenAPI block so the CSS
* is only fetched on pages that use one.
*/
export const OpenAPIStyles = React.lazy(() => import('./OpenAPIStyles'));
/**
* Get the OpenAPI context to render a block.
*/
@@ -52,7 +58,6 @@ export function getOpenAPIContext(args: {
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
mcp: <Icon icon="mcp" />,
hashtag: <Icon icon="hashtag" />,
},
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
@@ -116,52 +116,8 @@ button.openapi-mcp {
@apply !mb-0;
}
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
/* Method / status-code tag styles moved to `./tags.css`, loaded globally so the sidebar method
* badges are styled even before an OpenAPI block mounts this deferred stylesheet. */
/* URL */
.openapi-url {
@@ -865,30 +821,6 @@ 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;
}
@@ -989,7 +921,7 @@ body:has(.openapi-select-popover) {
}
}
.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg {
.openapi-disclosure-trigger[aria-expanded="true"] svg {
@apply rotate-45;
}
@@ -0,0 +1,56 @@
/*
* OpenAPI method / status-code tag styles.
*
* These live outside the deferred `style.css` and are loaded from the global stylesheet because
* the HTTP method badge (`OpenAPIMethodBadge`) renders in the always-present sidebar (table of
* contents) on every page — not only on pages that mount an OpenAPI block. If they stayed in the
* lazily-loaded stylesheet, the sidebar badges would be unstyled until an OpenAPI page pulled in
* the heavy Scalar CSS.
*/
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -0,0 +1,18 @@
'use client';
/**
* Factory for a fire-and-forget loader of a code-split stylesheet: the CSS only downloads on
* pages that render the associated block, instead of shipping in every page's CSS chunk. The
* returned component renders nothing.
*/
export function createLazyStylesheet(load: () => Promise<unknown>) {
let loaded = false;
return function LazyStylesheet() {
// Load during render (not in an effect) so the request starts as early as possible.
if (!loaded && typeof window !== 'undefined') {
loaded = true;
load();
}
return null;
};
}
@@ -47,10 +47,7 @@ export function BreadcrumbItemDropdown(props: {
const content = (
<>
{emoji || icon ? (
<PageIcon
page={{ emoji, icon }}
style="mr-1 inline size-[1em] shrink-0 align-middle"
/>
<PageIcon page={{ emoji, icon }} style="mr-1 inline size-[1em] shrink-0" />
) : null}
{label}
</>
@@ -4,6 +4,10 @@
@import "./prose.css";
/* OpenAPI method/status-code tags render in the always-present sidebar, so their styles must
ship globally instead of in the deferred OpenAPI stylesheet. */
@import "../DocumentView/OpenAPI/tags.css";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -17,6 +17,8 @@ import { SearchScopeControl } from './SearchScopeControl';
import type { SearchBaseProps } from './search-props';
import { useSearchController } from './useSearchController';
// The results panel (and its ranking/AI code) only appears once search is used, so load it on
// demand instead of shipping it in every page's client bundle.
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
ssr: false,
});
@@ -117,20 +119,8 @@ 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 || wasSearchOpened)
? Boolean(state?.open || state?.query || withAI)
: 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, score = 0): OrderedComputedResult {
function remotePage(id: string, title = id): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
@@ -22,7 +22,7 @@ function remotePage(id: string, title = id, score = 0): OrderedComputedResult {
spaceId: 'space',
title,
href: `/${id}`,
score,
score: 0,
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 order of the first
* Reciprocal Rank Fusion (RRF), while preserving the API order for the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
@@ -37,12 +37,7 @@ export type ComputedRecordResult = BaseComputedResult & {
export type SearchSiteContentScope =
| { mode: 'all' }
| {
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: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
export interface SearchSiteContentRequest {
@@ -1,79 +0,0 @@
'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;
}
@@ -2,13 +2,26 @@
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
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[];
}
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
@@ -28,7 +41,7 @@ export interface LocalPageResult {
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
breadcrumbs?: Breadcrumb[];
}
type LocalSearchState = {
@@ -45,14 +58,14 @@ 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?: SiteIndexBreadcrumb[] }
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
pages: RawIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
@@ -100,14 +113,17 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
}
pendingFetch = (async () => {
// 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),
]);
// Load FlexSearch lazily so its code lands in an on-demand chunk instead of the
// main client bundle — it's only needed once the user actually searches.
const [{ Document }, response] = await Promise.all([import('flexsearch'), fetch(indexURL)]);
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
const data: { version: 1; pages: RawIndexPage[] } = await response.json();
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, SiteIndexPage[]>();
const pagesByLang = new Map<string, RawIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
@@ -123,8 +139,6 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
@@ -147,10 +161,11 @@ 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;
/** Whether search is active (opened or has a query). The whole-site index is only
* fetched/built once this is true, so an idle page never downloads it. */
active?: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, active = true } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
@@ -161,17 +176,13 @@ 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
// Load the indexes once search becomes active (opened or queried).
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
@@ -200,7 +200,9 @@ export function useSearchController(props: SearchBaseProps) {
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(state?.query || withAI),
open: Boolean(state?.open),
// Only load the local search index once the user shows intent (opens search
// or has a query). Avoids fetching the whole-site index on every page view.
active: Boolean(state?.open || state?.query),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -8,7 +8,7 @@ import {
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import type { OrderedComputedResult } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { useAI } from '@/components/AI';
@@ -28,9 +28,6 @@ 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
@@ -45,8 +42,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;
/** Whether the search surface is active (opened or has a query). Gates loading of the local index. */
active: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -54,7 +51,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/site-index')). */
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
@@ -64,7 +61,7 @@ export function useSearchResults(props: {
const {
asEmbeddable,
disabled,
open,
active,
query,
siteSpaceId,
siteSpaceIds,
@@ -88,16 +85,15 @@ export function useSearchResults(props: {
indexURL,
lang,
disabled,
open,
active,
filterSiteSpaceIds,
});
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
otherSpacesResults: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], otherSpacesResults: [], fetching: false, error: false });
}>({ results: [], 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.
@@ -113,12 +109,7 @@ export function useSearchResults(props: {
}
if (!query) {
if (!withAI) {
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
setRemoteState({ results: [], fetching: false, error: false });
return;
}
@@ -129,21 +120,11 @@ export function useSearchResults(props: {
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
setRemoteState({ results: [], fetching: false, error: false });
return;
}
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
setRemoteState({ results: [], fetching: false, error: false });
let cancelled = false;
@@ -156,12 +137,7 @@ export function useSearchResults(props: {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
setRemoteState({ results: [], fetching: false, error: false });
return;
}
@@ -187,12 +163,7 @@ export function useSearchResults(props: {
if (!cancelled) {
// Recommended questions are handled via a separate path below
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
setRemoteState({ results: [], fetching: false, error: false });
}
}
}, 100);
@@ -204,121 +175,67 @@ export function useSearchResults(props: {
}
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: true,
error: false,
});
let cancelled = false;
const abortController = new AbortController();
const timeout = setTimeout(async () => {
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[]>;
} => {
const results = await (() => {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(
searchURL,
scope,
query,
abortController.signal,
asEmbeddable
);
switch (scope) {
case 'all':
// Search all content on the site
return { resultsPromise: fetchSearch({ mode: 'all' }) };
return fetchSearch({ mode: 'all' });
case 'default':
// 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',
}),
};
// Search the current section's variant + matched/default variant for other sections
return fetchSearch({ mode: 'current', siteSpaceId });
case 'extended':
// Search all variants of the current section
return {
resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }),
};
return fetchSearch({ mode: 'specific', siteSpaceIds });
case 'current':
// Search only the current section's current variant
return {
resultsPromise: fetchSearch({
mode: 'specific',
siteSpaceIds: [siteSpaceId],
}),
};
return 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;
}
setRemoteState((prev) => ({ ...prev, fetching: false }));
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,
});
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: true,
});
setRemoteState({ results: [], fetching: false, error: true });
}
}, 200);
@@ -345,7 +262,6 @@ export function useSearchResults(props: {
suggestions,
searchURL,
asEmbeddable,
withSections,
]);
const abort = React.useCallback(() => {
@@ -372,23 +288,10 @@ export function useSearchResults(props: {
});
}
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,
]);
const merged = reciprocalRankFusion(localResults, remoteState.results, query);
return merged;
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
return {
results,
@@ -403,7 +306,10 @@ export function useSearchResults(props: {
*/
async function fetchSearchResults(
searchURL: string,
scope: SearchSiteContentScope,
scope:
| { mode: 'all' }
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] },
query: string,
signal?: AbortSignal,
asEmbeddable?: boolean
@@ -425,16 +331,3 @@ 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,15 +39,6 @@ export async function SiteLayout(props: {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// 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 }) => {
ReactDOM.preload(script, {
as: 'script',
@@ -1,93 +0,0 @@
'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>
);
}
@@ -1,203 +0,0 @@
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>
);
}
@@ -1,39 +0,0 @@
'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.',
};
}
}
@@ -1 +0,0 @@
export { ConsentScreen, ConsentError } from './ConsentScreen';
@@ -11,7 +11,6 @@ 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,
@@ -230,21 +229,34 @@ 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` — 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).
* 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).
*/
async function getRelatedPages(
indexURL: string,
requestedPath: string,
siteSpaceId: string | null
): Promise<RelatedPage[]> {
const { pages } = await fetchSiteIndex(indexURL);
const response = await fetch(indexURL);
if (!response.ok) {
return [];
}
const { pages } = (await response.json()) as { pages: IndexPage[] };
return pages
.filter((page) => !siteSpaceId || page.siteSpaceId === siteSpaceId)
@@ -9,8 +9,10 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
// Import directly (not via the AI barrel) so the chat runtime stays out of the graph of every
// consumer of '../AI'; the provider itself is only mounted when AI chat is enabled.
import { AIChatProvider } from '../AI/AIChatProvider';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
@@ -88,9 +90,13 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
{isAIChatEnabled(customization.ai?.mode) ? (
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
) : (
children
)}
</InsightsProvider>
</VisitorProvider>
</CurrentContentProvider>
@@ -1,104 +0,0 @@
'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,9 +85,7 @@ function scrollToHash(hash: string) {
if (element) {
element.scrollIntoView({
block: 'start',
// 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',
behavior: 'smooth',
});
return true;
}
@@ -3,7 +3,7 @@
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { useScrollOverflow } from '../hooks/useScrollOverflow';
import { useScrollListener } from '../hooks/useScrollListener';
import { Button, type ButtonProps } from './Button';
/**
@@ -56,9 +56,50 @@ 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();
const { scrollPosition, scrollSize } = useScrollOverflow(orientation, containerRef);
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]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1,128 +0,0 @@
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 }]);
});
});
@@ -1,136 +0,0 @@
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`);
}
}
+1 -12
View File
@@ -5,7 +5,6 @@ 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';
@@ -786,17 +785,7 @@ const searchSiteContent = cache(
siteId,
{
query,
...(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),
...scope,
},
{},
{
+1 -6
View File
@@ -178,12 +178,7 @@ export interface GitBookDataFetcher {
query: string;
scope:
| { mode: 'all' }
| {
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: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] };
/** Cache bust to ensure the search results are fresh when the space is updated. */
cacheBust?: string;
@@ -8,11 +8,7 @@ mock.module('./openapi/resolveOpenAPIOperationBlock', () => ({
}));
mock.module('./openapi/resolveOpenAPISchemasBlock', () => ({
resolveOpenAPISchemasBlock: async ({ block }: { block: { data: { schemas?: string[] } } }) => ({
data: {
schemas: (block.data.schemas ?? []).map((name) => ({ name, schema: {} })),
},
}),
resolveOpenAPISchemasBlock: async () => ({ data: null }),
}));
const { getDocumentSections } = await import('./document-sections');
@@ -229,61 +225,4 @@ 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' },
]);
});
});
+21 -21
View File
@@ -1,6 +1,5 @@
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';
@@ -143,30 +142,31 @@ 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,
});
// 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));
const schema = data?.schemas[0];
if (schema) {
sections.push(
withTags(
{
id,
title: `The ${schema.name} object`,
depth: 1,
},
tags
)
);
}
continue;
}
-8
View File
@@ -132,14 +132,6 @@ 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(
@@ -1,32 +0,0 @@
/**
* 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);
}
@@ -1,136 +0,0 @@
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 -55
View File
@@ -11,11 +11,6 @@ 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';
@@ -41,7 +36,6 @@ import {
getPreviewRequestIdentifier,
isPreviewRequest,
} from '@/lib/preview';
import { shouldRenderSiteOAuthConsent } from '@/lib/site-oauth/flag';
import {
type ResponseCookies,
getPathScopedCookieName,
@@ -200,19 +194,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
if (siteOAuthAuthorizeMatch) {
const siteId = siteOAuthAuthorizeMatch.pathname.groups.siteId;
// 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());
}
const siteOAuthAuthorizeURL = new URL(oauthServerURL);
siteOAuthAuthorizeURL.pathname += `/${siteId}/authorize`;
siteOAuthAuthorizeURL.search = siteOAuthAuthorizeMatch.search.input.replace('?', '');
return NextResponse.redirect(siteOAuthAuthorizeURL.toString());
}
//
@@ -556,21 +541,6 @@ 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);
};
@@ -651,31 +621,22 @@ 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()),
getAPITokenResponseCookies({
cookies: requestCookies.getAll(),
cookieName,
apiToken: queryAPIToken,
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [
{
name: cookieName,
value: 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 = getAPITokenFromCookies(requestCookies.getAll(), cookieName);
const apiToken = requestCookies.get(cookieName)?.value;
return serve(apiToken ?? null);
}
@@ -770,11 +731,6 @@ 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) {
@@ -1,18 +0,0 @@
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');
});
});
-13
View File
@@ -2,19 +2,6 @@ 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, current page) as state to the webframe.
// Send data and client-only context (visitor claims) as state to the webframe.
React.useEffect(() => {
const abort = { cancelled: false };
sendWebframeState({
@@ -231,14 +231,10 @@ function resolveWebframeState(
}
/**
* Resolve the optional client-only contexts (visitor claims, current page)
* to merge into the webframe state.
* Resolve the optional client-only contexts (visitor claims) to merge into the webframe state.
*/
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
return await Promise.all([
clientContext?.getVisitorContext?.(),
clientContext?.getPageContext?.(),
]);
return await Promise.all([clientContext?.getVisitorContext?.()]);
}
/**
-19
View File
@@ -17,16 +17,6 @@ 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.
@@ -38,15 +28,6 @@ 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 { useEffect, useState } from 'react';
import { useState } from 'react';
import { Button, Disclosure, DisclosurePanel } from 'react-aria-components';
/**
@@ -14,36 +14,10 @@ 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, id } = props;
const { icon, header, label, children, className, defaultExpanded = false } = 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)}
-1
View File
@@ -17,7 +17,6 @@ export interface OpenAPIClientContext {
check: React.ReactNode;
lock: React.ReactNode;
mcp: React.ReactNode;
hashtag: React.ReactNode;
};
/**
@@ -8,45 +8,20 @@ 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, id } = props;
const { schema, context, name } = props;
return (
<OpenAPIDisclosure
className="openapi-schemas-disclosure"
key={name}
id={id}
icon={context.icons.plus}
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
)
}
header={name}
label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })}
defaultExpanded={context.expandAllModelSections}
>
@@ -1,4 +1,3 @@
import { getOpenAPISchemaAnchorId } from '@gitbook/openapi-parser';
import clsx from 'classnames';
import { OpenAPIExample } from '../OpenAPIExample';
import { OpenAPIRootSchema } from '../OpenAPISchemaServer';
@@ -42,9 +41,7 @@ export function OpenAPISchemas(props: {
const title = `The ${firstSchema.name} object`;
return (
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
{/* 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">
<div className="openapi-summary" id={context.id}>
{context.renderHeading({
title,
deprecated: Boolean(firstSchema.schema.deprecated),
@@ -91,7 +88,6 @@ export function OpenAPISchemas(props: {
return (
<OpenAPISchemaItem
key={name}
id={getOpenAPISchemaAnchorId(name)}
name={name}
context={clientContext}
schema={schema}
@@ -45,5 +45,4 @@ export const de = {
or: 'oder',
and: 'und',
possible_values: 'Mögliche Werte',
direct_link_to_model: 'Direktlink zu ${1}',
};
@@ -45,5 +45,4 @@ export const en = {
properties: 'Properties',
or: 'or',
and: 'and',
direct_link_to_model: 'Direct link to ${1}',
};
@@ -45,5 +45,4 @@ export const es = {
or: 'o',
and: 'y',
possible_values: 'Valores posibles',
direct_link_to_model: 'Enlace directo a ${1}',
};
@@ -45,5 +45,4 @@ export const fr = {
or: 'ou',
and: 'et',
possible_values: 'Valeurs possibles',
direct_link_to_model: 'Lien direct vers ${1}',
};
@@ -45,5 +45,4 @@ export const ja = {
or: 'または',
and: 'および',
possible_values: '可能な値',
direct_link_to_model: '${1} への直接リンク',
};
@@ -45,5 +45,4 @@ export const nl = {
or: 'of',
and: 'en',
possible_values: 'Mogelijke waarden',
direct_link_to_model: 'Directe link naar ${1}',
};
@@ -45,5 +45,4 @@ export const no = {
or: 'eller',
and: 'og',
possible_values: 'Mulige verdier',
direct_link_to_model: 'Direktelenke til ${1}',
};
@@ -45,5 +45,4 @@ export const pt_br = {
or: 'ou',
and: 'e',
possible_values: 'Valores possíveis',
direct_link_to_model: 'Link direto para ${1}',
};
@@ -45,5 +45,4 @@ export const zh = {
or: '或',
and: '和',
possible_values: '可能的值',
direct_link_to_model: '${1} 的直接链接',
};