mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +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": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.2",
|
||||
"turbo": "^2.9.6",
|
||||
"vercel": "50.37.3",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
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
|
||||
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
|
||||
- **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.
|
||||
@@ -40,7 +41,7 @@ GitBook('configure', {
|
||||
label: 'Ask',
|
||||
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||
},
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
@@ -93,7 +94,7 @@ frame.clearChat();
|
||||
|
||||
// Configure the embed (see Configuration section for all options)
|
||||
frame.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
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
|
||||
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
||||
}}
|
||||
tabs={['assistant', 'docs']}
|
||||
tabs={['assistant', 'search', 'docs']}
|
||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||
assistantName="Support Assistant"
|
||||
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.
|
||||
|
||||
- **Type**: `('assistant' | 'docs')[]`
|
||||
- **Type**: `('assistant' | 'search' | 'docs')[]`
|
||||
|
||||
```javascript
|
||||
tabs: ['assistant', 'docs']
|
||||
tabs: ['assistant', 'search', 'docs']
|
||||
```
|
||||
|
||||
### `closeButton`
|
||||
|
||||
@@ -64,7 +64,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
|
||||
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
|
||||
*/
|
||||
export type GitBookEmbeddableConfiguration = {
|
||||
/** 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. */
|
||||
actions: GitBookEmbeddableActionDefinition[];
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
greeting,
|
||||
suggestions = [],
|
||||
tools = [],
|
||||
tabs = ['assistant', 'docs'],
|
||||
tabs = ['assistant', 'search', 'docs'],
|
||||
trademark = true,
|
||||
closeButton = false,
|
||||
assistantName,
|
||||
|
||||
@@ -64,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
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,
|
||||
SearchSiteContentRequest,
|
||||
} from '@/components/Search/search-types';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
@@ -16,54 +16,41 @@ import type {
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
type SearchResultGroup = {
|
||||
score: number;
|
||||
items: OrderedComputedResult[];
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const [context, { organization, site, shareKey }] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
|
||||
const [context, siteURLData] = await Promise.all([
|
||||
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
|
||||
getSiteURLDataFromMiddleware(),
|
||||
]);
|
||||
const body = (await request.json()) as SearchSiteContentRequest;
|
||||
const { query, scope } = body;
|
||||
|
||||
if (query.length <= 1) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const [searchResults, { structure }] = await Promise.all([
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
),
|
||||
throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
siteShareKey: siteURLData.shareKey,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const results = searchResults
|
||||
.flatMap((resultItem): SearchResultGroup[] => {
|
||||
.flatMap((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
@@ -73,31 +60,25 @@ export async function POST(request: NextRequest) {
|
||||
href: resultItem.url,
|
||||
score: resultItem.score,
|
||||
};
|
||||
return [
|
||||
{
|
||||
score: resultItem.score,
|
||||
items: [result],
|
||||
},
|
||||
];
|
||||
|
||||
return [{ score: resultItem.score, items: [result] }];
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
const siteSection = found?.siteSection;
|
||||
const siteSectionGroup = found?.siteSectionGroup;
|
||||
|
||||
return resultItem.pages.map((pageItem) => ({
|
||||
score: pageItem.score,
|
||||
items: transformSitePageResult(context, {
|
||||
items: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: context.linker,
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
space: found?.siteSpace.space,
|
||||
spaceURL: found?.siteSpace.urls.published,
|
||||
siteSection: siteSection ?? undefined,
|
||||
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
|
||||
siteSection: found?.siteSection ?? undefined,
|
||||
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||
}),
|
||||
}));
|
||||
})
|
||||
@@ -107,72 +88,103 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
function transformSitePageResult(
|
||||
context: GitBookBaseContext,
|
||||
args: {
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
space?: Space;
|
||||
siteSpace?: SiteSpace;
|
||||
spaceURL?: string;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup;
|
||||
}
|
||||
): OrderedComputedResult[] {
|
||||
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
|
||||
const { linker } = context;
|
||||
function transformSitePageResult(args: {
|
||||
asEmbeddable: boolean;
|
||||
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
siteSpace?: SiteSpace;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup | null;
|
||||
}): OrderedComputedResult[] {
|
||||
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||
args;
|
||||
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 = {
|
||||
type: 'page',
|
||||
id: `${spaceItem.id}/${pageItem.id}`,
|
||||
title: pageItem.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
href: pageHref,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: pageItem.score,
|
||||
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),
|
||||
breadcrumbs,
|
||||
};
|
||||
|
||||
const pageSections =
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => ({
|
||||
type: 'section',
|
||||
id: `${page.id}/${section.id}`,
|
||||
title: section.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
body: section.body,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: section.score,
|
||||
})) ?? [];
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(pageItem.path);
|
||||
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
sectionHref = toEmbeddableLinkForPublishedContent(
|
||||
linker,
|
||||
spaceURL,
|
||||
section.path
|
||||
);
|
||||
} 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];
|
||||
}
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ export async function GET(
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
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';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -36,10 +35,9 @@ export async function* streamAIChatResponse({
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
const context = await getServerActionBaseContext({
|
||||
isEmbeddable: options?.asEmbeddable,
|
||||
});
|
||||
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
} from '@/components/AIChat';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import * as api from '@gitbook/api';
|
||||
import React, { use, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { LinkContext, type LinkContextType } from '../primitives';
|
||||
import { LinkContext } from '../primitives';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameBody,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
EmbeddableIframeButtons,
|
||||
EmbeddableIframeCloseButton,
|
||||
EmbeddableIframeTabs,
|
||||
useEmbeddableConfiguration,
|
||||
useEmbeddableLinkContext,
|
||||
} from './EmbeddableIframeAPI';
|
||||
|
||||
type EmbeddableAIChatProps = {
|
||||
@@ -43,7 +43,6 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
const chat = useAIChatState();
|
||||
const { config: siteConfig } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
const embedConfig = useEmbeddableConfiguration();
|
||||
const language = useLanguage();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -65,21 +64,8 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
}, [trackEvent]);
|
||||
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasDocsTab = embedConfig.tabs.includes('docs');
|
||||
const trademark = siteConfig.trademark;
|
||||
const currentLinkContext = use(LinkContext);
|
||||
const linkContext: LinkContextType = useMemo(
|
||||
() =>
|
||||
hasDocsTab
|
||||
? { ...currentLinkContext, externalTarget: '_blank' }
|
||||
: {
|
||||
...currentLinkContext,
|
||||
isExternalClient: () => true,
|
||||
isExternalServer: () => true,
|
||||
externalTarget: '_blank',
|
||||
},
|
||||
[hasDocsTab, currentLinkContext]
|
||||
);
|
||||
const { linkContext } = useEmbeddableLinkContext();
|
||||
|
||||
return (
|
||||
<EmbeddableFrame>
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { useAI, useAIChatController } from '@/components/AI';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { Button } from '../primitives';
|
||||
import { Button, LinkContext, type LinkContextType } from '../primitives';
|
||||
import { getChannel } from './channel';
|
||||
|
||||
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
|
||||
@@ -105,6 +106,31 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
|
||||
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.
|
||||
*/
|
||||
@@ -151,55 +177,59 @@ export function EmbeddableIframeTabs(props: {
|
||||
siteTitle: string;
|
||||
}) {
|
||||
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 language = useLanguage();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tabs = [
|
||||
const enabledTabs = [
|
||||
config.aiMode === CustomizationAIMode.Assistant &&
|
||||
assistants[0] &&
|
||||
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
|
||||
tabs.includes('assistant')
|
||||
? {
|
||||
key: 'assistant',
|
||||
label: assistants[0].label,
|
||||
icon: assistants[0].icon,
|
||||
onClick: () => {
|
||||
router.push(`${baseURL}/assistant`);
|
||||
},
|
||||
href: `${baseURL}/assistant`,
|
||||
}
|
||||
: 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',
|
||||
label: siteTitle,
|
||||
icon: 'book-open',
|
||||
onClick: () => {
|
||||
router.push(`${baseURL}/page/`);
|
||||
},
|
||||
href: `${baseURL}/page/`,
|
||||
}
|
||||
: 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(() => {
|
||||
const hasAssistant = tabs.find((tab) => tab.key === 'assistant');
|
||||
const hasDocs = tabs.find((tab) => tab.key === 'docs');
|
||||
if (!hasAssistant && !hasDocs) {
|
||||
// No valid tabs, do not redirect
|
||||
if (enabledTabs.length === 0) {
|
||||
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}>
|
||||
{tabs.map((tab) => (
|
||||
{enabledTabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
data-testid={`embed-tab-${tab.key}`}
|
||||
@@ -210,7 +240,9 @@ export function EmbeddableIframeTabs(props: {
|
||||
active={tab.key === active}
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={tab.onClick}
|
||||
onClick={() => {
|
||||
router.push(tab.href);
|
||||
}}
|
||||
tooltipProps={{
|
||||
contentProps: {
|
||||
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 './EmbeddableAssistantPage';
|
||||
export * from './EmbeddableDocsPage';
|
||||
export * from './EmbeddableSearchPage';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { SiteSpace } from '@gitbook/api';
|
||||
import { SocialAccountButton } from '../Footer/SocialAccounts';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||
import { HeaderLink } from './HeaderLink';
|
||||
import { HeaderLinkMore } from './HeaderLinkMore';
|
||||
@@ -26,7 +26,8 @@ export function Header(props: {
|
||||
};
|
||||
}) {
|
||||
const { context, withTopHeader, variants } = props;
|
||||
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
|
||||
const { siteSpace, visibleSections, customization } = context;
|
||||
const searchProps = getSearchBaseProps(context);
|
||||
|
||||
const withSections = Boolean(
|
||||
visibleSections &&
|
||||
@@ -143,27 +144,9 @@ export function Header(props: {
|
||||
)}
|
||||
>
|
||||
<SearchContainer
|
||||
{...searchProps}
|
||||
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}
|
||||
searchURL={context.linker.toPathInSpace('~gitbook/search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export type SearchAskState =
|
||||
/**
|
||||
* Fetch and render the answers to a question.
|
||||
*/
|
||||
export function SearchAskAnswer(props: { query: string }) {
|
||||
const { query } = props;
|
||||
export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }) {
|
||||
const { query, asEmbeddable } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -49,7 +49,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
query,
|
||||
});
|
||||
|
||||
const { stream } = await streamAskQuestion({ question: query });
|
||||
const { stream } = await streamAskQuestion({ question: query, asEmbeddable });
|
||||
for await (const chunk of readStreamableValue(stream)) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -74,7 +74,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
}, [query, setAskState, trackEvent]);
|
||||
}, [asEmbeddable, query, setAskState, trackEvent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,123 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { getLocalizedTitle } from '@/lib/sites';
|
||||
import { CustomizationSearchStyle, type SiteSection, type SiteSpace } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAI } from '../AI';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Popover, useBodyLoaded } from '../primitives';
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { useSearchAskState } from './SearchAskContext';
|
||||
import { SearchAskProvider } from './SearchAskContext';
|
||||
import { Popover } from '../primitives';
|
||||
import { SearchFrame } from './SearchFrame';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import { useSearchState, useSetSearchState } from './useSearch';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
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;
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchController } from './useSearchController';
|
||||
|
||||
interface SearchContainerProps extends SearchBaseProps {
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
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.
|
||||
*/
|
||||
export function SearchContainer({
|
||||
siteSpace,
|
||||
section,
|
||||
withVariants,
|
||||
withSiteVariants,
|
||||
withSections,
|
||||
style,
|
||||
className,
|
||||
viewport,
|
||||
siteSpaces,
|
||||
searchURL,
|
||||
...searchProps
|
||||
}: 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 isLoaded = useBodyLoaded();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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
|
||||
const initialRef = React.useRef(state?.ask === undefined || state?.ask === null); // If ask is not set on page load, we will never trigger
|
||||
React.useEffect(() => {
|
||||
if (initialRef.current) return;
|
||||
if (assistants.length === 0) return;
|
||||
if (state?.ask === undefined || state?.ask === null) return;
|
||||
|
||||
// For simplicity we're only triggering the first assistant
|
||||
// Because this is in the layout, we need to await for the body to be loaded.
|
||||
if (isLoaded) {
|
||||
assistants[0]?.open(state.ask ?? undefined);
|
||||
initialRef.current = true;
|
||||
}
|
||||
}, [state?.ask, assistants.length, assistants[0]?.open, 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 {
|
||||
assistants,
|
||||
askQuery,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
open,
|
||||
query,
|
||||
results,
|
||||
resultsId,
|
||||
resultsRef,
|
||||
searchValue,
|
||||
setQuery,
|
||||
showAsk,
|
||||
state,
|
||||
withAI,
|
||||
withSearchAI,
|
||||
scopeControl,
|
||||
} = useSearchController(searchProps);
|
||||
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
|
||||
|
||||
useHotkeys(
|
||||
'mod+k',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
open();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
@@ -128,35 +68,17 @@ export function SearchContainer({
|
||||
'mod+i',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
if (assistants) {
|
||||
assistants[0]?.open();
|
||||
}
|
||||
assistants[0]?.open();
|
||||
},
|
||||
{
|
||||
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(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
@@ -164,110 +86,33 @@ export function SearchContainer({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
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;
|
||||
}, [close]);
|
||||
|
||||
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined;
|
||||
|
||||
return (
|
||||
<SearchAskProvider value={searchAsk}>
|
||||
<>
|
||||
<Popover
|
||||
content={
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<div className="scroll-py-2 overflow-y-scroll p-2">
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
id={searchResultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : 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>
|
||||
<SearchFrame
|
||||
askQuery={askQuery}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
fetching={fetching}
|
||||
query={query}
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
showAsk={showAsk}
|
||||
scopeControl={
|
||||
searchProps.withVariants || searchProps.withSections ? (
|
||||
<SearchScopeControl {...scopeControl} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
rootProps={{
|
||||
@@ -278,14 +123,14 @@ export function SearchContainer({
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
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) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
close();
|
||||
},
|
||||
sideOffset: 8,
|
||||
collisionPadding: {
|
||||
@@ -302,54 +147,32 @@ export function SearchContainer({
|
||||
>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
aria-activedescendant={searchResultsActiveDescendant}
|
||||
aria-controls={resultsId}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
value={searchValue}
|
||||
onFocus={onOpen}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
withAI={withSearchAI}
|
||||
isOpen={state?.open ?? false}
|
||||
className={className}
|
||||
aria-controls={searchResultsId}
|
||||
aria-activedescendant={
|
||||
cursor !== null ? `${searchResultsId}-${cursor}` : undefined
|
||||
}
|
||||
onFocus={open}
|
||||
>
|
||||
<LiveResultsAnnouncer
|
||||
<SearchLiveResultsAnnouncer
|
||||
count={results.length}
|
||||
showing={Boolean(searchValue) && !fetching}
|
||||
/>
|
||||
</SearchInput>
|
||||
</Popover>
|
||||
{assistants
|
||||
.filter((assistant) => assistant.ui === true)
|
||||
.map((assistant, index) => (
|
||||
<AIChatButton
|
||||
key={assistant.id}
|
||||
assistant={assistant}
|
||||
withShortcut={index === 0}
|
||||
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>
|
||||
{uiAssistants.map((assistant, index) => (
|
||||
<AIChatButton
|
||||
key={assistant.id}
|
||||
assistant={assistant}
|
||||
withShortcut={index === 0}
|
||||
showLabel={
|
||||
uiAssistants.length === 1 && style === CustomizationSearchStyle.Prominent
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
interface SearchInputProps {
|
||||
'aria-activedescendant'?: string;
|
||||
'aria-controls'?: string;
|
||||
onChange: (value: string) => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onFocus: () => void;
|
||||
onFocus?: () => void;
|
||||
value: string;
|
||||
withAI: boolean;
|
||||
isOpen: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
mode?: 'header' | 'frame';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +33,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
isOpen,
|
||||
className,
|
||||
children,
|
||||
mode = 'header',
|
||||
...rest
|
||||
} = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isFrame = mode === 'frame';
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
@@ -50,7 +55,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
}, [isOpen, value]);
|
||||
|
||||
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
|
||||
data-testid="search-input"
|
||||
name="search-input"
|
||||
@@ -58,14 +67,22 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
|
||||
sizing="medium"
|
||||
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')}…`}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
leading={
|
||||
<Icon
|
||||
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}
|
||||
@@ -75,18 +92,27 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={value && isOpen ? 'true' : 'false'}
|
||||
clearButton={{
|
||||
className:
|
||||
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
|
||||
}}
|
||||
keyboardShortcut={{
|
||||
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'],
|
||||
}}
|
||||
clearButton={
|
||||
isFrame
|
||||
? true
|
||||
: {
|
||||
className:
|
||||
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
|
||||
}
|
||||
}
|
||||
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}
|
||||
type="text"
|
||||
/>
|
||||
{children}
|
||||
</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 './SearchFrame';
|
||||
export * from './SearchLiveResultsAnnouncer';
|
||||
export * from './SearchContainer';
|
||||
export * from './SearchScopeControl';
|
||||
export * from './search-props';
|
||||
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[] };
|
||||
|
||||
export interface SearchSiteContentRequest {
|
||||
asEmbeddable?: boolean;
|
||||
query: string;
|
||||
scope: SearchSiteContentScope;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createStreamableValue } from 'ai/rsc';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -37,15 +38,19 @@ export interface AskAnswerResult {
|
||||
* Server action to ask a question in a space.
|
||||
*/
|
||||
export async function streamAskQuestion({
|
||||
asEmbeddable,
|
||||
question,
|
||||
}: {
|
||||
asEmbeddable?: boolean;
|
||||
question: string;
|
||||
}) {
|
||||
return traceErrorOnly('Search.streamAskQuestion', async () => {
|
||||
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
|
||||
|
||||
(async () => {
|
||||
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
|
||||
const context = await fetchServerActionSiteContext(
|
||||
await getServerActionBaseContext({ isEmbeddable: asEmbeddable })
|
||||
);
|
||||
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
|
||||
@@ -107,7 +112,11 @@ export async function streamAskQuestion({
|
||||
}, new Map<string, RevisionPage[]>());
|
||||
});
|
||||
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,
|
||||
{
|
||||
answer,
|
||||
asEmbeddable,
|
||||
spacePages,
|
||||
}: {
|
||||
answer: SearchAIAnswer;
|
||||
asEmbeddable: boolean;
|
||||
spacePages: Map<string, RevisionPage[]>;
|
||||
}
|
||||
): Promise<AskAnswerResult> {
|
||||
@@ -197,12 +208,24 @@ async function transformAnswer(
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
|
||||
const href = spaceURL
|
||||
? joinPathWithBaseURL(spaceURL, page.page.path)
|
||||
: context.linker.toPathForPage({
|
||||
pages,
|
||||
page: page.page,
|
||||
});
|
||||
let href = context.linker.toPathForPage({
|
||||
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 {
|
||||
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();
|
||||
|
||||
export function useSearchResults(props: {
|
||||
asEmbeddable?: boolean;
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
scope: SearchScope;
|
||||
withAI: boolean;
|
||||
suggestions?: string[];
|
||||
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
||||
searchURL: string;
|
||||
}) {
|
||||
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props;
|
||||
const {
|
||||
asEmbeddable,
|
||||
disabled,
|
||||
query,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
scope,
|
||||
suggestions,
|
||||
searchURL,
|
||||
} = props;
|
||||
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
@@ -148,7 +157,13 @@ export function useSearchResults(props: {
|
||||
const fetchSearch = (
|
||||
scope: Parameters<typeof fetchSearchResults>[1]
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(searchURL, scope, query, abortController.signal);
|
||||
fetchSearchResults(
|
||||
searchURL,
|
||||
scope,
|
||||
query,
|
||||
abortController.signal,
|
||||
asEmbeddable
|
||||
);
|
||||
|
||||
switch (scope) {
|
||||
case 'all':
|
||||
@@ -214,6 +229,7 @@ export function useSearchResults(props: {
|
||||
disabled,
|
||||
suggestions,
|
||||
searchURL,
|
||||
asEmbeddable,
|
||||
getAssistants,
|
||||
]);
|
||||
|
||||
@@ -230,12 +246,14 @@ async function fetchSearchResults(
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] },
|
||||
query: string,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
asEmbeddable?: boolean
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
const response = await fetch(searchURL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asEmbeddable,
|
||||
query,
|
||||
scope,
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
@@ -106,7 +106,8 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
*/
|
||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
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;
|
||||
|
||||
@@ -199,23 +200,9 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
{!withTopHeader && (
|
||||
<div className="flex gap-2 max-lg:hidden">
|
||||
<SearchContainer
|
||||
{...searchProps}
|
||||
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"
|
||||
searchURL={context.linker.toPathInSpace(
|
||||
'~gitbook/search'
|
||||
)}
|
||||
/>
|
||||
</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 { 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';
|
||||
|
||||
describe('getEmbeddableLinker', () => {
|
||||
@@ -21,6 +22,36 @@ describe('getEmbeddableLinker', () => {
|
||||
'/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', () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext, getStaticSiteContext } from '@/app/utils';
|
||||
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 { getEmbeddableLinker } from './embeddable-linker';
|
||||
|
||||
/**
|
||||
* Get the context for the embeddable static routes.
|
||||
@@ -37,38 +35,7 @@ export async function getEmbeddableDynamicContext(params: RouteLayoutParams) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
export { getEmbeddableLinker } from './embeddable-linker';
|
||||
|
||||
/**
|
||||
* Resolve theme behavior for docs embeds.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type GitBookBaseContext, fetchSiteContextByURLLookup, getBaseContext } from './context';
|
||||
import { getEmbeddableLinker } from './embeddable-linker';
|
||||
import {
|
||||
getSiteURLDataFromMiddleware,
|
||||
getSiteURLFromMiddleware,
|
||||
@@ -9,16 +10,25 @@ import {
|
||||
* Get the base context for 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 siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const urlMode = await getURLModeFromMiddleware();
|
||||
|
||||
return getBaseContext({
|
||||
const context = getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode,
|
||||
});
|
||||
|
||||
if (options?.isEmbeddable) {
|
||||
return {
|
||||
...context,
|
||||
linker: getEmbeddableLinker(context.linker),
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -721,6 +721,7 @@ function encodePathInSiteContent(
|
||||
switch (pathname) {
|
||||
case '~gitbook/embed':
|
||||
case '~gitbook/embed/assistant':
|
||||
case '~gitbook/embed/search':
|
||||
case '~gitbook/icon':
|
||||
return { pathname };
|
||||
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
||||
|
||||
Reference in New Issue
Block a user