mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-19 17:15:24 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76a94b8316 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Scroll to an in-page heading even when the URL hash is unchanged (e.g. clicking the same anchor again).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Force software rendering (SwiftShader) in Playwright Chromium to eliminate image downscaling drift between GPU-equipped local runs and headless CI runs in Argos screenshots.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add an assistant tool to rate its own previous response when the user reacts to it.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add an assistant tool to submit feedback about the current page on behalf of the user.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Assistant: the "Explored briefly" activity heading no longer appears when there's nothing to show. It now renders only when the answer is preceded by a real preamble or one or more tool calls, so a simple answer with an empty reasoning step no longer surfaces an empty collapsible.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add configurable default visibility to Prompt block
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Introduce client-side content selection (`select`): a site-wide, recency-ordered list of selected slugs, persisted in localStorage and shareable via `?select=`, applied to `<html>` before first paint so the right variant renders with no flash. All variants stay server-rendered, so pages are byte-identical for every visitor (no cache impact).
|
||||
|
||||
Tabs now use it: switching a tab activates its slug, and every tab group offering that slug follows, across pages. Tabs no longer write to the URL fragment (`#` returns to anchors only); deep-links into a tab still activate and scroll to it.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add missing link reference to OpenAPI models
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@gitbook/browser-types": patch
|
||||
"@gitbook/embed": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add an optional `context` property (string, up to 512 characters) to the `confirmation` of custom AI tools, shown above the confirmation dialog to help the user understand what they are approving or rejecting. The `confirmation` can now also be a function that receives the AI-provided input and returns the confirmation, so the context can be derived dynamically from the arguments the tool is about to run with. Available both to integrations (`GitBookIntegrationTool`) and to embed consumers (`GitBookToolDefinition`).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Submit `sendFeedback` MCP tool findings through the dedicated `submitSiteAgentFeedback` API endpoint. The `pageUrl` is now required and an optional `goal` can be provided.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Serve `X-Robots-Tag: noindex` on internal search/assistant URLs (`?q=` / `?ask=`) and stop disallowing them in robots.txt, so Google can crawl the directive and drop them from the index instead of reporting "Indexed, though blocked by robots.txt".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add an "On this page" table of contents on OpenAPI models pages. Each model in a grouped/multi-model "Models" section is now listed as its own section, matching operations and webhooks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve cookie handling in the OpenAPI "Test it" request proxy.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Serve the OpenAPI "Test it" request proxy from GitBook's own domain.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prevent long page titles from being cut off in the auto-generated social/link preview image by wrapping and scaling the title font size to fit.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ScrollContainer scroll buttons not reflecting content overflow immediately or after dynamic content changes (e.g. search results).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Keep the last search query visible after closing search, and restore it when reopening, without breaking navigation when clicking a search result.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Make the table search "no results" empty state more prominent with vertical spacing so it no longer blends into the content below.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix heading anchor links being unreachable on touch devices by adding a tap-to-reveal state. The anchor icon now appears after the heading text without wrapping onto an orphan line while retaining its existing desktop placement. Use `pointerup` for the dismiss listener to fix unreliable dismissal on iOS Safari, and enlarge the anchor's touch tap target to a square 24px area (meeting the WCAG 2.5.8 minimum) so the icon stays centered instead of overflowing shorter headings' line height.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-contentkit": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Expose the current page (`id`, `path`, `title`) to integration block webframes through the client-only webframe `state.page`, alongside adaptive visitor claims.
|
||||
@@ -53,7 +53,6 @@ runs:
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
|
||||
@@ -55,7 +55,6 @@ runs:
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_SITE_OAUTH_SIGNING_SECRET: ${{ inputs.opItem }}/GITBOOK_SITE_OAUTH_SIGNING_SECRET
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.0",
|
||||
"@opennextjs/cloudflare": "1.20.2",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -165,7 +165,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.11",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -346,8 +346,8 @@
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next@16.2.11": "patches/next@16.2.11.patch",
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"next@16.2.6": "patches/next@16.2.6.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
@@ -360,7 +360,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.191.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -756,7 +756,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.191.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-Bizv/lGBeUqUCajS4AOu9uAtc9mDJBUh6BjEmgoCzolxVAa68rW/T4GugyiRl0JrAKIimLVmkYCPevPzXa6lLg=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.189.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-QezuW8dMScSTJ+2IL/GGixkhGEsMRFRoO/W2r80pKRjoWyI9Ebai9oZsoN/pbRZz3cTnwa7hpRDtzaqHeeOAdQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -924,23 +924,23 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="],
|
||||
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
|
||||
|
||||
@@ -976,9 +976,9 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.1.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.21 <16 || >=16.2.11" }, "bin": { "open-next": "dist/index.js" } }, "sha512-GuKkdUbnJhLvtwTiJlytNtWIcSYN3+Dzi+OusUlRgf+Ur7dGoXdzBopd+VFiSdTBduPOvr9lPFDg6BYNjEV3wQ=="],
|
||||
"@opennextjs/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.2", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.0", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.21 <16 || >=16.2.11", "rclone.js": "^0.6.6", "wrangler": "^4.86.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-iFBjABnaDk3be27F5EpxyMLMGPbVnnArFx5I3Y8Rf6BSx5nBV8h0UuJiMKrx3+whDU5ahIy4d8sfbvWvMiF1Kg=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -2866,7 +2866,7 @@
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="],
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.191.0",
|
||||
"@gitbook/api": "0.189.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -62,6 +62,6 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
|
||||
"next@16.2.11": "patches/next@16.2.11.patch"
|
||||
"next@16.2.6": "patches/next@16.2.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,14 @@ export type GitBookIntegrationEvent = 'load' | 'unload';
|
||||
|
||||
export type GitBookIntegrationEventCallback = (...args: any[]) => void;
|
||||
|
||||
export type GitBookIntegrationToolConfirmation = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Supporting context displayed to the user above the confirmation dialog,
|
||||
* to help them understand what they are approving or rejecting.
|
||||
* Limited to 512 characters.
|
||||
*/
|
||||
context?: string;
|
||||
};
|
||||
|
||||
export type GitBookIntegrationTool = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
* Provide a static object, or a function that receives the input provided by
|
||||
* the AI assistant and returns the confirmation — useful to display dynamic
|
||||
* context based on the arguments the tool is about to be executed with.
|
||||
*/
|
||||
confirmation?:
|
||||
| GitBookIntegrationToolConfirmation
|
||||
| ((input: object) => GitBookIntegrationToolConfirmation);
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import type { AIToolCallResult, AIToolDefinition } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing a tool.
|
||||
*/
|
||||
export type GitBookToolConfirmation = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Supporting context displayed to the user above the confirmation dialog,
|
||||
* to help them understand what they are approving or rejecting.
|
||||
* Limited to 512 characters.
|
||||
*/
|
||||
context?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom tool definition to be passed to the AI assistant.
|
||||
*/
|
||||
export type GitBookToolDefinition = AIToolDefinition & {
|
||||
/**
|
||||
* Confirmation action to be displayed to the user before executing the tool.
|
||||
* Provide a static object, or a function that receives the input provided by
|
||||
* the AI assistant and returns the confirmation — useful to display dynamic
|
||||
* context based on the arguments the tool is about to be executed with.
|
||||
*/
|
||||
confirmation?: GitBookToolConfirmation | ((input: object) => GitBookToolConfirmation);
|
||||
confirmation?: {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback when the tool is executed.
|
||||
|
||||
@@ -282,6 +282,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://vimeo.com',
|
||||
tests: [{ name: 'Home', url: '/legal' }],
|
||||
},
|
||||
{
|
||||
name: 'help.platipomiru.com',
|
||||
contentBaseURL: 'https://help.platipomiru.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'help.aikido.dev',
|
||||
contentBaseURL: 'https://help.aikido.dev',
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
// Import the specific modules (not the package barrel) so this stays free of the `@/` path alias
|
||||
// that the store pulls in — Playwright's loader doesn't resolve it.
|
||||
import { SELECT_LIST_CAP, selectRankAttribute } from '../src/lib/select/constants';
|
||||
import { generateSelectCSS, selectSetClassName } from '../src/lib/select/generateSelectCSS';
|
||||
|
||||
/**
|
||||
* Behaviour tests for the `select` CSS: given a recency-ordered selection applied to `<html>`, a
|
||||
* group must show exactly the most-recently-activated of its options (its "first ranking"
|
||||
* selection), falling back to its default when none are active. These run in a real browser against
|
||||
* the actual generated CSS, so they assert observable visibility — not how the selectors are built.
|
||||
*/
|
||||
|
||||
/** Render a single group of option panes with the generated stylesheet. First slug = default. */
|
||||
async function renderGroup(page: Page, slugs: string[]) {
|
||||
const css = generateSelectCSS(slugs);
|
||||
const scope = selectSetClassName(slugs);
|
||||
const panes = slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${css}</style></head><body><div class="${scope}" data-select-group>${panes}</div></body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the recency list to `<html>` as `data-sel-*` attributes (most-recent first), via the shared
|
||||
* attribute-name helper — mirroring what the pre-paint script / store do at runtime.
|
||||
*/
|
||||
async function applySelection(page: Page, active: string[]) {
|
||||
for (const [rank, value] of active.entries()) {
|
||||
await page.evaluate(
|
||||
({ attr, value }) => document.documentElement.setAttribute(attr, value),
|
||||
{ attr: selectRankAttribute(rank), value }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert exactly one pane is visible, and it is the expected slug. */
|
||||
async function expectOnlyVisible(page: Page, slugs: string[], expectedSlug: string) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(page: Page, slugs: string[], active: string[]) {
|
||||
await renderGroup(page, slugs);
|
||||
await applySelection(page, active);
|
||||
}
|
||||
|
||||
test.describe('select CSS visibility', () => {
|
||||
const slugs = ['python', 'go', 'java'];
|
||||
|
||||
test('shows the default when nothing is selected', async ({ page }) => {
|
||||
await setup(page, slugs, []);
|
||||
await expectOnlyVisible(page, slugs, 'python'); // first pane is the default
|
||||
});
|
||||
|
||||
test('shows the selected option and hides the rest', async ({ page }) => {
|
||||
await setup(page, slugs, ['go']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('shows the most-recently-activated option of the group', async ({ page }) => {
|
||||
// Recency list is most-recent-first: `go` is more recent than `python`.
|
||||
await setup(page, slugs, ['go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
|
||||
await setup(page, slugs, ['python', 'go']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('ignores more-recent selections that are not in the group', async ({ page }) => {
|
||||
// `dark` is more recent but not one of this group's options, so `go` still wins.
|
||||
await setup(page, slugs, ['dark', 'go', 'python']);
|
||||
await expectOnlyVisible(page, slugs, 'go');
|
||||
});
|
||||
|
||||
test('falls back to the default when no active slug is in the group', async ({ page }) => {
|
||||
await setup(page, slugs, ['dark', 'light']);
|
||||
await expectOnlyVisible(page, slugs, 'python');
|
||||
});
|
||||
|
||||
test('keeps symbol-bearing slugs (c / c++ / c#) distinct through the CSS selectors', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Slugs can contain `+` and `#` (see slugifySelectValue); they must survive quoted attribute
|
||||
// selectors without collapsing together.
|
||||
const symbols = ['c', 'c++', 'c#'];
|
||||
await setup(page, symbols, ['c++']);
|
||||
await expectOnlyVisible(page, symbols, 'c++');
|
||||
});
|
||||
|
||||
test('shows only the first pane when a group repeats a slug (duplicate tab names)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Two panes share the slug `js`; activating it must reveal only the first, never both.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default>js 1</div><div data-testid="js-second" data-select-option="js">js 2</div><div data-testid="ts" data-select-option="ts">ts</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-first')).toBeVisible();
|
||||
await expect(page.getByTestId('js-second')).toBeHidden();
|
||||
await expect(page.getByTestId('ts')).toBeHidden();
|
||||
});
|
||||
|
||||
test('a pinned pane overrides first-match (the duplicate the visitor clicked)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The client marks the clicked pane data-select-pinned and its same-slug sibling unpinned;
|
||||
// the pinned one must win over the first-match default.
|
||||
const scope = selectSetClassName(['js', 'ts']);
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head><style>${generateSelectCSS(['js', 'ts'])}</style></head><body><div class="${scope}" data-select-group><div data-testid="js-first" data-select-option="js" data-select-default data-select-unpinned>js 1</div><div data-testid="js-second" data-select-option="js" data-select-pinned>js 2</div></div></body></html>`
|
||||
);
|
||||
await applySelection(page, ['js']);
|
||||
await expect(page.getByTestId('js-second')).toBeVisible();
|
||||
await expect(page.getByTestId('js-first')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupSpec {
|
||||
id: string;
|
||||
slugs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render several tab groups, each with clickable tab buttons wired to `__select` — an in-page
|
||||
* stand-in for the store's `activate()`/`mirrorToHtml()` (whose recency/dedupe/cap logic is unit
|
||||
* tested in store.test.ts). It prepends the clicked slug onto the `data-sel-*` recency list on
|
||||
* `<html>`, most-recent first. This keeps the test focused on the observable behaviour a visitor
|
||||
* sees — a real click switching every group that offers that option — driven by real browser CSS.
|
||||
*/
|
||||
async function renderGroups(page: Page, groups: GroupSpec[]) {
|
||||
const styles = [
|
||||
...new Map(
|
||||
groups.map((group) => [selectSetClassName(group.slugs), generateSelectCSS(group.slugs)])
|
||||
).values(),
|
||||
]
|
||||
.map((css) => `<style>${css}</style>`)
|
||||
.join('');
|
||||
|
||||
const markup = groups
|
||||
.map((group) => {
|
||||
const scope = selectSetClassName(group.slugs);
|
||||
const buttons = group.slugs
|
||||
.map(
|
||||
(slug) =>
|
||||
`<button data-testid="${group.id}-btn-${slug}" onclick="__select('${slug}')">${slug}</button>`
|
||||
)
|
||||
.join('');
|
||||
const panes = group.slugs
|
||||
.map(
|
||||
(slug, index) =>
|
||||
`<div data-testid="${group.id}-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
|
||||
)
|
||||
.join('');
|
||||
return `<div class="${scope}" data-select-group><div role="tablist">${buttons}</div>${panes}</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const selectScript = `window.__select=function(slug){var el=document.documentElement,cur=[],i,v;for(i=0;i<${SELECT_LIST_CAP};i++){v=el.getAttribute('data-sel-'+i);if(v)cur.push(v);}var next=[slug];for(i=0;i<cur.length;i++){if(cur[i]!==slug)next.push(cur[i]);}next=next.slice(0,${SELECT_LIST_CAP});for(i=0;i<${SELECT_LIST_CAP};i++){if(next[i])el.setAttribute('data-sel-'+i,next[i]);else el.removeAttribute('data-sel-'+i);}};`;
|
||||
|
||||
await page.setContent(
|
||||
`<!doctype html><html><head>${styles}<script>${selectScript}</script></head><body>${markup}</body></html>`
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert a specific group shows exactly `expectedSlug` and hides its other options. */
|
||||
async function expectGroupShows(
|
||||
page: Page,
|
||||
groupId: string,
|
||||
slugs: string[],
|
||||
expectedSlug: string
|
||||
) {
|
||||
for (const slug of slugs) {
|
||||
const pane = page.getByTestId(`${groupId}-pane-${slug}`);
|
||||
if (slug === expectedSlug) {
|
||||
await expect(pane).toBeVisible();
|
||||
} else {
|
||||
await expect(pane).toBeHidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('select syncing across groups (click-driven)', () => {
|
||||
test('clicking a tab syncs every group offering that option', async ({ page }) => {
|
||||
const slugs = ['python', 'go'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs },
|
||||
{ id: 'b', slugs },
|
||||
]);
|
||||
|
||||
// Both groups start on their default (first) pane.
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
|
||||
// Clicking a tab in group A switches group B too.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'go');
|
||||
await expectGroupShows(page, 'b', slugs, 'go');
|
||||
|
||||
// And the sync works from either group.
|
||||
await page.getByTestId('b-btn-python').click();
|
||||
await expectGroupShows(page, 'a', slugs, 'python');
|
||||
await expectGroupShows(page, 'b', slugs, 'python');
|
||||
});
|
||||
|
||||
test('only groups that share the clicked option follow along', async ({ page }) => {
|
||||
const shared = ['python', 'go'];
|
||||
const other = ['go', 'rust'];
|
||||
await renderGroups(page, [
|
||||
{ id: 'a', slugs: shared },
|
||||
{ id: 'b', slugs: other },
|
||||
]);
|
||||
|
||||
// `rust` exists only in group B, so clicking it leaves group A on its default.
|
||||
await page.getByTestId('b-btn-rust').click();
|
||||
await expectGroupShows(page, 'b', other, 'rust');
|
||||
await expectGroupShows(page, 'a', shared, 'python');
|
||||
|
||||
// `go` is shared, so clicking it in A moves both groups.
|
||||
await page.getByTestId('a-btn-go').click();
|
||||
await expectGroupShows(page, 'a', shared, 'go');
|
||||
await expectGroupShows(page, 'b', other, 'go');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,5 @@
|
||||
// @ts-check
|
||||
|
||||
import { networkInterfaces } from 'node:os';
|
||||
|
||||
// Next blocks its dev client and HMR when a physical device opens the server over a LAN address.
|
||||
// Needed to hydrate the dev client on physical phones/tablets over the internal network
|
||||
const allowedDevOrigins =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [
|
||||
...new Set(
|
||||
Object.values(networkInterfaces())
|
||||
.flat()
|
||||
.filter(
|
||||
(networkInterface) =>
|
||||
networkInterface &&
|
||||
!networkInterface.internal &&
|
||||
networkInterface.family === 'IPv4'
|
||||
)
|
||||
.map((networkInterface) => networkInterface?.address)
|
||||
),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
|
||||
let deploymentId =
|
||||
process.env.GITBOOK_RUNTIME === 'cloudflare'
|
||||
@@ -42,7 +21,6 @@ if (VERCEL_TARGET_ENV === 'preview') {
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
allowedDevOrigins,
|
||||
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
@@ -62,17 +40,12 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
env: {
|
||||
BUILD_VERSION: (
|
||||
process.env.GITBOOK_HEAD_SHA ||
|
||||
process.env.GITHUB_SHA ||
|
||||
Date.now().toString()
|
||||
).slice(0, 7),
|
||||
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
|
||||
// GitBook envs
|
||||
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_SITE_OAUTH_SIGNING_SECRET: process.env.GITBOOK_SITE_OAUTH_SIGNING_SECRET,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2026-04-02",
|
||||
"minify": true,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "4.1.0",
|
||||
"@opennextjs/cloudflare": "1.20.2",
|
||||
"@opennextjs/aws": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -57,7 +57,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.11",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -132,7 +132,7 @@
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
|
||||
@@ -26,12 +26,6 @@ export default defineConfig({
|
||||
'--disable-lcd-text',
|
||||
// Disable font hinting so glyph rasterization is platform-independent.
|
||||
'--font-render-hinting=none',
|
||||
// Force software rendering everywhere so image compositing/downscaling
|
||||
// always goes through the same filter, whether or not a GPU is present —
|
||||
// a machine with a real GPU renders images more sharply than headless CI
|
||||
// (no GPU, SwiftShader fallback), causing smooth-vs-pixelated diffs.
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+66
-64
@@ -1,4 +1,8 @@
|
||||
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
SiteFindingType,
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
@@ -346,6 +350,11 @@ export async function handleMcpRequest(
|
||||
'sendFeedback',
|
||||
`Report an issue in the documentation of ${site.title} so the team can fix it. Use it whenever, while helping a user, you come across content that is outdated, contradictory, missing information, or otherwise unhelpful. Also use it when the user themselves reports a problem with the docs, even if you could not verify it yourself. If it's your own observation, do a quick sanity check that the issue is real before reporting — no need to exhaustively re-read the page. Send one call per distinct issue and do not report the same issue twice in a conversation. Do not use this tool to confirm that a page is accurate; it is for reporting problems only.`,
|
||||
{
|
||||
category: z
|
||||
.nativeEnum(SiteFindingType)
|
||||
.describe(
|
||||
'The kind of issue. "content-outdated": the content was correct at some point but no longer matches the current product or reality. "incoherence": the content contradicts itself or another page. "content-gap": information the reader needs is missing entirely, whether or not it was ever documented. "other": only as a last resort when none of the above fits.'
|
||||
),
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -356,7 +365,7 @@ export async function handleMcpRequest(
|
||||
pageUrl: z
|
||||
.string()
|
||||
.describe(
|
||||
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started), so the finding is linked to the exact page.`
|
||||
`The full URL of the page the issue is about (e.g. ${siteUrl}/getting-started). Provide it whenever you can so the finding is linked to the exact page.`
|
||||
)
|
||||
.transform((value, ctx) => {
|
||||
const candidate = URL.canParse(value)
|
||||
@@ -377,13 +386,8 @@ export async function handleMcpRequest(
|
||||
}
|
||||
|
||||
return candidate.toString();
|
||||
}),
|
||||
goal: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The broader end goal you were ultimately trying to accomplish (as/on behalf of the user) when you hit this issue. Gives the team the context you were working towards. Optional.'
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
{
|
||||
title: 'Send feedback',
|
||||
@@ -392,64 +396,62 @@ export async function handleMcpRequest(
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
async ({ content, pageUrl, goal }) => {
|
||||
async ({ category, content, pageUrl }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, pageUrl);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
let pageLocation:
|
||||
| { page: string; space: string; revision: string }
|
||||
| undefined;
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${pageUrl}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedGoal = goal?.trim() || undefined;
|
||||
|
||||
//!! DISABLED FOR NOW: We'll add this back in when we have a way to track agent feedback.
|
||||
// trackMcpEvent({
|
||||
// organizationId: context.organizationId,
|
||||
// siteId: site.id,
|
||||
// events: [
|
||||
// {
|
||||
// type: 'agent_feedback',
|
||||
// feedback: { content, category },
|
||||
// location: {
|
||||
// displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
// page: resolved.page.id,
|
||||
// space: match.siteSpace.space.id,
|
||||
// revision: match.siteSpace.space.revision,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// request,
|
||||
// });
|
||||
|
||||
const apiClient = await dataFetcher.api();
|
||||
await apiClient.orgs.submitSiteAgentFeedback(
|
||||
context.organizationId,
|
||||
site.id,
|
||||
{
|
||||
feedback: content,
|
||||
url: pageUrl,
|
||||
spaceId: match.siteSpace.space.id,
|
||||
pageId: resolved.page.id,
|
||||
...(trimmedGoal ? { goal: trimmedGoal } : {}),
|
||||
if (pageUrl) {
|
||||
const match = findSiteSpaceByUrl(context.structure, pageUrl);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Page not found: "${pageUrl}"` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Page not found: "${pageUrl}"` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
pageLocation = {
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
};
|
||||
}
|
||||
|
||||
trackMcpEvent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'agent_feedback',
|
||||
feedback: { content, category },
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
...pageLocation,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Feedback recorded. Thank you.' }],
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout/CustomizationRootLayout';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
|
||||
/**
|
||||
* Layout for the sites OAuth consent screen.
|
||||
*/
|
||||
export default async function Layout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ params: Promise<RouteLayoutParams> }>) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout context={context} forcedTheme={forcedTheme}>
|
||||
{children}
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getDynamicSiteContext,
|
||||
getSiteURLDataFromParams,
|
||||
} from '@/app/utils';
|
||||
import { ConsentError, ConsentScreen } from '@/components/SiteOAuthConsent';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import { SiteOAuthConsentError, startSiteOAuthConsent } from '@/lib/site-oauth';
|
||||
import { getVisitorToken } from '@/lib/visitors';
|
||||
|
||||
// The consent screen depends on the request (visitor, one-time interaction) and must never be cached.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type PageParams = RouteLayoutParams & { siteId: string };
|
||||
|
||||
/**
|
||||
* Render the sites OAuth consent screen for a post-login authorize resume.
|
||||
*/
|
||||
export default async function Page(props: {
|
||||
params: Promise<PageParams>;
|
||||
searchParams: Promise<{ gb_oauth_state?: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const { siteId } = params;
|
||||
|
||||
// Only a post-login resume (carrying the interaction id) legitimately reaches this route.
|
||||
if (!searchParams.gb_oauth_state) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const siteBasePath = withTrailingSlash(
|
||||
withLeadingSlash(getSiteURLDataFromParams(params).siteBasePath)
|
||||
);
|
||||
const authorizeURL = new URL(
|
||||
`${siteBasePath}~gitbook/oauth2/v1/${siteId}/authorize`,
|
||||
context.linker.toAbsoluteURL('/')
|
||||
);
|
||||
const visitorToken = getVisitorToken({
|
||||
cookies: (await cookies()).getAll(),
|
||||
headers: await headers(),
|
||||
url: authorizeURL,
|
||||
});
|
||||
const jwtToken = visitorToken?.token;
|
||||
const interactionId = searchParams.gb_oauth_state;
|
||||
|
||||
if (!interactionId || !jwtToken) {
|
||||
return <ConsentError />;
|
||||
}
|
||||
|
||||
try {
|
||||
const consent = await startSiteOAuthConsent({ siteId, interactionId, jwtToken });
|
||||
return <ConsentScreen siteId={siteId} siteTitle={context.site.title} consent={consent} />;
|
||||
} catch (error) {
|
||||
if (error instanceof SiteOAuthConsentError) {
|
||||
return <ConsentError />;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleOpenAPIProxyOptions();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export {
|
||||
handleOpenAPIProxyRequest as GET,
|
||||
handleOpenAPIProxyRequest as POST,
|
||||
handleOpenAPIProxyRequest as PUT,
|
||||
handleOpenAPIProxyRequest as DELETE,
|
||||
handleOpenAPIProxyRequest as PATCH,
|
||||
handleOpenAPIProxyRequest as HEAD,
|
||||
handleOpenAPIProxyOptions as OPTIONS,
|
||||
};
|
||||
@@ -16,13 +16,6 @@ export const ConfirmControlDef = createAIControl({
|
||||
description:
|
||||
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.',
|
||||
inputSchema: z.object({
|
||||
context: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.describe(
|
||||
'Supporting context shown above the prompt to help the user understand what they are approving or rejecting.'
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -36,37 +29,30 @@ export const ConfirmControlDef = createAIControl({
|
||||
});
|
||||
|
||||
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
|
||||
const { label, icon, context, onSubmit } = props;
|
||||
const { label, icon, onSubmit } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
{context ? (
|
||||
<p className="whitespace-pre-line px-2 pt-1 text-sm text-tint">{context}</p>
|
||||
) : null}
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-cancel"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'cancelled' });
|
||||
}}
|
||||
truncate={false}
|
||||
variant="secondary"
|
||||
icon="xmark"
|
||||
label={tString(language, 'cancel')}
|
||||
className="shrink-0 grow justify-center"
|
||||
/>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-accept"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'confirmed' });
|
||||
}}
|
||||
variant="primary"
|
||||
truncate={false}
|
||||
icon={icon}
|
||||
label={label}
|
||||
className="min-w-1/2 shrink-0 grow justify-center"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-cancel"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'cancelled' });
|
||||
}}
|
||||
truncate={false}
|
||||
variant="blank"
|
||||
icon="xmark"
|
||||
label={tString(language, 'cancel')}
|
||||
/>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-accept"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'confirmed' });
|
||||
}}
|
||||
variant="primary"
|
||||
truncate={false}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type AIStreamResponse,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
@@ -25,11 +24,6 @@ import { type RenderAIMessageOptions, streamAIChatResponse } from './server-acti
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
import {
|
||||
type ResponseToRate,
|
||||
useSubmitAssistantFeedbackTool,
|
||||
} from './useSubmitAssistantFeedbackTool';
|
||||
import { useSubmitPageFeedbackTool } from './useSubmitPageFeedbackTool';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
@@ -226,11 +220,9 @@ function notify(
|
||||
*/
|
||||
export function AIChatProvider(props: {
|
||||
renderMessageOptions?: RenderAIMessageOptions;
|
||||
/** Whether page feedback is enabled for the site (gates the submit-feedback tool). */
|
||||
withPageFeedback?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { renderMessageOptions, withPageFeedback = false, children } = props;
|
||||
const { renderMessageOptions, children } = props;
|
||||
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -238,35 +230,9 @@ export function AIChatProvider(props: {
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
const displayContext = renderMessageOptions?.asEmbeddable
|
||||
? SiteInsightsDisplayContext.Embed
|
||||
: SiteInsightsDisplayContext.Site;
|
||||
|
||||
// The assistant response the user is reacting to. Snapshotted when a new user turn begins
|
||||
// (before it overwrites the store's responseId/query), so the self-feedback tool rates that
|
||||
// previous response rather than the one this reaction turn produces.
|
||||
const responseToRateRef = React.useRef<ResponseToRate>({ responseId: null, query: null });
|
||||
const getResponseToRate = React.useCallback(() => responseToRateRef.current, []);
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page, submitting page or
|
||||
// assistant feedback). Each tool has a stable identity, so it can be referenced directly from
|
||||
// the streaming callback.
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
const submitPageFeedbackTool = useSubmitPageFeedbackTool({ displayContext });
|
||||
const submitAssistantFeedbackTool = useSubmitAssistantFeedbackTool({
|
||||
displayContext,
|
||||
getResponseToRate,
|
||||
});
|
||||
|
||||
// The assistant-feedback tool is always available (it mirrors the chat's own thumbs up/down
|
||||
// rating), while the page-feedback tool is gated on the site's "Was this helpful?" setting.
|
||||
const builtInTools = React.useMemo(() => {
|
||||
const tools = [navigateToPageTool, submitAssistantFeedbackTool];
|
||||
if (withPageFeedback) {
|
||||
tools.push(submitPageFeedbackTool);
|
||||
}
|
||||
return tools;
|
||||
}, [navigateToPageTool, submitAssistantFeedbackTool, submitPageFeedbackTool, withPageFeedback]);
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
@@ -346,7 +312,7 @@ export function AIChatProvider(props: {
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools(builtInTools);
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
@@ -382,7 +348,7 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools(builtInTools);
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
@@ -468,18 +434,6 @@ export function AIChatProvider(props: {
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
// The confirmation can be a static object or a function that
|
||||
// derives it from the AI-provided input (e.g. dynamic context).
|
||||
// The function call is awaited because, for embed-registered
|
||||
// tools, it arrives as an async proxy over the postMessage channel.
|
||||
const resolvedConfirmation =
|
||||
typeof confirmation === 'function'
|
||||
? await confirmation(event.toolCall.input)
|
||||
: confirmation;
|
||||
const supportingContext =
|
||||
typeof resolvedConfirmation.context === 'string'
|
||||
? resolvedConfirmation.context.slice(0, 512)
|
||||
: undefined;
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: ConfirmControlDef.createControl({
|
||||
@@ -488,9 +442,8 @@ export function AIChatProvider(props: {
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: {
|
||||
label: resolvedConfirmation.label,
|
||||
icon: resolvedConfirmation.icon,
|
||||
context: supportingContext,
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
@@ -509,7 +462,7 @@ export function AIChatProvider(props: {
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
resolvedConfirmation.label
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -602,7 +555,7 @@ export function AIChatProvider(props: {
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
builtInTools,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -652,14 +605,6 @@ export function AIChatProvider(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the response the user is reacting to before this turn overwrites the store's
|
||||
// query/responseId, so the self-feedback tool rates the previous answer. `query` and
|
||||
// `responseId` here still describe the last completed turn.
|
||||
const { responseId: previousResponseId } = globalState.getState();
|
||||
if (previousResponseId) {
|
||||
responseToRateRef.current = { responseId: previousResponseId, query };
|
||||
}
|
||||
|
||||
trackEvent({ type: 'ask_question', query: input.message });
|
||||
|
||||
// Add user message and placeholder for AI response
|
||||
@@ -706,7 +651,6 @@ export function AIChatProvider(props: {
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
responseToRateRef.current = { responseId: null, query: null };
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
@@ -888,22 +832,12 @@ function updateAIChatMessageActivity(
|
||||
return {
|
||||
...activity,
|
||||
currentPhase: event.phase,
|
||||
hasCommentary:
|
||||
activity.hasCommentary || event.phase === AIMessageStepPhase.Commentary,
|
||||
hasFinalAnswer:
|
||||
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
|
||||
};
|
||||
}
|
||||
case 'response_document': {
|
||||
// A commentary phase can start without ever producing anything visible. Only a
|
||||
// commentary step that emits document content is a real preamble worth collapsing
|
||||
// behind the activity heading, so flag it here rather than on phase start.
|
||||
return {
|
||||
...activity,
|
||||
hasCommentary:
|
||||
activity.hasCommentary ||
|
||||
(activity.currentPhase === AIMessageStepPhase.Commentary &&
|
||||
event.blocks.length > 0),
|
||||
};
|
||||
}
|
||||
case 'response_tool_call': {
|
||||
return {
|
||||
...activity,
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { AIToolDefinition, SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
|
||||
import { type PagePointer, useCurrentPage } from '../hooks';
|
||||
|
||||
/** The assistant response the user is reacting to, which the tool rates. */
|
||||
export type ResponseToRate = {
|
||||
responseId: string | null;
|
||||
query: string | null;
|
||||
};
|
||||
|
||||
const SubmitAssistantFeedbackInputSchema = z.object({
|
||||
rating: z.enum(['good', 'bad']).describe(
|
||||
`How the user rated your previous response:
|
||||
- 'good' when they express it helped or that they are satisfied.
|
||||
- 'bad' when they indicate it was wrong, unhelpful, incomplete, or otherwise problematic.`
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the built-in `submitAssistantFeedback` tool exposed to the assistant.
|
||||
*
|
||||
* The tool records the user's rating of the assistant's own previous response, reusing the same
|
||||
* `ask_rate_response` insights signal as the thumbs up/down control shown under each answer. It
|
||||
* rates the response the user is reacting to — read from a snapshot taken before the current
|
||||
* (reaction) turn started, since by tool-execution time the store already holds this turn's
|
||||
* response and query. Because it acts on the user's behalf, it asks for confirmation first.
|
||||
*/
|
||||
export function useSubmitAssistantFeedbackTool(options: {
|
||||
/** Display context recorded with the rating event (e.g. `site` vs. `embed`). */
|
||||
displayContext: SiteInsightsDisplayContext;
|
||||
/** Read the previous response to rate, resolved at tool-execution time. */
|
||||
getResponseToRate: () => ResponseToRate;
|
||||
}): GitBookIntegrationTool {
|
||||
const { displayContext, getResponseToRate } = options;
|
||||
const trackEvent = useTrackEvent();
|
||||
const language = useLanguage();
|
||||
const currentPage = useCurrentPage();
|
||||
|
||||
// The tool object is memoized once, so read the latest values from a ref at call time.
|
||||
const ref = React.useRef<{
|
||||
trackEvent: typeof trackEvent;
|
||||
language: typeof language;
|
||||
currentPage: PagePointer | null;
|
||||
displayContext: SiteInsightsDisplayContext;
|
||||
getResponseToRate: () => ResponseToRate;
|
||||
}>({ trackEvent, language, currentPage, displayContext, getResponseToRate });
|
||||
React.useEffect(() => {
|
||||
ref.current = { trackEvent, language, currentPage, displayContext, getResponseToRate };
|
||||
});
|
||||
|
||||
return React.useMemo<GitBookIntegrationTool>(
|
||||
() => ({
|
||||
name: 'submitAssistantFeedback',
|
||||
description:
|
||||
"Record the user's rating of your own previous response, reusing the same signal as the thumbs up/down control shown under each answer. Use this when the user reacts to how you answered — telling you it was wrong, unhelpful, or incomplete (rate 'bad'), or that it helped (rate 'good'). This is about your response, not the page's content — use submitPageFeedback for feedback about the documentation page itself. Only use it when the user clearly expresses such a sentiment, not for every follow-up or minor correction. The user will be asked to confirm before the rating is recorded.",
|
||||
confirmation: (input) => {
|
||||
const parsed = SubmitAssistantFeedbackInputSchema.safeParse(input);
|
||||
const rating = parsed.success ? parsed.data.rating : undefined;
|
||||
return {
|
||||
icon: rating === 'good' ? 'thumbs-up' : 'thumbs-down',
|
||||
label: tString(language, 'ai_chat_tools_submit_feedback'),
|
||||
context: tString(
|
||||
language,
|
||||
'ai_chat_tools_submit_assistant_feedback',
|
||||
(rating === 'good'
|
||||
? tString(language, 'was_this_helpful_positive_label')
|
||||
: tString(language, 'was_this_helpful_negative_label')
|
||||
).toLocaleLowerCase()
|
||||
),
|
||||
};
|
||||
},
|
||||
inputSchema: zodToJsonSchema(
|
||||
SubmitAssistantFeedbackInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
execute: async (input) => {
|
||||
const { trackEvent, language, currentPage, displayContext, getResponseToRate } =
|
||||
ref.current;
|
||||
const { rating } = SubmitAssistantFeedbackInputSchema.parse(input);
|
||||
|
||||
const { responseId, query } = getResponseToRate();
|
||||
if (!responseId || !query) {
|
||||
throw new Error('There is no previous response to rate.');
|
||||
}
|
||||
|
||||
// Attribute to the current page with an explicit context so the event still flushes
|
||||
// on pathnames whose ambient insights context has no page (e.g. the embed's
|
||||
// assistant tab), matching the submit-page-feedback tool.
|
||||
const pageContext: InsightsEventPageContext = {
|
||||
pageId: currentPage?.pageId ?? null,
|
||||
displayContext,
|
||||
};
|
||||
|
||||
trackEvent(
|
||||
{
|
||||
type: 'ask_rate_response',
|
||||
query,
|
||||
responseId,
|
||||
rating: rating === 'good' ? 1 : -1,
|
||||
},
|
||||
pageContext,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
output: { submitted: true, rating, responseId },
|
||||
summary: {
|
||||
icon: rating === 'good' ? 'thumbs-up' : 'thumbs-down',
|
||||
text: tString(language, 'ai_chat_tools_submitted_feedback'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
// Rebuild when the locale changes so the confirmation label (read at memo time, not from
|
||||
// the ref) stays translated.
|
||||
[language]
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
type AIToolDefinition,
|
||||
PageFeedbackRating,
|
||||
type SiteInsightsDisplayContext,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { type InsightsEventPageContext, useTrackEvent } from '../Insights';
|
||||
import { type PagePointer, useCurrentPage } from '../hooks';
|
||||
|
||||
// Matches the comment cap the "Was this helpful?" widget enforces (PageFeedbackForm).
|
||||
const MAX_COMMENT_LENGTH = 512;
|
||||
|
||||
const SubmitPageFeedbackInputSchema = z.object({
|
||||
rating: z.enum(['good', 'ok', 'bad']).describe(
|
||||
`The user's overall sentiment about the current page:
|
||||
- 'good' if it was helpful. Only use it when the user actively expresses positive sentiment.
|
||||
- 'ok' if it was fine but could be improved. Default to 'ok' for neutral feedback (typos, incoherent or missing content, etc).
|
||||
- 'bad' if it was unhelpful. Only use it when the user actively expresses negative sentiment.
|
||||
Infer it from the conversation; if it is unclear, ask the user before submitting.`
|
||||
),
|
||||
comment: z
|
||||
.string()
|
||||
.max(MAX_COMMENT_LENGTH)
|
||||
.optional()
|
||||
.describe(
|
||||
`The user's feedback about the page, in their own words (e.g. what was confusing or missing). Optional — omit it when the user only expressed a rating.`
|
||||
),
|
||||
});
|
||||
|
||||
const ratingByInput: Record<
|
||||
z.infer<typeof SubmitPageFeedbackInputSchema>['rating'],
|
||||
PageFeedbackRating
|
||||
> = {
|
||||
good: PageFeedbackRating.Good,
|
||||
ok: PageFeedbackRating.Ok,
|
||||
bad: PageFeedbackRating.Bad,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the built-in `submitPageFeedback` tool exposed to the assistant.
|
||||
*
|
||||
* The tool records the user's feedback about the page they are currently viewing, reusing the same
|
||||
* insights pipeline as the "Was this helpful?" widget (a `page_post_feedback` event, plus a
|
||||
* `page_post_feedback_comment` event when the user left a comment). Because it acts on the user's
|
||||
* behalf, it asks for confirmation before submitting.
|
||||
*
|
||||
* The events are tracked with an explicit page context so they attribute to the current page even
|
||||
* on pathnames whose ambient insights context has no page (e.g. the embed's assistant tab).
|
||||
*/
|
||||
export function useSubmitPageFeedbackTool(options: {
|
||||
/** Display context recorded with the feedback events (e.g. `site` vs. `embed`). */
|
||||
displayContext: SiteInsightsDisplayContext;
|
||||
}): GitBookIntegrationTool {
|
||||
const { displayContext } = options;
|
||||
const trackEvent = useTrackEvent();
|
||||
const language = useLanguage();
|
||||
const currentPage = useCurrentPage();
|
||||
|
||||
// The tool object is memoized once, so read the latest values from a ref at call time.
|
||||
const ref = React.useRef<{
|
||||
trackEvent: typeof trackEvent;
|
||||
language: typeof language;
|
||||
currentPage: PagePointer | null;
|
||||
displayContext: SiteInsightsDisplayContext;
|
||||
}>({ trackEvent, language, currentPage, displayContext });
|
||||
React.useEffect(() => {
|
||||
ref.current = { trackEvent, language, currentPage, displayContext };
|
||||
});
|
||||
|
||||
return React.useMemo<GitBookIntegrationTool>(
|
||||
() => ({
|
||||
name: 'submitPageFeedback',
|
||||
description:
|
||||
"Submit the feedback on behalf of the user about the documentation page they are currently viewing. Use this when the user is indicating a sentiment about the page, particularly a negative one, or pointing to incorrect or incoherent information on a page. Proactively suggest to submit feedback for the user to help alleviate frustration or indicate a content gap they've encountered. The user will be asked to confirm before the feedback is recorded. Provide a rating and, when the user gave one, a comment in their own words.",
|
||||
confirmation: (input) => {
|
||||
const parsed = SubmitPageFeedbackInputSchema.safeParse(input);
|
||||
const comment = parsed.success ? parsed.data.comment?.trim() : undefined;
|
||||
return {
|
||||
icon: 'paper-plane',
|
||||
label: tString(language, 'ai_chat_tools_submit_feedback', ''),
|
||||
context: comment ? `"${comment}"` : undefined,
|
||||
};
|
||||
},
|
||||
inputSchema: zodToJsonSchema(
|
||||
SubmitPageFeedbackInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
execute: async (input) => {
|
||||
const { trackEvent, language, currentPage, displayContext } = ref.current;
|
||||
const { rating, comment } = SubmitPageFeedbackInputSchema.parse(input);
|
||||
|
||||
if (!currentPage) {
|
||||
throw new Error(
|
||||
'No documentation page is currently open to submit feedback for.'
|
||||
);
|
||||
}
|
||||
|
||||
const pageFeedbackRating = ratingByInput[rating];
|
||||
const trimmedComment = comment?.trim() || undefined;
|
||||
|
||||
const pageContext: InsightsEventPageContext = {
|
||||
pageId: currentPage.pageId,
|
||||
displayContext,
|
||||
};
|
||||
|
||||
trackEvent(
|
||||
{ type: 'page_post_feedback', feedback: { rating: pageFeedbackRating } },
|
||||
pageContext,
|
||||
{ immediate: !trimmedComment }
|
||||
);
|
||||
|
||||
if (trimmedComment) {
|
||||
trackEvent(
|
||||
{
|
||||
type: 'page_post_feedback_comment',
|
||||
feedback: { rating: pageFeedbackRating, comment: trimmedComment },
|
||||
},
|
||||
pageContext,
|
||||
{ immediate: true }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
output: {
|
||||
submitted: true,
|
||||
rating,
|
||||
comment: trimmedComment ?? null,
|
||||
pageId: currentPage.pageId,
|
||||
},
|
||||
summary: {
|
||||
icon: 'comment-check',
|
||||
text: `${tString(language, 'ai_chat_tools_submitted_feedback')}${trimmedComment ? `: "${trimmedComment}"` : ''}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
// Rebuild when the locale changes so the confirmation label (read at memo time, not from
|
||||
// the ref) stays translated.
|
||||
[language]
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'st
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
mode: BlockProps<DocumentBlockCode>['context']['mode'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
@@ -31,7 +30,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, mode, style, inlines, inlineExprVariables, themes, embedded } = props;
|
||||
const { block, mode, style, inlines, inlineExprVariables, themes } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
@@ -143,7 +142,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
theme={theme ?? plainTheme}
|
||||
id={codeBlockBodyId}
|
||||
isPrint={mode === 'print'}
|
||||
embedded={embedded}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function CodeBlock(
|
||||
props: BlockProps<DocumentBlockCode> & {
|
||||
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
embedded?: boolean;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
@@ -33,7 +32,6 @@ export async function CodeBlock(
|
||||
context,
|
||||
themeKey = 'default',
|
||||
themes: providedThemes,
|
||||
embedded,
|
||||
} = props;
|
||||
const inlines = getInlines(block);
|
||||
const isMermaid = block.data.syntax?.toLowerCase() === 'mermaid';
|
||||
@@ -89,7 +87,6 @@ export async function CodeBlock(
|
||||
style={style}
|
||||
theme={theme}
|
||||
isPrint={context.mode === 'print'}
|
||||
embedded={embedded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -111,7 +108,6 @@ export async function CodeBlock(
|
||||
inlineExprVariables: variables,
|
||||
mode: context.mode,
|
||||
themes,
|
||||
embedded,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,6 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
|
||||
* When true, the "Ask AI" button is not rendered.
|
||||
*/
|
||||
isPrint?: boolean;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,12 +28,11 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
props: CodeBlockRendererProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { block, style, theme, 'aria-busy': ariaBusy, isPrint, embedded } = props;
|
||||
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
|
||||
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
const withTitle = Boolean(title && !embedded);
|
||||
const isSingleLine = theme.lines.length <= 1 && !withWrap;
|
||||
|
||||
const id = useId();
|
||||
@@ -44,7 +42,6 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
So we parse the foreground & background into objects that can be passed into React's `style` prop. */
|
||||
const bg = parseShikiColorString(theme.bg);
|
||||
const fg = parseShikiColorString(theme.fg);
|
||||
const codeBackground = embedded ? undefined : bg;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -53,10 +50,8 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls(
|
||||
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
|
||||
!embedded &&
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
|
||||
!embedded &&
|
||||
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
|
||||
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
|
||||
style
|
||||
)}
|
||||
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
|
||||
@@ -70,7 +65,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
className="pointer-events-none absolute size-0 outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
{withTitle ? (
|
||||
{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"
|
||||
style={{
|
||||
@@ -85,37 +80,31 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-col">
|
||||
{!embedded ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
|
||||
isSingleLine ? 'inset-y-0' : 'top-2'
|
||||
)}
|
||||
>
|
||||
{!isPrint ? (
|
||||
<AskAICodeButton
|
||||
codeId={codeId}
|
||||
title={title}
|
||||
syntax={block.data.syntax}
|
||||
style="backdrop-blur-md"
|
||||
/>
|
||||
) : null}
|
||||
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
|
||||
isSingleLine ? 'inset-y-0' : 'top-2'
|
||||
)}
|
||||
>
|
||||
{!isPrint ? (
|
||||
<AskAICodeButton
|
||||
codeId={codeId}
|
||||
title={title}
|
||||
syntax={block.data.syntax}
|
||||
style="backdrop-blur-md"
|
||||
/>
|
||||
) : null}
|
||||
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
|
||||
</div>
|
||||
<pre
|
||||
className={tcls(
|
||||
'relative overflow-auto py-2.5 text-tint-strong print:overflow-visible',
|
||||
embedded
|
||||
? 'bg-tint-base'
|
||||
: 'border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base contrast-more:border-tint contrast-more:bg-tint-base',
|
||||
!embedded &&
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
withTitle && 'rounded-ss-none!'
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 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: codeBackground?.color,
|
||||
...codeBackground?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
}}
|
||||
@@ -129,8 +118,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
>
|
||||
{theme.lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
bg={codeBackground}
|
||||
embedded={embedded}
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
key={index}
|
||||
line={line}
|
||||
@@ -148,13 +136,11 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
function CodeHighlightLine(props: {
|
||||
line: HighlightLine;
|
||||
bg?: ShikiColorDefinition;
|
||||
embedded?: boolean;
|
||||
fg?: ShikiColorDefinition;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers, bg, embedded, fg } = props;
|
||||
const embeddedPlainLine = Boolean(embedded && !line.diff && !line.highlighted);
|
||||
const { line, isLast, withLineNumbers, bg, fg } = props;
|
||||
const lineStyle = {
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
@@ -165,7 +151,6 @@ function CodeHighlightLine(props: {
|
||||
<span
|
||||
className={tcls(
|
||||
'highlight-line',
|
||||
embeddedPlainLine && 'bg-transparent! hover:bg-tint-subtle!',
|
||||
line.diff === 'added' && 'diff-added',
|
||||
line.diff === 'deleted' && 'diff-deleted',
|
||||
line.highlighted && 'highlighted'
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'grid w-full @2xl:grid-flow-col @2xl:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] grid-cols-1 @2xl:grid-rows-1 gap-x-8 gap-y-4',
|
||||
'grid w-full grid-cols-1 gap-x-8 gap-y-4 md:grid-flow-col md:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] md:grid-rows-1',
|
||||
style
|
||||
)}
|
||||
style={{ '--grid-slices': COLUMN_DIVISIONS } as React.CSSProperties}
|
||||
@@ -102,7 +102,7 @@ export function transformLengthToCSS(length: Length) {
|
||||
}
|
||||
if (length.unit === '%') {
|
||||
return {
|
||||
className: '@2xl:flex-shrink-0 @2xl:[grid-column:var(--grid-col)]',
|
||||
className: 'md:flex-shrink-0 md:[grid-column:var(--grid-col)]',
|
||||
style: {
|
||||
'--grid-col': `auto / span ${Math.round(length.value * 0.01 * COLUMN_DIVISIONS)}`,
|
||||
} as React.CSSProperties,
|
||||
|
||||
@@ -19,38 +19,29 @@ export function HashLinkButton(props: {
|
||||
}) {
|
||||
const { id, block, className, iconClassName, label } = props;
|
||||
const textStyle = getBlockTextStyle(block);
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
'hash',
|
||||
'inline-grid',
|
||||
'grid',
|
||||
'grid-area-1-1',
|
||||
'pointer-fine:h-[1em]',
|
||||
'h-[1em]',
|
||||
'border-0',
|
||||
'opacity-0',
|
||||
'pointer-events-none',
|
||||
'site-background',
|
||||
'rounded',
|
||||
'transition-opacity',
|
||||
'pointer-fine:pointer-events-auto',
|
||||
'pointer-fine:group-hover/hash:opacity-100',
|
||||
'pointer-fine:group-focus-within/hash:opacity-100',
|
||||
'[.hash-revealed_&]:opacity-100',
|
||||
'[.hash-revealed_&]:pointer-events-auto',
|
||||
'group-hover/hash:opacity-[0]',
|
||||
'group-focus/hash:opacity-[0]',
|
||||
'md:group-hover/hash:opacity-[1]',
|
||||
'md:group-focus/hash:opacity-[1]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
aria-label={label}
|
||||
className={tcls(
|
||||
'inline-flex items-center',
|
||||
'p-1',
|
||||
'pointer-fine:h-full pointer-fine:p-0',
|
||||
textStyle.lineHeight
|
||||
)}
|
||||
className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
|
||||
>
|
||||
<Icon
|
||||
icon="hashtag"
|
||||
@@ -59,16 +50,12 @@ export function HashLinkButton(props: {
|
||||
'self-center',
|
||||
'transition-colors',
|
||||
'text-transparent',
|
||||
'pointer-fine:group-hover/hash:text-tint-subtle',
|
||||
'contrast-more:pointer-fine:group-hover/hash:text-tint-strong',
|
||||
'pointer-fine:group-focus-within/hash:text-tint-subtle',
|
||||
'contrast-more:pointer-fine:group-focus-within/hash:text-tint-strong',
|
||||
'[.hash-revealed_&]:text-tint-subtle',
|
||||
'contrast-more:[.hash-revealed_&]:text-tint-strong',
|
||||
'group-hover/hash:text-tint-subtle',
|
||||
'contrast-more:group-hover/hash:text-tint-strong',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from './Block';
|
||||
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
|
||||
import { HeadingRevealWrapper } from './HeadingRevealWrapper';
|
||||
import { Inlines } from './Inlines';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
import { getTextAlignment } from './utils';
|
||||
@@ -26,28 +25,36 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
: defaultLanguage;
|
||||
|
||||
return (
|
||||
<HeadingRevealWrapper
|
||||
as={Tag}
|
||||
<Tag
|
||||
id={id}
|
||||
className={tcls(
|
||||
textStyle.textSize,
|
||||
'heading',
|
||||
'pdf-heading',
|
||||
'block',
|
||||
'pr-6',
|
||||
'pointer-fine:flex',
|
||||
'pointer-fine:items-baseline',
|
||||
'pointer-fine:pr-0',
|
||||
'flex',
|
||||
'items-baseline',
|
||||
'scroll-mt-(--content-scroll-margin)',
|
||||
getTextAlignment(block.data.align),
|
||||
hashLinkButtonWrapperStyles,
|
||||
style,
|
||||
textStyle.marginTop
|
||||
)}
|
||||
data-pdf-heading
|
||||
>
|
||||
<span
|
||||
<HashLinkButton
|
||||
id={id}
|
||||
block={block}
|
||||
className={tcls(
|
||||
'pointer-fine:flex-1',
|
||||
'-ml-6 self-center pr-2',
|
||||
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
|
||||
)}
|
||||
iconClassName={tcls('size-4')}
|
||||
label={tString(language, 'direct_link_to_heading')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={tcls(
|
||||
'flex-1',
|
||||
'z-1',
|
||||
'justify-self-start',
|
||||
'max-w-full',
|
||||
@@ -60,23 +67,8 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
)}
|
||||
>
|
||||
<Inlines {...rest} context={context} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</span>
|
||||
|
||||
<HashLinkButton
|
||||
id={id}
|
||||
block={block}
|
||||
className={tcls(
|
||||
'absolute',
|
||||
block.type === 'heading-1'
|
||||
? '[transform:translateY(0.125em)]'
|
||||
: '[transform:translateY(0.17em)]',
|
||||
'pointer-fine:-ml-6 pointer-fine:relative pointer-fine:order-first pointer-fine:self-center pointer-fine:pr-2 pointer-fine:[transform:none]',
|
||||
'pointer-fine:[.flip-heading-hash_&]:order-last pointer-fine:[.flip-heading-hash_&]:ml-1 pointer-fine:[.flip-heading-hash_&]:pl-2'
|
||||
)}
|
||||
iconClassName={tcls('size-4')}
|
||||
label={tString(language, 'direct_link_to_heading')}
|
||||
/>
|
||||
</HeadingRevealWrapper>
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
const HEADING_ATTR = 'data-heading-reveal-wrapper';
|
||||
|
||||
/**
|
||||
* Shared store tracking which single heading (by id) currently has its anchor
|
||||
* icon revealed, so tapping one heading hides any other that was revealed.
|
||||
*/
|
||||
let revealedId: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function handleDocumentPointerUp(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || !target.closest(`[${HEADING_ATTR}]`)) {
|
||||
setRevealedId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void) {
|
||||
listeners.add(onStoreChange);
|
||||
if (listeners.size === 1) {
|
||||
document.addEventListener('pointerup', handleDocumentPointerUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(onStoreChange);
|
||||
if (listeners.size === 0) {
|
||||
document.removeEventListener('pointerup', handleDocumentPointerUp);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return revealedId;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function setRevealedId(id: string | null) {
|
||||
if (revealedId === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
revealedId = id;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a heading tag with tap-to-reveal state for its anchor-link icon on
|
||||
* touch devices, where hover isn't available to reveal it.
|
||||
* Only one heading can be revealed at a time, and tapping outside of any
|
||||
* heading clears it.
|
||||
*/
|
||||
export function HeadingRevealWrapper(props: {
|
||||
as: React.ElementType;
|
||||
id: string;
|
||||
className?: ClassValue;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { as: Tag, id, className, children, ...rest } = props;
|
||||
const currentRevealedId = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const revealed = currentRevealedId === id;
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (event.pointerType === 'mouse' && window.matchMedia('(hover: hover)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('a, button, input, select, textarea, [role="button"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRevealedId(revealed ? null : id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tag
|
||||
id={id}
|
||||
className={tcls(className, revealed && 'hash-revealed')}
|
||||
onPointerUp={handlePointerUp}
|
||||
{...{ [HEADING_ATTR]: true }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { createLazyStylesheet } from '../createLazyStylesheet';
|
||||
|
||||
/**
|
||||
* Lazy-loads the ContentKit stylesheet so it only downloads on pages that actually render
|
||||
* an integration block, instead of shipping in every page's CSS chunk.
|
||||
*/
|
||||
export default createLazyStylesheet(() => import('./contentkit.css'));
|
||||
+4
-8
@@ -6,7 +6,6 @@ import { type GitBookLinker, createLinker } from '@/lib/links';
|
||||
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import type { WebframePageContext } from './adaptive';
|
||||
|
||||
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
|
||||
|
||||
@@ -18,20 +17,18 @@ export type WebframeLinkerData = Pick<
|
||||
|
||||
/**
|
||||
* ContentKit wrapper for integration blocks that expose client-only capabilities to webframes:
|
||||
* the current page, navigation to other pages, and adaptive visitor claims (only when the
|
||||
* integration is allowed to access them).
|
||||
* navigation to other pages, and adaptive visitor claims (only when the integration is allowed to
|
||||
* access them).
|
||||
*/
|
||||
export function ContentKitWithClientContext<RenderContext>(
|
||||
props: ContentKitProps<RenderContext> & {
|
||||
/** Whether visitor claims may be exposed to the webframe (integration scope gated). */
|
||||
canAccessVisitorClaims: boolean;
|
||||
/** Current page to inject into the webframe, or `null` when unknown. */
|
||||
page: WebframePageContext | null;
|
||||
/** Data to rebuild the site linker, used to resolve webframe navigation requests. */
|
||||
linkerData: WebframeLinkerData;
|
||||
}
|
||||
) {
|
||||
const { canAccessVisitorClaims, page, linkerData, ...contentKitProps } = props;
|
||||
const { canAccessVisitorClaims, linkerData, ...contentKitProps } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
@@ -59,7 +56,6 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
getVisitorContext: canAccessVisitorClaims
|
||||
? () => ({ visitor: visitorClaims?.visitor ?? null })
|
||||
: undefined,
|
||||
getPageContext: page ? () => ({ page }) : undefined,
|
||||
navigate: ({ path, anchor }) => {
|
||||
// Resolve the requested path relative to the site root so a webframe can navigate
|
||||
// to any section or space within the site (and nowhere outside it).
|
||||
@@ -67,7 +63,7 @@ export function ContentKitWithClientContext<RenderContext>(
|
||||
navigateTo(linker.toPathInSite(path) + suffix);
|
||||
},
|
||||
}),
|
||||
[canAccessVisitorClaims, visitorClaims, page, linker, navigateTo]
|
||||
[canAccessVisitorClaims, visitorClaims, linker, navigateTo]
|
||||
);
|
||||
|
||||
return <ContentKit {...contentKitProps} clientContext={clientContext} />;
|
||||
|
||||
@@ -2,19 +2,22 @@ import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
||||
import React from 'react';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import './contentkit.css';
|
||||
import type { GitBookLinker } from '@/lib/links';
|
||||
import type { BlockProps } from '../Block';
|
||||
import {
|
||||
ContentKitWithClientContext,
|
||||
type WebframeLinkerData,
|
||||
} from './ContentKitWithClientContext';
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
import { contentKitServerContext } from './contentkit';
|
||||
import { fetchSafeIntegrationUI } from './render';
|
||||
import { renderIntegrationUi } from './server-actions';
|
||||
|
||||
// Lazy so the ContentKit CSS is only fetched on pages that render an integration block.
|
||||
const ContentKitStyles = React.lazy(() => import('./ContentKitStyles'));
|
||||
|
||||
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
@@ -77,11 +80,8 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
const containsWebframe = integrationBlockContainsWebframe(initialOutput);
|
||||
const canAccessVisitorClaims = initialOutput.canAccessVisitorClaims === true;
|
||||
|
||||
// The current page (path/id/title) is non-sensitive, so it is always exposed to webframes.
|
||||
const page = getWebframePageContext(context.contentContext);
|
||||
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages and
|
||||
// exposes the current page, plus visitor claims when the integration is allowed them.
|
||||
// Any webframe uses the client-context wrapper: it enables navigation to other pages, plus
|
||||
// visitor claims when the integration is allowed them.
|
||||
const useClientContext = containsWebframe;
|
||||
|
||||
const contentKitProps = {
|
||||
@@ -105,11 +105,11 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
||||
|
||||
return (
|
||||
<div className={tcls(style)}>
|
||||
<ContentKitStyles />
|
||||
{useClientContext ? (
|
||||
<ContentKitWithClientContext
|
||||
{...contentKitProps}
|
||||
canAccessVisitorClaims={canAccessVisitorClaims}
|
||||
page={page}
|
||||
linkerData={getWebframeLinkerData(context.contentContext.linker)}
|
||||
>
|
||||
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ContentKitRenderOutput, ContentKitWebFrame } from '@gitbook/api';
|
||||
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { createLinker } from '@/lib/links';
|
||||
import { getWebframePageContext, integrationBlockContainsWebframe } from './adaptive';
|
||||
import { integrationBlockContainsWebframe } from './adaptive';
|
||||
|
||||
const webframe: ContentKitWebFrame = {
|
||||
type: 'webframe',
|
||||
@@ -40,47 +38,3 @@ describe('integrationBlockContainsWebframe', () => {
|
||||
expect(integrationBlockContainsWebframe(output)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebframePageContext', () => {
|
||||
it('returns null when the context has no page', () => {
|
||||
const context = { space: { id: 'space-1' } } as unknown as GitBookAnyContext;
|
||||
expect(getWebframePageContext(context)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the page path relative to the site root, including the section slug', () => {
|
||||
const context = {
|
||||
page: {
|
||||
id: 'page-1',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
slug: 'getting-started',
|
||||
},
|
||||
// Site served at /docs, with the page's space mounted under the `api` section.
|
||||
linker: createLinker({ siteBasePath: '/docs/', spaceBasePath: '/docs/api/' }),
|
||||
} as unknown as GitBookAnyContext;
|
||||
|
||||
expect(getWebframePageContext(context)).toEqual({
|
||||
id: 'page-1',
|
||||
path: 'api/guides/getting-started',
|
||||
title: 'Getting started',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the path unprefixed when the space is served at the site root', () => {
|
||||
const context = {
|
||||
page: {
|
||||
id: 'page-2',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
slug: 'getting-started',
|
||||
},
|
||||
linker: createLinker({ siteBasePath: '/', spaceBasePath: '/' }),
|
||||
} as unknown as GitBookAnyContext;
|
||||
|
||||
expect(getWebframePageContext(context)).toEqual({
|
||||
id: 'page-2',
|
||||
path: 'guides/getting-started',
|
||||
title: 'Getting started',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import type {
|
||||
ContentKitDescendantElement,
|
||||
ContentKitRenderOutput,
|
||||
@@ -8,19 +7,9 @@ import type {
|
||||
|
||||
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
|
||||
|
||||
/**
|
||||
* Current page exposed to a webframe through the client-only webframe state.
|
||||
*/
|
||||
export type WebframePageContext = {
|
||||
id: string;
|
||||
/** Path of the page relative to the site root (includes the section and variant). */
|
||||
path: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an integration block's output contains a webframe that can consume client-only context
|
||||
* (navigation, visitor claims and/or the current page).
|
||||
* (navigation and/or visitor claims).
|
||||
*/
|
||||
export function integrationBlockContainsWebframe(output: ContentKitRenderOutput): boolean {
|
||||
if (output.type === 'complete') {
|
||||
@@ -30,31 +19,6 @@ export function integrationBlockContainsWebframe(output: ContentKitRenderOutput)
|
||||
return doesContentKitElementContainWebframe(output.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current page to expose to a webframe, or `null` when it is unknown
|
||||
* (e.g. a non-page context, or reusable content resolved from another source).
|
||||
*
|
||||
* The exposed `path` is resolved relative to the site root — so it carries the section and
|
||||
* variant, unlike the space-relative `page.path` — matching how `@webframe.navigate` resolves a
|
||||
* path. A webframe can pass `page.path` straight back to the navigate action.
|
||||
*/
|
||||
export function getWebframePageContext(
|
||||
contentContext: GitBookAnyContext
|
||||
): WebframePageContext | null {
|
||||
if (!('page' in contentContext) || !contentContext.page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { linker } = contentContext;
|
||||
const { id, path, title } = contentContext.page;
|
||||
|
||||
return {
|
||||
id,
|
||||
path: linker.toRelativePathInSite(linker.toPathInSpace(path)),
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a ContentKit element tree contains a webframe element.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-operation block.
|
||||
@@ -14,6 +14,7 @@ export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlo
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPIOperationBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
||||
|
||||
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi-schemas block.
|
||||
@@ -13,6 +13,7 @@ export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPISchemasBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { createLazyStylesheet } from '../createLazyStylesheet';
|
||||
|
||||
/**
|
||||
* Lazy-loads the OpenAPI/Scalar stylesheet. Kept out of the static import graph so the
|
||||
* ~148KB Scalar CSS only downloads on pages that actually render an OpenAPI block.
|
||||
*/
|
||||
export default createLazyStylesheet(() => import('./style.css'));
|
||||
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { getOpenAPIContext } from './context';
|
||||
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||
|
||||
/**
|
||||
* Render an openapi block or an openapi-webhook block.
|
||||
@@ -14,6 +14,7 @@ export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||
<OpenAPIStyles />
|
||||
<OpenAPIWebhookBody {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { JSONDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||
import React from 'react';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { Heading } from '../Heading';
|
||||
|
||||
import './style.css';
|
||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||
import type { GitBookAnyContext } from '@/lib/context';
|
||||
import { GITBOOK_URL } from '@/lib/env';
|
||||
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
||||
import type {
|
||||
AnyOpenAPIOperationsBlock,
|
||||
@@ -18,11 +17,11 @@ import type {
|
||||
OpenAPIWebhookBlock,
|
||||
} from '@/lib/openapi/types';
|
||||
|
||||
// Serve the proxy from GitBook's own origin rather than the customer domain, so a proxied
|
||||
// response can never execute as HTML under a customer's trusted origin.
|
||||
const OPEN_ORIGIN_PROXY_URL = GITBOOK_URL
|
||||
? new URL('/~scalar/proxy', GITBOOK_URL).toString()
|
||||
: null;
|
||||
/**
|
||||
* Lazy loader for the OpenAPI/Scalar stylesheet, rendered by each OpenAPI block so the CSS
|
||||
* is only fetched on pages that use one.
|
||||
*/
|
||||
export const OpenAPIStyles = React.lazy(() => import('./OpenAPIStyles'));
|
||||
|
||||
/**
|
||||
* Get the OpenAPI context to render a block.
|
||||
@@ -41,22 +40,16 @@ export function getOpenAPIContext(args: {
|
||||
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
|
||||
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
|
||||
|
||||
const siteId = context && 'site' in context ? context.site.id : undefined;
|
||||
|
||||
// Fall back to the site's own host root when GITBOOK_URL is unset (self-hosted single-origin);
|
||||
// the proxy route is only ever mounted at the host root, never under the site base path.
|
||||
const proxyUrl =
|
||||
context && siteId && props.context.mode !== 'print'
|
||||
? (OPEN_ORIGIN_PROXY_URL ?? context.linker.toAbsoluteURL('/~scalar/proxy'))
|
||||
context && props.context.mode !== 'print'
|
||||
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
specUrl,
|
||||
resolveProxyUrl:
|
||||
proxyUrl && siteId
|
||||
? (allowedOrigins: string[]) =>
|
||||
buildSignedProxyUrl(proxyUrl, allowedOrigins, siteId)
|
||||
: undefined,
|
||||
resolveProxyUrl: proxyUrl
|
||||
? (allowedOrigins: string[]) => buildSignedProxyUrl(proxyUrl, allowedOrigins)
|
||||
: undefined,
|
||||
icons: {
|
||||
chevronDown: <Icon icon="chevron-down" />,
|
||||
chevronRight: <Icon icon="chevron-right" />,
|
||||
@@ -65,7 +58,6 @@ export function getOpenAPIContext(args: {
|
||||
check: <Icon icon="check" />,
|
||||
lock: <Icon icon="lock" />,
|
||||
mcp: <Icon icon="mcp" />,
|
||||
hashtag: <Icon icon="hashtag" />,
|
||||
},
|
||||
renderCodeBlock: (codeProps) => (
|
||||
<PlainCodeBlock
|
||||
|
||||
@@ -116,52 +116,8 @@ button.openapi-mcp {
|
||||
@apply !mb-0;
|
||||
}
|
||||
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
/* Method / status-code tag styles moved to `./tags.css`, loaded globally so the sidebar method
|
||||
* badges are styled even before an OpenAPI block mounts this deferred stylesheet. */
|
||||
|
||||
/* URL */
|
||||
.openapi-url {
|
||||
@@ -865,30 +821,6 @@ body:has(.openapi-select-popover) {
|
||||
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
|
||||
}
|
||||
|
||||
/* Model name + its hover hash-link, inline. Carries the anchor id / deep-link scroll target. */
|
||||
.openapi-schemas-model-title {
|
||||
@apply flex items-center gap-2 min-w-0;
|
||||
/* Offset by the trigger's p-5 so a deep-link lands the model's top edge, not its title, below the header. */
|
||||
scroll-margin-top: calc(var(--content-scroll-margin) + 1.25rem);
|
||||
}
|
||||
|
||||
.openapi-schemas-model-title-name {
|
||||
@apply min-w-0 truncate;
|
||||
}
|
||||
|
||||
.openapi-schemas-anchor-link {
|
||||
@apply shrink-0 flex items-center opacity-0 transition-opacity;
|
||||
}
|
||||
|
||||
.openapi-schemas-disclosure:hover .openapi-schemas-anchor-link,
|
||||
.openapi-schemas-anchor-link:focus-visible {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.openapi-schemas-anchor-link svg {
|
||||
@apply size-3 text-tint-subtle hover:text-tint-strong;
|
||||
}
|
||||
|
||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
||||
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
||||
}
|
||||
@@ -989,7 +921,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
}
|
||||
|
||||
.openapi-disclosure-trigger[aria-expanded="true"] .openapi-disclosure-trigger-label svg {
|
||||
.openapi-disclosure-trigger[aria-expanded="true"] svg {
|
||||
@apply rotate-45;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* OpenAPI method / status-code tag styles.
|
||||
*
|
||||
* These live outside the deferred `style.css` and are loaded from the global stylesheet because
|
||||
* the HTTP method badge (`OpenAPIMethodBadge`) renders in the always-present sidebar (table of
|
||||
* contents) on every page — not only on pages that mount an OpenAPI block. If they stayed in the
|
||||
* lazily-loaded stylesheet, the sidebar badges would be unstyled until an OpenAPI page pulled in
|
||||
* the heavy Scalar CSS.
|
||||
*/
|
||||
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-small {}
|
||||
|
||||
.openapi-method-medium {
|
||||
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
|
||||
}
|
||||
|
||||
.toclink .openapi-method {
|
||||
@apply text-[0.625rem] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
.openapi-statuscode-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
|
||||
.openapi-method-post,
|
||||
.openapi-statuscode-redirection {
|
||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||
}
|
||||
|
||||
.openapi-method-put,
|
||||
.openapi-statuscode-informational {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
.openapi-method-patch {
|
||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||
}
|
||||
|
||||
.openapi-method-delete,
|
||||
.openapi-statuscode-error {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
@@ -1,46 +1,25 @@
|
||||
import {
|
||||
CustomizationPageActionType,
|
||||
type DocumentBlockCode,
|
||||
type DocumentBlockPrompt,
|
||||
type SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { validateIconName } from '@gitbook/icons/icons';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { CodeBlock } from '../CodeBlock';
|
||||
import { getPlainCodeBlock } from '../CodeBlock/highlight';
|
||||
import { PromptClient } from './PromptClient';
|
||||
|
||||
const PROMPT_COLLAPSED_LINE_COUNT = 10;
|
||||
|
||||
export function Prompt(props: BlockProps<DocumentBlockPrompt>) {
|
||||
const { block, document, ancestorBlocks, context, isEstimatedOffscreen } = props;
|
||||
const { block } = props;
|
||||
const contentIcon =
|
||||
block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null;
|
||||
const prompt = getPromptText(block);
|
||||
const defaultExpanded = block.data.defaultExpanded ?? 'hidden';
|
||||
|
||||
const promptContent = block.nodes.map((node, index) => (
|
||||
<CodeBlock
|
||||
key={node.key ?? index}
|
||||
block={getPromptCodeBlock(node, { defaultExpanded })}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
embedded
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<PromptClient
|
||||
{...block.data}
|
||||
contentIcon={contentIcon}
|
||||
defaultExpanded={defaultExpanded}
|
||||
description={block.data.description}
|
||||
prompt={getPromptText(block)}
|
||||
openInAIProviders={getOpenInAIProviders(props)}
|
||||
prompt={prompt}
|
||||
>
|
||||
{promptContent}
|
||||
</PromptClient>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,22 +49,3 @@ function isExternalAIPageActionEnabled(
|
||||
function getPromptText(block: DocumentBlockPrompt): string {
|
||||
return (block.nodes ?? []).map((node) => getPlainCodeBlock(node)).join('\n');
|
||||
}
|
||||
|
||||
function getPromptCodeBlock(
|
||||
block: DocumentBlockCode,
|
||||
options: { defaultExpanded: 'hidden' | 'partial' | 'full' }
|
||||
): DocumentBlockCode {
|
||||
const isPartiallyExpanded = options.defaultExpanded === 'partial';
|
||||
|
||||
return {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
syntax: block.data.syntax ?? 'markdown',
|
||||
expandable: isPartiallyExpanded,
|
||||
collapsedLineCount: isPartiallyExpanded ? PROMPT_COLLAPSED_LINE_COUNT : undefined,
|
||||
lineNumbers: false,
|
||||
overflow: 'wrap',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,83 +4,67 @@ import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '@/compone
|
||||
import { getURLForLLM } from '@/components/utils';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockPrompt } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
|
||||
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
|
||||
|
||||
type PromptClientProps = DocumentBlockPrompt['data'] & {
|
||||
export function PromptClient(props: {
|
||||
contentIcon: IconName | null;
|
||||
description: string;
|
||||
prompt: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PromptClient(props: PromptClientProps) {
|
||||
const {
|
||||
contentIcon,
|
||||
description,
|
||||
openInAIProviders = false,
|
||||
defaultExpanded = 'hidden',
|
||||
prompt,
|
||||
children,
|
||||
} = props;
|
||||
openInAIProviders: boolean;
|
||||
}) {
|
||||
const { contentIcon, description, prompt, openInAIProviders } = props;
|
||||
const language = useLanguage();
|
||||
const promptId = React.useId();
|
||||
const [open, setOpen] = React.useState(defaultExpanded === 'full');
|
||||
const isPartiallyExpanded = defaultExpanded === 'partial';
|
||||
const expanded = isPartiallyExpanded || open;
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(defaultExpanded === 'full');
|
||||
}, [defaultExpanded]);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative flex w-full flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs text-tint-strong',
|
||||
'border border-tint-subtle contrast-more:border-tint',
|
||||
'transition',
|
||||
expanded ? 'bg-tint depth-subtle:shadow-xs' : 'bg-tint-base'
|
||||
open ? 'bg-tint depth-subtle:shadow-xs' : 'bg-tint-base'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 p-3 transition-colors',
|
||||
!isPartiallyExpanded && (open ? 'hover:bg-tint-hover' : 'hover:bg-tint-subtle')
|
||||
open ? 'hover:bg-tint-hover' : 'hover:bg-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{!isPartiallyExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={promptId}
|
||||
aria-expanded={open}
|
||||
aria-label={tString(language, 'view')}
|
||||
className={tcls(
|
||||
'absolute inset-0 z-10 cursor-pointer outline-hidden',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-hover'
|
||||
)}
|
||||
disabled={!prompt}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={promptId}
|
||||
aria-expanded={open}
|
||||
aria-label={tString(language, 'view')}
|
||||
className={tcls(
|
||||
'absolute inset-0 z-10 cursor-pointer outline-hidden',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-hover'
|
||||
)}
|
||||
disabled={!prompt}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
/>
|
||||
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
|
||||
{!isPartiallyExpanded ? (
|
||||
<ToggleChevron
|
||||
open={open}
|
||||
orientation="right-to-down"
|
||||
className="size-3 shrink-0 text-tint-subtle transition-colors group-hover/prompt-header:text-tint-strong"
|
||||
/>
|
||||
) : null}
|
||||
<ToggleChevron
|
||||
open={open}
|
||||
orientation="right-to-down"
|
||||
className="size-3 shrink-0 text-tint-subtle transition-colors group-hover/prompt-header:text-tint-strong"
|
||||
/>
|
||||
{contentIcon ? <Icon icon={contentIcon} className="size-4 shrink-0" /> : null}
|
||||
<span className="min-w-0 truncate">{description}</span>
|
||||
</div>
|
||||
<PromptActions prompt={prompt} openInAIProviders={openInAIProviders} />
|
||||
</div>
|
||||
{isPartiallyExpanded || open ? (
|
||||
{open ? (
|
||||
<div id={promptId} className="border-tint-subtle border-t bg-tint-base">
|
||||
{children}
|
||||
<pre className="overflow-auto p-4 text-sm text-tint-strong">
|
||||
<code className="language-markdown whitespace-pre-wrap font-mono">
|
||||
{prompt}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Prompt';
|
||||
export type * from './types';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
export type PromptBlock = {
|
||||
object: 'block';
|
||||
type: 'prompt';
|
||||
key?: string;
|
||||
data: {
|
||||
icon?: string;
|
||||
description?: string;
|
||||
openInAIProviders?: boolean;
|
||||
};
|
||||
nodes?: DocumentBlockCode[];
|
||||
isVoid?: false;
|
||||
};
|
||||
@@ -201,7 +201,7 @@ export function TableSearchEmpty(props: { className?: ClassValue }) {
|
||||
|
||||
const trimmed = query.trim();
|
||||
return (
|
||||
<div className={tcls('mx-auto py-8 text-center text-sm text-tint', props.className)}>
|
||||
<div className={tcls('mx-auto text-center text-sm text-tint', props.className)}>
|
||||
{trimmed
|
||||
? tString(language, 'search_no_results_for', trimmed)
|
||||
: tString(language, 'search_no_results')}
|
||||
|
||||
@@ -31,7 +31,8 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
|
||||
'inline-grid',
|
||||
'gap-4',
|
||||
'grid-cols-1',
|
||||
view.cardSize === 'large' ? '@xl:grid-cols-2' : '@2xl:grid-cols-3 @sm:grid-cols-2', // Large cards break earlier to avoid becoming *too* big.
|
||||
'@sm:grid-cols-2',
|
||||
view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3',
|
||||
block.data.fullWidth ? 'large:flex-column' : null
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,107 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import type React from 'react';
|
||||
import { type ComponentPropsWithRef, memo, useCallback, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithRef,
|
||||
} from 'react';
|
||||
|
||||
import { useResolvedSlug, useSelect } from '@/components/Select';
|
||||
import { useListOverflow } from '@/components/hooks';
|
||||
import { NavigationStatusContext, useListOverflow } from '@/components/hooks';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
SELECT_DEFAULT_ATTR,
|
||||
SELECT_GROUP_ATTR,
|
||||
SELECT_OPTION_ATTR,
|
||||
SELECT_PINNED_ATTR,
|
||||
SELECT_UNPINNED_ATTR,
|
||||
} from '@/lib/select';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
const defaultTabsState: TabsState = {
|
||||
activeIds: {},
|
||||
activeTitles: [],
|
||||
};
|
||||
|
||||
let globalTabsState = getLocalStorageItem('@gitbook/tabsState', defaultTabsState);
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function useTabsState() {
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}, []);
|
||||
|
||||
const getSnapshot = useCallback(() => globalTabsState, []);
|
||||
|
||||
const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => {
|
||||
globalTabsState = updater(globalTabsState);
|
||||
setLocalStorageItem('@gitbook/tabsState', globalTabsState);
|
||||
listeners.forEach((listener) => listener());
|
||||
}, []);
|
||||
const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return [state, setTabsState] as const;
|
||||
}
|
||||
|
||||
// How many titles are remembered:
|
||||
const TITLES_MAX = 5;
|
||||
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
/** The `select` slug for this tab, derived from its title (see Tabs.tsx). */
|
||||
slug: string;
|
||||
icon?: IconName;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side component for the tabs, taking care of interactions.
|
||||
*
|
||||
* Pane visibility is driven entirely by CSS (see generateSelectCSS): each pane carries its slug as
|
||||
* `data-select-option`, and the generated stylesheet shows the most-recently-activated one based on
|
||||
* the `data-sel-*` attributes on `<html>`. That means the correct pane is visible before hydration
|
||||
* (no flash) and with JS disabled.
|
||||
*
|
||||
* The one thing CSS can't decide is *which* of several same-named tabs in one group the visitor
|
||||
* clicked — by slug they're identical, so the stylesheet falls back to the first. After an explicit
|
||||
* click we pin the exact pane via `data-select-pinned`/`-unpinned` (a client-only override that
|
||||
* reverts to first-match on reload). The tablist highlight follows the same resolved tab.
|
||||
*/
|
||||
export function DynamicTabs(props: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
setClassName: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { tabs, setClassName, className } = props;
|
||||
const { activate } = useSelect();
|
||||
// The tab the visitor explicitly clicked this session (not persisted — reload reverts to CSS).
|
||||
const [manualId, setManualId] = useState<string | null>(null);
|
||||
const { id, tabs, className } = props;
|
||||
const router = useRouter();
|
||||
|
||||
const candidateSlugs = useMemo(() => tabs.map((tab) => tab.slug), [tabs]);
|
||||
const activeSlug = useResolvedSlug(candidateSlugs, tabs[0]?.slug ?? null);
|
||||
const { onNavigationClick, hash } = React.useContext(NavigationStatusContext);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [tabsState, setTabsState] = useTabsState();
|
||||
const activeState = useMemo(() => {
|
||||
const input = { id, tabs };
|
||||
return (
|
||||
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
|
||||
);
|
||||
}, [id, tabs, tabsState]);
|
||||
|
||||
// Resolve which tab is shown. Default: the first tab of the active slug — matching the CSS
|
||||
// first-match. A manual click pins a specific tab, but only while its slug is the active one; if
|
||||
// the active slug is a duplicate and the pinned tab isn't the first of it, `override` carries the
|
||||
// pin so the panes below can steer CSS past first-match.
|
||||
const { activeTabId, override } = useMemo(() => {
|
||||
const firstMatch = tabs.find((tab) => tab.slug === activeSlug) ?? tabs[0];
|
||||
const manual = manualId ? tabs.find((tab) => tab.id === manualId) : undefined;
|
||||
const pinned =
|
||||
manual && manual.slug === activeSlug && manual.id !== firstMatch?.id ? manual : null;
|
||||
return { activeTabId: pinned?.id ?? firstMatch?.id ?? null, override: pinned };
|
||||
}, [tabs, activeSlug, manualId]);
|
||||
// Track if the tab has been touched by the user.
|
||||
const touchedRef = useRef(false);
|
||||
|
||||
// To avoid issue with hydration, we only use the state from localStorage
|
||||
// once the component has been initialized (=mounted).
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const active = initialized ? activeState : tabs[0];
|
||||
|
||||
// When clicking to select a tab, we:
|
||||
// - update the URL hash
|
||||
// - mark this specific ID as selected
|
||||
// - store the ID to auto-select other tabs with the same title
|
||||
const selectTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const tab = tabs.find((item) => item.id === tabId);
|
||||
if (tab?.slug) {
|
||||
activate(tab.slug);
|
||||
setManualId(tabId);
|
||||
(tabId: string, manual = true) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
touchedRef.current = true;
|
||||
const href = `#${tab.id}`;
|
||||
if (window.location.hash !== href) {
|
||||
onNavigationClick(href);
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTabsState((prev) => {
|
||||
if (prev.activeIds[id] === tab.id) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[tabs, activate]
|
||||
[router, setTabsState, tabs, id]
|
||||
);
|
||||
|
||||
// When the hash changes, we try to select the tab containing the targetted element.
|
||||
React.useLayoutEffect(() => {
|
||||
setInitialized(true);
|
||||
|
||||
if (hash) {
|
||||
// First check if the hash matches a tab ID.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
selectTab(hash, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check if the hash matches an element inside a tab.
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabPanel = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectTab(tabPanel.id, false);
|
||||
}
|
||||
}, [selectTab, tabs, hash]);
|
||||
|
||||
// Scroll to active element in the tab.
|
||||
React.useLayoutEffect(() => {
|
||||
// If there is no hash or active tab, nothing to scroll.
|
||||
if (!hash || hash !== '' || !active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the tab is touched, we don't want to scroll.
|
||||
if (touchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the hash matches a tab, then the scroll is already done.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeElement.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [active, tabs, hash]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...{ [SELECT_GROUP_ATTR]: '' }}
|
||||
className={tcls(
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-xs',
|
||||
'ring-1 ring-tint-subtle ring-inset',
|
||||
'flex min-w-0 flex-col',
|
||||
setClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TabItemList tabs={tabs} activeTabId={activeTabId} onSelect={selectTab} />
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isDefault={index === 0}
|
||||
pin={
|
||||
override && tab.slug === override.slug
|
||||
? tab.id === override.id
|
||||
? 'pinned'
|
||||
: 'unpinned'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -109,24 +215,19 @@ export function DynamicTabs(props: {
|
||||
|
||||
const TabPanel = memo(function TabPanel(props: {
|
||||
tab: TabsItem;
|
||||
isDefault: boolean;
|
||||
pin?: 'pinned' | 'unpinned';
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const { tab, isDefault, pin } = props;
|
||||
const { tab, isActive } = props;
|
||||
return (
|
||||
<div
|
||||
{...{
|
||||
[SELECT_OPTION_ATTR]: tab.slug,
|
||||
...(isDefault ? { [SELECT_DEFAULT_ATTR]: '' } : {}),
|
||||
...(pin === 'pinned' ? { [SELECT_PINNED_ATTR]: '' } : {}),
|
||||
...(pin === 'unpinned' ? { [SELECT_UNPINNED_ATTR]: '' } : {}),
|
||||
}}
|
||||
role="tabpanel"
|
||||
id={tab.id}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||
>
|
||||
<div className="p-4">{tab.body}</div>
|
||||
<div className="p-4" hidden={!isActive}>
|
||||
{tab.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -337,3 +438,42 @@ function getTabIdFromButtonId(buttonId: string) {
|
||||
}
|
||||
return buttonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicitly selected tab in a set of tabs.
|
||||
*/
|
||||
function getTabBySelection(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
const activeId = state.activeIds[input.id];
|
||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||
*/
|
||||
function getTabByTitle(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
return (
|
||||
input.tabs
|
||||
.map((item) => {
|
||||
return {
|
||||
item,
|
||||
score: state.activeTitles.indexOf(item.title),
|
||||
};
|
||||
})
|
||||
.filter(({ score }) => score >= 0)
|
||||
// .sortBy(({ score }) => -score)
|
||||
.sort(({ score: a }, { score: b }) => b - a)
|
||||
.map(({ item }) => item)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ import type { DocumentBlockTabs } from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { validateIconName } from '@gitbook/icons/icons';
|
||||
|
||||
import { generateSelectCSS, selectSetClassName, slugifySelectValue } from '@/lib/select';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { DynamicTabs } from './DynamicTabs';
|
||||
import { DynamicTabs, type TabsItem } from './DynamicTabs';
|
||||
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
const { block, ancestorBlocks, document, style, context } = props;
|
||||
@@ -16,7 +15,9 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
throw new Error('Tabs block is missing a key');
|
||||
}
|
||||
|
||||
const items = block.nodes.map((tab) => {
|
||||
const id = block.key;
|
||||
|
||||
const tabs: TabsItem[] = block.nodes.map((tab) => {
|
||||
if (!tab.key) {
|
||||
throw new Error('Tab block is missing a key');
|
||||
}
|
||||
@@ -42,72 +43,12 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
};
|
||||
});
|
||||
|
||||
const tabs = withSelectSlugs(items);
|
||||
|
||||
// When printing, we display the tabs one after the other, each as its own single-tab group so
|
||||
// every variant is visible (no selection to hide them).
|
||||
// When printing we show every tab, one after another, so there's no selection to resolve — skip
|
||||
// the generated stylesheet entirely (each single-tab group's pane is its own default and stays
|
||||
// visible on its own).
|
||||
// When printing, we display the tab, one after the other
|
||||
if (context.mode === 'print') {
|
||||
return tabs.map((tab) => (
|
||||
<DynamicTabs
|
||||
key={tab.id}
|
||||
tabs={[tab]}
|
||||
setClassName={selectSetClassName([tab.slug])}
|
||||
className={tcls(style)}
|
||||
/>
|
||||
));
|
||||
return tabs.map((tab) => {
|
||||
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
|
||||
});
|
||||
}
|
||||
|
||||
const slugs = tabs.map((tab) => tab.slug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectGroupStyle slugs={slugs} />
|
||||
<DynamicTabs
|
||||
tabs={tabs}
|
||||
setClassName={selectSetClassName(slugs)}
|
||||
className={tcls(style)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stylesheet that resolves which pane a tab group shows, purely in CSS (see generateSelectCSS).
|
||||
* Byte-identical for every visitor, so it has no cache impact.
|
||||
*
|
||||
* `href` + `precedence` opt into React's stylesheet hoisting: the tag is moved to `<head>` (out of
|
||||
* the content flow, so sibling/child selectors like Tailwind's `space-y-*` never count it as a
|
||||
* phantom node) and deduped by `href`, so identical option-sets across the page share one sheet.
|
||||
*/
|
||||
function SelectGroupStyle({ slugs }: { slugs: string[] }) {
|
||||
const css = generateSelectCSS(slugs);
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<style href={selectSetClassName(slugs)} precedence="high">
|
||||
{css}
|
||||
</style>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a `select` slug for each tab from its title. Untitled tabs fall back to their (stable) id
|
||||
* so they stay selectable.
|
||||
*
|
||||
* Same-named tabs deliberately share a slug — selecting one syncs every tab of that name, here and
|
||||
* on other pages, which is the whole point of name-based selection. We don't disambiguate duplicates
|
||||
* with a positional suffix: that would desync the duplicate and, because the slug rides in the
|
||||
* frozen `?select=` URL, a shared link would silently retarget when tabs are renamed or reordered.
|
||||
*/
|
||||
function withSelectSlugs<T extends { id: string; title: string }>(
|
||||
items: T[]
|
||||
): Array<T & { slug: string }> {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
slug: slugifySelectValue(item.title) || slugifySelectValue(item.id) || item.id,
|
||||
}));
|
||||
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Factory for a fire-and-forget loader of a code-split stylesheet: the CSS only downloads on
|
||||
* pages that render the associated block, instead of shipping in every page's CSS chunk. The
|
||||
* returned component renders nothing.
|
||||
*/
|
||||
export function createLazyStylesheet(load: () => Promise<unknown>) {
|
||||
let loaded = false;
|
||||
return function LazyStylesheet() {
|
||||
// Load during render (not in an effect) so the request starts as early as possible.
|
||||
if (!loaded && typeof window !== 'undefined') {
|
||||
loaded = true;
|
||||
load();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@@ -197,9 +197,8 @@ export function EmbeddableIframeTabs(props: {
|
||||
active?: string;
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
}) {
|
||||
const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props;
|
||||
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
||||
const actions = useEmbeddableConfiguration((state) => state.actions);
|
||||
const tabs = useEmbeddableTabs();
|
||||
|
||||
@@ -262,10 +261,6 @@ export function EmbeddableIframeTabs(props: {
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
if (tab.key !== active && onNavigate) {
|
||||
onNavigate(tab.href);
|
||||
return;
|
||||
}
|
||||
router.push(tab.href);
|
||||
}}
|
||||
tooltipProps={{
|
||||
@@ -279,8 +274,7 @@ export function EmbeddableIframeTabs(props: {
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function EmbeddableIframeCloseButton(props: { onClose?: () => void }) {
|
||||
const { onClose } = props;
|
||||
export function EmbeddableIframeCloseButton() {
|
||||
const { closeButton } = useEmbeddableConfiguration();
|
||||
|
||||
if (!closeButton) {
|
||||
@@ -297,7 +291,6 @@ export function EmbeddableIframeCloseButton(props: { onClose?: () => void }) {
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
getChannel()?.send({ type: 'close' });
|
||||
}}
|
||||
tooltipProps={{
|
||||
|
||||
@@ -38,12 +38,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
askQuery,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
query,
|
||||
results,
|
||||
resultsId,
|
||||
@@ -53,10 +51,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
showAsk,
|
||||
withSearchAI,
|
||||
scopeControl,
|
||||
} = useSearchController(
|
||||
{ ...searchProps, asEmbeddable: hasDocsTab },
|
||||
{ restoreLastQueryOnMount: true }
|
||||
);
|
||||
} = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab });
|
||||
|
||||
return (
|
||||
<LinkContext value={linkContext}>
|
||||
@@ -71,7 +66,6 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
onResultSelect={onResultSelect}
|
||||
showAsk={showAsk}
|
||||
dataTestId="embed-search"
|
||||
input={
|
||||
@@ -103,10 +97,9 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) {
|
||||
active="search"
|
||||
baseURL={baseURL}
|
||||
siteTitle={siteTitle}
|
||||
onNavigate={close}
|
||||
/>
|
||||
<EmbeddableIframeButtons />
|
||||
<EmbeddableIframeCloseButton onClose={close} />
|
||||
<EmbeddableIframeCloseButton />
|
||||
</>
|
||||
}
|
||||
scopeControl={
|
||||
|
||||
@@ -154,11 +154,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
const pathname = window.location.pathname;
|
||||
const previous = eventsRef.current[pathname];
|
||||
eventsRef.current[pathname] = {
|
||||
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
|
||||
// attribute to their page even when the pathname's ambient context has none — such
|
||||
// as the embed's assistant tab, whose view records a null page. Events that pass no
|
||||
// context keep the stored one.
|
||||
pageContext: ctx ?? previous?.pageContext,
|
||||
pageContext: previous?.pageContext ?? ctx,
|
||||
url: previous?.url ?? window.location.href,
|
||||
events: [
|
||||
...(previous?.events ?? []),
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from '@/lib/icons/inline';
|
||||
import { defaultCustomization } from '@/lib/utils';
|
||||
import { AnnouncementDismissedScript } from '../Announcement';
|
||||
import { SelectStateScript } from '../Select';
|
||||
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
|
||||
|
||||
function preloadFont(fontData: FontData) {
|
||||
@@ -148,9 +147,6 @@ export async function CustomizationRootLayout(props: {
|
||||
|
||||
<OperatingSystemClassScript />
|
||||
|
||||
{/* Apply the visitor's content selection to <html> before first paint (no flash) */}
|
||||
<SelectStateScript />
|
||||
|
||||
{/* Inject custom font @font-face rules */}
|
||||
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
|
||||
{monospaceFontData.type === 'custom' ? (
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
@import "./prose.css";
|
||||
|
||||
/* OpenAPI method/status-code tags render in the always-present sidebar, so their styles must
|
||||
ship globally instead of in the deferred OpenAPI stylesheet. */
|
||||
@import "../DocumentView/OpenAPI/tags.css";
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AIChatButton } from '../AIChat';
|
||||
@@ -11,16 +10,13 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button, Popover } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { SearchFrame } from './SearchFrame';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchController } from './useSearchController';
|
||||
|
||||
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface SearchContainerProps extends SearchBaseProps {
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
@@ -44,12 +40,12 @@ export function SearchContainer({
|
||||
askQuery,
|
||||
askCount,
|
||||
askInAssistant,
|
||||
abort,
|
||||
close,
|
||||
cursor,
|
||||
error,
|
||||
fetching,
|
||||
onInputKeyDown,
|
||||
onResultSelect,
|
||||
open,
|
||||
query,
|
||||
results,
|
||||
@@ -117,20 +113,8 @@ export function SearchContainer({
|
||||
cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined;
|
||||
const isSearchOpen = Boolean(visible && (state?.open ?? false));
|
||||
const shouldFillHeight = Boolean(query || showAsk);
|
||||
|
||||
// The SideSheet always renders its children (it hides them with CSS, unlike the desktop
|
||||
// Popover which mounts its content on open). Mounting the frame only after the first open
|
||||
// keeps the dynamic SearchFrame chunk off the mobile startup path, while leaving it mounted
|
||||
// afterwards so the sheet's exit animation isn't cut short.
|
||||
const [wasSearchOpened, setWasSearchOpened] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (isSearchOpen) {
|
||||
setWasSearchOpened(true);
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
const shouldShowSearchFrame = usesSideSheet
|
||||
? Boolean(state?.open || state?.query || wasSearchOpened)
|
||||
? Boolean(state?.open || state?.query || withAI)
|
||||
: Boolean(state?.query || withAI);
|
||||
const scopeControlNode =
|
||||
searchProps.withVariants || searchProps.withSections ? (
|
||||
@@ -169,7 +153,7 @@ export function SearchContainer({
|
||||
results={results}
|
||||
resultsId={resultsId}
|
||||
resultsRef={resultsRef}
|
||||
onResultSelect={onResultSelect}
|
||||
onResultSelect={abort}
|
||||
showAsk={showAsk}
|
||||
scopeControl={scopeControlNode}
|
||||
fillHeight={usesSideSheet || shouldFillHeight}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
type LastSearchQueryBySiteSpace = Record<string, string>;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let globalLastSearchQuery: LastSearchQueryBySiteSpace = {};
|
||||
|
||||
function emitChange() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function getLastSearchQuery(siteSpaceId: string): string | null {
|
||||
return globalLastSearchQuery[siteSpaceId] ?? null;
|
||||
}
|
||||
|
||||
export function setLastSearchQuery(siteSpaceId: string, query: string | null): void {
|
||||
if (!siteSpaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = query?.trim() || null;
|
||||
const nextState = { ...globalLastSearchQuery };
|
||||
|
||||
if (normalizedQuery) {
|
||||
nextState[siteSpaceId] = normalizedQuery;
|
||||
} else {
|
||||
delete nextState[siteSpaceId];
|
||||
}
|
||||
|
||||
globalLastSearchQuery = nextState;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
export function clearLastSearchQuery(siteSpaceId: string): void {
|
||||
setLastSearchQuery(siteSpaceId, null);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useLastSearchQuery(siteSpaceId: string): string | null {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => globalLastSearchQuery[siteSpaceId] ?? null,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Client-side access to the `~gitbook/site-index` JSON, shared by every consumer
|
||||
* (instant search in `useLocalSearchResults`, related pages on the 404 page).
|
||||
*
|
||||
* Loading strategy: the download starts as early as possible (server-rendered
|
||||
* preload hint in `SiteLayout`, deduped here at first call) and is kept as raw
|
||||
* text so the multi-MB `JSON.parse` — main-thread work — is only paid when a
|
||||
* consumer actually needs the pages.
|
||||
*/
|
||||
|
||||
export interface SiteIndexBreadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Raw entry from the `~gitbook/site-index` JSON response */
|
||||
export interface SiteIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
/** BCP-47 language code emitted by the index route, absent when no language is set. */
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: SiteIndexBreadcrumb[];
|
||||
}
|
||||
|
||||
let siteIndexText: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Start (or reuse) the single-flight download of the raw index. Errors clear the
|
||||
* cache so the next consumer retries.
|
||||
*/
|
||||
export function prefetchSiteIndex(indexURL: string): void {
|
||||
fetchSiteIndexText(indexURL).catch(() => {
|
||||
// Ignored: consumers surface errors when they actually read the index.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parsed index pages. Parses per call (cheap for the rare second
|
||||
* consumer) so the parsed object graph is never retained at module scope.
|
||||
*/
|
||||
export async function fetchSiteIndex(
|
||||
indexURL: string
|
||||
): Promise<{ version: 1; pages: SiteIndexPage[] }> {
|
||||
return JSON.parse(await fetchSiteIndexText(indexURL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached raw text (several MB for large sites) once a consumer has
|
||||
* turned it into a longer-lived form. Purely a memory release: a later consumer
|
||||
* re-fetches, hitting the HTTP cache.
|
||||
*/
|
||||
export function releaseSiteIndex(): void {
|
||||
siteIndexText = null;
|
||||
}
|
||||
|
||||
function fetchSiteIndexText(indexURL: string): Promise<string> {
|
||||
if (!siteIndexText) {
|
||||
siteIndexText = fetch(indexURL).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
|
||||
siteIndexText.catch(() => {
|
||||
siteIndexText = null;
|
||||
});
|
||||
}
|
||||
|
||||
return siteIndexText;
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import type { Document, DocumentValue } from 'flexsearch';
|
||||
import { Document, type DocumentValue } from 'flexsearch';
|
||||
import React from 'react';
|
||||
import {
|
||||
type SiteIndexBreadcrumb,
|
||||
type SiteIndexPage,
|
||||
fetchSiteIndex,
|
||||
prefetchSiteIndex,
|
||||
releaseSiteIndex,
|
||||
} from './site-index';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
/** Raw entry from the `~gitbook/index` JSON response */
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
/** BCP-47 language code emitted by the index route, absent when no language is set. */
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
/** FlexSearch-compatible document type — satisfies DocumentData via explicit index signature */
|
||||
interface IndexPage {
|
||||
@@ -28,7 +41,7 @@ export interface LocalPageResult {
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: SiteIndexBreadcrumb[];
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type LocalSearchState = {
|
||||
@@ -45,16 +58,13 @@ const cachedIndexes = new Map<string, Document<IndexPage>>();
|
||||
// Keyed by page id, shared across all language groups.
|
||||
const cachedPageData = new Map<
|
||||
string,
|
||||
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: SiteIndexBreadcrumb[] }
|
||||
{ pathname: string; icon?: string; emoji?: string; breadcrumbs?: Breadcrumb[] }
|
||||
>();
|
||||
|
||||
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
||||
|
||||
function buildLangIndex(
|
||||
DocumentCtor: typeof import('flexsearch').Document,
|
||||
pages: SiteIndexPage[]
|
||||
): Document<IndexPage> {
|
||||
const index = new DocumentCtor<IndexPage>({
|
||||
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
|
||||
const index = new Document<IndexPage>({
|
||||
document: {
|
||||
id: 'id',
|
||||
index: ['title', 'description'],
|
||||
@@ -100,14 +110,15 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
}
|
||||
|
||||
pendingFetch = (async () => {
|
||||
// FlexSearch stays in an on-demand chunk instead of the main client bundle, it's only needed once the user actually searches.
|
||||
const [{ Document }, data] = await Promise.all([
|
||||
import('flexsearch'),
|
||||
fetchSiteIndex(indexURL),
|
||||
]);
|
||||
const response = await fetch(indexURL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: { version: 1; pages: RawIndexPage[] } = await response.json();
|
||||
|
||||
// Group pages by their `lang` value (empty string for pages without one)
|
||||
const pagesByLang = new Map<string, SiteIndexPage[]>();
|
||||
const pagesByLang = new Map<string, RawIndexPage[]>();
|
||||
for (const page of data.pages) {
|
||||
const key = page.lang ?? '';
|
||||
const bucket = pagesByLang.get(key);
|
||||
@@ -120,11 +131,9 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
||||
|
||||
// Build one FlexSearch Document per language group
|
||||
for (const [lang, pages] of pagesByLang) {
|
||||
cachedIndexes.set(lang, buildLangIndex(Document, pages));
|
||||
cachedIndexes.set(lang, buildLangIndex(pages));
|
||||
}
|
||||
|
||||
releaseSiteIndex();
|
||||
|
||||
return cachedIndexes;
|
||||
})();
|
||||
|
||||
@@ -147,10 +156,8 @@ export function useLocalSearchResults(props: {
|
||||
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
||||
filterSiteSpaceIds?: string[];
|
||||
disabled?: boolean;
|
||||
/** Whether the search surface is open. */
|
||||
open: boolean;
|
||||
}): LocalSearchState {
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, open } = props;
|
||||
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false } = props;
|
||||
|
||||
const [state, setState] = React.useState<LocalSearchState>({
|
||||
results: [],
|
||||
@@ -161,8 +168,6 @@ export function useLocalSearchResults(props: {
|
||||
// Track whether the indexes are loaded so the search effect re-runs after load
|
||||
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
|
||||
|
||||
const active = open || Boolean(query);
|
||||
|
||||
// Load the indexes once
|
||||
React.useEffect(() => {
|
||||
if (cachedIndexes.size > 0) {
|
||||
@@ -170,12 +175,6 @@ export function useLocalSearchResults(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchSiteIndex(indexURL);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
||||
|
||||
@@ -195,7 +194,7 @@ export function useLocalSearchResults(props: {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [indexURL, active]);
|
||||
}, [indexURL]);
|
||||
|
||||
// Perform instant local search whenever query, lang, or index readiness changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { shouldKeepSearchState } from './useSearch';
|
||||
|
||||
describe('shouldKeepSearchState', () => {
|
||||
it('discards an empty default search state', () => {
|
||||
expect(
|
||||
shouldKeepSearchState({
|
||||
q: null,
|
||||
ask: null,
|
||||
scope: 'default',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a non-default scope without a query', () => {
|
||||
expect(
|
||||
shouldKeepSearchState({
|
||||
q: null,
|
||||
ask: null,
|
||||
scope: 'current',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,6 @@ function normalizeRawState(values: Values<typeof keyMap>) {
|
||||
return values;
|
||||
}
|
||||
|
||||
export function shouldKeepSearchState(
|
||||
values: Pick<Values<typeof keyMap>, 'q' | 'ask' | 'scope'>
|
||||
): boolean {
|
||||
return values.q !== null || values.ask !== null || values.scope !== 'default';
|
||||
}
|
||||
|
||||
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
|
||||
const { children } = props;
|
||||
|
||||
@@ -80,7 +74,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
|
||||
const state = React.useMemo<SearchState | null>(() => {
|
||||
const normalized = normalizeRawState(rawState);
|
||||
if (!shouldKeepSearchState(normalized)) {
|
||||
if (normalized.q === null && normalized.ask === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -7,12 +7,6 @@ import { useAI } from '../AI';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useBodyLoaded } from '../primitives';
|
||||
import type { SearchResultsRef } from './SearchResults';
|
||||
import {
|
||||
clearLastSearchQuery,
|
||||
getLastSearchQuery,
|
||||
setLastSearchQuery,
|
||||
useLastSearchQuery,
|
||||
} from './last-query';
|
||||
import { addRecentSearchQuery } from './recent-queries';
|
||||
import type { SearchBaseProps } from './search-props';
|
||||
import { useSearchState, useSetSearchState } from './useSearch';
|
||||
@@ -115,10 +109,7 @@ function useSearchKeyboardNavigation(props: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearchController(
|
||||
props: SearchBaseProps,
|
||||
options: { restoreLastQueryOnMount?: boolean } = {}
|
||||
) {
|
||||
export function useSearchController(props: SearchBaseProps) {
|
||||
const {
|
||||
asEmbeddable,
|
||||
siteSpace,
|
||||
@@ -139,32 +130,6 @@ export function useSearchController(
|
||||
const resultsRef = React.useRef<SearchResultsRef>(null);
|
||||
const isLoaded = useBodyLoaded();
|
||||
|
||||
const restoredLastQueryForSiteSpaceRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!options.restoreLastQueryOnMount ||
|
||||
restoredLastQueryForSiteSpaceRef.current === siteSpace.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredLastQueryForSiteSpaceRef.current = siteSpace.id;
|
||||
const restoredQuery = getLastSearchQuery(siteSpace.id);
|
||||
if (!restoredQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setSearchState(
|
||||
(prev) =>
|
||||
prev ?? {
|
||||
ask: null,
|
||||
query: restoredQuery,
|
||||
scope: 'default',
|
||||
open: true,
|
||||
}
|
||||
);
|
||||
}, [options.restoreLastQueryOnMount, setSearchState, siteSpace.id]);
|
||||
|
||||
const withAI = assistants.length > 0;
|
||||
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
|
||||
|
||||
@@ -176,46 +141,38 @@ export function useSearchController(
|
||||
|
||||
const onClose = React.useCallback(
|
||||
async (to?: string) => {
|
||||
setSearchState((prev) => {
|
||||
if (!prev) return null;
|
||||
|
||||
if (prev.query !== null) {
|
||||
setLastSearchQuery(siteSpace.id, prev.query);
|
||||
}
|
||||
|
||||
return { ...prev, open: false, query: null };
|
||||
});
|
||||
setSearchState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
open: false,
|
||||
query: prev.query === '' ? null : prev.query,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (to) {
|
||||
router.push(to);
|
||||
}
|
||||
},
|
||||
[setSearchState, router, siteSpace.id]
|
||||
[setSearchState, router]
|
||||
);
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
if (state?.open) {
|
||||
return;
|
||||
}
|
||||
setSearchState((prev) => {
|
||||
const query =
|
||||
prev?.query ??
|
||||
getLastSearchQuery(siteSpace.id) ??
|
||||
(withSearchAI || !withAI ? prev?.ask : null) ??
|
||||
'';
|
||||
|
||||
return {
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query,
|
||||
open: true,
|
||||
};
|
||||
});
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
|
||||
trackEvent({
|
||||
type: 'search_open',
|
||||
});
|
||||
}, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]);
|
||||
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
|
||||
|
||||
const setQuery = React.useCallback(
|
||||
(value: string) => {
|
||||
@@ -229,8 +186,7 @@ export function useSearchController(
|
||||
[setSearchState, withAI, withSearchAI]
|
||||
);
|
||||
|
||||
const lastSearchQuery = useLastSearchQuery(siteSpace.id);
|
||||
const normalizedQuery = (state?.query ?? lastSearchQuery ?? '').trim();
|
||||
const normalizedQuery = state?.query?.trim() ?? '';
|
||||
const normalizedAsk = state?.ask?.trim() ?? '';
|
||||
const showAsk = withSearchAI && normalizedAsk.length > 0;
|
||||
|
||||
@@ -243,8 +199,7 @@ export function useSearchController(
|
||||
|
||||
const { results, fetching, error, abort } = useSearchResults({
|
||||
asEmbeddable,
|
||||
disabled: !(normalizedQuery || withAI),
|
||||
open: Boolean(state?.open),
|
||||
disabled: !(state?.query || withAI),
|
||||
query: normalizedQuery,
|
||||
siteSpaceId: siteSpace.id,
|
||||
siteSpaceIds,
|
||||
@@ -256,19 +211,9 @@ export function useSearchController(
|
||||
withSections,
|
||||
});
|
||||
|
||||
const searchValue =
|
||||
state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? '';
|
||||
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
||||
const searchResultsId = `search-results-${React.useId()}`;
|
||||
|
||||
// Only clears the remembered last query and stops any in-flight fetch — it must
|
||||
// NOT touch searchState here. Navigable results dismiss the popover for free via
|
||||
// their own <Link> navigation; racing that with a searchState/URL update in the
|
||||
// same click caused a Next.js App Router transition conflict (RND-11972 regression).
|
||||
const onResultSelect = React.useCallback(() => {
|
||||
clearLastSearchQuery(siteSpace.id);
|
||||
abort();
|
||||
}, [abort, siteSpace.id]);
|
||||
|
||||
const askInAssistant = React.useCallback(
|
||||
(assistantIndex = 0) => {
|
||||
const assistant = assistants[assistantIndex];
|
||||
@@ -314,7 +259,6 @@ export function useSearchController(
|
||||
abort,
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
onResultSelect,
|
||||
query: normalizedQuery,
|
||||
results,
|
||||
resultsId: searchResultsId,
|
||||
|
||||
@@ -45,8 +45,6 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
|
||||
export function useSearchResults(props: {
|
||||
asEmbeddable?: boolean;
|
||||
disabled: boolean;
|
||||
/** Whether the search surface is open. Gates building the local search index. */
|
||||
open: boolean;
|
||||
query: string;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
@@ -54,7 +52,7 @@ export function useSearchResults(props: {
|
||||
suggestions?: string[];
|
||||
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
|
||||
searchURL: string;
|
||||
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/site-index')). */
|
||||
/** URL for the local index JSON (e.g. from linker.toPathInSite('~gitbook/index')). */
|
||||
indexURL: string;
|
||||
/** BCP-47 language code of the current site space, used to filter local search results. */
|
||||
lang?: string;
|
||||
@@ -64,7 +62,6 @@ export function useSearchResults(props: {
|
||||
const {
|
||||
asEmbeddable,
|
||||
disabled,
|
||||
open,
|
||||
query,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
@@ -88,7 +85,6 @@ export function useSearchResults(props: {
|
||||
indexURL,
|
||||
lang,
|
||||
disabled,
|
||||
open,
|
||||
filterSiteSpaceIds,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SELECT_URL_PARAM, selectStore } from '@/lib/select';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import type React from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useSelect } from './useSelect';
|
||||
import { useSelectAnchor } from './useSelectAnchor';
|
||||
|
||||
// `useLayoutEffect` runs before paint but warns during SSR (effects don't run on the server anyway),
|
||||
// so fall back to `useEffect` there.
|
||||
const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
function parseSelectParam(value: string | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((slug) => slug.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the `select` store to the `?select=` URL param and hydrates it from localStorage. Mounted
|
||||
* once at the site layout level (inside NuqsAdapter). Provides no React context — the store is a
|
||||
* module singleton — so it simply renders its children.
|
||||
*/
|
||||
export function SelectProvider(props: { children: React.ReactNode }) {
|
||||
const [param, setParam] = useQueryState(SELECT_URL_PARAM, parseAsString);
|
||||
const { slugs } = useSelect();
|
||||
// The last value we wrote to the URL, so we can tell our own writes apart from external ones.
|
||||
const mirroredRef = useRef<string | null>(null);
|
||||
|
||||
useSelectAnchor();
|
||||
|
||||
// Adopt whatever the pre-paint script already merged (URL + storage) into the in-memory store.
|
||||
// Layout effect so the store (and the tab highlight it drives) is settled before first paint,
|
||||
// matching the `<html data-sel-*>` the pre-paint script already applied.
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
selectStore.init();
|
||||
}, []);
|
||||
|
||||
// URL → store: a shared link or client-side navigation carrying ?select= prepends its slugs, so
|
||||
// the link wins while the visitor's other preferences survive.
|
||||
useEffect(() => {
|
||||
if (param === mirroredRef.current) {
|
||||
return;
|
||||
}
|
||||
const fromUrl = parseSelectParam(param);
|
||||
if (fromUrl.length > 0) {
|
||||
selectStore.setSlugs([...fromUrl, ...selectStore.getState().slugs]);
|
||||
}
|
||||
}, [param]);
|
||||
|
||||
// store → URL: keep ?select= as a shareable mirror of the recency list (replaceState, no history spam).
|
||||
useEffect(() => {
|
||||
const desired = slugs.length > 0 ? slugs.join(',') : null;
|
||||
if ((param ?? null) === desired) {
|
||||
return;
|
||||
}
|
||||
mirroredRef.current = desired;
|
||||
setParam(desired);
|
||||
}, [slugs, param, setParam]);
|
||||
|
||||
return props.children;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { SELECT_LIST_CAP, SELECT_STORAGE_KEY, SELECT_URL_PARAM } from '@/lib/select';
|
||||
import { applySelectStateScript } from './script';
|
||||
|
||||
/**
|
||||
* Inline `<head>` script that applies the visitor's `select` state to `<html>` before first paint,
|
||||
* so the right content variant renders with no flash. Mounted once in the root layout head.
|
||||
*/
|
||||
export function SelectStateScript() {
|
||||
const scriptArgs = JSON.stringify([
|
||||
SELECT_STORAGE_KEY,
|
||||
SELECT_URL_PARAM,
|
||||
SELECT_LIST_CAP,
|
||||
]).slice(1, -1);
|
||||
|
||||
return (
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${applySelectStateScript.toString()})(${scriptArgs})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { SelectStateScript } from './SelectStateScript';
|
||||
export { SelectProvider } from './SelectProvider';
|
||||
export { useSelect, useResolvedSlug } from './useSelect';
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Read the `select` state (URL `?select=` + localStorage) and apply it to `<html>` as `data-sel-N`
|
||||
* attributes as early as possible, so the correct content variant is visible before hydration — no
|
||||
* flash, and it works on cached/static HTML.
|
||||
*
|
||||
* NOTE: this runs in `<head>` before `<body>` exists, and is stringified and injected — so it must be
|
||||
* self-contained (no imports/closures) and touch only `document.documentElement`. The attribute name
|
||||
* and merge rules mirror `lib/select` (`selectRankAttribute`, the store's `normalize`); keep them in
|
||||
* sync. URL slugs are prepended so a shared link wins while the visitor's other preferences survive.
|
||||
*/
|
||||
export function applySelectStateScript(storageKey: string, urlParam: string, cap: number) {
|
||||
try {
|
||||
const slugs: string[] = [];
|
||||
// A Set (not a plain object) so slugs like "constructor"/"toString" aren't treated as
|
||||
// already-seen via Object.prototype — matching the runtime store's dedupe.
|
||||
const seen = new Set<string>();
|
||||
const push = (value: string | null | undefined) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug) || slugs.length >= cap) {
|
||||
return;
|
||||
}
|
||||
seen.add(slug);
|
||||
slugs.push(slug);
|
||||
};
|
||||
|
||||
const fromUrl = new URLSearchParams(window.location.search).get(urlParam);
|
||||
if (fromUrl) {
|
||||
const parts = fromUrl.split(',');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
push(parts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const storedStr = window.localStorage.getItem(storageKey);
|
||||
if (storedStr) {
|
||||
const stored = JSON.parse(storedStr);
|
||||
// Only trust a real array — corrupted storage (a string, or an object with `length`)
|
||||
// would otherwise iterate per character/index. Matches the runtime store's handling.
|
||||
if (Array.isArray(stored)) {
|
||||
for (let j = 0; j < stored.length; j++) {
|
||||
push(stored[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const el = document.documentElement;
|
||||
for (let rank = 0; rank < cap; rank++) {
|
||||
const attribute = `data-sel-${rank}`;
|
||||
const slug = slugs[rank];
|
||||
if (slug) {
|
||||
el.setAttribute(attribute, slug);
|
||||
} else {
|
||||
el.removeAttribute(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(slugs));
|
||||
} catch {
|
||||
// localStorage blocked (private mode) or malformed state — fall through to block defaults.
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { selectStore } from '@/lib/select';
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* Subscribe to the site-wide `select` state. Returns the current recency list plus the setters.
|
||||
* Consumers that only need "which of my options is active" should prefer {@link useResolvedSlug}.
|
||||
*/
|
||||
export function useSelect() {
|
||||
const slugs = useSyncExternalStore(
|
||||
selectStore.subscribe,
|
||||
selectStore.getState,
|
||||
selectStore.getState
|
||||
).slugs;
|
||||
|
||||
return {
|
||||
slugs,
|
||||
activate: selectStore.activate,
|
||||
deactivate: selectStore.deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which of a block's candidate slugs is active, falling back to `defaultSlug`. Recomputes
|
||||
* whenever the selection changes.
|
||||
*/
|
||||
export function useResolvedSlug(candidateSlugs: string[], defaultSlug: string | null = null) {
|
||||
// `candidateSlugs` is a fresh array each render; key on its contents to keep the snapshot stable.
|
||||
const key = candidateSlugs.join(',');
|
||||
const getResolved = useCallback(() => {
|
||||
const candidates = key ? key.split(',') : [];
|
||||
return selectStore.resolveActiveSlug(candidates) ?? defaultSlug;
|
||||
}, [key, defaultSlug]);
|
||||
return useSyncExternalStore(selectStore.subscribe, getResolved, getResolved);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useHash } from '@/components/hooks';
|
||||
import { SELECT_OPTION_ATTR, selectStore } from '@/lib/select';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Make anchors work across `select` variants: when the URL points at an element inside an inactive
|
||||
* pane (e.g. a heading in a non-selected tab), activate the slugs of its `data-select-option`
|
||||
* ancestors so the pane becomes visible, then scroll to it after the panes reflow.
|
||||
*
|
||||
* Runs once globally (mounted by SelectProvider) so nested groups resolve in a single pass — the
|
||||
* outermost pane is activated first, leaving the innermost (the actual target) most-recent.
|
||||
*/
|
||||
export function useSelectAnchor() {
|
||||
const hash = useHash();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(hash);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slugs: string[] = [];
|
||||
let node: Element | null = target.closest(`[${SELECT_OPTION_ATTR}]`);
|
||||
while (node) {
|
||||
const slug = node.getAttribute(SELECT_OPTION_ATTR);
|
||||
if (slug) {
|
||||
slugs.push(slug);
|
||||
}
|
||||
node = node.parentElement?.closest(`[${SELECT_OPTION_ATTR}]`) ?? null;
|
||||
}
|
||||
|
||||
if (slugs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Outermost first, so the innermost target ends up most-recent and wins in its group.
|
||||
for (let i = slugs.length - 1; i >= 0; i--) {
|
||||
const slug = slugs[i];
|
||||
if (slug) {
|
||||
selectStore.activate(slug);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
});
|
||||
}, [hash]);
|
||||
}
|
||||
@@ -39,13 +39,10 @@ export async function SiteLayout(props: {
|
||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
||||
}
|
||||
|
||||
// Start the search-index download from the HTML itself. `crossOrigin` must match the
|
||||
// client `fetch()` (cors + same-origin credentials) or the preload is ignored and the
|
||||
// index downloads twice — the omission was exactly that bug before.
|
||||
// We also preload the site index
|
||||
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
|
||||
as: 'fetch',
|
||||
type: 'application/json',
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
|
||||
scripts.forEach(({ script }) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { SearchContextProvider } from '../Search';
|
||||
import { SelectProvider } from '../Select';
|
||||
import { useClearRouterCache } from '../hooks/useClearRouterCache';
|
||||
import { LinkContext, type LinkContextType } from '../primitives';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
@@ -63,13 +62,11 @@ export function SiteLayoutClientContexts(props: {
|
||||
storageKey={themeStorageKey}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<SelectProvider>
|
||||
<LinkContext.Provider value={linkContext}>
|
||||
<SearchContextProvider>
|
||||
<ReducedMotionProvider>{children}</ReducedMotionProvider>
|
||||
</SearchContextProvider>
|
||||
</LinkContext.Provider>
|
||||
</SelectProvider>
|
||||
<LinkContext.Provider value={linkContext}>
|
||||
<SearchContextProvider>
|
||||
<ReducedMotionProvider>{children}</ReducedMotionProvider>
|
||||
</SearchContextProvider>
|
||||
</LinkContext.Provider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { Checkbox } from '@/components/primitives/Checkbox';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { type SubmitConsentInput, submitSiteOAuthConsent } from './actions';
|
||||
|
||||
/**
|
||||
* Site's OAuth consent form to present to the user the client's information requesting access to the site's MCP.
|
||||
*/
|
||||
export function ConsentForm(props: {
|
||||
siteId: string;
|
||||
consentSessionId: string;
|
||||
/** Whether the OAuth server recognizes the client as verified. */
|
||||
verified: boolean;
|
||||
}) {
|
||||
const { siteId, consentSessionId, verified } = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string>();
|
||||
const [trusted, setTrusted] = useState(false);
|
||||
|
||||
// Unverified clients can only be approved once the visitor explicitly acknowledges they trust
|
||||
// the app. The OAuth server re-checks this, so it can't be bypassed by tampering with the client.
|
||||
const canApprove = verified || trusted;
|
||||
|
||||
const decide = (decision: SubmitConsentInput['decision']) => {
|
||||
setError(undefined);
|
||||
startTransition(async () => {
|
||||
const result = await submitSiteOAuthConsent({
|
||||
siteId,
|
||||
consentSessionId,
|
||||
decision,
|
||||
trusted,
|
||||
});
|
||||
if ('redirectURL' in result) {
|
||||
// Full-page navigation to the client's (external) redirect URI.
|
||||
window.location.href = result.redirectURL;
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger-strong text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
|
||||
{verified ? (
|
||||
<span />
|
||||
) : (
|
||||
<label
|
||||
htmlFor="site-oauth-trusted"
|
||||
className="flex items-center gap-2 text-sm text-tint"
|
||||
>
|
||||
<Checkbox
|
||||
id="site-oauth-trusted"
|
||||
checked={trusted}
|
||||
onCheckedChange={(value) => setTrusted(value === true)}
|
||||
/>
|
||||
<span>I recognize and trust this client</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={tcls('ms-auto flex gap-2')}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="xmark"
|
||||
disabled={isPending}
|
||||
onClick={() => decide('deny')}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={isPending || !canApprove}
|
||||
onClick={() => decide('approve')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { StyledLink } from '@/components/primitives/StyledLink';
|
||||
import type { SiteOAuthConsentStart } from '@/lib/site-oauth';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ConsentForm } from './ConsentForm';
|
||||
|
||||
/**
|
||||
* Consent screen shown to a visitor when an MCP client requests authorization to a published site.
|
||||
*/
|
||||
export function ConsentScreen(props: {
|
||||
siteId: string;
|
||||
siteTitle: string;
|
||||
consent: SiteOAuthConsentStart;
|
||||
}) {
|
||||
const { siteId, siteTitle, consent } = props;
|
||||
const { client, redirectUri, consentSessionId } = consent;
|
||||
const redirectParts = parseRedirectURI(redirectUri);
|
||||
|
||||
return (
|
||||
<ConsentCard>
|
||||
<div className="flex flex-col gap-6 p-6 sm:p-8">
|
||||
{/* Client identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
{client.logoUri ? (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-corners:rounded-lg straight-corners:rounded-none object-contain"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-corners:rounded-lg straight-corners:rounded-none bg-tint-subtle text-tint">
|
||||
<Icon icon="key" className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="font-semibold text-tint-strong">{client.name}</h1>
|
||||
<ClientTrustBadge verified={client.verified} />
|
||||
</div>
|
||||
{client.uri ? (
|
||||
<StyledLink
|
||||
href={client.uri}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-tint"
|
||||
>
|
||||
Website
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</StyledLink>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request statement — toned down, with the client and site names emphasized. */}
|
||||
<p className="text-base text-tint leading-snug">
|
||||
<span className="font-semibold text-tint-strong">{client.name}</span> wants to
|
||||
access <span className="font-semibold text-tint-strong">{siteTitle} MCP</span>{' '}
|
||||
on your behalf.
|
||||
</p>
|
||||
|
||||
{/* Redirect URI, shown in full with the destination host emphasized. */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm text-tint">
|
||||
After approving, an authorization code will be sent to:
|
||||
</span>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex items-center gap-2.5',
|
||||
'rounded-corners:rounded-md straight-corners:rounded-none',
|
||||
'border border-tint-subtle bg-tint-subtle px-3 py-2'
|
||||
)}
|
||||
>
|
||||
<Icon icon="link" className="size-4 shrink-0 text-tint" />
|
||||
<code className="break-all font-mono text-sm">
|
||||
{redirectParts ? (
|
||||
<>
|
||||
<span className="text-tint">{redirectParts.prefix}</span>
|
||||
<span className="font-semibold text-tint-strong">
|
||||
{redirectParts.host}
|
||||
</span>
|
||||
<span className="text-tint">{redirectParts.rest}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-tint-strong">{redirectUri}</span>
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.verified ? null : (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-3',
|
||||
'rounded-corners:rounded-md straight-corners:rounded-none',
|
||||
'bg-warning p-3 text-sm text-warning-strong'
|
||||
)}
|
||||
>
|
||||
<Icon icon="triangle-exclamation" className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
GitBook has not verified this client
|
||||
</span>
|
||||
<span>
|
||||
Only approve if you recognize this application and trust it with
|
||||
access to {siteTitle}.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: trust acknowledgement + decision */}
|
||||
<div className="border-tint-subtle border-t p-4 sm:px-8">
|
||||
<ConsentForm
|
||||
siteId={siteId}
|
||||
consentSessionId={consentSessionId}
|
||||
verified={client.verified}
|
||||
/>
|
||||
</div>
|
||||
</ConsentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered, branded card shell shared by the consent screen and its error state.
|
||||
*/
|
||||
function ConsentCard(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-tint-subtle p-4">
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full max-w-lg',
|
||||
'flex flex-col',
|
||||
'rounded-corners:rounded-lg straight-corners:rounded-none',
|
||||
'border border-tint-subtle bg-tint-base',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state shown when the consent flow cannot be started (e.g. a refreshed or expired link).
|
||||
*/
|
||||
export function ConsentError(props: { title?: string; message?: string }) {
|
||||
const {
|
||||
title = 'This authorization link has expired',
|
||||
message = 'Please start the sign-in again from the application.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ConsentCard>
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<span className="flex size-12 items-center justify-center rounded-corners:rounded-full straight-corners:rounded-none bg-danger text-danger-strong">
|
||||
<Icon icon="circle-exclamation" className="size-6" />
|
||||
</span>
|
||||
<h1 className="font-semibold text-lg text-tint-strong">{title}</h1>
|
||||
<p className="text-tint">{message}</p>
|
||||
</div>
|
||||
</ConsentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a redirect URI so the destination host (the trust-relevant part) can be emphasized while
|
||||
* the scheme and path are shown muted. Returns null if the URI can't be parsed.
|
||||
*/
|
||||
function parseRedirectURI(uri: string): { prefix: string; host: string; rest: string } | null {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return {
|
||||
prefix: `${url.protocol}//`,
|
||||
host: url.host,
|
||||
rest: `${url.pathname}${url.search}${url.hash}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline verified/unverified indicator shown next to the client name.
|
||||
*/
|
||||
function ClientTrustBadge(props: { verified: boolean }) {
|
||||
const { verified } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={tcls(
|
||||
'inline-flex items-center gap-1 font-medium text-xs',
|
||||
verified ? 'text-success-strong' : 'text-warning-strong'
|
||||
)}
|
||||
>
|
||||
<Icon icon={verified ? 'circle-check' : 'triangle-exclamation'} className="size-3" />
|
||||
{verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { type SiteOAuthConsentDecision, submitSiteOAuthConsentDecision } from '@/lib/site-oauth';
|
||||
|
||||
export type SubmitConsentInput = {
|
||||
siteId: string;
|
||||
consentSessionId: string;
|
||||
decision: SiteOAuthConsentDecision;
|
||||
trusted: boolean;
|
||||
};
|
||||
|
||||
export type SubmitConsentResult = { redirectURL: string } | { error: string };
|
||||
|
||||
/**
|
||||
* Server action to submit the consent decision to the sites OAuth server's `consent/decision` endpoint.
|
||||
*/
|
||||
export async function submitSiteOAuthConsent(
|
||||
input: SubmitConsentInput
|
||||
): Promise<SubmitConsentResult> {
|
||||
const { siteId, consentSessionId, decision, trusted } = input;
|
||||
|
||||
if (!siteId || !consentSessionId || (decision !== 'approve' && decision !== 'deny')) {
|
||||
return { error: 'Invalid request. Please start again from the application.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { redirectURL } = await submitSiteOAuthConsentDecision({
|
||||
siteId,
|
||||
consentSessionId,
|
||||
decision,
|
||||
trusted,
|
||||
});
|
||||
return { redirectURL };
|
||||
} catch (_error) {
|
||||
return {
|
||||
error: 'We could not complete the authorization. The request may have expired — please start again from the application.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ConsentScreen, ConsentError } from './ConsentScreen';
|
||||
@@ -11,7 +11,6 @@ import { useEffect, useState } from 'react';
|
||||
import { useAI } from '../AI';
|
||||
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { fetchSiteIndex } from '../Search/site-index';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import {
|
||||
useSiteAdaptiveAuthLoginHref,
|
||||
@@ -230,21 +229,34 @@ function NotFoundSuggestions(props: { suggestions: RelatedPage[] | null }) {
|
||||
) : null;
|
||||
}
|
||||
|
||||
/** Minimal shape of an entry in the `~gitbook/site-index` response. */
|
||||
type IndexPage = {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the pages whose path is closest to the one that 404'd.
|
||||
*
|
||||
* Rather than asking the server (which would mean an extra request per 404), this reuses the
|
||||
* search index served at `~gitbook/site-index` — preloaded on every page and shared with
|
||||
* instant search via the module cache in `site-index.ts`, so at most one request is made.
|
||||
* The ranking is a lighter, client-side cousin of `getSimilarPages` (which the Markdown 404
|
||||
* runs server-side from the full page tree).
|
||||
* search index served at `~gitbook/site-index` — already preloaded and CDN-cached on every page —
|
||||
* so it's a cache hit, not an origin request. The ranking is a lighter, client-side cousin of
|
||||
* `getSimilarPages` (which the Markdown 404 runs server-side from the full page tree).
|
||||
*/
|
||||
async function getRelatedPages(
|
||||
indexURL: string,
|
||||
requestedPath: string,
|
||||
siteSpaceId: string | null
|
||||
): Promise<RelatedPage[]> {
|
||||
const { pages } = await fetchSiteIndex(indexURL);
|
||||
const response = await fetch(indexURL);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const { pages } = (await response.json()) as { pages: IndexPage[] };
|
||||
|
||||
return pages
|
||||
.filter((page) => !siteSpaceId || page.siteSpaceId === siteSpaceId)
|
||||
|
||||
@@ -88,10 +88,7 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||
>
|
||||
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||
<AIChatProvider
|
||||
renderMessageOptions={aiChatRenderMessageOptions}
|
||||
withPageFeedback={customization.feedback.enabled}
|
||||
>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useScrollListener } from './useScrollListener';
|
||||
|
||||
/**
|
||||
* Track the scroll position and overflow amount of a scrollable container,
|
||||
* keeping them in sync with scroll events, resizes, and content changes.
|
||||
*/
|
||||
export function useScrollOverflow(
|
||||
orientation: 'horizontal' | 'vertical',
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
) {
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
const [scrollSize, setScrollSize] = React.useState(0);
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollDimension =
|
||||
orientation === 'horizontal' ? container.scrollWidth : container.scrollHeight;
|
||||
const clientDimension =
|
||||
orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
|
||||
|
||||
setScrollSize(Math.max(scrollDimension - clientDimension - 1, 0));
|
||||
setScrollPosition(
|
||||
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
|
||||
);
|
||||
}, [orientation, containerRef]);
|
||||
|
||||
useScrollListener(measure, containerRef);
|
||||
|
||||
// Measure synchronously on mount (and when the container/orientation changes), so
|
||||
// initial overflow is detected without waiting for a resize/scroll event. Subsequent
|
||||
// content changes are picked up by the observers below instead of re-measuring on
|
||||
// every render.
|
||||
React.useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Children can overflow (or stop overflowing) without the container itself
|
||||
// changing size, so we observe the direct children in addition to the container,
|
||||
// and re-register observers as children are added/removed.
|
||||
let frame: number | null = null;
|
||||
const scheduleMeasure = () => {
|
||||
if (frame !== null) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
measure();
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(scheduleMeasure);
|
||||
ro.observe(container);
|
||||
for (const child of Array.from(container.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
// Only re-register direct children with the resize observer; descendants
|
||||
// deeper in the tree are covered by their parent's resize/mutation handling.
|
||||
if (mutation.target !== container) {
|
||||
continue;
|
||||
}
|
||||
for (const node of Array.from(mutation.addedNodes)) {
|
||||
if (node instanceof Element) {
|
||||
ro.observe(node);
|
||||
}
|
||||
}
|
||||
for (const node of Array.from(mutation.removedNodes)) {
|
||||
if (node instanceof Element) {
|
||||
ro.unobserve(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleMeasure();
|
||||
});
|
||||
// Also watch descendants (subtree/characterData) so text/content changes deeper in
|
||||
// the tree that grow or shrink scrollHeight/scrollWidth still trigger a re-measure.
|
||||
mo.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [measure, containerRef]);
|
||||
|
||||
return { scrollPosition, scrollSize };
|
||||
}
|
||||
@@ -80,9 +80,8 @@ export function useScrollToHash() {
|
||||
/**
|
||||
* Scroll to a hash, if scroll didn't work, return false.
|
||||
*/
|
||||
export function scrollToHash(hash: string) {
|
||||
// Decode so non-ASCII / spaced heading ids (percent-encoded in the URL) resolve.
|
||||
const element = document.getElementById(decodeHash(hash));
|
||||
function scrollToHash(hash: string) {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
@@ -94,14 +93,3 @@ export function scrollToHash(hash: string) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a URL hash, falling back to the raw value if it is malformed.
|
||||
*/
|
||||
function decodeHash(hash: string): string {
|
||||
try {
|
||||
return decodeURIComponent(hash);
|
||||
} catch {
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getSamePageAnchor, resolveAnchorURL } from '@/lib/urls';
|
||||
import { checkIsAnchor, resolveAnchorURL } from '@/lib/urls';
|
||||
import { type TrackEventInput, useTrackEvent } from '../Insights';
|
||||
import { NavigationStatusContext, scrollToHash } from '../hooks';
|
||||
import { NavigationStatusContext } from '../hooks';
|
||||
import { isExternalLink, toNonEmbedLink } from '../utils/link';
|
||||
import { type DesignTokenName, useClassnames } from './StyleProvider';
|
||||
|
||||
@@ -93,19 +93,17 @@ export function Link(props: LinkProps) {
|
||||
const trackEvent = useTrackEvent();
|
||||
const forwardedClassNames = useClassnames(classNames || []);
|
||||
const isExternal = isExternalServer(href);
|
||||
const isAnchor = checkIsAnchor(href);
|
||||
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Only trigger navigation context for internal links in the same window without modifier keys (i.e. open in new tab).
|
||||
if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
|
||||
const samePageAnchor = getSamePageAnchor(href, window.location);
|
||||
if (samePageAnchor) {
|
||||
if (isAnchor) {
|
||||
event.preventDefault();
|
||||
const resolvedHref = resolveAnchorURL(`#${samePageAnchor}`, window.location);
|
||||
const resolvedHref = resolveAnchorURL(href, window.location);
|
||||
window.history.pushState(null, '', resolvedHref);
|
||||
onNavigationClick(resolvedHref);
|
||||
// Repeated anchor clicks don't change hash state, so the navigation effect won't rerun.
|
||||
scrollToHash(samePageAnchor);
|
||||
} else {
|
||||
onNavigationClick(href);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { useScrollOverflow } from '../hooks/useScrollOverflow';
|
||||
import { useScrollListener } from '../hooks/useScrollListener';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
/**
|
||||
@@ -56,9 +56,50 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
const [scrollSize, setScrollSize] = React.useState(0);
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const { scrollPosition, scrollSize } = useScrollOverflow(orientation, containerRef);
|
||||
useScrollListener(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScrollSize(
|
||||
orientation === 'horizontal'
|
||||
? container.scrollWidth - container.clientWidth - 1
|
||||
: container.scrollHeight - container.clientHeight - 1
|
||||
);
|
||||
|
||||
setScrollPosition(
|
||||
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
|
||||
);
|
||||
}, containerRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update max scroll position using resize observer
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry) {
|
||||
setScrollSize(
|
||||
orientation === 'horizontal'
|
||||
? entry.target.scrollWidth - entry.target.clientWidth - 1
|
||||
: entry.target.scrollHeight - entry.target.clientHeight - 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ro.observe(container);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, [orientation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user