Compare commits

...

1 Commits

Author SHA1 Message Date
Peter White 91ec127e01 Search the API directly from the browser on published sites
The browser posts to the search API with the content token of the page and
builds results with the code the search route uses. The route stays as the
fallback for previews, expiring tokens and failed direct calls.

Drop the cache directive on the search fetcher: its key holds the token and
the typed prefix, so entries are written and not read back.

searchSpikeOverrides.ts is a measurement switch for the preview deploy and
must be removed before merge.

Part of RND-13965
2026-11-12 12:58:14 +00:00
12 changed files with 571 additions and 298 deletions
@@ -1,13 +1,10 @@
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 { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { transformSitePageResult } from '@/lib/search';
import { computeSearchResults } from '@/lib/search-results';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy, getLinkerForSiteSpace } from '@/lib/sites';
@@ -56,49 +53,24 @@ 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);
}
@@ -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'> & {
@@ -27,6 +29,7 @@ export interface SearchBaseProps {
withSiteVariants: boolean;
indexURL: string;
searchURL: string;
directSearch?: DirectSearchProps;
}
export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps {
@@ -38,6 +41,9 @@ export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps
return {
searchURL: context.linker.toPathInSpace('~gitbook/search'),
directSearch: context.directSearch
? { ...context.directSearch, spaces: getSearchSpaceLookup(context.structure) }
: undefined,
section,
sections,
siteSpace,
@@ -0,0 +1,37 @@
// Spike only: lets a measurement harness switch the search arms from the browser without a rebuild,
// via `localStorage.gb_search_spike = '{"direct": false, "debounceMs": 200}'`.
// Delete this module (and its two call sites in useSearchResults.ts) when the spike ends.
const STORAGE_KEY = 'gb_search_spike';
type SearchSpikeOverrides = {
/** `false` forces the search route, `true` uses the API directly whenever the page allows it. */
direct?: boolean;
/** Replaces the search debounce. */
debounceMs?: number;
};
export function getSearchSpikeOverrides(): SearchSpikeOverrides {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
const { direct, debounceMs } = parsed as Record<string, unknown>;
return {
direct: typeof direct === 'boolean' ? direct : undefined,
debounceMs:
typeof debounceMs === 'number' && Number.isFinite(debounceMs) && debounceMs >= 0
? debounceMs
: undefined,
};
} catch {
return {};
}
}
@@ -131,6 +131,7 @@ export function useSearchController(
withSections,
siteSpaces,
searchURL,
directSearch,
indexURL,
} = props;
@@ -259,6 +260,7 @@ export function useSearchController(
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
directSearch,
indexURL,
lang: siteSpace.space.language,
withSections,
@@ -1,7 +1,10 @@
import { readStreamableValue } from 'ai/rsc';
import { jwtDecode } from 'jwt-decode';
import React from 'react';
import { assert } from 'ts-essentials';
import type { SearchRecordResult, SearchSpaceResult } from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import {
type RecommendedQuestionResult,
@@ -13,10 +16,19 @@ import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, fuseSearchResults } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { getSearchSpikeOverrides } from './searchSpikeOverrides';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
import type { SearchScope } from './useSearch';
import { useAI } from '@/components/AI';
import { getEmbeddableLinker } from '@/lib/embeddable-linker';
import { createSiteLinker } from '@/lib/links';
import { removeTrailingSlash } from '@/lib/paths';
import {
type DirectSearchProps,
computePublishedSearchResults,
toSearchSiteContentBody,
} from '@/lib/search-results';
export type ResultType =
| OrderedComputedResult
@@ -29,6 +41,9 @@ export type { LocalPageResult, MergedPageResult };
// Small helper extracted for unit testing of scope → local filter mapping
// computeFilterSiteSpaceIds is imported from './filter' for testability
/** Delay between the last keystroke and the remote search request. */
const SEARCH_DEBOUNCE_MS = 200;
/**
* We cache the recommended questions globally to avoid calling the API multiple times
* when re-opening the search modal. The cache is per space, so that we can
@@ -50,6 +65,8 @@ export function useSearchResults(props: {
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
/** Target to query the search API directly from the browser, when the page allows it. */
directSearch?: DirectSearchProps;
/** 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. */
@@ -68,6 +85,7 @@ export function useSearchResults(props: {
scope,
suggestions,
searchURL,
directSearch,
indexURL,
lang,
withSections,
@@ -208,10 +226,15 @@ export function useSearchResults(props: {
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 fetchSearch = (scope: SearchSiteContentScope): Promise<OrderedComputedResult[]> =>
fetchSearchResults({
searchURL,
directSearch,
scope,
query,
signal: abortController.signal,
asEmbeddable,
});
try {
const resultsPromise = fetchSearch(
@@ -261,7 +284,7 @@ export function useSearchResults(props: {
error: true,
});
}
}, 200);
}, getSearchSpikeOverrides().debounceMs ?? SEARCH_DEBOUNCE_MS);
abortRef.current = () => {
cancelled = true;
@@ -286,6 +309,7 @@ export function useSearchResults(props: {
disabled,
suggestions,
searchURL,
directSearch,
asEmbeddable,
]);
@@ -338,14 +362,107 @@ export function useSearchResults(props: {
};
}
/**
* Fetch search results from the search API, or from the search route when the page can't query it.
*/
async function fetchSearchResults(input: {
searchURL: string;
directSearch: DirectSearchProps | undefined;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean | undefined;
}): Promise<OrderedComputedResult[]> {
const { searchURL, directSearch, scope, query, signal, asEmbeddable } = input;
if (directSearch && canSearchDirectly(directSearch)) {
try {
return await fetchDirectSearchResults({
directSearch,
scope,
query,
signal,
asEmbeddable: Boolean(asEmbeddable),
});
} catch (error) {
if (signal.aborted) {
throw error;
}
}
}
return fetchRouteSearchResults(searchURL, scope, query, signal, asEmbeddable);
}
function canSearchDirectly(directSearch: DirectSearchProps): boolean {
if (getSearchSpikeOverrides().direct === false) {
return false;
}
try {
const { exp } = jwtDecode<{ exp?: number }>(directSearch.apiToken);
// Same margin as the static routes, which stop serving a token this close to expiry.
return typeof exp === 'number' && exp > Date.now() / 1000 + 120;
} catch {
return false;
}
}
async function fetchDirectSearchResults(input: {
directSearch: DirectSearchProps;
scope: SearchSiteContentScope;
query: string;
signal: AbortSignal;
asEmbeddable: boolean;
}): Promise<OrderedComputedResult[]> {
const { directSearch, scope, query, signal, asEmbeddable } = input;
const { apiURL, apiToken, organizationId, siteId, spaces } = directSearch;
if (query.length <= 1) {
return [];
}
const response = await fetch(
`${removeTrailingSlash(apiURL)}/v1/orgs/${organizationId}/sites/${siteId}/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(toSearchSiteContentBody(query, scope)),
signal,
}
);
if (!response.ok) {
throw new Error(`Direct search request failed: ${response.status}`);
}
const data = (await response.json()) as {
items?: (SearchSpaceResult | SearchRecordResult)[];
};
if (!Array.isArray(data?.items)) {
throw new Error('Direct search response has no items');
}
const linker = createSiteLinker(directSearch.linker);
return computePublishedSearchResults({
asEmbeddable,
linker: asEmbeddable ? getEmbeddableLinker(linker) : linker,
searchResults: data.items,
spaces,
});
}
/**
* Fetch search results from the search API route.
*/
async function fetchSearchResults(
async function fetchRouteSearchResults(
searchURL: string,
scope: SearchSiteContentScope,
query: string,
signal?: AbortSignal,
signal: AbortSignal,
asEmbeddable?: boolean
): Promise<OrderedComputedResult[]> {
const response = await fetch(searchURL, {
@@ -65,11 +65,16 @@ export async function SiteLayout(props: {
const scripts = withTracking ? context.scripts : [];
// React dedupes preconnects by href, so reduce to the origin; relative URLs are same-origin.
[GITBOOK_API_PUBLIC_URL, GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
[GITBOOK_ICONS_URL, GITBOOK_ASSETS_URL].forEach((url) => {
if (url && URL.canParse(url)) {
ReactDOM.preconnect(new URL(url).origin);
}
});
// Search queries the API with a CORS fetch without credentials, which only reuses an
// anonymous connection.
if (URL.canParse(GITBOOK_API_PUBLIC_URL)) {
ReactDOM.preconnect(new URL(GITBOOK_API_PUBLIC_URL).origin, { crossOrigin: 'anonymous' });
}
// 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
+36 -24
View File
@@ -20,9 +20,14 @@ import type {
TranslationLanguage,
} from '@gitbook/api';
import { GITBOOK_URL } from './env';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
import {
type GitBookLinker,
type SiteLinkerInput,
createSiteLinker,
linkerForPublishedURL,
} from './links';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import {
type GitBookDataFetcher,
@@ -31,6 +36,7 @@ import {
throwIfDataError,
} from '@/lib/data';
import { getLogger } from '@/lib/logger';
import type { DirectSearchTarget } from '@/lib/search-results';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
@@ -133,6 +139,11 @@ export type GitBookBaseContext = {
* Translation language of the space.
*/
locale?: TranslationLanguage;
/**
* Target for the browser to query the site search API directly, when the request allows it.
*/
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,25 @@ export function getBaseContext(input: {
linker: linker,
});
const directSearch: DirectSearchTarget | undefined =
siteURLData.apiToken &&
!siteURLData.revision &&
!siteURLData.changeRequest &&
!siteURLData.preview
? {
apiURL: GITBOOK_API_PUBLIC_URL,
apiToken: siteURLData.apiToken,
organizationId: siteURLData.organization,
siteId: siteURLData.site,
linker: linkerInput,
}
: undefined;
return {
dataFetcher,
linker,
imageResizer,
directSearch,
};
}
-87
View File
@@ -1,87 +0,0 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const cacheLife = mock((_profile: unknown) => {});
const cacheTag = mock((..._tags: string[]) => {});
const realNextCache = await import('next/cache');
mock.module('next/cache', () => ({ ...realNextCache, cacheLife, cacheTag }));
const { createDataFetcher } = await import('./api');
const originalFetch = globalThis.fetch;
afterAll(() => mock.restore());
describe('searchSiteContent', () => {
beforeEach(() => {
cacheLife.mockClear();
cacheTag.mockClear();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function mockFetch(impl: () => Promise<Response>) {
globalThis.fetch = mock(impl);
}
function search() {
return createDataFetcher().searchSiteContent({
organizationId: 'org-1',
siteId: 'site-1',
query: 'test',
scope: { mode: 'all' },
});
}
it('caches successful results for hours with the site and response tags', async () => {
mockFetch(async () =>
Response.json(
{ items: [] },
{ headers: { 'x-gitbook-cache-tag': 'space:space-1,space:space-2' } }
)
);
const result = await search();
expect(result).toEqual({ data: [] });
expect(cacheLife.mock.calls).toEqual([['hours']]);
expect(cacheTag.mock.calls.flat()).toEqual([
'site:site-1',
'space:space-1',
'space:space-2',
]);
});
it('does not cache a transient API error', async () => {
mockFetch(async () =>
Response.json({ error: { code: 429, message: 'Namespace saturated' } }, { status: 429 })
);
const result = await search();
expect(result.error?.code).toBe(429);
expect(cacheLife.mock.calls).toEqual([[{ stale: 0, revalidate: 0, expire: 0 }]]);
});
it('does not cache a network failure', async () => {
mockFetch(() => Promise.reject(new TypeError('fetch failed')));
const result = await search();
expect(result.error?.code).toBe(500);
expect(cacheLife.mock.calls).toEqual([[{ stale: 0, revalidate: 0, expire: 0 }]]);
});
it('keeps the default cache life for a stable 404', async () => {
mockFetch(async () =>
Response.json({ error: { code: 404, message: 'Site not found' } }, { status: 404 })
);
const result = await search();
expect(result.error?.code).toBe(404);
expect(cacheLife).not.toHaveBeenCalled();
});
});
+4 -36
View File
@@ -6,14 +6,14 @@ import {
GitBookAPI,
type HttpResponse,
type RenderIntegrationUI,
type SiteSearchScope,
} from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { cache } from '../cache';
import { DataFetcherError, isTransientDataFetcherError, wrapDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
import { toSearchSiteContentBody } from '@/lib/search-results';
import { trace } from '@/lib/tracing';
interface DataFetcherInput {
@@ -785,21 +785,12 @@ const getEmbedByUrl = cache(
}
);
// We don't use remote cache for this one because of the number of potential cache entry causing 429 on vercel runtime cache when revalidating
const searchSiteContent = cache(
async (
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => {
'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
const result = await wrapDataFetcherError(async () => {
return wrapDataFetcherError(async () => {
return trace(
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
async () => {
@@ -808,39 +799,16 @@ const searchSiteContent = cache(
const res = await api.orgs.searchSiteContent(
organizationId,
siteId,
{
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),
},
toSearchSiteContentBody(query, scope),
{},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('hours');
return res.data.items;
}
);
});
// Don't serve transient errors for the default 15 minutes. Only safe because search runs in
// request handlers: a page cache calling it would inherit this short life during API incidents.
if (result.error && isTransientDataFetcherError(result.error)) {
cacheLife({ stale: 0, revalidate: 0, expire: 0 });
}
return result;
}
);
+44
View File
@@ -197,6 +197,50 @@ export function createLinker(
return linker;
}
/**
* Serializable inputs of the linker of a site request.
*/
export type SiteLinkerInput = {
urlMode: 'url' | 'url-host';
siteHost: string;
siteBasePath: string;
spaceBasePath: string;
/** Main host GitBook runs on, used in `url` mode. */
gitbookURL: string | undefined;
};
/**
* Create the linker of a site request, for the URL mode it is served in.
*/
export function createSiteLinker(input: SiteLinkerInput): GitBookLinker {
const { urlMode, siteHost, siteBasePath, spaceBasePath } = input;
const gitbookURL = input.gitbookURL ? new URL(input.gitbookURL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteHost,
siteBasePath,
spaceBasePath,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
siteBasePath: `/url/${siteHost}${siteBasePath}`,
spaceBasePath: `/url/${siteHost}${spaceBasePath}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`;
};
}
return linker;
}
/**
* Create a new linker that intercepts links that belongs to the published site and rewrite them
* relative to the URL being served.
+219
View File
@@ -0,0 +1,219 @@
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 to query the search API of a published site directly.
*/
export type DirectSearchTarget = {
apiURL: string;
apiToken: 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.
*/
export function computePublishedSearchResults(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
searchResults: (SearchSpaceResult | SearchRecordResult)[];
spaces: SearchSpaceLookup;
}): OrderedComputedResult[] {
const { asEmbeddable, linker, searchResults, spaces } = input;
return computeSearchResults(searchResults, (spaceItem, pageItem) => {
const entry: SearchSpaceEntry | undefined = spaces[spaceItem.id];
const toHref = (pagePath: string) =>
getPublishedPageHref({ asEmbeddable, linker, pagePath, spaceURL: entry?.spaceURL });
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: toHref(pageItem.path),
getSectionHref: toHref,
});
});
}
/**
* Build the result for a page, with its best section.
*/
export function buildSearchPageResult(input: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
entry: SearchSpaceEntry | undefined;
pageHref: string;
getSectionHref: (sectionPath: string) => string;
}): ComputedPageResult {
const { pageItem, spaceItem, entry, pageHref, getSectionHref } = input;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
description: pageItem.description,
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
siteSpaceId: entry?.siteSpaceId,
score: pageItem.score,
rank: pageItem.rank,
resultType: pageItem.resultType,
breadcrumbs: [
...(entry?.breadcrumbs ?? []),
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
],
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: getSectionHref(section.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
})) ?? [];
// The API returns at most one section per page, ordered for use as the section destination preview.
const bestSection = pageSections[0];
if (bestSection) {
page.bestSection = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return page;
}
/**
* Link to a page path of the published space at `spaceURL`.
*/
export function getPublishedPageHref(input: {
asEmbeddable: boolean;
linker: GitBookLinker;
pagePath: string;
spaceURL: string | undefined;
}) {
const { asEmbeddable, linker, pagePath, spaceURL } = input;
if (checkIsHttpURL(pagePath)) {
return pagePath;
}
if (!spaceURL) {
return linker.toPathInSpace(pagePath);
}
return asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pagePath)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pagePath));
}
+74 -96
View File
@@ -6,16 +6,21 @@ import type {
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ComputedPageResult, ComputedSectionResult } from '@/components/Search/search-types';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import type { ComputedPageResult } from '@/components/Search/search-types';
import type { GitBookLinker } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getLocalizedTitle } from '@/lib/sites';
import { checkIsHttpURL } from '@/lib/urls';
import {
type SearchResultBreadcrumb,
type SearchSpaceEntry,
type SearchSpaceLookup,
buildSearchPageResult,
getPublishedPageHref,
} from '@/lib/search-results';
import { findSiteSpaceBy, getLocalizedTitle, listAllSiteSpaces } from '@/lib/sites';
export function transformSitePageResult(args: {
asEmbeddable: boolean;
@@ -37,20 +42,77 @@ export function transformSitePageResult(args: {
linker,
revisionPages,
} = args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
const entry = getSearchSpaceEntry({ siteSpace, siteSection, siteSectionGroup });
let revisionTarget: { pages: Revision['pages']; page: RevisionPageDocument } | undefined;
if (revisionPages) {
const resolved = resolvePageId(revisionPages, pageItem.id);
if (!resolved || resolved.page.id !== pageItem.id) {
return null;
}
revisionTarget = { pages: revisionPages, page: resolved.page };
const revisionTarget: { pages: Revision['pages']; page: RevisionPageDocument } = {
pages: revisionPages,
page: resolved.page,
};
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: linker.toPathForPage(revisionTarget),
getSectionHref: (sectionPath) =>
linker.toPathForPage({
...revisionTarget,
anchor: getRetainableSectionAnchor(sectionPath, pageItem.path),
}),
});
}
const toHref = (pagePath: string) =>
getPublishedPageHref({ asEmbeddable, linker, pagePath, spaceURL: entry.spaceURL });
return buildSearchPageResult({
pageItem,
spaceItem,
entry,
pageHref: toHref(pageItem.path),
getSectionHref: toHref,
});
}
/**
* Index the site structure 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) {
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));
}