This commit is contained in:
Nolann Biron
2026-07-15 22:27:53 +02:00
parent fe20392d21
commit c16ab0a2ce
5 changed files with 90 additions and 64 deletions
@@ -0,0 +1,5 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
+67 -41
View File
@@ -1,4 +1,5 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import { cache } from '@/lib/cache';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context';
import { getDynamicCustomizationSettings } from '@/lib/customization';
import type { SiteAPIToken } from '@gitbook/api';
@@ -26,56 +27,81 @@ export type RouteParams = RouteLayoutParams & {
* Get the static context when rendering statically a site.
*/
export async function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
// For static routes, we check the expiration of the JWT token
// as the route might be revalidated after expiration
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
return fetchStaticSiteContext(params.mode, params.siteURL, params.siteData);
}
// Deduped per request so the layout, page and their metadata/viewport generators don't each
// re-parse the site structure. Keyed on the primitive route params — the pagePath differs between
// layout and page, so it is intentionally excluded to keep a single cache entry per request.
const fetchStaticSiteContext = cache(
async (mode: string, encodedSiteURL: string, encodedSiteData: string) => {
const params: RouteLayoutParams = {
mode,
siteURL: encodedSiteURL,
siteData: encodedSiteData,
};
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
// For static routes, we check the expiration of the JWT token
// as the route might be revalidated after expiration
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
);
/**
* Get the site context when rendering dynamically.
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
context.customization = await getDynamicCustomizationSettings(context.customization);
return {
context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
};
return fetchDynamicSiteContext(params.mode, params.siteURL, params.siteData);
}
const fetchDynamicSiteContext = cache(
async (mode: string, encodedSiteURL: string, encodedSiteData: string) => {
const params: RouteLayoutParams = {
mode,
siteURL: encodedSiteURL,
siteData: encodedSiteData,
};
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
context.customization = await getDynamicCustomizationSettings(context.customization);
return {
context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
};
}
);
/**
* Get the decoded page path from the params.
*/
@@ -29,15 +29,10 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
// The image is always rendered server-side (reserving space via aspect-ratio) so it stays
// discoverable by the preload scanner as the LCP element; the client probe only refines
// `objectPositionY` once real dimensions are known.
const { containerRef, objectPositionY } = useCoverPosition(imgs, y);
return (
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
@@ -1,6 +1,7 @@
import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationDefaultThemeMode } from '@gitbook/api';
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import React from 'react';
import * as ReactDOM from 'react-dom';
@@ -39,12 +40,6 @@ export async function SiteLayout(props: {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
scripts.forEach(({ script }) => {
ReactDOM.preload(script, {
as: 'script',
});
});
return (
<SiteLayoutClientContexts
contextId={context.contextId}
@@ -76,7 +71,9 @@ export async function SiteLayout(props: {
<LoadIntegrations />
{scripts.length > 0
? scripts.map(({ script }) => <script key={script} async src={script} />)
? scripts.map(({ script }) => (
<Script key={script} src={script} strategy="afterInteractive" />
))
: null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
@@ -159,7 +159,7 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro
}
export async function generateSitePageMetadata(props: SitePageProps): Promise<Metadata> {
const { context, pageTarget, pageMetaLinks } = await getPageDataWithFallback({
const { context, pageTarget } = await getPageDataWithFallback({
context: props.context,
pagePathParams: props.pageParams,
});
@@ -174,6 +174,8 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
const { page, ancestors } = pageTarget;
const { customization, revision, linker, imageResizer } = context;
const pageMetaLinks = await resolvePageMetaLinks(context, page.id);
const canonical = (
pageMetaLinks?.canonical
? new URL(
@@ -246,7 +248,7 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
* Fetches all the data required to render the site page.
*/
export async function getSitePageData(props: SitePageProps) {
const { context, pageTarget, pageMetaLinks } = await getPageDataWithFallback({
const { context, pageTarget } = await getPageDataWithFallback({
context: props.context,
pagePathParams: props.pageParams,
});
@@ -287,7 +289,12 @@ export async function getSitePageData(props: SitePageProps) {
const withSections = Boolean(visibleSections && visibleSections.list.length > 0);
const document = await getPageDocument(context, page);
// The page document and its meta links are independent; resolve them concurrently to
// avoid stacking two round trips in front of the LCP content.
const [document, pageMetaLinks] = await Promise.all([
getPageDocument(context, page),
resolvePageMetaLinks(context, page.id),
]);
const iconStyle = getCustomizationIconStyle(customization);
const iconSources = await getInlineIconSources(
getContentInlineIconSourceRequests({
@@ -319,9 +326,6 @@ async function getPageDataWithFallback(args: {
}) {
const { context: baseContext, pagePathParams } = args;
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
const pageMetaLinks = await (pageTarget?.page
? resolvePageMetaLinks(context, pageTarget.page.id)
: null);
return {
context: {
@@ -329,7 +333,6 @@ async function getPageDataWithFallback(args: {
page: pageTarget?.page,
},
pageTarget,
pageMetaLinks,
};
}