mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-12 07:58:49 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d25cc924e4 | |||
| 68e50d68e5 | |||
| 46b43c4372 | |||
| f8fd5e2bb4 | |||
| 9ab6cfb878 | |||
| c9a637e0d9 | |||
| c16ab0a2ce | |||
| fe20392d21 | |||
| 8a81d77151 | |||
| 55ac341cc9 | |||
| 2f5b0a7f8a | |||
| 99f751c060 | |||
| c41cf9c12a | |||
| 3d376845d7 | |||
| bcd4d4e980 | |||
| 88b1919a45 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a carousel layout option to cards blocks, rendering them as a horizontally-scrolling, scroll-snapping row instead of a wrapping grid.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add cover image background mode and masks
|
||||
@@ -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,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="./assets/gitbook_icon_dark.svg" alt="GitBook" width="48" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="65" height="65" viewBox="0 0 65 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.3963 34.2195C30.5255 36.0255 32.09 36.9285 33.8082 36.93C35.5265 36.9315 37.0926 36.0313 40.2249 34.2308L60.1913 22.7534C61.0927 22.2353 61.6484 21.2749 61.6484 20.2352C61.6484 19.1955 61.0927 18.2351 60.1913 17.717L40.2177 6.23554C37.0888 4.43695 35.5243 3.53766 33.8078 3.53833C32.0912 3.539 30.5275 4.43951 27.4 6.24053L10.2292 16.1286C10.102 16.2019 10.0383 16.2385 9.97905 16.2732C4.11368 19.7068 0.489862 25.9754 0.441408 32.7717C0.440918 32.8404 0.440918 32.9138 0.440918 33.0607C0.440918 33.2074 0.440918 33.2807 0.441407 33.3494C0.489754 40.138 4.10549 46.4008 9.96041 49.837C10.0196 49.8718 10.0831 49.9085 10.2101 49.9818L20.9658 56.1918C27.2331 59.8104 30.3668 61.6197 33.808 61.6208C37.2493 61.622 40.3842 59.8148 46.6539 56.2005L58.008 49.6551C61.1474 47.8454 62.7171 46.9405 63.579 45.4487C64.4409 43.957 64.4409 42.1451 64.4409 38.5215V31.5212C64.4409 30.5159 63.8965 29.5895 63.0182 29.1004C62.1683 28.627 61.1325 28.6341 60.2891 29.1189L37.0074 42.5019C35.4453 43.3998 34.6643 43.8487 33.8072 43.849C32.9501 43.8493 32.1688 43.4008 30.6062 42.5038L14.8487 33.4586C14.0593 33.0055 13.6647 32.7789 13.3477 32.738C12.625 32.6448 11.9301 33.0497 11.6548 33.7244C11.534 34.0203 11.5365 34.4753 11.5414 35.3855C11.545 36.0555 11.5468 36.3905 11.6094 36.6987C11.7496 37.3887 12.1127 38.0136 12.6428 38.4771C12.8795 38.6842 13.1696 38.8516 13.7499 39.1866L30.5974 48.9103C32.164 49.8145 32.9473 50.2666 33.8075 50.2668C34.6677 50.267 35.4512 49.8154 37.0184 48.912L57.6684 37.0085C58.2037 36.7 58.4713 36.5457 58.672 36.6616C58.8727 36.7776 58.8727 37.0865 58.8727 37.7044V40.8796C58.8727 41.7855 58.8727 42.2385 58.6572 42.6114C58.4417 42.9844 58.0493 43.2106 57.2644 43.663L40.2322 53.4811C37.0966 55.2885 35.5288 56.1923 33.8078 56.1915C32.0869 56.1907 30.5199 55.2855 27.386 53.4752L11.4509 44.2701C11.4003 44.2409 11.375 44.2262 11.3514 44.2125C8.0102 42.26 5.94856 38.6882 5.92922 34.8185C5.92909 34.7911 5.92909 34.7619 5.92909 34.7035V31.7889C5.92909 29.6526 7.06686 27.678 8.9151 26.6067C10.5483 25.66 12.5628 25.6582 14.1977 26.6018L27.3963 34.2195Z" fill="#181C1F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -360,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.188.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -756,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.188.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-kZHpDM3XJdf1oZKhxYk3OZsjNHR4ZJrF47JgUxintnEvsW1yS8UvZlq6+dxgalL1k9wGmz3XXhiNv+p7p6egsA=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.188.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -36,6 +36,14 @@ export interface DocumentContext {
|
||||
* @default false
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* True when this document is the main page body — the only place a background page cover is
|
||||
* shown behind the content. Cover-aware text styling (contrast over the cover) is scoped to it
|
||||
* so auxiliary documents (search answers, AI chat, PDF export) don't inherit the cover colors.
|
||||
* @default false
|
||||
*/
|
||||
isPageBody?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
|
||||
@@ -59,6 +59,9 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
'justify-self-start',
|
||||
'max-w-full',
|
||||
'break-words',
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody && 'page-cover-background:text-contrast-cover',
|
||||
getTextAlignment(block.data.align),
|
||||
textStyle.lineHeight
|
||||
)}
|
||||
|
||||
@@ -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'));
|
||||
@@ -2,10 +2,10 @@ 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,
|
||||
@@ -15,6 +15,9 @@ 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;
|
||||
|
||||
@@ -102,6 +105,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKitStyles />
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,17 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
|
||||
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
|
||||
|
||||
const paragraph = (
|
||||
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
|
||||
<p
|
||||
className={tcls(
|
||||
// Cover-aware contrast text applies only to the page body, not to documents
|
||||
// rendered in overlays (search answers, AI chat) on a background-cover page.
|
||||
context.isPageBody &&
|
||||
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
|
||||
inlineButtonStyle,
|
||||
style,
|
||||
getTextAlignment(block.data?.align)
|
||||
)}
|
||||
>
|
||||
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DocumentTableViewCards } from '@gitbook/api';
|
||||
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordCard } from './RecordCard';
|
||||
@@ -7,6 +8,20 @@ import type { TableViewProps } from './Table';
|
||||
import { TableSearchRecord } from './TableSearch';
|
||||
|
||||
export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
// `wrap` defaults to `true` (a wrapping grid); only an explicit `false` opts into the
|
||||
// horizontally-scrolling carousel row. Fall back to the grid in print mode: a PDF can't
|
||||
// scroll, so carousel overflow would be silently clipped.
|
||||
if (props.view.wrap === false && props.context.mode !== 'print') {
|
||||
return <CardsCarousel {...props} />;
|
||||
}
|
||||
|
||||
return <CardsGrid {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default layout: cards wrap into a responsive grid.
|
||||
*/
|
||||
function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { block, view, records, style } = props;
|
||||
|
||||
return (
|
||||
@@ -35,3 +50,130 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The carousel layout: cards lay out in a single horizontally-scrolling row that
|
||||
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
|
||||
*
|
||||
* Rather than fading the edges, the row breaks out of the content column so it can
|
||||
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
|
||||
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
|
||||
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
|
||||
* See `bleedVars` below for how far each side reaches.
|
||||
*/
|
||||
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
|
||||
const { view, records } = props;
|
||||
|
||||
// Cards need a fixed width so the row overflows and scrolls; mirror the grid's
|
||||
// medium/large sizing.
|
||||
const cardWidth =
|
||||
view.cardSize === 'large'
|
||||
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
|
||||
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
|
||||
|
||||
// Break the row out of the content column so it bleeds to the page edges instead of
|
||||
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
|
||||
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
|
||||
// scroller), so the first/last cards stay aligned with the body text at rest while cards
|
||||
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
|
||||
//
|
||||
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
|
||||
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
|
||||
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
|
||||
// row past where every other block ends, jutting into the window frame.
|
||||
//
|
||||
// On the default layout:
|
||||
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
|
||||
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
|
||||
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
|
||||
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
|
||||
// `html` clips horizontal overflow, so a small overshoot is harmless.
|
||||
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
|
||||
// cards never slide under it.
|
||||
const bleedVars = tcls(
|
||||
'[--cards-bleed-l:1rem]',
|
||||
'sm:[--cards-bleed-l:1.5rem]',
|
||||
'md:[--cards-bleed-l:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
|
||||
'xl:[--cards-bleed-l:3rem]',
|
||||
|
||||
'[--cards-bleed-r:1rem]',
|
||||
'sm:[--cards-bleed-r:1.5rem]',
|
||||
'md:[--cards-bleed-r:2rem]',
|
||||
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
|
||||
'layout-default:xl:[--cards-bleed-r:3rem]',
|
||||
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
|
||||
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
|
||||
|
||||
// Default centered
|
||||
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
|
||||
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
|
||||
|
||||
// Full width, no outline
|
||||
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
|
||||
|
||||
// Full width centered
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
|
||||
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
orientation="horizontal"
|
||||
className={tcls(
|
||||
bleedVars,
|
||||
'ml-[calc(var(--cards-bleed-l)*-1)]',
|
||||
'mr-[calc(var(--cards-bleed-r)*-1)]',
|
||||
'xl:transition-[margin]',
|
||||
'hover:z-11'
|
||||
)}
|
||||
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
|
||||
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
|
||||
contentClassName={tcls(
|
||||
'gap-4',
|
||||
'pt-px',
|
||||
'-mt-px',
|
||||
'pb-6',
|
||||
'-mb-6',
|
||||
'pl-[var(--cards-bleed-l)]',
|
||||
'pr-[var(--cards-bleed-r)]',
|
||||
'scroll-pl-[var(--cards-bleed-l)]',
|
||||
'scroll-pr-[var(--cards-bleed-r)]',
|
||||
'snap-x',
|
||||
'snap-mandatory',
|
||||
'xl:transition-[padding]'
|
||||
)}
|
||||
leading={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
|
||||
}}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
|
||||
}}
|
||||
>
|
||||
{records.map((record) => {
|
||||
return (
|
||||
<TableSearchRecord
|
||||
key={record[0]}
|
||||
recordId={record[0]}
|
||||
// `grid grid-cols-1` stretches the card to fill the fixed-width,
|
||||
// equal-height track; `snap-start` aligns it to the left edge.
|
||||
visibleClassName={tcls(
|
||||
'grid',
|
||||
'grid-cols-1',
|
||||
'shrink-0',
|
||||
'snap-start',
|
||||
cardWidth
|
||||
)}
|
||||
>
|
||||
<RecordCard {...props} record={record} />
|
||||
</TableSearchRecord>
|
||||
);
|
||||
})}
|
||||
</ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -475,7 +475,10 @@ function PageActionWrapper(props: {
|
||||
variant="secondary"
|
||||
label={label ?? shortLabel}
|
||||
aria-label={shortLabel}
|
||||
className="bg-tint-base"
|
||||
// `relative z-20` keeps the button above the breadcrumbs, whose container can sit on
|
||||
// top of it over a page cover. `disabled:bg-tint-base` preserves the background while
|
||||
// busy — the `secondary` variant would otherwise reset it via `disabled:bg-transparent`.
|
||||
className="relative z-20 bg-tint-base disabled:bg-tint-base"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
target={href ? target : undefined}
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function PageAside(props: {
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
|
||||
'lg:z-10',
|
||||
'lg:hover:z-12',
|
||||
'layout-default:xl:not-chat-open:pr-0',
|
||||
'layout-default:xl:not-chat-open:pl-8',
|
||||
'layout-default:xl:not-chat-open:flex!',
|
||||
@@ -98,9 +99,16 @@ export async function PageAside(props: {
|
||||
'page-api-block:page-has-outline:min-[96rem]:border-l-0',
|
||||
'page-api-block:page-has-outline:min-[96rem]:pl-8',
|
||||
|
||||
'hydrated:site-background', // Only add a background once the element is positioned correctly to prevent overlapping the page cover
|
||||
// Only add a background once the element is positioned correctly, to prevent
|
||||
// overlapping the page cover. Kept opaque wherever the outline is a toggleable
|
||||
// overlay (below xl in any layout, and xl–3xl in wide layout, where it opens as
|
||||
// a desktop SideSheet), but dropped for the permanent outline column so a bleeding
|
||||
// cards carousel can scroll behind it.
|
||||
'max-xl:hydrated:site-background',
|
||||
'layout-wide:max-3xl:hydrated:site-background',
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong'
|
||||
'contrast-more:text-tint-strong',
|
||||
'xl:page-cover-background:text-contrast-cover'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
|
||||
|
||||
@@ -107,6 +107,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
'sidebar-list-line:border-l-2',
|
||||
'border-transparent',
|
||||
'sidebar-list-line:-left-px',
|
||||
'xl:page-cover-background:text-contrast-cover',
|
||||
|
||||
// The method badge no longer has a right margin, so lay the row out with a gap
|
||||
section.tag && ['flex', 'items-baseline', 'gap-2'],
|
||||
|
||||
@@ -93,6 +93,9 @@ export async function PageBody(props: {
|
||||
'py-8',
|
||||
'layout-wide:no-sidebar:lg:max-xl:pb-20', // Add padding to prevent overlap of minimised trademark
|
||||
'@container',
|
||||
// Flex column so the growing content wrapper below fills the page: the footer
|
||||
// navigation settles at the bottom, and a full-page cover shows behind the content.
|
||||
'flex flex-col',
|
||||
CONTENT_STYLE,
|
||||
pageHasToc ? 'page-has-toc' : 'page-no-toc',
|
||||
wideLayout ? 'layout-wide' : 'layout-default'
|
||||
@@ -104,37 +107,44 @@ export async function PageBody(props: {
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
<PageHeader
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
withRSSFeed={contentHasUpdates}
|
||||
hasAPIBlocks={hasAPIBlocks}
|
||||
/>
|
||||
{document && !isNodeEmpty(document) ? (
|
||||
<OptionalSuspense
|
||||
staticRoute={staticRoute}
|
||||
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
|
||||
>
|
||||
<SuspenseLoadedHint />
|
||||
<div className="contents" data-content-ref-root="">
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OptionalSuspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} context={context} />
|
||||
)}
|
||||
{/* Grows to fill the page (so the footer navigation below settles at the bottom) and
|
||||
stays a plain block — this gives the floated, sticky API page-actions a tall
|
||||
containing block to travel within while letting the breadcrumbs wrap around them
|
||||
(see PageHeader). */}
|
||||
<div className="min-w-0 grow">
|
||||
<PageHeader
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
withRSSFeed={contentHasUpdates}
|
||||
hasAPIBlocks={hasAPIBlocks}
|
||||
/>
|
||||
{document && !isNodeEmpty(document) ? (
|
||||
<OptionalSuspense
|
||||
staticRoute={staticRoute}
|
||||
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
|
||||
>
|
||||
<SuspenseLoadedHint />
|
||||
<div className="contents" data-content-ref-root="">
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="flex flex-col [&>*+*]:mt-5"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
isPageBody: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</OptionalSuspense>
|
||||
) : (
|
||||
<PageBodyBlankslate page={page} context={context} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{page.layout.pagination && customization.pagination.enabled ? (
|
||||
<PageFooterNavigation context={context} page={page} />
|
||||
@@ -176,10 +186,7 @@ export async function PageBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LLMsTxtPageDirective(props: {
|
||||
context: GitBookSiteContext;
|
||||
page: RevisionPageDocument;
|
||||
}) {
|
||||
function LLMsTxtPageDirective(props: { context: GitBookSiteContext; page: RevisionPageDocument }) {
|
||||
const { context, page } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationHeaderPreset,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageDocumentCover,
|
||||
} from '@gitbook/api';
|
||||
import type { StaticImageData } from 'next/image';
|
||||
|
||||
import { getImageAttributes } from '@/components/utils';
|
||||
@@ -13,18 +17,38 @@ import { getCoverHeight } from './coverHeight';
|
||||
import defaultPageCoverSVG from './default-page-cover.svg';
|
||||
|
||||
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
|
||||
const DEFAULT_RESPONSIVE_COVER_CUTOFF = '56.25%';
|
||||
|
||||
/**
|
||||
* Cover for the page.
|
||||
*/
|
||||
export async function PageCover(props: {
|
||||
as: 'hero' | 'full';
|
||||
as: 'hero' | 'full' | 'background';
|
||||
page: RevisionPageDocument;
|
||||
cover: RevisionPageDocumentCover;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { as, cover, context } = props;
|
||||
const { as, page, cover, context } = props;
|
||||
const height = getCoverHeight(cover);
|
||||
const mask = page.layout.coverMask === 'radial' ? 'radial' : 'none';
|
||||
|
||||
const initialCoverCutoff = () => {
|
||||
if (!height) {
|
||||
return DEFAULT_RESPONSIVE_COVER_CUTOFF;
|
||||
}
|
||||
|
||||
let total = height;
|
||||
if (context.customization.announcement?.enabled) {
|
||||
total += 68;
|
||||
}
|
||||
if (context.customization.header.preset !== CustomizationHeaderPreset.None) {
|
||||
total += 64;
|
||||
}
|
||||
if (context.visibleSections && context.visibleSections.list.length > 1) {
|
||||
total += 45;
|
||||
}
|
||||
return `${total}px`;
|
||||
};
|
||||
|
||||
const [resolved, resolvedDark] = await Promise.all([
|
||||
cover.ref ? resolveContentRef(cover.ref, context) : null,
|
||||
@@ -79,56 +103,73 @@ export async function PageCover(props: {
|
||||
assert(light, 'Light image should be defined');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-gb-page-cover
|
||||
data-full={String(as === 'full')}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
'-mx-4',
|
||||
<>
|
||||
<style>{`:root { --cover-height: ${initialCoverCutoff()}; }`}</style>
|
||||
<div
|
||||
data-gb-page-cover
|
||||
data-cover-text-color={cover.textColor}
|
||||
data-cover-text-color-dark={cover.textColorDark}
|
||||
data-cover-type={as}
|
||||
data-full={String(as === 'full')}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
'-mx-4',
|
||||
|
||||
// Full-width cover: extend to edges, disregard TOC where possible
|
||||
as === 'full'
|
||||
? [
|
||||
'sm:-mx-6',
|
||||
'md:-mx-8',
|
||||
'lg:-ml-12',
|
||||
// Full-width cover: extend to edges, disregard TOC where possible
|
||||
as === 'full' || as === 'background'
|
||||
? [
|
||||
'sm:-mx-6',
|
||||
'md:-mx-8',
|
||||
'lg:-ml-12',
|
||||
|
||||
// Extend the full-width cover
|
||||
'layout-default:page-no-toc:lg:-ml-92', // Extend into the left sidebar if there's no TOC...
|
||||
'layout-wide:2xl:-mr-[clamp(2rem,calc((100vw-90rem)/2+2rem),18rem)]', // ...and to the right if there's no outline.
|
||||
'layout-wide:page-no-toc:2xl:-mx-[max(calc((100vw-90rem)/2+2rem),2rem)]', // Span full width if the page content is centered.
|
||||
'layout-wide:has-sidebar:page-no-toc:lg:-ml-[max(calc((100vw-90rem)/2+23rem),23rem)]', // If there's still a sidebar, we have to factor it in too.
|
||||
// Extend the full-width cover
|
||||
'layout-default:page-no-toc:lg:-ml-92', // Extend into the left sidebar if there's no TOC...
|
||||
'layout-wide:2xl:-mr-[clamp(2rem,calc((100vw-90rem)/2+2rem),18rem)]', // ...and to the right if there's no outline.
|
||||
'layout-wide:page-no-toc:2xl:-mx-[max(calc((100vw-90rem)/2+2rem),2rem)]', // Span full width if the page content is centered.
|
||||
'layout-wide:has-sidebar:page-no-toc:lg:-ml-[max(calc((100vw-90rem)/2+23rem),23rem)]', // If there's still a sidebar, we have to factor it in too.
|
||||
|
||||
// Corner rounding: we round once the page is wide enough to have space around the cover.
|
||||
'layout-default:2xl:rounded-corners:rounded-b-xl',
|
||||
'layout-default:2xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:rounded-corners:rounded-b-xl',
|
||||
// Round the bottom left corner once the sidebar is shown next to it
|
||||
'has-sidebar:lg:rounded-corners:rounded-bl-xl',
|
||||
'has-sidebar:lg:circular-corners:rounded-bl-3xl',
|
||||
]
|
||||
: [
|
||||
// Regular cover: size regularly along with other content
|
||||
CONTENT_STYLE,
|
||||
'max-sm:-mx-4',
|
||||
'sm:rounded-corners:rounded-xl',
|
||||
'sm:circular-corners:rounded-3xl',
|
||||
'mb-8',
|
||||
'max-sm:w-screen',
|
||||
'max-sm:-mt-8',
|
||||
]
|
||||
)}
|
||||
>
|
||||
<PageCoverImage
|
||||
imgs={{
|
||||
light,
|
||||
dark,
|
||||
}}
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
// Corner rounding: we round once the page is wide enough to have space around the cover.
|
||||
'layout-default:2xl:rounded-corners:rounded-b-xl',
|
||||
'layout-default:2xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:circular-corners:rounded-b-3xl',
|
||||
'layout-wide:3xl:rounded-corners:rounded-b-xl',
|
||||
// Round the bottom left corner once the sidebar is shown next to it
|
||||
'has-sidebar:lg:rounded-corners:rounded-bl-xl',
|
||||
'has-sidebar:lg:circular-corners:rounded-bl-3xl',
|
||||
]
|
||||
: null,
|
||||
|
||||
as === 'hero'
|
||||
? [
|
||||
// Regular cover: size regularly along with other content
|
||||
CONTENT_STYLE,
|
||||
'max-sm:-mx-4',
|
||||
'sm:rounded-corners:rounded-xl',
|
||||
'sm:circular-corners:rounded-3xl',
|
||||
'mb-8',
|
||||
'max-sm:w-screen',
|
||||
'max-sm:-mt-8',
|
||||
]
|
||||
: null,
|
||||
|
||||
as === 'background'
|
||||
? [
|
||||
'-z-1 absolute inset-x-0 contrast-more:opacity-5 *:contrast-more:blur-md',
|
||||
]
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<PageCoverImage
|
||||
imgs={{
|
||||
light,
|
||||
dark,
|
||||
}}
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
mask={mask}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,11 @@ interface PageCoverImageProps {
|
||||
y: number;
|
||||
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
|
||||
height: number | undefined;
|
||||
mask?: 'none' | 'radial';
|
||||
}
|
||||
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height } = props;
|
||||
const { imgs, y, height, mask } = props;
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -53,6 +54,11 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
mask === 'radial'
|
||||
? 'radial-gradient(200% 200% at 50% -100%, black 70%, rgba(0,0,0,0.85) 77.5%, rgba(0,0,0,0.6) 85%, rgba(0,0,0,0.1) 96.25%, transparent 100%), linear-gradient(to left, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%), linear-gradient(to right, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%)'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
@@ -69,6 +75,11 @@ export function PageCoverImage(props: PageCoverImageProps) {
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
maskComposite: 'intersect',
|
||||
maskImage:
|
||||
mask === 'radial'
|
||||
? 'radial-gradient(200% 200% at 50% -100%, black 70%, rgba(0,0,0,0.85) 77.5%, rgba(0,0,0,0.6) 85%, rgba(0,0,0,0.1) 96.25%, transparent 100%), linear-gradient(to left, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%), linear-gradient(to right, black 60%, rgba(0,0,0,0.8) 75%, rgba(0,0,0,0.1) 95%, transparent 100%)'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -148,11 +148,14 @@ export async function PageHeader(props: {
|
||||
className={tcls(
|
||||
'float-right ml-4 flex gap-2',
|
||||
showBreadcrumbs ? '-mb-1 -mt-1.5' : '-mt-3 xs:mt-2',
|
||||
// On desktop API pages (where this <div> is rendered as a sibling of <header>, see
|
||||
// below) keep the actions pinned below the site header while scrolling long
|
||||
// operations. The offset tracks the header height (banner, cover…) via the same
|
||||
// --toc-top-offset the outline and code samples use. Hidden while the outline drawer
|
||||
// is open (drawer widths only) so it doesn't overlap the sheet.
|
||||
// On desktop API pages these actions are pulled out of <header> (rendered as its
|
||||
// preceding sibling, see below) so their sticky containing block is the tall,
|
||||
// growing content wrapper in <main> — a plain block — rather than the short header.
|
||||
// There they keep the base `float-right`, so the breadcrumbs wrap around them at any
|
||||
// width, while pinning below the site header when scrolling long operations. The
|
||||
// offset tracks the header height (banner, cover…) via the same --toc-top-offset the
|
||||
// outline and code samples use. Hidden while the outline drawer is open (drawer
|
||||
// widths only) so it doesn't overlap the sheet.
|
||||
hasAPIBlocks && [
|
||||
'page-api-block:lg:sticky',
|
||||
'page-api-block:lg:top-[calc(var(--toc-top-offset,4rem)+1rem)]',
|
||||
@@ -185,7 +188,7 @@ export async function PageHeader(props: {
|
||||
// content spans the full width with no navigation column, so the crumbs sit stranded.
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="layout-wide:page-no-toc:hidden text-tint text-xs leading-relaxed"
|
||||
className="layout-wide:page-no-toc:hidden page-cover-background:text-contrast-cover text-tint text-xs leading-relaxed page-cover-background:opacity-9"
|
||||
>
|
||||
<ol className="inline">
|
||||
{contextCrumbs.map((crumb, index) => (
|
||||
@@ -238,7 +241,8 @@ export async function PageHeader(props: {
|
||||
'grow',
|
||||
'text-pretty',
|
||||
'clear-right',
|
||||
'xs:clear-none'
|
||||
'xs:clear-none',
|
||||
'page-cover-background:text-contrast-cover'
|
||||
)}
|
||||
>
|
||||
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
||||
@@ -246,7 +250,15 @@ export async function PageHeader(props: {
|
||||
</h1>
|
||||
) : null}
|
||||
{page.description && page.layout.description ? (
|
||||
<p className={tcls(CONTENT_STYLE_REDUCED, 'text-lg', 'text-tint', 'clear-both')}>
|
||||
<p
|
||||
className={tcls(
|
||||
CONTENT_STYLE_REDUCED,
|
||||
'text-lg',
|
||||
'page-cover-background:text-contrast-cover',
|
||||
'text-tint contrast-more:text-tint-strong',
|
||||
'clear-both'
|
||||
)}
|
||||
>
|
||||
{page.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
@config '../../../tailwind.config.ts';
|
||||
|
||||
@import './prose.css';
|
||||
@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,
|
||||
@@ -14,37 +17,37 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
@utility hide-scrollbar {
|
||||
/* Hide scrollbar by default */
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease-in-out;
|
||||
/* Hide scrollbar by default */
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
/* Show scrollbar on hover */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
&:hover {
|
||||
/* Show scrollbar on hover */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
/* Always show scrollbar in high contrast mode */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
@media (prefers-contrast: more) {
|
||||
/* Always show scrollbar in high contrast mode */
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -55,137 +58,147 @@
|
||||
wrapper from React, so let pointer events pass through it for these tooltips only.
|
||||
*/
|
||||
[data-radix-popper-content-wrapper]:has([data-non-interactive]) {
|
||||
pointer-events: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@utility linear-mask-gradient {
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0));
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 96px,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@utility linear-mask-util {
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
mask-image: linear-gradient(to bottom, white, white);
|
||||
}
|
||||
|
||||
@utility grid-area-1-1 {
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
@utility gutter-stable {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@utility triangle {
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707) translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707) translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
position: relative;
|
||||
background-color: orange;
|
||||
text-align: left;
|
||||
transform: rotate(-60deg) skewX(-30deg) scale(1, 0.866);
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: inherit;
|
||||
}
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
&:before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
&:after {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-top-right-radius: 30%;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: rotate(-135deg) skewX(-45deg) scale(1.414, 0.707)
|
||||
translate(0, -50%);
|
||||
}
|
||||
&:after {
|
||||
transform: rotate(135deg) skewY(-45deg) scale(0.707, 1.414) translate(50%);
|
||||
}
|
||||
}
|
||||
|
||||
@utility break-anywhere {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-gradient-primary {
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-primary-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility bg-gradient-tint {
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
@apply bg-linear-to-bl from-tint-4 to-tint-base to-60% bg-fixed;
|
||||
}
|
||||
|
||||
@utility site-background {
|
||||
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
|
||||
@apply bg-tint-base;
|
||||
@apply theme-muted:bg-tint-subtle;
|
||||
@apply [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle;
|
||||
@apply bg-tint-base;
|
||||
@apply theme-muted:bg-tint-subtle;
|
||||
|
||||
@apply theme-gradient:bg-gradient-primary;
|
||||
@apply theme-gradient-tint:bg-gradient-tint;
|
||||
@apply theme-gradient:bg-gradient-primary;
|
||||
@apply theme-gradient-tint:bg-gradient-tint;
|
||||
}
|
||||
|
||||
@utility elevate-link {
|
||||
& a[href]:not(.link-overlay),
|
||||
& [data-annotation] {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
& a[href]:not(.link-overlay),
|
||||
& [data-annotation] {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -197,141 +210,190 @@
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply leading-relaxed;
|
||||
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
|
||||
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
|
||||
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
|
||||
}
|
||||
|
||||
/* Modern browsers with `scrollbar-*` support */
|
||||
@supports (scrollbar-color: auto) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.7") transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
:root {
|
||||
@apply leading-relaxed;
|
||||
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
|
||||
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
|
||||
--ai-chat-width: 24rem; /* Default AI chat panel width (= AI_CHAT_DEFAULT_WIDTH 384px); overridden client-side from local storage. */
|
||||
--cover-height: 0px;
|
||||
--cover-text-top: rgb(var(--tint-12));
|
||||
--cover-text-bottom: rgb(var(--tint-12));
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.11") transparent;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, use `::-webkit-scrollbar-*` pseudo-elements */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
:root:not(.dark):has(
|
||||
[data-gb-page-cover][data-cover-type="background"][data-cover-text-color="white"]
|
||||
),
|
||||
:root.dark:has(
|
||||
[data-gb-page-cover][data-cover-type="background"][data-cover-text-color-dark="black"]
|
||||
) {
|
||||
--cover-text-top: rgb(var(--contrast-tint-12));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.7");
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.8");
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.11");
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.12");
|
||||
}
|
||||
}
|
||||
/* Modern browsers with `scrollbar-*` support */
|
||||
@supports (scrollbar-color: auto) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.7") transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
@apply text-tint-strong antialiased;
|
||||
}
|
||||
html {
|
||||
@media (prefers-contrast: more) {
|
||||
* {
|
||||
scrollbar-color: theme("colors.tint.11") transparent;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
@apply tracking-[-0.025em] text-tint-strong text-balance;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply tracking-[-0.0125em] text-tint-strong;
|
||||
}
|
||||
|
||||
/* Otherwise, use `::-webkit-scrollbar-*` pseudo-elements */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
@apply focus-visible:outline-2 focus-visible:outline-primary;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.7");
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.8");
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
/* Don't apply antialiased to `code` and `pre` elements */
|
||||
@apply subpixel-antialiased;
|
||||
@media (prefers-contrast: more) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme("colors.tint.11");
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme("colors.tint.12");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-tint-strong antialiased;
|
||||
}
|
||||
html {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
h1 {
|
||||
@apply tracking-[-0.025em] text-tint-strong text-balance;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply tracking-[-0.0125em] text-tint-strong;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
@apply focus-visible:outline-2 focus-visible:outline-primary;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
/* Don't apply antialiased to `code` and `pre` elements */
|
||||
@apply subpixel-antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@utility contrast-cover {
|
||||
background-clip: border-box;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--cover-text-top) 0,
|
||||
var(--cover-text-top) var(--cover-height),
|
||||
var(--cover-text-bottom) var(--cover-height),
|
||||
var(--cover-text-bottom) 100vh
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100vh;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
@utility text-contrast-cover {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--cover-text-top) 0,
|
||||
var(--cover-text-top) var(--cover-height),
|
||||
var(--cover-text-bottom) var(--cover-height),
|
||||
var(--cover-text-bottom) 100vh
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100vh;
|
||||
background-attachment: fixed;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
color: var(--cover-text-bottom);
|
||||
-webkit-text-fill-color: var(--cover-text-bottom);
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
color-scheme: light;
|
||||
|
||||
/** Ensure PDF export and print correctly displays the background colors */
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
/** Ensure PDF export and print correctly displays the background colors */
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark light;
|
||||
color-scheme: dark light;
|
||||
}
|
||||
|
||||
/** While the AI chat panel is being resized, suppress transitions */
|
||||
html[data-ai-chat-resizing="true"] {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
html[data-ai-chat-resizing="true"] * {
|
||||
transition-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
html.announcement-hidden [data-gb-announcement-banner] {
|
||||
@apply hidden;
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
html.font-Lato {
|
||||
/* Lato's default ligatures impact readability, so we turn them off */
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
/* Lato's default ligatures impact readability, so we turn them off */
|
||||
font-variant-ligatures: no-common-ligatures;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
|
||||
* These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
|
||||
* typography beyond just colors. We apply them here so code blocks respect theme font styling. */
|
||||
.shiki, .shiki span {
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
.shiki,
|
||||
.shiki span {
|
||||
font-style: var(--shiki-light-font-style) !important;
|
||||
font-weight: var(--shiki-light-font-weight) !important;
|
||||
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
@@ -342,29 +404,29 @@ html.dark .shiki span {
|
||||
}
|
||||
|
||||
.highlight-line {
|
||||
@apply min-w-min text-tint-strong table-row bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
|
||||
@apply only:hover:ring-transparent;
|
||||
@apply [counter-increment:line];
|
||||
@apply min-w-min text-tint-strong table-row bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
|
||||
@apply only:hover:ring-transparent;
|
||||
@apply [counter-increment:line];
|
||||
|
||||
&.highlighted {
|
||||
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
|
||||
@apply bg-tint-base invert-10 hover:invert-15;
|
||||
@apply first:rounded-t-md first:*:mt-1;
|
||||
@apply last:rounded-b-md last:*:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
&.highlighted {
|
||||
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
|
||||
@apply bg-tint-base invert-10 hover:invert-15;
|
||||
@apply first:rounded-t-md first:*:mt-1;
|
||||
@apply last:rounded-b-md last:*:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -381,70 +443,70 @@ html.dark .shiki span {
|
||||
* otherwise muddy the tint.
|
||||
*/
|
||||
.highlight-line.diff-added {
|
||||
background-color: #e1f8ec !important; /* gbx --green-3 (light) */
|
||||
@apply rounded-md;
|
||||
background-color: #e1f8ec !important; /* gbx --green-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-deleted {
|
||||
background-color: #ffeae6 !important; /* gbx --red-3 (light) */
|
||||
@apply rounded-md;
|
||||
background-color: #ffeae6 !important; /* gbx --red-3 (light) */
|
||||
@apply rounded-md;
|
||||
}
|
||||
.highlight-line.diff-added.highlighted {
|
||||
background-color: #baebd3 !important; /* gbx --green-5 (light) */
|
||||
filter: none !important;
|
||||
background-color: #baebd3 !important; /* gbx --green-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
.highlight-line.diff-deleted.highlighted {
|
||||
background-color: #ffcac0 !important; /* gbx --red-5 (light) */
|
||||
filter: none !important;
|
||||
background-color: #ffcac0 !important; /* gbx --red-5 (light) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
html.dark .highlight-line.diff-added {
|
||||
background-color: #113023 !important; /* gbx --green-3 (dark) */
|
||||
background-color: #113023 !important; /* gbx --green-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted {
|
||||
background-color: #411510 !important; /* gbx --red-3 (dark) */
|
||||
background-color: #411510 !important; /* gbx --red-3 (dark) */
|
||||
}
|
||||
html.dark .highlight-line.diff-added.highlighted {
|
||||
background-color: #064b34 !important; /* gbx --green-5 (dark) */
|
||||
filter: none !important;
|
||||
background-color: #064b34 !important; /* gbx --green-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted.highlighted {
|
||||
background-color: #67100a !important; /* gbx --red-5 (dark) */
|
||||
filter: none !important;
|
||||
background-color: #67100a !important; /* gbx --red-5 (dark) */
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply table-cell whitespace-nowrap w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
|
||||
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
|
||||
@apply table-cell whitespace-nowrap w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
|
||||
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
|
||||
|
||||
.highlighted & {
|
||||
@apply bg-tint-base
|
||||
}
|
||||
.highlighted & {
|
||||
@apply bg-tint-base;
|
||||
}
|
||||
}
|
||||
|
||||
/* Diff line gutter (line numbers) — same tint as the line itself. */
|
||||
.diff-added > .highlight-line-number {
|
||||
background-color: #e1f8ec !important;
|
||||
background-color: #e1f8ec !important;
|
||||
}
|
||||
.diff-deleted > .highlight-line-number {
|
||||
background-color: #ffeae6 !important;
|
||||
background-color: #ffeae6 !important;
|
||||
}
|
||||
.diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #baebd3 !important;
|
||||
background-color: #baebd3 !important;
|
||||
}
|
||||
.diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #ffcac0 !important;
|
||||
background-color: #ffcac0 !important;
|
||||
}
|
||||
html.dark .diff-added > .highlight-line-number {
|
||||
background-color: #113023 !important;
|
||||
background-color: #113023 !important;
|
||||
}
|
||||
html.dark .diff-deleted > .highlight-line-number {
|
||||
background-color: #411510 !important;
|
||||
background-color: #411510 !important;
|
||||
}
|
||||
html.dark .diff-added.highlighted > .highlight-line-number {
|
||||
background-color: #064b34 !important;
|
||||
background-color: #064b34 !important;
|
||||
}
|
||||
html.dark .diff-deleted.highlighted > .highlight-line-number {
|
||||
background-color: #67100a !important;
|
||||
background-color: #67100a !important;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -455,32 +517,31 @@ html.dark .diff-deleted.highlighted > .highlight-line-number {
|
||||
*/
|
||||
.highlight-line.diff-added .highlight-line-content::before,
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
margin-right: 0.5ch;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
width: 1ch;
|
||||
margin-right: 0.5ch;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.highlight-line.diff-added .highlight-line-content::before {
|
||||
content: "+";
|
||||
color: #247758; /* gbx --green-11 light */
|
||||
content: "+";
|
||||
color: #247758; /* gbx --green-11 light */
|
||||
}
|
||||
.highlight-line.diff-deleted .highlight-line-content::before {
|
||||
content: "\2212"; /* U+2212 minus sign — visually clearer than a hyphen */
|
||||
color: #de0000; /* gbx --red-11 light */
|
||||
content: "\2212"; /* U+2212 minus sign — visually clearer than a hyphen */
|
||||
color: #de0000; /* gbx --red-11 light */
|
||||
}
|
||||
html.dark .highlight-line.diff-added .highlight-line-content::before {
|
||||
color: #6ecea5; /* gbx --green-11 dark */
|
||||
color: #6ecea5; /* gbx --green-11 dark */
|
||||
}
|
||||
html.dark .highlight-line.diff-deleted .highlight-line-content::before {
|
||||
color: #ff9080; /* gbx --red-11 dark */
|
||||
color: #ff9080; /* gbx --red-11 dark */
|
||||
}
|
||||
|
||||
|
||||
.highlight-line-content {
|
||||
@apply table-cell text-sm px-4;
|
||||
@apply table-cell text-sm px-4;
|
||||
|
||||
.highlight-line-number + & {
|
||||
@apply pl-2;
|
||||
}
|
||||
.highlight-line-number + & {
|
||||
@apply pl-2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
@@ -10,13 +11,18 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button, Popover } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { SearchFrame } from './SearchFrame';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchController } from './useSearchController';
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
interface SearchContainerProps extends SearchBaseProps {
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Document, type DocumentValue } from 'flexsearch';
|
||||
import type { Document, DocumentValue } from 'flexsearch';
|
||||
import React from 'react';
|
||||
|
||||
interface Breadcrumb {
|
||||
@@ -63,8 +63,11 @@ const cachedPageData = new Map<
|
||||
|
||||
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
||||
|
||||
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
|
||||
const index = new Document<IndexPage>({
|
||||
function buildLangIndex(
|
||||
DocumentCtor: typeof import('flexsearch').Document,
|
||||
pages: RawIndexPage[]
|
||||
): Document<IndexPage> {
|
||||
const index = new DocumentCtor<IndexPage>({
|
||||
document: {
|
||||
id: 'id',
|
||||
index: ['title', 'description'],
|
||||
@@ -110,7 +113,9 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
}
|
||||
|
||||
pendingFetch = (async () => {
|
||||
const response = await fetch(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}`);
|
||||
}
|
||||
@@ -131,7 +136,7 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
|
||||
// Build one FlexSearch Document per language group
|
||||
for (const [lang, pages] of pagesByLang) {
|
||||
cachedIndexes.set(lang, buildLangIndex(pages));
|
||||
cachedIndexes.set(lang, buildLangIndex(Document, pages));
|
||||
}
|
||||
|
||||
return cachedIndexes;
|
||||
@@ -156,8 +161,11 @@ export function useLocalSearchResults(props: {
|
||||
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
||||
filterSiteSpaceIds?: string[];
|
||||
disabled?: 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 } = props;
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, active = true } = props;
|
||||
|
||||
const [state, setState] = React.useState<LocalSearchState>({
|
||||
results: [],
|
||||
@@ -168,13 +176,17 @@ 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);
|
||||
|
||||
// Load the indexes once
|
||||
// Load the indexes once search becomes active (opened or queried).
|
||||
React.useEffect(() => {
|
||||
if (cachedIndexes.size > 0) {
|
||||
setIndexReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
||||
|
||||
@@ -194,7 +206,7 @@ export function useLocalSearchResults(props: {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [indexURL]);
|
||||
}, [indexURL, active]);
|
||||
|
||||
// Perform instant local search whenever query, lang, or index readiness changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -200,6 +200,9 @@ export function useSearchController(props: SearchBaseProps) {
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(state?.query || withAI),
|
||||
// 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,
|
||||
|
||||
@@ -42,6 +42,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
|
||||
export function useSearchResults(props: {
|
||||
asEmbeddable?: boolean;
|
||||
disabled: 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[];
|
||||
@@ -59,6 +61,7 @@ export function useSearchResults(props: {
|
||||
const {
|
||||
asEmbeddable,
|
||||
disabled,
|
||||
active,
|
||||
query,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
@@ -82,6 +85,7 @@ export function useSearchResults(props: {
|
||||
indexURL,
|
||||
lang,
|
||||
disabled,
|
||||
active,
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,12 +39,6 @@ export async function SiteLayout(props: {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
|
||||
// We also preload the site index
|
||||
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
|
||||
as: 'fetch',
|
||||
type: 'application/json',
|
||||
});
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
ReactDOM.preload(script, {
|
||||
as: 'script',
|
||||
|
||||
@@ -24,6 +24,7 @@ export function PageClientLayout({
|
||||
useRegisterPageMetadata({ pageMetaLinks });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
useSetCoverHeight();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -60,3 +61,78 @@ function useRegisterPageMetadata(metadata: {
|
||||
currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
|
||||
}, [pageMetaLinks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the visible bottom edge of the page cover as a viewport-relative CSS variable.
|
||||
*/
|
||||
function useSetCoverHeight() {
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
let animationFrame: number | null = null;
|
||||
|
||||
const updateCoverHeight = () => {
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
const isBackgroundCover = pageCover?.dataset.coverType === 'background';
|
||||
|
||||
if (!isBackgroundCover) {
|
||||
root.style.setProperty('--cover-height', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageCover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bottom = pageCover.getBoundingClientRect().bottom;
|
||||
const height = Math.max(Math.min(bottom, window.innerHeight), 0);
|
||||
|
||||
root.style.setProperty('--cover-height', `${height}px`);
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (animationFrame !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = null;
|
||||
updateCoverHeight();
|
||||
});
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
|
||||
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||
|
||||
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
|
||||
const resizeObserver =
|
||||
pageCover && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => {
|
||||
scheduleUpdate();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (pageCover && resizeObserver) {
|
||||
resizeObserver.observe(pageCover);
|
||||
}
|
||||
|
||||
// Dismissing the announcement banner only toggles a class on <html> (see
|
||||
// dismissAnnouncement) — no scroll/resize event and no cover resize — yet it shifts the
|
||||
// cover up. Watch <html> class changes so the cover height is recomputed in that case too.
|
||||
const classObserver =
|
||||
typeof MutationObserver !== 'undefined' ? new MutationObserver(scheduleUpdate) : null;
|
||||
classObserver?.observe(root, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
classObserver?.disconnect();
|
||||
window.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -81,9 +81,18 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
|
||||
<>
|
||||
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
|
||||
If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */}
|
||||
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
|
||||
<div
|
||||
className={
|
||||
withFullPageCover && page.cover ? 'relative flex grow flex-col' : 'contents'
|
||||
}
|
||||
>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={context} />
|
||||
<PageCover
|
||||
as={page.layout.coverSize === 'background' ? 'background' : 'full'}
|
||||
page={page}
|
||||
cover={page.cover}
|
||||
context={context}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
@@ -272,7 +281,7 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
const withFullPageCover = !!(
|
||||
page.cover &&
|
||||
page.layout.cover &&
|
||||
page.layout.coverSize === 'full'
|
||||
(page.layout.coverSize === 'full' || page.layout.coverSize === 'background')
|
||||
);
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const variantClasses = {
|
||||
],
|
||||
secondary: [
|
||||
'bg-tint',
|
||||
'depth-flat:bg-transparent',
|
||||
'depth-flat:bg-tint-base',
|
||||
'text-tint',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:not-disabled:depth-flat:bg-tint-hover',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
type RevisionPage,
|
||||
RevisionPageLayoutOptionsCoverMask,
|
||||
RevisionPageLayoutOptionsCoverSize,
|
||||
RevisionPageLayoutOptionsWidth,
|
||||
} from '@gitbook/api';
|
||||
@@ -77,6 +78,7 @@ describe('resolveFirstDocument', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -130,6 +132,7 @@ describe('resolveFirstDocument', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -176,6 +179,7 @@ describe('resolvePagePath', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -252,6 +256,7 @@ describe('resolvePagePath', () => {
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
@@ -370,6 +375,7 @@ function createDocumentPage(id: string, path: string, hidden = false): RevisionP
|
||||
layout: {
|
||||
cover: true,
|
||||
coverSize: RevisionPageLayoutOptionsCoverSize.Full,
|
||||
coverMask: RevisionPageLayoutOptionsCoverMask.None,
|
||||
title: true,
|
||||
description: true,
|
||||
tableOfContents: true,
|
||||
|
||||
@@ -770,6 +770,14 @@ const config: Config = {
|
||||
*/
|
||||
addVariant('page-api-block', 'body:has(.openapi-block) &');
|
||||
|
||||
/**
|
||||
* Variant for the page cover type
|
||||
*/
|
||||
addVariant(
|
||||
'page-cover-background',
|
||||
'body:has([data-gb-page-cover][data-cover-type="background"]) &'
|
||||
);
|
||||
|
||||
/**
|
||||
* Variant when the page is displayed in print mode.
|
||||
*/
|
||||
|
||||
@@ -55,35 +55,6 @@ it(
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should record agent feedback through MCP',
|
||||
async () => {
|
||||
const client = new Client({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(getContentTestURL('https://gitbook.com/docs/~gitbook/mcp/auth'))
|
||||
)
|
||||
);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'sendFeedback',
|
||||
arguments: {
|
||||
category: 'content-gap',
|
||||
content: 'The authentication section does not explain how to rotate API tokens.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.isError).toBeFalsy();
|
||||
// @ts-expect-error - response.content is of type unknown
|
||||
expect(response.content[0]?.text).toContain('Feedback recorded');
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
it(
|
||||
'should get a page from another site space through MCP',
|
||||
async () => {
|
||||
|
||||
Reference in New Issue
Block a user