Compare commits

...

10 Commits

34 changed files with 934 additions and 288 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Use localized titles/descriptions for site entities
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Import site redirect patterns from @gitbook/api
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Support x-gitbook-token-placeholder as TryIt prefill fallback for HTTP auth (basic and bearer)
-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)
+5 -4
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": {
@@ -171,6 +171,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
@@ -348,7 +349,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.167.0",
"@gitbook/api": "0.169.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -745,7 +746,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.167.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9A9Bsgi2aRbGf3DPejK4YQh9M/+grtNymFwUkK3B3N4m7VOxcUMQ2HaV7s51mtrBdWKcEdeFm4LEnEaAGHcM1A=="],
"@gitbook/api": ["@gitbook/api@0.169.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-kzJT8P5HCnqeYG3kFmBfvKMjQhTuUVmNV5Wvyh87QnoOA3odwh2C3qONpTmBA6cNE3ymDnxs8SJT9FG/qquRZQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.167.0",
"@gitbook/api": "0.169.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+181 -2
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,
@@ -709,6 +709,26 @@ const testCases: TestsCase[] = [
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
},
},
{
name: 'Show error when missing token',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
// Intentionally not setting the token to test error handling when the token is missing
const searchParams = new URLSearchParams();
searchParams.set('limit', '10');
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
},
screenshot: false,
run: async (page, response) => {
expect(response).not.toBeNull();
expect(response?.status()).toBe(400);
await expect(page.getByText('Missing API token')).toBeVisible();
},
},
],
},
{
@@ -754,6 +774,157 @@ 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();
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
const data = await getSiteAPIToken('https://gitbook.com/docs');
const searchParams = new URLSearchParams();
// Intentionally not setting the token to test redirection for authentication
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
await page.waitForURL(
(url) =>
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
);
},
},
],
},
{
@@ -1337,13 +1508,21 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to SSO page',
name: 'Basic redirect',
url: 'a/redirect/to/sso',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
screenshot: false,
},
{
name: 'Complex wildcard with special characters',
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SEO');
},
screenshot: false,
},
],
},
{
+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,
+1
View File
@@ -63,6 +63,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
@@ -9,7 +9,7 @@ import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
import type {
SearchPageResult,
SearchSpaceResult,
@@ -108,6 +108,7 @@ function transformSitePageResult(
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const currentLanguage = siteSpace?.space.language;
const page: ComputedPageResult = {
type: 'page',
@@ -121,11 +122,11 @@ function transformSitePageResult(
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
label: getLocalizedTitle(siteSection, currentLanguage),
},
(siteSection?.siteSpaces?.filter(
(space) =>
@@ -134,7 +135,7 @@ function transformSitePageResult(
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: siteSpace.title,
label: getLocalizedTitle(siteSpace, currentLanguage),
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
@@ -41,14 +41,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
ref={ref}
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
style
)}
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
<div className="flex items-center justify-start gap-2 text-sm">
{title ? (
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
@@ -65,11 +65,11 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
<CopyCodeButton
codeId={codeId}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
style="absolute top-2 right-2 z-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base',
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
@@ -83,7 +83,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<code
id={codeId}
className={tcls(
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
'table max-h-full w-fit [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
@@ -2,7 +2,7 @@ import type { SiteSpace } from '@gitbook/api';
import { useMemo } from 'react';
import type { GitBookSiteContext } from '@/lib/context';
import { getSiteSpaceURL } from '@/lib/sites';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
@@ -24,6 +24,7 @@ export function SpacesDropdown(props: {
icon?: ButtonProps['icon'];
}) {
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
const currentLanguage = context.siteSpace.space.language;
return (
<DropdownMenu
@@ -40,14 +41,16 @@ export function SpacesDropdown(props: {
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{siteSpace.title}</span>
<span className="button-content">
{getLocalizedTitle(siteSpace, currentLanguage)}
</span>
</Button>
}
>
<SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: siteSp.title,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
@@ -67,7 +70,8 @@ export function TranslationsDropdown(props: {
const { context, siteSpace, siteSpaces, className } = props;
// Memoize the emoji check to avoid repeated regex execution
const hasEmojiPrefix = useMemo(() => startsWithEmoji(siteSpace.title), [siteSpace.title]);
const title = getLocalizedTitle(siteSpace, context.siteSpace.space.language);
const hasEmojiPrefix = useMemo(() => startsWithEmoji(title), [title]);
return (
<SpacesDropdown
@@ -319,7 +319,7 @@ html.dark .shiki span {
}
.highlight-line {
@apply text-tint-strong grid grid-cols-subgrid col-span-2 bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
@apply min-w-min text-tint-strong table-row bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
@@ -345,7 +345,7 @@ html.dark .shiki span {
}
.highlight-line-number {
@apply text-sm text-tint text-right pr-3.5 bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base rounded-l pl-2 sticky left-[-3px] mask-r-from-80%;
@apply table-cell whitespace-nowrap text-sm text-tint pr-4 pl-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base rounded-l sticky -left-2;
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
.highlighted & {
@@ -354,5 +354,5 @@ html.dark .shiki span {
}
.highlight-line-content {
@apply ml-3 block text-sm;
@apply table-cell text-sm;
}
@@ -1,6 +1,7 @@
'use client';
import { t, useLanguage } from '@/intl/client';
import { getLocalizedTitle } from '@/lib/sites';
import { CustomizationSearchStyle, type SiteSection, type SiteSpace } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import React, { useRef } from 'react';
@@ -256,7 +257,10 @@ export function SearchContainer({
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
<SearchScopeControl
section={section}
spaceTitle={siteSpace.title}
spaceTitle={getLocalizedTitle(
siteSpace,
siteSpace.space.language
)}
withVariants={withVariants}
withSiteVariants={withSiteVariants}
withSections={withSections}
@@ -17,7 +17,7 @@ import { isPageIndexable, isSiteIndexable } from '@/lib/seo';
import { getResizedImageURL } from '@/lib/images';
import { resolveContentRef } from '@/lib/references';
import { getSiteSectionTitle } from '@/lib/sites';
import { getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { getPageRSSURL } from '@/routes/rss';
import { PageContextProvider } from '../PageContext';
@@ -134,14 +134,14 @@ function getSiteStructureTitle(context: GitBookSiteContext): string | null {
sections.current.default === false && // Only if the current section is not the default one
sections.list.filter((section) => section.object === 'site-section').length > 1 // Only if there are multiple sections
) {
title.push(getSiteSectionTitle(sections.current, currentLanguage));
title.push(getLocalizedTitle(sections.current, currentLanguage));
}
if (
siteSpaces.length > 1 && // Only if there are multiple variants
siteSpace.default === false && // Only if the variant is not the default one
siteSpaces.filter((space) => space.space.language === siteSpace.space.language).length > 1 // Only if there are multiple variants *for the current language*. This filters out spaces that are "just" translations of each other, not versions.
) {
title.push(siteSpace.title);
title.push(getLocalizedTitle(siteSpace, siteSpace.space.language));
}
return title.join(' ');
}
@@ -1,6 +1,11 @@
import type { GitBookSiteContext } from '@/lib/context';
import { redirect } from 'next/navigation';
import {
SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH,
SITE_REDIRECT_SOURCE_PATH_PATTERN,
} from '@gitbook/api';
import { getDataOrNull } from '@/lib/data';
import { resolvePageId, resolvePagePath } from '@/lib/pages';
import { withLeadingSlash } from '@/lib/paths';
@@ -53,38 +58,37 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
// API has a limit of less than 512 characters for the source path, so we use the same limit here.
if (rawPathname.length < 512) {
// Duplicated the regex pattern from SiteRedirectSourcePath API type.
const SITE_REDIRECT_SOURCE_PATH_REGEX =
/^\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+(?:\/(?:[A-Za-z0-9\-._~]|%[0-9A-Fa-f]{2})+)*$/;
if (rawPathname.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH) {
const SITE_REDIRECT_SOURCE_PATH_REGEX = new RegExp(SITE_REDIRECT_SOURCE_PATH_PATTERN);
const redirectPathname = withLeadingSlash(rawPathname);
// If a page can't be found, we try with the API, in case we have a redirect at site level.
if (SITE_REDIRECT_SOURCE_PATH_REGEX.test(redirectPathname)) {
const redirectSources = new Set<string>([
const redirectSources = new Set(
[
// Test the pathname relative to the root
// For example hello/world -> section/variant/hello/world
withLeadingSlash(
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname))
),
linker.toRelativePathInSite(linker.toPathInSpace(redirectPathname)),
// Test the pathname relative to the content/space
// For example hello/world -> /hello/world
redirectPathname,
]);
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < 512 &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
}
]
.map(toSiteRedirectSourceCandidate)
.filter((source) => SITE_REDIRECT_SOURCE_PATH_REGEX.test(source))
);
for (const source of redirectSources) {
// We try to resolve the site redirect
const resolvedSiteRedirect =
source.length < SITE_REDIRECT_SOURCE_PATH_MAX_LENGTH &&
(await getDataOrNull(
context.dataFetcher.getSiteRedirectBySource({
organizationId,
siteId: site.id,
source,
siteShareKey: shareKey,
})
));
if (resolvedSiteRedirect) {
return redirect(linker.toLinkForContent(resolvedSiteRedirect.target));
}
}
@@ -106,6 +110,21 @@ async function resolvePage(context: GitBookSiteContext, params: PagePathParams |
return undefined;
}
/**
* Transform a pathname into a candidate source for site redirect matching.
* We also encode each segment to handle special characters in redirects.
*/
function toSiteRedirectSourceCandidate(pathname: string): string {
const normalized = withLeadingSlash(pathname);
return withLeadingSlash(
normalized
.slice(1)
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
);
}
/**
* Get the page path from the params.
*/
@@ -1,5 +1,10 @@
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import { getSectionURL, getSiteSectionTitle, getSiteSpaceURL } from '@/lib/sites';
import {
getLocalizedDescription,
getLocalizedTitle,
getSectionURL,
getSiteSpaceURL,
} from '@/lib/sites';
import type { SiteSection, SiteSectionGroup, SiteSpace } from '@gitbook/api';
import assertNever from 'assert-never';
@@ -24,6 +29,7 @@ export type ClientSiteSectionGroup = Pick<SiteSectionGroup, 'id' | 'title' | 'ic
*/
export function encodeClientSiteSections(context: GitBookSiteContext, sections: SiteSections) {
const { list, current } = sections;
const currentLanguage = context.siteSpace.space.language;
const clientSections: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
@@ -39,7 +45,7 @@ export function encodeClientSiteSections(context: GitBookSiteContext, sections:
clientSections.push({
id: item.id,
title: item.title,
title: getLocalizedTitle(item, currentLanguage),
icon: item.icon,
object: item.object,
children,
@@ -66,6 +72,7 @@ function encodeChildren(
children: (SiteSection | SiteSectionGroup)[]
): (ClientSiteSection | ClientSiteSectionGroup)[] {
const clientChildren: (ClientSiteSection | ClientSiteSectionGroup)[] = [];
const currentLanguage = context.siteSpace.space.language;
for (const child of children) {
switch (child.object) {
@@ -83,7 +90,7 @@ function encodeChildren(
clientChildren.push({
id: child.id,
title: child.title,
title: getLocalizedTitle(child, currentLanguage),
icon: child.icon,
object: child.object,
children: nestedChildren,
@@ -102,8 +109,8 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) {
const currentLanguage = context.siteSpace.space.language;
return {
id: section.id,
title: getSiteSectionTitle(section, currentLanguage),
description: section.description,
title: getLocalizedTitle(section, currentLanguage),
description: getLocalizedDescription(section, currentLanguage),
icon: section.icon,
object: section.object,
url: findBestTargetURL(context, section),
@@ -25,6 +25,7 @@ export function Trademark(props: {
return (
<Button
data-testid="gb-trademark"
target="_blank"
variant="secondary"
size="large"
@@ -1,17 +1,17 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`parseMarkdown should parse a simple table 1`] = `
"<h2>Table</h2>
<table>
<thead>
<tr>
<th>a</th>
<th align="left">b</th>
<th align="right">c</th>
<th align="center">d</th>
</tr>
</thead>
</table>"
<table><thead><tr><th>a</th><th align="left">b</th><th align="right">c</th><th align="center">d</th></tr></thead></table>"
`;
exports[`parseMarkdown should parse a complex table 1`] = `
@@ -31,116 +31,176 @@ A serialnumbers value governs its order within the given token.id</p>
<h2>Filtering</h2>
<p>When filtering there are some restrictions enforced to ensure correctness and scalability.</p>
<p><strong>The table below defines the restrictions and support for the NFT ownership endpoint</strong></p>
<table>
<thead>
<tr>
<th>Query Param</th>
<th>Comparison Operator</th>
<th>Support</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>token.id</td>
<td>eq</td>
<td>Y</td>
<td>Single occurrence only.</td>
<td>?token.id=X</td>
</tr>
<tr>
<td></td>
<td>ne</td>
<td>N</td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td>lt(e)</td>
<td>Y</td>
<td>Single occurrence only.</td>
<td>?token.id=lte:X</td>
</tr>
<tr>
<td></td>
<td>gt(e)</td>
<td>Y</td>
<td>Single occurrence only.</td>
<td>?token.id=gte:X</td>
</tr>
<tr>
<td>serialnumber</td>
<td>eq</td>
<td>Y</td>
<td>Single occurrence only. Requires the presence of a <strong>token.id</strong> query</td>
<td>?serialnumber=Y</td>
</tr>
<tr>
<td></td>
<td>ne</td>
<td>N</td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td>lt(e)</td>
<td>Y</td>
<td>Single occurrence only. Requires the presence of an <strong>lte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td>
<td>?token.id=lte:X&#x26;serialnumber=lt:Y</td>
</tr>
<tr>
<td></td>
<td>gt(e)</td>
<td>Y</td>
<td>Single occurrence only. Requires the presence of an <strong>gte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td>
<td>?token.id=gte:X&#x26;serialnumber=gt:Y</td>
</tr>
<tr>
<td>spender.id</td>
<td>eq</td>
<td>Y</td>
<td></td>
<td>?spender.id=Z</td>
</tr>
<tr>
<td></td>
<td>ne</td>
<td>N</td>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td>lt(e)</td>
<td>Y</td>
<td></td>
<td>?spender.id=lt:Z</td>
</tr>
<tr>
<td></td>
<td>gt(e)</td>
<td>Y</td>
<td></td>
<td>?spender.id=gt:Z</td>
</tr>
</tbody>
</table>
<table><thead><tr><th>Query Param</th><th>Comparison Operator</th><th>Support</th><th>Description</th><th>Example</th></tr></thead><tbody><tr><td>token.id</td><td>eq</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=X</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=lte:X</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=gte:X</td></tr><tr><td>serialnumber</td><td>eq</td><td>Y</td><td>Single occurrence only. Requires the presence of a <strong>token.id</strong> query</td><td>?serialnumber=Y</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td>Single occurrence only. Requires the presence of an <strong>lte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td><td>?token.id=lte:X&#x26;serialnumber=lt:Y</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td>Single occurrence only. Requires the presence of an <strong>gte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td><td>?token.id=gte:X&#x26;serialnumber=gt:Y</td></tr><tr><td>spender.id</td><td>eq</td><td>Y</td><td></td><td>?spender.id=Z</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td></td><td>?spender.id=lt:Z</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td></td><td>?spender.id=gt:Z</td></tr></tbody></table>
<p>Note: When searching across a range for individual NFTs a <strong>serialnumber</strong> with an additional <strong>token.id</strong> query filter must be provided.
Both filters must be a single occurrence of <strong>gt(e)</strong> or <strong>lt(e)</strong> which provide a lower and or upper boundary for search.</p>"
`;
exports[`parseMarkdown should parse html 1`] = `
"<p>Returns trending events for the given time frame.</p>
"<div>
<div>
<div>
ID:
</div>
<div>
get_trending_events
</div>
</div>
<div>
<div>
<span>
<span>
</span>
<span>
sports_events_trending
</span>
</span>
</div>
</div>
</div>
<div>
<span>
Sports
</span>
<span>
Personalization
</span>
</div>
<p>Returns trending events for the given time frame.</p>
<p>The events are sorted by <code>trending score</code>.</p>
<div>
<div>
<span>
</span>
<span>
Trending score
</span>
</div>
<div>
<p>To calculate trending score for an event, each bet placed on this event
contributes a value determined by how recently the bet was placed.</p>
<p>Recent bets add more value than older ones.</p>
<p>The normalized value of its score is also assigned to each event as its confidence.</p>
</div>
</div>
<div>
<div>
<span>
</span>
<span>
Filtering example
</span>
</div>
<div>
<p>In this example we get all trending events where <code>league</code> is <code>UEFA Champions League</code>.</p>
<pre><code class="language-bash">$ curl --request GET \\
--url 'https://api.vaix.ai/api/sports/events/trending?filters=league%3Aeq%3AUEFA%20Champions%20League'
</code></pre>"
</code></pre>
</div>
</div>"
`;
+1
View File
@@ -45,6 +45,7 @@ export type SiteURLData = Pick<
| 'siteBasePath'
| 'basePath'
| 'contextId'
| 'preview'
> & {
/**
* Identifier used for image resizing.
+8 -1
View File
@@ -78,6 +78,7 @@ export async function ignoreAllThrownError<T>(promise: Promise<T>): Promise<T |
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
* This function should not throw.
*/
export async function wrapDataFetcherError<T>(
fn: () => Promise<T>
@@ -178,6 +179,7 @@ export function extractCacheControl(error: GitBookAPIError) {
/**
* Get a data fetcher exposable error from a JS error.
* This function should never throw, even if the error is not in the expected format. In that case, it should return a generic error with code 500.
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
@@ -197,5 +199,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
};
}
throw error;
console.warn('An unexpected error occurred', error);
return {
code: 500,
message: 'An unexpected error occurred',
};
}
+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.
+4 -7
View File
@@ -1,3 +1,4 @@
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
@@ -5,18 +6,14 @@ import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
/**
* Parse markdown and output HTML.
*/
export async function parseMarkdown(markdown: string): Promise<string> {
const promise = unified()
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(markdown)
.then((file) => file.toString());
return promise;
}
@@ -13,7 +13,7 @@ describe('#enrichFilesystem', () => {
});
const enriched = await enrichFilesystem(filesystem);
expect(enriched[0]?.specification.paths['/pet'].put['x-gitbook-description-html']).toBe(
'<p>Social platform</p>'
'<p>Social platform<br>Hello</p>'
);
});
});
@@ -9,7 +9,7 @@ paths:
put:
summary: Update an existing pet
description: |
Social platform
Social platform<br>Hello
operationId: updatePet
requestBody:
description: Update an existent pet in the store
+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,
},
};
}
+22 -6
View File
@@ -29,7 +29,12 @@ import { PageIcon } from '@/components/PageIcon';
import { getGitBookAppHref } from './app';
import { getBlockById, getBlockTitle } from './document';
import { resolvePageId } from './pages';
import { findSiteSpaceBy, getFallbackSiteSpacePath } from './sites';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLocalizedTitle,
isSpaceInSiteStructure,
} from './sites';
import { getRevisionTags, resolveTag } from './tags';
import type { ClassValue } from './tailwind';
import { filterOutNullable } from './typescript';
@@ -163,6 +168,14 @@ export async function resolveContentRef(
case 'anchor':
case 'page': {
if (isContentRefInDifferentSpace(contentRef, context)) {
// Only resolve cross-space page/anchor refs when the target space
// is part of the current site's structure.
if (
'site' in context &&
!isSpaceInSiteStructure(context.structure, contentRef.space)
) {
return null;
}
return resolveContentRefInSpace(contentRef.space, context, contentRef, options);
}
@@ -488,15 +501,18 @@ async function resolveContentRefInSpace(
// Prefer the variant title when available, then the section title, then fallback to the space title.
const ancestorLabel = (() => {
if ('site' in context) {
const currentLanguage = context.siteSpace.space.language;
const foundSiteSpace = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
);
return (
foundSiteSpace?.siteSpace.title ??
foundSiteSpace?.siteSection?.title ??
ctx.spaceContext.space.title
);
if (foundSiteSpace?.siteSpace) {
return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage);
}
if (foundSiteSpace?.siteSection) {
return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage);
}
return ctx.spaceContext.space.title;
}
return ctx.spaceContext.space.title;
+36 -13
View File
@@ -1,5 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import type {
LocalizedString,
SiteSection,
SiteSectionGroup,
SiteSpace,
@@ -56,6 +57,14 @@ export function listAllSiteSpaces(siteStructure: SiteStructure) {
});
}
/**
* Check if a space is part of a site structure by scanning every site section
* and all the siteSpaces associated with each section.
*/
export function isSpaceInSiteStructure(siteStructure: SiteStructure, spaceId: string): boolean {
return !!findSiteSpaceBy(siteStructure, (siteSpace) => siteSpace.space.id === spaceId);
}
/**
* Find a site space by its spaceId in a site structure.
*/
@@ -185,20 +194,34 @@ function findSiteSpaceByIdInSiteSpaces(
}
/**
* Get the appropriate title for a siteSection.
* Uses localizedTitle if available and current language is provided, otherwise uses title.
* Get the localized title for a site entity (SiteSection, SiteSectionGroup, or SiteSpace).
*/
export function getSiteSectionTitle(
siteSection: SiteSection,
export function getLocalizedTitle(
entity: { title: string; localizedTitle?: LocalizedString },
currentLanguage: TranslationLanguage | undefined
): string {
if (
siteSection.localizedTitle &&
currentLanguage &&
siteSection.localizedTitle[currentLanguage]
) {
return siteSection.localizedTitle[currentLanguage];
}
return siteSection.title;
return getLocalizedField(entity.localizedTitle, currentLanguage) ?? entity.title;
}
/**
* Get the localized description for a site entity.
*/
export function getLocalizedDescription(
entity: { description?: string; localizedDescription?: LocalizedString },
currentLanguage: TranslationLanguage | undefined
): string | undefined {
return getLocalizedField(entity.localizedDescription, currentLanguage) ?? entity.description;
}
/**
* Get a localized field value for the given language.
*/
function getLocalizedField(
localizedField: LocalizedString | undefined,
currentLanguage: TranslationLanguage | undefined
): string | undefined {
if (localizedField && currentLanguage && localizedField[currentLanguage]) {
return localizedField[currentLanguage];
}
return undefined;
}
+67 -56
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,17 +467,20 @@ 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(
return serveWithQueryAPIToken({
// We scope the API token to the site ID.
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
request,
withAPIToken
);
scopePath: ['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'),
// We keep the original request URL when using `url` mode
requestURL: mode === 'url' ? requestURL : siteRequestURL,
requestCookies: request.cookies,
serve: withAPIToken,
});
}
return withAPIToken(null);
@@ -487,18 +495,22 @@ async function serveSpacePDFRoutes(requestURL: URL, request: NextRequest) {
return null;
}
return serveWithQueryAPIToken(
pathnameParts.slice(0, 2).join('/'),
request,
async (apiToken) => {
return serveWithQueryAPIToken({
scopePath: pathnameParts.slice(0, 2).join('/'),
requestURL,
requestCookies: request.cookies,
serve: async (apiToken) => {
if (!apiToken) {
throw new DataFetcherError('Missing API token', 400);
}
// Handle the rest with the router default logic
return NextResponse.next({
headers: {
[MiddlewareHeaders.APIToken]: apiToken,
},
});
}
);
},
});
}
/**
@@ -516,23 +528,25 @@ function serveErrorResponse(error: Error) {
}
/**
* Server a response with an API token obtained from the query params.
* Serve a response with an API token obtained from the query params.
*/
async function serveWithQueryAPIToken(
scopePath: string,
request: NextRequest,
serve: (apiToken: string) => Promise<NextResponse>
) {
async function serveWithQueryAPIToken(input: {
scopePath: string;
requestURL: URL;
requestCookies: NextRequest['cookies'];
serve: (apiToken: string | null) => Promise<NextResponse>;
}) {
const { scopePath, requestURL, requestCookies, serve } = input;
// We store the API token in a cookie that is scoped to the specific route
// to avoid errors when multiple previews are opened in different tabs.
const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath);
// Extract a potential GitBook API token passed in the request
// If found, we redirect to the same URL but with the token in the cookie
const queryAPIToken = request.nextUrl.searchParams.get('token');
const queryAPIToken = requestURL.searchParams.get('token');
if (queryAPIToken) {
request.nextUrl.searchParams.delete('token');
return writeResponseCookies(NextResponse.redirect(request.nextUrl.toString()), [
requestURL.searchParams.delete('token');
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [
{
name: cookieName,
value: queryAPIToken,
@@ -546,12 +560,9 @@ async function serveWithQueryAPIToken(
]);
}
const apiToken = request.cookies.get(cookieName)?.value;
if (!apiToken) {
throw new DataFetcherError('Missing API token', 400);
}
const apiToken = requestCookies.get(cookieName)?.value;
return serve(apiToken);
return serve(apiToken ?? null);
}
/**
+8 -3
View File
@@ -3,7 +3,7 @@ import { throwIfDataError } from '@/lib/data';
import type { GitBookLinker } from '@/lib/links';
import { joinPath } from '@/lib/paths';
import { type FlatPageEntry, getIndexablePages } from '@/lib/sitemap';
import { getSiteSectionTitle, getSiteStructureSections } from '@/lib/sites';
import { getLocalizedTitle, getSiteStructureSections } from '@/lib/sites';
import type { SiteSection, SiteSpace } from '@gitbook/api';
import assertNever from 'assert-never';
import type { ListItem, Paragraph, Root, RootContent } from 'mdast';
@@ -101,7 +101,7 @@ async function getNodesFromSections(
type: 'heading',
depth: 2,
children: [
{ type: 'text', value: getSiteSectionTitle(siteSection, currentLanguage) },
{ type: 'text', value: getLocalizedTitle(siteSection, currentLanguage) },
],
},
...siteSpaceNodes,
@@ -152,7 +152,12 @@ async function getNodesFromSiteSpaces(
nodes.push({
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSpace.title }],
children: [
{
type: 'text',
value: getLocalizedTitle(siteSpace, context.siteSpace.space.language),
},
],
});
}
+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.
*/
@@ -50,6 +50,71 @@ describe('getSecurityHeaders', () => {
});
});
it('should use default placeholder for basic auth', () => {
const securities: OpenAPIOperationData['securities'] = [
[
'basicAuth',
{
type: 'http',
scheme: 'basic',
},
],
];
const result = getSecurityHeaders({
securityRequirement: [{ basicAuth: [] }],
securities,
});
expect(result).toEqual({
Authorization: 'Basic username:password',
});
});
it('should use x-gitbook-token-placeholder for basic auth', () => {
const securities: OpenAPIOperationData['securities'] = [
[
'basicAuth',
{
type: 'http',
scheme: 'basic',
'x-gitbook-token-placeholder': 'admin:secret123',
},
],
];
const result = getSecurityHeaders({
securityRequirement: [{ basicAuth: [] }],
securities,
});
expect(result).toEqual({
Authorization: 'Basic admin:secret123',
});
});
it('should use x-gitbook-token-placeholder for bearer auth', () => {
const securities: OpenAPIOperationData['securities'] = [
[
'bearerAuth',
{
type: 'http',
scheme: 'bearer',
'x-gitbook-token-placeholder': 'MY_CUSTOM_TOKEN',
},
],
];
const result = getSecurityHeaders({
securityRequirement: [{ bearerAuth: [] }],
securities,
});
expect(result).toEqual({
Authorization: 'Bearer MY_CUSTOM_TOKEN',
});
});
it('should not use x-gitbook-prefix for http scheme', () => {
const securities: OpenAPIOperationData['securities'] = [
[
@@ -118,6 +118,144 @@ describe('resolveTryItPrefillForOperation', () => {
});
});
it('should use x-gitbook-token-placeholder as fallback for basic auth in try-it', () => {
const operation: OpenAPIOperationData = {
path: '/users',
method: 'GET',
operation: { summary: 'List users' },
servers: [{ url: 'https://api.example.com' }],
securities: [
[
'basicAuth',
{
type: 'http',
scheme: 'basic',
'x-gitbook-token-placeholder': 'admin:secret123',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: {} },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
basicAuth: { username: 'admin', password: 'secret123' },
},
},
});
});
it('should use x-gitbook-token-placeholder as fallback for bearer auth in try-it', () => {
const operation: OpenAPIOperationData = {
path: '/users',
method: 'GET',
operation: { summary: 'List users' },
servers: [{ url: 'https://api.example.com' }],
securities: [
[
'bearerAuth',
{
type: 'http',
scheme: 'bearer',
'x-gitbook-token-placeholder': 'my-default-token',
},
],
],
};
const prefillInputContext: PrefillInputContextData = {
visitor: { claims: {} },
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
bearerAuth: { token: 'my-default-token' },
},
},
});
});
it('should fall back to x-gitbook-token-placeholder when x-gitbook-prefill exists but prefillInputContext is null', () => {
const operation: OpenAPIOperationData = {
path: '/users',
method: 'GET',
operation: { summary: 'List users' },
servers: [{ url: 'https://api.example.com' }],
securities: [
[
'basicAuth',
{
type: 'http',
scheme: 'basic',
'x-gitbook-prefill': '{{ visitor.claims.basicAuth }}',
'x-gitbook-token-placeholder': 'admin:secret123',
},
],
],
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext: null,
});
expect(result).toEqual({
authentication: {
securitySchemes: {
basicAuth: { username: 'admin', password: 'secret123' },
},
},
});
});
it('should fall back to x-gitbook-token-placeholder when x-gitbook-prefill expression resolves to undefined', () => {
const operation: OpenAPIOperationData = {
path: '/users',
method: 'GET',
operation: { summary: 'List users' },
servers: [{ url: 'https://api.example.com' }],
securities: [
[
'bearerAuth',
{
type: 'http',
scheme: 'bearer',
'x-gitbook-prefill': '{{ visitor.claims.missing }}',
'x-gitbook-token-placeholder': 'fallback-token',
},
],
],
};
const result = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: {} } },
});
expect(result).toEqual({
authentication: {
securitySchemes: {
bearerAuth: { token: 'fallback-token' },
},
},
});
});
it('should return empty object if no visitor data matches prefill expression', () => {
const operation: OpenAPIOperationData = {
path: '/orgs/<orgId>/spaces',
@@ -167,7 +305,9 @@ describe('resolveTryItPrefillForOperation', () => {
const prefillInputContext: PrefillInputContextData = {
visitor: {
claims: { api: { endpointUrl: 'https://api.gitbook-staging.com/v1/' } },
claims: {
api: { endpointUrl: 'https://api.gitbook-staging.com/v1/' },
},
},
};
@@ -214,7 +354,9 @@ describe('resolveTryItPrefillForOperation', () => {
// Override env
const overrideEnvResult = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: { api: { env: 'staging' } } } },
prefillInputContext: {
visitor: { claims: { api: { env: 'staging' } } },
},
});
expect(overrideEnvResult).toEqual({
servers: [
@@ -232,7 +374,9 @@ describe('resolveTryItPrefillForOperation', () => {
// Override version
const overrideVersionResult = resolveTryItPrefillForOperation({
operation,
prefillInputContext: { visitor: { claims: { api: { version: 'v2' } } } },
prefillInputContext: {
visitor: { claims: { api: { version: 'v2' } } },
},
});
expect(overrideVersionResult).toEqual({
servers: [
@@ -467,7 +611,10 @@ describe('resolveURLWithPrefillCodePlaceholdersFromServer', () => {
const result = resolveURLWithPrefillCodePlaceholdersFromServer({
url: 'https://{region}.example.com',
variables: {
region: { default: 'us-east-1', 'x-gitbook-prefill': '{{ user.region }}' },
region: {
default: 'us-east-1',
'x-gitbook-prefill': '{{ user.region }}',
},
},
});
@@ -492,7 +639,10 @@ describe('resolveURLWithPrefillCodePlaceholdersFromServer', () => {
url: 'https://{region}.example.com/{version}',
'x-gitbook-prefill': '{{ user.baseUrl }}',
variables: {
region: { default: 'us-east-1', 'x-gitbook-prefill': '{{ user.region }}' },
region: {
default: 'us-east-1',
'x-gitbook-prefill': '{{ user.region }}',
},
version: { default: 'v1' },
},
});
@@ -33,18 +33,18 @@ export function resolveTryItPrefillForOperation(args: {
prefillInputContext,
} = args;
if (!prefillInputContext) {
return {};
}
const runtime = new ExpressionRuntime();
const resolveTryItPrefillExpression = (expr: string) => {
const parts = parseTemplate(expr);
if (!parts.length) {
return undefined;
}
return runtime.evaluateTemplate(expr, prefillInputContext);
};
const resolveTryItPrefillExpression = prefillInputContext
? (() => {
const runtime = new ExpressionRuntime();
return (expr: string) => {
const parts = parseTemplate(expr);
if (!parts.length) {
return undefined;
}
return runtime.evaluateTemplate(expr, prefillInputContext);
};
})()
: undefined;
const prefillAuth = securities
? resolveTryItPrefillAuthForOperationSecurities({
@@ -54,7 +54,10 @@ export function resolveTryItPrefillForOperation(args: {
: undefined;
const prefillServers = servers
? resolveTryItPrefillServersForOperationServers({ servers, resolveTryItPrefillExpression })
? resolveTryItPrefillServersForOperationServers({
servers,
resolveTryItPrefillExpression,
})
: [];
return {
@@ -68,15 +71,17 @@ export function resolveTryItPrefillForOperation(args: {
*/
function resolveTryItPrefillAuthForOperationSecurities(args: {
securities: OpenAPIOperationData['securities'];
resolveTryItPrefillExpression: (expr: string) => string | undefined;
resolveTryItPrefillExpression?: (expr: string) => string | undefined;
}): ApiClientConfiguration['authentication'] | undefined {
const { securities, resolveTryItPrefillExpression } = args;
const prefillAuthConfig: ApiClientConfiguration['authentication']['securitySchemes'] = {};
for (const [schemeName, security] of Object.values(securities)) {
const tryitPrefillAuthValue = security[PREFILL_CUSTOM_PROPERTY]
? resolveTryItPrefillExpression(security[PREFILL_CUSTOM_PROPERTY])
const resolvedPrefill = security[PREFILL_CUSTOM_PROPERTY]
? resolveTryItPrefillExpression?.(security[PREFILL_CUSTOM_PROPERTY])
: undefined;
const tryitPrefillAuthValue =
resolvedPrefill || security['x-gitbook-token-placeholder'] || undefined;
if (!tryitPrefillAuthValue) {
continue;
@@ -120,7 +125,7 @@ function resolveTryItPrefillAuthForOperationSecurities(args: {
*/
function resolveTryItPrefillServersForOperationServers(args: {
servers: OpenAPIOperationData['servers'];
resolveTryItPrefillExpression: (expr: string) => string | undefined;
resolveTryItPrefillExpression?: (expr: string) => string | undefined;
}): ApiClientConfiguration['servers'] | undefined {
const { servers, resolveTryItPrefillExpression } = args;
const resolvedServers: ApiClientConfiguration['servers'] = [];
@@ -128,9 +133,10 @@ function resolveTryItPrefillServersForOperationServers(args: {
for (const server of servers) {
// Url-level prefill
const tryItPrefillServerUrlExpr = server[PREFILL_CUSTOM_PROPERTY];
const tryItPrefillServerUrlValue = tryItPrefillServerUrlExpr
? resolveTryItPrefillExpression(tryItPrefillServerUrlExpr)
: undefined;
const tryItPrefillServerUrlValue =
tryItPrefillServerUrlExpr && resolveTryItPrefillExpression
? resolveTryItPrefillExpression(tryItPrefillServerUrlExpr)
: undefined;
const variables: { [variable: string]: OpenAPIV3.ServerVariableObject } = server.variables
? { ...server.variables }
@@ -143,7 +149,7 @@ function resolveTryItPrefillServersForOperationServers(args: {
variable;
const tryItPrefillVarValue = tryItPrefillVarExpr
? resolveTryItPrefillExpression(tryItPrefillVarExpr)
? resolveTryItPrefillExpression?.(tryItPrefillVarExpr)
: undefined;
variables[varName] = {
...variableProps,
@@ -266,5 +272,7 @@ function templatePartsToExpression(parts: ReturnType<typeof parseTemplate>) {
}
function toPrefillCodePlaceholder(expression: string, defaultValue?: string) {
return `$$__X-GITBOOK-PREFILL[(${expression})${defaultValue ? ` ?? '${defaultValue}'` : ''}]__$$`;
return `$$__X-GITBOOK-PREFILL[(${expression})${
defaultValue ? ` ?? '${defaultValue}'` : ''
}]__$$`;
}