Compare commits

...

6 Commits

Author SHA1 Message Date
Zeno Kapitein d8cfb10974 Format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 3cdba0ae8e Cleanup & format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 83e02b621a Iteration 4 2025-05-01 10:49:32 +02:00
Zeno Kapitein cb2cc52c26 Third iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 0e15229c52 Second iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 8547867c6d Initial version 2025-05-01 10:49:32 +02:00
18 changed files with 930 additions and 207 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Adds AI sidebar with recommendations based on browsing behaviour
+1
View File
@@ -1394,6 +1394,7 @@ async function* streamAIResponse(
input: params.input,
output: params.output,
model: params.model,
tools: params.tools,
});
for await (const event of res) {
@@ -189,5 +189,6 @@ export interface GitBookDataFetcher {
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
tools?: api.AIToolCapabilities;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -0,0 +1,138 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Emoji } from '../primitives';
import { type SuggestedPage, useAdaptiveContext } from './AdaptiveContext';
import { streamNextPageSuggestions } from './server-actions/streamNextPageSuggestions';
export function AINextPageSuggestions() {
const { selectedJourney, open } = useAdaptiveContext();
const currentPage = usePageContext();
const visitedPages = useVisitedPages((state) => state.pages);
const [pages, setPages] = useState<SuggestedPage[]>(selectedJourney?.pages ?? []);
const [suggestedPages, setSuggestedPages] = useState<SuggestedPage[]>([]);
useEffect(() => {
let canceled = false;
if (selectedJourney?.pages && selectedJourney.pages.length > 0) {
setPages(selectedJourney.pages);
} else {
setPages(suggestedPages);
}
if (suggestedPages.length === 0) {
(async () => {
const stream = await streamNextPageSuggestions({
currentPage: {
id: currentPage.pageId,
title: currentPage.title,
},
currentSpace: {
id: currentPage.spaceId,
},
visitedPages: visitedPages,
});
for await (const page of stream) {
if (canceled) return;
setPages((prev) => [...prev, page]);
setSuggestedPages((prev) => [...prev, page]);
}
})();
}
return () => {
canceled = true;
};
}, [
selectedJourney,
currentPage.pageId,
currentPage.spaceId,
currentPage.title,
visitedPages,
suggestedPages,
]);
return (
open && (
<div className="animate-fadeIn">
<motion.div className="mb-2 flex flex-row items-start gap-3">
<AnimatePresence mode="wait">
{selectedJourney?.icon ? (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ delay: 0.2 }}
key={selectedJourney.icon}
>
<Icon
icon={selectedJourney.icon as IconName}
className="left-0 mt-2 size-6 shrink-0 text-tint-subtle"
/>
</motion.div>
) : null}
</AnimatePresence>
<motion.div className={tcls('flex flex-col')} layout="position">
<div className="font-semibold text-tint text-xs uppercase tracking-wide">
Suggested pages
</div>
<AnimatePresence mode="wait">
{selectedJourney?.label ? (
<motion.h5
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key={selectedJourney.label}
className="font-semibold text-base"
>
{selectedJourney.label}
</motion.h5>
) : null}
</AnimatePresence>
</motion.div>
</motion.div>
<div className="-mb-1.5 flex flex-col gap-1">
{Object.assign(Array.from({ length: 5 }), pages).map(
(page: SuggestedPage | undefined, index) =>
page ? (
<Link
key={`${selectedJourney?.label}-${page.id}`}
className="-mx-2 flex animate-fadeIn gap-2 rounded px-2.5 py-1 transition-all hover:bg-tint-hover hover:text-tint-strong"
href={page.href}
style={{ animationDelay: `${0.2 + index * 0.05}s` }}
>
{page.icon ? (
<Icon
icon={page.icon as IconName}
className="mt-0.5 size-4 shrink-0 text-tint-subtle"
/>
) : null}
{page.emoji ? <Emoji code={page.emoji} /> : null}
{page.title}
</Link>
) : (
<div
key={index}
className="my-1 h-5 animate-pulse rounded bg-tint-hover"
style={{
animationDelay: `${index * 0.2}s`,
width: `${((index * 17) % 50) + 50}%`,
}}
/>
)
)}
</div>
</div>
)
);
}
@@ -0,0 +1,60 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { JOURNEY_COUNT, type Journey, useAdaptiveContext } from './AdaptiveContext';
export function AIPageJourneySuggestions() {
const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext();
return (
open && (
<div className="animate-fadeIn">
<div className="mb-2 flex flex-row items-center gap-1 font-semibold text-tint text-xs uppercase tracking-wide">
More to explore
</div>
<div className="grid grid-cols-2 gap-2">
{Object.assign(Array.from({ length: JOURNEY_COUNT }), journeys).map(
(journey: Journey | undefined, index) => {
const isSelected =
journey?.label && journey.label === selectedJourney?.label;
const isLoading = !journey || journey?.label === undefined;
return (
<button
type="button"
key={index}
disabled={journey?.label === undefined}
className={tcls(
'flex flex-col items-center justify-center gap-2 rounded bg-tint px-2 py-4 text-center ring-1 ring-tint-subtle ring-inset transition-all',
isLoading
? 'h-24 scale-90 animate-pulse'
: 'hover:bg-tint-hover hover:text-tint-strong hover:ring-tint active:scale-95',
isSelected &&
'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary'
)}
style={{
animationDelay: `${index * 0.2}s`,
}}
onClick={() =>
setSelectedJourney(isSelected ? undefined : journey)
}
>
{journey?.icon ? (
<Icon
icon={journey.icon as IconName}
className="size-4 animate-fadeIn text-tint-subtle [animation-delay:300ms]"
/>
) : null}
{journey?.label ? (
<span className="animate-fadeIn leading-tight [animation-delay:400ms]">
{journey.label}
</span>
) : null}
</button>
);
}
)}
</div>
</div>
)
);
}
@@ -0,0 +1,101 @@
'use client';
import React, { useEffect } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { streamPageJourneySuggestions } from './server-actions';
export type SuggestedPage = {
id: string;
title: string;
href: string;
icon?: string;
emoji?: string;
};
export type Journey = {
label: string;
icon?: string;
pages?: Array<SuggestedPage>;
};
type AdaptiveContextType = {
journeys: Journey[];
selectedJourney: Journey | undefined;
setSelectedJourney: (journey: Journey | undefined) => void;
loading: boolean;
open: boolean;
setOpen: (open: boolean) => void;
};
export const AdaptiveContext = React.createContext<AdaptiveContextType | null>(null);
export const JOURNEY_COUNT = 4;
/**
* Client side context provider to pass information about the current page.
*/
export function JourneyContextProvider({
children,
spaces,
}: { children: React.ReactNode; spaces: { id: string; title: string }[] }) {
const [journeys, setJourneys] = React.useState<Journey[]>([]);
const [selectedJourney, setSelectedJourney] = React.useState<Journey | undefined>(undefined);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(true);
const currentPage = usePageContext();
const visitedPages = useVisitedPages((state) => state.pages);
useEffect(() => {
let canceled = false;
setJourneys([]);
(async () => {
const stream = await streamPageJourneySuggestions({
count: JOURNEY_COUNT,
currentPage: {
id: currentPage.pageId,
title: currentPage.title,
},
currentSpace: {
id: currentPage.spaceId,
},
allSpaces: spaces,
visitedPages,
});
for await (const journey of stream) {
if (canceled) return;
setJourneys((prev) => [...prev, journey]);
}
setLoading(false);
})();
return () => {
canceled = true;
};
}, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]);
return (
<AdaptiveContext.Provider
value={{ journeys, selectedJourney, setSelectedJourney, loading, open, setOpen }}
>
{children}
</AdaptiveContext.Provider>
);
}
/**
* Hook to use the adaptive context.
*/
export function useAdaptiveContext() {
const context = React.useContext(AdaptiveContext);
if (!context) {
throw new Error('useAdaptiveContext must be used within a AdaptiveContextProvider');
}
return context;
}
@@ -0,0 +1,23 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { AINextPageSuggestions } from './AINextPageSuggestions';
import { AIPageJourneySuggestions } from './AIPageJourneySuggestions';
import { useAdaptiveContext } from './AdaptiveContext';
import { AdaptivePaneHeader } from './AdaptivePaneHeader';
export function AdaptivePane() {
const { open } = useAdaptiveContext();
return (
<div
className={tcls(
'flex flex-col gap-4 rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
open ? 'w-72 px-4 py-4' : 'w-56 px-4 py-3'
)}
>
<AdaptivePaneHeader />
<AIPageJourneySuggestions />
<AINextPageSuggestions />
</div>
);
}
@@ -0,0 +1,40 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { AnimatePresence, motion } from 'framer-motion';
import { Button, Loading } from '../primitives';
import { useAdaptiveContext } from './AdaptiveContext';
export function AdaptivePaneHeader() {
const { loading, open, setOpen } = useAdaptiveContext();
return (
<div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500">
<div className="flex grow flex-col">
<h4 className="flex items-center gap-1.5 font-semibold ">
<Loading className="size-4 text-tint-subtle" busy={loading} />
For you
</h4>
<AnimatePresence initial={false} mode="wait">
<motion.h5
key={loading ? 'loading' : 'loaded'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-tint-subtle text-xs"
>
{loading ? 'Basing on your context...' : 'Based on your context'}
</motion.h5>
</AnimatePresence>
</div>
<Button
variant="blank"
className={tcls('px-2 *:transition-transform', !open && '*:-rotate-45')}
iconOnly
label="Close"
icon="close"
onClick={() => setOpen(!open)}
/>
</div>
);
}
@@ -1 +1,3 @@
export * from './AIPageLinkSummary';
export * from './AdaptiveContext';
export * from './AdaptivePane';
@@ -1,5 +1,10 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import {
type AIMessageInput,
AIModel,
type AIStreamResponse,
type AIToolCapabilities,
} from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
@@ -47,11 +52,13 @@ export async function streamGenerateObject<T>(
schema,
messages,
model = AIModel.Fast,
tools = {},
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
tools?: AIToolCapabilities;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
@@ -62,12 +69,13 @@ export async function streamGenerateObject<T>(
type: 'object',
schema: zodToJsonSchema(schema),
},
tools,
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
if (event.type === 'response_object' && event.jsonChunk) {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
@@ -1 +1,2 @@
export * from './streamLinkPageSummary';
export * from './streamPageJourneySuggestions';
@@ -0,0 +1,128 @@
'use server';
import { resolvePageId } from '@/lib/pages';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a list of pages to read next
*/
export async function* streamNextPageSuggestions({
currentPage,
currentSpace,
visitedPages,
}: {
currentPage: {
id: string;
title: string;
};
currentSpace: {
id: string;
// title: string;
};
visitedPages?: Array<{ spaceId: string; pageId: string }>;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }, context] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
pages: z
.array(z.string().describe('The IDs of the page to read next.'))
.min(5)
.max(5),
}),
tools: {
getPages: true,
getPageContent: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
"You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a list of pages to read next.",
},
{
role: AIMessageRole.Developer,
content: `The user is in space (ID ${currentSpace.id})`,
},
// {
// role: AIMessageRole.Developer,
// content: `Other spaces in the documentation are: ${allSpaces
// .map(
// (space) => `
// - "${space.title}" (ID ${space.id})`
// )
// .join('\n')}
// Feel free to create journeys across spaces.`,
// },
{
role: AIMessageRole.Developer,
content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpace.id,
pageId: currentPage.id,
},
],
},
...(visitedPages && visitedPages.length > 0
? [
{
role: AIMessageRole.Developer,
content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`,
attachments: visitedPages.slice(0, 5).map((page) => ({
type: 'page' as const,
spaceId: page.spaceId,
pageId: page.pageId,
})),
},
]
: []),
],
}
),
fetchServerActionSiteContext(baseContext),
]);
const emitted = new Set<string>();
for await (const value of stream) {
const pages = value.pages;
if (!pages) continue;
for (const pageId of pages) {
if (!pageId) continue;
if (emitted.has(pageId)) continue;
emitted.add(pageId);
const resolvedPage = resolvePageId(context.pages, pageId);
if (!resolvedPage) continue;
yield {
id: resolvedPage.page.id,
title: resolvedPage.page.title,
icon: resolvedPage.page.icon,
emoji: resolvedPage.page.emoji,
href: context.linker.toPathForPage({
pages: context.pages,
page: resolvedPage.page,
}),
};
}
}
}
@@ -0,0 +1,190 @@
'use server';
import { type AncestorRevisionPage, resolvePageId } from '@/lib/pages';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole, type RevisionPageDocument } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamPageJourneySuggestions({
currentPage,
currentSpace,
allSpaces,
visitedPages,
count,
}: {
currentPage: {
id: string;
title: string;
};
currentSpace: {
id: string;
// title: string;
};
allSpaces: {
id: string;
title: string;
}[];
visitedPages?: Array<{ spaceId: string; pageId: string }>;
count: number;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }, context] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
journeys: z
.array(
z.object({
label: z.string().describe('The label of the journey.'),
icon: z
.string()
.describe(
'The icon of the journey. Use an icon from FontAwesome, stripping the `fa-`. Examples: rocket-launch, tennis-ball, cat'
),
pages: z
.array(
z.object({
id: z.string(),
})
)
.describe(
'A list of pages in the journey, excluding the current page. Try to avoid duplicate content that is very similar.'
)
.min(5)
.max(10),
})
)
.describe('The possible journeys to take through the documentation.')
.min(count)
.max(count),
}),
tools: {
getPages: true,
getPageContent: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
"You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a named journey through the documentation. A journey is a list of pages that are related to each other. A journey's label starts with a verb and has a clear subject. Use sentence case (so only capitalize the first letter of the first word). Be concise and use short words to fit in the label. For example, use 'docs' instead of 'documentation'. Try to pick out specific journeys, not too generic.",
},
{
role: AIMessageRole.Developer,
content: `The user is in space "${allSpaces.find((space) => space.id === currentSpace.id)?.title}" (ID ${currentSpace.id})`,
},
{
role: AIMessageRole.Developer,
content: `Other spaces in the documentation are: ${allSpaces
.map(
(space) => `
- "${space.title}" (ID ${space.id})`
)
.join('\n')}
Feel free to create journeys across spaces.`,
},
{
role: AIMessageRole.Developer,
content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpace.id,
pageId: currentPage.id,
},
],
},
...(visitedPages && visitedPages.length > 0
? [
{
role: AIMessageRole.Developer,
content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`,
attachments: visitedPages.slice(0, 5).map((page) => ({
type: 'page' as const,
spaceId: page.spaceId,
pageId: page.pageId,
})),
},
]
: []),
],
}
),
fetchServerActionSiteContext(baseContext),
]);
const emitted: { label: string; pageIds: string[] }[] = [];
const allEmittedPageIds = new Set<string>();
for await (const value of stream) {
const journeys = value.journeys;
if (!journeys) continue;
for (const journey of journeys) {
if (!journey?.label) continue;
if (!journey?.pages || journey.pages?.length === 0) continue;
if (emitted.find((item) => item.label === journey.label)) continue;
const pageIds: string[] = [];
const resolvedPages: {
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
}[] = [];
for (const page of journey.pages) {
if (!page) continue;
if (!page.id) continue;
if (pageIds.includes(page.id)) continue;
pageIds.push(page.id);
const resolvedPage = resolvePageId(context.pages, page.id);
if (!resolvedPage) continue;
resolvedPages.push(resolvedPage);
}
emitted.push({
label: journey.label,
pageIds: pageIds,
});
// Deduplicate pages before yielding
const uniquePages = resolvedPages.filter((page) => {
if (allEmittedPageIds.has(page.page.id)) {
return false;
}
allEmittedPageIds.add(page.page.id);
return true;
});
yield {
label: journey.label,
icon: journey.icon,
pages: uniquePages.map((page) => ({
id: page.page.id,
title: page.page.title,
icon: page.page.icon,
emoji: page.page.emoji,
href: context.linker.toPathForPage({
pages: context.pages,
page: page.page,
}),
})),
};
}
}
}
@@ -0,0 +1,106 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import type { RevisionPageDocument, Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type { GitBookSiteContext } from '@v2/lib/context';
import React from 'react';
import urlJoin from 'url-join';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
export function PageActions(props: {
page: RevisionPageDocument;
context: GitBookSiteContext;
withPageFeedback: boolean;
}) {
const { page, withPageFeedback, context } = props;
const { customization, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
return (
<div
className={tcls(
'flex',
'flex-col',
'gap-2',
'sidebar-list-default:px-3',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
);
}
function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') {
return 'GitHub';
}
if (space.gitSync?.installationProvider === 'gitlab') {
return 'GitLab';
}
return 'Git';
}
@@ -3,22 +3,17 @@ import {
type RevisionPageDocument,
SiteAdsStatus,
SiteInsightsAdPlacement,
type Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type { GitBookSiteContext } from '@v2/lib/context';
import React from 'react';
import urlJoin from 'url-join';
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { AdaptivePane } from '../Adaptive/AdaptivePane';
import { Ad } from '../Ads';
import { getPDFURLSearchParams } from '../PDF';
import { PageFeedbackForm } from '../PageFeedback';
import { ThemeToggler } from '../ThemeToggler';
import { ScrollSectionsList } from './ScrollSectionsList';
import { PageActions } from './PageActions';
import { PageOutline } from './PageOutline';
/**
* Aside listing the headings in the document.
@@ -33,33 +28,26 @@ export function PageAside(props: {
}) {
const { page, document, withPageFeedback, context } = props;
const { customization, site, space } = context;
const language = getSpaceLanguage(customization);
const pdfHref = context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
);
const useAdaptivePane = true;
return (
<aside
className={tcls(
'group/aside',
'hidden',
'xl:flex',
// 'page-no-toc:lg:flex',
'flex-col',
'basis-56',
// 'page-no-toc:basis-40',
// 'page-no-toc:xl:basis-56',
'grow-0',
'shrink-0',
'break-anywhere', // To prevent long words in headings from breaking the layout
'text-tint',
'contrast-more:text-tint-strong',
'text-sm',
'sticky',
// Without header
'lg:top-0',
'lg:max-h-screen',
@@ -91,141 +79,19 @@ export function PageAside(props: {
'page-api-block:p-2'
)}
>
{page.layout.outline ? (
<>
<div
className={tcls(
'hidden',
'page-api-block:xl:max-2xl:flex',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'page-api-block:xl:max-2xl:group-hover/aside:hidden'
)}
<div className="lg:top:0 sticky flex grow flex-col gap-6 overflow-y-auto overflow-x-visible border-none py-8 *:border-tint-subtle site-header-sections:lg:top-[6.75rem] site-header:lg:top-16 [&>*:not(:first-child)]:border-t [&>*:not(:first-child)]:pt-6">
{useAdaptivePane ? <AdaptivePane /> : null}
{page.layout.outline ? (
<>
<PageOutline document={document} context={context} />
<PageActions
page={page}
context={context}
withPageFeedback={withPageFeedback}
/>
</div>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-6',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</div>
</>
) : null}
</>
) : null}
</div>
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
@@ -254,22 +120,3 @@ export function PageAside(props: {
</aside>
);
}
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
const { document, context } = props;
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
}
function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') {
return 'GitHub';
}
if (space.gitSync?.installationProvider === 'gitlab') {
return 'GitLab';
}
return 'Git';
}
@@ -0,0 +1,45 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type { GitBookSiteContext } from '@v2/lib/context';
import React from 'react';
import { ScrollSectionsList } from './ScrollSectionsList';
export async function PageOutline(props: {
document: JSONDocument | null;
context: GitBookSiteContext;
}) {
const { document, context } = props;
const { customization } = context;
const language = getSpaceLanguage(customization);
if (!document) return;
const sections = await getDocumentSections(context, document);
return document && sections.length > 1 ? (
<div>
<div className="mb-1 flex flex-row items-center gap-2 font-semibold text-xs uppercase tracking-wide">
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
</div>
<div
className={tcls(
'flex',
'flex-col'
// 'page-api-block:xl:max-2xl:py-0',
// // Hide it for api page, until hovered
// 'page-api-block:xl:max-2xl:hidden',
// 'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
<React.Suspense fallback={null}>
<ScrollSectionsList sections={sections} />
</React.Suspense>
</div>
</div>
) : null;
}
@@ -1,4 +1,8 @@
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
import {
CustomizationHeaderPreset,
CustomizationThemeMode,
type SiteStructure,
} from '@gitbook/api';
import type { GitBookSiteContext } from '@v2/lib/context';
import { getPageDocument } from '@v2/lib/data';
import type { Metadata, Viewport } from 'next';
@@ -11,6 +15,7 @@ import { getPagePath } from '@/lib/pages';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getResizedImageURL } from '@v2/lib/images';
import { JourneyContextProvider } from '../Adaptive/AdaptiveContext';
import { PageContextProvider } from '../PageContext';
import { PageClientLayout } from './PageClientLayout';
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
@@ -66,30 +71,32 @@ export async function SitePage(props: SitePageProps) {
return (
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
<div className="flex grow flex-row-reverse justify-end">
<PageAside
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={context}
/>
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
/>
</div>
<React.Suspense fallback={null}>
<PageClientLayout withSections={withSections} />
</React.Suspense>
<JourneyContextProvider spaces={getSpaces(context.structure)}>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
<div className="flex grow flex-row-reverse justify-end">
<PageAside
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={context}
/>
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
/>
</div>
<React.Suspense fallback={null}>
<PageClientLayout withSections={withSections} />
</React.Suspense>
</JourneyContextProvider>
</PageContextProvider>
);
}
@@ -163,3 +170,23 @@ async function getPageDataWithFallback(args: {
pageTarget,
};
}
function getSpaces(structure: SiteStructure) {
if (structure.type === 'siteSpaces') {
return structure.structure.map((siteSpace) => ({
id: siteSpace.space.id,
title: siteSpace.space.title,
}));
}
const sections = structure.structure.flatMap((item) =>
item.object === 'site-section-group' ? item.sections : item
);
return sections.flatMap((section) =>
section.siteSpaces.map((siteSpace) => ({
id: siteSpace.space.id,
title: siteSpace.space.title,
}))
);
}
+8 -8
View File
@@ -296,14 +296,14 @@ const config: Config = {
},
animation: {
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease',
scaleOut: 'scaleOut 200ms ease',
fadeIn: 'fadeIn 200ms ease forwards',
fadeOut: 'fadeOut 200ms ease forwards',
enterFromLeft: 'enterFromLeft 250ms ease',
enterFromRight: 'enterFromRight 250ms ease',
exitToLeft: 'exitToLeft 250ms ease',
exitToRight: 'exitToRight 250ms ease',
scaleIn: 'scaleIn 200ms ease both',
scaleOut: 'scaleOut 200ms ease both',
fadeIn: 'fadeIn 200ms ease both',
fadeOut: 'fadeOut 200ms ease both',
enterFromLeft: 'enterFromLeft 250ms ease both',
enterFromRight: 'enterFromRight 250ms ease both',
exitToLeft: 'exitToLeft 250ms ease both',
exitToRight: 'exitToRight 250ms ease both',
},
keyframes: {
pulseAlt: {