mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 16:15:22 +00:00
Add Search tab to Docs Embed, refactor search into an embeddable frame (#4185)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
"@gitbook/embed": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Search tab to Docs Embed, refactor search
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@changesets/cli": "^2.30.0",
|
"@changesets/cli": "^2.30.0",
|
||||||
"turbo": "^2.9.2",
|
"turbo": "^2.9.6",
|
||||||
"vercel": "50.37.3",
|
"vercel": "50.37.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Embed your GitBook docs in your product or website.
|
Embed your GitBook docs in your product or website.
|
||||||
|
|
||||||
The Docs Embed can contain two tabs:
|
The Docs Embed can contain three tabs:
|
||||||
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
|
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
|
||||||
|
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
|
||||||
- **Docs**: A browser for navigating your documentation site
|
- **Docs**: A browser for navigating your documentation site
|
||||||
|
|
||||||
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
|
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
|
||||||
@@ -40,7 +41,7 @@ GitBook('configure', {
|
|||||||
label: 'Ask',
|
label: 'Ask',
|
||||||
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||||
},
|
},
|
||||||
tabs: ['assistant', 'docs'],
|
tabs: ['assistant', 'search', 'docs'],
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
icon: 'circle-question',
|
icon: 'circle-question',
|
||||||
@@ -93,7 +94,7 @@ frame.clearChat();
|
|||||||
|
|
||||||
// Configure the embed (see Configuration section for all options)
|
// Configure the embed (see Configuration section for all options)
|
||||||
frame.configure({
|
frame.configure({
|
||||||
tabs: ['assistant', 'docs'],
|
tabs: ['assistant', 'search', 'docs'],
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
icon: 'circle-question',
|
icon: 'circle-question',
|
||||||
@@ -128,7 +129,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
|||||||
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||||
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
||||||
}}
|
}}
|
||||||
tabs={['assistant', 'docs']}
|
tabs={['assistant', 'search', 'docs']}
|
||||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||||
assistantName="Support Assistant"
|
assistantName="Support Assistant"
|
||||||
suggestions={['What is GitBook?', 'How do I get started?']}
|
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||||
@@ -230,10 +231,10 @@ Available in: Standalone script, NPM package, React components
|
|||||||
|
|
||||||
Override which tabs are displayed. Defaults to your site's configuration.
|
Override which tabs are displayed. Defaults to your site's configuration.
|
||||||
|
|
||||||
- **Type**: `('assistant' | 'docs')[]`
|
- **Type**: `('assistant' | 'search' | 'docs')[]`
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
tabs: ['assistant', 'docs']
|
tabs: ['assistant', 'search', 'docs']
|
||||||
```
|
```
|
||||||
|
|
||||||
### `closeButton`
|
### `closeButton`
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
|||||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
const configuration: GitBookEmbeddableConfiguration = {
|
const configuration: GitBookEmbeddableConfiguration = {
|
||||||
tabs: ['assistant', 'docs'],
|
tabs: ['assistant', 'search', 'docs'],
|
||||||
actions: [],
|
actions: [],
|
||||||
greeting: { title: '', subtitle: '' },
|
greeting: { title: '', subtitle: '' },
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
|
|||||||
*/
|
*/
|
||||||
export type GitBookEmbeddableConfiguration = {
|
export type GitBookEmbeddableConfiguration = {
|
||||||
/** Tabs to display in the embed (if enabled on the site). */
|
/** Tabs to display in the embed (if enabled on the site). */
|
||||||
tabs: ('assistant' | 'docs')[];
|
tabs: ('assistant' | 'docs' | 'search')[];
|
||||||
|
|
||||||
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
||||||
actions: GitBookEmbeddableActionDefinition[];
|
actions: GitBookEmbeddableActionDefinition[];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
|||||||
greeting,
|
greeting,
|
||||||
suggestions = [],
|
suggestions = [],
|
||||||
tools = [],
|
tools = [],
|
||||||
tabs = ['assistant', 'docs'],
|
tabs = ['assistant', 'search', 'docs'],
|
||||||
trademark = true,
|
trademark = true,
|
||||||
closeButton = false,
|
closeButton = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
|||||||
greeting: { title: '', subtitle: '' },
|
greeting: { title: '', subtitle: '' },
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
tabs: ['assistant', 'docs'],
|
tabs: ['assistant', 'search', 'docs'],
|
||||||
trademark: true,
|
trademark: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { EmbeddableSearchPage } from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
|
|
||||||
|
return <EmbeddableSearchPage context={context} />;
|
||||||
|
}
|
||||||
+110
-98
@@ -4,8 +4,8 @@ import type {
|
|||||||
OrderedComputedResult,
|
OrderedComputedResult,
|
||||||
SearchSiteContentRequest,
|
SearchSiteContentRequest,
|
||||||
} from '@/components/Search/search-types';
|
} from '@/components/Search/search-types';
|
||||||
import type { GitBookBaseContext } from '@/lib/context';
|
|
||||||
import { throwIfDataError } from '@/lib/data';
|
import { throwIfDataError } from '@/lib/data';
|
||||||
|
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
@@ -16,54 +16,41 @@ import type {
|
|||||||
SiteSection,
|
SiteSection,
|
||||||
SiteSectionGroup,
|
SiteSectionGroup,
|
||||||
SiteSpace,
|
SiteSpace,
|
||||||
Space,
|
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import type { IconName } from '@gitbook/icons';
|
import type { IconName } from '@gitbook/icons';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
type SearchResultGroup = {
|
|
||||||
score: number;
|
|
||||||
items: OrderedComputedResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const [context, { organization, site, shareKey }] = await Promise.all([
|
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
|
||||||
getServerActionBaseContext(),
|
const [context, siteURLData] = await Promise.all([
|
||||||
|
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
|
||||||
getSiteURLDataFromMiddleware(),
|
getSiteURLDataFromMiddleware(),
|
||||||
]);
|
]);
|
||||||
const body = (await request.json()) as SearchSiteContentRequest;
|
|
||||||
const { query, scope } = body;
|
|
||||||
|
|
||||||
if (query.length <= 1) {
|
if (query.length <= 1) {
|
||||||
return NextResponse.json([]);
|
return NextResponse.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [searchResults, { structure }] = await Promise.all([
|
const [searchResults, { structure }] = await Promise.all([
|
||||||
(async () => {
|
throwIfDataError(
|
||||||
const result = await throwIfDataError(
|
context.dataFetcher.searchSiteContent({
|
||||||
context.dataFetcher.searchSiteContent({
|
organizationId: siteURLData.organization,
|
||||||
organizationId: organization,
|
siteId: siteURLData.site,
|
||||||
siteId: site,
|
query,
|
||||||
query,
|
scope,
|
||||||
scope,
|
})
|
||||||
})
|
),
|
||||||
);
|
throwIfDataError(
|
||||||
return result;
|
context.dataFetcher.getPublishedContentSite({
|
||||||
})(),
|
organizationId: siteURLData.organization,
|
||||||
(async () => {
|
siteId: siteURLData.site,
|
||||||
const result = await throwIfDataError(
|
siteShareKey: siteURLData.shareKey,
|
||||||
context.dataFetcher.getPublishedContentSite({
|
})
|
||||||
organizationId: organization,
|
),
|
||||||
siteId: site,
|
|
||||||
siteShareKey: shareKey,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
})(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const results = searchResults
|
const results = searchResults
|
||||||
.flatMap((resultItem): SearchResultGroup[] => {
|
.flatMap((resultItem) => {
|
||||||
if (resultItem.type === 'record') {
|
if (resultItem.type === 'record') {
|
||||||
const result: OrderedComputedResult = {
|
const result: OrderedComputedResult = {
|
||||||
type: 'record',
|
type: 'record',
|
||||||
@@ -73,31 +60,25 @@ export async function POST(request: NextRequest) {
|
|||||||
href: resultItem.url,
|
href: resultItem.url,
|
||||||
score: resultItem.score,
|
score: resultItem.score,
|
||||||
};
|
};
|
||||||
return [
|
|
||||||
{
|
return [{ score: resultItem.score, items: [result] }];
|
||||||
score: resultItem.score,
|
|
||||||
items: [result],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = findSiteSpaceBy(
|
const found = findSiteSpaceBy(
|
||||||
structure,
|
structure,
|
||||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||||
);
|
);
|
||||||
const siteSection = found?.siteSection;
|
|
||||||
const siteSectionGroup = found?.siteSectionGroup;
|
|
||||||
|
|
||||||
return resultItem.pages.map((pageItem) => ({
|
return resultItem.pages.map((pageItem) => ({
|
||||||
score: pageItem.score,
|
score: pageItem.score,
|
||||||
items: transformSitePageResult(context, {
|
items: transformSitePageResult({
|
||||||
|
asEmbeddable: Boolean(asEmbeddable),
|
||||||
|
linker: context.linker,
|
||||||
pageItem,
|
pageItem,
|
||||||
spaceItem: resultItem,
|
spaceItem: resultItem,
|
||||||
siteSpace: found?.siteSpace,
|
siteSpace: found?.siteSpace,
|
||||||
space: found?.siteSpace.space,
|
siteSection: found?.siteSection ?? undefined,
|
||||||
spaceURL: found?.siteSpace.urls.published,
|
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||||
siteSection: siteSection ?? undefined,
|
|
||||||
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
@@ -107,72 +88,103 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformSitePageResult(
|
function transformSitePageResult(args: {
|
||||||
context: GitBookBaseContext,
|
asEmbeddable: boolean;
|
||||||
args: {
|
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
|
||||||
pageItem: SearchPageResult;
|
pageItem: SearchPageResult;
|
||||||
spaceItem: SearchSpaceResult;
|
spaceItem: SearchSpaceResult;
|
||||||
space?: Space;
|
siteSpace?: SiteSpace;
|
||||||
siteSpace?: SiteSpace;
|
siteSection?: SiteSection;
|
||||||
spaceURL?: string;
|
siteSectionGroup?: SiteSectionGroup | null;
|
||||||
siteSection?: SiteSection;
|
}): OrderedComputedResult[] {
|
||||||
siteSectionGroup?: SiteSectionGroup;
|
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||||
}
|
args;
|
||||||
): OrderedComputedResult[] {
|
|
||||||
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
|
|
||||||
const { linker } = context;
|
|
||||||
const currentLanguage = siteSpace?.space.language;
|
const currentLanguage = siteSpace?.space.language;
|
||||||
|
const spaceURL = siteSpace?.urls.published;
|
||||||
|
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
|
||||||
|
|
||||||
|
if (siteSectionGroup) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
icon: siteSectionGroup.icon as IconName,
|
||||||
|
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteSection) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
icon: siteSection.icon as IconName,
|
||||||
|
label: getLocalizedTitle(siteSection, currentLanguage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(siteSection?.siteSpaces?.filter(
|
||||||
|
(space) =>
|
||||||
|
siteSection.siteSpaces?.filter(
|
||||||
|
(candidate) => candidate.space.language === space.space.language
|
||||||
|
).length > 1
|
||||||
|
).length ?? 0) > 1 &&
|
||||||
|
siteSpace
|
||||||
|
) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
label: getLocalizedTitle(siteSpace, currentLanguage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
breadcrumbs.push(
|
||||||
|
...pageItem.ancestors.map((ancestor) => ({
|
||||||
|
label: ancestor.title,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageHref = !spaceURL
|
||||||
|
? linker.toPathInSpace(pageItem.path)
|
||||||
|
: asEmbeddable
|
||||||
|
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
|
||||||
|
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
|
||||||
|
|
||||||
const page: ComputedPageResult = {
|
const page: ComputedPageResult = {
|
||||||
type: 'page',
|
type: 'page',
|
||||||
id: `${spaceItem.id}/${pageItem.id}`,
|
id: `${spaceItem.id}/${pageItem.id}`,
|
||||||
title: pageItem.title,
|
title: pageItem.title,
|
||||||
href: spaceURL
|
href: pageHref,
|
||||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
|
|
||||||
: linker.toPathInSpace(pageItem.path),
|
|
||||||
pageId: pageItem.id,
|
pageId: pageItem.id,
|
||||||
spaceId: spaceItem.id,
|
spaceId: spaceItem.id,
|
||||||
score: pageItem.score,
|
score: pageItem.score,
|
||||||
breadcrumbs: [
|
breadcrumbs,
|
||||||
siteSectionGroup && {
|
|
||||||
icon: siteSectionGroup?.icon as IconName,
|
|
||||||
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
|
|
||||||
},
|
|
||||||
siteSection && {
|
|
||||||
icon: siteSection?.icon as IconName,
|
|
||||||
label: getLocalizedTitle(siteSection, currentLanguage),
|
|
||||||
},
|
|
||||||
(siteSection?.siteSpaces?.filter(
|
|
||||||
(space) =>
|
|
||||||
siteSection?.siteSpaces?.filter(
|
|
||||||
(s) => s.space.language === space.space.language
|
|
||||||
).length > 1
|
|
||||||
).length ?? 0) > 1 && siteSpace
|
|
||||||
? {
|
|
||||||
label: getLocalizedTitle(siteSpace, currentLanguage),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
...pageItem.ancestors.map((ancestor) => ({
|
|
||||||
label: ancestor.title,
|
|
||||||
})),
|
|
||||||
].filter((item) => item !== undefined),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageSections =
|
const pageSections =
|
||||||
pageItem.sections
|
pageItem.sections
|
||||||
?.filter((section) => section.title || section.body)
|
?.filter((section) => section.title || section.body)
|
||||||
.map<ComputedSectionResult>((section) => ({
|
.map<ComputedSectionResult>((section) => {
|
||||||
type: 'section',
|
let sectionHref = linker.toPathInSpace(pageItem.path);
|
||||||
id: `${page.id}/${section.id}`,
|
|
||||||
title: section.title,
|
if (spaceURL) {
|
||||||
href: spaceURL
|
if (asEmbeddable) {
|
||||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
|
sectionHref = toEmbeddableLinkForPublishedContent(
|
||||||
: linker.toPathInSpace(pageItem.path),
|
linker,
|
||||||
body: section.body,
|
spaceURL,
|
||||||
pageId: pageItem.id,
|
section.path
|
||||||
spaceId: spaceItem.id,
|
);
|
||||||
score: section.score,
|
} else {
|
||||||
})) ?? [];
|
sectionHref = linker.toLinkForContent(
|
||||||
|
joinPathWithBaseURL(spaceURL, section.path)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
return [page, ...pageSections];
|
return [page, ...pageSections];
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ export async function GET(
|
|||||||
'What can I ask you?',
|
'What can I ask you?',
|
||||||
'Show me tips and tricks',
|
'Show me tips and tricks',
|
||||||
],
|
],
|
||||||
tabs: ['assistant', 'docs'],
|
tabs: ['assistant', 'search', 'docs'],
|
||||||
closeButton: useCustomTrigger
|
closeButton: useCustomTrigger
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { EmbeddableSearchPage } from '@/components/Embeddable';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
|
||||||
|
return <EmbeddableSearchPage context={context} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||||
import { traceErrorOnly } from '@/lib/tracing';
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
@@ -36,10 +35,9 @@ export async function* streamAIChatResponse({
|
|||||||
options?: RenderAIMessageOptions;
|
options?: RenderAIMessageOptions;
|
||||||
}) {
|
}) {
|
||||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||||
let context = await getServerActionBaseContext();
|
const context = await getServerActionBaseContext({
|
||||||
if (options?.asEmbeddable) {
|
isEmbeddable: options?.asEmbeddable,
|
||||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
} from '@/components/AIChat';
|
} from '@/components/AIChat';
|
||||||
import { useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
import * as api from '@gitbook/api';
|
import * as api from '@gitbook/api';
|
||||||
import React, { use, useMemo } from 'react';
|
import React from 'react';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { LinkContext, type LinkContextType } from '../primitives';
|
import { LinkContext } from '../primitives';
|
||||||
import {
|
import {
|
||||||
EmbeddableFrame,
|
EmbeddableFrame,
|
||||||
EmbeddableFrameBody,
|
EmbeddableFrameBody,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
EmbeddableIframeButtons,
|
EmbeddableIframeButtons,
|
||||||
EmbeddableIframeCloseButton,
|
EmbeddableIframeCloseButton,
|
||||||
EmbeddableIframeTabs,
|
EmbeddableIframeTabs,
|
||||||
useEmbeddableConfiguration,
|
useEmbeddableLinkContext,
|
||||||
} from './EmbeddableIframeAPI';
|
} from './EmbeddableIframeAPI';
|
||||||
|
|
||||||
type EmbeddableAIChatProps = {
|
type EmbeddableAIChatProps = {
|
||||||
@@ -43,7 +43,6 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
|||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
const { config: siteConfig } = useAI();
|
const { config: siteConfig } = useAI();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
const embedConfig = useEmbeddableConfiguration();
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -65,21 +64,8 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
|||||||
}, [trackEvent]);
|
}, [trackEvent]);
|
||||||
|
|
||||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||||
const hasDocsTab = embedConfig.tabs.includes('docs');
|
|
||||||
const trademark = siteConfig.trademark;
|
const trademark = siteConfig.trademark;
|
||||||
const currentLinkContext = use(LinkContext);
|
const { linkContext } = useEmbeddableLinkContext();
|
||||||
const linkContext: LinkContextType = useMemo(
|
|
||||||
() =>
|
|
||||||
hasDocsTab
|
|
||||||
? { ...currentLinkContext, externalTarget: '_blank' }
|
|
||||||
: {
|
|
||||||
...currentLinkContext,
|
|
||||||
isExternalClient: () => true,
|
|
||||||
isExternalServer: () => true,
|
|
||||||
externalTarget: '_blank',
|
|
||||||
},
|
|
||||||
[hasDocsTab, currentLinkContext]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbeddableFrame>
|
<EmbeddableFrame>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { useAI, useAIChatController } from '@/components/AI';
|
import { useAI, useAIChatController } from '@/components/AI';
|
||||||
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createStore, useStore } from 'zustand';
|
import { createStore, useStore } from 'zustand';
|
||||||
import { integrationsAssistantTools } from '../Integrations';
|
import { integrationsAssistantTools } from '../Integrations';
|
||||||
import { Button } from '../primitives';
|
import { Button, LinkContext, type LinkContextType } from '../primitives';
|
||||||
import { getChannel } from './channel';
|
import { getChannel } from './channel';
|
||||||
|
|
||||||
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
|
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
|
||||||
@@ -105,6 +106,31 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
|
|||||||
return useStore(embeddableConfiguration, fn);
|
return useStore(embeddableConfiguration, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEmbeddableTabs() {
|
||||||
|
const configuredTabs = useEmbeddableConfiguration((state) => state.tabs);
|
||||||
|
return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmbeddableLinkContext() {
|
||||||
|
const tabs = useEmbeddableTabs();
|
||||||
|
const hasDocsTab = tabs.includes('docs');
|
||||||
|
const currentLinkContext = React.useContext(LinkContext);
|
||||||
|
const linkContext: LinkContextType = React.useMemo(
|
||||||
|
() =>
|
||||||
|
hasDocsTab
|
||||||
|
? { ...currentLinkContext, externalTarget: '_blank' }
|
||||||
|
: {
|
||||||
|
...currentLinkContext,
|
||||||
|
isExternalClient: () => true,
|
||||||
|
isExternalServer: () => true,
|
||||||
|
externalTarget: '_blank',
|
||||||
|
},
|
||||||
|
[currentLinkContext, hasDocsTab]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { hasDocsTab, linkContext };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the buttons defined by the parent window.
|
* Display the buttons defined by the parent window.
|
||||||
*/
|
*/
|
||||||
@@ -151,55 +177,59 @@ export function EmbeddableIframeTabs(props: {
|
|||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
}) {
|
}) {
|
||||||
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
||||||
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
|
const actions = useEmbeddableConfiguration((state) => state.actions);
|
||||||
|
const tabs = useEmbeddableTabs();
|
||||||
|
|
||||||
const { assistants, config } = useAI();
|
const { assistants, config } = useAI();
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tabs = [
|
const enabledTabs = [
|
||||||
config.aiMode === CustomizationAIMode.Assistant &&
|
config.aiMode === CustomizationAIMode.Assistant &&
|
||||||
assistants[0] &&
|
assistants[0] &&
|
||||||
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
|
tabs.includes('assistant')
|
||||||
? {
|
? {
|
||||||
key: 'assistant',
|
key: 'assistant',
|
||||||
label: assistants[0].label,
|
label: assistants[0].label,
|
||||||
icon: assistants[0].icon,
|
icon: assistants[0].icon,
|
||||||
onClick: () => {
|
href: `${baseURL}/assistant`,
|
||||||
router.push(`${baseURL}/assistant`);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
configuredTabs.includes('docs') || configuredTabs.length === 0
|
tabs.includes('search')
|
||||||
|
? {
|
||||||
|
key: 'search',
|
||||||
|
label: tString(language, 'search'),
|
||||||
|
icon: 'search',
|
||||||
|
href: `${baseURL}/search`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tabs.includes('docs')
|
||||||
? {
|
? {
|
||||||
key: 'docs',
|
key: 'docs',
|
||||||
label: siteTitle,
|
label: siteTitle,
|
||||||
icon: 'book-open',
|
icon: 'book-open',
|
||||||
onClick: () => {
|
href: `${baseURL}/page/`,
|
||||||
router.push(`${baseURL}/page/`);
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter((tab) => tab !== null);
|
].filter((tab) => tab !== null);
|
||||||
|
|
||||||
// Override the active tab if it doesn't match the configured tabs
|
// Override the active tab if it doesn't match the configured tabs.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hasAssistant = tabs.find((tab) => tab.key === 'assistant');
|
if (enabledTabs.length === 0) {
|
||||||
const hasDocs = tabs.find((tab) => tab.key === 'docs');
|
|
||||||
if (!hasAssistant && !hasDocs) {
|
|
||||||
// No valid tabs, do not redirect
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (active === 'assistant' && !hasAssistant) {
|
|
||||||
router.replace(`${baseURL}/page`);
|
|
||||||
} else if (active === 'docs' && !hasDocs) {
|
|
||||||
router.replace(`${baseURL}/assistant`);
|
|
||||||
}
|
|
||||||
}, [tabs, baseURL, router, active]);
|
|
||||||
|
|
||||||
return tabs.length > 1 || actions.length > 0 ? (
|
const activeTab = enabledTabs.find((tab) => tab.key === active);
|
||||||
|
const fallbackTab = enabledTabs.at(0);
|
||||||
|
if (!activeTab && fallbackTab) {
|
||||||
|
router.replace(fallbackTab.href);
|
||||||
|
}
|
||||||
|
}, [enabledTabs, router, active]);
|
||||||
|
|
||||||
|
return enabledTabs.length > 1 || actions.length > 0 ? (
|
||||||
<div className="flex flex-col items-center gap-2" ref={ref}>
|
<div className="flex flex-col items-center gap-2" ref={ref}>
|
||||||
{tabs.map((tab) => (
|
{enabledTabs.map((tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
data-testid={`embed-tab-${tab.key}`}
|
data-testid={`embed-tab-${tab.key}`}
|
||||||
@@ -210,7 +240,9 @@ export function EmbeddableIframeTabs(props: {
|
|||||||
active={tab.key === active}
|
active={tab.key === active}
|
||||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||||
iconOnly
|
iconOnly
|
||||||
onClick={tab.onClick}
|
onClick={() => {
|
||||||
|
router.push(tab.href);
|
||||||
|
}}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
contentProps: {
|
contentProps: {
|
||||||
side: 'right',
|
side: 'right',
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type SearchBaseProps,
|
||||||
|
SearchFrame,
|
||||||
|
SearchInput,
|
||||||
|
SearchLiveResultsAnnouncer,
|
||||||
|
SearchScopeControl,
|
||||||
|
useSearchController,
|
||||||
|
} from '@/components/Search';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { LinkContext } from '../primitives';
|
||||||
|
import {
|
||||||
|
EmbeddableIframeButtons,
|
||||||
|
EmbeddableIframeCloseButton,
|
||||||
|
EmbeddableIframeTabs,
|
||||||
|
useEmbeddableLinkContext,
|
||||||
|
} from './EmbeddableIframeAPI';
|
||||||
|
|
||||||
|
type EmbeddableSearchProps = {
|
||||||
|
baseURL: string;
|
||||||
|
siteTitle: string;
|
||||||
|
searchProps: SearchBaseProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||||
|
const { baseURL, searchProps, siteTitle } = props;
|
||||||
|
const { hasDocsTab, linkContext } = useEmbeddableLinkContext();
|
||||||
|
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
React.useEffect(() => {
|
||||||
|
trackEvent({
|
||||||
|
type: 'search_open',
|
||||||
|
});
|
||||||
|
}, [trackEvent]);
|
||||||
|
|
||||||
|
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const {
|
||||||
|
askQuery,
|
||||||
|
cursor,
|
||||||
|
error,
|
||||||
|
fetching,
|
||||||
|
onInputKeyDown,
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
resultsId,
|
||||||
|
resultsRef,
|
||||||
|
searchValue,
|
||||||
|
setQuery,
|
||||||
|
showAsk,
|
||||||
|
withSearchAI,
|
||||||
|
scopeControl,
|
||||||
|
} = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkContext value={linkContext}>
|
||||||
|
<SearchFrame
|
||||||
|
asEmbeddable={hasDocsTab}
|
||||||
|
askQuery={askQuery}
|
||||||
|
cursor={cursor}
|
||||||
|
error={error}
|
||||||
|
fetching={fetching}
|
||||||
|
query={query}
|
||||||
|
results={results}
|
||||||
|
resultsId={resultsId}
|
||||||
|
resultsRef={resultsRef}
|
||||||
|
showAsk={showAsk}
|
||||||
|
dataTestId="embed-search"
|
||||||
|
input={
|
||||||
|
<SearchInput
|
||||||
|
aria-activedescendant={
|
||||||
|
cursor !== null ? `${resultsId}-${cursor}` : undefined
|
||||||
|
}
|
||||||
|
aria-controls={resultsId}
|
||||||
|
onChange={setQuery}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
|
value={searchValue}
|
||||||
|
withAI={withSearchAI}
|
||||||
|
isOpen={true}
|
||||||
|
mode="frame"
|
||||||
|
>
|
||||||
|
<SearchLiveResultsAnnouncer
|
||||||
|
count={results.length}
|
||||||
|
showing={Boolean(searchValue) && !fetching}
|
||||||
|
/>
|
||||||
|
</SearchInput>
|
||||||
|
}
|
||||||
|
sidebar={
|
||||||
|
<>
|
||||||
|
<EmbeddableIframeTabs
|
||||||
|
ref={tabsRef}
|
||||||
|
active="search"
|
||||||
|
baseURL={baseURL}
|
||||||
|
siteTitle={siteTitle}
|
||||||
|
/>
|
||||||
|
<EmbeddableIframeButtons />
|
||||||
|
<EmbeddableIframeCloseButton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
scopeControl={
|
||||||
|
searchProps.withVariants || searchProps.withSections ? (
|
||||||
|
<SearchScopeControl {...scopeControl} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</LinkContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getSearchBaseProps } from '@/components/Search/search-props';
|
||||||
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import { EmbeddableSearch } from './EmbeddableSearch';
|
||||||
|
|
||||||
|
type EmbeddableSearchPageProps = {
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable page component for the embed search page.
|
||||||
|
*/
|
||||||
|
export function EmbeddableSearchPage(props: EmbeddableSearchPageProps) {
|
||||||
|
const { context } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableSearch
|
||||||
|
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||||
|
siteTitle={context.site.title}
|
||||||
|
searchProps={getSearchBaseProps(context)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from './EmbeddableFrame';
|
|||||||
export * from './EmbeddableRootLayout';
|
export * from './EmbeddableRootLayout';
|
||||||
export * from './EmbeddableAssistantPage';
|
export * from './EmbeddableAssistantPage';
|
||||||
export * from './EmbeddableDocsPage';
|
export * from './EmbeddableDocsPage';
|
||||||
|
export * from './EmbeddableSearchPage';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSpaceLanguage, t } from '@/intl/server';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import type { SiteSpace } from '@gitbook/api';
|
import type { SiteSpace } from '@gitbook/api';
|
||||||
import { SocialAccountButton } from '../Footer/SocialAccounts';
|
import { SocialAccountButton } from '../Footer/SocialAccounts';
|
||||||
import { SearchContainer } from '../Search';
|
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { HeaderLink } from './HeaderLink';
|
import { HeaderLink } from './HeaderLink';
|
||||||
import { HeaderLinkMore } from './HeaderLinkMore';
|
import { HeaderLinkMore } from './HeaderLinkMore';
|
||||||
@@ -26,7 +26,8 @@ export function Header(props: {
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { context, withTopHeader, variants } = props;
|
const { context, withTopHeader, variants } = props;
|
||||||
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
|
const { siteSpace, visibleSections, customization } = context;
|
||||||
|
const searchProps = getSearchBaseProps(context);
|
||||||
|
|
||||||
const withSections = Boolean(
|
const withSections = Boolean(
|
||||||
visibleSections &&
|
visibleSections &&
|
||||||
@@ -143,27 +144,9 @@ export function Header(props: {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SearchContainer
|
<SearchContainer
|
||||||
|
{...searchProps}
|
||||||
style={customization.styling.search}
|
style={customization.styling.search}
|
||||||
withVariants={variants.generic.length > 1}
|
|
||||||
withSiteVariants={
|
|
||||||
visibleSections?.list.some(
|
|
||||||
(s) =>
|
|
||||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
|
||||||
) ?? false
|
|
||||||
}
|
|
||||||
withSections={
|
|
||||||
visibleSections ? visibleSections.list.length > 1 : false
|
|
||||||
}
|
|
||||||
section={
|
|
||||||
visibleSections
|
|
||||||
? // Client-encode to avoid a serialization issue that was causing the language selector to disappear
|
|
||||||
encodeClientSiteSections(context, visibleSections).current
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
siteSpace={siteSpace}
|
|
||||||
siteSpaces={visibleSiteSpaces}
|
|
||||||
viewport={!withTopHeader ? 'mobile' : undefined}
|
viewport={!withTopHeader ? 'mobile' : undefined}
|
||||||
searchURL={context.linker.toPathInSpace('~gitbook/search')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export type SearchAskState =
|
|||||||
/**
|
/**
|
||||||
* Fetch and render the answers to a question.
|
* Fetch and render the answers to a question.
|
||||||
*/
|
*/
|
||||||
export function SearchAskAnswer(props: { query: string }) {
|
export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }) {
|
||||||
const { query } = props;
|
const { query, asEmbeddable } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
@@ -49,7 +49,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
query,
|
query,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { stream } = await streamAskQuestion({ question: query });
|
const { stream } = await streamAskQuestion({ question: query, asEmbeddable });
|
||||||
for await (const chunk of readStreamableValue(stream)) {
|
for await (const chunk of readStreamableValue(stream)) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -74,7 +74,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [query, setAskState, trackEvent]);
|
}, [asEmbeddable, query, setAskState, trackEvent]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,123 +1,63 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||||
import { getLocalizedTitle } from '@/lib/sites';
|
|
||||||
import { CustomizationSearchStyle, type SiteSection, type SiteSpace } from '@gitbook/api';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useAI } from '../AI';
|
|
||||||
import { AIChatButton } from '../AIChat';
|
import { AIChatButton } from '../AIChat';
|
||||||
import { useTrackEvent } from '../Insights';
|
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { Popover, useBodyLoaded } from '../primitives';
|
import { Popover } from '../primitives';
|
||||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
import { SearchFrame } from './SearchFrame';
|
||||||
import { useSearchAskState } from './SearchAskContext';
|
|
||||||
import { SearchAskProvider } from './SearchAskContext';
|
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||||
import { SearchScopeControl } from './SearchScopeControl';
|
import { SearchScopeControl } from './SearchScopeControl';
|
||||||
import { useSearchState, useSetSearchState } from './useSearch';
|
import type { SearchBaseProps } from './search-props';
|
||||||
import { useSearchResults } from './useSearchResults';
|
import { useSearchController } from './useSearchController';
|
||||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
|
||||||
|
|
||||||
interface SearchContainerProps {
|
|
||||||
/** The current site space. */
|
|
||||||
siteSpace: SiteSpace;
|
|
||||||
|
|
||||||
/** All site spaces in the current section. */
|
|
||||||
siteSpaces: ReadonlyArray<SiteSpace>;
|
|
||||||
|
|
||||||
/** Whether there are sections on the site. */
|
|
||||||
withSections: boolean;
|
|
||||||
|
|
||||||
/** The current section, displayed in search scope toggle. */
|
|
||||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
|
||||||
|
|
||||||
/** Whether the current section has variants. */
|
|
||||||
withVariants: boolean;
|
|
||||||
|
|
||||||
/** Whether any section on the site has variants. */
|
|
||||||
withSiteVariants: boolean;
|
|
||||||
|
|
||||||
|
interface SearchContainerProps extends SearchBaseProps {
|
||||||
style: CustomizationSearchStyle;
|
style: CustomizationSearchStyle;
|
||||||
className?: string;
|
className?: string;
|
||||||
viewport?: 'desktop' | 'mobile';
|
viewport?: 'desktop' | 'mobile';
|
||||||
|
|
||||||
/** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */
|
|
||||||
searchURL: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client component to render the search input and results.
|
* Client component to render the search input and results.
|
||||||
*/
|
*/
|
||||||
export function SearchContainer({
|
export function SearchContainer({
|
||||||
siteSpace,
|
|
||||||
section,
|
|
||||||
withVariants,
|
|
||||||
withSiteVariants,
|
|
||||||
withSections,
|
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
viewport,
|
viewport,
|
||||||
siteSpaces,
|
...searchProps
|
||||||
searchURL,
|
|
||||||
}: SearchContainerProps) {
|
}: SearchContainerProps) {
|
||||||
const { assistants, config } = useAI();
|
|
||||||
|
|
||||||
const state = useSearchState();
|
|
||||||
const setSearchState = useSetSearchState();
|
|
||||||
const searchAsk = useSearchAskState();
|
|
||||||
const router = useRouter();
|
|
||||||
const trackEvent = useTrackEvent();
|
|
||||||
const resultsRef = useRef<SearchResultsRef>(null);
|
|
||||||
const searchInputRef = useRef<HTMLDivElement>(null);
|
const searchInputRef = useRef<HTMLDivElement>(null);
|
||||||
const isLoaded = useBodyLoaded();
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const {
|
||||||
const withAI = assistants.length > 0;
|
assistants,
|
||||||
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
|
askQuery,
|
||||||
|
close,
|
||||||
// Handle initial ask state on page load, once assistants are ready
|
cursor,
|
||||||
const initialRef = React.useRef(state?.ask === undefined || state?.ask === null); // If ask is not set on page load, we will never trigger
|
error,
|
||||||
React.useEffect(() => {
|
fetching,
|
||||||
if (initialRef.current) return;
|
onInputKeyDown,
|
||||||
if (assistants.length === 0) return;
|
open,
|
||||||
if (state?.ask === undefined || state?.ask === null) return;
|
query,
|
||||||
|
results,
|
||||||
// For simplicity we're only triggering the first assistant
|
resultsId,
|
||||||
// Because this is in the layout, we need to await for the body to be loaded.
|
resultsRef,
|
||||||
if (isLoaded) {
|
searchValue,
|
||||||
assistants[0]?.open(state.ask ?? undefined);
|
setQuery,
|
||||||
initialRef.current = true;
|
showAsk,
|
||||||
}
|
state,
|
||||||
}, [state?.ask, assistants.length, assistants[0]?.open, isLoaded]);
|
withAI,
|
||||||
|
withSearchAI,
|
||||||
const onClose = React.useCallback(
|
scopeControl,
|
||||||
async (to?: string) => {
|
} = useSearchController(searchProps);
|
||||||
setSearchState((prev) =>
|
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
open: false,
|
|
||||||
query: prev.query === '' ? null : prev.query,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
router.push(to);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setSearchState, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+k',
|
'mod+k',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpen();
|
open();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableOnFormTags: true,
|
enableOnFormTags: true,
|
||||||
@@ -128,35 +68,17 @@ export function SearchContainer({
|
|||||||
'mod+i',
|
'mod+i',
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (assistants) {
|
assistants[0]?.open();
|
||||||
assistants[0]?.open();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableOnFormTags: true,
|
enableOnFormTags: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const onOpen = React.useCallback(() => {
|
|
||||||
if (state?.open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSearchState((prev) => ({
|
|
||||||
ask: withAI ? (prev?.ask ?? null) : null,
|
|
||||||
scope: prev?.scope ?? 'default',
|
|
||||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
|
||||||
open: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
trackEvent({
|
|
||||||
type: 'search_open',
|
|
||||||
});
|
|
||||||
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onClose();
|
close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -164,110 +86,33 @@ export function SearchContainer({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [close]);
|
||||||
|
|
||||||
const onChange = (value: string) => {
|
|
||||||
setSearchState((prev) => ({
|
|
||||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
|
||||||
query: value,
|
|
||||||
scope: prev?.scope ?? 'default',
|
|
||||||
open: true,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// We trim the query to avoid invalidating the search when the user is typing between words.
|
|
||||||
const normalizedQuery = state?.query?.trim() ?? '';
|
|
||||||
const normalizedAsk = state?.ask?.trim() ?? '';
|
|
||||||
|
|
||||||
const showAsk = withSearchAI && normalizedAsk;
|
|
||||||
|
|
||||||
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
||||||
|
const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined;
|
||||||
const searchResultsId = `search-results-${React.useId()}`;
|
|
||||||
|
|
||||||
// If searching all variants of the current section (the "extended" scope),
|
|
||||||
// filter by language if the language is set for both the current and the target site space.
|
|
||||||
const siteSpaceIds = React.useMemo(
|
|
||||||
() =>
|
|
||||||
siteSpaces.reduce((acc: string[], ss) => {
|
|
||||||
if (
|
|
||||||
!siteSpace.space.language ||
|
|
||||||
!ss.space.language ||
|
|
||||||
ss.space.language === siteSpace.space.language
|
|
||||||
) {
|
|
||||||
acc.push(ss.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, []),
|
|
||||||
[siteSpaces, siteSpace.space.language]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { results, fetching, error } = useSearchResults({
|
|
||||||
disabled: !(state?.query || withAI),
|
|
||||||
query: normalizedQuery,
|
|
||||||
siteSpaceId: siteSpace.id,
|
|
||||||
siteSpaceIds,
|
|
||||||
scope: state?.scope ?? 'default',
|
|
||||||
withAI,
|
|
||||||
suggestions: config.suggestions,
|
|
||||||
searchURL,
|
|
||||||
});
|
|
||||||
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
|
||||||
|
|
||||||
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
|
||||||
query: normalizedQuery,
|
|
||||||
results,
|
|
||||||
});
|
|
||||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
event.preventDefault();
|
|
||||||
moveCursorBy(-1);
|
|
||||||
} else if (event.key === 'ArrowDown') {
|
|
||||||
event.preventDefault();
|
|
||||||
moveCursorBy(1);
|
|
||||||
} else if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
resultsRef.current?.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchAskProvider value={searchAsk}>
|
<>
|
||||||
<Popover
|
<Popover
|
||||||
content={
|
content={
|
||||||
// Only show content if there's a query or Ask is enabled
|
// Only show content if there's a query or Ask is enabled
|
||||||
state?.query || withAI ? (
|
state?.query || withAI ? (
|
||||||
<React.Suspense fallback={null}>
|
<SearchFrame
|
||||||
<div className="scroll-py-2 overflow-y-scroll p-2">
|
askQuery={askQuery}
|
||||||
{state !== null && !showAsk ? (
|
cursor={cursor}
|
||||||
<SearchResults
|
error={error}
|
||||||
ref={resultsRef}
|
fetching={fetching}
|
||||||
query={normalizedQuery}
|
query={query}
|
||||||
id={searchResultsId}
|
results={results}
|
||||||
fetching={fetching}
|
resultsId={resultsId}
|
||||||
results={results}
|
resultsRef={resultsRef}
|
||||||
cursor={cursor}
|
showAsk={showAsk}
|
||||||
error={error}
|
scopeControl={
|
||||||
/>
|
searchProps.withVariants || searchProps.withSections ? (
|
||||||
) : null}
|
<SearchScopeControl {...scopeControl} />
|
||||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
) : null
|
||||||
</div>
|
}
|
||||||
{(withVariants || withSections) && !showAsk ? (
|
/>
|
||||||
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
|
|
||||||
<SearchScopeControl
|
|
||||||
section={section}
|
|
||||||
spaceTitle={getLocalizedTitle(
|
|
||||||
siteSpace,
|
|
||||||
siteSpace.space.language
|
|
||||||
)}
|
|
||||||
withVariants={withVariants}
|
|
||||||
withSiteVariants={withSiteVariants}
|
|
||||||
withSections={withSections}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
rootProps={{
|
rootProps={{
|
||||||
@@ -278,14 +123,14 @@ export function SearchContainer({
|
|||||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||||
align: 'start',
|
align: 'start',
|
||||||
className:
|
className:
|
||||||
'@container flex flex-col bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||||
onInteractOutside: (event) => {
|
onInteractOutside: (event) => {
|
||||||
// Don't close if clicking on the search input itself
|
// Don't close if clicking on the search input itself
|
||||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClose();
|
close();
|
||||||
},
|
},
|
||||||
sideOffset: 8,
|
sideOffset: 8,
|
||||||
collisionPadding: {
|
collisionPadding: {
|
||||||
@@ -302,54 +147,32 @@ export function SearchContainer({
|
|||||||
>
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
|
aria-activedescendant={searchResultsActiveDescendant}
|
||||||
|
aria-controls={resultsId}
|
||||||
|
onChange={setQuery}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onFocus={onOpen}
|
|
||||||
onChange={onChange}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
withAI={withSearchAI}
|
withAI={withSearchAI}
|
||||||
isOpen={state?.open ?? false}
|
isOpen={state?.open ?? false}
|
||||||
className={className}
|
className={className}
|
||||||
aria-controls={searchResultsId}
|
onFocus={open}
|
||||||
aria-activedescendant={
|
|
||||||
cursor !== null ? `${searchResultsId}-${cursor}` : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LiveResultsAnnouncer
|
<SearchLiveResultsAnnouncer
|
||||||
count={results.length}
|
count={results.length}
|
||||||
showing={Boolean(searchValue) && !fetching}
|
showing={Boolean(searchValue) && !fetching}
|
||||||
/>
|
/>
|
||||||
</SearchInput>
|
</SearchInput>
|
||||||
</Popover>
|
</Popover>
|
||||||
{assistants
|
{uiAssistants.map((assistant, index) => (
|
||||||
.filter((assistant) => assistant.ui === true)
|
<AIChatButton
|
||||||
.map((assistant, index) => (
|
key={assistant.id}
|
||||||
<AIChatButton
|
assistant={assistant}
|
||||||
key={assistant.id}
|
withShortcut={index === 0}
|
||||||
assistant={assistant}
|
showLabel={
|
||||||
withShortcut={index === 0}
|
uiAssistants.length === 1 && style === CustomizationSearchStyle.Prominent
|
||||||
showLabel={
|
}
|
||||||
assistants.filter((assistant) => assistant.ui === true).length === 1 &&
|
/>
|
||||||
style === CustomizationSearchStyle.Prominent
|
))}
|
||||||
}
|
</>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SearchAskProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Screen reader announcement for search results.
|
|
||||||
* Without it there is no feedback for screen reader users when a search returns no results.
|
|
||||||
*/
|
|
||||||
function LiveResultsAnnouncer({ count, showing }: { count: number; showing: boolean }) {
|
|
||||||
const language = useLanguage();
|
|
||||||
return (
|
|
||||||
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
|
|
||||||
{showing
|
|
||||||
? count > 0
|
|
||||||
? t(language, 'search_results_count', count)
|
|
||||||
: t(language, 'search_no_results')
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
EmbeddableFrame,
|
||||||
|
EmbeddableFrameHeader,
|
||||||
|
EmbeddableFrameMain,
|
||||||
|
EmbeddableFrameSidebar,
|
||||||
|
} from '../Embeddable/EmbeddableFrame';
|
||||||
|
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||||
|
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||||
|
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||||
|
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||||
|
import type { ResultType } from './useSearchResults';
|
||||||
|
|
||||||
|
export function SearchFrame(props: {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
|
askQuery: string;
|
||||||
|
cursor: number | null;
|
||||||
|
error: boolean;
|
||||||
|
fetching: boolean;
|
||||||
|
input?: React.ReactNode;
|
||||||
|
query: string;
|
||||||
|
results: ResultType[];
|
||||||
|
resultsId: string;
|
||||||
|
resultsRef: React.Ref<SearchResultsRef>;
|
||||||
|
scopeControl?: React.ReactNode;
|
||||||
|
showAsk: boolean;
|
||||||
|
sidebar?: React.ReactNode;
|
||||||
|
className?: ClassValue;
|
||||||
|
dataTestId?: string;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
askQuery,
|
||||||
|
asEmbeddable,
|
||||||
|
className,
|
||||||
|
cursor,
|
||||||
|
dataTestId,
|
||||||
|
error,
|
||||||
|
fetching,
|
||||||
|
input,
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
resultsId,
|
||||||
|
resultsRef,
|
||||||
|
scopeControl,
|
||||||
|
showAsk,
|
||||||
|
sidebar,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const searchAsk = useSearchAskState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchAskProvider value={searchAsk}>
|
||||||
|
<EmbeddableFrame
|
||||||
|
className={tcls('bg-tint-base from-transparent to-transparent', className)}
|
||||||
|
>
|
||||||
|
{sidebar ? <EmbeddableFrameSidebar>{sidebar}</EmbeddableFrameSidebar> : null}
|
||||||
|
<EmbeddableFrameMain data-testid={dataTestId}>
|
||||||
|
{input ? (
|
||||||
|
<EmbeddableFrameHeader className="p-3 pb-0">{input}</EmbeddableFrameHeader>
|
||||||
|
) : null}
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<ScrollContainer orientation="vertical" contentClassName="p-3">
|
||||||
|
{showAsk ? (
|
||||||
|
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
|
||||||
|
) : (
|
||||||
|
<SearchResults
|
||||||
|
ref={resultsRef}
|
||||||
|
query={query}
|
||||||
|
id={resultsId}
|
||||||
|
fetching={fetching}
|
||||||
|
results={results}
|
||||||
|
cursor={cursor}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollContainer>
|
||||||
|
{scopeControl && !showAsk ? (
|
||||||
|
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
|
||||||
|
{scopeControl}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</React.Suspense>
|
||||||
|
</EmbeddableFrameMain>
|
||||||
|
</EmbeddableFrame>
|
||||||
|
</SearchAskProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,14 +6,17 @@ import { Icon } from '@gitbook/icons';
|
|||||||
import { Input } from '../primitives';
|
import { Input } from '../primitives';
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
|
'aria-activedescendant'?: string;
|
||||||
|
'aria-controls'?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
onFocus: () => void;
|
onFocus?: () => void;
|
||||||
value: string;
|
value: string;
|
||||||
withAI: boolean;
|
withAI: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
mode?: 'header' | 'frame';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +33,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
isOpen,
|
isOpen,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
mode = 'header',
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isFrame = mode === 'frame';
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
@@ -50,7 +55,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
}, [isOpen, value]);
|
}, [isOpen, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex @max-2xl:size-9.5 grow">
|
<div
|
||||||
|
className={
|
||||||
|
isFrame ? 'relative flex w-full grow' : 'relative flex @max-2xl:size-9.5 grow'
|
||||||
|
}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
data-testid="search-input"
|
data-testid="search-input"
|
||||||
name="search-input"
|
name="search-input"
|
||||||
@@ -58,14 +67,22 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
|
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
|
||||||
sizing="medium"
|
sizing="medium"
|
||||||
label={tString(language, withAI ? 'search_or_ask' : 'search')}
|
label={tString(language, withAI ? 'search_or_ask' : 'search')}
|
||||||
className="@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-link/1 @max-2xl:px-2.5 site-header:theme-bold:text-header-link site-header:theme-bold:shadow-none! site-header:theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] site-header:theme-bold:focus-within:border-header-link/6 site-header:theme-bold:focus-within:ring-header-link/5 site-header:theme-bold:hover:border-header-link/5 site-header:theme-bold:hover:not-focus-within:bg-header-link/2 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 site-header:theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11"
|
className={
|
||||||
|
isFrame
|
||||||
|
? 'grow bg-tint-base [&_input]:text-sm'
|
||||||
|
: '@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-link/1 @max-2xl:px-2.5 site-header:theme-bold:text-header-link site-header:theme-bold:shadow-none! site-header:theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] site-header:theme-bold:focus-within:border-header-link/6 site-header:theme-bold:focus-within:ring-header-link/5 site-header:theme-bold:hover:border-header-link/5 site-header:theme-bold:hover:not-focus-within:bg-header-link/2 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 site-header:theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11'
|
||||||
|
}
|
||||||
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}…`}
|
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}…`}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
leading={
|
leading={
|
||||||
<Icon
|
<Icon
|
||||||
icon="search"
|
icon="search"
|
||||||
className="size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint"
|
className={
|
||||||
|
isFrame
|
||||||
|
? 'size-text-lg shrink-0 text-tint-subtle'
|
||||||
|
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
@@ -75,18 +92,27 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
|||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={value && isOpen ? 'true' : 'false'}
|
aria-expanded={value && isOpen ? 'true' : 'false'}
|
||||||
clearButton={{
|
clearButton={
|
||||||
className:
|
isFrame
|
||||||
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
|
? true
|
||||||
}}
|
: {
|
||||||
keyboardShortcut={{
|
className:
|
||||||
className:
|
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
|
||||||
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
|
}
|
||||||
keys: isOpen ? ['esc'] : ['mod', 'k'],
|
}
|
||||||
}}
|
keyboardShortcut={
|
||||||
|
isFrame
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
className:
|
||||||
|
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
|
||||||
|
keys: isOpen ? ['esc'] : ['mod', 'k'],
|
||||||
|
}
|
||||||
|
}
|
||||||
{...rest}
|
{...rest}
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { t, useLanguage } from '@/intl/client';
|
||||||
|
|
||||||
|
export function SearchLiveResultsAnnouncer(props: { count: number; showing: boolean }) {
|
||||||
|
const { count, showing } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
|
||||||
|
{showing
|
||||||
|
? count > 0
|
||||||
|
? t(language, 'search_results_count', count)
|
||||||
|
: t(language, 'search_no_results')
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
export * from './SearchInput';
|
export * from './SearchInput';
|
||||||
|
export * from './SearchFrame';
|
||||||
|
export * from './SearchLiveResultsAnnouncer';
|
||||||
export * from './SearchContainer';
|
export * from './SearchContainer';
|
||||||
|
export * from './SearchScopeControl';
|
||||||
|
export * from './search-props';
|
||||||
export * from './useSearch';
|
export * from './useSearch';
|
||||||
|
export * from './useSearchController';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
|
import type { SiteSection, SiteSpace } from '@gitbook/api';
|
||||||
|
import { encodeClientSiteSections } from '../SiteSections';
|
||||||
|
|
||||||
|
export interface SearchBaseProps {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
|
siteSpace: SiteSpace;
|
||||||
|
siteSpaces: ReadonlyArray<SiteSpace>;
|
||||||
|
withSections: boolean;
|
||||||
|
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||||
|
withVariants: boolean;
|
||||||
|
withSiteVariants: boolean;
|
||||||
|
searchURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps {
|
||||||
|
const { siteSpace, visibleSections, visibleSiteSpaces } = context;
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchURL: context.linker.toPathInSpace('~gitbook/search'),
|
||||||
|
section: visibleSections
|
||||||
|
? encodeClientSiteSections(context, visibleSections).current
|
||||||
|
: undefined,
|
||||||
|
siteSpace,
|
||||||
|
siteSpaces: visibleSiteSpaces,
|
||||||
|
withSections: Boolean(visibleSections && visibleSections.list.length > 1),
|
||||||
|
withSiteVariants:
|
||||||
|
visibleSections?.list.some(
|
||||||
|
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
|
||||||
|
) ?? false,
|
||||||
|
withVariants: visibleSiteSpaces.length > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export type SearchSiteContentScope =
|
|||||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||||
|
|
||||||
export interface SearchSiteContentRequest {
|
export interface SearchSiteContentRequest {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
scope: SearchSiteContentScope;
|
scope: SearchSiteContentScope;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createStreamableValue } from 'ai/rsc';
|
|||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { throwIfDataError } from '@/lib/data';
|
import { throwIfDataError } from '@/lib/data';
|
||||||
|
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { traceErrorOnly } from '@/lib/tracing';
|
import { traceErrorOnly } from '@/lib/tracing';
|
||||||
@@ -37,15 +38,19 @@ export interface AskAnswerResult {
|
|||||||
* Server action to ask a question in a space.
|
* Server action to ask a question in a space.
|
||||||
*/
|
*/
|
||||||
export async function streamAskQuestion({
|
export async function streamAskQuestion({
|
||||||
|
asEmbeddable,
|
||||||
question,
|
question,
|
||||||
}: {
|
}: {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
question: string;
|
question: string;
|
||||||
}) {
|
}) {
|
||||||
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
const context = await fetchServerActionSiteContext(
|
||||||
|
await getServerActionBaseContext({ isEmbeddable: asEmbeddable })
|
||||||
|
);
|
||||||
|
|
||||||
const apiClient = await context.dataFetcher.api();
|
const apiClient = await context.dataFetcher.api();
|
||||||
|
|
||||||
@@ -107,7 +112,11 @@ export async function streamAskQuestion({
|
|||||||
}, new Map<string, RevisionPage[]>());
|
}, new Map<string, RevisionPage[]>());
|
||||||
});
|
});
|
||||||
responseStream.update(
|
responseStream.update(
|
||||||
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
|
await transformAnswer(context, {
|
||||||
|
answer: chunk.answer,
|
||||||
|
asEmbeddable: Boolean(asEmbeddable),
|
||||||
|
spacePages: pages,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -166,9 +175,11 @@ async function transformAnswer(
|
|||||||
context: GitBookSiteContext,
|
context: GitBookSiteContext,
|
||||||
{
|
{
|
||||||
answer,
|
answer,
|
||||||
|
asEmbeddable,
|
||||||
spacePages,
|
spacePages,
|
||||||
}: {
|
}: {
|
||||||
answer: SearchAIAnswer;
|
answer: SearchAIAnswer;
|
||||||
|
asEmbeddable: boolean;
|
||||||
spacePages: Map<string, RevisionPage[]>;
|
spacePages: Map<string, RevisionPage[]>;
|
||||||
}
|
}
|
||||||
): Promise<AskAnswerResult> {
|
): Promise<AskAnswerResult> {
|
||||||
@@ -197,12 +208,24 @@ async function transformAnswer(
|
|||||||
);
|
);
|
||||||
const spaceURL = found?.siteSpace.urls.published;
|
const spaceURL = found?.siteSpace.urls.published;
|
||||||
|
|
||||||
const href = spaceURL
|
let href = context.linker.toPathForPage({
|
||||||
? joinPathWithBaseURL(spaceURL, page.page.path)
|
pages,
|
||||||
: context.linker.toPathForPage({
|
page: page.page,
|
||||||
pages,
|
});
|
||||||
page: page.page,
|
|
||||||
});
|
if (spaceURL) {
|
||||||
|
if (asEmbeddable) {
|
||||||
|
href = toEmbeddableLinkForPublishedContent(
|
||||||
|
context.linker,
|
||||||
|
spaceURL,
|
||||||
|
page.page.path
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
href = context.linker.toLinkForContent(
|
||||||
|
joinPathWithBaseURL(spaceURL, page.page.path)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: source.page,
|
id: source.page,
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getLocalizedTitle } from '@/lib/sites';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import React from 'react';
|
||||||
|
import { useAI } from '../AI';
|
||||||
|
import { useTrackEvent } from '../Insights';
|
||||||
|
import { useBodyLoaded } from '../primitives';
|
||||||
|
import type { SearchResultsRef } from './SearchResults';
|
||||||
|
import type { SearchBaseProps } from './search-props';
|
||||||
|
import { useSearchState, useSetSearchState } from './useSearch';
|
||||||
|
import { useSearchResults } from './useSearchResults';
|
||||||
|
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||||
|
|
||||||
|
function useInitialAskBootstrap(props: {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
|
assistants: ReturnType<typeof useAI>['assistants'];
|
||||||
|
initialAsk: string | null;
|
||||||
|
isLoaded: boolean;
|
||||||
|
}) {
|
||||||
|
const { asEmbeddable, assistants, initialAsk, isLoaded } = props;
|
||||||
|
const handledInitialAskRef = React.useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (asEmbeddable) return;
|
||||||
|
if (assistants.length === 0) return;
|
||||||
|
if (initialAsk === null) return;
|
||||||
|
if (handledInitialAskRef.current === initialAsk) return;
|
||||||
|
|
||||||
|
// For simplicity we're only triggering the first assistant.
|
||||||
|
if (isLoaded) {
|
||||||
|
assistants[0]?.open(initialAsk || undefined);
|
||||||
|
handledInitialAskRef.current = initialAsk;
|
||||||
|
}
|
||||||
|
}, [asEmbeddable, assistants, initialAsk, isLoaded]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFilteredSiteSpaceIds(props: {
|
||||||
|
siteSpaces: SearchBaseProps['siteSpaces'];
|
||||||
|
language: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const { siteSpaces, language } = props;
|
||||||
|
|
||||||
|
return React.useMemo(
|
||||||
|
() =>
|
||||||
|
siteSpaces.reduce((acc: string[], siteSpace) => {
|
||||||
|
if (
|
||||||
|
!language ||
|
||||||
|
!siteSpace.space.language ||
|
||||||
|
siteSpace.space.language === language
|
||||||
|
) {
|
||||||
|
acc.push(siteSpace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
[siteSpaces, language]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSearchKeyboardNavigation(props: {
|
||||||
|
query: string;
|
||||||
|
results: ReturnType<typeof useSearchResults>['results'];
|
||||||
|
resultsRef: React.RefObject<SearchResultsRef | null>;
|
||||||
|
}) {
|
||||||
|
const { query, results, resultsRef } = props;
|
||||||
|
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInputKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveCursorBy(-1);
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveCursorBy(1);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
resultsRef.current?.select();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[moveCursorBy, resultsRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor,
|
||||||
|
onInputKeyDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchController(props: SearchBaseProps) {
|
||||||
|
const {
|
||||||
|
asEmbeddable,
|
||||||
|
siteSpace,
|
||||||
|
section,
|
||||||
|
withVariants,
|
||||||
|
withSiteVariants,
|
||||||
|
withSections,
|
||||||
|
siteSpaces,
|
||||||
|
searchURL,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { assistants, config } = useAI();
|
||||||
|
const state = useSearchState();
|
||||||
|
const setSearchState = useSetSearchState();
|
||||||
|
const router = useRouter();
|
||||||
|
const trackEvent = useTrackEvent();
|
||||||
|
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||||
|
const isLoaded = useBodyLoaded();
|
||||||
|
|
||||||
|
const withAI = assistants.length > 0;
|
||||||
|
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
|
||||||
|
|
||||||
|
// Handle initial ask state on page load, once assistants are ready.
|
||||||
|
// `ask=` should still bootstrap the assistant on the docs site, so we must
|
||||||
|
// distinguish between `null` (no ask param) and an empty string.
|
||||||
|
const initialAsk = state?.ask ?? null;
|
||||||
|
useInitialAskBootstrap({ asEmbeddable, assistants, initialAsk, isLoaded });
|
||||||
|
|
||||||
|
const onClose = React.useCallback(
|
||||||
|
async (to?: string) => {
|
||||||
|
setSearchState((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
open: false,
|
||||||
|
query: prev.query === '' ? null : prev.query,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
router.push(to);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSearchState, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onOpen = React.useCallback(() => {
|
||||||
|
if (state?.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: withAI ? (prev?.ask ?? null) : null,
|
||||||
|
scope: prev?.scope ?? 'default',
|
||||||
|
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
trackEvent({
|
||||||
|
type: 'search_open',
|
||||||
|
});
|
||||||
|
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
||||||
|
|
||||||
|
const setQuery = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSearchState((prev) => ({
|
||||||
|
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null,
|
||||||
|
query: value,
|
||||||
|
scope: prev?.scope ?? 'default',
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setSearchState, withAI, withSearchAI]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedQuery = state?.query?.trim() ?? '';
|
||||||
|
const normalizedAsk = state?.ask?.trim() ?? '';
|
||||||
|
const showAsk = withSearchAI && normalizedAsk.length > 0;
|
||||||
|
|
||||||
|
// If searching all variants of the current section (the "extended" scope),
|
||||||
|
// filter by language if the language is set for both the current and the target site space.
|
||||||
|
const siteSpaceIds = useFilteredSiteSpaceIds({
|
||||||
|
siteSpaces,
|
||||||
|
language: siteSpace.space.language,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { results, fetching, error } = useSearchResults({
|
||||||
|
asEmbeddable,
|
||||||
|
disabled: !(state?.query || withAI),
|
||||||
|
query: normalizedQuery,
|
||||||
|
siteSpaceId: siteSpace.id,
|
||||||
|
siteSpaceIds,
|
||||||
|
scope: state?.scope ?? 'default',
|
||||||
|
suggestions: config.suggestions,
|
||||||
|
searchURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
||||||
|
const searchResultsId = `search-results-${React.useId()}`;
|
||||||
|
|
||||||
|
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
|
||||||
|
query: normalizedQuery,
|
||||||
|
results,
|
||||||
|
resultsRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
assistants,
|
||||||
|
askQuery: normalizedAsk,
|
||||||
|
cursor,
|
||||||
|
error,
|
||||||
|
fetching,
|
||||||
|
open: onOpen,
|
||||||
|
close: onClose,
|
||||||
|
query: normalizedQuery,
|
||||||
|
results,
|
||||||
|
resultsId: searchResultsId,
|
||||||
|
resultsRef,
|
||||||
|
searchValue,
|
||||||
|
setQuery,
|
||||||
|
state,
|
||||||
|
onInputKeyDown,
|
||||||
|
showAsk,
|
||||||
|
withAI,
|
||||||
|
withSearchAI,
|
||||||
|
scopeControl: {
|
||||||
|
section,
|
||||||
|
spaceTitle: getLocalizedTitle(siteSpace, siteSpace.space.language),
|
||||||
|
withVariants,
|
||||||
|
withSiteVariants,
|
||||||
|
withSections,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,17 +27,26 @@ export type ResultType =
|
|||||||
const cachedRecommendedQuestions: Map<string, ResultType[]> = new Map();
|
const cachedRecommendedQuestions: Map<string, ResultType[]> = new Map();
|
||||||
|
|
||||||
export function useSearchResults(props: {
|
export function useSearchResults(props: {
|
||||||
|
asEmbeddable?: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
siteSpaceId: string;
|
siteSpaceId: string;
|
||||||
siteSpaceIds: string[];
|
siteSpaceIds: string[];
|
||||||
scope: SearchScope;
|
scope: SearchScope;
|
||||||
withAI: boolean;
|
|
||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
||||||
searchURL: string;
|
searchURL: string;
|
||||||
}) {
|
}) {
|
||||||
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props;
|
const {
|
||||||
|
asEmbeddable,
|
||||||
|
disabled,
|
||||||
|
query,
|
||||||
|
siteSpaceId,
|
||||||
|
siteSpaceIds,
|
||||||
|
scope,
|
||||||
|
suggestions,
|
||||||
|
searchURL,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
@@ -148,7 +157,13 @@ export function useSearchResults(props: {
|
|||||||
const fetchSearch = (
|
const fetchSearch = (
|
||||||
scope: Parameters<typeof fetchSearchResults>[1]
|
scope: Parameters<typeof fetchSearchResults>[1]
|
||||||
): Promise<OrderedComputedResult[]> =>
|
): Promise<OrderedComputedResult[]> =>
|
||||||
fetchSearchResults(searchURL, scope, query, abortController.signal);
|
fetchSearchResults(
|
||||||
|
searchURL,
|
||||||
|
scope,
|
||||||
|
query,
|
||||||
|
abortController.signal,
|
||||||
|
asEmbeddable
|
||||||
|
);
|
||||||
|
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case 'all':
|
case 'all':
|
||||||
@@ -214,6 +229,7 @@ export function useSearchResults(props: {
|
|||||||
disabled,
|
disabled,
|
||||||
suggestions,
|
suggestions,
|
||||||
searchURL,
|
searchURL,
|
||||||
|
asEmbeddable,
|
||||||
getAssistants,
|
getAssistants,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -230,12 +246,14 @@ async function fetchSearchResults(
|
|||||||
| { mode: 'current'; siteSpaceId: string }
|
| { mode: 'current'; siteSpaceId: string }
|
||||||
| { mode: 'specific'; siteSpaceIds: string[] },
|
| { mode: 'specific'; siteSpaceIds: string[] },
|
||||||
query: string,
|
query: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
asEmbeddable?: boolean
|
||||||
): Promise<OrderedComputedResult[]> {
|
): Promise<OrderedComputedResult[]> {
|
||||||
const response = await fetch(searchURL, {
|
const response = await fetch(searchURL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
asEmbeddable,
|
||||||
query,
|
query,
|
||||||
scope,
|
scope,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
|||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||||
import { SearchContainer } from '../Search';
|
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||||
import { CurrentContentProvider } from '../hooks';
|
import { CurrentContentProvider } from '../hooks';
|
||||||
import { CONTAINER_STYLE } from '../layout';
|
import { CONTAINER_STYLE } from '../layout';
|
||||||
@@ -106,7 +106,8 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
|||||||
*/
|
*/
|
||||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||||
const { context, children } = props;
|
const { context, children } = props;
|
||||||
const { siteSpace, customization, visibleSections, visibleSiteSpaces } = context;
|
const { siteSpace, customization, visibleSections } = context;
|
||||||
|
const searchProps = getSearchBaseProps(context);
|
||||||
|
|
||||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||||
|
|
||||||
@@ -199,23 +200,9 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
|||||||
{!withTopHeader && (
|
{!withTopHeader && (
|
||||||
<div className="flex gap-2 max-lg:hidden">
|
<div className="flex gap-2 max-lg:hidden">
|
||||||
<SearchContainer
|
<SearchContainer
|
||||||
|
{...searchProps}
|
||||||
style={CustomizationSearchStyle.Subtle}
|
style={CustomizationSearchStyle.Subtle}
|
||||||
withVariants={variants.generic.length > 1}
|
|
||||||
withSiteVariants={
|
|
||||||
visibleSections?.list.some(
|
|
||||||
(s) =>
|
|
||||||
s.object === 'site-section' &&
|
|
||||||
s.siteSpaces.length > 1
|
|
||||||
) ?? false
|
|
||||||
}
|
|
||||||
withSections={withSections}
|
|
||||||
section={visibleSections?.current}
|
|
||||||
siteSpace={siteSpace}
|
|
||||||
siteSpaces={visibleSiteSpaces}
|
|
||||||
viewport="desktop"
|
viewport="desktop"
|
||||||
searchURL={context.linker.toPathInSpace(
|
|
||||||
'~gitbook/search'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { GitBookLinker } from '@/lib/links';
|
||||||
|
import { getPagePath } from '@/lib/pages';
|
||||||
|
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||||
|
|
||||||
|
function createLocalURL(href: string) {
|
||||||
|
return new URL(href, 'https://gitbook.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEmbeddablePath(pathname: string, contentPath: string) {
|
||||||
|
return joinPath(pathname, '~gitbook/embed/page', contentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEmbeddableLinkForPublishedContent(
|
||||||
|
linker: GitBookLinker,
|
||||||
|
publishedURL: string,
|
||||||
|
contentPath: string
|
||||||
|
): string {
|
||||||
|
const spaceRoot = linker.toLinkForContent(publishedURL);
|
||||||
|
if (!spaceRoot.startsWith('/')) {
|
||||||
|
return joinPathWithBaseURL(publishedURL, contentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = createLocalURL(spaceRoot);
|
||||||
|
return `${toEmbeddablePath(url.pathname, contentPath)}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a linker to generate links in the embeddable context.
|
||||||
|
*/
|
||||||
|
export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker {
|
||||||
|
return {
|
||||||
|
...linker,
|
||||||
|
toPathForPage({ pages, page, anchor }) {
|
||||||
|
const pagePath = getPagePath(pages, page);
|
||||||
|
const embedPagePath = joinPath('~gitbook/embed/page', pagePath);
|
||||||
|
|
||||||
|
return `${linker.toPathInSpace(embedPagePath)}${anchor ? `#${anchor}` : ''}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
withOtherSiteSpace(override: { spaceBasePath: string }): GitBookLinker {
|
||||||
|
return linker.withOtherSiteSpace({
|
||||||
|
// We make sure that links in the other site space will be shown in the embeddable view.
|
||||||
|
spaceBasePath: joinPath(override.spaceBasePath, '~gitbook/embed/page'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toLinkForContent(rawURL: string): string {
|
||||||
|
const result = linker.toLinkForContent(rawURL);
|
||||||
|
// If the link is not relative or already an embed, return it as is
|
||||||
|
if (result.includes('~gitbook/embed') || !result.startsWith('/')) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = createLocalURL(result);
|
||||||
|
if (url.pathname.startsWith(linker.spaceBasePath)) {
|
||||||
|
const contentPath = url.pathname.slice(linker.spaceBasePath.length);
|
||||||
|
return `${linker.toPathInSpace(joinPath('~gitbook/embed/page', contentPath))}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
|
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
|
||||||
import { getEmbeddableLinker, resolveEmbeddableTheme } from './embeddable';
|
import { resolveEmbeddableTheme } from './embeddable';
|
||||||
|
import { getEmbeddableLinker, toEmbeddableLinkForPublishedContent } from './embeddable-linker';
|
||||||
import { createLinker } from './links';
|
import { createLinker } from './links';
|
||||||
|
|
||||||
describe('getEmbeddableLinker', () => {
|
describe('getEmbeddableLinker', () => {
|
||||||
@@ -21,6 +22,36 @@ describe('getEmbeddableLinker', () => {
|
|||||||
'/section/variant/~gitbook/embed/page/some/path'
|
'/section/variant/~gitbook/embed/page/some/path'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('toLinkForContent should keep current-space links inside the embed namespace', () => {
|
||||||
|
const root = createLinker({
|
||||||
|
host: 'docs.company.com',
|
||||||
|
spaceBasePath: '/api/js',
|
||||||
|
siteBasePath: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeddableLinker = getEmbeddableLinker(root);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
embeddableLinker.toLinkForContent('https://docs.company.com/api/js/getting-started')
|
||||||
|
).toBe('/api/js/~gitbook/embed/page/getting-started');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toEmbeddableLinkForPublishedContent should insert the embed path before the page slug', () => {
|
||||||
|
const root = createLinker({
|
||||||
|
host: 'docs.company.com',
|
||||||
|
spaceBasePath: '/api/js',
|
||||||
|
siteBasePath: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toEmbeddableLinkForPublishedContent(
|
||||||
|
root,
|
||||||
|
'https://docs.company.com/api/python',
|
||||||
|
'getting-started'
|
||||||
|
)
|
||||||
|
).toBe('/api/python/~gitbook/embed/page/getting-started');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveEmbeddableTheme', () => {
|
describe('resolveEmbeddableTheme', () => {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { type RouteLayoutParams, getDynamicSiteContext, getStaticSiteContext } from '@/app/utils';
|
import { type RouteLayoutParams, getDynamicSiteContext, getStaticSiteContext } from '@/app/utils';
|
||||||
import type { GitBookSiteContext } from '@/lib/context';
|
import type { GitBookSiteContext } from '@/lib/context';
|
||||||
import type { GitBookLinker } from '@/lib/links';
|
|
||||||
import { getPagePath } from '@/lib/pages';
|
|
||||||
import { joinPath } from '@/lib/paths';
|
|
||||||
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
|
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
|
||||||
|
import { getEmbeddableLinker } from './embeddable-linker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the context for the embeddable static routes.
|
* Get the context for the embeddable static routes.
|
||||||
@@ -37,38 +35,7 @@ export async function getEmbeddableDynamicContext(params: RouteLayoutParams) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { getEmbeddableLinker } from './embeddable-linker';
|
||||||
* Get a linker to generate links in the embeddable context.
|
|
||||||
*/
|
|
||||||
export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker {
|
|
||||||
return {
|
|
||||||
...linker,
|
|
||||||
toPathForPage({ pages, page, anchor }) {
|
|
||||||
const pagePath = getPagePath(pages, page);
|
|
||||||
const embedPagePath = joinPath('~gitbook/embed/page', pagePath);
|
|
||||||
|
|
||||||
return linker.toPathInSpace(embedPagePath) + (anchor ? `#${anchor}` : '');
|
|
||||||
},
|
|
||||||
|
|
||||||
withOtherSiteSpace(override: { spaceBasePath: string }): GitBookLinker {
|
|
||||||
return linker.withOtherSiteSpace({
|
|
||||||
// We make sure that links in the other site space will be shown in the embeddeable view.
|
|
||||||
spaceBasePath: joinPath(override.spaceBasePath, '~gitbook/embed/page'),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toLinkForContent(rawURL: string): string {
|
|
||||||
const result = linker.toLinkForContent(rawURL);
|
|
||||||
// If the link is not relative or already an embed, return it as is
|
|
||||||
if (result.includes('~gitbook/embed') || !result.startsWith('/')) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the link is relative, assume it's a section link and append the embed path
|
|
||||||
return joinPath(result, '~gitbook/embed/page');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve theme behavior for docs embeds.
|
* Resolve theme behavior for docs embeds.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
|
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
|
||||||
|
import { getEmbeddableLinker } from './embeddable-linker';
|
||||||
import {
|
import {
|
||||||
getSiteURLDataFromMiddleware,
|
getSiteURLDataFromMiddleware,
|
||||||
getSiteURLFromMiddleware,
|
getSiteURLFromMiddleware,
|
||||||
@@ -9,16 +10,25 @@ import {
|
|||||||
* Get the base context for a server action.
|
* Get the base context for a server action.
|
||||||
* This function should only be called in a server action.
|
* This function should only be called in a server action.
|
||||||
*/
|
*/
|
||||||
export async function getServerActionBaseContext() {
|
export async function getServerActionBaseContext(options?: { isEmbeddable?: boolean }) {
|
||||||
const siteURL = await getSiteURLFromMiddleware();
|
const siteURL = await getSiteURLFromMiddleware();
|
||||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
const urlMode = await getURLModeFromMiddleware();
|
const urlMode = await getURLModeFromMiddleware();
|
||||||
|
|
||||||
return getBaseContext({
|
const context = getBaseContext({
|
||||||
siteURL,
|
siteURL,
|
||||||
siteURLData,
|
siteURLData,
|
||||||
urlMode,
|
urlMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options?.isEmbeddable) {
|
||||||
|
return {
|
||||||
|
...context,
|
||||||
|
linker: getEmbeddableLinker(context.linker),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ function encodePathInSiteContent(
|
|||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
case '~gitbook/embed':
|
case '~gitbook/embed':
|
||||||
case '~gitbook/embed/assistant':
|
case '~gitbook/embed/assistant':
|
||||||
|
case '~gitbook/embed/search':
|
||||||
case '~gitbook/icon':
|
case '~gitbook/icon':
|
||||||
return { pathname };
|
return { pathname };
|
||||||
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
||||||
|
|||||||
Reference in New Issue
Block a user