mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 16:15:22 +00:00
Docs Embed: Better support light/dark mode overrides (#4181)
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
"@gitbook/embed": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Docs Embed: Better support light/dark mode overrides
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
+3
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user