Merge branch 'main' into peter/rnd-6756-add-option-to-freeze-columns-andor-rows-in-tables

This commit is contained in:
Peter White
2026-03-23 08:58:02 +01:00
58 changed files with 1704 additions and 614 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track new events for site analytics.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Small OpenAPI Improvements
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix normalization of URL causing invalid redirections.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track events from the proper server URL.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Pan and zoom controls for mermaid diagrams
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix PDF export crash when page contains an OpenAPI block
-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)
+8 -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": {
@@ -129,6 +129,7 @@
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -171,6 +172,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 +350,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.169.0",
"@gitbook/api": "0.172.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -745,7 +747,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.169.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-kzJT8P5HCnqeYG3kFmBfvKMjQhTuUVmNV5Wvyh87QnoOA3odwh2C3qONpTmBA6cNE3ymDnxs8SJT9FG/qquRZQ=="],
"@gitbook/api": ["@gitbook/api@0.172.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-EoOhOt4cZpwZKPaQ0E5jb3Ea4Fvmi11at5Y/62cFy5ahyB3355jfKp0HPxe1bGsKfm421RkaM4Ico0vtVZwQ6Q=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -1009,6 +1011,8 @@
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.111.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QddKW4kBH0Wof6Y65eYCNHM4iOGmCTWLLcNYY1FGswhzmTYOUVXajNROR+iCXAOFnOF0ldtsR79SyqgyHH1Bgg=="],
"@panzoom/panzoom": ["@panzoom/panzoom@4.6.1", "", {}, "sha512-ogf/KhHHjj+DYAvHfaf3TXMQ8OE36pJtKpabLlx1OmpjcgtpCvkUoCiNONA8kuVRPGJdLiqMf0n8LRFXj1OyuA=="],
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.169.0",
"@gitbook/api": "0.172.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+89 -7
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,10 +709,30 @@ 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();
},
},
],
},
{
name: 'Site Preview',
name: 'Site Previews',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
tests: [
{
@@ -725,7 +745,7 @@ const testCases: TestsCase[] = [
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
@@ -740,7 +760,7 @@ const testCases: TestsCase[] = [
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
@@ -750,10 +770,72 @@ const testCases: TestsCase[] = [
const sectionTabLinks = sectionTabs.getByRole('link');
for (const link of await sectionTabLinks.all()) {
const href = await link.getAttribute('href');
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
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')
);
},
},
],
},
{
@@ -1306,7 +1388,7 @@ const testCases: TestsCase[] = [
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
@@ -1326,7 +1408,7 @@ const testCases: TestsCase[] = [
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
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,
+2
View File
@@ -21,6 +21,7 @@
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -63,6 +64,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",
@@ -0,0 +1,30 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
if (!context.site.urls.login) {
return NextResponse.redirect(noLoginFallbackURL);
}
try {
const loginURL = new URL(context.site.urls.login);
const location = request.nextUrl.searchParams.get('location');
if (location) {
loginURL.searchParams.set('location', location);
}
return NextResponse.redirect(loginURL);
} catch (_error) {
return NextResponse.redirect(noLoginFallbackURL);
}
}
@@ -10,5 +10,6 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -11,12 +11,35 @@ import type { NextRequest } from 'next/server';
import { z } from 'zod';
async function handler(
nextRequest: NextRequest,
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = rawRequest.nextUrl.search;
const request = new Request(requestURL, rawRequest);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: context.site.id,
events: [
{
type: 'mcp_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
request,
})
);
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
@@ -49,7 +72,7 @@ async function handler(
},
},
],
request: nextRequest,
request,
})
);
@@ -114,13 +137,6 @@ async function handler(
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
@@ -193,10 +193,10 @@ function CodeBlockExpandable(props: {
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
variant="blank"
variant="secondary"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
className="pointer-events-auto z-1 my-2 bg-tint! text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
@@ -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"
@@ -63,43 +63,45 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
) : null}
</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"
/>
<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',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
<code
id={codeId}
<div className="relative">
<CopyCodeButton
codeId={codeId}
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-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!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
<code
id={codeId}
className={tcls(
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
>
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
</div>
</div>
);
});
@@ -5,7 +5,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
/**
@@ -14,7 +16,9 @@ import { getPlainCodeBlock } from './highlight';
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, style } = props;
const source = getPlainCodeBlock(block);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { resolvedTheme } = useTheme();
@@ -23,11 +27,13 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
useEffect(() => {
const container = diagramRef.current;
if (!container) {
const wrapper = wrapperRef.current;
if (!container || !wrapper) {
return;
}
let cancelled = false;
let cleanupPanZoom: (() => void) | undefined;
setError(false);
setIsLoading(true);
@@ -37,6 +43,15 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
id,
darkMode,
})
.then(() => {
if (!cancelled) {
cleanupPanZoom = initPanzoom({
container,
wrapper,
onInit: setPanZoom,
});
}
})
.catch(() => {
if (!cancelled) {
setError(true);
@@ -50,6 +65,8 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
return () => {
cancelled = true;
cleanupPanZoom?.();
setPanZoom(null);
};
}, [source, id, darkMode]);
@@ -59,7 +76,14 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
return (
<div className={tcls('relative', style)} contentEditable={false}>
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
<div
ref={wrapperRef}
className={
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
@@ -70,6 +94,7 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
</div>
);
}
@@ -100,6 +125,35 @@ async function renderMermaidDiagram(args: {
bindFunctions?.(container);
}
/**
* Initialize panzoom on the diagram container.
*/
function initPanzoom(args: {
container: HTMLElement;
wrapper: HTMLElement;
onInit: (instance: ReturnType<typeof Panzoom> | null) => void;
}): () => void {
const { container, wrapper, onInit } = args;
const instance = Panzoom(container, {
maxScale: 5,
minScale: 0.5,
contain: 'outside',
cursor: 'grab',
panOnlyWhenZoomed: true,
});
onInit(instance);
wrapper.addEventListener('wheel', instance.zoomWithWheel, { passive: false });
return () => {
wrapper.removeEventListener('wheel', instance.zoomWithWheel);
instance.destroy();
onInit(null);
};
}
function useSafeId() {
const rawId = useId();
return useMemo(() => rawId.replace(/[^a-zA-Z0-9]/g, ''), [rawId]);
@@ -0,0 +1,80 @@
'use client';
import type { PanzoomObject } from '@panzoom/panzoom';
import { Button } from '@/components/primitives';
const PAN_STEP = 50;
/**
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
label="Pan up"
icon="chevron-up"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button
label="Zoom in"
icon="plus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomIn()}
/>
{/* Row 2: pan left, reset, pan right */}
<Button
label="Pan left"
icon="chevron-left"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button
label="Reset view"
icon="refresh"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.reset()}
/>
<Button
label="Pan right"
icon="chevron-right"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
label="Pan down"
icon="chevron-down"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button
label="Zoom out"
icon="minus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomOut()}
/>
</div>
);
}
@@ -1,13 +1,16 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type React from 'react';
import { Button, type ButtonProps } from '../primitives';
import { SiteAuthLoginButton } from '../primitives/SiteAuthLoginLink';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline } = props;
const { inline, context } = props;
const buttonProps: ButtonProps = {
label: inline.data.label,
@@ -17,7 +20,8 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
};
const ButtonImplementation = () => {
if ('action' in inline.data && 'query' in inline.data.action) {
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
return (
<InlineActionButton
action={inline.data.action.action}
@@ -57,21 +61,27 @@ export async function InlineLinkButton(
const href =
resolved?.href ??
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
const sharedProps: React.ComponentProps<typeof Button> = {
...buttonProps,
insights: {
type: 'link_click' as const,
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
},
href,
disabled: href === undefined,
};
const button = (
<Button
{...buttonProps}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
href={href}
disabled={href === undefined}
/>
);
const button =
href &&
context.contentContext &&
isSiteAuthLoginHref(context.contentContext.linker, href) ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
if (inline.data.ref && !resolved) {
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
@@ -2,8 +2,6 @@ import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
@@ -33,9 +31,10 @@ export function getOpenAPIContext(args: {
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
const proxyUrl = context
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
: undefined;
const proxyUrl =
context && props.context.mode !== 'print'
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
: undefined;
return {
specUrl,
@@ -75,12 +74,7 @@ export function getOpenAPIContext(args: {
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:pt-0'
: undefined,
])}
style={headingProps.deprecated ? 'line-through' : undefined}
block={{
object: 'block',
key: `${block.key}-heading`,
@@ -4,12 +4,13 @@
/* Layout Components */
.openapi-operation,
.openapi-schemas,
.openapi-webhook {
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
.openapi-webhook,
.openapi-schemas-single {
@apply flex-1 flex flex-col gap-6 mb-14 min-w-0;
}
.openapi-schemas {
@apply flex flex-col mb-14 gap-0 flex-1;
.openapi-schemas:not(.openapi-schemas-single) {
@apply gap-0;
}
.openapi-schemas-title {
@@ -33,6 +34,10 @@
@apply flex flex-row gap-2 mt-[0.75em];
}
.openapi-summary-tags + .heading {
@apply pt-0;
}
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@@ -144,7 +149,7 @@
}
.openapi-column-preview {
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:py-6 sticky max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
}
.openapi-column-preview-body {
@@ -233,7 +238,7 @@
}
.openapi-schema-type {
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem] whitespace-normal;
}
.openapi-schema-type:only-child {
@@ -1,3 +1,4 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type ContentRef,
@@ -7,6 +8,7 @@ import {
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -17,6 +19,11 @@ import {
DropdownMenu,
DropdownMenuItem,
} from '../primitives/DropdownMenu';
import {
SiteAuthLoginButton,
SiteAuthLoginDropdownMenuItem,
SiteAuthLoginLink,
} from '../primitives/SiteAuthLoginLink';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -44,6 +51,7 @@ export async function HeaderLink(props: {
title={link.title}
isDropdown
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
)
}
@@ -68,6 +76,7 @@ export async function HeaderLink(props: {
title={link.title}
isDropdown={false}
href={target?.href}
isSiteAuthLoginHref={target ? isSiteAuthLoginHref(context.linker, target.href) : false}
/>
);
}
@@ -79,6 +88,7 @@ export type HeaderLinkNavItemProps = {
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
@@ -99,7 +109,16 @@ function HeaderItemButton(
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const { linkTarget, linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
@@ -110,21 +129,25 @@ function HeaderItemButton(
assertNever(linkStyle);
}
})();
return (
<Button
href={href}
variant={variant}
size="medium"
insights={{
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
label={title}
{...rest}
/>
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
@@ -154,20 +177,28 @@ function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPrese
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
return (
<Link
href={href ?? '#'}
className={getHeaderLinkClassName({ headerPreset })}
insights={{
type: 'link_click',
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
}}
{...rest}
>
const { linkTarget, headerPreset, title, isDropdown, href, isSiteAuthLoginHref, ...rest } =
props;
const sharedProps = {
href: href ?? '#',
className: getHeaderLinkClassName({ headerPreset }),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
@@ -204,18 +235,20 @@ async function SubHeaderLink(props: {
return null;
}
return (
<DropdownMenuItem
href={target.href}
insights={{
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}}
>
{link.title}
</DropdownMenuItem>
const sharedProps = {
href: target.href,
insights: {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
},
};
return isSiteAuthLoginHref(context.linker, target.href) ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps}>{link.title}</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{link.title}</DropdownMenuItem>
);
}
@@ -1,3 +1,4 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type CustomizationContentLink,
@@ -19,6 +20,7 @@ import {
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { SiteAuthLoginDropdownMenuItem } from '../primitives/SiteAuthLoginLink';
import styles from './headerLinks.module.css';
/**
@@ -84,6 +86,18 @@ async function MoreMenuLink(props: {
const { context, link } = props;
const target = link.to ? await resolveContentRef(link.to, context) : null;
const sharedProps = {
href: target?.href,
insights: link.to
? {
type: 'link_click' as const,
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined,
};
return 'links' in link && link.links.length > 0 ? (
<DropdownSubMenu label={link.title}>
@@ -91,22 +105,11 @@ async function MoreMenuLink(props: {
return <MoreMenuLink key={index} {...props} link={subLink} />;
})}
</DropdownSubMenu>
) : (
<DropdownMenuItem
href={target?.href}
insights={
link.to
? {
type: 'link_click',
link: {
target: link.to,
position: SiteInsightsLinkPosition.Header,
},
}
: undefined
}
>
) : isSiteAuthLoginHref(context.linker, target?.href) && sharedProps.href ? (
<SiteAuthLoginDropdownMenuItem {...sharedProps} href={sharedProps.href}>
{link.title}
</DropdownMenuItem>
</SiteAuthLoginDropdownMenuItem>
) : (
<DropdownMenuItem {...sharedProps}>{link.title}</DropdownMenuItem>
);
}
@@ -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 w-0 text-sm text-tint pl-4 pr-2 text-right bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base sticky -left-2;
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
.highlighted & {
@@ -353,6 +353,11 @@ html.dark .shiki span {
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
@apply table-cell text-sm px-4;
.highlight-line-number + & {
@apply pl-2;
}
}
@@ -25,6 +25,7 @@ export function Trademark(props: {
return (
<Button
data-testid="gb-trademark"
target="_blank"
variant="secondary"
size="large"
@@ -0,0 +1,76 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { removeTrailingSlash } from '@/lib/paths';
import { Button, type ButtonProps } from './Button';
import { DropdownMenuItem } from './DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from './Link';
/**
* Enrich a site auth login link with the current location relative to the site URL.
*/
function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
const pathname = rawPathname ?? '/';
return useMemo(() => {
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
resolved.pathname.replace(/\/~gitbook\/auth\/login\/?$/, '')
);
const locationPath =
siteBasePath && pathname.startsWith(`${siteBasePath}/`)
? pathname.slice(siteBasePath.length)
: pathname === siteBasePath
? '/'
: pathname;
resolved.searchParams.set(
'location',
`${locationPath}${currentSearch ? `?${currentSearch}` : ''}`
);
return href.startsWith('http')
? resolved.toString()
: `${resolved.pathname}${resolved.search}`;
}, [currentSearch, href, pathname]);
}
/**
* Link component that preserves the current page location through the auth login flow.
*/
export function SiteAuthLoginLink(props: LinkProps) {
const href = useSiteAuthLoginHrefWithLocation(props.href);
return <Link {...props} href={href} />;
}
/**
* Button variant of SiteAuthLoginLink.
*/
export function SiteAuthLoginButton(props: ButtonProps) {
const href = useSiteAuthLoginHrefWithLocation(props.href ?? '#');
return <Button {...props} href={href} />;
}
/**
* Dropdown menu item variant of SiteAuthLoginLink.
*/
export function SiteAuthLoginDropdownMenuItem(
props: {
href: string;
target?: React.HTMLAttributeAnchorTarget;
active?: boolean;
className?: string;
children: React.ReactNode;
leadingIcon?: React.ReactNode | string;
} & LinkInsightsProps
) {
const href = useSiteAuthLoginHrefWithLocation(props.href);
return <DropdownMenuItem {...props} href={href} />;
}
@@ -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>"
`;
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'bun:test';
import { isSiteAuthLoginHref } from './auth-login-link';
import { createLinker, linkerForPublishedURL } from './links';
type SiteFixture = {
label: string;
publishedURL: string;
siteBasePath: string;
localhostLoginURL: string;
previewLoginURL: string;
publishedLoginURL: string;
};
const previewHost = '41ba7c7e-gitbook-open-v2-preview.gitbook.workers.dev';
const fixtures: SiteFixture[] = [
{
label: 'proxy sites',
publishedURL: 'https://gitbook.com/docs',
siteBasePath: '/url/gitbook.com/docs',
localhostLoginURL: 'https://localhost:3000/url/gitbook.com/docs/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/gitbook.com/docs/~gitbook/auth/login`,
publishedLoginURL: 'https://gitbook.com/docs/~gitbook/auth/login',
},
{
label: 'GitBook-hosted site with a path prefix',
publishedURL: 'https://gitbook.gitbook.io/test/',
siteBasePath: '/url/gitbook.gitbook.io/test',
localhostLoginURL: 'https://localhost:3000/url/gitbook.gitbook.io/test/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/gitbook.gitbook.io/test/~gitbook/auth/login`,
publishedLoginURL: 'https://gitbook.gitbook.io/test/~gitbook/auth/login',
},
{
label: 'custom domain at the root path',
publishedURL: 'https://docs.acme.org/',
siteBasePath: '/url/docs.acme.org',
localhostLoginURL: 'https://localhost:3000/url/docs.acme.org/~gitbook/auth/login',
previewLoginURL: `https://${previewHost}/url/docs.acme.org/~gitbook/auth/login`,
publishedLoginURL: 'https://docs.acme.org/~gitbook/auth/login',
},
];
function createURLModeSiteLinker(currentHost: string, siteBasePath: string, publishedURL: string) {
const linker = createLinker({
protocol: 'https:',
host: currentHost,
siteBasePath,
spaceBasePath: `${siteBasePath}/getting-started`,
});
return linkerForPublishedURL(linker, publishedURL);
}
describe('isSiteAuthLoginHref', () => {
describe.each(fixtures)('$label', (fixture) => {
it('matches the localhost absolute login URL for the current site', () => {
const linker = createURLModeSiteLinker(
'localhost:3000',
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.localhostLoginURL)).toBe(true);
});
it('matches the workers preview absolute login URL for the current site', () => {
const linker = createURLModeSiteLinker(
previewHost,
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.previewLoginURL)).toBe(true);
});
it('matches the published-site absolute login URL when served through preview', () => {
const linker = createURLModeSiteLinker(
previewHost,
fixture.siteBasePath,
fixture.publishedURL
);
expect(isSiteAuthLoginHref(linker, fixture.publishedLoginURL)).toBe(true);
});
});
});
@@ -0,0 +1,36 @@
import type { GitBookLinker } from './links';
import { removeTrailingSlash } from './paths';
/**
* Check if an href points to the current site's auth login route.
*/
export function isSiteAuthLoginHref(
linker: Pick<GitBookLinker, 'toPathInSite' | 'toAbsoluteURL' | 'toLinkForContent'>,
href?: string | null
) {
if (!href) {
return false;
}
const relativeLoginHref = linker.toPathInSite('~gitbook/auth/login');
const absoluteLoginHref = linker.toAbsoluteURL(relativeLoginHref);
const siteRelativeHref = URL.canParse(href) ? linker.toLinkForContent(href) : href;
return (
normalizeHref(href) === normalizeHref(relativeLoginHref) ||
normalizeHref(href) === normalizeHref(absoluteLoginHref) ||
normalizeHref(siteRelativeHref) === normalizeHref(relativeLoginHref)
);
}
/**
* Normalize an href so login-route comparisons ignore trailing slashes.
*/
function normalizeHref(href: string) {
if (URL.canParse(href)) {
const url = new URL(href);
return `${url.origin}${removeTrailingSlash(url.pathname)}`;
}
return removeTrailingSlash(href);
}
+19 -8
View File
@@ -5,7 +5,7 @@ import {
throwIfDataError,
} from '@/lib/data';
import { getLogger } from '@/lib/logger';
import { getSiteStructureSections } from '@/lib/sites';
import { getLocalizedTitle, getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContent,
@@ -45,6 +45,7 @@ export type SiteURLData = Pick<
| 'siteBasePath'
| 'basePath'
| 'contextId'
| 'preview'
> & {
/**
* Identifier used for image resizing.
@@ -257,13 +258,6 @@ export async function fetchSiteContextByIds(
fetchSpaceContextByIds(baseContext, ids),
]);
// override the title with the customization title
// TODO: remove this hack once we have a proper way to handle site customizations
const site = {
...orgSite,
...(customizations.site?.title ? { title: customizations.site.title } : {}),
};
const sections = ids.siteSection
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null;
@@ -336,6 +330,23 @@ export async function fetchSiteContextByIds(
return customizations.site;
})();
// override the title with the customization title
// TODO: remove this hack once we have a proper way to handle site customizations
const site = {
...orgSite,
...(customizations.site?.title
? {
title: getLocalizedTitle(
{
title: customizations.site.title,
localizedTitle: customizations.site.localizedTitle,
},
siteSpace.space.language
),
}
: {}),
};
return {
...spaceContext,
linker: site.urls.published
+2 -2
View File
@@ -59,7 +59,7 @@ export async function ignoreDataThrownError<T>(promise: Promise<T>): Promise<T |
try {
return await promise;
} catch (error) {
getExposableError(error as Error);
console.warn('ignored Data error', getExposableError(error as Error));
return null;
}
}
@@ -181,7 +181,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 {
export function getExposableError(error: unknown): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
const cache = extractCacheControl(error);
+31 -30
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { decodeURLPath, getURLLookupAlternatives, normalizeURL } from './urls';
import { getURLLookupAlternatives, normalizeURL } from './urls';
describe('getURLLookupAlternatives', () => {
it('should return all URLs up to the root', () => {
@@ -405,12 +405,20 @@ describe('normalizeURL', () => {
new URL('https://docs.mycompany.com/hello/there')
);
});
it('should throw for URL paths exceeding 2048 characters', () => {
const longPath = '/a'.repeat(1025); // 2050 chars
const url = new URL(`https://docs.mycompany.com${longPath}`);
expect(() => {
normalizeURL(url);
}).toThrow('URL path is too long');
});
});
describe('decodeURLPath', () => {
describe('normalizeURL with encoded paths', () => {
it('should decode encoded path components', () => {
const url = new URL('https://docs.mycompany.com/helloworld/tes%74');
const result = decodeURLPath(url);
const result = normalizeURL(url);
expect(result.pathname).toBe('/helloworld/test');
expect(result.toString()).toBe('https://docs.mycompany.com/helloworld/test');
});
@@ -419,48 +427,49 @@ describe('decodeURLPath', () => {
// Double encoded: tes%2574 → tes%74 → test
// %2574 decodes as: %25 → %, leaving %74, which then decodes to t
const url = new URL('https://docs.mycompany.com/helloworld/tes%2574');
const result = decodeURLPath(url);
const result = normalizeURL(url);
expect(result.pathname).toBe('/helloworld/test');
// Triple encoding (tes%252574) exceeds the 2-pass limit and is rejected
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/tes%252574'));
}).toThrow('URL path is malformed');
// Triple encoding is also normalized through the nested normalizeURL flow.
const tripleEncoded = normalizeURL(
new URL('https://docs.mycompany.com/helloworld/tes%252574')
);
expect(tripleEncoded.pathname).toBe('/helloworld/test');
});
it('should throw for malformed percent-encoding in the path', () => {
// Invalid hex digits in percent-encoding
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/%ZZ'));
normalizeURL(new URL('https://docs.mycompany.com/helloworld/%ZZ'));
}).toThrow('URL path is malformed');
// Incomplete or invalid UTF-8 sequence
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/%E0%A4%A'));
normalizeURL(new URL('https://docs.mycompany.com/helloworld/%E0%A4%A'));
}).toThrow('URL path is malformed');
// Trailing '%' without two following hex digits
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/helloworld/trailing%'));
normalizeURL(new URL('https://docs.mycompany.com/helloworld/trailing%'));
}).toThrow('URL path is malformed');
});
it.skip('should throw an error for invalid characters in the path', () => {
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello:world'));
normalizeURL(new URL('https://docs.mycompany.com/hello:world'));
}).toThrow('URL path contains invalid characters');
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%3Btest'));
normalizeURL(new URL('https://docs.mycompany.com/hello%3Btest'));
}).toThrow('URL path contains invalid characters');
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%40anchor'));
normalizeURL(new URL('https://docs.mycompany.com/hello%40anchor'));
}).toThrow('URL path contains invalid characters');
// %20 (space) re-encodes to %20 after decoding, so the path is stable
// and considered fully decoded — it should not throw.
expect(decodeURLPath(new URL('https://docs.mycompany.com/hello%20world')).pathname).toBe(
expect(normalizeURL(new URL('https://docs.mycompany.com/hello%20world')).pathname).toBe(
'/hello%20world'
);
});
@@ -470,33 +479,25 @@ describe('decodeURLPath', () => {
// %25252525 needs 4 passes: %25252525 → %252525 → %2525 → %25 → %
const url = new URL('https://docs.mycompany.com/%25252525');
expect(() => {
decodeURLPath(url);
normalizeURL(url);
}).toThrow('URL path is malformed');
const deepUrl = new URL('https://docs.mycompany.com/%2525252525252525');
expect(() => {
decodeURLPath(deepUrl);
normalizeURL(deepUrl);
}).toThrow('URL path is malformed');
});
it('should throw for URL paths exceeding 2048 characters', () => {
const longPath = '/a'.repeat(1025); // 2050 chars
const url = new URL(`https://docs.mycompany.com${longPath}`);
expect(() => {
decodeURLPath(url);
}).toThrow('URL path is too long');
});
// TODO: should we do that actually?
it.skip('should throw an error if the encoded path contains /', () => {
expect(() => {
decodeURLPath(new URL('https://docs.mycompany.com/hello%2Fworld'));
normalizeURL(new URL('https://docs.mycompany.com/hello%2Fworld'));
}).toThrow('URL path contains invalid characters');
});
it('should not decode search params or hash fragments', () => {
const url = new URL('https://docs.mycompany.com/helloworld/tes%74?query=%74est#sec%74ion');
const result = decodeURLPath(url);
const result = normalizeURL(url);
expect(result.pathname).toBe('/helloworld/test');
expect(result.search).toBe('?query=%74est');
expect(result.hash).toBe('#sec%74ion');
@@ -508,7 +509,7 @@ describe('decodeURLPath', () => {
const url = new URL(`https://docs.mycompany.com/short-path?jwt_token=${fakeJwt}`);
// The path itself is well within the limit; only the query param is huge.
expect(url.pathname.length).toBeLessThan(2048);
const result = decodeURLPath(url);
const result = normalizeURL(url);
expect(result.pathname).toBe('/short-path');
// The query string must pass through untouched.
expect(result.searchParams.get('jwt_token')).toBe(fakeJwt);
@@ -521,9 +522,9 @@ describe('decodeURLPath', () => {
const url = new URL(
`https://docs.mycompany.com/some-page?filter=${encodeURIComponent(risonValue)}`
);
const result = decodeURLPath(url);
const result = normalizeURL(url);
expect(result.pathname).toBe('/some-page');
// The rison param must survive decodeURLPath intact.
// The rison param must survive normalizeURL intact.
expect(result.searchParams.get('filter')).toBe(risonValue);
});
});
+28 -40
View File
@@ -1,5 +1,5 @@
import { isProxyRootRequest } from '../proxy';
import { DataFetcherError } from './errors';
import { DataFetcherError, getExposableError } from './errors';
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
@@ -118,46 +118,39 @@ export function getURLLookupAlternatives(input: URL) {
return { urls: alternatives, basePath, changeRequest, revision };
}
/**
* Normalize the URL in a request and redirect if the normalized URL is different from the original one.
*/
export function normalizeRequestURL(url: URL): Response | null {
try {
const normalizedURL = normalizeURL(url);
if (normalizedURL.toString() !== url.toString()) {
return Response.redirect(normalizedURL.toString(), 302);
}
return null;
} catch (error) {
const sanitized = getExposableError(error);
return new Response(sanitized.message, { status: sanitized.code });
}
}
/**
* Normalize a URL to remove duplicate slashes and trailing slashes
* and transform the pathname to lowercase.
*/
export function normalizeURL(url: URL) {
const result = new URL(url);
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
return result;
}
/**
* This function checks if a decoded URL path segment contains characters that are not allowed
* in GitBook content paths. These characters are valid in generic RFC 3986 URL paths, but are
* rejected here as an application-level constraint for GitBook routing and security.
* https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding
* "%" itself is excluded because it could be part of percent-encoding and may require decoding.
*/
// function containsInvalidURLCharacters(segment: string): boolean {
// const invalidCharacters = [
// ':',
// '/',
// '?',
// '#',
// '[',
// ']',
// '@',
// '!',
// '$',
// '&',
// "'",
// '(',
// ')',
// '*',
// '+',
// ',',
// ';',
// '=',
// ];
// return invalidCharacters.some((char) => segment.includes(char));
// }
// Reject excessively long paths up-front to bound per-request work.
if (url.pathname.length > 2048) {
throw new DataFetcherError('URL path is too long.', 400);
}
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
return decodeURLPath(result);
}
/**
* Decode the url path component, we redirect URLs with encoded path components
@@ -167,12 +160,7 @@ export function normalizeURL(url: URL) {
* percent-encoding. Legitimate URLs are at most singly encoded; double-encoding covers
* any reasonable proxy behaviour.
*/
export function decodeURLPath(url: URL): URL {
// Reject excessively long paths up-front to bound per-request work.
if (url.pathname.length > 2048) {
throw new DataFetcherError(`URL path is too long: ${url.pathname}`, 400);
}
function decodeURLPath(url: URL): URL {
let current = url;
for (let i = 0; i < 2; i++) {
@@ -6,6 +6,7 @@ import { getDataOrNull } from './data';
import { getNodeReactText } from './document';
import { resolveOpenAPIOperationBlock } from './openapi/resolveOpenAPIOperationBlock';
import { resolveOpenAPISchemasBlock } from './openapi/resolveOpenAPISchemasBlock';
import { resolveOpenAPIWebhookBlock } from './openapi/resolveOpenAPIWebhookBlock';
import { resolveContentRef } from './references';
export interface DocumentSection {
@@ -99,6 +100,25 @@ async function getSectionsFromNodes(
}
continue;
}
case 'openapi-webhook': {
const id = block.meta?.id;
if (!id) {
continue;
}
const { data: webhook } = await resolveOpenAPIWebhookBlock({
block,
context,
});
if (webhook) {
sections.push({
id,
title: webhook.operation.summary || webhook.name,
depth: 1,
deprecated: webhook.operation.deprecated,
});
}
continue;
}
case 'openapi-schemas': {
const id = block.meta?.id;
if (!id) {
+5 -1
View File
@@ -25,7 +25,11 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
if (block.data && 'fullWidth' in block.data && block.data.fullWidth) {
return true;
}
if (block.type === 'swagger' || block.type === 'openapi-operation') {
if (
block.type === 'swagger' ||
block.type === 'openapi-operation' ||
block.type === 'openapi-webhook'
) {
return true;
}
return false;
+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.
@@ -8,7 +8,7 @@ describe('getImageResizingContextId', () => {
});
it('should return preview identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
const previewRequestURL = new URL('https://sites.gitbook.com/preview/site_foo/hello/world');
expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo');
});
+8 -8
View File
@@ -20,9 +20,9 @@ const siteGitBookIO = createLinker({
});
const preview = createLinker({
host: 'preview',
spaceBasePath: '/site_abc/section/space/',
siteBasePath: '/site_abc/',
host: 'sites.gitbook.com',
spaceBasePath: '/preview/site_abc/section/space/',
siteBasePath: '/preview/site_abc/',
});
describe('toPathInSpace', () => {
@@ -156,11 +156,11 @@ describe('linkerForPublishedURL', () => {
it('should rewrite links that belongs to the published site to be part of the preview site', () => {
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path')).toBe(
'/site_abc/some/path'
'/preview/site_abc/some/path'
);
expect(
previewLinker.toLinkForContent('https://docs.company.com/section/variant/some/path')
).toBe('/site_abc/section/variant/some/path');
).toBe('/preview/site_abc/section/variant/some/path');
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
'https://www.google.com'
);
@@ -175,12 +175,12 @@ describe('linkerForPublishedURL', () => {
);
expect(
previewLinker.toLinkForContent('https://org.gitbook.io/sitename/some/path')
).toBe('/site_abc/some/path');
).toBe('/preview/site_abc/some/path');
expect(
previewLinker.toLinkForContent(
'https://org.gitbook.io/sitename/section/variant/some/path'
)
).toBe('/site_abc/section/variant/some/path');
).toBe('/preview/site_abc/section/variant/some/path');
expect(previewLinker.toLinkForContent('https://www.google.com')).toBe(
'https://www.google.com'
);
@@ -190,7 +190,7 @@ describe('linkerForPublishedURL', () => {
it('should should preserve hash and search', () => {
const previewLinker = linkerForPublishedURL(preview, 'https://docs.company.com/');
expect(previewLinker.toLinkForContent('https://docs.company.com/some/path?a=b#c')).toBe(
'/site_abc/some/path?a=b#c'
'/preview/site_abc/some/path?a=b#c'
);
});
});
+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;
}
+15 -42
View File
@@ -1,6 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { DataFetcherResponse } from '@/lib/data';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
import { DataFetcherError } from '@/lib/data';
import type { ResolvedPagePath } from '@/lib/pages';
import { getIndexablePages } from '@/lib/sitemap';
import { getMarkdownForPagesTree } from '@/routes/llms';
import { type RevisionPageDocument, type RevisionPageGroup, RevisionPageType } from '@gitbook/api';
@@ -14,37 +14,22 @@ import { gfm } from 'micromark-extension-gfm';
import { remove } from 'unist-util-remove';
import { type GitBookLinker, relativeToAbsoluteLinks } from './links';
type MarkdownResult = DataFetcherResponse<string>;
/**
* Generate a markdown version of a page.
* Handles both regular document pages and group pages (pages with child pages).
*/
export async function getMarkdownForPage(
context: GitBookSiteContext,
pagePath: string
): Promise<MarkdownResult> {
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath);
if (!pageLookup) {
return {
error: {
message: `Page "${pagePath}" not found`,
code: 404,
},
};
}
pageLookup: ResolvedPagePath<RevisionPageDocument | RevisionPageGroup>
): Promise<string> {
const { page } = pageLookup;
// Only handle documents and groups
if (page.type !== RevisionPageType.Document && page.type !== RevisionPageType.Group) {
return {
error: {
message: `Page "${pagePath}" is not a document or group`,
code: 400,
},
};
throw new DataFetcherError(
`Page "${pageLookup.page.title}" is not a document or group`,
400
);
}
// Handle group pages
@@ -59,12 +44,7 @@ export async function getMarkdownForPage(
});
if (error) {
return {
error: {
message: 'An error occurred while fetching the markdown for this page',
code: 500,
},
};
throw error;
}
const tree = fromPageMarkdown({
@@ -78,7 +58,7 @@ export async function getMarkdownForPage(
return servePageGroup(context, page);
}
return { data: toPageMarkdown(tree) };
return toPageMarkdown(tree);
}
/**
@@ -150,15 +130,10 @@ function isEmptyMarkdownPage(tree: Root): boolean {
async function servePageGroup(
context: GitBookSiteContext,
page: RevisionPageDocument | RevisionPageGroup
): Promise<MarkdownResult> {
): Promise<string> {
const siteSpaceUrl = context.space.urls.published;
if (!siteSpaceUrl) {
return {
error: {
message: `Page "${page.title}" is not published`,
code: 404,
},
};
throw new DataFetcherError(`Page "${page.title}" is not published`, 404);
}
const indexablePages = getIndexablePages(page.pages);
@@ -180,9 +155,7 @@ async function servePageGroup(
],
};
return {
data: toMarkdown(markdownTree, {
bullet: '-',
}),
};
return toMarkdown(markdownTree, {
bullet: '-',
});
}
@@ -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 -1
View File
@@ -8,7 +8,7 @@ import {
export type AncestorRevisionPage = RevisionPageDocument | RevisionPageGroup;
type ResolvedPagePath<Page extends RevisionPageDocument | RevisionPageGroup> = {
export type ResolvedPagePath<Page extends RevisionPageDocument | RevisionPageGroup> = {
page: Page;
ancestors: AncestorRevisionPage[];
};
+7 -4
View File
@@ -3,19 +3,22 @@ import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
const previewRequestURL = new URL('https://sites.gitbook.com/preview/site_foo/hello/world');
expect(isPreviewRequest(previewRequestURL)).toBe(true);
});
it('should return false for non-preview requests', () => {
const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false);
const nonPreviewRequestURL1 = new URL('https://example.com/docs/foo/hello/world');
expect(isPreviewRequest(nonPreviewRequestURL1)).toBe(false);
const previewRequestURL2 = new URL('https://preview/site_foo/hello/world');
expect(isPreviewRequest(previewRequestURL2)).toBe(false);
});
});
describe('getPreviewRequestIdentifier', () => {
it('should return the correct identifier for preview requests', () => {
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
const previewRequestURL = new URL('https://sites.gitbook.com/preview/site_foo/hello/world');
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
});
});
+61 -5
View File
@@ -1,13 +1,69 @@
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';
const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL);
return (
requestURL.host === gitbookPreviewBaseURL.host &&
requestURL.pathname.startsWith(gitbookPreviewBaseURL.pathname)
);
}
/**
* Get the preview request site identifier from the request URL.
*/
export function getPreviewRequestIdentifier(requestURL: URL): string {
// 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]!;
if (isPreviewRequest(requestURL)) {
const siteIdentifier = requestURL.pathname.split('/').filter(Boolean)[1];
assert(siteIdentifier, 'Expected site identifier in preview URL');
return siteIdentifier;
}
throw new Error('Not a preview request');
}
/**
* 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 gitbookPreviewHostPath =
gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, '');
return `/url/${gitbookPreviewHostPath}/${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,
},
};
}
+57 -52
View File
@@ -458,63 +458,68 @@ async function resolveContentRefInSpace(
contentRef: ContentRef,
options: ResolveContentRefOptions = {}
) {
const ctx = await createContextForSpace(spaceId, {
...context,
shareKey: (() => {
// If the space is found in the current site, we use the current share key to generate links.
if ('site' in context) {
return findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
)
? context.shareKey
: undefined;
}
try {
const ctx = await createContextForSpace(spaceId, {
...context,
shareKey: (() => {
// If the space is found in the current site, we use the current share key to generate links.
if ('site' in context) {
return findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
)
? context.shareKey
: undefined;
}
return context.space.id === spaceId ? context.shareKey : undefined;
})(),
});
return context.space.id === spaceId ? context.shareKey : undefined;
})(),
});
if (!ctx) {
return null;
}
const resolved = await resolveContentRef(contentRef, ctx.spaceContext, options);
if (!resolved) {
return null;
}
// 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
);
if (foundSiteSpace?.siteSpace) {
return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage);
}
if (foundSiteSpace?.siteSection) {
return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage);
}
return ctx.spaceContext.space.title;
if (!ctx) {
return null;
}
return ctx.spaceContext.space.title;
})();
const resolved = await resolveContentRef(contentRef, ctx.spaceContext, options);
return {
...resolved,
ancestors: [
{
label: ancestorLabel,
href: ctx.baseURL.toString(),
},
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
if (!resolved) {
return null;
}
// 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
);
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;
})();
return {
...resolved,
ancestors: [
{
label: ancestorLabel,
href: ctx.baseURL.toString(),
},
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
} catch (error) {
console.warn(`Error resolving content ref in space ${spaceId}:`, error);
return null;
}
}
/**
+4 -4
View File
@@ -1,6 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import type {
LocalizedString,
LocalizedTitle,
SiteSection,
SiteSectionGroup,
SiteSpace,
@@ -189,7 +189,7 @@ function findSiteSpaceByIdInSiteSpaces(
* Get the localized title for a site entity (SiteSection, SiteSectionGroup, or SiteSpace).
*/
export function getLocalizedTitle(
entity: { title: string; localizedTitle?: LocalizedString },
entity: { title: string; localizedTitle?: LocalizedTitle },
currentLanguage: TranslationLanguage | undefined
): string {
return getLocalizedField(entity.localizedTitle, currentLanguage) ?? entity.title;
@@ -199,7 +199,7 @@ export function getLocalizedTitle(
* Get the localized description for a site entity.
*/
export function getLocalizedDescription(
entity: { description?: string; localizedDescription?: LocalizedString },
entity: { description?: string; localizedDescription?: LocalizedTitle },
currentLanguage: TranslationLanguage | undefined
): string | undefined {
return getLocalizedField(entity.localizedDescription, currentLanguage) ?? entity.description;
@@ -209,7 +209,7 @@ export function getLocalizedDescription(
* Get a localized field value for the given language.
*/
function getLocalizedField(
localizedField: LocalizedString | undefined,
localizedField: LocalizedTitle | undefined,
currentLanguage: TranslationLanguage | undefined
): string | undefined {
if (localizedField && currentLanguage && localizedField[currentLanguage]) {
+14 -6
View File
@@ -2,6 +2,7 @@ import type * as api from '@gitbook/api';
import type { headers as nextHeaders } from 'next/headers';
import { apiClient } from './data/api';
import { GITBOOK_DISABLE_TRACKING } from './env';
import { getLogger } from './logger';
/**
* Return true if events should be tracked on the site.
@@ -52,7 +53,9 @@ const defaultLocation: api.SiteInsightsEventLocation = {
* Extract a full session object from a request.
* Generates new sessionId/visitorId and extracts headers.
*/
function extractSessionFromRequest(request: Request): api.SiteInsightsEventSession {
function extractSessionFromRequest(
request: Pick<Request, 'headers'>
): api.SiteInsightsEventSession {
return {
sessionId: crypto.randomUUID(),
visitorId: crypto.randomUUID(),
@@ -72,22 +75,27 @@ export async function trackServerInsightsEvents(args: {
organizationId: string;
siteId: string;
events: ServerInsightsEventInput[];
request: Request;
request: Pick<Request, 'headers' | 'url'>;
}) {
const { organizationId, siteId, events, request } = args;
const logger = getLogger().subLogger('tracking');
logger.info(
`Tracking ${args.events.length} events at ${request.url} for site ${args.siteId} (enabled=${!GITBOOK_DISABLE_TRACKING})`
);
if (GITBOOK_DISABLE_TRACKING) {
return;
}
const { organizationId, siteId, events, request } = args;
const api = apiClient();
const geolocation = extractGeolocation(request);
const requestSession = extractSessionFromRequest(request);
const locationURL = request.url;
const fullEvents: api.SiteInsightsEvent[] = events.map((event) => ({
...event,
session: { ...requestSession, ...event.session },
location: { ...defaultLocation, url: request.url, ...event.location },
location: { ...defaultLocation, url: locationURL, ...event.location },
timestamp: event.timestamp ?? new Date().toISOString(),
})) as api.SiteInsightsEvent[];
@@ -179,7 +187,7 @@ function getXForwardedFor(headers: Headers): string | null {
/**
* Extract geolocation headers from a request (Vercel/OpenNext).
*/
function extractGeolocation(req: Request): Record<string, string> {
function extractGeolocation(req: Pick<Request, 'headers'>): Record<string, string> {
const country =
req.headers.get('x-open-next-country') || req.headers.get('x-vercel-ip-country');
const latitude =
+156 -80
View File
@@ -1,23 +1,36 @@
import { CustomizationThemeMode } from '@gitbook/api';
import {
CustomizationThemeMode,
SiteInsightsDisplayContext,
SiteInsightsLLMSVariant,
} from '@gitbook/api';
import Negotiator from 'negotiator';
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 {
DataFetcherError,
decodeURLPath,
getVisitorAuthBasePath,
lookupPublishedContentByUrl,
normalizeURL,
normalizeRequestURL,
throwIfDataError,
} from '@/lib/data';
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,
@@ -26,15 +39,14 @@ import {
normalizeVisitorURL,
serveVisitorClaimsDataRequest,
} from '@/lib/visitors';
import { waitUntil } from '@/lib/waitUntil';
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';
type ServerInsightsEventInput,
serveProxyAnalyticsEvent,
trackServerInsightsEvents,
} from './lib/tracking';
export const config = {
matcher: [
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
@@ -47,18 +59,6 @@ export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
// Redirect to normalize the URL
const normalized = normalizeURL(requestURL);
if (normalized.toString() !== requestURL.toString()) {
return NextResponse.redirect(normalized.toString());
}
// If the URL path is encoded, decode it and redirect to the decoded URL
const decoded = decodeURLPath(requestURL);
if (decoded.toString() !== requestURL.toString()) {
return NextResponse.redirect(decoded.toString());
}
// Reject malicious requests
const rejectResponse = await validateServerActionRequest(request);
if (rejectResponse) {
@@ -139,6 +139,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
}
const { url: siteRequestURL, mode } = match;
// Normalize URL after extracting the URL from the request to make sure the client is redirected to the proper one
const normalizationResponse = normalizeRequestURL(siteRequestURL);
if (normalizationResponse) {
return normalizationResponse;
}
const imagesContextId = getImageResizingContextId(siteRequestURL);
/**
* Serve image resizing requests (all requests containing `/~gitbook/image`).
@@ -340,6 +347,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 +372,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 +390,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
@@ -406,12 +414,27 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname,
request
);
const {
pathname,
routeType: routeTypeFromPathname,
events,
} = encodePathInSiteContent(siteURLData.pathname, request);
routeType = routeTypeFromPathname ?? routeType;
if (events && events.length > 0) {
waitUntil(
trackServerInsightsEvents({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
events,
request: {
url: siteRequestURL.toString(),
headers: requestHeaders,
},
})
);
}
const route = [
'sites',
routeType,
@@ -462,17 +485,19 @@ 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://<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 +512,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 +545,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 +577,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);
}
/**
@@ -623,6 +651,7 @@ function encodePathInSiteContent(
): {
pathname: string;
routeType?: 'static' | 'dynamic';
events?: ServerInsightsEventInput[] | undefined;
} {
const pathname = removeLeadingSlash(removeTrailingSlash(rawPathname));
@@ -636,12 +665,32 @@ function encodePathInSiteContent(
return {
pathname: `~gitbook/rss/${encodePagePath(rssMatch[2])}`,
routeType: 'static',
events: [
{
type: 'rss_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
};
}
// We skip encoding for paginated llms-full.txt pages (i.e. llms-full.txt/100)
if (pathname.match(LLMS_FULL_PATH_REGEX)) {
return { pathname, routeType: 'static' };
return {
pathname,
routeType: 'static',
events: [
{
type: 'llms_request',
llmsVariant: SiteInsightsLLMSVariant.Full,
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
};
}
// If the pathname is an embedded page
@@ -657,21 +706,38 @@ function encodePathInSiteContent(
case '~gitbook/embed/assistant':
case '~gitbook/icon':
return { pathname };
case '~gitbook/mcp':
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
// as they only depend on the site structure / pages.
case 'llms.txt':
case 'llms-full.txt':
return {
pathname,
routeType: 'static',
events: [
{
type: 'llms_request',
llmsVariant:
pathname === 'llms.txt'
? SiteInsightsLLMSVariant.Standard
: SiteInsightsLLMSVariant.Full,
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
};
case '~gitbook/mcp':
case 'sitemap.xml':
case 'sitemap-pages.xml':
case 'robots.txt':
case '~gitbook/embed/script.js':
case '~gitbook/embed/demo':
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
// as they only depend on the site structure / pages.
return { pathname, routeType: 'static' };
case '~gitbook/pdf':
case '~gitbook/search':
case '~gitbook/auth/login':
case '~scalar/proxy':
// PDF and search routes are always dynamic as they depend on the request.
// PDF, search and auth routes are always dynamic as they depend on the request.
return { pathname, routeType: 'dynamic' };
default: {
// If the pathname is a markdown file or the request is accepting markdown,
@@ -682,6 +748,16 @@ function encodePathInSiteContent(
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
events: [
{
type: 'page_markdown_request',
// TODO: track pageId / spaceId when possible
// We don't do it at the moment as we can't easily extract it from the URL.
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
};
}
return { pathname: encodePagePath(pathname) };
+11 -13
View File
@@ -1,6 +1,7 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull } from '@/lib/data';
import { DataFetcherError, getExposableError } from '@/lib/data';
import { getMarkdownForPage } from '@/lib/markdownPage';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
/**
* Serve a markdown version of a page.
@@ -8,26 +9,23 @@ import { getMarkdownForPage } from '@/lib/markdownPage';
*/
export async function servePageMarkdown(context: GitBookSiteContext, pagePath: string) {
try {
const result = await getDataOrNull(getMarkdownForPage(context, pagePath));
if (!result) {
return new Response('Page not found', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
const pageLookup = resolvePagePathDocumentOrGroup(context.revision.pages, pagePath);
if (!pageLookup) {
throw new DataFetcherError(`Page "${pagePath}" not found`, 404);
}
return new Response(result, {
const markdown = await getMarkdownForPage(context, pageLookup);
return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Robots-Tag': 'noindex',
},
});
} catch (error) {
console.error('Error serving markdown page:', error);
return new Response('Internal Server Error', {
status: 500,
const exposable = getExposableError(error);
return new Response(exposable.message, {
status: exposable.code,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
+2 -1
View File
@@ -4,7 +4,8 @@ import { getPageDocument } from '@/lib/data/pages';
import { getBlocksByType, getNodeText, isHeadingBlock } from '@/lib/document';
import { resolvePagePathDocumentOrGroup } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { type RevisionPageDocument, RevisionPageType } from '@gitbook/api';
import type { RevisionPageDocument } from '@gitbook/api';
import { RevisionPageType } from '@gitbook/api';
import { Feed } from 'feed';
/**
+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.
*/
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { getSchemaAlternatives } from './OpenAPISchema';
import { getSchemaAlternatives, getSchemaProperties } from './OpenAPISchema';
describe('getSchemaAlternatives', () => {
it('should flatten oneOf', () => {
@@ -381,6 +381,168 @@ describe('getSchemaAlternatives', () => {
});
});
it('should merge annotation-only schema into object schema', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
required: ['id'],
},
{
description: 'Overridden description',
} as any,
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
required: ['id'],
description: 'Overridden description',
},
],
});
});
it('should preserve parent metadata when flattening allOf inside oneOf alternative', () => {
const result = getSchemaAlternatives({
oneOf: [
{
title: 'Option A',
description: 'First option',
'x-custom': 'custom-value',
allOf: [
{
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
},
{
type: 'object',
properties: {
age: { type: 'integer' },
},
},
],
},
{
type: 'string',
},
],
});
expect(result).toEqual({
type: 'oneOf',
schemas: [
{
title: 'Option A',
description: 'First option',
'x-custom': 'custom-value',
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'integer' },
},
required: ['name'],
},
{
type: 'string',
},
],
});
});
it('should merge annotation-only schema with multiple safe extensions', () => {
expect(
getSchemaAlternatives({
allOf: [
{
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
description: 'Original description',
},
{
description: 'Overridden description',
title: 'Overridden title',
deprecated: true,
} as any,
],
})
).toEqual({
type: 'allOf',
schemas: [
{
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
description: 'Overridden description',
title: 'Overridden title',
deprecated: true,
},
],
});
});
it('should preserve parent metadata when flattening allOf inside anyOf alternative', () => {
const result = getSchemaAlternatives({
anyOf: [
{
title: 'Variant B',
description: 'A variant with extensions',
'x-deprecated-reason': 'use v2',
allOf: [
{
type: 'object',
properties: {
id: { type: 'string' },
},
},
{
type: 'object',
properties: {
value: { type: 'number' },
},
},
],
},
],
});
expect(result).toEqual({
type: 'anyOf',
schemas: [
{
title: 'Variant B',
description: 'A variant with extensions',
'x-deprecated-reason': 'use v2',
type: 'object',
properties: {
id: { type: 'string' },
value: { type: 'number' },
},
required: [],
},
],
});
});
it('should NOT merge objects with unsafe properties', () => {
expect(
getSchemaAlternatives({
@@ -520,3 +682,85 @@ describe('getSchemaAlternatives', () => {
});
});
});
describe('getSchemaProperties', () => {
it('should merge required fields from allOf schemas', () => {
const schema: OpenAPIV3.SchemaObject = {
allOf: [
{
type: 'object',
properties: { id: { type: 'integer' } },
required: ['id'],
},
{
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
},
],
};
const result = getSchemaProperties(schema);
expect(result?.find((p) => p.propertyName === 'id')?.required).toBe(true);
expect(result?.find((p) => p.propertyName === 'name')?.required).toBe(true);
});
it('should deep-merge overlapping properties from allOf schemas', () => {
const schema: OpenAPIV3.SchemaObject = {
allOf: [
{
type: 'object',
properties: {
tags: {
type: 'array',
items: {
type: 'object',
properties: {
color: { type: 'string' },
category: {
type: 'object',
properties: { icon: { type: 'string' } },
},
},
},
},
},
},
{
type: 'object',
properties: {
tags: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
category: {
type: 'object',
properties: { name: { type: 'string' } },
},
},
},
},
},
},
],
};
const result = getSchemaProperties(schema);
const tagItemProps =
(
result?.find((p) => p.propertyName === 'tags')?.schema
?.items as OpenAPIV3.SchemaObject
)?.properties ?? {};
// Array items merged from both branches
expect(tagItemProps).toHaveProperty('id');
expect(tagItemProps).toHaveProperty('color');
// Nested object properties also deep-merged
const categoryProps = (tagItemProps.category as OpenAPIV3.SchemaObject)?.properties ?? {};
expect(categoryProps).toHaveProperty('name');
expect(categoryProps).toHaveProperty('icon');
});
});
+116 -31
View File
@@ -75,15 +75,28 @@ function OpenAPISchemaProperty(
const circularRefs = new Map(parentCircularRefs);
circularRefs.set(schema, id);
const properties = getSchemaProperties(schema, discriminator, discriminatorValue);
const ancestors = new Set(circularRefs.keys());
const alternatives = getSchemaAlternatives(schema, ancestors);
const header = <OpenAPISchemaPresentation id={id} context={context} property={property} />;
// When allOf fully merges into a single schema, use it directly
// instead of rendering as "allOf" with one child.
const mergedAllOfSchema =
alternatives?.type === 'allOf' && alternatives.schemas.length === 1
? alternatives.schemas[0]
: undefined;
const effectiveSchema = mergedAllOfSchema ?? schema;
const effectiveProperty = mergedAllOfSchema
? { ...property, schema: mergedAllOfSchema }
: property;
const properties = getSchemaProperties(effectiveSchema, discriminator, discriminatorValue);
const header = (
<OpenAPISchemaPresentation id={id} context={context} property={effectiveProperty} />
);
const content = (() => {
// For oneOf/anyOf, show alternatives. For allOf, merge properties instead
if (alternatives?.schemas && alternatives.schemas.length > 0) {
// For oneOf/anyOf/allOf with multiple schemas, show alternatives
if (!mergedAllOfSchema && alternatives?.schemas && alternatives.schemas.length > 0) {
return (
<OpenAPISchemaAlternatives
alternatives={alternatives}
@@ -114,7 +127,9 @@ function OpenAPISchemaProperty(
<OpenAPIDisclosure
icon={context.icons.plus}
header={header}
label={(isExpanded) => getDisclosureLabel({ schema, isExpanded, context })}
label={(isExpanded) =>
getDisclosureLabel({ schema: effectiveSchema, isExpanded, context })
}
>
{content}
</OpenAPIDisclosure>
@@ -191,16 +206,24 @@ function OpenAPIRootSchema(props: {
} = props;
const id = useId();
const properties = getSchemaProperties(schema);
const description = resolveDescription(schema);
const ancestors = new Set(parentCircularRefs.keys());
const alternatives = getSchemaAlternatives(schema, ancestors);
// When allOf fully merges into a single schema, use it directly
const mergedAllOfSchema =
alternatives?.type === 'allOf' && alternatives.schemas.length === 1
? alternatives.schemas[0]
: undefined;
const effectiveSchema = mergedAllOfSchema ?? schema;
const properties = getSchemaProperties(effectiveSchema);
const description = resolveDescription(effectiveSchema);
const circularRefs = new Map(parentCircularRefs);
circularRefs.set(schema, id);
// Handle root-level oneOf/anyOf (allOf is handled by merging properties in getSchemaProperties)
if (alternatives?.schemas && alternatives.schemas.length > 0) {
// Handle root-level oneOf/anyOf/allOf with multiple schemas
if (!mergedAllOfSchema && alternatives?.schemas && alternatives.schemas.length > 0) {
return (
<>
{description ? (
@@ -623,7 +646,7 @@ function processSchemaProperties(
}
/**
* Merge properties into a result array, with later properties overriding earlier ones.
* Merge properties into a result array, deep-merging overlapping schemas.
*/
function mergeProperties(
result: OpenAPISchemaPropertyEntry[],
@@ -632,17 +655,78 @@ function mergeProperties(
for (const prop of newProperties) {
const existingIndex = result.findIndex((p) => p.propertyName === prop.propertyName);
if (existingIndex >= 0) {
result[existingIndex] = prop;
const existing = result[existingIndex];
if (existing) {
result[existingIndex] = {
...existing,
...prop,
schema: deepMergeSchemas(existing.schema, prop.schema),
required: prop.required ?? existing.required,
};
}
} else {
result.push(prop);
}
}
}
/**
* Deep-merge two schemas by combining their properties recursively.
* schema2 takes precedence for metadata and property order.
*/
function deepMergeSchemas(
schema1: OpenAPIV3.SchemaObject,
schema2: OpenAPIV3.SchemaObject,
seen: WeakSet<OpenAPIV3.SchemaObject> = new WeakSet()
): OpenAPIV3.SchemaObject {
if (seen.has(schema1) || seen.has(schema2)) {
return { ...schema1, ...schema2 };
}
seen.add(schema1);
seen.add(schema2);
if (schema1.properties && schema2.properties) {
const mergedProperties: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject> =
{ ...schema2.properties };
for (const [key, value] of Object.entries(schema1.properties)) {
const existing = mergedProperties[key];
if (existing && !checkIsReference(existing) && !checkIsReference(value)) {
mergedProperties[key] = deepMergeSchemas(value, existing, seen);
} else if (!existing) {
mergedProperties[key] = value;
}
}
return {
...schema1,
...schema2,
properties: mergedProperties,
};
}
if (
schema1.type === 'array' &&
schema2.type === 'array' &&
schema1.items &&
schema2.items &&
!checkIsReference(schema1.items) &&
!checkIsReference(schema2.items)
) {
return {
...schema1,
...schema2,
items: deepMergeSchemas(schema1.items, schema2.items, seen),
};
}
return { ...schema1, ...schema2 };
}
/**
* Get the sub-properties of a schema.
*/
function getSchemaProperties(
export function getSchemaProperties(
schema: OpenAPIV3.SchemaObject,
discriminator?: OpenAPIV3.DiscriminatorObject | undefined,
discriminatorValue?: string | undefined
@@ -830,15 +914,14 @@ const safeExtensions = [
function isSafeToMerge(schema: OpenAPIV3.SchemaObject): boolean {
const keys = Object.keys(schema);
const coreProperties = ['type', 'properties', 'required', 'nullable'];
const safeProperties = ['type', 'properties', 'required', 'nullable'];
const coreKeys = keys.filter((key) => coreProperties.includes(key));
const unknownKeys = keys.filter(
(key) =>
!coreProperties.includes(key) && !safeExtensions.includes(key) && !key.startsWith('x-')
!safeProperties.includes(key) && !safeExtensions.includes(key) && !key.startsWith('x-')
);
return coreKeys.length > 0 && unknownKeys.length === 0;
return unknownKeys.length === 0;
}
/**
@@ -867,7 +950,8 @@ function mergeAlternatives(
return acc;
}
acc.push(schemaOrRef);
// Clone to avoid mutating shared schema objects from $ref dereferencing
acc.push({ ...schemaOrRef });
return acc;
}, []);
}
@@ -890,7 +974,11 @@ function mergeAlternatives(
}
}
if (latest && latest.type === 'object' && schemaOrRef.type === 'object') {
if (
latest &&
latest.type === 'object' &&
(!schemaOrRef.type || schemaOrRef.type === 'object')
) {
const keys = Object.keys(schemaOrRef);
if (isSafeToMerge(schemaOrRef)) {
@@ -930,7 +1018,8 @@ function mergeAlternatives(
}
}
acc.push(schemaOrRef);
// Clone to avoid mutating shared schema objects from $ref dereferencing
acc.push({ ...schemaOrRef });
return acc;
}, []);
}
@@ -1020,18 +1109,14 @@ function flattenSchema(
if (merged && merged.length > 0) {
// Only merge if all schemas were successfully merged into one (safe to merge)
if (merged.length === 1) {
// Merge the parent schema's own fields (title, description, etc.)
// onto the merged result, so the parent's overrides take precedence.
const { allOf: _, ...schemaWithoutAllOf } = schema;
return merged.map((s) => {
const required = mergeRequiredFields(s, latestAncestor);
const result: OpenAPIV3.SchemaObject = {
...s,
...(required ? { required } : {}),
};
if (schema.title && !s.title) {
result.title = schema.title;
}
return result;
const result = mergeTwoSchemas(s, schemaWithoutAllOf);
const required = mergeRequiredFields(result, latestAncestor);
return { ...result, ...(required ? { required } : {}) };
});
}
}
@@ -45,7 +45,7 @@ export function OpenAPIWebhookExample(props: {
})();
return (
<div className="openapi-panel">
<div className="openapi-panel" data-follow-color-scheme="true">
<h4 className="openapi-panel-heading">Payload</h4>
<div className="openapi-panel-body">
<OpenAPIMediaTypeContent
@@ -39,7 +39,7 @@ export function OpenAPISchemas(props: {
if (schemas.length === 1 && !grouped) {
const title = `The ${firstSchema.name} object`;
return (
<div className={clsx('openapi-schemas', className)}>
<div className={clsx('openapi-schemas openapi-schemas-single', className)}>
<div className="openapi-summary" id={context.id}>
{context.renderHeading({
title,
@@ -61,7 +61,7 @@ export function OpenAPISchemas(props: {
</div>
<div className="openapi-column-preview">
<div className="openapi-column-preview-body">
<div className="openapi-panel">
<div className="openapi-panel" data-follow-color-scheme="true">
<h4 className="openapi-panel-heading">{title}</h4>
<div className="openapi-panel-body">
<OpenAPIExample
+1 -1
View File
@@ -306,7 +306,7 @@ export function getSchemaTitle(
// Only add the title if it's an object (no need for the title of a string, number, etc.)
if (type === 'object' && schema.title) {
type += ` · ${schema.title.replaceAll(' ', '')}`;
type += ` · ${schema.title}`;
}
}
}