diff --git a/.changeset/ask-ai-paragraph-margin-button.md b/.changeset/ask-ai-paragraph-margin-button.md new file mode 100644 index 000000000..a273e6a38 --- /dev/null +++ b/.changeset/ask-ai-paragraph-margin-button.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a hover affordance in the document margin to ask the AI Assistant about a paragraph. On devices with a fine pointer, hovering a top-level paragraph reveals a small button that stages the paragraph's text as context and opens the assistant — making the existing text-selection "Ask" flow more discoverable. diff --git a/.changeset/mcp-available-badge.md b/.changeset/mcp-available-badge.md new file mode 100644 index 000000000..67a7b53bd --- /dev/null +++ b/.changeset/mcp-available-badge.md @@ -0,0 +1,7 @@ +--- +"@gitbook/openapi-parser": patch +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Add an "Available in MCP" badge on OpenAPI operations marked with `x-gitbook-mcp: true`. When `x-gitbook-mcp-url` is set (on the operation, path, or root — most specific wins), the badge becomes a button that copies the MCP server URL to the clipboard. diff --git a/.changeset/mcp-search-best-section.md b/.changeset/mcp-search-best-section.md new file mode 100644 index 000000000..189c1799a --- /dev/null +++ b/.changeset/mcp-search-best-section.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Reduce the size of `searchDocumentation` MCP responses by returning only the best-matching section per page instead of concatenating every section body. diff --git a/.changeset/rnd-11620-changed-pages-toolbar.md b/.changeset/rnd-11620-changed-pages-toolbar.md new file mode 100644 index 000000000..8847645f3 --- /dev/null +++ b/.changeset/rnd-11620-changed-pages-toolbar.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Show changed pages in preview toolbars for change requests and revisions. diff --git a/bun.lock b/bun.lock index 51e19e618..072d9c885 100644 --- a/bun.lock +++ b/bun.lock @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.185.0", + "@gitbook/api": "0.186.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.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="], + "@gitbook/api": ["@gitbook/api@0.186.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-UAF+tVtstqyA8s9OYXoYhgHfOUQaaQBRjQX4WLEB1YxcYtJZ9YM/EM8Ouj38Tg4qW3tr76t1/OESZipCE6t8cw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index b11176ae4..c46549d49 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.185.0", + "@gitbook/api": "0.186.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index e61364808..77a86956f 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -185,17 +185,8 @@ const searchTestCases: Test[] = [ await waitForCookiesDialog(page); const searchInput = page.locator('css=[data-testid="search-input"]'); - // Focus search input, expecting recommended questions - await searchInput.focus(); - await expect(page.getByTestId('search-results')).toBeVisible({ - timeout: 30_000, - }); - const recommendedQuestions = await page - .getByTestId('search-recommended-question') - .all(); - await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question - // Fill search input, expecting AI search option + await searchInput.focus(); await searchInput.fill(AI_PROMPT); const aiSearchResult = page.getByTestId('search-ask-question'); await expect(aiSearchResult).toBeVisible(); @@ -1364,8 +1355,6 @@ const testCases: TestsCase[] = [ name: 'Without page actions', url: getCustomizationURL({ pageActions: { - markdown: false, - externalAI: false, items: [], }, }), diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index dca42428b..9b170e78c 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -390,9 +390,6 @@ export function getCustomizationURL(partial: DeepPartial[0]) { + waitUntil( + trackServerInsightsEvents(args).catch((error) => { + console.error('Failed to track MCP insights event:', error); + }) + ); +} + export async function handleMcpRequest( rawRequest: NextRequest, params: RouteLayoutParams, @@ -20,13 +33,8 @@ export async function handleMcpRequest( const { context } = await getDynamicSiteContext(params); const { dataFetcher, linker, site } = context; - // Use the configured `items` list when the API provides it, and fall back to the deprecated - // `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on - // any page-rendering fallback. const { pageActions } = context.customization; - const isMcpEnabled = pageActions.items - ? pageActions.items.includes(CustomizationPageActionType.Mcp) - : pageActions.mcp; + const isMcpEnabled = pageActions.items.includes(CustomizationPageActionType.Mcp); if (!isMcpEnabled) { return new Response('Not Found', { status: 404 }); } @@ -36,21 +44,19 @@ export async function handleMcpRequest( requestURL.search = rawRequest.nextUrl.search; const request = new Request(requestURL, rawRequest); - waitUntil( - trackServerInsightsEvents({ - organizationId: context.organizationId, - siteId: context.site.id, - events: [ - { - type: 'mcp_request', - location: { - displayContext: SiteInsightsDisplayContext.Server, - }, + trackMcpEvent({ + organizationId: context.organizationId, + siteId: context.site.id, + events: [ + { + type: 'mcp_request', + location: { + displayContext: SiteInsightsDisplayContext.Server, }, - ], - request, - }) - ); + }, + ], + request, + }); const mcpHandler = createMcpHandler( (server) => { @@ -60,6 +66,13 @@ export async function handleMcpRequest( { query: z.string(), }, + { + title: 'Search documentation', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, async ({ query }) => { const results = await throwIfDataError( dataFetcher.searchSiteContent({ @@ -70,22 +83,20 @@ export async function handleMcpRequest( }) ); - waitUntil( - trackServerInsightsEvents({ - organizationId: context.organizationId, - siteId: site.id, - events: [ - { - type: 'search_type_query', - query, - location: { - displayContext: SiteInsightsDisplayContext.Mcp, - }, + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'search_type_query', + query, + location: { + displayContext: SiteInsightsDisplayContext.Mcp, }, - ], - request, - }) - ); + }, + ], + request, + }); return { content: results.flatMap((result) => { @@ -118,9 +129,9 @@ export async function handleMcpRequest( ) ); - const body = pageResult.sections - ?.map((section) => section.body) - .join('\n'); + const body = getBestScoredResult( + (pageResult.sections ?? []).filter((section) => section.body) + )?.body; return { type: 'text', @@ -160,6 +171,13 @@ export async function handleMcpRequest( return z.NEVER; }), }, + { + title: 'Get page content', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, async ({ url }) => { try { const match = findSiteSpaceByUrl(context.structure, url); @@ -191,24 +209,22 @@ export async function handleMcpRequest( resolved.page ); - waitUntil( - trackServerInsightsEvents({ - organizationId: context.organizationId, - siteId: site.id, - events: [ - { - type: 'page_view', - location: { - displayContext: SiteInsightsDisplayContext.Mcp, - page: resolved.page.id, - space: match.siteSpace.space.id, - revision: match.siteSpace.space.revision, - }, + trackMcpEvent({ + organizationId: context.organizationId, + siteId: site.id, + events: [ + { + type: 'page_view', + location: { + displayContext: SiteInsightsDisplayContext.Mcp, + page: resolved.page.id, + space: match.siteSpace.space.id, + revision: match.siteSpace.space.revision, }, - ], - request, - }) - ); + }, + ], + request, + }); return { content: [{ type: 'text', text: markdown }] }; } catch (error) { diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts index f450acf0f..e55e729d7 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts @@ -8,6 +8,7 @@ import { throwIfDataError } from '@/lib/data'; import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker'; import { getSiteURLDataFromMiddleware } from '@/lib/middleware'; import { joinPathWithBaseURL } from '@/lib/paths'; +import { getBestScoredResult } from '@/lib/search'; import { getServerActionBaseContext } from '@/lib/server-actions'; import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites'; import type { @@ -187,10 +188,7 @@ function transformSitePageResult(args: { }) ?? []; // Find the best-scoring section to use as a body preview on the page result. - const bestSection = pageSections.reduce( - (best, section) => (!best || section.score > best.score ? section : best), - undefined - ); + const bestSection = getBestScoredResult(pageSections); if (bestSection) { page.bestSection = { href: bestSection.href, diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 575639341..44389b432 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -119,10 +119,10 @@ export type AIChatState = { references: AIChatReference[]; /** - * Draft text to pre-fill into the chat input without sending it. Consumed by - * the input on the next render and then reset to `null`. + * Draft text to pre-fill the chat input with, without sending it. The chat input consumes + * this value (seeding its editable content) and then clears it back to an empty string. */ - inputDraft: string | null; + draft: string; }; export type AIChatEvent = @@ -146,8 +146,6 @@ export type AIChatController = { close: () => void; /** Post a message to the session */ postMessage: (input: { message: string }) => void; - /** Pre-fill the chat input with a draft message, without sending it */ - setDraft: (value: string | null) => void; /** Clear the conversation */ clear: () => void; /** Stage a reference on the next message */ @@ -158,6 +156,8 @@ export type AIChatController = { clearReferences: () => void; /** Focus the chat input */ focus: () => void; + /** Pre-fill the chat input with draft text, without sending it. */ + setDraft: (draft: string) => void; /** Register an event listener */ on: ( event: T, @@ -181,7 +181,7 @@ const globalState = zustand.create(() => { error: false, initialQuery: null, references: [], - inputDraft: null, + draft: '', }; }); @@ -621,7 +621,6 @@ export function AIChatProvider(props: { error: false, initialQuery: null, references: [], - inputDraft: null, })); // Reset ask parameter to empty string (keeps chat open but clears content) @@ -671,8 +670,8 @@ export function AIChatProvider(props: { notify(eventsRef.current.get('focus'), {}); }, []); - const onSetDraft = React.useCallback((value: string | null) => { - globalState.setState((state) => ({ ...state, inputDraft: value })); + const onSetDraft = React.useCallback((draft: string) => { + globalState.setState({ draft }); }, []); const onEvent = React.useCallback( @@ -700,11 +699,11 @@ export function AIChatProvider(props: { close: onClose, clear: onClear, postMessage: onPostMessage, - setDraft: onSetDraft, addReference: onAddReference, removeReference: onRemoveReference, clearReferences: onClearReferences, focus: onFocus, + setDraft: onSetDraft, on: onEvent, }; }, [ @@ -712,11 +711,11 @@ export function AIChatProvider(props: { onClose, onClear, onPostMessage, - onSetDraft, onAddReference, onRemoveReference, onClearReferences, onFocus, + onSetDraft, onEvent, ]); diff --git a/packages/gitbook/src/components/AIChat/AIChatInput.tsx b/packages/gitbook/src/components/AIChat/AIChatInput.tsx index f330bd619..5e52ff8f4 100644 --- a/packages/gitbook/src/components/AIChat/AIChatInput.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatInput.tsx @@ -23,16 +23,29 @@ export function AIChatInput(props: { const chatController = useAIChatController(); const inputRef = useRef(null); + + // Controlled value so a pre-filled draft can be injected without sending it. const [value, setValue] = useState(''); - // Consume a draft requested via the controller (e.g. "rewrite this code sample"), - // pre-filling the input without sending it. + // Consume a draft staged via the controller (e.g. the per-paragraph "Ask" button): seed the + // input, focus it with the cursor at the end, then clear the pending draft so it is applied + // once and not re-injected on a later mount. useEffect(() => { - if (chat.inputDraft != null) { - setValue(chat.inputDraft); - chatController.setDraft(null); + if (!chat.draft) { + return; } - }, [chat.inputDraft, chatController]); + setValue(chat.draft); + chatController.setDraft(''); + const raf = requestAnimationFrame(() => { + const el = inputRef.current; + if (el) { + el.focus(); + const end = el.value.length; + el.setSelectionRange(end, end); + } + }); + return () => cancelAnimationFrame(raf); + }, [chat.draft, chatController]); useEffect(() => { if (chat.opened && !disabled && !responding) { diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx new file mode 100644 index 000000000..2f2b269a1 --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx @@ -0,0 +1,81 @@ +'use client'; + +import fnv1a from '@sindresorhus/fnv1a'; + +import { useAIChatController, useAIConfig } from '@/components/AI'; +import { Button } from '@/components/primitives'; +import { t, tString, useLanguage } from '@/intl/client'; +import { type ClassValue, tcls } from '@/lib/tailwind'; + +import { getAIChatName } from '../AIChat'; +import { AIChatIcon } from '../AIChatIcon'; + +/** + * A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on + * devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens + * the AI chat — a more discoverable variant of the text-selection "Ask" affordance. + * + * The button is absolutely positioned so it never affects the document flow, and its visibility is + * driven purely by the `group/ask-ai` hover state of its paragraph wrapper. + */ +export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) { + const { content, className } = props; + const config = useAIConfig(); + const language = useLanguage(); + const chatController = useAIChatController(); + + const onClick = () => { + const text = content.trim(); + if (!text) { + return; + } + + chatController.addReference({ + type: 'text', + id: `text-${fnv1a(text, { size: 32 })}`, + content: text, + }); + chatController.open(); + chatController.setDraft(tString(language, 'ai_chat_paragraph_draft')); + chatController.focus(); + }; + + return ( + + ); +} diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/index.ts b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/index.ts new file mode 100644 index 000000000..5b7c3c94c --- /dev/null +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/index.ts @@ -0,0 +1 @@ +export * from './AskAIParagraphButton'; diff --git a/packages/gitbook/src/components/AIChat/index.ts b/packages/gitbook/src/components/AIChat/index.ts index 57307673a..f74539c07 100644 --- a/packages/gitbook/src/components/AIChat/index.ts +++ b/packages/gitbook/src/components/AIChat/index.ts @@ -4,3 +4,4 @@ export * from './AIChatIcon'; export * from './AIResponseFeedback'; export * from './AIChatControlButton'; export * from './AskAITextSelection'; +export * from './AskAIParagraphButton'; diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index fa406accd..a1c461f85 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -1,5 +1,6 @@ import type { GitBookSiteContext } from '@/lib/context'; import { AdminToolbarClient } from './AdminToolbarClient'; +import { getToolbarChangedPages } from './changedPages'; import type { AdminToolbarContext } from './types'; export interface AdminToolbarProps { @@ -11,6 +12,7 @@ export interface AdminToolbarProps { */ export async function AdminToolbar(props: AdminToolbarProps) { const { context } = props; + const changedPages = await getToolbarChangedPages(context); // Create a minimal context to avoid serializing and passing too many data to the client const minimalContext: AdminToolbarContext = { @@ -57,6 +59,7 @@ export async function AdminToolbar(props: AdminToolbarProps) { published: context.site.urls.published, }, }, + changedPages, }; return ; diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 3b7a2636a..0988db421 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -4,6 +4,7 @@ import { MotionConfig, motion } from 'motion/react'; import { useCheckForContentUpdate } from '../AutoRefreshContent'; import { useVisitor } from '../Insights'; import { useCurrentPagePath } from '../hooks'; +import { ChangedPagesButton } from './ChangedPagesButton'; import { HideToolbarButton } from './HideToolbarButton'; import { IframeWrapper } from './IframeWrapper'; import { RefreshContentButton } from './RefreshContentButton'; @@ -150,6 +151,8 @@ function ChangeRequestToolbar(props: ToolbarViewProps) { {/* Refresh to retrieve latest changes */} {updated ? : null} + {/* View a popover with quick links to the changed pages */} + {/* Edit in GitBook */} @@ -211,6 +214,9 @@ function RevisionToolbar(props: ToolbarViewProps) { } /> + {/* View a popover with quick links to the changed pages */} + + {/* Open commit in Git client */} = { + created: { + chip: 'border-green-200 bg-green-50', + icon: 'plus', + iconClassName: 'size-3.5 text-green-600', + }, + edited: { + chip: 'border-blue-200 bg-blue-50', + icon: 'pencil', + iconClassName: 'size-3 text-blue-600', + iconStyle: IconStyle.Regular, + }, + moved: { + chip: 'border-blue-200 bg-blue-50', + icon: 'arrow-right', + iconClassName: 'size-3 text-blue-600', + }, + deleted: { + chip: 'border-red-200 bg-red-50', + icon: 'minus', + iconClassName: 'size-3 text-red-600', + }, +}; + +const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center rounded-md border'; + +/** + * A button that opens a popover with quick links to the _changed_ pages. + */ +export function ChangedPagesButton(props: { + changedPages: MinimalChangedPages | null; + motionValues?: ToolbarButtonProps['motionValues']; +}) { + const { changedPages, motionValues } = props; + const reduceMotion = useReducedMotion(); + + if (!changedPages?.pages.length) { + return null; + } + + const changedPagesCount = changedPages.pages.length + changedPages.more; + const changedPagesLabel = `${changedPagesCount} changed ${ + changedPagesCount === 1 ? 'page' : 'pages' + }`; + + const trigger = ( + + ); + + return ( + +
{changedPagesLabel}
+ +
+ {changedPages.pages.map((page) => ( + + ))} +
+ {changedPages.more > 0 ? ( + <> + +
+ {changedPages.more} more changes not shown +
+ + ) : null} +
+ ); +} + +function ChangedPageMenuItem(props: { page: MinimalChangedPage }) { + const { page } = props; + const actionLabel = page.action === 'editor' ? 'Open in editor' : 'Open'; + + return ( + + + + + + {page.title} + + + /{page.path || ''} + + + + {actionLabel} + + + + + ); +} + +function ChangedPageStatusIcon(props: { status: MinimalChangedPage['status'] }) { + const { status } = props; + const style = STATUS_ICON_STYLES[status]; + + return ( + + + + ); +} + +function DiffIcon(props: SVGProps) { + return ( + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/changedPages.ts b/packages/gitbook/src/components/AdminToolbar/changedPages.ts new file mode 100644 index 000000000..82dfe1f7f --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/changedPages.ts @@ -0,0 +1,57 @@ +import type { GitBookSiteContext } from '@/lib/context'; +import { getDataOrNull, ignoreAllThrownError } from '@/lib/data'; +import { getToolbarChangedPagesFromChanges } from './changedPagesMapper'; +import type { MinimalChangedPages } from './types'; + +export const TOOLBAR_CHANGED_PAGES_LIMIT = 100; + +/** + * Fetch and reduce semantic changes to the minimal list needed by the toolbar client. + */ +export async function getToolbarChangedPages( + context: GitBookSiteContext +): Promise { + // The changed-pages list is a non-critical toolbar enhancement. Any failure to fetch or + // reduce the changes should simply hide the button, never break rendering of the site page. + return ignoreAllThrownError(fetchToolbarChangedPages(context)); +} + +async function fetchToolbarChangedPages( + context: GitBookSiteContext +): Promise { + const changes = context.changeRequest + ? await getDataOrNull( + context.dataFetcher.getChangeRequestChanges({ + spaceId: context.space.id, + changeRequestId: context.changeRequest.id, + limit: TOOLBAR_CHANGED_PAGES_LIMIT, + }) + ) + : context.revisionId !== context.space.revision + ? await getDataOrNull( + context.dataFetcher.getRevisionSemanticChanges({ + spaceId: context.space.id, + revisionId: context.revisionId, + limit: TOOLBAR_CHANGED_PAGES_LIMIT, + }) + ) + : null; + + if (!changes) { + return null; + } + + const pages = getToolbarChangedPagesFromChanges({ + changes: changes.changes, + editorBaseURL: context.changeRequest?.urls.app ?? context.revision.urls.app, + linker: context.linker, + pages: context.revision.pages, + }); + + return pages.length > 0 + ? { + pages, + more: changes.more ?? 0, + } + : null; +} diff --git a/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts b/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts new file mode 100644 index 000000000..74cd770d2 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts @@ -0,0 +1,114 @@ +import type { GitBookLinker } from '@/lib/links'; +import { getPagePath, resolvePageId } from '@/lib/pages'; +import { joinPathWithBaseURL } from '@/lib/paths'; +import { + type ChangedRevisionPage, + type Revision, + RevisionPageType, + type RevisionSemanticChange, +} from '@gitbook/api'; +import type { MinimalChangedPage } from './types'; + +// When one page has multiple semantic changes, show the label that best summarizes its final state. +const CHANGE_STATUS_SUMMARY_ORDER: Array = [ + 'edited', + 'moved', + 'created', + 'deleted', +]; + +export function getToolbarChangedPagesFromChanges(input: { + changes: RevisionSemanticChange[]; + linker: Pick; + pages: Revision['pages']; + editorBaseURL: string; +}): MinimalChangedPage[] { + const changedPages = new Map(); + + for (const change of input.changes) { + // Only real document page changes can become toolbar rows; computed/link/group pages are ignored. + const pageChange = getChangedPageChange(change); + if (!pageChange || pageChange.page.type !== RevisionPageType.Document) { + continue; + } + + // Resolve against the current revision so preview links use the current title and path. + const resolved = resolvePageId(input.pages, pageChange.page.id); + const isDeleted = pageChange.status === 'deleted'; + const currentPage = resolved?.page; + const path = currentPage ? getPagePath(input.pages, currentPage) : pageChange.page.path; + + if (!path && !currentPage) { + continue; + } + + // Missing non-deleted pages cannot be linked reliably from the preview. + if (!currentPage && !isDeleted) { + continue; + } + + // Deleted pages may no longer exist in the preview, so link to their editor change. + if (!currentPage) { + changedPages.set(pageChange.page.id, { + id: pageChange.page.id, + title: pageChange.page.title, + path: path ?? '', + href: joinPathWithBaseURL(input.editorBaseURL, path ?? ''), + status: pageChange.status, + action: 'editor', + }); + continue; + } + + const pageId = currentPage.id; + const existing = changedPages.get(pageId); + // The API can return multiple changes for one page; keep the clearest one-row summary. + if (existing && shouldKeepExistingPageChange(existing.status, pageChange.status)) { + continue; + } + + changedPages.set(pageId, { + id: pageId, + title: currentPage.title, + path: path ?? '', + href: isDeleted + ? joinPathWithBaseURL(input.editorBaseURL, path ?? '') + : input.linker.toPathForPage({ + pages: input.pages, + page: currentPage, + }), + status: pageChange.status, + action: isDeleted ? 'editor' : 'preview', + }); + } + + return Array.from(changedPages.values()); +} + +function getChangedPageChange( + change: RevisionSemanticChange +): { page: ChangedRevisionPage; status: MinimalChangedPage['status'] } | null { + switch (change.type) { + case 'page_created': + return { page: change.page, status: 'created' }; + case 'page_edited': + return { page: change.page, status: 'edited' }; + case 'page_moved': + return { page: change.page, status: 'moved' }; + case 'page_deleted': + return { page: change.page, status: 'deleted' }; + default: + return null; + } +} + +function shouldKeepExistingPageChange( + existing: MinimalChangedPage['status'], + next: MinimalChangedPage['status'] +) { + return getChangeStatusSummaryIndex(existing) >= getChangeStatusSummaryIndex(next); +} + +function getChangeStatusSummaryIndex(status: MinimalChangedPage['status']) { + return CHANGE_STATUS_SUMMARY_ORDER.indexOf(status); +} diff --git a/packages/gitbook/src/components/AdminToolbar/types.ts b/packages/gitbook/src/components/AdminToolbar/types.ts index f3ec3fed6..705911271 100644 --- a/packages/gitbook/src/components/AdminToolbar/types.ts +++ b/packages/gitbook/src/components/AdminToolbar/types.ts @@ -43,6 +43,20 @@ export type MinimalSite = { }; }; +export type MinimalChangedPage = { + id: string; + title: string; + path: string; + href: string; + status: 'created' | 'edited' | 'moved' | 'deleted'; + action: 'preview' | 'editor'; +}; + +export type MinimalChangedPages = { + pages: MinimalChangedPage[]; + more: number; +}; + export type AdminToolbarContext = { organizationId: string; revisionId: string; @@ -50,6 +64,7 @@ export type AdminToolbarContext = { changeRequest: MinimalChangeRequest | null; revision: MinimalRevision; site: MinimalSite; + changedPages: MinimalChangedPages | null; }; export interface AdminToolbarClientProps { diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx index 0a102bf13..682c25bea 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/context.tsx @@ -51,6 +51,7 @@ export function getOpenAPIContext(args: { copy: , check: , lock: , + mcp: , }, renderCodeBlock: (codeProps) => ( ) { const { block, style, ...contextProps } = props; + const { context } = contextProps; // InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container. const inlineButtonStyle = 'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center'; - return ( + const paragraph = (

); + + // Offer to ask the assistant about any paragraph, in Assistant mode, on screen. + const contentContext = context.contentContext; + const aiAssistantEnabled = + context.mode !== 'print' && + contentContext != null && + 'customization' in contentContext && + contentContext.customization.ai.mode === CustomizationAIMode.Assistant; + + const text = aiAssistantEnabled ? getNodeText(block) : ''; + if (aiAssistantEnabled && text.trim()) { + return ( +
+ {paragraph} + +
+ ); + } + + return paragraph; } diff --git a/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx index db8574405..b4c50f356 100644 --- a/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx +++ b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx @@ -53,11 +53,7 @@ function getOpenInAIProviders(props: BlockProps): boolean { function isExternalAIPageActionEnabled( pageActions: SiteCustomizationSettings['pageActions'] ): boolean { - // Use the configured `items` list when the API provides it, and fall back to the deprecated - // `externalAI` flag otherwise (legacy mode). - return pageActions.items - ? pageActions.items.includes(CustomizationPageActionType.ExternalAi) - : pageActions.externalAI; + return pageActions.items.includes(CustomizationPageActionType.ExternalAi); } function getPromptText(block: DocumentBlockPrompt): string { diff --git a/packages/gitbook/src/components/DocumentView/Table/Table.tsx b/packages/gitbook/src/components/DocumentView/Table/Table.tsx index 362a0400a..3e0c1ffd7 100644 --- a/packages/gitbook/src/components/DocumentView/Table/Table.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/Table.tsx @@ -16,14 +16,10 @@ import { getTableRecordSearchData, getTableSelectColumns, } from './search'; +import { shouldShowTableSearch } from './shouldShowSearch'; 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 extends BlockProps { view: View; records: TableRecordKV[]; @@ -38,10 +34,14 @@ export function Table(props: BlockProps) { a[1].orderIndex.localeCompare(b[1].orderIndex) ); - const showSearch = - context.mode !== 'print' && - records.length >= MIN_RECORDS_FOR_SEARCH && - block.data.view.type === 'grid'; // Tables only for now + // Authors can override the smart default per block with `search: true | false`; `undefined` + // keeps the default (search on grid tables with enough rows, off on cards). See the helper. + const showSearch = shouldShowTableSearch({ + recordCount: records.length, + viewType: block.data.view.type, + searchOverride: block.data.search, + isPrint: context.mode === 'print', + }); const searchRecords = showSearch ? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) })) : []; diff --git a/packages/gitbook/src/components/DocumentView/Table/search.ts b/packages/gitbook/src/components/DocumentView/Table/search.ts index f8c9716e4..4e3039e8f 100644 --- a/packages/gitbook/src/components/DocumentView/Table/search.ts +++ b/packages/gitbook/src/components/DocumentView/Table/search.ts @@ -18,11 +18,24 @@ export interface TableSelectColumn { } /** - * List the visible "select" columns of a table along with their options. + * All column ids of a table: visible columns first (in view order), then any hidden ones (defined + * but not shown). The filter input offers every column, so hidden select/checkbox fields are + * filterable too even though they aren't displayed. + */ +function getTableColumnIds(block: DocumentBlockTable): string[] { + const visible = block.data.view.columns; + return [ + ...visible, + ...Object.keys(block.data.definition).filter((id) => !visible.includes(id)), + ]; +} + +/** + * List the "select" columns of a table along with their options, including hidden fields. * 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) => { + return getTableColumnIds(block).flatMap((column) => { const definition = block.data.definition[column]; if (definition?.type !== 'select') { return []; @@ -40,11 +53,11 @@ export interface TableCheckboxColumn { } /** - * List the visible "checkbox" columns of a table. + * List the "checkbox" columns of a table, including hidden fields. * 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) => { + return getTableColumnIds(block).flatMap((column) => { const definition = block.data.definition[column]; if (definition?.type !== 'checkbox') { return []; @@ -63,10 +76,15 @@ export function getTableRecordSearchData(block: DocumentBlockTable, record: Docu const selectValues: Record = {}; const checkboxValues: Record = {}; - for (const column of block.data.view.columns) { - const text = getTableCellSearchText(block, record, column); - if (text) { - searchText.push(text); + // Free-text search only covers visible columns; select/checkbox filters also cover hidden + // columns, since those are offered in the filter input. + const visibleColumns = new Set(block.data.view.columns); + for (const column of getTableColumnIds(block)) { + if (visibleColumns.has(column)) { + const text = getTableCellSearchText(block, record, column); + if (text) { + searchText.push(text); + } } const value = record.values[column]; diff --git a/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.test.ts b/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.test.ts new file mode 100644 index 000000000..73c2b710e --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test'; + +import { MIN_RECORDS_FOR_SEARCH, shouldShowTableSearch } from './shouldShowSearch'; + +describe('shouldShowTableSearch', () => { + describe('default (no override)', () => { + it('shows search on a grid table once it reaches the row threshold', () => { + expect( + shouldShowTableSearch({ + recordCount: MIN_RECORDS_FOR_SEARCH, + viewType: 'grid', + searchOverride: undefined, + isPrint: false, + }) + ).toBe(true); + }); + + it('hides search on a grid table below the row threshold', () => { + expect( + shouldShowTableSearch({ + recordCount: MIN_RECORDS_FOR_SEARCH - 1, + viewType: 'grid', + searchOverride: undefined, + isPrint: false, + }) + ).toBe(false); + }); + + it('hides search on cards regardless of row count', () => { + expect( + shouldShowTableSearch({ + recordCount: 100, + viewType: 'cards', + searchOverride: undefined, + isPrint: false, + }) + ).toBe(false); + }); + }); + + describe('explicit override', () => { + it('forces search on for a small grid table', () => { + expect( + shouldShowTableSearch({ + recordCount: 1, + viewType: 'grid', + searchOverride: true, + isPrint: false, + }) + ).toBe(true); + }); + + it('forces search on for cards', () => { + expect( + shouldShowTableSearch({ + recordCount: 1, + viewType: 'cards', + searchOverride: true, + isPrint: false, + }) + ).toBe(true); + }); + + it('forces search off for a large grid table', () => { + expect( + shouldShowTableSearch({ + recordCount: 1000, + viewType: 'grid', + searchOverride: false, + isPrint: false, + }) + ).toBe(false); + }); + }); + + it('never shows search when printing, even when forced on', () => { + expect( + shouldShowTableSearch({ + recordCount: 1000, + viewType: 'grid', + searchOverride: true, + isPrint: true, + }) + ).toBe(false); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.ts b/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.ts new file mode 100644 index 000000000..ab4179f2e --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/shouldShowSearch.ts @@ -0,0 +1,36 @@ +import type { DocumentBlockTable } from '@gitbook/api'; + +/** + * Number of records a grid table needs before the search bar is shown by default. + * + * Duplicated in `@gitbook/doc-core` (`table/search.ts`) so the editor preview matches this default. + * There's no shared module for it — GBO consumes the published `@gitbook/api`, not `doc-core` — so + * the two copies must be kept in sync by hand when changing the default behaviour. + */ +export const MIN_RECORDS_FOR_SEARCH = 7; + +/** + * Decide whether to render the search bar for a table block. + * + * Authors can force search on or off per block via `block.data.search`. When that override is left + * unset, we fall back to a smart default: search appears on grid tables once they have enough rows, + * and stays off on cards. Search is never shown when printing, regardless of the override. + */ +export function shouldShowTableSearch(args: { + /** Number of records in the table. */ + recordCount: number; + /** The table view type (`grid` or `cards`). */ + viewType: DocumentBlockTable['data']['view']['type']; + /** Explicit per-block override, from `block.data.search`; `undefined` defers to the default. */ + searchOverride: boolean | undefined; + /** Whether the document is being rendered for print/PDF, where search is never shown. */ + isPrint: boolean; +}): boolean { + if (args.isPrint) { + return false; + } + + const showByDefault = args.recordCount >= MIN_RECORDS_FOR_SEARCH && args.viewType === 'grid'; + + return args.searchOverride ?? showByDefault; +} diff --git a/packages/gitbook/src/components/PageActions/PageActions.tsx b/packages/gitbook/src/components/PageActions/PageActions.tsx index c5fd6d28c..209c35e05 100644 --- a/packages/gitbook/src/components/PageActions/PageActions.tsx +++ b/packages/gitbook/src/components/PageActions/PageActions.tsx @@ -45,7 +45,12 @@ export function ActionOpenAssistant(props: { icon={assistant.icon} label={assistant.label} shortLabel={tString(language, 'ask')} - description={tString(language, 'ai_chat_ask_about_page', assistant.label)} + description={tString( + language, + 'ai_chat_ask_about', + assistant.label, + tString(language, 'this_page') + )} disabled={chat.responding} onClick={() => { // Stage a reference to the current page so the assistant is informed about @@ -213,7 +218,12 @@ export function ActionOpenInLLM(props: { icon={provider} label={tString(language, 'open_in', providerLabel)} shortLabel={providerLabel} - description={tString(language, 'ai_chat_ask_about_page', providerLabel)} + description={tString( + language, + 'ai_chat_ask_about', + providerLabel, + tString(language, 'this_page') + )} href={getURLForLLM(provider, prompt)} /> ); diff --git a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx index bb03a3d71..3962358b6 100644 --- a/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx +++ b/packages/gitbook/src/components/PageActions/PageActionsDropdown.tsx @@ -30,27 +30,6 @@ import { */ 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; @@ -88,11 +67,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) { 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); + const items = props.actions.items; let defaultAction: ReactNode = null; let markdownIsDefault = false; @@ -102,12 +77,8 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) { // present, as a contextual override of the configured list. defaultAction = ; } 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) + const defaultActionType = items.find( + (type) => items?.includes(type) && isActionTypeAvailable(type, urls, assistants) ); if (defaultActionType) { defaultAction = renderDefaultActionForType(defaultActionType, { @@ -123,17 +94,19 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) { // 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); + ? 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({ @@ -178,43 +151,6 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) { ) : null; } -/** - * Return the configured ordered list of enabled page actions, or `null` when the API does not - * provide it yet (legacy mode). - */ -function getConfiguredPageActionItems( - actions: SiteCustomizationSettings['pageActions'] -): PageActionType[] | null { - return actions.items ?? null; -} - -/** - * 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; - } - }); -} - /** * Whether an action type can be rendered given the available URLs and assistants. */ diff --git a/packages/gitbook/src/components/PageBody/PageHeader.tsx b/packages/gitbook/src/components/PageBody/PageHeader.tsx index 9e0bdda34..c48d86697 100644 --- a/packages/gitbook/src/components/PageBody/PageHeader.tsx +++ b/packages/gitbook/src/components/PageBody/PageHeader.tsx @@ -3,7 +3,6 @@ import type { AncestorRevisionPage } from '@/lib/pages'; import { tcls } from '@/lib/tailwind'; import { getPageRSSURL } from '@/routes/rss'; import { - CustomizationAIMode, CustomizationPageActionType, type RevisionPageDocument, SiteVisibility, @@ -191,9 +190,7 @@ function getPageActionsURLs({ } /** - * Whether a given built-in page action is enabled. Uses the configured `items` list when the API - * provides it, and falls back to the deprecated boolean flags otherwise (legacy mode), matching the - * fallback used by the page actions dropdown. + * Whether a given built-in page action is enabled. */ function isPageActionEnabled( customization: GitBookSiteContext['customization'], @@ -203,22 +200,7 @@ function isPageActionEnabled( if (pageActions.items) { return pageActions.items.includes(type); } - switch (type) { - case CustomizationPageActionType.ExternalAi: - return pageActions.externalAI; - case CustomizationPageActionType.Markdown: - return pageActions.markdown; - case CustomizationPageActionType.Mcp: - return pageActions.mcp; - case CustomizationPageActionType.Pdf: - return customization.pdf.enabled; - case CustomizationPageActionType.Git: - return customization.git.showEditLink; - case CustomizationPageActionType.Assistant: - return customization.ai.mode === CustomizationAIMode.Assistant; - default: - return false; - } + return false; } /** diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 29848568f..83abc6a28 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -114,7 +114,7 @@ export const SearchResults = React.forwardRef(function SearchResults( const { assistants } = useAI(); const primaryAssistant = assistants[0]; - if (error) { + if (error && results.length === 0) { return ( ); + const partialError = error ? ( +
+ {t(language, 'search_partial_error')} +
+ ) : null; + return ( {children} @@ -318,6 +338,7 @@ export const SearchResults = React.forwardRef(function SearchResults( {!fetching && results.length === 0 ? noResults : null} )} + {partialError} {fetching ? (
{ + it('keeps the first 3 remote results first and in remote order', () => { + const results = reciprocalRankFusion( + [localPage('local-match', 'Alpha Local Match')], + [ + remotePage('remote-1'), + remoteRecord('remote-2'), + remotePage('remote-3'), + remotePage('remote-4'), + ], + 'alpha' + ); + + expect(results.slice(0, 3).map(getResultKey)).toEqual([ + 'page:remote-1', + 'record:remote-2', + 'page:remote-3', + ]); + }); + + it('applies fusion only after the pinned remote results', () => { + const results = reciprocalRankFusion( + [localPage('local-match', 'Alpha Local Match')], + [ + remotePage('remote-1'), + remotePage('remote-2'), + remotePage('remote-3'), + remotePage('remote-4'), + ], + 'alpha' + ); + + expect(results.slice(0, 3).map(getResultKey)).toEqual([ + 'page:remote-1', + 'page:remote-2', + 'page:remote-3', + ]); + const firstFusedResult = results[3]; + if (!firstFusedResult) { + throw new Error('Expected a fused result after the pinned remote results'); + } + expect(getResultKey(firstFusedResult)).toBe('page:local-match'); + }); + + it('pins all remote results when fewer than 3 are present', () => { + const results = reciprocalRankFusion( + [localPage('local-match', 'Alpha Local Match')], + [remoteRecord('remote-1'), remotePage('remote-2')], + 'alpha' + ); + + expect(results.map(getResultKey)).toEqual([ + 'record:remote-1', + 'page:remote-2', + 'page:local-match', + ]); + }); + + it('merges a pinned remote page with the matching local page without duplicating it', () => { + const results = reciprocalRankFusion( + [localPage('remote-1', 'Local title')], + [ + remotePage('remote-1', 'Remote title'), + remotePage('remote-2'), + remotePage('remote-3'), + ], + 'remote' + ); + const pinnedResult = results[0] as MergedPageResult; + + expect(pinnedResult.type).toBe('page'); + expect(pinnedResult.title).toBe('Remote title'); + expect(pinnedResult.pathname).toBe('/remote-1'); + expect(pinnedResult.description).toBe('Local description for Local title'); + expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]); + expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1); + }); + + it('does not duplicate pinned records in the fused tail', () => { + const results = reciprocalRankFusion( + [], + [ + remoteRecord('record-1'), + remotePage('remote-2'), + remotePage('remote-3'), + remoteRecord('record-1'), + remotePage('remote-4'), + ], + 'remote' + ); + + expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength( + 1 + ); + }); +}); diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index a432d22d9..a610627ce 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -66,6 +66,8 @@ function jaroWinkler(s1: string, s2: string, p = 0.1): number { const RRF_K = 60; /** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */ const RRF_K_LOCAL = 50; +/** Number of remote results that should keep their API order before fusion. */ +const PINNED_REMOTE_RESULTS_COUNT = 3; /** * A page result that was present in both local and remote lists. @@ -73,11 +75,12 @@ const RRF_K_LOCAL = 50; * and remote fields (href, pageId, spaceId, title) override them. * Breadcrumbs prefer local (has icon + emoji) and fall back to remote. */ -export type MergedPageResult = ComputedPageResult & { +export type MergedPageResult = Omit & { pathname?: string; icon?: string; emoji?: string; description?: string; + breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs']; }; /** @@ -100,16 +103,47 @@ export function getResultKey( type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult; +function mergeLocalPageWithRemotePage( + localResult: LocalPageResult, + remoteResult: ComputedPageResult +): MergedPageResult { + return { + ...localResult, + ...remoteResult, + // Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote. + breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs, + }; +} + +function mergePinnedRemoteResult( + result: OrderedComputedResult, + localResultsByKey: Map +): RRFResult { + if (result.type !== 'page') { + return result; + } + + const localResult = localResultsByKey.get(getResultKey(result)); + if (!localResult) { + return result; + } + + return mergeLocalPageWithRemotePage(localResult, result); +} + /** * Merge local (FlexSearch) and remote (API) search results using - * Reciprocal Rank Fusion (RRF). + * Reciprocal Rank Fusion (RRF), while preserving the API order for the first + * three remote results. * * RRF formula: score(d) = Σ_i 1 / (k + rank_i(d)) * - * Pages present in both lists are deep-merged: local fields act as the base + * The pinned remote results are excluded from fusion and returned first. Pages + * present in both lists are deep-merged: local fields act as the base * (preserving description, icon, emoji, pathname) and remote fields override * (providing href, pageId, spaceId, title). Breadcrumbs prefer local (has icon - * + emoji) and fall back to remote. Their rank contributions from both lists are summed. + * + emoji) and fall back to remote. In the fused tail, their rank contributions + * from both lists are summed. * * Sections and records have no local equivalent and only accumulate their own * rank contribution. @@ -123,12 +157,22 @@ export function reciprocalRankFusion( remoteResults: OrderedComputedResult[], query: string ): Array { + const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result])); + const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT); + const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result))); + const remainingLocalResults = localResults.filter( + (result) => !pinnedResultKeys.has(getResultKey(result)) + ); + const remainingRemoteResults = remoteResults + .slice(PINNED_REMOTE_RESULTS_COUNT) + .filter((result) => !pinnedResultKeys.has(getResultKey(result))); + // Map from dedup key → { result, score } const scoreMap = new Map(); // Process local results first (1-indexed rank) // Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones. - localResults.forEach((result, index) => { + remainingLocalResults.forEach((result, index) => { const rank = index + 1; const key = getResultKey(result); const contribution = 1 / (RRF_K_LOCAL + rank); @@ -142,7 +186,7 @@ export function reciprocalRankFusion( }); // Process remote results, deduplicating against local pages - remoteResults.forEach((result, index) => { + remainingRemoteResults.forEach((result, index) => { const rank = index + 1; const contribution = 1 / (RRF_K + rank); @@ -155,12 +199,7 @@ export function reciprocalRankFusion( // (href, pageId, spaceId, breadcrumbs, title). existing.score += contribution; if (existing.result.type === 'local-page' && result.type === 'page') { - existing.result = { - ...existing.result, - ...result, - // Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote. - breadcrumbs: existing.result.breadcrumbs ?? result.breadcrumbs, - } as MergedPageResult; + existing.result = mergeLocalPageWithRemotePage(existing.result, result); } else { existing.result = result; } @@ -197,7 +236,12 @@ export function reciprocalRankFusion( } // Sort descending by RRF score - return Array.from(scoreMap.values()) + const fusedResults = Array.from(scoreMap.values()) .sort((a, b) => b.score - a.score) .map(({ result }) => result); + + return [ + ...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)), + ...fusedResults, + ]; } diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 130a1e138..33ac845c3 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -11,6 +11,7 @@ import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; import { assert } from 'ts-essentials'; import { Link, type LinkInsightsProps } from '.'; import { ToggleChevron } from './ToggleChevron'; +import { Tooltip } from './Tooltip'; export type DropdownButtonProps = Omit< Partial, E>>, @@ -36,6 +37,8 @@ const DROPDOWN_CONTENT_INNER_CLASS = export function DropdownMenu(props: { /** Content of the button */ button: React.ReactNode; + /** Tooltip label for the button */ + buttonTooltip?: React.ReactNode; /** Content of the dropdown */ children: React.ReactNode; /** Custom styles */ @@ -52,32 +55,53 @@ export function DropdownMenu(props: { * @default "start" */ align?: RadixDropdownMenu.DropdownMenuContentProps['align']; + /** + * Distance between the trigger and the dropdown. + * @default 0 + */ + sideOffset?: RadixDropdownMenu.DropdownMenuContentProps['sideOffset']; }) { const { button, + buttonTooltip, children, className, openOnHover = false, side = 'bottom', align = 'start', + sideOffset = 0, } = props; const [hovered, setHovered] = useState(false); const [open, setOpen] = useState(false); const isOpen = openOnHover ? open || hovered : open; + const trigger = ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => (openOnHover ? setOpen(!open) : null)} + className="group/dropdown" + > + {button} + + ); + return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - onClick={() => (openOnHover ? setOpen(!open) : null)} - className="group/dropdown" - > - {button} - + {buttonTooltip ? ( + + {trigger} + + ) : ( + trigger + )} setHovered(false)} align={align} side={side} + sideOffset={sideOffset} className={DROPDOWN_CONTENT_OUTER_CLASS} >
diff --git a/packages/gitbook/src/components/primitives/Tooltip.tsx b/packages/gitbook/src/components/primitives/Tooltip.tsx index d09aa0679..13a0994ee 100644 --- a/packages/gitbook/src/components/primitives/Tooltip.tsx +++ b/packages/gitbook/src/components/primitives/Tooltip.tsx @@ -20,6 +20,7 @@ export function Tooltip(props: { arrowProps?: RadixTooltip.TooltipArrowProps; arrow?: boolean; className?: string; + pinOnClick?: boolean; }) { const { children, @@ -31,6 +32,7 @@ export function Tooltip(props: { arrowProps, arrow = false, className, + pinOnClick = true, } = props; const [open, setOpen] = useState(false); @@ -56,7 +58,11 @@ export function Tooltip(props: { return ( - setClicked(true)} {...triggerProps}> + setClicked(true) : undefined} + {...triggerProps} + > {children} diff --git a/packages/gitbook/src/intl/translations/ar.ts b/packages/gitbook/src/intl/translations/ar.ts index 4169b61cf..769ce44ca 100644 --- a/packages/gitbook/src/intl/translations/ar.ts +++ b/packages/gitbook/src/intl/translations/ar.ts @@ -40,6 +40,7 @@ export const ar: TranslationLanguage = { search_ask_no_answer: 'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.', search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.', + search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.', on_this_page: 'في هذه الصفحة', next_page: 'التالي', previous_page: 'السابق', @@ -144,7 +145,10 @@ export const ar: TranslationLanguage = { ai_chat_tools_navigate_failed: 'تعذّر فتح الصفحة', ai_chat_tools_mcp_tool: 'تم استدعاء ${1}', ai_chat_ask: 'اسأل ${1}', - ai_chat_ask_about_page: 'اسأل ${1} عن هذه الصفحة', + ai_chat_ask_about: 'اسأل ${1} عن ${2}', + ai_chat_ask_about_this: 'اسأل ${1} عن هذا', + this_page: 'هذه الصفحة', + ai_chat_paragraph_draft: 'أخبرني المزيد عن هذا', ai_chat_ask_query: 'اسأل ${1} "${2}"', copy_for_llms: 'نسخ للنماذج اللغوية الكبيرة', copy_page_markdown: 'نسخ الصفحة بصيغة Markdown للنماذج اللغوية الكبيرة', diff --git a/packages/gitbook/src/intl/translations/bg.ts b/packages/gitbook/src/intl/translations/bg.ts index 51a90614a..8bb82d0df 100644 --- a/packages/gitbook/src/intl/translations/bg.ts +++ b/packages/gitbook/src/intl/translations/bg.ts @@ -40,6 +40,8 @@ export const bg: TranslationLanguage = { search_ask_no_answer: 'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.', search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.', + search_partial_error: + 'Някои резултати не можаха да се заредят. Показват се наличните резултати.', on_this_page: 'На тази страница', next_page: 'Следваща', previous_page: 'Предишна', @@ -147,7 +149,10 @@ export const bg: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Страницата не може да бъде отворена', ai_chat_tools_mcp_tool: 'Извика ${1}', ai_chat_ask: 'Попитайте ${1}', - ai_chat_ask_about_page: 'Попитайте ${1} за тази страница', + ai_chat_ask_about: 'Попитайте ${1} за ${2}', + ai_chat_ask_about_this: 'Попитайте ${1} за това', + this_page: 'тази страница', + ai_chat_paragraph_draft: 'Кажи ми повече за това', ai_chat_ask_query: 'Попитайте ${1} "${2}"', copy_for_llms: 'Копиране за LLM', copy_page_markdown: 'Копиране на страницата като Markdown за LLM', diff --git a/packages/gitbook/src/intl/translations/cs.ts b/packages/gitbook/src/intl/translations/cs.ts index caf848c47..e4fc09a43 100644 --- a/packages/gitbook/src/intl/translations/cs.ts +++ b/packages/gitbook/src/intl/translations/cs.ts @@ -40,6 +40,7 @@ export const cs: TranslationLanguage = { search_ask_no_answer: 'Na vaši otázku se nepodařilo najít odpověď. Zkuste ji přeformulovat nebo být konkrétnější.', search_ask_error: 'Něco se pokazilo. Zkuste to prosím znovu později.', + search_partial_error: 'Některé výsledky se nepodařilo načíst. Zobrazují se dostupné výsledky.', on_this_page: 'Na této stránce', next_page: 'Další', previous_page: 'Předchozí', @@ -146,7 +147,10 @@ export const cs: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Stránku se nepodařilo otevřít', ai_chat_tools_mcp_tool: 'Zavolal ${1}', ai_chat_ask: 'Zeptat se ${1}', - ai_chat_ask_about_page: 'Zeptat se ${1} na tuto stránku', + ai_chat_ask_about: 'Zeptat se ${1} na ${2}', + ai_chat_ask_about_this: 'Zeptat se ${1} na to', + this_page: 'tuto stránku', + ai_chat_paragraph_draft: 'Řekni mi o tom více', ai_chat_ask_query: 'Zeptat se ${1} "${2}"', copy_for_llms: 'Kopírovat pro LLM', copy_page_markdown: 'Kopírovat stránku jako Markdown pro LLM', diff --git a/packages/gitbook/src/intl/translations/da.ts b/packages/gitbook/src/intl/translations/da.ts index 0e911fa33..b3130fd83 100644 --- a/packages/gitbook/src/intl/translations/da.ts +++ b/packages/gitbook/src/intl/translations/da.ts @@ -40,6 +40,7 @@ export const da: TranslationLanguage = { search_ask_no_answer: 'Der kunne ikke findes et svar på dit spørgsmål. Prøv at omformulere det eller være mere specifik.', search_ask_error: 'Noget gik galt. Prøv igen senere.', + search_partial_error: 'Nogle resultater kunne ikke indlæses. Viser tilgængelige resultater.', on_this_page: 'På denne side', next_page: 'Næste', previous_page: 'Forrige', @@ -145,7 +146,10 @@ export const da: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Kunne ikke åbne siden', ai_chat_tools_mcp_tool: 'Kaldte ${1}', ai_chat_ask: 'Spørg ${1}', - ai_chat_ask_about_page: 'Spørg ${1} om denne side', + ai_chat_ask_about: 'Spørg ${1} om ${2}', + ai_chat_ask_about_this: 'Spørg ${1} om dette', + this_page: 'denne side', + ai_chat_paragraph_draft: 'Fortæl mig mere om dette', ai_chat_ask_query: 'Spørg ${1} "${2}"', copy_for_llms: 'Kopiér til LLMer', copy_page_markdown: 'Kopiér siden som Markdown til LLMer', diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index afd044890..fdfb1b07e 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -1,4 +1,6 @@ -export const de = { +import type { TranslationLanguage } from './types'; + +export const de: TranslationLanguage = { locale: 'de', language: 'Deutsch', flag: '🇩🇪', @@ -39,6 +41,8 @@ export const de = { search_ask_no_answer: 'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.', search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.', + search_partial_error: + 'Einige Ergebnisse konnten nicht geladen werden. Verfügbare Ergebnisse werden angezeigt.', on_this_page: 'Auf dieser Seite', next_page: 'Nächste', previous_page: 'Vorherige', @@ -149,7 +153,10 @@ export const de = { ai_chat_tools_navigate_failed: 'Seite konnte nicht geöffnet werden', ai_chat_tools_mcp_tool: '${1} aufgerufen', ai_chat_ask: '${1} fragen', - ai_chat_ask_about_page: '${1} zu dieser Seite befragen', + ai_chat_ask_about: '${1} zu ${2} befragen', + ai_chat_ask_about_this: '${1} dazu befragen', + this_page: 'dieser Seite', + ai_chat_paragraph_draft: 'Erzähl mir mehr darüber', copy_for_llms: 'Für LLMs kopieren', copy_page_markdown: 'Seite als Markdown für LLMs kopieren', copy_page: 'Seite kopieren', diff --git a/packages/gitbook/src/intl/translations/el.ts b/packages/gitbook/src/intl/translations/el.ts index bf25b83f8..32296d8db 100644 --- a/packages/gitbook/src/intl/translations/el.ts +++ b/packages/gitbook/src/intl/translations/el.ts @@ -40,6 +40,8 @@ export const el: TranslationLanguage = { search_ask_no_answer: 'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.', search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.', + search_partial_error: + 'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.', on_this_page: 'Σε αυτήν τη σελίδα', next_page: 'Επόμενη', previous_page: 'Προηγούμενη', @@ -149,7 +151,10 @@ export const el: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Αποτυχία ανοίγματος της σελίδας', ai_chat_tools_mcp_tool: 'Κλήθηκε ${1}', ai_chat_ask: 'Ρωτήστε ${1}', - ai_chat_ask_about_page: 'Ρωτήστε ${1} για αυτήν τη σελίδα', + ai_chat_ask_about: 'Ρωτήστε ${1} για ${2}', + ai_chat_ask_about_this: 'Ρωτήστε ${1} για αυτό', + this_page: 'αυτή τη σελίδα', + ai_chat_paragraph_draft: 'Πες μου περισσότερα για αυτό', ai_chat_ask_query: 'Ρωτήστε ${1} "${2}"', copy_for_llms: 'Αντιγραφή για LLM', copy_page_markdown: 'Αντιγραφή σελίδας ως Markdown για LLM', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index 342cfcd71..e26f18458 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -38,6 +38,7 @@ export const en = { search_ask_no_answer: 'An answer could not be found for your question. You could try rephrasing it, or be more specific.', search_ask_error: 'Something went wrong. Please try again later.', + search_partial_error: "Some results couldn't load. Showing available results.", on_this_page: 'On this page', next_page: 'Next', previous_page: 'Previous', @@ -143,8 +144,11 @@ export const en = { ai_chat_tools_navigate_failed: 'Failed to open the page', ai_chat_tools_mcp_tool: 'Called ${1}', ai_chat_ask: 'Ask ${1}', - ai_chat_ask_about_page: 'Ask ${1} about this page', + ai_chat_ask_about: 'Ask ${1} about ${2}', + ai_chat_ask_about_this: 'Ask ${1} about this', ai_chat_ask_query: 'Ask ${1} "${2}"', + this_page: 'this page', + ai_chat_paragraph_draft: 'Tell me more about this', copy_for_llms: 'Copy for LLMs', copy_page_markdown: 'Copy page as Markdown for LLMs', copy_page: 'Copy page', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index a868ae59c..4557282ef 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -41,6 +41,8 @@ export const es: TranslationLanguage = { search_ask_no_answer: 'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.', search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.', + search_partial_error: + 'No se pudieron cargar algunos resultados. Se muestran los resultados disponibles.', on_this_page: 'En esta página', next_page: 'Siguiente', previous_page: 'Anterior', @@ -149,7 +151,10 @@ export const es: TranslationLanguage = { ai_chat_tools_navigate_failed: 'No se pudo abrir la página', ai_chat_tools_mcp_tool: 'Llamó a ${1}', ai_chat_ask: 'Preguntar a ${1}', - ai_chat_ask_about_page: 'Preguntar a ${1} sobre esta página', + ai_chat_ask_about: 'Preguntar a ${1} sobre ${2}', + ai_chat_ask_about_this: 'Preguntar a ${1} sobre esto', + this_page: 'esta página', + ai_chat_paragraph_draft: 'Cuéntame más sobre esto', copy_for_llms: 'Copiar para LLMs', copy_page_markdown: 'Copiar página como Markdown para LLMs', copy_page: 'Copiar página', diff --git a/packages/gitbook/src/intl/translations/et.ts b/packages/gitbook/src/intl/translations/et.ts index b408b66c8..49e53b025 100644 --- a/packages/gitbook/src/intl/translations/et.ts +++ b/packages/gitbook/src/intl/translations/et.ts @@ -40,6 +40,7 @@ export const et: TranslationLanguage = { search_ask_no_answer: 'Teie küsimusele ei leitud vastust. Proovige seda ümber sõnastada või olla täpsem.', search_ask_error: 'Midagi läks valesti. Palun proovige hiljem uuesti.', + search_partial_error: 'Mõnda tulemust ei saanud laadida. Kuvatakse saadaolevad tulemused.', on_this_page: 'Sellel lehel', next_page: 'Järgmine', previous_page: 'Eelmine', @@ -145,7 +146,10 @@ export const et: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Lehe avamine ebaõnnestus', ai_chat_tools_mcp_tool: 'Kutsus ${1}', ai_chat_ask: 'Küsi ${1}', - ai_chat_ask_about_page: 'Küsi ${1} selle lehe kohta', + ai_chat_ask_about: 'Küsi ${1}: ${2}', + ai_chat_ask_about_this: 'Küsi ${1} käest selle kohta', + this_page: 'seda lehte', + ai_chat_paragraph_draft: 'Räägi mulle sellest lähemalt', ai_chat_ask_query: 'Küsi ${1} "${2}"', copy_for_llms: 'Kopeeri LLM-idele', copy_page_markdown: 'Kopeeri leht Markdownina LLM-idele', diff --git a/packages/gitbook/src/intl/translations/fi.ts b/packages/gitbook/src/intl/translations/fi.ts index 652f5b1ac..6f573bec3 100644 --- a/packages/gitbook/src/intl/translations/fi.ts +++ b/packages/gitbook/src/intl/translations/fi.ts @@ -40,6 +40,8 @@ export const fi: TranslationLanguage = { search_ask_no_answer: 'Kysymykseesi ei löytynyt vastausta. Voit yrittää muotoilla sen uudelleen tai olla tarkempi.', search_ask_error: 'Jokin meni pieleen. Yritä myöhemmin uudelleen.', + search_partial_error: + 'Joitakin tuloksia ei voitu ladata. Näytetään saatavilla olevat tulokset.', on_this_page: 'Tällä sivulla', next_page: 'Seuraava', previous_page: 'Edellinen', @@ -146,7 +148,10 @@ export const fi: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Sivun avaaminen epäonnistui', ai_chat_tools_mcp_tool: 'Kutsuttiin ${1}', ai_chat_ask: 'Kysy ${1}', - ai_chat_ask_about_page: 'Kysy ${1} tältä sivulta', + ai_chat_ask_about: 'Kysy ${1}: ${2}', + ai_chat_ask_about_this: 'Kysy ${1}:lta tästä', + this_page: 'tätä sivua', + ai_chat_paragraph_draft: 'Kerro minulle tästä lisää', ai_chat_ask_query: 'Kysy ${1}: "${2}"', copy_for_llms: 'Kopioi LLM:ille', copy_page_markdown: 'Kopioi sivu Markdownina LLM:ille', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index df07b8ecf..301e8270e 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -1,4 +1,6 @@ -export const fr = { +import type { TranslationLanguage } from './types'; + +export const fr: TranslationLanguage = { locale: 'fr', language: 'Français', flag: '🇫🇷', @@ -38,6 +40,8 @@ export const fr = { search_ask_sources_no_answer: 'Pages associées', search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.', search_ask_error: 'Une erreur est survenue. Veuillez réessayer plus tard.', + search_partial_error: + 'Certains résultats n’ont pas pu être chargés. Les résultats disponibles sont affichés.', on_this_page: 'Sur cette page', next_page: 'Suivant', previous_page: 'Précédent', @@ -143,7 +147,10 @@ export const fr = { ai_chat_tools_navigate_failed: "Échec de l'ouverture de la page", ai_chat_tools_mcp_tool: 'A appelé ${1}', ai_chat_ask: 'Demander à ${1}', - ai_chat_ask_about_page: 'Demander à ${1} à propos de cette page', + ai_chat_ask_about: 'Demander à ${1} à propos de ${2}', + ai_chat_ask_about_this: 'Interroger ${1} à ce sujet', + this_page: 'cette page', + ai_chat_paragraph_draft: 'En savoir plus à ce sujet', copy_for_llms: 'Copier pour un LLM', copy_page_markdown: 'Copier la page en Markdown pour un LLM', copy_page: 'Copier la page', diff --git a/packages/gitbook/src/intl/translations/he.ts b/packages/gitbook/src/intl/translations/he.ts index 6a36b0d28..bafe59cb8 100644 --- a/packages/gitbook/src/intl/translations/he.ts +++ b/packages/gitbook/src/intl/translations/he.ts @@ -40,6 +40,7 @@ export const he: TranslationLanguage = { search_ask_no_answer: 'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.', search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.', + search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.', on_this_page: 'בדף הזה', next_page: 'הבא', previous_page: 'הקודם', @@ -143,7 +144,10 @@ export const he: TranslationLanguage = { ai_chat_tools_navigate_failed: 'פתיחת הדף נכשלה', ai_chat_tools_mcp_tool: 'קרא ל-${1}', ai_chat_ask: 'שאל את ${1}', - ai_chat_ask_about_page: 'שאל את ${1} על הדף הזה', + ai_chat_ask_about: 'שאל את ${1} על ${2}', + ai_chat_ask_about_this: 'שאל את ${1} על זה', + this_page: 'הדף הזה', + ai_chat_paragraph_draft: 'ספר לי עוד על זה', ai_chat_ask_query: 'שאל את ${1} "${2}"', copy_for_llms: 'העתקה עבור LLMs', copy_page_markdown: 'העתקת הדף כ-Markdown עבור LLMs', diff --git a/packages/gitbook/src/intl/translations/hi.ts b/packages/gitbook/src/intl/translations/hi.ts index 4058a4bbc..fa21019cd 100644 --- a/packages/gitbook/src/intl/translations/hi.ts +++ b/packages/gitbook/src/intl/translations/hi.ts @@ -40,6 +40,7 @@ export const hi: TranslationLanguage = { search_ask_no_answer: 'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।', search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।', + search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।', on_this_page: 'इस पृष्ठ पर', next_page: 'अगला', previous_page: 'पिछला', @@ -144,7 +145,10 @@ export const hi: TranslationLanguage = { ai_chat_tools_navigate_failed: 'पेज खोलने में विफल', ai_chat_tools_mcp_tool: '${1} को कॉल किया', ai_chat_ask: '${1} से पूछें', - ai_chat_ask_about_page: 'इस पृष्ठ के बारे में ${1} से पूछें', + ai_chat_ask_about: '${1} से ${2} के बारे में पूछें', + ai_chat_ask_about_this: '${1} से इसके बारे में पूछें', + this_page: 'इस पृष्ठ', + ai_chat_paragraph_draft: 'इसके बारे में और बताएं', ai_chat_ask_query: '${1} से "${2}" पूछें', copy_for_llms: 'LLM के लिए कॉपी करें', copy_page_markdown: 'पृष्ठ को LLM के लिए Markdown के रूप में कॉपी करें', diff --git a/packages/gitbook/src/intl/translations/hr.ts b/packages/gitbook/src/intl/translations/hr.ts index 2d459316e..05c417d84 100644 --- a/packages/gitbook/src/intl/translations/hr.ts +++ b/packages/gitbook/src/intl/translations/hr.ts @@ -40,6 +40,7 @@ export const hr: TranslationLanguage = { search_ask_no_answer: 'Nije bilo moguće pronaći odgovor na vaše pitanje. Pokušajte ga preformulirati ili biti konkretniji.', search_ask_error: 'Nešto je pošlo po zlu. Pokušajte ponovno kasnije.', + search_partial_error: 'Neki se rezultati nisu mogli učitati. Prikazuju se dostupni rezultati.', on_this_page: 'Na ovoj stranici', next_page: 'Sljedeća', previous_page: 'Prethodna', @@ -146,7 +147,10 @@ export const hr: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Stranicu nije moguće otvoriti', ai_chat_tools_mcp_tool: 'Pozvao ${1}', ai_chat_ask: 'Pitaj ${1}', - ai_chat_ask_about_page: 'Pitaj ${1} o ovoj stranici', + ai_chat_ask_about: 'Pitaj ${1} o ${2}', + ai_chat_ask_about_this: 'Pitaj ${1} o ovome', + this_page: 'ova stranica', + ai_chat_paragraph_draft: 'Reci mi više o ovome', ai_chat_ask_query: 'Pitaj ${1} "${2}"', copy_for_llms: 'Kopiraj za LLM-ove', copy_page_markdown: 'Kopiraj stranicu kao Markdown za LLM-ove', diff --git a/packages/gitbook/src/intl/translations/hu.ts b/packages/gitbook/src/intl/translations/hu.ts index 916a0f38f..9c3d4586d 100644 --- a/packages/gitbook/src/intl/translations/hu.ts +++ b/packages/gitbook/src/intl/translations/hu.ts @@ -40,6 +40,8 @@ export const hu: TranslationLanguage = { search_ask_no_answer: 'Nem található válasz a kérdésére. Próbálja meg átfogalmazni, vagy legyen pontosabb.', search_ask_error: 'Valami hiba történt. Kérjük, próbálja újra később.', + search_partial_error: + 'Néhány találatot nem sikerült betölteni. Az elérhető találatok jelennek meg.', on_this_page: 'Ezen az oldalon', next_page: 'Következő', previous_page: 'Előző', @@ -146,7 +148,10 @@ export const hu: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Az oldal megnyitása sikertelen', ai_chat_tools_mcp_tool: '${1} meghívva', ai_chat_ask: '${1} kérdezése', - ai_chat_ask_about_page: '${1} kérdezése erről az oldalról', + ai_chat_ask_about: '${1} megkérdezése ${2} témában', + ai_chat_ask_about_this: '${1} megkérdezése erről', + this_page: 'ez az oldal', + ai_chat_paragraph_draft: 'Mesélj erről többet', ai_chat_ask_query: '${1} kérdezése: "${2}"', copy_for_llms: 'Másolás LLM-ekhez', copy_page_markdown: 'Oldal másolása Markdownként LLM-ekhez', diff --git a/packages/gitbook/src/intl/translations/id.ts b/packages/gitbook/src/intl/translations/id.ts index 0f8a4cc5a..d87cf516e 100644 --- a/packages/gitbook/src/intl/translations/id.ts +++ b/packages/gitbook/src/intl/translations/id.ts @@ -40,6 +40,7 @@ export const id: TranslationLanguage = { search_ask_no_answer: 'Jawaban untuk pertanyaan Anda tidak dapat ditemukan. Anda dapat mencoba menyusun ulang atau membuatnya lebih spesifik.', search_ask_error: 'Terjadi kesalahan. Silakan coba lagi nanti.', + search_partial_error: 'Beberapa hasil tidak dapat dimuat. Menampilkan hasil yang tersedia.', on_this_page: 'Di halaman ini', next_page: 'Berikutnya', previous_page: 'Sebelumnya', @@ -145,7 +146,10 @@ export const id: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Gagal membuka halaman', ai_chat_tools_mcp_tool: 'Memanggil ${1}', ai_chat_ask: 'Tanya ${1}', - ai_chat_ask_about_page: 'Tanya ${1} tentang halaman ini', + ai_chat_ask_about: 'Tanya ${1} tentang ${2}', + ai_chat_ask_about_this: 'Tanya ${1} tentang ini', + this_page: 'halaman ini', + ai_chat_paragraph_draft: 'Ceritakan lebih banyak tentang ini', ai_chat_ask_query: 'Tanya ${1} "${2}"', copy_for_llms: 'Salin untuk LLM', copy_page_markdown: 'Salin halaman sebagai Markdown untuk LLM', diff --git a/packages/gitbook/src/intl/translations/it.ts b/packages/gitbook/src/intl/translations/it.ts index c76b386e4..30cecfe79 100644 --- a/packages/gitbook/src/intl/translations/it.ts +++ b/packages/gitbook/src/intl/translations/it.ts @@ -41,6 +41,8 @@ export const it: TranslationLanguage = { search_ask_no_answer: 'Non è stato possibile trovare una risposta alla tua domanda. Prova a riformularla o a essere più specifico.', search_ask_error: 'Qualcosa è andato storto. Riprova più tardi.', + search_partial_error: + 'Non è stato possibile caricare alcuni risultati. Visualizziamo i risultati disponibili.', on_this_page: 'In questa pagina', next_page: 'Successivo', previous_page: 'Precedente', @@ -148,7 +150,10 @@ export const it: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Impossibile aprire la pagina', ai_chat_tools_mcp_tool: 'Ha chiamato ${1}', ai_chat_ask: 'Chiedi a ${1}', - ai_chat_ask_about_page: 'Chiedi a ${1} riguardo a questa pagina', + ai_chat_ask_about: 'Chiedi a ${1} riguardo a ${2}', + ai_chat_ask_about_this: 'Chiedi a ${1} riguardo a questo', + this_page: 'questa pagina', + ai_chat_paragraph_draft: 'Dimmi di più a riguardo', copy_for_llms: 'Copia per LLM', copy_page_markdown: 'Copia la pagina come Markdown per LLM', copy_page: 'Copia pagina', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 7c7496974..a908bf42b 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -41,6 +41,7 @@ export const ja: TranslationLanguage = { search_ask_no_answer: 'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。', search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。', + search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。', on_this_page: 'このページ内', next_page: '次へ', previous_page: '前へ', @@ -146,7 +147,10 @@ export const ja: TranslationLanguage = { ai_chat_tools_navigate_failed: 'ページを開けませんでした', ai_chat_tools_mcp_tool: '${1} を呼び出しました', ai_chat_ask: '${1} に質問する', - ai_chat_ask_about_page: 'このページについて ${1} に質問する', + ai_chat_ask_about: '${1}に${2}について質問する', + ai_chat_ask_about_this: '${1}にこれについて質問する', + this_page: 'このページ', + ai_chat_paragraph_draft: 'これについてもっと教えて', copy_for_llms: 'LLM 用にコピー', copy_page_markdown: 'ページを Markdown として LLM 用にコピー', copy_page: 'ページをコピー', diff --git a/packages/gitbook/src/intl/translations/ko.ts b/packages/gitbook/src/intl/translations/ko.ts index 9af69c1eb..f5ed3d8e1 100644 --- a/packages/gitbook/src/intl/translations/ko.ts +++ b/packages/gitbook/src/intl/translations/ko.ts @@ -41,6 +41,7 @@ export const ko: TranslationLanguage = { search_ask_no_answer: '질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.', search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.', + search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.', on_this_page: '이 페이지에서', next_page: '다음', previous_page: '이전', @@ -145,7 +146,10 @@ export const ko: TranslationLanguage = { ai_chat_tools_navigate_failed: '페이지를 열지 못했습니다', ai_chat_tools_mcp_tool: '${1} 호출함', ai_chat_ask: '${1}에게 질문', - ai_chat_ask_about_page: '이 페이지에 대해 ${1}에게 질문', + ai_chat_ask_about: '${1}에게 ${2}에 대해 질문', + ai_chat_ask_about_this: '${1}에게 이에 대해 질문', + this_page: '이 페이지', + ai_chat_paragraph_draft: '이것에 대해 더 알려줘', copy_for_llms: 'LLM용 복사', copy_page_markdown: 'LLM용으로 페이지를 Markdown으로 복사', copy_page: '페이지 복사', diff --git a/packages/gitbook/src/intl/translations/lt.ts b/packages/gitbook/src/intl/translations/lt.ts index d51618fa1..9dcf78ccd 100644 --- a/packages/gitbook/src/intl/translations/lt.ts +++ b/packages/gitbook/src/intl/translations/lt.ts @@ -40,6 +40,7 @@ export const lt: TranslationLanguage = { search_ask_no_answer: 'Nepavyko rasti atsakymo į jūsų klausimą. Galite pabandyti jį performuluoti arba pateikti konkrečiau.', search_ask_error: 'Kažkas nepavyko. Bandykite dar kartą vėliau.', + search_partial_error: 'Nepavyko įkelti kai kurių rezultatų. Rodomi pasiekiami rezultatai.', on_this_page: 'Šiame puslapyje', next_page: 'Kitas', previous_page: 'Ankstesnis', @@ -146,7 +147,10 @@ export const lt: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Nepavyko atidaryti puslapio', ai_chat_tools_mcp_tool: 'Iškviesta ${1}', ai_chat_ask: 'Klausti ${1}', - ai_chat_ask_about_page: 'Klausti ${1} apie šį puslapį', + ai_chat_ask_about: 'Klausti ${1} apie ${2}', + ai_chat_ask_about_this: 'Klausti ${1} apie tai', + this_page: 'šį puslapį', + ai_chat_paragraph_draft: 'Papasakok apie tai daugiau', ai_chat_ask_query: 'Klausti ${1} "${2}"', copy_for_llms: 'Kopijuoti LLM', copy_page_markdown: 'Kopijuoti puslapį kaip Markdown LLM', diff --git a/packages/gitbook/src/intl/translations/lv.ts b/packages/gitbook/src/intl/translations/lv.ts index 28c574733..324f4ad22 100644 --- a/packages/gitbook/src/intl/translations/lv.ts +++ b/packages/gitbook/src/intl/translations/lv.ts @@ -40,6 +40,7 @@ export const lv: TranslationLanguage = { search_ask_no_answer: 'Neizdevās atrast atbildi uz jūsu jautājumu. Varat mēģināt to pārfrāzēt vai būt konkrētāks.', search_ask_error: 'Kaut kas nogāja greizi. Lūdzu, mēģiniet vēlāk vēlreiz.', + search_partial_error: 'Dažus rezultātus neizdevās ielādēt. Tiek rādīti pieejamie rezultāti.', on_this_page: 'Šajā lapā', next_page: 'Nākamā', previous_page: 'Iepriekšējā', @@ -144,7 +145,10 @@ export const lv: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Neizdevās atvērt lapu', ai_chat_tools_mcp_tool: 'Izsauca ${1}', ai_chat_ask: 'Jautāt ${1}', - ai_chat_ask_about_page: 'Jautāt ${1} par šo lapu', + ai_chat_ask_about: 'Jautāt ${1} par ${2}', + ai_chat_ask_about_this: 'Jautāt ${1} par to', + this_page: 'šo lapu', + ai_chat_paragraph_draft: 'Pastāsti man par to vairāk', ai_chat_ask_query: 'Jautāt ${1} "${2}"', copy_for_llms: 'Kopēt LLM vajadzībām', copy_page_markdown: 'Kopēt lapu kā Markdown LLM vajadzībām', diff --git a/packages/gitbook/src/intl/translations/ms.ts b/packages/gitbook/src/intl/translations/ms.ts index 357cc21ed..84651b9b7 100644 --- a/packages/gitbook/src/intl/translations/ms.ts +++ b/packages/gitbook/src/intl/translations/ms.ts @@ -40,6 +40,7 @@ export const ms: TranslationLanguage = { search_ask_no_answer: 'Jawapan untuk soalan anda tidak ditemui. Anda boleh cuba menyusunnya semula atau bertanya dengan lebih khusus.', search_ask_error: 'Ada sesuatu yang tidak kena. Sila cuba lagi kemudian.', + search_partial_error: 'Sesetengah hasil tidak dapat dimuatkan. Memaparkan hasil yang tersedia.', on_this_page: 'Pada halaman ini', next_page: 'Seterusnya', previous_page: 'Sebelumnya', @@ -145,7 +146,10 @@ export const ms: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Gagal membuka halaman', ai_chat_tools_mcp_tool: 'Memanggil ${1}', ai_chat_ask: 'Tanya ${1}', - ai_chat_ask_about_page: 'Tanya ${1} tentang halaman ini', + ai_chat_ask_about: 'Tanya ${1} tentang ${2}', + ai_chat_ask_about_this: 'Tanya ${1} tentang ini', + this_page: 'halaman ini', + ai_chat_paragraph_draft: 'Beritahu saya lebih lanjut tentang ini', ai_chat_ask_query: 'Tanya ${1} "${2}"', copy_for_llms: 'Salin untuk LLM', copy_page_markdown: 'Salin halaman sebagai Markdown untuk LLM', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 937678356..6fe56a31e 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -41,6 +41,8 @@ export const nl: TranslationLanguage = { search_ask_no_answer: 'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.', search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.', + search_partial_error: + 'Sommige resultaten konden niet worden geladen. Beschikbare resultaten worden weergegeven.', on_this_page: 'Op deze pagina', next_page: 'Volgende', previous_page: 'Vorige', @@ -147,7 +149,10 @@ export const nl: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Kan de pagina niet openen', ai_chat_tools_mcp_tool: '${1} aangeroepen', ai_chat_ask: 'Vraag het aan ${1}', - ai_chat_ask_about_page: 'Vraag ${1} naar deze pagina', + ai_chat_ask_about: 'Stel ${1} een vraag over ${2}', + ai_chat_ask_about_this: 'Stel ${1} een vraag hierover', + this_page: 'deze pagina', + ai_chat_paragraph_draft: 'Vertel me hier meer over', copy_for_llms: 'Kopiëren voor LLMs', copy_page_markdown: 'Pagina kopiëren als Markdown voor LLMs', copy_page: 'Pagina kopiëren', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 9203fd6e0..f9bc27f09 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -41,6 +41,7 @@ export const no: TranslationLanguage = { search_ask_no_answer: 'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.', search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.', + search_partial_error: 'Noen resultater kunne ikke lastes inn. Viser tilgjengelige resultater.', on_this_page: 'På denne siden', next_page: 'Neste', previous_page: 'Forrige', @@ -147,7 +148,10 @@ export const no: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Kunne ikke åpne siden', ai_chat_tools_mcp_tool: 'Kalte ${1}', ai_chat_ask: 'Spør ${1}', - ai_chat_ask_about_page: 'Spør ${1} om denne siden', + ai_chat_ask_about: 'Spør ${1} om ${2}', + ai_chat_ask_about_this: 'Spør ${1} om dette', + this_page: 'denne siden', + ai_chat_paragraph_draft: 'Fortell meg mer om dette', copy_for_llms: 'Kopier for LLMs', copy_page_markdown: 'Kopier siden som Markdown for LLMs', copy_page: 'Kopier side', diff --git a/packages/gitbook/src/intl/translations/pl.ts b/packages/gitbook/src/intl/translations/pl.ts index 371b86500..a7a8fa41d 100644 --- a/packages/gitbook/src/intl/translations/pl.ts +++ b/packages/gitbook/src/intl/translations/pl.ts @@ -40,6 +40,7 @@ export const pl: TranslationLanguage = { search_ask_no_answer: 'Nie udało się znaleźć odpowiedzi na Twoje pytanie. Spróbuj sformułować je inaczej lub bardziej szczegółowo.', search_ask_error: 'Coś poszło nie tak. Spróbuj ponownie później.', + search_partial_error: 'Nie udało się wczytać niektórych wyników. Wyświetlamy dostępne wyniki.', on_this_page: 'Na tej stronie', next_page: 'Dalej', previous_page: 'Wstecz', @@ -146,7 +147,10 @@ export const pl: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Nie udało się otworzyć strony', ai_chat_tools_mcp_tool: 'Wywołano ${1}', ai_chat_ask: 'Zapytaj ${1}', - ai_chat_ask_about_page: 'Zapytaj ${1} o tę stronę', + ai_chat_ask_about: 'Zapytaj ${1} o ${2}', + ai_chat_ask_about_this: 'Zapytaj ${1} o to', + this_page: 'tę stronę', + ai_chat_paragraph_draft: 'Powiedz mi o tym więcej', ai_chat_ask_query: 'Zapytaj ${1} "${2}"', copy_for_llms: 'Kopiuj dla LLM', copy_page_markdown: 'Kopiuj stronę jako Markdown dla LLM', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 44db75df7..fe067c70a 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -1,4 +1,6 @@ -export const pt_br = { +import type { TranslationLanguage } from './types'; + +export const pt_br: TranslationLanguage = { locale: 'pt-br', language: 'Português (Brasil)', flag: '🇧🇷', @@ -39,6 +41,8 @@ export const pt_br = { search_ask_no_answer: 'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.', search_ask_error: 'Algo deu errado. Por favor, tente novamente mais tarde.', + search_partial_error: + 'Alguns resultados não puderam ser carregados. Mostrando os resultados disponíveis.', on_this_page: 'Nesta página', next_page: 'Próximo', previous_page: 'Anterior', @@ -147,7 +151,10 @@ export const pt_br = { ai_chat_tools_navigate_failed: 'Falha ao abrir a página', ai_chat_tools_mcp_tool: 'Chamou ${1}', ai_chat_ask: 'Perguntar a ${1}', - ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página', + ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}', + ai_chat_ask_about_this: 'Perguntar a ${1} sobre isso', + this_page: 'esta página', + ai_chat_paragraph_draft: 'Conte-me mais sobre isso', copy_for_llms: 'Copiar para LLMs', copy_page_markdown: 'Copiar página como Markdown para LLMs', copy_page: 'Copiar página', diff --git a/packages/gitbook/src/intl/translations/pt.ts b/packages/gitbook/src/intl/translations/pt.ts index 1c1f26606..a9fe630ea 100644 --- a/packages/gitbook/src/intl/translations/pt.ts +++ b/packages/gitbook/src/intl/translations/pt.ts @@ -40,6 +40,8 @@ export const pt: TranslationLanguage = { search_ask_no_answer: 'Não foi possível encontrar uma resposta para a sua pergunta. Pode tentar reformulá-la ou ser mais específico.', search_ask_error: 'Algo correu mal. Tente novamente mais tarde.', + search_partial_error: + 'Não foi possível carregar alguns resultados. A mostrar os resultados disponíveis.', on_this_page: 'Nesta página', next_page: 'Seguinte', previous_page: 'Anterior', @@ -146,7 +148,10 @@ export const pt: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Falha ao abrir a página', ai_chat_tools_mcp_tool: 'Chamou ${1}', ai_chat_ask: 'Perguntar a ${1}', - ai_chat_ask_about_page: 'Perguntar a ${1} sobre esta página', + ai_chat_ask_about: 'Perguntar a ${1} sobre ${2}', + ai_chat_ask_about_this: 'Perguntar a ${1} sobre isto', + this_page: 'esta página', + ai_chat_paragraph_draft: 'Conta-me mais sobre isto', ai_chat_ask_query: 'Perguntar a ${1} "${2}"', copy_for_llms: 'Copiar para LLMs', copy_page_markdown: 'Copiar página como Markdown para LLMs', diff --git a/packages/gitbook/src/intl/translations/ro.ts b/packages/gitbook/src/intl/translations/ro.ts index 5b5058530..379f2ff51 100644 --- a/packages/gitbook/src/intl/translations/ro.ts +++ b/packages/gitbook/src/intl/translations/ro.ts @@ -40,6 +40,8 @@ export const ro: TranslationLanguage = { search_ask_no_answer: 'Nu s-a putut găsi un răspuns la întrebarea ta. Poți încerca să o reformulezi sau să fii mai specific.', search_ask_error: 'Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu.', + search_partial_error: + 'Unele rezultate nu au putut fi încărcate. Se afișează rezultatele disponibile.', on_this_page: 'Pe această pagină', next_page: 'Următoarea', previous_page: 'Anterioara', @@ -148,7 +150,10 @@ export const ro: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Deschiderea paginii a eșuat', ai_chat_tools_mcp_tool: 'A apelat ${1}', ai_chat_ask: 'Întreabă ${1}', - ai_chat_ask_about_page: 'Întreabă ${1} despre această pagină', + ai_chat_ask_about: 'Întreabă ${1} despre ${2}', + ai_chat_ask_about_this: 'Întreabă ${1} despre asta', + this_page: 'această pagină', + ai_chat_paragraph_draft: 'Spune-mi mai multe despre asta', ai_chat_ask_query: 'Întreabă ${1} "${2}"', copy_for_llms: 'Copiază pentru LLM-uri', copy_page_markdown: 'Copiază pagina ca Markdown pentru LLM-uri', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index f15ecfb98..faf8f1111 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -1,4 +1,6 @@ -export const ru = { +import type { TranslationLanguage } from './types'; + +export const ru: TranslationLanguage = { locale: 'ru', language: 'Русский', flag: '🇷🇺', @@ -39,6 +41,8 @@ export const ru = { search_ask_no_answer: 'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.', search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.', + search_partial_error: + 'Не удалось загрузить некоторые результаты. Показаны доступные результаты.', on_this_page: 'На этой странице', next_page: 'Следующая', previous_page: 'Предыдущая', @@ -146,7 +150,10 @@ export const ru = { ai_chat_tools_navigate_failed: 'Не удалось открыть страницу', ai_chat_tools_mcp_tool: 'Вызван ${1}', ai_chat_ask: 'Спросить у ${1}', - ai_chat_ask_about_page: 'Спросить у ${1} об этой странице', + ai_chat_ask_about: 'Спросить у ${1} о ${2}', + ai_chat_ask_about_this: 'Спросить у ${1} об этом', + this_page: 'этой странице', + ai_chat_paragraph_draft: 'Расскажи мне об этом подробнее', copy_for_llms: 'Скопировать для LLM', copy_page_markdown: 'Скопировать страницу как Markdown для LLM', copy_page: 'Скопировать страницу', diff --git a/packages/gitbook/src/intl/translations/sk.ts b/packages/gitbook/src/intl/translations/sk.ts index 47646d0cb..da48a3be2 100644 --- a/packages/gitbook/src/intl/translations/sk.ts +++ b/packages/gitbook/src/intl/translations/sk.ts @@ -40,6 +40,8 @@ export const sk: TranslationLanguage = { search_ask_no_answer: 'Na vašu otázku sa nepodarilo nájsť odpoveď. Skúste ju preformulovať alebo byť konkrétnejší.', search_ask_error: 'Niečo sa pokazilo. Skúste to znova neskôr.', + search_partial_error: + 'Niektoré výsledky sa nepodarilo načítať. Zobrazujú sa dostupné výsledky.', on_this_page: 'Na tejto stránke', next_page: 'Ďalej', previous_page: 'Predchádzajúca', @@ -147,7 +149,10 @@ export const sk: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Stránku sa nepodarilo otvoriť', ai_chat_tools_mcp_tool: 'Zavolal ${1}', ai_chat_ask: 'Opýtať sa ${1}', - ai_chat_ask_about_page: 'Opýtať sa ${1} na túto stránku', + ai_chat_ask_about: 'Opýtať sa ${1} na ${2}', + ai_chat_ask_about_this: 'Opýtať sa ${1} na to', + this_page: 'túto stránku', + ai_chat_paragraph_draft: 'Povedz mi o tom viac', ai_chat_ask_query: 'Opýtať sa ${1} "${2}"', copy_for_llms: 'Kopírovať pre LLM', copy_page_markdown: 'Kopírovať stránku ako Markdown pre LLM', diff --git a/packages/gitbook/src/intl/translations/sl.ts b/packages/gitbook/src/intl/translations/sl.ts index a06b0fae8..b3ae2b620 100644 --- a/packages/gitbook/src/intl/translations/sl.ts +++ b/packages/gitbook/src/intl/translations/sl.ts @@ -40,6 +40,8 @@ export const sl: TranslationLanguage = { search_ask_no_answer: 'Odgovora na vaše vprašanje ni bilo mogoče najti. Poskusite ga preoblikovati ali biti bolj natančni.', search_ask_error: 'Nekaj je šlo narobe. Poskusite znova pozneje.', + search_partial_error: + 'Nekaterih rezultatov ni bilo mogoče naložiti. Prikazani so razpoložljivi rezultati.', on_this_page: 'Na tej strani', next_page: 'Naslednja', previous_page: 'Prejšnja', @@ -145,7 +147,10 @@ export const sl: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Strani ni bilo mogoče odpreti', ai_chat_tools_mcp_tool: 'Poklicano ${1}', ai_chat_ask: 'Vprašaj ${1}', - ai_chat_ask_about_page: 'Vprašaj ${1} o tej strani', + ai_chat_ask_about: 'Vprašaj ${1} o ${2}', + ai_chat_ask_about_this: 'Vprašaj ${1} o tem', + this_page: 'to stran', + ai_chat_paragraph_draft: 'Povej mi več o tem', ai_chat_ask_query: 'Vprašaj ${1} "${2}"', copy_for_llms: 'Kopiraj za LLM', copy_page_markdown: 'Kopiraj stran kot Markdown za LLM', diff --git a/packages/gitbook/src/intl/translations/sv.ts b/packages/gitbook/src/intl/translations/sv.ts index 84ed7ada1..0c375c5da 100644 --- a/packages/gitbook/src/intl/translations/sv.ts +++ b/packages/gitbook/src/intl/translations/sv.ts @@ -40,6 +40,7 @@ export const sv: TranslationLanguage = { search_ask_no_answer: 'Det gick inte att hitta ett svar på din fråga. Du kan försöka formulera om den eller vara mer specifik.', search_ask_error: 'Något gick fel. Försök igen senare.', + search_partial_error: 'Vissa resultat kunde inte läsas in. Visar tillgängliga resultat.', on_this_page: 'På den här sidan', next_page: 'Nästa', previous_page: 'Föregående', @@ -146,7 +147,10 @@ export const sv: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Det gick inte att öppna sidan', ai_chat_tools_mcp_tool: 'Anropade ${1}', ai_chat_ask: 'Fråga ${1}', - ai_chat_ask_about_page: 'Fråga ${1} om den här sidan', + ai_chat_ask_about: 'Fråga ${1} om ${2}', + ai_chat_ask_about_this: 'Fråga ${1} om det här', + this_page: 'den här sidan', + ai_chat_paragraph_draft: 'Berätta mer om det här', ai_chat_ask_query: 'Fråga ${1} "${2}"', copy_for_llms: 'Kopiera för LLM:er', copy_page_markdown: 'Kopiera sidan som Markdown för LLM:er', diff --git a/packages/gitbook/src/intl/translations/th.ts b/packages/gitbook/src/intl/translations/th.ts index b902e6be6..36cd3f058 100644 --- a/packages/gitbook/src/intl/translations/th.ts +++ b/packages/gitbook/src/intl/translations/th.ts @@ -39,6 +39,7 @@ export const th: TranslationLanguage = { search_ask_sources_no_answer: 'หน้าที่เกี่ยวข้อง', search_ask_no_answer: 'ไม่พบคำตอบสำหรับคำถามของคุณ ลองเรียบเรียงใหม่หรือถามให้เฉพาะเจาะจงขึ้น', search_ask_error: 'มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง', + search_partial_error: 'โหลดผลลัพธ์บางรายการไม่ได้ กำลังแสดงผลลัพธ์ที่พร้อมใช้งาน', on_this_page: 'ในหน้านี้', next_page: 'ถัดไป', previous_page: 'ก่อนหน้า', @@ -142,7 +143,10 @@ export const th: TranslationLanguage = { ai_chat_tools_navigate_failed: 'ไม่สามารถเปิดหน้าได้', ai_chat_tools_mcp_tool: 'เรียกใช้ ${1}', ai_chat_ask: 'ถาม ${1}', - ai_chat_ask_about_page: 'ถาม ${1} เกี่ยวกับหน้านี้', + ai_chat_ask_about: 'ถาม ${1} เกี่ยวกับ ${2}', + ai_chat_ask_about_this: 'ถาม ${1} เกี่ยวกับเรื่องนี้', + this_page: 'หน้านี้', + ai_chat_paragraph_draft: 'บอกฉันเพิ่มเติมเกี่ยวกับเรื่องนี้', ai_chat_ask_query: 'ถาม ${1} "${2}"', copy_for_llms: 'คัดลอกสำหรับ LLM', copy_page_markdown: 'คัดลอกหน้าเป็น Markdown สำหรับ LLM', diff --git a/packages/gitbook/src/intl/translations/tr.ts b/packages/gitbook/src/intl/translations/tr.ts index 2cbf11ca7..f819970ad 100644 --- a/packages/gitbook/src/intl/translations/tr.ts +++ b/packages/gitbook/src/intl/translations/tr.ts @@ -40,6 +40,7 @@ export const tr: TranslationLanguage = { search_ask_no_answer: 'Sorunuz için bir cevap bulunamadı. Soruyu yeniden ifade etmeyi veya daha spesifik olmayı deneyebilirsiniz.', search_ask_error: 'Bir şeyler ters gitti. Lütfen daha sonra tekrar deneyin.', + search_partial_error: 'Bazı sonuçlar yüklenemedi. Kullanılabilir sonuçlar gösteriliyor.', on_this_page: 'Bu sayfada', next_page: 'Sonraki', previous_page: 'Önceki', @@ -144,7 +145,10 @@ export const tr: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Sayfa açılamadı', ai_chat_tools_mcp_tool: '${1} çağrıldı', ai_chat_ask: '${1} sor', - ai_chat_ask_about_page: 'Bu sayfa hakkında ${1} sor', + ai_chat_ask_about: '${1} için ${2} hakkında sor', + ai_chat_ask_about_this: '${1} için bununla ilgili sor', + this_page: 'bu sayfa', + ai_chat_paragraph_draft: 'Bana bundan daha fazla bahset', ai_chat_ask_query: '${1} için "${2}" sor', copy_for_llms: 'LLM için kopyala', copy_page_markdown: 'Sayfayı LLM için Markdown olarak kopyala', diff --git a/packages/gitbook/src/intl/translations/uk.ts b/packages/gitbook/src/intl/translations/uk.ts index 96dc69577..9bec9bad0 100644 --- a/packages/gitbook/src/intl/translations/uk.ts +++ b/packages/gitbook/src/intl/translations/uk.ts @@ -40,6 +40,7 @@ export const uk: TranslationLanguage = { search_ask_no_answer: 'Не вдалося знайти відповідь на ваше запитання. Спробуйте переформулювати його або зробити конкретнішим.', search_ask_error: 'Щось пішло не так. Спробуйте ще раз пізніше.', + search_partial_error: 'Не вдалося завантажити деякі результати. Показано доступні результати.', on_this_page: 'На цій сторінці', next_page: 'Наступна', previous_page: 'Попередня', @@ -144,7 +145,10 @@ export const uk: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Не вдалося відкрити сторінку', ai_chat_tools_mcp_tool: 'Викликано ${1}', ai_chat_ask: 'Запитати ${1}', - ai_chat_ask_about_page: 'Запитати ${1} про цю сторінку', + ai_chat_ask_about: 'Запитати ${1} про ${2}', + ai_chat_ask_about_this: 'Запитати ${1} про це', + this_page: 'цю сторінку', + ai_chat_paragraph_draft: 'Розкажи мені про це більше', ai_chat_ask_query: 'Запитати ${1} "${2}"', copy_for_llms: 'Копіювати для LLM', copy_page_markdown: 'Копіювати сторінку як Markdown для LLM', diff --git a/packages/gitbook/src/intl/translations/vi.ts b/packages/gitbook/src/intl/translations/vi.ts index d0c98bcbe..8d5f587ef 100644 --- a/packages/gitbook/src/intl/translations/vi.ts +++ b/packages/gitbook/src/intl/translations/vi.ts @@ -40,6 +40,7 @@ export const vi: TranslationLanguage = { search_ask_no_answer: 'Không tìm thấy câu trả lời cho câu hỏi của bạn. Bạn có thể thử diễn đạt lại hoặc hỏi cụ thể hơn.', search_ask_error: 'Đã xảy ra lỗi. Vui lòng thử lại sau.', + search_partial_error: 'Không thể tải một số kết quả. Đang hiển thị các kết quả có sẵn.', on_this_page: 'Trên trang này', next_page: 'Tiếp theo', previous_page: 'Trước', @@ -144,7 +145,10 @@ export const vi: TranslationLanguage = { ai_chat_tools_navigate_failed: 'Không thể mở trang', ai_chat_tools_mcp_tool: 'Đã gọi ${1}', ai_chat_ask: 'Hỏi ${1}', - ai_chat_ask_about_page: 'Hỏi ${1} về trang này', + ai_chat_ask_about: 'Hỏi ${1} về ${2}', + ai_chat_ask_about_this: 'Hỏi ${1} về điều này', + this_page: 'trang này', + ai_chat_paragraph_draft: 'Cho tôi biết thêm về điều này', ai_chat_ask_query: 'Hỏi ${1} "${2}"', copy_for_llms: 'Sao chép cho LLM', copy_page_markdown: 'Sao chép trang dưới dạng Markdown cho LLM', diff --git a/packages/gitbook/src/intl/translations/yue.ts b/packages/gitbook/src/intl/translations/yue.ts index 0f91be454..436b4f94f 100644 --- a/packages/gitbook/src/intl/translations/yue.ts +++ b/packages/gitbook/src/intl/translations/yue.ts @@ -39,6 +39,7 @@ export const yue: TranslationLanguage = { search_ask_sources_no_answer: '相關頁面', search_ask_no_answer: '搵唔到你問題嘅答案。你可以試下改寫,或者問得更具體。', search_ask_error: '發生錯誤。請稍後再試。', + search_partial_error: '部分結果載入唔到。顯示緊可用嘅結果。', on_this_page: '本頁內容', next_page: '下一頁', previous_page: '上一頁', @@ -141,7 +142,10 @@ export const yue: TranslationLanguage = { ai_chat_tools_navigate_failed: '無法開啟頁面', ai_chat_tools_mcp_tool: '已呼叫 ${1}', ai_chat_ask: '問 ${1}', - ai_chat_ask_about_page: '問 ${1} 關於此頁面', + ai_chat_ask_about: '問 ${1} 關於${2}', + ai_chat_ask_about_this: '問 ${1} 關於呢樣嘢', + this_page: '此頁面', + ai_chat_paragraph_draft: '同我講多啲呢方面嘅嘢', ai_chat_ask_query: '問 ${1}「${2}」', copy_for_llms: '複製俾 LLM 用', copy_page_markdown: '以 Markdown 複製頁面俾 LLM 用', diff --git a/packages/gitbook/src/intl/translations/zh-tw.ts b/packages/gitbook/src/intl/translations/zh-tw.ts index 31a131c0e..830e519f7 100644 --- a/packages/gitbook/src/intl/translations/zh-tw.ts +++ b/packages/gitbook/src/intl/translations/zh-tw.ts @@ -39,6 +39,7 @@ export const zh_tw: TranslationLanguage = { search_ask_sources_no_answer: '相關頁面', search_ask_no_answer: '找不到您問題的答案。您可以嘗試改寫問題,或提出更具體的內容。', search_ask_error: '發生錯誤。請稍後再試。', + search_partial_error: '部分結果無法載入。正在顯示可用結果。', on_this_page: '本頁內容', next_page: '下一頁', previous_page: '上一頁', @@ -141,7 +142,10 @@ export const zh_tw: TranslationLanguage = { ai_chat_tools_navigate_failed: '無法開啟頁面', ai_chat_tools_mcp_tool: '已呼叫 ${1}', ai_chat_ask: '詢問 ${1}', - ai_chat_ask_about_page: '詢問 ${1} 關於此頁面', + ai_chat_ask_about: '詢問 ${1} 關於${2}', + ai_chat_ask_about_this: '詢問 ${1} 關於這個', + this_page: '此頁面', + ai_chat_paragraph_draft: '告訴我更多相關資訊', ai_chat_ask_query: '詢問 ${1}「${2}」', copy_for_llms: '複製給 LLM 使用', copy_page_markdown: '將頁面以 Markdown 複製給 LLM 使用', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index dc7ba52da..ff64c3071 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -40,6 +40,7 @@ export const zh: TranslationLanguage = { search_ask_sources_no_answer: '相关页面', search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。', search_ask_error: '出了些问题。请稍后再试。', + search_partial_error: '部分结果无法加载。正在显示可用结果。', on_this_page: '在本页', next_page: '下一页', previous_page: '上一页', @@ -142,7 +143,10 @@ export const zh: TranslationLanguage = { ai_chat_tools_navigate_failed: '无法打开页面', ai_chat_tools_mcp_tool: '调用了 ${1}', ai_chat_ask: '向 ${1} 提问', - ai_chat_ask_about_page: '向 ${1} 提问有关此页面的问题', + ai_chat_ask_about: '向 ${1} 询问${2}', + ai_chat_ask_about_this: '向 ${1} 询问这个', + this_page: '此页面', + ai_chat_paragraph_draft: '告诉我更多相关信息', copy_for_llms: '复制供 LLMs 使用', copy_page_markdown: '将页面以 Markdown 格式复制供 LLMs 使用', copy_page: '复制页面', diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index a1092ce91..fa5704904 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -10,7 +10,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t import { parse as parseCacheControl } from '@tusbar/cache-control'; import { cacheLife, cacheTag } from 'next/cache'; import { cache } from '../cache'; -import { DataFetcherError, wrapCacheDataFetcherError } from './errors'; +import { DataFetcherError, wrapDataFetcherError } from './errors'; import type { GitBookDataFetcher } from './types'; interface DataFetcherInput { @@ -139,6 +139,20 @@ export function createDataFetcher( changeRequestId: params.changeRequestId, }); }, + getChangeRequestChanges(params) { + return getChangeRequestChanges(input, { + spaceId: params.spaceId, + changeRequestId: params.changeRequestId, + limit: params.limit, + }); + }, + getRevisionSemanticChanges(params) { + return getRevisionSemanticChanges(input, { + spaceId: params.spaceId, + revisionId: params.revisionId, + limit: params.limit, + }); + }, getDocument(params) { return getDocument(input, { spaceId: params.spaceId, @@ -223,7 +237,7 @@ function cacheLifeFromResponse( const getUserById = cache(async (input: DataFetcherInput, params: { userId: string }) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`getUserById(${params.userId})`, async () => { const api = apiClient(input); const res = await api.users.getUserById(params.userId, { @@ -246,7 +260,7 @@ const getSpace = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => { const api = apiClient(input); const res = await api.spaces.getSpaceById( @@ -277,7 +291,7 @@ const getChangeRequest = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getChangeRequest(${params.spaceId}, ${params.changeRequestId})`, async () => { @@ -302,7 +316,7 @@ const getChangeRequest = cache( const getRevision = cache( async (input: DataFetcherInput, params: { spaceId: string; revisionId: string }) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { const api = apiClient(input); const res = await api.spaces.getRevisionById( @@ -323,6 +337,76 @@ const getRevision = cache( } ); +const getChangeRequestChanges = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; changeRequestId: string; limit?: number } + ) => { + 'use cache: remote'; + cacheTag( + getCacheTag({ + tag: 'change-request', + space: params.spaceId, + changeRequest: params.changeRequestId, + }) + ); + + return wrapDataFetcherError(async () => { + return trace( + `getChangeRequestChanges(${params.spaceId}, ${params.changeRequestId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getChangeRequestChanges( + params.spaceId, + params.changeRequestId, + { + limit: params.limit, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('minutes'); + return res.data; + } + ); + }); + } +); + +const getRevisionSemanticChanges = cache( + async ( + input: DataFetcherInput, + params: { spaceId: string; revisionId: string; limit?: number } + ) => { + 'use cache: remote'; + return wrapDataFetcherError(async () => { + return trace( + `getRevisionSemanticChanges(${params.spaceId}, ${params.revisionId})`, + async () => { + const api = apiClient(input); + const res = await api.spaces.getRevisionSemanticChanges( + params.spaceId, + params.revisionId, + { + computed: false, + limit: params.limit, + metadata: false, + }, + { + ...noCacheFetchOptions, + } + ); + cacheTag(...getCacheTagsFromResponse(res)); + cacheLife('max'); + return res.data; + } + ); + }); + } +); + const getRevisionPageMarkdown = cache( async ( input: DataFetcherInput, @@ -331,7 +415,7 @@ const getRevisionPageMarkdown = cache( _functionName: string ) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, async () => { @@ -373,7 +457,7 @@ const getRevisionPageDocument = cache( _functionName: string ) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, async () => { @@ -407,7 +491,7 @@ const getRevisionReusableContentDocument = cache( params: { spaceId: string; revisionId: string; reusableContentId: string } ) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getRevisionReusableContentDocument(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, async () => { @@ -440,7 +524,7 @@ const getRevisionPageByPath = cache( params: { spaceId: string; revisionId: string; path: string } ) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`, async () => { @@ -467,7 +551,7 @@ const getRevisionPageByPath = cache( const getDocument = cache( async (input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => { const api = apiClient(input); const res = await api.spaces.getDocumentById( @@ -507,7 +591,7 @@ const getComputedDocument = cache( ) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`, async () => { @@ -544,7 +628,7 @@ const getLatestOpenAPISpecVersionContent = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`, async () => { @@ -579,7 +663,7 @@ const getPublishedContentSite = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`, async () => { @@ -621,7 +705,7 @@ const getSiteRedirectBySource = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`, async () => { @@ -656,7 +740,7 @@ const getEmbedByUrl = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { const api = apiClient(input); const res = await api.spaces.getEmbedByUrlInSpace( @@ -690,7 +774,7 @@ const searchSiteContent = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`, async () => { @@ -730,7 +814,7 @@ const renderIntegrationUi = cache( }) ); - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace(`renderIntegrationUi(${params.integrationName})`, async () => { const api = apiClient(input); const res = await api.integrations.renderIntegrationUiWithPost( @@ -757,7 +841,7 @@ const listRevisionPageMetaLinks = cache( params: { spaceId: string; revisionId: string; pageId: string } ) => { 'use cache'; - return wrapCacheDataFetcherError(async () => { + return wrapDataFetcherError(async () => { return trace( `listRevisionPageMetaLinks(${params.spaceId}, ${params.revisionId}, ${params.pageId})`, async () => { diff --git a/packages/gitbook/src/lib/data/errors.ts b/packages/gitbook/src/lib/data/errors.ts index 6682185bc..4611bbe4f 100644 --- a/packages/gitbook/src/lib/data/errors.ts +++ b/packages/gitbook/src/lib/data/errors.ts @@ -1,6 +1,5 @@ import { GitBookAPIError } from '@gitbook/api'; import { parse as parseCacheControl } from '@tusbar/cache-control'; -import { cacheLife } from 'next/cache'; import type { DataFetcherErrorData, DataFetcherResponse } from './types'; export class DataFetcherError extends Error { @@ -92,34 +91,6 @@ export async function wrapDataFetcherError( } } -/** - * Wrap an async execution to handle errors and return a DataFetcherResponse. - * This should be used inside 'use cache' functions. - */ -export async function wrapCacheDataFetcherError( - fn: () => Promise -): Promise> { - const result = await wrapDataFetcherError(fn); - if (result.error) { - const cacheValue = result.error.cache; - // We only want to cache 404 errors for "long", because that's an "expected" error. - if (result.error.code === 404) { - cacheLife({ - stale: 60, - revalidate: cacheValue?.maxAge ?? 60 * 60, // 1 hour - expire: cacheValue?.staleWhileRevalidate ?? 60 * 60 * 24, // 1 day - }); - } else { - cacheLife({ - stale: 60, // This one is only for the client - revalidate: cacheValue?.maxAge ?? 30, // we don't want to cache it for too long, but at least 30 seconds to avoid hammering the API - expire: cacheValue?.staleWhileRevalidate ?? 90, // we want to revalidate this error after 90 seconds for sure - }); - } - } - return result; -} - /** * Ignore some data fetcher errors. */ diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index b01a823dc..878479f98 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -65,6 +65,15 @@ export interface GitBookDataFetcher { changeRequestId: string; }): Promise>; + /** + * Get the semantic changes for a change request. + */ + getChangeRequestChanges(params: { + spaceId: string; + changeRequestId: string; + limit?: number; + }): Promise>; + /** * Get the revision by its space ID and revision ID. */ @@ -73,6 +82,15 @@ export interface GitBookDataFetcher { revisionId: string; }): Promise>; + /** + * Get the semantic changes for a revision. + */ + getRevisionSemanticChanges(params: { + spaceId: string; + revisionId: string; + limit?: number; + }): Promise>; + /** * Get a revision page by its path. */ diff --git a/packages/gitbook/src/lib/search.ts b/packages/gitbook/src/lib/search.ts new file mode 100644 index 000000000..14acd8e24 --- /dev/null +++ b/packages/gitbook/src/lib/search.ts @@ -0,0 +1,11 @@ +/** + * Return the highest-scoring item in the list, or undefined when empty. + */ +export function getBestScoredResult( + items: readonly T[] +): T | undefined { + return items.reduce( + (best, item) => (best === undefined || item.score > best.score ? item : best), + undefined + ); +} diff --git a/packages/gitbook/src/lib/utils.ts b/packages/gitbook/src/lib/utils.ts index dcf202114..487a894fa 100644 --- a/packages/gitbook/src/lib/utils.ts +++ b/packages/gitbook/src/lib/utils.ts @@ -54,9 +54,6 @@ export function defaultCustomization(): api.SiteCustomizationSettings { default: api.CustomizationDefaultThemeMode.Light, toggeable: true, }, - pdf: { - enabled: true, - }, feedback: { enabled: false, }, @@ -69,20 +66,16 @@ export function defaultCustomization(): api.SiteCustomizationSettings { advancedCustomization: { enabled: true, }, - git: { - showEditLink: false, - }, pagination: { enabled: true, }, pageActions: { - externalAI: true, - markdown: true, - mcp: true, items: [ + CustomizationPageActionType.Assistant, CustomizationPageActionType.Markdown, CustomizationPageActionType.ExternalAi, CustomizationPageActionType.Mcp, + CustomizationPageActionType.Pdf, ], }, trademark: { diff --git a/packages/openapi-parser/src/types.ts b/packages/openapi-parser/src/types.ts index cde56d09a..cd63333a6 100644 --- a/packages/openapi-parser/src/types.ts +++ b/packages/openapi-parser/src/types.ts @@ -37,6 +37,12 @@ export interface OpenAPICustomSpecProperties { * Description in HTML format. */ 'x-gitbook-description-html'?: string; + + /** + * Default URL of the MCP server exposing the operations in this spec. + * Can be overridden at the path or operation level via the same extension. + */ + 'x-gitbook-mcp-url'?: string; } /** @@ -100,6 +106,18 @@ export interface OpenAPICustomOperationProperties { * @enum 'experimental' | 'alpha' | 'beta' */ 'x-stability'?: OpenAPIStability; + + /** + * If `true`, indicates this operation is available as a tool in an MCP server. + */ + 'x-gitbook-mcp'?: boolean; + + /** + * URL of the MCP server exposing this operation. When set — here, or inherited from + * the path or root level (operation > path > root) — the "Available in MCP" badge + * becomes a button that copies this URL to the clipboard. + */ + 'x-gitbook-mcp-url'?: string; } /** diff --git a/packages/react-openapi/src/common/OpenAPIMcpBadge.tsx b/packages/react-openapi/src/common/OpenAPIMcpBadge.tsx new file mode 100644 index 000000000..914f22e9e --- /dev/null +++ b/packages/react-openapi/src/common/OpenAPIMcpBadge.tsx @@ -0,0 +1,29 @@ +import { OpenAPICopyButton } from '../OpenAPICopyButton'; +import { type OpenAPIContext, getOpenAPIClientContext } from '../context'; +import { t, tString } from '../translate'; + +export function OpenAPIMcpBadge(props: { url?: string; context: OpenAPIContext }) { + const { url, context } = props; + + const content = ( + <> + {context.icons.mcp} + {t(context.translation, 'available_in_mcp')} + + ); + + if (!url) { + return
{content}
; + } + + return ( + + {content} + + ); +} diff --git a/packages/react-openapi/src/common/OpenAPISummary.tsx b/packages/react-openapi/src/common/OpenAPISummary.tsx index 78e8b289d..f5b214c39 100644 --- a/packages/react-openapi/src/common/OpenAPISummary.tsx +++ b/packages/react-openapi/src/common/OpenAPISummary.tsx @@ -1,6 +1,7 @@ import { OpenAPIPath } from '../OpenAPIPath'; import type { OpenAPIContext } from '../context'; import type { OpenAPIOperationData, OpenAPIWebhookData } from '../types'; +import { OpenAPIMcpBadge } from './OpenAPIMcpBadge'; import { OpenAPIStability } from './OpenAPIStability'; export function OpenAPISummary(props: { @@ -27,12 +28,15 @@ export function OpenAPISummary(props: { className="openapi-summary" id={!context.headless && operation.summary ? undefined : context.id} > - {(operation.deprecated || operation['x-stability']) && ( + {(operation.deprecated || operation['x-stability'] || operation['x-gitbook-mcp']) && (
{operation.deprecated &&
Deprecated
} {operation['x-stability'] && ( )} + {operation['x-gitbook-mcp'] && ( + + )}
)} {!context.headless && title diff --git a/packages/react-openapi/src/context.ts b/packages/react-openapi/src/context.ts index f3dfec1e9..7c5305714 100644 --- a/packages/react-openapi/src/context.ts +++ b/packages/react-openapi/src/context.ts @@ -16,6 +16,7 @@ export interface OpenAPIClientContext { copy: React.ReactNode; check: React.ReactNode; lock: React.ReactNode; + mcp: React.ReactNode; }; /** diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.ts b/packages/react-openapi/src/resolveOpenAPIOperation.ts index ebb7ab5f9..5c4ba647b 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.ts @@ -6,7 +6,7 @@ import type { } from '@gitbook/openapi-parser'; import { dereferenceFilesystem } from './dereference'; import type { OpenAPIOperationData, OpenAPISecurityScope } from './types'; -import { checkIsReference } from './utils'; +import { checkIsReference, readMcpUrl } from './utils'; export { fromJSON, toJSON } from 'flatted'; @@ -71,7 +71,11 @@ export async function resolveOpenAPIOperation( return { servers, - operation: { ...operation, security }, + operation: { + ...operation, + security, + 'x-gitbook-mcp-url': getMcpUrl(schema, path, operation), + }, method, path, securities: Array.from(securitiesMap.entries()), @@ -148,6 +152,18 @@ function getServers( return 'servers' in schema ? (schema.servers ?? []) : []; } +/** + * Resolve the MCP server URL for an operation, following the same precedence as servers: + * operation > path > root. + */ +function getMcpUrl( + schema: OpenAPIV3.Document | OpenAPIV3_1.Document, + path: string, + operation: OpenAPIV3.OperationObject +): string | undefined { + return readMcpUrl(operation) ?? readMcpUrl(getPathObject(schema, path)) ?? readMcpUrl(schema); +} + /** * Get an operation by its path and method. */ diff --git a/packages/react-openapi/src/resolveOpenAPIWebhook.ts b/packages/react-openapi/src/resolveOpenAPIWebhook.ts index ed799ac9a..0825b45a2 100644 --- a/packages/react-openapi/src/resolveOpenAPIWebhook.ts +++ b/packages/react-openapi/src/resolveOpenAPIWebhook.ts @@ -6,6 +6,7 @@ import type { } from '@gitbook/openapi-parser'; import { dereferenceFilesystem } from './dereference'; import type { OpenAPIWebhookData } from './types'; +import { readMcpUrl } from './utils'; export { fromJSON, toJSON } from 'flatted'; @@ -40,7 +41,13 @@ export async function resolveOpenAPIWebhook( return { servers, - operation, + operation: { + ...operation, + 'x-gitbook-mcp-url': + readMcpUrl(operation) ?? + readMcpUrl(getPathObject(schema, name)) ?? + readMcpUrl(schema), + }, method, name, 'x-expandAllResponses': diff --git a/packages/react-openapi/src/translations/de.ts b/packages/react-openapi/src/translations/de.ts index 0ea563bef..d2086815e 100644 --- a/packages/react-openapi/src/translations/de.ts +++ b/packages/react-openapi/src/translations/de.ts @@ -5,6 +5,8 @@ export const de = { stability_experimental: 'Experimentell', stability_alpha: 'Alpha', stability_beta: 'Beta', + available_in_mcp: 'Im MCP verfügbar', + copy_url: 'URL kopieren', discriminator: 'Diskriminator', copy_to_clipboard: 'In die Zwischenablage kopieren', copied: 'Kopiert', diff --git a/packages/react-openapi/src/translations/en.ts b/packages/react-openapi/src/translations/en.ts index a46807813..69c7d0693 100644 --- a/packages/react-openapi/src/translations/en.ts +++ b/packages/react-openapi/src/translations/en.ts @@ -5,6 +5,8 @@ export const en = { stability_experimental: 'Experimental', stability_alpha: 'Alpha', stability_beta: 'Beta', + available_in_mcp: 'Available in MCP', + copy_url: 'Copy URL', discriminator: 'Discriminator', copy_to_clipboard: 'Copy to clipboard', copied: 'Copied', diff --git a/packages/react-openapi/src/translations/es.ts b/packages/react-openapi/src/translations/es.ts index b7a920be4..5b04f7ef8 100644 --- a/packages/react-openapi/src/translations/es.ts +++ b/packages/react-openapi/src/translations/es.ts @@ -5,6 +5,8 @@ export const es = { stability_experimental: 'Experimental', stability_alpha: 'Alfa', stability_beta: 'Beta', + available_in_mcp: 'Disponible en el MCP', + copy_url: 'Copiar URL', discriminator: 'Discriminador', copy_to_clipboard: 'Copiar al portapapeles', copied: 'Copiado', diff --git a/packages/react-openapi/src/translations/fr.ts b/packages/react-openapi/src/translations/fr.ts index 67c029c09..59aa143a4 100644 --- a/packages/react-openapi/src/translations/fr.ts +++ b/packages/react-openapi/src/translations/fr.ts @@ -5,6 +5,8 @@ export const fr = { stability_experimental: 'Expérimental', stability_alpha: 'Alpha', stability_beta: 'Bêta', + available_in_mcp: 'Disponible dans le MCP', + copy_url: "Copier l'URL", discriminator: 'Discriminateur', copy_to_clipboard: 'Copier dans le presse-papiers', copied: 'Copié', diff --git a/packages/react-openapi/src/translations/ja.ts b/packages/react-openapi/src/translations/ja.ts index ca0b06c28..40427d20e 100644 --- a/packages/react-openapi/src/translations/ja.ts +++ b/packages/react-openapi/src/translations/ja.ts @@ -5,6 +5,8 @@ export const ja = { stability_experimental: '実験的', stability_alpha: 'アルファ', stability_beta: 'ベータ', + available_in_mcp: 'MCPで利用可能', + copy_url: 'URLをコピー', discriminator: 'ディスクリミネーター', copy_to_clipboard: 'クリップボードにコピー', copied: 'コピー済み', diff --git a/packages/react-openapi/src/translations/nl.ts b/packages/react-openapi/src/translations/nl.ts index fcfa4ad8e..3f75dee7c 100644 --- a/packages/react-openapi/src/translations/nl.ts +++ b/packages/react-openapi/src/translations/nl.ts @@ -5,6 +5,8 @@ export const nl = { stability_experimental: 'Experimenteel', stability_alpha: 'Alfa', stability_beta: 'Bèta', + available_in_mcp: 'Beschikbaar in MCP', + copy_url: 'URL kopiëren', discriminator: 'Discriminator', copy_to_clipboard: 'Kopiëren naar klembord', copied: 'Gekopieerd', diff --git a/packages/react-openapi/src/translations/no.ts b/packages/react-openapi/src/translations/no.ts index 499896ea2..53d335fb3 100644 --- a/packages/react-openapi/src/translations/no.ts +++ b/packages/react-openapi/src/translations/no.ts @@ -5,6 +5,8 @@ export const no = { stability_experimental: 'Eksperimentell', stability_alpha: 'Alfa', stability_beta: 'Beta', + available_in_mcp: 'Tilgjengelig i MCP', + copy_url: 'Kopier URL', discriminator: 'Diskriminator', copy_to_clipboard: 'Kopier til utklippstavle', copied: 'Kopiert', diff --git a/packages/react-openapi/src/translations/pt-br.ts b/packages/react-openapi/src/translations/pt-br.ts index 6fc3caf1d..8352fea49 100644 --- a/packages/react-openapi/src/translations/pt-br.ts +++ b/packages/react-openapi/src/translations/pt-br.ts @@ -5,6 +5,8 @@ export const pt_br = { stability_experimental: 'Experimental', stability_alpha: 'Alfa', stability_beta: 'Beta', + available_in_mcp: 'Disponível no MCP', + copy_url: 'Copiar URL', discriminator: 'Discriminador', copy_to_clipboard: 'Copiar para a área de transferência', copied: 'Copiado', diff --git a/packages/react-openapi/src/translations/zh.ts b/packages/react-openapi/src/translations/zh.ts index 3fa406ed8..64b8c31d2 100644 --- a/packages/react-openapi/src/translations/zh.ts +++ b/packages/react-openapi/src/translations/zh.ts @@ -5,6 +5,8 @@ export const zh = { stability_experimental: '实验性', stability_alpha: 'Alpha', stability_beta: 'Beta', + available_in_mcp: '可在 MCP 中使用', + copy_url: '复制 URL', discriminator: '判别器', copy_to_clipboard: '复制到剪贴板', copied: '已复制', diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index f0523766b..1c6ef6a88 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -16,6 +16,18 @@ export function createStateKey(key: string, scope?: string) { return scope ? `${scope}_${key}` : key; } +/** + * Read the `x-gitbook-mcp-url` extension from any spec object, treating an empty string as unset + * so it falls through to the next level when resolving the operation > path > root cascade. + */ +export function readMcpUrl(obj: unknown): string | undefined { + if (obj && typeof obj === 'object' && 'x-gitbook-mcp-url' in obj) { + const value = obj['x-gitbook-mcp-url']; + return typeof value === 'string' && value ? value : undefined; + } + return undefined; +} + /** * Check if an object has a description. Either at the root level or in items. */