11import { CustomizationHeaderPreset , CustomizationThemeMode } from '@gitbook/api' ;
22import { Metadata , Viewport } from 'next' ;
3+ import { headers } from 'next/headers' ;
34import { notFound , redirect } from 'next/navigation' ;
4- import React from 'react' ;
5+ import React , { Suspense } from 'react' ;
56
67import { PageAside } from '@/components/PageAside' ;
78import { PageBody , PageCover } from '@/components/PageBody' ;
9+ import { SkeletonHeading , SkeletonParagraph } from '@/components/primitives' ;
810import { PageHrefContext , absoluteHref , pageHref } from '@/lib/links' ;
911import { getPagePath , resolveFirstDocument } from '@/lib/pages' ;
1012import { ContentRefContext } from '@/lib/references' ;
@@ -17,15 +19,23 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr
1719
1820export const runtime = 'edge' ;
1921
20- /**
21- * Fetch and render a page.
22- */
23- export default async function Page ( props : {
22+ type PageProps = {
2423 params : PagePathParams ;
2524 searchParams : { fallback ?: string } ;
26- } ) {
27- const { params, searchParams } = props ;
25+ } ;
26+
27+ export default async function Page ( props : PageProps ) {
28+ // We wrap the page in Suspense to enable streaming at page level
29+ // it's only enabled in "navigation" mode
30+ return (
31+ < Suspense fallback = { < PageSkeleton /> } >
32+ < PageContent { ...props } />
33+ </ Suspense >
34+ ) ;
35+ }
2836
37+ async function PageContent ( props : PageProps ) {
38+ const data = await getPageDataWithFallback ( props , { redirectOnFallback : true } ) ;
2939 const {
3040 content : contentPointer ,
3141 contentTarget,
@@ -36,26 +46,7 @@ export default async function Page(props: {
3646 pages,
3747 page,
3848 document,
39- } = await getPageDataWithFallback ( {
40- pagePathParams : params ,
41- searchParams,
42- redirectOnFallback : true ,
43- } ) ;
44-
45- const linksContext : PageHrefContext = { } ;
46- const rawPathname = getPathnameParam ( params ) ;
47- if ( ! page ) {
48- const pathname = normalizePathname ( rawPathname ) ;
49- if ( pathname !== rawPathname ) {
50- // If the pathname was not normalized, redirect to the normalized version
51- // before trying to resolve the page again
52- redirect ( absoluteHref ( pathname ) ) ;
53- } else {
54- notFound ( ) ;
55- }
56- } else if ( getPagePath ( pages , page ) !== rawPathname ) {
57- redirect ( pageHref ( pages , page , linksContext ) ) ;
58- }
49+ } = data ;
5950
6051 const withTopHeader = customization . header . preset !== CustomizationHeaderPreset . None ;
6152 const withFullPageCover = ! ! (
@@ -78,6 +69,8 @@ export default async function Page(props: {
7869
7970 return (
8071 < >
72+ { /* Title is displayed by the browser, except in navigation mode */ }
73+ < title > { getTitle ( data ) } </ title >
8174 { withFullPageCover && page . cover ? (
8275 < PageCover as = "full" page = { page } cover = { page . cover } context = { contentRefContext } />
8376 ) : null }
@@ -117,7 +110,30 @@ export default async function Page(props: {
117110 ) ;
118111}
119112
120- export async function generateViewport ( { params } : { params : PagePathParams } ) : Promise < Viewport > {
113+ function PageSkeleton ( ) {
114+ return (
115+ < div
116+ className = { tcls (
117+ 'flex' ,
118+ 'flex-row' ,
119+ 'flex-1' ,
120+ 'relative' ,
121+ 'py-8' ,
122+ 'lg:px-16' ,
123+ 'xl:mr-56' ,
124+ 'items-center' ,
125+ 'lg:items-start' ,
126+ ) }
127+ >
128+ < div className = { tcls ( 'flex-1' , 'max-w-3xl' , 'mx-auto' , 'page-full-width:mx-0' ) } >
129+ < SkeletonHeading style = { tcls ( 'mb-8' ) } />
130+ < SkeletonParagraph style = { tcls ( 'mb-4' ) } />
131+ </ div >
132+ </ div >
133+ ) ;
134+ }
135+
136+ export async function generateViewport ( { params } : PageProps ) : Promise < Viewport > {
121137 const { customization } = await fetchPageData ( params ) ;
122138 return {
123139 colorScheme : customization . themes . toggeable
@@ -128,26 +144,25 @@ export async function generateViewport({ params }: { params: PagePathParams }):
128144 } ;
129145}
130146
131- export async function generateMetadata ( {
132- params,
133- searchParams,
134- } : {
135- params : PagePathParams ;
136- searchParams : { fallback ?: string } ;
137- } ) : Promise < Metadata > {
138- const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback ( {
139- pagePathParams : params ,
140- searchParams,
141- } ) ;
147+ function getTitle ( input : Awaited < ReturnType < typeof getPageDataWithFallback > > ) {
148+ const { page, space, customization, site } = input ;
149+ return [ page . title , getContentTitle ( space , customization , site ?? null ) ]
150+ . filter ( Boolean )
151+ . join ( ' | ' ) ;
152+ }
142153
143- if ( ! page ) {
144- notFound ( ) ;
154+ export async function generateMetadata ( props : PageProps ) : Promise < Metadata > {
155+ // We only generate metadata in navigation mode. Else we let the browser handle it.
156+ if ( await checkIsInAppNavigation ( ) ) {
157+ return { } ;
145158 }
146159
160+ const data = await getPageDataWithFallback ( props , { redirectOnFallback : false } ) ;
161+
162+ const { page, pages, space, customization, site, ancestors } = data ;
163+
147164 return {
148- title : [ page . title , getContentTitle ( space , customization , site ?? null ) ]
149- . filter ( Boolean )
150- . join ( ' | ' ) ,
165+ title : getTitle ( data ) ,
151166 description : page . description ?? '' ,
152167 alternates : {
153168 canonical : absoluteHref ( getPagePath ( pages , page ) , true ) ,
@@ -165,17 +180,30 @@ export async function generateMetadata({
165180 } ;
166181}
167182
183+ /**
184+ * Check if the navigation is in-app, meaning the user clicks on a link.
185+ */
186+ async function checkIsInAppNavigation ( ) {
187+ const headerList = await headers ( ) ;
188+ const fetchMode = headerList . get ( 'sec-fetch-mode' ) ;
189+
190+ return fetchMode === 'cors' ;
191+ }
192+
168193/**
169194 * Fetches the page data matching the requested pathname and fallback to root page when page is not found.
170195 */
171- async function getPageDataWithFallback ( args : {
172- pagePathParams : PagePathParams ;
173- searchParams : { fallback ?: string } ;
174- redirectOnFallback ?: boolean ;
175- } ) {
176- const { pagePathParams, searchParams, redirectOnFallback = false } = args ;
196+ async function getPageDataWithFallback (
197+ props : PageProps ,
198+ behaviour : {
199+ redirectOnFallback : boolean ;
200+ } ,
201+ ) {
202+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2000 ) ) ;
203+ const { params, searchParams } = props ;
204+ const { redirectOnFallback } = behaviour ;
177205
178- const { pages, page : targetPage , ...otherPageData } = await fetchPageData ( pagePathParams ) ;
206+ const { pages, page : targetPage , ...otherPageData } = await fetchPageData ( params ) ;
179207
180208 let page = targetPage ;
181209 const canFallback = ! ! searchParams . fallback ;
@@ -189,6 +217,21 @@ async function getPageDataWithFallback(args: {
189217 page = rootPage ?. page ;
190218 }
191219
220+ const linksContext : PageHrefContext = { } ;
221+ const rawPathname = getPathnameParam ( params ) ;
222+ if ( ! page ) {
223+ const pathname = normalizePathname ( rawPathname ) ;
224+ if ( pathname !== rawPathname ) {
225+ // If the pathname was not normalized, redirect to the normalized version
226+ // before trying to resolve the page again
227+ redirect ( absoluteHref ( pathname ) ) ;
228+ } else {
229+ notFound ( ) ;
230+ }
231+ } else if ( getPagePath ( pages , page ) !== rawPathname ) {
232+ redirect ( pageHref ( pages , page , linksContext ) ) ;
233+ }
234+
192235 return {
193236 ...otherPageData ,
194237 pages,
0 commit comments