Expose the current page context to integration block webframes (#4411)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Greg Bergé
2026-07-17 12:06:38 +02:00
committed by GitHub
parent bf6a7af72b
commit 6083a88845
8 changed files with 131 additions and 17 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-contentkit": patch
"gitbook": patch
---
Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
-5
View File
@@ -282,11 +282,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://vimeo.com', contentBaseURL: 'https://vimeo.com',
tests: [{ name: 'Home', url: '/legal' }], tests: [{ name: 'Home', url: '/legal' }],
}, },
{
name: 'help.platipomiru.com',
contentBaseURL: 'https://help.platipomiru.com',
tests: [{ name: 'Home', url: '/' }],
},
{ {
name: 'help.aikido.dev', name: 'help.aikido.dev',
contentBaseURL: 'https://help.aikido.dev', contentBaseURL: 'https://help.aikido.dev',
@@ -6,6 +6,7 @@ import { type GitBookLinker, createLinker } from '@/lib/links';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client'; import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React from 'react'; import React from 'react';
import type { WebframePageContext } from './adaptive';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>; type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
@@ -17,18 +18,20 @@ export type WebframeLinkerData = Pick<
/** /**
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes: * 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 * the current page, navigation to other pages, and adaptive visitor claims (only when the
* access them). * integration is allowed to access them).
*/ */
export function ContentKitWithClientContext<RenderContext>( export function ContentKitWithClientContext<RenderContext>(
props: ContentKitProps<RenderContext> & { props: ContentKitProps<RenderContext> & {
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */ /** Whether visitor claims may be exposed to the webframe (integration scope gated). */
canAccessVisitorClaims: boolean; canAccessVisitorClaims: boolean;
/** Current page to inject into the webframe, or `null` when unknown. */
page: WebframePageContext | null;
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */ /** Data to rebuild the site linker, used to resolve webframe navigation requests. */
linkerData: WebframeLinkerData; linkerData: WebframeLinkerData;
} }
) { ) {
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props; const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props;
const router = useRouter(); const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext); const { onNavigationClick } = React.useContext(NavigationStatusContext);
@@ -56,6 +59,7 @@ export function ContentKitWithClientContext<RenderContext>(
getVisitorContext: canAccessVisitorClaims getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null }) ? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined, : undefined,
getPageContext: page ? () => ({ page }) : undefined,
navigate: ({ path, anchor }) => { navigate: ({ path, anchor }) => {
// Resolve the requested path relative to the site root so a webframe can navigate // 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). // to any section or space within the site (and nowhere outside it).
@@ -63,7 +67,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + suffix); navigateTo(linker.toPathInSite(path) + suffix);
}, },
}), }),
[canAccessVisitorClaims, visitorClaims, linker, navigateTo] [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
); );
return <ContentKit {...contentKitProps} clientContext={clientContext} />; return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -10,7 +10,7 @@ import {
ContentKitWithClientContext, ContentKitWithClientContext,
type WebframeLinkerData, type WebframeLinkerData,
} from './ContentKitWithClientContext'; } from './ContentKitWithClientContext';
import { integrationBlockContainsWebframe } from './adaptive'; import { getWebframePageContext, 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';
@@ -77,8 +77,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
const containsWebframe = integrationBlockContainsWebframe(initialOutput); const containsWebframe = integrationBlockContainsWebframe(initialOutput);
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true; const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus // The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
// visitor claims when the integration is allowed them. const page = getWebframePageContext(context.contentContext);
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
// exposes the current page, plus visitor claims when the integration is allowed them.
const useClientContext = containsWebframe; const useClientContext = containsWebframe;
const contentKitProps = { const contentKitProps = {
@@ -106,6 +109,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
<ContentKitWithClientContext <ContentKitWithClientContext
{...contentKitProps} {...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims} canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)} linkerData={getWebframeLinkerData(context.contentContext.linker)}
> >
<ContentKitOutput output={initialOutput} context={contentKitServerContext} /> <ContentKitOutput output={initialOutput} context={contentKitServerContext} />
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api'; import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
import { integrationBlockContainsWebframe } from './adaptive'; import type { GitBookAnyContext } from '@/lib/context';
import { createLinker } from '@/lib/links';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
const webframe: ContentKitWebFrame = { const webframe: ContentKitWebFrame = {
type: 'webframe', type: 'webframe',
@@ -38,3 +40,47 @@ describe('integrationBlockContainsWebframe', () => {
expect(integrationBlockContainsWebframe(output)).toBe(true); expect(integrationBlockContainsWebframe(output)).toBe(true);
}); });
}); });
describe('getWebframePageContext', () => {
it('returns null when the context has no page', () => {
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toBeNull();
});
it('resolves the page path relative to the site root, including the section slug', () => {
const context = {
page: {
id: 'page-1',
path: 'guides/getting-started',
title: 'Getting started',
slug: 'getting-started',
},
// Site served at /docs, with the page's space mounted under the `api` section.
linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }),
} as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toEqual({
id: 'page-1',
path: 'api/guides/getting-started',
title: 'Getting started',
});
});
it('leaves the path unprefixed when the space is served at the site root', () => {
const context = {
page: {
id: 'page-2',
path: 'guides/getting-started',
title: 'Getting started',
slug: 'getting-started',
},
linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }),
} as unknown as GitBookAnyContext;
expect(getWebframePageContext(context)).toEqual({
id: 'page-2',
path: 'guides/getting-started',
title: 'Getting started',
});
});
});
@@ -1,3 +1,4 @@
import type { GitBookAnyContext } from '@/lib/context';
import type { import type {
ContentKitDescendantElement, ContentKitDescendantElement,
ContentKitRenderOutput, ContentKitRenderOutput,
@@ -7,9 +8,19 @@ import type {
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Current page exposed to a webframe through the client-only webframe state.
*/
export type WebframePageContext = {
id: string;
/** Path of the page relative to the site root (includes the section and variant). */
path: string;
title: string;
};
/** /**
* Whether an integration block's output contains a webframe that can consume client-only context * Whether an integration block's output contains a webframe that can consume client-only context
* (navigation and/or visitor claims). * (navigation, visitor claims and/or the current page).
*/ */
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean { export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') { if (output.type === 'complete') {
@@ -19,6 +30,31 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput)
return doesContentKitElementContainWebframe(output.element); return doesContentKitElementContainWebframe(output.element);
} }
/**
* Extract the current page to expose to a webframe, or `null` when it is unknown
* (e.g. a non-page context, or reusable content resolved from another source).
*
* The exposed `path` is resolved relative to the site root — so it carries the section and
* variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a
* path. A webframe can pass `page.path` straight back to the navigate action.
*/
export function getWebframePageContext(
contentContext: GitBookAnyContext
): WebframePageContext | null {
if (!('page' in contentContext) || !contentContext.page) {
return null;
}
const { linker } = contentContext;
const { id, path, title } = contentContext.page;
return {
id,
path: linker.toRelativePathInSite(linker.toPathInSpace(path)),
title,
};
}
/** /**
* Check whether a ContentKit element tree contains a webframe element. * Check whether a ContentKit element tree contains a webframe element.
*/ */
@@ -159,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
}; };
}, [renderer, sendMessage]); }, [renderer, sendMessage]);
// Send data and client-only context (visitor claims) as state to the webframe. // Send data and client-only context (visitor claims, current page) as state to the webframe.
React.useEffect(() => { React.useEffect(() => {
const abort = { cancelled: false }; const abort = { cancelled: false };
sendWebframeState({ sendWebframeState({
@@ -231,10 +231,14 @@ function resolveWebframeState(
} }
/** /**
* Resolve the optional client-only contexts (visitor claims) to merge into the webframe state. * Resolve the optional client-only contexts (visitor claims, current page)
* to merge into the webframe state.
*/ */
async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) { async function resolveClientContexts(clientContext: ContentKitClientContextData | undefined) {
return await Promise.all([clientContext?.getVisitorContext?.()]); return await Promise.all([
clientContext?.getVisitorContext?.(),
clientContext?.getPageContext?.(),
]);
} }
/** /**
+19
View File
@@ -17,6 +17,16 @@ export type ContentKitRenderUpdate = Partial<
Pick<RequestRenderIntegrationUI, 'action' | 'props' | 'state'> Pick<RequestRenderIntegrationUI, 'action' | 'props' | 'state'>
>; >;
/**
* The current page exposed to a webframe through the client-only webframe state.
*/
export type ContentKitWebframePage = {
id: string;
/** Path of the page relative to the site root. */
path: string;
title: string;
};
export type ContentKitClientContextData = { export type ContentKitClientContextData = {
/** /**
* Client-only visitor claims, merged into the webframe state. * Client-only visitor claims, merged into the webframe state.
@@ -28,6 +38,15 @@ export type ContentKitClientContextData = {
| undefined | undefined
| Promise<Record<string, unknown> | null | undefined>; | Promise<Record<string, unknown> | null | undefined>;
/**
* Client-only current-page context, merged into the webframe state.
*/
getPageContext?: () =>
| { page: ContentKitWebframePage }
| null
| undefined
| Promise<{ page: ContentKitWebframePage } | null | undefined>;
/** /**
* Navigate the host page to another page, in response to a webframe `@webframe.navigate` * 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 * action. The destination is addressed by `path` (resolved against the site base path); the