refactor toc components to client (#3394)

Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
This commit is contained in:
conico974
2025-06-26 11:35:02 +02:00
committed by GitHub
parent 5134d9e7f8
commit e6c3c7635d
9 changed files with 190 additions and 90 deletions
@@ -4,7 +4,10 @@ import { Icon, type IconName } from '@gitbook/icons';
import { Emoji } from '@/components/primitives';
import { type ClassValue, tcls } from '@/lib/tailwind';
export function PageIcon(props: { page: RevisionPage; style?: ClassValue }) {
export function PageIcon(props: {
page: Pick<RevisionPage, 'emoji' | 'icon'>;
style?: ClassValue;
}) {
const { page, style } = props;
if (page.emoji) {
@@ -1,48 +1,32 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import {
type RevisionPage,
type RevisionPageDocument,
SiteInsightsLinkPosition,
} from '@gitbook/api';
'use client';
import { tcls } from '@/lib/tailwind';
import type { ClientTOCPageDocument } from './encodeClientTableOfContents';
import { SiteInsightsLinkPosition } from '@gitbook/api';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
import { ToggleableLinkItem } from './ToggleableLinkItem';
export async function PageDocumentItem(props: {
rootPages: RevisionPage[];
page: RevisionPageDocument;
context: GitBookSiteContext;
}) {
const { rootPages, page, context } = props;
let href = context.linker.toPathForPage({ pages: rootPages, page });
// toPathForPage can returns an empty path, this will cause all links to point to the current page.
if (href === '') {
href = '/';
}
export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
const { page } = props;
return (
<li className="flex flex-col">
<ToggleableLinkItem
href={href}
pathnames={getPagePaths(rootPages, page)}
href={page.href ?? '#'}
pathnames={page.pathnames}
insights={{
type: 'link_click',
link: {
target: {
kind: 'page',
page: page.id,
},
target: { kind: 'page', page: page.id },
position: SiteInsightsLinkPosition.Sidebar,
},
}}
descendants={
hasPageVisibleDescendant(page) ? (
page.descendants && page.descendants.length > 0 ? (
<PagesList
rootPages={rootPages}
pages={page.pages}
pages={page.descendants}
style={tcls(
'ml-5',
'my-2',
@@ -50,7 +34,6 @@ export async function PageDocumentItem(props: {
'sidebar-list-default:border-l',
'sidebar-list-line:border-l'
)}
context={context}
/>
) : null
}
@@ -1,18 +1,14 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { RevisionPage, RevisionPageGroup } from '@gitbook/api';
'use client';
import type { ClientTOCPageGroup } from './encodeClientTableOfContents';
import { hasPageVisibleDescendant } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { PagesList } from './PagesList';
import { TOCPageIcon } from './TOCPageIcon';
export function PageGroupItem(props: {
rootPages: RevisionPage[];
page: RevisionPageGroup;
context: GitBookSiteContext;
}) {
const { rootPages, page, context } = props;
export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
const { page } = props;
return (
<li className="group/page-group-item flex flex-col">
@@ -36,8 +32,8 @@ export function PageGroupItem(props: {
<TOCPageIcon page={page} />
{page.title}
</div>
{hasPageVisibleDescendant(page) ? (
<PagesList rootPages={rootPages} pages={page.pages} context={context} />
{page.descendants && page.descendants.length > 0 ? (
<PagesList pages={page.descendants} />
) : null}
</li>
);
@@ -1,22 +1,21 @@
import type { GitBookSiteContext } from '@/lib/context';
import { type RevisionPageLink, SiteInsightsLinkPosition } from '@gitbook/api';
'use client';
import { Icon } from '@gitbook/icons';
import type { ClientTOCPageLink } from './encodeClientTableOfContents';
import { Link } from '@/components/primitives';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { SiteInsightsLinkPosition } from '@gitbook/api';
import { TOCPageIcon } from './TOCPageIcon';
export async function PageLinkItem(props: { page: RevisionPageLink; context: GitBookSiteContext }) {
const { page, context } = props;
const resolved = await resolveContentRef(page.target, context);
export function PageLinkItem(props: { page: ClientTOCPageLink }) {
const { page } = props;
return (
<li className={tcls('flex', 'flex-col')}>
<Link
href={resolved?.href ?? '#'}
href={page.href ?? '#'}
classNames={['PageLinkItemStyles']}
insights={{
type: 'link_click',
@@ -1,5 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { RevisionPage } from '@gitbook/api';
'use client';
import type { ClientTOCPage } from './encodeClientTableOfContents';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -8,50 +9,21 @@ import { PageDocumentItem } from './PageDocumentItem';
import { PageGroupItem } from './PageGroupItem';
import { PageLinkItem } from './PageLinkItem';
export function PagesList(props: {
context: GitBookSiteContext;
rootPages: RevisionPage[];
pages: RevisionPage[];
style?: ClassValue;
}) {
const { rootPages, pages, context, style } = props;
export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue }) {
const { pages, style } = props;
return (
<ul className={tcls('flex flex-col gap-y-0.5', style)}>
{pages.map((page) => {
if (page.type === 'computed') {
throw new Error(
'Unexpected computed page, it should have been computed in the API'
);
}
if (page.hidden) {
return null;
}
switch (page.type) {
case 'document':
return (
<PageDocumentItem
key={page.id}
rootPages={rootPages}
page={page}
context={context}
/>
);
return <PageDocumentItem key={page.id} page={page} />;
case 'link':
return <PageLinkItem key={page.id} page={page} context={context} />;
return <PageLinkItem key={page.id} page={page} />;
case 'group':
return (
<PageGroupItem
key={page.id}
rootPages={rootPages}
page={page}
context={context}
/>
);
return <PageGroupItem key={page.id} page={page} />;
default:
assertNever(page);
@@ -7,7 +7,7 @@ import { PageIcon } from '../PageIcon';
/**
* Styled page icon for the table of contents.
*/
export function TOCPageIcon({ page }: { page: RevisionPage }) {
export function TOCPageIcon({ page }: { page: Pick<RevisionPage, 'emoji' | 'icon'> }) {
return (
<PageIcon
page={page}
@@ -8,8 +8,9 @@ import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
export function TableOfContents(props: {
export async function TableOfContents(props: {
context: GitBookSiteContext;
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
@@ -17,6 +18,8 @@ export function TableOfContents(props: {
const { innerHeader, context, header } = props;
const { space, customization, revision } = context;
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
return (
<>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
@@ -106,9 +109,7 @@ export function TableOfContents(props: {
)}
>
<PagesList
rootPages={revision.pages}
pages={revision.pages}
context={context}
pages={pages}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
@@ -0,0 +1,124 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getPagePaths, hasPageVisibleDescendant } from '@/lib/pages';
import { resolveContentRef } from '@/lib/references';
import { removeUndefined } from '@/lib/typescript';
import type { ContentRef, RevisionPage } from '@gitbook/api';
import assertNever from 'assert-never';
export type ClientTOCPageLink = {
type: 'link';
id: string;
title: string;
href: string;
emoji?: string;
icon?: string;
target: ContentRef;
};
export type ClientTOCPageDocument = {
type: 'document';
id: string;
title: string;
href: string;
emoji?: string;
icon?: string;
pathnames: string[];
descendants?: ClientTOCPage[];
};
export type ClientTOCPageGroup = {
type: 'group';
id: string;
title: string;
emoji?: string;
icon?: string;
descendants?: ClientTOCPage[];
};
export type ClientTOCPage = ClientTOCPageLink | ClientTOCPageDocument | ClientTOCPageGroup;
/**
*
* Encodes a table of contents for client components.
* We do this to reduce the amount of data sent as RSC, we only send the encoded ClientTableOfContents once to a single client component.
*/
export async function encodeClientTableOfContents(
context: GitBookSiteContext,
rootPages: RevisionPage[],
pages: RevisionPage[]
): Promise<ClientTOCPage[]> {
const result: ClientTOCPage[] = [];
for (const page of pages) {
if (page.type === 'computed') {
throw new Error('Unexpected computed page, it should have been computed in the API');
}
if (page.hidden) {
continue;
}
switch (page.type) {
case 'document': {
let href = context.linker.toPathForPage({ pages: rootPages, page });
if (href === '') {
href = '/';
}
const descendants = hasPageVisibleDescendant(page)
? await encodeClientTableOfContents(context, rootPages, page.pages)
: undefined;
result.push(
removeUndefined({
id: page.id,
title: page.title,
href,
emoji: page.emoji,
icon: page.icon,
pathnames: getPagePaths(rootPages, page),
descendants,
type: 'document',
})
);
break;
}
case 'link': {
const resolved = await resolveContentRef(page.target, context);
result.push(
removeUndefined({
id: page.id,
title: page.title,
href: resolved?.href ?? '#',
emoji: page.emoji,
icon: page.icon,
target: page.target,
type: 'link',
})
);
break;
}
case 'group': {
const descendants = hasPageVisibleDescendant(page)
? await encodeClientTableOfContents(context, rootPages, page.pages)
: undefined;
result.push(
removeUndefined({
id: page.id,
title: page.title,
emoji: page.emoji,
icon: page.icon,
descendants,
type: 'group',
})
);
break;
}
default:
assertNever(page);
}
}
return result;
}
+22
View File
@@ -11,3 +11,25 @@ export function filterOutNullable<T>(value: T): value is NonNullable<T> {
export function nullIfNever(_value: never): null {
return null;
}
type WithoutUndefined<T> = {
[K in keyof T]: T[K] extends undefined ? never : T[K];
};
/**
* Removes `undefined` properties from an object.
* This is useful for RSC serialization, as it avoids sending `"$undefined"` values.
*
*/
// biome-ignore lint/suspicious/noExplicitAny: can't avoid for the generic
export function removeUndefined<T extends Record<string, any>>(obj: T): WithoutUndefined<T> {
const result: Partial<T> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key as keyof T] = value;
}
}
return result as WithoutUndefined<T>;
}