From 8daede542772250dfe404762bb95eda761ad8544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Tue, 8 Jul 2025 13:05:31 +0200 Subject: [PATCH] Ensure all content links are resolved relatively to preview (#3450) --- .changeset/real-bags-exercise.md | 5 ++ packages/gitbook/e2e/internal.spec.ts | 22 +++++++++ packages/gitbook/src/lib/context.ts | 5 +- packages/gitbook/src/lib/links.test.ts | 63 +++++++++++++++++++++++++- packages/gitbook/src/lib/links.ts | 33 +++++++++++++- 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 .changeset/real-bags-exercise.md diff --git a/.changeset/real-bags-exercise.md b/.changeset/real-bags-exercise.md new file mode 100644 index 000000000..d3812b764 --- /dev/null +++ b/.changeset/real-bags-exercise.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Ensure all content links are resolved relatively to preview. diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 577fa2496..002fb36ff 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -415,6 +415,28 @@ const testCases: TestsCase[] = [ await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible(); }, }, + { + name: 'With sections', + url: async () => { + const data = await getSiteAPIToken('https://gitbook.com/docs'); + + const searchParams = new URLSearchParams(); + searchParams.set('token', data.apiToken); + + return `url/preview/${data.site}/?${searchParams.toString()}`; + }, + screenshot: false, + run: async (page) => { + const sectionTabs = page.getByLabel('Sections'); + await expect(sectionTabs).toBeVisible(); + + const sectionTabLinks = sectionTabs.getByRole('link'); + for (const link of await sectionTabLinks.all()) { + const href = await link.getAttribute('href'); + expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/); + } + }, + }, ], }, { diff --git a/packages/gitbook/src/lib/context.ts b/packages/gitbook/src/lib/context.ts index 162ea03a1..7332da1f1 100644 --- a/packages/gitbook/src/lib/context.ts +++ b/packages/gitbook/src/lib/context.ts @@ -24,7 +24,7 @@ import { notFound } from 'next/navigation'; import { assert } from 'ts-essentials'; import { GITBOOK_URL } from './env'; import { type ImageResizer, createImageResizer } from './images'; -import { type GitBookLinker, createLinker } from './links'; +import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links'; /** * Data about the site URL. Provided by the middleware. @@ -301,6 +301,9 @@ export async function fetchSiteContextByIds( return { ...spaceContext, + linker: site.urls.published + ? linkerForPublishedURL(spaceContext.linker, site.urls.published) + : spaceContext.linker, organizationId: ids.organization, site, siteSpaces, diff --git a/packages/gitbook/src/lib/links.test.ts b/packages/gitbook/src/lib/links.test.ts index 4d5199220..8430a69cd 100644 --- a/packages/gitbook/src/lib/links.test.ts +++ b/packages/gitbook/src/lib/links.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'bun:test'; -import { createLinker, linkerWithAbsoluteURLs, linkerWithOtherSpaceBasePath } from './links'; +import { + createLinker, + linkerForPublishedURL, + linkerWithAbsoluteURLs, + linkerWithOtherSpaceBasePath, +} from './links'; const root = createLinker({ host: 'docs.company.com', @@ -19,6 +24,12 @@ const siteGitBookIO = createLinker({ siteBasePath: '/sitename/', }); +const preview = createLinker({ + host: 'preview', + spaceBasePath: '/site_abc/section/space/', + siteBasePath: '/site_abc/', +}); + describe('toPathInSpace', () => { it('should return the correct path', () => { expect(root.toPathInSpace('some/path')).toBe('/some/path'); @@ -88,6 +99,12 @@ describe('toLinkForContent', () => { ); }); + it('should preserve the search and hash', () => { + expect(root.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe( + '/some/path?a=b#c' + ); + }); + it('should preserve an absolute URL if the site is not the same', () => { expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe( 'https://org.gitbook.io/anothersite/some/path' @@ -138,3 +155,47 @@ describe('linkerWithOtherSpaceBasePath', () => { expect(otherSpaceBasePathLinker.toPathInSpace('some/path')).toBe('/sitename/a/b/some/path'); }); }); + +describe('linkerForPublishedURL', () => { + describe('Root custom domain', () => { + it('should rewrite links that belongs to the published site to be part of the preview site', () => { + const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/'); + expect(previewLinker.toLinkForContent('https://docs.company.com/some/path')).toBe( + '/site_abc/some/path' + ); + expect( + previewLinker.toLinkForContent('https://docs.company.com/section/variant/some/path') + ).toBe('/site_abc/section/variant/some/path'); + expect(previewLinker.toLinkForContent('https://www.google.com')).toBe( + 'https://www.google.com' + ); + }); + }); + + describe('gitbook.io domain', () => { + it('should rewrite links that belongs to the published site to be part of the preview site', () => { + const previewLinker = linkerForPublishedURL( + preview, + 'https://org.gitbook.io/sitename/' + ); + expect( + previewLinker.toLinkForContent('https://org.gitbook.io/sitename/some/path') + ).toBe('/site_abc/some/path'); + expect( + previewLinker.toLinkForContent( + 'https://org.gitbook.io/sitename/section/variant/some/path' + ) + ).toBe('/site_abc/section/variant/some/path'); + expect(previewLinker.toLinkForContent('https://www.google.com')).toBe( + 'https://www.google.com' + ); + }); + }); + + it('should should preserve hash and search', () => { + const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/'); + expect(previewLinker.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe( + '/site_abc/some/path?a=b#c' + ); + }); +}); diff --git a/packages/gitbook/src/lib/links.ts b/packages/gitbook/src/lib/links.ts index 3825cf492..fb560f025 100644 --- a/packages/gitbook/src/lib/links.ts +++ b/packages/gitbook/src/lib/links.ts @@ -115,7 +115,7 @@ export function createLinker( // If the link points to a content in the same site, we return an absolute path // instead of a full URL; it makes it possible to use router navigation if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) { - return url.pathname; + return url.pathname + url.search + url.hash; } return rawURL; @@ -125,6 +125,37 @@ export function createLinker( return linker; } +/** + * Create a new linker that intercepts links that belongs to the published site and rewrite them + * relative to the URL being served. + * + * It is needed for preview of site where the served URL (http://preview/site_abc) + * is different from the actual published site URL (https://docs.company.com). + */ +export function linkerForPublishedURL(linker: GitBookLinker, rawSitePublishedURL: string) { + const sitePublishedURL = new URL(rawSitePublishedURL); + + return { + ...linker, + toLinkForContent(rawURL: string): string { + const url = new URL(rawURL); + + // If the link is part of the published site, we rewrite it to be part of the preview site. + if ( + url.hostname === sitePublishedURL.hostname && + url.pathname.startsWith(sitePublishedURL.pathname) + ) { + // When detecting that the url has been computed as apart of the published site, + // we rewrite it to be part of the preview site. + const extractedPath = url.pathname.slice(sitePublishedURL.pathname.length); + return linker.toPathInSite(extractedPath) + url.search + url.hash; + } + + return linker.toLinkForContent(rawURL); + }, + }; +} + /** * Create a new linker that always returns absolute URLs. */