Skip to content

Commit bba2e52

Browse files
authored
Fix support for site redirects that include section/variant paths (#3024)
1 parent 54ee014 commit bba2e52

File tree

12 files changed

+122
-41
lines changed

12 files changed

+122
-41
lines changed

.changeset/quiet-forks-occur.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"gitbook-v2": patch
3+
"gitbook": patch
4+
---
5+
6+
Fix site redirects when it includes a section/variant path

packages/gitbook-v2/src/lib/links.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ describe('toPathInSite', () => {
3838
});
3939
});
4040

41+
describe('toRelativePathInSite', () => {
42+
it('should return the correct path', () => {
43+
expect(root.toRelativePathInSite('/some/path')).toBe('some/path');
44+
expect(siteGitBookIO.toRelativePathInSite('/sitename/some/path')).toBe('some/path');
45+
});
46+
47+
it('should preserve absolute paths outside of the site', () => {
48+
expect(siteGitBookIO.toRelativePathInSite('/outside/some/path')).toBe('/outside/some/path');
49+
});
50+
});
51+
4152
describe('toAbsoluteURL', () => {
4253
it('should return the correct path', () => {
4354
expect(root.toAbsoluteURL('some/path')).toBe('https://docs.company.com/some/path');

packages/gitbook-v2/src/lib/links.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getPagePath } from '@/lib/pages';
2+
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
23
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
34
import warnOnce from 'warn-once';
45

@@ -25,6 +26,11 @@ export interface GitBookLinker {
2526
*/
2627
toPathInSite(relativePath: string): string;
2728

29+
/**
30+
* Transform an absolute path in a site, to a relative path from the root of the site.
31+
*/
32+
toRelativePathInSite(absolutePath: string): string;
33+
2834
/**
2935
* Generate an absolute path for a page in the current content.
3036
* The result should NOT be passed to `toPathInContent`.
@@ -64,13 +70,26 @@ export function createLinker(
6470
): GitBookLinker {
6571
warnOnce(!servedOn.host, 'No host provided to createLinker. It can lead to issues with links.');
6672

73+
const siteBasePath = withTrailingSlash(withLeadingSlash(servedOn.siteBasePath));
74+
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
75+
6776
const linker: GitBookLinker = {
6877
toPathInSpace(relativePath: string): string {
69-
return joinPaths(servedOn.spaceBasePath, relativePath);
78+
return joinPaths(spaceBasePath, relativePath);
7079
},
7180

7281
toPathInSite(relativePath: string): string {
73-
return joinPaths(servedOn.siteBasePath, relativePath);
82+
return joinPaths(siteBasePath, relativePath);
83+
},
84+
85+
toRelativePathInSite(absolutePath: string): string {
86+
const normalizedPath = withLeadingSlash(absolutePath);
87+
88+
if (!normalizedPath.startsWith(servedOn.siteBasePath)) {
89+
return normalizedPath;
90+
}
91+
92+
return normalizedPath.slice(servedOn.siteBasePath.length);
7493
},
7594

7695
toAbsoluteURL(absolutePath: string): string {

packages/gitbook/e2e/internal.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,21 @@ const testCases: TestsCase[] = [
808808
},
809809
],
810810
},
811+
{
812+
name: 'Site Redirects with sections',
813+
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
814+
tests: [
815+
{
816+
// This test that a redirect that incudes a section path works
817+
name: 'Redirect to Quickstart page',
818+
url: 'sections-2/redirect-test',
819+
run: async (page) => {
820+
await expect(page.locator('h1')).toHaveText('Quickstart');
821+
},
822+
screenshot: false,
823+
},
824+
],
825+
},
811826
{
812827
name: 'Share links',
813828
contentBaseURL: 'https://gitbook.gitbook.io/gbo-tests-share-links/',

packages/gitbook/src/components/SitePage/SitePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
1212

1313
import { getResizedImageURL } from '@v2/lib/images';
1414
import { PageClientLayout } from './PageClientLayout';
15-
import { type PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from './fetch';
15+
import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch';
1616

1717
export const runtime = 'edge';
1818
export const dynamic = 'force-dynamic';
@@ -33,7 +33,7 @@ export async function SitePage(props: SitePageProps) {
3333

3434
const rawPathname = getPathnameParam(props.pageParams);
3535
if (!pageTarget) {
36-
const pathname = normalizePathname(rawPathname);
36+
const pathname = rawPathname.toLowerCase();
3737
if (pathname !== rawPathname) {
3838
// If the pathname was not normalized, redirect to the normalized version
3939
// before trying to resolve the page again

packages/gitbook/src/components/SitePage/fetch.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { GitBookSiteContext } from '@v2/lib/context';
22
import { redirect } from 'next/navigation';
33

44
import { resolvePageId, resolvePagePath } from '@/lib/pages';
5+
import { withLeadingSlash } from '@/lib/paths';
56
import { getDataOrNull } from '@v2/lib/data';
67

78
export interface PagePathParams {
@@ -35,14 +36,14 @@ export async function fetchPageData(context: GitBookSiteContext, params: PagePar
3536
* If the path can't be found, we try to resolve it from the API to handle redirects.
3637
*/
3738
async function resolvePage(context: GitBookSiteContext, params: PagePathParams | PageIdParams) {
38-
const { organizationId, site, space, revisionId, pages, shareKey } = context;
39+
const { organizationId, site, space, revisionId, pages, shareKey, linker } = context;
3940

4041
if ('pageId' in params) {
4142
return resolvePageId(pages, params.pageId);
4243
}
4344

4445
const rawPathname = getPathnameParam(params);
45-
const pathname = normalizePathname(rawPathname);
46+
const pathname = rawPathname.toLowerCase();
4647

4748
// When resolving a page, we use the lowercased pathname
4849
const page = resolvePagePath(pages, pathname);
@@ -67,16 +68,31 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
6768
}
6869

6970
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
70-
const resolvedSiteRedirect = await getDataOrNull(
71-
context.dataFetcher.getSiteRedirectBySource({
72-
organizationId,
73-
siteId: site.id,
74-
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
75-
siteShareKey: shareKey,
76-
})
77-
);
78-
if (resolvedSiteRedirect) {
79-
return redirect(resolvedSiteRedirect.target);
71+
const redirectPathname = withLeadingSlash(rawPathname);
72+
if (/^\/[a-zA-Z0-9-_.\/]+[a-zA-Z0-9-_.]$/.test(redirectPathname)) {
73+
const redirectSources = new Set<string>([
74+
// Test the pathname relative to the root
75+
// For example hello/world -> section/variant/hello/world
76+
withLeadingSlash(
77+
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname))
78+
),
79+
// Test the pathname relative to the content/space
80+
// For example hello/world -> /hello/world
81+
redirectPathname,
82+
]);
83+
for (const source of redirectSources) {
84+
const resolvedSiteRedirect = await getDataOrNull(
85+
context.dataFetcher.getSiteRedirectBySource({
86+
organizationId,
87+
siteId: site.id,
88+
source,
89+
siteShareKey: shareKey,
90+
})
91+
);
92+
if (resolvedSiteRedirect) {
93+
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
94+
}
95+
}
8096
}
8197
}
8298

@@ -99,10 +115,3 @@ export function getPathnameParam(params: PagePathParams): string {
99115

100116
return pathname.map((part) => decodeURIComponent(part)).join('/');
101117
}
102-
103-
/**
104-
* Normalize the URL pathname into the format used in the revision page path.
105-
*/
106-
export function normalizePathname(pathname: string) {
107-
return pathname.toLowerCase();
108-
}

packages/gitbook/src/lib/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function withAPI<T>(client: GitBookAPIContext, fn: () => Promise<T>): Pro
153153

154154
type SpaceContentLookup = Pick<
155155
PublishedSiteContent,
156-
'space' | 'changeRequest' | 'revision' | 'pathname' | 'basePath' | 'apiToken'
156+
'space' | 'changeRequest' | 'revision' | 'pathname' | 'basePath' | 'siteBasePath' | 'apiToken'
157157
> & { kind: 'space' };
158158

159159
export type PublishedContentWithCache =

packages/gitbook/src/lib/links.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { headers } from 'next/headers';
1010

1111
import { GITBOOK_APP_URL } from '@v2/lib/env';
1212
import { getPagePath } from './pages';
13+
import { withLeadingSlash, withTrailingSlash } from './paths';
1314
import { assertIsNotV2 } from './v2';
1415

1516
export interface PageHrefContext {
@@ -27,17 +28,21 @@ export interface PageHrefContext {
2728
export async function getBasePath(): Promise<string> {
2829
assertIsNotV2();
2930
const headersList = await headers();
30-
let path = headersList.get('x-gitbook-basepath') ?? '/';
31+
const path = headersList.get('x-gitbook-basepath') ?? '/';
3132

32-
if (!path.startsWith('/')) {
33-
path = `/${path}`;
34-
}
33+
return withTrailingSlash(withLeadingSlash(path));
34+
}
3535

36-
if (!path.endsWith('/')) {
37-
path = `${path}/`;
38-
}
36+
/**
37+
* Return the site base path for the current request.
38+
* The value will start and finish with /
39+
*/
40+
export async function getSiteBasePath(): Promise<string> {
41+
assertIsNotV2();
42+
const headersList = await headers();
43+
const path = headersList.get('x-gitbook-site-basepath') ?? '/';
3944

40-
return path;
45+
return withTrailingSlash(withLeadingSlash(path));
4146
}
4247

4348
/**

packages/gitbook/src/lib/paths.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,21 @@ export function removeLeadingSlash(path: string): string {
2222
/**
2323
* Normalize a pathname to make it start with a slash
2424
*/
25-
export function normalizePathname(pathname: string): string {
25+
export function withLeadingSlash(pathname: string): string {
2626
if (!pathname.startsWith('/')) {
2727
pathname = `/${pathname}`;
2828
}
2929

3030
return pathname;
3131
}
32+
33+
/**
34+
* Normalize a pathname to make it end with a slash
35+
*/
36+
export function withTrailingSlash(pathname: string): string {
37+
if (!pathname.endsWith('/')) {
38+
pathname = `${pathname}/`;
39+
}
40+
41+
return pathname;
42+
}

packages/gitbook/src/lib/proxy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PublishedSiteContent } from '@gitbook/api';
2-
import { joinPath, normalizePathname, removeTrailingSlash } from './paths';
2+
import { joinPath, removeTrailingSlash, withLeadingSlash } from './paths';
33

44
/**
55
* Compute the final base path for a site served in proxy mode.
@@ -16,6 +16,6 @@ export function getProxyModeBasePath(
1616
.replace(removeTrailingSlash(resolved.pathname), '')
1717
.replace(removeTrailingSlash(resolved.basePath), '');
1818

19-
const result = joinPath(normalizePathname(proxySitePath), resolved.basePath);
19+
const result = joinPath(withLeadingSlash(proxySitePath), resolved.basePath);
2020
return result.endsWith('/') ? result : `${result}/`;
2121
}

0 commit comments

Comments
 (0)