Add Search tab to Docs Embed, refactor search into an embeddable frame (#4185)

This commit is contained in:
Zeno Kapitein
2026-04-15 12:09:59 +02:00
committed by GitHub
parent bda9fe2d76
commit 88c38fa505
35 changed files with 1017 additions and 511 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": minor
"@gitbook/embed": minor
---
Add Search tab to Docs Embed, refactor search
+1 -1
View File
@@ -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",
}, },
}, },
+7 -6
View File
@@ -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: [],
+1 -1
View File
@@ -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[];
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
}; };
@@ -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} />;
}
@@ -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];
} }
@@ -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
}); });
@@ -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;
},
};
}
+32 -1
View File
@@ -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', () => {
+2 -35
View File
@@ -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.
+12 -2
View File
@@ -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;
} }
/** /**
+1
View File
@@ -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