mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Implement fallback handling for variant space navigation
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
<PageClientLayout pageMetaLinks={pageMetaLinks} />
|
||||
<PageClientLayout pageMetaLinks={pageMetaLinks} currentPage={{ id: page.id, spaceId: context.space.id }} />
|
||||
</div>
|
||||
</PageContextProvider>
|
||||
);
|
||||
@@ -151,7 +155,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
});
|
||||
|
||||
if (!pageTarget) {
|
||||
if (context.isFallback) {
|
||||
if (context.fallback.isFallback) {
|
||||
redirect(context.linker.toPathInSpace('/'));
|
||||
}
|
||||
notFound();
|
||||
@@ -246,12 +250,13 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
redirect(context.linker.toPathInSpace(pathname));
|
||||
} else {
|
||||
// If the page is not found and we are in fallback mode, return a redirect to the basepath
|
||||
if (context.isFallback) {
|
||||
if (context.fallback.isFallback) {
|
||||
redirect(context.linker.toPathInSpace('/'));
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
|
||||
// If the resolved page path doesn't match the requested pathname, redirect to the correct one
|
||||
redirect(
|
||||
context.linker.toPathForPage({
|
||||
pages: context.revision.pages,
|
||||
@@ -337,6 +342,7 @@ async function resolvePageMetaLinks(
|
||||
space: resolved?.space
|
||||
? { id: resolved.space.id, language: resolved.space.language }
|
||||
: null,
|
||||
pageID: resolved?.page?.id,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -348,7 +354,7 @@ async function resolvePageMetaLinks(
|
||||
return {
|
||||
canonical: resolvedCanonical ?? null,
|
||||
alternates: resolvedAlternates.filter(
|
||||
(alt): alt is { href: string; space: AlternateLinkSpace | null } => !!alt.href
|
||||
(alt): alt is { href: string; space: AlternateLinkSpace | null, pageID: string | undefined } => !!alt.href
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
@@ -352,7 +370,7 @@ export async function fetchSiteContextByIds(
|
||||
visibleSections,
|
||||
scripts,
|
||||
contextId: ids.contextId,
|
||||
isFallback: ids.isFallback,
|
||||
fallback: ids.fallback,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user