From 4916f74f3c3bc54ceb787cbca7871b37e919ecf5 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Thu, 18 Sep 2025 14:29:01 +0200 Subject: [PATCH 1/5] Support site section groups --- .changeset/fluffy-clouds-tap.md | 5 + bun.lock | 4 +- package.json | 2 +- .../RootLayout/CustomizationRootLayout.tsx | 3 + .../SiteSections/SiteSectionList.tsx | 67 ++++- .../SiteSections/SiteSectionTabs.tsx | 269 ++++++++++++------ .../SiteSections/encodeClientSiteSections.ts | 45 ++- packages/gitbook/src/lib/sites.ts | 67 +++-- 8 files changed, 339 insertions(+), 123 deletions(-) create mode 100644 .changeset/fluffy-clouds-tap.md 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/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 63c54b4c0e..dc670b4043 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,4 +1,5 @@ import { + CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, @@ -80,6 +81,8 @@ export async function CustomizationRootLayout(props: { variable: fonts.IBMPlexMono.variable, }; + customization.header.preset = CustomizationHeaderPreset.None; + // Preconnect and preload custom fonts if needed preloadFont(fontData); preloadFont(monospaceFontData); diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index 066625f423..79602a9023 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -15,7 +15,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 +72,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 +164,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 +219,27 @@ export function SiteSectionGroupItem(props: { {hasDescendants ? ( - {group.sections.map((section) => ( - - ))} + {group.children.map((child) => { + if (child.object === 'site-section') { + return ( + + ); + } + + return ( + + ); + })} ) : null} @@ -239,10 +255,31 @@ function Descendants(props: { return ( li]:opacity-1'} + className={isVisible ? 'pl-3' : 'pl-3 [&_ul>li]:opacity-1'} initial={isVisible ? show : hide} > {children} ); } + +/** + * Recursively find a section by ID within a group and its nested children + */ +function findSectionInGroup( + group: ClientSiteSectionGroup, + sectionId: string +): ClientSiteSection | null { + for (const child of group.children) { + if (child.object === 'site-section' && child.id === sectionId) { + return child; + } + if (child.object === 'site-section-group') { + const found = findSectionInGroup(child, sectionId); + if (found) { + return found; + } + } + } + return null; +} diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 5cd2e9e309..935db55806 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -10,11 +10,14 @@ 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 +27,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 +49,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 && ( + )} - > -
    - {icon ? ( - - ) : null} - {title} -
    - {section.description ? ( -

    {section.description}

    - ) : null} - + {title} +
    +
  • ); } + +/** + * Recursively find a section by ID within a group and its nested children + */ +function findSectionInGroup( + group: ClientSiteSectionGroup, + sectionId: string +): ClientSiteSection | null { + for (const child of group.children) { + if (child.object === 'site-section' && child.id === sectionId) { + return child; + } + if (child.object === 'site-section-group') { + const found = findSectionInGroup(child, sectionId); + if (found) { + return found; + } + } + } + return null; +} 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..52eab1fdd5 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -2,6 +2,23 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api'; import { joinPath } from './paths'; +/** + * Recursively flatten all sections from nested groups + */ +function flattenGroupChildren(children: (SiteSection | SiteSectionGroup)[]): SiteSection[] { + const sections: SiteSection[] = []; + + for (const child of children) { + if (child.object === 'site-section') { + sections.push(child); + } else if (child.object === 'site-section-group') { + sections.push(...flattenGroupChildren(child.children)); + } + } + + return sections; +} + /** * Get all sections from a site structure. * Set the `ignoreGroups` option to true to flatten the list to only include SiteSection and to not include SiteSectionGroups. @@ -22,7 +39,7 @@ 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' ? flattenGroupChildren(item.children) : item ) : siteStructure.structure : []; @@ -41,7 +58,9 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) { return section.siteSpaces; } - return section.sections.flatMap((subSection) => subSection.siteSpaces); + return flattenGroupChildren(section.children).flatMap( + (subSection) => subSection.siteSpaces + ); }); } @@ -80,13 +99,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 +155,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; + } } } From b0003887d483c3bb74fa039a1a65c8ea865a7370 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Thu, 18 Sep 2025 14:33:46 +0200 Subject: [PATCH 2/5] Adapt --- .../components/RootLayout/CustomizationRootLayout.tsx | 3 --- .../src/components/SiteSections/SiteSectionTabs.tsx | 9 +++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index dc670b4043..63c54b4c0e 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,5 +1,4 @@ import { - CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, @@ -81,8 +80,6 @@ export async function CustomizationRootLayout(props: { variable: fonts.IBMPlexMono.variable, }; - customization.header.preset = CustomizationHeaderPreset.None; - // Preconnect and preload custom fonts if needed preloadFont(fontData); preloadFont(monospaceFontData); diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 935db55806..73d5868399 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -308,14 +308,19 @@ function SectionGroupTile(props: { const { title, icon, children } = child; return ( -
  • +
  • {icon && ( )} {title}
    -
      +
        {children.map((nestedChild: ClientSiteSection | ClientSiteSectionGroup) => ( Date: Thu, 18 Sep 2025 14:42:30 +0200 Subject: [PATCH 3/5] Review --- .../gitbook/src/components/SiteSections/SiteSectionList.tsx | 1 - .../gitbook/src/components/SiteSections/SiteSectionTabs.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index 79602a9023..4a12417702 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx @@ -226,7 +226,6 @@ export function SiteSectionGroupItem(props: { section={child} isActive={child.id === currentSection.id} key={child.id} - // className="pl-5" /> ); } diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index 73d5868399..e20eba899a 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -321,7 +321,7 @@ function SectionGroupTile(props: { gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, 1fr))`, }} > - {children.map((nestedChild: ClientSiteSection | ClientSiteSectionGroup) => ( + {children.map((nestedChild) => ( Date: Thu, 18 Sep 2025 14:54:28 +0200 Subject: [PATCH 4/5] Update sites.ts --- packages/gitbook/src/lib/sites.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index 52eab1fdd5..27b68d8ae8 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -5,14 +5,14 @@ import { joinPath } from './paths'; /** * Recursively flatten all sections from nested groups */ -function flattenGroupChildren(children: (SiteSection | SiteSectionGroup)[]): SiteSection[] { +function flattenSectionsFromGroup(children: (SiteSection | SiteSectionGroup)[]): SiteSection[] { const sections: SiteSection[] = []; for (const child of children) { if (child.object === 'site-section') { sections.push(child); } else if (child.object === 'site-section-group') { - sections.push(...flattenGroupChildren(child.children)); + sections.push(...flattenSectionsFromGroup(child.children)); } } @@ -39,7 +39,9 @@ export function getSiteStructureSections( return siteStructure.type === 'sections' ? ignoreGroups ? siteStructure.structure.flatMap((item) => - item.object === 'site-section-group' ? flattenGroupChildren(item.children) : item + item.object === 'site-section-group' + ? flattenSectionsFromGroup(item.children) + : item ) : siteStructure.structure : []; @@ -58,7 +60,7 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) { return section.siteSpaces; } - return flattenGroupChildren(section.children).flatMap( + return flattenSectionsFromGroup(section.children).flatMap( (subSection) => subSection.siteSpaces ); }); From 7120605541331a5b4e9e1ff618c9d802f96f8f42 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Thu, 18 Sep 2025 15:12:43 +0200 Subject: [PATCH 5/5] Refactor utils --- .../SiteSections/SiteSectionList.tsx | 22 +------------ .../SiteSections/SiteSectionTabs.tsx | 22 +------------ packages/gitbook/src/lib/sites.ts | 26 +++------------- packages/gitbook/src/lib/utils.ts | 31 +++++++++++++++++++ 4 files changed, 38 insertions(+), 63 deletions(-) diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionList.tsx index 4a12417702..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'; @@ -261,24 +262,3 @@ function Descendants(props: { ); } - -/** - * Recursively find a section by ID within a group and its nested children - */ -function findSectionInGroup( - group: ClientSiteSectionGroup, - sectionId: string -): ClientSiteSection | null { - for (const child of group.children) { - if (child.object === 'site-section' && child.id === sectionId) { - return child; - } - if (child.object === 'site-section-group') { - const found = findSectionInGroup(child, sectionId); - if (found) { - return found; - } - } - } - return null; -} diff --git a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx index e20eba899a..1e1cab0170 100644 --- a/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx +++ b/packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx @@ -6,6 +6,7 @@ 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'; @@ -332,24 +333,3 @@ function SectionGroupTile(props: { ); } - -/** - * Recursively find a section by ID within a group and its nested children - */ -function findSectionInGroup( - group: ClientSiteSectionGroup, - sectionId: string -): ClientSiteSection | null { - for (const child of group.children) { - if (child.object === 'site-section' && child.id === sectionId) { - return child; - } - if (child.object === 'site-section-group') { - const found = findSectionInGroup(child, sectionId); - if (found) { - return found; - } - } - } - return null; -} diff --git a/packages/gitbook/src/lib/sites.ts b/packages/gitbook/src/lib/sites.ts index 27b68d8ae8..4da397187c 100644 --- a/packages/gitbook/src/lib/sites.ts +++ b/packages/gitbook/src/lib/sites.ts @@ -1,23 +1,7 @@ import type { GitBookSiteContext } from '@/lib/context'; import type { SiteSection, SiteSectionGroup, SiteSpace, SiteStructure } from '@gitbook/api'; import { joinPath } from './paths'; - -/** - * Recursively flatten all sections from nested groups - */ -function flattenSectionsFromGroup(children: (SiteSection | SiteSectionGroup)[]): SiteSection[] { - const sections: SiteSection[] = []; - - for (const child of children) { - if (child.object === 'site-section') { - sections.push(child); - } else if (child.object === 'site-section-group') { - sections.push(...flattenSectionsFromGroup(child.children)); - } - } - - return sections; -} +import { flattenSectionsFromGroup } from './utils'; /** * Get all sections from a site structure. @@ -40,7 +24,7 @@ export function getSiteStructureSections( ? ignoreGroups ? siteStructure.structure.flatMap((item) => item.object === 'site-section-group' - ? flattenSectionsFromGroup(item.children) + ? flattenSectionsFromGroup(item.children) : item ) : siteStructure.structure @@ -60,9 +44,9 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) { return section.siteSpaces; } - return flattenSectionsFromGroup(section.children).flatMap( - (subSection) => subSection.siteSpaces - ); + return flattenSectionsFromGroup(section.children) + .filter((subSection): subSection is SiteSection => subSection.object === 'site-section') + .flatMap((subSection) => subSection.siteSpaces); }); } 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 + ); +}