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',
tests: [{ name: 'Home', url: '/legal' }],
},
{
name: 'help.platipomiru.com',
contentBaseURL: 'https://help.platipomiru.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: '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 { useRouter } from 'next/navigation';
import React from 'react';
import type { WebframePageContext } from './adaptive';
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:
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
* access them).
* the current page, 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;
/** 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. */
linkerData: WebframeLinkerData;
}
) {
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props;
const router = useRouter();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
@@ -56,6 +59,7 @@ export function ContentKitWithClientContext<RenderContext>(
getVisitorContext: canAccessVisitorClaims
? () => ({ visitor: visitorClaims?.visitor ?? null })
: undefined,
getPageContext: page ? () => ({ page }) : 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).
@@ -63,7 +67,7 @@ export function ContentKitWithClientContext<RenderContext>(
navigateTo(linker.toPathInSite(path) + suffix);
},
}),
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
);
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
@@ -10,7 +10,7 @@ import {
ContentKitWithClientContext,
type WebframeLinkerData,
} from './ContentKitWithClientContext';
import { integrationBlockContainsWebframe } from './adaptive';
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
@@ -77,8 +77,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
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.
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
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 contentKitProps = {
@@ -106,6 +109,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
<ContentKitWithClientContext
{...contentKitProps}
canAccessVisitorClaims={canAccessVisitorClaims}
page={page}
linkerData={getWebframeLinkerData(context.contentContext.linker)}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test';
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 = {
type: 'webframe',
@@ -38,3 +40,47 @@ describe('integrationBlockContainsWebframe', () => {
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 {
ContentKitDescendantElement,
ContentKitRenderOutput,
@@ -7,9 +8,19 @@ import type {
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
* (navigation and/or visitor claims).
* (navigation, visitor claims and/or the current page).
*/
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
if (output.type === 'complete') {
@@ -19,6 +30,31 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput)
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.
*/
@@ -159,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
};
}, [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(() => {
const abort = { cancelled: false };
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) {
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'>
>;
/**
* 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 = {
/**
* Client-only visitor claims, merged into the webframe state.
@@ -28,6 +38,15 @@ export type ContentKitClientContextData = {
| 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`
* action. The destination is addressed by `path` (resolved against the site base path); the