mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 03:16:40 +00:00
Merge branch 'main' into peter/rnd-6756-add-option-to-freeze-columns-andor-rows-in-tables
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track new events for site analytics.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Small OpenAPI Improvements
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix normalization of URL causing invalid redirections.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track events from the proper server URL.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Pan and zoom controls for mermaid diagrams
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix PDF export crash when page contains an OpenAPI block
|
||||
@@ -136,7 +136,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-vercel
|
||||
visual-testing-v2-cloudflare:
|
||||
@@ -161,7 +160,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-cloudflare
|
||||
visual-testing-customers-v2:
|
||||
@@ -186,7 +184,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
@@ -211,7 +208,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-cloudflare
|
||||
browserless-testing-v2-vercel:
|
||||
@@ -235,7 +231,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.13",
|
||||
"vercel": "^50.26.1",
|
||||
"turbo": "^2.8.16",
|
||||
"vercel": "^50.31.1",
|
||||
},
|
||||
},
|
||||
"packages/browser-types": {
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+30
@@ -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 });
|
||||
}
|
||||
|
||||
+25
-9
@@ -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&serialnumber=lt:Y</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>gt(e)</td>
|
||||
<td>Y</td>
|
||||
<td>Single occurrence only. Requires the presence of an <strong>gte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td>
|
||||
<td>?token.id=gte:X&serialnumber=gt:Y</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>spender.id</td>
|
||||
<td>eq</td>
|
||||
<td>Y</td>
|
||||
<td></td>
|
||||
<td>?spender.id=Z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>ne</td>
|
||||
<td>N</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>lt(e)</td>
|
||||
<td>Y</td>
|
||||
<td></td>
|
||||
<td>?spender.id=lt:Z</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>gt(e)</td>
|
||||
<td>Y</td>
|
||||
<td></td>
|
||||
<td>?spender.id=gt:Z</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<table><thead><tr><th>Query Param</th><th>Comparison Operator</th><th>Support</th><th>Description</th><th>Example</th></tr></thead><tbody><tr><td>token.id</td><td>eq</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=X</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=lte:X</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td>Single occurrence only.</td><td>?token.id=gte:X</td></tr><tr><td>serialnumber</td><td>eq</td><td>Y</td><td>Single occurrence only. Requires the presence of a <strong>token.id</strong> query</td><td>?serialnumber=Y</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td>Single occurrence only. Requires the presence of an <strong>lte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td><td>?token.id=lte:X&serialnumber=lt:Y</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td>Single occurrence only. Requires the presence of an <strong>gte</strong> or <strong>eq</strong> <strong>token.id</strong> query</td><td>?token.id=gte:X&serialnumber=gt:Y</td></tr><tr><td>spender.id</td><td>eq</td><td>Y</td><td></td><td>?spender.id=Z</td></tr><tr><td></td><td>ne</td><td>N</td><td></td><td></td></tr><tr><td></td><td>lt(e)</td><td>Y</td><td></td><td>?spender.id=lt:Z</td></tr><tr><td></td><td>gt(e)</td><td>Y</td><td></td><td>?spender.id=gt:Z</td></tr></tbody></table>
|
||||
<p>Note: When searching across a range for individual NFTs a <strong>serialnumber</strong> with an additional <strong>token.id</strong> query filter must be provided.
|
||||
Both filters must be a single occurrence of <strong>gt(e)</strong> or <strong>lt(e)</strong> which provide a lower and or upper boundary for search.</p>"
|
||||
`;
|
||||
|
||||
exports[`parseMarkdown should parse html 1`] = `
|
||||
"<p>Returns trending events for the given time frame.</p>
|
||||
"<div>
|
||||
<div>
|
||||
<div>
|
||||
ID:
|
||||
</div>
|
||||
<div>
|
||||
get_trending_events
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<span>
|
||||
<span>
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
<span>
|
||||
sports_events_trending
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
Sports
|
||||
</span>
|
||||
<span>
|
||||
Personalization
|
||||
</span>
|
||||
</div>
|
||||
<p>Returns trending events for the given time frame.</p>
|
||||
<p>The events are sorted by <code>trending score</code>.</p>
|
||||
<div>
|
||||
<div>
|
||||
<span>
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
<span>
|
||||
Trending score
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>To calculate trending score for an event, each bet placed on this event
|
||||
contributes a value determined by how recently the bet was placed.</p>
|
||||
<p>Recent bets add more value than older ones.</p>
|
||||
<p>The normalized value of its score is also assigned to each event as its confidence.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<span>
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
<span>
|
||||
Filtering example
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>In this example we get all trending events where <code>league</code> is <code>UEFA Champions League</code>.</p>
|
||||
<pre><code class="language-bash">$ curl --request GET \\
|
||||
--url 'https://api.vaix.ai/api/sports/events/trending?filters=league%3Aeq%3AUEFA%20Champions%20League'
|
||||
</code></pre>"
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>"
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user