Skip to content

Commit 7b8665e

Browse files
committed
Fix support for site redirects that include section/variant paths
1 parent 73e2b47 commit 7b8665e

File tree

7 files changed

+75
-30
lines changed

7 files changed

+75
-30
lines changed

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

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 {

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

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

+23-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,26 @@ 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 redirectSources = [
72+
// Test the pathname relative to the root
73+
// For example hello/world -> section/variant/hello/world
74+
withLeadingSlash(linker.toRelativePathInSite(linker.toPathInSpace(rawPathname))),
75+
// Test the pathname relative to the content/space
76+
// For example hello/world -> /hello/world
77+
withLeadingSlash(rawPathname),
78+
];
79+
for (const source of redirectSources) {
80+
const resolvedSiteRedirect = await getDataOrNull(
81+
context.dataFetcher.getSiteRedirectBySource({
82+
organizationId,
83+
siteId: site.id,
84+
source,
85+
siteShareKey: shareKey,
86+
})
87+
);
88+
if (resolvedSiteRedirect) {
89+
return redirect(resolvedSiteRedirect.target);
90+
}
8091
}
8192
}
8293

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

100111
return pathname.map((part) => decodeURIComponent(part)).join('/');
101112
}
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/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+
}

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
}

packages/gitbook/src/middleware.ts

+4-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;
@@ -549,15 +549,15 @@ async function lookupSiteOrSpaceInMultiIdMode(
549549
};
550550
}
551551

552-
const basePath = normalizePathname(basePathParts.join('/'));
552+
const basePath = withLeadingSlash(basePathParts.join('/'));
553553
return {
554554
// In multi-id mode, complete is always considered true because there is no URL to resolve
555555
...(decoded.kind === 'site' ? { ...decoded, complete: true } : decoded),
556556
changeRequest: changeRequestId,
557557
revision: revisionId,
558558
siteBasePath: basePath,
559559
basePath,
560-
pathname: normalizePathname(pathSegments.join('/')),
560+
pathname: withLeadingSlash(pathSegments.join('/')),
561561
apiToken,
562562
apiEndpoint,
563563
contextId,

0 commit comments

Comments
 (0)