diff --git a/.changeset/webframe-page-context.md b/.changeset/webframe-page-context.md new file mode 100644 index 000000000..041df33bc --- /dev/null +++ b/.changeset/webframe-page-context.md @@ -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. diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts index 8e4d60707..8ccdcda75 100644 --- a/packages/gitbook/e2e/customers.spec.ts +++ b/packages/gitbook/e2e/customers.spec.ts @@ -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', diff --git a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx index 47d6104cf..c044638d8 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/ContentKitWithClientContext.tsx @@ -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 = React.ComponentProps>; @@ -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( props: ContentKitProps & { /** 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( 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( navigateTo(linker.toPathInSite(path) + suffix); }, }), - [canAccessVisitorClaims, visitorClaims, linker, navigateTo] + [canAccessVisitorClaims, visitorClaims, page, linker, navigateTo] ); return ; diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 1443cc2c2..1d599549c 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -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 diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts index 11dbae41f..20108b89e 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.test.ts @@ -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', + }); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts index 9adeb2686..77c5732a7 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts +++ b/packages/gitbook/src/components/DocumentView/Integration/adaptive.ts @@ -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. */ diff --git a/packages/react-contentkit/src/ElementWebframe.tsx b/packages/react-contentkit/src/ElementWebframe.tsx index 9ff9f58de..0f4a3a423 100644 --- a/packages/react-contentkit/src/ElementWebframe.tsx +++ b/packages/react-contentkit/src/ElementWebframe.tsx @@ -159,7 +159,7 @@ export function ElementWebframe(props: ContentKitClientElementProps { 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?.(), + ]); } /** diff --git a/packages/react-contentkit/src/context.ts b/packages/react-contentkit/src/context.ts index fcff8f10a..c50a1abd6 100644 --- a/packages/react-contentkit/src/context.ts +++ b/packages/react-contentkit/src/context.ts @@ -17,6 +17,16 @@ export type ContentKitRenderUpdate = Partial< Pick >; +/** + * 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 | 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