mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-15 01:11:26 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfc03fb15b | |||
| 404c65844a | |||
| f55dc464f6 | |||
| ab39dc74c8 | |||
| 35148805e7 | |||
| 36154a2d86 | |||
| a018e930de | |||
| d596bdc43b | |||
| b1dc336f00 | |||
| a92f11b9dd | |||
| 1f656f1713 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Show loading rows shaped like search results while search waits for the server.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Show one server-ordered search list and open its top result on Enter.
|
||||
+41
-47
@@ -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: '上一页',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user