mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-25 11:52:10 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c38d403f77 | |||
| c3bde7d989 | |||
| 8a08b0f366 | |||
| ed17c11715 | |||
| 6ce3f4b17a | |||
| fb9c8f4d2c | |||
| fbf6951c71 | |||
| 5e975ab95b | |||
| a3ec264764 | |||
| 5d504ffa4c | |||
| 04999662db | |||
| f7a34706c7 | |||
| f328a41982 | |||
| 20ebecb114 |
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook-v2": patch
|
||||
"gitbook": patch
|
||||
"@gitbook/colors": patch
|
||||
---
|
||||
|
||||
Fix code highlighting for HTTP
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix resolution of links in reusable contents
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/colors": patch
|
||||
---
|
||||
|
||||
Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Missing top-level required OpenAPI alternatives
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix Python code sample "null vs None"
|
||||
@@ -214,7 +214,11 @@ export function colorScale(
|
||||
const targetL =
|
||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||
|
||||
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
|
||||
if (
|
||||
index === 8 &&
|
||||
!mix &&
|
||||
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
|
||||
) {
|
||||
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||
result.push(hex);
|
||||
continue;
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
'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,97 @@
|
||||
'use client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVisitedPages } from '../Insights';
|
||||
import { usePageContext } from '../PageContext';
|
||||
import { useAdaptiveContext } from './AdaptiveContext';
|
||||
import { streamPageSummary } from './server-actions/streamPageSummary';
|
||||
|
||||
export function AIPageSummary() {
|
||||
const { toggle, setLoading, setToggle } = useAdaptiveContext();
|
||||
|
||||
const currentPage = usePageContext();
|
||||
const visitedPages = useVisitedPages((state) => state.pages);
|
||||
const visitedPagesRef = useRef(visitedPages);
|
||||
|
||||
const [summary, setSummary] = useState<{
|
||||
keyFacts?: string;
|
||||
bigPicture?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!summary.keyFacts) setLoading(true);
|
||||
if (!visitedPages?.length) return;
|
||||
|
||||
// Skip if the visited pages haven't changed
|
||||
if (JSON.stringify(visitedPagesRef.current) === JSON.stringify(visitedPages)) return;
|
||||
|
||||
visitedPagesRef.current = visitedPages;
|
||||
|
||||
let canceled = false;
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
const stream = await streamPageSummary({
|
||||
currentPage: {
|
||||
id: currentPage.pageId,
|
||||
title: currentPage.title,
|
||||
},
|
||||
currentSpace: {
|
||||
id: currentPage.spaceId,
|
||||
},
|
||||
visitedPages: visitedPages,
|
||||
});
|
||||
|
||||
for await (const summary of stream) {
|
||||
if (canceled) return;
|
||||
|
||||
setSummary(summary);
|
||||
}
|
||||
})().finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [currentPage, visitedPages, toggle, setLoading, setToggle]);
|
||||
|
||||
const shimmerBlocks = [20, 35, 25, 10, 45, 30, 30, 35, 25, 10, 40, 30]; // Widths in percentages
|
||||
|
||||
return (
|
||||
toggle.open && (
|
||||
<div className="flex min-w-64 animate-fadeIn flex-col gap-4">
|
||||
{summary.keyFacts ? (
|
||||
<div>
|
||||
<h5 className="mb-0.5 font-semibold text-tint-subtle text-xs uppercase">
|
||||
Key facts
|
||||
</h5>
|
||||
{summary.keyFacts}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{shimmerBlocks.map((width, index) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: No other distinguishing feature available
|
||||
key={index}
|
||||
className="h-4 animate-pulse rounded bg-tint-active"
|
||||
style={{
|
||||
width: `${width}%`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visitedPages.length > 1 && summary?.bigPicture ? (
|
||||
<div>
|
||||
<h5 className="mb-0.5 font-semibold text-tint-subtle text-xs uppercase">
|
||||
Big Picture
|
||||
</h5>
|
||||
{summary?.bigPicture}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,59 @@
|
||||
'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>;
|
||||
};
|
||||
import React from 'react';
|
||||
|
||||
type AdaptiveContextType = {
|
||||
journeys: Journey[];
|
||||
selectedJourney: Journey | undefined;
|
||||
setSelectedJourney: (journey: Journey | undefined) => void;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
toggle: {
|
||||
open: boolean;
|
||||
manual: boolean;
|
||||
};
|
||||
setToggle: (toggle: { open: boolean; manual: 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);
|
||||
export function AdaptiveContextProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const currentPage = usePageContext();
|
||||
const visitedPages = useVisitedPages((state) => state.pages);
|
||||
// Start with a default state that works for SSR
|
||||
const [toggle, setToggle] = React.useState({
|
||||
open: false, // Default to open for SSR
|
||||
manual: false,
|
||||
});
|
||||
|
||||
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]);
|
||||
// Update the toggle state on the client side only
|
||||
React.useEffect(() => {
|
||||
// Check for mobile only on the client
|
||||
const handleResize = () => {
|
||||
if (!toggle.manual) {
|
||||
const isMobile = window.innerWidth < 1280;
|
||||
setToggle((prev) => ({
|
||||
...prev,
|
||||
open: !isMobile,
|
||||
}));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]);
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [toggle.manual]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toggle.open) {
|
||||
document.body.classList.add('adaptive-pane');
|
||||
} else {
|
||||
document.body.classList.remove('adaptive-pane');
|
||||
}
|
||||
}, [toggle.open]);
|
||||
|
||||
return (
|
||||
<AdaptiveContext.Provider
|
||||
value={{ journeys, selectedJourney, setSelectedJourney, loading, open, setOpen }}
|
||||
>
|
||||
<AdaptiveContext.Provider value={{ loading, setLoading, toggle, setToggle }}>
|
||||
{children}
|
||||
</AdaptiveContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AINextPageSuggestions } from './AINextPageSuggestions';
|
||||
import { AIPageJourneySuggestions } from './AIPageJourneySuggestions';
|
||||
import { AIPageSummary } from './AIPageSummary';
|
||||
import { useAdaptiveContext } from './AdaptiveContext';
|
||||
import { AdaptivePaneHeader } from './AdaptivePaneHeader';
|
||||
export function AdaptivePane() {
|
||||
const { open } = useAdaptiveContext();
|
||||
const { toggle } = 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'
|
||||
'flex shrink-0 flex-col gap-4 overflow-x-hidden rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
|
||||
toggle.open ? 'max-h px-4 py-4 xl:w-72' : 'px-4 py-3 xl:w-56'
|
||||
)}
|
||||
>
|
||||
<AdaptivePaneHeader />
|
||||
<AIPageJourneySuggestions />
|
||||
<AINextPageSuggestions />
|
||||
<AIPageSummary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Loading } from '../primitives';
|
||||
import { useAdaptiveContext } from './AdaptiveContext';
|
||||
|
||||
export function AdaptivePaneHeader() {
|
||||
const { loading, open, setOpen } = useAdaptiveContext();
|
||||
const { loading, toggle, setToggle } = useAdaptiveContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500">
|
||||
@@ -21,6 +21,7 @@ export function AdaptivePaneHeader() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-tint-subtle text-xs"
|
||||
>
|
||||
{loading ? 'Basing on your context...' : 'Based on your context'}
|
||||
@@ -29,11 +30,16 @@ export function AdaptivePaneHeader() {
|
||||
</div>
|
||||
<Button
|
||||
variant="blank"
|
||||
className={tcls('px-2 *:transition-transform', !open && '*:-rotate-45')}
|
||||
className={tcls('px-2 *:transition-transform', !toggle.open && '*:-rotate-45')}
|
||||
iconOnly
|
||||
label="Close"
|
||||
icon="close"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() =>
|
||||
setToggle({
|
||||
open: !toggle.open,
|
||||
manual: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './AIPageLinkSummary';
|
||||
export * from './AIPageSummary';
|
||||
export * from './AdaptiveContext';
|
||||
export * from './AdaptivePane';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './streamLinkPageSummary';
|
||||
export * from './streamPageJourneySuggestions';
|
||||
export * from './streamPageSummary';
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
'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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
'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,214 @@
|
||||
'use server';
|
||||
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 summary of a page, in the context of another page
|
||||
*/
|
||||
export async function* streamPageSummary({
|
||||
currentPage,
|
||||
currentSpace,
|
||||
visitedPages,
|
||||
}: {
|
||||
currentPage: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
currentSpace: {
|
||||
id: string;
|
||||
// title: string;
|
||||
};
|
||||
visitedPages: {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
}[];
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const [{ stream }] = await Promise.all([
|
||||
streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
keyFacts: z
|
||||
.string()
|
||||
.describe(
|
||||
'A collection of key facts from the page that together form a comprehensive summary. Keep it under 30 words.'
|
||||
),
|
||||
bigPicture:
|
||||
visitedPages.length > 0
|
||||
? z
|
||||
.string()
|
||||
.describe(
|
||||
'A natural-sounding summary of how specific concepts connect with real benefits. Use a conversational tone with concrete examples. Avoid overly formal language while still being specific. Keep it under 30 words.'
|
||||
)
|
||||
: z.undefined(),
|
||||
}),
|
||||
tools: {
|
||||
// getPages: true,
|
||||
// getPageContent: true,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 1. Role
|
||||
You are a fact extractor. Your job is to identify and extract the most important facts from the current page.
|
||||
|
||||
# 2. Task
|
||||
Extract multiple key facts that:
|
||||
- Cover the most important concepts, features, or capabilities on the page
|
||||
- Represent specific, actionable information rather than general descriptions
|
||||
- Provide concrete details about functionality, limitations, or specifications
|
||||
- Together form a comprehensive understanding of the page content
|
||||
- Relate to the user's learning journey through the documentation when relevant
|
||||
|
||||
# 3. Instructions
|
||||
1. Analyze the current page to identify 3-5 concrete, specific facts (not general summaries)
|
||||
2. Focus on facts that would be most useful and relevant to someone using this documentation
|
||||
3. If the user has visited other pages, identify facts that build upon their previous knowledge
|
||||
4. Present facts as clear, declarative statements about what exists or is true
|
||||
5. Separate distinct facts rather than combining them into a single summary
|
||||
6. Include specific details, numbers, limitations, or capabilities where available`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 4. Current page
|
||||
The content of the current page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId: currentSpace.id,
|
||||
pageId: currentPage.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
...(visitedPages && visitedPages.length > 0
|
||||
? [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 5. Previous Pages and Learning Journey
|
||||
The content across ${visitedPages.length} page(s) builds a knowledge framework. Use this to:
|
||||
- Identify specific, concrete ways concepts interact (not just "work together")
|
||||
- Show exact functional relationships between ideas (not vague "enhances")
|
||||
- Highlight tangible capabilities that emerge from combined concepts
|
||||
- Describe precise benefits that result from these connections
|
||||
- Focus on what becomes possible when these concepts are combined
|
||||
|
||||
The content of up to 5 most recent pages are included below:`,
|
||||
},
|
||||
...visitedPages.slice(0, 5).map(({ spaceId, pageId }, index) => ({
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Previous Page ${index + 1}: ${pageId}`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page' as const,
|
||||
spaceId,
|
||||
pageId,
|
||||
},
|
||||
],
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 6. Guidelines for Fact Extraction
|
||||
ALWAYS:
|
||||
- ALWAYS extract multiple distinct facts rather than a single summary
|
||||
- ALWAYS focus on specific, concrete details rather than general descriptions
|
||||
- ALWAYS include numbers, limitations, requirements, or specifications when available
|
||||
- ALWAYS prioritize facts that would be most useful to someone using the documentation
|
||||
- ALWAYS consider how facts on this page relate to previously visited pages
|
||||
|
||||
NEVER:
|
||||
- NEVER use instructional language like "learn", "how to", "discover", etc.
|
||||
- NEVER include vague or generic statements that lack specific details.
|
||||
- NEVER repeat the page title without adding informative value.
|
||||
- NEVER combine multiple distinct facts into a single general statement.
|
||||
- NEVER use numbered lists.`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Examples
|
||||
|
||||
Page content: "Content blocks in GitBook include text, images, videos, code snippets, and more. Each block can be customized with specific settings. Text blocks support Markdown formatting and can include inline code. Images can be resized and have alt text added."
|
||||
✓ "Text blocks support Markdown formatting. Images can be resized and include alt text. Available block types include text, images, videos, and code snippets."
|
||||
✗ "GitBook offers various content blocks with customization options."
|
||||
|
||||
Page content: "Change Requests allow teams to propose, review, and approve content changes before publishing. Each reviewer's approval is tracked separately. Changes are highlighted with color coding. Change Requests can be merged automatically or manually after approval."
|
||||
✓ "Reviewer approvals are tracked individually. Changes are color-coded for visibility. Merging can be automatic or manual after approval. Multiple reviewers can collaborate on a single Change Request."
|
||||
✗ "Change Requests provide a collaborative workflow for content changes."
|
||||
|
||||
Page content: "API authentication requires an API key generated in account settings. Keys expire after 90 days by default. Rate limits are set to 1000 requests per hour. Keys can have read-only or read-write permissions."
|
||||
✓ "API keys expire after 90 days by default. Rate limits are capped at 1000 requests per hour. Keys can be configured with read-only or read-write permissions."
|
||||
✗ "API keys are required for authentication and have various settings."`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# 7. Guidelines for Big Picture
|
||||
For the big picture summary:
|
||||
|
||||
ALWAYS:
|
||||
- ALWAYS highlight practical patterns and workflows that emerge when combining these concepts.
|
||||
- ALWAYS focus on real capabilities that come from understanding multiple features together.
|
||||
- ALWAYS use specific examples that show the value of combining these ideas.
|
||||
- ALWAYS keep the language simple, direct and conversational without corporate jargon.
|
||||
- ALWAYS use short sentences with a single clause and no commas.
|
||||
|
||||
NEVER:
|
||||
- NEVER use corporate jargon like "seamless", "ensures", "integrates", etc.
|
||||
- NEVER use complex sentences with multiple clauses.
|
||||
- NEVER use passive voice.
|
||||
- NEVER state the same fact twice.
|
||||
- NEVER repeat the page title without adding informative value.`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Big Picture Examples
|
||||
POOR "BIG PICTURE" EXAMPLES TO AVOID:
|
||||
✗ "GitBook combines content creation, collaboration, and integrations, building on your understanding of identifiers and paginated results for seamless documentation management."
|
||||
✗ "The platform's robust features for content organization, versioning, and access control work together to create a powerful documentation ecosystem."
|
||||
✗ "By leveraging GitBook's content blocks, permissions system, and API capabilities, you can build comprehensive documentation solutions."
|
||||
|
||||
GOOD "BIG PICTURE" EXAMPLES TO FOLLOW:
|
||||
✓ "Combining Markdown tables with webhook notifications means your API docs stay up-to-date automatically. When you update a parameter, the PDF version refreshes too."
|
||||
✓ "Content blocks and version history together solve the biggest docs headache. You can experiment with different layouts while keeping a clean record of what changed and why."
|
||||
✓ "The real power comes from linking custom domains with content permissions. Your sales team gets branded docs while your developers see the technical details on the same site."
|
||||
✓ "With spaces, webhooks, and custom metadata working together, you're not just making docs. You're building a knowledge system that responds to how your team actually works."`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `The current page is: "${currentPage.title}" (ID ${currentPage.id})`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content:
|
||||
'What are the key facts on this page, and what have I learned across the documentation so far?',
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
fetchServerActionSiteContext(baseContext),
|
||||
]);
|
||||
|
||||
for await (const value of stream) {
|
||||
const keyFacts = value.keyFacts;
|
||||
const bigPicture = value.bigPicture;
|
||||
|
||||
if (!keyFacts) continue;
|
||||
|
||||
yield {
|
||||
keyFacts,
|
||||
bigPicture,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
--shiki-token-link: theme("colors.primary.10");
|
||||
|
||||
--shiki-token-constant: theme("colors.warning.10");
|
||||
--shiki-token-string: theme("colors.success.10");
|
||||
--shiki-token-string: theme("colors.warning.10");
|
||||
--shiki-token-string-expression: theme("colors.success.10");
|
||||
--shiki-token-keyword: theme("colors.danger.10");
|
||||
--shiki-token-parameter: theme("colors.warning.10");
|
||||
@@ -24,7 +24,7 @@
|
||||
--shiki-token-link: theme("colors.primary.11");
|
||||
|
||||
--shiki-token-constant: theme("colors.warning.11");
|
||||
--shiki-token-string: theme("colors.success.11");
|
||||
--shiki-token-string: theme("colors.warning.11");
|
||||
--shiki-token-string-expression: theme("colors.success.11");
|
||||
--shiki-token-keyword: theme("colors.danger.11");
|
||||
--shiki-token-parameter: theme("colors.warning.11");
|
||||
@@ -41,7 +41,7 @@ html.dark {
|
||||
--shiki-token-comment: theme("colors.neutral.9");
|
||||
|
||||
--shiki-token-constant: theme("colors.warning.11");
|
||||
--shiki-token-string: theme("colors.success.11");
|
||||
--shiki-token-string: theme("colors.warning.11");
|
||||
--shiki-token-string-expression: theme("colors.success.11");
|
||||
--shiki-token-keyword: theme("colors.danger.11");
|
||||
--shiki-token-parameter: theme("colors.warning.11");
|
||||
|
||||
@@ -46,14 +46,21 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
||||
// Create a new context for reusable content block, including
|
||||
// the data fetcher with the token from the block meta and the correct
|
||||
// space and revision pointers.
|
||||
const reusableContentContext: GitBookSpaceContext = {
|
||||
...context.contentContext,
|
||||
dataFetcher,
|
||||
space: resolved.reusableContent.space,
|
||||
revisionId: resolved.reusableContent.revision,
|
||||
pages: [],
|
||||
shareKey: undefined,
|
||||
};
|
||||
const reusableContentContext: GitBookSpaceContext =
|
||||
context.contentContext.space.id === resolved.reusableContent.space.id
|
||||
? context.contentContext
|
||||
: {
|
||||
...context.contentContext,
|
||||
dataFetcher,
|
||||
space: resolved.reusableContent.space,
|
||||
revisionId: resolved.reusableContent.revision,
|
||||
// When the reusable content is in a different space, we don't resolve relative links to pages
|
||||
// as this space might not be part of the current site.
|
||||
// In the future, we might expand the logic to look up the space from the list of all spaces in the site
|
||||
// and adapt the relative links to point to the correct variant.
|
||||
pages: [],
|
||||
shareKey: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<UnwrappedBlocks
|
||||
|
||||
@@ -106,12 +106,16 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
|
||||
'lg:max-w-lg',
|
||||
'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - margin (3rem)
|
||||
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'adaptive-pane:xl:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),1.5rem)]',
|
||||
'page-no-toc:lg:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
|
||||
'page-full-width:lg:ml-[max(calc((100%-18rem-103rem-3rem)/2),1.5rem)]',
|
||||
'page-full-width:2xl:ml-[max(calc((100%-18rem-96rem-14rem+3rem)/2),1.5rem)]',
|
||||
'[body.adaptive-pane:has(.page-full-width)_&]:2xl:ml-[max(calc((100%-18rem-96rem-18rem+3rem)/2),1.5rem)]',
|
||||
'md:mr-auto',
|
||||
'order-last',
|
||||
'md:order-[unset]',
|
||||
'transition-[margin-left]',
|
||||
'duration-300',
|
||||
]
|
||||
: ['order-last']
|
||||
)}
|
||||
|
||||
@@ -29,16 +29,17 @@ export function PageAside(props: {
|
||||
const { page, document, withPageFeedback, context } = props;
|
||||
const { customization, site, space } = context;
|
||||
|
||||
const useAdaptivePane = true;
|
||||
const useAdaptivePane = customization.ai?.pageLinkSummaries.enabled;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={tcls(
|
||||
'group/aside',
|
||||
'hidden',
|
||||
'flex',
|
||||
// 'hidden',
|
||||
'xl:flex',
|
||||
'flex-col',
|
||||
'basis-56',
|
||||
'xl:basis-56',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
@@ -46,7 +47,14 @@ export function PageAside(props: {
|
||||
'text-tint',
|
||||
'contrast-more:text-tint-strong',
|
||||
'text-sm',
|
||||
'sticky',
|
||||
'xl:sticky',
|
||||
|
||||
'lg:px-12',
|
||||
'xl:px-0',
|
||||
'mx-auto',
|
||||
'xl:mx-0',
|
||||
'w-full',
|
||||
'max-w-3xl',
|
||||
|
||||
// Without header
|
||||
'lg:top-0',
|
||||
@@ -79,22 +87,22 @@ export function PageAside(props: {
|
||||
'page-api-block:p-2'
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<div className="lg:top:0 sticky flex grow flex-col gap-6 overflow-y-auto overflow-x-visible border-none pt-8 *:border-tint-subtle site-header-sections:lg:top-[6.75rem] site-header:lg:top-16 xl:pb-8 [&>*:not(:first-child)]:border-t [&>*:not(:first-child)]:pt-6">
|
||||
{useAdaptivePane ? <AdaptivePane /> : null}
|
||||
{page.layout.outline ? (
|
||||
<>
|
||||
<div className="hidden flex-col gap-6 xl:flex">
|
||||
<PageOutline document={document} context={context} />
|
||||
<PageActions
|
||||
page={page}
|
||||
context={context}
|
||||
withPageFeedback={withPageFeedback}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : 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',
|
||||
'sticky bottom-0 z-10 mt-auto hidden flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:flex 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',
|
||||
'page-api-block:xl:max-2xl:bg-transparent'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ClientContexts } from './ClientContexts';
|
||||
import '@gitbook/icons/style.css';
|
||||
import './globals.css';
|
||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
import { AdaptiveContextProvider } from '../Adaptive/AdaptiveContext';
|
||||
import { AnnouncementDismissedScript } from '../Announcement';
|
||||
|
||||
/**
|
||||
@@ -175,7 +176,9 @@ export async function CustomizationRootLayout(props: {
|
||||
: null) || IconStyle.Regular
|
||||
}
|
||||
>
|
||||
<ClientContexts language={language}>{children}</ClientContexts>
|
||||
<ClientContexts language={language}>
|
||||
<AdaptiveContextProvider>{children}</AdaptiveContextProvider>
|
||||
</ClientContexts>
|
||||
</IconsProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationThemeMode,
|
||||
type SiteStructure,
|
||||
} from '@gitbook/api';
|
||||
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
|
||||
import type { GitBookSiteContext } from '@v2/lib/context';
|
||||
import { getPageDocument } from '@v2/lib/data';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
@@ -15,7 +11,6 @@ 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';
|
||||
@@ -71,32 +66,30 @@ export async function SitePage(props: SitePageProps) {
|
||||
|
||||
return (
|
||||
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
||||
<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>
|
||||
{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-col xl:flex-row-reverse xl: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>
|
||||
</PageContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -170,23 +163,3 @@ 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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export const Loading = ({
|
||||
busy = true,
|
||||
...props
|
||||
@@ -13,29 +11,26 @@ export const Loading = ({
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-busy
|
||||
aria-busy={busy}
|
||||
{...props}
|
||||
>
|
||||
<title>{busy ? 'Loading' : 'Loaded'}</title>
|
||||
<path
|
||||
className={tcls(
|
||||
busy
|
||||
? 'animate-[pathLoading_2s_ease_infinite_forwards]'
|
||||
: 'animate-[pathLoading_2s_ease_forwards]'
|
||||
)}
|
||||
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
|
||||
stroke="currentColor"
|
||||
pathLength="100"
|
||||
strokeOpacity={busy ? 0.24 : 1}
|
||||
className="transition-[stroke-opacity] duration-500"
|
||||
fill="none"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
className="animate-[pathLoading_2s_ease_infinite_forwards] transition-opacity delay-500 duration-500"
|
||||
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
|
||||
stroke="currentColor"
|
||||
pathLength="100"
|
||||
strokeOpacity={busy ? 0.24 : 1}
|
||||
className="transition-opacity duration-1000"
|
||||
fill="none"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -242,7 +242,6 @@ export const getLatestOpenAPISpecVersionContent = cache({
|
||||
* Resolve a URL to the content to render.
|
||||
*/
|
||||
export const getPublishedContentByUrl = cache({
|
||||
timeout: 30 * 1000,
|
||||
name: 'api.getPublishedContentByUrl.v7',
|
||||
tag: (url) => getCacheTagForURL(url),
|
||||
get: async (
|
||||
|
||||
@@ -141,7 +141,7 @@ function getUrlsFromSiteSpaces(context: GitBookSiteContext, siteSpaces: SiteSpac
|
||||
}
|
||||
const url = new URL(siteSpace.urls.published);
|
||||
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
|
||||
return context.linker.toLinkForContent(url.toString());
|
||||
return context.linker.toAbsoluteURL(context.linker.toLinkForContent(url.toString()));
|
||||
}, []);
|
||||
return urls.filter(filterOutNullable);
|
||||
}
|
||||
|
||||
@@ -465,6 +465,11 @@ const config: Config = {
|
||||
'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Variant when the adaptive pane is open.
|
||||
*/
|
||||
addVariant('adaptive-pane', 'body.adaptive-pane &');
|
||||
|
||||
const customisationVariants = {
|
||||
// Sidebar styles
|
||||
sidebar: ['sidebar-default', 'sidebar-filled'],
|
||||
|
||||
@@ -572,6 +572,9 @@ function flattenAlternatives(
|
||||
schemasOrRefs: (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[],
|
||||
ancestors: Set<OpenAPIV3.SchemaObject>
|
||||
): OpenAPIV3.SchemaObject[] {
|
||||
// Get the parent schema's required fields from the most recent ancestor
|
||||
const latestAncestor = Array.from(ancestors).pop();
|
||||
|
||||
return schemasOrRefs.reduce<OpenAPIV3.SchemaObject[]>((acc, schemaOrRef) => {
|
||||
if (checkIsReference(schemaOrRef)) {
|
||||
return acc;
|
||||
@@ -580,16 +583,47 @@ function flattenAlternatives(
|
||||
if (schemaOrRef[alternativeType] && !ancestors.has(schemaOrRef)) {
|
||||
const schemas = getSchemaAlternatives(schemaOrRef, ancestors);
|
||||
if (schemas) {
|
||||
acc.push(...schemas);
|
||||
acc.push(
|
||||
...schemas.map((schema) => ({
|
||||
...schema,
|
||||
required: mergeRequiredFields(schema, latestAncestor),
|
||||
}))
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(schemaOrRef);
|
||||
// For direct schemas, handle required fields
|
||||
const schema = {
|
||||
...schemaOrRef,
|
||||
required: mergeRequiredFields(schemaOrRef, latestAncestor),
|
||||
};
|
||||
|
||||
acc.push(schema);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the required fields of a schema with the required fields of its latest ancestor.
|
||||
*/
|
||||
function mergeRequiredFields(
|
||||
schemaOrRef: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
|
||||
latestAncestor: OpenAPIV3.SchemaObject | undefined
|
||||
) {
|
||||
if (!schemaOrRef.required && !latestAncestor?.required) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (checkIsReference(schemaOrRef)) {
|
||||
return latestAncestor?.required;
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set([...(latestAncestor?.required || []), ...(schemaOrRef.required || [])])
|
||||
);
|
||||
}
|
||||
|
||||
function getSchemaTitle(schema: OpenAPIV3.SchemaObject): string {
|
||||
// Otherwise try to infer a nice title
|
||||
let type = 'any';
|
||||
|
||||
@@ -415,13 +415,14 @@ describe('python code sample generator', () => {
|
||||
key: 'value',
|
||||
truethy: true,
|
||||
falsey: false,
|
||||
nullish: null,
|
||||
},
|
||||
};
|
||||
|
||||
const output = generator?.generate(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False})\n)\n\ndata = response.json()'
|
||||
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False,"nullish":None})\n)\n\ndata = response.json()'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
{
|
||||
id: 'http',
|
||||
label: 'HTTP',
|
||||
syntax: 'bash',
|
||||
syntax: 'http',
|
||||
generate: ({ method, url, headers = {}, body }: CodeSampleInput) => {
|
||||
const { host, path } = parseHostAndPath(url);
|
||||
|
||||
@@ -362,12 +362,15 @@ const BodyGenerators = {
|
||||
return '$$__TRUE__$$';
|
||||
case false:
|
||||
return '$$__FALSE__$$';
|
||||
case null:
|
||||
return '$$__NULL__$$';
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
})
|
||||
.replaceAll('"$$__TRUE__$$"', 'True')
|
||||
.replaceAll('"$$__FALSE__$$"', 'False');
|
||||
.replaceAll('"$$__FALSE__$$"', 'False')
|
||||
.replaceAll('"$$__NULL__$$"', 'None');
|
||||
}
|
||||
|
||||
return { body, code, headers };
|
||||
|
||||
Reference in New Issue
Block a user