diff --git a/.changeset/webframe-navigate.md b/.changeset/webframe-navigate.md new file mode 100644 index 000000000..52baed3b1 --- /dev/null +++ b/.changeset/webframe-navigate.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-contentkit": patch +"gitbook": patch +--- + +Let integration block webframes navigate the reader to another page in the site by posting a `@webframe.navigate` action with a `path` (and optional `anchor`). Resolved client-side against the site base path, so navigation stays in-site and drives the standard navigation progress bar. diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx deleted file mode 100644 index 3d174632d..000000000 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { useAdaptiveVisitor } from '@/components/Adaptive'; -import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; -import React from 'react'; - -type ContentKitProps = React.ComponentProps>; - -/** - * ContentKit wrapper for integration blocks that need client-only adaptive context. - */ -export function ContentKitWithAdaptiveVisitorContext( - props: ContentKitProps -) { - const getAdaptiveVisitorClaims = useAdaptiveVisitor(); - const visitorClaims = getAdaptiveVisitorClaims(); - - const clientContext = React.useMemo( - () => ({ - getVisitorContext: () => ({ - visitor: visitorClaims?.visitor ?? null, - }), - }), - [visitorClaims] - ); - - return ; -} diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx new file mode 100644 index 000000000..47d6104cf --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useAdaptiveVisitor } from '@/components/Adaptive'; +import { NavigationStatusContext } from '@/components/hooks'; +import { type GitBookLinker, createLinker } from '@/lib/links'; +import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; +import { useRouter } from 'next/navigation'; +import React from 'react'; + +type ContentKitProps = React.ComponentProps>; + +/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */ +export type WebframeLinkerData = Pick< + Parameters[0], + 'host' | 'protocol' | 'siteBasePath' | 'spaceBasePath' +>; + +/** + * ContentKit wrapper for integration blocks that expose client-only capabilities to webframes: + * navigation to other pages, and adaptive visitor claims (only when the integration is allowed to + * access them). + */ +export function ContentKitWithClientContext( + props: ContentKitProps & { + /** Whether visitor claims may be exposed to the webframe (integration scope gated). */ + canAccessVisitorClaims: boolean; + /** Data to rebuild the site linker, used to resolve webframe navigation requests. */ + linkerData: WebframeLinkerData; + } +) { + const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props; + + const router = useRouter(); + const { onNavigationClick } = React.useContext(NavigationStatusContext); + const getAdaptiveVisitorClaims = useAdaptiveVisitor(); + + // Rebuild the (tested) linker on the client so navigation resolves paths exactly like the rest + // of the app, instead of duplicating the join logic here. + const linker = React.useMemo(() => createLinker(linkerData), [linkerData]); + + // Navigate to an in-site href, driving the same navigation progress bar as a regular link so + // the reader gets feedback while the destination page loads. + const navigateTo = React.useCallback( + (href: string) => { + onNavigationClick(href); + router.push(href); + }, + [onNavigationClick, router] + ); + // Read during render (Suspense) only when the integration is allowed visitor claims, so that + // webframes that don't use visitor claims don't suspend on the visitor-claims fetch. + const visitorClaims = canAccessVisitorClaims ? getAdaptiveVisitorClaims() : null; + + const clientContext = React.useMemo( + () => ({ + getVisitorContext: canAccessVisitorClaims + ? () => ({ visitor: visitorClaims?.visitor ?? null }) + : undefined, + navigate: ({ path, anchor }) => { + // Resolve the requested path relative to the site root so a webframe can navigate + // to any section or space within the site (and nowhere outside it). + const suffix = anchor ? `#${anchor}` : ''; + navigateTo(linker.toPathInSite(path) + suffix); + }, + }), + [canAccessVisitorClaims, visitorClaims, linker, navigateTo] + ); + + return ; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 8904145fd..1443cc2c2 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -5,8 +5,12 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit'; import type { BlockProps } from '../Block'; import './contentkit.css'; -import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext'; -import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive'; +import type { GitBookLinker } from '@/lib/links'; +import { + ContentKitWithClientContext, + type WebframeLinkerData, +} from './ContentKitWithClientContext'; +import { integrationBlockContainsWebframe } from './adaptive'; import { contentKitServerContext } from './contentkit'; import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; @@ -70,34 +74,70 @@ export async function IntegrationBlock(props: BlockProps - - - + {useClientContext ? ( + + + + ) : ( + + + + )} ); } + +/** + * Extract the serializable data needed to rebuild the site linker on the client, so webframe + * navigation resolves paths through the same (tested) linker as the rest of the app. + */ +function getWebframeLinkerData(linker: GitBookLinker): WebframeLinkerData { + const data: WebframeLinkerData = { + siteBasePath: linker.siteBasePath, + spaceBasePath: linker.spaceBasePath, + }; + + // `host`/`protocol` are only used to build absolute URLs, which webframe navigation never does. + // Carry them along when available so the rebuilt linker is complete (and avoids a dev warning). + try { + const url = new URL(linker.toAbsoluteURL('/')); + data.host = url.host; + data.protocol = url.protocol; + } catch { + // No usable host (e.g. tests): the linker still resolves in-site paths without it. + } + + return data; +} diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts new file mode 100644 index 000000000..11dbae41f --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api'; + +import { integrationBlockContainsWebframe } from './adaptive'; + +const webframe: ContentKitWebFrame = { + type: 'webframe', + source: { url: 'https://integrations.gitbook.com/frame' }, +}; + +function elementOutput(element: unknown): ContentKitRenderOutput { + return { + type: 'element', + element, + state: {}, + props: {}, + } as ContentKitRenderOutput; +} + +describe('integrationBlockContainsWebframe', () => { + it('returns false for a completed output', () => { + expect(integrationBlockContainsWebframe({ type: 'complete' })).toBe(false); + }); + + it('returns false when there is no webframe in the tree', () => { + const output = elementOutput({ + type: 'block', + children: [{ type: 'text', text: 'hello' }], + } as never); + expect(integrationBlockContainsWebframe(output)).toBe(false); + }); + + it('returns true when a webframe is nested in the tree', () => { + const output = elementOutput({ + type: 'block', + children: [{ type: 'vstack', children: [webframe] }], + } as never); + expect(integrationBlockContainsWebframe(output)).toBe(true); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index 162f0f631..9adeb2686 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -8,19 +8,15 @@ import type { type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; /** - * Decide whether an integration block should expose Adaptive visitor context to webframes. + * Whether an integration block's output contains a webframe that can consume client-only context + * (navigation and/or visitor claims). */ -export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext( - output: ContentKitRenderOutput -) { +export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean { if (output.type === 'complete') { return false; } - return ( - output.canAccessVisitorClaims === true && - doesContentKitElementContainWebframe(output.element) - ); + return doesContentKitElementContainWebframe(output.element); } /** diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 26cd8f490..9ff9f58de 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -127,6 +127,19 @@ export function ElementWebframe(props: ContentKitClientElementProps { const abort = { cancelled: false }; sendWebframeState({ @@ -218,14 +231,14 @@ function resolveWebframeState( } /** - * Read optional client-only visitor context. + * Resolve the optional client-only contexts (visitor claims) to merge into the webframe state. */ -async function resolveVisitorContext(clientContext: ContentKitClientContextData | undefined) { - return await clientContext?.getVisitorContext?.(); +async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) { + return await Promise.all([clientContext?.getVisitorContext?.()]); } /** - * Send the combined webframe state once visitor context has been resolved. + * Send the combined webframe state once client-only contexts have been resolved. */ async function sendWebframeState(args: { elementData: ContentKitWebFrame['data']; @@ -236,14 +249,16 @@ async function sendWebframeState(args: { }) { const { elementData, rendererState, clientContext, sendMessage, abort } = args; const state = resolveWebframeState(elementData, rendererState); - const visitorContext = await resolveVisitorContext(clientContext); + const clientContexts = await resolveClientContexts(clientContext); if (abort.cancelled) { return; } - if (typeof visitorContext !== 'undefined') { - Object.assign(state, visitorContext); + for (const context of clientContexts) { + if (context) { + Object.assign(state, context); + } } if (Object.keys(state).length > 0) { diff --git a/packages/react-contentkit/src/context.ts b/packages/react-contentkit/src/context.ts index c1dadf5dc..fcff8f10a 100644 --- a/packages/react-contentkit/src/context.ts +++ b/packages/react-contentkit/src/context.ts @@ -18,11 +18,22 @@ export type ContentKitRenderUpdate = Partial< >; export type ContentKitClientContextData = { + /** + * Client-only visitor claims, merged into the webframe state. + * Gated by the integration's visitor-claims scope. + */ getVisitorContext?: () => | Record | null | undefined | Promise | null | undefined>; + + /** + * Navigate the host page to another page, in response to a webframe `@webframe.navigate` + * action. The destination is addressed by `path` (resolved against the site base path); the + * host restricts navigation to destinations within the current site. + */ + navigate?: (target: { path: string; anchor?: string }) => void; }; export interface ContentKitClientContextType {