Compare commits

...

11 Commits

Author SHA1 Message Date
Peter White bfc03fb15b changeset 2026-11-14 07:42:31 +00:00
Peter White 404c65844a Search the API directly from the browser with a search pass
When the site's search warm-up reply carries a search pass, the browser
sends searches to the search API with it and builds the same result rows
as the search route. Any failure sends that search to the route once and
keeps the page on the route. Pages without a pass keep using the route.
2026-11-14 07:42:28 +00:00
Peter White f55dc464f6 changeset 2026-11-13 15:56:01 +00:00
Peter White ab39dc74c8 Send each search once on sites without a top header
Only the search container shown on the current viewport sends requests
and logs search_type_query. Its visibility follows the lg breakpoint the
header and sidebar CSS use, so search works between 768 and 1023 px. A
hidden container drops its pending Enter.
2026-11-13 15:56:01 +00:00
Peter White 35148805e7 changeset 2026-11-13 12:57:31 +00:00
Peter White 36154a2d86 Show result-shaped placeholders while search loads 2026-11-13 12:57:31 +00:00
Peter White a018e930de Lower the search idle delay to 150 ms 2026-11-13 12:07:13 +00:00
Peter White d596bdc43b Search IME composition text like typing and send on commit 2026-11-13 11:59:40 +00:00
Peter White b1dc336f00 changeset 2026-11-13 11:50:20 +00:00
Peter White a92f11b9dd Merge remote-tracking branch 'origin/main' into peter/search-server-list 2026-11-13 11:50:19 +00:00
Peter White 1f656f1713 Show one server-ordered search list and open its top result on Enter 2026-11-13 11:50:19 +00:00
78 changed files with 1945 additions and 1439 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Send site searches straight to the search API when the site's search warm-up hands out a search pass, and fall back to the site's search route once on any failure.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Send each search once on sites without a top header, and make search work between 768 and 1023 px wide.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show loading rows shaped like search results while search waits for the server.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show one server-ordered search list and open its top result on Enter.
@@ -1,13 +1,12 @@
import { type NextRequest, NextResponse } from 'next/server';
import { orderSearchResultGroups } from './orderSearchResults';
import type {
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { SearchSiteContentRequest } from '@/components/Search/search-types';
import { throwIfDataError } from '@/lib/data';
import { getLogger } from '@/lib/logger';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { transformSitePageResult } from '@/lib/search';
import { SEARCH_FALLBACK_HEADER, SEARCH_FALLBACK_REASONS } from '@/lib/search-pass';
import { computeSearchResults } from '@/lib/search-results';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
@@ -18,6 +17,8 @@ export async function POST(request: NextRequest) {
getSiteURLDataFromMiddleware(),
]);
logSearchFallback(request, siteURLData);
if (query.length <= 1) {
return NextResponse.json([]);
}
@@ -56,49 +57,42 @@ export async function POST(request: NextRequest) {
? getLinkerForSiteSpace(context.linker, currentSiteSpace.siteSpace, revision.pages)
: context.linker;
const results = orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
score: resultItem.score,
};
const results = computeSearchResults(searchResults, (spaceItem, pageItem) => {
const isCurrentRevisionSpace = Boolean(revision && spaceItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(structure, (siteSpace) => siteSpace.space.id === spaceItem.id);
return { type: 'context' as const, results: [result] };
}
const isCurrentRevisionSpace = Boolean(revision && resultItem.id === siteURLData.space);
const found =
isCurrentRevisionSpace && currentSiteSpace
? currentSiteSpace
: findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
return {
type: 'pages' as const,
results: resultItem.pages.flatMap((pageItem) => {
const result = transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
return result ? [{ rank: pageItem.rank, result }] : [];
}),
};
})
);
return transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: isCurrentRevisionSpace ? revisionLinker : context.linker,
pageItem,
spaceItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
revisionPages: isCurrentRevisionSpace ? revision?.pages : undefined,
});
});
return NextResponse.json(results);
}
/** Log the reason a search reached the route after the browser gave up its search pass. */
function logSearchFallback(
request: NextRequest,
siteURLData: { organization: string; site: string }
) {
const header = request.headers.get(SEARCH_FALLBACK_HEADER);
if (!header) {
return;
}
const reason = SEARCH_FALLBACK_REASONS.find((candidate) => candidate === header) ?? 'unknown';
getLogger()
.subLogger('search')
.info(
`Search pass fallback to the route (reason=${reason}, organization=${siteURLData.organization}, site=${siteURLData.site})`
);
}
@@ -12,6 +12,7 @@ import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
import { EmbeddableThemeSync } from './EmbeddableThemeSync';
import { IfEmbeddableTrademark } from './EmbeddableTrademark';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getDirectSearchProps, getSearchPassKey } from '@/components/Search/search-props';
import {
SiteLayoutClientContexts,
generateSiteLayoutMetadata,
@@ -60,6 +61,8 @@ export async function EmbeddableRootLayout({
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
searchPrewarmURL={context.linker.toPathInSite('~gitbook/search-prewarm')}
searchPassKey={getSearchPassKey(context)}
directSearch={getDirectSearchProps(context)}
>
{/* Persist an explicit ?theme= override so it survives tab navigation. RND-11571 */}
<EmbeddableThemeSync forcedTheme={rememberedTheme} />
@@ -50,6 +50,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
query,
results,
@@ -89,6 +90,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={true}
@@ -1,96 +0,0 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '../primitives/Emoji/Emoji';
import { Link } from '../primitives/Link';
import type { LocalPageResult } from './useLocalSearchResults';
import { tcls } from '@/lib/tailwind';
/**
* 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 (
<div
className={tcls(
'py-2',
'flex',
'flex-row',
'gap-2',
fetching
? 'flex-wrap'
: ['overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden']
)}
>
{results.map((result) => (
<LocalSearchResultCard key={result.id} result={result} fetching={fetching} />
))}
</div>
);
}
function LocalSearchResultCard({
result,
fetching,
}: {
result: LocalPageResult;
fetching: boolean;
}) {
return (
<Link
href={result.pathname}
className={tcls(
'group',
'flex',
'flex-col',
'gap-1',
'p-3',
'rounded-corners:rounded-lg',
'circular-corners:rounded-2xl',
'text-tint',
'bg-tint-subtle',
'hover:bg-tint',
'hover:text-tint-strong',
'transition-colors',
'cursor-pointer',
fetching ? ['basis-[calc(50%-4px)]', 'min-w-0'] : ['w-40', 'shrink-0']
)}
>
<div className={tcls('flex', 'flex-row', 'items-center', 'gap-1.5', 'min-w-0')}>
{result.emoji ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Emoji code={result.emoji} style="text-base leading-none" />
</span>
) : result.icon ? (
<span className="size-4 shrink-0 text-tint-subtle">
<Icon icon={result.icon as IconName} className="size-4" />
</span>
) : null}
<p className="grow truncate font-heading text-sm font-semibold leading-snug text-tint-strong">
{result.title}
</p>
<span className="ml-auto shrink-0 text-tint-subtle">
<Icon icon="chevron-right" className="size-3" />
</span>
</div>
{result.description ? (
<p className="line-clamp-2 text-xs leading-snug">{result.description}</p>
) : null}
</Link>
);
}
@@ -43,6 +43,9 @@ export function SearchContainer({
const [searchPopup, setSearchPopup] = React.useState<HTMLDivElement | null>(null);
const language = useLanguage();
const usesSideSheet = useIsMobile(768);
// The header and sidebar copies are shown and hidden by CSS at the `lg` breakpoint.
const belowLg = useIsMobile();
const visible = viewport === 'desktop' ? !belowLg : viewport === 'mobile' ? belowLg : true;
const {
assistants,
askQuery,
@@ -53,6 +56,7 @@ export function SearchContainer({
error,
fetching,
onInputKeyDown,
inputHandlers,
onResultSelect,
open,
query,
@@ -66,7 +70,7 @@ export function SearchContainer({
withAI,
withSearchAI,
scopeControl,
} = useSearchController(searchProps);
} = useSearchController(searchProps, { requestsEnabled: visible });
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
useHotkeys(
@@ -114,8 +118,6 @@ export function SearchContainer({
};
}, [close]);
const visible =
viewport === 'desktop' ? !usesSideSheet : viewport === 'mobile' ? usesSideSheet : true;
const searchResultsActiveDescendant =
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
const isSearchOpen = Boolean(visible && (state?.open ?? false));
@@ -149,6 +151,7 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -215,6 +218,7 @@ export function SearchContainer({
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
{...inputHandlers}
value={searchValue}
withAI={withSearchAI}
isOpen={isSearchOpen}
@@ -13,6 +13,8 @@ interface SearchInputProps {
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: () => void;
onInput?: (event: React.FormEvent<HTMLInputElement>) => void;
onCompositionEnd?: (event: React.CompositionEvent<HTMLInputElement>) => void;
value: string;
withAI: boolean;
isOpen: boolean;
@@ -2,24 +2,18 @@ import React from 'react';
import { Icon, type IconName } from '@gitbook/icons';
import { SkeletonParagraph } from '../primitives';
import { Tooltip } from '../primitives';
import { Emoji } from '../primitives/Emoji/Emoji';
import { getPageResultPresentation } from './getPageResultHref';
import { HighlightQuery } from './HighlightQuery';
import type { MergedPageResult } from './reciprocalRankFusion';
import type { ComputedPageResult } from './search-types';
import { SearchResultItem } from './SearchResultItem';
import type { LocalPageResult } from './useLocalSearchResults';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
type PageItem = ComputedPageResult | LocalPageResult | MergedPageResult;
export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem(
props: {
query: string;
item: PageItem;
item: ComputedPageResult;
active: boolean;
style?: React.CSSProperties;
},
@@ -28,46 +22,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
const { query, item, active, style, ...rest } = props;
const language = useLanguage();
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, description, href } = getPageResultPresentation(item);
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;
const leadingIcon = emoji ? (
<span className="flex size-4 shrink-0 items-center justify-center">
<Emoji code={emoji} style="text-base leading-none" />
</span>
) : icon ? (
<Icon icon={icon as IconName} className="size-4" />
) : (
<Icon icon="file-lines" className="size-4" />
);
const insights =
item.type === 'page'
? {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
}
: undefined;
const insights = {
type: 'search_open_result' as const,
query,
result: {
pageId: item.pageId,
spaceId: item.spaceId,
},
};
return (
<SearchResultItem
@@ -76,7 +43,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
active={active}
data-testid="search-page-result"
action={tString(language, 'view')}
leadingIcon={leadingIcon}
leadingIcon={<Icon icon="file-lines" className="size-4" />}
insights={insights}
aria-label={tString(language, 'search_page_result_title', item.title)}
style={{
@@ -91,11 +58,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: !preview && !description
? 'h-0'
: ''
!preview && !description ? 'h-0' : ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
@@ -119,26 +82,15 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
<HighlightQuery query={query} text={description} />
</p>
) : null}
{item.type === 'local-page' ? (
<SkeletonParagraph
size="small"
lines={1}
className={tcls(
'absolute inset-0 origin-left',
preview || description
? 'hidden animate-blur-out'
: '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out'
)}
style={{ animationDelay: style?.animationDelay }}
/>
) : null}
</div>
</SearchResultItem>
);
});
const Breadcrumbs = (props: { breadcrumbs: PageItem['breadcrumbs']; withOverflow?: boolean }) => {
const Breadcrumbs = (props: {
breadcrumbs: ComputedPageResult['breadcrumbs'];
withOverflow?: boolean;
}) => {
const { breadcrumbs, withOverflow = (breadcrumbs?.length ?? 0) > 4 } = props;
if (!breadcrumbs || breadcrumbs.length === 0) return null;
@@ -4,27 +4,39 @@ import assertNever from 'assert-never';
import { motion } from 'framer-motion';
import React from 'react';
import { Button, SkeletonParagraph, SkeletonSmall } from '../primitives';
import { Button } from '../primitives';
import { isExternalLink } from '../utils/link';
import { addRecentSearchQuery } from './recent-queries';
import { SearchPageResultItem } from './SearchPageResultItem';
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
import { SearchRecordResultItem } from './SearchRecordResultItem';
import { SearchResultItem } from './SearchResultItem';
import type { ResultType } from './useSearchResults';
import { useAI } from '@/components/AI';
import { useCurrentContent } from '@/components/hooks';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
/**
* Bar widths (breadcrumb, title, description, in %) of the loading placeholder rows. Six rows of
* this height fill the results area of the desktop popover; the count never depends on an answer.
*/
const PLACEHOLDER_ROWS: [crumb: number, title: number, description: number][] = [
[28, 64, 86],
[20, 48, 78],
[34, 58, 90],
[24, 70, 72],
[30, 52, 84],
[22, 60, 76],
];
export interface SearchResultsRef {
select(): boolean;
/** Activate the first result, ignoring the cursor. */
selectFirst(): boolean;
}
function getResultKey(item: ResultType): string {
switch (item.type) {
case 'local-page':
return `page:${item.id}`;
case 'page':
return `page:${item.pageId}`;
case 'record':
@@ -107,6 +119,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
ref,
() => ({
select,
selectFirst: () => {
const first = refs.current[0];
first?.click();
return Boolean(first);
},
}),
[select]
);
@@ -114,7 +131,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const { assistants } = useAI();
const primaryAssistant = assistants[0];
if (error && results.length === 0) {
if (error) {
return (
<output
className={tcls(
@@ -157,26 +174,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
</output>
);
const partialError = error ? (
<div
className={tcls(
'mt-3',
'rounded-corners:rounded-md',
'circular-corners:rounded-2xl',
'bg-tint-subtle',
'px-3',
'py-2',
'text-center',
'text-sm',
'text-tint-subtle',
'animate-blur-in-slow'
)}
role="status"
>
{t(language, 'search_partial_error')}
</div>
) : null;
return (
<output className="flex grow flex-col" aria-busy={fetching}>
{children}
@@ -202,10 +199,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
const isPageResult = item.type === 'page' || item.type === 'record';
if (
isPageResult &&
@@ -238,7 +232,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
onClickCapture: handleResultSelect,
};
switch (item.type) {
case 'local-page':
case 'page': {
return (
<motion.div
@@ -353,49 +346,62 @@ export const SearchResults = React.forwardRef(function SearchResults(
{!fetching && results.length === 0 ? noResults : null}
</>
)}
{partialError}
{fetching ? (
<div
role="status"
aria-busy="true"
className={tcls(
results.length > 0 ? 'mt-1' : '',
'flex animate-blur-in flex-col gap-1'
'flex flex-col gap-1',
'animate-[fadeIn_150ms_ease-out_both,pulse_2.4s_ease-in-out_600ms_infinite] motion-reduce:animate-none'
)}
style={{ animationDelay: `${results.length * 25}ms` }}
>
<SearchResultsSkeleton items={Math.max(3, 5 - results.length)} />
<SearchResultsPlaceholder />
</div>
) : null}
</output>
);
});
const SearchResultsSkeleton = (props: { items: number }) => {
const { items } = props;
const skeletonKeys = Array.from({ length: items }, (_, index) => `skeleton:${index}`);
/**
* Loading rows shaped like a page result: breadcrumb, title and description lines in the real
* row's box, so the first result lands where the first placeholder was.
*/
const SearchResultsPlaceholder = () =>
PLACEHOLDER_ROWS.map(([crumb, title, description], index) => (
<div
key={index}
data-search-placeholder-row
aria-hidden
className="flex items-center gap-3 py-2 pl-3 pr-2"
>
<div className="size-4 shrink-0 rounded-sm bg-tint-solid/2" />
<div className="grow">
<PlaceholderLine className="text-xs" barClassName="h-2" width={crumb} />
<PlaceholderLine
className="font-heading text-base font-semibold leading-snug"
barClassName="h-3.5 bg-tint-solid/3"
width={title}
/>
<PlaceholderLine className="h-5 text-sm" barClassName="h-2.5" width={description} />
</div>
<div className="size-7 shrink-0" />
</div>
));
return (
<>
{skeletonKeys.map((key, index) => (
<SearchResultItem
key={key}
active={false}
href="#"
action=""
disabled
data-testid="search-page-result"
leadingIcon={
<SkeletonSmall
className="size-4"
style={{ animationDelay: `${index * 0.3}s` }}
/>
}
>
<SkeletonParagraph size="small" start={index * 3} />
</SearchResultItem>
))}
</>
);
};
/** One text line of a placeholder row: the line box of the real text, with a bar inside it. */
const PlaceholderLine = (props: { className: string; barClassName: string; width: number }) => (
<div className={tcls('relative', props.className)}>
{'\u00a0'}
<div
className={tcls(
'absolute inset-y-0 left-0 my-auto rounded-full bg-tint-solid/2',
props.barClassName
)}
style={{ width: `${props.width}%` }}
/>
</div>
);
/**
* Whether clicking a result navigates the current window, and so closes the search — as opposed
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { computeFilterSiteSpaceIds } from './filter';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import type { OrderedComputedResult } from './search-types';
describe('computeFilterSiteSpaceIds', () => {
const currentId = 'space-current';
@@ -34,3 +35,31 @@ describe('computeFilterSiteSpaceIds', () => {
expect(r).toEqual(['space-other-a', 'space-other-b']);
});
});
describe('filterResultsBySiteSpaces', () => {
const page = (id: string, siteSpaceId?: string): OrderedComputedResult => ({
type: 'page',
id,
title: id,
href: `/${id}`,
score: 1,
pageId: id,
spaceId: 'space',
siteSpaceId,
description: '',
rank: 1,
});
it('keeps the server order and drops pages outside the scope', () => {
const results = [page('a', 'current'), page('b', 'other'), page('c', 'current'), page('d')];
expect(filterResultsBySiteSpaces(results, ['current']).map((result) => result.id)).toEqual([
'a',
'c',
]);
});
it('returns every result when the scope is unrestricted', () => {
const results = [page('b', 'other'), page('a', 'current')];
expect(filterResultsBySiteSpaces(results, undefined)).toBe(results);
});
});
@@ -1,16 +1,13 @@
import type { OrderedComputedResult } from './search-types';
import type { SearchScope } from './useSearch';
/**
* Map UI search scope to local FlexSearch filtering.
* Site spaces whose pages may be shown for a UI search scope, or `undefined` for no restriction.
*
* Rationale: when a site has no sections (only variants), the remote search
* "default" scope effectively queries the current variant. To keep local
* results consistent with remote ones, we also restrict "default" to the
* current `siteSpaceId` when `withSections` is false. Otherwise (sections
* present), local "default" stays unfiltered to match the broader section UI.
* An explicitly selected section takes precedence over these legacy scopes.
* The server's "default" scope also returns pages from other site spaces. Without sections
* (only variants) the UI presents "default" as the current variant, so it is restricted to
* the current `siteSpaceId`. An explicitly selected section takes precedence.
*/
export function computeFilterSiteSpaceIds(
scope: SearchScope,
siteSpaceId: string,
@@ -31,3 +28,21 @@ export function computeFilterSiteSpaceIds(
return withSections ? undefined : [siteSpaceId];
}
}
/** Keep the server's order, dropping pages owned by a site space outside the scope. */
export function filterResultsBySiteSpaces(
results: OrderedComputedResult[],
allowedSiteSpaceIds: string[] | undefined
): OrderedComputedResult[] {
if (!allowedSiteSpaceIds) {
return results;
}
const allowed = new Set(allowedSiteSpaceIds);
return results.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
);
}
@@ -5,5 +5,4 @@ export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useLocalSearchResults';
export * from './useSearchController';
@@ -1,239 +0,0 @@
import { describe, expect, it } from 'bun:test';
import {
type MergedPageResult,
fuseSearchResults,
getResultKey,
reciprocalRankFusion,
} from './reciprocalRankFusion';
import type { OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
function localPage(id: string, title = id, siteSpaceId = 'site-space-alpha'): LocalPageResult {
return {
type: 'local-page',
id,
siteSpaceId,
title,
pathname: `/${id}`,
description: `Local description for ${title}`,
breadcrumbs: [{ label: 'Local', icon: 'book-open' }],
};
}
function remotePage(
id: string,
title = id,
score = 0,
rank = 1,
spaceId = 'space-alpha',
siteSpaceId = 'site-space-alpha'
): OrderedComputedResult {
return {
type: 'page',
id: `remote-${id}`,
pageId: id,
spaceId,
siteSpaceId,
title,
description: `Remote description for ${title}`,
href: `/${id}`,
score,
rank,
breadcrumbs: [{ label: 'Remote' }],
};
}
function remoteRecord(id: string, title = id): OrderedComputedResult {
return {
type: 'record',
id,
title,
href: `/records/${id}`,
score: 0,
description: undefined,
};
}
describe('reciprocalRankFusion', () => {
it('keeps the first 3 remote results first and in remote order', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remoteRecord('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'record:remote-2',
'page:remote-3',
]);
});
it('applies fusion only after the pinned remote results', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[
remotePage('remote-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remotePage('remote-4'),
],
'alpha'
);
expect(results.slice(0, 3).map(getResultKey)).toEqual([
'page:remote-1',
'page:remote-2',
'page:remote-3',
]);
const firstFusedResult = results[3];
if (!firstFusedResult) {
throw new Error('Expected a fused result after the pinned remote results');
}
expect(getResultKey(firstFusedResult)).toBe('page:local-match');
});
it('pins all remote results when fewer than 3 are present', () => {
const results = reciprocalRankFusion(
[localPage('local-match', 'Alpha Local Match')],
[remoteRecord('remote-1'), remotePage('remote-2')],
'alpha'
);
expect(results.map(getResultKey)).toEqual([
'record:remote-1',
'page:remote-2',
'page:local-match',
]);
});
it('merges a pinned remote page with the matching local page without duplicating it', () => {
const results = reciprocalRankFusion(
[localPage('remote-1', 'Local title')],
[
remotePage('remote-1', 'Remote title', 1, 1),
remotePage('remote-2'),
remotePage('remote-3'),
],
'remote'
);
const pinnedResult = results[0] as MergedPageResult;
expect(pinnedResult.type).toBe('page');
expect(pinnedResult.title).toBe('Remote title');
expect(pinnedResult.pathname).toBe('/remote-1');
expect(pinnedResult.description).toBe('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);
});
it('does not duplicate pinned records in the fused tail', () => {
const results = reciprocalRankFusion(
[],
[
remoteRecord('record-1'),
remotePage('remote-2'),
remotePage('remote-3'),
remoteRecord('record-1'),
remotePage('remote-4'),
],
'remote'
);
expect(results.map(getResultKey).filter((key) => key === 'record:record-1')).toHaveLength(
1
);
});
});
describe('fuseSearchResults', () => {
it('filters every source to the current site space before fusion', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('remote-alpha-1', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha-1', 'Alpha remote one', 10, 1),
remotePage(
'local-alpha',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
remotePage('remote-alpha-2', 'Alpha remote two', 8, 3),
],
query: 'result',
allowedSiteSpaceIds: ['site-space-alpha'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha-1',
'page:remote-alpha-2',
'page:local-alpha',
]);
expect(results[0]).not.toHaveProperty('pathname');
expect(results[2]?.type).toBe('local-page');
});
it('preserves cross-site-space results when the search is unrestricted', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha', 'Alpha local result'),
localPage('local-beta', 'Beta local result', 'site-space-beta'),
],
remoteResults: [
remotePage('remote-alpha', 'Alpha remote result', 10, 1),
remotePage(
'remote-beta',
'Beta remote result',
9,
2,
'space-beta',
'site-space-beta'
),
],
query: 'result',
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
it('preserves every allowed site space in an intentionally multi-space search', () => {
const results = fuseSearchResults({
localResults: [
localPage('local-alpha'),
localPage('local-beta', 'local-beta', 'site-space-beta'),
localPage('local-gamma', 'local-gamma', 'site-space-gamma'),
],
remoteResults: [
remotePage('remote-alpha'),
remotePage('remote-beta', 'remote-beta', 0, 2, 'space-beta', 'site-space-beta'),
remotePage('remote-gamma', 'remote-gamma', 0, 3, 'space-gamma', 'site-space-gamma'),
],
query: 'page',
allowedSiteSpaceIds: ['site-space-alpha', 'site-space-beta'],
});
expect(results.map(getResultKey)).toEqual([
'page:remote-alpha',
'page:remote-beta',
'page:local-alpha',
'page:local-beta',
]);
});
});
@@ -1,271 +0,0 @@
import type { ComputedPageResult, OrderedComputedResult } from './search-types';
import type { LocalPageResult } from './useLocalSearchResults';
/**
* Jaro-Winkler similarity between two strings, in the range [0, 1].
* Higher is more similar. 1 means an exact match.
* https://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance
*/
function jaroWinkler(s1: string, s2: string, p = 0.1): number {
if (s1 === s2) return 1;
const len1 = s1.length;
const len2 = s2.length;
if (len1 === 0 || len2 === 0) return 0;
const maxDist = Math.floor(Math.max(len1, len2) / 2) - 1;
const matched1 = new Array<boolean>(len1);
const matched2 = new Array<boolean>(len2);
for (let i = 0; i < len1; i++) matched1[i] = false;
for (let i = 0; i < len2; i++) matched2[i] = false;
let matches = 0;
for (let i = 0; i < len1; i++) {
const start = Math.max(0, i - maxDist);
const end = Math.min(len2 - 1, i + maxDist);
for (let j = start; j <= end; j++) {
if (!matched2[j] && s1.charCodeAt(i) === s2.charCodeAt(j)) {
matched1[i] = true;
matched2[j] = true;
matches++;
break;
}
}
}
if (matches === 0) return 0;
let t = 0;
let k = 0;
for (let i = 0; i < len1; i++) {
if (!matched1[i]) continue;
while (!matched2[k]) k++;
if (s1.charCodeAt(i) !== s2.charCodeAt(k)) t++;
k++;
}
const transpositions = t / 2;
const m = matches;
const jaro = (m / len1 + m / len2 + (m - transpositions) / m) / 3;
let prefix = 0;
const maxPrefix = 4;
const minLen = Math.min(len1, len2);
for (let i = 0; i < maxPrefix && i < minLen; i++) {
if (s1.charCodeAt(i) === s2.charCodeAt(i)) prefix++;
else break;
}
return jaro + prefix * p * (1 - jaro);
}
/** Standard RRF constant — moderates the impact of high-ranked items. */
const RRF_K = 60;
/** Lower k for local results gives them a slightly higher score than remote ones at the same rank. */
const RRF_K_LOCAL = 50;
/** Number of remote results that should keep their API order before fusion. */
const PINNED_REMOTE_RESULTS_COUNT = 3;
/**
* A page result that was present in both local and remote lists.
* 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;
breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs'];
};
/**
* 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 | MergedPageResult
): string {
switch (result.type) {
case 'local-page':
return `page:${result.id}`;
case 'page':
return `page:${result.pageId}`;
case 'record':
return `record:${result.id}`;
}
}
type RRFResult = LocalPageResult | OrderedComputedResult | MergedPageResult;
/** Filter page candidates at the final assembly boundary, immediately before ranking and fusion. */
export function fuseSearchResults(args: {
localResults: LocalPageResult[];
remoteResults: OrderedComputedResult[];
query: string;
allowedSiteSpaceIds?: string[];
}): RRFResult[] {
const { localResults, remoteResults, query, allowedSiteSpaceIds } = args;
if (!allowedSiteSpaceIds) {
return reciprocalRankFusion(localResults, remoteResults, query);
}
const allowed = new Set(allowedSiteSpaceIds);
return reciprocalRankFusion(
localResults.filter((result) => allowed.has(result.siteSpaceId)),
remoteResults.filter(
(result) =>
result.type === 'record' ||
(result.siteSpaceId !== undefined && allowed.has(result.siteSpaceId))
),
query
);
}
function mergeLocalPageWithRemotePage(
localResult: LocalPageResult,
remoteResult: ComputedPageResult
): MergedPageResult {
return {
...localResult,
...remoteResult,
// Merge breadcrumbs: prefer local (has icon + emoji), fall back to remote.
breadcrumbs: localResult.breadcrumbs ?? remoteResult.breadcrumbs,
};
}
function mergePinnedRemoteResult(
result: OrderedComputedResult,
localResultsByKey: Map<string, LocalPageResult>
): RRFResult {
if (result.type !== 'page') {
return result;
}
const localResult = localResultsByKey.get(getResultKey(result));
if (!localResult) {
return result;
}
return mergeLocalPageWithRemotePage(localResult, result);
}
/**
* Merge local (FlexSearch) and remote (API) search results using
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
* three remote results.
*
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
*
* 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 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.
*
* Sections and records have no local equivalent and only accumulate their own
* rank contribution.
*
* Additionally, a Jaro-Winkler title-match bonus is applied per query word:
* for each query word that appears as a substring in the title, the best
* Jaro-Winkler similarity score against any individual title word is added.
*/
export function reciprocalRankFusion(
localResults: LocalPageResult[],
remoteResults: OrderedComputedResult[],
query: string
): RRFResult[] {
const localResultsByKey = new Map(localResults.map((result) => [getResultKey(result), result]));
const pinnedRemoteResults = remoteResults.slice(0, PINNED_REMOTE_RESULTS_COUNT);
const pinnedResultKeys = new Set(pinnedRemoteResults.map((result) => getResultKey(result)));
const remainingLocalResults = localResults.filter(
(result) => !pinnedResultKeys.has(getResultKey(result))
);
const remainingRemoteResults = remoteResults
.slice(PINNED_REMOTE_RESULTS_COUNT)
.filter((result) => !pinnedResultKeys.has(getResultKey(result)));
// Map from dedup key → { result, score }
const scoreMap = new Map<string, { result: RRFResult; score: number }>();
// Process local results first (1-indexed rank)
// Using RRF_K_LOCAL (< RRF_K) slightly boosts local scores over remote ones.
remainingLocalResults.forEach((result, index) => {
const rank = index + 1;
const key = getResultKey(result);
const contribution = 1 / (RRF_K_LOCAL + 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
remainingRemoteResults.forEach((result, index) => {
const rank = index + 1;
const contribution = 1 / (RRF_K + rank);
const key = getResultKey(result);
const existing = scoreMap.get(key);
if (existing) {
// Page found in both lists: sum rank contributions and deep-merge.
// 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);
} else {
existing.result = result;
}
} else {
scoreMap.set(key, { result, score: contribution });
}
});
// Apply a title-match bonus per query word using Jaro-Winkler similarity.
// For each query word that appears (as a substring) in the title, compute the
// best Jaro-Winkler score against individual title words and add it to the score.
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 0);
if (queryWords.length > 0) {
for (const entry of scoreMap.values()) {
// Only apply the bonus to page or local-page results
if (entry.result.type === 'record') continue;
const title = entry.result.title?.toLowerCase() ?? '';
const titleWords = title.split(/\s+/).filter((w) => w.length > 0);
for (const word of queryWords) {
// Only apply when there is at least a substring match in the title.
if (!title.includes(word)) continue;
let best = 0;
for (const titleWord of titleWords) {
const sim = jaroWinkler(word, titleWord);
if (sim > best) best = sim;
}
entry.score += best;
}
}
}
// Sort descending by RRF score
const fusedResults = Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.map(({ result }) => result);
return [
...pinnedRemoteResults.map((result) => mergePinnedRemoteResult(result, localResultsByKey)),
...fusedResults,
];
}
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import { createSearchPassStore, searchWithPass } from './search-pass-store';
import type { DirectSearchProps } from '@/lib/search-results';
const endpoint = 'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/search';
const target: DirectSearchProps = {
apiURL: 'https://api.gitbook.com',
organizationId: 'org_1',
siteId: 'site_1',
linker: {
urlMode: 'url-host',
siteHost: 'docs.example.com',
siteBasePath: '/',
spaceBasePath: '/',
gitbookURL: undefined,
},
spaces: {},
};
type Call = { url: string; authorization: string | null };
/** Answer the warm-up with a new pass each time, and each API search with the next status. */
function mockFetch(
apiStatuses: number[],
passLifetimesMs: number[] = [],
warmUpStatuses: number[] = []
) {
const calls: Call[] = [];
let passes = 0;
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async (
input: RequestInfo | URL,
init?: RequestInit
) => {
const url = String(input);
const authorization = new Headers(init?.headers).get('authorization');
calls.push({ url, authorization });
if (init?.signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
if (url === '/~gitbook/search-prewarm') {
const warmUpStatus = warmUpStatuses.shift() ?? 200;
if (warmUpStatus !== 200) {
return new Response(null, { status: warmUpStatus });
}
passes += 1;
return Response.json(
{
token: `pass-${passes}`,
expiresAt: new Date(
Date.now() + (passLifetimesMs.shift() ?? 300_000)
).toISOString(),
endpoint,
},
{ headers: { date: new Date().toUTCString() } }
);
}
const status = apiStatuses.shift() ?? 200;
return status === 200 ? Response.json({ items: [] }) : new Response(null, { status });
}) as typeof fetch);
return { calls, fetchSpy };
}
async function search(store: ReturnType<typeof createSearchPassStore>, signal?: AbortSignal) {
return searchWithPass({
store,
body: { query: 'tokens', mode: 'all' },
signal: signal ?? new AbortController().signal,
});
}
const apiCalls = (calls: Call[]) => calls.filter((call) => call.url === endpoint);
describe('searchWithPass', () => {
let restore: (() => void) | undefined;
afterEach(() => restore?.());
it('falls back to the route once on 403 and never calls the API again for the page', async () => {
const { calls, fetchSpy } = mockFetch([403]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
expect(await search(store)).toBeNull();
expect(store.takeFallbackReport()).toBe('forbidden');
await store.warmUp();
expect(await search(store)).toBeNull();
expect(apiCalls(calls)).toHaveLength(1);
});
it('refreshes the pass once on 401 and retries once before falling back', async () => {
const { calls, fetchSpy } = mockFetch([401, 401]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
expect(await search(store)).toBeNull();
expect(apiCalls(calls).map((call) => call.authorization)).toEqual([
'Bearer pass-1',
'Bearer pass-2',
]);
expect(store.takeFallbackReport()).toBe('unauthorized');
});
it('stays on the route when a refresh only yields a nearly expired pass', async () => {
const { calls, fetchSpy } = mockFetch([401], [300_000, 3_000]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
expect(await search(store)).toBeNull();
expect(store.takeFallbackReport()).toBe('refresh_failed');
expect(await search(store)).toBeNull();
expect(apiCalls(calls)).toHaveLength(1);
expect(calls.filter((call) => call.url === '/~gitbook/search-prewarm')).toHaveLength(2);
});
it('does not retry with the rejected pass when the refresh fails', async () => {
const { calls, fetchSpy } = mockFetch([401], [], [200, 500]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
expect(await search(store)).toBeNull();
expect(apiCalls(calls)).toHaveLength(1);
expect(store.takeFallbackReport()).toBe('refresh_failed');
expect(store.takeFallbackReport()).toBeUndefined();
});
it('uses the retried answer when the refreshed pass is accepted', async () => {
const { fetchSpy } = mockFetch([401, 200]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
expect(await search(store)).toEqual([]);
expect(store.takeFallbackReport()).toBeUndefined();
});
it('does not retry or fall back when the search is aborted', async () => {
const { calls, fetchSpy } = mockFetch([]);
restore = () => fetchSpy.mockRestore();
const store = createSearchPassStore('/~gitbook/search-prewarm', target);
await store.warmUp();
const controller = new AbortController();
controller.abort();
await expect(search(store, controller.signal)).rejects.toThrow();
expect(apiCalls(calls)).toHaveLength(1);
expect(store.takeFallbackReport()).toBeUndefined();
});
});
@@ -0,0 +1,331 @@
import ReactDOM from 'react-dom';
import type { SearchRecordResult, SearchSpaceResult } from '@gitbook/api';
import {
type SearchFallbackReason,
type SearchPassStatusOutcome,
classifySearchPassStatus,
isSearchPassEndpoint,
readSearchPassFields,
} from '@/lib/search-pass';
import type { DirectSearchProps } from '@/lib/search-results';
const EXPIRY_MARGIN_MS = 5_000;
const MIN_USABLE_LIFETIME_MS = 15_000;
const REFRESH_BEFORE_EXPIRY_MS = 60_000;
const MIN_REFRESH_DELAY_MS = 10_000;
const RATE_LIMIT_BACK_OFF_MS = 2_000;
const WARM_UP_TIMEOUT_MS = 3_000;
const UNKNOWN_SPACE_LIMIT = 2;
type SearchPass = {
token: string;
endpoint: string;
/** Expiry on the browser's clock, in milliseconds. */
expiresAt: number;
};
type SearchPassState =
| { status: 'none' }
| { status: 'ready'; pass: SearchPass }
| { status: 'route'; reason: SearchFallbackReason };
export interface SearchPassStore {
/** The site the pass is for, or undefined when the page cannot search the API directly. */
readonly target: DirectSearchProps | undefined;
/** Send the site's search warm-up and keep the search pass of its reply. */
warmUp(): Promise<void>;
/** Refresh the pass before it expires while search is open. */
setOpen(open: boolean): void;
/** The pass to search with now, or null to use the search route. */
getPass(): SearchPass | null;
/** Use the search route for the rest of the store's life. */
fallBack(reason: SearchFallbackReason): void;
/** Send one search to the route because a result's space is unknown, and fall back on repeats. */
rerouteUnknownSpace(): void;
/** The reason to report with the next route request, given once per fallback. */
takeFallbackReport(): SearchFallbackReason | undefined;
}
/**
* Hold the search pass of a site and visitor context in memory.
*/
export function createSearchPassStore(
prewarmURL: string,
target: DirectSearchProps | undefined
): SearchPassStore {
let state: SearchPassState = { status: 'none' };
let open = false;
let pendingWarmUp: Promise<void> | null = null;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let report: SearchFallbackReason | undefined;
let unknownSpaces = 0;
const clearRefresh = () => {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
};
const scheduleRefresh = () => {
clearRefresh();
if (!open || state.status !== 'ready') {
return;
}
const delay = Math.max(
MIN_REFRESH_DELAY_MS,
state.pass.expiresAt - Date.now() - REFRESH_BEFORE_EXPIRY_MS
);
refreshTimer = setTimeout(() => {
void store.warmUp().catch(() => undefined);
}, delay);
};
const keepPass = (pass: SearchPass | null) => {
if (state.status === 'route') {
return;
}
const refreshing = state.status === 'ready';
const usable = pass !== null && pass.expiresAt - Date.now() > MIN_USABLE_LIFETIME_MS;
if (!usable) {
if (refreshing) {
store.fallBack('refresh_failed');
} else {
state = { status: 'none' };
}
return;
}
if (!target) {
state = { status: 'none' };
return;
}
if (!isSearchPassEndpoint(pass.endpoint, target)) {
store.fallBack('invalid_endpoint');
return;
}
state = { status: 'ready', pass };
ReactDOM.preconnect(new URL(pass.endpoint).origin, { crossOrigin: 'anonymous' });
scheduleRefresh();
};
const store: SearchPassStore = {
target,
warmUp() {
if (pendingWarmUp) {
return pendingWarmUp;
}
pendingWarmUp = (async () => {
try {
const response = await fetch(prewarmURL, {
method: 'POST',
signal: AbortSignal.timeout(WARM_UP_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Search prewarm request failed: ${response.status}`);
}
keepPass(await readSearchPass(response));
} catch (error) {
if (state.status === 'ready') {
store.fallBack('refresh_failed');
}
throw error;
}
})().finally(() => {
pendingWarmUp = null;
});
return pendingWarmUp;
},
setOpen(nextOpen) {
open = nextOpen;
if (open) {
scheduleRefresh();
} else {
clearRefresh();
}
},
getPass() {
if (state.status !== 'ready' || state.pass.expiresAt - Date.now() <= EXPIRY_MARGIN_MS) {
return null;
}
return state.pass;
},
fallBack(reason) {
if (state.status === 'route') {
return;
}
state = { status: 'route', reason };
report = reason;
clearRefresh();
},
rerouteUnknownSpace() {
unknownSpaces += 1;
if (unknownSpaces >= UNKNOWN_SPACE_LIMIT) {
store.fallBack('unknown_space');
} else {
report = 'unknown_space';
}
},
takeFallbackReport() {
const reason = report;
report = undefined;
return reason;
},
};
return store;
}
async function readSearchPass(response: Response): Promise<SearchPass | null> {
let body: unknown;
try {
body = await response.json();
} catch {
return null;
}
const fields = readSearchPassFields(body);
if (!fields) {
return null;
}
const expiresAt = Date.parse(fields.expiresAt);
const serverDate = Date.parse(response.headers.get('date') ?? '');
const lifetime = expiresAt - serverDate;
return {
token: fields.token,
endpoint: fields.endpoint,
expiresAt: Number.isNaN(lifetime) ? expiresAt : Date.now() + lifetime,
};
}
type SearchItems = (SearchSpaceResult | SearchRecordResult)[];
type PassRequestOutcome =
| { type: 'results'; items: SearchItems }
| { type: 'refresh' }
| { type: 'back_off' }
| { type: 'fall_back'; reason: SearchFallbackReason };
/**
* Search the API with the store's pass. Resolves to null when the search must use the route.
*/
export async function searchWithPass(input: {
store: SearchPassStore;
body: unknown;
signal: AbortSignal;
}): Promise<SearchItems | null> {
const { store, body, signal } = input;
const pass = store.getPass();
if (!pass) {
return null;
}
let outcome = await requestWithPass(pass, body, signal);
if (outcome.type === 'refresh') {
await abortable(
store.warmUp().catch(() => undefined),
signal
);
const refreshed = store.getPass();
outcome =
refreshed && refreshed.token !== pass.token
? await requestWithPass(refreshed, body, signal)
: { type: 'fall_back', reason: 'unauthorized' };
if (outcome.type === 'refresh') {
outcome = { type: 'fall_back', reason: 'unauthorized' };
}
}
switch (outcome.type) {
case 'results':
return outcome.items;
case 'back_off':
store.fallBack('rate_limited');
await wait(RATE_LIMIT_BACK_OFF_MS, signal);
return null;
case 'fall_back':
store.fallBack(outcome.reason);
return null;
}
}
async function requestWithPass(
pass: SearchPass,
body: unknown,
signal: AbortSignal
): Promise<PassRequestOutcome> {
let response: Response;
try {
response = await fetch(pass.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${pass.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
credentials: 'omit',
signal,
});
} catch (error) {
if (signal.aborted) {
throw error;
}
return { type: 'fall_back', reason: 'network' };
}
const outcome: SearchPassStatusOutcome = classifySearchPassStatus(response.status);
if (outcome.type === 'results') {
return readSearchItems(response, signal);
}
return outcome;
}
async function readSearchItems(
response: Response,
signal: AbortSignal
): Promise<PassRequestOutcome> {
try {
const data = (await response.json()) as { items?: unknown };
if (!Array.isArray(data?.items)) {
return { type: 'fall_back', reason: 'invalid_response' };
}
return { type: 'results', items: data.items as SearchItems };
} catch (error) {
if (signal.aborted) {
throw error;
}
return { type: 'fall_back', reason: 'invalid_response' };
}
}
function wait(delayMs: number, signal: AbortSignal): Promise<void> {
return abortable(new Promise((resolve) => setTimeout(resolve, delayMs)), signal);
}
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
return Promise.reject(signal.reason);
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
});
}
@@ -2,6 +2,8 @@ import type { SiteExternalLink, SiteSection, SiteSectionGroup, SiteSpace } from
import { hasMultipleSiteSections } from '../SiteSections';
import type { GitBookSiteContext } from '@/lib/context';
import { getSearchSpaceLookup } from '@/lib/search';
import type { DirectSearchProps } from '@/lib/search-results';
import { getLocalizedTitle } from '@/lib/sites';
export type SearchSection = Pick<SiteSection, 'id' | 'icon' | 'object'> & {
@@ -25,7 +27,6 @@ export interface SearchBaseProps {
sections: SearchSectionItem[];
withVariants: boolean;
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
}
@@ -47,7 +48,6 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
) ?? false,
indexURL: context.linker.toPathInSite('~gitbook/site-index'),
withVariants: visibleSiteSpaces.length > 1,
};
}
@@ -123,3 +123,26 @@ export function findSearchSection(
return undefined;
}
/**
* What the browser needs to search the API directly, built from the site spaces visitors can see.
*/
export function getDirectSearchProps(context: GitBookSiteContext): DirectSearchProps | undefined {
if (!context.directSearch) {
return undefined;
}
return { ...context.directSearch, spaces: getSearchSpaceLookup(context.structure) };
}
/**
* Key of the site, variant and visitor context that a search pass is valid for.
*/
export function getSearchPassKey(context: GitBookSiteContext): string {
return JSON.stringify([
context.organizationId,
context.site.id,
context.siteSpace.id,
context.contextId ?? null,
]);
}
@@ -1,15 +1,5 @@
'use client';
/**
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
*
* Loading strategy: the download starts as early as possible (server-rendered
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
* consumer actually needs the pages.
*/
export interface SiteIndexBreadcrumb {
label: string;
icon?: string;
@@ -30,50 +20,14 @@ export interface SiteIndexPage {
breadcrumbs?: SiteIndexBreadcrumb[];
}
let siteIndexText: Promise<string> | null = null;
/**
* Start (or reuse) the single-flight download of the raw index. Errors clear the
* cache so the next consumer retries.
*/
export function prefetchSiteIndex(indexURL: string): void {
fetchSiteIndexText(indexURL).catch(() => {
// Ignored: consumers surface errors when they actually read the index.
});
}
/**
* Get the parsed index pages. Parses per call (cheap for the rare second
* consumer) so the parsed object graph is never retained at module scope.
*/
/** Fetch the pages of the site from the `~gitbook/site-index` JSON. */
export async function fetchSiteIndex(
indexURL: string
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
return JSON.parse(await fetchSiteIndexText(indexURL));
}
/**
* Drop the cached raw text (several MB for large sites) once a consumer has
* turned it into a longer-lived form. Purely a memory release: a later consumer
* re-fetches, hitting the HTTP cache.
*/
export function releaseSiteIndex(): void {
siteIndexText = null;
}
function fetchSiteIndexText(indexURL: string): Promise<string> {
if (!siteIndexText) {
siteIndexText = fetch(indexURL).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
return response.text();
});
siteIndexText.catch(() => {
siteIndexText = null;
});
const response = await fetch(indexURL);
if (!response.ok) {
throw new Error(`Failed to fetch site index: ${response.status}`);
}
return siteIndexText;
return response.json();
}
@@ -1,262 +0,0 @@
'use client';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
import {
type SiteIndexBreadcrumb,
type SiteIndexPage,
fetchSiteIndex,
prefetchSiteIndex,
releaseSiteIndex,
} from './site-index';
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
interface IndexPage {
[key: string]: DocumentValue | DocumentValue[];
id: string;
title: string;
description: string | null;
siteSpaceId: string;
}
/** Result type returned by this hook */
export interface LocalPageResult {
type: 'local-page';
id: string;
siteSpaceId: string;
title: string;
pathname: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: SiteIndexBreadcrumb[];
}
type LocalSearchState = {
results: LocalPageResult[];
fetching: boolean;
error: boolean;
};
// 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<string, Document<IndexPage>>();
// 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?: SiteIndexBreadcrumb[] }
>();
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: SiteIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
store: ['id', 'title', 'description', 'siteSpaceId'],
tag: 'siteSpaceId',
},
tokenize: 'full',
resolution: 15,
encoder: 'Normalize',
});
for (const page of pages) {
index
.addAsync({
id: page.id,
title: page.title,
description: page.description ?? null,
siteSpaceId: page.siteSpaceId,
})
.catch(() => {
// We just ignore these errors, it's not worth failing the whole index for a single bad record
// And we still have remote search as a fallback for these cases
});
cachedPageData.set(`${page.siteSpaceId}:${page.id}`, {
pathname: page.pathname,
icon: page.icon,
emoji: page.emoji,
breadcrumbs: page.breadcrumbs?.length ? page.breadcrumbs : undefined,
});
}
return index;
}
async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document<IndexPage>>> {
if (cachedIndexes.size > 0) {
return cachedIndexes;
}
if (pendingFetch) {
return pendingFetch;
}
pendingFetch = (async () => {
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
const [{ Document }, data] = await Promise.all([
import('flexsearch'),
fetchSiteIndex(indexURL),
]);
// Group pages by their `lang` value (empty string for pages without one)
const pagesByLang = new Map<string, SiteIndexPage[]>();
for (const page of data.pages) {
const key = page.lang ?? '';
const bucket = pagesByLang.get(key);
if (bucket) {
bucket.push(page);
} else {
pagesByLang.set(key, [page]);
}
}
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
releaseSiteIndex();
return cachedIndexes;
})();
// Clear pendingFetch on error so a retry is possible
pendingFetch.catch(() => {
console.error('Error fetching/building search index', indexURL);
pendingFetch = null;
});
return pendingFetch;
}
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;
/** When provided, only results from pages belonging to one of these site space IDs
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether the search surface is open. */
open: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
fetching: false,
error: false,
});
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
const active = open || Boolean(query);
// Load the indexes once
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
prefetchSiteIndex(indexURL);
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
getOrBuildIndexes(indexURL)
.then(() => {
if (!cancelled) {
setIndexReady(true);
setState((prev) => ({ ...prev, fetching: false }));
}
})
.catch(() => {
if (!cancelled) {
setState({ results: [], fetching: false, error: true });
}
});
return () => {
cancelled = true;
};
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
// 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;
}
if (!query) {
setState({ results: [], fetching: false, error: false });
return;
}
//@ts-ignore - Typing is wrong here, tags can be arrays when using the `tag` filter option
const rawResults = index.search(query, {
enrich: true,
limit: 5,
suggest: true,
...(filterSiteSpaceIds
? {
tag: {
siteSpaceId: filterSiteSpaceIds,
},
}
: {}),
});
// Flatten and deduplicate results across fields (flexsearch returns one array per indexed field)
const seen = new Set<string>();
const results: LocalPageResult[] = [];
for (const fieldResult of rawResults) {
for (const item of fieldResult.result) {
const doc = (item as { id: string; doc: IndexPage }).doc;
const cacheKey = `${doc.siteSpaceId}:${doc.id}`;
if (!seen.has(cacheKey)) {
seen.add(cacheKey);
const extra = cachedPageData.get(cacheKey);
results.push({
type: 'local-page',
id: doc.id,
siteSpaceId: doc.siteSpaceId,
title: doc.title,
pathname: extra?.pathname ?? '',
icon: extra?.icon,
emoji: extra?.emoji,
description: (doc.description as string | null) ?? undefined,
breadcrumbs: extra?.breadcrumbs,
});
}
}
}
setState({ results, fetching: false, error: false });
}, [query, lang, filterSiteSpaceIds, indexReady, disabled]);
return state;
}
@@ -10,7 +10,9 @@ import {
import React from 'react';
import type { LinkProps } from '../primitives';
import { type SearchPassStore, createSearchPassStore } from './search-pass-store';
import { useSearchPrewarm } from './useSearchPrewarm';
import type { DirectSearchProps } from '@/lib/search-results';
export type SearchScope =
/** Search all content on the site */
@@ -49,6 +51,7 @@ export type UpdateSearchState = (
const SetSearchStateContext = React.createContext<UpdateSearchState | undefined>(undefined);
const SearchStateContext = React.createContext<SearchState | null | undefined>(undefined);
const SearchPassContext = React.createContext<{ store: SearchPassStore; key: string } | null>(null);
/**
* Normalize the raw state to handle legacy formats.
@@ -73,9 +76,14 @@ export function shouldKeepSearchState(
}
export function SearchContextProvider(
props: React.PropsWithChildren<{ prewarmURL: string }>
props: React.PropsWithChildren<{
prewarmURL: string;
/** Identifies the site, variant and visitor context a search pass belongs to. */
searchPassKey: string;
directSearch?: DirectSearchProps;
}>
): React.ReactElement {
const { children, prewarmURL } = props;
const { children, prewarmURL, searchPassKey, directSearch } = props;
// URL-backed state
const [rawState, setRawState] = useQueryStates(keyMap, {
@@ -99,7 +107,11 @@ export function SearchContextProvider(
};
}, [rawState, open]);
useSearchPrewarm(Boolean(state?.open), prewarmURL);
const searchPass = React.useMemo(
() => ({ store: createSearchPassStore(prewarmURL, directSearch), key: searchPassKey }),
[prewarmURL, searchPassKey, directSearch]
);
useSearchPrewarm(Boolean(state?.open), searchPass.store);
const stateRef = React.useRef(state);
React.useLayoutEffect(() => {
@@ -136,12 +148,21 @@ export function SearchContextProvider(
return (
<SearchStateContext.Provider value={state}>
<SetSearchStateContext.Provider value={setState}>
{children}
<SearchPassContext.Provider value={searchPass}>
{children}
</SearchPassContext.Provider>
</SetSearchStateContext.Provider>
</SearchStateContext.Provider>
);
}
/**
* The search pass store of the current site, variant and visitor context.
*/
export function useSearchPass() {
return React.useContext(SearchPassContext);
}
export function useSetSearchState() {
const setState = React.useContext(SetSearchStateContext);
if (setState === undefined) {
@@ -73,17 +73,97 @@ function useFilteredSiteSpaceIds(props: {
);
}
/** Edits that complete a query rather than continue typing it. */
const COMPLETING_INPUT_TYPES = new Set([
'insertFromPaste',
'insertFromDrop',
'insertReplacementText',
'insertFromYank',
'insertFromComposition',
]);
/**
* Read the intent of each edit from the native input event: completed edits are sent without the
* idle delay and a typed word boundary sends the words so far. IME composition text is searched
* like typing; committing it sends at once.
*/
function useSearchInputIntent() {
const immediateQueryRef = React.useRef<string | null>(null);
const flushRef = React.useRef<() => void>(() => {});
const consumeImmediate = React.useCallback((query: string) => {
const immediate = immediateQueryRef.current === query;
immediateQueryRef.current = null;
return immediate;
}, []);
const onInput = React.useCallback((event: React.FormEvent<HTMLInputElement>) => {
const native = event.nativeEvent as InputEvent;
if (native.isComposing) {
return;
}
const query = event.currentTarget.value.trim();
if (COMPLETING_INPUT_TYPES.has(native.inputType)) {
immediateQueryRef.current = query;
} else if (
native.inputType === 'insertText' &&
native.data &&
/^\s+$/.test(native.data) &&
query
) {
// A typed space leaves the trimmed query unchanged, so send the pending words now.
flushRef.current();
}
}, []);
const onCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
// The committed text is usually already the query, so send its pending request now.
immediateQueryRef.current = event.currentTarget.value.trim();
flushRef.current();
},
[]
);
return {
consumeImmediate,
flushRef,
inputHandlers: { onInput, onCompositionEnd },
};
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
fetching: boolean;
error: boolean;
isOpen: boolean;
requestKey: string;
flush: () => void;
resultsRef: React.RefObject<SearchResultsRef | null>;
abort: () => void;
askCount: number;
onAskSelect: (index: number) => void;
}) {
const { query, results, resultsRef, abort, askCount, onAskSelect } = props;
const {
query,
results,
fetching,
error,
isOpen,
requestKey,
flush,
resultsRef,
abort,
askCount,
onAskSelect,
} = props;
// Enter pressed before the answer arrived: open the top result of this exact query and scope,
// or Ask when that answer is empty. Closing the search cancels it.
const pendingOpenRef = React.useRef<string | null>(null);
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
fetching,
resultCount: results.length,
totalCount: results.length + askCount,
});
@@ -92,9 +172,11 @@ function useSearchKeyboardNavigation(props: {
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
pendingOpenRef.current = null;
moveCursorBy(1);
} else if (event.key === 'Enter' && !isComposingKeyEvent(event)) {
event.preventDefault();
@@ -106,12 +188,51 @@ function useSearchKeyboardNavigation(props: {
if (resultsRef.current?.select()) {
// Stop any in-flight search request only when activating a result.
abort();
return;
}
if (query && fetching) {
pendingOpenRef.current = requestKey;
flush();
}
}
},
[moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort]
[
moveCursorBy,
cursor,
results.length,
onAskSelect,
resultsRef,
abort,
query,
fetching,
requestKey,
flush,
]
);
React.useEffect(() => {
const pending = pendingOpenRef.current;
if (pending === null) {
return;
}
if (!isOpen || pending !== requestKey) {
pendingOpenRef.current = null;
return;
}
if (fetching) {
return;
}
pendingOpenRef.current = null;
if (results.length > 0) {
if (resultsRef.current?.selectFirst()) {
abort();
}
} else if (!error && askCount > 0) {
onAskSelect(0);
}
}, [isOpen, requestKey, fetching, error, results, resultsRef, abort, askCount, onAskSelect]);
return {
cursor,
onInputKeyDown,
@@ -120,7 +241,11 @@ function useSearchKeyboardNavigation(props: {
export function useSearchController(
props: SearchBaseProps,
options: { restoreLastQueryOnMount?: boolean } = {}
options: {
restoreLastQueryOnMount?: boolean;
/** False for a container the current viewport hides, so only the shown one sends searches. */
requestsEnabled?: boolean;
} = {}
) {
const {
asEmbeddable,
@@ -132,7 +257,6 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
indexURL,
} = props;
const { assistants, config } = useAI();
@@ -249,10 +373,10 @@ export function useSearchController(
});
const selectedSection = state?.section ? findSearchSection(sections, state.section) : undefined;
const { results, fetching, error, abort } = useSearchResults({
const { consumeImmediate, flushRef, inputHandlers } = useSearchInputIntent();
const { results, fetching, error, requestKey, flush, abort } = useSearchResults({
asEmbeddable,
disabled: !(normalizedQuery || withAI),
open: Boolean(state?.open),
disabled: options.requestsEnabled === false || !(normalizedQuery || withAI),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -260,10 +384,10 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
indexURL,
lang: siteSpace.space.language,
withSections,
consumeImmediate,
});
flushRef.current = flush;
const searchValue =
state?.query ?? (askIsSearchQuery ? state?.ask : null) ?? lastSearchQuery ?? '';
@@ -306,6 +430,12 @@ export function useSearchController(
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
fetching,
error,
// A container that hides on this viewport drops its pending Enter.
isOpen: Boolean(state?.open) && options.requestsEnabled !== false,
requestKey,
flush,
resultsRef,
abort,
askCount,
@@ -332,6 +462,7 @@ export function useSearchController(
setQuery,
state,
onInputKeyDown,
inputHandlers,
showAsk,
withAI,
withSearchAI,
@@ -2,20 +2,21 @@
import React from 'react';
import type { SearchPassStore } from './search-pass-store';
import { createSearchPrewarmer, type SearchPrewarmer } from './search-prewarm';
export function useSearchPrewarm(open: boolean, prewarmURL: string) {
const prewarmerRef = React.useRef<SearchPrewarmer | null>(null);
if (prewarmerRef.current === null) {
prewarmerRef.current = createSearchPrewarmer(open);
}
export function useSearchPrewarm(open: boolean, passStore: SearchPassStore) {
const prewarmer = React.useMemo<SearchPrewarmer>(
() => createSearchPrewarmer(false),
[passStore]
);
React.useEffect(() => {
prewarmerRef.current?.update(open, async () => {
const response = await fetch(prewarmURL, { method: 'POST' });
if (!response.ok) {
throw new Error(`Search prewarm request failed: ${response.status}`);
}
});
}, [open, prewarmURL]);
prewarmer.update(open, () => passStore.warmUp());
}, [open, passStore, prewarmer]);
React.useEffect(() => {
passStore.setOpen(open);
return () => passStore.setOpen(false);
}, [open, passStore]);
}
@@ -8,26 +8,30 @@ import {
createRecommendedQuestionResult,
getEmptySearchResults,
} from './empty-search-results';
import { computeFilterSiteSpaceIds } from './filter';
import { computeFilterSiteSpaceIds, filterResultsBySiteSpaces } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, fuseSearchResults } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import { type SearchPassStore, searchWithPass } from './search-pass-store';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
import { type SearchScope, useSearchPass } from './useSearch';
import { useAI } from '@/components/AI';
import { getEmbeddableLinker } from '@/lib/embeddable-linker';
import { createSiteLinker } from '@/lib/links';
import { SEARCH_FALLBACK_HEADER, type SearchFallbackReason } from '@/lib/search-pass';
import {
type DirectSearchProps,
computePublishedSearchResults,
toSearchSiteContentBody,
} from '@/lib/search-results';
export type ResultType =
| OrderedComputedResult
| LocalPageResult
| MergedPageResult
| RecommendedQuestionResult;
export type ResultType = OrderedComputedResult | RecommendedQuestionResult;
export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
type RemoteSearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; requestKey: string; results: OrderedComputedResult[] }
| { status: 'error' };
/**
* We cache the recommended questions globally to avoid calling the API multiple times
@@ -40,8 +44,6 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is open. Gates building the local search index. */
open: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -50,17 +52,14 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
indexURL: string;
/** BCP-47 language code of the current site space, used to filter local search results. */
lang?: string;
/** Whether the site has multiple sections. If false, treat default scope as current for local filtering. */
/** Whether the site has multiple sections. If false, the default scope only shows the current site space. */
withSections?: boolean;
/** Returns true when the latest edit completed the query (paste, IME commit), so it is sent without the idle delay. */
consumeImmediate?: (query: string) => boolean;
}) {
const {
asEmbeddable,
disabled,
open,
query,
siteSpaceId,
siteSpaceIds,
@@ -68,9 +67,8 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
indexURL,
lang,
withSections,
consumeImmediate,
} = props;
const trackEvent = useTrackEvent();
@@ -87,24 +85,23 @@ export function useSearchResults(props: {
[scope, siteSpaceId, siteSpaceIds, withSections, selectedSectionSiteSpaceIds]
);
const { results: localResults } = useLocalSearchResults({
query,
indexURL,
lang,
disabled,
open,
filterSiteSpaceIds,
});
const remoteScope = React.useMemo(
() =>
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds),
[scope, siteSpaceId, siteSpaceIds, selectedSectionSiteSpaceIds]
);
const searchPass = useSearchPass();
const passStore = searchPass?.store;
// Identifies the answer the UI needs now; an answer for any other key is never shown as current.
const requestKey = JSON.stringify([query, remoteScope, searchPass?.key ?? null]);
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], fetching: false, error: false });
const [remoteState, setRemoteState] = React.useState<RemoteSearchState>({ status: 'idle' });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
const abortRef = React.useRef<(() => void) | null>(null);
// Sends the pending request now instead of waiting for the idle delay.
const flushRef = React.useRef<(() => void) | null>(null);
const { assistants } = useAI();
const withAI = assistants.length > 0;
@@ -116,11 +113,7 @@ export function useSearchResults(props: {
}
if (!query) {
if (!withAI) {
setRemoteState({
results: [],
fetching: false,
error: false,
});
setRemoteState({ status: 'idle' });
return;
}
@@ -131,19 +124,11 @@ export function useSearchResults(props: {
`Cached recommended questions should be set for site-space ${siteSpaceId}`
);
// Recommended questions are stored as ResultType[] already
setRemoteState({
results: [],
fetching: false,
error: false,
});
setRemoteState({ status: 'idle' });
return;
}
setRemoteState({
results: [],
fetching: false,
error: false,
});
setRemoteState({ status: 'idle' });
let cancelled = false;
@@ -156,11 +141,7 @@ export function useSearchResults(props: {
suggestions.forEach((question) => {
questions.add(question);
});
setRemoteState({
results: [],
fetching: false,
error: false,
});
setRemoteState({ status: 'idle' });
return;
}
@@ -186,11 +167,7 @@ export function useSearchResults(props: {
if (!cancelled) {
// Recommended questions are handled via a separate path below
setRemoteState({
results: [],
fetching: false,
error: false,
});
setRemoteState({ status: 'idle' });
}
}
}, 100);
@@ -200,28 +177,23 @@ export function useSearchResults(props: {
clearTimeout(timeout);
};
}
setRemoteState({
results: [],
fetching: true,
error: false,
});
setRemoteState({ status: 'loading' });
let cancelled = false;
const abortController = new AbortController();
const timeout = setTimeout(async () => {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
const send = async () => {
flushRef.current = null;
const fetchSearch = (scope: SearchSiteContentScope): Promise<OrderedComputedResult[]> =>
fetchSearchResults({
searchURL,
passStore,
scope,
query,
signal: abortController.signal,
asEmbeddable,
});
try {
const resultsPromise = fetchSearch(
computeRemoteSearchScope(
scope,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds
)
);
const resultsPromise = fetchSearch(remoteScope);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
@@ -230,38 +202,34 @@ export function useSearchResults(props: {
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState((prev) => ({ ...prev, error: true }));
setRemoteState({ status: 'error' });
return;
}
setRemoteState((prev) => ({ ...prev, results }));
setRemoteState({ status: 'ready', requestKey, results });
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, error: true }));
setRemoteState({ status: 'error' });
};
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
}
setRemoteState((prev) => ({ ...prev, fetching: false }));
} catch {
// If there is an error, we need to catch it to avoid infinite loading state.
if (cancelled) {
return;
}
setRemoteState({
results: [],
fetching: false,
error: true,
});
setRemoteState({ status: 'error' });
}
}, 200);
};
const timeout = setTimeout(send, consumeImmediate?.(query) ? 0 : 150);
flushRef.current = () => {
clearTimeout(timeout);
void send();
};
abortRef.current = () => {
cancelled = true;
@@ -274,29 +242,33 @@ export function useSearchResults(props: {
clearTimeout(timeout);
abortController.abort();
abortRef.current = null;
flushRef.current = null;
};
}, [
query,
scope,
remoteScope,
requestKey,
trackEvent,
withAI,
siteSpaceId,
siteSpaceIds,
selectedSectionSiteSpaceIds,
disabled,
suggestions,
searchURL,
passStore,
asEmbeddable,
consumeImmediate,
]);
const flush = React.useCallback(() => {
flushRef.current?.();
}, []);
const abort = React.useCallback(() => {
abortRef.current?.();
abortRef.current = null;
setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev));
setRemoteState((prev) => (prev.status === 'loading' ? { status: 'idle' } : prev));
}, []);
// Merge local and remote results.
// Re-runs immediately whenever either result set changes.
const results = React.useMemo<ResultType[]>(() => {
if (!query) {
const recommendedQuestions =
@@ -313,15 +285,12 @@ export function useSearchResults(props: {
});
}
return fuseSearchResults({
localResults,
remoteResults: remoteState.results,
query,
allowedSiteSpaceIds: filterSiteSpaceIds,
});
return remoteState.status === 'ready' && remoteState.requestKey === requestKey
? filterResultsBySiteSpaces(remoteState.results, filterSiteSpaceIds)
: [];
}, [
localResults,
remoteState.results,
remoteState,
requestKey,
query,
filterSiteSpaceIds,
withAI,
@@ -330,27 +299,122 @@ export function useSearchResults(props: {
recentQueries,
]);
// Between a query change and its loading state, the previous answer must still read as loading.
const isOutdated = remoteState.status === 'ready' && remoteState.requestKey !== requestKey;
return {
results,
fetching: remoteState.fetching,
error: remoteState.error,
fetching: remoteState.status === 'loading' || (Boolean(query) && isOutdated),
error: remoteState.status === 'error',
requestKey,
flush,
abort,
};
}
/**
* Fetch search results from the search API with a search pass, or else from the search route.
*/
async function fetchSearchResults(input: {
searchURL: string;
passStore: SearchPassStore | undefined;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean | undefined;
}): Promise<OrderedComputedResult[]> {
const { searchURL, passStore, scope, query, signal, asEmbeddable } = input;
if (passStore?.target) {
const results = await fetchDirectSearchResults({
directSearch: passStore.target,
passStore,
scope,
query,
signal,
asEmbeddable: Boolean(asEmbeddable),
});
if (results) {
return results;
}
}
return fetchRouteSearchResults(
searchURL,
scope,
query,
signal,
asEmbeddable,
passStore?.takeFallbackReport()
);
}
/**
* Fetch search results from the search API, or null when the search must use the route.
*/
async function fetchDirectSearchResults(input: {
directSearch: DirectSearchProps;
passStore: SearchPassStore;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean;
}): Promise<OrderedComputedResult[] | null> {
const { directSearch, passStore, scope, query, signal, asEmbeddable } = input;
if (!passStore.getPass()) {
return null;
}
if (query.length <= 1) {
return [];
}
const items = await searchWithPass({
store: passStore,
body: toSearchSiteContentBody(query, scope),
signal,
});
if (!items) {
return null;
}
let results: OrderedComputedResult[] | null;
try {
const linker = createSiteLinker(directSearch.linker);
results = computePublishedSearchResults({
asEmbeddable,
linker: asEmbeddable ? getEmbeddableLinker(linker) : linker,
searchResults: items,
spaces: directSearch.spaces,
});
} catch {
passStore.fallBack('invalid_response');
return null;
}
if (!results) {
passStore.rerouteUnknownSpace();
}
return results;
}
/**
* Fetch search results from the search API route.
*/
async function fetchSearchResults(
async function fetchRouteSearchResults(
searchURL: string,
scope: SearchSiteContentScope,
query: string,
signal?: AbortSignal,
asEmbeddable?: boolean
signal: AbortSignal,
asEmbeddable: boolean | undefined,
fallbackReason: SearchFallbackReason | undefined
): Promise<OrderedComputedResult[]> {
const response = await fetch(searchURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(fallbackReason ? { [SEARCH_FALLBACK_HEADER]: fallbackReason } : {}),
},
body: JSON.stringify({
asEmbeddable,
query,
@@ -2,11 +2,12 @@ import React from 'react';
export function useSearchResultsCursor(props: {
query: string;
fetching: boolean;
resultCount: number;
totalCount: number;
}) {
const [cursor, setCursor] = React.useState<number | null>(null);
const { query, resultCount, totalCount } = props;
const { query, fetching, resultCount, totalCount } = props;
React.useEffect(() => {
if (!query) {
@@ -16,6 +17,12 @@ export function useSearchResultsCursor(props: {
}, [query]);
React.useEffect(() => {
if (fetching && resultCount === 0) {
// Nothing is highlighted while results load, so Enter can't open the Ask row instead.
setCursor(null);
return;
}
if (totalCount === 0) {
return;
}
@@ -27,7 +34,7 @@ export function useSearchResultsCursor(props: {
return prev ?? 0;
});
}, [resultCount, totalCount]);
}, [fetching, resultCount, totalCount]);
React.useEffect(() => {
setCursor((prev) => {
@@ -11,6 +11,7 @@ import { SiteLayoutClientContexts } from './SiteLayoutClientContexts';
import { AdminToolbar } from '@/components/AdminToolbar';
import { CookiesToast } from '@/components/Cookies';
import { LoadIntegrations } from '@/components/Integrations';
import { getDirectSearchProps, getSearchPassKey } from '@/components/Search/search-props';
import { SpaceLayout } from '@/components/SpaceLayout';
import { WebMCP } from '@/components/WebMCP/WebMCP';
import type { VisitorAuthClaims } from '@/lib/adaptive';
@@ -71,17 +72,6 @@ export async function SiteLayout(props: {
}
});
// Start the search-index download from the HTML itself. `crossOrigin` must match the
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
// index downloads twice — the omission was exactly that bug before.
// Low priority: it's only needed once search opens and must not compete with the LCP.
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
crossOrigin: 'anonymous',
fetchPriority: 'low',
});
scripts.forEach(({ script }) => {
if (!isDeferrableScript(script)) {
ReactDOM.preload(script, {
@@ -108,6 +98,8 @@ export async function SiteLayout(props: {
context.linker.toAbsoluteURL(context.linker.toPathInSite(''))
)}
searchPrewarmURL={context.linker.toPathInSite('~gitbook/search-prewarm')}
searchPassKey={getSearchPassKey(context)}
directSearch={getDirectSearchProps(context)}
>
<AIContextProvider
aiMode={customization.ai?.mode}
@@ -13,6 +13,7 @@ import { LinkContext, type LinkContextType } from '../primitives';
import { SearchContextProvider } from '../Search';
import { SelectProvider } from '../Select';
import { isExternalLink } from '../utils/link';
import type { DirectSearchProps } from '@/lib/search-results';
/**
* Client component context providers for the site layout.
@@ -27,6 +28,8 @@ export function SiteLayoutClientContexts(props: {
/** Absolute URL of the site, so same-site absolute links render as internal on the server. */
siteURL?: string;
searchPrewarmURL: string;
searchPassKey: string;
directSearch: DirectSearchProps | undefined;
children: React.ReactNode;
}) {
const {
@@ -39,6 +42,8 @@ export function SiteLayoutClientContexts(props: {
proxyOrigin,
siteURL,
searchPrewarmURL,
searchPassKey,
directSearch,
} = props;
useClearRouterCache(contextId);
@@ -72,7 +77,11 @@ export function SiteLayoutClientContexts(props: {
<NuqsAdapter>
<SelectProvider>
<LinkContext.Provider value={linkContext}>
<SearchContextProvider prewarmURL={searchPrewarmURL}>
<SearchContextProvider
prewarmURL={searchPrewarmURL}
searchPassKey={searchPassKey}
directSearch={directSearch}
>
<ReducedMotionProvider>{children}</ReducedMotionProvider>
</SearchContextProvider>
</LinkContext.Provider>
@@ -70,8 +70,8 @@ export function SitePageNotFound() {
return;
}
// Otherwise, rank the site's pages against the path that 404'd. We reuse the search index
// (already preloaded and CDN-cached), so this adds no origin request — see getRelatedPages.
// Otherwise, rank the site's pages against the path that 404'd. The site index is
// CDN-cached, so this adds no origin request — see getRelatedPages.
let active = true;
getRelatedPages(siteIndexURL, pathname ?? '', siteSpaceId).then(
(pages) => {
@@ -261,9 +261,8 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
/**
* Return the pages whose path is closest to the one that 404'd.
*
* Rather than asking the server (which would mean an extra request per 404), this reuses the
* search index served at `~gitbook/site-index` — preloaded on every page and shared with
* instant search via the module cache in `site-index.ts`, so at most one request is made.
* Rather than asking the server (which would mean an extra origin request per 404), this reads
* the CDN-cached page index served at `~gitbook/site-index`.
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
* runs server-side from the full page tree).
*/
@@ -70,7 +70,7 @@ export function useIsLoggedInVisitor() {
}
/**
* Return the URL of the site search index (`~gitbook/site-index`).
* Return the URL of the site page index (`~gitbook/site-index`).
*/
export function useSiteIndexURL() {
const context = React.useContext(SpaceLayoutContext);
@@ -43,7 +43,6 @@ export const ar: TranslationLanguage = {
search_ask_no_answer:
'تعذر العثور على إجابة لسؤالك. يمكنك محاولة إعادة صياغته أو جعله أكثر تحديدا.',
search_ask_error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى لاحقا.',
search_partial_error: 'تعذر تحميل بعض النتائج. يتم عرض النتائج المتاحة.',
on_this_page: 'في هذه الصفحة',
next_page: 'التالي',
previous_page: 'السابق',
@@ -43,8 +43,6 @@ export const bg: TranslationLanguage = {
search_ask_no_answer:
'Не беше намерен отговор на въпроса ви. Можете да опитате да го преформулирате или да бъдете по-конкретни.',
search_ask_error: 'Нещо се обърка. Моля, опитайте отново по-късно.',
search_partial_error:
'Някои резултати не можаха да се заредят. Показват се наличните резултати.',
on_this_page: 'На тази страница',
next_page: 'Следваща',
previous_page: 'Предишна',
@@ -43,7 +43,6 @@ export const cs: TranslationLanguage = {
search_ask_no_answer:
'Na vaši otázku se nepodařilo najít odpověď. Zkuste ji přeformulovat nebo být konkrétnější.',
search_ask_error: 'Něco se pokazilo. Zkuste to prosím znovu později.',
search_partial_error: 'Některé výsledky se nepodařilo načíst. Zobrazují se dostupné výsledky.',
on_this_page: 'Na této stránce',
next_page: 'Další',
previous_page: 'Předchozí',
@@ -43,7 +43,6 @@ export const da: TranslationLanguage = {
search_ask_no_answer:
'Der kunne ikke findes et svar på dit spørgsmål. Prøv at omformulere det eller være mere specifik.',
search_ask_error: 'Noget gik galt. Prøv igen senere.',
search_partial_error: 'Nogle resultater kunne ikke indlæses. Viser tilgængelige resultater.',
on_this_page: 'På denne side',
next_page: 'Næste',
previous_page: 'Forrige',
@@ -44,8 +44,6 @@ export const de: TranslationLanguage = {
search_ask_no_answer:
'Es konnte keine Antwort auf Ihre Frage gefunden werden. Versuchen Sie, sie umzuformulieren oder genauer zu sein.',
search_ask_error: 'Etwas ist schief gelaufen. Bitte versuchen Sie es später noch einmal.',
search_partial_error:
'Einige Ergebnisse konnten nicht geladen werden. Verfügbare Ergebnisse werden angezeigt.',
on_this_page: 'Auf dieser Seite',
next_page: 'Nächste',
previous_page: 'Vorherige',
@@ -43,8 +43,6 @@ export const el: TranslationLanguage = {
search_ask_no_answer:
'Δεν ήταν δυνατή η εύρεση απάντησης στην ερώτησή σας. Δοκιμάστε να τη διατυπώσετε διαφορετικά ή πιο συγκεκριμένα.',
search_ask_error: 'Κάτι πήγε στραβά. Δοκιμάστε ξανά αργότερα.',
search_partial_error:
'Δεν ήταν δυνατή η φόρτωση ορισμένων αποτελεσμάτων. Εμφανίζονται τα διαθέσιμα αποτελέσματα.',
on_this_page: 'Σε αυτήν τη σελίδα',
next_page: 'Επόμενη',
previous_page: 'Προηγούμενη',
@@ -41,7 +41,6 @@ export const en = {
search_ask_no_answer:
'An answer could not be found for your question. You could try rephrasing it, or be more specific.',
search_ask_error: 'Something went wrong. Please try again later.',
search_partial_error: "Some results couldn't load. Showing available results.",
on_this_page: 'On this page',
next_page: 'Next',
previous_page: 'Previous',
@@ -44,8 +44,6 @@ export const es: TranslationLanguage = {
search_ask_no_answer:
'No se pudo encontrar una respuesta para su pregunta. Puede intentar reformularla o ser más específico.',
search_ask_error: 'Algo salió mal. Por favor, inténtalo de nuevo más tarde.',
search_partial_error:
'No se pudieron cargar algunos resultados. Se muestran los resultados disponibles.',
on_this_page: 'En esta página',
next_page: 'Siguiente',
previous_page: 'Anterior',
@@ -43,7 +43,6 @@ export const et: TranslationLanguage = {
search_ask_no_answer:
'Teie küsimusele ei leitud vastust. Proovige seda ümber sõnastada või olla täpsem.',
search_ask_error: 'Midagi läks valesti. Palun proovige hiljem uuesti.',
search_partial_error: 'Mõnda tulemust ei saanud laadida. Kuvatakse saadaolevad tulemused.',
on_this_page: 'Sellel lehel',
next_page: 'Järgmine',
previous_page: 'Eelmine',
@@ -43,8 +43,6 @@ export const fi: TranslationLanguage = {
search_ask_no_answer:
'Kysymykseesi ei löytynyt vastausta. Voit yrittää muotoilla sen uudelleen tai olla tarkempi.',
search_ask_error: 'Jokin meni pieleen. Yritä myöhemmin uudelleen.',
search_partial_error:
'Joitakin tuloksia ei voitu ladata. Näytetään saatavilla olevat tulokset.',
on_this_page: 'Tällä sivulla',
next_page: 'Seuraava',
previous_page: 'Edellinen',
@@ -43,8 +43,6 @@ export const fr: TranslationLanguage = {
search_ask_sources_no_answer: 'Pages associées',
search_ask_no_answer: 'Pas de réponse trouvée. Essayez de reformuler votre question.',
search_ask_error: 'Une erreur est survenue. Veuillez réessayer plus tard.',
search_partial_error:
'Certains résultats n’ont pas pu être chargés. Les résultats disponibles sont affichés.',
on_this_page: 'Sur cette page',
next_page: 'Suivant',
previous_page: 'Précédent',
@@ -43,7 +43,6 @@ export const he: TranslationLanguage = {
search_ask_no_answer:
'לא נמצאה תשובה לשאלה שלך. אפשר לנסות לנסח אותה מחדש או להיות מדויקים יותר.',
search_ask_error: 'משהו השתבש. נסה שוב מאוחר יותר.',
search_partial_error: 'לא ניתן היה לטעון חלק מהתוצאות. מוצגות התוצאות הזמינות.',
on_this_page: 'בדף הזה',
next_page: 'הבא',
previous_page: 'הקודם',
@@ -43,7 +43,6 @@ export const hi: TranslationLanguage = {
search_ask_no_answer:
'आपके प्रश्न का उत्तर नहीं मिल सका। आप इसे फिर से लिखने या अधिक विशिष्ट बनाने की कोशिश कर सकते हैं।',
search_ask_error: 'कुछ गलत हो गया। कृपया बाद में फिर कोशिश करें।',
search_partial_error: 'कुछ परिणाम लोड नहीं हो सके। उपलब्ध परिणाम दिखाए जा रहे हैं।',
on_this_page: 'इस पृष्ठ पर',
next_page: 'अगला',
previous_page: 'पिछला',
@@ -43,7 +43,6 @@ export const hr: TranslationLanguage = {
search_ask_no_answer:
'Nije bilo moguće pronaći odgovor na vaše pitanje. Pokušajte ga preformulirati ili biti konkretniji.',
search_ask_error: 'Nešto je pošlo po zlu. Pokušajte ponovno kasnije.',
search_partial_error: 'Neki se rezultati nisu mogli učitati. Prikazuju se dostupni rezultati.',
on_this_page: 'Na ovoj stranici',
next_page: 'Sljedeća',
previous_page: 'Prethodna',
@@ -43,8 +43,6 @@ export const hu: TranslationLanguage = {
search_ask_no_answer:
'Nem található válasz a kérdésére. Próbálja meg átfogalmazni, vagy legyen pontosabb.',
search_ask_error: 'Valami hiba történt. Kérjük, próbálja újra később.',
search_partial_error:
'Néhány találatot nem sikerült betölteni. Az elérhető találatok jelennek meg.',
on_this_page: 'Ezen az oldalon',
next_page: 'Következő',
previous_page: 'Előző',
@@ -43,7 +43,6 @@ export const id: TranslationLanguage = {
search_ask_no_answer:
'Jawaban untuk pertanyaan Anda tidak dapat ditemukan. Anda dapat mencoba menyusun ulang atau membuatnya lebih spesifik.',
search_ask_error: 'Terjadi kesalahan. Silakan coba lagi nanti.',
search_partial_error: 'Beberapa hasil tidak dapat dimuat. Menampilkan hasil yang tersedia.',
on_this_page: 'Di halaman ini',
next_page: 'Berikutnya',
previous_page: 'Sebelumnya',
@@ -44,8 +44,6 @@ export const it: TranslationLanguage = {
search_ask_no_answer:
'Non è stato possibile trovare una risposta alla tua domanda. Prova a riformularla o a essere più specifico.',
search_ask_error: 'Qualcosa è andato storto. Riprova più tardi.',
search_partial_error:
'Non è stato possibile caricare alcuni risultati. Visualizziamo i risultati disponibili.',
on_this_page: 'In questa pagina',
next_page: 'Successivo',
previous_page: 'Precedente',
@@ -44,7 +44,6 @@ export const ja: TranslationLanguage = {
search_ask_no_answer:
'ご質問への回答が見つかりませんでした。質問を言い換えるか、もう少し具体的にしてください。',
search_ask_error: '何らかのエラーが発生しました。後ほど再度お試しください。',
search_partial_error: '一部の結果を読み込めませんでした。利用可能な結果を表示しています。',
on_this_page: 'このページ内',
next_page: '次へ',
previous_page: '前へ',
@@ -44,7 +44,6 @@ export const ko: TranslationLanguage = {
search_ask_no_answer:
'질문에 대한 답변을 찾을 수 없습니다. 질문을 다시 표현하거나 더 구체적으로 작성해 보세요.',
search_ask_error: '문제가 발생했습니다. 나중에 다시 시도해 주세요.',
search_partial_error: '일부 결과를 불러오지 못했습니다. 사용 가능한 결과를 표시합니다.',
on_this_page: '이 페이지에서',
next_page: '다음',
previous_page: '이전',
@@ -43,7 +43,6 @@ export const lt: TranslationLanguage = {
search_ask_no_answer:
'Nepavyko rasti atsakymo į jūsų klausimą. Galite pabandyti jį performuluoti arba pateikti konkrečiau.',
search_ask_error: 'Kažkas nepavyko. Bandykite dar kartą vėliau.',
search_partial_error: 'Nepavyko įkelti kai kurių rezultatų. Rodomi pasiekiami rezultatai.',
on_this_page: 'Šiame puslapyje',
next_page: 'Kitas',
previous_page: 'Ankstesnis',
@@ -43,7 +43,6 @@ export const lv: TranslationLanguage = {
search_ask_no_answer:
'Neizdevās atrast atbildi uz jūsu jautājumu. Varat mēģināt to pārfrāzēt vai būt konkrētāks.',
search_ask_error: 'Kaut kas nogāja greizi. Lūdzu, mēģiniet vēlāk vēlreiz.',
search_partial_error: 'Dažus rezultātus neizdevās ielādēt. Tiek rādīti pieejamie rezultāti.',
on_this_page: 'Šajā lapā',
next_page: 'Nākamā',
previous_page: 'Iepriekšējā',
@@ -43,7 +43,6 @@ export const ms: TranslationLanguage = {
search_ask_no_answer:
'Jawapan untuk soalan anda tidak ditemui. Anda boleh cuba menyusunnya semula atau bertanya dengan lebih khusus.',
search_ask_error: 'Ada sesuatu yang tidak kena. Sila cuba lagi kemudian.',
search_partial_error: 'Sesetengah hasil tidak dapat dimuatkan. Memaparkan hasil yang tersedia.',
on_this_page: 'Pada halaman ini',
next_page: 'Seterusnya',
previous_page: 'Sebelumnya',
@@ -44,8 +44,6 @@ export const nl: TranslationLanguage = {
search_ask_no_answer:
'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.',
search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.',
search_partial_error:
'Sommige resultaten konden niet worden geladen. Beschikbare resultaten worden weergegeven.',
on_this_page: 'Op deze pagina',
next_page: 'Volgende',
previous_page: 'Vorige',
@@ -44,7 +44,6 @@ export const no: TranslationLanguage = {
search_ask_no_answer:
'Et svar på spørsmålet ditt kunne ikke finnes. Prøv å omformulere det eller være mer spesifikk.',
search_ask_error: 'Noe gikk galt. Vennligst prøv igjen senere.',
search_partial_error: 'Noen resultater kunne ikke lastes inn. Viser tilgjengelige resultater.',
on_this_page: 'På denne siden',
next_page: 'Neste',
previous_page: 'Forrige',
@@ -43,7 +43,6 @@ export const pl: TranslationLanguage = {
search_ask_no_answer:
'Nie udało się znaleźć odpowiedzi na Twoje pytanie. Spróbuj sformułować je inaczej lub bardziej szczegółowo.',
search_ask_error: 'Coś poszło nie tak. Spróbuj ponownie później.',
search_partial_error: 'Nie udało się wczytać niektórych wyników. Wyświetlamy dostępne wyniki.',
on_this_page: 'Na tej stronie',
next_page: 'Dalej',
previous_page: 'Wstecz',
@@ -44,8 +44,6 @@ export const pt_br: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para sua pergunta. Você pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo deu errado. Por favor, tente novamente mais tarde.',
search_partial_error:
'Alguns resultados não puderam ser carregados. Mostrando os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Próximo',
previous_page: 'Anterior',
@@ -43,8 +43,6 @@ export const pt: TranslationLanguage = {
search_ask_no_answer:
'Não foi possível encontrar uma resposta para a sua pergunta. Pode tentar reformulá-la ou ser mais específico.',
search_ask_error: 'Algo correu mal. Tente novamente mais tarde.',
search_partial_error:
'Não foi possível carregar alguns resultados. A mostrar os resultados disponíveis.',
on_this_page: 'Nesta página',
next_page: 'Seguinte',
previous_page: 'Anterior',
@@ -43,8 +43,6 @@ export const ro: TranslationLanguage = {
search_ask_no_answer:
'Nu s-a putut găsi un răspuns la întrebarea ta. Poți încerca să o reformulezi sau să fii mai specific.',
search_ask_error: 'Ceva nu a mers bine. Te rugăm să încerci din nou mai târziu.',
search_partial_error:
'Unele rezultate nu au putut fi încărcate. Se afișează rezultatele disponibile.',
on_this_page: 'Pe această pagină',
next_page: 'Următoarea',
previous_page: 'Anterioara',
@@ -44,8 +44,6 @@ export const ru: TranslationLanguage = {
search_ask_no_answer:
'Не удалось найти ответ на ваш вопрос. Попробуйте перефразировать его или задать более конкретный вопрос.',
search_ask_error: 'Что-то пошло не так. Пожалуйста, попробуйте позже.',
search_partial_error:
'Не удалось загрузить некоторые результаты. Показаны доступные результаты.',
on_this_page: 'На этой странице',
next_page: 'Следующая',
previous_page: 'Предыдущая',
@@ -43,8 +43,6 @@ export const sk: TranslationLanguage = {
search_ask_no_answer:
'Na vašu otázku sa nepodarilo nájsť odpoveď. Skúste ju preformulovať alebo byť konkrétnejší.',
search_ask_error: 'Niečo sa pokazilo. Skúste to znova neskôr.',
search_partial_error:
'Niektoré výsledky sa nepodarilo načítať. Zobrazujú sa dostupné výsledky.',
on_this_page: 'Na tejto stránke',
next_page: 'Ďalej',
previous_page: 'Predchádzajúca',
@@ -43,8 +43,6 @@ export const sl: TranslationLanguage = {
search_ask_no_answer:
'Odgovora na vaše vprašanje ni bilo mogoče najti. Poskusite ga preoblikovati ali biti bolj natančni.',
search_ask_error: 'Nekaj je šlo narobe. Poskusite znova pozneje.',
search_partial_error:
'Nekaterih rezultatov ni bilo mogoče naložiti. Prikazani so razpoložljivi rezultati.',
on_this_page: 'Na tej strani',
next_page: 'Naslednja',
previous_page: 'Prejšnja',
@@ -43,7 +43,6 @@ export const sv: TranslationLanguage = {
search_ask_no_answer:
'Det gick inte att hitta ett svar på din fråga. Du kan försöka formulera om den eller vara mer specifik.',
search_ask_error: 'Något gick fel. Försök igen senare.',
search_partial_error: 'Vissa resultat kunde inte läsas in. Visar tillgängliga resultat.',
on_this_page: 'På den här sidan',
next_page: 'Nästa',
previous_page: 'Föregående',
@@ -42,7 +42,6 @@ export const th: TranslationLanguage = {
search_ask_sources_no_answer: 'หน้าที่เกี่ยวข้อง',
search_ask_no_answer: 'ไม่พบคำตอบสำหรับคำถามของคุณ ลองเรียบเรียงใหม่หรือถามให้เฉพาะเจาะจงขึ้น',
search_ask_error: 'มีบางอย่างผิดพลาด โปรดลองอีกครั้งในภายหลัง',
search_partial_error: 'โหลดผลลัพธ์บางรายการไม่ได้ กำลังแสดงผลลัพธ์ที่พร้อมใช้งาน',
on_this_page: 'ในหน้านี้',
next_page: 'ถัดไป',
previous_page: 'ก่อนหน้า',
@@ -43,7 +43,6 @@ export const tr: TranslationLanguage = {
search_ask_no_answer:
'Sorunuz için bir cevap bulunamadı. Soruyu yeniden ifade etmeyi veya daha spesifik olmayı deneyebilirsiniz.',
search_ask_error: 'Bir şeyler ters gitti. Lütfen daha sonra tekrar deneyin.',
search_partial_error: 'Bazı sonuçlar yüklenemedi. Kullanılabilir sonuçlar gösteriliyor.',
on_this_page: 'Bu sayfada',
next_page: 'Sonraki',
previous_page: 'Önceki',
@@ -43,7 +43,6 @@ export const uk: TranslationLanguage = {
search_ask_no_answer:
'Не вдалося знайти відповідь на ваше запитання. Спробуйте переформулювати його або зробити конкретнішим.',
search_ask_error: 'Щось пішло не так. Спробуйте ще раз пізніше.',
search_partial_error: 'Не вдалося завантажити деякі результати. Показано доступні результати.',
on_this_page: 'На цій сторінці',
next_page: 'Наступна',
previous_page: 'Попередня',
@@ -43,7 +43,6 @@ export const vi: TranslationLanguage = {
search_ask_no_answer:
'Không tìm thấy câu trả lời cho câu hỏi của bạn. Bạn có thể thử diễn đạt lại hoặc hỏi cụ thể hơn.',
search_ask_error: 'Đã xảy ra lỗi. Vui lòng thử lại sau.',
search_partial_error: 'Không thể tải một số kết quả. Đang hiển thị các kết quả có sẵn.',
on_this_page: 'Trên trang này',
next_page: 'Tiếp theo',
previous_page: 'Trước',
@@ -42,7 +42,6 @@ export const yue: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '搵唔到你問題嘅答案。你可以試下改寫,或者問得更具體。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果載入唔到。顯示緊可用嘅結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -42,7 +42,6 @@ export const zh_tw: TranslationLanguage = {
search_ask_sources_no_answer: '相關頁面',
search_ask_no_answer: '找不到您問題的答案。您可以嘗試改寫問題,或提出更具體的內容。',
search_ask_error: '發生錯誤。請稍後再試。',
search_partial_error: '部分結果無法載入。正在顯示可用結果。',
on_this_page: '本頁內容',
next_page: '下一頁',
previous_page: '上一頁',
@@ -43,7 +43,6 @@ export const zh: TranslationLanguage = {
search_ask_sources_no_answer: '相关页面',
search_ask_no_answer: '无法找到您的问题的答案。您可以尝试改述问题或提供更具体的信息。',
search_ask_error: '出了些问题。请稍后再试。',
search_partial_error: '部分结果无法加载。正在显示可用结果。',
on_this_page: '在本页',
next_page: '下一页',
previous_page: '上一页',
+32 -24
View File
@@ -20,9 +20,14 @@ import type {
TranslationLanguage,
} from '@gitbook/api';
import { GITBOOK_URL } from './env';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
import {
type GitBookLinker,
type SiteLinkerInput,
createSiteLinker,
linkerForPublishedURL,
} from './links';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import {
type GitBookDataFetcher,
@@ -31,6 +36,7 @@ import {
throwIfDataError,
} from '@/lib/data';
import { getLogger } from '@/lib/logger';
import type { DirectSearchTarget } from '@/lib/search-results';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
@@ -133,6 +139,11 @@ export type GitBookBaseContext = {
* Translation language of the space.
*/
locale?: TranslationLanguage;
/**
* Target for the browser to query the site search API directly with a search pass.
*/
directSearch?: DirectSearchTarget;
};
/**
@@ -240,28 +251,14 @@ export function getBaseContext(input: {
apiToken: siteURLData.apiToken ?? null,
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteURL.host,
siteBasePath: siteURLData.siteBasePath,
spaceBasePath: siteURLData.basePath,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
siteBasePath: `/url/${siteURL.host}${siteURLData.siteBasePath}`,
spaceBasePath: `/url/${siteURL.host}${siteURLData.basePath}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`;
};
}
const linkerInput: SiteLinkerInput = {
urlMode,
siteHost: siteURL.host,
siteBasePath: siteURLData.siteBasePath,
spaceBasePath: siteURLData.basePath,
gitbookURL: GITBOOK_URL || undefined,
};
const linker = createSiteLinker(linkerInput);
const imageResizer = createImageResizer({
imagesContextId: siteURLData.imagesContextId,
@@ -270,10 +267,21 @@ export function getBaseContext(input: {
linker: linker,
});
const directSearch: DirectSearchTarget | undefined =
!siteURLData.revision && !siteURLData.changeRequest && !siteURLData.preview
? {
apiURL: GITBOOK_API_PUBLIC_URL,
organizationId: siteURLData.organization,
siteId: siteURLData.site,
linker: linkerInput,
}
: undefined;
return {
dataFetcher,
linker,
imageResizer,
directSearch,
};
}
+44
View File
@@ -197,6 +197,50 @@ export function createLinker(
return linker;
}
/**
* Serializable inputs of the linker of a site request.
*/
export type SiteLinkerInput = {
urlMode: 'url' | 'url-host';
siteHost: string;
siteBasePath: string;
spaceBasePath: string;
/** Main host GitBook runs on, used in `url` mode. */
gitbookURL: string | undefined;
};
/**
* Create the linker of a site request, for the URL mode it is served in.
*/
export function createSiteLinker(input: SiteLinkerInput): GitBookLinker {
const { urlMode, siteHost, siteBasePath, spaceBasePath } = input;
const gitbookURL = input.gitbookURL ? new URL(input.gitbookURL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteHost,
siteBasePath,
spaceBasePath,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
siteBasePath: `/url/${siteHost}${siteBasePath}`,
spaceBasePath: `/url/${siteHost}${spaceBasePath}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`;
};
}
return linker;
}
/**
* Create a new linker that intercepts links that belongs to the published site and rewrite them
* relative to the URL being served.
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'bun:test';
import {
classifySearchPassStatus,
isSearchPassEndpoint,
readSearchPassFromPrewarmReply,
} from './search-pass';
const target = { apiURL: 'https://api.gitbook.com', organizationId: 'org_1', siteId: 'site_1' };
describe('isSearchPassEndpoint', () => {
it('accepts only the search operation of the current site on the public API', () => {
expect(
isSearchPassEndpoint(
'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/search',
target
)
).toBe(true);
for (const endpoint of [
'https://evil.example.com/v1/orgs/org_1/sites/site_1/search',
'http://api.gitbook.com/v1/orgs/org_1/sites/site_1/search',
'https://api.gitbook.com.evil.example.com/v1/orgs/org_1/sites/site_1/search',
'https://api.gitbook.com/v1/orgs/org_1/sites/site_2/search',
'https://api.gitbook.com/v1/orgs/org_2/sites/site_1/search',
'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/search/../ask',
'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/ask',
'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/search?redirect=1',
'https://user@api.gitbook.com/v1/orgs/org_1/sites/site_1/search',
'/v1/orgs/org_1/sites/site_1/search',
]) {
expect(isSearchPassEndpoint(endpoint, target)).toBe(false);
}
});
});
describe('classifySearchPassStatus', () => {
it('maps each API status to the browser action of the search pass contract', () => {
expect(classifySearchPassStatus(200)).toEqual({ type: 'results' });
expect(classifySearchPassStatus(401)).toEqual({ type: 'refresh' });
expect(classifySearchPassStatus(403)).toEqual({ type: 'fall_back', reason: 'forbidden' });
expect(classifySearchPassStatus(429)).toEqual({ type: 'back_off' });
expect(classifySearchPassStatus(503)).toEqual({
type: 'fall_back',
reason: 'server_error',
});
expect(classifySearchPassStatus(404)).toEqual({
type: 'fall_back',
reason: 'unexpected_status',
});
});
});
describe('readSearchPassFromPrewarmReply', () => {
it('finds a pass only in a well-formed searchPass field', () => {
const searchPass = {
token: 'pass',
expiresAt: '2026-11-14T12:00:00.000Z',
endpoint: 'https://api.gitbook.com/v1/orgs/org_1/sites/site_1/search',
};
expect(readSearchPassFromPrewarmReply({ searchPass })).toEqual(searchPass);
for (const body of [
{},
null,
'',
{ searchPass: null },
{ searchPass: { ...searchPass, token: '' } },
{ searchPass: { ...searchPass, expiresAt: 'soon' } },
{ searchPass: { token: 'pass', expiresAt: searchPass.expiresAt } },
searchPass,
]) {
expect(readSearchPassFromPrewarmReply(body)).toBeNull();
}
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Short-lived credential for a visitor's browser to search a published site with the API directly.
*/
export type SearchPassFields = {
token: string;
expiresAt: string;
endpoint: string;
};
/**
* Why a search went to the search route after the visitor's browser held a search pass.
*/
export type SearchFallbackReason =
| 'invalid_endpoint'
| 'unauthorized'
| 'refresh_failed'
| 'forbidden'
| 'rate_limited'
| 'server_error'
| 'unexpected_status'
| 'network'
| 'invalid_response'
| 'unknown_space';
export const SEARCH_FALLBACK_REASONS: readonly SearchFallbackReason[] = [
'invalid_endpoint',
'unauthorized',
'refresh_failed',
'forbidden',
'rate_limited',
'server_error',
'unexpected_status',
'network',
'invalid_response',
'unknown_space',
];
/** Request header naming the reason a search was sent to the search route instead of the API. */
export const SEARCH_FALLBACK_HEADER = 'x-gitbook-search-fallback';
/**
* Read the search pass fields from a warm-up reply, or null when they are missing or malformed.
*/
export function readSearchPassFields(value: unknown): SearchPassFields | null {
if (!value || typeof value !== 'object') {
return null;
}
const { token, expiresAt, endpoint } = value as Record<string, unknown>;
if (
typeof token !== 'string' ||
typeof expiresAt !== 'string' ||
typeof endpoint !== 'string' ||
!token ||
Number.isNaN(Date.parse(expiresAt))
) {
return null;
}
return { token, expiresAt, endpoint };
}
/**
* Read the search pass from the API's warm-up reply body, or null when it holds no valid one.
*/
export function readSearchPassFromPrewarmReply(body: unknown): SearchPassFields | null {
if (!body || typeof body !== 'object') {
return null;
}
return readSearchPassFields((body as { searchPass?: unknown }).searchPass);
}
/**
* Whether `endpoint` is the search operation of the site on the configured public API.
*/
export function isSearchPassEndpoint(
endpoint: string,
target: { apiURL: string; organizationId: string; siteId: string }
): boolean {
if (!URL.canParse(endpoint) || !URL.canParse(target.apiURL)) {
return false;
}
const url = new URL(endpoint);
const expectedPath = `/v1/orgs/${encodeURIComponent(target.organizationId)}/sites/${encodeURIComponent(target.siteId)}/search`;
return (
url.origin === new URL(target.apiURL).origin &&
url.pathname === expectedPath &&
!url.username &&
!url.password &&
!url.search &&
!url.hash
);
}
/**
* What the browser does after the API answers a search made with a search pass.
*/
export type SearchPassStatusOutcome =
| { type: 'results' }
| { type: 'refresh' }
| { type: 'back_off' }
| { type: 'fall_back'; reason: SearchFallbackReason };
/**
* Classify the HTTP status of a search made with a search pass.
*/
export function classifySearchPassStatus(status: number): SearchPassStatusOutcome {
if (status >= 200 && status < 300) {
return { type: 'results' };
}
if (status === 401) {
return { type: 'refresh' };
}
if (status === 403) {
return { type: 'fall_back', reason: 'forbidden' };
}
if (status === 429) {
return { type: 'back_off' };
}
if (status >= 500) {
return { type: 'fall_back', reason: 'server_error' };
}
return { type: 'fall_back', reason: 'unexpected_status' };
}
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'bun:test';
import type {
SearchPageResult,
SearchRecordResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
} from '@gitbook/api';
import { getBaseContext } from './context';
import { getEmbeddableLinker } from './embeddable-linker';
import { createSiteLinker } from './links';
import { getSearchSpaceLookup, transformSitePageResult } from './search';
import { computePublishedSearchResults, computeSearchResults } from './search-results';
import { findSiteSpaceBy } from './sites';
function makeSiteSpace(id: string, spaceId: string, title: string, published: string): SiteSpace {
return {
object: 'site-space',
id,
title,
path: '',
default: id.endsWith('default'),
draft: false,
space: { id: spaceId, title, language: 'en' },
urls: { published },
} as SiteSpace;
}
const apiV1 = makeSiteSpace('ss_api_v1', 'space_api_v1', 'v1', 'https://docs.example.com/api/v1/');
const apiV2 = makeSiteSpace(
'ss_api_default',
'space_api_v2',
'v2',
'https://docs.example.com/api/'
);
const guides = makeSiteSpace(
'ss_guides_default',
'space_guides',
'Guides',
'https://docs.example.com/'
);
const apiSection = {
object: 'site-section',
id: 'section_api',
title: 'API',
icon: 'code',
siteSpaces: [apiV2, apiV1],
} as unknown as SiteSection;
const guidesSection = {
object: 'site-section',
id: 'section_guides',
title: 'Guides',
default: true,
siteSpaces: [guides],
} as unknown as SiteSection;
const productsGroup = {
object: 'site-section-group',
id: 'group_products',
title: 'Products',
icon: 'box',
children: [apiSection],
} as unknown as SiteSectionGroup;
const structure: SiteStructure = { type: 'sections', structure: [guidesSection, productsGroup] };
function page(id: string, path: string, rank: number, extra: Partial<SearchPageResult> = {}) {
return {
id,
title: `Page ${id}`,
description: 'Synthetic result',
path,
score: 10 - rank,
rank,
ancestors: [],
urls: { app: 'https://app.gitbook.com/o/org/s/space' },
...extra,
} as SearchPageResult;
}
const items: (SearchSpaceResult | SearchRecordResult)[] = [
{
type: 'space',
id: 'space_api_v2',
title: 'v2',
score: 3,
pages: [
page('page_auth', 'authentication', 2, {
ancestors: [{ title: 'Getting started' }],
sections: [
{
id: 'tokens',
title: 'Tokens',
body: 'Create a token',
path: 'authentication#tokens',
score: 4,
urls: { app: 'https://app.gitbook.com/o/org/s/space' },
},
],
}),
],
},
{
type: 'space',
id: 'space_guides',
title: 'Guides',
score: 2,
pages: [page('page_intro', '', 1), page('page_status', 'https://status.example.com/', 3)],
},
{
type: 'space',
id: 'space_api_v1',
title: 'v1',
score: 1,
pages: [page('page_legacy', 'legacy/authentication', 4)],
},
{
type: 'record',
id: 'record_1',
title: 'Support ticket',
description: 'Reset a token',
url: 'https://support.example.com/1',
score: 2,
} as SearchRecordResult,
];
function routeResults(linker: ReturnType<typeof createSiteLinker>, asEmbeddable: boolean) {
return computeSearchResults(items, (spaceItem, pageItem) => {
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === spaceItem.id
);
return transformSitePageResult({
asEmbeddable,
linker,
pageItem,
spaceItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
});
});
}
describe('computePublishedSearchResults', () => {
it.each([
['url-host', false],
['url-host', true],
['url', false],
['url', true],
] as const)(
'gives the search route results for the same API answer (%s, embedded: %p)',
(urlMode, asEmbeddable) => {
const context = getBaseContext({
siteURL: 'https://docs.example.com/api/',
siteURLData: {
organization: 'org_1',
site: 'site_1',
siteSection: 'section_api',
siteSpace: 'ss_api_default',
space: 'space_api_v2',
siteBasePath: '/',
basePath: '/api/',
apiToken: 'page-token',
imagesContextId: 'images',
} as Parameters<typeof getBaseContext>[0]['siteURLData'],
urlMode,
});
const routeLinker = asEmbeddable ? getEmbeddableLinker(context.linker) : context.linker;
const directSearch = context.directSearch!;
const browserLinker = createSiteLinker(directSearch.linker);
const browser = computePublishedSearchResults({
asEmbeddable,
linker: asEmbeddable ? getEmbeddableLinker(browserLinker) : browserLinker,
searchResults: items,
spaces: getSearchSpaceLookup(structure),
});
expect(JSON.stringify(directSearch)).not.toContain('page-token');
expect(browser).toEqual(routeResults(routeLinker, asEmbeddable));
expect(browser?.map((result) => result.id)).toEqual([
'space_guides/page_intro',
'space_api_v2/page_auth',
'space_guides/page_status',
'space_api_v1/page_legacy',
'record_1',
]);
}
);
});
describe('getSearchSpaceLookup', () => {
it('leaves hidden variants out, so their results go to the route', () => {
const hiddenV1 = { ...apiV1, hidden: true } as SiteSpace;
const withHidden: SiteStructure = {
type: 'sections',
structure: [
guidesSection,
{
...productsGroup,
children: [{ ...apiSection, siteSpaces: [apiV2, hiddenV1] }],
} as unknown as SiteSectionGroup,
],
};
const spaces = getSearchSpaceLookup(withHidden);
expect(Object.keys(spaces).sort()).toEqual(['space_api_v2', 'space_guides']);
expect(JSON.stringify(spaces)).not.toContain('ss_api_v1');
expect(JSON.stringify(spaces)).not.toContain('/api/v1/');
const linker = createSiteLinker({
urlMode: 'url-host',
siteHost: 'docs.example.com',
siteBasePath: '/',
spaceBasePath: '/api/',
gitbookURL: undefined,
});
expect(
computePublishedSearchResults({
asEmbeddable: false,
linker,
searchResults: items,
spaces,
})
).toBeNull();
});
});
+226
View File
@@ -0,0 +1,226 @@
import type {
GitBookAPI,
SearchPageResult,
SearchRecordResult,
SearchSpaceResult,
SiteSearchScope,
} from '@gitbook/api';
import { orderSearchResultGroups } from '@/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults';
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentScope,
} from '@/components/Search/search-types';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import type { GitBookLinker, SiteLinkerInput } from '@/lib/links';
import { joinPathWithBaseURL } from '@/lib/paths';
import { checkIsHttpURL } from '@/lib/urls';
export type SearchResultBreadcrumb = NonNullable<ComputedPageResult['breadcrumbs']>[number];
/**
* What a search result needs from the site structure for the space it belongs to.
*/
export type SearchSpaceEntry = {
siteSpaceId?: string;
spaceURL?: string;
breadcrumbs: SearchResultBreadcrumb[];
};
/**
* Search space entries keyed by space ID, for the first site space showing that space.
*/
export type SearchSpaceLookup = Record<string, SearchSpaceEntry>;
/**
* Everything the browser needs, besides a search pass, to query the search API of a site directly.
*/
export type DirectSearchTarget = {
apiURL: string;
organizationId: string;
siteId: string;
linker: SiteLinkerInput;
};
export type DirectSearchProps = DirectSearchTarget & {
spaces: SearchSpaceLookup;
};
/**
* Body of the site search API request for a query in a scope.
*/
export function toSearchSiteContentBody(
query: string,
scope: SearchSiteContentScope
): Parameters<GitBookAPI['orgs']['searchSiteContent']>[2] {
return {
query,
...(scope.mode === 'current' && scope.restrictTo
? {
// `restrictTo` only exists in the newer `scope` request shape,
// and the published @gitbook/api types don't include it yet.
scope: {
mode: 'default',
currentSiteSpace: scope.siteSpaceId,
restrictTo: scope.restrictTo,
} as SiteSearchScope,
}
: scope),
};
}
/**
* Turn the search API items into ordered results, with page results computed by the caller.
*/
export function computeSearchResults(
searchResults: (SearchSpaceResult | SearchRecordResult)[],
getPageResult: (
spaceItem: SearchSpaceResult,
pageItem: SearchPageResult
) => ComputedPageResult | null
): OrderedComputedResult[] {
return orderSearchResultGroups<OrderedComputedResult>(
searchResults.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
score: resultItem.score,
};
return { type: 'context' as const, results: [result] };
}
return {
type: 'pages' as const,
results: resultItem.pages.flatMap((pageItem) => {
const result = getPageResult(resultItem, pageItem);
return result ? [{ rank: pageItem.rank, result }] : [];
}),
};
})
);
}
/**
* Compute the results of the published content of a site, resolving spaces from a lookup.
* Returns null when a page result belongs to a space missing from the lookup.
*/
export function computePublishedSearchResults(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
searchResults: (SearchSpaceResult | SearchRecordResult)[];
spaces: SearchSpaceLookup;
}): OrderedComputedResult[] | null {
const { asEmbeddable, linker, searchResults, spaces } = input;
const hasUnknownSpace = searchResults.some(
(item) => item.type === 'space' && item.pages.length > 0 && !spaces[item.id]
);
if (hasUnknownSpace) {
return null;
}
return computeSearchResults(searchResults, (spaceItem, pageItem) => {
const entry: SearchSpaceEntry | undefined = spaces[spaceItem.id];
const toHref = (pagePath: string) =>
getPublishedPageHref({ asEmbeddable, linker, pagePath, spaceURL: entry?.spaceURL });
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: toHref(pageItem.path),
getSectionHref: toHref,
});
});
}
/**
* Build the result for a page, with its best section.
*/
export function buildSearchPageResult(input: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
entry: SearchSpaceEntry | undefined;
pageHref: string;
getSectionHref: (sectionPath: string) => string;
}): ComputedPageResult {
const { pageItem, spaceItem, entry, pageHref, getSectionHref } = input;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
description: pageItem.description,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
siteSpaceId: entry?.siteSpaceId,
score: pageItem.score,
rank: pageItem.rank,
resultType: pageItem.resultType,
breadcrumbs: [
...(entry?.breadcrumbs ?? []),
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
],
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: getSectionHref(section.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
})) ?? [];
// 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 = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return page;
}
/**
* Link to a page path of the published space at `spaceURL`.
*/
export function getPublishedPageHref(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
pagePath: string;
spaceURL: string | undefined;
}) {
const { asEmbeddable, linker, pagePath, spaceURL } = input;
if (checkIsHttpURL(pagePath)) {
return pagePath;
}
if (!spaceURL) {
return linker.toPathInSpace(pagePath);
}
return asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pagePath)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pagePath));
}
+74 -96
View File
@@ -6,16 +6,21 @@ import type {
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ComputedPageResult, ComputedSectionResult } from '@/components/Search/search-types';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import type { ComputedPageResult } from '@/components/Search/search-types';
import type { GitBookLinker } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getLocalizedTitle } from '@/lib/sites';
import { checkIsHttpURL } from '@/lib/urls';
import {
type SearchResultBreadcrumb,
type SearchSpaceEntry,
type SearchSpaceLookup,
buildSearchPageResult,
getPublishedPageHref,
} from '@/lib/search-results';
import { findSiteSpaceBy, getLocalizedTitle, listAllSiteSpaces } from '@/lib/sites';
export function transformSitePageResult(args: {
asEmbeddable: boolean;
@@ -37,20 +42,77 @@ export function transformSitePageResult(args: {
linker,
revisionPages,
} = args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
const entry = getSearchSpaceEntry({ siteSpace, siteSection, siteSectionGroup });
let revisionTarget: { pages: Revision['pages']; page: RevisionPageDocument } | undefined;
if (revisionPages) {
const resolved = resolvePageId(revisionPages, pageItem.id);
if (!resolved || resolved.page.id !== pageItem.id) {
return null;
}
revisionTarget = { pages: revisionPages, page: resolved.page };
const revisionTarget: { pages: Revision['pages']; page: RevisionPageDocument } = {
pages: revisionPages,
page: resolved.page,
};
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: linker.toPathForPage(revisionTarget),
getSectionHref: (sectionPath) =>
linker.toPathForPage({
...revisionTarget,
anchor: getRetainableSectionAnchor(sectionPath, pageItem.path),
}),
});
}
const toHref = (pagePath: string) =>
getPublishedPageHref({ asEmbeddable, linker, pagePath, spaceURL: entry.spaceURL });
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: toHref(pageItem.path),
getSectionHref: toHref,
});
}
/**
* Index the visible site spaces by space, resolving each space as the search route does.
*/
export function getSearchSpaceLookup(structure: SiteStructure): SearchSpaceLookup {
const lookup: SearchSpaceLookup = {};
for (const { space } of listAllSiteSpaces(structure)) {
if (lookup[space.id]) {
continue;
}
const found = findSiteSpaceBy(structure, (siteSpace) => siteSpace.space.id === space.id);
if (found && !found.siteSpace.hidden) {
lookup[space.id] = getSearchSpaceEntry({
siteSpace: found.siteSpace,
siteSection: found.siteSection ?? undefined,
siteSectionGroup: found.siteSectionGroup ?? undefined,
});
}
}
return lookup;
}
function getSearchSpaceEntry(input: {
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): SearchSpaceEntry {
const { siteSpace, siteSection, siteSectionGroup } = input;
const currentLanguage = siteSpace?.space.language;
const breadcrumbs: SearchResultBreadcrumb[] = [];
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
@@ -79,74 +141,11 @@ export function transformSitePageResult(args: {
});
}
breadcrumbs.push(
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
}))
);
const pageHref = revisionTarget
? linker.toPathForPage(revisionTarget)
: getPublishedPageHref({ asEmbeddable, linker, pagePath: pageItem.path, spaceURL });
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
description: pageItem.description,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
return {
siteSpaceId: siteSpace?.id,
score: pageItem.score,
rank: pageItem.rank,
resultType: pageItem.resultType,
spaceURL: siteSpace?.urls.published,
breadcrumbs,
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
const anchor = revisionTarget
? getRetainableSectionAnchor(section.path, pageItem.path)
: undefined;
const sectionHref = revisionTarget
? linker.toPathForPage({
...revisionTarget,
anchor,
})
: getPublishedPageHref({
asEmbeddable,
linker,
pagePath: section.path,
spaceURL,
});
return {
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: sectionHref,
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
};
}) ?? [];
// 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 = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return page;
}
function getRetainableSectionAnchor(sectionPath: string, pagePath: string): string | undefined {
@@ -157,24 +156,3 @@ function getRetainableSectionAnchor(sectionPath: string, pagePath: string): stri
return sectionPath.slice(hashIndex + 1) || undefined;
}
function getPublishedPageHref(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
pagePath: string;
spaceURL: string | undefined;
}) {
const { asEmbeddable, linker, pagePath, spaceURL } = input;
if (checkIsHttpURL(pagePath)) {
return pagePath;
}
if (!spaceURL) {
return linker.toPathInSpace(pagePath);
}
return asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pagePath)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pagePath));
}
+25 -2
View File
@@ -60,6 +60,7 @@ import {
createRobotsTxtResponse,
isRobotsTxtRequest,
} from '@/lib/robots';
import { readSearchPassFromPrewarmReply } from '@/lib/search-pass';
import {
type ResponseCookies,
getPathScopedCookieName,
@@ -812,6 +813,8 @@ const PATH_ALIASES: Record<string, string> = {
[AI_CATALOG_WELL_KNOWN_PATH]: AI_CATALOG_PATH,
};
const SEARCH_PREWARM_TIMEOUT_MS = 1_500;
function isSearchPrewarmRequest(siteURLData: PublishedSiteContent) {
return (
removeLeadingSlash(removeTrailingSlash(siteURLData.pathname)) === '~gitbook/search-prewarm'
@@ -827,9 +830,29 @@ async function serveSearchPrewarm(request: NextRequest, siteURLData: PublishedSi
}
const api = apiClient({ apiToken: siteURLData.apiToken ?? null });
await waitUntil(api.orgs.prewarmSiteSearch(siteURLData.organization, siteURLData.site));
const prewarm = api.orgs
.prewarmSiteSearch(siteURLData.organization, siteURLData.site)
.then((response) => response.json())
.then(readSearchPassFromPrewarmReply)
.catch(() => null);
void waitUntil(prewarm);
return new NextResponse(null, { status: 204 });
let timeout: ReturnType<typeof setTimeout> | undefined;
const searchPass = await Promise.race([
prewarm,
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), SEARCH_PREWARM_TIMEOUT_MS);
}),
]);
clearTimeout(timeout);
if (!searchPass) {
return new NextResponse(null, { status: 204 });
}
return NextResponse.json(searchPass, {
headers: { 'cache-control': 'private, no-store' },
});
}
/**