Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,9 +70,15 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}

const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
initialOutput
)
? ContentKitWithAdaptiveVisitorContext
: ContentKit;

return (
<div className={tcls(style)}>
<ContentKit
<ContentKitComponent
renderContext={{
integrationName: block.data.integration,
}}
Expand All @@ -80,7 +88,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
render={renderIntegrationUi}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
</ContentKitComponent>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions packages/react-contentkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/react-contentkit/src/ContentKit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import React from 'react';

import {
ContentKitClientContext,
type ContentKitClientContextData,
type ContentKitClientContextType,
type ContentKitRenderUpdate,
type ContentKitSecurity,
Expand All @@ -28,6 +29,8 @@ export function ContentKit<RenderContext>(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 */
Expand Down Expand Up @@ -58,6 +61,7 @@ export function ContentKit<RenderContext>(props: {
const {
renderContext,
security,
clientContext,
initialInput,
initialOutput,
children: initialChildren,
Expand Down Expand Up @@ -125,6 +129,7 @@ export function ContentKit<RenderContext>(props: {
const renderer = React.useMemo<ContentKitClientContextType>(() => {
return {
security,
clientContext,
state: current.state,
setState: (newState) => {
setCurrent((latest) => ({
Expand Down Expand Up @@ -184,7 +189,7 @@ export function ContentKit<RenderContext>(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) {
Expand All @@ -208,6 +213,7 @@ export function ContentKit<RenderContext>(props: {
<ContentKit
renderContext={renderContext}
security={security}
clientContext={clientContext}
initialInput={subView.initialInput}
initialOutput={subView.initialOutput}
render={render}
Expand Down
82 changes: 70 additions & 12 deletions packages/react-contentkit/src/ElementWebframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ContentKitWebFrame } from '@gitbook/api';
import React from 'react';

import { Icon } from '@gitbook/icons';
import { useContentKitClientContext } from './context';
import { type ContentKitClientContextData, useContentKitClientContext } from './context';
import { resolveDynamicBinding } from './dynamic';
import type { ContentKitClientElementProps } from './types';

Expand Down Expand Up @@ -47,7 +47,7 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
messagesQueueRef.current.push(message);
}
},
[renderer.security]
[element.source.url, renderer.security]
);

// Listen to messages coming from the webframe
Expand Down Expand Up @@ -146,19 +146,21 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
};
}, [renderer, sendMessage]);

// Send data to the webframe
// Send data and client-only visitor context as state to the webframe.
React.useEffect(() => {
if (!element.data) {
return;
}

const state: Record<string, string> = {};
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;

Expand Down Expand Up @@ -192,3 +194,59 @@ export function ElementWebframe(props: ContentKitClientElementProps<ContentKitWe
/>
);
}

type WebframeState = Record<string, unknown>;

/**
* 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 });
}
}
5 changes: 5 additions & 0 deletions packages/react-contentkit/src/client.ts
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions packages/react-contentkit/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,23 @@ export type ContentKitRenderUpdate = Partial<
Pick<RequestRenderIntegrationUI, 'action' | 'props' | 'state'>
>;

export type ContentKitClientContextData = {
getVisitorContext?: () =>
| Record<string, unknown>
| null
| undefined
| Promise<Record<string, unknown> | 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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/react-contentkit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './ContentKit';
export * from './ContentKitOutput';
export type { ContentKitClientContextData } from './context';
export type { ContentKitServerContext } from './types';
2 changes: 1 addition & 1 deletion packages/react-contentkit/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown';

export default defineConfig([
{
entry: 'src/index.ts',
entry: ['src/index.ts', 'src/client.ts'],
unbundle: true,
},
]);
Loading