diff --git a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx
index 68c28b8fc..8e790f52b 100644
--- a/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx
+++ b/packages/gitbook/src/components/Header/SpacesDropdownMenuItem.tsx
@@ -17,7 +17,7 @@ interface VariantSpace {
*/
function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: string, active = false) {
const currentPathname = useCurrentPagePath();
- const { metaLinks } = useCurrentPageMetadata();
+ const { metaLinks, currentPage } = useCurrentPageMetadata();
// We first check if there is an alternate link for the variant space in the current page metadata.
const pageHasAlternateForVariant = metaLinks?.alternates.find(
@@ -27,6 +27,15 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
return pageHasAlternateForVariant.href;
}
+ const firstAlternate = metaLinks?.alternates[0];
+ const computed = firstAlternate ? {
+ pageID: firstAlternate.pageID,
+ spaceID: firstAlternate.space?.id
+ } : {
+ pageID: currentPage?.id,
+ spaceID: currentPage?.spaceId
+ };
+
// If there is no alternate link, we reconstruct the URL by swapping the space path.
// We need to ensure that the variant space URL is not the same as the current space path.
@@ -44,9 +53,18 @@ function useVariantSpaceHref(variantSpace: VariantSpace, currentSpacePath: strin
targetUrl.searchParams.set('fallback', 'true');
+ if(computed?.spaceID && computed?.pageID) {
+ targetUrl.searchParams.set('fallbackPageID', computed.pageID);
+ targetUrl.searchParams.set('fallbackSpaceID', computed.spaceID);
+ }
+
return targetUrl.toString();
}
+ if(computed?.spaceID && computed?.pageID) {
+ return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true&fallbackPageID=${computed.pageID}&fallbackSpaceID=${computed.spaceID}`;
+ }
+
// Fallback when the URL path is a relative path (in development mode)
return `${joinPath(variantSpaceUrl, currentPathname)}?fallback=true`;
}
diff --git a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
index ce016816f..9723fca3e 100644
--- a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
+++ b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx
@@ -11,8 +11,13 @@ import type { PageMetaLinks } from './SitePage';
*/
export function PageClientLayout({
pageMetaLinks,
+ currentPage
}: {
pageMetaLinks: PageMetaLinks | null;
+ currentPage: {
+ id: string;
+ spaceId: string;
+ } | null;
}) {
// We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page
@@ -21,7 +26,7 @@ export function PageClientLayout({
// The page metadata such as meta links are generated on the server side,
// but need to be registered on the client side in other parts of the layout
// such as the SpaceDropdown.
- useRegisterPageMetadata({ pageMetaLinks });
+ useRegisterPageMetadata({ pageMetaLinks, currentPage });
useStripFallbackQueryParam();
return null;
@@ -44,6 +49,8 @@ function useStripFallbackQueryParam() {
if (searchParams?.has('fallback')) {
const params = new URLSearchParams(searchParams.toString());
params.delete('fallback');
+ params.delete('fallbackPageID');
+ params.delete('fallbackSpaceID');
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
}
}, [router, pathname, searchParams]);
@@ -54,9 +61,13 @@ function useStripFallbackQueryParam() {
*/
function useRegisterPageMetadata(metadata: {
pageMetaLinks: PageMetaLinks | null;
+ currentPage: {
+ id: string;
+ spaceId: string;
+ } | null;
}) {
- const { pageMetaLinks } = metadata;
+ const { pageMetaLinks, currentPage } = metadata;
React.useEffect(() => {
- currentPageMetadataStore.setState({ metaLinks: pageMetaLinks });
- }, [pageMetaLinks]);
+ currentPageMetadataStore.setState({ metaLinks: pageMetaLinks, currentPage });
+ }, [pageMetaLinks, currentPage]);
}
diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx
index e75a89c34..dd20bbca5 100644
--- a/packages/gitbook/src/components/SitePage/SitePage.tsx
+++ b/packages/gitbook/src/components/SitePage/SitePage.tsx
@@ -47,6 +47,11 @@ export type PageMetaLinks = {
* Space the alternate link points to, if any.
*/
space: AlternateLinkSpace | null;
+
+ /**
+ * The page ID the alternate link points to, if any.
+ */
+ pageID?: string;
}>;
};
@@ -66,7 +71,6 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
pageMetaLinks,
} = await getSitePageData(props);
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
-
return (
{/* Using `contents` makes the children of this div according to its parent — which keeps them in a single flex row with the TOC by default.
@@ -102,7 +106,7 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
staticRoute={props.staticRoute}
/>
-
+
);
@@ -151,7 +155,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise !!alt.href
+ (alt): alt is { href: string; space: AlternateLinkSpace | null, pageID: string | undefined } => !!alt.href
),
};
}
diff --git a/packages/gitbook/src/components/SitePage/fetch.ts b/packages/gitbook/src/components/SitePage/fetch.ts
index 8b5a1b76f..e7051473f 100644
--- a/packages/gitbook/src/components/SitePage/fetch.ts
+++ b/packages/gitbook/src/components/SitePage/fetch.ts
@@ -2,7 +2,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import { redirect } from 'next/navigation';
import { getDataOrNull } from '@/lib/data';
-import { resolvePageId, resolvePagePath } from '@/lib/pages';
+import { resolveFallbackPage, resolvePageId, resolvePagePath } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
export interface PagePathParams {
@@ -36,7 +36,7 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
- const { organizationId, site, space, revision, shareKey, linker, revisionId } = context;
+ const { organizationId, site, space, revision, shareKey, linker, revisionId, fallback } = context;
if ('pageId' in params) {
return resolvePageId(revision.pages, params.pageId);
@@ -100,6 +100,12 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
);
if (resolved) {
return resolvePageId(revision.pages, resolved.id);
+ } else if(fallback.isFallback && fallback.pageID && fallback.spaceID) {
+ return resolveFallbackPage(
+ revision.pages,
+ fallback.spaceID,
+ fallback.pageID
+ );
}
}
diff --git a/packages/gitbook/src/components/hooks/useCurrentPageMetadata.tsx b/packages/gitbook/src/components/hooks/useCurrentPageMetadata.tsx
index abb76325f..57f018c1d 100644
--- a/packages/gitbook/src/components/hooks/useCurrentPageMetadata.tsx
+++ b/packages/gitbook/src/components/hooks/useCurrentPageMetadata.tsx
@@ -11,16 +11,18 @@ import * as zustand from 'zustand';
*/
export const currentPageMetadataStore = zustand.create<{
metaLinks: PageMetaLinks | null;
+ currentPage: {
+ id: string;
+ spaceId: string;
+ } | null;
}>(() => ({
metaLinks: null,
+ currentPage: null,
}));
/**
* Return the metadata for the current page.
*/
export function useCurrentPageMetadata() {
- const metaLinks = zustand.useStore(currentPageMetadataStore, (state) => state.metaLinks);
- return {
- metaLinks,
- };
+ return zustand.useStore(currentPageMetadataStore, (state) => state);
}
diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts
index bfb5f0fb8..feae82fb1 100644
--- a/packages/gitbook/src/lib/context.ts
+++ b/packages/gitbook/src/lib/context.ts
@@ -27,6 +27,25 @@ import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
+type FallbackData = {
+ /**
+ *
+ */
+ isFallback: boolean;
+
+ /**
+ * Space ID of the main space.
+ * Only provided for a computed space.
+ */
+ spaceID?: string;
+
+ /**
+ * page ID of the main revision.
+ * Only provided for a computed revision.
+ */
+ pageID?: string;
+};
+
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
@@ -52,11 +71,10 @@ export type SiteURLData = Pick<
imagesContextId: string;
/**
- * Whether this request is a fallback rendering.
- * We use this when switching variant as we don't know if the page exists in the other variant.
- * By knowing it's a fallback, we can redirect to the space base path instead of returning a 404.
+ * Necessary data to properly handle fallback rendering.
+ * This is used when switching between variants to avoid fetching every revision every time.
*/
- isFallback?: boolean;
+ fallback?: FallbackData;
};
/**
@@ -142,7 +160,7 @@ export type GitBookSiteContext = GitBookSpaceContext & {
contextId?: string;
/** Whether this request is a fallback rendering. */
- isFallback: boolean;
+ fallback: FallbackData;
};
/**
@@ -221,7 +239,7 @@ export async function fetchSiteContextByURLLookup(
changeRequest: data.changeRequest,
revision: data.revision,
contextId: data.contextId,
- isFallback: data.isFallback ?? false,
+ fallback: data.fallback ?? { isFallback: false },
});
}
@@ -240,7 +258,7 @@ export async function fetchSiteContextByIds(
changeRequest: string | undefined;
revision: string | undefined;
contextId?: string;
- isFallback: boolean;
+ fallback: FallbackData;
}
): Promise {
const { dataFetcher } = baseContext;
@@ -352,7 +370,7 @@ export async function fetchSiteContextByIds(
visibleSections,
scripts,
contextId: ids.contextId,
- isFallback: ids.isFallback,
+ fallback: ids.fallback,
};
}
diff --git a/packages/gitbook/src/lib/pages.ts b/packages/gitbook/src/lib/pages.ts
index eecb821e4..6faba0774 100644
--- a/packages/gitbook/src/lib/pages.ts
+++ b/packages/gitbook/src/lib/pages.ts
@@ -116,6 +116,50 @@ export function resolvePageId(
return iteratePages(rootPages, []);
}
+/**
+ *
+ * Resolve a page by its related space ID and page ID from the revision pages.
+ * Related pages are pages that are linked via meta links alternates.
+ * It uses the metaLinks alternates to find the matching computed page.
+ * It always use the first alternate to resolve the parent page.
+ *
+ * @param rootPages
+ * @param relatedSpaceId Space ID of the parent page (i.e. )
+ * @param relatedPageId
+ * @returns
+ */
+export function resolveFallbackPage(
+ rootPages: Revision['pages'],
+ relatedSpaceId: string,
+ relatedPageId: string
+): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined {
+ const iteratePages = (
+ pages: RevisionPage[],
+ ancestors: AncestorRevisionPage[]
+ ): { page: RevisionPageDocument; ancestors: AncestorRevisionPage[] } | undefined => {
+ for (const page of pages) {
+ if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
+ continue;
+ }
+
+ if(page.type === RevisionPageType.Document) {
+ const metaLinksAlternates = page.metaLinks?.alternates;
+ if(metaLinksAlternates && metaLinksAlternates.length > 0 && metaLinksAlternates[0]?.kind !== "url") {
+ if(metaLinksAlternates[0]?.space === relatedSpaceId && metaLinksAlternates[0]?.page === relatedPageId) {
+ return resolvePageDocument(page, ancestors);
+ }
+ }
+ }
+
+ const result = iteratePages(page.pages, [...ancestors, page]);
+ if (result) {
+ return result;
+ }
+ }
+ };
+ return iteratePages(rootPages, []);
+}
+
/**
* Resolve the next/previous page before another one.
* It ignores hidden pages as this is used for navigation purpose.
@@ -220,7 +264,7 @@ function resolvePageDocument(
return;
}
if (page.type === RevisionPageType.Link || page.type === RevisionPageType.Computed) {
- return undefined;
+ return undefined;
}
return { page, ancestors };
diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts
index f5b7f1113..8f4029003 100644
--- a/packages/gitbook/src/middleware.ts
+++ b/packages/gitbook/src/middleware.ts
@@ -282,6 +282,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
+ // Extract fallback pageID and spaceID from the URL
+ // These are used when switching variant spaces to redirect to a specific page if the current path doesn't exist in the new variant.
+ // Because there is no link between every variant, we need to pass a stable spaceID/pageID to redirect to.
+ // They are the first alternate link in the page metadata of the previous variant.
+ const fallbackPageID = requestURL.searchParams.get('fallbackPageID');
+ const fallbackSpaceID = requestURL.searchParams.get('fallbackSpaceID');
+
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
@@ -298,7 +305,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
- isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : undefined,
+ fallback: {
+ isFallback: requestURL.searchParams.get('fallback') === 'true' ? true : false,
+ ...(fallbackPageID ? { pageID: fallbackPageID } : {}),
+ ...(fallbackSpaceID ? { spaceID: fallbackSpaceID } : {}),
+ }
+
};
const requestHeaders = new Headers(request.headers);