mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +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 type { BlockProps } from '../Block';
|
||||||
import './contentkit.css';
|
import './contentkit.css';
|
||||||
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
|
import type { GitBookLinker } from '@/lib/links';
|
||||||
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
|
import {
|
||||||
|
ContentKitWithClientContext,
|
||||||
|
type WebframeLinkerData,
|
||||||
|
} from './ContentKitWithClientContext';
|
||||||
|
import { integrationBlockContainsWebframe } from './adaptive';
|
||||||
import { contentKitServerContext } from './contentkit';
|
import { contentKitServerContext } from './contentkit';
|
||||||
import { fetchSafeIntegrationUI } from './render';
|
import { fetchSafeIntegrationUI } from './render';
|
||||||
import { renderIntegrationUi } from './server-actions';
|
import { renderIntegrationUi } from './server-actions';
|
||||||
@@ -70,34 +74,70 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
|
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||||
initialOutput
|
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||||
)
|
|
||||||
? ContentKitWithAdaptiveVisitorContext
|
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||||
: ContentKit;
|
// 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 (
|
return (
|
||||||
<div className={tcls(style)}>
|
<div className={tcls(style)}>
|
||||||
<ContentKitComponent
|
{useClientContext ? (
|
||||||
renderContext={{
|
<ContentKitWithClientContext
|
||||||
integrationName: block.data.integration,
|
{...contentKitProps}
|
||||||
}}
|
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||||
security={{
|
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||||
// Trust both the integrations host and the (cookieless) content host that
|
>
|
||||||
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
|
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||||
// postMessage on this list, so a WebFrame served from the content host would
|
</ContentKitWithClientContext>
|
||||||
// break (no resize/ready/actions) if the content host weren't trusted.
|
) : (
|
||||||
// The hosts are identical until a distinct content origin is configured.
|
<ContentKit {...contentKitProps}>
|
||||||
firstPartyDomains: [
|
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||||
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
|
</ContentKit>
|
||||||
],
|
)}
|
||||||
}}
|
|
||||||
initialInput={initialInput}
|
|
||||||
initialOutput={initialOutput}
|
|
||||||
render={renderIntegrationUi}
|
|
||||||
>
|
|
||||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
|
||||||
</ContentKitComponent>
|
|
||||||
</div>
|
</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;
|
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(
|
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||||
output: ContentKitRenderOutput
|
|
||||||
) {
|
|
||||||
if (output.type === 'complete') {
|
if (output.type === 'complete') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return doesContentKitElementContainWebframe(output.element);
|
||||||
output.canAccessVisitorClaims === true &&
|
|
||||||
doesContentKitElementContainWebframe(output.element)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
|||||||
})(),
|
})(),
|
||||||
}));
|
}));
|
||||||
break;
|
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:
|
default:
|
||||||
renderer.update({
|
renderer.update({
|
||||||
action: message.action,
|
action: message.action,
|
||||||
@@ -146,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
|
|||||||
};
|
};
|
||||||
}, [renderer, sendMessage]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
const abort = { cancelled: false };
|
const abort = { cancelled: false };
|
||||||
sendWebframeState({
|
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) {
|
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
|
||||||
return await clientContext?.getVisitorContext?.();
|
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: {
|
async function sendWebframeState(args: {
|
||||||
elementData: ContentKitWebFrame['data'];
|
elementData: ContentKitWebFrame['data'];
|
||||||
@@ -236,14 +249,16 @@ async function sendWebframeState(args: {
|
|||||||
}) {
|
}) {
|
||||||
const { elementData, rendererState, clientContext, sendMessage, abort } = args;
|
const { elementData, rendererState, clientContext, sendMessage, abort } = args;
|
||||||
const state = resolveWebframeState(elementData, rendererState);
|
const state = resolveWebframeState(elementData, rendererState);
|
||||||
const visitorContext = await resolveVisitorContext(clientContext);
|
const clientContexts = await resolveClientContexts(clientContext);
|
||||||
|
|
||||||
if (abort.cancelled) {
|
if (abort.cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof visitorContext !== 'undefined') {
|
for (const context of clientContexts) {
|
||||||
Object.assign(state, visitorContext);
|
if (context) {
|
||||||
|
Object.assign(state, context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(state).length > 0) {
|
if (Object.keys(state).length > 0) {
|
||||||
|
|||||||
@@ -18,11 +18,22 @@ export type ContentKitRenderUpdate = Partial<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export type ContentKitClientContextData = {
|
export type ContentKitClientContextData = {
|
||||||
|
/**
|
||||||
|
* Client-only visitor claims, merged into the webframe state.
|
||||||
|
* Gated by the integration's visitor-claims scope.
|
||||||
|
*/
|
||||||
getVisitorContext?: () =>
|
getVisitorContext?: () =>
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| null
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
| Promise<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 {
|
export interface ContentKitClientContextType {
|
||||||
|
|||||||
Reference in New Issue
Block a user