From f35be52c80f221c0a98d0915196ed616c614ff02 Mon Sep 17 00:00:00 2001 From: Peter White <1788320+peterwhite@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:25:35 +0200 Subject: [PATCH] Allow configuring the tab and page the Docs Embed opens on (RND-12456) (#4669) --- .changeset/embed-default-tab-page.md | 6 ++ packages/embed/README.md | 30 ++++++++ packages/embed/src/client/protocol.ts | 6 ++ packages/embed/src/react/GitBookFrame.tsx | 6 ++ .../Embeddable/EmbeddableIframeAPI.tsx | 76 ++++++++++++++----- .../getEmbeddableLandingTab.test.ts | 39 ++++++++++ .../Embeddable/getEmbeddableLandingTab.ts | 17 +++++ 7 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 .changeset/embed-default-tab-page.md create mode 100644 packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts create mode 100644 packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts diff --git a/.changeset/embed-default-tab-page.md b/.changeset/embed-default-tab-page.md new file mode 100644 index 000000000..fc3cbf8bd --- /dev/null +++ b/.changeset/embed-default-tab-page.md @@ -0,0 +1,6 @@ +--- +"@gitbook/embed": minor +"gitbook": patch +--- + +Add `defaultTab` and `defaultPage` options to the Docs Embed to choose where it opens. `defaultTab` opens the embed on the assistant, search or docs tab. `defaultPage` sets the page the docs tab opens on in place of the site's home page, and the embed opens on that page unless `defaultTab` says otherwise. Both work with the standalone script's `configure`, `frame.configure` and `` props. diff --git a/packages/embed/README.md b/packages/embed/README.md index 229129e3a..88a3f268e 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -54,6 +54,7 @@ GitBook('configure', { icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book' }, tabs: ['assistant', 'search', 'docs'], + defaultPage: '/getting-started', actions: [ { icon: 'circle-question', @@ -107,6 +108,7 @@ frame.clearChat(); // Configure the embed (see Configuration section for all options) frame.configure({ tabs: ['assistant', 'search', 'docs'], + defaultPage: '/getting-started', actions: [ { icon: 'circle-question', @@ -142,6 +144,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react'; unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions }} tabs={['assistant', 'search', 'docs']} + defaultPage="/getting-started" greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }} assistantName="Support Assistant" suggestions={['What is GitBook?', 'How do I get started?']} @@ -249,6 +252,33 @@ Override which tabs are displayed. Defaults to your site's configuration. tabs: ['assistant', 'search', 'docs'] ``` +### `defaultTab` + +Available in: Standalone script, NPM package, React components + +The tab the embed opens on. Without it, the embed opens on `defaultPage` if set, otherwise on the assistant (or the docs when the assistant isn't available). The tab must be one of the enabled `tabs`. + +- **Type**: `'assistant' | 'search' | 'docs'` + +```javascript +tabs: ['assistant', 'search', 'docs'], +defaultTab: 'search' +``` + +`defaultTab` and `defaultPage` apply when the embed first loads: the standalone widget keeps its place when it is closed and reopened. To move it later, use `navigateToPage` or `navigateToAssistant`. + +### `defaultPage` + +Available in: Standalone script, NPM package, React components + +The page the docs tab opens on, in place of your site's home page. Accepts the same references as `navigateToPage`: the page's path within the site, an absolute path, or its full published URL. Unless `defaultTab` says otherwise, the embed opens on this page. + +- **Type**: `string` + +```javascript +defaultPage: '/getting-started/quickstart' +``` + ### `closeButton` Available in: Standalone script, NPM package, React components diff --git a/packages/embed/src/client/protocol.ts b/packages/embed/src/client/protocol.ts index 5deb02524..6b1c5848a 100644 --- a/packages/embed/src/client/protocol.ts +++ b/packages/embed/src/client/protocol.ts @@ -62,6 +62,12 @@ export type GitBookEmbeddableConfiguration = { /** Tabs to display in the embed (if enabled on the site). */ tabs: ('assistant' | 'docs' | 'search')[]; + /** Tab to open the embed on. */ + defaultTab?: 'assistant' | 'docs' | 'search'; + + /** Page to open the docs tab on, instead of the site's home page. */ + defaultPage?: string; + /** Additional buttons to be displayed in the header of the GitBook embed. */ actions: GitBookEmbeddableActionDefinition[]; diff --git a/packages/embed/src/react/GitBookFrame.tsx b/packages/embed/src/react/GitBookFrame.tsx index d2968cb91..e28c7b1b8 100644 --- a/packages/embed/src/react/GitBookFrame.tsx +++ b/packages/embed/src/react/GitBookFrame.tsx @@ -27,6 +27,8 @@ export function GitBookFrame(props: GitBookFrameProps) { suggestions = [], tools = [], tabs = ['assistant', 'search', 'docs'], + defaultTab, + defaultPage, trademark = true, closeButton = false, assistantName, @@ -50,6 +52,8 @@ export function GitBookFrame(props: GitBookFrameProps) { useEffect(() => { gitbookFrame?.configure({ tabs, + defaultTab, + defaultPage, actions, greeting, suggestions, @@ -65,6 +69,8 @@ export function GitBookFrame(props: GitBookFrameProps) { suggestions, tools, tabs, + defaultTab, + defaultPage, closeButton, trademark, assistantName, diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx index 6df5958e4..a1c97287c 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx @@ -9,6 +9,7 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb import { integrationsAssistantTools } from '../Integrations'; import { Button, LinkContext, type LinkContextType } from '../primitives'; import { getChannel } from './channel'; +import { DEFAULT_EMBEDDABLE_TABS, getEmbeddableLandingTab } from './getEmbeddableLandingTab'; import { resolveEmbedPageLink } from './server-actions'; import { useAI, useAIChatController } from '@/components/AI'; import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; @@ -23,6 +24,17 @@ const embeddableConfiguration = createStore(() = trademark: true, })); +const docsHome = createStore<{ reference?: string; href?: string }>(() => ({})); + +// Module-level so it survives the layout remounting on a cross-space navigation. +let landed = false; + +function resolvePageHref(pagePath: string, baseURL: string): Promise { + return resolveEmbedPageLink(pagePath) + .then((resolved) => ('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`)) + .catch(() => `${baseURL}/page/${pagePath}`); +} + // oxlint-disable-next-line typescript/no-explicit-any function log(...data: any[]) { // oxlint-disable-next-line no-console @@ -66,6 +78,15 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) { const { baseURL, router, chatController } = refs.current; const message = payload as ParentToFrameMessage; + const navigate = (href: Promise) => { + const token = ++navToken.current; + href.then((href) => { + if (navToken.current === token) { + refs.current.router.push(href); + } + }); + }; + log('[gitbook] received message', message); switch (message.type) { @@ -83,29 +104,43 @@ export function EmbeddableIframeAPI(props: { baseURL: string }) { break; } case 'configure': { - embeddableConfiguration.setState(message.settings); + const { settings } = message; + embeddableConfiguration.setState(settings); integrationsAssistantTools.setState({ - tools: message.settings.tools, + tools: settings.tools, }); + + const defaultPage = + typeof settings.defaultPage === 'string' && settings.defaultPage + ? settings.defaultPage + : undefined; + let docsHomeHref: Promise | undefined; + if (defaultPage !== docsHome.getState().reference) { + docsHome.setState({ reference: defaultPage, href: undefined }); + if (defaultPage) { + docsHomeHref = resolvePageHref(defaultPage, baseURL); + docsHomeHref.then((href) => { + if (docsHome.getState().reference === defaultPage) { + docsHome.setState({ href }); + } + }); + } + } + + if (!landed) { + landed = true; + const tab = getEmbeddableLandingTab(settings); + if (tab === 'docs') { + navigate(docsHomeHref ?? Promise.resolve(`${baseURL}/page/`)); + } else if (tab) { + navToken.current++; + router.push(`${baseURL}/${tab}`); + } + } break; } case 'navigateToPage': { - // Resolve the target server-side: on a multi-space site the page may - // live in another space/section, whose base must go before - // `~gitbook/embed/page`. Fall back to a same-space push on failure. - const { pagePath } = message; - const token = ++navToken.current; - // Ignore the result if a later navigation has since superseded this one. - const push = (href: string) => { - if (navToken.current === token) { - router.push(href); - } - }; - resolveEmbedPageLink(pagePath) - .then((resolved) => - push('href' in resolved ? resolved.href : `${baseURL}/page/${pagePath}`) - ) - .catch(() => push(`${baseURL}/page/${pagePath}`)); + navigate(resolvePageHref(message.pagePath, baseURL)); break; } case 'navigateToAssistant': { @@ -132,7 +167,7 @@ export function useEmbeddableConfiguration( export function useEmbeddableTabs() { const configuredTabs = useEmbeddableConfiguration((state) => state.tabs); - return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs']; + return configuredTabs.length > 0 ? configuredTabs : DEFAULT_EMBEDDABLE_TABS; } export function useEmbeddableLinkContext() { @@ -200,6 +235,7 @@ export function EmbeddableIframeTabs(props: { const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props; const actions = useEmbeddableConfiguration((state) => state.actions); const tabs = useEmbeddableTabs(); + const docsHomeHref = useStore(docsHome, (state) => state.href); const { assistants, config } = useAI(); const language = useLanguage(); @@ -228,7 +264,7 @@ export function EmbeddableIframeTabs(props: { key: 'docs', label: siteTitle, icon: 'book-open', - href: `${baseURL}/page/`, + href: docsHomeHref ?? `${baseURL}/page/`, } : null, ].filter((tab) => tab !== null); diff --git a/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts new file mode 100644 index 000000000..6010d87c5 --- /dev/null +++ b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; + +import { getEmbeddableLandingTab } from './getEmbeddableLandingTab'; + +describe('getEmbeddableLandingTab', () => { + it('keeps the embed default when nothing is configured', () => { + expect(getEmbeddableLandingTab({})).toBeNull(); + expect(getEmbeddableLandingTab({ tabs: ['docs', 'search'] })).toBeNull(); + }); + + it('opens on the configured tab', () => { + expect(getEmbeddableLandingTab({ defaultTab: 'search' })).toBe('search'); + expect( + getEmbeddableLandingTab({ tabs: ['assistant', 'search', 'docs'], defaultTab: 'search' }) + ).toBe('search'); + }); + + it('opens on the docs when only a default page is set', () => { + expect(getEmbeddableLandingTab({ defaultPage: 'getting-started' })).toBe('docs'); + }); + + it('prefers the configured tab over the default page', () => { + expect( + getEmbeddableLandingTab({ defaultTab: 'assistant', defaultPage: 'getting-started' }) + ).toBe('assistant'); + }); + + it('ignores a tab that is not enabled', () => { + expect(getEmbeddableLandingTab({ tabs: ['assistant'], defaultTab: 'search' })).toBeNull(); + expect( + getEmbeddableLandingTab({ tabs: ['assistant', 'search'], defaultPage: 'quickstart' }) + ).toBeNull(); + }); + + it('ignores an unknown tab', () => { + // @ts-expect-error - the parent window is plain JS and can send anything. + expect(getEmbeddableLandingTab({ defaultTab: 'settings' })).toBeNull(); + }); +}); diff --git a/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts new file mode 100644 index 000000000..fa686c28d --- /dev/null +++ b/packages/gitbook/src/components/Embeddable/getEmbeddableLandingTab.ts @@ -0,0 +1,17 @@ +import type { GitBookEmbeddableConfiguration } from '@gitbook/embed'; + +export type EmbeddableTab = GitBookEmbeddableConfiguration['tabs'][number]; + +export const DEFAULT_EMBEDDABLE_TABS: EmbeddableTab[] = ['assistant', 'search', 'docs']; + +export function getEmbeddableLandingTab( + settings: Partial> +): EmbeddableTab | null { + const tab = settings.defaultTab ?? (settings.defaultPage ? 'docs' : undefined); + if (!tab || !DEFAULT_EMBEDDABLE_TABS.includes(tab)) { + return null; + } + + const tabs = settings.tabs?.length ? settings.tabs : DEFAULT_EMBEDDABLE_TABS; + return tabs.includes(tab) ? tab : null; +}