diff --git a/bun.lock b/bun.lock
index a01983909..c7377600d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
- "turbo": "^2.9.15",
+ "turbo": "^2.9.18",
"vercel": "50.37.3",
},
},
diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx
new file mode 100644
index 000000000..a14e64fcd
--- /dev/null
+++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx
@@ -0,0 +1,16 @@
+export const metadata = {
+ title: 'Next.js',
+ description: 'Generated by Next.js',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
{children}
+
+ )
+}
diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/page.tsx
new file mode 100644
index 000000000..4cd841675
--- /dev/null
+++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/page.tsx
@@ -0,0 +1,65 @@
+import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
+import { CustomizationRootLayout } from '@/components/RootLayout';
+import { SiteLayoutClientContexts } from '@/components/SiteLayout';
+import { StructurePreview, type StructurePreviewSnapshot } from '@/components/StructurePreview';
+import { getThemeFromMiddleware } from '@/lib/middleware';
+
+type PageProps = {
+ params: Promise;
+};
+
+export default async function Page(props: PageProps) {
+ const { context } = await getDynamicSiteContext(await props.params);
+ const forcedTheme = await getThemeFromMiddleware();
+ const snapshot: StructurePreviewSnapshot = {
+ site: {
+ id: context.site.id,
+ title: context.site.title,
+ proxyOrigin: context.site.proxy?.origin,
+ },
+ contextId: context.contextId,
+ locale: context.locale,
+ customization: context.customization,
+ structure: context.structure,
+ siteSpace: context.siteSpace,
+ siteSpaces: context.siteSpaces,
+ visibleSiteSpaces: context.visibleSiteSpaces,
+ sections: context.sections,
+ visibleSections: context.visibleSections,
+ revision: {
+ pages: context.revision.pages,
+ tags: context.revision.tags,
+ },
+ icons: {
+ large: {
+ light: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
+ dark: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/gitbook/src/components/StructurePreview/StructurePreview.tsx b/packages/gitbook/src/components/StructurePreview/StructurePreview.tsx
new file mode 100644
index 000000000..e99568ad8
--- /dev/null
+++ b/packages/gitbook/src/components/StructurePreview/StructurePreview.tsx
@@ -0,0 +1,679 @@
+'use client';
+
+import type { CustomizationContentLink, CustomizationHeaderItem, SiteSpace } from '@gitbook/api';
+import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/api';
+import { Icon, type IconName } from '@gitbook/icons';
+import * as React from 'react';
+
+import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
+import { SiteSectionTabs } from '@/components/SiteSections';
+import { PagesList } from '@/components/TableOfContents/PagesList';
+import { getLocalizedTitle } from '@/lib/sites';
+import { tcls } from '@/lib/tailwind';
+
+import { tString, useLanguage } from '@/intl/client';
+import headerLinksStyles from '../Header/headerLinks.module.css';
+import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '../layout';
+import { Button, ToggleChevron } from '../primitives';
+import { DropdownMenu, DropdownMenuItem, DropdownSubMenu } from '../primitives/DropdownMenu';
+import { ScrollContainer } from '../primitives/ScrollContainer';
+import { SideSheet } from '../primitives/SideSheet';
+import {
+ SOCIAL_PLATFORM_ICONS,
+ encodePreviewSiteSections,
+ encodePreviewTableOfContents,
+ getContentRefKey,
+ getHeaderSocialAccounts,
+ getPreviewVariants,
+ getStructurePreviewViewportMode,
+ isStructurePreviewMessage,
+} from './state';
+import type { StructurePreviewSnapshot, StructurePreviewViewportMode } from './types';
+
+export function StructurePreview(props: { initialSnapshot: StructurePreviewSnapshot }) {
+ const { initialSnapshot } = props;
+ const [snapshot, setSnapshot] = React.useState(initialSnapshot);
+ const [localMode, setLocalMode] = React.useState(
+ getStructurePreviewViewportMode(initialSnapshot.viewportMode)
+ );
+
+ React.useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (event.source !== window.parent || !isStructurePreviewMessage(event.data)) {
+ return;
+ }
+
+ setSnapshot(event.data.payload);
+ setLocalMode(getStructurePreviewViewportMode(event.data.payload.viewportMode));
+ };
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
+ const mode = getStructurePreviewViewportMode(localMode);
+
+ const preventNavigation = (event: React.MouseEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return;
+ }
+
+ const anchor = target.closest('a');
+ if (!anchor) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+function StructurePreviewModeToggle(props: {
+ mode: StructurePreviewViewportMode;
+ onModeChange: (mode: StructurePreviewViewportMode) => void;
+}) {
+ const { mode, onModeChange } = props;
+ return (
+
+ {(['auto', 'desktop', 'mobile'] as const).map((nextMode) => (
+
+ ))}
+
+ );
+}
+
+function StructurePreviewHeader(props: {
+ snapshot: StructurePreviewSnapshot;
+ viewportMode: StructurePreviewViewportMode;
+}) {
+ const { snapshot, viewportMode } = props;
+ const { customization } = snapshot;
+ const language = useLanguage();
+ const variants = getPreviewVariants(snapshot);
+ const sections = encodePreviewSiteSections(snapshot);
+ const headerSocialAccounts = getHeaderSocialAccounts(customization);
+ const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
+ const withSections = Boolean(
+ sections &&
+ (sections.list.length > 1 ||
+ sections.list.some((section) => section.object === 'site-section-group'))
+ );
+ const forceMobile = viewportMode === 'mobile';
+ const forceDesktop = viewportMode === 'desktop';
+
+ return (
+
+ );
+}
+
+function StructurePreviewLogo(props: { snapshot: StructurePreviewSnapshot }) {
+ const { snapshot } = props;
+ const { customization } = snapshot;
+
+ return (
+
+ {customization.header.logo ? (
+
+ {customization.header.logo.dark ? (
+
+ ) : null}
+
+
+ ) : (
+ <>
+ {'emoji' in customization.favicon && customization.favicon.emoji ? (
+
{customization.favicon.emoji}
+ ) : (
+
+
+
+
+ )}
+
+ {snapshot.site.title}
+
+ >
+ )}
+
+ );
+}
+
+function StructurePreviewSearch(props: { style: CustomizationSearchStyle; mobile: boolean }) {
+ const language = useLanguage();
+ const label = tString(language, 'search');
+
+ if (props.mobile) {
+ return ;
+ }
+
+ if (props.style === CustomizationSearchStyle.Prominent) {
+ return (
+
+
+ {label}
+
+ );
+ }
+
+ return ;
+}
+
+function StructurePreviewHeaderLink(props: {
+ snapshot: StructurePreviewSnapshot;
+ link: CustomizationHeaderItem;
+}) {
+ const { snapshot, link } = props;
+ const title = getLocalizedTitle(link, snapshot.locale);
+ const linkStyle = link.style ?? 'link';
+
+ if (link.links && link.links.length > 0) {
+ return (
+ }
+ />
+ }
+ >
+ {link.links.map((subLink, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (!link.to) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function StructurePreviewHeaderLinkButton(props: {
+ title: string;
+ linkStyle: 'link' | 'button-secondary' | 'button-primary';
+ headerPreset: CustomizationHeaderPreset;
+ trailing?: React.ReactNode;
+}) {
+ const { title, linkStyle, trailing } = props;
+
+ if (linkStyle === 'button-primary' || linkStyle === 'button-secondary') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function StructurePreviewMoreMenu(props: {
+ snapshot: StructurePreviewSnapshot;
+ links: CustomizationHeaderItem[];
+ label: React.ReactNode;
+}) {
+ const { snapshot, links, label } = props;
+ return (
+
+
+ {label}
+
+
+
+ }
+ >
+ {links.map((link, index) => (
+
+ ))}
+
+
+ );
+}
+
+function StructurePreviewMenuLink(props: {
+ snapshot: StructurePreviewSnapshot;
+ link: CustomizationHeaderItem | CustomizationContentLink;
+}) {
+ const { snapshot, link } = props;
+ const title = getLocalizedTitle(link, snapshot.locale);
+
+ return 'links' in link && link.links.length > 0 ? (
+
+ {link.links.map((subLink, index) => (
+
+ ))}
+
+ ) : (
+ {title}
+ );
+}
+
+function StructurePreviewSpacesDropdown(props: {
+ snapshot: StructurePreviewSnapshot;
+ siteSpace: SiteSpace;
+ siteSpaces: SiteSpace[];
+ className?: string;
+ variant?: React.ComponentProps['variant'];
+ icon?: IconName;
+}) {
+ const { snapshot, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
+ const title = getLocalizedTitle(siteSpace, snapshot.locale);
+
+ return (
+ }
+ className={tcls('bg-tint-base', className)}
+ >
+ {title}
+
+ }
+ >
+ {siteSpaces.map((space) => (
+
+ {getLocalizedTitle(space, snapshot.locale)}
+
+ ))}
+
+ );
+}
+
+function StructurePreviewMobileNavigation(props: { snapshot: StructurePreviewSnapshot }) {
+ const { snapshot } = props;
+ const pages = React.useMemo(() => encodePreviewTableOfContents(snapshot), [snapshot]);
+ const variants = getPreviewVariants(snapshot);
+
+ return (
+
+
+
+ {variants.translations.length > 1 ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/packages/gitbook/src/components/StructurePreview/index.ts b/packages/gitbook/src/components/StructurePreview/index.ts
new file mode 100644
index 000000000..3a90a4a52
--- /dev/null
+++ b/packages/gitbook/src/components/StructurePreview/index.ts
@@ -0,0 +1,2 @@
+export * from './StructurePreview';
+export * from './types';
diff --git a/packages/gitbook/src/components/StructurePreview/state.test.ts b/packages/gitbook/src/components/StructurePreview/state.test.ts
new file mode 100644
index 000000000..8193b10dd
--- /dev/null
+++ b/packages/gitbook/src/components/StructurePreview/state.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'bun:test';
+
+import { defaultCustomization } from '@/lib/utils';
+
+import {
+ encodePreviewSiteSections,
+ encodePreviewTableOfContents,
+ getStructurePreviewViewportMode,
+ isStructurePreviewMessage,
+} from './state';
+import type { StructurePreviewSnapshot } from './types';
+
+function createSnapshot(
+ overrides: Partial = {}
+): StructurePreviewSnapshot {
+ const siteSpace = {
+ id: 'site-space-1',
+ title: 'Docs',
+ path: '',
+ default: true,
+ hidden: false,
+ urls: {},
+ space: {
+ id: 'space-1',
+ revision: 'revision-1',
+ language: 'en',
+ },
+ };
+
+ return {
+ site: {
+ id: 'site-1',
+ title: 'Acme Docs',
+ },
+ customization: defaultCustomization(),
+ structure: {
+ type: 'siteSpaces',
+ structure: [siteSpace],
+ },
+ siteSpace,
+ siteSpaces: [siteSpace],
+ visibleSiteSpaces: [siteSpace],
+ sections: null,
+ visibleSections: null,
+ revision: {
+ pages: [],
+ tags: [],
+ },
+ icons: {
+ large: {
+ light: '/~gitbook/icon?size=large&theme=light',
+ dark: '/~gitbook/icon?size=large&theme=dark',
+ },
+ },
+ ...overrides,
+ } as StructurePreviewSnapshot;
+}
+
+describe('structure preview state', () => {
+ it('validates full snapshot update messages', () => {
+ const snapshot = createSnapshot();
+
+ expect(
+ isStructurePreviewMessage({
+ type: 'gitbook.structure.update',
+ payload: snapshot,
+ })
+ ).toBe(true);
+ expect(isStructurePreviewMessage({ type: 'gitbook.structure.update' })).toBe(false);
+ expect(isStructurePreviewMessage({ type: 'other', payload: snapshot })).toBe(false);
+ });
+
+ it('normalizes viewport mode', () => {
+ expect(getStructurePreviewViewportMode('desktop')).toBe('desktop');
+ expect(getStructurePreviewViewportMode('mobile')).toBe('mobile');
+ expect(getStructurePreviewViewportMode('auto')).toBe('auto');
+ expect(getStructurePreviewViewportMode(undefined)).toBe('auto');
+ });
+
+ it('encodes section structures with inert URLs', () => {
+ const section = {
+ object: 'site-section',
+ id: 'section-1',
+ title: 'Guides',
+ localizedTitle: { fr: 'Guides FR' },
+ description: 'Learn',
+ path: 'guides',
+ default: true,
+ siteSpaces: [],
+ urls: {},
+ };
+ const snapshot = createSnapshot({
+ locale: 'fr',
+ sections: {
+ list: [
+ {
+ object: 'site-section-group',
+ id: 'group-1',
+ title: 'Products',
+ children: [section],
+ },
+ ],
+ current: section,
+ },
+ } as unknown as Partial);
+
+ const encoded = encodePreviewSiteSections(snapshot);
+
+ expect(encoded?.current.title).toBe('Guides FR');
+ expect(encoded?.current.url).toBe('#');
+ expect(encoded?.list[0]?.object).toBe('site-section-group');
+ });
+
+ it('encodes revision pages for the mobile menu without navigable URLs', () => {
+ const snapshot = createSnapshot({
+ revision: {
+ tags: [],
+ pages: [
+ {
+ type: 'document',
+ id: 'page-1',
+ title: 'Introduction',
+ path: 'intro',
+ pages: [
+ {
+ type: 'document',
+ id: 'page-2',
+ title: 'Nested',
+ path: 'nested',
+ pages: [],
+ },
+ ],
+ },
+ {
+ type: 'document',
+ id: 'hidden-page',
+ title: 'Hidden',
+ hidden: true,
+ path: 'hidden',
+ pages: [],
+ },
+ ],
+ },
+ } as unknown as Partial);
+
+ const encoded = encodePreviewTableOfContents(snapshot);
+
+ expect(encoded).toHaveLength(1);
+ expect(encoded[0]).toMatchObject({
+ type: 'document',
+ title: 'Introduction',
+ href: '#',
+ });
+ const nested = encoded[0]?.type === 'document' ? encoded[0].descendants?.[0] : null;
+ expect(nested?.type === 'document' ? nested.href : null).toBe('#');
+ });
+});
diff --git a/packages/gitbook/src/components/StructurePreview/state.ts b/packages/gitbook/src/components/StructurePreview/state.ts
new file mode 100644
index 000000000..ae354a133
--- /dev/null
+++ b/packages/gitbook/src/components/StructurePreview/state.ts
@@ -0,0 +1,272 @@
+import type {
+ ContentRef,
+ RevisionPage,
+ RevisionPageTag,
+ RevisionTag,
+ SiteCustomizationSettings,
+ SiteSection,
+ SiteSectionGroup,
+ SiteSocialAccountPlatform,
+} from '@gitbook/api';
+import type { IconName } from '@gitbook/icons';
+import assertNever from 'assert-never';
+
+import type {
+ ClientSiteSection,
+ ClientSiteSectionGroup,
+ ClientSiteSections,
+} from '@/components/SiteSections/encodeClientSiteSections';
+import type { ClientTOCPage } from '@/components/TableOfContents/encodeClientTableOfContents';
+import { getLocalizedDescription, getLocalizedTitle } from '@/lib/sites';
+import { resolveTag } from '@/lib/tags';
+
+import type {
+ StructurePreviewMessage,
+ StructurePreviewSnapshot,
+ StructurePreviewViewportMode,
+} from './types';
+
+export function isStructurePreviewMessage(value: unknown): value is StructurePreviewMessage {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const message = value as Partial;
+ return (
+ message.type === 'gitbook.structure.update' && isStructurePreviewSnapshot(message.payload)
+ );
+}
+
+export function isStructurePreviewSnapshot(value: unknown): value is StructurePreviewSnapshot {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const snapshot = value as Partial;
+ return Boolean(
+ snapshot.site &&
+ typeof snapshot.site.title === 'string' &&
+ snapshot.customization &&
+ snapshot.structure &&
+ snapshot.siteSpace &&
+ Array.isArray(snapshot.siteSpaces) &&
+ Array.isArray(snapshot.visibleSiteSpaces) &&
+ snapshot.revision &&
+ Array.isArray(snapshot.revision.pages) &&
+ Array.isArray(snapshot.revision.tags)
+ );
+}
+
+export function getStructurePreviewViewportMode(
+ mode: StructurePreviewViewportMode | undefined
+): StructurePreviewViewportMode {
+ return mode === 'desktop' || mode === 'mobile' ? mode : 'auto';
+}
+
+export function encodePreviewSiteSections(
+ snapshot: StructurePreviewSnapshot
+): ClientSiteSections | null {
+ const sections = snapshot.visibleSections ?? snapshot.sections;
+ if (!sections) {
+ return null;
+ }
+
+ return {
+ list: sections.list.flatMap((item) => encodePreviewSectionItem(snapshot, item)),
+ current: encodePreviewSection(snapshot, sections.current),
+ };
+}
+
+function encodePreviewSectionItem(
+ snapshot: StructurePreviewSnapshot,
+ item: SiteSection | SiteSectionGroup
+): (ClientSiteSection | ClientSiteSectionGroup)[] {
+ switch (item.object) {
+ case 'site-section': {
+ return [encodePreviewSection(snapshot, item)];
+ }
+ case 'site-section-group': {
+ const children = item.children.flatMap((child) =>
+ encodePreviewSectionItem(snapshot, child)
+ );
+ if (children.length === 0) {
+ return [];
+ }
+
+ return [
+ {
+ id: item.id,
+ title: getLocalizedTitle(item, snapshot.locale),
+ icon: item.icon,
+ object: item.object,
+ children,
+ },
+ ];
+ }
+ default:
+ assertNever(item);
+ }
+}
+
+function encodePreviewSection(
+ snapshot: StructurePreviewSnapshot,
+ section: SiteSection
+): ClientSiteSection {
+ return {
+ id: section.id,
+ title: getLocalizedTitle(section, snapshot.locale),
+ description: getLocalizedDescription(section, snapshot.locale),
+ icon: section.icon,
+ object: section.object,
+ url: '#',
+ };
+}
+
+export function encodePreviewTableOfContents(snapshot: StructurePreviewSnapshot): ClientTOCPage[] {
+ return encodePreviewPages(
+ snapshot.revision.pages,
+ snapshot.revision.pages,
+ snapshot.revision.tags
+ );
+}
+
+function encodePreviewPages(
+ rootPages: RevisionPage[],
+ pages: RevisionPage[],
+ tags: RevisionTag[]
+): ClientTOCPage[] {
+ return pages.flatMap((page): ClientTOCPage[] => {
+ if (page.type === 'computed' || page.hidden) {
+ return [];
+ }
+
+ switch (page.type) {
+ case 'document': {
+ return [
+ removeUndefined({
+ id: page.id,
+ title: page.linkTitle || page.title,
+ href: '#',
+ emoji: page.emoji,
+ icon: page.icon,
+ pathnames: [],
+ descendants: hasVisibleDescendant(page.pages)
+ ? encodePreviewPages(rootPages, page.pages, tags)
+ : undefined,
+ primaryTag: resolvePrimaryPageTag(page.tags, tags),
+ type: 'document' as const,
+ }),
+ ];
+ }
+ case 'link': {
+ return [
+ removeUndefined({
+ id: page.id,
+ title: page.title,
+ href: '#',
+ emoji: page.emoji,
+ icon: page.icon,
+ target: page.target,
+ type: 'link' as const,
+ }),
+ ];
+ }
+ case 'group': {
+ return [
+ removeUndefined({
+ id: page.id,
+ title: page.title,
+ emoji: page.emoji,
+ icon: page.icon,
+ descendants: hasVisibleDescendant(page.pages)
+ ? encodePreviewPages(rootPages, page.pages, tags)
+ : undefined,
+ type: 'group' as const,
+ }),
+ ];
+ }
+ default:
+ assertNever(page);
+ }
+ });
+}
+
+function hasVisibleDescendant(pages: RevisionPage[]) {
+ return pages.some((page) => page.type !== 'computed' && !page.hidden);
+}
+
+function resolvePrimaryPageTag(
+ pageTags: RevisionPageTag[] | undefined,
+ tags: RevisionTag[]
+): RevisionTag | undefined {
+ if (!pageTags || pageTags.length === 0) {
+ return undefined;
+ }
+
+ const primary = pageTags.find((tag) => tag.primary);
+ if (!primary) {
+ return undefined;
+ }
+
+ return resolveTag(primary.tag.tag, tags);
+}
+
+function removeUndefined>(object: T): T {
+ return Object.fromEntries(
+ Object.entries(object).filter(([, value]) => value !== undefined)
+ ) as T;
+}
+
+export function getPreviewVariants(snapshot: StructurePreviewSnapshot) {
+ const siteSpaces =
+ snapshot.visibleSiteSpaces.length > 0 ? snapshot.visibleSiteSpaces : snapshot.siteSpaces;
+ const currentLanguage = snapshot.locale ?? snapshot.siteSpace.space.language ?? 'en';
+ const languages = [...new Set(siteSpaces.map((space) => space.space.language ?? 'en'))];
+ const isMultiLanguage = languages.length > 1;
+
+ return {
+ generic: isMultiLanguage
+ ? siteSpaces.filter(
+ (space) =>
+ space.id === snapshot.siteSpace.id ||
+ (space.space.language ?? 'en') === currentLanguage
+ )
+ : siteSpaces,
+ translations: isMultiLanguage
+ ? siteSpaces.filter(
+ (space) =>
+ space.id === snapshot.siteSpace.id ||
+ (space.space.language ?? 'en') !== currentLanguage
+ )
+ : [],
+ };
+}
+
+export function getHeaderSocialAccounts(customization: SiteCustomizationSettings) {
+ return customization.socialAccounts.filter((account) => account.display.header === true);
+}
+
+export const SOCIAL_PLATFORM_ICONS: Partial> = {
+ twitter: 'x-twitter',
+ instagram: 'instagram',
+ facebook: 'facebook',
+ linkedin: 'linkedin',
+ github: 'github',
+ discord: 'discord',
+ slack: 'slack',
+ youtube: 'youtube',
+ tiktok: 'tiktok',
+ reddit: 'reddit',
+ bluesky: 'bluesky',
+ mastodon: 'mastodon',
+ threads: 'threads',
+ medium: 'medium',
+};
+
+export function getContentRefKey(ref: ContentRef | null | undefined) {
+ if (!ref) {
+ return 'empty';
+ }
+
+ return JSON.stringify(ref);
+}
diff --git a/packages/gitbook/src/components/StructurePreview/types.ts b/packages/gitbook/src/components/StructurePreview/types.ts
new file mode 100644
index 000000000..23078d5b7
--- /dev/null
+++ b/packages/gitbook/src/components/StructurePreview/types.ts
@@ -0,0 +1,45 @@
+import type {
+ RevisionPage,
+ RevisionTag,
+ SiteCustomizationSettings,
+ SiteSpace,
+ SiteStructure,
+ TranslationLanguage,
+} from '@gitbook/api';
+
+import type { SiteSections } from '@/lib/context';
+
+export type StructurePreviewViewportMode = 'auto' | 'desktop' | 'mobile';
+
+export type StructurePreviewSnapshot = {
+ site: {
+ id: string;
+ title: string;
+ proxyOrigin?: string;
+ };
+ contextId?: string;
+ locale?: TranslationLanguage;
+ customization: SiteCustomizationSettings;
+ structure: SiteStructure;
+ siteSpace: SiteSpace;
+ siteSpaces: SiteSpace[];
+ visibleSiteSpaces: SiteSpace[];
+ sections: SiteSections | null;
+ visibleSections: SiteSections | null;
+ revision: {
+ pages: RevisionPage[];
+ tags: RevisionTag[];
+ };
+ icons: {
+ large: {
+ light: string;
+ dark: string;
+ };
+ };
+ viewportMode?: StructurePreviewViewportMode;
+};
+
+export type StructurePreviewMessage = {
+ type: 'gitbook.structure.update';
+ payload: StructurePreviewSnapshot;
+};
diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts
index dcf2804ab..c8cadea88 100644
--- a/packages/gitbook/src/middleware.ts
+++ b/packages/gitbook/src/middleware.ts
@@ -803,6 +803,7 @@ function encodePathInSiteContent(
case '~gitbook/auth/login':
case '~gitbook/auth/logout':
case '~scalar/proxy':
+ case '~gitbook/structure':
// PDF, search and auth routes are always dynamic as they depend on the request.
return { pathname, routeType: 'dynamic' };
default: {