mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 02:53:29 +00:00
Use page descriptions for page search results
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
type RankedPageResult<TResult> = {
|
||||
rank: number | undefined;
|
||||
rank: number;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
@@ -27,16 +27,6 @@ export function orderSearchResultGroups<TResult>(groups: SearchResultGroup<TResu
|
||||
}
|
||||
|
||||
pages.sort((left, right) => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
+5
-16
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<div
|
||||
className={tcls(
|
||||
'relative h-5 w-full transition-[height] duration-300',
|
||||
item.type === 'local-page' && !bestSection?.body && !item.description
|
||||
item.type === 'local-page' && !preview && !description
|
||||
? '[[aria-busy=false]_&]:h-0'
|
||||
: ''
|
||||
: !preview && !description
|
||||
? 'h-0'
|
||||
: ''
|
||||
)}
|
||||
style={{ transitionDelay: style?.animationDelay }}
|
||||
>
|
||||
{bestSection?.body ? (
|
||||
{preview ? (
|
||||
<p
|
||||
className="animate-blur-in absolute inset-0 line-clamp-1 origin-left text-sm"
|
||||
style={{ animationDelay: style?.animationDelay }}
|
||||
>
|
||||
<HighlightQuery
|
||||
query={query}
|
||||
text={`${bestSection.title ? `${bestSection.title} · ` : ''}${bestSection.body}`}
|
||||
/>
|
||||
<HighlightQuery query={query} text={preview} />
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{'description' in item && item.description ? (
|
||||
{description ? (
|
||||
<p
|
||||
className={tcls(
|
||||
'absolute inset-0 line-clamp-1 origin-left text-sm',
|
||||
bestSection?.body ? 'hidden animate-blur-out' : ''
|
||||
preview ? 'hidden animate-blur-out' : ''
|
||||
)}
|
||||
style={{ animationDelay: style?.animationDelay }}
|
||||
>
|
||||
<HighlightQuery query={query} text={item.description} />
|
||||
<HighlightQuery query={query} text={description} />
|
||||
</p>
|
||||
) : 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'
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ComputedPageResult, 'bestSection' | 'href' | 'resultType'>
|
||||
result: Pick<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
|
||||
): 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<ComputedPageResult, 'bestSection' | 'href' | 'resultType'>
|
||||
result: Pick<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
|
||||
): string {
|
||||
return getPageResultPresentation(result).href;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ComputedPageResult, 'breadcrumbs'> & {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user