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

Diff for: .changeset/quiet-forks-occur.md

+6
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

Diff for: packages/gitbook-v2/src/lib/links.test.ts

+11
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');

Diff for: packages/gitbook-v2/src/lib/links.ts

+21-2
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 {

Diff for: packages/gitbook/e2e/internal.spec.ts

+15
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/',

Diff for: packages/gitbook/src/components/SitePage/SitePage.tsx

+2-2
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

Diff for: packages/gitbook/src/components/SitePage/fetch.ts

+28-19
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-
}

Diff for: packages/gitbook/src/lib/api.ts

+1-1
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 =

Diff for: packages/gitbook/src/lib/links.ts

+13-8
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
/**

Diff for: packages/gitbook/src/lib/paths.ts

+12-1
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+
}

Diff for: packages/gitbook/src/lib/proxy.ts

+2-2
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
}

Diff for: packages/gitbook/src/lib/v1.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
searchSiteContent,
3232
} from './api';
3333
import { getDynamicCustomizationSettings } from './customization';
34-
import { getBasePath, getHost } from './links';
34+
import { getBasePath, getHost, getSiteBasePath } from './links';
3535

3636
/*
3737
* Code that will be used until the migration to v2 is complete.
@@ -43,11 +43,12 @@ import { getBasePath, getHost } from './links';
4343
export async function getV1BaseContext(): Promise<GitBookBaseContext> {
4444
const host = await getHost();
4545
const basePath = await getBasePath();
46+
const siteBasePath = await getSiteBasePath();
4647

4748
const linker = createLinker({
4849
host,
4950
spaceBasePath: basePath,
50-
siteBasePath: basePath,
51+
siteBasePath: siteBasePath,
5152
});
5253

5354
// On V1, we use hard-navigation between different spaces because of layout issues

Diff for: packages/gitbook/src/middleware.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
normalizeVisitorAuthURL,
2929
} from '@/lib/visitor-token';
3030

31-
import { joinPath, normalizePathname } from '@/lib/paths';
31+
import { joinPath, withLeadingSlash } from '@/lib/paths';
3232
import { getProxyModeBasePath } from '@/lib/proxy';
3333
import { MiddlewareHeaders } from '@v2/lib/middleware';
3434
import { addResponseCacheTag } from './lib/cache/response';
@@ -139,7 +139,7 @@ export async function middleware(request: NextRequest) {
139139
}
140140

141141
// Because of how Next will encode, we need to encode ourselves the pathname before rewriting to it.
142-
const rewritePathname = normalizePathname(encodePathname(resolved.pathname));
142+
const rewritePathname = withLeadingSlash(encodePathname(resolved.pathname));
143143

144144
// Resolution might have changed the API endpoint
145145
apiEndpoint = resolved.apiEndpoint ?? apiEndpoint;
@@ -164,6 +164,7 @@ export async function middleware(request: NextRequest) {
164164
? getProxyModeBasePath(inputURL, resolved)
165165
: joinPath(originBasePath, resolved.basePath)
166166
);
167+
headers.set('x-gitbook-site-basepath', joinPath(originBasePath, resolved.siteBasePath));
167168
headers.set('x-gitbook-content-space', resolved.space);
168169
if ('site' in resolved) {
169170
headers.set('x-gitbook-content-organization', resolved.organization);
@@ -371,6 +372,7 @@ async function lookupSiteInSingleMode(url: URL): Promise<LookupResult> {
371372
kind: 'space',
372373
space: spaceId,
373374
basePath: '',
375+
siteBasePath: '',
374376
pathname: url.pathname,
375377
apiToken,
376378
visitorToken: undefined,
@@ -549,15 +551,15 @@ async function lookupSiteOrSpaceInMultiIdMode(
549551
};
550552
}
551553

552-
const basePath = normalizePathname(basePathParts.join('/'));
554+
const basePath = withLeadingSlash(basePathParts.join('/'));
553555
return {
554556
// In multi-id mode, complete is always considered true because there is no URL to resolve
555557
...(decoded.kind === 'site' ? { ...decoded, complete: true } : decoded),
556558
changeRequest: changeRequestId,
557559
revision: revisionId,
558560
siteBasePath: basePath,
559561
basePath,
560-
pathname: normalizePathname(pathSegments.join('/')),
562+
pathname: withLeadingSlash(pathSegments.join('/')),
561563
apiToken,
562564
apiEndpoint,
563565
contextId,
@@ -636,6 +638,7 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis
636638

637639
return {
638640
...lookup,
641+
siteBasePath: joinPath(target.host, lookup.siteBasePath),
639642
basePath: joinPath(target.host, lookup.basePath),
640643
...('basePath' in lookup && visitorAuthToken
641644
? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken)
@@ -722,6 +725,7 @@ async function lookupSiteByAPI(
722725
space: data.space,
723726
changeRequest,
724727
revision: data.revision ?? lookup.revision,
728+
siteBasePath: data.siteBasePath,
725729
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
726730
pathname: joinPath(data.pathname, alternative.extraPath),
727731
apiToken: data.apiToken,

0 commit comments

Comments
 (0)