Compare commits

...

30 Commits

Author SHA1 Message Date
Nicolas Dorseuil bb46b5b38b Make href prop optional in SubHeaderLinkItem and update StructurePreview to remove default href values 2026-06-24 20:23:30 +02:00
Nicolas Dorseuil 8a9f163d31 Add SiteSectionList to StructurePreviewVariantSelector for improved section display 2026-06-24 20:14:18 +02:00
Nicolas Dorseuil 7c365f9162 Add social account links to StructurePreviewMoreMenu component 2026-06-24 20:11:48 +02:00
Nicolas Dorseuil 10a7095182 Add metadata export to Page component for SEO configuration 2026-06-24 19:56:36 +02:00
conico974 614a50373a Merge branch 'main' into conico/structure-preview 2026-06-24 10:47:51 +02:00
Nicolas Dorseuil 7646cb301c Add GITBOOK_APP_URL prop to StructurePreview component and update Page to pass it 2026-06-23 20:44:16 +02:00
Nicolas Dorseuil d2ce6adeb6 Merge remote-tracking branch 'origin/main' into conico/structure-preview 2026-06-23 18:48:26 +02:00
Nicolas Dorseuil 63e4968987 Enhance StructurePreview message handling by validating event origin against GITBOOK_APP_URL 2026-06-18 16:33:48 +02:00
Nicolas Dorseuil 6eb3d3b4e5 Refactor StructurePreview by removing obsolete test files and updating middleware for demo routing 2026-06-17 18:40:08 +02:00
Nicolas Dorseuil 2ba90833a3 fix typescript 2026-06-17 18:15:23 +02:00
Zeno Kapitein b0553d5fc8 Skeletons and layout 2026-06-17 17:18:12 +02:00
Nicolas Dorseuil a06da70f5f Refactor StructurePreview to handle partial updates and navigation messages 2026-06-17 11:41:06 +02:00
Nicolas Dorseuil 53ec8b9f29 Enhance StructurePreview navigation by adding sectionId handling and updating state management 2026-06-17 10:30:14 +02:00
Nicolas Dorseuil 6f63c00300 Refactor StructurePreview components and state management to reduce client side data 2026-06-17 10:13:40 +02:00
Nicolas Dorseuil c334ea2748 linting 2026-06-16 17:38:28 +02:00
Nicolas Dorseuil d75cdf1334 small comment 2026-06-16 17:38:12 +02:00
Nicolas Dorseuil cb649c55cb Commit to revert, just to demonstrate what we can do directly in GBO 2026-06-16 17:38:02 +02:00
Nicolas Dorseuil 205c512cb6 Refactor variant categorization in StructurePreview to utilize categorizeVariants function for improved clarity and maintainability 2026-06-16 16:53:32 +02:00
Nicolas Dorseuil 334a31d7fc Refactor TableOfContents styles and components for improved class management and responsiveness 2026-06-16 16:37:56 +02:00
Nicolas Dorseuil f157cfc9a7 Add disableAnimations prop to SiteSectionTabs for improved rendering control 2026-06-16 16:24:34 +02:00
Nicolas Dorseuil 3485c0192b Refactor StructurePreviewLogoImage to utilize Image component for improved logo rendering and responsiveness 2026-06-16 13:40:54 +02:00
Nicolas Dorseuil 27d9ad6fe4 Refactor Header and StructurePreview components to utilize HeaderLayout for improved structure and consistency in header rendering 2026-06-16 13:31:47 +02:00
Nicolas Dorseuil 96602eb552 Refactor AIChatButton and StructurePreview components to enhance AI assistant integration, improving button rendering and functionality 2026-06-16 13:08:59 +02:00
Nicolas Dorseuil 17d092acdc Refactor Search components to enhance structure and functionality, introducing SearchHeaderInput for improved search input handling 2026-06-16 13:01:25 +02:00
Nicolas Dorseuil 107018f570 Refactor SpacesDropdown and related components for improved structure and functionality 2026-06-16 12:49:15 +02:00
Nicolas Dorseuil 3e152b0662 Refactor layout and page components to streamline structure and enhance theme handling 2026-06-16 12:15:26 +02:00
Nicolas Dorseuil 6a8720894d Refactor header link components for improved structure and styling consistency 2026-06-16 12:05:36 +02:00
Nicolas Dorseuil 8a5ed60c26 Refactor StructurePreview and HeaderLogo components to utilize HeaderLogoContent for improved logo rendering and fallback handling 2026-06-16 11:45:02 +02:00
Nicolas Dorseuil 52fc156b06 Refactor StructurePreview component and related state management by removing unused viewport mode logic and simplifying imports 2026-06-16 11:35:54 +02:00
Nicolas Dorseuil 46091b65d1 Add Structure Preview component and related functionality 2026-06-15 13:40:27 +02:00
35 changed files with 2402 additions and 380 deletions
@@ -0,0 +1,23 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { StructurePreview } from '@/components/StructurePreview';
import { GITBOOK_APP_URL } from '@/lib/env';
import type { Metadata } from 'next';
import { getStructurePreviewSnapshot } from '../snapshot';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function Page(props: PageProps) {
const { context } = await getDynamicSiteContext(await props.params);
return (
<StructurePreview
initialSnapshot={getStructurePreviewSnapshot(context)}
GITBOOK_APP_URL={GITBOOK_APP_URL}
/>
);
}
@@ -0,0 +1,42 @@
import type React from 'react';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayoutClientContexts } from '@/components/SiteLayout/SiteLayoutClientContexts';
import { getThemeFromMiddleware } from '@/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function RootLayout({
children,
...props
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const { context } = await getDynamicSiteContext(await props.params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable overflow-hidden site-background"
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}
>
{children}
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
}
@@ -0,0 +1,184 @@
import type {
CustomizationContentLink,
CustomizationHeaderItem,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type {
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
} from '@/components/SiteSections';
import { categorizeVariants } from '@/components/SpaceLayout/categorizeVariants';
import type { StructurePreviewSnapshot } from '@/components/StructurePreview';
import type { PreviewContentLink, PreviewHeaderLink } from '@/components/StructurePreview/types';
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import { getLocalizedDescription, getLocalizedTitle } from '@/lib/sites';
export function getStructurePreviewSnapshot(context: GitBookSiteContext): StructurePreviewSnapshot {
const variants = categorizeVariants(context);
const sections = context.visibleSections ?? context.sections;
return {
site: {
title: context.site.title,
},
locale: context.locale,
customization: encodePreviewCustomization(context),
siteSpace: encodePreviewSiteSpace(context.siteSpace, context),
variants: {
generic: variants.generic.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
translations: variants.translations.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
},
sections: sections ? encodePreviewSiteSections(context, sections) : null,
icons: {
large: {
light: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
dark: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
},
},
};
}
function encodePreviewCustomization(
context: GitBookSiteContext
): StructurePreviewSnapshot['customization'] {
const { customization, locale } = context;
return {
styling: {
search: customization.styling.search,
},
favicon:
'emoji' in customization.favicon && customization.favicon.emoji
? { emoji: customization.favicon.emoji }
: {},
header: {
preset: customization.header.preset,
logo: customization.header.logo
? {
light: customization.header.logo.light,
dark: customization.header.logo.dark,
}
: undefined,
links: customization.header.links.map((link) => encodePreviewHeaderLink(link, locale)),
},
ai: {
mode: customization.ai.mode,
},
trademark: {
enabled: customization.trademark.enabled,
},
socialAccounts: customization.socialAccounts
.filter((account) => account.display.header === true)
.map((account) => ({
platform: account.platform,
handle: account.handle,
})),
};
}
function encodePreviewHeaderLink(
link: CustomizationHeaderItem,
locale: GitBookSiteContext['locale']
): PreviewHeaderLink {
return {
title: getLocalizedTitle(link, locale),
style: link.style,
hasTarget: Boolean(link.to),
links: link.links.map((subLink) => encodePreviewContentLink(subLink, locale)),
};
}
function encodePreviewContentLink(
link: CustomizationContentLink,
locale: GitBookSiteContext['locale']
): PreviewContentLink {
return {
title: getLocalizedTitle(link, locale),
hasTarget: Boolean(link.to),
};
}
function encodePreviewSiteSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['siteSpace'] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
path: siteSpace.path,
};
}
function encodePreviewDropdownSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['variants']['generic'][number] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
isActive: siteSpace.id === context.siteSpace.id,
};
}
export function encodePreviewSiteSections(
context: Pick<GitBookSiteContext, 'locale'>,
sections: SiteSections
): ClientSiteSections {
return {
list: sections.list.flatMap((item) => encodePreviewSectionItem(context, item)),
current: encodePreviewSection(context, sections.current),
};
}
function encodePreviewSectionItem(
context: Pick<GitBookSiteContext, 'locale'>,
item: SiteSection | SiteSectionGroup
): (ClientSiteSection | ClientSiteSectionGroup)[] {
switch (item.object) {
case 'site-section':
return [encodePreviewSection(context, item)];
case 'site-section-group': {
const children = item.children.flatMap((child) =>
encodePreviewSectionItem(context, child)
);
if (children.length === 0) {
return [];
}
return [
{
id: item.id,
title: getLocalizedTitle(item, context.locale),
icon: item.icon,
object: item.object,
children,
},
];
}
default:
assertNever(item);
}
}
function encodePreviewSection(
context: Pick<GitBookSiteContext, 'locale'>,
section: SiteSection
): ClientSiteSection {
return {
id: section.id,
title: getLocalizedTitle(section, context.locale),
description: getLocalizedDescription(section, context.locale),
icon: section.icon,
object: section.object,
url: '#',
};
}
@@ -1,6 +1,9 @@
'use client';
import type { ReactNode } from 'react';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
@@ -8,6 +11,49 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button visual for an AI assistant in the header.
*/
export function AIChatButtonView(props: {
icon: ReactNode;
label: string;
onClick?: () => void;
showLabel?: boolean;
withShortcut?: boolean;
inert?: boolean;
}) {
const { icon, label, onClick, showLabel = true, withShortcut = true, inert = false } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
icon={icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', label)}
onClick={inert ? undefined : onClick}
tabIndex={inert ? -1 : undefined}
className={tcls(inert ? 'pointer-events-none select-none' : null)}
>
{showLabel ? t(language, 'ask') : null}
</Button>
);
}
/**
* Button to open/close the AI chat.
*/
@@ -17,31 +63,14 @@ export function AIChatButton(props: {
withShortcut?: boolean;
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
<AIChatButtonView
icon={assistant.icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
label={assistant.label}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
</Button>
showLabel={showLabel}
withShortcut={withShortcut}
/>
);
}
@@ -8,6 +8,10 @@ import { PageBody } from '../PageBody';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { categorizeVariants } from '../SpaceLayout/categorizeVariants';
import { TableOfContents } from '../TableOfContents';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsInnerHeaderClassName,
} from '../TableOfContents/styles';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
import {
@@ -129,13 +133,13 @@ export async function EmbeddableDocsPage(
}
innerHeader={
variants.generic.length > 1 ? (
<div className="my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden">
<div className={getTableOfContentsInnerHeaderClassName()}>
{variants.generic.length > 1 ? (
<SpacesDropdown
context={context}
siteSpace={context.siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
@@ -0,0 +1,127 @@
import type { CustomizationSearchStyle } from '@gitbook/api';
import type React from 'react';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { tcls } from '@/lib/tailwind';
const PROMINENT_SEARCH_STYLE: CustomizationSearchStyle = 'prominent' as CustomizationSearchStyle;
/**
* Shared visual layout for the site header.
*
* The live site and structure preview provide different interactive pieces, but the shell,
* spacing, responsive behavior, and theme classes should stay identical.
*/
export function HeaderLayout(props: {
leading: React.ReactNode;
search: React.ReactNode;
searchStyle: CustomizationSearchStyle;
withTopHeader?: boolean;
links?: React.ReactNode;
sections?: React.ReactNode;
}) {
const { leading, search, searchStyle, withTopHeader, links, sections } = props;
const hasProminentSearch = searchStyle === PROMINENT_SEARCH_STYLE;
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 className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<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,
'transition-[max-width] duration-300 motion-reduce:transition-none',
'@container/header'
)}
>
<div
className={tcls(
'flex max-w-full',
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
hasProminentSearch ? 'lg:@2xl:basis-72' : null
)}
>
{leading}
</div>
<div
className={tcls(
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
'transition-[margin] duration-300 motion-reduce:transition-none',
hasProminentSearch
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]',
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]',
'md:@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
]
: ['order-last']
)}
>
{search}
</div>
{links}
</div>
</div>
</div>
{sections ? (
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
{sections}
</div>
) : null}
</header>
);
}
@@ -1,16 +1,10 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import type { CustomizationContentLink, CustomizationHeaderItem } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
import { HeaderLinkItem, SubHeaderLinkItem } from './HeaderLinkClient';
import { getHeaderLinkDropdownClassName } from './HeaderLinkStyles';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -20,45 +14,21 @@ export async function HeaderLink(props: {
const { customization } = context;
const target = link.to ? await resolveContentRef(link.to, context) : null;
const headerPreset = customization.header.preset;
const linkStyle = link.style ?? 'link';
const title = getLocalizedTitle(link, context.locale);
if (link.links && link.links.length > 0) {
return (
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={!!target}
linkTarget={link.to}
linkStyle={linkStyle}
href={target?.href}
isSiteAuthLoginHref={
target ? isSiteAuthLoginHref(context.linker, target.href) : false
}
dropdownClassName={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</HeaderLinkDropdown>
);
}
if (!link.to) {
return null;
}
return (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown={false}
<HeaderLinkItem
link={link}
locale={context.locale}
headerPreset={customization.header.preset}
hasTarget={!!target}
href={target?.href}
isSiteAuthLoginHref={target ? isSiteAuthLoginHref(context.linker, target.href) : false}
/>
dropdownClassName={getHeaderLinkDropdownClassName(customization.styling.search)}
>
{link.links?.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</HeaderLinkItem>
);
}
@@ -74,21 +44,12 @@ async function SubHeaderLink(props: {
return null;
}
const title = getLocalizedTitle(link, context.locale);
const sharedProps = {
href: target.href,
insights: {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
},
};
return isSiteAuthLoginHref(context.linker, target.href) ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps}>{title}</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
return (
<SubHeaderLinkItem
link={link}
locale={context.locale}
href={target.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
);
}
@@ -0,0 +1,124 @@
'use client';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
type TranslationLanguage,
} from '@gitbook/api';
import type React from 'react';
import { getLocalizedTitle } from '@/lib/sites';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem, DropdownSubMenu } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
type HeaderLinkStyle = 'link' | 'button-secondary' | 'button-primary';
export function HeaderLinkItem(props: {
link: CustomizationHeaderItem;
locale: TranslationLanguage | undefined;
headerPreset: CustomizationHeaderPreset;
dropdownClassName: string | null;
href?: string;
hasTarget: boolean;
isSiteAuthLoginHref?: boolean;
children?: React.ReactNode;
}) {
const {
link,
locale,
headerPreset,
dropdownClassName,
href,
hasTarget,
isSiteAuthLoginHref = false,
children,
} = props;
const linkStyle = (link.style ?? 'link') satisfies HeaderLinkStyle;
const title = getLocalizedTitle(link, locale);
if (link.links && link.links.length > 0) {
return (
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={hasTarget}
linkTarget={link.to ?? null}
linkStyle={linkStyle}
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
dropdownClassName={dropdownClassName ?? ''}
>
{children}
</HeaderLinkDropdown>
);
}
if (!link.to) {
return null;
}
return (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown={false}
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
/>
);
}
export function SubHeaderLinkItem(props: {
link: CustomizationContentLink;
locale: TranslationLanguage | undefined;
href?: string;
isSiteAuthLoginHref?: boolean;
}) {
return <HeaderLinkMenuItem {...props} />;
}
export function HeaderLinkSubMenu(props: {
link: CustomizationHeaderItem;
locale: TranslationLanguage | undefined;
children: React.ReactNode;
}) {
const { link, locale, children } = props;
const title = getLocalizedTitle(link, locale);
return <DropdownSubMenu label={title}>{children}</DropdownSubMenu>;
}
export function HeaderLinkMenuItem(props: {
link: CustomizationHeaderItem | CustomizationContentLink;
locale: TranslationLanguage | undefined;
href?: string;
isSiteAuthLoginHref?: boolean;
}) {
const { link, locale, href, isSiteAuthLoginHref = false } = props;
const title = getLocalizedTitle(link, locale);
const sharedProps = {
href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return isSiteAuthLoginHref && href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={href}>
{title}
</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
);
}
@@ -1,25 +1,19 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
type CustomizationHeaderItem,
SiteInsightsLinkPosition,
type SiteSocialAccount,
import type {
CustomizationContentLink,
CustomizationHeaderItem,
SiteSocialAccount,
} from '@gitbook/api';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { SocialAccountLink } from '../Footer/SocialAccounts';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { DropdownMenuSeparator } from '../primitives/DropdownMenu';
import { HeaderLinkMenuItem, HeaderLinkSubMenu } from './HeaderLinkClient';
import { HeaderLinkMoreDropdown } from './HeaderLinkMoreClient';
import { getHeaderLinkMoreDropdownClassName } from './HeaderLinkStyles';
import styles from './headerLinks.module.css';
/**
@@ -37,9 +31,8 @@ export function HeaderLinkMore(props: {
<div className={`${styles.linkEllipsis} z-20 items-center`}>
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
'max-md:right-0 max-md:left-auto',
context.customization.styling.search === 'prominent' && 'right-0 left-auto'
dropdownClassName={getHeaderLinkMoreDropdownClassName(
context.customization.styling.search
)}
>
{links.map((link, index) => (
@@ -63,32 +56,20 @@ async function MoreMenuLink(props: {
}) {
const { context, link } = props;
const title = getLocalizedTitle(link, context.locale);
const target = link.to ? await resolveContentRef(link.to, context) : null;
const sharedProps = {
href: target?.href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return 'links' in link && link.links.length > 0 ? (
<DropdownSubMenu label={title}>
<HeaderLinkSubMenu link={link} locale={context.locale}>
{link.links.map((subLink, index) => {
return <MoreMenuLink key={index} {...props} link={subLink} />;
})}
</DropdownSubMenu>
) : isSiteAuthLoginHref(context.linker, target?.href) && sharedProps.href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={sharedProps.href}>
{title}
</SiteAuthLoginDropdownMenuItem>
</HeaderLinkSubMenu>
) : (
<DropdownMenuItem {...sharedProps}>{title}</DropdownMenuItem>
<HeaderLinkMenuItem
link={link}
locale={context.locale}
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target?.href)}
/>
);
}
@@ -0,0 +1,17 @@
import { CustomizationSearchStyle } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
export function getHeaderLinkDropdownClassName(searchStyle: CustomizationSearchStyle) {
return tcls(
'shrink',
searchStyle === CustomizationSearchStyle.Prominent && 'right-0 left-auto'
);
}
export function getHeaderLinkMoreDropdownClassName(searchStyle: CustomizationSearchStyle) {
return tcls(
'max-md:right-0 max-md:left-auto',
searchStyle === CustomizationSearchStyle.Prominent && 'right-0 left-auto'
);
}
@@ -9,7 +9,7 @@ interface HeaderLinksProps {
style?: ClassValue;
}
export async function HeaderLinks({ children, style }: HeaderLinksProps) {
export function HeaderLinks({ children, style }: HeaderLinksProps) {
return (
<div
className={tcls(
@@ -1,11 +1,16 @@
import type { GitBookSiteContext } from '@/lib/context';
import { Image } from '@/components/utils';
import { tcls } from '@/lib/tailwind';
import { resolveContentRef } from '@/lib/references';
import { Link } from '../primitives';
import { CurrentContentIcon } from './CurrentContentIcon';
import {
HEADER_LOGO_CONTAINER_CLASS,
HEADER_LOGO_IMAGE_CLASS,
HEADER_LOGO_IMAGE_SIZES,
HeaderLogoContent,
} from './HeaderLogoContent';
interface HeaderLogoProps {
context: GitBookSiteContext;
@@ -26,83 +31,56 @@ export async function HeaderLogo(props: HeaderLogoProps) {
return (
<Link
href={primaryLink?.href ?? linker.toPathInSite('')}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
className={HEADER_LOGO_CONTAINER_CLASS}
>
{customization.header.logo ? (
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.header.logo.light,
},
dark: customization.header.logo.dark
? {
src: customization.header.logo.dark,
}
: null,
}}
sizes={[
{
media: '(max-width: 1024px)',
width: 160,
},
{
width: 260,
},
]}
preload
style={tcls(
'overflow-hidden',
'shrink',
'min-w-0',
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-8',
'h-full',
'w-full',
'object-contain',
'object-left'
)}
/>
) : (
<LogoFallback {...props} />
)}
<HeaderLogoContent
logo={customization.header.logo ? <LogoImage context={context} /> : null}
fallbackIcon={<LogoFallbackIcon context={context} />}
title={context.site.title}
/>
</Link>
);
}
function LogoFallback(props: HeaderLogoProps) {
function LogoImage(props: HeaderLogoProps) {
const { context } = props;
const { site } = context;
const { customization } = context;
if (!customization.header.logo) {
return null;
}
return (
<>
<CurrentContentIcon
context={context}
alt=""
sizes={[{ width: 32 }]}
style={['object-contain', 'size-8']}
fetchPriority="high"
/>
<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'
)}
>
{site.title}
</div>
</>
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.header.logo.light,
},
dark: customization.header.logo.dark
? {
src: customization.header.logo.dark,
}
: null,
}}
sizes={HEADER_LOGO_IMAGE_SIZES}
preload
style={HEADER_LOGO_IMAGE_CLASS}
/>
);
}
function LogoFallbackIcon(props: HeaderLogoProps) {
const { context } = props;
return (
<CurrentContentIcon
context={context}
alt=""
sizes={[{ width: 32 }]}
style={['object-contain', 'size-8']}
fetchPriority="high"
/>
);
}
@@ -0,0 +1,78 @@
import type { ReactNode } from 'react';
import { tcls } from '@/lib/tailwind';
export const HEADER_LOGO_IMAGE_SIZES = [
{
media: '(max-width: 1024px)',
width: 160,
},
{
width: 260,
},
];
export const HEADER_LOGO_CONTAINER_CLASS = tcls(
'group/headerlogo',
'min-w-0',
'shrink',
'flex',
'items-center'
);
export const HEADER_LOGO_IMAGE_CLASS = tcls(
'overflow-hidden',
'shrink',
'min-w-0',
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-8',
'h-full',
'w-full',
'object-contain',
'object-left'
);
interface HeaderLogoContentProps {
logo: ReactNode | null;
fallbackIcon: ReactNode;
title: ReactNode;
}
export function HeaderLogoContent(props: HeaderLogoContentProps) {
const { logo, fallbackIcon, title } = props;
if (logo) {
return logo;
}
return (
<>
{fallbackIcon}
<HeaderLogoTitle>{title}</HeaderLogoTitle>
</>
);
}
function HeaderLogoTitle(props: { children: ReactNode }) {
return (
<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'
)}
>
{props.children}
</div>
);
}
@@ -2,17 +2,15 @@ import type { SiteSpace } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getSiteSpaceURL } from '@/lib/sites';
import type { ButtonProps } from '../primitives';
import { SpacesDropdownClient } from './SpacesDropdownClient';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
function startsWithEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
import {
getSlimSiteSpaces,
getSpacesDropdownMenuClassName,
getSpacesDropdownTitle,
getTranslationsDropdownClassName,
} from './SpacesDropdownData';
export function SpacesDropdown(props: {
context: GitBookSiteContext;
@@ -25,26 +23,20 @@ export function SpacesDropdown(props: {
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
const slimSpaces = getSlimSiteSpaces({
siteSpace,
siteSpaces,
currentLanguage,
getURL: (siteSp) => getSiteSpaceURL(context, siteSp),
});
return (
<SpacesDropdownClient
title={getLocalizedTitle(siteSpace, currentLanguage)}
title={getSpacesDropdownTitle(siteSpace, currentLanguage)}
icon={icon}
variant={variant}
className={className}
dropdownClassName={dropdownClassName}
dropdownClassName={getSpacesDropdownMenuClassName()}
slimSpaces={slimSpaces}
curPath={siteSpace.path}
/>
@@ -59,8 +51,7 @@ export function TranslationsDropdown(props: {
}) {
const { context, siteSpace, siteSpaces, className } = props;
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
const title = getSpacesDropdownTitle(siteSpace, context.locale);
return (
<SpacesDropdown
@@ -69,13 +60,7 @@ export function TranslationsDropdown(props: {
siteSpace={siteSpace}
siteSpaces={siteSpaces}
variant="blank"
className={tcls(
'-mx-3 bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '',
className
)}
className={getTranslationsDropdownClassName({ title, className })}
/>
);
}
@@ -5,6 +5,7 @@ import type { IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import type { SlimSiteSpace } from './SpacesDropdownData';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
/**
@@ -17,16 +18,12 @@ export function SpacesDropdownClient(props: {
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: Array<{
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}>;
slimSpaces: SlimSiteSpace[];
curPath: string;
clickable?: boolean;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath, clickable } =
props;
return (
<DropdownMenu
@@ -44,7 +41,11 @@ export function SpacesDropdownClient(props: {
</Button>
}
>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
<SpacesDropdownMenuItems
slimSpaces={slimSpaces}
curPath={curPath}
clickable={clickable}
/>
</DropdownMenu>
);
}
@@ -0,0 +1,66 @@
import type { SiteSpace, TranslationLanguage } from '@gitbook/api';
import { getLocalizedTitle } from '@/lib/sites';
import { type ClassValue, tcls } from '@/lib/tailwind';
export type SlimSiteSpace = {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
};
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
function startsWithEmoji(text: string): boolean {
return EMOJI_REGEX.test(text);
}
export function getSpacesDropdownTitle(
siteSpace: SiteSpace,
currentLanguage: TranslationLanguage | undefined
) {
return getLocalizedTitle(siteSpace, currentLanguage);
}
export function getSlimSiteSpaces(props: {
siteSpace: SiteSpace;
siteSpaces: SiteSpace[];
currentLanguage: TranslationLanguage | undefined;
getURL: (siteSpace: SiteSpace) => string;
}): SlimSiteSpace[] {
const { siteSpace, siteSpaces, currentLanguage, getURL } = props;
return siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getSpacesDropdownTitle(siteSp, currentLanguage),
url: getURL(siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
}
export function getTranslationsDropdownClassName(props: {
title: string;
className?: ClassValue;
}) {
const { title, className } = props;
const hasEmojiPrefix = startsWithEmoji(title);
return tcls(
'-mx-3 bg-transparent lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '',
className
);
}
export function getSpacesDropdownMenuClassName() {
return tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
}
@@ -3,19 +3,16 @@
import { joinPath } from '@/lib/paths';
import { useCurrentPageMetadata, useCurrentPagePath } from '../hooks';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
interface VariantSpace {
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}
import type { SlimSiteSpace } from './SpacesDropdownData';
/**
* Return the href for a variant space, taking into account the current page path and metadata.
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
function useVariantSpaceHref(
variantSpace: SlimSiteSpace,
currentSpacePath: string,
active = false
) {
const currentPathname = useCurrentPagePath();
const { metaLinks } = useCurrentPageMetadata();
@@ -52,7 +49,7 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
}
export function SpacesDropdownMenuItem(props: {
variantSpace: VariantSpace;
variantSpace: SlimSiteSpace;
active: boolean;
currentSpacePath: string;
}) {
@@ -66,22 +63,40 @@ export function SpacesDropdownMenuItem(props: {
);
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: VariantSpace[];
curPath: string;
function StaticSpacesDropdownMenuItem(props: {
variantSpace: SlimSiteSpace;
active: boolean;
}) {
const { slimSpaces, curPath } = props;
const { variantSpace, active } = props;
return <DropdownMenuItem active={active}>{variantSpace.title}</DropdownMenuItem>;
}
export function SpacesDropdownMenuItems(props: {
slimSpaces: SlimSiteSpace[];
curPath: string;
clickable?: boolean;
}) {
const { slimSpaces, curPath, clickable = true } = props;
return (
<>
{slimSpaces.map((space) => (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
currentSpacePath={curPath}
/>
))}
{slimSpaces.map((space) =>
clickable ? (
<SpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
currentSpacePath={curPath}
/>
) : (
<StaticSpacesDropdownMenuItem
key={space.id}
variantSpace={space}
active={space.isActive}
/>
)
)}
</>
);
}
@@ -11,6 +11,7 @@ import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SideSheet } from '../primitives/SideSheet';
import { SearchFrame } from './SearchFrame';
import { SearchHeaderInput } from './SearchHeaderInput';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
@@ -221,10 +222,10 @@ export function SearchContainer({
asChild: true,
}}
>
<SearchInput
<SearchHeaderInput
ref={searchInputRef}
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
activeDescendant={searchResultsActiveDescendant}
controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
@@ -235,12 +236,7 @@ export function SearchContainer({
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
/>
</Popover>
)}
{usesSideSheet ? (
@@ -0,0 +1,75 @@
'use client';
import { tcls } from '@/lib/tailwind';
import React from 'react';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
export interface SearchHeaderInputProps {
activeDescendant?: string;
controls?: string;
className?: string;
fetching?: boolean;
interactive?: boolean;
isOpen?: boolean;
onChange?: (value: string) => void;
onFocus?: () => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
resultsCount?: number;
showAsk?: boolean;
value?: string;
withAI?: boolean;
}
const noop = () => {};
/**
* Header search input visual used by the live site and structure preview.
*/
export const SearchHeaderInput = React.forwardRef<HTMLDivElement, SearchHeaderInputProps>(
function SearchHeaderInput(props, ref) {
const {
activeDescendant,
controls,
className,
fetching = false,
interactive = true,
isOpen = false,
onChange = noop,
onFocus,
onKeyDown,
resultsCount = 0,
showAsk = false,
value = '',
withAI = false,
} = props;
return (
<SearchInput
ref={ref}
aria-activedescendant={activeDescendant}
aria-controls={controls}
onChange={interactive ? onChange : noop}
onKeyDown={interactive && onKeyDown ? onKeyDown : noop}
value={value}
withAI={withAI}
isOpen={interactive && isOpen}
className={tcls(className, !interactive ? 'pointer-events-none select-none' : null)}
onFocus={interactive ? onFocus : undefined}
resultsCount={resultsCount}
fetching={fetching}
showAsk={showAsk}
readOnly={!interactive}
tabIndex={interactive ? undefined : -1}
>
{interactive ? (
<SearchLiveResultsAnnouncer
count={resultsCount}
showing={Boolean(value) && !fetching}
/>
) : null}
</SearchInput>
);
}
);
@@ -21,6 +21,8 @@ interface SearchInputProps {
resultsCount: number;
fetching: boolean;
showAsk: boolean;
readOnly?: boolean;
tabIndex?: number;
}
/**
@@ -1,4 +1,5 @@
export * from './SearchInput';
export * from './SearchHeaderInput';
export * from './SearchFrame';
export * from './SearchLiveResultsAnnouncer';
export * from './SearchContainer';
@@ -82,6 +82,7 @@ export function SiteSectionListItem(props: {
href={section.url}
aria-current={isActive && 'page'}
id={section.id}
data-gb-site-section-id={section.id}
className={tcls(
'group/section-link',
'flex',
@@ -32,11 +32,13 @@ export function SiteSectionTabs(props: {
sections: ClientSiteSections;
className?: string;
children?: React.ReactNode;
disableAnimations?: boolean;
}) {
const {
sections: { list: structure, current: currentSection },
className,
children,
disableAnimations,
} = props;
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -130,7 +132,9 @@ export function SiteSectionTabs(props: {
<NavigationMenu.Content
className={tcls([
'absolute top-0 left-0 w-full md:w-auto',
'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]',
!disableAnimations
? 'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]'
: '',
])}
>
<div className="max-h-[calc(100vh-8rem)] w-full overflow-y-auto overflow-x-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl">
@@ -149,6 +153,11 @@ export function SiteSectionTabs(props: {
? structureItem.url
: undefined
}
sectionId={
structureItem.object === 'site-section'
? structureItem.id
: undefined
}
isActive={isActive}
title={title}
icon={icon ? (icon as IconName) : undefined}
@@ -173,8 +182,10 @@ export function SiteSectionTabs(props: {
// inside an iframe or `overflow-hidden` ancestor. Clipping is done on the inner content wrapper instead.
'relative origin-[center_top] circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg',
'-mt-0.5 h-(--radix-navigation-menu-viewport-height) w-full max-w-full md:w-(--radix-navigation-menu-viewport-width)',
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300'
'max-h-[calc(100vh-8rem)]',
!disableAnimations
? 'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300 data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in'
: ''
)}
style={{
left: viewportLeft,
@@ -225,10 +236,10 @@ function useNavigationMenuViewportOffset(args: {
* A tab representing a section or section group
*/
const SectionTab = React.forwardRef(function SectionTab(
props: { isActive: boolean; title: string; icon?: IconName; url?: string },
props: { isActive: boolean; title: string; icon?: IconName; url?: string; sectionId?: string },
ref: React.Ref<HTMLAnchorElement>
) {
const { isActive, title, icon, url, ...rest } = props;
const { isActive, title, icon, url, sectionId, ...rest } = props;
const isGroup = url === undefined;
return (
<Button
@@ -240,6 +251,7 @@ const SectionTab = React.forwardRef(function SectionTab(
label={title}
trailing={isGroup ? <ToggleChevron /> : null}
active={isActive}
data-gb-site-section-id={sectionId}
className={tcls(
'group/dropdown relative my-1.5 overflow-visible',
isActive
@@ -348,6 +360,7 @@ function SectionGroupTile(props: {
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-column-width)]">
<Link
href={url}
data-gb-site-section-id={child.id}
className={tcls(
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-2.5 py-1.5 transition-colors',
isActive
@@ -9,6 +9,10 @@ import type React from 'react';
import { Footer } from '@/components/Footer';
import { Header, HeaderLogo } from '@/components/Header';
import { TableOfContents } from '@/components/TableOfContents';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsInnerHeaderClassName,
} from '@/components/TableOfContents/styles';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
@@ -197,10 +201,9 @@ export function SpaceLayout(props: SpaceLayoutProps) {
innerHeader={
!withTopHeader || variants.generic.length > 1 ? (
<div
className={tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
variants.generic.length > 1 ? '' : 'max-lg:hidden'
)}
className={getTableOfContentsInnerHeaderClassName({
hideOnMobile: variants.generic.length <= 1,
})}
>
{!withTopHeader && (
<div className="flex gap-2 max-lg:hidden">
@@ -225,7 +228,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
context={context}
siteSpace={siteSpace}
siteSpaces={variants.generic}
className="w-full px-3"
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
@@ -2,10 +2,15 @@ import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteSpaceLanguages, normalizeLanguage } from '@/lib/sites';
type SiteSpaceVariantsContext = Pick<
GitBookSiteContext,
'locale' | 'siteSpace' | 'siteSpaces' | 'visibleSiteSpaces'
>;
/**
* Categorize the variants of the space into generic and translation variants.
*/
export function categorizeVariants(context: GitBookSiteContext) {
export function categorizeVariants(context: SiteSpaceVariantsContext) {
const { siteSpace } = context;
// By default, variants only include visible spaces.
@@ -0,0 +1,603 @@
'use client';
import type { ContentRef, CustomizationContentLink, CustomizationHeaderItem } from '@gitbook/api';
import {
CustomizationAIMode,
CustomizationHeaderPreset,
CustomizationSearchStyle,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { SiteSectionList, SiteSectionTabs } from '@/components/SiteSections';
import {
TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS,
getTableOfContentsClassName,
getTableOfContentsInnerHeaderClassName,
getTableOfContentsSidebarClassName,
} from '@/components/TableOfContents/styles';
import { Image } from '@/components/utils';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { AIChatButtonView, AIChatIcon, getAIChatName } from '../AIChat';
import { HeaderLayout } from '../Header/HeaderLayout';
import {
HeaderLinkItem,
HeaderLinkMenuItem,
HeaderLinkSubMenu,
SubHeaderLinkItem,
} from '../Header/HeaderLinkClient';
import { HeaderLinkMoreDropdown } from '../Header/HeaderLinkMoreClient';
import {
getHeaderLinkDropdownClassName,
getHeaderLinkMoreDropdownClassName,
} from '../Header/HeaderLinkStyles';
import { HeaderLinks } from '../Header/HeaderLinks';
import {
HEADER_LOGO_CONTAINER_CLASS,
HEADER_LOGO_IMAGE_CLASS,
HEADER_LOGO_IMAGE_SIZES,
HeaderLogoContent,
} from '../Header/HeaderLogoContent';
import {
getSpacesDropdownMenuClassName,
getTranslationsDropdownClassName,
} from '../Header/SpacesDropdownData';
import headerLinksStyles from '../Header/headerLinks.module.css';
import { SearchHeaderInput } from '../Search';
import { CONTAINER_STYLE, CONTENT_STYLE } from '../layout';
import {
Button,
type ButtonProps,
SkeletonHeading,
SkeletonImage,
SkeletonParagraph,
ToggleChevron,
} from '../primitives';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives/DropdownMenu';
import {
SOCIAL_PLATFORM_ICONS,
isStructurePreviewMessage,
selectStructurePreviewSection,
} from './state';
import type {
PreviewContentLink,
PreviewDropdownSpace,
PreviewHeaderLink,
StructurePreviewNavigationMessage,
StructurePreviewSnapshot,
} from './types';
const PREVIEW_CONTENT_REF = {
kind: 'url',
url: '#',
} as ContentRef;
const SOCIAL_PLATFORM_LABELS: Partial<
Record<StructurePreviewSnapshot['customization']['socialAccounts'][number]['platform'], string>
> = {
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 StructurePreview(props: {
initialSnapshot: StructurePreviewSnapshot;
GITBOOK_APP_URL: string;
}) {
const { initialSnapshot, GITBOOK_APP_URL } = props;
const [snapshot, setSnapshot] = React.useState(initialSnapshot);
React.useEffect(() => {
const handleMessage = (event: MessageEvent<unknown>) => {
if (
event.source !== window.parent ||
event.origin !== GITBOOK_APP_URL ||
!isStructurePreviewMessage(event.data)
) {
return;
}
const message = event.data;
setSnapshot((currentSnapshot) => ({
...currentSnapshot,
...message.payload,
}));
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
const postNavigationChange = (sectionId: string) => {
const message: StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate',
payload: { sectionId },
};
window.parent.postMessage(message, GITBOOK_APP_URL);
};
const preventNavigation = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target;
if (!(target instanceof Element)) {
return null;
}
const anchor = target.closest('a');
if (!anchor) {
return null;
}
event.preventDefault();
event.stopPropagation();
return anchor;
};
const fakeSectionNavigation = (event: React.MouseEvent<HTMLElement>) => {
const anchor = preventNavigation(event);
const sectionId = anchor?.getAttribute('data-gb-site-section-id');
if (!sectionId) {
return;
}
setSnapshot((currentSnapshot) => {
const nextSnapshot = selectStructurePreviewSection(currentSnapshot, sectionId);
if (nextSnapshot !== currentSnapshot) {
postNavigationChange(sectionId);
}
return nextSnapshot;
});
};
return (
<div
data-gb-structure-preview
data-viewport-mode="desktop"
onClickCapture={fakeSectionNavigation}
onAuxClickCapture={preventNavigation}
>
<StructurePreviewHeader snapshot={snapshot} />
<div className={tcls('flex gap-8', CONTAINER_STYLE)}>
<StructurePreviewVariantSelector snapshot={snapshot} />
<div className={tcls('my-8 flex min-w-xl grow flex-col gap-8', CONTENT_STYLE)}>
<SkeletonHeading animated={false} />
<SkeletonParagraph lines={4} animated={false} />
<SkeletonParagraph lines={5} animated={false} start={4} />
<SkeletonImage animated={false} />
<SkeletonParagraph lines={3} animated={false} start={9} />
<SkeletonParagraph lines={2} animated={false} start={12} />
</div>
</div>
</div>
);
}
function StructurePreviewHeader(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
const language = useLanguage();
const { variants, sections } = snapshot;
const headerSocialAccounts = customization.socialAccounts;
const previewAssistants = getPreviewAssistants(snapshot, language);
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(
sections &&
(sections.list.length > 1 ||
sections.list.some((section) => section.object === 'site-section-group'))
);
return (
<HeaderLayout
withTopHeader={withTopHeader}
searchStyle={customization.styling.search}
leading={<StructurePreviewLogo snapshot={snapshot} />}
search={
<>
<StructurePreviewSearch />
{previewAssistants.map((assistant, index) => (
<AIChatButtonView
key={assistant.id}
icon={assistant.icon}
label={assistant.label}
withShortcut={index === 0}
showLabel={
previewAssistants.length === 1 &&
customization.styling.search === CustomizationSearchStyle.Prominent
}
inert
/>
))}
</>
}
links={
customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ||
(!withSections && variants.translations.length > 1) ? (
<HeaderLinks>
{customization.header.links.map((link, index) => (
<StructurePreviewHeaderLink
key={`${link.title}-${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}
socialAccounts={headerSocialAccounts}
snapshot={snapshot}
/>
) : null}
{!withSections && variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
siteSpaces={variants.translations}
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}
</HeaderLinks>
) : null
}
sections={
sections && withSections ? (
//TODO: figure out why enabling animations here break the rendering of what's inside the tabs
<SiteSectionTabs sections={sections} disableAnimations>
{variants.translations.length > 1 ? (
<StructurePreviewTranslationsDropdown
siteSpaces={variants.translations}
className="my-1.5 ml-2 self-start"
/>
) : null}
</SiteSectionTabs>
) : null
}
/>
);
}
function StructurePreviewLogo(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
return (
<div className={HEADER_LOGO_CONTAINER_CLASS}>
<HeaderLogoContent
logo={
customization.header.logo ? (
<StructurePreviewLogoImage logo={customization.header.logo} />
) : null
}
fallbackIcon={<StructurePreviewLogoFallbackIcon snapshot={snapshot} />}
title={snapshot.site.title}
/>
</div>
);
}
function StructurePreviewLogoImage(props: {
logo: NonNullable<StructurePreviewSnapshot['customization']['header']['logo']>;
}) {
const { logo } = props;
return (
<Image
alt="Logo"
resize={false}
sources={{
light: {
src: logo.light,
},
dark: logo.dark
? {
src: logo.dark,
}
: null,
}}
sizes={HEADER_LOGO_IMAGE_SIZES}
preload
style={HEADER_LOGO_IMAGE_CLASS}
/>
);
}
function StructurePreviewLogoFallbackIcon(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization } = snapshot;
if ('emoji' in customization.favicon && customization.favicon.emoji) {
return <span className="text-xl">{customization.favicon.emoji}</span>;
}
return (
<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>
);
}
function StructurePreviewSearch() {
return <SearchHeaderInput interactive={false} />;
}
function StructurePreviewVariantSelector(props: { snapshot: StructurePreviewSnapshot }) {
const { snapshot } = props;
const { customization, sections, variants } = snapshot;
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 withSidebarHeader = (!withTopHeader && withSections) || variants.generic.length > 1;
return (
<div data-gb-table-of-contents className={tcls(getTableOfContentsClassName(), 'max-w-xs')}>
<div className={getTableOfContentsSidebarClassName()}>
{withSidebarHeader ? (
<div className={getTableOfContentsInnerHeaderClassName()}>
{!withTopHeader && withSections && sections ? (
<SiteSectionList className="hidden lg:block" sections={sections} />
) : null}
{variants.generic.length > 1 ? (
<StructurePreviewSpacesDropdown
title={snapshot.siteSpace.title}
siteSpaces={variants.generic}
className={TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS}
/>
) : null}
</div>
) : null}
<div className="ml-5 flex flex-col gap-6">
{Array.from({ length: 4 }).map((_, group) => (
<div className="flex flex-col gap-2" key={group}>
{Array.from({ length: [3, 5, 4, 3][group] ?? 0 }).map((_, index) => (
<SkeletonParagraph
key={index}
start={group * 5 + index}
lines={1}
animated={false}
/>
))}
</div>
))}
</div>
</div>
</div>
);
}
function getPreviewAssistants(
snapshot: StructurePreviewSnapshot,
language: ReturnType<typeof useLanguage>
) {
if (snapshot.customization.ai?.mode !== CustomizationAIMode.Assistant) {
return [];
}
return [
{
id: 'gitbook-assistant',
label: getAIChatName(language, snapshot.customization.trademark.enabled),
icon: (
<AIChatIcon
state="default"
trademark={snapshot.customization.trademark.enabled}
className="size-text-lg"
/>
),
},
];
}
function StructurePreviewHeaderLink(props: {
snapshot: StructurePreviewSnapshot;
link: PreviewHeaderLink;
}) {
const { snapshot, link } = props;
const headerLink = toCustomizationHeaderItem(link);
return (
<HeaderLinkItem
link={headerLink}
locale={snapshot.locale}
headerPreset={snapshot.customization.header.preset}
href={link.hasTarget ? '#' : undefined}
hasTarget={link.hasTarget}
dropdownClassName={getHeaderLinkDropdownClassName(
snapshot.customization.styling.search
)}
>
{link.links.map((subLink, index) => (
<SubHeaderLinkItem
key={index}
link={toCustomizationContentLink(subLink)}
locale={snapshot.locale}
/>
))}
</HeaderLinkItem>
);
}
function StructurePreviewMoreMenu(props: {
snapshot: StructurePreviewSnapshot;
links: PreviewHeaderLink[];
socialAccounts: StructurePreviewSnapshot['customization']['socialAccounts'];
label: React.ReactNode;
}) {
const { snapshot, links, socialAccounts, label } = props;
return (
<div className={`${headerLinksStyles.linkEllipsis} z-20 items-center`}>
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={getHeaderLinkMoreDropdownClassName(
snapshot.customization.styling.search
)}
>
{links.map((link, index) => (
<StructurePreviewMenuLink key={index} link={link} snapshot={snapshot} />
))}
{socialAccounts.length > 0 && <DropdownMenuSeparator />}
{socialAccounts.map((account) => (
<StructurePreviewSocialAccountLink
key={`${account.platform}-${account.handle}`}
account={account}
/>
))}
</HeaderLinkMoreDropdown>
</div>
);
}
function StructurePreviewMenuLink(props: {
snapshot: StructurePreviewSnapshot;
link: PreviewHeaderLink | PreviewContentLink;
}) {
const { snapshot, link } = props;
return isPreviewHeaderLink(link) && link.links.length > 0 ? (
<HeaderLinkSubMenu link={toCustomizationHeaderItem(link)} locale={snapshot.locale}>
{link.links.map((subLink, index) => (
<StructurePreviewMenuLink key={index} link={subLink} snapshot={snapshot} />
))}
</HeaderLinkSubMenu>
) : (
<HeaderLinkMenuItem
link={
isPreviewHeaderLink(link)
? toCustomizationHeaderItem(link)
: toCustomizationContentLink(link)
}
locale={snapshot.locale}
/>
);
}
function StructurePreviewSocialAccountLink(props: {
account: StructurePreviewSnapshot['customization']['socialAccounts'][number];
}) {
const { account } = props;
const icon = SOCIAL_PLATFORM_ICONS[account.platform];
if (!icon) {
return null;
}
return (
<DropdownMenuItem leadingIcon={icon}>
{SOCIAL_PLATFORM_LABELS[account.platform] ?? account.platform}
</DropdownMenuItem>
);
}
function isPreviewHeaderLink(
link: PreviewHeaderLink | PreviewContentLink
): link is PreviewHeaderLink {
return 'links' in link;
}
function toCustomizationHeaderItem(link: PreviewHeaderLink): CustomizationHeaderItem {
return {
title: link.title,
style: link.style,
to: link.hasTarget ? PREVIEW_CONTENT_REF : null,
links: link.links.map(toCustomizationContentLink),
} as CustomizationHeaderItem;
}
function toCustomizationContentLink(link: PreviewContentLink): CustomizationContentLink {
return {
title: link.title,
to: link.hasTarget ? PREVIEW_CONTENT_REF : undefined,
} as CustomizationContentLink;
}
function StructurePreviewTranslationsDropdown(props: {
siteSpaces: PreviewDropdownSpace[];
className?: string;
}) {
const { siteSpaces, className } = props;
const title = siteSpaces.find((siteSpace) => siteSpace.isActive)?.title ?? siteSpaces[0]?.title;
if (!title) {
return null;
}
return (
<StructurePreviewSpacesDropdown
title={title}
siteSpaces={siteSpaces}
icon="globe"
variant="blank"
className={getTranslationsDropdownClassName({ title, className })}
/>
);
}
function StructurePreviewSpacesDropdown(props: {
title: string;
siteSpaces: PreviewDropdownSpace[];
className?: ButtonProps['className'];
icon?: IconName;
variant?: ButtonProps['variant'];
}) {
const { title, siteSpaces, className, icon, variant = 'secondary' } = props;
return (
<DropdownMenu
className={getSpacesDropdownMenuClassName()}
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((siteSpace) => (
<DropdownMenuItem key={siteSpace.id} active={siteSpace.isActive}>
{siteSpace.title}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
@@ -0,0 +1,2 @@
export * from './StructurePreview';
export * from './types';
@@ -0,0 +1,313 @@
import { describe, expect, it } from 'bun:test';
import { getStructurePreviewSnapshot } from '@/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot';
import { languages } from '@/intl/translations';
import type { GitBookSiteContext } from '@/lib/context';
import { defaultCustomization, findSectionInGroup } from '@/lib/utils';
import { SiteSocialAccountPlatform, TranslationLanguage } from '@gitbook/api';
import {
isStructurePreviewMessage,
isStructurePreviewNavigationMessage,
selectStructurePreviewSection,
} from './state';
function createContext(overrides: Partial<GitBookSiteContext> = {}): GitBookSiteContext {
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',
},
locale: undefined,
customization: defaultCustomization(),
siteSpace,
siteSpaces: [siteSpace],
visibleSiteSpaces: [siteSpace],
sections: null,
visibleSections: null,
linker: {
toPathInSpace: (path: string) => `/space/${path}`,
},
...overrides,
} as GitBookSiteContext;
}
describe('structure preview state', () => {
it('validates partial preview update messages without revision data', () => {
const snapshot = getStructurePreviewSnapshot(createContext());
const update = {
sections: snapshot.sections,
variants: snapshot.variants,
siteSpace: snapshot.siteSpace,
};
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: update,
})
).toBe(true);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: { sections: snapshot.sections },
})
).toBe(true);
expect('revision' in snapshot).toBe(false);
expect('structure' in snapshot).toBe(false);
expect('siteSpaces' in snapshot).toBe(false);
expect('visibleSiteSpaces' in snapshot).toBe(false);
expect(isStructurePreviewMessage({ type: 'gitbook.structure.update' })).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: snapshot,
})
).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.update',
payload: { site: snapshot.site },
})
).toBe(false);
expect(isStructurePreviewMessage({ type: 'other', payload: snapshot })).toBe(false);
expect(
isStructurePreviewMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 'section-1' },
})
).toBe(false);
});
it('validates preview navigation messages', () => {
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 'section-1' },
})
).toBe(true);
expect(isStructurePreviewNavigationMessage({ type: 'gitbook.structure.navigate' })).toBe(
false
);
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.navigate',
payload: { sectionId: 1 },
})
).toBe(false);
expect(
isStructurePreviewNavigationMessage({
type: 'gitbook.structure.update',
payload: { sectionId: 'section-1' },
})
).toBe(false);
});
it('stores pre-encoded 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 = getStructurePreviewSnapshot(
createContext({
locale: TranslationLanguage.Fr,
sections: {
list: [
{
object: 'site-section-group',
id: 'group-1',
title: 'Products',
children: [section],
},
],
current: section,
},
} as unknown as Partial<GitBookSiteContext>)
);
expect(snapshot.sections?.current.title).toBe('Guides FR');
expect(snapshot.sections?.current.url).toBe('#');
expect(snapshot.sections?.list[0]?.object).toBe('site-section-group');
});
it('stores precomputed variant groups with slim translation titles', () => {
const currentSiteSpace = {
id: 'v15-it',
title: 'v15',
path: '',
default: false,
hidden: false,
urls: {},
space: {
id: 'space-v15-it',
revision: 'revision-v15-it',
language: TranslationLanguage.It,
},
};
const siteSpaces = [
{ id: 'v20-en', title: 'v20', language: TranslationLanguage.En },
{ id: 'v20-fr', title: 'v20', language: TranslationLanguage.Fr },
{ id: 'v20-it', title: 'v20', language: TranslationLanguage.It },
{ id: 'v15-en', title: 'v15', language: TranslationLanguage.En },
{ id: 'v15-fr', title: 'v15', language: TranslationLanguage.Fr },
currentSiteSpace,
].map((siteSpace) =>
'space' in siteSpace
? siteSpace
: {
id: siteSpace.id,
title: siteSpace.title,
path: '',
default: false,
hidden: false,
urls: {},
space: {
id: `space-${siteSpace.id}`,
revision: `revision-${siteSpace.id}`,
language: siteSpace.language,
},
}
);
const snapshot = getStructurePreviewSnapshot(
createContext({
locale: TranslationLanguage.It,
siteSpace: currentSiteSpace,
siteSpaces,
visibleSiteSpaces: siteSpaces,
} as Partial<GitBookSiteContext>)
);
expect(snapshot.variants.generic.map((space) => space.id)).toEqual(['v20-it', 'v15-it']);
expect(
snapshot.variants.translations.map((space) => ({
id: space.id,
title: space.title,
isActive: space.isActive,
}))
).toEqual([
{ id: 'v15-en', title: languages.en.language, isActive: false },
{ id: 'v15-fr', title: languages.fr.language, isActive: false },
{ id: 'v15-it', title: languages.it.language, isActive: true },
]);
});
it('stores only header-visible social account fields', () => {
const customization = defaultCustomization();
customization.socialAccounts = [
{
platform: SiteSocialAccountPlatform.Github,
handle: 'gitbook',
display: { header: true, footer: true },
},
{
platform: SiteSocialAccountPlatform.Discord,
handle: 'hidden',
display: { header: false, footer: true },
},
];
const snapshot = getStructurePreviewSnapshot(createContext({ customization }));
expect(snapshot.customization.socialAccounts).toEqual([
{ platform: SiteSocialAccountPlatform.Github, handle: 'gitbook' },
]);
});
it('selects a top-level section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).not.toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('reference');
expect(nextSnapshot.sections?.current.title).toBe('Reference');
});
it('selects a nested section in the local snapshot', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'api');
const currentSection = nextSnapshot.sections?.current;
const group = nextSnapshot.sections?.list[1];
expect(currentSection?.id).toBe('api');
expect(group?.object).toBe('site-section-group');
if (!currentSection || group?.object !== 'site-section-group') {
throw new Error('Expected a nested section inside a section group');
}
expect(findSectionInGroup(group, currentSection.id)?.id).toBe('api');
});
it('keeps the current snapshot when selecting an unknown section', () => {
const snapshot = createSnapshotWithSections();
const nextSnapshot = selectStructurePreviewSection(snapshot, 'missing');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections?.current.id).toBe('intro');
});
it('keeps snapshots without sections unchanged', () => {
const snapshot = getStructurePreviewSnapshot(createContext());
const nextSnapshot = selectStructurePreviewSection(snapshot, 'reference');
expect(nextSnapshot).toBe(snapshot);
expect(nextSnapshot.sections).toBeNull();
});
});
function createSnapshotWithSections() {
const intro = createSection('intro', 'Intro');
const reference = createSection('reference', 'Reference');
const api = createSection('api', 'API');
return getStructurePreviewSnapshot(
createContext({
sections: {
list: [
intro,
{
object: 'site-section-group',
id: 'developers',
title: 'Developers',
children: [api],
},
reference,
],
current: intro,
},
} as unknown as Partial<GitBookSiteContext>)
);
}
function createSection(id: string, title: string) {
return {
object: 'site-section',
id,
title,
description: '',
path: id,
default: false,
siteSpaces: [],
urls: {},
};
}
@@ -0,0 +1,216 @@
import type { SiteSocialAccountPlatform } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type { ClientSiteSection, ClientSiteSectionGroup } from '../SiteSections';
import type {
StructurePreviewMessage,
StructurePreviewNavigationMessage,
StructurePreviewSnapshot,
StructurePreviewUpdate,
} from './types';
const STRUCTURE_PREVIEW_UPDATE_KEYS = ['sections', 'siteSpace', 'variants'] as const;
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' && isStructurePreviewUpdate(message.payload);
}
export function isStructurePreviewNavigationMessage(
value: unknown
): value is StructurePreviewNavigationMessage {
if (!value || typeof value !== 'object') {
return false;
}
const message = value as Partial<StructurePreviewNavigationMessage>;
const payload = message.payload as Partial<StructurePreviewNavigationMessage['payload']>;
return message.type === 'gitbook.structure.navigate' && typeof payload?.sectionId === 'string';
}
export function isStructurePreviewUpdate(value: unknown): value is StructurePreviewUpdate {
if (!value || typeof value !== 'object') {
return false;
}
const update = value as Partial<StructurePreviewUpdate>;
const keys = Object.keys(update);
if (
keys.length === 0 ||
keys.some(
(key) =>
!STRUCTURE_PREVIEW_UPDATE_KEYS.includes(
key as (typeof STRUCTURE_PREVIEW_UPDATE_KEYS)[number]
)
)
) {
return false;
}
return (
(!('sections' in update) || isStructurePreviewSections(update.sections)) &&
(!('siteSpace' in update) || isStructurePreviewSiteSpace(update.siteSpace)) &&
(!('variants' in update) || isStructurePreviewVariants(update.variants))
);
}
export function selectStructurePreviewSection(
snapshot: StructurePreviewSnapshot,
sectionId: string
): StructurePreviewSnapshot {
const sections = snapshot.sections;
if (!sections || sections.current.id === sectionId) {
return snapshot;
}
const selectedSection = findPreviewSection(sections.list, sectionId);
if (!selectedSection) {
return snapshot;
}
return {
...snapshot,
sections: {
...sections,
current: selectedSection,
},
};
}
function findPreviewSection(
items: (ClientSiteSection | ClientSiteSectionGroup)[],
sectionId: string
): ClientSiteSection | null {
for (const item of items) {
if (item.object === 'site-section') {
if (item.id === sectionId) {
return item;
}
continue;
}
const childSection = findPreviewSection(item.children, sectionId);
if (childSection) {
return childSection;
}
}
return null;
}
function isStructurePreviewSections(value: unknown): value is StructurePreviewSnapshot['sections'] {
if (value === null) {
return true;
}
if (!value || typeof value !== 'object') {
return false;
}
const sections = value as Partial<NonNullable<StructurePreviewSnapshot['sections']>>;
return (
Array.isArray(sections.list) &&
sections.list.every(isStructurePreviewSectionItem) &&
isStructurePreviewSection(sections.current)
);
}
function isStructurePreviewSectionItem(
value: unknown
): value is ClientSiteSection | ClientSiteSectionGroup {
return isStructurePreviewSection(value) || isStructurePreviewSectionGroup(value);
}
function isStructurePreviewSection(value: unknown): value is ClientSiteSection {
if (!value || typeof value !== 'object') {
return false;
}
const section = value as Partial<ClientSiteSection>;
return (
section.object === 'site-section' &&
typeof section.id === 'string' &&
typeof section.title === 'string' &&
typeof section.url === 'string'
);
}
function isStructurePreviewSectionGroup(value: unknown): value is ClientSiteSectionGroup {
if (!value || typeof value !== 'object') {
return false;
}
const group = value as Partial<ClientSiteSectionGroup>;
return (
group.object === 'site-section-group' &&
typeof group.id === 'string' &&
typeof group.title === 'string' &&
Array.isArray(group.children) &&
group.children.every(isStructurePreviewSectionItem)
);
}
function isStructurePreviewSiteSpace(
value: unknown
): value is StructurePreviewSnapshot['siteSpace'] {
if (!value || typeof value !== 'object') {
return false;
}
const siteSpace = value as Partial<StructurePreviewSnapshot['siteSpace']>;
return (
typeof siteSpace.id === 'string' &&
typeof siteSpace.title === 'string' &&
typeof siteSpace.path === 'string'
);
}
function isStructurePreviewVariants(value: unknown): value is StructurePreviewSnapshot['variants'] {
if (!value || typeof value !== 'object') {
return false;
}
const variants = value as Partial<StructurePreviewSnapshot['variants']>;
return (
Array.isArray(variants.generic) &&
variants.generic.every(isPreviewDropdownSpace) &&
Array.isArray(variants.translations) &&
variants.translations.every(isPreviewDropdownSpace)
);
}
function isPreviewDropdownSpace(
value: unknown
): value is StructurePreviewSnapshot['variants']['generic'][number] {
if (!value || typeof value !== 'object') {
return false;
}
const siteSpace = value as Partial<StructurePreviewSnapshot['variants']['generic'][number]>;
return (
typeof siteSpace.id === 'string' &&
typeof siteSpace.title === 'string' &&
typeof siteSpace.isActive === 'boolean'
);
}
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',
};
@@ -0,0 +1,93 @@
import type {
CustomizationAIMode,
CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationSearchStyle,
SiteSocialAccountPlatform,
TranslationLanguage,
} from '@gitbook/api';
import type { ClientSiteSections } from '@/components/SiteSections';
export type PreviewHeaderLink = {
title: string;
style?: CustomizationHeaderItem['style'];
hasTarget: boolean;
links: PreviewContentLink[];
};
export type PreviewContentLink = {
title: string;
hasTarget: boolean;
};
export type PreviewDropdownSpace = {
id: string;
title: string;
isActive: boolean;
};
export type StructurePreviewSnapshot = {
site: {
title: string;
};
locale?: TranslationLanguage;
customization: {
styling: {
search: CustomizationSearchStyle;
};
favicon: {
emoji?: string;
};
header: {
preset: CustomizationHeaderPreset;
logo?: {
light: string;
dark?: string;
};
links: PreviewHeaderLink[];
};
ai: {
mode: CustomizationAIMode;
};
trademark: {
enabled: boolean;
};
socialAccounts: {
platform: SiteSocialAccountPlatform;
handle: string;
}[];
};
siteSpace: {
id: string;
title: string;
path: string;
};
variants: {
generic: PreviewDropdownSpace[];
translations: PreviewDropdownSpace[];
};
sections: ClientSiteSections | null;
icons: {
large: {
light: string;
dark: string;
};
};
};
export type StructurePreviewUpdate = Partial<
Pick<StructurePreviewSnapshot, 'sections' | 'siteSpace' | 'variants'>
>;
export type StructurePreviewMessage = {
type: 'gitbook.structure.update';
payload: StructurePreviewUpdate;
};
export type StructurePreviewNavigationMessage = {
type: 'gitbook.structure.navigate';
payload: {
sectionId: string;
};
};
@@ -9,6 +9,7 @@ import { PagesList } from './PagesList';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
import { getTableOfContentsClassName, getTableOfContentsSidebarClassName } from './styles';
/**
* Sidebar container, responsible for setting the right dimensions and position for the sidebar.
@@ -34,90 +35,11 @@ export async function TableOfContents(props: {
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',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
'lg:mr-12',
'lg:z-0',
'layout-wide:no-sidebar:lg:fixed',
'layout-wide:no-sidebar:lg:max-3xl:w-12',
'layout-wide:no-sidebar:lg:left-5',
'layout-wide:no-sidebar:lg:z-30',
'layout-default:no-sidebar:lg:max-xl:fixed',
'layout-default:no-sidebar:lg:max-xl:w-12',
'layout-default:no-sidebar:lg:max-xl:left-5',
'layout-default:no-sidebar:lg:max-xl:z-30',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'lg:announcement:h-[calc(100vh-4.25rem)]',
// With header
'lg:site-header:top-16',
'lg:site-header:h-[calc(100vh-4rem)]',
'lg:announcement:site-header:h-[calc(100vh-4rem-4.25rem)]',
'lg:site-header-sections:top-27',
'lg:site-header-sections:h-[calc(100vh-6.75rem)]',
'lg:site-header-sections:announcement:h-[calc(100vh-6.75rem-4.25rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:h-(--outline-height)!',
'pt-6 pb-4',
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]', // Override bottom padding on iOS since we have a transparent bottom bar
'lg:max-3xl:has-sidebar:sidebar-filled:layout-default:pr-6',
'max-lg:pl-8',
'flex',
'flex-col',
'min-h-0',
'gap-4',
className
)}
className={getTableOfContentsClassName(className)}
>
{header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'-ms-5', // By default we shift the sidebar to the left to compensate for the PagesList padding.
'layout-wide:no-sidebar:ms-0 layout-default:no-sidebar:lg:max-xl:ms-0',
'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',
'lg:page-no-toc:bg-transparent!',
'lg:page-no-toc:border-none!',
'sidebar-filled:rounded-2xl',
'straight-corners:rounded-none',
'[html.sidebar-filled.circular-corners_&]:layout-wide:rounded-4xl'
)}
className={getTableOfContentsSidebarClassName()}
>
{innerHeader}
<ScrollContainer
@@ -1,3 +1,5 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
export const ToCItemBaseStyles = [
'flex flex-row justify-start items-center gap-3',
'circular-corners:rounded-2xl rounded-md straight-corners:rounded-none p-1.5 pl-3',
@@ -46,3 +48,106 @@ export const ToCLinkItemActiveStyles = [
'contrast-more:ring-primary',
'contrast-more:hover:ring-primary-hover',
];
export function getTableOfContentsClassName(className?: ClassValue) {
return 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',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
'lg:mr-12',
'lg:z-0',
'layout-wide:no-sidebar:lg:fixed',
'layout-wide:no-sidebar:lg:max-3xl:w-12',
'layout-wide:no-sidebar:lg:left-5',
'layout-wide:no-sidebar:lg:z-30',
'layout-default:no-sidebar:lg:max-xl:fixed',
'layout-default:no-sidebar:lg:max-xl:w-12',
'layout-default:no-sidebar:lg:max-xl:left-5',
'layout-default:no-sidebar:lg:z-30',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'lg:announcement:h-[calc(100vh-4.25rem)]',
// With header
'lg:site-header:top-16',
'lg:site-header:h-[calc(100vh-4rem)]',
'lg:announcement:site-header:h-[calc(100vh-4rem-4.25rem)]',
'lg:site-header-sections:top-27',
'lg:site-header-sections:h-[calc(100vh-6.75rem)]',
'lg:site-header-sections:announcement:h-[calc(100vh-6.75rem-4.25rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--toc-top-offset"]_&]:top-(--toc-top-offset)!',
'lg:[html[style*="--toc-height"]_&]:h-(--toc-height)!',
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:h-(--outline-height)!',
'pt-6 pb-4',
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]',
'lg:max-3xl:has-sidebar:sidebar-filled:layout-default:pr-6',
'max-lg:pl-8',
'flex',
'flex-col',
'min-h-0',
'gap-4',
className
);
}
export function getTableOfContentsSidebarClassName(className?: ClassValue) {
return tcls(
'-ms-5',
'layout-wide:no-sidebar:ms-0 layout-default:no-sidebar:lg:max-xl:ms-0',
'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',
'lg:page-no-toc:bg-transparent!',
'lg:page-no-toc:border-none!',
'sidebar-filled:rounded-2xl',
'straight-corners:rounded-none',
'[html.sidebar-filled.circular-corners_&]:layout-wide:rounded-4xl',
className
);
}
export function getTableOfContentsInnerHeaderClassName(props?: {
hideOnMobile?: boolean;
className?: ClassValue;
}) {
const { hideOnMobile = false, className } = props ?? {};
return tcls(
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
hideOnMobile ? 'max-lg:hidden' : '',
className
);
}
export const TABLE_OF_CONTENTS_SPACES_DROPDOWN_CLASS = 'w-full px-3';
@@ -14,8 +14,9 @@ export function SkeletonParagraph(props: {
start?: number;
className?: ClassValue;
style?: React.CSSProperties;
animated?: boolean;
}) {
const { lines = 3, id, size = 'medium', start = 0, className, style } = props;
const { lines = 3, id, size = 'medium', start = 0, className, style, animated = true } = props;
const lineHeight = size === 'small' ? 'h-5' : 'h-6';
const itemHeight = size === 'small' ? 'h-3' : 'h-4';
@@ -45,6 +46,7 @@ export function SkeletonParagraph(props: {
flexGrow: ((line + start + item) % 3) + 1,
animationDelay: `${(item + line + start) * 0.1}s`,
}}
animated={animated}
/>
))}
</div>
@@ -57,27 +59,18 @@ export function SkeletonParagraph(props: {
/**
* Placeholder when loading a title.
*/
export function SkeletonHeading(props: { id?: string; style?: ClassValue }) {
const { id, style } = props;
return (
<div id={id} role="status" aria-busy className={tcls(style)}>
<LoadingPane tile={12} style={['rounded-md', 'h-[47px]', 'max-w-[calc(48rem-1px)]']} />
</div>
);
export function SkeletonHeading(props: { id?: string; style?: ClassValue; animated?: boolean }) {
const { id, style, animated = true } = props;
return <LoadingItem id={id} className={tcls('h-12 max-w-3/4', style)} animated={animated} />;
}
/**
* Placeholder when loading an asset (image, video, etc.)
*/
export function SkeletonImage(props: { id?: string; style?: ClassValue }) {
const { id, style } = props;
export function SkeletonImage(props: { id?: string; style?: ClassValue; animated?: boolean }) {
const { id, style, animated = true } = props;
return (
<div id={id} role="status" aria-busy className={tcls(style)}>
<LoadingPane
tile={96}
style={['rounded-md', 'h-full', 'aspect-video', 'max-w-[calc(48rem-1px)]']}
/>
</div>
<LoadingItem id={id} className={tcls('aspect-video w-full', style)} animated={animated} />
);
}
@@ -99,7 +92,7 @@ export function SkeletonCard(props: { id?: string; style?: ClassValue }) {
* Placeholder when loading small elements
*/
export function SkeletonSmall(
props: { id?: string; className?: ClassValue } & React.ComponentProps<'div'>
props: { id?: string; className?: ClassValue; animated?: boolean } & React.ComponentProps<'div'>
) {
const { id, className, ...rest } = props;
@@ -109,7 +102,7 @@ export function SkeletonSmall(
/**
* Placeholder when loading an Update block
*/
export function SkeletonUpdate(props: { id?: string; className?: ClassValue }) {
export function SkeletonUpdate(props: { id?: string; className?: ClassValue; animated?: boolean }) {
const { id, className } = props;
return (
<div
@@ -127,13 +120,16 @@ export function SkeletonUpdate(props: { id?: string; className?: ClassValue }) {
);
}
function LoadingItem(props: React.ComponentProps<'div'>): React.ReactNode {
const { className, ...rest } = props;
function LoadingItem(props: React.ComponentProps<'div'> & { animated?: boolean }): React.ReactNode {
const { className, animated = true, ...rest } = props;
return (
<div
role="status"
aria-busy={animated}
className={tcls(
'animate-[blurIn_500ms_ease-out_both,pulse_2s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2',
animated ? 'animate-[blurIn_500ms_ease-out_both,pulse_2s_infinite]' : '',
'circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2',
className
)}
{...rest}
@@ -156,16 +156,76 @@ function ImagePicture(
} & ImageCommonProps
>
) {
const { source, ...rest } = props;
const { source, resize, ...rest } = props;
const { size } = source;
if (resize === false) {
return (
<ImagePictureStatic
{...rest}
resize={resize}
source={{ ...source, size: source.size ?? null }}
/>
);
}
return size ? (
<ImagePictureSized {...rest} source={{ ...source, size }} />
<ImagePictureSized {...rest} resize={resize} source={{ ...source, size }} />
) : (
<ImagePictureUnsized {...rest} source={source} />
<ImagePictureUnsized {...rest} resize={resize} source={source} />
);
}
function ImagePictureStatic(
props: PolymorphicComponentProp<
'img',
{
source: ImageSourceSized;
} & ImageCommonProps
>
) {
const {
source,
sizes: _sizes,
style: _style,
alt,
quality: _quality = 100,
inline: _inline = false,
zoom = false,
resize: _resize = false,
preload = false,
loading,
fetchPriority,
inlineStyle,
...rest
} = props;
const aspectRatioStyle = source.aspectRatio ? { aspectRatio: source.aspectRatio } : {};
const style = { ...aspectRatioStyle, ...inlineStyle };
const attrs = {
src: source.src,
...source.size,
};
if (fetchPriority === 'high' || preload) {
ReactDOM.preload(attrs.src, {
as: 'image',
fetchPriority,
});
}
const imgProps: ImgDOMPropsWithSrc = {
alt,
style,
loading,
fetchPriority,
...rest,
...attrs,
};
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
}
async function ImagePictureUnsized(
props: PolymorphicComponentProp<
'img',
+1
View File
@@ -803,6 +803,7 @@ function encodePathInSiteContent(
case '~gitbook/auth/login':
case '~gitbook/auth/logout':
case '~scalar/proxy':
case '~gitbook/structure/demo':
// PDF, search and auth routes are always dynamic as they depend on the request.
return { pathname, routeType: 'dynamic' };
default: {