mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb46b5b38b | |||
| 8a9f163d31 | |||
| 7c365f9162 | |||
| 10a7095182 | |||
| 614a50373a | |||
| 7646cb301c | |||
| d2ce6adeb6 | |||
| 63e4968987 | |||
| 6eb3d3b4e5 | |||
| 2ba90833a3 | |||
| b0553d5fc8 | |||
| a06da70f5f | |||
| 53ec8b9f29 | |||
| 6f63c00300 | |||
| c334ea2748 | |||
| d75cdf1334 | |||
| cb649c55cb | |||
| 205c512cb6 | |||
| 334a31d7fc | |||
| f157cfc9a7 | |||
| 3485c0192b | |||
| 27d9ad6fe4 | |||
| 96602eb552 | |||
| 17d092acdc | |||
| 107018f570 | |||
| 3e152b0662 | |||
| 6a8720894d | |||
| 8a5ed60c26 | |||
| 52fc156b06 | |||
| 46091b65d1 |
+23
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+184
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user