Compare commits

...

7 Commits

Author SHA1 Message Date
Nicolas Dorseuil f2c745b553 add e2e test for repositioned cover 2025-07-01 12:34:29 +02:00
Samy Pessé f3affc3034 Add support for MCP tools (#3410) 2025-07-01 09:58:59 +02:00
conico974 59da30f3c6 Add support for cover image repositioning (#3404)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-06-30 19:07:48 +02:00
Greg Bergé 2db721112a Fix OpenAPI error (#3408) 2025-06-30 15:56:22 +00:00
Samy Pessé b60039b7d5 Fix links to other spaces in a section (#3409)
Co-authored-by: Steven H <steven@gitbook.io>
2025-06-30 17:47:23 +02:00
Steven H 8fe9c9afea Fix an issue where same-site references across spaces created invalid links. (#3407) 2025-06-30 16:03:27 +01:00
Zeno Kapitein 216ba7a556 Add analytics on AI chat (#3405) 2025-06-30 08:41:12 +00:00
28 changed files with 320 additions and 93 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Display MCP tool calls in AI chat.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces within a section.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add support for cover repositioning
+2 -2
View File
@@ -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
View File
@@ -34,7 +34,7 @@
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.123.0"
"@gitbook/api": "^0.125.0"
}
},
"patchedDependencies": {
+5
View File
@@ -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}',
};
+23
View File
@@ -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');
});
});
+11 -2
View File
@@ -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}` : '');
},
+17 -2
View File
@@ -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;
+7 -4
View File
@@ -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 };
}
}
+59 -19
View File
@@ -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;
}