v2: fix fallback and VA redirects (#2948)

This commit is contained in:
Samy Pessé
2025-03-08 11:21:49 +01:00
committed by GitHub
parent d9b5a898ab
commit 8d5de97ddf
14 changed files with 282 additions and 179 deletions
+6 -6
View File
@@ -49,7 +49,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.6.5",
"version": "0.7.2",
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/cache-do": "workspace:*",
@@ -132,7 +132,7 @@
},
"packages/gitbook-v2": {
"name": "gitbook-v2",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@gitbook/api": "0.96.1",
"@gitbook/cache-tags": "workspace:*",
@@ -144,7 +144,7 @@
"warn-once": "^0.1.1",
},
"devDependencies": {
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d",
"gitbook": "*",
"postcss": "^8",
"tailwindcss": "^3.4.0",
@@ -172,7 +172,7 @@
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "2.0.2",
"version": "2.1.0",
"dependencies": {
"@scalar/openapi-parser": "^0.10.9",
"@scalar/openapi-types": "^0.1.9",
@@ -227,7 +227,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.0.5",
"version": "1.1.2",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "^1.1.36",
@@ -793,7 +793,7 @@
"@opennextjs/aws": ["@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@756", { "dependencies": { "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.19.2", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0" }, "bin": { "open-next": "./dist/index.js" } }],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d", { "dependencies": { "@ast-grep/napi": "^0.34.1", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@756", "enquirer": "^2.4.1", "glob": "^11.0.0", "yaml": "^2.7.0" }, "peerDependencies": { "wrangler": "^3.111.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d", { "dependencies": { "@ast-grep/napi": "^0.34.1", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@756", "enquirer": "^2.4.1", "glob": "^11.0.0", "yaml": "^2.7.0" }, "peerDependencies": { "wrangler": "^3.111.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+1 -1
View File
@@ -14,7 +14,7 @@
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
@@ -16,7 +16,7 @@ export default async function Page(props: PageProps) {
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
return <SitePage context={context} pageParams={{ pathname }} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -25,14 +25,12 @@ export async function generateViewport(props: PageProps): Promise<Viewport> {
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
const params = await props.params;
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
fallback: !!searchParams.fallback,
});
}
@@ -28,7 +28,7 @@ export default async function Page(props: PageProps) {
})
);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
return <SitePage context={context} pageParams={{ pathname }} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -44,6 +44,5 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
});
}
+37 -20
View File
@@ -116,27 +116,11 @@ export function getBaseContext(input: {
apiToken: input.apiToken ?? GITBOOK_API_TOKEN,
apiEndpoint: GITBOOK_API_URL,
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: url.host,
pathname: url.pathname,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
pathname: `/url/${url.host}${url.pathname}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}`;
};
}
const linker = getLinkerForSiteURL({
siteURL: url,
urlMode,
});
const imageResizer = createImageResizer({
host: url.host,
@@ -152,6 +136,39 @@ export function getBaseContext(input: {
};
}
/**
* Get the linker for a given site URL.
*/
export function getLinkerForSiteURL(input: {
siteURL: URL;
urlMode: 'url' | 'url-host';
}) {
const { siteURL, urlMode } = input;
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteURL.host,
pathname: siteURL.pathname,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
pathname: `/url/${siteURL.host}${siteURL.pathname}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
};
}
return linker;
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
+77 -15
View File
@@ -5,9 +5,15 @@ import { NextResponse } from 'next/server';
import { getContentSecurityPolicy } from '@/lib/csp';
import { validateSerializedCustomization } from '@/lib/customization';
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
import { getResponseCookiesForVisitorAuth, getVisitorToken } from '@/lib/visitor-token';
import {
type ResponseCookies,
getResponseCookiesForVisitorAuth,
getVisitorToken,
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
import { serveResizedImage } from '@/routes/image';
import { getPublishedContentByURL } from '@v2/lib/data';
import { getLinkerForSiteURL } from '@v2/lib/context';
import { getPublishedContentByURL, normalizeURL } from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { MiddlewareHeaders } from '@v2/lib/middleware';
@@ -21,6 +27,14 @@ type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
// Redirect to normalize the URL
const normalized = normalizeURL(requestURL);
if (normalized.toString() !== requestURL.toString()) {
return NextResponse.redirect(normalized.toString());
}
// Route all requests to a site
const extracted = getSiteURLFromRequest(request);
if (extracted) {
@@ -38,7 +52,7 @@ export async function middleware(request: NextRequest) {
});
}
return await serveSiteByURL(request, extracted);
return await serveSiteByURL(requestURL, request, extracted);
}
// Handle the rest with the router default logic
@@ -51,7 +65,7 @@ export async function middleware(request: NextRequest) {
/**
* Serve site by URL.
*/
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
async function serveSiteByURL(requestURL: URL, request: NextRequest, urlWithMode: URLWithMode) {
const { url, mode } = urlWithMode;
// Visitor authentication
@@ -72,11 +86,55 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
}
const { data } = result;
let cookies: ResponseCookies = {};
//
// Handle redirects
//
if ('redirect' in data) {
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
console.log('redirect', data.redirect);
if (data.target === 'content') {
// For content redirects, we use the linker to redirect the optimal URL
// during development and testing in 'url' mode.
const linker = getLinkerForSiteURL({
siteURL: url,
urlMode: mode,
});
const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url);
// Keep the same search params as the original request
// as it might contain a VA token
contentRedirect.search = request.nextUrl.search;
return NextResponse.redirect(contentRedirect);
}
return NextResponse.redirect(data.redirect);
}
cookies = {
...cookies,
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
};
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const requestURLWithoutToken = normalizeVisitorAuthURL(requestURL);
if (requestURLWithoutToken.toString() !== requestURL.toString()) {
return writeResponseCookies(
NextResponse.redirect(requestURLWithoutToken.toString()),
cookies
);
}
//
// Render and serve the content
//
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
let routeType = visitorToken ? 'dynamic' : 'static';
@@ -108,13 +166,13 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin);
const siteURL = `${url.host}${data.basePath}`;
const siteURLWithoutProtocol = `${url.host}${data.basePath}`;
const route = [
'sites',
routeType,
mode,
encodeURIComponent(siteURL),
encodeURIComponent(siteURLWithoutProtocol),
encodePathInSiteContent(data.pathname),
].join('/');
@@ -135,16 +193,9 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
response.headers.set('x-content-type-options', 'nosniff');
// Debug header
response.headers.set('x-gitbook-route-type', routeType);
response.headers.set('x-gitbook-site-url', siteURL);
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
if (visitorToken) {
const cookies = getResponseCookiesForVisitorAuth(data.basePath, visitorToken);
for (const [key, value] of Object.entries(cookies)) {
response.cookies.set(key, value.value, value.options);
}
}
return response;
return writeResponseCookies(response, cookies);
}
/**
@@ -248,3 +299,14 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
return url;
}
/**
* Write the cookies to a response.
*/
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
Object.entries(cookies).forEach(([key, { value, options }]) => {
response.cookies.set(key, value, options);
});
return response;
}
+14 -9
View File
@@ -155,8 +155,8 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/2.0/reference/api-reference/pets?fallback=true'
await page.waitForURL((url) =>
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
);
},
},
@@ -178,8 +178,10 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets?fallback=true'
await page.waitForURL((url) =>
url.pathname.includes(
'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets'
)
);
},
},
@@ -213,8 +215,10 @@ const testCases: TestsCase[] = [
.click();
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/2.0/reference/api-reference/pets?fallback=true'
await page.waitForURL((url) =>
url.pathname.includes(
'api-multi-versions-va/2.0/reference/api-reference/pets'
)
);
},
},
@@ -244,9 +248,7 @@ const testCases: TestsCase[] = [
const sectionGroupDropdown = await page.getByText('Test Section Group 1');
await sectionGroupDropdown.hover();
await page.getByText('Section B').click();
await page.waitForURL(
'https://gitbook-open-e2e-sites.gitbook.io/sections/sections-4'
);
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
},
],
@@ -820,6 +822,9 @@ const testCases: TestsCase[] = [
expiresIn: '24h',
}
);
// Test that when accessing the non-canonical URL, we are redirected to the canonical URL
// with the jwt token in the query string
return `spacea?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
@@ -38,10 +38,9 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
}
async function getSitePageProps(props: PageProps) {
const { params: rawParams, searchParams: rawSearchParams } = props;
const { params: rawParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const pointer = await getSiteContentPointer();
const context = await fetchV1ContextForSitePointer(pointer);
@@ -49,7 +48,5 @@ async function getSitePageProps(props: PageProps) {
return {
context,
pageParams: params,
redirectOnFallback: true,
fallback: !!searchParams.fallback,
};
}
@@ -48,7 +48,7 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
const mode = await headers().get('x-gitbook-mode');
const mode = (await headers()).get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
@@ -101,7 +101,6 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
return {
title: site.title,
generator: `GitBook (${buildVersion()})`,
// metadataBase: new URL(await getBaseUrl()),
icons: {
icon: [
{
@@ -7,7 +7,7 @@ import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { getPagePath } from '@/lib/pages';
import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { PageClientLayout } from './PageClientLayout';
@@ -19,8 +19,6 @@ export const dynamic = 'force-dynamic';
export type SitePageProps = {
context: GitBookSiteContext;
pageParams: PagePathParams;
redirectOnFallback: boolean;
fallback?: boolean;
};
/**
@@ -30,8 +28,6 @@ export async function SitePage(props: SitePageProps) {
const { context, pageTarget } = await getPageDataWithFallback({
context: props.context,
pagePathParams: props.pageParams,
fallback: props.fallback,
redirectOnFallback: props.redirectOnFallback,
});
const rawPathname = getPathnameParam(props.pageParams);
@@ -115,7 +111,6 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
const { context, pageTarget } = await getPageDataWithFallback({
context: props.context,
pagePathParams: props.pageParams,
fallback: props.fallback,
});
if (!pageTarget) {
@@ -153,28 +148,9 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
async function getPageDataWithFallback(args: {
context: GitBookSiteContext;
pagePathParams: PagePathParams;
fallback?: boolean;
redirectOnFallback?: boolean;
}) {
const { context: baseContext, pagePathParams, fallback, redirectOnFallback = false } = args;
let { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
const canFallback = !!fallback;
if (!pageTarget && canFallback) {
const rootPage = resolveFirstDocument(context.pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(
context.linker.toPathForPage({
pages: context.pages,
page: rootPage?.page,
})
);
}
pageTarget = rootPage;
}
const { context: baseContext, pagePathParams } = args;
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
return {
context: {
@@ -3,12 +3,26 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSpaceBasePath } from '../SpaceLayout/SpaceLayoutContext';
/**
* Component that displays a "page not found" message.
*/
export function SitePageNotFound() {
const basePath = useSpaceBasePath();
const language = useLanguage();
const router = useRouter();
const searchParams = useSearchParams();
// If ?fallback=true, redirect to the root page.
const fallback = searchParams.get('fallback');
useEffect(() => {
if (fallback) {
router.replace(basePath);
}
}, [basePath, fallback, router]);
return (
<div
@@ -15,6 +15,7 @@ import { GITBOOK_APP_URL } from '@v2/lib/env';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
/**
* Render the entire layout of the space (header, table of contents, footer).
@@ -46,98 +47,100 @@ export function SpaceLayout(props: {
customization.footer.groups?.length;
return (
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={context.dataFetcher.apiEndpoint}
visitorAuthToken={visitorAuthToken}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
revisionId={context.revisionId}
spaceId={context.space.id}
>
<Header withTopHeader={withTopHeader} context={context} />
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
withTopHeader ? null : 'lg:min-h-screen'
)}
<SpaceLayoutContextProvider basePath={context.linker.toPathInContent('')}>
<InsightsProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={context.dataFetcher.apiEndpoint}
visitorAuthToken={visitorAuthToken}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
siteSpaceId={context.siteSpace.id}
siteShareKey={context.shareKey ?? null}
revisionId={context.revisionId}
spaceId={context.space.id}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1'
)}
>
<HeaderLogo context={context} />
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
</span>
</SearchButton>
</React.Suspense>
<Header withTopHeader={withTopHeader} context={context} />
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_STYLE,
// Ensure the footer is display below the viewport even if the content is not enough
withFooter && 'min-h-[calc(100vh-64px)]',
withTopHeader ? null : 'lg:min-h-screen'
)}
>
<TableOfContents
context={context}
header={
withTopHeader ? null : (
<div
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'grow-0',
'flex-wrap',
'dark:shadow-light/1'
)}
>
<HeaderLogo context={context} />
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(context, sections)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls('w-full')}
/>
)}
</>
}
/>
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
</div>
)
}
innerHeader={
// displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC.
<>
{!withTopHeader && (
<div className={tcls('hidden', 'lg:block')}>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled
? 'search_or_ask'
: 'search'
)}
</span>
</SearchButton>
</React.Suspense>
</div>
)}
{!withTopHeader && withSections && sections && (
<SiteSectionList
className={tcls('hidden', 'lg:block')}
sections={encodeClientSiteSections(context, sections)}
/>
)}
{isMultiVariants && (
<SpacesDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
className={tcls('w-full')}
/>
)}
</>
}
/>
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
</div>
{withFooter ? <Footer context={context} /> : null}
{withFooter ? <Footer context={context} /> : null}
<React.Suspense fallback={null}>
<SearchModal
spaceTitle={siteSpace.title}
withAsk={customization.aiSearch.enabled}
isMultiVariants={isMultiVariants}
/>
</React.Suspense>
</InsightsProvider>
<React.Suspense fallback={null}>
<SearchModal
spaceTitle={siteSpace.title}
withAsk={customization.aiSearch.enabled}
isMultiVariants={isMultiVariants}
/>
</React.Suspense>
</InsightsProvider>
</SpaceLayoutContextProvider>
);
}
@@ -0,0 +1,33 @@
'use client';
import React from 'react';
const SpaceLayoutContext = React.createContext({
basePath: '',
});
/**
* Provide the client context about the currently rendered space.
*/
export function SpaceLayoutContextProvider(
props: React.PropsWithChildren<{
basePath: string;
}>
) {
const { basePath, children } = props;
const value = React.useMemo(() => ({ basePath }), [basePath]);
return <SpaceLayoutContext.Provider value={value}>{children}</SpaceLayoutContext.Provider>;
}
/**
* Return the base path of the currently rendered space.
*/
export function useSpaceBasePath() {
const context = React.useContext(SpaceLayoutContext);
if (!context) {
throw new Error('SpaceLayoutContext not found');
}
return context.basePath;
}