Site Preview URLs (#4096)

This commit is contained in:
Taran Vohra
2026-03-12 20:27:59 +05:30
committed by GitHub
parent 0f62a629b4
commit 45d32b5084
10 changed files with 253 additions and 41 deletions
-5
View File
@@ -136,7 +136,6 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-vercel
visual-testing-v2-cloudflare:
@@ -161,7 +160,6 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: v2-cloudflare
visual-testing-customers-v2:
@@ -186,7 +184,6 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-vercel
visual-testing-customers-v2-cloudflare:
@@ -211,7 +208,6 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: customers-v2-cloudflare
browserless-testing-v2-vercel:
@@ -235,7 +231,6 @@ jobs:
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
+2 -2
View File
@@ -7,8 +7,8 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.13",
"vercel": "^50.26.1",
"turbo": "^2.8.16",
"vercel": "^50.31.1",
},
},
"packages/browser-types": {
+134 -1
View File
@@ -16,7 +16,7 @@ import jwt from 'jsonwebtoken';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
import { getSiteAPIToken } from '../tests/utils';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
type Test,
type TestsCase,
@@ -754,6 +754,139 @@ const testCases: TestsCase[] = [
}
},
},
{
name: 'With customization cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
},
screenshot: false,
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
// Go to another page with the customization query to disable the trademark
const pageBlocks = new URL(page.url());
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
pageBlocks.search = getCustomizationURL({
trademark: {
enabled: false,
},
}).slice(1);
await page.goto(pageBlocks.toString());
// No trademark because customization is disabled
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Blocks' })
).toBeVisible();
const pageBlocksCode = new URL(page.url());
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
pageBlocksCode.search = '';
await page.goto(pageBlocksCode.toString());
// The trademark should not be visible because the cookie is still set,
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Code' })
).toBeVisible();
},
},
],
},
{
name: 'Site Previews',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
tests: [
{
name: 'Main content',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
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/${getGitBookPreviewURL(`${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?.includes('/preview/site_p4Xo4')).toBeTruthy();
}
},
},
{
name: 'With customization cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
// Go to another page with the customization query to disable the trademark
const pageBlocks = new URL(page.url());
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
pageBlocks.search = getCustomizationURL({
trademark: {
enabled: false,
},
}).slice(1);
await page.goto(pageBlocks.toString());
// No trademark because customization is disabled
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Blocks' })
).toBeVisible();
const pageBlocksCode = new URL(page.url());
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
pageBlocksCode.search = '';
await page.goto(pageBlocksCode.toString());
// The trademark should not be visible because the cookie is still set,
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Code' })
).toBeVisible();
},
},
],
},
{
+1
View File
@@ -27,6 +27,7 @@ const nextConfig = {
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
@@ -25,6 +25,7 @@ export function Trademark(props: {
return (
<Button
data-testid="gb-trademark"
target="_blank"
variant="secondary"
size="large"
+1
View File
@@ -45,6 +45,7 @@ export type SiteURLData = Pick<
| 'siteBasePath'
| 'basePath'
| 'contextId'
| 'preview'
> & {
/**
* Identifier used for image resizing.
+6
View File
@@ -64,6 +64,12 @@ export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT || 'GitBook-Ope
export const GITBOOK_OAUTH_SERVER_URL =
process.env.GITBOOK_OAUTH_SERVER_URL || 'https://sites.gitbook.com/oauth2/v1';
/**
* Base URL for GitBook preview requests.
*/
export const GITBOOK_PREVIEW_BASE_URL =
process.env.GITBOOK_PREVIEW_BASE_URL || 'https://sites.gitbook.com/preview/';
/**
* Whether to disable tracking of events into site insights.
* This is used to disable tracking in development mode.
+62 -1
View File
@@ -1,13 +1,74 @@
import type { PublishedSiteContent } from '@gitbook/api';
import assertNever from 'assert-never';
import { assert } from 'ts-essentials';
import { GITBOOK_PREVIEW_BASE_URL } from './env';
import type { ResponseCookie } from './visitors';
/**
* Check if the request to the site is a preview request.
*/
export function isPreviewRequest(requestURL: URL): boolean {
return requestURL.host === 'preview';
// TODO: Remove the `requestURL.host === 'preview'` condition once we have fully migrated to the new preview URL structure.
return (
requestURL.host === 'preview' || requestURL.toString().startsWith(GITBOOK_PREVIEW_BASE_URL)
);
}
export function getPreviewRequestIdentifier(requestURL: URL): string {
if (requestURL.toString().startsWith(GITBOOK_PREVIEW_BASE_URL)) {
const siteIdentifier = requestURL.pathname.split('/').filter(Boolean)[1];
assert(siteIdentifier, 'Expected site identifier in preview URL');
return siteIdentifier;
}
// TODO: Remove the `requestURL.host === 'preview'` condition once we have fully migrated to the new preview URL structure.
// For preview requests, we extract the site ID from the pathname
// e.g. https://preview/site_id/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname[0]!;
}
/**
* Get a cookie for the preview request.
*/
export function getPreviewCookieResponse(args: {
name: string;
value: string;
mode: 'url' | 'url-host';
siteRequestURL: URL;
siteURLData: PublishedSiteContent;
}): ResponseCookie {
const { name, value, mode, siteRequestURL, siteURLData } = args;
// Only send the cookie to preview routes and scope it to the specific site
// to avoid conflicts between different sites previews potentially opened at the same time.
const path = (() => {
switch (mode) {
case 'url': {
const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL);
const gitbookPreviewHost =
gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, '');
// TODO: Remove support for 'preview' hostnames later.
const host =
siteRequestURL.hostname === 'preview'
? siteRequestURL.hostname
: gitbookPreviewHost;
return `/url/${host}/${getPreviewRequestIdentifier(siteRequestURL)}`;
}
case 'url-host':
return siteURLData.siteBasePath;
default:
assertNever(mode);
}
})();
return {
name,
value,
options: {
httpOnly: true,
sameSite: 'lax',
maxAge: 10 * 60, // 10 minutes
path,
},
};
}
+38 -32
View File
@@ -4,6 +4,7 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import rison from 'rison';
import type { SiteURLData } from '@/lib/context';
import { getContentSecurityPolicy } from '@/lib/csp';
import { validateSerializedCustomization } from '@/lib/customization';
import {
@@ -17,7 +18,16 @@ import {
import { GITBOOK_OAUTH_SERVER_URL, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
import { getImageResizingContextId } from '@/lib/images';
import { MiddlewareHeaders } from '@/lib/middleware';
import {
handleUnauthedOAuthProtectedResourceRequest,
isOAuthProtectedResourceRequest,
} from '@/lib/oauth-protected';
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
import {
getPreviewCookieResponse,
getPreviewRequestIdentifier,
isPreviewRequest,
} from '@/lib/preview';
import {
type ResponseCookies,
getPathScopedCookieName,
@@ -28,12 +38,6 @@ import {
} from '@/lib/visitors';
import { serveResizedImage } from '@/routes/image';
import { cookies } from 'next/headers';
import type { SiteURLData } from './lib/context';
import {
handleUnauthedOAuthProtectedResourceRequest,
isOAuthProtectedResourceRequest,
} from './lib/oauth-protected';
import { getPreviewRequestIdentifier } from './lib/preview';
import { serveProxyAnalyticsEvent } from './lib/tracking';
export const config = {
matcher: [
@@ -340,6 +344,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
preview: siteURLData.preview,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
contextId: siteURLData.contextId,
@@ -364,18 +369,17 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
routeType = 'dynamic';
// We need to encode the customization headers, otherwise it will fail for some customization values containing non ASCII chars on vercel.
requestHeaders.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
cookies.push({
name: MiddlewareHeaders.Customization,
value: encodeURIComponent(customization),
options: {
httpOnly: true,
sameSite: 'lax',
maxAge: 10 * 60, // 10 minutes
// Only send the cookie to preview routes and scope it to the specific site
// to avoid conflicts between different sites previews potentially opened at the same time.
path: `/url/preview/${getPreviewRequestIdentifier(siteRequestURL)}`,
},
});
if (siteURLData.preview) {
cookies.push(
getPreviewCookieResponse({
name: MiddlewareHeaders.Customization,
value: encodeURIComponent(customization),
mode,
siteRequestURL,
siteURLData,
})
);
}
}
const theme =
siteRequestURL.searchParams.get('theme') ??
@@ -383,16 +387,17 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme);
cookies.push({
name: MiddlewareHeaders.Theme,
value: theme,
options: {
httpOnly: true,
sameSite: 'lax',
maxAge: 10 * 60, // 10 minutes
path: '/url/preview', // Only send the cookie to preview routes
},
});
if (siteURLData.preview) {
cookies.push(
getPreviewCookieResponse({
name: MiddlewareHeaders.Theme,
value: theme,
mode,
siteRequestURL,
siteURLData,
})
);
}
}
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
@@ -462,14 +467,15 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return writeResponseCookies(response, cookies);
};
// For https://preview/<siteURL> requests,
if (siteRequestURL.hostname === 'preview') {
// For preview requests like:
// - https://preview/<siteURL> requests, TODO: Remove support for this format later
// - https://<GITBOOK_PREVIEW_BASE_URL>/<siteID> requests (ex: https://sites.gitbook.com/preview/site_id/path)
if (isPreviewRequest(siteRequestURL)) {
// Do not track page views for preview requests
request.headers.set('x-gitbook-disable-tracking', 'true');
return serveWithQueryAPIToken(
// We scope the API token to the site ID.
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'),
request,
withAPIToken
);
+8
View File
@@ -45,6 +45,14 @@ export function getTestURL(urlRest: string): string {
return url.toString();
}
/**
* Get the upstream GitBook preview URL for a preview route.
*/
export function getGitBookPreviewURL(urlRest: string): string {
const url = new URL(urlRest, 'https://sites.gitbook.com/preview/');
return url.href.replace(`${url.protocol}//`, '');
}
/**
* Get an API token for a site by its URL.
*/