mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 19:06:31 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user