diff --git a/.changeset/funny-avocados-tickle.md b/.changeset/funny-avocados-tickle.md new file mode 100644 index 000000000..0fc0db7a4 --- /dev/null +++ b/.changeset/funny-avocados-tickle.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Add navigation tabs for sections diff --git a/packages/gitbook/src/app/(site)/(content)/layout.tsx b/packages/gitbook/src/app/(site)/(content)/layout.tsx index e94124169..01ff72b65 100644 --- a/packages/gitbook/src/app/(site)/(content)/layout.tsx +++ b/packages/gitbook/src/app/(site)/(content)/layout.tsx @@ -39,6 +39,7 @@ export default async function ContentLayout(props: { children: React.ReactNode } spaces, ancestors, scripts, + sections, } = await fetchContentData(); ReactDOM.preconnect(api().endpoint); @@ -66,6 +67,7 @@ export default async function ContentLayout(props: { children: React.ReactNode } contentTarget={contentTarget} site={site} spaces={spaces} + sections={sections} customization={customization} pages={pages} ancestors={ancestors} diff --git a/packages/gitbook/src/app/(site)/fetch.ts b/packages/gitbook/src/app/(site)/fetch.ts index e45d55b15..44574a00d 100644 --- a/packages/gitbook/src/app/(site)/fetch.ts +++ b/packages/gitbook/src/app/(site)/fetch.ts @@ -1,4 +1,5 @@ -import { RevisionPage, Space } from '@gitbook/api'; +import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api'; +import { assert } from 'ts-essentials'; import { getRevisionPageByPath, @@ -6,8 +7,8 @@ import { ContentTarget, getSiteData, getSite, - getSiteSpaces, getCurrentSiteCustomization, + getSiteStructure, } from '@/lib/api'; import { resolvePagePath, resolvePageId } from '@/lib/pages'; import { getSiteContentPointer } from '@/lib/pointer'; @@ -20,6 +21,8 @@ export interface PageIdParams { pageId: string; } +type SectionsList = { list: SiteSection[]; section: SiteSection }; + /** * Fetch all the data needed to render the content layout. */ @@ -37,15 +40,25 @@ export async function fetchContentData() { ]); const site = siteStructure.site; - const spaces = siteStructure.spaces; + + const siteSections = + content.siteSectionId && siteStructure.sections + ? parseSiteSectionsList(content.siteSectionId, siteStructure.sections) + : null; + + const spaces = + siteStructure.spaces ?? + (siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []); + // we grab the space attached to the parent as it contains overriden customizations - const spaceRelativeToParent = spaces.find((space) => space.id === content.spaceId); + const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId); return { content, contentTarget, space: spaceRelativeToParent ?? space, pages, + sections: siteSections, site, spaces, customization, @@ -54,6 +67,16 @@ export async function fetchContentData() { }; } +function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) { + const section = sections.find((section) => section.id === siteSectionId); + assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections'); + return { list: sections, section } satisfies SectionsList; +} + +function sectionIsDefined(section?: SiteSection): section is NonNullable { + return typeof section !== 'undefined' && section !== null; +} + /** * Fetch all the data needed to render the content. * Optimized to fetch in parallel as much as possible. @@ -129,7 +152,7 @@ async function resolvePage( /** * Fetch the structure of an organization site. - * This includes the site and its spaces. + * This includes the site and its sections or spaces. */ async function fetchSiteStructure(args: { organizationId: string; @@ -137,12 +160,35 @@ async function fetchSiteStructure(args: { siteShareKey: string | undefined; }) { const { organizationId, siteId, siteShareKey } = args; - const [orgSite, siteSpaces, siteParentCustomizations] = await Promise.all([ + const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([ getSite(organizationId, siteId), - getSiteSpaces({ organizationId, siteId, siteShareKey }), + getSiteStructure({ organizationId, siteId, siteShareKey }), getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }), ]); + const siteSections = + siteStructure.type === 'sections' && siteStructure.structure + ? siteStructure.structure + : null; + const siteSpaces = + siteStructure.type === 'siteSpaces' && siteStructure.structure + ? parseSpacesFromSiteSpaces(siteStructure.structure) + : null; + + // override the title with the customization title + const site = { + ...orgSite, + ...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}), + }; + + return { + site, + spaces: siteSpaces, + sections: siteSections, + }; +} + +function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) { const spaces: Record = {}; siteSpaces.forEach((siteSpace) => { spaces[siteSpace.space.id] = { @@ -154,17 +200,7 @@ async function fetchSiteStructure(args: { }, }; }); - - // override the title with the customization title - const site = { - ...orgSite, - ...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}), - }; - - return { - site, - spaces: Object.values(spaces), - }; + return Object.values(spaces); } /** diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index 3c3e6ae9b..cb21b39fb 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -1,10 +1,4 @@ -import { - Collection, - CustomizationSettings, - Site, - SiteCustomizationSettings, - Space, -} from '@gitbook/api'; +import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api'; import { CustomizationHeaderPreset } from '@gitbook/api'; import { Suspense } from 'react'; diff --git a/packages/gitbook/src/components/SiteSectionTabs/SiteSectionTab.tsx b/packages/gitbook/src/components/SiteSectionTabs/SiteSectionTab.tsx new file mode 100644 index 000000000..9c3eca8ae --- /dev/null +++ b/packages/gitbook/src/components/SiteSectionTabs/SiteSectionTab.tsx @@ -0,0 +1,134 @@ +'use client'; +import { SiteSection } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; +import React from 'react'; + +import { tcls } from '@/lib/tailwind'; + +import { Button, Link } from '../primitives'; + +/** + * A set of tabs representing site sections for multi-section sites + */ +export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteSection }) { + const { sections, section: currentSection } = props; + + const tabs = sections.map((section) => ({ + id: section.id, + label: section.title, + path: section.urls.published ?? '', + })); + + const currentTabRef = React.useRef(null); + const navRef = React.useRef(null); + + const [currentIndex, setCurrentIndex] = React.useState( + sections.findIndex((section) => section.id === currentSection?.id) || 0, + ); + const [tabDimensions, setTabDimensions] = React.useState<{ + left: number; + width: number; + } | null>(null); + + React.useEffect(() => { + if (currentTabRef.current && navRef.current) { + const rect = currentTabRef.current.getBoundingClientRect(); + const navRect = navRef.current.getBoundingClientRect(); + setTabDimensions({ left: rect.left - navRect.left, width: rect.width }); + } + }, [currentIndex]); + + React.useLayoutEffect(() => { + function onResize() { + if (currentTabRef.current && navRef.current) { + const rect = currentTabRef.current.getBoundingClientRect(); + const navRect = navRef.current.getBoundingClientRect(); + setTabDimensions({ left: rect.left - navRect.left, width: rect.width }); + } + } + window.addEventListener('resize', onResize); + () => window.removeEventListener('resize', onResize); + }, []); + + const scale = (tabDimensions?.width ?? 0) * 0.01; + const startPos = `${tabDimensions?.left ?? 0}px`; + + const hasMoreSections = false; /** TODO: determine whether we need to show the more button */ + + return tabs.length > 0 ? ( + + ) : null; +} + +/** + * The tab item - a link to a site section + */ +const Tab = React.forwardRef< + HTMLAnchorElement, + { active: boolean; href: string; label: string; onClick: any } +>(function Tab(props, ref) { + const { active, href, label, onClick } = props; + return ( +
+ + {label} + +
+ ); +}); + +/** + * Dropdown trigger for when there are too many sections to show them all + */ +function MoreSectionsButton() { + return ( +
+ +
+ ); +} diff --git a/packages/gitbook/src/components/SiteSectionTabs/index.ts b/packages/gitbook/src/components/SiteSectionTabs/index.ts new file mode 100644 index 000000000..0a5116c84 --- /dev/null +++ b/packages/gitbook/src/components/SiteSectionTabs/index.ts @@ -0,0 +1 @@ +export * from './SiteSectionTab'; diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index bde1514e9..146cb1e78 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -6,6 +6,7 @@ import { RevisionPageGroup, Site, SiteCustomizationSettings, + SiteSection, Space, } from '@gitbook/api'; import React from 'react'; @@ -20,6 +21,8 @@ import { ContentTarget, SiteContentPointer } from '@/lib/api'; import { ContentRefContext } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; +import { SiteSectionTabs } from '../SiteSectionTabs'; + /** * Render the entire content of the space (header, table of contents, footer, and page content). */ @@ -28,6 +31,7 @@ export function SpaceLayout(props: { contentTarget: ContentTarget; space: Space; site: Site | null; + sections: { list: SiteSection[]; section: SiteSection } | null; spaces: Space[]; customization: CustomizationSettings | SiteCustomizationSettings; pages: Revision['pages']; @@ -38,6 +42,7 @@ export function SpaceLayout(props: { space, contentTarget, site, + sections, spaces, content, pages, @@ -66,8 +71,12 @@ export function SpaceLayout(props: { context={contentRefContext} customization={customization} /> -
+ {sections ? ( +
+ +
+ ) : null}
getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( + args: { + organizationId: string; + siteId: string; + /** Site share key that can be used as context to resolve site space published urls */ + siteShareKey: string | undefined; + }, + options: CacheFunctionOptions, + ) => { + const response = await api().orgs.getSiteStructure( + args.organizationId, + args.siteId, + { + ...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}), + }, + { + ...noCacheFetchOptions, + signal: options.signal, + }, + ); + return cacheResponse(response, { + revalidateBefore: 60 * 60, + data: response.data, + }); + }, +}); + /** * List the scripts to load for the site. */ diff --git a/packages/gitbook/src/lib/pointer.ts b/packages/gitbook/src/lib/pointer.ts index bd9d804f1..826217846 100644 --- a/packages/gitbook/src/lib/pointer.ts +++ b/packages/gitbook/src/lib/pointer.ts @@ -11,6 +11,7 @@ export function getSiteContentPointer(): SiteContentPointer { const siteId = headerSet.get('x-gitbook-content-site'); const organizationId = headerSet.get('x-gitbook-content-organization'); const siteSpaceId = headerSet.get('x-gitbook-content-site-space'); + const siteSectionId = headerSet.get('x-gitbook-content-site-section'); const siteShareKey = headerSet.get('x-gitbook-content-site-share-key'); if (!spaceId || !siteId || !organizationId) { @@ -22,6 +23,7 @@ export function getSiteContentPointer(): SiteContentPointer { const pointer: SiteContentPointer = { siteId, spaceId, + siteSectionId: siteSectionId ?? undefined, siteSpaceId: siteSpaceId ?? undefined, siteShareKey: siteShareKey ?? undefined, organizationId, diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 2a4452e71..57da6abb3 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -200,6 +200,9 @@ export async function middleware(request: NextRequest) { if ('site' in resolved) { headers.set('x-gitbook-content-organization', resolved.organization); headers.set('x-gitbook-content-site', resolved.site); + if (resolved.siteSection) { + headers.set('x-gitbook-content-site-section', resolved.siteSection); + } if (resolved.siteSpace) { headers.set('x-gitbook-content-site-space', resolved.siteSpace); }