diff --git a/.changeset/late-cherries-rule.md b/.changeset/late-cherries-rule.md new file mode 100644 index 0000000000..d546d94ba5 --- /dev/null +++ b/.changeset/late-cherries-rule.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Adds AI sidebar with recommendations based on browsing behaviour diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 20e495656e..0af1e9e367 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -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) { diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..a2b1519389 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -189,5 +189,6 @@ export interface GitBookDataFetcher { input: api.AIMessageInput[]; output: api.AIOutputFormat; model: api.AIModel; + tools?: api.AIToolCapabilities; }): AsyncGenerator; } diff --git a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx new file mode 100644 index 0000000000..04e0e776a6 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx @@ -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(selectedJourney?.pages ?? []); + const [suggestedPages, setSuggestedPages] = useState([]); + + 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 && ( +
+ + + {selectedJourney?.icon ? ( + + + + ) : null} + + +
+ Suggested pages +
+ + {selectedJourney?.label ? ( + + {selectedJourney.label} + + ) : null} + +
+
+
+ {Object.assign(Array.from({ length: 5 }), pages).map( + (page: SuggestedPage | undefined, index) => + page ? ( + + {page.icon ? ( + + ) : null} + {page.emoji ? : null} + {page.title} + + ) : ( +
+ ) + )} +
+
+ ) + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx new file mode 100644 index 0000000000..a00d4721ea --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -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 && ( +
+
+ More to explore +
+
+ {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 ( + + ); + } + )} +
+
+ ) + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx new file mode 100644 index 0000000000..002818937c --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx @@ -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; +}; + +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(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([]); + const [selectedJourney, setSelectedJourney] = React.useState(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 ( + + {children} + + ); +} + +/** + * 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; +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx new file mode 100644 index 0000000000..c56134c8a8 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx new file mode 100644 index 0000000000..0141b13165 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx @@ -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 ( +
+
+

+ + For you +

+ + + {loading ? 'Basing on your context...' : 'Based on your context'} + + +
+
+ ); +} diff --git a/packages/gitbook/src/components/Adaptive/index.ts b/packages/gitbook/src/components/Adaptive/index.ts index 2d93029d7e..cf7f351a2d 100644 --- a/packages/gitbook/src/components/Adaptive/index.ts +++ b/packages/gitbook/src/components/Adaptive/index.ts @@ -1 +1,3 @@ export * from './AIPageLinkSummary'; +export * from './AdaptiveContext'; +export * from './AdaptivePane'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/api.ts b/packages/gitbook/src/components/Adaptive/server-actions/api.ts index a1396987d7..fdc642c158 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/api.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/api.ts @@ -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( schema, messages, model = AIModel.Fast, + tools = {}, }: { schema: z.ZodSchema; messages: AIMessageInput[]; model?: AIModel; previousResponseId?: string; + tools?: AIToolCapabilities; } ) { const rawStream = context.dataFetcher.streamAIResponse({ @@ -62,12 +69,13 @@ export async function streamGenerateObject( type: 'object', schema: zodToJsonSchema(schema), }, + tools, model, }); let json = ''; return parseResponse>(rawStream, (event) => { - if (event.type === 'response_object') { + if (event.type === 'response_object' && event.jsonChunk) { json += event.jsonChunk; const parsed = partialJson.parse(json, partialJson.ALL); diff --git a/packages/gitbook/src/components/Adaptive/server-actions/index.ts b/packages/gitbook/src/components/Adaptive/server-actions/index.ts index 664e869e23..81f5686fc1 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/index.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/index.ts @@ -1 +1,2 @@ export * from './streamLinkPageSummary'; +export * from './streamPageJourneySuggestions'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts new file mode 100644 index 0000000000..8cae6ae414 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts @@ -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(); + 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, + }), + }; + } + } +} diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts new file mode 100644 index 0000000000..08649217ca --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -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(); + + 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, + }), + })), + }; + } + } +} diff --git a/packages/gitbook/src/components/PageAside/PageActions.tsx b/packages/gitbook/src/components/PageAside/PageActions.tsx new file mode 100644 index 0000000000..31cf9f7d95 --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageActions.tsx @@ -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 ( +
+ {withPageFeedback ? ( + + + + ) : null} + {customization.git.showEditLink && space.gitSync?.url && page.git ? ( + + ) : null} + {customization.pdf.enabled ? ( + + ) : null} +
+ ); +} + +function getGitSyncName(space: Space): string { + if (space.gitSync?.installationProvider === 'github') { + return 'GitHub'; + } + if (space.gitSync?.installationProvider === 'gitlab') { + return 'GitLab'; + } + + return 'Git'; +} diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index a51410cbc0..99cb7a2a25 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -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 (
); } - -async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { - const { document, context } = props; - - const sections = await getDocumentSections(context, document); - - return sections.length > 1 ? : null; -} - -function getGitSyncName(space: Space): string { - if (space.gitSync?.installationProvider === 'github') { - return 'GitHub'; - } - if (space.gitSync?.installationProvider === 'gitlab') { - return 'GitLab'; - } - - return 'Git'; -} diff --git a/packages/gitbook/src/components/PageAside/PageOutline.tsx b/packages/gitbook/src/components/PageAside/PageOutline.tsx new file mode 100644 index 0000000000..84c40a25b1 --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageOutline.tsx @@ -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 ? ( +
+
+ + {t(language, 'on_this_page')} +
+
+ + + +
+
+ ) : null; +} diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index fe6934a529..83a9d947d2 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -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 ( - {withFullPageCover && page.cover ? ( - - ) : null} - {/* We use a flex row reverse to render the aside first because the page is streamed. */} -
- - -
- - - + + {withFullPageCover && page.cover ? ( + + ) : null} + {/* We use a flex row reverse to render the aside first because the page is streamed. */} +
+ + +
+ + + +
); } @@ -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, + })) + ); +} diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 2e5b7c359f..d0596cca98 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -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: {