diff --git a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx index 42dde40c5..4341a853d 100644 --- a/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx +++ b/packages/gitbook/src/app/sites/ppr/[mode]/[siteURL]/[siteData]/[revisionId]/[revalidationId]/[pprDefaults]/(content)/layout.tsx @@ -4,7 +4,9 @@ import { type PPRRouteLayoutParams, getPPRHeaderRouteParams, getPPRPageRouteParams, + getPPRSiteRouteParams, getPPRStaticSiteContext, + getPPRStaticSiteScopeContext, getPPRTableOfContentsRouteParams, getPPRVisitorAuthClaims, } from '@/app/utils'; @@ -14,7 +16,14 @@ import { generateSiteLayoutMetadata, generateSiteLayoutViewport, } from '@/components/SiteLayout'; -import { PPRHeader, PPRTableOfContents } from '@/components/SitePage/PPRSitePage'; +import { + PPRAdminToolbar, + PPRAnnouncement, + PPRFooter, + PPRHeader, + PPRRevisionIconsProvider, + PPRTableOfContents, +} from '@/components/SitePage/PPRSitePage'; import { shouldTrackEvents } from '@/lib/tracking'; interface SitePPRLayoutProps { @@ -26,16 +35,17 @@ export default async function SitePPRLayout({ children, }: React.PropsWithChildren) { const routeParams = await params; - const [pageParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([ - getPPRPageRouteParams(routeParams), + const [siteParams, headerParams, tableOfContentsParams, visitorAuthClaims] = await Promise.all([ + getPPRSiteRouteParams(routeParams), getPPRHeaderRouteParams(routeParams), getPPRTableOfContentsRouteParams(routeParams), // Each component holds a token narrowed to one scope, so the client claims need their union. getPPRVisitorAuthClaims(routeParams), ]); - // The layout resolves context from the same page params as PPRPageBody, so it shares its - // cache scope and its data entries rather than adding a fourth set of fetches. - const { context } = await getPPRStaticSiteContext(pageParams, 'body'); + // The layout is rendered on every request, so it only resolves site-level data — under the + // scope of the header, whose site fetch it then shares. Everything below the site level is + // delegated to the cached components in the slots. + const { context } = await getPPRStaticSiteScopeContext(siteParams, 'header'); const withTracking = shouldTrackEvents(); return ( @@ -44,18 +54,25 @@ export default async function SitePPRLayout({ bodyClassName="site-background" context={context} > - } - tableOfContentsSlot={} - // The header and table of contents are cached across pages, so the selection they - // were rendered with belongs to another page and has to be resolved on the client. - clientNavigationSelection - > - {children} - + + , + header: , + tableOfContents: , + footer: , + adminToolbar: , + }} + // The header and table of contents are cached across pages, so the selection they + // were rendered with belongs to another page and has to be resolved on the client. + clientNavigationSelection + > + {children} + + ); } diff --git a/packages/gitbook/src/app/utils.test.ts b/packages/gitbook/src/app/utils.test.ts index 26ba2f0dc..e2c55b7ae 100644 --- a/packages/gitbook/src/app/utils.test.ts +++ b/packages/gitbook/src/app/utils.test.ts @@ -11,6 +11,7 @@ mock.module('@/lib/context', () => ({ ...realContext, getBaseContext: (input: unknown) => input, fetchSiteContextByURLLookup: async (_baseContext: unknown, data: unknown) => data, + fetchSiteScopeContextByURLLookup: async (_baseContext: unknown, data: unknown) => data, })); // Stand in for the exchange endpoint, which is the only thing that can narrow the claims. It is // stubbed at the network boundary rather than with `mock.module`, which would replace @@ -38,7 +39,9 @@ const { getPPRHeaderRouteParams, getPPRPageRouteParams, getPPRRouteParams, + getPPRSiteRouteParams, getPPRStaticSiteContext, + getPPRStaticSiteScopeContext, getPPRTableOfContentsRouteParams, getPPRVisitorAuthClaims, getSiteURLDataFromParams, @@ -142,6 +145,22 @@ describe('PPR cache region params', () => { expect(headerData).toEqual(changedHeaderData); }); + it('keeps the visited location in the site params, with the header token', async () => { + const siteData = getSiteURLDataFromParams(await getPPRSiteRouteParams(routeParams)); + const headerData = getSiteURLDataFromParams(await getPPRHeaderRouteParams(routeParams)); + + // The shell renders the variant the visitor is on, it just never reads below the site level. + expect(siteData).toMatchObject({ + siteSection: 'page-site-section-id', + siteSpace: 'page-site-space-id', + space: 'space-id', + basePath: '/docs/v/page-variant/', + revision: 'ppr-revision-id', + }); + // Same scope as the header, so they share their site fetch. + expect(siteData.apiToken).toBe(headerData.apiToken); + }); + it('narrows the header token to the site scope', async () => { const { apiToken: headerToken } = getSiteURLDataFromParams( await getPPRHeaderRouteParams(routeParams) @@ -222,6 +241,20 @@ describe('getPPRVisitorAuthClaims', () => { }); }); +describe('getPPRStaticSiteScopeContext', () => { + it('resolves the site scope from the supplied params', async () => { + const { context } = await getPPRStaticSiteScopeContext( + getPPRRouteParams(routeParams), + 'header' + ); + + expect(context).toMatchObject({ + apiToken, + revision: 'ppr-revision-id', + }); + }); +}); + describe('getPPRStaticSiteContext', () => { it('uses the supplied API token without resolving published content again', async () => { const { context } = await getPPRStaticSiteContext(getPPRRouteParams(routeParams), 'body'); diff --git a/packages/gitbook/src/app/utils.ts b/packages/gitbook/src/app/utils.ts index ba39412c7..dc33e82fa 100644 --- a/packages/gitbook/src/app/utils.ts +++ b/packages/gitbook/src/app/utils.ts @@ -10,7 +10,12 @@ import { getVisitorAuthClaimsFromToken, } from '@/lib/adaptive'; import type { PPRCacheScope } from '@/lib/cache-tags'; -import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context'; +import { + type SiteURLData, + fetchSiteContextByURLLookup, + fetchSiteScopeContextByURLLookup, + getBaseContext, +} from '@/lib/context'; import { getDynamicCustomizationSettings } from '@/lib/customization'; import { PPR_TOKEN_SCOPE, type PPRTokenScope, exchangePPRToken } from '@/lib/ppr-token'; @@ -47,6 +52,33 @@ export async function getStaticSiteContext( params: RouteLayoutParams, options?: { pprScope?: PPRCacheScope } ) { + const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options); + + return { + context: await fetchSiteContextByURLLookup(baseContext, siteURLData), + visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + }; +} + +/** + * Get the site-level part of the static context, without resolving the space and its revision. + */ +export async function getStaticSiteScopeContext( + params: RouteLayoutParams, + options?: { pprScope?: PPRCacheScope } +) { + const { baseContext, siteURLData, decoded } = getStaticBaseContext(params, options); + + return { + context: await fetchSiteScopeContextByURLLookup(baseContext, siteURLData), + visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + }; +} + +/** + * Decode the params of a static route and open a base context for them. + */ +function getStaticBaseContext(params: RouteLayoutParams, options?: { pprScope?: PPRCacheScope }) { const siteURL = getSiteURLFromParams(params); const siteURLData = getSiteURLDataFromParams(params); @@ -57,19 +89,15 @@ export async function getStaticSiteContext( forbidden(); } - const context = await fetchSiteContextByURLLookup( - getBaseContext({ + return { + baseContext: getBaseContext({ siteURL, siteURLData, urlMode: getModeFromParams(params.mode), ...(options?.pprScope ? { pprScope: options.pprScope } : {}), }), - siteURLData - ); - - return { - context, - visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded), + siteURLData, + decoded, }; } @@ -182,6 +210,15 @@ export function getPPRPageRouteParams(params: PPRRouteLayoutParams): Promise { + return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.header); +} + /** * Project PPR params for the shared header by replacing page-varying location data. */ @@ -277,6 +314,16 @@ export async function getPPRStaticSiteContext(params: RouteLayoutParams, pprScop return getStaticSiteContext(params, { pprScope }); } +/** + * Get the site-level context for a PPR component, without resolving the space and its revision. + */ +export async function getPPRStaticSiteScopeContext( + params: RouteLayoutParams, + pprScope: PPRCacheScope +) { + return getStaticSiteScopeContext(params, { pprScope }); +} + function getPPRRouteParam(encodedParam: string, name: string): string { try { return decodeURIComponent(encodedParam); diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 62db370e6..f102c48e2 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -36,7 +36,7 @@ import { import './globals.css'; import { getContentLocale, getSpaceLanguage } from '@/intl/server'; import { getAssetURL } from '@/lib/assets'; -import type { GitBookAnyContext } from '@/lib/context'; +import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context'; import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env'; import { getContentInlineIconSourceRequests, @@ -73,7 +73,7 @@ export async function CustomizationRootLayout(props: { /** The class name to apply to the body element. */ bodyClassName?: string; forcedTheme?: CustomizationDefaultThemeMode | null; - context: GitBookAnyContext; + context: GitBookAnyContext | GitBookSiteScopeContext; children: React.ReactNode; }) { const { htmlClassName, bodyClassName, context, forcedTheme, children } = props; @@ -111,12 +111,15 @@ export async function CustomizationRootLayout(props: { preloadFont(headingFontData); } const iconStyle = getCustomizationIconStyle(customization); + // A site scope context has no revision: its page and tag icons are provided further down the + // tree, by a component that resolves the revision under its own cache scope. + const revision = 'revision' in context ? context.revision : null; const iconSources = await getInlineIconSources([ ...getDefaultInlineIconSourceRequests(iconStyle), ...getContentInlineIconSourceRequests({ iconStyle, - pages: context.revision.pages, - tags: context.revision.tags, + pages: revision?.pages, + tags: revision?.tags, sections: 'sections' in context ? [...(context.sections?.list ?? []), ...(context.visibleSections?.list ?? [])] diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 1e56b802c..d82084f62 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -6,15 +6,17 @@ import * as ReactDOM from 'react-dom'; import { CustomizationDefaultThemeMode } from '@gitbook/api'; import { AIContextProvider } from '../AI'; +import { Announcement } from '../Announcement'; import { RocketLoaderDetector } from './RocketLoaderDetector'; import { SiteLayoutClientContexts } from './SiteLayoutClientContexts'; import { AdminToolbar } from '@/components/AdminToolbar'; import { CookiesToast } from '@/components/Cookies'; +import { Footer } from '@/components/Footer'; import { LoadIntegrations } from '@/components/Integrations'; -import { SpaceLayout } from '@/components/SpaceLayout'; +import { SpaceHeader, SpaceLayout, SpaceTableOfContents } from '@/components/SpaceLayout'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { buildVersion } from '@/lib/build'; -import type { GitBookSiteContext } from '@/lib/context'; +import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context'; import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@/lib/env'; import { getResizedImageURL } from '@/lib/images'; import { isSiteIndexable } from '@/lib/seo'; @@ -45,29 +47,68 @@ function isDeferrableScript(script: string): boolean { } /** - * Layout when rendering a site. + * Parts of the layout that can only be rendered from a full site context, as they read the + * revision. A site scope context has to provide them itself. */ -export async function SiteLayout(props: { - context: GitBookSiteContext; +export type SiteLayoutSlots = { + announcement: React.ReactNode; + header: React.ReactNode; + tableOfContents: React.ReactNode; + footer: React.ReactNode; + adminToolbar: React.ReactNode; +}; + +/** + * Build the default slots of the layout from a full site context. + */ +export function getSiteLayoutSlots(context: GitBookSiteContext): SiteLayoutSlots { + return { + announcement: , + header: , + tableOfContents: , + footer: