From 91cb47b8ed5f1ebf56565f56a49986b2c020cd2f Mon Sep 17 00:00:00 2001 From: Johan Preynat Date: Tue, 25 Aug 2026 11:36:16 +0200 Subject: [PATCH] Use page descriptions for page search results --- .../preserve-published-search-ranking.md | 2 +- bun.lock | 4 +- package.json | 2 +- .../~gitbook/search/orderSearchResults.ts | 12 +----- .../[siteData]/~gitbook/search/route.ts | 21 +++------- .../Search/SearchPageResultItem.tsx | 34 +++++++++------- .../Search/combineRemoteResults.test.ts | 1 + .../components/Search/combineRemoteResults.ts | 6 +-- .../Search/getPageResultHref.test.ts | 40 +++++++++++++++---- .../components/Search/getPageResultHref.ts | 8 ++-- .../Search/reciprocalRankFusion.test.ts | 5 ++- .../components/Search/reciprocalRankFusion.ts | 12 +++--- .../src/components/Search/search-types.ts | 6 ++- 13 files changed, 82 insertions(+), 71 deletions(-) diff --git a/.changeset/preserve-published-search-ranking.md b/.changeset/preserve-published-search-ranking.md index 7848b2b57..cfe2ebed8 100644 --- a/.changeset/preserve-published-search-ranking.md +++ b/.changeset/preserve-published-search-ranking.md @@ -2,4 +2,4 @@ "gitbook": patch --- -Preserve canonical backend ranking and page or section destinations in published search results. +Preserve canonical backend ranking and present page or section context that matches each published search destination. diff --git a/bun.lock b/bun.lock index 82752faa5..aea449415 100644 --- a/bun.lock +++ b/bun.lock @@ -354,7 +354,7 @@ }, "catalog": { "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.195.0", + "@gitbook/api": "0.196.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -726,7 +726,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.195.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-66OGIiiKUfHB7dF8fb9Ai8PkhQldD4SSQXHZ+X+SSEki2WXpgn6ze+PEXRQvDsJj2JKEvD9A7FfaGzUYiYn9hA=="], + "@gitbook/api": ["@gitbook/api@0.196.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-14f3LXiZljPuxFtYbsdH9mkXN4jucRBnF3QpSACuACDdvJHpTqGsCe52chg3nj72XBbMjD57BsXGklY+IYz9gw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 7917ffa56..4e3cfc099 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", "@base-ui/react": "^1.7.0", - "@gitbook/api": "0.195.0", + "@gitbook/api": "0.196.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts index 3c4ade565..4d8b579fa 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts @@ -1,5 +1,5 @@ type RankedPageResult = { - rank: number | undefined; + rank: number; result: TResult; }; @@ -27,16 +27,6 @@ export function orderSearchResultGroups(groups: SearchResultGroup { - if (left.rank === undefined && right.rank === undefined) { - return left.inputOrder - right.inputOrder; - } - if (left.rank === undefined) { - return 1; - } - if (right.rank === undefined) { - return -1; - } - return left.rank - right.rank || left.inputOrder - right.inputOrder; }); diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts index 288821156..e93986e63 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts @@ -75,7 +75,7 @@ export async function POST(request: NextRequest) { return { type: 'pages' as const, results: resultItem.pages.map((pageItem) => ({ - rank: getSearchPageRank(pageItem), + rank: pageItem.rank, result: transformSitePageResult({ asEmbeddable: Boolean(asEmbeddable), linker: context.linker, @@ -148,23 +148,17 @@ function transformSitePageResult(args: { ? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path) : linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path)); - // The deployed API already returns this field, but older generated clients and responses do not. - const resultType = - 'resultType' in pageItem && - (pageItem.resultType === 'page' || pageItem.resultType === 'section') - ? pageItem.resultType - : undefined; - const page: ComputedPageResult = { type: 'page', id: `${spaceItem.id}/${pageItem.id}`, title: pageItem.title, + description: pageItem.description, href: pageHref, pageId: pageItem.id, spaceId: spaceItem.id, score: pageItem.score, - rank: getSearchPageRank(pageItem), - resultType, + rank: pageItem.rank, + resultType: pageItem.resultType, breadcrumbs, }; @@ -200,8 +194,7 @@ function transformSitePageResult(args: { }; }) ?? []; - // The search API returns each page's sections ordered highest-score-first and caps them at one - // per page, so the first section is the best-scoring one to use as a body preview. + // The API returns at most one section per page, ordered for use as the section destination preview. const bestSection = pageSections[0]; if (bestSection) { page.bestSection = { @@ -214,7 +207,3 @@ function transformSitePageResult(args: { return page; } - -function getSearchPageRank(page: SearchPageResult): number | undefined { - return 'rank' in page && typeof page.rank === 'number' ? page.rank : undefined; -} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 76c03cd8d..324429a6e 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -28,14 +28,21 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const { query, item, active, style, ...rest } = props; const language = useLanguage(); - const { bestSection, href } = (() => { + const { bestSection, description, href } = (() => { if (item.type === 'page') { const presentation = getPageResultPresentation(item); - return { bestSection: presentation.preview, href: presentation.href }; + return { + bestSection: presentation.preview, + description: presentation.description, + href: presentation.href, + }; } - return { bestSection: undefined, href: item.pathname }; + return { bestSection: undefined, description: item.description, href: item.pathname }; })(); + const preview = bestSection + ? [bestSection.title, bestSection.body].filter(Boolean).join(' · ') + : undefined; const emoji = 'emoji' in item ? item.emoji : undefined; const icon = 'icon' in item ? item.icon : undefined; @@ -84,33 +91,32 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
- {bestSection?.body ? ( + {preview ? (

- +

) : null} - {'description' in item && item.description ? ( + {description ? (

- +

) : null} @@ -120,7 +126,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt lines={1} className={tcls( 'absolute inset-0 origin-left', - bestSection?.body || item.description + preview || description ? 'hidden animate-blur-out' : '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out' )} diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.test.ts b/packages/gitbook/src/components/Search/combineRemoteResults.test.ts index 293f2b672..adbfcc23e 100644 --- a/packages/gitbook/src/components/Search/combineRemoteResults.test.ts +++ b/packages/gitbook/src/components/Search/combineRemoteResults.test.ts @@ -10,6 +10,7 @@ function page(title: string, rank: number, score: number, spaceId: string): Comp pageId: title, spaceId, title, + description: `Description for ${title}`, href: `/${title}`, rank, score, diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.ts b/packages/gitbook/src/components/Search/combineRemoteResults.ts index ea6bc5f45..35822fe34 100644 --- a/packages/gitbook/src/components/Search/combineRemoteResults.ts +++ b/packages/gitbook/src/components/Search/combineRemoteResults.ts @@ -29,19 +29,15 @@ export function combineRemoteResults( return [...rankedPages.map(({ result }) => result), ...context]; function addResults(results: OrderedComputedResult[], weight: number) { - let pageIndex = 0; - for (const result of results) { if (result.type === 'record') { context.push(result); continue; } - pageIndex += 1; - const rank = result.rank ?? pageIndex; rankedPages.push({ result, - fusionScore: weight / (RRF_K + rank), + fusionScore: weight / (RRF_K + result.rank), inputOrder: rankedPages.length, }); } diff --git a/packages/gitbook/src/components/Search/getPageResultHref.test.ts b/packages/gitbook/src/components/Search/getPageResultHref.test.ts index a7e8f2e0d..8bca20cdc 100644 --- a/packages/gitbook/src/components/Search/getPageResultHref.test.ts +++ b/packages/gitbook/src/components/Search/getPageResultHref.test.ts @@ -4,6 +4,7 @@ import { getPageResultHref, getPageResultPresentation } from './getPageResultHre const pageResult = { href: '/operations/analyst-guidance/understanding-vectra-ai-detections', + description: 'Learn how Vectra AI detections help analysts investigate threats.', bestSection: { href: '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note', title: 'Please note', @@ -16,7 +17,7 @@ describe('getPageResultHref', () => { it('links Vectra page matches to the page root without presenting a section destination', () => { expect(getPageResultPresentation({ ...pageResult, resultType: 'page' })).toEqual({ href: '/operations/analyst-guidance/understanding-vectra-ai-detections', - preview: { body: 'Individual detections are no longer scored.' }, + description: 'Learn how Vectra AI detections help analysts investigate threats.', }); }); @@ -37,15 +38,24 @@ describe('getPageResultHref', () => { }); it('links to the page when no section preview is available', () => { - expect(getPageResultHref({ href: '/getting-started', resultType: 'section' })).toBe( - '/getting-started' - ); + expect( + getPageResultPresentation({ + href: '/getting-started', + description: 'Start using the product.', + resultType: 'section', + }) + ).toEqual({ + href: '/getting-started', + description: 'Start using the product.', + preview: undefined, + }); }); - it('links a section match to its anchor when it only has a heading', () => { + it('links a section match to its anchor and presents its heading without the page description', () => { expect( - getPageResultHref({ + getPageResultPresentation({ href: '/getting-started', + description: 'Start using the product.', resultType: 'section', bestSection: { href: '/getting-started#requirements', @@ -53,6 +63,22 @@ describe('getPageResultHref', () => { score: 1, }, }) - ).toBe('/getting-started#requirements'); + ).toEqual({ + href: '/getting-started#requirements', + description: undefined, + preview: { + title: 'Requirements', + body: undefined, + }, + }); + }); + + it('does not replace an empty page description with arbitrary section content', () => { + expect( + getPageResultPresentation({ ...pageResult, description: '', resultType: 'page' }) + ).toEqual({ + href: '/operations/analyst-guidance/understanding-vectra-ai-detections', + description: '', + }); }); }); diff --git a/packages/gitbook/src/components/Search/getPageResultHref.ts b/packages/gitbook/src/components/Search/getPageResultHref.ts index ee85c1054..abb8dd0f4 100644 --- a/packages/gitbook/src/components/Search/getPageResultHref.ts +++ b/packages/gitbook/src/components/Search/getPageResultHref.ts @@ -2,6 +2,7 @@ import type { ComputedPageResult } from './search-types'; type PageResultPresentation = { href: string; + description?: string; preview?: { title?: string; body?: string; @@ -10,19 +11,20 @@ type PageResultPresentation = { /** Keep the displayed preview consistent with the result's single click destination. */ export function getPageResultPresentation( - result: Pick + result: Pick ): PageResultPresentation { const { bestSection } = result; if (result.resultType === 'page') { return { href: result.href, - preview: bestSection?.body ? { body: bestSection.body } : undefined, + description: result.description, }; } return { href: bestSection?.href ?? result.href, + description: bestSection ? undefined : result.description, preview: bestSection ? { title: bestSection.title, @@ -33,7 +35,7 @@ export function getPageResultPresentation( } export function getPageResultHref( - result: Pick + result: Pick ): string { return getPageResultPresentation(result).href; } diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts index 4ad9f28b2..8548ec123 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts @@ -15,13 +15,14 @@ function localPage(id: string, title = id): LocalPageResult { }; } -function remotePage(id: string, title = id, score = 0, rank?: number): OrderedComputedResult { +function remotePage(id: string, title = id, score = 0, rank = 1): OrderedComputedResult { return { type: 'page', id: `remote-${id}`, pageId: id, spaceId: 'space', title, + description: `Remote description for ${title}`, href: `/${id}`, score, rank, @@ -113,7 +114,7 @@ describe('reciprocalRankFusion', () => { expect(pinnedResult.type).toBe('page'); expect(pinnedResult.title).toBe('Remote title'); expect(pinnedResult.pathname).toBe('/remote-1'); - expect(pinnedResult.description).toBe('Local description for Local title'); + expect(pinnedResult.description).toBe('Remote description for Remote title'); expect(pinnedResult.rank).toBe(1); expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]); expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1); diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index 6b6a5e024..38a2faef0 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -71,15 +71,14 @@ const PINNED_REMOTE_RESULTS_COUNT = 3; /** * 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. + * Local fields (icon, emoji, pathname) are carried over as a base, and remote fields + * (description, href, pageId, spaceId, title) override them. * Breadcrumbs prefer local (has icon + emoji) and fall back to remote. */ export type MergedPageResult = Omit & { pathname?: string; icon?: string; emoji?: string; - description?: string; breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs']; }; @@ -140,8 +139,8 @@ function mergePinnedRemoteResult( * * The pinned remote results are excluded from fusion and returned first. Pages * present in both lists are deep-merged: local fields act as the base - * (preserving description, icon, emoji, pathname) and remote fields override - * (providing href, pageId, spaceId, title). Breadcrumbs prefer local (has icon + * (preserving icon, emoji, pathname) and remote fields override (providing the + * authoritative page description, href, pageId, spaceId, and title). Breadcrumbs prefer local (has icon * + emoji) and fall back to remote. In the fused tail, their rank contributions * from both lists are summed. * @@ -195,8 +194,7 @@ export function reciprocalRankFusion( const existing = scoreMap.get(key); if (existing) { // 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). + // Local is the base (icon, emoji, pathname), while remote provides page fields. existing.score += contribution; if (existing.result.type === 'local-page' && result.type === 'page') { existing.result = mergeLocalPageWithRemotePage(existing.result, result); diff --git a/packages/gitbook/src/components/Search/search-types.ts b/packages/gitbook/src/components/Search/search-types.ts index ccdb050e4..b18a0742b 100644 --- a/packages/gitbook/src/components/Search/search-types.ts +++ b/packages/gitbook/src/components/Search/search-types.ts @@ -20,12 +20,14 @@ export type ComputedPageResult = BaseComputedResult & { type: 'page'; pageId: string; spaceId: string; + /** Page-level description for a page-root search destination. */ + description: string; /** Canonical one-based relevance position assigned by the search backend. */ - rank?: number; + rank: number; /** Whether the page matched on its own fields or on one of its sections. */ resultType?: 'page' | 'section'; breadcrumbs?: { icon?: IconName; label: string }[]; - /** The highest-scoring section for this page, used as a body snippet preview. */ + /** The highest-scoring section for this page, used for a section destination preview. */ bestSection?: { href: string; title?: string;