mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Add Structure Preview component and related functionality
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export const metadata = {
|
||||
title: 'Next.js',
|
||||
description: 'Generated by Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+65
@@ -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<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
forcedTheme={forcedTheme}
|
||||
context={context}
|
||||
>
|
||||
<SiteLayoutClientContexts
|
||||
contextId={context.contextId}
|
||||
forcedTheme={
|
||||
forcedTheme ??
|
||||
(context.customization.themes.toggeable
|
||||
? undefined
|
||||
: context.customization.themes.default)
|
||||
}
|
||||
defaultTheme={context.customization.themes.default}
|
||||
themeStorageKey={`gitbook-theme-structure:${context.site.id}`}
|
||||
externalLinksTarget={context.customization.externalLinks.target}
|
||||
proxyOrigin={context.site.proxy?.origin}
|
||||
>
|
||||
<StructurePreview initialSnapshot={snapshot} />
|
||||
</SiteLayoutClientContexts>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<StructurePreviewViewportMode>(
|
||||
getStructurePreviewViewportMode(initialSnapshot.viewportMode)
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<unknown>) => {
|
||||
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<HTMLElement>) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target.closest('a');
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-gb-structure-preview
|
||||
data-viewport-mode={mode}
|
||||
className={tcls(
|
||||
'site-background min-h-screen overflow-hidden',
|
||||
mode === 'mobile' ? 'mx-auto max-w-[420px]' : null,
|
||||
mode === 'desktop' ? 'min-w-[1024px]' : null
|
||||
)}
|
||||
onClickCapture={preventNavigation}
|
||||
onAuxClickCapture={preventNavigation}
|
||||
>
|
||||
<StructurePreviewModeToggle mode={mode} onModeChange={setLocalMode} />
|
||||
<StructurePreviewHeader snapshot={snapshot} viewportMode={mode} />
|
||||
<StructurePreviewMobileNavigation snapshot={snapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewModeToggle(props: {
|
||||
mode: StructurePreviewViewportMode;
|
||||
onModeChange: (mode: StructurePreviewViewportMode) => void;
|
||||
}) {
|
||||
const { mode, onModeChange } = props;
|
||||
return (
|
||||
<div className="fixed top-2 right-2 z-50 flex rounded-md border border-tint-subtle bg-tint-base/9 p-0.5 text-xs shadow-sm backdrop-blur">
|
||||
{(['auto', 'desktop', 'mobile'] as const).map((nextMode) => (
|
||||
<button
|
||||
key={nextMode}
|
||||
type="button"
|
||||
className={tcls(
|
||||
'rounded-sm px-2 py-1 text-tint capitalize transition-colors hover:bg-tint-hover',
|
||||
mode === nextMode ? 'bg-primary-active text-primary-strong' : null
|
||||
)}
|
||||
onClick={() => onModeChange(nextMode)}
|
||||
>
|
||||
{nextMode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<header
|
||||
data-gb-site-header
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
|
||||
'sticky',
|
||||
'top-0',
|
||||
'pt-[env(safe-area-inset-top)]',
|
||||
'z-30',
|
||||
'w-full',
|
||||
'flex-none',
|
||||
'shadow-[0px_1px_0px]',
|
||||
'shadow-tint-12/2',
|
||||
'bg-tint-base/9',
|
||||
'theme-muted:bg-tint-subtle/9',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle/9',
|
||||
'theme-gradient:bg-gradient-primary',
|
||||
'theme-gradient-tint:bg-gradient-tint',
|
||||
'contrast-more:bg-tint-base',
|
||||
withTopHeader ? null : 'mobile-only lg:hidden',
|
||||
'text-sm',
|
||||
'backdrop-blur-lg'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'site-header:theme-bold:bg-header-background',
|
||||
'site-header:theme-bold:shadow-[0px_1px_0px]',
|
||||
'site-header:theme-bold:shadow-tint-12/2'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
data-gb-header-content
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
'lg:gap-6',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-between',
|
||||
'w-full',
|
||||
'py-3',
|
||||
'min-h-16',
|
||||
'sm:h-16',
|
||||
CONTAINER_STYLE,
|
||||
'@container/header'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex max-w-full',
|
||||
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
|
||||
'search' in customization.styling &&
|
||||
customization.styling.search === 'prominent'
|
||||
? 'lg:@2xl:basis-72'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<HeaderMobileMenu
|
||||
className={tcls(
|
||||
'-ml-2',
|
||||
'text-tint-strong',
|
||||
'site-header:theme-bold:text-header-link',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:site-header:theme-bold:bg-header-link/3',
|
||||
forceDesktop ? 'hidden' : forceMobile ? 'flex' : 'lg:hidden'
|
||||
)}
|
||||
/>
|
||||
<StructurePreviewLogo snapshot={snapshot} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'md:@2xl:basis-56',
|
||||
'justify-self-end',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
'search' in customization.styling &&
|
||||
customization.styling.search === 'prominent'
|
||||
? [
|
||||
'md:@2xl:grow-[0.8]',
|
||||
'md:@4xl:basis-40',
|
||||
'md:@2xl:max-w-[50%]',
|
||||
'md:@4xl:max-w-lg',
|
||||
'md:@2xl:mr-auto',
|
||||
'order-last',
|
||||
'md:@2xl:order-[unset]',
|
||||
]
|
||||
: ['order-last']
|
||||
)}
|
||||
>
|
||||
<StructurePreviewSearch
|
||||
style={customization.styling.search}
|
||||
mobile={forceMobile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(customization.header.links.length > 0 ||
|
||||
headerSocialAccounts.length > 0 ||
|
||||
(!withSections && variants.translations.length > 1)) &&
|
||||
!forceMobile ? (
|
||||
<div
|
||||
className={tcls(
|
||||
headerLinksStyles.containerHeaderlinks,
|
||||
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
|
||||
forceMobile ? 'hidden' : null
|
||||
)}
|
||||
>
|
||||
{customization.header.links.map((link, index) => (
|
||||
<StructurePreviewHeaderLink
|
||||
key={`${getContentRefKey(link.to)}-${index}`}
|
||||
link={link}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
))}
|
||||
{headerSocialAccounts.length > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{headerSocialAccounts.map((account) => {
|
||||
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
|
||||
return icon ? (
|
||||
<Button
|
||||
key={`${account.platform}-${account.handle}`}
|
||||
iconOnly
|
||||
label={account.platform}
|
||||
icon={icon}
|
||||
variant="blank"
|
||||
size="large"
|
||||
className="p-2 theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link focus-visible:site-header:theme-bold:bg-header-link/3"
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{customization.header.links.length > 0 ||
|
||||
headerSocialAccounts.length > 0 ? (
|
||||
<StructurePreviewMoreMenu
|
||||
label={tString(language, 'more')}
|
||||
links={customization.header.links}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
) : null}
|
||||
{!withSections && variants.translations.length > 1 ? (
|
||||
<StructurePreviewSpacesDropdown
|
||||
snapshot={snapshot}
|
||||
siteSpace={snapshot.siteSpace}
|
||||
siteSpaces={variants.translations}
|
||||
icon="globe"
|
||||
variant="blank"
|
||||
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections && withSections && !forceMobile ? (
|
||||
<div>
|
||||
<SiteSectionTabs sections={sections}>
|
||||
{variants.translations.length > 1 ? (
|
||||
<StructurePreviewSpacesDropdown
|
||||
snapshot={snapshot}
|
||||
siteSpace={snapshot.siteSpace}
|
||||
siteSpaces={variants.translations}
|
||||
icon="globe"
|
||||
variant="blank"
|
||||
className="my-1.5 ml-2 self-start"
|
||||
/>
|
||||
) : null}
|
||||
</SiteSectionTabs>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewLogo(props: { snapshot: StructurePreviewSnapshot }) {
|
||||
const { snapshot } = props;
|
||||
const { customization } = snapshot;
|
||||
|
||||
return (
|
||||
<div className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}>
|
||||
{customization.header.logo ? (
|
||||
<picture>
|
||||
{customization.header.logo.dark ? (
|
||||
<source
|
||||
srcSet={customization.header.logo.dark}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
) : null}
|
||||
<img
|
||||
alt="Logo"
|
||||
src={customization.header.logo.light}
|
||||
className={tcls(
|
||||
'block overflow-hidden',
|
||||
'shrink',
|
||||
'min-w-0',
|
||||
'max-w-40',
|
||||
'lg:max-w-64',
|
||||
'max-h-8',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'object-contain',
|
||||
'object-left'
|
||||
)}
|
||||
/>
|
||||
</picture>
|
||||
) : (
|
||||
<>
|
||||
{'emoji' in customization.favicon && customization.favicon.emoji ? (
|
||||
<span className="text-xl">{customization.favicon.emoji}</span>
|
||||
) : (
|
||||
<picture>
|
||||
<source
|
||||
srcSet={snapshot.icons.large.dark}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
src={snapshot.icons.large.light}
|
||||
className="size-8 object-contain"
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
<div
|
||||
className={tcls(
|
||||
'text-pretty',
|
||||
'line-clamp-2',
|
||||
'tracking-tight',
|
||||
'max-w-[18ch]',
|
||||
'lg:max-w-[24ch]',
|
||||
'font-semibold',
|
||||
'ms-3',
|
||||
'text-base/tight',
|
||||
'lg:text-lg/tight',
|
||||
'text-tint-strong',
|
||||
'theme-bold:text-header-link'
|
||||
)}
|
||||
>
|
||||
{snapshot.site.title}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewSearch(props: { style: CustomizationSearchStyle; mobile: boolean }) {
|
||||
const language = useLanguage();
|
||||
const label = tString(language, 'search');
|
||||
|
||||
if (props.mobile) {
|
||||
return <Button icon="search" variant="header" size="medium" iconOnly label={label} />;
|
||||
}
|
||||
|
||||
if (props.style === CustomizationSearchStyle.Prominent) {
|
||||
return (
|
||||
<div className="flex min-h-10 w-full items-center gap-2 rounded-md border border-tint-subtle bg-tint-base px-3 text-tint">
|
||||
<Icon icon="search" className="size-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button icon="search" variant="header" size="medium" label={label} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu
|
||||
openOnHover
|
||||
className={
|
||||
snapshot.customization.styling.search === 'prominent'
|
||||
? 'right-0 left-auto'
|
||||
: null
|
||||
}
|
||||
button={
|
||||
<StructurePreviewHeaderLinkButton
|
||||
title={title}
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={snapshot.customization.header.preset}
|
||||
trailing={<ToggleChevron />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{link.links.map((subLink, index) => (
|
||||
<StructurePreviewMenuLink key={index} link={subLink} snapshot={snapshot} />
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (!link.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StructurePreviewHeaderLinkButton
|
||||
title={title}
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={snapshot.customization.header.preset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
variant={linkStyle === 'button-primary' ? 'primary' : 'header'}
|
||||
size="medium"
|
||||
label={title}
|
||||
trailing={trailing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={tcls(
|
||||
'flex items-center gap-1',
|
||||
'shrink',
|
||||
'contrast-more:underline',
|
||||
'truncate',
|
||||
'text-tint',
|
||||
'links-default:hover:text-primary',
|
||||
'links-default:tint:hover:text-tint-strong',
|
||||
'underline-offset-2',
|
||||
'links-accent:hover:underline',
|
||||
'links-accent:underline-offset-4',
|
||||
'links-accent:decoration-primary-subtle',
|
||||
'links-accent:decoration-[3px]',
|
||||
'links-accent:py-0.5',
|
||||
'theme-bold:text-header-link',
|
||||
'hover:theme-bold:text-header-link/7!'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewMoreMenu(props: {
|
||||
snapshot: StructurePreviewSnapshot;
|
||||
links: CustomizationHeaderItem[];
|
||||
label: React.ReactNode;
|
||||
}) {
|
||||
const { snapshot, links, label } = props;
|
||||
return (
|
||||
<div className={`${headerLinksStyles.linkEllipsis} z-20 items-center`}>
|
||||
<DropdownMenu
|
||||
openOnHover
|
||||
className={tcls(
|
||||
'max-md:right-0 max-md:left-auto',
|
||||
snapshot.customization.styling.search === 'prominent' && 'right-0 left-auto'
|
||||
)}
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
className={tcls(
|
||||
'text-tint',
|
||||
'hover:text-primary',
|
||||
'dark:hover:text-primary',
|
||||
'theme-bold:text-header-link',
|
||||
'theme-bold:hover:text-header-link/8',
|
||||
'flex',
|
||||
'gap-1',
|
||||
'items-center'
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon icon="ellipsis" className="size-4" />
|
||||
<ToggleChevron />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{links.map((link, index) => (
|
||||
<StructurePreviewMenuLink key={index} link={link} snapshot={snapshot} />
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<DropdownSubMenu label={title}>
|
||||
{link.links.map((subLink, index) => (
|
||||
<StructurePreviewMenuLink key={index} link={subLink} snapshot={snapshot} />
|
||||
))}
|
||||
</DropdownSubMenu>
|
||||
) : (
|
||||
<DropdownMenuItem>{title}</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewSpacesDropdown(props: {
|
||||
snapshot: StructurePreviewSnapshot;
|
||||
siteSpace: SiteSpace;
|
||||
siteSpaces: SiteSpace[];
|
||||
className?: string;
|
||||
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||
icon?: IconName;
|
||||
}) {
|
||||
const { snapshot, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
|
||||
const title = getLocalizedTitle(siteSpace, snapshot.locale);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
className={tcls(
|
||||
'group-hover/dropdown:invisible',
|
||||
'group-focus-within/dropdown:group-hover/dropdown:visible'
|
||||
)}
|
||||
button={
|
||||
<Button
|
||||
icon={icon}
|
||||
data-testid="space-dropdown-button"
|
||||
size="small"
|
||||
variant={variant}
|
||||
trailing={<ToggleChevron />}
|
||||
className={tcls('bg-tint-base', className)}
|
||||
>
|
||||
<span className="button-content">{title}</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{siteSpaces.map((space) => (
|
||||
<DropdownMenuItem key={space.id} active={space.id === siteSpace.id}>
|
||||
{getLocalizedTitle(space, snapshot.locale)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function StructurePreviewMobileNavigation(props: { snapshot: StructurePreviewSnapshot }) {
|
||||
const { snapshot } = props;
|
||||
const pages = React.useMemo(() => encodePreviewTableOfContents(snapshot), [snapshot]);
|
||||
const variants = getPreviewVariants(snapshot);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
side="left"
|
||||
data-testid="table-of-contents"
|
||||
data-gb-table-of-contents
|
||||
toggleClass="navigation-open"
|
||||
withOverlay={true}
|
||||
withCloseButton={true}
|
||||
className={tcls(
|
||||
'group/table-of-contents',
|
||||
'text-sm',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'w-4/5',
|
||||
'md:w-1/2',
|
||||
'lg:w-72',
|
||||
'max-lg:not-sidebar-filled:bg-tint-base',
|
||||
'max-lg:not-sidebar-filled:border-r',
|
||||
'border-tint-subtle',
|
||||
'pt-6 pb-4',
|
||||
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]',
|
||||
'max-lg:pl-8',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'min-h-0',
|
||||
'gap-4'
|
||||
)}
|
||||
>
|
||||
<div className="flex grow-0 items-center pr-4 text-base/tight">
|
||||
<StructurePreviewLogo snapshot={snapshot} />
|
||||
{variants.translations.length > 1 ? (
|
||||
<StructurePreviewSpacesDropdown
|
||||
snapshot={snapshot}
|
||||
siteSpace={snapshot.siteSpace}
|
||||
siteSpaces={variants.translations}
|
||||
icon="globe"
|
||||
variant="blank"
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'-ms-5',
|
||||
'relative flex min-h-0 grow flex-col border-tint-subtle',
|
||||
'sidebar-filled:bg-tint-subtle',
|
||||
'theme-muted:bg-tint-subtle',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-gradient_&]:border',
|
||||
'max-lg:sidebar-filled:border',
|
||||
'sidebar-filled:rounded-2xl',
|
||||
'straight-corners:rounded-none'
|
||||
)}
|
||||
>
|
||||
<ScrollContainer
|
||||
data-testid="toc-scroll-container"
|
||||
orientation="vertical"
|
||||
contentClassName="flex flex-col p-2 gutter-stable"
|
||||
active="[data-active=true]"
|
||||
leading={{
|
||||
fade: true,
|
||||
button: {
|
||||
className: '-mt-4',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PagesList
|
||||
pages={pages}
|
||||
isRoot={true}
|
||||
style="grow border-tint-subtle sidebar-list-line:border-l"
|
||||
/>
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './StructurePreview';
|
||||
export * from './types';
|
||||
@@ -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> = {}
|
||||
): 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<StructurePreviewSnapshot>);
|
||||
|
||||
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<StructurePreviewSnapshot>);
|
||||
|
||||
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('#');
|
||||
});
|
||||
});
|
||||
@@ -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<StructurePreviewMessage>;
|
||||
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<StructurePreviewSnapshot>;
|
||||
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<T extends Record<string, unknown>>(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<Record<SiteSocialAccountPlatform, IconName>> = {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user