mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-04 04:21:33 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03be3e79de | |||
| a7c0f7251b | |||
| efc6117665 | |||
| 6c613d0df3 | |||
| 45d32b5084 | |||
| 0f62a629b4 | |||
| 7ab63516bd | |||
| 9dd2db6678 | |||
| 4787b7e910 | |||
| be02e33ada |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Use localized titles/descriptions for site entities
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Import site redirect patterns from @gitbook/api
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Support x-gitbook-token-placeholder as TryIt prefill fallback for HTTP auth (basic and bearer)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-4
@@ -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&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&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&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&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>"
|
||||
`;
|
||||
|
||||
@@ -45,6 +45,7 @@ export type SiteURLData = Pick<
|
||||
| 'siteBasePath'
|
||||
| 'basePath'
|
||||
| 'contextId'
|
||||
| 'preview'
|
||||
> & {
|
||||
/**
|
||||
* Identifier used for image resizing.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}'` : ''
|
||||
}]__$$`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user