diff --git a/bun.lock b/bun.lock index a01983909a..c7377600d8 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.15", + "turbo": "^2.9.18", "vercel": "50.37.3", }, }, diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/demo/page.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/demo/page.tsx new file mode 100644 index 0000000000..7c5ccb372d --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/demo/page.tsx @@ -0,0 +1,12 @@ +import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils'; +import { StructurePreview } from '@/components/StructurePreview'; +import { getStructurePreviewSnapshot } from '../snapshot'; + +type PageProps = { + params: Promise; +}; + +export default async function Page(props: PageProps) { + const { context } = await getDynamicSiteContext(await props.params); + return ; +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx new file mode 100644 index 0000000000..ba00be1d47 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/layout.tsx @@ -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; +} + +export default async function RootLayout({ + children, + ...props +}: React.PropsWithChildren) { + const { context } = await getDynamicSiteContext(await props.params); + const forcedTheme = await getThemeFromMiddleware(); + return ( + + + {children} + + + ); +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot.ts new file mode 100644 index 0000000000..3a629b027a --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/structure/snapshot.ts @@ -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, + sections: SiteSections +): ClientSiteSections { + return { + list: sections.list.flatMap((item) => encodePreviewSectionItem(context, item)), + current: encodePreviewSection(context, sections.current), + }; +} + +function encodePreviewSectionItem( + context: Pick, + 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, + section: SiteSection +): ClientSiteSection { + return { + id: section.id, + title: getLocalizedTitle(section, context.locale), + description: getLocalizedDescription(section, context.locale), + icon: section.icon, + object: section.object, + url: '#', + }; +} diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index abf533d347..642d6f6ce1 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -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'; @@ -9,27 +12,30 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint /** - * Button to open/close the AI chat. + * Button visual for an AI assistant in the header. */ -export function AIChatButton(props: { - assistant: Assistant; +export function AIChatButtonView(props: { + icon: ReactNode; + label: string; + onClick?: () => void; showLabel?: boolean; withShortcut?: boolean; + inert?: boolean; }) { - const { assistant, showLabel = true, withShortcut = true } = props; + 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 to open/close the AI chat. + */ +export function AIChatButton(props: { + assistant: Assistant; + showLabel?: boolean; + withShortcut?: boolean; +}) { + const { assistant, showLabel = true, withShortcut = true } = props; + + return ( + assistant.open()} + showLabel={showLabel} + withShortcut={withShortcut} + /> + ); +} diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx index 2eb0acceb0..cf508fde79 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableDocsPage.tsx @@ -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 ? ( -
+
{variants.generic.length > 1 ? ( ) : null}
diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index bbe8086538..4d7d61ac15 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -1,12 +1,12 @@ import type { GitBookSiteContext } from '@/lib/context'; -import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout'; import { getSpaceLanguage, t } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; import type { SiteSpace } from '@gitbook/api'; import { SocialAccountButton } from '../Footer/SocialAccounts'; import { SearchContainer, getSearchBaseProps } from '../Search'; import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections'; +import { HeaderLayout } from './HeaderLayout'; import { HeaderLink } from './HeaderLink'; import { HeaderLinkMore } from './HeaderLinkMore'; import { HeaderLinks } from './HeaderLinks'; @@ -41,172 +41,81 @@ export async function Header(props: { ); return ( -
-
-
-
+ 1 + ? 'lg:hidden' + : 'no-sidebar:hidden lg:hidden' )} - > -
- 1 - ? 'lg:hidden' - : 'no-sidebar:hidden lg:hidden' - )} - /> - -
- -
- -
- - {customization.header.links.length > 0 || - headerSocialAccounts.length > 0 || - (!withSections && variants.translations.length > 1) ? ( - - {customization.header.links.map((link) => { + /> + + + } + search={ + + } + links={ + customization.header.links.length > 0 || + headerSocialAccounts.length > 0 || + (!withSections && variants.translations.length > 1) ? ( + + {customization.header.links.map((link) => { + return ; + })} + {headerSocialAccounts.length > 0 ? ( +
+ {headerSocialAccounts.map((account) => { return ( - ); })} - {headerSocialAccounts.length > 0 ? ( -
- {headerSocialAccounts.map((account) => { - return ( - - ); - })} -
- ) : null} - {customization.header.links.length > 0 || - headerSocialAccounts.length > 0 ? ( - - ) : null} - {!withSections && variants.translations.length > 1 ? ( - space.id === siteSpace.id - ) ?? siteSpace - } - 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} - +
) : null} -
-
-
- - {visibleSections && withSections ? ( -
+ {customization.header.links.length > 0 || + headerSocialAccounts.length > 0 ? ( + + ) : null} + {!withSections && variants.translations.length > 1 ? ( + space.id === siteSpace.id + ) ?? siteSpace + } + 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} + + ) : null + } + sections={ + visibleSections && withSections ? ( {variants.translations.length > 1 ? ( ) : null} -
- ) : null} -
+ ) : null + } + /> ); } diff --git a/packages/gitbook/src/components/Header/HeaderLayout.tsx b/packages/gitbook/src/components/Header/HeaderLayout.tsx new file mode 100644 index 0000000000..1c76947f32 --- /dev/null +++ b/packages/gitbook/src/components/Header/HeaderLayout.tsx @@ -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 ( +
+
+
+
+
+ {leading} +
+ +
+ {search} +
+ + {links} +
+
+
+ + {sections ? ( +
+ {sections} +
+ ) : null} +
+ ); +} diff --git a/packages/gitbook/src/components/Header/HeaderLink.tsx b/packages/gitbook/src/components/Header/HeaderLink.tsx index 68410c741b..ef07103c9f 100644 --- a/packages/gitbook/src/components/Header/HeaderLink.tsx +++ b/packages/gitbook/src/components/Header/HeaderLink.tsx @@ -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 ( - - {link.links.map((subLink, index) => ( - - ))} - - ); - } - - if (!link.to) { - return null; - } return ( - + dropdownClassName={getHeaderLinkDropdownClassName(customization.styling.search)} + > + {link.links?.map((subLink, index) => ( + + ))} + ); } @@ -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) ? ( - {title} - ) : ( - {title} + return ( + ); } diff --git a/packages/gitbook/src/components/Header/HeaderLinkClient.tsx b/packages/gitbook/src/components/Header/HeaderLinkClient.tsx new file mode 100644 index 0000000000..f1570814b2 --- /dev/null +++ b/packages/gitbook/src/components/Header/HeaderLinkClient.tsx @@ -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 ( + + {children} + + ); + } + + if (!link.to) { + return null; + } + + return ( + + ); +} + +export function SubHeaderLinkItem(props: { + link: CustomizationContentLink; + locale: TranslationLanguage | undefined; + href: string; + isSiteAuthLoginHref?: boolean; +}) { + return ; +} + +export function HeaderLinkSubMenu(props: { + link: CustomizationHeaderItem; + locale: TranslationLanguage | undefined; + children: React.ReactNode; +}) { + const { link, locale, children } = props; + const title = getLocalizedTitle(link, locale); + + return {children}; +} + +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 ? ( + + {title} + + ) : ( + {title} + ); +} diff --git a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx index e4456682ac..c38e622c19 100644 --- a/packages/gitbook/src/components/Header/HeaderLinkMore.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinkMore.tsx @@ -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: {
{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 ? ( - + {link.links.map((subLink, index) => { return ; })} - - ) : isSiteAuthLoginHref(context.linker, target?.href) && sharedProps.href ? ( - - {title} - + ) : ( - {title} + ); } diff --git a/packages/gitbook/src/components/Header/HeaderLinkStyles.ts b/packages/gitbook/src/components/Header/HeaderLinkStyles.ts new file mode 100644 index 0000000000..c749e6fd02 --- /dev/null +++ b/packages/gitbook/src/components/Header/HeaderLinkStyles.ts @@ -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' + ); +} diff --git a/packages/gitbook/src/components/Header/HeaderLinks.tsx b/packages/gitbook/src/components/Header/HeaderLinks.tsx index da2e01e831..ced126dee2 100644 --- a/packages/gitbook/src/components/Header/HeaderLinks.tsx +++ b/packages/gitbook/src/components/Header/HeaderLinks.tsx @@ -9,7 +9,7 @@ interface HeaderLinksProps { style?: ClassValue; } -export async function HeaderLinks({ children, style }: HeaderLinksProps) { +export function HeaderLinks({ children, style }: HeaderLinksProps) { return (
- {customization.header.logo ? ( - Logo - ) : ( - - )} + : null} + fallbackIcon={} + title={context.site.title} + /> ); } -function LogoFallback(props: HeaderLogoProps) { +function LogoImage(props: HeaderLogoProps) { const { context } = props; - const { site } = context; + const { customization } = context; + + if (!customization.header.logo) { + return null; + } return ( - <> - -
- {site.title} -
- + Logo + ); +} + +function LogoFallbackIcon(props: HeaderLogoProps) { + const { context } = props; + + return ( + ); } diff --git a/packages/gitbook/src/components/Header/HeaderLogoContent.tsx b/packages/gitbook/src/components/Header/HeaderLogoContent.tsx new file mode 100644 index 0000000000..c2a6c8e06c --- /dev/null +++ b/packages/gitbook/src/components/Header/HeaderLogoContent.tsx @@ -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} + {title} + + ); +} + +function HeaderLogoTitle(props: { children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/gitbook/src/components/Header/SpacesDropdown.tsx b/packages/gitbook/src/components/Header/SpacesDropdown.tsx index 477a87a6f4..a1d8718cf4 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdown.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdown.tsx @@ -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 ( @@ -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 ( ); } diff --git a/packages/gitbook/src/components/Header/SpacesDropdownClient.tsx b/packages/gitbook/src/components/Header/SpacesDropdownClient.tsx index 3fe46ca522..8f8505ac20 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdownClient.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdownClient.tsx @@ -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 ( } > - + ); } diff --git a/packages/gitbook/src/components/Header/SpacesDropdownData.ts b/packages/gitbook/src/components/Header/SpacesDropdownData.ts new file mode 100644 index 0000000000..400af147ab --- /dev/null +++ b/packages/gitbook/src/components/Header/SpacesDropdownData.ts @@ -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 + ); +} diff --git a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx index 68c28b8fca..c3b84aaa75 100644 --- a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx +++ b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx @@ -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: { ); } +function StaticSpacesDropdownMenuItem(props: { + variantSpace: SlimSiteSpace; + active: boolean; +}) { + const { variantSpace, active } = props; + + return {variantSpace.title}; +} + export function SpacesDropdownMenuItems(props: { - slimSpaces: VariantSpace[]; + slimSpaces: SlimSiteSpace[]; curPath: string; + clickable?: boolean; }) { - const { slimSpaces, curPath } = props; + const { slimSpaces, curPath, clickable = true } = props; return ( <> - {slimSpaces.map((space) => ( - - ))} + {slimSpaces.map((space) => + clickable ? ( + + ) : ( + + ) + )} ); } diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 0e69119098..f4fb1c700f 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -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'; @@ -214,10 +215,10 @@ export function SearchContainer({ asChild: true, }} > - - - + /> )} {usesSideSheet ? ( diff --git a/packages/gitbook/src/components/Search/SearchHeaderInput.tsx b/packages/gitbook/src/components/Search/SearchHeaderInput.tsx new file mode 100644 index 0000000000..f846b1f508 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchHeaderInput.tsx @@ -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) => 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( + 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 ( + + {interactive ? ( + + ) : null} + + ); + } +); diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 7fc172c840..128d5d7d10 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -21,6 +21,8 @@ interface SearchInputProps { resultsCount: number; fetching: boolean; showAsk: boolean; + readOnly?: boolean; + tabIndex?: number; } /** diff --git a/packages/gitbook/src/components/Search/index.ts b/packages/gitbook/src/components/Search/index.ts index fb4967b95e..4749181401 100644 --- a/packages/gitbook/src/components/Search/index.ts +++ b/packages/gitbook/src/components/Search/index.ts @@ -1,4 +1,5 @@ export * from './SearchInput'; +export * from './SearchHeaderInput'; export * from './SearchFrame'; export * from './SearchLiveResultsAnnouncer'; export * from './SearchContainer'; diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 341a2c6987..482f8d29cb 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -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(null); @@ -130,7 +132,9 @@ export function SiteSectionTabs(props: {
) { - const { isActive, title, icon, url, ...rest } = props; + const { isActive, title, icon, url, sectionId, ...rest } = props; const isGroup = url === undefined; return (
+ ) : null} + {customization.header.links.length > 0 || + headerSocialAccounts.length > 0 ? ( + + ) : null} + {!withSections && variants.translations.length > 1 ? ( + + ) : null} + + ) : null + } + sections={ + sections && withSections ? ( + //TODO: figure out why enabling animations here break the rendering of what's inside the tabs + + {variants.translations.length > 1 ? ( + + ) : null} + + ) : null + } + /> + ); +} + +function StructurePreviewLogo(props: { snapshot: StructurePreviewSnapshot }) { + const { snapshot } = props; + const { customization } = snapshot; + + return ( +
+ + ) : null + } + fallbackIcon={} + title={snapshot.site.title} + /> +
+ ); +} + +function StructurePreviewLogoImage(props: { + logo: NonNullable; +}) { + const { logo } = props; + + return ( + Logo + ); +} + +function StructurePreviewLogoFallbackIcon(props: { snapshot: StructurePreviewSnapshot }) { + const { snapshot } = props; + const { customization } = snapshot; + + if ('emoji' in customization.favicon && customization.favicon.emoji) { + return {customization.favicon.emoji}; + } + + return ( + + + + + ); +} + +function StructurePreviewSearch() { + return ; +} + +function StructurePreviewVariantSelector(props: { snapshot: StructurePreviewSnapshot }) { + const { snapshot } = props; + const { variants } = snapshot; + + return ( +
+
+ {variants.generic.length > 1 ? ( +
+ +
+ ) : null} +
+ {Array.from({ length: 4 }).map((_, group) => ( +
+ {Array.from({ length: [3, 5, 4, 3][group] ?? 0 }).map((_, index) => ( + + ))} +
+ ))} +
+
+
+ ); +} + +function getPreviewAssistants( + snapshot: StructurePreviewSnapshot, + language: ReturnType +) { + if (snapshot.customization.ai?.mode !== CustomizationAIMode.Assistant) { + return []; + } + + return [ + { + id: 'gitbook-assistant', + label: getAIChatName(language, snapshot.customization.trademark.enabled), + icon: ( + + ), + }, + ]; +} + +function StructurePreviewHeaderLink(props: { + snapshot: StructurePreviewSnapshot; + link: PreviewHeaderLink; +}) { + const { snapshot, link } = props; + const headerLink = toCustomizationHeaderItem(link); + + return ( + + {link.links.map((subLink, index) => ( + + ))} + + ); +} + +function StructurePreviewMoreMenu(props: { + snapshot: StructurePreviewSnapshot; + links: PreviewHeaderLink[]; + label: React.ReactNode; +}) { + const { snapshot, links, label } = props; + return ( +
+ + {links.map((link, index) => ( + + ))} + +
+ ); +} + +function StructurePreviewMenuLink(props: { + snapshot: StructurePreviewSnapshot; + link: PreviewHeaderLink | PreviewContentLink; +}) { + const { snapshot, link } = props; + + return isPreviewHeaderLink(link) && link.links.length > 0 ? ( + + {link.links.map((subLink, index) => ( + + ))} + + ) : ( + + ); +} + +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 ( + + ); +} + +function StructurePreviewSpacesDropdown(props: { + title: string; + siteSpaces: PreviewDropdownSpace[]; + className?: ButtonProps['className']; + icon?: IconName; + variant?: ButtonProps['variant']; +}) { + const { title, siteSpaces, className, icon, variant = 'secondary' } = props; + + return ( + } + className={tcls('bg-tint-base', className)} + > + {title} + + } + > + {siteSpaces.map((siteSpace) => ( + + {siteSpace.title} + + ))} + + ); +} diff --git a/packages/gitbook/src/components/StructurePreview/index.ts b/packages/gitbook/src/components/StructurePreview/index.ts new file mode 100644 index 0000000000..3a90a4a527 --- /dev/null +++ b/packages/gitbook/src/components/StructurePreview/index.ts @@ -0,0 +1,2 @@ +export * from './StructurePreview'; +export * from './types'; diff --git a/packages/gitbook/src/components/StructurePreview/state.test.ts b/packages/gitbook/src/components/StructurePreview/state.test.ts new file mode 100644 index 0000000000..b4b3aef152 --- /dev/null +++ b/packages/gitbook/src/components/StructurePreview/state.test.ts @@ -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 { + 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) + ); + + 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) + ); + + 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) + ); +} + +function createSection(id: string, title: string) { + return { + object: 'site-section', + id, + title, + description: '', + path: id, + default: false, + siteSpaces: [], + urls: {}, + }; +} diff --git a/packages/gitbook/src/components/StructurePreview/state.ts b/packages/gitbook/src/components/StructurePreview/state.ts new file mode 100644 index 0000000000..1a27e9767d --- /dev/null +++ b/packages/gitbook/src/components/StructurePreview/state.ts @@ -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; + 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; + const payload = message.payload as Partial; + 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; + 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>; + 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; + 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; + 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; + 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; + 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; + return ( + typeof siteSpace.id === 'string' && + typeof siteSpace.title === 'string' && + typeof siteSpace.isActive === 'boolean' + ); +} + +export const SOCIAL_PLATFORM_ICONS: Partial> = { + twitter: 'x-twitter', + instagram: 'instagram', + facebook: 'facebook', + linkedin: 'linkedin', + github: 'github', + discord: 'discord', + slack: 'slack', + youtube: 'youtube', + tiktok: 'tiktok', + reddit: 'reddit', + bluesky: 'bluesky', + mastodon: 'mastodon', + threads: 'threads', + medium: 'medium', +}; diff --git a/packages/gitbook/src/components/StructurePreview/types.ts b/packages/gitbook/src/components/StructurePreview/types.ts new file mode 100644 index 0000000000..6058ad3d94 --- /dev/null +++ b/packages/gitbook/src/components/StructurePreview/types.ts @@ -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 +>; + +export type StructurePreviewMessage = { + type: 'gitbook.structure.update'; + payload: StructurePreviewUpdate; +}; + +export type StructurePreviewNavigationMessage = { + type: 'gitbook.structure.navigate'; + payload: { + sectionId: string; + }; +}; diff --git a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx index 25d2c5b95c..1314c4a501 100644 --- a/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/gitbook/src/components/TableOfContents/TableOfContents.tsx @@ -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}
{innerHeader} ))}
@@ -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 ( -
- -
- ); +export function SkeletonHeading(props: { id?: string; style?: ClassValue; animated?: boolean }) { + const { id, style, animated = true } = props; + return ; } /** * 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 ( -
- -
+ ); } @@ -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 (
): React.ReactNode { - const { className, ...rest } = props; +function LoadingItem(props: React.ComponentProps<'div'> & { animated?: boolean }): React.ReactNode { + const { className, animated = true, ...rest } = props; return (
) { - const { source, ...rest } = props; + const { source, resize, ...rest } = props; const { size } = source; + if (resize === false) { + return ( + + ); + } + return size ? ( - + ) : ( - + ); } +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 ? : {imgProps.alt; +} + async function ImagePictureUnsized( props: PolymorphicComponentProp< 'img', diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index dcf2804ab4..c409680c6b 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -803,6 +803,7 @@ function encodePathInSiteContent( case '~gitbook/auth/login': case '~gitbook/auth/logout': case '~scalar/proxy': + case '~gitbook/structure/demo': // PDF, search and auth routes are always dynamic as they depend on the request. return { pathname, routeType: 'dynamic' }; default: {