diff --git a/.changeset/fruity-queens-jog.md b/.changeset/fruity-queens-jog.md new file mode 100644 index 000000000..37b640671 --- /dev/null +++ b/.changeset/fruity-queens-jog.md @@ -0,0 +1,6 @@ +--- +"gitbook": patch +"@gitbook/embed": patch +--- + +Docs Embed: Better support light/dark mode overrides diff --git a/packages/embed/README.md b/packages/embed/README.md index 096546c05..7c3385b88 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -72,6 +72,7 @@ const gitbook = createGitBook({ // Create an iframe and get its URL const iframe = document.createElement('iframe'); iframe.src = gitbook.getFrameURL({ + colorScheme: 'dark', // Optional: force the embed to render in dark mode visitor: { token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access unsignedClaims: { // Optional: custom claims for dynamic expressions @@ -122,6 +123,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react'; ` + +- **Type**: `'light' | 'dark'` + +```javascript +colorScheme: 'dark' +``` + ### `button` Available in: Standalone script only diff --git a/packages/embed/src/client/createGitBook.test.ts b/packages/embed/src/client/createGitBook.test.ts index 3ddcd660e..54e3a36ca 100644 --- a/packages/embed/src/client/createGitBook.test.ts +++ b/packages/embed/src/client/createGitBook.test.ts @@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => { expect(url.searchParams.get('visitor.count')).toBe('3'); expect(url.searchParams.get('visitor.enabled')).toBe('false'); }); + + it('adds an explicit color scheme override when requested', () => { + const client = createGitBook({ siteURL: 'https://example.com/docs/' }); + + const url = new URL( + client.getFrameURL({ + colorScheme: 'dark', + }) + ); + + expect(url.pathname).toBe('/docs/~gitbook/embed'); + expect(url.searchParams.get('theme')).toBe('dark'); + }); }); diff --git a/packages/embed/src/client/createGitBook.ts b/packages/embed/src/client/createGitBook.ts index 1f12277e9..89d094b1c 100644 --- a/packages/embed/src/client/createGitBook.ts +++ b/packages/embed/src/client/createGitBook.ts @@ -8,6 +8,12 @@ export type CreateGitBookOptions = { }; export type GetFrameURLOptions = { + /** + * Override the color scheme used by the embedded docs. + * When omitted, the embed follows the iframe's CSS `color-scheme`. + */ + colorScheme?: 'light' | 'dark'; + /** * Authentication to use for the frame. */ @@ -42,6 +48,10 @@ export function createGitBook(options: CreateGitBookOptions) { const url = new URL(options.siteURL); url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`; + if (frameOptions.colorScheme) { + url.searchParams.set('theme', frameOptions.colorScheme); + } + if (frameOptions.visitor?.token) { url.searchParams.set('jwt_token', frameOptions.visitor.token); } diff --git a/packages/embed/src/react/GitBookFrame.tsx b/packages/embed/src/react/GitBookFrame.tsx index 28bee3449..135fea58d 100644 --- a/packages/embed/src/react/GitBookFrame.tsx +++ b/packages/embed/src/react/GitBookFrame.tsx @@ -19,6 +19,7 @@ export type GitBookFrameProps = { export function GitBookFrame(props: GitBookFrameProps) { const { className, + colorScheme, visitor, actions = [], greeting, @@ -34,7 +35,10 @@ export function GitBookFrame(props: GitBookFrameProps) { const gitbook = useGitBook(); const [gitbookFrame, setGitbookFrame] = useState(null); - const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); + const frameURL = useMemo( + () => gitbook.getFrameURL({ visitor, colorScheme }), + [gitbook, visitor, colorScheme] + ); useEffect(() => { if (frameRef.current) { @@ -73,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) { width="100%" height="100%" className={className} + style={colorScheme ? { colorScheme } : undefined} /> ); } diff --git a/packages/embed/src/standalone/index.ts b/packages/embed/src/standalone/index.ts index 3751984b7..609dfc71d 100644 --- a/packages/embed/src/standalone/index.ts +++ b/packages/embed/src/standalone/index.ts @@ -101,6 +101,9 @@ function getIframe() { widgetIframe?.remove(); widgetIframe = document.createElement('iframe'); widgetIframe.id = 'gitbook-widget-iframe'; + if (frameOptions?.colorScheme) { + widgetIframe.style.colorScheme = frameOptions.colorScheme; + } widgetIframe.src = client.getFrameURL({ ...frameOptions, }); diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/layout.tsx index 36a75310a..349844fd5 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/layout.tsx +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/embed/layout.tsx @@ -5,6 +5,7 @@ import { generateEmbeddableViewport, } from '@/components/Embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable'; +import { getThemeFromMiddleware } from '@/lib/middleware'; import { shouldTrackEvents } from '@/lib/tracking'; import { headers } from 'next/headers'; @@ -18,12 +19,14 @@ export default async function RootLayout({ }: React.PropsWithChildren) { const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params); const withTracking = shouldTrackEvents(await headers()); + const forcedTheme = await getThemeFromMiddleware(); return ( {children} diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx index c8596b185..13656d28d 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx @@ -6,6 +6,8 @@ import { } from '@/components/SiteLayout'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import type { GitBookSiteContext } from '@/lib/context'; +import { resolveEmbeddableTheme } from '@/lib/embeddable'; +import type { CustomizationDefaultThemeMode } from '@gitbook/api'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import { SpaceLayoutServerContext } from '../SpaceLayout'; import { Trademark } from '../TableOfContents/Trademark'; @@ -18,6 +20,7 @@ type EmbeddableRootLayoutProps = { context: GitBookSiteContext; withTracking: boolean; visitorAuthClaims: VisitorAuthClaims; + forcedTheme?: CustomizationDefaultThemeMode | null; }; /** @@ -27,17 +30,22 @@ export async function EmbeddableRootLayout({ context, withTracking, visitorAuthClaims, + forcedTheme, children, }: React.PropsWithChildren) { + const theme = resolveEmbeddableTheme(context.customization, forcedTheme); + return ( - + diff --git a/packages/gitbook/src/lib/embeddable.test.ts b/packages/gitbook/src/lib/embeddable.test.ts index 740bd9cb6..1ea733c86 100644 --- a/packages/gitbook/src/lib/embeddable.test.ts +++ b/packages/gitbook/src/lib/embeddable.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { getEmbeddableLinker } from './embeddable'; +import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api'; +import { getEmbeddableLinker, resolveEmbeddableTheme } from './embeddable'; import { createLinker } from './links'; describe('getEmbeddableLinker', () => { @@ -21,3 +22,58 @@ describe('getEmbeddableLinker', () => { ); }); }); + +describe('resolveEmbeddableTheme', () => { + function createCustomization( + themes: SiteCustomizationSettings['themes'] + ): Pick { + return { themes }; + } + + it('follows the frame color scheme for multi-theme sites by default', () => { + expect( + resolveEmbeddableTheme( + createCustomization({ + toggeable: true, + default: CustomizationDefaultThemeMode.Dark, + }) + ) + ).toEqual({ + htmlTheme: CustomizationDefaultThemeMode.System, + defaultTheme: CustomizationDefaultThemeMode.System, + forcedTheme: undefined, + }); + }); + + it('accepts an explicit override for multi-theme sites', () => { + expect( + resolveEmbeddableTheme( + createCustomization({ + toggeable: true, + default: CustomizationDefaultThemeMode.Light, + }), + CustomizationDefaultThemeMode.Dark + ) + ).toEqual({ + htmlTheme: CustomizationDefaultThemeMode.Dark, + defaultTheme: CustomizationDefaultThemeMode.Dark, + forcedTheme: CustomizationDefaultThemeMode.Dark, + }); + }); + + it('keeps the site theme for single-theme sites', () => { + expect( + resolveEmbeddableTheme( + createCustomization({ + toggeable: false, + default: CustomizationDefaultThemeMode.Light, + }), + CustomizationDefaultThemeMode.Dark + ) + ).toEqual({ + htmlTheme: CustomizationDefaultThemeMode.Light, + defaultTheme: CustomizationDefaultThemeMode.Light, + forcedTheme: CustomizationDefaultThemeMode.Light, + }); + }); +}); diff --git a/packages/gitbook/src/lib/embeddable.ts b/packages/gitbook/src/lib/embeddable.ts index 84d743544..bd4ee8a4c 100644 --- a/packages/gitbook/src/lib/embeddable.ts +++ b/packages/gitbook/src/lib/embeddable.ts @@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookLinker } from '@/lib/links'; import { getPagePath } from '@/lib/pages'; import { joinPath } from '@/lib/paths'; +import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api'; /** * Get the context for the embeddable static routes. @@ -68,3 +69,35 @@ export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker { }, }; } + +/** + * Resolve theme behavior for docs embeds. + * Embeds should follow the parent frame's color-scheme by default, + * while still allowing an explicit override for multi-theme sites. + */ +export function resolveEmbeddableTheme( + customization: Pick, + forcedTheme?: CustomizationDefaultThemeMode | null +) { + if (!customization.themes.toggeable) { + return { + htmlTheme: customization.themes.default, + defaultTheme: customization.themes.default, + forcedTheme: customization.themes.default, + }; + } + + if (forcedTheme) { + return { + htmlTheme: forcedTheme, + defaultTheme: forcedTheme, + forcedTheme, + }; + } + + return { + htmlTheme: CustomizationDefaultThemeMode.System, + defaultTheme: CustomizationDefaultThemeMode.System, + forcedTheme: undefined, + }; +}