Docs Embed: Better support light/dark mode overrides (#4181)

This commit is contained in:
Zeno Kapitein
2026-04-14 10:50:05 +02:00
committed by GitHub
parent 68c842bd4c
commit 8d2a95b168
11 changed files with 180 additions and 14 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Docs Embed: Better support light/dark mode overrides
+23 -3
View File
@@ -72,6 +72,7 @@ const gitbook = createGitBook({
// Create an iframe and get its URL // Create an iframe and get its URL
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({ iframe.src = gitbook.getFrameURL({
colorScheme: 'dark', // Optional: force the embed to render in dark mode
visitor: { visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions unsignedClaims: { // Optional: custom claims for dynamic expressions
@@ -122,6 +123,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com"> <GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame <GitBookFrame
colorScheme="dark"
visitor={{ visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
@@ -150,7 +152,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() { function MyComponent() {
const gitbook = useGitBook(); const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } }); const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
// ... // ...
} }
``` ```
@@ -178,7 +180,7 @@ function MyComponent() {
### Standalone Script ### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget - `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button - `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button - `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window - `GitBook('open')` - Open widget window
@@ -195,7 +197,7 @@ function MyComponent() {
**Client Factory:** **Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient` - `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string` - `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient` - `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:** **Frame Client:**
@@ -457,6 +459,24 @@ visitor: {
} }
``` ```
### `colorScheme`
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```
### `button` ### `button`
Available in: Standalone script only Available in: Standalone script only
@@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => {
expect(url.searchParams.get('visitor.count')).toBe('3'); expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false'); 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');
});
}); });
@@ -8,6 +8,12 @@ export type CreateGitBookOptions = {
}; };
export type GetFrameURLOptions = { 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. * Authentication to use for the frame.
*/ */
@@ -42,6 +48,10 @@ export function createGitBook(options: CreateGitBookOptions) {
const url = new URL(options.siteURL); const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`; url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.colorScheme) {
url.searchParams.set('theme', frameOptions.colorScheme);
}
if (frameOptions.visitor?.token) { if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token); url.searchParams.set('jwt_token', frameOptions.visitor.token);
} }
+6 -1
View File
@@ -19,6 +19,7 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { const {
className, className,
colorScheme,
visitor, visitor,
actions = [], actions = [],
greeting, greeting,
@@ -34,7 +35,10 @@ export function GitBookFrame(props: GitBookFrameProps) {
const gitbook = useGitBook(); const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null); const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
useEffect(() => { useEffect(() => {
if (frameRef.current) { if (frameRef.current) {
@@ -73,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%" width="100%"
height="100%" height="100%"
className={className} className={className}
style={colorScheme ? { colorScheme } : undefined}
/> />
); );
} }
+3
View File
@@ -101,6 +101,9 @@ function getIframe() {
widgetIframe?.remove(); widgetIframe?.remove();
widgetIframe = document.createElement('iframe'); widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe'; widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({ widgetIframe.src = client.getFrameURL({
...frameOptions, ...frameOptions,
}); });
@@ -5,6 +5,7 @@ import {
generateEmbeddableViewport, generateEmbeddableViewport,
} from '@/components/Embeddable'; } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking'; import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
@@ -18,12 +19,14 @@ export default async function RootLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) { }: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params); const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers()); const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
return ( return (
<EmbeddableRootLayout <EmbeddableRootLayout
context={context} context={context}
withTracking={withTracking} withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims} visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
> >
{children} {children}
</EmbeddableRootLayout> </EmbeddableRootLayout>
@@ -6,6 +6,8 @@ import {
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive'; import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { resolveEmbeddableTheme } from '@/lib/embeddable';
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout'; import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark'; import { Trademark } from '../TableOfContents/Trademark';
@@ -18,6 +20,7 @@ type EmbeddableRootLayoutProps = {
context: GitBookSiteContext; context: GitBookSiteContext;
withTracking: boolean; withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims; visitorAuthClaims: VisitorAuthClaims;
forcedTheme?: CustomizationDefaultThemeMode | null;
}; };
/** /**
@@ -27,17 +30,22 @@ export async function EmbeddableRootLayout({
context, context,
withTracking, withTracking,
visitorAuthClaims, visitorAuthClaims,
forcedTheme,
children, children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) { }: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
const theme = resolveEmbeddableTheme(context.customization, forcedTheme);
return ( return (
<CustomizationRootLayout context={context} htmlClassName="embed"> <CustomizationRootLayout
context={context}
htmlClassName="embed"
forcedTheme={theme.htmlTheme}
>
<SiteLayoutClientContexts <SiteLayoutClientContexts
forcedTheme={ forcedTheme={theme.forcedTheme}
context.customization.themes.toggeable defaultTheme={theme.defaultTheme}
? undefined // Keep embed theme separate from site so it does not reuse the full site's saved theme and vice versa.
: context.customization.themes.default themeStorageKey={`gitbook-theme-embed:${context.site.id}`}
}
defaultTheme={context.customization.themes.default}
externalLinksTarget={context.customization.externalLinks.target} externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId} contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin} proxyOrigin={context.site.proxy?.origin}
@@ -17,13 +17,21 @@ import { isExternalLink } from '../utils/link';
export function SiteLayoutClientContexts(props: { export function SiteLayoutClientContexts(props: {
forcedTheme: CustomizationDefaultThemeMode | undefined; forcedTheme: CustomizationDefaultThemeMode | undefined;
defaultTheme: CustomizationDefaultThemeMode | undefined; defaultTheme: CustomizationDefaultThemeMode | undefined;
themeStorageKey?: string;
externalLinksTarget: SiteExternalLinksTarget; externalLinksTarget: SiteExternalLinksTarget;
contextId: string | undefined; contextId: string | undefined;
proxyOrigin: string | undefined; proxyOrigin: string | undefined;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { children, forcedTheme, defaultTheme, externalLinksTarget, contextId, proxyOrigin } = const {
props; children,
forcedTheme,
defaultTheme,
themeStorageKey,
externalLinksTarget,
contextId,
proxyOrigin,
} = props;
useClearRouterCache(contextId); useClearRouterCache(contextId);
@@ -51,6 +59,7 @@ export function SiteLayoutClientContexts(props: {
enableSystem enableSystem
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
defaultTheme={defaultTheme} defaultTheme={defaultTheme}
storageKey={themeStorageKey}
> >
<NuqsAdapter> <NuqsAdapter>
<LinkContext.Provider value={linkContext}> <LinkContext.Provider value={linkContext}>
+57 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test'; 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'; import { createLinker } from './links';
describe('getEmbeddableLinker', () => { describe('getEmbeddableLinker', () => {
@@ -21,3 +22,58 @@ describe('getEmbeddableLinker', () => {
); );
}); });
}); });
describe('resolveEmbeddableTheme', () => {
function createCustomization(
themes: SiteCustomizationSettings['themes']
): Pick<SiteCustomizationSettings, 'themes'> {
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,
});
});
});
+33
View File
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookLinker } from '@/lib/links'; import type { GitBookLinker } from '@/lib/links';
import { getPagePath } from '@/lib/pages'; import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths'; import { joinPath } from '@/lib/paths';
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
/** /**
* Get the context for the embeddable static routes. * 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<SiteCustomizationSettings, 'themes'>,
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,
};
}