1
1
import { CustomizationHeaderPreset , CustomizationThemeMode } from '@gitbook/api' ;
2
2
import { Metadata , Viewport } from 'next' ;
3
+ import { headers } from 'next/headers' ;
3
4
import { notFound , redirect } from 'next/navigation' ;
4
- import React from 'react' ;
5
+ import React , { Suspense } from 'react' ;
5
6
6
7
import { PageAside } from '@/components/PageAside' ;
7
8
import { PageBody , PageCover } from '@/components/PageBody' ;
9
+ import { SkeletonHeading , SkeletonParagraph } from '@/components/primitives' ;
8
10
import { PageHrefContext , absoluteHref , pageHref } from '@/lib/links' ;
9
11
import { getPagePath , resolveFirstDocument } from '@/lib/pages' ;
10
12
import { ContentRefContext } from '@/lib/references' ;
@@ -17,15 +19,23 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr
17
19
18
20
export const runtime = 'edge' ;
19
21
20
- /**
21
- * Fetch and render a page.
22
- */
23
- export default async function Page ( props : {
22
+ type PageProps = {
24
23
params : PagePathParams ;
25
24
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
+ }
28
36
37
+ async function PageContent ( props : PageProps ) {
38
+ const data = await getPageDataWithFallback ( props , { redirectOnFallback : true } ) ;
29
39
const {
30
40
content : contentPointer ,
31
41
contentTarget,
@@ -36,26 +46,7 @@ export default async function Page(props: {
36
46
pages,
37
47
page,
38
48
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 ;
59
50
60
51
const withTopHeader = customization . header . preset !== CustomizationHeaderPreset . None ;
61
52
const withFullPageCover = ! ! (
@@ -78,6 +69,8 @@ export default async function Page(props: {
78
69
79
70
return (
80
71
< >
72
+ { /* Title is displayed by the browser, except in navigation mode */ }
73
+ < title > { getTitle ( data ) } </ title >
81
74
{ withFullPageCover && page . cover ? (
82
75
< PageCover as = "full" page = { page } cover = { page . cover } context = { contentRefContext } />
83
76
) : null }
@@ -117,7 +110,30 @@ export default async function Page(props: {
117
110
) ;
118
111
}
119
112
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 > {
121
137
const { customization } = await fetchPageData ( params ) ;
122
138
return {
123
139
colorScheme : customization . themes . toggeable
@@ -128,26 +144,25 @@ export async function generateViewport({ params }: { params: PagePathParams }):
128
144
} ;
129
145
}
130
146
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
+ }
142
153
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 { } ;
145
158
}
146
159
160
+ const data = await getPageDataWithFallback ( props , { redirectOnFallback : false } ) ;
161
+
162
+ const { page, pages, space, customization, site, ancestors } = data ;
163
+
147
164
return {
148
- title : [ page . title , getContentTitle ( space , customization , site ?? null ) ]
149
- . filter ( Boolean )
150
- . join ( ' | ' ) ,
165
+ title : getTitle ( data ) ,
151
166
description : page . description ?? '' ,
152
167
alternates : {
153
168
canonical : absoluteHref ( getPagePath ( pages , page ) , true ) ,
@@ -165,17 +180,30 @@ export async function generateMetadata({
165
180
} ;
166
181
}
167
182
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
+
168
193
/**
169
194
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
170
195
*/
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 ;
177
205
178
- const { pages, page : targetPage , ...otherPageData } = await fetchPageData ( pagePathParams ) ;
206
+ const { pages, page : targetPage , ...otherPageData } = await fetchPageData ( params ) ;
179
207
180
208
let page = targetPage ;
181
209
const canFallback = ! ! searchParams . fallback ;
@@ -189,6 +217,21 @@ async function getPageDataWithFallback(args: {
189
217
page = rootPage ?. page ;
190
218
}
191
219
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
+
192
235
return {
193
236
...otherPageData ,
194
237
pages,
0 commit comments