mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-19 17:15:24 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dad9c05bc6 | |||
| 9f3f8cacfd | |||
| bd2954a368 | |||
| 91cb47b8ed | |||
| 5eac5d2abf | |||
| 83a5ebc4c2 | |||
| c89774e1ed |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Preserve canonical backend ranking and present page or section context that matches each published search destination.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { orderSearchResultGroups } from './orderSearchResults';
|
||||
|
||||
describe('orderSearchResultGroups', () => {
|
||||
it('reconstructs canonical page ranks across space groups before context records', () => {
|
||||
const results = orderSearchResultGroups([
|
||||
{
|
||||
type: 'pages',
|
||||
results: [
|
||||
{
|
||||
rank: 4,
|
||||
result: { title: 'Snyk CLI documentation', score: 42, rank: 4 },
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
result: { title: 'Types of automations', score: 12, rank: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
results: [{ title: 'Snyk integration context', score: 100, rank: undefined }],
|
||||
},
|
||||
{
|
||||
type: 'pages',
|
||||
results: [
|
||||
{
|
||||
rank: 1,
|
||||
result: { title: 'Snyk CLI', score: 1, rank: 1 },
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
result: { title: 'Method URL', score: 24, rank: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results.map(({ title }) => title)).toEqual([
|
||||
'Snyk CLI',
|
||||
'Types of automations',
|
||||
'Method URL',
|
||||
'Snyk CLI documentation',
|
||||
'Snyk integration context',
|
||||
]);
|
||||
expect(results[0]?.score).toBe(1);
|
||||
expect(results[3]?.score).toBe(42);
|
||||
expect(results.map(({ rank }) => rank)).toEqual([1, 2, 3, 4, undefined]);
|
||||
});
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
type RankedPageResult<TResult> = {
|
||||
rank: number;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
export type SearchResultGroup<TResult> =
|
||||
| { type: 'pages'; results: RankedPageResult<TResult>[] }
|
||||
| { type: 'context'; results: TResult[] };
|
||||
|
||||
/** Reconstruct the backend's global page order after it grouped results by space. */
|
||||
export function orderSearchResultGroups<TResult>(groups: SearchResultGroup<TResult>[]): TResult[] {
|
||||
const pages: (RankedPageResult<TResult> & { inputOrder: number })[] = [];
|
||||
const context: TResult[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
if (group.type === 'pages') {
|
||||
const inputOffset = pages.length;
|
||||
pages.push(
|
||||
...group.results.map((entry, index) => ({
|
||||
...entry,
|
||||
inputOrder: inputOffset + index,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
context.push(...group.results);
|
||||
}
|
||||
}
|
||||
|
||||
pages.sort((left, right) => {
|
||||
return left.rank - right.rank || left.inputOrder - right.inputOrder;
|
||||
});
|
||||
|
||||
return [...pages.map(({ result }) => result), ...context];
|
||||
}
|
||||
+26
-29
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
import { orderSearchResultGroups } from './orderSearchResults';
|
||||
import type {
|
||||
ComputedPageResult,
|
||||
ComputedSectionResult,
|
||||
@@ -51,8 +52,8 @@ export async function POST(request: NextRequest) {
|
||||
),
|
||||
]);
|
||||
|
||||
const results = searchResults
|
||||
.flatMap((resultItem) => {
|
||||
const results = orderSearchResultGroups(
|
||||
searchResults.map((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
@@ -63,7 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
score: resultItem.score,
|
||||
};
|
||||
|
||||
return [{ score: resultItem.score, items: [result] }];
|
||||
return { type: 'context' as const, results: [result] };
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
@@ -71,21 +72,23 @@ export async function POST(request: NextRequest) {
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
|
||||
return resultItem.pages.map((pageItem) => ({
|
||||
score: pageItem.score,
|
||||
items: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: context.linker,
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
siteSection: found?.siteSection ?? undefined,
|
||||
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||
}),
|
||||
}));
|
||||
return {
|
||||
type: 'pages' as const,
|
||||
results: resultItem.pages.map((pageItem) => ({
|
||||
rank: pageItem.rank,
|
||||
result: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: context.linker,
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
siteSection: found?.siteSection ?? undefined,
|
||||
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.flatMap((group) => group.items);
|
||||
);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
@@ -98,7 +101,7 @@ function transformSitePageResult(args: {
|
||||
siteSpace?: SiteSpace;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup | null;
|
||||
}): OrderedComputedResult[] {
|
||||
}): OrderedComputedResult {
|
||||
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||
args;
|
||||
const currentLanguage = siteSpace?.space.language;
|
||||
@@ -145,22 +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,
|
||||
resultType,
|
||||
rank: pageItem.rank,
|
||||
resultType: pageItem.resultType,
|
||||
breadcrumbs,
|
||||
};
|
||||
|
||||
@@ -196,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 = {
|
||||
@@ -208,5 +205,5 @@ function transformSitePageResult(args: {
|
||||
};
|
||||
}
|
||||
|
||||
return [page];
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { SkeletonParagraph } from '../primitives';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { Emoji } from '../primitives/Emoji/Emoji';
|
||||
import { getPageResultHref } from './getPageResultHref';
|
||||
import { getPageResultPresentation } from './getPageResultHref';
|
||||
import { HighlightQuery } from './HighlightQuery';
|
||||
import type { MergedPageResult } from './reciprocalRankFusion';
|
||||
import type { ComputedPageResult } from './search-types';
|
||||
@@ -28,8 +28,21 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
const { query, item, active, style, ...rest } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const bestSection = item.type === 'page' ? item.bestSection : undefined;
|
||||
const href = item.type === 'page' ? getPageResultHref(item) : item.pathname;
|
||||
const { bestSection, description, href } = (() => {
|
||||
if (item.type === 'page') {
|
||||
const presentation = getPageResultPresentation(item);
|
||||
return {
|
||||
bestSection: presentation.preview,
|
||||
description: presentation.description,
|
||||
href: presentation.href,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -78,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}
|
||||
|
||||
@@ -114,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'
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,84 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { getPageResultHref } from './getPageResultHref';
|
||||
import { getPageResultHref, getPageResultPresentation } from './getPageResultHref';
|
||||
|
||||
const pageResult = {
|
||||
href: '/getting-started',
|
||||
href: '/operations/analyst-guidance/understanding-vectra-ai-detections',
|
||||
description: 'Learn how Vectra AI detections help analysts investigate threats.',
|
||||
bestSection: {
|
||||
href: '/getting-started#installation',
|
||||
title: 'Installation',
|
||||
body: 'Install the application.',
|
||||
href: '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note',
|
||||
title: 'Please note',
|
||||
body: 'Individual detections are no longer scored.',
|
||||
score: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('getPageResultHref', () => {
|
||||
it('links page matches to the top of the page', () => {
|
||||
expect(getPageResultHref({ ...pageResult, resultType: 'page' })).toBe('/getting-started');
|
||||
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',
|
||||
description: 'Learn how Vectra AI detections help analysts investigate threats.',
|
||||
});
|
||||
});
|
||||
|
||||
it('links section matches to the matching section', () => {
|
||||
expect(getPageResultHref({ ...pageResult, resultType: 'section' })).toBe(
|
||||
'/getting-started#installation'
|
||||
);
|
||||
it('links Vectra section matches to the matching section and presents its preview', () => {
|
||||
expect(getPageResultPresentation({ ...pageResult, resultType: 'section' })).toEqual({
|
||||
href: '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note',
|
||||
preview: {
|
||||
title: 'Please note',
|
||||
body: 'Individual detections are no longer scored.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves anchored links when the result type is absent', () => {
|
||||
expect(getPageResultHref(pageResult)).toBe('/getting-started#installation');
|
||||
expect(getPageResultHref(pageResult)).toBe(
|
||||
'/operations/analyst-guidance/understanding-vectra-ai-detections#please-note'
|
||||
);
|
||||
});
|
||||
|
||||
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 and presents its heading without the page description', () => {
|
||||
expect(
|
||||
getPageResultPresentation({
|
||||
href: '/getting-started',
|
||||
description: 'Start using the product.',
|
||||
resultType: 'section',
|
||||
bestSection: {
|
||||
href: '/getting-started#requirements',
|
||||
title: 'Requirements',
|
||||
score: 1,
|
||||
},
|
||||
})
|
||||
).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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import type { ComputedPageResult } from './search-types';
|
||||
|
||||
/** Return the page top for page matches and the preview anchor for section matches. */
|
||||
export function getPageResultHref(
|
||||
result: Pick<ComputedPageResult, 'bestSection' | 'href' | 'resultType'>
|
||||
): string {
|
||||
type PageResultPresentation = {
|
||||
href: string;
|
||||
description?: string;
|
||||
preview?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Keep the displayed preview consistent with the result's single click destination. */
|
||||
export function getPageResultPresentation(
|
||||
result: Pick<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
|
||||
): PageResultPresentation {
|
||||
const { bestSection } = result;
|
||||
|
||||
if (result.resultType === 'page') {
|
||||
return result.href;
|
||||
return {
|
||||
href: result.href,
|
||||
description: result.description,
|
||||
};
|
||||
}
|
||||
|
||||
return result.bestSection?.body ? result.bestSection.href : result.href;
|
||||
return {
|
||||
href: bestSection?.href ?? result.href,
|
||||
description: bestSection ? undefined : result.description,
|
||||
preview: bestSection
|
||||
? {
|
||||
title: bestSection.title,
|
||||
body: bestSection.body,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPageResultHref(
|
||||
result: Pick<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
|
||||
): string {
|
||||
return getPageResultPresentation(result).href;
|
||||
}
|
||||
|
||||
@@ -15,15 +15,17 @@ function localPage(id: string, title = id): LocalPageResult {
|
||||
};
|
||||
}
|
||||
|
||||
function remotePage(id: string, title = id, score = 0): 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,
|
||||
breadcrumbs: [{ label: 'Remote' }],
|
||||
};
|
||||
}
|
||||
@@ -101,7 +103,7 @@ describe('reciprocalRankFusion', () => {
|
||||
const results = reciprocalRankFusion(
|
||||
[localPage('remote-1', 'Local title')],
|
||||
[
|
||||
remotePage('remote-1', 'Remote title'),
|
||||
remotePage('remote-1', 'Remote title', 1, 1),
|
||||
remotePage('remote-2'),
|
||||
remotePage('remote-3'),
|
||||
],
|
||||
@@ -112,7 +114,8 @@ 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);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { computeRemoteSearchScope } from './remote-scope';
|
||||
|
||||
describe('computeRemoteSearchScope', () => {
|
||||
it('requests one globally ranked result set for a multi-section default search', () => {
|
||||
expect(
|
||||
computeRemoteSearchScope('default', 'snyk-discover', [
|
||||
'snyk-discover',
|
||||
'snyk-developer-tools',
|
||||
])
|
||||
).toEqual({
|
||||
mode: 'current',
|
||||
siteSpaceId: 'snyk-discover',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import type { SearchSiteContentScope } from './search-types';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
/** Map the UI scope to one backend request so every returned rank is globally comparable. */
|
||||
export function computeRemoteSearchScope(
|
||||
scope: SearchScope,
|
||||
siteSpaceId: string,
|
||||
siteSpaceIds: string[]
|
||||
): SearchSiteContentScope {
|
||||
switch (scope) {
|
||||
case 'all':
|
||||
return { mode: 'all' };
|
||||
case 'default':
|
||||
return { mode: 'current', siteSpaceId };
|
||||
case 'extended':
|
||||
return { mode: 'specific', siteSpaceIds };
|
||||
case 'current':
|
||||
return { mode: 'specific', siteSpaceIds: [siteSpaceId] };
|
||||
default:
|
||||
assertNever(scope);
|
||||
}
|
||||
}
|
||||
@@ -20,10 +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;
|
||||
/** 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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
import { computeFilterSiteSpaceIds } from './filter';
|
||||
import { useRecentSearchQueries } from './recent-queries';
|
||||
import { type MergedPageResult, reciprocalRankFusion } from './reciprocalRankFusion';
|
||||
import { computeRemoteSearchScope } from './remote-scope';
|
||||
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
|
||||
import { streamRecommendedQuestions } from './server-actions';
|
||||
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
|
||||
@@ -26,9 +26,6 @@ export type ResultType =
|
||||
|
||||
export type { LocalPageResult, MergedPageResult };
|
||||
|
||||
// Score multiplier for current site space results when combined with those from other site spaces
|
||||
const CURRENT_SITE_SPACE_SCORE_MULTIPLIER = 2;
|
||||
|
||||
// Small helper extracted for unit testing of scope → local filter mapping
|
||||
// computeFilterSiteSpaceIds is imported from './filter' for testability
|
||||
|
||||
@@ -92,10 +89,9 @@ export function useSearchResults(props: {
|
||||
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
otherSpacesResults: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], otherSpacesResults: [], fetching: false, error: false });
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
|
||||
// Track the current in-flight fetch so it can be aborted imperatively
|
||||
// when the user navigates away before the request completes.
|
||||
@@ -113,7 +109,6 @@ export function useSearchResults(props: {
|
||||
if (!withAI) {
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
@@ -129,7 +124,6 @@ export function useSearchResults(props: {
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
@@ -138,7 +132,6 @@ export function useSearchResults(props: {
|
||||
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
@@ -156,7 +149,6 @@ export function useSearchResults(props: {
|
||||
});
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
@@ -187,7 +179,6 @@ export function useSearchResults(props: {
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
@@ -202,7 +193,6 @@ export function useSearchResults(props: {
|
||||
}
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
@@ -215,81 +205,24 @@ export function useSearchResults(props: {
|
||||
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
|
||||
|
||||
try {
|
||||
// Each scope resolves to a primary search request and, for the default scope
|
||||
// on a multi-section site, a secondary request for the other site spaces
|
||||
const { resultsPromise, otherSpacesResultsPromise } = ((): {
|
||||
resultsPromise: Promise<OrderedComputedResult[]>;
|
||||
otherSpacesResultsPromise?: Promise<OrderedComputedResult[]>;
|
||||
} => {
|
||||
switch (scope) {
|
||||
case 'all':
|
||||
// Search all content on the site
|
||||
return { resultsPromise: fetchSearch({ mode: 'all' }) };
|
||||
case 'default':
|
||||
// Search the current section's variant + matched/default variant for other sections.
|
||||
// Without sections, the scope resolves to the current site space alone, so a
|
||||
// second request restricted to the other site spaces would be redundant.
|
||||
if (!withSections) {
|
||||
return {
|
||||
resultsPromise: fetchSearch({ mode: 'current', siteSpaceId }),
|
||||
};
|
||||
}
|
||||
const resultsPromise = fetchSearch(
|
||||
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds)
|
||||
);
|
||||
|
||||
// Split into two parallel requests so the (smaller, faster) current site
|
||||
// space results can be shown while the other site spaces are still being searched.
|
||||
return {
|
||||
resultsPromise: fetchSearch({
|
||||
mode: 'current',
|
||||
siteSpaceId,
|
||||
restrictTo: 'currentSiteSpace',
|
||||
}),
|
||||
otherSpacesResultsPromise: fetchSearch({
|
||||
mode: 'current',
|
||||
siteSpaceId,
|
||||
restrictTo: 'otherSiteSpaces',
|
||||
}),
|
||||
};
|
||||
case 'extended':
|
||||
// Search all variants of the current section
|
||||
return {
|
||||
resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }),
|
||||
};
|
||||
case 'current':
|
||||
// Search only the current section's current variant
|
||||
return {
|
||||
resultsPromise: fetchSearch({
|
||||
mode: 'specific',
|
||||
siteSpaceIds: [siteSpaceId],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
assertNever(scope);
|
||||
const onResults = (results: OrderedComputedResult[]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
// Render each result set as soon as its response arrives; a failed
|
||||
// request reports an error without discarding the other result set.
|
||||
let tracked = false;
|
||||
const onResults =
|
||||
(key: 'results' | 'otherSpacesResults') =>
|
||||
(results: OrderedComputedResult[]) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// Can happen when the route cannot be found and returns the page's html.
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState((prev) => ({ ...prev, [key]: results }));
|
||||
|
||||
if (!tracked) {
|
||||
tracked = true;
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
}
|
||||
};
|
||||
setRemoteState((prev) => ({ ...prev, results }));
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
};
|
||||
const onError = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -297,10 +230,7 @@ export function useSearchResults(props: {
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
resultsPromise.then(onResults('results'), onError),
|
||||
otherSpacesResultsPromise?.then(onResults('otherSpacesResults'), onError),
|
||||
]);
|
||||
await resultsPromise.then(onResults, onError);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -313,7 +243,6 @@ export function useSearchResults(props: {
|
||||
}
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: true,
|
||||
});
|
||||
@@ -343,7 +272,6 @@ export function useSearchResults(props: {
|
||||
suggestions,
|
||||
searchURL,
|
||||
asEmbeddable,
|
||||
withSections,
|
||||
]);
|
||||
|
||||
const abort = React.useCallback(() => {
|
||||
@@ -370,23 +298,8 @@ export function useSearchResults(props: {
|
||||
});
|
||||
}
|
||||
|
||||
return reciprocalRankFusion(
|
||||
localResults,
|
||||
remoteState.otherSpacesResults.length > 0
|
||||
? combineRemoteResults(remoteState.results, remoteState.otherSpacesResults)
|
||||
: remoteState.results,
|
||||
query
|
||||
);
|
||||
}, [
|
||||
localResults,
|
||||
remoteState.results,
|
||||
remoteState.otherSpacesResults,
|
||||
query,
|
||||
withAI,
|
||||
siteSpaceId,
|
||||
suggestions,
|
||||
recentQueries,
|
||||
]);
|
||||
return reciprocalRankFusion(localResults, remoteState.results, query);
|
||||
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
|
||||
|
||||
return {
|
||||
results,
|
||||
@@ -423,16 +336,3 @@ async function fetchSearchResults(
|
||||
|
||||
return response.json() as Promise<OrderedComputedResult[]>;
|
||||
}
|
||||
|
||||
function combineRemoteResults(
|
||||
remoteResultsCurrentSpace: OrderedComputedResult[],
|
||||
remoteResultsOtherSpaces: OrderedComputedResult[]
|
||||
): OrderedComputedResult[] {
|
||||
return [
|
||||
...remoteResultsCurrentSpace.map((result) => ({
|
||||
...result,
|
||||
score: result.score * CURRENT_SITE_SPACE_SCORE_MULTIPLIER,
|
||||
})),
|
||||
...remoteResultsOtherSpaces,
|
||||
].sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user