mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Split the PPR layout context between site and revision scopes
`SitePPRLayout` resolved a full site context, which fetches the published site (site-scoped token) and the space with its revision. Only the site part is needed to render the shell, and the shell is re-rendered on every request. Split `GitBookSiteContext` into a `GitBookSiteScopeContext` holding everything derived from the published site, and the space context it is merged with. The PPR layout now resolves only the site scope, under the header scope so it shares its site fetch, and delegates the announcement, header, table of contents, footer, admin toolbar and the page/tag icons to cached components that resolve the revision under their own scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-18
@@ -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<SitePPRLayoutProps>) {
|
||||
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}
|
||||
>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
headerSlot={<PPRHeader params={headerParams} />}
|
||||
tableOfContentsSlot={<PPRTableOfContents params={tableOfContentsParams} />}
|
||||
// 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}
|
||||
</SiteLayout>
|
||||
<PPRRevisionIconsProvider params={tableOfContentsParams}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
slots={{
|
||||
announcement: <PPRAnnouncement params={tableOfContentsParams} />,
|
||||
header: <PPRHeader params={headerParams} />,
|
||||
tableOfContents: <PPRTableOfContents params={tableOfContentsParams} />,
|
||||
footer: <PPRFooter params={tableOfContentsParams} />,
|
||||
adminToolbar: <PPRAdminToolbar params={tableOfContentsParams} />,
|
||||
}}
|
||||
// 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}
|
||||
</SiteLayout>
|
||||
</PPRRevisionIconsProvider>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<Rou
|
||||
return withExchangedPPRToken(getPPRRouteParams(params), PPR_TOKEN_SCOPE.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project PPR params for the site-level shell, with a token scoped to the site claims.
|
||||
* Unlike the header params, they keep the location data of the visited page: the shell renders the
|
||||
* current variant, it just never reads anything below the site level.
|
||||
*/
|
||||
export function getPPRSiteRouteParams(params: PPRRouteLayoutParams): Promise<RouteLayoutParams> {
|
||||
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);
|
||||
|
||||
@@ -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 ?? [])]
|
||||
|
||||
@@ -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: <Announcement context={context} />,
|
||||
header: <SpaceHeader context={context} />,
|
||||
tableOfContents: <SpaceTableOfContents context={context} />,
|
||||
footer: <Footer context={context} />,
|
||||
adminToolbar: <AdminToolbar context={context} />,
|
||||
};
|
||||
}
|
||||
|
||||
type SiteLayoutProps = {
|
||||
forcedTheme?: CustomizationDefaultThemeMode | null;
|
||||
withTracking: boolean;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
children: React.ReactNode;
|
||||
headerSlot?: React.ReactNode;
|
||||
tableOfContentsSlot?: React.ReactNode;
|
||||
clientNavigationSelection?: boolean;
|
||||
}) {
|
||||
} & (
|
||||
| {
|
||||
context: GitBookSiteContext;
|
||||
/** Overrides of the slots that would otherwise be rendered from the context. */
|
||||
slots?: Partial<SiteLayoutSlots>;
|
||||
}
|
||||
| {
|
||||
context: GitBookSiteScopeContext;
|
||||
/** A site scope context can't render any of them, so they are all required. */
|
||||
slots: SiteLayoutSlots;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Layout when rendering a site.
|
||||
*/
|
||||
export async function SiteLayout(props: SiteLayoutProps) {
|
||||
const {
|
||||
context,
|
||||
forcedTheme,
|
||||
withTracking,
|
||||
visitorAuthClaims,
|
||||
children,
|
||||
headerSlot,
|
||||
tableOfContentsSlot,
|
||||
clientNavigationSelection,
|
||||
} = props;
|
||||
|
||||
// The prop type guarantees `slots` is complete whenever the context can't build them itself.
|
||||
const slots = {
|
||||
...('revision' in context ? getSiteLayoutSlots(context) : null),
|
||||
...props.slots,
|
||||
} as SiteLayoutSlots;
|
||||
|
||||
const { customization } = context;
|
||||
const { ai } = customization;
|
||||
const aiGreeting = (context.locale && ai?.localizedGreeting?.[context.locale]) ?? ai?.greeting;
|
||||
@@ -123,8 +164,10 @@ export async function SiteLayout(props: {
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
headerSlot={headerSlot}
|
||||
tableOfContentsSlot={tableOfContentsSlot}
|
||||
announcementSlot={slots.announcement}
|
||||
headerSlot={slots.header}
|
||||
tableOfContentsSlot={slots.tableOfContents}
|
||||
footerSlot={slots.footer}
|
||||
clientNavigationSelection={clientNavigationSelection}
|
||||
>
|
||||
{children}
|
||||
@@ -148,7 +191,7 @@ export async function SiteLayout(props: {
|
||||
|
||||
<RocketLoaderDetector />
|
||||
|
||||
<AdminToolbar context={context} />
|
||||
{slots.adminToolbar}
|
||||
</SiteLayoutClientContexts>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cacheLife } from 'next/cache';
|
||||
|
||||
import { IconsProvider } from '@gitbook/icons';
|
||||
|
||||
import { SitePage, generateSitePageMetadata, generateSitePageViewport } from './SitePage';
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
@@ -8,7 +10,15 @@ import {
|
||||
getPPRStaticSiteContext,
|
||||
getPagePathFromParams,
|
||||
} from '@/app/utils';
|
||||
import { AdminToolbar } from '@/components/AdminToolbar';
|
||||
import { Announcement } from '@/components/Announcement';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { SpaceHeader, SpaceTableOfContents } from '@/components/SpaceLayout';
|
||||
import {
|
||||
getContentInlineIconSourceRequests,
|
||||
getCustomizationIconStyle,
|
||||
getInlineIconSources,
|
||||
} from '@/lib/icons/inline';
|
||||
|
||||
// Each component below resolves its context under its own PPR cache scope. The scope is part of the
|
||||
// cache key of every data fetcher, so the tags they emit are scoped too and propagate up to the
|
||||
@@ -39,6 +49,67 @@ export async function PPRTableOfContents(props: { params: RouteLayoutParams }) {
|
||||
return <SpaceTableOfContents context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the announcement banner, which resolves its link against the revision.
|
||||
*/
|
||||
export async function PPRAnnouncement(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <Announcement context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the footer, whose links resolve against the revision.
|
||||
*/
|
||||
export async function PPRFooter(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <Footer context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin toolbar, which reports on the revision and its change request.
|
||||
*/
|
||||
export async function PPRAdminToolbar(props: { params: RouteLayoutParams }) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
|
||||
return <AdminToolbar context={context} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the icons of the pages and tags of the revision to the tree below.
|
||||
*
|
||||
* The header and the body both render them, so they are resolved once here rather than duplicated
|
||||
* in every per-page cache entry. The provider merges with the one of the root layout, which carries
|
||||
* the site-level icons.
|
||||
*/
|
||||
export async function PPRRevisionIconsProvider(
|
||||
props: React.PropsWithChildren<{ params: RouteLayoutParams }>
|
||||
) {
|
||||
'use cache: remote';
|
||||
cacheLife('days'); // Cache for 1 day
|
||||
|
||||
const { context } = await getPPRStaticSiteContext(props.params, 'toc');
|
||||
const iconSources = await getInlineIconSources(
|
||||
getContentInlineIconSourceRequests({
|
||||
iconStyle: getCustomizationIconStyle(context.customization),
|
||||
pages: context.revision.pages,
|
||||
tags: context.revision.tags,
|
||||
})
|
||||
);
|
||||
|
||||
return <IconsProvider iconSources={iconSources}>{props.children}</IconsProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page body independently from the shared navigation shell.
|
||||
*/
|
||||
|
||||
@@ -8,10 +8,7 @@ import type { IconName } from '@gitbook/icons';
|
||||
import { useSelectedSiteSectionId } from '../hooks';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import type {
|
||||
ClientSiteSections,
|
||||
ClientSiteStructureNode,
|
||||
} from './encodeClientSiteSections';
|
||||
import type { ClientSiteSections, ClientSiteStructureNode } from './encodeClientSiteSections';
|
||||
import { SectionIcon } from './SectionIcon';
|
||||
import { Button, Link, ToggleChevron } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat, AskAITextSelection } from '../AIChat';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { ClientNavigationSelectionProvider, CurrentContentProvider } from '../hooks';
|
||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||
@@ -20,17 +19,16 @@ import {
|
||||
} from '../SiteSections';
|
||||
import { categorizeVariants } from './categorizeVariants';
|
||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Header, HeaderLogo } from '@/components/Header';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
type SpaceLayoutProps = {
|
||||
context: GitBookSiteContext;
|
||||
context: GitBookSiteScopeContext;
|
||||
|
||||
/** Whether to enable tracking of events into site insights. */
|
||||
withTracking: boolean;
|
||||
@@ -44,12 +42,21 @@ type SpaceLayoutProps = {
|
||||
/** The children of the layout. */
|
||||
children: React.ReactNode;
|
||||
|
||||
/** Override the site header without changing the surrounding layout. */
|
||||
// The slots below all read the revision, which a site scope context doesn't carry, so they are
|
||||
// rendered by the caller rather than from `context`.
|
||||
|
||||
/** Announcement banner, rendered above the header. */
|
||||
announcementSlot?: React.ReactNode;
|
||||
|
||||
/** Site header. */
|
||||
headerSlot?: React.ReactNode;
|
||||
|
||||
/** Override the table of contents without changing the surrounding layout. */
|
||||
/** Table of contents. */
|
||||
tableOfContentsSlot?: React.ReactNode;
|
||||
|
||||
/** Site footer, rendered only when the customization asks for one. */
|
||||
footerSlot?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Resolve the selected section/space/page on the client instead of trusting the server render.
|
||||
* Set when the navigation shell comes from a cache shared across pages (PPR).
|
||||
@@ -102,7 +109,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
siteSectionId={context.sections?.current?.id ?? null}
|
||||
siteSpaceId={context.siteSpace.id}
|
||||
siteShareKey={context.shareKey ?? null}
|
||||
spaceId={context.space.id}
|
||||
spaceId={context.siteSpace.space.id}
|
||||
revisionId={context.revisionId}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
@@ -235,7 +242,8 @@ export function SpaceTableOfContents(props: { context: GitBookSiteContext }) {
|
||||
* Render the entire layout of the space (header, table of contents, footer).
|
||||
*/
|
||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const { context, children, headerSlot, tableOfContentsSlot } = props;
|
||||
const { context, children, headerSlot, tableOfContentsSlot, announcementSlot, footerSlot } =
|
||||
props;
|
||||
const { customization } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
@@ -258,8 +266,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
aiChatRenderMessageOptions={props.aiChatRenderMessageOptions}
|
||||
clientNavigationSelection={props.clientNavigationSelection}
|
||||
>
|
||||
<Announcement context={context} />
|
||||
{headerSlot ?? <SpaceHeader context={context} />}
|
||||
{announcementSlot}
|
||||
{headerSlot}
|
||||
<NavigationLoader />
|
||||
{isAIChatEnabled(customization.ai?.mode) ? (
|
||||
<>
|
||||
@@ -292,12 +300,12 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
: 'lg:min-h-screen'
|
||||
)}
|
||||
>
|
||||
{tableOfContentsSlot ?? <SpaceTableOfContents context={context} />}
|
||||
{tableOfContentsSlot}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withFooter ? <Footer context={context} /> : null}
|
||||
{withFooter ? footerSlot : null}
|
||||
</SpaceLayoutServerContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { languages } from '@/intl/translations';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import type { GitBookSiteScopeContext } from '@/lib/context';
|
||||
import { getSiteSpaceLanguages, normalizeLanguage } from '@/lib/sites';
|
||||
|
||||
/**
|
||||
* Categorize the variants of the space into generic and translation variants.
|
||||
*/
|
||||
export function categorizeVariants(context: GitBookSiteContext) {
|
||||
export function categorizeVariants(context: GitBookSiteScopeContext) {
|
||||
const { siteSpace } = context;
|
||||
|
||||
// By default, variants only include visible spaces.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isAvailableLanguage,
|
||||
loadLanguage,
|
||||
} from './translations';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type { GitBookAnyContext, GitBookSiteScopeContext } from '@/lib/context';
|
||||
|
||||
export * from './translate';
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DEFAULT_LOCALE = 'en' satisfies TranslationLocale;
|
||||
* Get the locale to use for the HTML lang attribute.
|
||||
* This returns the actual content language even if we don't have UI translations for it.
|
||||
*/
|
||||
export function getContentLocale(context: GitBookAnyContext): string {
|
||||
export function getContentLocale(context: GitBookAnyContext | GitBookSiteScopeContext): string {
|
||||
if (context.locale) {
|
||||
return context.locale;
|
||||
}
|
||||
@@ -31,7 +31,9 @@ export function getContentLocale(context: GitBookAnyContext): string {
|
||||
/**
|
||||
* Get the locale to use for a space.
|
||||
*/
|
||||
export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
|
||||
export function getSpaceLocale(
|
||||
context: GitBookAnyContext | GitBookSiteScopeContext
|
||||
): TranslationLocale {
|
||||
const customization = 'site' in context ? context.customization : null;
|
||||
|
||||
// If the language is configured in the space, use it in priority
|
||||
@@ -55,7 +57,9 @@ export function getSpaceLocale(context: GitBookAnyContext): TranslationLocale {
|
||||
/**
|
||||
* Create the translation context for a space to use in the server components.
|
||||
*/
|
||||
export async function getSpaceLanguage(context: GitBookAnyContext): Promise<TranslationLanguage> {
|
||||
export async function getSpaceLanguage(
|
||||
context: GitBookAnyContext | GitBookSiteScopeContext
|
||||
): Promise<TranslationLanguage> {
|
||||
const locale = getSpaceLocale(context);
|
||||
const language = locale === DEFAULT_LOCALE ? defaultLanguage : await loadLanguage(locale);
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import type { SiteExternalLink, SiteSection, SiteSectionGroup } from '@gitbook/api';
|
||||
import {
|
||||
type SiteExternalLink,
|
||||
type SiteSection,
|
||||
type SiteSectionGroup,
|
||||
TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { filterSectionsAndGroupsWithHiddenSiteSpaces } from './context';
|
||||
import {
|
||||
type GitBookBaseContext,
|
||||
fetchSiteContextByIds,
|
||||
fetchSiteScopeContextByIds,
|
||||
filterSectionsAndGroupsWithHiddenSiteSpaces,
|
||||
} from './context';
|
||||
import { createLinker } from './links';
|
||||
|
||||
function makeExternalLink(id: string): SiteExternalLink {
|
||||
return {
|
||||
@@ -37,3 +48,105 @@ describe('filterSectionsAndGroupsWithHiddenSiteSpaces', () => {
|
||||
expect(filterSectionsAndGroupsWithHiddenSiteSpaces([hiddenSection, link])).toEqual([link]);
|
||||
});
|
||||
});
|
||||
|
||||
const siteSpace = {
|
||||
object: 'site-space',
|
||||
id: 'site-space-id',
|
||||
title: 'Docs',
|
||||
space: { id: 'space-id', language: TranslationLanguage.Fr, revision: 'space-revision-id' },
|
||||
urls: {},
|
||||
draft: false,
|
||||
};
|
||||
|
||||
const space = {
|
||||
id: 'space-id',
|
||||
organization: 'org-id',
|
||||
language: TranslationLanguage.En,
|
||||
revision: 'space-revision-id',
|
||||
};
|
||||
const revision = { id: 'revision-id', pages: [], tags: [] };
|
||||
|
||||
function getBaseContext(): GitBookBaseContext {
|
||||
const dataFetcher = {
|
||||
getPublishedContentSite: async () => ({
|
||||
data: {
|
||||
site: { id: 'site-id', title: 'Site', urls: {} },
|
||||
structure: { type: 'siteSpaces', structure: [siteSpace] },
|
||||
customizations: { site: {}, siteSpaces: { 'site-space-id': {} } },
|
||||
scripts: [],
|
||||
},
|
||||
}),
|
||||
getSpace: async () => ({ data: space }),
|
||||
getRevision: async () => ({ data: revision }),
|
||||
};
|
||||
|
||||
return {
|
||||
dataFetcher,
|
||||
linker: createLinker({
|
||||
host: 'docs.example.com',
|
||||
siteBasePath: '/',
|
||||
spaceBasePath: '/',
|
||||
}),
|
||||
} as unknown as GitBookBaseContext;
|
||||
}
|
||||
|
||||
const ids = {
|
||||
organization: 'org-id',
|
||||
site: 'site-id',
|
||||
siteSection: undefined,
|
||||
siteSpace: 'site-space-id',
|
||||
shareKey: undefined,
|
||||
isFallback: false,
|
||||
noIndexSearch: false,
|
||||
isLoggedInVisitor: false,
|
||||
};
|
||||
|
||||
describe('fetchSiteScopeContextByIds', () => {
|
||||
it('resolves the site without reading the space or the revision', async () => {
|
||||
const context = await fetchSiteScopeContextByIds(getBaseContext(), {
|
||||
...ids,
|
||||
revision: 'revision-id',
|
||||
});
|
||||
|
||||
expect(context.site.id).toBe('site-id');
|
||||
expect(context.siteSpace.id).toBe('site-space-id');
|
||||
expect(context.revisionId).toBe('revision-id');
|
||||
// The language comes from the site structure, as the space itself is never fetched.
|
||||
expect(context.locale).toBe(TranslationLanguage.Fr);
|
||||
expect(context).not.toHaveProperty('space');
|
||||
expect(context).not.toHaveProperty('revision');
|
||||
expect(context).not.toHaveProperty('changeRequest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSiteContextByIds', () => {
|
||||
it('carries the same site data as the site scope, plus the space and the revision', async () => {
|
||||
const baseContext = getBaseContext();
|
||||
const [context, siteScopeContext] = await Promise.all([
|
||||
fetchSiteContextByIds(baseContext, {
|
||||
...ids,
|
||||
space: 'space-id',
|
||||
changeRequest: undefined,
|
||||
revision: 'revision-id',
|
||||
}),
|
||||
fetchSiteScopeContextByIds(baseContext, { ...ids, revision: 'revision-id' }),
|
||||
]);
|
||||
|
||||
const { linker: _linker, ...siteScope } = siteScopeContext;
|
||||
expect(context).toMatchObject(siteScope);
|
||||
expect(context.space).toBe(space as unknown as typeof context.space);
|
||||
expect(context.revision).toBe(revision as unknown as typeof context.revision);
|
||||
expect(context.changeRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the revision of the space when none is requested', async () => {
|
||||
const context = await fetchSiteContextByIds(getBaseContext(), {
|
||||
...ids,
|
||||
space: 'space-id',
|
||||
changeRequest: undefined,
|
||||
revision: undefined,
|
||||
});
|
||||
|
||||
expect(context.revisionId).toBe('space-revision-id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,9 +164,23 @@ export type SiteSections = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
* Site-level context: everything derived from the published site and the URL data, without the
|
||||
* space, revision or change request. It is what the PPR shell renders with, so its data only
|
||||
* depends on the site-scoped token.
|
||||
*
|
||||
* Its `linker` does not know about a custom home page (that needs the revision pages), so page
|
||||
* links must be built from the full site context. Its `dataFetcher` may hold a site-scoped token,
|
||||
* so revision data must never be fetched through it.
|
||||
*/
|
||||
export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
export type GitBookSiteScopeContext = GitBookBaseContext & {
|
||||
organizationId: string;
|
||||
|
||||
/** Identifier of the revision, as resolved by the URL lookup. */
|
||||
revisionId: string;
|
||||
|
||||
/** Share key of the space. */
|
||||
shareKey: string | undefined;
|
||||
|
||||
site: Site;
|
||||
|
||||
/** Current site space. */
|
||||
@@ -215,6 +229,11 @@ export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
revalidationId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
*/
|
||||
export type GitBookSiteContext = GitBookSpaceContext & GitBookSiteScopeContext;
|
||||
|
||||
/**
|
||||
* Context when rendering a page.
|
||||
*/
|
||||
@@ -304,41 +323,88 @@ export async function fetchSiteContextByURLLookup(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a site context by IDs.
|
||||
* Identifiers needed to resolve the site-level part of a context. They all come from the URL
|
||||
* lookup, so no space or revision is involved.
|
||||
*/
|
||||
export async function fetchSiteContextByIds(
|
||||
type SiteScopeIds = {
|
||||
organization: string;
|
||||
site: string;
|
||||
siteSection: string | undefined;
|
||||
siteSpace: string | undefined;
|
||||
shareKey: string | undefined;
|
||||
contextId?: string;
|
||||
isFallback: boolean;
|
||||
noIndexSearch: boolean;
|
||||
isLoggedInVisitor: boolean;
|
||||
displayAgentInstructions?: boolean;
|
||||
isAiAgent?: boolean;
|
||||
revalidationId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a site scope context by IDs.
|
||||
*/
|
||||
export async function fetchSiteScopeContextByIds(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: {
|
||||
organization: string;
|
||||
site: string;
|
||||
siteSection: string | undefined;
|
||||
siteSpace: string | undefined;
|
||||
space: string;
|
||||
shareKey: string | undefined;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
contextId?: string;
|
||||
isFallback: boolean;
|
||||
noIndexSearch: boolean;
|
||||
isLoggedInVisitor: boolean;
|
||||
displayAgentInstructions?: boolean;
|
||||
isAiAgent?: boolean;
|
||||
revalidationId?: string;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
ids: SiteScopeIds & { revision: string }
|
||||
): Promise<GitBookSiteScopeContext> {
|
||||
return {
|
||||
...(await resolveSiteScope(baseContext, ids)),
|
||||
revisionId: ids.revision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the site scope context of a site using the resolution of a URL.
|
||||
*/
|
||||
export async function fetchSiteScopeContextByURLLookup(
|
||||
baseContext: GitBookBaseContext,
|
||||
data: SiteURLData
|
||||
): Promise<GitBookSiteScopeContext> {
|
||||
// The revision is only resolved upstream for PPR requests, the only ones rendering a site scope.
|
||||
assert(data.revision, 'cannot resolve a site scope context without a resolved revision');
|
||||
|
||||
return fetchSiteScopeContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
shareKey: data.shareKey,
|
||||
revision: data.revision,
|
||||
contextId: data.contextId,
|
||||
isFallback: data.isFallback ?? false,
|
||||
noIndexSearch: data.noIndexSearch ?? false,
|
||||
isLoggedInVisitor: data.isLoggedInVisitor ?? false,
|
||||
displayAgentInstructions: data.displayAgentInstructions,
|
||||
isAiAgent: data.isAiAgent,
|
||||
revalidationId: data.revalidationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a site context holds that doesn't depend on the space or the revision.
|
||||
*
|
||||
* It never sets `revisionId`: the caller knows whether it comes from the URL lookup (site scope) or
|
||||
* from the resolved space context, and a key here would override the latter.
|
||||
*/
|
||||
async function resolveSiteScope(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: SiteScopeIds
|
||||
): Promise<Omit<GitBookSiteScopeContext, 'revisionId'>> {
|
||||
const { dataFetcher } = baseContext;
|
||||
|
||||
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
|
||||
await Promise.all([
|
||||
throwIfDataError(
|
||||
dataFetcher.getPublishedContentSite({
|
||||
organizationId: ids.organization,
|
||||
siteId: ids.site,
|
||||
siteShareKey: ids.shareKey,
|
||||
})
|
||||
),
|
||||
fetchSpaceContextByIds(baseContext, ids),
|
||||
]);
|
||||
const {
|
||||
site: orgSite,
|
||||
structure: siteStructure,
|
||||
customizations,
|
||||
scripts,
|
||||
} = await throwIfDataError(
|
||||
dataFetcher.getPublishedContentSite({
|
||||
organizationId: ids.organization,
|
||||
siteId: ids.site,
|
||||
siteShareKey: ids.shareKey,
|
||||
})
|
||||
);
|
||||
|
||||
const sections = ids.siteSection
|
||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||
@@ -402,7 +468,7 @@ export async function fetchSiteContextByIds(
|
||||
return siteSpaceSettings;
|
||||
}
|
||||
|
||||
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
|
||||
const logger = getLogger().subLogger('resolveSiteScope', {});
|
||||
// We got the pointer from an API and customizations from another.
|
||||
// It's possible that the two are unsynced leading to not found customizations for the space.
|
||||
// It's better to fallback on customization of the site that displaying an error.
|
||||
@@ -430,14 +496,15 @@ export async function fetchSiteContextByIds(
|
||||
};
|
||||
|
||||
const siteLinker = site.urls.published
|
||||
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
|
||||
: spaceContext.linker;
|
||||
? linkerForPublishedURL(baseContext.linker, site.urls.published)
|
||||
: baseContext.linker;
|
||||
|
||||
return {
|
||||
...spaceContext,
|
||||
locale: siteSpace.space.language ?? spaceContext.locale,
|
||||
linker: getLinkerForSiteSpace(siteLinker, siteSpace, spaceContext.revision.pages),
|
||||
...baseContext,
|
||||
locale: siteSpace.space.language,
|
||||
linker: siteLinker,
|
||||
organizationId: ids.organization,
|
||||
shareKey: ids.shareKey,
|
||||
site,
|
||||
siteSpaces,
|
||||
visibleSiteSpaces,
|
||||
@@ -457,6 +524,34 @@ export async function fetchSiteContextByIds(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a site context by IDs.
|
||||
*/
|
||||
export async function fetchSiteContextByIds(
|
||||
baseContext: GitBookBaseContext,
|
||||
ids: SiteScopeIds & {
|
||||
space: string;
|
||||
changeRequest: string | undefined;
|
||||
revision: string | undefined;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const [siteScope, spaceContext] = await Promise.all([
|
||||
resolveSiteScope(baseContext, ids),
|
||||
fetchSpaceContextByIds(baseContext, ids),
|
||||
]);
|
||||
|
||||
return {
|
||||
...spaceContext,
|
||||
...siteScope,
|
||||
locale: siteScope.siteSpace.space.language ?? spaceContext.locale,
|
||||
linker: getLinkerForSiteSpace(
|
||||
siteScope.linker,
|
||||
siteScope.siteSpace,
|
||||
spaceContext.revision.pages
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context scoped to a specific site space.
|
||||
* This keeps the site structure from the current context while resolving content
|
||||
|
||||
Reference in New Issue
Block a user