Skip to content

Commit 8daede5

Browse files
authored
Ensure all content links are resolved relatively to preview (#3450)
1 parent 4c89aa6 commit 8daede5

File tree

5 files changed

+125
-3
lines changed

5 files changed

+125
-3
lines changed

.changeset/real-bags-exercise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Ensure all content links are resolved relatively to preview.

packages/gitbook/e2e/internal.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,28 @@ const testCases: TestsCase[] = [
415415
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
416416
},
417417
},
418+
{
419+
name: 'With sections',
420+
url: async () => {
421+
const data = await getSiteAPIToken('https://gitbook.com/docs');
422+
423+
const searchParams = new URLSearchParams();
424+
searchParams.set('token', data.apiToken);
425+
426+
return `url/preview/${data.site}/?${searchParams.toString()}`;
427+
},
428+
screenshot: false,
429+
run: async (page) => {
430+
const sectionTabs = page.getByLabel('Sections');
431+
await expect(sectionTabs).toBeVisible();
432+
433+
const sectionTabLinks = sectionTabs.getByRole('link');
434+
for (const link of await sectionTabLinks.all()) {
435+
const href = await link.getAttribute('href');
436+
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
437+
}
438+
},
439+
},
418440
],
419441
},
420442
{

packages/gitbook/src/lib/context.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { notFound } from 'next/navigation';
2424
import { assert } from 'ts-essentials';
2525
import { GITBOOK_URL } from './env';
2626
import { type ImageResizer, createImageResizer } from './images';
27-
import { type GitBookLinker, createLinker } from './links';
27+
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
2828

2929
/**
3030
* Data about the site URL. Provided by the middleware.
@@ -301,6 +301,9 @@ export async function fetchSiteContextByIds(
301301

302302
return {
303303
...spaceContext,
304+
linker: site.urls.published
305+
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
306+
: spaceContext.linker,
304307
organizationId: ids.organization,
305308
site,
306309
siteSpaces,

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

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from 'bun:test';
2-
import { createLinker, linkerWithAbsoluteURLs, linkerWithOtherSpaceBasePath } from './links';
2+
import {
3+
createLinker,
4+
linkerForPublishedURL,
5+
linkerWithAbsoluteURLs,
6+
linkerWithOtherSpaceBasePath,
7+
} from './links';
38

49
const root = createLinker({
510
host: 'docs.company.com',
@@ -19,6 +24,12 @@ const siteGitBookIO = createLinker({
1924
siteBasePath: '/sitename/',
2025
});
2126

27+
const preview = createLinker({
28+
host: 'preview',
29+
spaceBasePath: '/site_abc/section/space/',
30+
siteBasePath: '/site_abc/',
31+
});
32+
2233
describe('toPathInSpace', () => {
2334
it('should return the correct path', () => {
2435
expect(root.toPathInSpace('some/path')).toBe('/some/path');
@@ -88,6 +99,12 @@ describe('toLinkForContent', () => {
8899
);
89100
});
90101

102+
it('should preserve the search and hash', () => {
103+
expect(root.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe(
104+
'/some/path?a=b#c'
105+
);
106+
});
107+
91108
it('should preserve an absolute URL if the site is not the same', () => {
92109
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe(
93110
'https://org.gitbook.io/anothersite/some/path'
@@ -138,3 +155,47 @@ describe('linkerWithOtherSpaceBasePath', () => {
138155
expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path');
139156
});
140157
});
158+
159+
describe('linkerForPublishedURL', () => {
160+
describe('Root custom domain', () => {
161+
it('should rewrite links that belongs to the published site to be part of the preview site', () => {
162+
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
163+
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path')).toBe(
164+
'/site_abc/some/path'
165+
);
166+
expect(
167+
previewLinker.toLinkForContent('https://docs.company.com/section/variant/some/path')
168+
).toBe('/site_abc/section/variant/some/path');
169+
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
170+
'https://www.google.com'
171+
);
172+
});
173+
});
174+
175+
describe('gitbook.io domain', () => {
176+
it('should rewrite links that belongs to the published site to be part of the preview site', () => {
177+
const previewLinker = linkerForPublishedURL(
178+
preview,
179+
'https://org.gitbook.io/sitename/'
180+
);
181+
expect(
182+
previewLinker.toLinkForContent('https://org.gitbook.io/sitename/some/path')
183+
).toBe('/site_abc/some/path');
184+
expect(
185+
previewLinker.toLinkForContent(
186+
'https://org.gitbook.io/sitename/section/variant/some/path'
187+
)
188+
).toBe('/site_abc/section/variant/some/path');
189+
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
190+
'https://www.google.com'
191+
);
192+
});
193+
});
194+
195+
it('should should preserve hash and search', () => {
196+
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
197+
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe(
198+
'/site_abc/some/path?a=b#c'
199+
);
200+
});
201+
});

packages/gitbook/src/lib/links.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export function createLinker(
115115
// If the link points to a content in the same site, we return an absolute path
116116
// instead of a full URL; it makes it possible to use router navigation
117117
if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) {
118-
return url.pathname;
118+
return url.pathname + url.search + url.hash;
119119
}
120120

121121
return rawURL;
@@ -125,6 +125,37 @@ export function createLinker(
125125
return linker;
126126
}
127127

128+
/**
129+
* Create a new linker that intercepts links that belongs to the published site and rewrite them
130+
* relative to the URL being served.
131+
*
132+
* It is needed for preview of site where the served URL (http://preview/site_abc)
133+
* is different from the actual published site URL (https://docs.company.com).
134+
*/
135+
export function linkerForPublishedURL(linker: GitBookLinker, rawSitePublishedURL: string) {
136+
const sitePublishedURL = new URL(rawSitePublishedURL);
137+
138+
return {
139+
...linker,
140+
toLinkForContent(rawURL: string): string {
141+
const url = new URL(rawURL);
142+
143+
// If the link is part of the published site, we rewrite it to be part of the preview site.
144+
if (
145+
url.hostname === sitePublishedURL.hostname &&
146+
url.pathname.startsWith(sitePublishedURL.pathname)
147+
) {
148+
// When detecting that the url has been computed as apart of the published site,
149+
// we rewrite it to be part of the preview site.
150+
const extractedPath = url.pathname.slice(sitePublishedURL.pathname.length);
151+
return linker.toPathInSite(extractedPath) + url.search + url.hash;
152+
}
153+
154+
return linker.toLinkForContent(rawURL);
155+
},
156+
};
157+
}
158+
128159
/**
129160
* Create a new linker that always returns absolute URLs.
130161
*/

0 commit comments

Comments
 (0)