mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +00:00
Add a navigate action to integration block webframes (#4362)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
-28
@@ -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<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that need client-only adaptive context.
|
||||
*/
|
||||
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext>
|
||||
) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
|
||||
const clientContext = React.useMemo<ContentKitClientContextData>(
|
||||
() => ({
|
||||
getVisitorContext: () => ({
|
||||
visitor: visitorClaims?.visitor ?? null,
|
||||
}),
|
||||
}),
|
||||
[visitorClaims]
|
||||
);
|
||||
|
||||
return <ContentKit {...props} clientContext={clientContext} />;
|
||||
}
|
||||
+70
@@ -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<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
/** Serializable inputs to rebuild the tested linker on the client (functions can't cross the RSC boundary). */
|
||||
export type WebframeLinkerData = Pick<
|
||||
Parameters<typeof createLinker>[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<RenderContext>(
|
||||
props: ContentKitProps<RenderContext> & {
|
||||
/** 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<GitBookLinker>(() => 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<ContentKitClientContextData>(
|
||||
() => ({
|
||||
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 <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
}
|
||||
@@ -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<DocumentBlockIntegratio
|
||||
return null;
|
||||
}
|
||||
|
||||
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
||||
initialOutput
|
||||
)
|
||||
? ContentKitWithAdaptiveVisitorContext
|
||||
: ContentKit;
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||
// visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
renderContext: {
|
||||
integrationName: block.data.integration,
|
||||
},
|
||||
security: {
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
},
|
||||
initialInput,
|
||||
initialOutput,
|
||||
render: renderIntegrationUi,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKitComponent
|
||||
renderContext={{
|
||||
integrationName: block.data.integration,
|
||||
}}
|
||||
security={{
|
||||
// Trust both the integrations host and the (cookieless) content host that
|
||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
||||
// postMessage on this list, so a WebFrame served from the content host would
|
||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
||||
// The hosts are identical until a distinct content origin is configured.
|
||||
firstPartyDomains: [
|
||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
||||
],
|
||||
}}
|
||||
initialInput={initialInput}
|
||||
initialOutput={initialOutput}
|
||||
render={renderIntegrationUi}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitComponent>
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKitWithClientContext>
|
||||
) : (
|
||||
<ContentKit {...contentKitProps}>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
</ContentKit>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,6 +127,19 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
})(),
|
||||
}));
|
||||
break;
|
||||
case '@webframe.navigate':
|
||||
// Let the host navigate to another page. The destination is addressed by
|
||||
// `path`; the host resolves it within the current site and gates it.
|
||||
if (typeof message.action.path === 'string') {
|
||||
renderer.clientContext?.navigate?.({
|
||||
path: message.action.path,
|
||||
anchor:
|
||||
typeof message.action.anchor === 'string'
|
||||
? message.action.anchor
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
renderer.update({
|
||||
action: message.action,
|
||||
@@ -146,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
||||
};
|
||||
}, [renderer, sendMessage]);
|
||||
|
||||
// Send data and client-only visitor context as state to the webframe.
|
||||
// Send data and client-only context (visitor claims) as state to the webframe.
|
||||
React.useEffect(() => {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
| null
|
||||
| undefined
|
||||
| Promise<Record<string, unknown> | 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 {
|
||||
|
||||
Reference in New Issue
Block a user