diff --git a/.changeset/fluffy-clouds-tap.md b/.changeset/fluffy-clouds-tap.md new file mode 100644 index 0000000000..6699ebbc33 --- /dev/null +++ b/.changeset/fluffy-clouds-tap.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Support nested site section groups diff --git a/bun.lock b/bun.lock index a515522a67..60a8166d9f 100644 --- a/bun.lock +++ b/bun.lock @@ -303,7 +303,7 @@ "react-dom": "^19.0.0", }, "catalog": { - "@gitbook/api": "^0.141.0", + "@gitbook/api": "^0.142.0", "bidc": "^0.0.2", }, "packages": { @@ -675,7 +675,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], - "@gitbook/api": ["@gitbook/api@0.141.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-KViAAUUStITbNGqDydUjZhxGdG4lJrS3ll8eQI5Tvs0j+Wvb4x1lxGvnm/LgtrhW+FoEgrFuRKZWVga1bI1Qmg=="], + "@gitbook/api": ["@gitbook/api@0.142.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Lq1IbepAykHNG8y0fBvC7hQj3i/f1XATX58wLYXWCL3W1x6Z9f6Rs5K2qCOONswJh3l2NrX3ujrbxx3D8goRdw=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index 69c91be978..3cf554d864 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "workspaces": { "packages": ["packages/*"], "catalog": { - "@gitbook/api": "^0.141.0", + "@gitbook/api": "^0.142.0", "bidc": "^0.0.2" } }, diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index 066625f423..c2fe03874b 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -5,6 +5,7 @@ import { motion } from 'framer-motion'; import React from 'react'; import { type ClassValue, tcls } from '@/lib/tailwind'; +import { findSectionInGroup } from '@/lib/utils'; import { useToggleAnimation } from '../hooks'; import { Link } from '../primitives'; import { ScrollContainer } from '../primitives/ScrollContainer'; @@ -15,7 +16,7 @@ import type { ClientSiteSections, } from './encodeClientSiteSections'; -const MAX_ITEMS = 5; // If there are more sections than this, they'll be shown below the fold in a scrollview. +const MAX_ITEMS = 6; // If there are more sections than this, they'll be shown below the fold in a scrollview. /** * A list of items representing site sections for multi-section sites @@ -72,8 +73,9 @@ export function SiteSectionListItem(props: { section: ClientSiteSection; isActive: boolean; className?: string; + style?: React.CSSProperties; }) { - const { section, isActive, className, ...otherProps } = props; + const { section, isActive, className, style, ...otherProps } = props; return (
0; - const isActiveGroup = group.sections.some((section) => section.id === currentSection.id); + const hasDescendants = group.children.length > 0; + const isActiveGroup = Boolean(findSectionInGroup(group, currentSection.id)); const shouldOpen = hasDescendants && isActiveGroup; const [isOpen, setIsOpen] = React.useState(shouldOpen); @@ -161,7 +165,7 @@ export function SiteSectionGroupItem(props: { className={tcls( 'flex size-8 shrink-0 items-center justify-center rounded-md straight-corners:rounded-none bg-tint-subtle text-lg text-tint leading-none shadow-tint shadow-xs ring-1 ring-tint-subtle transition-transform group-hover/section-link:scale-110 group-hover/section-link:ring-tint-hover group-active/section-link:scale-90 group-active/section-link:shadow-none contrast-more:text-tint-strong dark:shadow-none', isActiveGroup - ? 'bg-primary tint:bg-primary-solid text-primary tint:text-contrast-primary-solid shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary-strong contrast-more:ring-2 contrast-more:ring-primary' + ? 'bg-primary text-primary shadow-md shadow-primary ring-primary group-hover/section-link:ring-primary-hover, contrast-more:text-primary-strong contrast-more:ring-2 contrast-more:ring-primary' : null )} > @@ -216,14 +220,26 @@ export function SiteSectionGroupItem(props: { {hasDescendants ? ( - {group.sections.map((section) => ( - - ))} + {group.children.map((child) => { + if (child.object === 'site-section') { + return ( + + ); + } + + return ( + + ); + })} ) : null} @@ -239,7 +255,7 @@ function Descendants(props: { return ( li]:opacity-1'} + className={isVisible ? 'pl-3' : 'pl-3 [&_ul>li]:opacity-1'} initial={isVisible ? show : hide} > {children} diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 5cd2e9e309..1e1cab0170 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -6,15 +6,19 @@ import React from 'react'; import { Button, DropdownChevron, Link } from '@/components/primitives'; import { tcls } from '@/lib/tailwind'; +import { findSectionInGroup } from '@/lib/utils'; import { useIsMobile } from '../hooks/useIsMobile'; import { CONTAINER_STYLE } from '../layout'; import { ScrollContainer } from '../primitives/ScrollContainer'; import { SectionIcon } from './SectionIcon'; -import type { ClientSiteSection, ClientSiteSections } from './encodeClientSiteSections'; +import type { + ClientSiteSection, + ClientSiteSectionGroup, + ClientSiteSections, +} from './encodeClientSiteSections'; const SCREEN_OFFSET = 16; // 1rem -const VIEWPORT_PADDING = 8; // 0.5rem -const MIN_ITEMS_FOR_COLS = 4; /* number of items to switch to 2 columns */ +const MAX_ITEMS_PER_COLUMN = 5; // number of items per column /** * A set of navigational links representing site sections for multi-section sites */ @@ -24,14 +28,14 @@ export function SiteSectionTabs(props: { children?: React.ReactNode; }) { const { - sections: { list: sectionsAndGroups, current: currentSection }, + sections: { list: structure, current: currentSection }, className, children, } = props; const currentTriggerRef = React.useRef(null); const [offset, setOffset] = React.useState(null); - const [value, setValue] = React.useState(undefined); + const [value, setValue] = React.useState(); const isMobile = useIsMobile(768); @@ -46,7 +50,7 @@ export function SiteSectionTabs(props: { setOffset(triggerLeft + triggerWidth / 2); }, [value]); - return sectionsAndGroups.length > 0 ? ( + return structure.length > 0 ? ( - {sectionsAndGroups.map((sectionOrGroup) => { - const { id, title, icon } = sectionOrGroup; - const isGroup = sectionOrGroup.object === 'site-section-group'; + {structure.map((structureItem) => { + const { id, title, icon } = structureItem; + const isGroup = structureItem.object === 'site-section-group'; const isActiveGroup = isGroup && - Boolean( - sectionOrGroup.sections.find((s) => s.id === currentSection.id) - ); + Boolean(findSectionInGroup(structureItem, currentSection.id)); const isActive = isActiveGroup || id === currentSection.id; return ( - {isGroup ? ( - sectionOrGroup.sections.length > 0 ? ( - <> - { - // Prevent clicking the trigger from closing when the viewport is open - if (value === id) { - e.preventDefault(); - e.stopPropagation(); - } - }} - > - - - - - - - ) : null + {isGroup && structureItem.children.length > 0 ? ( + <> + { + // Prevent clicking the trigger from closing when the viewport is open + if (value === id) { + e.preventDefault(); + e.stopPropagation(); + } + }} + > + + + + + + ) : ( item.object === 'site-section'); + const groups = items.filter((item) => item.object === 'site-section-group'); + + const hasSections = sections.length > 0; + const hasGroups = groups.length > 0; + return ( -
    + {/* Non-grouped sections */} + {hasSections && ( +
      + {sections.map((section) => ( + + ))} +
    )} - > - {sections.map((section) => ( - - ))} -
+ + {/* Grouped sections */} + {hasGroups && ( +
    + {groups.map((group) => ( + + ))} +
+ )} +
); } /** * A section tile shown in the dropdown for a section group */ -function SectionGroupTile(props: { section: ClientSiteSection; isActive: boolean }) { - const { section, isActive } = props; - const { url, icon, title } = section; +function SectionGroupTile(props: { + child: ClientSiteSection | ClientSiteSectionGroup; + currentSection: ClientSiteSection; +}) { + const { child, currentSection } = props; + + if (child.object === 'site-section') { + const { url, icon, title, description } = child; + const isActive = child.id === currentSection.id; + return ( +
  • + +
    + {icon && ( +
    + +
    + )} +
    + {title} + {description && ( +

    + {description} +

    + )} +
    +
    + +
  • + ); + } + + // Handle nested section group + const { title, icon, children } = child; + return ( -
  • - +
    + {icon && ( + )} + {title} +
    +
  • ); } diff --git a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts index 1a2ef44590..500a98ada7 100644 --- a/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts +++ b/packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts @@ -16,7 +16,7 @@ export type ClientSiteSection = Pick< }; export type ClientSiteSectionGroup = Pick & { - sections: ClientSiteSection[]; + children: (ClientSiteSection | ClientSiteSectionGroup)[]; }; /** @@ -30,10 +30,10 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections: for (const item of list) { switch (item.object) { case 'site-section-group': { - const sections = item.sections.map((section) => encodeSection(context, section)); + const children = encodeChildren(context, item.children); // Skip empty groups - if (sections.length === 0) { + if (children.length === 0) { continue; } @@ -42,7 +42,7 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections: title: item.title, icon: item.icon, object: item.object, - sections, + children, }); continue; } @@ -61,6 +61,43 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections: }; } +function encodeChildren( + context: GitBookSiteContext, + children: (SiteSection | SiteSectionGroup)[] +): (ClientSiteSection | ClientSiteSectionGroup)[] { + const clientChildren: (ClientSiteSection | ClientSiteSectionGroup)[] = []; + + for (const child of children) { + switch (child.object) { + case 'site-section': { + clientChildren.push(encodeSection(context, child)); + break; + } + case 'site-section-group': { + const nestedChildren = encodeChildren(context, child.children); + + // Skip empty groups + if (nestedChildren.length === 0) { + continue; + } + + clientChildren.push({ + id: child.id, + title: child.title, + icon: child.icon, + object: child.object, + children: nestedChildren, + }); + break; + } + default: + assertNever(child, 'Unknown site section object type'); + } + } + + return clientChildren; +} + function encodeSection(context: GitBookSiteContext, section: SiteSection) { return { id: section.id, diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index f773e92387..4da397187c 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -1,6 +1,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api'; import { joinPath } from './paths'; +import { flattenSectionsFromGroup } from './utils'; /** * Get all sections from a site structure. @@ -22,7 +23,9 @@ export function getSiteStructureSections( return siteStructure.type === 'sections' ? ignoreGroups ? siteStructure.structure.flatMap((item) => - item.object === 'site-section-group' ? item.sections : item + item.object === 'site-section-group' + ? flattenSectionsFromGroup(item.children) + : item ) : siteStructure.structure : []; @@ -41,7 +44,9 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) { return section.siteSpaces; } - return section.sections.flatMap((subSection) => subSection.siteSpaces); + return flattenSectionsFromGroup(section.children) + .filter((subSection): subSection is SiteSection => subSection.object === 'site-section') + .flatMap((subSection) => subSection.siteSpaces); }); } @@ -80,13 +85,13 @@ export function findSiteSpaceBy( }; } } else { - const found = findSiteSpaceByIdInSections(sectionOrGroup.sections, predicate); + const found = findSiteSpaceByIdInGroupChildren( + sectionOrGroup.children, + predicate, + sectionOrGroup + ); if (found) { - return { - siteSpace: found.siteSpace, - siteSection: found.siteSection, - siteSectionGroup: sectionOrGroup, - }; + return found; } } } @@ -136,14 +141,30 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace: return siteSpacePath; } -function findSiteSpaceByIdInSections( - sections: SiteSection[], - predicate: (siteSpace: SiteSpace) => boolean -): { siteSpace: SiteSpace; siteSection: SiteSection } | null { - for (const siteSection of sections) { - const siteSpace = siteSection.siteSpaces.find(predicate) ?? null; - if (siteSpace) { - return { siteSpace, siteSection }; +function findSiteSpaceByIdInGroupChildren( + children: (SiteSection | SiteSectionGroup)[], + predicate: (siteSpace: SiteSpace) => boolean, + parentGroup: SiteSectionGroup +): { + siteSpace: SiteSpace; + siteSection: SiteSection; + siteSectionGroup: SiteSectionGroup; +} | null { + for (const child of children) { + if (child.object === 'site-section') { + const siteSpace = child.siteSpaces.find(predicate) ?? null; + if (siteSpace) { + return { + siteSpace, + siteSection: child, + siteSectionGroup: parentGroup, + }; + } + } else if (child.object === 'site-section-group') { + const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child); + if (found) { + return found; + } } } diff --git a/packages/gitbook/src/lib/utils.ts b/packages/gitbook/src/lib/utils.ts index 5f76f02f2b..d3761350df 100644 --- a/packages/gitbook/src/lib/utils.ts +++ b/packages/gitbook/src/lib/utils.ts @@ -78,3 +78,34 @@ export function defaultCustomization(): api.SiteCustomizationSettings { socialPreview: {}, }; } + +/** + * Recursively flatten all sections from nested groups + */ +export function flattenSectionsFromGroup( + children: T[] +): T[] { + const sections: T[] = []; + + for (const child of children) { + if (child.object === 'site-section') { + sections.push(child); + } else if (child.object === 'site-section-group' && child.children) { + sections.push(...flattenSectionsFromGroup(child.children)); + } + } + + return sections; +} + +/** + * Recursively find a section by ID within a group and its nested children + */ +export function findSectionInGroup( + group: { children: T[] }, + sectionId: string +): T | null { + return ( + flattenSectionsFromGroup(group.children).find((section) => section.id === sectionId) ?? null + ); +}