From bbfd3caee525cff2f4fe674d579ba0dfd8f50161 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 3 Mar 2026 19:08:42 +0100 Subject: [PATCH 01/22] wip --- .../[siteData]/~gitbook/index/route.ts | 31 +++++++++++++++++++ packages/gitbook/src/app/utils.ts | 2 ++ packages/gitbook/src/middleware.ts | 3 +- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts new file mode 100644 index 0000000000..5359110b0c --- /dev/null +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -0,0 +1,31 @@ +import type { NextRequest } from 'next/server'; + +import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils'; + +export const revalidate = 86400; // 1 day + +export async function GET( + request: NextRequest, + { params }: { params: Promise } +) { + const { context, token, visitorAuthClaims } = await getStaticSiteContext(await params); + const { organizationId, site } = context; + console.log('Fetching static index for site', site.id, token); + // TODO: Obviously switch to real API client + const result = await fetch(`http://localhost:6100/v1/orgs/${organizationId}/sites/${site.id}/index`, { + headers: { + Authorization: `Bearer ${token}`, + } + }); + if (!result.ok) { + return new Response('Error fetching index', { status: 500 }); + } + const index = await result.json(); + console.log('Fetched index for site', site.id, JSON.stringify(index).length, 'bytes', visitorAuthClaims, result.headers); + return new Response(JSON.stringify(index), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }, + }); +} diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index ff6fd6ffc3..c0f2cdcfa5 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -48,6 +48,8 @@ export async function getStaticSiteContext(params: RouteLayoutParams) { return { context, visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + //TODO: remove, just for easier testing + token: siteURLData.apiToken, }; } diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 11cdc5484e..5663a4d0bf 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -460,7 +460,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // When we use adaptive content, we want to ensure that the cache is not used at all on the client side. // Vercel already set this header, this is needed in OpenNext. - if (siteURLData.contextId) { + if (siteURLData.contextId && !siteRequestURL.pathname.endsWith('~gitbook/index')) { response.headers.set('cache-control', 'public, max-age=0, must-revalidate'); } @@ -676,6 +676,7 @@ function encodePathInSiteContent( case 'robots.txt': case '~gitbook/embed/script.js': case '~gitbook/embed/demo': + case '~gitbook/index': // LLMs.txt, sitemap, sitemap-pages and robots.txt are always static // as they only depend on the site structure / pages. return { pathname, routeType: 'static' }; From b60cd1149af97321306b57cbdca04ffc4c22e699 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 3 Mar 2026 20:05:36 +0100 Subject: [PATCH 02/22] dummy useLocalSearchResults --- bun.lock | 3 + packages/gitbook/package.json | 1 + .../src/components/Search/SearchContainer.tsx | 10 + .../gitbook/src/components/Search/index.ts | 1 + .../Search/useLocalSearchResults.tsx | 173 ++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 packages/gitbook/src/components/Search/useLocalSearchResults.tsx diff --git a/bun.lock b/bun.lock index a88481850a..d80805196a 100644 --- a/bun.lock +++ b/bun.lock @@ -145,6 +145,7 @@ "direction": "^2.0.1", "event-iterator": "^2.0.0", "feed": "^5.1.0", + "flexsearch": "^0.8.212", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", @@ -2308,6 +2309,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "flexsearch": ["flexsearch@0.8.212", "", {}, "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw=="], + "focus-trap": ["focus-trap@7.6.1", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-nB8y4nQl8PshahLpGKZOq1sb0xrMVFSn6at7u/qOsBZTlZRzaapISGENcB6mOkoezbClZyiMwEF/dGY8AZ00rA=="], "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 2345c4918e..5a890f6b35 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -37,6 +37,7 @@ "direction": "^2.0.1", "event-iterator": "^2.0.0", "feed": "^5.1.0", + "flexsearch": "^0.8.212", "image-size": "^2.0.2", "js-cookie": "^3.0.5", "jsontoxml": "^1.0.1", diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 66d2a524eb..f0ab2a2d77 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -20,6 +20,7 @@ import { SearchScopeControl } from './SearchScopeControl'; import { useSearchState, useSetSearchState } from './useSearch'; import { useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; +import { useLocalSearchResults } from './useLocalSearchResults'; interface SearchContainerProps { /** The current site space. */ @@ -213,6 +214,15 @@ export function SearchContainer({ suggestions: config.suggestions, searchURL, }); + + const {results: localResults} = useLocalSearchResults({ + query: normalizedQuery, + siteBasePath: "http://localhost:3000/url/test-va-adaptive.gitbook-x-dev-nicolas.firebaseapp.com/test-va-adaptive-docs", + disabled: Boolean(state?.query) || withAI, // We only want to use local search when there is no query and no AI, as a fallback + }) + + console.log('Local search results', localResults); + const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ diff --git a/packages/gitbook/src/components/Search/index.ts b/packages/gitbook/src/components/Search/index.ts index 693456c8ec..2da89f8b2e 100644 --- a/packages/gitbook/src/components/Search/index.ts +++ b/packages/gitbook/src/components/Search/index.ts @@ -1,3 +1,4 @@ export * from './SearchInput'; export * from './SearchContainer'; export * from './useSearch'; +export * from './useLocalSearchResults'; diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx new file mode 100644 index 0000000000..755007c61c --- /dev/null +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { Document, type DocumentValue } from 'flexsearch'; +import React from 'react'; + +/** Raw entry from the `~gitbook/index` JSON response */ +interface RawIndexPage { + id: string; + title: string; + icon?: string; + description?: string; +} + +/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */ +interface IndexPage { + [key: string]: DocumentValue | DocumentValue[]; + id: string; + title: string; + icon: string | null; + description: string | null; +} + +/** Result type returned by this hook */ +export interface LocalPageResult { + type: 'local-page'; + id: string; + title: string; + icon?: string; + description?: string; +} + +type LocalSearchState = { + results: LocalPageResult[]; + fetching: boolean; + error: boolean; +}; + +// Module-level singletons — one site per session +let cachedIndex: Document | null = null; +let pendingFetch: Promise> | null = null; + +async function getOrBuildIndex(siteBasePath: string): Promise> { + if (cachedIndex) { + return cachedIndex; + } + + if (pendingFetch) { + return pendingFetch; + } + + pendingFetch = (async () => { + const url = `${siteBasePath}/~gitbook/index`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch search index: ${response.status}`); + } + + const data: { pages: RawIndexPage[] } = await response.json(); + + const index = new Document({ + document: { + id: 'id', + index: ['title'], + store: ['id', 'title', 'icon', 'description'], + }, + tokenize: 'forward', + }); + + for (const page of data.pages) { + index.add({ + id: page.id, + title: page.title, + icon: page.icon ?? null, + description: page.description ?? null, + }); + } + + cachedIndex = index; + return index; + })(); + + // Clear pendingFetch on error so a retry is possible + pendingFetch.catch(() => { + console.error('Error fetching/building search index for siteBasePath', siteBasePath); + pendingFetch = null; + }); + + return pendingFetch; +} + +export function useLocalSearchResults(props: { + query: string; + siteBasePath: string; + disabled?: boolean; +}): LocalSearchState { + const { query, siteBasePath, disabled = false } = props; + + const [state, setState] = React.useState({ + results: [], + fetching: false, + error: false, + }); + + // Track whether the index is loaded so the search effect re-runs after load + const [indexReady, setIndexReady] = React.useState(!!cachedIndex); + + // Load the index once + React.useEffect(() => { + + if (cachedIndex) { + setIndexReady(true); + return; + } + + let cancelled = false; + setState((prev) => ({ ...prev, fetching: true, error: false })); + + getOrBuildIndex(siteBasePath) + .then(() => { + if (!cancelled) { + setIndexReady(true); + setState((prev) => ({ ...prev, fetching: false })); + } + }) + .catch(() => { + if (!cancelled) { + setState({ results: [], fetching: false, error: true }); + } + }); + + return () => { + cancelled = true; + }; + }, [siteBasePath]); + + // Perform instant local search whenever query or index readiness changes + React.useEffect(() => { + if (disabled || !indexReady || !cachedIndex) { + return; + } + + if (!query) { + setState({ results: [], fetching: false, error: false }); + return; + } + + const rawResults = cachedIndex.search(query, { enrich: true, limit: 10 }); + + // Flatten and deduplicate results across fields (flexsearch returns one array per indexed field) + const seen = new Set(); + const results: LocalPageResult[] = []; + + for (const fieldResult of rawResults) { + for (const item of fieldResult.result) { + const doc = (item as { id: string; doc: IndexPage }).doc; + if (!seen.has(doc.id)) { + seen.add(doc.id); + results.push({ + type: 'local-page', + id: doc.id, + title: doc.title, + icon: doc.icon ?? undefined, + description: doc.description ?? undefined, + }); + } + } + } + + setState({ results, fetching: false, error: false }); + }, [query, indexReady, disabled]); + + return state; +} From 8bef9928aab61cdc4cd005a07389975455b8d93b Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 3 Mar 2026 21:02:33 +0100 Subject: [PATCH 03/22] Add LocalSearchResults component and integrate into SearchContainer - Introduced LocalSearchResults component to display local search results. - Updated SearchContainer to utilize LocalSearchResults and manage local search state. - Enhanced useLocalSearchResults hook to include URL in results. - Passed siteBasePath to relevant components for improved routing. --- .../gitbook/src/components/Header/Header.tsx | 1 + .../components/Search/LocalSearchResults.tsx | 82 +++++++++++++++++++ .../src/components/Search/SearchContainer.tsx | 21 +++-- .../Search/useLocalSearchResults.tsx | 14 +++- .../components/SpaceLayout/SpaceLayout.tsx | 1 + 5 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 packages/gitbook/src/components/Search/LocalSearchResults.tsx diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 0cd61a21ab..3aeb90ca0f 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -162,6 +162,7 @@ export function Header(props: { } siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} + siteBasePath={context.linker.siteBasePath} viewport={!withTopHeader ? 'mobile' : undefined} searchURL={context.linker.toPathInSpace('~gitbook/search')} /> diff --git a/packages/gitbook/src/components/Search/LocalSearchResults.tsx b/packages/gitbook/src/components/Search/LocalSearchResults.tsx new file mode 100644 index 0000000000..81f8843681 --- /dev/null +++ b/packages/gitbook/src/components/Search/LocalSearchResults.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import { Emoji } from '../primitives/Emoji/Emoji'; +import type { LocalPageResult } from './useLocalSearchResults'; + +/** + * Renders local search results above the main server results. + * + * Layout: + * - While server results are still fetching → 2-column wrapping row (cards fill width) + * - Once server results are ready → single horizontal scrollable row (fixed-width cards) + * + * The card width is CSS-transitioned so the switch between the two layouts animates smoothly. + */ +export function LocalSearchResults({ + results, + fetching, +}: { + results: LocalPageResult[]; + fetching: boolean; +}) { + if (results.length === 0) return null; + + return ( +
+ {results.map((result) => ( + + ))} +
+ ); +} + +function LocalSearchResultCard({ + result, + fetching, +}: { + result: LocalPageResult; + fetching: boolean; +}) { + return ( + +
+ {result.icon ? ( + + ) : null} +

+ {result.title} +

+
+ {result.description ? ( +

{result.description}

+ ) : null} +
+ ); +} diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index f0ab2a2d77..c26799f2b9 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -11,16 +11,17 @@ import { AIChatButton } from '../AIChat'; import { useTrackEvent } from '../Insights'; import { useIsMobile } from '../hooks/useIsMobile'; import { Popover, useBodyLoaded } from '../primitives'; +import { LocalSearchResults } from './LocalSearchResults'; import { SearchAskAnswer } from './SearchAskAnswer'; import { useSearchAskState } from './SearchAskContext'; import { SearchAskProvider } from './SearchAskContext'; import { SearchInput } from './SearchInput'; import { SearchResults, type SearchResultsRef } from './SearchResults'; import { SearchScopeControl } from './SearchScopeControl'; +import { useLocalSearchResults } from './useLocalSearchResults'; import { useSearchState, useSetSearchState } from './useSearch'; import { useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; -import { useLocalSearchResults } from './useLocalSearchResults'; interface SearchContainerProps { /** The current site space. */ @@ -47,6 +48,7 @@ interface SearchContainerProps { /** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */ searchURL: string; + siteBasePath: string; } /** @@ -63,6 +65,7 @@ export function SearchContainer({ viewport, siteSpaces, searchURL, + siteBasePath, }: SearchContainerProps) { const { assistants, config } = useAI(); @@ -215,13 +218,11 @@ export function SearchContainer({ searchURL, }); - const {results: localResults} = useLocalSearchResults({ + const { results: localResults } = useLocalSearchResults({ query: normalizedQuery, - siteBasePath: "http://localhost:3000/url/test-va-adaptive.gitbook-x-dev-nicolas.firebaseapp.com/test-va-adaptive-docs", - disabled: Boolean(state?.query) || withAI, // We only want to use local search when there is no query and no AI, as a fallback - }) - - console.log('Local search results', localResults); + siteBasePath: siteBasePath, + disabled: !(state?.query && withAI), // We only want to use local search when there is no query and no AI, as a fallback + }); const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; @@ -250,6 +251,12 @@ export function SearchContainer({ state?.query || withAI ? (
+ {localResults.length > 0 && !showAsk ? ( + + ) : null} {state !== null && !showAsk ? ( ({ document: { id: 'id', - index: ['title'], - store: ['id', 'title', 'icon', 'description'], + index: ['title', 'description'], + store: ['id', 'title', 'url', 'icon', 'description'], }, - tokenize: 'forward', + tokenize: 'tolerant', + encoder: "Normalize" + }); for (const page of data.pages) { index.add({ id: page.id, title: page.title, + url: page.url, icon: page.icon ?? null, description: page.description ?? null, }); @@ -106,7 +112,6 @@ export function useLocalSearchResults(props: { // Load the index once React.useEffect(() => { - if (cachedIndex) { setIndexReady(true); return; @@ -159,6 +164,7 @@ export function useLocalSearchResults(props: { type: 'local-page', id: doc.id, title: doc.title, + url: doc.url as string, icon: doc.icon ?? undefined, description: doc.description ?? undefined, }); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 3303e3db66..f342e68486 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -200,6 +200,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { section={visibleSections?.current} siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} + siteBasePath={context.linker.siteBasePath} viewport="desktop" searchURL={context.linker.toPathInSpace( '~gitbook/search' From 9ebe6c389ec47d089adf9247e66fb25f19389219 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 3 Mar 2026 21:10:04 +0100 Subject: [PATCH 04/22] Integrate local search results into useSearchResults and SearchContainer --- .../src/components/Search/SearchContainer.tsx | 9 ++------- .../src/components/Search/useSearchResults.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index c26799f2b9..2c8e73f2a8 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -18,7 +18,6 @@ import { SearchAskProvider } from './SearchAskContext'; import { SearchInput } from './SearchInput'; import { SearchResults, type SearchResultsRef } from './SearchResults'; import { SearchScopeControl } from './SearchScopeControl'; -import { useLocalSearchResults } from './useLocalSearchResults'; import { useSearchState, useSetSearchState } from './useSearch'; import { useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; @@ -207,7 +206,7 @@ export function SearchContainer({ [siteSpaces, siteSpace.space.language] ); - const { results, fetching, error } = useSearchResults({ + const { results, fetching, error, localResults } = useSearchResults({ disabled: !(state?.query || withAI), query: normalizedQuery, siteSpaceId: siteSpace.id, @@ -216,13 +215,9 @@ export function SearchContainer({ withAI, suggestions: config.suggestions, searchURL, + siteBasePath }); - const { results: localResults } = useLocalSearchResults({ - query: normalizedQuery, - siteBasePath: siteBasePath, - disabled: !(state?.query && withAI), // We only want to use local search when there is no query and no AI, as a fallback - }); const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index a9bca51916..d8751d7000 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -11,12 +11,15 @@ import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; import { isQuestion } from './isQuestion'; import type { SearchScope } from './useSearch'; +import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; export type ResultType = | OrderedComputedResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; +export type { LocalPageResult }; + /** * We cache the recommended questions globally to avoid calling the API multiple times * when re-opening the search modal. The cache is per space, so that we can @@ -35,11 +38,18 @@ export function useSearchResults(props: { suggestions?: string[]; /** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */ searchURL: string; + siteBasePath: string; }) { - const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props; + const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL, siteBasePath } = props; const trackEvent = useTrackEvent(); + const { results: localResults } = useLocalSearchResults({ + query, + siteBasePath, + disabled, + }); + const [resultsState, setResultsState] = React.useState<{ results: ResultType[]; fetching: boolean; @@ -214,7 +224,7 @@ export function useSearchResults(props: { searchURL, ]); - return resultsState; + return { ...resultsState, localResults }; } /** From 442d2ce8310e1d9d85a845a798e1b32a8851289d Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Tue, 3 Mar 2026 21:39:49 +0100 Subject: [PATCH 05/22] Fix URL formatting in getOrBuildIndex and update tokenization method; preload site index in SiteLayout --- .../src/components/Search/useLocalSearchResults.tsx | 4 ++-- packages/gitbook/src/components/SiteLayout/SiteLayout.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx index bece66a7e9..3cdf8d06e2 100644 --- a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -52,7 +52,7 @@ async function getOrBuildIndex(siteBasePath: string): Promise { - const url = `${siteBasePath}/~gitbook/index`; + const url = `${siteBasePath}~gitbook/index`; const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch search index: ${response.status}`); @@ -66,7 +66,7 @@ async function getOrBuildIndex(siteBasePath: string): Promise { ReactDOM.preload(script, { as: 'script', From b7e272fd2267024d1334e3e0dc46530b929e44aa Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 11:24:26 +0100 Subject: [PATCH 06/22] Refactor LocalSearchResults and useLocalSearchResults to use pathname instead of url; update SearchContainer and useSearchResults for consistency --- .../components/Search/LocalSearchResults.tsx | 20 +++++++++++++------ .../src/components/Search/SearchContainer.tsx | 3 +-- .../Search/useLocalSearchResults.tsx | 15 +++++++------- .../src/components/Search/useSearchResults.ts | 13 ++++++++++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/gitbook/src/components/Search/LocalSearchResults.tsx b/packages/gitbook/src/components/Search/LocalSearchResults.tsx index 81f8843681..44a8fccc4c 100644 --- a/packages/gitbook/src/components/Search/LocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/LocalSearchResults.tsx @@ -1,6 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; import { Emoji } from '../primitives/Emoji/Emoji'; import type { LocalPageResult } from './useLocalSearchResults'; @@ -50,32 +51,39 @@ function LocalSearchResultCard({ }) { return (
{result.icon ? ( - + + + ) : null} -

+

{result.title}

+ + +
{result.description ? ( -

{result.description}

+

{result.description}

) : null}
); diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 2c8e73f2a8..509699b1d1 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -215,10 +215,9 @@ export function SearchContainer({ withAI, suggestions: config.suggestions, searchURL, - siteBasePath + siteBasePath, }); - const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx index 3cdf8d06e2..c8b53502b1 100644 --- a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -7,7 +7,7 @@ import React from 'react'; interface RawIndexPage { id: string; title: string; - url: string; + pathname: string; icon?: string; description?: string; } @@ -17,7 +17,7 @@ interface IndexPage { [key: string]: DocumentValue | DocumentValue[]; id: string; title: string; - url: string; + pathname: string; icon: string | null; description: string | null; } @@ -27,7 +27,7 @@ export interface LocalPageResult { type: 'local-page'; id: string; title: string; - url: string; + pathname: string; icon?: string; description?: string; } @@ -64,18 +64,17 @@ async function getOrBuildIndex(siteBasePath: string): Promise Date: Wed, 4 Mar 2026 11:59:57 +0100 Subject: [PATCH 07/22] implement the route in GBO directly --- .../[siteData]/~gitbook/index/route.ts | 67 ++++++++++++++----- .../gitbook/src/components/Header/Header.tsx | 2 +- .../src/components/Search/SearchContainer.tsx | 7 +- .../Search/useLocalSearchResults.tsx | 20 +++--- .../src/components/Search/useSearchResults.ts | 17 ++--- .../components/SpaceLayout/SpaceLayout.tsx | 4 +- 6 files changed, 76 insertions(+), 41 deletions(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index 5359110b0c..4f8247ebe4 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -1,31 +1,68 @@ import type { NextRequest } from 'next/server'; import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils'; +import { throwIfDataError } from '@/lib/data'; +import { getIndexablePages } from '@/lib/sitemap'; +import { listAllSiteSpaces } from '@/lib/sites'; + +interface RawIndexPage { + id: string; + title: string; + pathname: string; + icon?: string; + emoji?: string; + description?: string; +} export const revalidate = 86400; // 1 day +export const dynamic = 'force-dynamic'; export async function GET( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise } ) { - const { context, token, visitorAuthClaims } = await getStaticSiteContext(await params); - const { organizationId, site } = context; - console.log('Fetching static index for site', site.id, token); - // TODO: Obviously switch to real API client - const result = await fetch(`http://localhost:6100/v1/orgs/${organizationId}/sites/${site.id}/index`, { - headers: { - Authorization: `Bearer ${token}`, + const { context } = await getStaticSiteContext(await params); + const { dataFetcher, linker, structure } = context; + + const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden); + + const revisions = await Promise.all( + visibleSpaces.map((ss) => + throwIfDataError( + dataFetcher.getRevision({ + spaceId: ss.space.id, + revisionId: ss.space.revision, + }) + ) + ) + ); + + const seen = new Set(); + const pages: RawIndexPage[] = []; + + for (let i = 0; i < visibleSpaces.length; i++) { + const siteSpace = visibleSpaces[i]!; + const revision = revisions[i]!; + const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: siteSpace.path }); + + for (const { page } of getIndexablePages(revision.pages)) { + if (seen.has(page.id)) continue; + seen.add(page.id); + + pages.push({ + id: page.id, + title: page.title, + pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }), + icon: page.icon ?? undefined, + emoji: page.emoji ?? undefined, + description: page.description ?? undefined, + }); } - }); - if (!result.ok) { - return new Response('Error fetching index', { status: 500 }); } - const index = await result.json(); - console.log('Fetched index for site', site.id, JSON.stringify(index).length, 'bytes', visitorAuthClaims, result.headers); - return new Response(JSON.stringify(index), { + + return new Response(JSON.stringify({ pages }), { headers: { 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=3600', }, }); } diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 3aeb90ca0f..8f0806cc50 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -162,7 +162,7 @@ export function Header(props: { } siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} - siteBasePath={context.linker.siteBasePath} + indexURL={context.linker.toPathInSite('~gitbook/index')} viewport={!withTopHeader ? 'mobile' : undefined} searchURL={context.linker.toPathInSpace('~gitbook/search')} /> diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 509699b1d1..cf0031433c 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -47,7 +47,8 @@ interface SearchContainerProps { /** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */ searchURL: string; - siteBasePath: string; + /** URL for the local index JSON, e.g. from linker.toPathInSite('~gitbook/index'). */ + indexURL: string; } /** @@ -64,7 +65,7 @@ export function SearchContainer({ viewport, siteSpaces, searchURL, - siteBasePath, + indexURL, }: SearchContainerProps) { const { assistants, config } = useAI(); @@ -215,7 +216,7 @@ export function SearchContainer({ withAI, suggestions: config.suggestions, searchURL, - siteBasePath, + indexURL, }); const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx index c8b53502b1..8993d1fe1c 100644 --- a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -9,6 +9,7 @@ interface RawIndexPage { title: string; pathname: string; icon?: string; + emoji?: string; description?: string; } @@ -19,6 +20,7 @@ interface IndexPage { title: string; pathname: string; icon: string | null; + emoji: string | null; description: string | null; } @@ -42,7 +44,7 @@ type LocalSearchState = { let cachedIndex: Document | null = null; let pendingFetch: Promise> | null = null; -async function getOrBuildIndex(siteBasePath: string): Promise> { +async function getOrBuildIndex(indexURL: string): Promise> { if (cachedIndex) { return cachedIndex; } @@ -52,8 +54,7 @@ async function getOrBuildIndex(siteBasePath: string): Promise { - const url = `${siteBasePath}~gitbook/index`; - const response = await fetch(url); + const response = await fetch(indexURL); if (!response.ok) { throw new Error(`Failed to fetch search index: ${response.status}`); } @@ -74,8 +75,9 @@ async function getOrBuildIndex(siteBasePath: string): Promise { - console.error('Error fetching/building search index for siteBasePath', siteBasePath); + console.error('Error fetching/building search index', indexURL); pendingFetch = null; }); @@ -95,10 +97,10 @@ async function getOrBuildIndex(siteBasePath: string): Promise({ results: [], @@ -119,7 +121,7 @@ export function useLocalSearchResults(props: { let cancelled = false; setState((prev) => ({ ...prev, fetching: true, error: false })); - getOrBuildIndex(siteBasePath) + getOrBuildIndex(indexURL) .then(() => { if (!cancelled) { setIndexReady(true); @@ -135,7 +137,7 @@ export function useLocalSearchResults(props: { return () => { cancelled = true; }; - }, [siteBasePath]); + }, [indexURL]); // Perform instant local search whenever query or index readiness changes React.useEffect(() => { diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 09755f96df..cc4f79bde8 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -38,24 +38,17 @@ export function useSearchResults(props: { suggestions?: string[]; /** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */ searchURL: string; - siteBasePath: string; + /** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */ + indexURL: string; }) { - const { - disabled, - query, - siteSpaceId, - siteSpaceIds, - scope, - suggestions, - searchURL, - siteBasePath, - } = props; + const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL, indexURL } = + props; const trackEvent = useTrackEvent(); const { results: localResults } = useLocalSearchResults({ query, - siteBasePath, + indexURL, disabled, }); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index f342e68486..72dfbc42ef 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -200,7 +200,9 @@ export function SpaceLayout(props: SpaceLayoutProps) { section={visibleSections?.current} siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} - siteBasePath={context.linker.siteBasePath} + indexURL={context.linker.toPathInSite( + '~gitbook/index' + )} viewport="desktop" searchURL={context.linker.toPathInSpace( '~gitbook/search' From c2d3d864623cef8c02753b1a76e2f9392e511d7b Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 12:09:16 +0100 Subject: [PATCH 08/22] Replace anchor tag with Link component in LocalSearchResultCard for improved navigation --- .../gitbook/src/components/Search/LocalSearchResults.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/Search/LocalSearchResults.tsx b/packages/gitbook/src/components/Search/LocalSearchResults.tsx index 44a8fccc4c..6b3b4ad2b8 100644 --- a/packages/gitbook/src/components/Search/LocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/LocalSearchResults.tsx @@ -3,6 +3,7 @@ import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import { Emoji } from '../primitives/Emoji/Emoji'; +import { Link } from '../primitives/Link'; import type { LocalPageResult } from './useLocalSearchResults'; /** @@ -50,7 +51,7 @@ function LocalSearchResultCard({ fetching: boolean; }) { return ( - {result.description}

) : null} -
+ ); } From 04e9dad217c34caa8307127ddda2e380cc89db6f Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 12:34:43 +0100 Subject: [PATCH 09/22] add a limit to the number of page in the index --- .../[siteData]/~gitbook/index/route.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index 4f8247ebe4..069a5af10c 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -17,6 +17,12 @@ interface RawIndexPage { export const revalidate = 86400; // 1 day export const dynamic = 'force-dynamic'; +/** + * Maximum number of pages to include in the index for non-default spaces. + * Pages from the default space are always included regardless of this limit. + */ +const ADDITIONAL_PAGES_LIMIT = 1000; + export async function GET( _request: NextRequest, { params }: { params: Promise } @@ -39,16 +45,27 @@ export async function GET( const seen = new Set(); const pages: RawIndexPage[] = []; + let additionalPagesCount = 0; + + const defaultSiteSpace = visibleSpaces.find((ss) => ss.default); for (let i = 0; i < visibleSpaces.length; i++) { const siteSpace = visibleSpaces[i]!; const revision = revisions[i]!; const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: siteSpace.path }); + const isDefaultSpace = siteSpace.id === defaultSiteSpace?.id; for (const { page } of getIndexablePages(revision.pages)) { if (seen.has(page.id)) continue; seen.add(page.id); + if (!isDefaultSpace) { + if (additionalPagesCount >= ADDITIONAL_PAGES_LIMIT) { + break; + } + additionalPagesCount++; + } + pages.push({ id: page.id, title: page.title, From b3263435672644461da34feb6380468970c630a5 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 13:35:06 +0100 Subject: [PATCH 10/22] enable caching --- .../[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index 069a5af10c..cec9a63772 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -15,7 +15,7 @@ interface RawIndexPage { } export const revalidate = 86400; // 1 day -export const dynamic = 'force-dynamic'; +export const dynamic = 'force-static'; /** * Maximum number of pages to include in the index for non-default spaces. @@ -80,6 +80,8 @@ export async function GET( return new Response(JSON.stringify({ pages }), { headers: { 'Content-Type': 'application/json', + // Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day + 'Cache-Control': 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400', }, }); } From bf26ba7d5c54ef4a774ef0ae53a414d5a10f4479 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 13:37:56 +0100 Subject: [PATCH 11/22] Revert "add a limit to the number of page in the index" This reverts commit 892632a81519d7cf53bfc46823bda4f3a403a3c8. --- .../[siteData]/~gitbook/index/route.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index cec9a63772..3bbb7533be 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -17,12 +17,6 @@ interface RawIndexPage { export const revalidate = 86400; // 1 day export const dynamic = 'force-static'; -/** - * Maximum number of pages to include in the index for non-default spaces. - * Pages from the default space are always included regardless of this limit. - */ -const ADDITIONAL_PAGES_LIMIT = 1000; - export async function GET( _request: NextRequest, { params }: { params: Promise } @@ -45,27 +39,16 @@ export async function GET( const seen = new Set(); const pages: RawIndexPage[] = []; - let additionalPagesCount = 0; - - const defaultSiteSpace = visibleSpaces.find((ss) => ss.default); for (let i = 0; i < visibleSpaces.length; i++) { const siteSpace = visibleSpaces[i]!; const revision = revisions[i]!; const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: siteSpace.path }); - const isDefaultSpace = siteSpace.id === defaultSiteSpace?.id; for (const { page } of getIndexablePages(revision.pages)) { if (seen.has(page.id)) continue; seen.add(page.id); - if (!isDefaultSpace) { - if (additionalPagesCount >= ADDITIONAL_PAGES_LIMIT) { - break; - } - additionalPagesCount++; - } - pages.push({ id: page.id, title: page.title, From c78fb2fa17fd20f4589f170b86094007cf4dab99 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 14:03:01 +0100 Subject: [PATCH 12/22] Add emoji support to LocalPageResult and update LocalSearchResultCard --- .../src/components/Search/LocalSearchResults.tsx | 10 +++++++--- .../src/components/Search/useLocalSearchResults.tsx | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/gitbook/src/components/Search/LocalSearchResults.tsx b/packages/gitbook/src/components/Search/LocalSearchResults.tsx index 6b3b4ad2b8..84bdd432c8 100644 --- a/packages/gitbook/src/components/Search/LocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/LocalSearchResults.tsx @@ -1,7 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; -import { Icon } from '@gitbook/icons'; +import { Icon, type IconName } from '@gitbook/icons'; import { Emoji } from '../primitives/Emoji/Emoji'; import { Link } from '../primitives/Link'; import type { LocalPageResult } from './useLocalSearchResults'; @@ -71,9 +71,13 @@ function LocalSearchResultCard({ )} >
- {result.icon ? ( + {result.emoji ? ( - + + + ) : result.icon ? ( + + ) : null}

diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx index 8993d1fe1c..34e014b8af 100644 --- a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -31,6 +31,7 @@ export interface LocalPageResult { title: string; pathname: string; icon?: string; + emoji?: string; description?: string; } @@ -167,6 +168,7 @@ export function useLocalSearchResults(props: { title: doc.title, pathname: doc.pathname as string, icon: doc.icon ?? undefined, + emoji: doc.emoji ?? undefined, description: doc.description ?? undefined, }); } From b4d07a752ba93bc9d7b1f31c9f1daa13da91452f Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 14:17:02 +0100 Subject: [PATCH 13/22] Add language support to search components and indexing logic --- .../[siteData]/~gitbook/index/route.ts | 2 + .../src/components/Search/SearchContainer.tsx | 1 + .../Search/useLocalSearchResults.tsx | 103 ++++++++++++------ .../src/components/Search/useSearchResults.ts | 16 ++- 4 files changed, 84 insertions(+), 38 deletions(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index 3bbb7533be..e12a81d6a0 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -9,6 +9,7 @@ interface RawIndexPage { id: string; title: string; pathname: string; + lang?: string; icon?: string; emoji?: string; description?: string; @@ -53,6 +54,7 @@ export async function GET( id: page.id, title: page.title, pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }), + lang: siteSpace.space.language ?? undefined, icon: page.icon ?? undefined, emoji: page.emoji ?? undefined, description: page.description ?? undefined, diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index cf0031433c..99db486b81 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -217,6 +217,7 @@ export function SearchContainer({ suggestions: config.suggestions, searchURL, indexURL, + lang: siteSpace.space.language, }); const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; diff --git a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx index 34e014b8af..8b1ea1730b 100644 --- a/packages/gitbook/src/components/Search/useLocalSearchResults.tsx +++ b/packages/gitbook/src/components/Search/useLocalSearchResults.tsx @@ -8,6 +8,8 @@ interface RawIndexPage { id: string; title: string; pathname: string; + /** BCP-47 language code emitted by the index route, absent when no language is set. */ + lang?: string; icon?: string; emoji?: string; description?: string; @@ -41,13 +43,39 @@ type LocalSearchState = { error: boolean; }; -// Module-level singletons — one site per session -let cachedIndex: Document | null = null; -let pendingFetch: Promise> | null = null; +// Module-level singletons — one Document per language per session. +// Keys are the page's `lang` value, or `''` when no language is set. +const cachedIndexes = new Map>(); +let pendingFetch: Promise>> | null = null; + +function buildLangIndex(pages: RawIndexPage[]): Document { + const index = new Document({ + document: { + id: 'id', + index: ['title', 'description'], + store: ['id', 'title', 'pathname', 'icon', 'description'], + }, + tokenize: 'bidirectional', + encoder: 'Normalize', + }); -async function getOrBuildIndex(indexURL: string): Promise> { - if (cachedIndex) { - return cachedIndex; + for (const page of pages) { + index.add({ + id: page.id, + title: page.title, + pathname: page.pathname, + icon: page.icon ?? null, + emoji: page.emoji ?? null, + description: page.description ?? null, + }); + } + + return index; +} + +async function getOrBuildIndexes(indexURL: string): Promise>> { + if (cachedIndexes.size > 0) { + return cachedIndexes; } if (pendingFetch) { @@ -62,29 +90,24 @@ async function getOrBuildIndex(indexURL: string): Promise> { const data: { pages: RawIndexPage[] } = await response.json(); - const index = new Document({ - document: { - id: 'id', - index: ['title', 'description'], - store: ['id', 'title', 'pathname', 'icon', 'description'], - }, - tokenize: 'bidirectional', - encoder: 'Normalize', - }); - + // Group pages by their `lang` value (empty string for pages without one) + const pagesByLang = new Map(); for (const page of data.pages) { - index.add({ - id: page.id, - title: page.title, - pathname: page.pathname, - icon: page.icon ?? null, - emoji: page.emoji ?? null, - description: page.description ?? null, - }); + const key = page.lang ?? ''; + const bucket = pagesByLang.get(key); + if (bucket) { + bucket.push(page); + } else { + pagesByLang.set(key, [page]); + } } - cachedIndex = index; - return index; + // Build one FlexSearch Document per language group + for (const [lang, pages] of pagesByLang) { + cachedIndexes.set(lang, buildLangIndex(pages)); + } + + return cachedIndexes; })(); // Clear pendingFetch on error so a retry is possible @@ -99,9 +122,12 @@ async function getOrBuildIndex(indexURL: string): Promise> { export function useLocalSearchResults(props: { query: string; indexURL: string; + /** BCP-47 language code of the current site space. When provided, only results + * from pages with a matching language are returned. */ + lang?: string; disabled?: boolean; }): LocalSearchState { - const { query, indexURL, disabled = false } = props; + const { query, indexURL, lang, disabled = false } = props; const [state, setState] = React.useState({ results: [], @@ -109,12 +135,12 @@ export function useLocalSearchResults(props: { error: false, }); - // Track whether the index is loaded so the search effect re-runs after load - const [indexReady, setIndexReady] = React.useState(!!cachedIndex); + // Track whether the indexes are loaded so the search effect re-runs after load + const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0); - // Load the index once + // Load the indexes once React.useEffect(() => { - if (cachedIndex) { + if (cachedIndexes.size > 0) { setIndexReady(true); return; } @@ -122,7 +148,7 @@ export function useLocalSearchResults(props: { let cancelled = false; setState((prev) => ({ ...prev, fetching: true, error: false })); - getOrBuildIndex(indexURL) + getOrBuildIndexes(indexURL) .then(() => { if (!cancelled) { setIndexReady(true); @@ -140,9 +166,14 @@ export function useLocalSearchResults(props: { }; }, [indexURL]); - // Perform instant local search whenever query or index readiness changes + // Perform instant local search whenever query, lang, or index readiness changes React.useEffect(() => { - if (disabled || !indexReady || !cachedIndex) { + // Resolve the per-language index to query. When `lang` is not set we fall + // back to the `''` bucket (pages with no language tag). + const langKey = lang ?? ''; + const index = cachedIndexes.get(langKey); + + if (disabled || !indexReady || !index) { return; } @@ -151,7 +182,7 @@ export function useLocalSearchResults(props: { return; } - const rawResults = cachedIndex.search(query, { enrich: true, limit: 10 }); + const rawResults = index.search(query, { enrich: true, limit: 10 }); // Flatten and deduplicate results across fields (flexsearch returns one array per indexed field) const seen = new Set(); @@ -176,7 +207,7 @@ export function useLocalSearchResults(props: { } setState({ results, fetching: false, error: false }); - }, [query, indexReady, disabled]); + }, [query, lang, indexReady, disabled]); return state; } diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index cc4f79bde8..c8e275dda1 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -40,15 +40,27 @@ export function useSearchResults(props: { searchURL: string; /** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */ indexURL: string; + /** BCP-47 language code of the current site space, used to filter local search results. */ + lang?: string; }) { - const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL, indexURL } = - props; + const { + disabled, + query, + siteSpaceId, + siteSpaceIds, + scope, + suggestions, + searchURL, + indexURL, + lang, + } = props; const trackEvent = useTrackEvent(); const { results: localResults } = useLocalSearchResults({ query, indexURL, + lang, disabled, }); From 6a2d417232a400f7b3e06e0b3f119d833f8996da Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 14:30:27 +0100 Subject: [PATCH 14/22] bump vercel version --- bun.lock | 47 ++++++++++++++++++----------------- package.json | 9 ++++--- packages/gitbook/package.json | 2 +- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index d80805196a..7e16c25905 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,9 @@ "name": "gitbook", "devDependencies": { "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.30.0", - "turbo": "^2.8.16", - "vercel": "^50.31.1", + "@changesets/cli": "^2.29.8", + "turbo": "^2.8.10", + "vercel": "catalog:", }, }, "packages/browser-types": { @@ -219,7 +219,7 @@ "tailwindcss": "^4.1.11", "ts-essentials": "^10.0.1", "typescript": "catalog:", - "vercel": "^50.15.1", + "vercel": "catalog:", "wrangler": "^4.43.0", }, }, @@ -365,6 +365,7 @@ "tsdown": "^0.15.6", "typescript": "^5.5.3", "usehooks-ts": "^3.1.1", + "vercel": "^50.26.1", }, "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="], @@ -1703,61 +1704,61 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="], - "@vercel/backends": ["@vercel/backends@0.0.45", "", { "dependencies": { "@vercel/build-utils": "13.8.0", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-KIdt/z4LfH7NgFMqgSuKi0H9UIasly7ByzP+/ZXulgNrWyeJKT9KCas3SDT65o5tU6x1D/jBysZA9AnOt8Ivew=="], + "@vercel/backends": ["@vercel/backends@0.0.40", "", { "dependencies": { "@vercel/build-utils": "13.6.2", "@vercel/nft": "1.3.0", "execa": "3.2.0", "fs-extra": "11.1.0", "oxc-transform": "0.111.0", "path-to-regexp": "8.3.0", "resolve.exports": "2.0.3", "rolldown": "1.0.0-rc.1", "srvx": "0.8.9", "tsx": "4.21.0", "zod": "3.22.4" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" } }, "sha512-cYyfPeGrpqyxBgcgKoaZJ+9M7NRKf1eevzKkrX9QRmiBBlGduR34hV859uK4ouBJByHE7n9Tkga2dRLOH8pMcQ=="], "@vercel/blob": ["@vercel/blob@2.3.0", "", { "dependencies": { "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", "throttleit": "^2.1.0", "undici": "^6.23.0" } }, "sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q=="], - "@vercel/build-utils": ["@vercel/build-utils@13.8.0", "", { "dependencies": { "@vercel/python-analysis": "0.9.1" } }, "sha512-moQS4Qd0pvluPd6WRTHxLN3Hh0oSObVNdFv3V0spiEmCk/wm6571up3n1th2PQFqf1a3gheNfxzL7h4I9CWs2A=="], + "@vercel/build-utils": ["@vercel/build-utils@13.6.2", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-KiwRUd2x1ZHm73p+/hfdVWZHWXT8r+oN/5ZN1lJyb41AGK4Zhrug6X60jNyVhnw6GjpwBuPTBcys29cCsPjkWQ=="], - "@vercel/cervel": ["@vercel/cervel@0.0.32", "", { "dependencies": { "@vercel/backends": "0.0.45" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-g/LIa97d/m3yIZGyBjwl4FOQK1Lg5HU2+N3uHiGVajLoSgJM2aBiwETDZc8bVCr8IO3IqXZQsT2hGy1Jnyko1g=="], + "@vercel/cervel": ["@vercel/cervel@0.0.27", "", { "dependencies": { "@vercel/backends": "0.0.40" }, "peerDependencies": { "typescript": "^4.0.0 || ^5.0.0" }, "bin": { "cervel": "bin/cervel.mjs" } }, "sha512-w6FPgnnZD2nr43pkhXSY0tGMwCJT6kh8f8VoEL97ISSkxywZYX+Bw5FxrNavAEMC0wYm7esEosOdAEAh8Twbag=="], "@vercel/detect-agent": ["@vercel/detect-agent@1.2.0", "", {}, "sha512-kY0+TPdYr170bVmVSCg0XvwakWQMENy79uf/sM86DF/HVabaagdTjPwSPujlZid6Bgr66R4kHhjg4bkOxEzFDA=="], - "@vercel/elysia": ["@vercel/elysia@0.1.48", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-QlmOHUSOx/uE67Y6u/o8VbNF7ebZ8SZFbrEoBo7iZAd0MC2Vn4xUmlrHFXNR/FEkHFXWkhFKda5Ade3XMqMY8A=="], + "@vercel/elysia": ["@vercel/elysia@0.1.43", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-Q5XCGVMuO0XF2n/XEEP43d+AIk8Yl09NwJmJ0m9w5Mx0Pyacb1Yo6dcbn6vnsj4j50dpYGeejdaqu77xOKi8mQ=="], "@vercel/error-utils": ["@vercel/error-utils@2.0.3", "", {}, "sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ=="], - "@vercel/express": ["@vercel/express@0.1.57", "", { "dependencies": { "@vercel/cervel": "0.0.32", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-/Ih1eiJrBGSW6JPzexB8y8AMX/8MzuDUf0xzmpYbKLCbehp3NNuxde5RxT+6cALglFivDETsJBCFwaFuIh3ZqQ=="], + "@vercel/express": ["@vercel/express@0.1.52", "", { "dependencies": { "@vercel/cervel": "0.0.27", "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-lvZyw/7IOVMx6YqniijlMrU0ys+bcbqkY+5slGQMoXrtW7Mo2RhF9f27y+1Bl1ELv8JzyrXhOLHu2f5gn4CsgQ=="], - "@vercel/fastify": ["@vercel/fastify@0.1.51", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-c9CwFQqmoUm5eEGwAcqb98DcxsRmSZGngCAo1B+nlDhaACKcrJ7Ro0tYdTDhi8Lkt+OcPnRWr4+pepgWwH94GQ=="], + "@vercel/fastify": ["@vercel/fastify@0.1.46", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-R8uSV3SSbFJ39o3VAfsKwlWOPGDfQyNXxJ2dT8Z05GQ4O3qgGTcRd/oEothQZgnJR9fjGzDQ3q7SwWBd12150A=="], "@vercel/fun": ["@vercel/fun@1.3.0", "", { "dependencies": { "@tootallnate/once": "2.0.0", "async-listen": "1.2.0", "debug": "4.3.4", "generic-pool": "3.4.2", "micro": "9.3.5-canary.3", "ms": "2.1.1", "node-fetch": "2.6.7", "path-to-regexp": "8.2.0", "promisepipe": "3.0.0", "semver": "7.5.4", "stat-mode": "0.3.0", "stream-to-promise": "2.2.0", "tar": "7.5.7", "tinyexec": "0.3.2", "tree-kill": "1.2.2", "uid-promise": "1.0.0", "xdg-app-paths": "5.1.0", "yauzl-promise": "2.1.3" } }, "sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg=="], "@vercel/gatsby-plugin-vercel-analytics": ["@vercel/gatsby-plugin-vercel-analytics@1.0.11", "", { "dependencies": { "web-vitals": "0.2.4" } }, "sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw=="], - "@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.1.0", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.8.0", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-avJ5IFev2h2K6E/Pd7qd00cFLALj3OyEmQE3UoGs1dmoncINFqa1RoIZDJ9wIhWm1Euan4wrFMRsXkCwNhEGhw=="], + "@vercel/gatsby-plugin-vercel-builder": ["@vercel/gatsby-plugin-vercel-builder@2.0.142", "", { "dependencies": { "@sinclair/typebox": "0.25.24", "@vercel/build-utils": "13.6.2", "esbuild": "0.27.0", "etag": "1.8.1", "fs-extra": "11.1.0" } }, "sha512-F6Mo5fROP57wX/vAngXHA+xBZVwi98vU5YT1J+n/PYxIwbOVCa6SyPOI98o6a+uLQnchqbWbcXcV2CaYmU85VA=="], - "@vercel/go": ["@vercel/go@3.4.4", "", {}, "sha512-jfIx/gUe2YAp46/H4uqdA+PLQQuRwb5lwU37m/e5wrnMCUZ2ksuhg50xPHcBBweboYeE6WuT4e1W6Cwp3tBPzg=="], + "@vercel/go": ["@vercel/go@3.4.3", "", {}, "sha512-LDT4wpx7SW2UJHd3rOL4+2m2V4w7a5zjyuU0pu0yDBiuxp9bEh7QuAH5qZC7pINGLC3vGftiFVzynd5N454sVA=="], - "@vercel/h3": ["@vercel/h3@0.1.57", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-I7Q1ity7xEdIVw4OQuKOAR8i6DrvfjpKy+y2uTwbqBD80Gg0KpsMS/KKA+UjvCuclFmP/EHYS3kpMLc/O1rVqg=="], + "@vercel/h3": ["@vercel/h3@0.1.52", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-bk8id7w9evxD+JeOUJt6qfDohGwJFBVh7N1qh1OG/nngzzb/Ojg+poH5EGpYwYiKymPK7NMgQl0fb0ZjhIU4bg=="], - "@vercel/hono": ["@vercel/hono@0.2.51", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-iYEjjF4qR3gTZpVoB4sMQNm5OOdKw5/P2bL1CtolDTeGaea9KwqUrLyXmdK4N41HIRZr/wduID31mmHhSdC3sA=="], + "@vercel/hono": ["@vercel/hono@0.2.46", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2", "fs-extra": "11.1.0", "path-to-regexp": "8.3.0", "ts-morph": "12.0.0", "zod": "3.22.4" } }, "sha512-rg+6PnvIV20lUM3r2fcjJ/1g1z3ND6h3EqlOQytwCuwpP/jB88TUy+guutB4rDpylPvHzleZqjgi2rE9yH4URg=="], "@vercel/hydrogen": ["@vercel/hydrogen@1.3.6", "", { "dependencies": { "@vercel/static-config": "3.2.0", "ts-morph": "12.0.0" } }, "sha512-Ec8dKEjGIM4BfThcRLtQs5zaJ4+iJbgLZwkytwi7Blk8VrK6W2F1dtLDmVQYZdVnQcnmHmTx8mxUuMkfP06Mnw=="], - "@vercel/koa": ["@vercel/koa@0.1.31", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-Gj4sjqNA80/gnHpC0tRPCTtVEct69tUK21fsjVmawCa+nDNQ4bqXu3dSewna7GtCcp9KnrWgzAIaeVAQUl53mQ=="], + "@vercel/koa": ["@vercel/koa@0.1.26", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-TzH4xovhGbr2kmZyvMraaJ3A28qRdo8SxkBO+LIb3ahM739VyTAkH3EYR5CPaemkRv00+nTyBxJJxfnhP1n2MA=="], - "@vercel/nestjs": ["@vercel/nestjs@0.2.52", "", { "dependencies": { "@vercel/node": "5.6.15", "@vercel/static-config": "3.2.0" } }, "sha512-yfy4rpWJ1BRWZ1xBBPew0egVIblFe6G7laCKDzyESnbw19zY0F16g9HCu1H/0IfuKIQvOc95dtbmBsqLm9jyqw=="], + "@vercel/nestjs": ["@vercel/nestjs@0.2.47", "", { "dependencies": { "@vercel/node": "5.6.10", "@vercel/static-config": "3.1.2" } }, "sha512-/yIbLc5rv8GQ+lsJ45JkObcNiMe5yUgXx2d8wryW1SajPV1/iP+wFRK28/Q+6zdvapcgqhFRwInZh+61um/ZWg=="], - "@vercel/next": ["@vercel/next@4.16.1", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-gwy3XQRZ/f6RdKuC7BZIRMAzUOQf/R5+k9LMf1LcOm1CVZOLpDhDkeZMTG5vb3Lk9LWwBrJJ2ohhZaYuYEOHaw=="], + "@vercel/next": ["@vercel/next@4.15.39", "", { "dependencies": { "@vercel/nft": "1.1.1" } }, "sha512-E3Wnn5Wfxa7e2RvguE4YNO8OBoOVZhnRXDRfn5jJ9ct+GVRZDgPS9VdXK3eQazMJUCLmQCKdyTca/I57ImPNpQ=="], "@vercel/nft": ["@vercel/nft@1.3.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-i4EYGkCsIjzu4vorDUbqglZc5eFtQI2syHb++9ZUDm6TU4edVywGpVnYDein35x9sevONOn9/UabfQXuNXtuzQ=="], - "@vercel/node": ["@vercel/node@5.6.15", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.8.0", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-xc5fxmdk8jtuUY8y9/8W5UhTn8R1Ii1Fb3q+V8Zv+2moU9enrrBADA9ercHgE0/DtoiNDpb9Wmvnrb4bUcFOzA=="], + "@vercel/node": ["@vercel/node@5.6.10", "", { "dependencies": { "@edge-runtime/node-utils": "2.3.0", "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", "@vercel/build-utils": "13.6.2", "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "async-listen": "3.0.0", "cjs-module-lexer": "1.2.3", "edge-runtime": "2.5.9", "es-module-lexer": "1.4.1", "esbuild": "0.27.0", "etag": "1.8.1", "mime-types": "2.1.35", "node-fetch": "2.6.9", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0", "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" } }, "sha512-HEydd6y0KPjCyI9sM3n2/XCjS/fZV/7sO7jFjia9I5XbEJ6LCwkI6rR+GQB751NL0He0vFusy+mm394hJC/Rqg=="], - "@vercel/python": ["@vercel/python@6.22.0", "", { "dependencies": { "@vercel/python-analysis": "0.9.1" } }, "sha512-wyHIWKXrDp+VkiWTIkz/++m037lwIy1PUNLRooYFUUBylBWpF/oYy42Kj8vyAKtsXO+14SBtTCekLE+9+2wmhA=="], + "@vercel/python": ["@vercel/python@6.20.1", "", { "dependencies": { "@vercel/python-analysis": "0.8.1" } }, "sha512-a/VgUJ/N7SfKgE2fen6iaNow/nWEGEQj57SNyPwo4emvPfO1dPxUlZWyAEdctfXYKjnJR2WAU2kYcCDw+zenZg=="], - "@vercel/python-analysis": ["@vercel/python-analysis@0.9.1", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.3", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-ZwEi/F2DPxFPYmfjHFy7qM3+JTWRxD1EMpbIotNNhUyd/pnIG0wNt7S73RJSx62n1Y7pmFOFowoImnhULQgKvA=="], + "@vercel/python-analysis": ["@vercel/python-analysis@0.8.1", "", { "dependencies": { "@bytecodealliance/preview2-shim": "0.17.6", "@renovatebot/pep440": "4.2.1", "fs-extra": "11.1.1", "js-yaml": "4.1.1", "minimatch": "10.1.1", "pip-requirements-js": "1.0.2", "smol-toml": "1.5.2", "zod": "3.22.4" } }, "sha512-gW1pZDqJaTcjZYPvNhXXLOPgLu6vJW9PKweJoX2f8EKAoW+JIiYncl8AddcSlngNhQRG7SqUl2u3qosZM4kUBA=="], "@vercel/redwood": ["@vercel/redwood@2.4.10", "", { "dependencies": { "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "semver": "6.3.1", "ts-morph": "12.0.0" } }, "sha512-7C5lUn9g9kLm1KpX55b8iizVPOB6087+kVyQyKyXGk8bbkYySL26yb+LIwyL/7mXwHlq/JTC0AxVdC3nNmPZuw=="], - "@vercel/remix-builder": ["@vercel/remix-builder@5.7.0", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.2.0", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-R44EHl+PQjX5PrCmyGust+bk+65eT5omxOLhEIJkSI90Kx/vvuyKnFNic/Zwo//GCMjxt9httvQUr/6vIBbjHg=="], + "@vercel/remix-builder": ["@vercel/remix-builder@5.6.0", "", { "dependencies": { "@vercel/error-utils": "2.0.3", "@vercel/nft": "1.1.1", "@vercel/static-config": "3.1.2", "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0", "ts-morph": "12.0.0" } }, "sha512-neTpO4aGksYcPJjTbAEUhWmsOdFqgx02H47RUsXBKHdMTW4ZKrL9oAKT3pD+Bv9kUgj7uRzqAr8JeiPILPWybg=="], "@vercel/ruby": ["@vercel/ruby@2.3.2", "", {}, "sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ=="], "@vercel/rust": ["@vercel/rust@1.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "execa": "5" } }, "sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ=="], - "@vercel/static-build": ["@vercel/static-build@2.9.0", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.1.0", "@vercel/static-config": "3.2.0", "ts-morph": "12.0.0" } }, "sha512-3SHWntz8swxL6ve750dY8kyl4NwVUplYtun/ei7y11q2UnI70WnWmm6L9fi02Wy1o3RGhbvOnlFLcHOUBm3DXQ=="], + "@vercel/static-build": ["@vercel/static-build@2.8.44", "", { "dependencies": { "@vercel/gatsby-plugin-vercel-analytics": "1.0.11", "@vercel/gatsby-plugin-vercel-builder": "2.0.142", "@vercel/static-config": "3.1.2", "ts-morph": "12.0.0" } }, "sha512-xzRBFm+tgVoyE/qSF+BeKXKckLVYXR2dGHsp+fNRttLXi7fq2AUCTM7DNNT4TkkTxcPX5FaJ1tRHxlqLizv91A=="], "@vercel/static-config": ["@vercel/static-config@3.2.0", "", { "dependencies": { "ajv": "8.6.3", "json-schema-to-ts": "1.6.4", "ts-morph": "12.0.0" } }, "sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA=="], @@ -3411,7 +3412,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vercel": ["vercel@50.31.1", "", { "dependencies": { "@vercel/backends": "0.0.45", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.8.0", "@vercel/detect-agent": "1.2.0", "@vercel/elysia": "0.1.48", "@vercel/express": "0.1.57", "@vercel/fastify": "0.1.51", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.4", "@vercel/h3": "0.1.57", "@vercel/hono": "0.2.51", "@vercel/hydrogen": "1.3.6", "@vercel/koa": "0.1.31", "@vercel/nestjs": "0.2.52", "@vercel/next": "4.16.1", "@vercel/node": "5.6.15", "@vercel/python": "6.22.0", "@vercel/redwood": "2.4.10", "@vercel/remix-builder": "5.7.0", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.9.0", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-cu8ukSJVuKecKavhMWMP/sgcetQ/6qsEaIGf9626tWXpRddUsLLJAsGyZUoZxo+EshMEp5kjuLZvTonyOUBLcA=="], + "vercel": ["vercel@50.26.1", "", { "dependencies": { "@vercel/backends": "0.0.40", "@vercel/blob": "2.3.0", "@vercel/build-utils": "13.6.2", "@vercel/detect-agent": "1.1.0", "@vercel/elysia": "0.1.43", "@vercel/express": "0.1.52", "@vercel/fastify": "0.1.46", "@vercel/fun": "1.3.0", "@vercel/go": "3.4.3", "@vercel/h3": "0.1.52", "@vercel/hono": "0.2.46", "@vercel/hydrogen": "1.3.5", "@vercel/koa": "0.1.26", "@vercel/nestjs": "0.2.47", "@vercel/next": "4.15.39", "@vercel/node": "5.6.10", "@vercel/python": "6.20.1", "@vercel/redwood": "2.4.9", "@vercel/remix-builder": "5.6.0", "@vercel/ruby": "2.3.2", "@vercel/rust": "1.0.5", "@vercel/static-build": "2.8.44", "chokidar": "4.0.0", "esbuild": "0.27.0", "form-data": "^4.0.0", "jose": "5.9.6", "luxon": "^3.4.0", "proxy-agent": "6.4.0" }, "bin": { "vc": "dist/vc.js", "vercel": "dist/vc.js" } }, "sha512-dFIsEkqTZYBNIZwe5cprBDe1a1AoWQXtBsk+/otAjuVk9WJ3x6kA9fE4rvEWAsnpk2jYj2ZbY04xjBt2cxGt5w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], diff --git a/package.json b/package.json index 163b103ede..160c12c367 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "@changesets/cli": "^2.30.0", - "turbo": "^2.8.16", - "vercel": "^50.31.1" + "@changesets/cli": "^2.29.8", + "turbo": "^2.8.10", + "vercel": "catalog:" }, "packageManager": "bun@1.3.7", "overrides": { @@ -53,7 +53,8 @@ "react-dom": "^19.0.1", "tsdown": "^0.15.6", "typescript": "^5.5.3", - "usehooks-ts": "^3.1.1" + "usehooks-ts": "^3.1.1", + "vercel": "^50.26.1" } }, "patchedDependencies": { diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 5a890f6b35..7492e57176 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -110,7 +110,7 @@ "tailwindcss": "^4.1.11", "ts-essentials": "^10.0.1", "typescript": "catalog:", - "vercel": "^50.15.1", + "vercel": "catalog:", "wrangler": "^4.43.0", "rss-parser": "^3.13.0" }, From d5a2dd2d7a9e3c6f1ef787de413fe18fe30c478c Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 14:56:01 +0100 Subject: [PATCH 15/22] fix section url --- .../[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index e12a81d6a0..e362b40cd2 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'; import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils'; import { throwIfDataError } from '@/lib/data'; import { getIndexablePages } from '@/lib/sitemap'; -import { listAllSiteSpaces } from '@/lib/sites'; +import { getFallbackSiteSpacePath, listAllSiteSpaces } from '@/lib/sites'; interface RawIndexPage { id: string; @@ -44,7 +44,7 @@ export async function GET( for (let i = 0; i < visibleSpaces.length; i++) { const siteSpace = visibleSpaces[i]!; const revision = revisions[i]!; - const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: siteSpace.path }); + const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: getFallbackSiteSpacePath(context, siteSpace) }); for (const { page } of getIndexablePages(revision.pages)) { if (seen.has(page.id)) continue; From ecd3fdd28d8159e7309593c9f6321bd85ceffb75 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 15:02:04 +0100 Subject: [PATCH 16/22] remove revalidate --- .../static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts index e362b40cd2..fb70d31228 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts @@ -15,7 +15,6 @@ interface RawIndexPage { description?: string; } -export const revalidate = 86400; // 1 day export const dynamic = 'force-static'; export async function GET( From aeb8d892fc15b373d1f71c655b4021044dadaf28 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Wed, 4 Mar 2026 15:11:26 +0100 Subject: [PATCH 17/22] Update index URLs to point to the new site index and add route for site index --- .../[siteData]/~gitbook/{index => site-index}/route.ts | 1 + packages/gitbook/src/components/Header/Header.tsx | 2 +- packages/gitbook/src/components/SiteLayout/SiteLayout.tsx | 2 +- packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx | 2 +- packages/gitbook/src/middleware.ts | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) rename packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/{index => site-index}/route.ts (97%) diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts similarity index 97% rename from packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts rename to packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts index fb70d31228..d1b4d1bdf9 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts @@ -15,6 +15,7 @@ interface RawIndexPage { description?: string; } +export const revalidate = 86400; // 1 day in seconds export const dynamic = 'force-static'; export async function GET( diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 8f0806cc50..84128c2898 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -162,7 +162,7 @@ export function Header(props: { } siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} - indexURL={context.linker.toPathInSite('~gitbook/index')} + indexURL={context.linker.toPathInSite('~gitbook/site-index')} viewport={!withTopHeader ? 'mobile' : undefined} searchURL={context.linker.toPathInSpace('~gitbook/search')} /> diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 053e32b659..4f551f635c 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -41,7 +41,7 @@ export async function SiteLayout(props: { // We also preload the site index //TODO: enable this only for a subset of website first - ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/index`, { + ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, { as: 'fetch', type: 'application/json', }); diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 72dfbc42ef..0717878f7f 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -201,7 +201,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { siteSpace={siteSpace} siteSpaces={visibleSiteSpaces} indexURL={context.linker.toPathInSite( - '~gitbook/index' + '~gitbook/site-index' )} viewport="desktop" searchURL={context.linker.toPathInSpace( diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 5663a4d0bf..86bce6f2ca 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -676,7 +676,7 @@ function encodePathInSiteContent( case 'robots.txt': case '~gitbook/embed/script.js': case '~gitbook/embed/demo': - case '~gitbook/index': + case '~gitbook/site-index': // LLMs.txt, sitemap, sitemap-pages and robots.txt are always static // as they only depend on the site structure / pages. return { pathname, routeType: 'static' }; From 46523e93f9ef951b4f14ae3890b6b9e5e47f709a Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 15 Mar 2026 16:53:20 +0100 Subject: [PATCH 18/22] reciprocal rank fusion --- .../[siteData]/~gitbook/site-index/route.ts | 4 +- .../src/components/Search/SearchContainer.tsx | 9 +-- .../Search/SearchLocalPageResultItem.tsx | 56 ++++++++++++++ .../src/components/Search/SearchResults.tsx | 17 +++++ .../components/Search/reciprocalRankFusion.ts | 73 +++++++++++++++++++ .../src/components/Search/useSearchResults.ts | 68 +++++++++-------- 6 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 packages/gitbook/src/components/Search/SearchLocalPageResultItem.tsx create mode 100644 packages/gitbook/src/components/Search/reciprocalRankFusion.ts diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts index d1b4d1bdf9..63fd077db7 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts @@ -44,7 +44,9 @@ export async function GET( for (let i = 0; i < visibleSpaces.length; i++) { const siteSpace = visibleSpaces[i]!; const revision = revisions[i]!; - const forkedLinker = linker.withOtherSiteSpace({ spaceBasePath: getFallbackSiteSpacePath(context, siteSpace) }); + const forkedLinker = linker.withOtherSiteSpace({ + spaceBasePath: getFallbackSiteSpacePath(context, siteSpace), + }); for (const { page } of getIndexablePages(revision.pages)) { if (seen.has(page.id)) continue; diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 99db486b81..9b24314576 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -11,7 +11,6 @@ import { AIChatButton } from '../AIChat'; import { useTrackEvent } from '../Insights'; import { useIsMobile } from '../hooks/useIsMobile'; import { Popover, useBodyLoaded } from '../primitives'; -import { LocalSearchResults } from './LocalSearchResults'; import { SearchAskAnswer } from './SearchAskAnswer'; import { useSearchAskState } from './SearchAskContext'; import { SearchAskProvider } from './SearchAskContext'; @@ -207,7 +206,7 @@ export function SearchContainer({ [siteSpaces, siteSpace.space.language] ); - const { results, fetching, error, localResults } = useSearchResults({ + const { results, fetching, error } = useSearchResults({ disabled: !(state?.query || withAI), query: normalizedQuery, siteSpaceId: siteSpace.id, @@ -247,12 +246,6 @@ export function SearchContainer({ state?.query || withAI ? (

- {localResults.length > 0 && !showAsk ? ( - - ) : null} {state !== null && !showAsk ? ( +) { + const { query, item, active, ...rest } = props; + const language = useLanguage(); + + const leadingIcon = item.emoji ? ( + + + + ) : item.icon ? ( + + ) : ( + + ); + + return ( + +

+ +

+ {item.description ? ( +

+ +

+ ) : null} +
+ ); +}); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 1106fc5150..3b504a7b82 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -8,11 +8,13 @@ import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Button, Loading } from '../primitives'; +import { SearchLocalPageResultItem } from './SearchLocalPageResultItem'; import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchRecordResultItem } from './SearchRecordResultItem'; import { SearchSectionResultItem } from './SearchSectionResultItem'; import type { OrderedComputedResult } from './search-types'; +import type { LocalPageResult } from './useLocalSearchResults'; export interface SearchResultsRef { select(): void; @@ -20,6 +22,7 @@ export interface SearchResultsRef { type ResultType = | OrderedComputedResult + | LocalPageResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; @@ -144,6 +147,20 @@ export const SearchResults = React.forwardRef(function SearchResults( id: `${id}-${index}`, }; switch (item.type) { + case 'local-page': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + query={query} + item={item} + active={index === cursor} + {...resultItemProps} + /> + ); + } case 'page': { return ( { + // Map from dedup key → { result, score } + const scoreMap = new Map< + string, + { result: LocalPageResult | OrderedComputedResult; score: number } + >(); + + // Process local results first (1-indexed rank) + localResults.forEach((result, index) => { + const rank = index + 1; + const key = `page:${result.id}`; + const contribution = 1 / (RRF_K + rank); + + const existing = scoreMap.get(key); + if (existing) { + existing.score += contribution; + } else { + scoreMap.set(key, { result, score: contribution }); + } + }); + + // Process remote results, deduplicating against local pages + remoteResults.forEach((result, index) => { + const rank = index + 1; + const contribution = 1 / (RRF_K + rank); + + // Determine the dedup key — pages match via pageId + const key = + result.type === 'page' + ? `page:${result.pageId}` + : result.type === 'section' + ? `section:${result.id}` + : `record:${result.id}`; + + const existing = scoreMap.get(key); + if (existing) { + // Page found in both lists: credit both rank contributions and + // prefer the remote result which carries richer metadata. + existing.score += contribution; + existing.result = result; + } else { + scoreMap.set(key, { result, score: contribution }); + } + }); + + // Sort descending by RRF score + return Array.from(scoreMap.values()) + .sort((a, b) => b.score - a.score) + .map(({ result }) => result); +} diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index c8e275dda1..17a3f99fe0 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -10,11 +10,13 @@ import { type Assistant, useAI } from '@/components/AI'; import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; import { isQuestion } from './isQuestion'; +import { reciprocalRankFusion } from './reciprocalRankFusion'; import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; import type { SearchScope } from './useSearch'; export type ResultType = | OrderedComputedResult + | LocalPageResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; @@ -64,8 +66,8 @@ export function useSearchResults(props: { disabled, }); - const [resultsState, setResultsState] = React.useState<{ - results: ResultType[]; + const [remoteState, setRemoteState] = React.useState<{ + results: OrderedComputedResult[]; fetching: boolean; error: boolean; }>({ results: [], fetching: false, error: false }); @@ -79,7 +81,7 @@ export function useSearchResults(props: { } if (!query) { if (!withAI) { - setResultsState({ results: [], fetching: false, error: false }); + setRemoteState({ results: [], fetching: false, error: false }); return; } @@ -89,11 +91,12 @@ export function useSearchResults(props: { results, `Cached recommended questions should be set for site-space ${siteSpaceId}` ); - setResultsState({ results, fetching: false, error: false }); + // Recommended questions are stored as ResultType[] already + setRemoteState({ results: [], fetching: false, error: false }); return; } - setResultsState({ results: [], fetching: false, error: false }); + setRemoteState({ results: [], fetching: false, error: false }); let cancelled = false; @@ -106,15 +109,7 @@ export function useSearchResults(props: { suggestions.forEach((question) => { questions.add(question); }); - setResultsState({ - results: suggestions.map((question, index) => ({ - type: 'recommended-question', - id: `recommended-question-${index}`, - question, - })), - fetching: false, - error: false, - }); + setRemoteState({ results: [], fetching: false, error: false }); return; } @@ -143,11 +138,8 @@ export function useSearchResults(props: { cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); if (!cancelled) { - setResultsState({ - results: [...recommendedQuestions], - fetching: false, - error: false, - }); + // Recommended questions are handled via a separate path below + setRemoteState({ results: [], fetching: false, error: false }); } } }, 100); @@ -157,8 +149,8 @@ export function useSearchResults(props: { clearTimeout(timeout); }; } - setResultsState({ - results: withAI ? withAskTriggers([], query, assistants) : [], + setRemoteState({ + results: [], fetching: true, error: false, }); @@ -198,15 +190,11 @@ export function useSearchResults(props: { // One time when this one returns undefined is when it cannot find the server action and returns the html from the page. // In that case, we want to avoid being stuck in a loading state, but it is an error. // We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience. - setResultsState({ results: [], fetching: false, error: true }); + setRemoteState({ results: [], fetching: false, error: true }); return; } - const aiEnrichedResults = withAI - ? withAskTriggers(results, query, assistants) - : results; - - setResultsState({ results: aiEnrichedResults, fetching: false, error: false }); + setRemoteState({ results, fetching: false, error: false }); trackEvent({ type: 'search_type_query', @@ -217,7 +205,7 @@ export function useSearchResults(props: { if (cancelled) { return; } - setResultsState({ results: [], fetching: false, error: true }); + setRemoteState({ results: [], fetching: false, error: true }); } }, 350); @@ -238,7 +226,29 @@ export function useSearchResults(props: { searchURL, ]); - return { ...resultsState, localResults }; + // Merge local and remote results using Reciprocal Rank Fusion. + // Re-runs immediately whenever either result set changes. + const results = React.useMemo(() => { + if (!query) { + // No query: show recommended questions (AI-only path) or nothing. + if (withAI && cachedRecommendedQuestions.has(siteSpaceId)) { + return cachedRecommendedQuestions.get(siteSpaceId) ?? []; + } + if (suggestions && suggestions.length > 0) { + return suggestions.map((question, index) => ({ + type: 'recommended-question' as const, + id: `recommended-question-${index}`, + question, + })); + } + return []; + } + + const merged = reciprocalRankFusion(localResults, remoteState.results); + return withAI ? withAskTriggers(merged, query, assistants) : merged; + }, [localResults, remoteState.results, query, withAI, assistants, siteSpaceId, suggestions]); + + return { results, fetching: remoteState.fetching, error: remoteState.error }; } /** From 5956db7937f2454dc236206b3e0fe651416ce544 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Sun, 15 Mar 2026 17:45:51 +0100 Subject: [PATCH 19/22] Refactor search components to remove SearchLocalPageResultItem and integrate breadcrumbs support in search results --- .../[siteData]/~gitbook/site-index/route.ts | 94 ++++++++++++++++++- .../Search/SearchLocalPageResultItem.tsx | 56 ----------- .../Search/SearchPageResultItem.tsx | 51 +++++++--- .../src/components/Search/SearchResults.tsx | 16 +--- .../Search/useLocalSearchResults.tsx | 41 +++++--- 5 files changed, 160 insertions(+), 98 deletions(-) delete mode 100644 packages/gitbook/src/components/Search/SearchLocalPageResultItem.tsx diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts index 63fd077db7..63ec6c05e7 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/site-index/route.ts @@ -1,9 +1,21 @@ +import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api'; import type { NextRequest } from 'next/server'; import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils'; import { throwIfDataError } from '@/lib/data'; -import { getIndexablePages } from '@/lib/sitemap'; -import { getFallbackSiteSpacePath, listAllSiteSpaces } from '@/lib/sites'; +import { isPageIndexable } from '@/lib/seo'; +import { + findSiteSpaceBy, + getFallbackSiteSpacePath, + getLocalizedTitle, + listAllSiteSpaces, +} from '@/lib/sites'; + +interface Breadcrumb { + label: string; + icon?: string; + emoji?: string; +} interface RawIndexPage { id: string; @@ -13,6 +25,55 @@ interface RawIndexPage { icon?: string; emoji?: string; description?: string; + breadcrumbs?: Breadcrumb[]; +} + +type AncestorPage = RevisionPageDocument | RevisionPageGroup; + +interface IndexPageEntry { + page: RevisionPageDocument; + ancestors: AncestorPage[]; +} + +/** + * Walk the page tree and return all indexable document pages together with + * their ancestor chain (groups + parent documents), enabling breadcrumb generation. + */ +function getIndexablePagesWithAncestors( + rootPages: RevisionPage[], + ancestors: AncestorPage[] = [] +): IndexPageEntry[] { + const results: IndexPageEntry[] = []; + + for (const page of rootPages) { + if (page.type === 'link' || page.type === 'computed') continue; + if (page.hidden || !isPageIndexable([], page)) continue; + + if (page.type === 'document') { + results.push({ page, ancestors }); + // Recurse into children with this document as an ancestor + if (page.pages?.length) { + results.push( + ...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [ + ...ancestors, + page, + ]) + ); + } + } else if (page.type === 'group') { + // Groups themselves are not documents — push them only as ancestors + if (page.pages?.length) { + results.push( + ...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [ + ...ancestors, + page, + ]) + ); + } + } + } + + return results; } export const revalidate = 86400; // 1 day in seconds @@ -48,18 +109,43 @@ export async function GET( spaceBasePath: getFallbackSiteSpacePath(context, siteSpace), }); - for (const { page } of getIndexablePages(revision.pages)) { + const lang = siteSpace.space.language ?? undefined; + const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id); + const { siteSection, siteSectionGroup } = sectionInfo ?? {}; + + for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) { if (seen.has(page.id)) continue; seen.add(page.id); + const breadcrumbs: Breadcrumb[] = [ + siteSectionGroup + ? { + label: getLocalizedTitle(siteSectionGroup, lang), + icon: siteSectionGroup.icon ?? undefined, + } + : undefined, + siteSection + ? { + label: getLocalizedTitle(siteSection, lang), + icon: siteSection.icon ?? undefined, + } + : undefined, + ...ancestors.map((a) => ({ + label: a.title, + icon: a.icon ?? undefined, + emoji: a.emoji ?? undefined, + })), + ].filter((c) => c !== undefined); + pages.push({ id: page.id, title: page.title, pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }), - lang: siteSpace.space.language ?? undefined, + lang, icon: page.icon ?? undefined, emoji: page.emoji ?? undefined, description: page.description ?? undefined, + breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined, }); } } diff --git a/packages/gitbook/src/components/Search/SearchLocalPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchLocalPageResultItem.tsx deleted file mode 100644 index d798f41e98..0000000000 --- a/packages/gitbook/src/components/Search/SearchLocalPageResultItem.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { tString, useLanguage } from '@/intl/client'; -import { Icon, type IconName } from '@gitbook/icons'; -import React from 'react'; -import { Emoji } from '../primitives/Emoji/Emoji'; -import { HighlightQuery } from './HighlightQuery'; -import { SearchResultItem } from './SearchResultItem'; -import type { LocalPageResult } from './useLocalSearchResults'; - -/** - * Result item for a locally-indexed page result inside the unified search list. - * Mirrors the structure of SearchPageResultItem but works with LocalPageResult, - * which only carries pathname/title/icon/emoji/description (no spaceId/breadcrumbs). - */ -export const SearchLocalPageResultItem = React.forwardRef(function SearchLocalPageResultItem( - props: { - query: string; - item: LocalPageResult; - active: boolean; - }, - ref: React.Ref -) { - const { query, item, active, ...rest } = props; - const language = useLanguage(); - - const leadingIcon = item.emoji ? ( - - - - ) : item.icon ? ( - - ) : ( - - ); - - return ( - -

- -

- {item.description ? ( -

- -

- ) : null} -
- ); -}); diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 108edb131a..7a9d55e495 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -3,14 +3,18 @@ import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; import { Tooltip } from '../primitives'; +import { Emoji } from '../primitives/Emoji/Emoji'; import { HighlightQuery } from './HighlightQuery'; import { SearchResultItem } from './SearchResultItem'; import type { ComputedPageResult } from './search-types'; +import type { LocalPageResult } from './useLocalSearchResults'; + +type PageItem = ComputedPageResult | LocalPageResult; export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem( props: { query: string; - item: ComputedPageResult; + item: PageItem; active: boolean; }, ref: React.Ref @@ -18,22 +22,40 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const { query, item, active, ...rest } = props; const language = useLanguage(); + const href = 'href' in item ? item.href : item.pathname; + + const leadingIcon = + item.type === 'local-page' && item.emoji ? ( + + + + ) : item.type === 'local-page' && item.icon ? ( + + ) : ( + + ); + + const insights = + item.type === 'page' + ? { + type: 'search_open_result' as const, + query, + result: { + pageId: item.pageId, + spaceId: item.spaceId, + }, + } + : undefined; + return ( } - insights={{ - type: 'search_open_result', - query, - result: { - pageId: item.pageId, - spaceId: item.spaceId, - }, - }} + leadingIcon={leadingIcon} + insights={insights} aria-label={tString(language, 'search_page_result_title', item.title)} {...rest} > @@ -41,12 +63,17 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt

+ {'description' in item && item.description ? ( +

+ +

+ ) : null}
); }); const Breadcrumbs = (props: { - breadcrumbs: ComputedPageResult['breadcrumbs']; + breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean; }) => { const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props; diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 3b504a7b82..6effba89c5 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -8,7 +8,6 @@ import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Button, Loading } from '../primitives'; -import { SearchLocalPageResultItem } from './SearchLocalPageResultItem'; import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchRecordResultItem } from './SearchRecordResultItem'; @@ -147,20 +146,7 @@ export const SearchResults = React.forwardRef(function SearchResults( id: `${id}-${index}`, }; switch (item.type) { - case 'local-page': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - {...resultItemProps} - /> - ); - } + case 'local-page': case 'page': { return ( >(); + +// Side-map for data that doesn't belong in the FlexSearch index. +// Keyed by page id, shared across all language groups. +const cachedPageData = new Map< + string, + { pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] } +>(); + let pendingFetch: Promise>> | null = null; function buildLangIndex(pages: RawIndexPage[]): Document { @@ -53,7 +66,7 @@ function buildLangIndex(pages: RawIndexPage[]): Document { document: { id: 'id', index: ['title', 'description'], - store: ['id', 'title', 'pathname', 'icon', 'description'], + store: ['id', 'title', 'description'], }, tokenize: 'bidirectional', encoder: 'Normalize', @@ -63,11 +76,15 @@ function buildLangIndex(pages: RawIndexPage[]): Document { index.add({ id: page.id, title: page.title, - pathname: page.pathname, - icon: page.icon ?? null, - emoji: page.emoji ?? null, description: page.description ?? null, }); + + cachedPageData.set(page.id, { + pathname: page.pathname, + icon: page.icon, + emoji: page.emoji, + breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined, + }); } return index; @@ -193,14 +210,16 @@ export function useLocalSearchResults(props: { const doc = (item as { id: string; doc: IndexPage }).doc; if (!seen.has(doc.id)) { seen.add(doc.id); + const extra = cachedPageData.get(doc.id); results.push({ type: 'local-page', id: doc.id, title: doc.title, - pathname: doc.pathname as string, - icon: doc.icon ?? undefined, - emoji: doc.emoji ?? undefined, - description: doc.description ?? undefined, + pathname: extra?.pathname ?? '', + icon: extra?.icon, + emoji: extra?.emoji, + description: (doc.description as string | null) ?? undefined, + breadcrumbs: extra?.breadcrumbs, }); } } From c7f5d4dddc188a6108e9baa8b108346e6e3aa4ff Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 20 Mar 2026 10:02:57 +0100 Subject: [PATCH 20/22] Enhance search functionality with interaction tracking and visibility management --- .../src/components/Search/SearchContainer.tsx | 14 +- .../src/components/Search/SearchResults.tsx | 64 +++++++- .../components/Search/reciprocalRankFusion.ts | 27 +++- .../src/components/Search/useSearchResults.ts | 153 +++++++++++++++++- 4 files changed, 244 insertions(+), 14 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 9b24314576..8d23d4e450 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -206,7 +206,7 @@ export function SearchContainer({ [siteSpaces, siteSpace.space.language] ); - const { results, fetching, error } = useSearchResults({ + const { results, fetching, error, setInteracted, onVisibilityChange } = useSearchResults({ disabled: !(state?.query || withAI), query: normalizedQuery, siteSpaceId: siteSpace.id, @@ -221,6 +221,8 @@ export function SearchContainer({ const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; + const scrollContainerRef = React.useRef(null); + const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ query: normalizedQuery, results, @@ -228,9 +230,11 @@ export function SearchContainer({ const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowUp') { event.preventDefault(); + setInteracted(); moveCursorBy(-1); } else if (event.key === 'ArrowDown') { event.preventDefault(); + setInteracted(); moveCursorBy(1); } else if (event.key === 'Enter') { event.preventDefault(); @@ -245,7 +249,11 @@ export function SearchContainer({ // Only show content if there's a query or Ask is enabled state?.query || withAI ? ( -
+
{state !== null && !showAsk ? ( ) : null} {showAsk ? : null} diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 6effba89c5..09131ecc49 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -12,6 +12,7 @@ import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchRecordResultItem } from './SearchRecordResultItem'; import { SearchSectionResultItem } from './SearchSectionResultItem'; +import { getResultKey } from './reciprocalRankFusion'; import type { OrderedComputedResult } from './search-types'; import type { LocalPageResult } from './useLocalSearchResults'; @@ -40,10 +41,24 @@ export const SearchResults = React.forwardRef(function SearchResults( fetching: boolean; cursor: number | null; error: boolean; + /** Ref to the scroll container — used as the IntersectionObserver root. */ + scrollContainerRef?: React.RefObject; + /** Called whenever the set of visible result keys changes. */ + onVisibilityChange?: (ids: ReadonlySet) => void; }, ref: React.Ref ) { - const { children, id, query, results, fetching, cursor, error } = props; + const { + children, + id, + query, + results, + fetching, + cursor, + error, + scrollContainerRef, + onVisibilityChange, + } = props; const language = useLanguage(); @@ -77,6 +92,53 @@ export const SearchResults = React.forwardRef(function SearchResults( [select] ); + // Observe which result items are visible in the scroll viewport. + React.useEffect(() => { + if (!onVisibilityChange) { + return; + } + + const root = scrollContainerRef?.current ?? null; + const visibleKeys = new Set(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = refs.current.indexOf(entry.target as HTMLAnchorElement); + if (index === -1) continue; + const result = results[index]; + if ( + !result || + result.type === 'question' || + result.type === 'recommended-question' + ) { + continue; + } + const key = getResultKey(result); + if (entry.isIntersecting) { + visibleKeys.add(key); + } else { + visibleKeys.delete(key); + } + } + onVisibilityChange(new Set(visibleKeys)); + }, + { root, threshold: 0.5 } + ); + + const observed: HTMLAnchorElement[] = []; + for (const el of refs.current) { + if (el) { + observer.observe(el); + observed.push(el); + } + } + + return () => { + observer.disconnect(); + }; + }, [results, scrollContainerRef, onVisibilityChange]); + const { assistants } = useAI(); if (error) { diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index 7b65b1548c..02e2f09b09 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -4,6 +4,24 @@ import type { LocalPageResult } from './useLocalSearchResults'; /** Standard RRF constant — moderates the impact of high-ranked items. */ const RRF_K = 60; +/** + * Derive a stable deduplication key for any result type. + * Pages from both local and remote share the same `page:` namespace so they + * can be deduplicated against each other. + */ +export function getResultKey(result: LocalPageResult | OrderedComputedResult): string { + switch (result.type) { + case 'local-page': + return `page:${result.id}`; + case 'page': + return `page:${result.pageId}`; + case 'section': + return `section:${result.id}`; + case 'record': + return `record:${result.id}`; + } +} + /** * Merge local (FlexSearch) and remote (API) search results using * Reciprocal Rank Fusion (RRF). @@ -31,7 +49,7 @@ export function reciprocalRankFusion( // Process local results first (1-indexed rank) localResults.forEach((result, index) => { const rank = index + 1; - const key = `page:${result.id}`; + const key = getResultKey(result); const contribution = 1 / (RRF_K + rank); const existing = scoreMap.get(key); @@ -48,12 +66,7 @@ export function reciprocalRankFusion( const contribution = 1 / (RRF_K + rank); // Determine the dedup key — pages match via pageId - const key = - result.type === 'page' - ? `page:${result.pageId}` - : result.type === 'section' - ? `section:${result.id}` - : `record:${result.id}`; + const key = getResultKey(result); const existing = scoreMap.get(key); if (existing) { diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 17a3f99fe0..ccd6911b7b 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -10,7 +10,7 @@ import { type Assistant, useAI } from '@/components/AI'; import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; import { isQuestion } from './isQuestion'; -import { reciprocalRankFusion } from './reciprocalRankFusion'; +import { getResultKey, reciprocalRankFusion } from './reciprocalRankFusion'; import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; import type { SearchScope } from './useSearch'; @@ -22,6 +22,87 @@ export type ResultType = export type { LocalPageResult }; +type MergeableResult = LocalPageResult | OrderedComputedResult; + +/** + * Append-only merge: keep all previous results unchanged and append any new + * items from a fresh RRF run at the end. + * Used once the user has interacted with the list (scroll / keyboard / pointer) + * so that already-visible items never jump to a different position. + */ +function appendMerge( + localResults: LocalPageResult[], + remoteResults: OrderedComputedResult[], + previousResults: MergeableResult[] +): MergeableResult[] { + const prevKeys = new Set(previousResults.map(getResultKey)); + const fresh = reciprocalRankFusion(localResults, remoteResults); + const newItems = fresh.filter((item) => !prevKeys.has(getResultKey(item))); + return [...previousResults, ...newItems]; +} + +/** + * Stable-visible merge: run a full RRF but keep the relative order of items + * that are currently visible in the scroll viewport. Items below the fold are + * still freely re-ranked by RRF. + * + * The slot-filling algorithm: + * 1. Partition the RRF output into "visible" and "non-visible" buckets. + * 2. Sort the visible bucket by each item's position in `previousResults`. + * 3. Walk the RRF output: every slot that held a visible item is filled from + * the sorted visible bucket; every other slot is filled from the non-visible + * bucket (preserving RRF order for that half). + */ +function stableVisibleMerge( + localResults: LocalPageResult[], + remoteResults: OrderedComputedResult[], + visibleIds: ReadonlySet, + previousResults: MergeableResult[] +): MergeableResult[] { + const rrfResult = reciprocalRankFusion(localResults, remoteResults); + + if (visibleIds.size === 0 || previousResults.length === 0) { + return rrfResult; + } + + const prevIndexMap = new Map( + previousResults.map((item, i) => [getResultKey(item), i]) + ); + + const visibleInRRF: MergeableResult[] = []; + const nonVisibleInRRF: MergeableResult[] = []; + + for (const item of rrfResult) { + if (visibleIds.has(getResultKey(item))) { + visibleInRRF.push(item); + } else { + nonVisibleInRRF.push(item); + } + } + + // Sort visible items by their previous display position + visibleInRRF.sort((a, b) => { + const ia = prevIndexMap.get(getResultKey(a)) ?? Number.POSITIVE_INFINITY; + const ib = prevIndexMap.get(getResultKey(b)) ?? Number.POSITIVE_INFINITY; + return ia - ib; + }); + + // Fill RRF-shaped slots: visible → stable-sorted visible; non-visible → RRF order + const result: MergeableResult[] = []; + let vi = 0; + let ni = 0; + + for (const item of rrfResult) { + if (visibleIds.has(getResultKey(item))) { + result.push(visibleInRRF[vi++]!); + } else { + result.push(nonVisibleInRRF[ni++]!); + } + } + + return result; +} + /** * We cache the recommended questions globally to avoid calling the API multiple times * when re-opening the search modal. The cache is per space, so that we can @@ -75,6 +156,39 @@ export function useSearchResults(props: { const { assistants } = useAI(); const withAI = assistants.length > 0; + // --- Interaction / visibility tracking (refs → no extra renders) --- + + /** True once the user has scrolled, moved the keyboard cursor, or hovered over results. */ + const hasInteractedRef = React.useRef(false); + /** Keys of result items currently visible in the scroll viewport. */ + const visibleIdsRef = React.useRef>(new Set()); + /** + * The last merged result list together with the query it was computed for. + * Storing the query lets us detect when the query has changed and discard + * stale data synchronously inside the useMemo, even before the reset effect + * has fired. + */ + const previousResultsRef = React.useRef<{ query: string; results: MergeableResult[] }>({ + query: '', + results: [], + }); + + // Reset interaction state whenever the query changes. + React.useEffect(() => { + hasInteractedRef.current = false; + visibleIdsRef.current = new Set(); + }, [query]); + + /** Call when the user scrolls, navigates with arrow keys, or hovers the list. */ + const setInteracted = React.useCallback(() => { + hasInteractedRef.current = true; + }, []); + + /** Update the set of result keys that are currently visible in the viewport. */ + const onVisibilityChange = React.useCallback((ids: ReadonlySet) => { + visibleIdsRef.current = ids; + }, []); + React.useEffect(() => { if (disabled) { return; @@ -226,7 +340,7 @@ export function useSearchResults(props: { searchURL, ]); - // Merge local and remote results using Reciprocal Rank Fusion. + // Merge local and remote results. // Re-runs immediately whenever either result set changes. const results = React.useMemo(() => { if (!query) { @@ -244,11 +358,42 @@ export function useSearchResults(props: { return []; } - const merged = reciprocalRankFusion(localResults, remoteState.results); + // Treat previousResults as empty if they belong to a different query. + // This is a synchronous guard that complements the reset useEffect above. + const previous = + previousResultsRef.current.query === query ? previousResultsRef.current.results : []; + + let merged: MergeableResult[]; + if (hasInteractedRef.current && previous.length > 0) { + // User has interacted: append new items, never reorder existing ones. + merged = appendMerge(localResults, remoteState.results, previous); + } else if (!hasInteractedRef.current && previous.length > 0) { + // User hasn't interacted: keep visible items in their current order, + // allow below-the-fold items to be freely re-ranked by RRF. + merged = stableVisibleMerge( + localResults, + remoteState.results, + visibleIdsRef.current, + previous + ); + } else { + // First render for this query: plain RRF with no stability constraints. + merged = reciprocalRankFusion(localResults, remoteState.results); + } + + // Store for the next merge cycle (written during render; safe for refs). + previousResultsRef.current = { query, results: merged }; + return withAI ? withAskTriggers(merged, query, assistants) : merged; }, [localResults, remoteState.results, query, withAI, assistants, siteSpaceId, suggestions]); - return { results, fetching: remoteState.fetching, error: remoteState.error }; + return { + results, + fetching: remoteState.fetching, + error: remoteState.error, + setInteracted, + onVisibilityChange, + }; } /** From d362618c56f4edd53fde503faac055d780ca8c89 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 20 Mar 2026 11:02:09 +0100 Subject: [PATCH 21/22] Refactor search result merging logic for improved stability and clarity --- .../src/components/Search/SearchResults.tsx | 3 +- .../components/Search/reciprocalRankFusion.ts | 2 +- .../src/components/Search/useSearchResults.ts | 99 +++++-------------- 3 files changed, 25 insertions(+), 79 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 09131ecc49..62e3c95188 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -109,8 +109,7 @@ export const SearchResults = React.forwardRef(function SearchResults( const result = results[index]; if ( !result || - result.type === 'question' || - result.type === 'recommended-question' + result.type !== 'local-page' ) { continue; } diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index 02e2f09b09..2e023350a9 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -16,7 +16,7 @@ export function getResultKey(result: LocalPageResult | OrderedComputedResult): s case 'page': return `page:${result.pageId}`; case 'section': - return `section:${result.id}`; + return `section:${result.pageId}`; case 'record': return `record:${result.id}`; } diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index ccd6911b7b..80f5903790 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -25,82 +25,48 @@ export type { LocalPageResult }; type MergeableResult = LocalPageResult | OrderedComputedResult; /** - * Append-only merge: keep all previous results unchanged and append any new - * items from a fresh RRF run at the end. + * Append-only merge: local results first, then remote results that aren't + * already covered by local. * Used once the user has interacted with the list (scroll / keyboard / pointer) * so that already-visible items never jump to a different position. */ function appendMerge( localResults: LocalPageResult[], - remoteResults: OrderedComputedResult[], - previousResults: MergeableResult[] + remoteResults: OrderedComputedResult[] ): MergeableResult[] { - const prevKeys = new Set(previousResults.map(getResultKey)); - const fresh = reciprocalRankFusion(localResults, remoteResults); - const newItems = fresh.filter((item) => !prevKeys.has(getResultKey(item))); - return [...previousResults, ...newItems]; + const localKeys = new Set(localResults.map(getResultKey)); + const remoteOnly = remoteResults.filter((item) => !localKeys.has(getResultKey(item))); + return [...localResults, ...remoteOnly]; } /** - * Stable-visible merge: run a full RRF but keep the relative order of items - * that are currently visible in the scroll viewport. Items below the fold are - * still freely re-ranked by RRF. - * - * The slot-filling algorithm: - * 1. Partition the RRF output into "visible" and "non-visible" buckets. - * 2. Sort the visible bucket by each item's position in `previousResults`. - * 3. Walk the RRF output: every slot that held a visible item is filled from - * the sorted visible bucket; every other slot is filled from the non-visible - * bucket (preserving RRF order for that half). + * Stable-visible merge: visible items are kept in the order they appear in + * `visibleIds` (insertion order), followed by non-visible items ranked by RRF. */ function stableVisibleMerge( localResults: LocalPageResult[], remoteResults: OrderedComputedResult[], - visibleIds: ReadonlySet, - previousResults: MergeableResult[] + visibleIds: ReadonlySet ): MergeableResult[] { const rrfResult = reciprocalRankFusion(localResults, remoteResults); - if (visibleIds.size === 0 || previousResults.length === 0) { + if (visibleIds.size === 0) { return rrfResult; } - const prevIndexMap = new Map( - previousResults.map((item, i) => [getResultKey(item), i]) + const rrfByKey = new Map( + rrfResult.map((item) => [getResultKey(item), item]) ); - const visibleInRRF: MergeableResult[] = []; - const nonVisibleInRRF: MergeableResult[] = []; - - for (const item of rrfResult) { - if (visibleIds.has(getResultKey(item))) { - visibleInRRF.push(item); - } else { - nonVisibleInRRF.push(item); - } + const visible: MergeableResult[] = []; + for (const id of visibleIds) { + const item = rrfByKey.get(id); + if (item) visible.push(item); } - // Sort visible items by their previous display position - visibleInRRF.sort((a, b) => { - const ia = prevIndexMap.get(getResultKey(a)) ?? Number.POSITIVE_INFINITY; - const ib = prevIndexMap.get(getResultKey(b)) ?? Number.POSITIVE_INFINITY; - return ia - ib; - }); - - // Fill RRF-shaped slots: visible → stable-sorted visible; non-visible → RRF order - const result: MergeableResult[] = []; - let vi = 0; - let ni = 0; + const nonVisible = rrfResult.filter((item) => !visibleIds.has(getResultKey(item))); - for (const item of rrfResult) { - if (visibleIds.has(getResultKey(item))) { - result.push(visibleInRRF[vi++]!); - } else { - result.push(nonVisibleInRRF[ni++]!); - } - } - - return result; + return [...visible, ...nonVisible]; } /** @@ -162,16 +128,6 @@ export function useSearchResults(props: { const hasInteractedRef = React.useRef(false); /** Keys of result items currently visible in the scroll viewport. */ const visibleIdsRef = React.useRef>(new Set()); - /** - * The last merged result list together with the query it was computed for. - * Storing the query lets us detect when the query has changed and discard - * stale data synchronously inside the useMemo, even before the reset effect - * has fired. - */ - const previousResultsRef = React.useRef<{ query: string; results: MergeableResult[] }>({ - query: '', - results: [], - }); // Reset interaction state whenever the query changes. React.useEffect(() => { @@ -358,32 +314,23 @@ export function useSearchResults(props: { return []; } - // Treat previousResults as empty if they belong to a different query. - // This is a synchronous guard that complements the reset useEffect above. - const previous = - previousResultsRef.current.query === query ? previousResultsRef.current.results : []; - let merged: MergeableResult[]; - if (hasInteractedRef.current && previous.length > 0) { + if (hasInteractedRef.current) { // User has interacted: append new items, never reorder existing ones. - merged = appendMerge(localResults, remoteState.results, previous); - } else if (!hasInteractedRef.current && previous.length > 0) { - // User hasn't interacted: keep visible items in their current order, + merged = appendMerge(localResults, remoteState.results); + } else if (!hasInteractedRef.current) { + // User hasn't interacted: keep visible items in local order, // allow below-the-fold items to be freely re-ranked by RRF. merged = stableVisibleMerge( localResults, remoteState.results, - visibleIdsRef.current, - previous + visibleIdsRef.current ); } else { // First render for this query: plain RRF with no stability constraints. merged = reciprocalRankFusion(localResults, remoteState.results); } - // Store for the next merge cycle (written during render; safe for refs). - previousResultsRef.current = { query, results: merged }; - return withAI ? withAskTriggers(merged, query, assistants) : merged; }, [localResults, remoteState.results, query, withAI, assistants, siteSpaceId, suggestions]); From 4968b7d890ee472faf8ef453eeeb1d6bb8d0745a Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Fri, 20 Mar 2026 11:09:44 +0100 Subject: [PATCH 22/22] Add MergedPageResult type and enhance search result merging logic --- .../Search/SearchPageResultItem.tsx | 14 +++-- .../components/Search/reciprocalRankFusion.ts | 53 +++++++++++++------ .../src/components/Search/useSearchResults.ts | 7 +-- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 7a9d55e495..f3e3a38578 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -7,9 +7,10 @@ import { Emoji } from '../primitives/Emoji/Emoji'; import { HighlightQuery } from './HighlightQuery'; import { SearchResultItem } from './SearchResultItem'; import type { ComputedPageResult } from './search-types'; +import type { MergedPageResult } from './reciprocalRankFusion'; import type { LocalPageResult } from './useLocalSearchResults'; -type PageItem = ComputedPageResult | LocalPageResult; +type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult; export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem( props: { @@ -24,13 +25,16 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const href = 'href' in item ? item.href : item.pathname; + const emoji = 'emoji' in item ? item.emoji : undefined; + const icon = 'icon' in item ? item.icon : undefined; + const leadingIcon = - item.type === 'local-page' && item.emoji ? ( + emoji ? ( - + - ) : item.type === 'local-page' && item.icon ? ( - + ) : icon ? ( + ) : ( ); diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index 2e023350a9..5fe8c22e24 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -1,15 +1,30 @@ -import type { OrderedComputedResult } from './search-types'; +import type { ComputedPageResult, OrderedComputedResult } from './search-types'; import type { LocalPageResult } from './useLocalSearchResults'; /** Standard RRF constant — moderates the impact of high-ranked items. */ const RRF_K = 60; +/** + * A page result that was present in both local and remote lists. + * Local fields (description, icon, emoji, pathname) are carried over as a base, + * and remote fields (href, pageId, spaceId, title) override them. + * Breadcrumbs prefer local (has icon + emoji) and fall back to remote. + */ +export type MergedPageResult = ComputedPageResult & { + pathname?: string; + icon?: string; + emoji?: string; + description?: string; +}; + /** * Derive a stable deduplication key for any result type. * Pages from both local and remote share the same `page:` namespace so they * can be deduplicated against each other. */ -export function getResultKey(result: LocalPageResult | OrderedComputedResult): string { +export function getResultKey( + result: LocalPageResult | OrderedComputedResult | MergedPageResult +): string { switch (result.type) { case 'local-page': return `page:${result.id}`; @@ -22,16 +37,18 @@ export function getResultKey(result: LocalPageResult | OrderedComputedResult): s } } +type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult; + /** * Merge local (FlexSearch) and remote (API) search results using * Reciprocal Rank Fusion (RRF). * * RRF formula: score(d) = Σ_i 1 / (k + rank_i(d)) * - * Pages present in both lists are deduplicated by matching the local result's - * `id` against the remote `page` result's `pageId`. Their rank contributions - * from both lists are summed, and the remote result is kept (richer metadata: - * breadcrumbs, spaceId). + * 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. * * Sections and records have no local equivalent and only accumulate their own * rank contribution. @@ -39,12 +56,9 @@ export function getResultKey(result: LocalPageResult | OrderedComputedResult): s export function reciprocalRankFusion( localResults: LocalPageResult[], remoteResults: OrderedComputedResult[] -): Array { +): Array { // Map from dedup key → { result, score } - const scoreMap = new Map< - string, - { result: LocalPageResult | OrderedComputedResult; score: number } - >(); + const scoreMap = new Map(); // Process local results first (1-indexed rank) localResults.forEach((result, index) => { @@ -65,15 +79,24 @@ export function reciprocalRankFusion( const rank = index + 1; const contribution = 1 / (RRF_K + rank); - // Determine the dedup key — pages match via pageId const key = getResultKey(result); const existing = scoreMap.get(key); if (existing) { - // Page found in both lists: credit both rank contributions and - // prefer the remote result which carries richer metadata. + // Page found in both lists: sum rank contributions and deep-merge. + // Local is the base (description, icon, emoji, pathname), remote overrides + // (href, pageId, spaceId, breadcrumbs, title). existing.score += contribution; - existing.result = result; + 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; + } else { + existing.result = result; + } } else { scoreMap.set(key, { result, score: contribution }); } diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 80f5903790..ce06f69d9e 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -10,19 +10,20 @@ import { type Assistant, useAI } from '@/components/AI'; import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; import { isQuestion } from './isQuestion'; -import { getResultKey, reciprocalRankFusion } from './reciprocalRankFusion'; +import { type MergedPageResult, getResultKey, reciprocalRankFusion } from './reciprocalRankFusion'; import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; import type { SearchScope } from './useSearch'; export type ResultType = | OrderedComputedResult | LocalPageResult + | MergedPageResult | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; -export type { LocalPageResult }; +export type { LocalPageResult, MergedPageResult }; -type MergeableResult = LocalPageResult | OrderedComputedResult; +type MergeableResult = LocalPageResult | OrderedComputedResult | MergedPageResult; /** * Append-only merge: local results first, then remote results that aren't