diff --git a/bun.lock b/bun.lock index 2f8612e21d..df0b146b0b 100644 --- a/bun.lock +++ b/bun.lock @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.182.0", + "@gitbook/api": "0.183.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.182.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-O5CgVzbRL2esrsQJ816UcHMqY22fsMFAeLRQ8Gapus4S+/teXk3nf5YU5T10o3112niSD3QeNp2uIcsyMGOweg=="], + "@gitbook/api": ["@gitbook/api@0.183.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-0+6VyRH7me5AtzU+mwZVFHDaJudtnHYEcxwQ/qO7p2swj7dOe/dOevfaphgx+pUvhXAg7VVlihETaAW/WsLTzQ=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 941d91b2c0..043afbe96f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.182.0", + "@gitbook/api": "0.183.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx new file mode 100644 index 0000000000..3d174632df --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithAdaptiveVisitorContext.tsx @@ -0,0 +1,28 @@ +'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/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 4befa2953e..6286bbce7e 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -5,6 +5,8 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit'; import type { BlockProps } from '../Block'; import './contentkit.css'; +import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext'; +import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive'; import { contentKitServerContext } from './contentkit'; import { fetchSafeIntegrationUI } from './render'; import { renderIntegrationUi } from './server-actions'; @@ -68,9 +70,15 @@ export async function IntegrationBlock(props: BlockProps - - + ); } diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts new file mode 100644 index 0000000000..162f0f631d --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -0,0 +1,67 @@ +import type { + ContentKitDescendantElement, + ContentKitRenderOutput, + ContentKitRootElement, + ContentKitStepper, +} from '@gitbook/api'; + +type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper; + +/** + * Decide whether an integration block should expose Adaptive visitor context to webframes. + */ +export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext( + output: ContentKitRenderOutput +) { + if (output.type === 'complete') { + return false; + } + + return ( + output.canAccessVisitorClaims === true && + doesContentKitElementContainWebframe(output.element) + ); +} + +/** + * Check whether a ContentKit element tree contains a webframe element. + */ +function doesContentKitElementContainWebframe(element: ContentKitElement): boolean { + switch (element.type) { + case 'webframe': + return true; + case 'block': + case 'box': + case 'hstack': + case 'vstack': + case 'step': + case 'modal': + case 'configuration': + case 'stepper': + case 'card': + return doesContentKitElementArrayContainWebframe(element.children); + case 'codeblock': + return ( + doesContentKitElementArrayContainWebframe(element.header) || + doesContentKitElementArrayContainWebframe(element.footer) + ); + default: + return false; + } +} + +function doesContentKitElementArrayContainWebframe(elements: unknown): boolean { + if (!Array.isArray(elements)) { + return doesContentKitElementContainWebframeValue(elements); + } + + return elements.some(doesContentKitElementContainWebframeValue); +} + +function doesContentKitElementContainWebframeValue(value: unknown): boolean { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false; + } + + return doesContentKitElementContainWebframe(value as ContentKitElement); +} diff --git a/packages/react-contentkit/package.json b/packages/react-contentkit/package.json index d16e6d6362..c74eb4e0c3 100644 --- a/packages/react-contentkit/package.json +++ b/packages/react-contentkit/package.json @@ -6,6 +6,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "default": "./dist/client.js" } }, "sideEffects": false, diff --git a/packages/react-contentkit/src/ContentKit.tsx b/packages/react-contentkit/src/ContentKit.tsx index 4f413f367a..985e8f20db 100644 --- a/packages/react-contentkit/src/ContentKit.tsx +++ b/packages/react-contentkit/src/ContentKit.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ContentKitClientContext, + type ContentKitClientContextData, type ContentKitClientContextType, type ContentKitRenderUpdate, type ContentKitSecurity, @@ -28,6 +29,8 @@ export function ContentKit(props: { renderContext: RenderContext; /** Security configuration */ security: ContentKitSecurity; + /** Client-only contextual data for client-rendered elements */ + clientContext?: ContentKitClientContextData; /** Initial input being displayed */ initialInput: RequestRenderIntegrationUI; /** Initial output being displayed */ @@ -58,6 +61,7 @@ export function ContentKit(props: { const { renderContext, security, + clientContext, initialInput, initialOutput, children: initialChildren, @@ -125,6 +129,7 @@ export function ContentKit(props: { const renderer = React.useMemo(() => { return { security, + clientContext, state: current.state, setState: (newState) => { setCurrent((latest) => ({ @@ -184,7 +189,7 @@ export function ContentKit(props: { } }, }; - }, [update, security, current.state, current.input.context, setCurrent, render]); + }, [update, security, clientContext, current.state, current.input.context, setCurrent, render]); const onSubViewAction = React.useCallback(async (action: ContentKitAction) => { switch (action.action) { @@ -208,6 +213,7 @@ export function ContentKit(props: { { - if (!element.data) { - return; - } - - const state: Record = {}; - Object.entries(element.data).forEach(([key, value]) => { - state[key] = resolveDynamicBinding(renderer.state, value); + const abort = { cancelled: false }; + sendWebframeState({ + elementData: element.data, + rendererState: renderer.state, + clientContext: renderer.clientContext, + sendMessage, + abort, }); - return sendMessage({ state }); - }, [element.data, renderer.state, sendMessage]); + return () => { + abort.cancelled = true; + }; + }, [element.data, renderer.state, renderer.clientContext, sendMessage]); const height = size.height ? Math.max(size.height, MIN_HEIGHT) : undefined; @@ -192,3 +194,59 @@ export function ElementWebframe(props: ContentKitClientElementProps ); } + +type WebframeState = Record; + +/** + * Resolve configured webframe data bindings against the current ContentKit state. + */ +function resolveWebframeState( + elementData: ContentKitWebFrame['data'], + rendererState: object +): WebframeState { + const state: WebframeState = {}; + + if (!elementData) { + return state; + } + + Object.entries(elementData).forEach(([key, value]) => { + state[key] = resolveDynamicBinding(rendererState, value); + }); + + return state; +} + +/** + * Read optional client-only visitor context. + */ +async function resolveVisitorContext(clientContext: ContentKitClientContextData | undefined) { + return await clientContext?.getVisitorContext?.(); +} + +/** + * Send the combined webframe state once visitor context has been resolved. + */ +async function sendWebframeState(args: { + elementData: ContentKitWebFrame['data']; + rendererState: object; + clientContext: ContentKitClientContextData | undefined; + sendMessage: (message: object) => void; + abort: { cancelled: boolean }; +}) { + const { elementData, rendererState, clientContext, sendMessage, abort } = args; + const state = resolveWebframeState(elementData, rendererState); + const visitorContext = await resolveVisitorContext(clientContext); + + if (abort.cancelled) { + return; + } + + if (typeof visitorContext !== 'undefined') { + Object.assign(state, visitorContext); + } + + if (Object.keys(state).length > 0) { + sendMessage({ state }); + } +} diff --git a/packages/react-contentkit/src/client.ts b/packages/react-contentkit/src/client.ts new file mode 100644 index 0000000000..a68102eddd --- /dev/null +++ b/packages/react-contentkit/src/client.ts @@ -0,0 +1,5 @@ +'use client'; + +// Client-safe entrypoint: avoid pulling server rendering exports into Client Components. +export { ContentKit } from './ContentKit'; +export type { ContentKitClientContextData } from './context'; diff --git a/packages/react-contentkit/src/context.ts b/packages/react-contentkit/src/context.ts index ee28808b8b..c1dadf5dc5 100644 --- a/packages/react-contentkit/src/context.ts +++ b/packages/react-contentkit/src/context.ts @@ -17,9 +17,23 @@ export type ContentKitRenderUpdate = Partial< Pick >; +export type ContentKitClientContextData = { + getVisitorContext?: () => + | Record + | null + | undefined + | Promise | null | undefined>; +}; + export interface ContentKitClientContextType { security: ContentKitSecurity; + /** + * Client-only contextual data for client-rendered elements such as webframes. + * This data must not be included in integration render requests. + */ + clientContext?: ContentKitClientContextData; + /** * Current value of the state. */ diff --git a/packages/react-contentkit/src/index.ts b/packages/react-contentkit/src/index.ts index b9bb05faf8..b5844cffc3 100644 --- a/packages/react-contentkit/src/index.ts +++ b/packages/react-contentkit/src/index.ts @@ -1,3 +1,4 @@ export * from './ContentKit'; export * from './ContentKitOutput'; +export type { ContentKitClientContextData } from './context'; export type { ContentKitServerContext } from './types'; diff --git a/packages/react-contentkit/tsdown.config.ts b/packages/react-contentkit/tsdown.config.ts index b9cb6f83a2..db2fcfafcb 100644 --- a/packages/react-contentkit/tsdown.config.ts +++ b/packages/react-contentkit/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig([ { - entry: 'src/index.ts', + entry: ['src/index.ts', 'src/client.ts'], unbundle: true, }, ]);