mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2c745b553 | |||
| f3affc3034 | |||
| 59da30f3c6 | |||
| 2db721112a | |||
| b60039b7d5 | |||
| 8fe9c9afea | |||
| 216ba7a556 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Display MCP tool calls in AI chat.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix links to other spaces within a section.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add support for cover repositioning
|
||||
@@ -250,7 +250,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.123.0",
|
||||
"@gitbook/api": "^0.125.0",
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew=="],
|
||||
@@ -613,7 +613,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.123.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-BszoIk4H/wWb0jnGSJPSSXSAO8tV4B4/LHwl5ygfERHqk9rOGpM/U7+yOlmbX/tnm30vE+MQ3QQ5i8hN8wVk1w=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.125.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-8WrsENzW7ehafLWbBfk0zs7xxmCJ/H8yy05BQGdZOR26TJzfMfkqMtuCLFukT8vbgjRgRrHanBoU98cCXHm1rg=="],
|
||||
|
||||
"@gitbook/cache-tags": ["@gitbook/cache-tags@workspace:packages/cache-tags"],
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.123.0"
|
||||
"@gitbook/api": "^0.125.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -732,6 +732,11 @@ const testCases: TestsCase[] = [
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With repositioned cover',
|
||||
url: 'page-options/page-with-repositioned-cover',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With icon',
|
||||
url: 'page-options/page-with-icon',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AIToolCall,
|
||||
AIToolCallGetPageContent,
|
||||
AIToolCallGetPages,
|
||||
AIToolCallMCP,
|
||||
AIToolCallSearch,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
@@ -54,6 +55,8 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
return <DescriptionForSearchToolCall toolCall={toolCall} context={context} />;
|
||||
case 'getPages':
|
||||
return <DescriptionForGetPagesToolCall toolCall={toolCall} context={context} />;
|
||||
case 'mcp':
|
||||
return <DescriptionForMCPToolCall toolCall={toolCall} context={context} />;
|
||||
default:
|
||||
return <>{toolCall.tool}</>;
|
||||
}
|
||||
@@ -89,6 +92,25 @@ function DescriptionForPageContentToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForMCPToolCall(props: {
|
||||
toolCall: AIToolCallMCP;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context.customization);
|
||||
|
||||
return (
|
||||
<p>
|
||||
{t(
|
||||
language,
|
||||
'ai_chat_tools_mcp_tool',
|
||||
<strong>{toolCall.mcpToolTitle ?? toolCall.mcpToolName}</strong>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
async function DescriptionForSearchToolCall(props: {
|
||||
toolCall: AIToolCallSearch;
|
||||
context: GitBookSiteContext;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as zustand from 'zustand';
|
||||
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { streamAIChatFollowUpResponses, streamAIChatResponse } from './server-actions';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
|
||||
@@ -86,6 +87,7 @@ export function useAIChatState(): AIChatState {
|
||||
export function useAIChatController(): AIChatController {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const setState = zustand.useStore(globalState, (state) => state.setState);
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
return React.useMemo(() => {
|
||||
/**
|
||||
@@ -113,6 +115,7 @@ export function useAIChatController(): AIChatController {
|
||||
responseId: null,
|
||||
})),
|
||||
postMessage: async (input: { message: string }) => {
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
@@ -168,5 +171,5 @@ export function useAIChatController(): AIChatController {
|
||||
}));
|
||||
},
|
||||
};
|
||||
}, [messageContextRef, setState]);
|
||||
}, [messageContextRef, setState, trackEvent]);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const contentKitServerContext: ContentKitServerContext = {
|
||||
lock: (props) => <Icon icon="lock" {...props} />,
|
||||
check: (props) => <Icon icon="check" {...props} />,
|
||||
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
|
||||
'eye-off': (props) => <Icon icon="eye-slash" {...props} />,
|
||||
},
|
||||
codeBlock: (props) => {
|
||||
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
|
||||
|
||||
@@ -2,14 +2,15 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api';
|
||||
import type { StaticImageData } from 'next/image';
|
||||
|
||||
import { Image, type ImageSize } from '@/components/utils';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { getImageAttributes } from '@/components/utils';
|
||||
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { assert } from 'ts-essentials';
|
||||
import { PageCoverImage } from './PageCoverImage';
|
||||
import defaultPageCoverSVG from './default-page-cover.svg';
|
||||
|
||||
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
/**
|
||||
* Cover for the page.
|
||||
@@ -26,6 +27,54 @@ export async function PageCover(props: {
|
||||
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
|
||||
]);
|
||||
|
||||
const sizes = [
|
||||
// Cover takes the full width on mobile/table
|
||||
{
|
||||
media: '(max-width: 768px)',
|
||||
width: 768,
|
||||
},
|
||||
{
|
||||
media: '(max-width: 1024px)',
|
||||
width: 1024,
|
||||
},
|
||||
// Maximum size of the cover
|
||||
{ width: 1248 },
|
||||
];
|
||||
|
||||
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
|
||||
if (!resolved && returnNull) return;
|
||||
const [attrs, size] = await Promise.all([
|
||||
getImageAttributes({
|
||||
sizes,
|
||||
source: resolved
|
||||
? {
|
||||
src: resolved.href,
|
||||
size: resolved.file?.dimensions ?? null,
|
||||
}
|
||||
: {
|
||||
src: defaultPageCover.src,
|
||||
size: {
|
||||
width: defaultPageCover.width,
|
||||
height: defaultPageCover.height,
|
||||
},
|
||||
},
|
||||
quality: 100,
|
||||
resize: context.imageResizer ?? false,
|
||||
}),
|
||||
context.imageResizer
|
||||
?.getImageSize(resolved?.href || defaultPageCover.src, {})
|
||||
.then((size) => size ?? undefined),
|
||||
]);
|
||||
return {
|
||||
...attrs,
|
||||
size,
|
||||
};
|
||||
};
|
||||
|
||||
const images = await Promise.all([getImage(resolved), getImage(resolvedDark, true)]);
|
||||
const [light, dark] = images;
|
||||
assert(light, 'Light image should be defined');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -52,47 +101,12 @@ export async function PageCover(props: {
|
||||
]
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
alt="Page cover image"
|
||||
sources={{
|
||||
light: resolved
|
||||
? {
|
||||
src: resolved.href,
|
||||
size: resolved.file?.dimensions,
|
||||
}
|
||||
: {
|
||||
src: defaultPageCover.src,
|
||||
size: {
|
||||
width: defaultPageCover.width,
|
||||
height: defaultPageCover.height,
|
||||
},
|
||||
},
|
||||
dark: resolvedDark
|
||||
? {
|
||||
src: resolvedDark.href,
|
||||
size: resolvedDark.file?.dimensions,
|
||||
}
|
||||
: null,
|
||||
<PageCoverImage
|
||||
imgs={{
|
||||
light,
|
||||
dark,
|
||||
}}
|
||||
resize={
|
||||
// When using the default cover, we don't want to resize as it's a SVG
|
||||
resolved ? context.imageResizer : false
|
||||
}
|
||||
sizes={[
|
||||
// Cover takes the full width on mobile/table
|
||||
{
|
||||
media: '(max-width: 768px)',
|
||||
width: 768,
|
||||
},
|
||||
{
|
||||
media: '(max-width: 1024px)',
|
||||
width: 1024,
|
||||
},
|
||||
// Maximum size of the cover
|
||||
{ width: 1248 },
|
||||
]}
|
||||
className={tcls('w-full', 'object-cover', 'object-center')}
|
||||
inlineStyle={{ aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}` }}
|
||||
y={cover.yPos}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useRef } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import type { ImageSize } from '../utils';
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: ImageSize;
|
||||
}
|
||||
|
||||
interface Images {
|
||||
light: ImageAttributes;
|
||||
dark?: ImageAttributes;
|
||||
}
|
||||
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) {
|
||||
// When the size of the image hasn't been determined, we fallback to the center position
|
||||
if (!img.size || y === 0) return '50%';
|
||||
const ratio =
|
||||
container.height && container.width
|
||||
? Math.max(container.width / img.size.width, container.height / img.size.height)
|
||||
: 1;
|
||||
const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height;
|
||||
const top =
|
||||
container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y;
|
||||
return `${top}px`;
|
||||
}
|
||||
|
||||
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const container = useResizeObserver({
|
||||
ref: containerRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<img
|
||||
src={imgs.light.src}
|
||||
fetchPriority="high"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
<img
|
||||
src={imgs.dark.src}
|
||||
fetchPriority="low"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { GitBookBaseContext, GitBookSiteContext } from '@/lib/context';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { filterOutNullable } from '@/lib/typescript';
|
||||
import type {
|
||||
Revision,
|
||||
@@ -256,24 +256,20 @@ async function searchSiteContent(
|
||||
return (
|
||||
await Promise.all(
|
||||
searchResults.map(async (spaceItem) => {
|
||||
const sections = getSiteStructureSections(structure).flatMap((item) =>
|
||||
item.object === 'site-section-group' ? [item, ...item.sections] : item
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceItem.id
|
||||
);
|
||||
const siteSpace = findSiteSpaceById(structure, spaceItem.id);
|
||||
const siteSection = sections.find(
|
||||
(section) => section.id === siteSpace?.section
|
||||
) as SiteSection;
|
||||
const siteSectionGroup = siteSection?.sectionGroup
|
||||
? sections.find((sectionGroup) => sectionGroup.id === siteSection.sectionGroup)
|
||||
: null;
|
||||
const siteSection = found?.siteSection;
|
||||
const siteSectionGroup = found?.siteSectionGroup;
|
||||
|
||||
return Promise.all(
|
||||
spaceItem.pages.map((pageItem) =>
|
||||
transformSitePageResult(context, {
|
||||
pageItem,
|
||||
spaceItem,
|
||||
space: siteSpace?.space,
|
||||
spaceURL: siteSpace?.urls.published,
|
||||
space: found?.siteSpace.space,
|
||||
spaceURL: found?.siteSpace.urls.published,
|
||||
siteSection: siteSection ?? undefined,
|
||||
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
|
||||
})
|
||||
@@ -313,8 +309,11 @@ async function transformAnswer(
|
||||
}
|
||||
|
||||
// Find the siteSpace in case it is nested in a site section so we can resolve the URL appropriately
|
||||
const siteSpace = findSiteSpaceById(context.structure, source.space);
|
||||
const spaceURL = siteSpace?.urls.published;
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === source.space
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
|
||||
const href = spaceURL
|
||||
? joinPathWithBaseURL(spaceURL, page.page.path)
|
||||
|
||||
@@ -16,7 +16,7 @@ type ImageSource = {
|
||||
aspectRatio?: string;
|
||||
};
|
||||
|
||||
type ImageSourceSized = {
|
||||
export type ImageSourceSized = {
|
||||
src: string;
|
||||
size: ImageSize | null;
|
||||
aspectRatio?: string;
|
||||
@@ -244,7 +244,7 @@ async function ImagePictureSized(
|
||||
* Get the attributes for an image.
|
||||
* src, srcSet, sizes, width, height, etc.
|
||||
*/
|
||||
async function getImageAttributes(params: {
|
||||
export async function getImageAttributes(params: {
|
||||
sizes: ImageResponsiveSize[];
|
||||
source: ImageSourceSized;
|
||||
quality: number;
|
||||
|
||||
@@ -90,4 +90,5 @@ export const de = {
|
||||
searched_for: 'Gesucht nach ${1}',
|
||||
ai_chat_tools_listed_pages: 'Docs durchsucht',
|
||||
ai_chat_tools_read_page: 'Seite ${1} gelesen',
|
||||
ai_chat_tools_mcp_tool: 'Aufgerufen ${1}',
|
||||
};
|
||||
|
||||
@@ -88,4 +88,5 @@ export const en = {
|
||||
searched_for: 'Searched for ${1}',
|
||||
ai_chat_tools_listed_pages: 'Browsed the docs',
|
||||
ai_chat_tools_read_page: 'Read page ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Called ${1}',
|
||||
};
|
||||
|
||||
@@ -92,4 +92,5 @@ export const es: TranslationLanguage = {
|
||||
searched_for: 'Buscado ${1}',
|
||||
ai_chat_tools_listed_pages: 'Exploró los docs',
|
||||
ai_chat_tools_read_page: 'Leyó la página ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Llamó a ${1}',
|
||||
};
|
||||
|
||||
@@ -90,4 +90,5 @@ export const fr: TranslationLanguage = {
|
||||
searched_for: 'Recherché ${1}',
|
||||
ai_chat_tools_listed_pages: 'Parcouru les docs',
|
||||
ai_chat_tools_read_page: 'Lu la page ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Appelé ${1}',
|
||||
};
|
||||
|
||||
@@ -91,4 +91,5 @@ export const ja: TranslationLanguage = {
|
||||
searched_for: '${1}を検索しました',
|
||||
ai_chat_tools_listed_pages: 'ドキュメントを閲覧',
|
||||
ai_chat_tools_read_page: 'ページ ${1} を読みました',
|
||||
ai_chat_tools_mcp_tool: '${1} を呼び出しました',
|
||||
};
|
||||
|
||||
@@ -90,4 +90,5 @@ export const nl: TranslationLanguage = {
|
||||
searched_for: 'Gezocht naar ${1}',
|
||||
ai_chat_tools_listed_pages: 'Docs doorzocht',
|
||||
ai_chat_tools_read_page: 'Pagina ${1} gelezen',
|
||||
ai_chat_tools_mcp_tool: '${1} aangeroepen',
|
||||
};
|
||||
|
||||
@@ -90,4 +90,5 @@ export const no: TranslationLanguage = {
|
||||
searched_for: 'Søkte etter ${1}',
|
||||
ai_chat_tools_listed_pages: 'Bladde gjennom docs',
|
||||
ai_chat_tools_read_page: 'Leste side ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Kallte ${1}',
|
||||
};
|
||||
|
||||
@@ -90,4 +90,5 @@ export const pt_br = {
|
||||
searched_for: 'Pesquisou por ${1}',
|
||||
ai_chat_tools_listed_pages: 'Navegou pelos docs',
|
||||
ai_chat_tools_read_page: 'Leu a página ${1}',
|
||||
ai_chat_tools_mcp_tool: 'Chamou ${1}',
|
||||
};
|
||||
|
||||
@@ -87,4 +87,5 @@ export const zh: TranslationLanguage = {
|
||||
searched_for: '搜索了 ${1}',
|
||||
ai_chat_tools_listed_pages: '浏览了文档',
|
||||
ai_chat_tools_read_page: '已读取页面 ${1}',
|
||||
ai_chat_tools_mcp_tool: '调用了 ${1}',
|
||||
};
|
||||
|
||||
@@ -114,4 +114,27 @@ describe('linkerWithOtherSpaceBasePath', () => {
|
||||
'/section/variant/some/path'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a new linker that resolves links relative to a new spaceBasePath in the current site', () => {
|
||||
const otherSpaceBasePathLinker = linkerWithOtherSpaceBasePath(root, {
|
||||
spaceBasePath: '/section/variant',
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe(
|
||||
'/section/variant/some/path'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use a basepath relative to the site', () => {
|
||||
const otherSpaceBasePathLinker = linkerWithOtherSpaceBasePath(siteGitBookIO, {
|
||||
spaceBasePath: 'a/b',
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path');
|
||||
});
|
||||
|
||||
it('should use a basepath relative to the site (with trailing slash)', () => {
|
||||
const otherSpaceBasePathLinker = linkerWithOtherSpaceBasePath(siteGitBookIO, {
|
||||
spaceBasePath: '/a/b',
|
||||
});
|
||||
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,13 +142,22 @@ export function linkerWithAbsoluteURLs(linker: GitBookLinker): GitBookLinker {
|
||||
*/
|
||||
export function linkerWithOtherSpaceBasePath(
|
||||
linker: GitBookLinker,
|
||||
{ spaceBasePath }: { spaceBasePath: string }
|
||||
{
|
||||
spaceBasePath,
|
||||
}: {
|
||||
/**
|
||||
* The base path of the space. It should be relative to the root of the site.
|
||||
*/
|
||||
spaceBasePath: string;
|
||||
}
|
||||
): GitBookLinker {
|
||||
const newLinker: GitBookLinker = {
|
||||
...linker,
|
||||
toPathInSpace(relativePath: string): string {
|
||||
return joinPaths(spaceBasePath, relativePath);
|
||||
return linker.toPathInSite(joinPaths(spaceBasePath, relativePath));
|
||||
},
|
||||
// implementation matches the base linker toPathForPage, but decouples from using `this` to
|
||||
// ensure we always use the updates `toPathInSpace` method.
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return newLinker.toPathInSpace(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { parseOpenAPI } from '@gitbook/openapi-parser';
|
||||
import { OpenAPIParseError, parseOpenAPI } from '@gitbook/openapi-parser';
|
||||
|
||||
import { noCacheFetchOptions } from '@/lib/data';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { unstable_cacheLife as cacheLife } from 'next/cache';
|
||||
import { assert } from 'ts-essentials';
|
||||
import { enrichFilesystem } from './enrich';
|
||||
import type {
|
||||
@@ -37,6 +38,10 @@ export async function fetchOpenAPIFilesystem(
|
||||
return fetchFilesystem(resolved.href);
|
||||
})();
|
||||
|
||||
if ('error' in filesystem) {
|
||||
throw new OpenAPIParseError(filesystem.error.message, { code: filesystem.error.code });
|
||||
}
|
||||
|
||||
return {
|
||||
filesystem,
|
||||
specUrl: resolved.href,
|
||||
@@ -45,7 +50,17 @@ export async function fetchOpenAPIFilesystem(
|
||||
|
||||
const fetchFilesystem = async (url: string) => {
|
||||
'use cache';
|
||||
return fetchFilesystemUncached(url);
|
||||
try {
|
||||
return await fetchFilesystemUncached(url);
|
||||
} catch (error) {
|
||||
// Throwing an error inside a "use cache" function obfuscates the error,
|
||||
// so we need to handle it here and recreates the error outside the cache function.
|
||||
if (error instanceof OpenAPIParseError) {
|
||||
cacheLife('seconds');
|
||||
return { error: { code: error.code, message: error.message } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchFilesystemUncached(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchOpenAPIFilesystem } from '@/lib/openapi/fetch';
|
||||
import type { OpenAPIParseError } from '@gitbook/openapi-parser';
|
||||
import { OpenAPIParseError } from '@gitbook/openapi-parser';
|
||||
import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi';
|
||||
import type {
|
||||
AnyOpenAPIOperationsBlock,
|
||||
@@ -55,8 +55,8 @@ async function baseResolveOpenAPIOperationBlock(
|
||||
|
||||
return { data, specUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'OpenAPIParseError') {
|
||||
return { error: error as OpenAPIParseError };
|
||||
if (error instanceof OpenAPIParseError) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -33,7 +33,7 @@ import { PageIcon } from '@/components/PageIcon';
|
||||
import { getGitBookAppHref } from './app';
|
||||
import { getBlockById, getBlockTitle } from './document';
|
||||
import { resolvePageId } from './pages';
|
||||
import { findSiteSpaceById, getFallbackSiteSpacePath } from './sites';
|
||||
import { findSiteSpaceBy, getFallbackSiteSpacePath } from './sites';
|
||||
import type { ClassValue } from './tailwind';
|
||||
import { filterOutNullable } from './typescript';
|
||||
|
||||
@@ -315,9 +315,12 @@ async function getBestTargetSpace(
|
||||
// In the context of sites, we try to find our target space in the site structure.
|
||||
// because the url of this space will be in the same site.
|
||||
if ('site' in context) {
|
||||
const siteSpace = findSiteSpaceById(context.structure, spaceId);
|
||||
if (siteSpace) {
|
||||
return { space: siteSpace.space, siteSpace };
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceId
|
||||
);
|
||||
if (found) {
|
||||
return { space: found.siteSpace.space, siteSpace: found.siteSpace };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,18 +48,46 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) {
|
||||
/**
|
||||
* Find a site space by its spaceId in a site structure.
|
||||
*/
|
||||
export function findSiteSpaceById(siteStructure: SiteStructure, spaceId: string): SiteSpace | null {
|
||||
export function findSiteSpaceBy(
|
||||
siteStructure: SiteStructure,
|
||||
predicate: (siteSpace: SiteSpace) => boolean
|
||||
): {
|
||||
siteSpace: SiteSpace;
|
||||
siteSection: SiteSection | null;
|
||||
siteSectionGroup: SiteSectionGroup | null;
|
||||
} | null {
|
||||
if (siteStructure.type === 'siteSpaces') {
|
||||
return siteStructure.structure.find((siteSpace) => siteSpace.space.id === spaceId) ?? null;
|
||||
const siteSpace = siteStructure.structure.find(predicate) ?? null;
|
||||
if (siteSpace) {
|
||||
return {
|
||||
siteSpace,
|
||||
siteSection: null,
|
||||
siteSectionGroup: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const section of siteStructure.structure) {
|
||||
const siteSpace =
|
||||
section.object === 'site-section'
|
||||
? findSiteSpaceByIdInSiteSpaces(section.siteSpaces, spaceId)
|
||||
: findSiteSpaceByIdInSections(section.sections, spaceId);
|
||||
if (siteSpace) {
|
||||
return siteSpace;
|
||||
for (const sectionOrGroup of siteStructure.structure) {
|
||||
if (sectionOrGroup.object === 'site-section') {
|
||||
const siteSpace = findSiteSpaceByIdInSiteSpaces(sectionOrGroup.siteSpaces, predicate);
|
||||
if (siteSpace) {
|
||||
return {
|
||||
siteSpace,
|
||||
siteSection: sectionOrGroup,
|
||||
siteSectionGroup: null,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const found = findSiteSpaceByIdInSections(sectionOrGroup.sections, predicate);
|
||||
if (found) {
|
||||
return {
|
||||
siteSpace: found.siteSpace,
|
||||
siteSection: found.siteSection,
|
||||
siteSectionGroup: sectionOrGroup,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,25 +122,37 @@ export function getSiteSpaceURL(context: GitBookSiteContext, siteSpace: SiteSpac
|
||||
|
||||
/**
|
||||
* Get the path of a site space in the current site.
|
||||
* This doesn't return the most optimized path, as it doesn't take into account which one is the default one.
|
||||
*/
|
||||
export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace: SiteSpace) {
|
||||
const { sections } = context;
|
||||
return sections?.current ? joinPath(sections.current.path, siteSpace.path) : siteSpace.path;
|
||||
const found = findSiteSpaceBy(context.structure, (entry) => entry.id === siteSpace.id);
|
||||
// don't include the path for the default site space
|
||||
const siteSpacePath = siteSpace.default ? '' : siteSpace.path;
|
||||
|
||||
// for non-default site sections, include the section path.
|
||||
if (found?.siteSection && !found?.siteSection.default) {
|
||||
return joinPath(found.siteSection.path, siteSpacePath);
|
||||
}
|
||||
|
||||
return siteSpacePath;
|
||||
}
|
||||
|
||||
function findSiteSpaceByIdInSections(sections: SiteSection[], spaceId: string): SiteSpace | null {
|
||||
for (const section of sections) {
|
||||
const siteSpace =
|
||||
section.siteSpaces.find((siteSpace) => siteSpace.space.id === spaceId) ?? null;
|
||||
function findSiteSpaceByIdInSections(
|
||||
sections: SiteSection[],
|
||||
predicate: (siteSpace: SiteSpace) => boolean
|
||||
): { siteSpace: SiteSpace; siteSection: SiteSection } | null {
|
||||
for (const siteSection of sections) {
|
||||
const siteSpace = siteSection.siteSpaces.find(predicate) ?? null;
|
||||
if (siteSpace) {
|
||||
return siteSpace;
|
||||
return { siteSpace, siteSection };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSiteSpaceByIdInSiteSpaces(siteSpaces: SiteSpace[], spaceId: string): SiteSpace | null {
|
||||
return siteSpaces.find((siteSpace) => siteSpace.space.id === spaceId) ?? null;
|
||||
function findSiteSpaceByIdInSiteSpaces(
|
||||
siteSpaces: SiteSpace[],
|
||||
predicate: (siteSpace: SiteSpace) => boolean
|
||||
): SiteSpace | null {
|
||||
return siteSpaces.find(predicate) ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user