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
+ );
+}