mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-12 14:48:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91ec127e01 |
+19
-47
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user