mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
v2: fix fallback and VA redirects (#2948)
This commit is contained in:
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user