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);