Skip to content

Commit b868ecb

Browse files
committed
Fix canonical URL ending with "/"
1 parent 2b501f1 commit b868ecb

File tree

2 files changed

+56
-94
lines changed

2 files changed

+56
-94
lines changed

packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx

Lines changed: 51 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
22
import { Metadata, Viewport } from 'next';
3-
import { headers } from 'next/headers';
43
import { notFound, redirect } from 'next/navigation';
5-
import React, { Suspense } from 'react';
4+
import React from 'react';
65

76
import { PageAside } from '@/components/PageAside';
87
import { PageBody, PageCover } from '@/components/PageBody';
9-
import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives';
108
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
119
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
1210
import { ContentRefContext } from '@/lib/references';
@@ -19,23 +17,15 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr
1917

2018
export const runtime = 'edge';
2119

22-
type PageProps = {
20+
/**
21+
* Fetch and render a page.
22+
*/
23+
export default async function Page(props: {
2324
params: PagePathParams;
2425
searchParams: { fallback?: string };
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-
}
26+
}) {
27+
const { params, searchParams } = props;
3628

37-
async function PageContent(props: PageProps) {
38-
const data = await getPageDataWithFallback(props, { redirectOnFallback: true });
3929
const {
4030
content: contentPointer,
4131
contentTarget,
@@ -46,7 +36,26 @@ async function PageContent(props: PageProps) {
4636
pages,
4737
page,
4838
document,
49-
} = data;
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+
}
5059

5160
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
5261
const withFullPageCover = !!(
@@ -69,8 +78,6 @@ async function PageContent(props: PageProps) {
6978

7079
return (
7180
<>
72-
{/* Title is displayed by the browser, except in navigation mode */}
73-
<title>{getTitle(data)}</title>
7481
{withFullPageCover && page.cover ? (
7582
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
7683
) : null}
@@ -110,30 +117,7 @@ async function PageContent(props: PageProps) {
110117
);
111118
}
112119

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> {
120+
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
137121
const { customization } = await fetchPageData(params);
138122
return {
139123
colorScheme: customization.themes.toggeable
@@ -144,25 +128,26 @@ export async function generateViewport({ params }: PageProps): Promise<Viewport>
144128
};
145129
}
146130

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-
}
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+
});
153142

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 {};
143+
if (!page) {
144+
notFound();
158145
}
159146

160-
const data = await getPageDataWithFallback(props, { redirectOnFallback: false });
161-
162-
const { page, pages, space, customization, site, ancestors } = data;
163-
164147
return {
165-
title: getTitle(data),
148+
title: [page.title, getContentTitle(space, customization, site ?? null)]
149+
.filter(Boolean)
150+
.join(' | '),
166151
description: page.description ?? '',
167152
alternates: {
168153
canonical: absoluteHref(getPagePath(pages, page), true),
@@ -180,30 +165,17 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
180165
};
181166
}
182167

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-
193168
/**
194169
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
195170
*/
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;
171+
async function getPageDataWithFallback(args: {
172+
pagePathParams: PagePathParams;
173+
searchParams: { fallback?: string };
174+
redirectOnFallback?: boolean;
175+
}) {
176+
const { pagePathParams, searchParams, redirectOnFallback = false } = args;
205177

206-
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(params);
178+
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams);
207179

208180
let page = targetPage;
209181
const canFallback = !!searchParams.fallback;
@@ -217,21 +189,6 @@ async function getPageDataWithFallback(
217189
page = rootPage?.page;
218190
}
219191

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-
235192
return {
236193
...otherPageData,
237194
pages,

packages/gitbook/src/lib/links.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,14 @@ export function baseUrl(): string {
7979

8080
/**
8181
* Create an absolute href in the current content.
82+
* If it's the root URL, the trailing slash will be removed.
8283
*/
8384
export function absoluteHref(href: string, withHost: boolean = false): string {
8485
const base = withHost ? baseUrl() : basePath();
86+
if (href === '') {
87+
// Remove the trailing slash if it's the root URL
88+
return base.slice(0, -1);
89+
}
8590
return `${base}${href.startsWith('/') ? href.slice(1) : href}`;
8691
}
8792

0 commit comments

Comments
 (0)