mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
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:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support OpenAPI one-operation-per-page mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './schemas';
|
||||
export * from './formatOpenAPIMethod';
|
||||
export * from './OpenAPIMethodBadge';
|
||||
export * from './OpenAPIOperation';
|
||||
export * from './OpenAPIWebhook';
|
||||
export * from './OpenAPIOperationContext';
|
||||
|
||||
Reference in New Issue
Block a user