diff --git a/.changeset/bold-points-flow.md b/.changeset/bold-points-flow.md new file mode 100644 index 000000000..385444f9d --- /dev/null +++ b/.changeset/bold-points-flow.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Track new events for site analytics. diff --git a/.changeset/frank-feet-buy.md b/.changeset/frank-feet-buy.md new file mode 100644 index 000000000..9b8fb170d --- /dev/null +++ b/.changeset/frank-feet-buy.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Small OpenAPI Improvements diff --git a/.changeset/mighty-ghosts-invent.md b/.changeset/mighty-ghosts-invent.md new file mode 100644 index 000000000..846fc0541 --- /dev/null +++ b/.changeset/mighty-ghosts-invent.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix normalization of URL causing invalid redirections. diff --git a/.changeset/pink-stars-think.md b/.changeset/pink-stars-think.md new file mode 100644 index 000000000..4eceddb92 --- /dev/null +++ b/.changeset/pink-stars-think.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Track events from the proper server URL. diff --git a/.changeset/ready-ducks-boil.md b/.changeset/ready-ducks-boil.md new file mode 100644 index 000000000..f8fd2a1f1 --- /dev/null +++ b/.changeset/ready-ducks-boil.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Pan and zoom controls for mermaid diagrams diff --git a/.changeset/tangy-rules-attack.md b/.changeset/tangy-rules-attack.md new file mode 100644 index 000000000..28127fa4a --- /dev/null +++ b/.changeset/tangy-rules-attack.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Fix PDF export crash when page contains an OpenAPI block diff --git a/.github/workflows/deploy-preview.yaml b/.github/workflows/deploy-preview.yaml index 3d7db35ce..5745f6b2d 100644 --- a/.github/workflows/deploy-preview.yaml +++ b/.github/workflows/deploy-preview.yaml @@ -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) diff --git a/bun.lock b/bun.lock index ffbfb180d..5dda4dea4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 163b103ed..0dd21d2b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index e68b6ba2d..ed5c8d58a 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -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, }, diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index e25a57fbf..bd9e2ac6c 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -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, diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 1a637d43f..c1fd604e0 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -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", diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/auth/login/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/auth/login/route.ts new file mode 100644 index 000000000..3e2362db8 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/auth/login/route.ts @@ -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 } +) { + 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); + } +} diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms.txt/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms.txt/route.ts index da1176d9b..475297000 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms.txt/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/llms.txt/route.ts @@ -10,5 +10,6 @@ export async function GET( { params }: { params: Promise } ) { const { context } = await getStaticSiteContext(await params); + return serveLLMsTxt(context, { withMarkdownPages: true }); } diff --git a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts index ec79c0543..d89ec7f82 100644 --- a/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts +++ b/packages/gitbook/src/app/sites/static/[mode]/[siteURL]/[siteData]/~gitbook/mcp/route.ts @@ -11,12 +11,35 @@ import type { NextRequest } from 'next/server'; import { z } from 'zod'; async function handler( - nextRequest: NextRequest, + rawRequest: NextRequest, { params }: { params: Promise } ) { 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); } diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx index cc4790c4f..fb637fc2f 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/ClientCodeBlock.tsx @@ -193,10 +193,10 @@ function CodeBlockExpandable(props: {