Merge remote-tracking branch 'origin/main' into claude/exciting-brahmagupta-7cd6fa

# Conflicts:
#	packages/react-openapi/src/OpenAPICodeSampleSelector.tsx
#	packages/react-openapi/src/context.ts
This commit is contained in:
Zeno Kapitein
2026-06-19 10:42:29 +02:00
76 changed files with 2596 additions and 381 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Support OpenAPI one-operation-per-page mode
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
@@ -55,6 +55,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
@@ -57,6 +57,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
+3 -3
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.15",
"turbo": "^2.9.18",
"vercel": "50.37.3",
},
},
@@ -360,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.183.0",
"@gitbook/api": "0.184.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.183.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-0+6VyRH7me5AtzU+mwZVFHDaJudtnHYEcxwQ/qO7p2swj7dOe/dOevfaphgx+pUvhXAg7VVlihETaAW/WsLTzQ=="],
"@gitbook/api": ["@gitbook/api@0.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.183.0",
"@gitbook/api": "0.184.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+104 -34
View File
@@ -31,12 +31,24 @@ import {
headerLinks,
runTestCases,
setTimeToMorning,
waitForAIChatResponse,
waitForCookiesDialog,
waitForCoverImages,
waitForNotFound,
} from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
// single fixed search, a concise answer, and a fixed number of follow-ups. The
// model is never perfectly deterministic, so `overrideAIResponse` still
// normalizes the rendered content below — this prompt just narrows the variance.
const AI_PROMPT = [
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
'Follow these instructions exactly and do not deviate:',
'1. Do not write any preamble, commentary, or reasoning before acting.',
'2. Perform a single search for exactly "Lorem ipsum".',
'3. Reply with only the first sentence of the first page you find, and nothing else.',
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
@@ -44,21 +56,45 @@ const overrideAIInitialState = () => {
greeting.textContent = 'Good morning';
}
};
/**
* Normalize the non-deterministic content of an AI response before screenshotting,
* while preserving the surrounding structure (message bubbles, tool/activity
* summary, response container, suggestion buttons) so visual regressions in the
* chat chrome are still caught. The actual answer formatting is covered separately
* by the deterministic page tests, since the AI response renders through the same
* `DocumentView`.
*
* Must run only once the chat is no longer `aria-busy` (the response has fully
* settled), otherwise React re-renders from late stream events will clobber these
* mutations. See `waitForAIChatResponse`.
*/
const overrideAIResponse = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
// The user's prompt varies in length; pin it to a fixed string.
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
userMessage.textContent = '[Replaced message] Chat message sent by the user';
}
const assistantMessage = document.querySelectorAll(
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
);
assistantMessage.forEach((message) => {
message.innerHTML = '[Replaced message] AI chat response';
});
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
// The assistant's answer text is non-deterministic; replace the rendered
// document body while keeping the `.ai-response-document` container.
document
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
.forEach((message) => {
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
});
// The "Explored with N tools" activity label varies with the number of tool
// calls; pin it (the chevron sibling is left intact).
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
summary.textContent = 'Explored';
});
// Follow-up suggestion text varies; pin each label.
document
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
};
const searchTestCases: Test[] = [
@@ -167,12 +203,13 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
{
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
@@ -186,9 +223,9 @@ const searchTestCases: Test[] = [
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - Button',
@@ -202,9 +239,9 @@ const searchTestCases: Test[] = [
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
@@ -219,9 +256,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Results)',
@@ -237,12 +274,13 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
];
@@ -1323,6 +1361,7 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
@@ -2221,9 +2260,13 @@ const testCases: TestsCase[] = [
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'API - navigateToPage',
@@ -2257,8 +2300,15 @@ const testCases: TestsCase[] = [
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
AI_PROMPT
);
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the full response to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Suggested questions',
@@ -2284,8 +2334,13 @@ const testCases: TestsCase[] = [
await expect(
iframe.getByTestId('ai-chat-suggested-question').nth(2)
).toHaveText('What can you do?');
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'Configuration - Custom action buttons',
@@ -2367,8 +2422,15 @@ const testCases: TestsCase[] = [
await actions.nth(3).click();
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
await page.locator('#gitbook-widget-button').click();
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the response posted above to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Custom tools',
@@ -2412,8 +2474,16 @@ const testCases: TestsCase[] = [
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
});
await iframe.owner().evaluate(overrideAIResponse);
// The turn settles (aria-busy clears) once the stream pauses on the
// confirmation control; wait for that before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
],
},
+49 -1
View File
@@ -13,6 +13,7 @@ import {
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationPageActionType,
CustomizationSearchStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -21,7 +22,14 @@ import {
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -39,6 +47,16 @@ export interface Test {
* Test to run
*/
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Re-applied right before every viewport screenshot (after Argos
* stabilization), so it survives re-renders triggered by viewport resizing.
*
* Use this — rather than mutating the DOM once in `run` — to normalize
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
* is clobbered when React re-renders on resize (e.g. crossing the mobile
* breakpoint), so only the first viewport ends up normalized.
*/
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
/**
* Mode for the test.
*/
@@ -159,6 +177,28 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
/**
* Wait for an AI chat response to be fully settled before asserting or
* screenshotting it.
*
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
* which stays true from the moment a message is sent until the stream — including
* the follow-up suggestion phase — completes. Gating on it avoids the two main
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
* answer, and running the content normalization while React is still re-rendering
* (which would clobber the replacements).
*
* Argos also waits for `aria-busy` to clear during its own stabilization
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
*
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
*/
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
timeout: 60_000,
});
}
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
@@ -253,6 +293,9 @@ export function runTestCases(testCases: TestsCase[]) {
await waitForTOCScrolling(page);
}
await waitForIcons(page);
// Re-apply per viewport, last — after any resize-driven
// re-render — so normalized content survives to capture.
await testEntry.normalizeBeforeScreenshot?.(page);
},
});
}
@@ -372,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
+1
View File
@@ -48,6 +48,7 @@ const nextConfig = {
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
+10
View File
@@ -18,6 +18,16 @@ export default defineConfig({
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
launchOptions: {
args: [
// Disable subpixel (LCD) text so glyphs always render with
// grayscale antialiasing — removes the red/blue edge fringing
// that varies between macOS (local) and Linux (CI) runs.
'--disable-lcd-text',
// Disable font hinting so glyph rasterization is platform-independent.
'--font-render-hinting=none',
],
},
},
},
],
+2
View File
@@ -10,6 +10,7 @@ import {
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
@@ -32,6 +33,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_ICONS_URL,
GITBOOK_USER_AGENT,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
// Secret envs
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test';
import { type AIChatReference, serializeReferences } from './references';
describe('serializeReferences', () => {
it('returns an empty string when there are no references', () => {
expect(serializeReferences([])).toBe('');
});
it('serializes a single page reference as a markdown link', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following page they are reading');
expect(result).toContain('- [Getting started](/getting-started)');
expect(result.endsWith('\n\n---\n\n')).toBe(true);
});
it('falls back to the path when no href is provided', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('- [Overview](getting-started)');
});
it('renders a quoted label when neither href nor path is provided', () => {
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
const result = serializeReferences(refs);
expect(result).toContain('- "Overview"');
expect(result).not.toContain('](');
});
it('uses the plural form for multiple pages', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'One' },
{ type: 'page', id: 'page-2', label: 'Two' },
];
const result = serializeReferences(refs);
expect(result).toContain('the following pages they are reading');
expect(result).toContain('in the context of them');
});
it('serializes a single code block reference', () => {
const refs: AIChatReference[] = [
{
type: 'code-block',
id: 'code-1',
label: 'main.ts',
content: 'const a = 1;',
syntax: 'ts',
},
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following code block');
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
});
it('escapes backtick fences inside the code content', () => {
const refs: AIChatReference[] = [
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
];
const result = serializeReferences(refs);
expect(result).toContain('````md\na ``` b\n````');
});
it('groups page and code references into distinct sections', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Quickstart' },
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
];
const result = serializeReferences(refs);
const pageIndex = result.indexOf('referring to the following page');
const codeIndex = result.indexOf('referring to the following code block');
expect(pageIndex).toBeGreaterThanOrEqual(0);
expect(codeIndex).toBeGreaterThan(pageIndex);
});
});
@@ -1,34 +1,93 @@
import assertNever from 'assert-never';
type BaseAIChatReference = {
id: string;
label?: string;
content: string;
};
export type CodeBlockReference = BaseAIChatReference & {
type: 'code-block';
content: string;
syntax?: string;
};
export type AIChatReference = CodeBlockReference;
export type PageReference = BaseAIChatReference & {
type: 'page';
/** Title of the page being referenced. */
label: string;
/** Path of the page, used to help the assistant identify it. */
path?: string;
/** Site-relative href of the page, used to navigate back to it from the chip. */
href?: string;
};
export type TextReference = BaseAIChatReference & {
type: 'text';
/** The selected text content. */
content: string;
};
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
/**
* Serialize the staged references into a preamble prepended to the user's message,
* so the assistant is informed about the context the user is referring to.
*/
export function serializeReferences(refs: AIChatReference[]): string {
if (refs.length === 0) {
return '';
}
const plural = refs.length > 1;
const blocks = refs.map(serializeReference).join('\n\n');
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}\n\n---\n\n`;
const sections: string[] = [];
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
if (pageRefs.length > 0) {
sections.push(serializePageReferences(pageRefs));
}
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
if (codeRefs.length > 0) {
sections.push(serializeCodeBlockReferences(codeRefs));
}
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
if (textRefs.length > 0) {
sections.push(serializeTextReferences(textRefs));
}
if (sections.length === 0) {
return '';
}
return `${sections.join('\n\n')}\n\n---\n\n`;
}
function serializeReference(ref: AIChatReference): string {
switch (ref.type) {
case 'code-block':
return buildCodeBlockFence(ref);
default:
assertNever(ref.type);
}
function serializePageReferences(refs: PageReference[]): string {
const plural = refs.length > 1;
const list = refs
.map((ref) => {
const url = ref.href ?? ref.path;
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
})
.join('\n');
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
}
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function serializeTextReferences(refs: TextReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function quoteText(content: string): string {
return content
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
function buildCodeBlockFence(ref: CodeBlockReference): string {
+1 -1
View File
@@ -95,7 +95,7 @@ export function useAI(): AIContext {
label: config.assistantName ?? getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
state={chat.responding ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
/>
@@ -86,7 +86,23 @@ export type AIChatState = {
control: AnyAIControl | null;
/**
* If true, the session is in progress.
* If true, the assistant is actively producing its answer — from the moment a
* message is sent until the `response_finish` event. It is cleared at that point
* (even though follow-up suggestions may still trickle in) so the input can be
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
* the loading shim, and the thinking/exploring/working status.
*/
responding: boolean;
/**
* If true, the turn is still in progress overall: from the moment a message is
* sent until the stream fully completes, including the follow-up suggestion
* phase.
*
* Unlike `responding` — which clears on `response_finish` — this stays true
* until the response is truly settled. It is the global busyness indicator,
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
* can wait for a complete, stable response.
*/
loading: boolean;
@@ -160,6 +176,7 @@ const globalState = zustand.create<AIChatState>(() => {
query: null,
followUpSuggestions: [],
control: null,
responding: false,
loading: false,
error: false,
initialQuery: null,
@@ -255,6 +272,7 @@ export function AIChatProvider(props: {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
@@ -268,6 +286,16 @@ export function AIChatProvider(props: {
};
});
// 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]);
@@ -330,8 +358,8 @@ export function AIChatProvider(props: {
for await (const data of stream) {
if (!data) continue;
if (input.userQuery && globalState.getState().query !== input.userQuery) {
// Chat was cleared, stop processing the stream
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
@@ -342,9 +370,9 @@ export function AIChatProvider(props: {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not loading when the response is finished
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
loading: false,
responding: false,
error: false,
}));
break;
@@ -462,23 +490,40 @@ export function AIChatProvider(props: {
}));
}
// Execute the tool call if it doesn't require confirmation
// 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,
}));
}
} catch (error) {
console.error('Error streaming AI response', error);
globalState.setState((state) => ({
...state,
loading: false,
error: true,
}));
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
@@ -494,14 +539,14 @@ export function AIChatProvider(props: {
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, loading } = globalState.getState();
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Ignore duplicates while a previous turn is still streaming
if (loading) {
if (responding) {
return;
}
@@ -550,7 +595,7 @@ export function AIChatProvider(props: {
],
query: input.message,
followUpSuggestions: [],
loading: true,
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
@@ -566,6 +611,7 @@ export function AIChatProvider(props: {
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
@@ -702,7 +748,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus {
return 'confirm';
}
if (chat.loading) {
if (chat.responding) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
@@ -88,8 +88,8 @@ export function AIChat() {
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameHeader className="not-embed:px-4">
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
@@ -281,8 +281,8 @@ export function AIChatBody(props: {
{chat.control ? <AIChatControl control={chat.control} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
responding={chat.responding}
disabled={chat.responding || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
@@ -13,10 +13,10 @@ export function AIChatInput(props: {
/**
* When true, the input is disabled
*/
loading: boolean;
responding: boolean;
onSubmit: (value: string) => void;
}) {
const { onSubmit, disabled, loading } = props;
const { onSubmit, disabled, responding } = props;
const language = useLanguage();
const chat = useAIChatState();
@@ -35,7 +35,7 @@ export function AIChatInput(props: {
}, [chat.inputDraft, chatController]);
useEffect(() => {
if (chat.opened && !disabled && !loading) {
if (chat.opened && !disabled && !responding) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -44,7 +44,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, loading, chat.opened]);
}, [disabled, responding, chat.opened]);
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
useEffect(() => {
@@ -61,6 +61,9 @@ export function AIChatInput(props: {
},
{
enableOnFormTags: true,
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
// instead of focusing the Assistant input. RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
}
);
@@ -87,21 +90,21 @@ export function AIChatInput(props: {
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
!disabled && !responding
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading || chat.control !== null}
aria-busy={loading}
disabled={disabled || responding || chat.control !== null}
aria-busy={responding}
ref={inputRef}
header={
<AIChatReferenceChips
references={chat.references}
onRemove={chatController.removeReference}
disabled={loading || disabled}
disabled={responding || disabled}
/>
}
trailing={
@@ -22,7 +22,7 @@ export function AIChatMessages(props: {
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
const showLoadingShim = chat.responding && status !== 'working' && status !== 'done';
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type MessageGroup = { message: AIChatMessage; originalIndex: number };
@@ -139,19 +139,21 @@ export function AIChatMessages(props: {
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
{toolCount > 0
? t(
language,
'ai_chat_explored_with',
tString(
<span data-testid="ai-chat-activity-summary">
{toolCount > 0
? t(
language,
toolCount === 1
? 'tool_count'
: 'tool_count_plural',
toolCount.toString()
'ai_chat_explored_with',
tString(
language,
toolCount === 1
? 'tool_count'
: 'tool_count_plural',
toolCount.toString()
)
)
)
: t(language, 'ai_chat_explored')}
: t(language, 'ai_chat_explored')}
</span>
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
@@ -184,7 +186,7 @@ export function AIChatMessages(props: {
{isLastMessage ? (
<>
{!chat.loading &&
{!chat.responding &&
!chat.error &&
chat.query &&
chat.responseId &&
@@ -1,6 +1,14 @@
import { Icon } from '@gitbook/icons';
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
import type { AIChatReference } from '../AI/references';
import { Link } from '../primitives';
const triggerClassName =
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
export function AIChatReferenceChips(props: {
references: AIChatReference[];
@@ -15,43 +23,99 @@ export function AIChatReferenceChips(props: {
return (
<div className="flex max-w-full flex-wrap gap-1.5">
{references.map((ref) => (
<div
key={ref.id}
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
focusReference(ref);
}}
className="inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 transition hover:bg-tint"
>
<Icon icon="code" className="size-3 shrink-0 opacity-7" />
<span className="min-w-0 truncate font-mono">{ref.label}</span>
</button>
{onRemove ? (
<button
type="button"
aria-label="Remove"
onClick={(event) => {
event.stopPropagation();
onRemove(ref.id);
}}
disabled={disabled}
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
{references.map((ref) => {
const content = (
<>
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
<span
className={tcls(
'min-w-0 truncate',
ref.type === 'code-block' && 'font-mono'
)}
>
<Icon icon="xmark" className="size-2.5" />
</button>
) : null}
</div>
))}
{ref.type === 'text' ? ref.content : ref.label}
</span>
</>
);
return (
<div
key={ref.id}
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
>
{ref.type === 'page' && ref.href ? (
// A page reference may be clicked from anywhere: render a link so it
// navigates back to the page (and supports cmd/ctrl-click to open in a
// new tab).
<Link
href={ref.href}
prefetch={false}
className={triggerClassName}
onClick={(event) => event.stopPropagation()}
>
{content}
</Link>
) : ref.type === 'text' ? (
// A text selection has no persistent DOM anchor to navigate to, so the
// excerpt is shown as plain (non-interactive) content.
<span className={triggerClassName}>{content}</span>
) : (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
focusReference(ref);
}}
className={triggerClassName}
>
{content}
</button>
)}
{onRemove ? (
<button
type="button"
aria-label="Remove"
onClick={(event) => {
event.stopPropagation();
onRemove(ref.id);
}}
disabled={disabled}
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
>
<Icon icon="xmark" className="size-2.5" />
</button>
) : null}
</div>
);
})}
</div>
);
}
function getReferenceIcon(ref: AIChatReference): IconName {
switch (ref.type) {
case 'code-block':
return 'code';
case 'page':
return 'memo';
case 'text':
return 'quote-left';
default:
assertNever(ref);
}
}
/**
* Jump to the content a reference points at, for chips that aren't rendered as links:
* - a code block: scroll it into view on the current page and focus it;
* - a page without a known href: scroll back to the top (the reader is most likely on it).
*/
function focusReference(ref: AIChatReference) {
if (ref.type === 'page') {
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
if (!target) {
@@ -0,0 +1,129 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import fnv1a from '@sindresorhus/fnv1a';
import { AnimatePresence, motion } from 'motion/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { useIsMobile } from '@/components/hooks/useIsMobile';
import { useIsMounted } from '@/components/hooks/useIsMounted';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { AIChatIcon } from '../AIChatIcon';
import { useStableTextSelection } from './useStableTextSelection';
/** Gap between the selection and the button. */
const GAP = 8;
/** Minimum distance to the viewport edges. */
const MARGIN = 8;
/**
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
*/
export function AskAITextSelection() {
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
const toolbarRef = React.useRef<HTMLDivElement>(null);
const { selection, clear } = useStableTextSelection({
rootSelector: '[data-content-ref-root]',
enabled,
ignoreRef: toolbarRef,
});
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
// Position once the button has been measured, so it can be centered and clamped to the viewport.
React.useLayoutEffect(() => {
if (!selection) {
return;
}
const el = toolbarRef.current;
if (!el) {
return;
}
const width = el.offsetWidth;
const height = el.offsetHeight;
const { anchor } = selection;
let top = anchor.top - GAP - height;
if (top < MARGIN) {
// Not enough room above the selection: drop below it.
top = anchor.bottom + GAP;
}
top = Math.min(top, window.innerHeight - height - MARGIN);
const left = Math.min(
Math.max(anchor.centerX - width / 2, MARGIN),
window.innerWidth - width - MARGIN
);
setCoords({ top, left });
}, [selection]);
const onClick = () => {
if (!selection) {
return;
}
const content = selection.text;
if (!content.trim()) {
return;
}
chatController.addReference({
type: 'text',
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.focus();
clear();
};
if (!enabled || !isMounted) {
return null;
}
return createPortal(
<AnimatePresence>
{selection ? (
<motion.div
ref={toolbarRef}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 40,
}}
// Keep the selection alive: prevent the button from stealing focus on click.
onMouseDown={(event) => event.preventDefault()}
>
<Button
size="small"
variant="primary"
icon={<AIChatIcon state="default" trademark={config.trademark} />}
onClick={onClick}
className="shadow-sm"
>
{t(language, 'ask')}
</Button>
</motion.div>
) : null}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1 @@
export * from './AskAITextSelection';
@@ -0,0 +1,202 @@
'use client';
import * as React from 'react';
type SelectionAnchor = {
top: number;
bottom: number;
centerX: number;
};
export type StableTextSelection = {
anchor: SelectionAnchor;
text: string;
};
type Options = {
/** Selector of the content root; both selection endpoints must be inside it. */
rootSelector: string;
enabled: boolean;
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
ignoreRef: React.RefObject<HTMLElement | null>;
};
const KEYBOARD_DEBOUNCE_MS = 250;
const SCROLL_SETTLE_MS = 200;
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
const POINTER_SETTLE_MS = 120;
/**
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
* when a new gesture starts.
*/
export function useStableTextSelection(options: Options): {
selection: StableTextSelection | null;
clear: () => void;
} {
const { rootSelector, enabled, ignoreRef } = options;
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
const clear = React.useCallback(() => {
window.getSelection()?.removeAllRanges();
setSelection(null);
}, []);
React.useEffect(() => {
if (!enabled) {
setSelection(null);
return;
}
let pointerDown = false;
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
const clearKeyboardTimer = () => {
if (keyboardTimer) {
clearTimeout(keyboardTimer);
keyboardTimer = null;
}
};
const clearFinalizeTimer = () => {
if (finalizeTimer) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
};
const finalize = () => {
// A new gesture started; it will reschedule its own finalize.
if (pointerDown) {
return;
}
setSelection(readStableSelection(rootSelector));
};
const schedulePointerFinalize = () => {
clearKeyboardTimer();
clearFinalizeTimer();
finalizeTimer = setTimeout(() => {
finalizeTimer = null;
finalize();
}, POINTER_SETTLE_MS);
};
const isIgnored = (target: EventTarget | null) =>
target instanceof Node && !!ignoreRef.current?.contains(target);
const onSelectionChange = () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
clearKeyboardTimer();
setSelection(null);
return;
}
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
if (pointerDown || finalizeTimer) {
return;
}
clearKeyboardTimer();
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
};
const onPointerDown = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
pointerDown = true;
clearFinalizeTimer();
setSelection(null);
};
const onPointerUp = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
pointerDown = false;
schedulePointerFinalize();
};
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
const onPointerCancel = () => {
pointerDown = false;
schedulePointerFinalize();
};
const onScroll = () => {
setSelection(null);
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
if (!pointerDown) {
finalize();
}
}, SCROLL_SETTLE_MS);
};
const onWindowBlur = () => {
clearKeyboardTimer();
setSelection(null);
};
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', onPointerCancel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onWindowBlur);
return () => {
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', onPointerCancel, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onWindowBlur);
clearKeyboardTimer();
clearFinalizeTimer();
if (scrollTimer) {
clearTimeout(scrollTimer);
}
};
}, [enabled, rootSelector, ignoreRef]);
return { selection, clear };
}
function readStableSelection(rootSelector: string): StableTextSelection | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
return null;
}
const text = sel.toString().trim();
if (!text) {
return null;
}
const root = document.querySelector(rootSelector);
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
return null;
}
const anchor = getSelectionAnchor(sel);
return anchor ? { anchor, text } : null;
}
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect.width && !rect.height) {
return null;
}
if (rect.bottom < 0 || rect.top > window.innerHeight) {
return null;
}
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
}
@@ -3,3 +3,4 @@ export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
@@ -33,6 +33,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const isSingleLine = theme.lines.length <= 1 && !withWrap;
const id = useId();
const codeId = props.id || id;
@@ -79,7 +80,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null}
</div>
<div className="relative flex min-h-0 flex-col">
<div className="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
<div
className={tcls(
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
isSingleLine ? 'inset-y-0' : 'top-2'
)}
>
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
@@ -92,7 +98,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
@@ -92,6 +92,11 @@ export async function Hint({
'empty:p-0',
'-row-end-1',
'-col-end-1',
// Allow the grid track to shrink below its content's min-content size so wide
// nested blocks (e.g. code blocks, tables) scroll internally instead of
// overflowing the hint. Without this, the `1fr` column keeps its default
// `min-width: auto` and grows past the hint's width.
'min-w-0',
'space-y-3',
'[&_.hint]:border',
'[&_pre]:border',
@@ -51,8 +51,8 @@ export function InlineActionButton(
className: 'text-[1em]',
}}
maxLength={action === 'ask' ? 2048 : 512}
disabled={action === 'ask' && chatState.loading}
aria-busy={action === 'ask' && chatState.loading}
disabled={action === 'ask' && chatState.responding}
aria-busy={action === 'ask' && chatState.responding}
leading={icon}
keyboardShortcut={false}
onSubmit={(value) => handleSubmit(value as string)}
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
@@ -2,91 +2,69 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
const { isExternal, target, breadcrumbs, children } = props;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">{target.icon}</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
@@ -1,4 +1,4 @@
import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
@@ -82,7 +82,16 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
renderContext={{
integrationName: block.data.integration,
}}
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
security={{
// Trust both the integrations host and the (cookieless) content host that
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
// postMessage on this list, so a WebFrame served from the content host would
// break (no resize/ready/actions) if the content host weren't trusted.
// The hosts are identical until a distinct content origin is configured.
firstPartyDomains: [
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
],
}}
initialInput={initialInput}
initialOutput={initialOutput}
render={renderIntegrationUi}
@@ -54,6 +54,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.type === 'openapi-operation' ? block.data.headless : undefined,
})}
className="openapi-block"
/>
@@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.data.headless,
})}
className="openapi-block"
/>
@@ -26,8 +26,9 @@ export function getOpenAPIContext(args: {
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
headless?: boolean;
}): OpenAPIContextInput {
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args;
const { block } = props;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
@@ -99,6 +100,7 @@ export function getOpenAPIContext(args: {
),
expandAllResponses: expandAllResponses || props.context.mode === 'print',
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
headless,
id: block.meta?.id,
blockKey: block.key,
locale,
@@ -272,7 +272,7 @@ body {
}
.scalar-activate-button {
@apply flex gap-2 items-center;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
@@ -40,7 +40,7 @@
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -78,7 +78,7 @@
}
.openapi-markdown code {
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -101,7 +101,17 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
@apply 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 mr-2 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,
@@ -130,7 +140,8 @@
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace {
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -149,7 +160,7 @@
}
.openapi-column-preview {
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
@apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)];
}
.openapi-column-preview-body {
@@ -276,11 +287,11 @@
}
.openapi-schema-enum-value:first-child {
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
@apply rounded-l-md straight-corners:rounded-none circular-corners:rounded-l-lg ml-0;
}
.openapi-schema-enum-value:last-child {
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
@apply rounded-r-md straight-corners:rounded-none circular-corners:rounded-r-lg;
}
/* Schema Description */
@@ -314,7 +325,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -344,7 +355,7 @@
}
.openapi-securities-url {
@apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
@apply ml-0.5 px-0.5 rounded-md straight-corners:rounded-none circular-corners:rounded-lg hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -455,16 +466,12 @@
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
}
.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
@apply h-full;
}
.openapi-codesample-header-content {
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
@apply flex items-center font-mono text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path-title {
@@ -491,17 +498,13 @@
}
.openapi-path-variable {
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
@apply text-tint inline;
}
.openapi-summary .openapi-path .openapi-method {
@apply m-0 items-center flex px-2 py-1 h-6;
}
.openapi-path-title {
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
@@ -537,7 +540,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
@apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
}
.openapi-response-examples-panel {
@@ -616,7 +619,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg leading-none;
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
@@ -649,7 +652,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -662,7 +665,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded-md straight-corners:rounded-none circular-corners:rounded-lg !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
@@ -763,7 +766,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded-md straight-corners:rounded-none circular-corners:rounded-lg;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -777,7 +780,7 @@ body:has(.openapi-select-popover) {
/* Disclosure group */
.openapi-disclosure-group {
@apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative;
@apply border-tint-subtle transition-all border-b border-x overflow-auto rounded-corners:last:rounded-b-xl circular-corners:last:rounded-b-2xl straight-corners:last:rounded-b-xs rounded-corners:first:rounded-t-xl circular-corners:first:rounded-t-2xl straight-corners:first:rounded-t-xs first:border-t relative;
}
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) {
@@ -830,7 +833,7 @@ body:has(.openapi-select-popover) {
/* Disclosure */
.openapi-schemas-disclosure {
@apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none;
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@@ -875,7 +878,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
@apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -898,7 +901,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger-label {
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded-md straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -961,7 +964,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tooltip {
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px];
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@@ -1025,7 +1028,7 @@ body:has(.openapi-select-popover) {
}
.openapi-path-copy-button {
@apply p-1 flex rounded-md straight-corners:rounded-none;
@apply p-1 flex rounded-lg straight-corners:rounded-none;
@apply hover:bg-tint dark:hover:bg-tint-hover;
}
@@ -1042,7 +1045,7 @@ body:has(.openapi-select-popover) {
}
.openapi-required-scopes {
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
@apply border text-base rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium mx-0;
}
.openapi-required-scopes .openapi-required-scopes-header {
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { TableSearchRecord } from './TableSearch';
import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
@@ -14,19 +15,21 @@ export function RecordRow(
fixedColumns: string[];
}
) {
const { view, autoSizedColumns, fixedColumns, block, context } = props;
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
return (
<div
<TableSearchRecord
role="row"
recordId={record[0]}
visibleClassName="flex"
className={tcls(
'group/row flex',
'group/row',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
role="row"
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
@@ -63,6 +66,6 @@ export function RecordRow(
</div>
);
})}
</div>
</TableSearchRecord>
);
}
@@ -1,4 +1,4 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import type { DocumentBlockTable } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
@@ -6,11 +6,23 @@ import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { StickyViewGrid } from './StickyViewGrid';
import { TableSearchEmpty, TableSearchInput, TableSearchProvider } from './TableSearch';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
import {
type TableRecordKV,
getTableCheckboxColumns,
getTableRecordSearchData,
getTableSelectColumns,
} from './search';
export type TableRecordKV = [string, DocumentTableRecord];
export type { TableRecordKV };
/**
* Only show the table search once there are enough records that searching is useful.
*/
const MIN_RECORDS_FOR_SEARCH = 7;
export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
view: View;
@@ -22,9 +34,40 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
return a[1].orderIndex.localeCompare(b[1].orderIndex);
});
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) =>
a[1].orderIndex.localeCompare(b[1].orderIndex)
);
const showSearch = context.mode !== 'print' && records.length >= MIN_RECORDS_FOR_SEARCH;
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
return (
<TableSearchProvider records={searchRecords}>
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
selectColumns={getTableSelectColumns(block)}
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
<TableView {...props} isOffscreen={isOffscreen} records={records} />
<TableSearchEmpty />
</div>
</TableSearchProvider>
);
}
/**
* Renders the table itself (grid or cards view) for the given records.
*/
function TableView({
isOffscreen,
records,
...props
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
const { block, context, style } = props;
switch (block.data.view.type) {
case 'cards':
@@ -0,0 +1,310 @@
'use client';
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import React from 'react';
import type { TableCheckboxColumn, TableSelectColumn } from './search';
import { type SelectedOptions, recordMatches } from './searchMatch';
/**
* Client-side table search.
*
* Site pages are statically rendered and can't read `searchParams`, so filtering happens entirely
* on the client rather than round-tripping through the server. The provider matches every record
* once and exposes the set of visible ids; each row/card just looks itself up by id.
*/
/** Per-record matching data, computed on the server. */
export interface TableSearchRecordData {
/** Record key, matching the `key` passed to `<TableSearchRecord>`. */
id: string;
searchText: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
}
type TableSearchContextValue = {
query: string;
setQuery: (query: string) => void;
selectedOptions: SelectedOptions;
toggleOption: (column: string, value: string) => void;
/** Checkbox columns whose filter is currently enabled. */
checkedColumns: ReadonlySet<string>;
toggleCheckbox: (column: string) => void;
/**
* Ids of the records matching the active filters, or `null` when no filter is active
* (in which case every record is shown).
*/
visibleIds: ReadonlySet<string> | null;
/** True when there are records but the active filters match none of them. */
isEmpty: boolean;
};
const TableSearchContext = React.createContext<TableSearchContextValue | null>(null);
/**
* Holds the search query and active filters for a single table.
*/
export function TableSearchProvider(props: {
records?: TableSearchRecordData[];
children: React.ReactNode;
}) {
const { records = [] } = props;
const [query, setQuery] = React.useState('');
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
() => new Set()
);
const toggleOption = React.useCallback((column: string, value: string) => {
setSelectedOptions((previous) => {
const values = new Set(previous[column]);
if (values.has(value)) {
values.delete(value);
} else {
values.add(value);
}
const next = { ...previous };
if (values.size === 0) {
delete next[column];
} else {
next[column] = values;
}
return next;
});
}, []);
const toggleCheckbox = React.useCallback((column: string) => {
setCheckedColumns((previous) => {
const next = new Set(previous);
if (next.has(column)) {
next.delete(column);
} else {
next.add(column);
}
return next;
});
}, []);
const hasActiveFilters =
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
// Match every record once, here, rather than in each row — rows just look themselves up by id.
const visibleIds = React.useMemo(() => {
if (!hasActiveFilters) {
return null;
}
const ids = new Set<string>();
for (const record of records) {
if (
recordMatches(
record.searchText,
record.selectValues,
record.checkboxValues,
query,
selectedOptions,
checkedColumns
)
) {
ids.add(record.id);
}
}
return ids;
}, [records, query, selectedOptions, checkedColumns, hasActiveFilters]);
const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
const value = React.useMemo(
() => ({
query,
setQuery,
selectedOptions,
toggleOption,
checkedColumns,
toggleCheckbox,
visibleIds,
isEmpty,
}),
[query, selectedOptions, toggleOption, checkedColumns, toggleCheckbox, visibleIds, isEmpty]
);
return (
<TableSearchContext.Provider value={value}>{props.children}</TableSearchContext.Provider>
);
}
function useTableSearch(): TableSearchContextValue {
const context = React.useContext(TableSearchContext);
if (!context) {
throw new Error('useTableSearch must be used within a <TableSearchProvider>');
}
return context;
}
/**
* The search form rendered above a table, with a multi-select filter dropdown per select column.
*/
export function TableSearchInput(props: {
selectColumns?: TableSelectColumn[];
checkboxColumns?: TableCheckboxColumn[];
className?: ClassValue;
}) {
const { selectColumns = [], checkboxColumns = [] } = props;
const language = useLanguage();
const { query, setQuery } = useTableSearch();
const hasFilters = selectColumns.length > 0 || checkboxColumns.length > 0;
return (
<Input
label={tString(language, 'search')}
value={query}
onValueChange={setQuery}
leading="magnifying-glass"
clearButton
sizing="small"
keyboardShortcut={false}
className={tcls('w-full', props.className)}
trailing={
hasFilters ? (
// Stop clicks from bubbling to the input container, which would refocus the input.
<div
className="flex items-center gap-1"
onClick={(event) => event.stopPropagation()}
>
{selectColumns.map((column) => (
<SelectFilterDropdown key={column.id} column={column} />
))}
{checkboxColumns.map((column) => (
<CheckboxFilter key={column.id} column={column} />
))}
</div>
) : undefined
}
/>
);
}
/**
* Shown below the table when the active filters match no records.
*/
export function TableSearchEmpty(props: { className?: ClassValue }) {
const language = useLanguage();
const { query, isEmpty } = useTableSearch();
if (!isEmpty) {
return null;
}
const trimmed = query.trim();
return (
<div className={tcls('mx-auto text-center text-sm text-tint', props.className)}>
{trimmed
? tString(language, 'search_no_results_for', trimmed)
: tString(language, 'search_no_results')}
</div>
);
}
/**
* A blank multi-select dropdown button for a single select column. Becomes `active` while any
* of its options are selected.
*/
function SelectFilterDropdown(props: { column: TableSelectColumn }) {
const { column } = props;
const language = useLanguage();
const { selectedOptions, toggleOption } = useTableSearch();
const selectedValues = selectedOptions[column.id];
const activeCount = selectedValues?.size ?? 0;
// Fall back to a generic "Filter" label when the column title is hidden/empty.
const label = column.label.trim() || tString(language, 'search_scope_title');
return (
<DropdownMenu
align="end"
button={
<Button
variant="blank"
size="xsmall"
active={activeCount > 0}
label={activeCount > 1 ? `${label} · ${activeCount}` : label}
trailing={<Icon icon="chevron-down" className="size-3" />}
/>
}
>
{column.options.map((option) => {
const selected = selectedValues?.has(option.value) ?? false;
return (
<DropdownMenuItem
key={option.value}
active={selected}
leadingIcon={selected ? 'check' : undefined}
onSelect={(event) => {
// Keep the menu open so several options can be toggled at once.
event.preventDefault();
toggleOption(column.id, option.value);
}}
>
{option.label || option.value}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
/**
* A checkbox control filtering the table to records where the given checkbox column is checked.
*/
function CheckboxFilter(props: { column: TableCheckboxColumn }) {
const { column } = props;
const language = useLanguage();
const { checkedColumns, toggleCheckbox } = useTableSearch();
const checked = checkedColumns.has(column.id);
const id = `table-search-checkbox-${column.id}`;
// Fall back to a generic "Filter" label when the column title is hidden/empty.
const label = column.label.trim() || tString(language, 'search_scope_title');
return (
<label
htmlFor={id}
className="flex cursor-pointer select-none items-center gap-1.5 px-2 text-sm text-tint"
>
<Checkbox
id={id}
size="small"
checked={checked}
onCheckedChange={() => toggleCheckbox(column.id)}
/>
{label}
</label>
);
}
type TableSearchRecordProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'className'> & {
/** Record id, matching the `id` of the data passed to `<TableSearchProvider>`. */
recordId: string;
/** Display utility applied when the record matches (e.g. `flex` for rows, `contents` for cards). */
visibleClassName: string;
className?: ClassValue;
children: React.ReactNode;
};
/**
* Wraps a single table record (a grid row or a card) and hides it when it doesn't match
* the current filters. Matching happens once in the provider; here we just look up the id.
*/
export function TableSearchRecord(props: TableSearchRecordProps) {
const { recordId, visibleClassName, className, children, ...rest } = props;
const { visibleIds } = useTableSearch();
const matches = visibleIds === null || visibleIds.has(recordId);
return (
<div className={tcls(matches ? visibleClassName : 'hidden', className)} {...rest}>
{children}
</div>
);
}
@@ -4,6 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordCard } from './RecordCard';
import type { TableViewProps } from './Table';
import { TableSearchRecord } from './TableSearch';
export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
const { block, view, records, style } = props;
@@ -21,7 +22,15 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
)}
>
{records.map((record) => {
return <RecordCard key={record[0]} {...props} record={record} />;
return (
<TableSearchRecord
key={record[0]}
recordId={record[0]}
visibleClassName="contents"
>
<RecordCard {...props} record={record} />
</TableSearchRecord>
);
})}
</div>
);
@@ -0,0 +1,146 @@
import type {
DocumentBlockTable,
DocumentTableRecord,
DocumentTableSelectOption,
} from '@gitbook/api';
import { getNodeFragmentByName, getNodeText } from '@/lib/document';
export type TableRecordKV = [string, DocumentTableRecord];
export interface TableSelectColumn {
/** Column id (the column key in `block.data.definition`). */
id: string;
/** Column title, shown on the filter button. */
label: string;
/** Available options for the column. */
options: DocumentTableSelectOption[];
}
/**
* List the visible "select" columns of a table along with their options.
* Used to render the per-column filter dropdowns next to the search input.
*/
export function getTableSelectColumns(block: DocumentBlockTable): TableSelectColumn[] {
return block.data.view.columns.flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'select') {
return [];
}
return [{ id: column, label: definition.title, options: definition.options }];
});
}
export interface TableCheckboxColumn {
/** Column id. */
id: string;
/** Column title, shown next to the checkbox. */
label: string;
}
/**
* List the visible "checkbox" columns of a table.
* Used to render a filter checkbox per column next to the search input.
*/
export function getTableCheckboxColumns(block: DocumentBlockTable): TableCheckboxColumn[] {
return block.data.view.columns.flatMap((column) => {
const definition = block.data.definition[column];
if (definition?.type !== 'checkbox') {
return [];
}
return [{ id: column, label: definition.title }];
});
}
/**
* Build the search data for a record (searchable text + select/checkbox values) in a single
* pass over its columns. Used to feed the client-side table search (see `TableSearch`).
*/
export function getTableRecordSearchData(block: DocumentBlockTable, record: DocumentTableRecord) {
const searchText: string[] = [];
const selectValues: Record<string, string[]> = {};
const checkboxValues: Record<string, boolean> = {};
for (const column of block.data.view.columns) {
const text = getTableCellSearchText(block, record, column);
if (text) {
searchText.push(text);
}
const value = record.values[column];
switch (block.data.definition[column]?.type) {
case 'select':
if (Array.isArray(value)) {
selectValues[column] = value.filter(
(item): item is string => typeof item === 'string'
);
}
break;
case 'checkbox':
if (typeof value === 'boolean') {
checkboxValues[column] = value;
}
break;
}
}
return { searchText: searchText.join(' '), selectValues, checkboxValues };
}
function getTableCellSearchText(
block: DocumentBlockTable,
record: DocumentTableRecord,
column: string
): string {
const definition = block.data.definition[column];
const value = record.values[column];
if (!definition || value === null || value === undefined) {
return '';
}
switch (definition.type) {
case 'text': {
if (typeof value !== 'string') {
return '';
}
const fragment = getNodeFragmentByName(block, value);
return normalizeSearchText(fragment ? getNodeText(fragment) : '');
}
case 'select': {
if (!Array.isArray(value)) {
return '';
}
return normalizeSearchText(
value
.map((selectId) => {
return (
definition.options.find((option) => option.value === selectId)?.label ??
selectId
);
})
.join(' ')
);
}
case 'number':
case 'rating': {
return typeof value === 'number' ? `${value}` : '';
}
case 'checkbox': {
return typeof value === 'boolean' ? `${value}` : '';
}
// Reference-like columns (files, users, content-ref, image) render resolved names/text
// asynchronously in `RecordColumnValue`. We only have raw ids here, so indexing them would
// never match the visible text — leave them out rather than search opaque ids.
default:
return '';
}
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'bun:test';
import { type SelectedOptions, matchesText, recordMatches } from './searchMatch';
const NO_OPTIONS: SelectedOptions = {};
const NO_CHECKBOXES: ReadonlySet<string> = new Set();
function match(
record: {
searchText?: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
},
filters: {
query?: string;
selectedOptions?: SelectedOptions;
checkedColumns?: ReadonlySet<string>;
}
): boolean {
return recordMatches(
record.searchText ?? '',
record.selectValues,
record.checkboxValues,
filters.query ?? '',
filters.selectedOptions ?? NO_OPTIONS,
filters.checkedColumns ?? NO_CHECKBOXES
);
}
describe('matchesText', () => {
it('matches everything when the query is empty', () => {
expect(matchesText('anything', '')).toBe(true);
expect(matchesText('', ' ')).toBe(true);
});
it('matches case-insensitively', () => {
expect(matchesText('Hello World', 'hello')).toBe(true);
expect(matchesText('Hello World', 'nope')).toBe(false);
});
it('matches regex metacharacters literally', () => {
expect(matchesText('value a+b here', 'a+b')).toBe(true);
expect(matchesText('v1x2', 'v1.2')).toBe(false);
expect(matchesText('a (b) c', '(b)')).toBe(true);
});
});
describe('recordMatches', () => {
it('shows every record when no filter is active', () => {
expect(match({ searchText: 'whatever' }, {})).toBe(true);
});
it('applies the text filter', () => {
expect(match({ searchText: 'Ace AI' }, { query: 'ace' })).toBe(true);
expect(match({ searchText: 'Ace AI' }, { query: 'discrete' })).toBe(false);
});
describe('select columns', () => {
const selectedStatus: SelectedOptions = { status: new Set(['active', 'pending']) };
it('ORs multiple values within a single column', () => {
expect(
match(
{ selectValues: { status: ['pending'] } },
{ selectedOptions: selectedStatus }
)
).toBe(true);
expect(
match(
{ selectValues: { status: ['archived'] } },
{ selectedOptions: selectedStatus }
)
).toBe(false);
});
it('ANDs across different columns', () => {
const filters: SelectedOptions = {
status: new Set(['active']),
tier: new Set(['gold']),
};
expect(
match(
{ selectValues: { status: ['active'], tier: ['gold'] } },
{ selectedOptions: filters }
)
).toBe(true);
// Matches one column but not the other → excluded.
expect(
match(
{ selectValues: { status: ['active'], tier: ['silver'] } },
{ selectedOptions: filters }
)
).toBe(false);
});
it('excludes records missing the column entirely', () => {
expect(match({ selectValues: {} }, { selectedOptions: selectedStatus })).toBe(false);
});
});
describe('checkbox columns', () => {
const featured: ReadonlySet<string> = new Set(['featured']);
it('keeps only records checked for the enabled column', () => {
expect(
match({ checkboxValues: { featured: true } }, { checkedColumns: featured })
).toBe(true);
expect(
match({ checkboxValues: { featured: false } }, { checkedColumns: featured })
).toBe(false);
expect(match({ checkboxValues: {} }, { checkedColumns: featured })).toBe(false);
});
it('ANDs multiple enabled checkbox columns', () => {
const both: ReadonlySet<string> = new Set(['featured', 'inStock']);
expect(
match(
{ checkboxValues: { featured: true, inStock: true } },
{ checkedColumns: both }
)
).toBe(true);
expect(
match(
{ checkboxValues: { featured: true, inStock: false } },
{ checkedColumns: both }
)
).toBe(false);
});
});
it('ANDs the text, select and checkbox filters together', () => {
const record = {
searchText: 'Ace AI',
selectValues: { status: ['active'] },
checkboxValues: { featured: true },
};
const filters = {
query: 'ace',
selectedOptions: { status: new Set(['active']) } satisfies SelectedOptions,
checkedColumns: new Set(['featured']),
};
expect(match(record, filters)).toBe(true);
// Each individual filter failing flips the result to false.
expect(match(record, { ...filters, query: 'discrete' })).toBe(false);
expect(
match(record, { ...filters, selectedOptions: { status: new Set(['archived']) } })
).toBe(false);
expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false);
});
});
@@ -0,0 +1,65 @@
/**
* Pure matching logic for the client-side table search.
*
* Kept free of React/client dependencies so it can be unit-tested in isolation and shared
* between the search UI and (potentially) other callers.
*/
/** Selected option values per select column, keyed by column id. */
export type SelectedOptions = Readonly<Record<string, ReadonlySet<string>>>;
/**
* Whether a record passes the current filters.
*
* The text query, each select column and each enabled checkbox column are combined with AND:
* a record must match the text (when present), satisfy every column that has a selection, and
* be checked for every enabled checkbox column. Within a single select column the selected
* values are combined with OR the record matches the column if it has any of them.
*/
export function recordMatches(
searchText: string,
selectValues: Record<string, string[]> | undefined,
checkboxValues: Record<string, boolean> | undefined,
query: string,
selectedOptions: SelectedOptions,
checkedColumns: ReadonlySet<string>
): boolean {
if (query.trim() !== '' && !matchesText(searchText, query)) {
return false;
}
for (const [column, values] of Object.entries(selectedOptions)) {
if (values.size === 0) {
continue;
}
const recordValues = selectValues?.[column];
const matchesColumn = !!recordValues && recordValues.some((value) => values.has(value));
if (!matchesColumn) {
return false;
}
}
for (const column of checkedColumns) {
if (checkboxValues?.[column] !== true) {
return false;
}
}
return true;
}
/**
* Whether a record's searchable text contains the query.
*
* Plain case-insensitive substring match: the field is a free-text search, so regex
* metacharacters (`.`, `+`, `(`, ) are matched literally rather than treated as patterns.
*/
export function matchesText(searchText: string, query: string): boolean {
const trimmed = query.trim();
if (!trimmed) {
return true;
}
return searchText.toLowerCase().includes(trimmed.toLowerCase());
}
@@ -79,7 +79,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameHeader>
{!tabsRef.current ? (
<AIChatDynamicIcon className="animate-blur-in-slow" trademark={trademark} />
@@ -1,6 +1,6 @@
'use client';
import { useAIChatState } from '@/components/AI';
import { useAIChatController, useAIChatState } from '@/components/AI';
import type { Assistant } from '@/components/AI';
import { Button } from '@/components/primitives/Button';
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
@@ -15,12 +15,28 @@ import { createStore, useStore } from 'zustand';
type PageActionType = 'button' | 'dropdown-menu-item';
/**
* Context about the current page, attached to the assistant as a reference when opened.
*/
export type PageActionAssistantContext = {
id: string;
title: string;
path?: string;
/** Site-relative href of the page, used to navigate back to it from the chip. */
href?: string;
};
/**
* Action to open the GitBook Assistant.
*/
export function ActionOpenAssistant(props: { assistant: Assistant; type: PageActionType }) {
const { assistant, type } = props;
export function ActionOpenAssistant(props: {
assistant: Assistant;
type: PageActionType;
page?: PageActionAssistantContext;
}) {
const { assistant, type, page } = props;
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
return (
@@ -30,8 +46,20 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
label={assistant.label}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
disabled={chat.loading}
disabled={chat.responding}
onClick={() => {
// Stage a reference to the current page so the assistant is informed about
// the context the user is asking from. Only the sidebar GitBook Assistant
// uses the chat reference system.
if (page && assistant.mode === 'sidebar') {
chatController.addReference({
type: 'page',
id: page.id,
label: page.title,
path: page.path,
href: page.href,
});
}
assistant.open();
}}
/>
@@ -3,9 +3,13 @@
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
import React, { useRef } from 'react';
import { useAI } from '../AI';
import type {
CustomizationPageActionType,
GitSyncState,
SiteCustomizationSettings,
} from '@gitbook/api';
import { type ReactNode, useRef } from 'react';
import { type Assistant, useAI } from '../AI';
import { ToggleChevron } from '../primitives';
import {
ActionCopyMCPCommand,
@@ -18,8 +22,35 @@ import {
ActionViewAsMarkdown,
ActionViewAsPDF,
ActionViewAsRSS,
type PageActionAssistantContext,
} from './PageActions';
/**
* Type of a built-in page action that can be displayed in the page actions menu.
*/
type PageActionType = `${CustomizationPageActionType}`;
/**
* Order used to derive the list of actions from the deprecated boolean flags when the API does not
* provide `items` yet. It matches the order the page actions menu used before the `items` model, so
* existing sites keep the same dropdown ordering until they are migrated.
*/
const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [
'assistant',
'markdown',
'external-ai',
'mcp',
'git',
'pdf',
];
/**
* Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior,
* which only ever surfaced the assistant, the Git edit link or the markdown copy as the default
* action never ChatGPT, MCP or PDF.
*/
const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown'];
export type PageActionsDropdownURLs = {
html: string;
markdown: string;
@@ -37,22 +68,95 @@ interface PageActionsDropdownProps {
urls: PageActionsDropdownURLs;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
/** The current page, referenced by the assistant when opened. */
page: PageActionAssistantContext;
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
* Dropdown menu for the page actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*
* The order and enabled state of the built-in actions are driven by `actions.items`, the ordered
* list of enabled page actions, with its first available action shown as a quick-access button.
* When the API does not provide `items` yet, the menu falls back to the previous ordering and
* default-action priority so existing sites keep their current behavior.
*/
export function PageActionsDropdown(props: PageActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
const { siteTitle, urls } = props;
const defaultAction = usePageDefaultAction(props);
const dropdownActions = getPageDropdownActions(props);
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
// `items` is the source of truth when the API provides it. Until then (legacy mode), we derive
// the list from the deprecated boolean flags using the previous ordering.
const configuredItems = getConfiguredPageActionItems(props.actions);
const isLegacy = configuredItems === null;
const items = configuredItems ?? deriveLegacyPageActionItems(props.actions);
return defaultAction || dropdownActions.length > 0 ? (
let defaultAction: ReactNode = null;
let markdownIsDefault = false;
if (urls.rss) {
// The RSS feed is not part of the configurable `items` list: it is only available on the
// relevant pages (e.g. blog/changelog index). It is promoted as the default action whenever
// present, as a contextual override of the configured list.
defaultAction = <ActionViewAsRSS url={urls.rss} type="button" />;
} else {
// The default button is the first available action. With `items`, that is simply the first
// entry of the configured list. In legacy mode we keep the previous default-action priority
// (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF.
const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items;
const defaultActionType = defaultPriority.find(
(type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants)
);
if (defaultActionType) {
defaultAction = renderDefaultActionForType(defaultActionType, {
siteTitle,
urls,
assistants,
page: props.page,
});
markdownIsDefault = defaultActionType === 'markdown';
}
}
// Build the dropdown menu items, grouped by action type. RSS is appended as its own group
// since it is not part of the configurable `items` list.
const groups: { key: string; items: ReactNode[] }[] = items
.map((type) => ({
key: type,
items: renderDropdownActionsForType(type, {
siteTitle,
urls,
markdownIsDefault,
assistants,
page: props.page,
}),
}))
.filter((group) => group.items.length > 0);
if (urls.rss) {
groups.push({
key: 'rss',
items: [<ActionViewAsRSS key="rss" url={urls.rss} type="dropdown-menu-item" />],
});
}
// Count the actual menu items (not the groups): the dropdown toggle must stay visible when a
// single action type still exposes more than one item beyond the default button (e.g. markdown
// exposes both "Copy page" and "View as Markdown").
const menuItemCount = groups.reduce((total, group) => total + group.items.length, 0);
// Insert a separator before each group; the leading one is hidden via `first:hidden`.
const dropdownActions = groups.flatMap((group) => [
<DropdownMenuSeparator key={`separator-${group.key}`} className="first:hidden" />,
...group.items,
]);
return defaultAction || menuItemCount > 0 ? (
<ButtonGroup ref={ref} className={props.className}>
{defaultAction}
{!defaultAction || dropdownActions.length > 1 ? (
{!defaultAction || menuItemCount > 1 ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
@@ -75,122 +179,212 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
}
/**
* Return the list of actions to show in the dropdown menu.
* Return the configured ordered list of enabled page actions, or `null` when the API does not
* provide it yet (legacy mode).
*/
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
const { siteTitle, urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
function getConfiguredPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] | null {
return actions.items ?? null;
}
return [
...assistants.map((assistant) => (
<ActionOpenAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
)),
/**
* Derive the ordered list of enabled page actions from the deprecated boolean flags, following the
* ordering used before the `items` model. Used only when the API does not provide `items`.
*/
function deriveLegacyPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] {
return LEGACY_PAGE_ACTION_ORDER.filter((type) => {
switch (type) {
case 'external-ai':
return actions.externalAI;
case 'markdown':
return actions.markdown;
case 'mcp':
return actions.mcp;
// `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented
// by the legacy `pageActions` flags; all three are gated by availability at render time.
case 'assistant':
case 'git':
case 'pdf':
return true;
default:
return false;
}
});
}
actions.markdown ? (
<React.Fragment key="markdown">
<DropdownMenuSeparator className="first:hidden" />
/**
* Whether an action type can be rendered given the available URLs and assistants.
*/
function isActionTypeAvailable(
type: PageActionType,
urls: PageActionsDropdownURLs,
assistants: Assistant[]
): boolean {
switch (type) {
case 'assistant':
return assistants.length > 0;
case 'external-ai':
case 'markdown':
return true;
case 'mcp':
return !!urls.mcp;
case 'git':
return !!urls.editOnGit;
case 'pdf':
return !!urls.pdf;
default:
return false;
}
}
/**
* Render the list of menu items shown in the dropdown for a given action type.
*
* Returns a flat array of items (without separators); the caller groups them and inserts the
* separators between groups.
*/
function renderDropdownActionsForType(
type: PageActionType,
params: {
siteTitle: string;
urls: PageActionsDropdownURLs;
markdownIsDefault: boolean;
assistants: Assistant[];
page: PageActionAssistantContext;
}
): ReactNode[] {
const { siteTitle, urls, markdownIsDefault, assistants, page } = params;
switch (type) {
case 'assistant':
return assistants.map((assistant) => (
<ActionOpenAssistant
key={`assistant-${assistant.id}`}
assistant={assistant}
type="dropdown-menu-item"
page={page}
/>
));
case 'external-ai':
return [
<ActionOpenInLLM
key="chatgpt"
provider="chatgpt"
url={urls.html}
type="dropdown-menu-item"
/>,
<ActionOpenInLLM
key="claude"
provider="claude"
url={urls.html}
type="dropdown-menu-item"
/>,
];
case 'markdown':
return [
<ActionCopyMarkdown
isDefaultAction={!assistants.length}
key="copy-markdown"
isDefaultAction={markdownIsDefault}
markdownPageURL={urls.markdown}
type="dropdown-menu-item"
/>
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" />
</React.Fragment>
) : null,
actions.externalAI ? (
<React.Fragment key="externalAI">
<DropdownMenuSeparator className="first:hidden" />
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" />
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" />
</React.Fragment>
) : null,
actions.mcp && urls.mcp ? (
<React.Fragment key="mcp">
<DropdownMenuSeparator className="first:hidden" />
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" />
/>,
<ActionViewAsMarkdown
key="view-markdown"
markdownPageURL={urls.markdown}
type="dropdown-menu-item"
/>,
];
case 'mcp':
if (!urls.mcp) {
return [];
}
return [
<ActionCopyMCPURL key="copy-mcp" mcpURL={urls.mcp} type="dropdown-menu-item" />,
<ActionOpenMCP
key="mcp-vscode"
provider="vscode"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>
/>,
<ActionCopyMCPCommand
key="mcp-claude-code"
provider="claude-code"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>
/>,
<ActionCopyMCPCommand
key="mcp-codex"
provider="codex"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>
</React.Fragment>
) : null,
urls.editOnGit || urls.pdf || urls.rss ? (
<React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" />
{urls.editOnGit ? (
<ActionOpenEditOnGit
type="dropdown-menu-item"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
) : null}
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
</React.Fragment>
) : null,
].filter(Boolean);
/>,
];
case 'git':
if (!urls.editOnGit) {
return [];
}
return [
<ActionOpenEditOnGit
key="edit-git"
type="dropdown-menu-item"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>,
];
case 'pdf':
if (!urls.pdf) {
return [];
}
return [<ActionViewAsPDF key="pdf" url={urls.pdf} type="dropdown-menu-item" />];
default:
return [];
}
}
/**
* A default action shown as a quick-access button beside the dropdown menu
* Render the action shown as the quick-access default button for a given action type.
*/
function usePageDefaultAction(props: PageActionsDropdownProps) {
const { urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
if (urls.rss) {
return <ActionViewAsRSS url={urls.rss} type="button" />;
function renderDefaultActionForType(
type: PageActionType,
params: {
siteTitle: string;
urls: PageActionsDropdownURLs;
assistants: Assistant[];
page: PageActionAssistantContext;
}
): ReactNode {
const { urls, assistants, page } = params;
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
switch (type) {
case 'assistant':
return assistants[0] ? (
<ActionOpenAssistant assistant={assistants[0]} type="button" page={page} />
) : null;
case 'external-ai':
return <ActionOpenInLLM provider="chatgpt" url={urls.html} type="button" />;
case 'markdown':
return (
<ActionCopyMarkdown isDefaultAction markdownPageURL={urls.markdown} type="button" />
);
case 'mcp':
return urls.mcp ? <ActionCopyMCPURL mcpURL={urls.mcp} type="button" /> : null;
case 'git':
return urls.editOnGit ? (
<ActionOpenEditOnGit
type="button"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
) : null;
case 'pdf':
return urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="button" /> : null;
default:
return null;
}
if (urls.editOnGit) {
return (
<ActionOpenEditOnGit
type="button"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
);
}
if (actions.markdown) {
return (
<ActionCopyMarkdown
isDefaultAction={!assistant}
markdownPageURL={urls.markdown}
type="button"
/>
);
}
return null;
}
@@ -7,6 +7,7 @@ import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { useBodyLoaded } from '@/components/primitives';
import { OpenAPIMethodBadge } from '@gitbook/react-openapi';
import { HEADER_HEIGHT_DESKTOP } from '../layout';
/**
@@ -141,11 +142,11 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
)}
>
{section.tag ? (
<span
className={`-mt-0.5 openapi-method text-xs! openapi-method-${section.tag.toLowerCase()}`}
>
{section.tag}
</span>
<OpenAPIMethodBadge
method={section.tag}
size="small"
className="-mt-0.5"
/>
) : null}
<span
@@ -106,18 +106,20 @@ export async function PageBody(props: {
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
>
<SuspenseLoadedHint />
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
...context,
page,
},
withLinkPreviews,
}}
/>
<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} />
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { CustomizationAIMode, type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import urlJoin from 'url-join';
import { getPDFURLSearchParams } from '../PDF';
@@ -33,7 +33,10 @@ export async function PageHeader(props: {
const hasPageActions =
pageActionsEnabled &&
[
...Object.values(context.customization.pageActions),
context.customization.ai.mode === CustomizationAIMode.Assistant,
context.customization.pageActions.externalAI,
context.customization.pageActions.markdown,
context.customization.pageActions.mcp,
context.customization.pdf.enabled,
context.customization.git.showEditLink,
withRSSFeed,
@@ -56,6 +59,12 @@ export async function PageHeader(props: {
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
page={{
id: page.id,
title: page.title,
path: page.path,
href: linker.toPathForPage({ pages: revision.pages, page }),
}}
/>
) : null}
<PageAsideToggleButton />
@@ -69,6 +69,10 @@ export function SearchContainer({
},
{
enableOnFormTags: true,
// Match the logical character typed, not the physical key position, so
// non-QWERTY layouts don't trigger the shortcut by position (e.g. on
// Dvorak the physical "K"/"I" keys produce other characters). RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'k',
}
);
@@ -84,6 +88,9 @@ export function SearchContainer({
},
{
enableOnFormTags: true,
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
// instead of opening the Assistant. RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
}
);
@@ -14,7 +14,7 @@ import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
import { AIChat } from '../AIChat';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
import { OpenAPICodeSampleAIProvider } from '../DocumentView/OpenAPI/OpenAPICodeSampleAIProvider';
@@ -130,7 +130,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<Announcement context={context} />
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? <AIChat /> : null}
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<>
<AIChat />
<AskAITextSelection />
</>
) : null}
{/* Chat panel shifts content left when open */}
<div className="motion-safe:transition-all motion-safe:duration-300 lg:chat-open:mr-80 xl:chat-open:mr-96">
@@ -2,6 +2,7 @@
import { tcls } from '@/lib/tailwind';
import { SiteInsightsLinkPosition } from '@gitbook/api';
import { OpenAPIMethodBadge } from '@gitbook/react-openapi';
import { Tag } from '../Tag';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
@@ -41,7 +42,18 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
icon={<TOCPageIcon page={page} />}
tag={page.primaryTag ? <Tag tag={page.primaryTag} /> : null}
>
{page.title}
{page.openAPIOperation ? (
<span className="flex h-[1lh] shrink-0 items-center self-center">
<OpenAPIMethodBadge
method={page.openAPIOperation.method}
short
size="small"
/>
</span>
) : null}
<span className={page.openAPIOperation?.deprecated ? 'line-through' : undefined}>
{page.title}
</span>
</ToggleableLinkItem>
</li>
);
@@ -1,4 +1,5 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getOpenAPIOperationPageProps } from '@/lib/openapi/computedSourceProps';
import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { getRevisionTags, resolveTag } from '@/lib/tags';
@@ -26,6 +27,7 @@ export type ClientTOCPageDocument = {
pathnames: string[];
descendants?: ClientTOCPage[];
primaryTag?: RevisionTag;
openAPIOperation?: { method: string; deprecated: boolean };
};
export type ClientTOCPageGroup = {
@@ -83,6 +85,7 @@ export async function encodeClientTableOfContents(
pathnames: getPagePaths(rootPages, page),
descendants,
primaryTag,
openAPIOperation: getOpenAPIOperationPageProps(page),
type: 'document',
})
);
@@ -3,7 +3,7 @@ export const ToCItemBaseStyles = [
'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3',
'focus-visible:-outline-offset-2',
'before:contents[] before:-left-px before:absolute before:inset-y-0',
'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px',
'sidebar-list-line:rounded-l-none! sidebar-list-line:before:w-px [&+div_a]:sidebar-list-default:rounded-l-none has-[.openapi-method]:pl-3 [&+div_a]:pl-5 [&+div_a]:sidebar-list-default:before:w-px',
];
export const ToCLinkItemStyles = [
+9
View File
@@ -84,6 +84,15 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
export const GITBOOK_INTEGRATIONS_HOST =
process.env.GITBOOK_INTEGRATIONS_HOST || 'integrations.gitbook.com';
/**
* Hostname serving integration-rendered content (e.g. WebFrames), isolated from the
* cookie-bearing integrations origin to remediate stored-XSS on the main origin.
* Defaults to `GITBOOK_INTEGRATIONS_HOST` until a distinct content origin is configured,
* which keeps behavior unchanged.
*/
export const GITBOOK_INTEGRATIONS_CONTENT_HOST =
process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST || GITBOOK_INTEGRATIONS_HOST;
/**
* Hostname for fonts.
*/
@@ -0,0 +1,28 @@
import type { ComputedContentSourceDocumentOpenAPI, RevisionPageDocument } from '@gitbook/api';
/**
* Get the method and deprecated state of a page generated by the OpenAPI
* computed source in one-operation-per-page mode.
*/
export function getOpenAPIOperationPageProps(
page: RevisionPageDocument
): { method: string; deprecated: boolean } | undefined {
if (!('computed' in page)) {
return;
}
const source = page.computed;
if (source.type !== 'builtin:openapi') {
return;
}
const { props } = source as ComputedContentSourceDocumentOpenAPI;
if (props.doc !== 'operation') {
return;
}
return {
method: props.method,
deprecated: props.deprecated === true,
};
}
+6
View File
@@ -1,4 +1,5 @@
import * as api from '@gitbook/api';
import { CustomizationPageActionType } from '@gitbook/api';
/**
* Return the default customization settings for a site.
@@ -78,6 +79,11 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
@@ -77,7 +77,13 @@ function OpenAPICodeSampleHeader(props: {
return (
<>
<OpenAPIPath context={context} canCopy={false} withServer={false} data={data} />
<OpenAPIPath
context={context}
canCopy={false}
withServer={false}
data={data}
methodBadgeSize="small"
/>
{allItems.length > 1 ? (
<OpenAPISelect
icon={selectIcon}
@@ -0,0 +1,28 @@
import clsx from 'classnames';
import { formatOpenAPIMethod } from './formatOpenAPIMethod';
/**
* Badge displaying the HTTP method of an OpenAPI operation.
*/
export function OpenAPIMethodBadge(props: {
method: string;
/** Abbreviate the label (DEL, OPTS) instead of the full method. */
short?: boolean;
size?: 'small' | 'medium';
className?: string;
}) {
const { method, short = false, size = 'medium', className } = props;
const normalized = method.toLowerCase();
return (
<span
className={clsx(
'openapi-method',
`openapi-method-${normalized}`,
`openapi-method-${size}`,
className
)}
>
{short ? formatOpenAPIMethod(normalized) : method}
</span>
);
}
@@ -16,6 +16,11 @@ export type OpenAPIPathProps = {
* @default true
*/
canCopy?: boolean;
/**
* The size of the HTTP method badge.
* @default 'medium'
*/
methodBadgeSize?: 'small' | 'medium';
};
/**
+11 -2
View File
@@ -1,4 +1,5 @@
import { OpenAPICopyButton } from './OpenAPICopyButton';
import { OpenAPIMethodBadge } from './OpenAPIMethodBadge';
import type { OpenAPIPathProps } from './OpenAPIPath';
import type { OpenAPIClientContext } from './context';
@@ -10,14 +11,22 @@ export function OpenAPIPathItem(
context: OpenAPIClientContext;
}
) {
const { value, canCopy = true, context, children, data, copyType = 'children' } = props;
const {
value,
canCopy = true,
context,
children,
data,
copyType = 'children',
methodBadgeSize = 'medium',
} = props;
const { operation, method } = data;
const title = <span className="openapi-path-title">{children}</span>;
return (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
<OpenAPIMethodBadge method={method} size={methodBadgeSize} />
{canCopy && value ? (
copyType === 'children' ? (
<OpenAPICopyButton
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { type OpenAPIV3, parseOpenAPI } from '@gitbook/openapi-parser';
import { getSchemaAlternatives, getSchemaProperties } from './OpenAPISchema';
import { dereferenceFilesystem } from './dereference';
describe('getSchemaAlternatives', () => {
it('should flatten oneOf', () => {
@@ -681,6 +682,209 @@ describe('getSchemaAlternatives', () => {
expect(result?.schemas[0]?.required).toContain('labelArgbColor');
});
});
describe('circular oneOf with discriminator and allOf', () => {
it('should handle variants that reference the parent via allOf', () => {
const pet: OpenAPIV3.SchemaObject = {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
};
const dog: OpenAPIV3.SchemaObject = {
title: 'Dog',
allOf: [pet, { type: 'object', properties: { barkVolume: { type: 'number' } } }],
};
const cat: OpenAPIV3.SchemaObject = {
title: 'Cat',
allOf: [pet],
properties: { huntingSkill: { type: 'string' } },
};
pet.oneOf = [dog, cat];
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
// Original schema must not be mutated
expect(Object.keys(pet.properties ?? {})).toHaveLength(2);
expect(pet.properties).not.toHaveProperty('barkVolume');
expect(pet.properties).not.toHaveProperty('huntingSkill');
});
it('should handle dereferenced copies (different object, shared property refs)', () => {
// After @scalar/openapi-parser dereference, $ref entries become new objects
// with shallow-copied properties from the original (not the same JS reference).
const pet: OpenAPIV3.SchemaObject = {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
};
// Simulate dereference: $ref is replaced with a NEW object that has
// the same property values (shared references) as the original.
const petCopyForDog = { ...pet };
const petCopyForCat = { ...pet };
const dog: OpenAPIV3.SchemaObject = {
title: 'Dog',
allOf: [
petCopyForDog,
{ type: 'object', properties: { barkVolume: { type: 'number' } } },
],
};
const cat: OpenAPIV3.SchemaObject = {
title: 'Cat',
allOf: [petCopyForCat],
properties: { huntingSkill: { type: 'string' } },
};
pet.oneOf = [dog, cat];
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
expect(catVariant).not.toHaveProperty('oneOf');
expect(catVariant).not.toHaveProperty('discriminator');
expect(catVariant).not.toHaveProperty('description');
});
});
describe('integration: parse + dereference + getSchemaAlternatives', () => {
it('should resolve polymorphic oneOf variants from a real spec', async () => {
const spec = JSON.stringify({
openapi: '3.0.1',
info: { title: 'PetStore', version: '1.0' },
paths: {},
components: {
schemas: {
Pet: {
type: 'object',
description: 'A pet in the store',
discriminator: {
propertyName: 'petType',
mapping: {
dog: '#/components/schemas/Dog',
cat: '#/components/schemas/Cat',
},
},
oneOf: [
{ $ref: '#/components/schemas/Dog' },
{ $ref: '#/components/schemas/Cat' },
],
properties: {
name: { type: 'string' },
petType: { type: 'string' },
},
required: ['petType'],
},
Dog: {
allOf: [
{ $ref: '#/components/schemas/Pet' },
{
type: 'object',
properties: { barkVolume: { type: 'number' } },
},
],
},
Cat: {
allOf: [{ $ref: '#/components/schemas/Pet' }],
properties: { huntingSkill: { type: 'string' } },
},
},
},
});
const { filesystem } = await parseOpenAPI({
value: spec,
rootURL: 'memory://spec.json',
});
const doc = await dereferenceFilesystem(filesystem);
const pet = doc.components?.schemas?.Pet as OpenAPIV3.SchemaObject;
const result = getSchemaAlternatives(pet);
expect(result?.type).toBe('oneOf');
expect(result?.schemas).toHaveLength(2);
const dogVariant = result?.schemas[0];
expect(dogVariant?.title).toBe('Dog');
expect(dogVariant?.properties).toHaveProperty('name');
expect(dogVariant?.properties).toHaveProperty('petType');
expect(dogVariant?.properties).toHaveProperty('barkVolume');
expect(dogVariant).not.toHaveProperty('oneOf');
expect(dogVariant).not.toHaveProperty('discriminator');
expect(dogVariant).not.toHaveProperty('description');
const catVariant = result?.schemas[1];
expect(catVariant?.title).toBe('Cat');
expect(catVariant?.properties).toHaveProperty('name');
expect(catVariant?.properties).toHaveProperty('petType');
expect(catVariant?.properties).toHaveProperty('huntingSkill');
expect(catVariant).not.toHaveProperty('oneOf');
expect(catVariant).not.toHaveProperty('discriminator');
expect(catVariant).not.toHaveProperty('description');
});
});
});
describe('getSchemaProperties', () => {
+100 -3
View File
@@ -453,7 +453,10 @@ function OpenAPISchemaAlternativeSeparator(props: {
/**
* Render a circular reference to a schema.
*/
function OpenAPISchemaCircularRef(props: { id: string; schema: OpenAPIV3.SchemaObject }) {
function OpenAPISchemaCircularRef(props: {
id: string;
schema: OpenAPIV3.SchemaObject;
}) {
const { id, schema } = props;
return (
@@ -1095,7 +1098,7 @@ function flattenSchema(
return [{ ...schema, ...(required ? { required } : {}) }];
}
// if a schema has allOf that can be safely merged, merge it
// If a schema has allOf that can be safely merged, merge it.
if (
(alternativeType === 'oneOf' || alternativeType === 'anyOf') &&
schema.allOf &&
@@ -1107,6 +1110,11 @@ function flattenSchema(
);
if (allOfSchemas.length > 0) {
// Circular allOf: a variant references its parent (e.g. Dog allOf: [Pet, ...])
if (allOfSchemas.some((s) => isAncestorOrCopy(s, ancestors))) {
return flattenCircularAllOf(schema, allOfSchemas, ancestors, latestAncestor);
}
const merged = mergeAlternatives('allOf', allOfSchemas);
if (merged && merged.length > 0) {
// Only merge if all schemas were successfully merged into one (safe to merge)
@@ -1135,6 +1143,95 @@ function flattenSchema(
];
}
/**
* Flatten a circular allOf by stripping ancestor fields and merging the rest.
*/
function flattenCircularAllOf(
schema: OpenAPIV3.SchemaObject,
allOfSchemas: OpenAPIV3.SchemaObject[],
ancestors: Set<OpenAPIV3.SchemaObject>,
latestAncestor: OpenAPIV3.SchemaObject | undefined
): OpenAPIV3.SchemaObject[] {
const cleanSchemas = allOfSchemas.map((s) =>
isAncestorOrCopy(s, ancestors) ? stripAncestorFields(s, ancestors) : s
);
const { allOf: _, oneOf: _1, anyOf: _2, discriminator: _3, ...ownProps } = schema;
let merged = mergeSchemas(cleanSchemas);
merged = mergeTwoSchemas(merged, ownProps);
const required = mergeRequiredFields(merged, latestAncestor);
return [
{
...merged,
...(required ? { required } : {}),
...(schema.title ? { title: schema.title } : {}),
},
];
}
/**
* Check if a schema is an ancestor or a structurally matching copy of one.
*/
function isAncestorOrCopy(
schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject>
): boolean {
if (ancestors.has(schema)) {
return true;
}
const discriminatorName = schema.discriminator?.propertyName;
if (!discriminatorName) {
return false;
}
for (const ancestor of Array.from(ancestors)) {
if (ancestor.discriminator?.propertyName === discriminatorName) {
return true;
}
}
return false;
}
const polymorphicFields = new Set([
'oneOf',
'anyOf',
'discriminator',
'description',
'x-gitbook-description-html',
]);
/**
* Strip polymorphic fields from an ancestor schema, preserving non-circular allOf composition.
*/
function stripAncestorFields(
schema: OpenAPIV3.SchemaObject,
ancestors: Set<OpenAPIV3.SchemaObject>
): OpenAPIV3.SchemaObject {
let base: OpenAPIV3.SchemaObject = schema;
// Merge non-circular allOf entries so composition properties aren't lost
if (Array.isArray(schema.allOf)) {
const safeAllOf = schema.allOf.filter(
(s): s is OpenAPIV3.SchemaObject =>
!checkIsReference(s) && !isAncestorOrCopy(s, ancestors)
);
if (safeAllOf.length > 0) {
base = mergeSchemas([schema, ...safeAllOf]);
}
}
const clean = Object.fromEntries(
Object.entries(base).filter(([key]) => !polymorphicFields.has(key) && key !== 'allOf')
) as OpenAPIV3.SchemaObject;
if (clean.properties) {
clean.properties = { ...clean.properties };
}
if (Array.isArray(clean.required)) {
clean.required = [...clean.required];
}
return clean;
}
/**
* Merge two schemas by combining their properties and required fields.
* Later schema properties override earlier ones.
@@ -1182,7 +1279,7 @@ function mergeSchemas(schemas: OpenAPIV3.SchemaObject[]): OpenAPIV3.SchemaObject
return firstSchema;
}
// Start with first schema and merge the rest into it
return schemas.reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema);
return schemas.slice(1).reduce((acc, schema) => mergeTwoSchemas(acc, schema), firstSchema);
}
/**
@@ -24,7 +24,9 @@ export function OpenAPIColumnSpec(props: {
])}
</div>
) : null}
<OpenAPIOperationDescription operation={operation} context={context} />
{!context.headless ? (
<OpenAPIOperationDescription operation={operation} context={context} />
) : null}
<OpenAPISpec data={data} context={clientContext} />
</div>
);
@@ -23,7 +23,10 @@ export function OpenAPISummary(props: {
})();
return (
<div className="openapi-summary" id={operation.summary ? undefined : context.id}>
<div
className="openapi-summary"
id={!context.headless && operation.summary ? undefined : context.id}
>
{(operation.deprecated || operation['x-stability']) && (
<div className="openapi-summary-tags">
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
@@ -32,7 +35,7 @@ export function OpenAPISummary(props: {
)}
</div>
)}
{title
{!context.headless && title
? context.renderHeading({
deprecated: operation.deprecated ?? false,
stability: operation['x-stability'],
+5
View File
@@ -85,6 +85,11 @@ export interface OpenAPIContext
label: string;
}) => React.ReactNode;
/**
* If `true`, the block omits its own title and description (the page renders them instead).
*/
headless?: boolean;
/**
* Public specification URL, used by Scalar's "Test it" modal.
* When null, the "Test it" button is hidden.
+34 -1
View File
@@ -1,4 +1,9 @@
import { type Filesystem, type OpenAPIV3xDocument, dereference } from '@gitbook/openapi-parser';
import {
type Filesystem,
type OpenAPIV3,
type OpenAPIV3xDocument,
dereference,
} from '@gitbook/openapi-parser';
const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
@@ -19,6 +24,10 @@ export function dereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3
* Dereference an OpenAPI schema.
*/
async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
// Set default titles BEFORE dereferencing so they propagate through $ref resolution.
// This is idempotent and only adds titles to schemas that don't already have one.
setDefaultSchemaTitles(filesystem);
const result = await dereference(filesystem);
if (!result.schema) {
@@ -27,3 +36,27 @@ async function baseDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAP
return result.schema as OpenAPIV3xDocument;
}
/**
* Default schema titles to their component name for discriminator value resolution.
* Must run before dereference so titles propagate through $ref resolution.
*/
function setDefaultSchemaTitles(filesystem: Filesystem): void {
const entrypoint = filesystem.find((f) => f.isEntrypoint);
const schemas = entrypoint?.specification?.components?.schemas as Record<
string,
OpenAPIV3.SchemaObject
>;
if (!schemas || typeof schemas !== 'object') {
return;
}
const entries = Object.entries(schemas);
for (const [name, schema] of entries) {
if (schema && typeof schema === 'object' && !schema.$ref && !schema.title) {
schema.title = name;
}
}
}
@@ -0,0 +1,14 @@
/**
* Format an HTTP method for display in a method badge.
* Uppercasing is left to the `.openapi-method` CSS.
*/
export function formatOpenAPIMethod(method: string): string {
switch (method) {
case 'delete':
return 'DEL';
case 'options':
return 'OPTS';
default:
return method;
}
}
+2
View File
@@ -1,4 +1,6 @@
export * from './schemas';
export * from './formatOpenAPIMethod';
export * from './OpenAPIMethodBadge';
export * from './OpenAPIOperation';
export * from './OpenAPIWebhook';
export * from './OpenAPIOperationContext';