Compare commits

..

19 Commits

Author SHA1 Message Date
Brett Jephson 1ae09b1ec5 Merge branch 'main' into brett/fa-icons-rendering 2026-05-05 09:49:03 +01:00
Brett Jephson 6cd9f2aaaa update 2026-04-30 20:38:57 +01:00
Brett Jephson 6d4a1653a2 use sprites to parse symbols 2026-04-30 18:52:05 +01:00
Brett Jephson 9ce2759e1b move spritesheet up 2026-04-30 18:05:38 +01:00
Brett Jephson 5663064b46 Speed up icon rendering 2026-04-30 17:32:51 +01:00
Brett Jephson 6246b106af formatting 2026-04-30 15:11:15 +01:00
Brett Jephson 9ad85cb30c tidy 2026-04-30 15:07:46 +01:00
Brett Jephson 25bcff6eff Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 15:02:00 +01:00
Brett Jephson f232b8fff5 e2e tests 2026-04-30 15:01:32 +01:00
Brett Jephson e7f9bc2565 use route to transform svg to local symbol 2026-04-30 14:57:34 +01:00
Brett Jephson 21053f56fb Update method of loading symbols 2026-04-30 13:47:16 +01:00
Brett Jephson 2746ffc000 test fix 2026-04-30 12:01:53 +01:00
Brett Jephson 568ee3a660 tidy and update icon loading in test 2026-04-30 11:31:00 +01:00
Brett Jephson 420bf4a6f6 Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 10:52:51 +01:00
Brett Jephson 72cffdd14b tidy 2026-04-30 10:45:25 +01:00
Brett Jephson 07be0e224c use shared supported style info 2026-04-30 10:16:35 +01:00
Brett Jephson c765394914 tidy 2026-04-30 10:16:35 +01:00
Brett Jephson ab96ee806b changeset 2026-04-30 10:16:35 +01:00
Brett Jephson 7cc6090e03 load icons as symbols 2026-04-30 10:16:35 +01:00
227 changed files with 1759 additions and 10933 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support variant selector in hidden section
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Update icon usage to render svg symbols rather than file.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix embeddable search links
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces/sections in the llms.txt.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
-12
View File
@@ -1,12 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "🚀 Dev server",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "bun install && bun dev"],
"port": 3000,
"autoPort": false
}
]
}
-4
View File
@@ -15,10 +15,6 @@ jobs:
id-token: write
pull-requests: write
contents: write
env:
# The repo is managed by Bun, but publishing uses npm for provenance.
# Keep Corepack from rejecting packageManager: bun@... when npm runs.
COREPACK_ENABLE_PROJECT_SPEC: 0
steps:
- name: Checkout Repo
uses: actions/checkout@v3
+44 -62
View File
@@ -6,8 +6,8 @@
"name": "gitbook",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.18",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.6",
"vercel": "50.37.3",
},
},
@@ -48,7 +48,7 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.5.1",
"version": "0.5.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -74,7 +74,7 @@
},
"packages/expr": {
"name": "@gitbook/expr",
"version": "1.3.0",
"version": "1.2.5",
"dependencies": {
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.27.0",
"version": "0.25.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
@@ -127,8 +127,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -164,18 +164,15 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-aria": "^3.44.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
@@ -189,7 +186,6 @@
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"tldts": "^7.0.30",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
@@ -232,7 +228,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.5.0",
"version": "0.4.6",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -270,7 +266,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.16",
"version": "0.7.15",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -312,7 +308,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.5.13",
"version": "1.5.12",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -347,7 +343,7 @@
},
"patchedDependencies": {
"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",
"next@16.2.3": "patches/next@16.2.3.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -360,7 +356,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.184.0",
"@gitbook/api": "0.180.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -556,21 +552,21 @@
"@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.0", "", { "dependencies": { "@changesets/config": "^3.1.3", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="],
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
"@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
"@changesets/cli": ["@changesets/cli@2.30.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.0", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.3", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.15", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA=="],
"@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
"@changesets/config": ["@changesets/config@3.1.3", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw=="],
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.15", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.3", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g=="],
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
@@ -756,7 +752,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.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="],
"@gitbook/api": ["@gitbook/api@0.180.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-jGPP9cVGDLqVv1YjuZMpoVNfD0Yj30vVbkYwnVc4B2GRhYENJm/27x+nAzG7379YNilWOneqE245NyIWNgTu1w=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -920,23 +916,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.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@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-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@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-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@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-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -972,9 +968,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@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/aws": ["@opennextjs/aws@3.10.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.15 || >=16.2.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-Pn8hLuP3M9EWlsZnS/O7Bm41Nt+lIOEsCaW/QJ3KlmIbtAeixnSj1CS80Z9PlQe/LQCe0GnQ8vGJeOdg5a4iWg=="],
"@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=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.10.1", "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.15 || >=16.2.3", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-qW6ivDkwDzomX4uBTwIHiSFMvsXjZ2GuVf/GITxGByr8sA3eyx3IMfFjWyaqBHVqcqtJaCiFo8IPxpLHv0jWbg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1590,17 +1586,17 @@
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
@@ -1974,8 +1970,6 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -2538,8 +2532,6 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
@@ -2862,7 +2854,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"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": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@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.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "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-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"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=="],
@@ -2930,8 +2922,6 @@
"p-map": ["p-map@7.0.3", "", {}, "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA=="],
"p-retry": ["p-retry@8.0.0", "", { "dependencies": { "is-network-error": "^1.3.0" } }, "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
@@ -3004,7 +2994,7 @@
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -3318,10 +3308,6 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -3356,7 +3342,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
"turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
@@ -3568,6 +3554,10 @@
"@babel/highlight/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
"@changesets/assemble-release-plan/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@changesets/get-dependents-graph/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@changesets/logger/picocolors": ["picocolors@1.1.0", "", {}, "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="],
"@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
@@ -4032,8 +4022,6 @@
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
"@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@zenuml/core/marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
"@zenuml/core/tailwind-merge": ["tailwind-merge@3.4.1", "", {}, "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q=="],
@@ -4606,8 +4594,6 @@
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"@zenuml/core/tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"@zenuml/core/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -4616,8 +4602,6 @@
"@zenuml/core/tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@zenuml/core/tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@zenuml/core/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"accepts/mime-types/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
@@ -4790,8 +4774,6 @@
"@zenuml/core/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"@zenuml/core/tailwindcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"micro/raw-body/http-errors/depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="],
"micro/raw-body/http-errors/setprototypeof": ["setprototypeof@1.1.1", "", {}, "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="],
+4 -4
View File
@@ -6,8 +6,8 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.18",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.6",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -43,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.184.0",
"@gitbook/api": "0.180.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.6": "patches/next@16.2.6.patch"
"next@16.2.3": "patches/next@16.2.3.patch"
}
}
-8
View File
@@ -1,13 +1,5 @@
# @gitbook/embed
## 0.5.1
### Patch Changes
- 37a9453: Allow copying in embed
- Updated dependencies [47c8ccb]
- @gitbook/icons@0.5.0
## 0.5.0
### Minor Changes
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.5.1",
"version": "0.5.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -44,16 +44,6 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
if (!iframe.contentWindow) {
throw new Error('Iframe must have a content window');
}
const allowTokens = iframe.allow
.split(';')
.map((token) => token.trim())
.filter(Boolean);
if (!allowTokens.includes('clipboard-write')) {
iframe.allow = [...allowTokens, 'clipboard-write'].join('; ');
}
const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => {
@@ -76,7 +76,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
src={frameURL}
width="100%"
height="100%"
allow="clipboard-write"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
-1
View File
@@ -101,7 +101,6 @@ function getIframe() {
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.allow = 'clipboard-write';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/expr
## 1.3.0
### Minor Changes
- 6eabaec: Implement a getVariables function for ExpressionRuntime
## 1.2.5
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.3.0",
"version": "1.2.5",
"type": "module",
"exports": {
".": {
@@ -195,49 +195,6 @@ describe('ExpressionRuntime', () => {
);
});
describe('getVariables', () => {
it.each([
{
scenario: 'single variable',
condition: 'isBetaUser === true',
expectedVariables: ['isBetaUser'],
},
{
scenario: 'multiple variables',
condition: 'useProductA && !isBetaUser',
expectedVariables: ['useProductA', 'isBetaUser'],
},
{
scenario: 'member expression',
condition: 'user.role === "admin"',
expectedVariables: ['user.role'],
},
{
scenario: 'nested member expression with method call',
condition: 'products.includes("productA") && userSegments.alpha',
expectedVariables: ['products.includes', 'userSegments.alpha'],
},
])(
'should return variables used in expression: $scenario',
({ condition, expectedVariables }) => {
expect(runtime.getVariables(condition)).toEqual(expectedVariables);
}
);
it.each([
{
scenario: 'invalid syntax',
condition: 't}=d',
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
},
])('should return an empty array for invalid expressions: $scenario', ({ condition }) => {
expect(runtime.getVariables(condition)).toEqual([]);
});
});
describe.skip('generate', () => {
it.each([
{
+1 -23
View File
@@ -11,7 +11,7 @@ import {
import { parse as parseLoose } from 'acorn-loose';
import escodegen from 'escodegen';
import evalESTreeExpr from 'eval-estree-expression';
const { evaluate, variables } = evalESTreeExpr;
const { evaluate } = evalESTreeExpr;
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
@@ -162,28 +162,6 @@ export class ExpressionRuntime {
.join('');
}
/**
* Given an expression, returns a list of variables used in the expression.
*/
public getVariables(expr: string): string[] {
try {
const parsed = this.parse(expr);
if (parsed.invalidNodes.length > 0) {
throw new ExpressionError('Invalid nodes found when parsing');
}
return variables(parsed.result, {
functions: true,
withMembers: true,
generate: escodegen.generate,
});
} catch (error) {
this.#logger.error(`Error while parsing expression ${expr} to get variables`, error);
return [];
}
}
/**
* Parses a binary expression and returns an @ExpressionParserResult.
*/
-10
View File
@@ -41,16 +41,6 @@ declare module 'eval-estree-expression' {
options?: EvalESTreeExpressionOptions
): Promise<any>;
/**
* Given an ESTree-compliant AST node, returns a list of variables used in the expression.
* @param ast An object representing an ESTree-compliant AST node.
* @param options Options for evaluation and compilation.
*/
export function variables<ASTNode>(
ast: ASTNode,
options?: EvalESTreeExpressionOptions
): string[];
/**
* Evaluates an ESTree expression synchronously against a given context.
* @param expression - An object representing an ESTree-compliant AST node.
+1 -1
View File
@@ -36,4 +36,4 @@ screenshots/
# cloudflare
.open-next
.wrangler
worker-configuration.d.ts
worker-configuration.d.ts
-64
View File
@@ -1,69 +1,5 @@
# gitbook
## 0.27.0
### Minor Changes
- aa9d41a: Update llms.txt footer for GitBook sites
- 4f754a7: Add GitBook information to footer of markdown pages
- 2ecbdd8: Add a `navigateToPage` assistant tool that lets the AI open a documentation page on the reader's behalf. The page opens instantly (no confirmation) and is pushed to the browser history so the reader can navigate back.
### Patch Changes
- 7036b67: Fix custom assistants (e.g. Kapa, Inkeep) being opened twice when clicking "Ask with …" in the search bar.
- 2885a13: Fix heading anchor alignment with heading text and translate its accessibility label.
- b9bb535: Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
- b116a0a: Fix OpenAPI CodeBlock scroll
- d83ea4c: Improve a11y of list item collapse/extend toggle
- 92b6856: Add Ask AI in CodeBlock
- 7a1236a: Fix search results linking to the page instead of the section anchor when a section snippet is displayed.
- b9bb535: Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
- Updated dependencies [6eabaec]
- Updated dependencies [f158064]
- @gitbook/expr@1.3.0
- @gitbook/react-openapi@1.5.13
## 0.26.2
### Patch Changes
- 67f31aa: Allow user-triggered AI assistants (ChatGPT-User, Claude-User, Perplexity-User) to read pages on non-indexable sites, so end-users can pull content into an LLM without an MCP connection. Search engines and training crawlers remain blocked.
- 0c577a0: Allow CORS for the same root domain
- 51fef84: Remove the date-gated header logo height logic and always apply the compact logo size to prevent size shifts between sections and translations.
- f405fff: Add icon support in tab items
- 3697dfe: Add support for hiding page actions from page layout options.
- 127ef54: Add tag filtering controls for Updates blocks.
## 0.26.1
### Patch Changes
- b70b8fd: Fix llms-full.txt pagination to include pages from all site sections.
- 6ba5e54: Support isComposing for search
- 47c8ccb: Render icons as svg markup
- a9ba996: Fix left/right aligment of images with caption
- 56aeb19: Fix events tracked from the middleware missing some properties.
- Updated dependencies [47c8ccb]
- Updated dependencies [37a9453]
- @gitbook/icons@0.5.0
- @gitbook/embed@0.5.1
- @gitbook/react-contentkit@0.7.16
## 0.26.0
### Minor Changes
- d3d5150: Add "Content-Signal" to "robots.txt"
- 7595706: Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
- a17ab65: Support variant selector in hidden section
### Patch Changes
- d1a2958: Skip AI questions when it matches the example provided to agents / crawlers.
- e3aca9e: Fix embeddable search links
- 4b78672: Fix links to other spaces/sections in the llms.txt.
- d1a2958: Pass visitor session to Ask AI.
## 0.25.1
### Patch Changes
+36 -106
View File
@@ -31,24 +31,12 @@ import {
headerLinks,
runTestCases,
setTimeToMorning,
waitForAIChatResponse,
waitForCookiesDialog,
waitForCoverImages,
waitForNotFound,
} from './util';
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
// single fixed search, a concise answer, and a fixed number of follow-ups. The
// model is never perfectly deterministic, so `overrideAIResponse` still
// normalizes the rendered content below — this prompt just narrows the variance.
const AI_PROMPT = [
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
'Follow these instructions exactly and do not deviate:',
'1. Do not write any preamble, commentary, or reasoning before acting.',
'2. Perform a single search for exactly "Lorem ipsum".',
'3. Reply with only the first sentence of the first page you find, and nothing else.',
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
@@ -56,45 +44,21 @@ const overrideAIInitialState = () => {
greeting.textContent = 'Good morning';
}
};
/**
* Normalize the non-deterministic content of an AI response before screenshotting,
* while preserving the surrounding structure (message bubbles, tool/activity
* summary, response container, suggestion buttons) so visual regressions in the
* chat chrome are still caught. The actual answer formatting is covered separately
* by the deterministic page tests, since the AI response renders through the same
* `DocumentView`.
*
* Must run only once the chat is no longer `aria-busy` (the response has fully
* settled), otherwise React re-renders from late stream events will clobber these
* mutations. See `waitForAIChatResponse`.
*/
const overrideAIResponse = () => {
// The user's prompt varies in length; pin it to a fixed string.
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
userMessage.textContent = '[Replaced message] Chat message sent by the user';
}
const assistantMessage = document.querySelectorAll(
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
);
assistantMessage.forEach((message) => {
message.innerHTML = '[Replaced message] AI chat response';
});
// The assistant's answer text is non-deterministic; replace the rendered
// document body while keeping the `.ai-response-document` container.
document
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
.forEach((message) => {
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
});
// The "Explored with N tools" activity label varies with the number of tool
// calls; pin it (the chevron sibling is left intact).
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
summary.textContent = 'Explored';
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
// Follow-up suggestion text varies; pin each label.
document
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
};
const searchTestCases: Test[] = [
@@ -203,13 +167,12 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
{
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
@@ -223,9 +186,9 @@ const searchTestCases: Test[] = [
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - Button',
@@ -239,9 +202,9 @@ const searchTestCases: Test[] = [
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
@@ -256,9 +219,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Results)',
@@ -274,13 +237,12 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
];
@@ -1094,8 +1056,8 @@ const testCases: TestsCase[] = [
url: 'blocks/annotations',
run: async (page) => {
await waitForCookiesDialog(page);
await page.waitForSelector('[data-annotation]');
await page.click('[data-annotation]');
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
},
},
{
@@ -1361,7 +1323,6 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
@@ -2260,13 +2221,9 @@ const testCases: TestsCase[] = [
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'API - navigateToPage',
@@ -2300,15 +2257,8 @@ const testCases: TestsCase[] = [
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
AI_PROMPT
);
// Wait for the full response to settle before normalizing.
await waitForAIChatResponse(iframe);
await iframe.owner().evaluate(overrideAIResponse);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Suggested questions',
@@ -2334,13 +2284,8 @@ const testCases: TestsCase[] = [
await expect(
iframe.getByTestId('ai-chat-suggested-question').nth(2)
).toHaveText('What can you do?');
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'Configuration - Custom action buttons',
@@ -2422,15 +2367,8 @@ const testCases: TestsCase[] = [
await actions.nth(3).click();
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
await page.locator('#gitbook-widget-button').click();
// Wait for the response posted above to settle before normalizing.
await waitForAIChatResponse(iframe);
await iframe.owner().evaluate(overrideAIResponse);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Custom tools',
@@ -2474,16 +2412,8 @@ const testCases: TestsCase[] = [
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
});
// The turn settles (aria-busy clears) once the stream pauses on the
// confirmation control; wait for that before normalizing.
await waitForAIChatResponse(iframe);
await iframe.owner().evaluate(overrideAIResponse);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
],
},
+24 -65
View File
@@ -13,7 +13,6 @@ import {
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationPageActionType,
CustomizationSearchStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -22,14 +21,7 @@ import {
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -47,16 +39,6 @@ export interface Test {
* Test to run
*/
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Re-applied right before every viewport screenshot (after Argos
* stabilization), so it survives re-renders triggered by viewport resizing.
*
* Use this — rather than mutating the DOM once in `run` — to normalize
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
* is clobbered when React re-renders on resize (e.g. crossing the mobile
* breakpoint), so only the first viewport ends up normalized.
*/
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
/**
* Mode for the test.
*/
@@ -164,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
},
];
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
type IconStateWindow = Window & {
__ICONS_STATES__?: Record<string, IconURLState>;
};
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({
@@ -177,28 +164,6 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
/**
* Wait for an AI chat response to be fully settled before asserting or
* screenshotting it.
*
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
* which stays true from the moment a message is sent until the stream — including
* the follow-up suggestion phase — completes. Gating on it avoids the two main
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
* answer, and running the content normalization while React is still re-rendering
* (which would clobber the replacements).
*
* Argos also waits for `aria-busy` to clear during its own stabilization
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
*
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
*/
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
timeout: 60_000,
});
}
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
@@ -293,9 +258,6 @@ export function runTestCases(testCases: TestsCase[]) {
await waitForTOCScrolling(page);
}
await waitForIcons(page);
// Re-apply per viewport, last — after any resize-driven
// re-render — so normalized content survives to capture.
await testEntry.normalizeBeforeScreenshot?.(page);
},
});
}
@@ -415,11 +377,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
@@ -444,13 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
type IconURLStates = Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
>;
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
iconStatesWindow.__ICONS_STATES__ = urlStates;
const iconWindow = window as IconStateWindow;
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
iconWindow.__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -483,6 +436,20 @@ export async function waitForIcons(page: Page) {
return true;
}
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
if (svgSymbol) {
if (icon.dataset.gbIconSymbolState === 'loaded') {
return true;
}
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
if (!href?.startsWith('#')) {
return false;
}
return document.getElementById(href.slice(1)) instanceof SVGElement;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
@@ -495,15 +462,7 @@ export async function waitForIcons(page: Page) {
const maskImage = icon.querySelector('[data-testid="mask-image"]');
if (!maskImage) {
const inlineContent = icon.querySelector(
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
);
if (inlineContent) {
icon.setAttribute('data-argos-state', 'loaded');
return true;
}
throw new Error('Icon has no inline SVG content or mask-image element');
throw new Error('No mask-image element');
}
const url = maskImage.getAttribute('href');
@@ -92,7 +92,8 @@
},
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
@@ -136,7 +137,8 @@
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
@@ -29,6 +29,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
console.log(`[GitbookIncrementalCache] Getting cache for key: ${cacheKey}`, key, cacheType);
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new Error('No R2 bucket');
@@ -5,8 +5,7 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
//TODO: bump it again once I figured out the race condition
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,15 +1,15 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
// and cause race conditions. This will be fixed in a future release of OpenNext
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
//TODO: remove, reducing cache ttl of regional cache to help debugging
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
// bypassTagCacheOnCacheHit: false,
// //TODO: remove, reducing cache ttl of regional cache to help debugging
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
//TODO: reenable regional cache once we know what's going on
export default new GitbookIncrementalCache();
+5 -9
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.27.0",
"version": "0.25.1",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -19,16 +19,16 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
@@ -56,18 +56,15 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-aria": "^3.44.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
@@ -81,7 +78,6 @@
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"tldts": "^7.0.30",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
@@ -158,7 +158,7 @@ function transformSitePageResult(args: {
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(section.path);
let sectionHref = linker.toPathInSpace(pageItem.path);
if (spaceURL) {
if (asEmbeddable) {
@@ -0,0 +1,28 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getIconSymbol } from '@/lib/icons/symbols';
import { getIconSymbolId } from '@gitbook/icons';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ style: string; icon: string }> }
) {
const { style, icon } = await params;
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
if (!symbol) {
return NextResponse.json(
{
error: 'Symbol not found',
},
{ status: 404 }
);
}
return new NextResponse(symbol.document, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=31536000, immutable',
},
});
}
@@ -1,89 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { normalizePathname, resolveNavigationTarget, toInSiteHref } from './navigation';
const location = {
href: 'https://docs.example.com/guides/intro',
origin: 'https://docs.example.com',
};
describe('resolveNavigationTarget', () => {
it('resolves an absolute same-origin URL to a relative href and pathname', () => {
expect(
resolveNavigationTarget('https://docs.example.com/reference/models', location)
).toEqual({ href: '/reference/models', pathname: '/reference/models' });
});
it('keeps the query string and section anchor in href but not in pathname', () => {
expect(
resolveNavigationTarget(
'https://docs.example.com/reference/models?tab=api#usage',
location
)
).toEqual({ href: '/reference/models?tab=api#usage', pathname: '/reference/models' });
});
it('resolves a relative path against the current location', () => {
expect(resolveNavigationTarget('/reference/models', location)).toEqual({
href: '/reference/models',
pathname: '/reference/models',
});
});
it('rejects a URL pointing to an external site', () => {
const result = resolveNavigationTarget('https://evil.example.org/phishing', location);
expect('error' in result).toBe(true);
});
});
describe('toInSiteHref', () => {
// A docs site served under `/docs/` on a host shared with other content/sites.
const linker = {
siteBasePath: '/docs/',
toLinkForContent: (url: string) => {
const parsed = new URL(url);
// Mirrors the real linker: in-site iff same host AND under the site base path.
if (parsed.hostname === 'gitbook.com' && parsed.pathname.startsWith('/docs/')) {
return parsed.pathname + parsed.search + parsed.hash;
}
return url;
},
};
it('accepts an in-site absolute URL and returns a relative path', () => {
expect(toInSiteHref('https://gitbook.com/docs/guides/intro', linker)).toBe(
'/docs/guides/intro'
);
});
it('accepts an in-site relative path under the site base path', () => {
expect(toInSiteHref('/docs/guides/intro?x=1#y', linker)).toBe('/docs/guides/intro?x=1#y');
});
it('rejects another page on the same host but outside the site base path', () => {
// The reviewer's case: same host, different site/section.
expect(toInSiteHref('https://gitbook.com/pricing', linker)).toBeNull();
expect(toInSiteHref('/pricing', linker)).toBeNull();
});
it('rejects an external host', () => {
expect(toInSiteHref('https://evil.example.org/docs/guides', linker)).toBeNull();
});
});
describe('normalizePathname', () => {
it('strips a trailing slash', () => {
expect(normalizePathname('/guides/intro/')).toBe('/guides/intro');
});
it('keeps the root slash', () => {
expect(normalizePathname('/')).toBe('/');
});
it('decodes percent-encoding so encoded and decoded paths compare equal', () => {
expect(normalizePathname('/h%C3%A9llo')).toBe(normalizePathname('/héllo'));
});
it('treats encoded and decoded paths with a trailing slash as equal', () => {
expect(normalizePathname('/caf%C3%A9/')).toBe(normalizePathname('/café'));
});
});
@@ -1,76 +0,0 @@
import { withTrailingSlash } from '@/lib/paths';
import { isExternalLink } from '../utils/link';
/**
* Resolve a link into a same-site target to navigate to.
*
* Returns an `error` when the URL is malformed or points outside of the documentation site,
* so the assistant can be told it could not navigate.
*/
export function resolveNavigationTarget(
url: string,
location: { href: string; origin: string }
): { href: string; pathname: string } | { error: string } {
let target: URL;
try {
target = new URL(url, location.href);
} catch {
return { error: `Invalid URL: ${url}` };
}
// Only allow navigating within the current documentation site to avoid sending the user to
// an external website without their consent.
if (isExternalLink(target.href, location.origin)) {
return { error: 'Cannot navigate to a page outside of this documentation site.' };
}
return { href: `${target.pathname}${target.search}${target.hash}`, pathname: target.pathname };
}
/**
* Return a site-relative href if `url` points within the current site, otherwise `null`.
*
* Unlike a bare same-origin check, this enforces the site base path, so an assistant cannot
* navigate the reader to another page on the same host (e.g. a marketing page, or a different
* docs site sharing the host such as `gitbook.com/other` or another `/url/...` proxied site).
*/
export function toInSiteHref(
url: string,
linker: { toLinkForContent: (url: string) => string; siteBasePath: string }
): string | null {
if (URL.canParse(url)) {
// toLinkForContent returns a site-relative path for in-site URLs (matching host AND site
// base path), or the raw absolute URL otherwise.
const link = linker.toLinkForContent(url);
return URL.canParse(link) ? null : link;
}
// Relative path: it must live under the site base path.
let pathname: string;
let rest = '';
try {
const parsed = new URL(url, 'https://navigation.invalid');
pathname = parsed.pathname;
rest = `${parsed.search}${parsed.hash}`;
} catch {
return null;
}
return withTrailingSlash(pathname).startsWith(linker.siteBasePath)
? `${pathname}${rest}`
: null;
}
/**
* Normalize a pathname so two equivalent paths compare equal regardless of percent-encoding or a
* trailing slash (e.g. `/h%C3%A9llo/` and `/héllo`). Used to detect when an SPA navigation has
* committed by comparing against `window.location.pathname`.
*/
export function normalizePathname(pathname: string): string {
let decoded = pathname;
try {
decoded = decodeURIComponent(pathname);
} catch {
// Keep the raw value if it isn't valid percent-encoding.
}
return decoded.length > 1 && decoded.endsWith('/') ? decoded.slice(0, -1) : decoded;
}
@@ -1,78 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { type AIChatReference, serializeReferences } from './references';
describe('serializeReferences', () => {
it('returns an empty string when there are no references', () => {
expect(serializeReferences([])).toBe('');
});
it('serializes a single page reference as a markdown link', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following page they are reading');
expect(result).toContain('- [Getting started](/getting-started)');
expect(result.endsWith('\n\n---\n\n')).toBe(true);
});
it('falls back to the path when no href is provided', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('- [Overview](getting-started)');
});
it('renders a quoted label when neither href nor path is provided', () => {
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
const result = serializeReferences(refs);
expect(result).toContain('- "Overview"');
expect(result).not.toContain('](');
});
it('uses the plural form for multiple pages', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'One' },
{ type: 'page', id: 'page-2', label: 'Two' },
];
const result = serializeReferences(refs);
expect(result).toContain('the following pages they are reading');
expect(result).toContain('in the context of them');
});
it('serializes a single code block reference', () => {
const refs: AIChatReference[] = [
{
type: 'code-block',
id: 'code-1',
label: 'main.ts',
content: 'const a = 1;',
syntax: 'ts',
},
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following code block');
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
});
it('escapes backtick fences inside the code content', () => {
const refs: AIChatReference[] = [
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
];
const result = serializeReferences(refs);
expect(result).toContain('````md\na ``` b\n````');
});
it('groups page and code references into distinct sections', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Quickstart' },
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
];
const result = serializeReferences(refs);
const pageIndex = result.indexOf('referring to the following page');
const codeIndex = result.indexOf('referring to the following code block');
expect(pageIndex).toBeGreaterThanOrEqual(0);
expect(codeIndex).toBeGreaterThan(pageIndex);
});
});
@@ -1,78 +0,0 @@
type BaseAIChatReference = {
id: string;
label?: string;
};
export type CodeBlockReference = BaseAIChatReference & {
type: 'code-block';
content: string;
syntax?: string;
};
export type PageReference = BaseAIChatReference & {
type: 'page';
/** Title of the page being referenced. */
label: string;
/** Path of the page, used to help the assistant identify it. */
path?: string;
/** Site-relative href of the page, used to navigate back to it from the chip. */
href?: string;
};
export type AIChatReference = CodeBlockReference | PageReference;
/**
* Serialize the staged references into a preamble prepended to the user's message,
* so the assistant is informed about the context the user is referring to.
*/
export function serializeReferences(refs: AIChatReference[]): string {
if (refs.length === 0) {
return '';
}
const sections: string[] = [];
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
if (pageRefs.length > 0) {
sections.push(serializePageReferences(pageRefs));
}
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
if (codeRefs.length > 0) {
sections.push(serializeCodeBlockReferences(codeRefs));
}
if (sections.length === 0) {
return '';
}
return `${sections.join('\n\n')}\n\n---\n\n`;
}
function serializePageReferences(refs: PageReference[]): string {
const plural = refs.length > 1;
const list = refs
.map((ref) => {
const url = ref.href ?? ref.path;
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
})
.join('\n');
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
}
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function buildCodeBlockFence(ref: CodeBlockReference): string {
const { label, content, syntax } = ref;
let max = 2;
for (const match of content.matchAll(/`+/g)) {
max = Math.max(max, match[0].length);
}
const fence = '`'.repeat(max + 1);
const heading = label ? `${label}\n` : '';
return `${heading}${fence}${syntax ?? ''}\n${content}\n${fence}`;
}
@@ -71,13 +71,13 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
}
}
async function DescriptionForPageContentToolCall(props: {
function DescriptionForPageContentToolCall(props: {
toolCall: AIToolCallGetPageContent;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = await getSpaceLanguage(context);
const language = getSpaceLanguage(context);
return (
<p>
@@ -101,13 +101,13 @@ async function DescriptionForPageContentToolCall(props: {
);
}
async function DescriptionForMCPToolCall(props: {
function DescriptionForMCPToolCall(props: {
toolCall: AIToolCallMCP;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = await getSpaceLanguage(context);
const language = getSpaceLanguage(context);
return (
<p>
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
}) {
const { toolCall, context } = props;
const language = await getSpaceLanguage(context);
const language = getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
);
}
async function DescriptionForGetPagesToolCall(props: {
function DescriptionForGetPagesToolCall(props: {
toolCall: AIToolCallGetPages;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = await getSpaceLanguage(context);
const language = getSpaceLanguage(context);
return (
<p>
@@ -1,3 +1,2 @@
export * from './types';
export * from './chat';
export * from './navigate';
@@ -1,48 +0,0 @@
'use server';
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { toInSiteHref } from '../navigation';
/**
* Resolve a link provided by the assistant into a path that can be navigated to within the site.
*
* The assistant references pages using the stable content-ref scheme (e.g.
* `/spaces/<spaceId>/pages/<pageId>`). Those URLs are not directly navigable in the published
* site, so we resolve them to the real site link using the site context. Any other URL is only
* accepted if it points within the current site, so the assistant cannot navigate the reader off
* the documentation site.
*/
export async function resolveAINavigationLink(
url: string
): Promise<{ href: string } | { error: string }> {
return traceErrorOnly('AI.resolveAINavigationLink', async () => {
const baseContext = await getServerActionBaseContext();
const context = await fetchServerActionSiteContext(baseContext);
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
let path = url;
if (URL.canParse(url)) {
const parsed = new URL(url);
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
const contentRef = resolveStringContentRef(path);
if (contentRef) {
const resolved = await resolveContentRef(contentRef, context);
if (!resolved) {
return { error: `Could not resolve page for ${url}` };
}
return { href: resolved.href };
}
// Not a content reference: only navigate to it if it points within the current site.
const inSiteHref = toInSiteHref(url, context.linker);
if (!inSiteHref) {
return { error: 'Cannot navigate to a page outside of this documentation site.' };
}
return { href: inSiteHref };
});
}
+2 -4
View File
@@ -2,9 +2,7 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(
builtInTools: GitBookIntegrationTool[] = []
): (GitBookIntegrationTool | AnyAIControlTool)[] {
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...builtInTools, ...integrationTools];
return [...getControlTools(), ...integrationTools];
}
+2 -2
View File
@@ -63,7 +63,7 @@ export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): Rea
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
export function useAIConfig(): AIConfig {
function useAIConfig(): AIConfig {
const ctx = React.useContext(AIContext);
if (!ctx) {
throw new Error('useAI must be used within AIContextProvider');
@@ -95,7 +95,7 @@ export function useAI(): AIContext {
label: config.assistantName ?? getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.responding ? 'thinking' : 'default'}
state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
/>
+21 -162
View File
@@ -2,7 +2,6 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
@@ -16,20 +15,16 @@ import assertNever from 'assert-never';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
references?: AIChatReference[];
activity?: AIChatMessageActivity;
};
@@ -86,23 +81,7 @@ export type AIChatState = {
control: AnyAIControl | null;
/**
* If true, the assistant is actively producing its answer — from the moment a
* message is sent until the `response_finish` event. It is cleared at that point
* (even though follow-up suggestions may still trickle in) so the input can be
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
* the loading shim, and the thinking/exploring/working status.
*/
responding: boolean;
/**
* If true, the turn is still in progress overall: from the moment a message is
* sent until the stream fully completes, including the follow-up suggestion
* phase.
*
* Unlike `responding` — which clears on `response_finish` — this stays true
* until the response is truly settled. It is the global busyness indicator,
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
* can wait for a complete, stable response.
* If true, the session is in progress.
*/
loading: boolean;
@@ -112,19 +91,13 @@ export type AIChatState = {
* display an error alert. Clearing the conversation will reset this flag.
*/
error: boolean;
/**
* References staged on the next user message.
*/
references: AIChatReference[];
};
export type AIChatEvent =
| { type: 'open' }
| { type: 'postMessage'; message: string }
| { type: 'clear' }
| { type: 'close' }
| { type: 'focus' };
| { type: 'close' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
@@ -142,14 +115,6 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void;
/** Clear the conversation */
clear: () => void;
/** Stage a reference on the next message */
addReference: (ref: AIChatReference) => string;
/** Remove a staged reference */
removeReference: (id: string) => void;
/** Clear all staged references */
clearReferences: () => void;
/** Focus the chat input */
focus: () => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
@@ -168,11 +133,9 @@ const globalState = zustand.create<AIChatState>(() => {
query: null,
followUpSuggestions: [],
control: null,
responding: false,
loading: false,
error: false,
initialQuery: null,
references: [],
};
});
@@ -207,13 +170,8 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// 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();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
@@ -253,8 +211,6 @@ export function AIChatProvider(props: {
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
@@ -263,7 +219,6 @@ export function AIChatProvider(props: {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
@@ -277,19 +232,9 @@ export function AIChatProvider(props: {
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools([navigateToPageTool]);
const tools = getTools();
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
@@ -325,7 +270,7 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools([navigateToPageTool]);
const tools = getTools();
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
@@ -349,8 +294,8 @@ export function AIChatProvider(props: {
for await (const data of stream) {
if (!data) continue;
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
if (input.message && globalState.getState().query !== input.message) {
// Chat was cleared, stop processing the stream
break;
}
@@ -361,9 +306,9 @@ export function AIChatProvider(props: {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not responding when the response is finished
// Mark as not loading when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
responding: false,
loading: false,
error: false,
}));
break;
@@ -481,40 +426,23 @@ export function AIChatProvider(props: {
}));
}
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
// Execute the tool call if it doesn't require confirmation
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
}
} catch (error) {
console.error('Error streaming AI response', error);
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
globalState.setState((state) => ({
...state,
loading: false,
error: true,
}));
}
},
[
@@ -523,32 +451,20 @@ export function AIChatProvider(props: {
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, responding } = globalState.getState();
const { query, messages, control } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Ignore duplicates while a previous turn is still streaming
if (responding) {
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
@@ -559,9 +475,8 @@ export function AIChatProvider(props: {
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message && references.length === 0) {
if (query === input.message) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
@@ -581,28 +496,25 @@ export function AIChatProvider(props: {
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
responding: true,
loading: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: wireMessage, userQuery: input.message });
streamResponse({ message: input.message });
},
[setSearchState, siteSpaceId, trackEvent, streamResponse]
[setSearchState, trackEvent, streamResponse, language]
);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
@@ -611,7 +523,6 @@ export function AIChatProvider(props: {
responseId: null,
error: false,
initialQuery: null,
references: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
@@ -623,44 +534,6 @@ export function AIChatProvider(props: {
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
@@ -686,23 +559,9 @@ export function AIChatProvider(props: {
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
on: onEvent,
};
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onEvent,
]);
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
return (
<AIChatControllerContext.Provider value={controller}>
@@ -732,7 +591,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus {
return 'confirm';
}
if (chat.responding) {
if (chat.loading) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
@@ -1,117 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import type { AIToolDefinition } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { NavigationStatusContext } from '../hooks';
import { normalizePathname, resolveNavigationTarget } from './navigation';
import { resolveAINavigationLink } from './server-actions';
const NavigateToPageInputSchema = z.object({
url: z
.string()
.describe(
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
),
});
/**
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
* after a timeout. App Router updates `window.location` only when the navigation commits, so this
* lets the tool hold its turn until the user is actually on the new page — after which the
* tool-result server action's router refresh can no longer cancel the navigation.
*/
function waitForNavigationCommit(pathname: string): Promise<boolean> {
const target = normalizePathname(pathname);
if (normalizePathname(window.location.pathname) === target) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const startedAt = Date.now();
const check = () => {
if (normalizePathname(window.location.pathname) === target) {
resolve(true);
} else if (Date.now() - startedAt > 3000) {
resolve(false);
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
});
}
/**
* Build the built-in `navigateToPage` tool exposed to the assistant.
*
* The tool opens a page within the current documentation site without confirmation. It navigates
* instantly with the Next.js router (adding a browser history entry, so the user can navigate
* back) and waits for the navigation to commit before reporting back, so the assistant's
* follow-up does not cancel the navigation.
*/
export function useNavigateToPageTool(): GitBookIntegrationTool {
const router = useRouter();
const language = useLanguage();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({ router, language, onNavigationClick });
React.useEffect(() => {
ref.current = { router, language, onNavigationClick };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'navigateToPage',
description:
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
inputSchema: zodToJsonSchema(
NavigateToPageInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { router, language, onNavigationClick } = ref.current;
const { url } = NavigateToPageInputSchema.parse(input);
// The assistant references pages using the stable content-ref scheme
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
const resolved = await resolveAINavigationLink(url);
const target =
'error' in resolved
? resolved
: resolveNavigationTarget(resolved.href, window.location);
if ('error' in target) {
return {
output: { error: target.error },
summary: {
icon: 'triangle-exclamation',
text: tString(language, 'ai_chat_tools_navigate_failed'),
},
};
}
onNavigationClick(target.href);
router.push(target.href);
const committed = await waitForNavigationCommit(target.pathname);
if (!committed) {
// biome-ignore lint/suspicious/noConsole: surfaces a navigation that never committed
console.warn(`navigateToPage: navigation to ${target.href} did not commit`);
}
return {
output: { navigated: true, url: target.href },
summary: {
icon: 'book-open',
text: tString(language, 'ai_chat_tools_navigated_to_page'),
},
};
},
}),
[]
);
}
@@ -83,13 +83,12 @@ export function AIChat() {
}
}}
withOverlay={true}
data-ai-chat
className={tcls(
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader className="not-embed:px-4">
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
@@ -226,7 +225,7 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="min-h-[20%] max-w-full shrink grow animate-fade-in-slow [container-type:size]"
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
orientation="vertical"
trailing={{ fade: false, button: true }}
@@ -281,8 +280,8 @@ export function AIChatBody(props: {
{chat.control ? <AIChatControl control={chat.control} /> : null}
<AIChatInput
responding={chat.responding}
disabled={chat.responding || chat.error}
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
@@ -1,6 +1,6 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { t } from '@/intl/translate';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
@@ -38,7 +38,6 @@ export function AIChatButton(props: {
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
@@ -3,29 +3,27 @@ import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { useAIChatState } from '../AI/useAIChat';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { Input } from '../primitives/Input';
import { AIChatReferenceChips } from './AIChatReferenceChips';
export function AIChatInput(props: {
disabled?: boolean;
/**
* When true, the input is disabled
*/
responding: boolean;
loading: boolean;
onSubmit: (value: string) => void;
}) {
const { onSubmit, disabled, responding } = props;
const { onSubmit, disabled, loading } = props;
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (chat.opened && !disabled && !responding) {
if (chat.opened && !disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -34,14 +32,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, responding, chat.opened]);
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
useEffect(() => {
return chatController.on('focus', () => {
inputRef.current?.focus();
});
}, [chatController]);
}, [disabled, loading, chat.opened]);
useHotkeys(
'mod+i',
@@ -75,23 +66,16 @@ export function AIChatInput(props: {
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !responding
!disabled && !loading
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || responding || chat.control !== null}
aria-busy={responding}
disabled={disabled || loading || chat.control !== null}
aria-busy={loading}
ref={inputRef}
header={
<AIChatReferenceChips
references={chat.references}
onRemove={chatController.removeReference}
disabled={responding || disabled}
/>
}
trailing={
<HoverCardRoot openDelay={500}>
<HoverCard
@@ -2,7 +2,6 @@ import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import { Fragment } from 'react';
import {
type AIChatController,
type AIChatMessage,
@@ -12,7 +11,6 @@ import {
import { ToggleChevron } from '../primitives';
import { Button } from '../primitives/Button';
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
import { AIChatReferenceChips } from './AIChatReferenceChips';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -22,7 +20,7 @@ export function AIChatMessages(props: {
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.responding && status !== 'working' && status !== 'done';
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type MessageGroup = { message: AIChatMessage; originalIndex: number };
@@ -51,29 +49,6 @@ export function AIChatMessages(props: {
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
const userItems = group.filter(({ message }) => message.role === AIMessageRole.User);
const assistantItems = group.filter(
({ message }) => message.role === AIMessageRole.Assistant
);
// Each client-side tool call splits a turn into a separate assistant message, so without
// aggregating here every tool would render its own "Explored with 1 tool" expandable.
// We collapse the whole assistant run behind a single expandable instead, reusing the
// commentary (hidden) vs final-answer (visible) distinction from AIMessageView.
const toolCount = assistantItems.reduce(
(acc, { message }) => acc + (message.activity?.toolCount ?? 0),
0
);
const hasCommentary =
assistantItems.some(({ message }) => message.activity?.hasCommentary) || toolCount > 0;
const hasFinalAnswer =
assistantItems.some(({ message }) => message.activity?.hasFinalAnswer) || !isLastGroup;
const firstAssistantIndex = assistantItems.at(0)?.originalIndex ?? -1;
const lastAssistantIndex = assistantItems.at(-1)?.originalIndex ?? -1;
const isLastMessage = lastAssistantIndex === chat.messages.length - 1;
return (
<div
key={groupIndex}
@@ -86,60 +61,52 @@ export function AIChatMessages(props: {
)}
style={{ animationDelay: '.2s' }}
>
{userItems.map(({ message, originalIndex }) => (
<Fragment key={originalIndex}>
{message.references?.length ? (
<div className="flex max-w-[80%] origin-top-right justify-end self-end">
<AIChatReferenceChips references={message.references} />
</div>
) : null}
<div
data-testid="ai-chat-message-user"
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
const toolCount = message.activity?.toolCount ?? 0;
const hasCommentary =
(message.activity?.hasCommentary ?? false) || toolCount > 0;
const hasFinalAnswer =
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
return (
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
? 'ai-chat-message-user'
: 'ai-chat-message-assistant'
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
message.role === AIMessageRole.User
? 'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong',
isLastMessage && message.role === AIMessageRole.Assistant
? 'grow'
: ''
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.content}
</div>
</Fragment>
))}
{assistantItems.length > 0 ? (
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
data-testid="ai-chat-message-assistant"
id={`message-${firstAssistantIndex}`}
className={tcls(
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
'origin-top-left text-tint-strong',
isLastMessage ? 'grow' : ''
)}
style={{
animationDelay: `${Math.min(firstAssistantIndex * 0.1, 0.6)}s`,
}}
>
{hasCommentary && hasFinalAnswer ? (
<CollapsibleTrigger asChild>
<Button
variant="blank"
size="small"
label={tString(language, 'ai_chat_view_activity')}
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
<span data-testid="ai-chat-activity-summary">
{message.role === AIMessageRole.Assistant &&
hasCommentary &&
hasFinalAnswer ? (
<CollapsibleTrigger asChild>
<Button
variant="blank"
size="small"
label={tString(language, 'ai_chat_view_activity')}
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
{toolCount > 0
? t(
language,
@@ -153,58 +120,50 @@ export function AIChatMessages(props: {
)
)
: t(language, 'ai_chat_explored')}
</span>
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
{assistantItems.map(({ message, originalIndex }) => (
<Fragment key={originalIndex}>{message.content}</Fragment>
))}
{message.content}
{isLastMessage ? (
<div
className={tcls(
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
showLoadingShim
? 'max-h-48 opacity-11'
: 'pointer-events-none max-h-0 opacity-0'
)}
>
<HoldMessage
className={
assistantItems.some(({ message }) => message.content)
? 'hidden'
: ''
}
/>
<LoadingSkeleton />
</div>
) : null}
{isLastMessage && message.role === AIMessageRole.Assistant ? (
<div
className={tcls(
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
showLoadingShim
? 'max-h-48 opacity-11'
: 'pointer-events-none max-h-0 opacity-0'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.responding &&
!chat.error &&
chat.query &&
chat.responseId &&
!chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1.5 -mt-4 mb-2"
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
!chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1.5 -mt-4 mb-2"
/>
) : null}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
) : null}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</Collapsible>
) : null}
</>
) : null}
</Collapsible>
);
})}
</div>
);
});
@@ -1,120 +0,0 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
import type { AIChatReference } from '../AI/references';
import { Link } from '../primitives';
const triggerClassName =
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
export function AIChatReferenceChips(props: {
references: AIChatReference[];
onRemove?: (id: string) => void;
disabled?: boolean;
}) {
const { references, onRemove, disabled } = props;
if (references.length === 0) {
return null;
}
return (
<div className="flex max-w-full flex-wrap gap-1.5">
{references.map((ref) => {
const content = (
<>
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
<span
className={tcls(
'min-w-0 truncate',
ref.type === 'code-block' && 'font-mono'
)}
>
{ref.label}
</span>
</>
);
return (
<div
key={ref.id}
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
>
{ref.type === 'page' && ref.href ? (
// A page reference may be clicked from anywhere: render a link so it
// navigates back to the page (and supports cmd/ctrl-click to open in a
// new tab).
<Link
href={ref.href}
prefetch={false}
className={triggerClassName}
onClick={(event) => event.stopPropagation()}
>
{content}
</Link>
) : (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
focusReference(ref);
}}
className={triggerClassName}
>
{content}
</button>
)}
{onRemove ? (
<button
type="button"
aria-label="Remove"
onClick={(event) => {
event.stopPropagation();
onRemove(ref.id);
}}
disabled={disabled}
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
>
<Icon icon="xmark" className="size-2.5" />
</button>
) : null}
</div>
);
})}
</div>
);
}
function getReferenceIcon(ref: AIChatReference): IconName {
switch (ref.type) {
case 'code-block':
return 'code';
case 'page':
return 'memo';
default:
assertNever(ref);
}
}
/**
* Jump to the content a reference points at, for chips that aren't rendered as links:
* - a code block: scroll it into view on the current page and focus it;
* - a page without a known href: scroll back to the top (the reader is most likely on it).
*/
function focusReference(ref: AIChatReference) {
if (ref.type === 'page') {
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.querySelector<HTMLElement>('[data-codeblock-focus]')?.focus({ preventScroll: true });
}
@@ -1,7 +1,5 @@
import { useCurrentContent } from '@/components/hooks';
import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI';
import { useRecentSearchQueries } from '../Search/recent-queries';
import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: {
@@ -9,31 +7,16 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[];
}) {
const language = useLanguage();
const { siteSpaceId } = useCurrentContent();
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
const { chatController, suggestions: configuredSuggestions } = props;
const { chatController, suggestions: _suggestions } = props;
const defaultSuggestions = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
const baseSuggestions =
configuredSuggestions && configuredSuggestions.length > 0
? configuredSuggestions
: defaultSuggestions;
const suggestions = [
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
...baseSuggestions,
].reduce<string[]>((acc, suggestion) => {
if (acc.includes(suggestion)) {
return acc;
}
acc.push(suggestion);
return acc;
}, []);
const suggestions =
_suggestions && _suggestions.length > 0
? _suggestions
: [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
return (
<div
+1 -1
View File
@@ -132,7 +132,7 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint')}>
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
<Link
target="_blank"
href={viaUrl.toString()}
@@ -18,7 +18,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
arrowProps={{ className: 'fill-tint-1' }}
>
<dfn
data-annotation=""
data-testid="annotation-button"
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
tabIndex={0}
@@ -3,7 +3,6 @@ import type {
DocumentBlockEmbed,
DocumentBlockFile,
DocumentBlockImage,
DocumentBlockImages,
JSONDocument,
} from '@gitbook/api';
@@ -13,14 +12,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentContextProps } from './DocumentView';
import { Inlines } from './Inlines';
export type CaptionAlign = NonNullable<DocumentBlockImages['data']['align']>;
const captionAlignStyles = {
center: 'mx-auto',
left: 'mr-auto ml-0',
right: 'mr-0 ml-auto',
} satisfies Record<CaptionAlign, ClassValue>;
/**
* Wrap a content of a block that has a potential caption.
*/
@@ -31,7 +22,6 @@ export function Caption(
style?: ClassValue;
fit?: boolean;
wrapperStyle?: ClassValue;
align?: CaptionAlign;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
withFrame?: boolean;
@@ -59,7 +49,6 @@ export function Caption(
withFrame && 'p-2',
],
style,
align = 'center',
} = props;
const caption = getNodeFragmentByName(block, 'caption');
@@ -75,7 +64,7 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption
className={tcls(
'text-xs',
@@ -1,57 +0,0 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { AIChatIcon } from '@/components/AIChat';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getCodeTextFromId } from './utils';
export function AskAICodeButton(props: {
codeId: string;
title?: string;
syntax?: string;
style: ClassValue;
}) {
const { codeId, title, syntax, style } = props;
const language = useLanguage();
const config = useAIConfig();
const chatController = useAIChatController();
if (config.aiMode !== CustomizationAIMode.Assistant) {
return null;
}
const onClick = () => {
const codeText = getCodeTextFromId(codeId);
if (!codeText?.trim()) {
return;
}
chatController.addReference({
type: 'code-block',
id: codeId,
label: title ?? 'Code',
content: codeText,
syntax,
});
chatController.open();
chatController.focus();
};
return (
<Button
size="xsmall"
variant="secondary"
icon={<AIChatIcon state="default" trademark={config.trademark} className="" />}
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden', '[[data-ai-chat]_&]:hidden')}
>
{t(language, 'ask')}
</Button>
);
}
@@ -141,7 +141,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
style={style}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
isPrint={mode === 'print'}
/>
);
@@ -81,14 +81,7 @@ export async function CodeBlock(
const theme = await highlight(block, richInlines, {
themes: themes,
});
return (
<CodeBlockRenderer
block={block}
style={style}
theme={theme}
isPrint={context.mode === 'print'}
/>
);
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
}
const variables = context.contentContext
@@ -6,7 +6,6 @@ import { tcls } from '@/lib/tailwind';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import { AskAICodeButton } from './AskAICodeButton';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
@@ -14,11 +13,6 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
theme: HighlightTheme;
'aria-busy'?: boolean;
id?: string;
/**
* Whether the block is rendered for print/PDF.
* When true, the "Ask AI" button is not rendered.
*/
isPrint?: boolean;
};
/**
@@ -28,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -45,24 +39,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
return (
<div
ref={ref}
id={codeId}
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
'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).
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
>
<span
data-codeblock-focus
tabIndex={-1}
aria-hidden
className="pointer-events-none absolute size-0 outline-none"
/>
<div className="flex items-center justify-start gap-2 text-sm">
{title ? (
<div
@@ -78,18 +63,11 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
) : null}
</div>
<div className="relative flex min-h-0 flex-col">
<div className="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
title={title}
syntax={block.data.syntax}
style="backdrop-blur-md"
/>
) : null}
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
</div>
<div className="relative">
<CopyCodeButton
codeId={codeId}
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
@@ -104,6 +82,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
}}
>
<code
id={codeId}
className={tcls(
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
@@ -135,30 +114,27 @@ function CodeHighlightLine(props: {
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers, bg, fg } = props;
const lineStyle = {
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
};
return (
<span
className={tcls(
'highlight-line',
line.diff === 'added' && 'diff-added',
line.diff === 'deleted' && 'diff-deleted',
line.highlighted && 'highlighted'
)}
aria-label={
line.diff === 'added'
? 'Added line'
: line.diff === 'deleted'
? 'Removed line'
: undefined
}
style={lineStyle}
className={tcls('highlight-line', line.highlighted && 'highlighted')}
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
>
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
{withLineNumbers && (
<span
className="highlight-line-number"
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
/>
)}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -6,8 +6,6 @@ import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getCodeTextFromId } from './utils';
/**
* Client component to copy the code of a code block.
* To avoid passing large payload to the client, the code is computed from the DOM.
@@ -33,12 +31,12 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
}, [copied]);
const onClick = () => {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
const element = document.getElementById(codeId);
if (!element) {
return;
}
navigator.clipboard.writeText(codeText);
navigator.clipboard.writeText(getCodeText(element));
setCopied(true);
};
@@ -54,3 +52,31 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
</Button>
);
}
/**
* Compute the code text from the DOM,
* ignoring the empty white space we use for empty lines (represented with a class "ew").
*/
function getCodeText(code: HTMLElement): string {
let text = '';
const iterate = (node: Node) => {
if (node instanceof HTMLBRElement) {
text += '\n';
} else if (node instanceof HTMLSpanElement) {
if (node.classList.contains('ew')) {
text += '\n';
} else {
text += node.innerText;
}
} else if (node instanceof HTMLElement) {
node.childNodes.forEach(iterate);
} else {
text += node.textContent;
}
};
iterate(code);
return text;
}
@@ -1,22 +1,17 @@
'use client';
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { FocusScope, usePreventScroll } from 'react-aria';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */
const DIALOG_ANIMATION_MS = 200;
/**
* Used to render a Mermaid diagram from a CodeBlock.
*/
@@ -24,28 +19,11 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, mode, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
// A stable container that holds the diagram subtree. We portal the diagram into it and
// only ever move this plain node between the inline slot and the dialog — never the
// React-managed subtree itself — so React stays in control and panzoom/SVG are preserved.
const diagramHostRef = useRef<HTMLDivElement | null>(null);
if (diagramHostRef.current === null && typeof document !== 'undefined') {
const host = document.createElement('div');
// `display: contents` so the host adds no box of its own (the diagram becomes a
// direct flex child of the dialog panel and can fill it).
host.style.display = 'contents';
diagramHostRef.current = host;
}
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it
// animates closed. `isPresent` is true whenever the diagram lives in the dialog.
const [isFullscreen, setIsFullscreen] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const isPresent = isFullscreen || isExiting;
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
@@ -118,114 +96,27 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
};
}, [source, id, darkMode, shouldRender]);
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
usePreventScroll({ isDisabled: !isPresent });
const openFullscreen = useCallback(() => {
// Reserve the inline slot's current height before the diagram is detached, so the
// page layout does not jump. Measured here while still inline and un-restyled.
const root = rootRef.current;
if (root) {
root.style.minHeight = `${root.offsetHeight}px`;
}
setIsExiting(false);
setIsFullscreen(true);
// Re-center the diagram for the larger view.
panZoom?.reset();
}, [panZoom]);
const closeFullscreen = useCallback(() => {
setIsFullscreen(false);
setIsExiting(true);
}, []);
// Keep the dialog mounted until the exit animation finishes, then unmount it.
useEffect(() => {
if (!isExiting) {
return;
}
const timer = window.setTimeout(() => {
setIsExiting(false);
panZoom?.reset();
}, DIALOG_ANIMATION_MS);
return () => window.clearTimeout(timer);
}, [isExiting, panZoom]);
// Allow Escape to close the dialog.
useEffect(() => {
if (!isFullscreen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeFullscreen();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isFullscreen, closeFullscreen]);
// Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog).
useLayoutEffect(() => {
const host = diagramHostRef.current;
const root = rootRef.current;
if (host && root && !host.parentNode) {
root.appendChild(host);
}
}, []);
// Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts.
// Done in the panel's ref callback so it happens during commit, before FocusScope reads
// focus. The inline slot's reserved height (set in openFullscreen) is cleared on return.
const setPanel = useCallback((panel: HTMLDivElement | null) => {
panelRef.current = panel;
const host = diagramHostRef.current;
const root = rootRef.current;
if (!host) {
return;
}
if (panel) {
panel.appendChild(host);
} else if (root) {
root.appendChild(host);
root.style.minHeight = '';
}
}, []);
if (error) {
return <ClientCodeBlock {...props} />;
}
// The live diagram subtree. It is portaled into a stable host that moves between the
// inline slot and the dialog, so its markup must not depend on where it currently lives.
const diagram = (
return (
<div
className={tcls(
'group/mermaid relative',
isPresent ? 'flex h-full w-full flex-col' : null
)}
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
>
<div
ref={wrapperRef}
className={tcls(
className={
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing',
isPresent && !isLoading ? 'flex-1' : null
)}
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
>
<div
ref={diagramRef}
className={tcls(
'overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full',
isPresent
? 'flex h-full items-center justify-center [&_svg]:max-h-full'
: null
)}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
/>
</div>
{isLoading ? (
@@ -233,53 +124,9 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? (
<MermaidPanZoomControls
panZoom={panZoom}
isFullscreen={isPresent}
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
/>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
</div>
);
return (
<>
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
{isPresent
? createPortal(
<FocusScope contain restoreFocus>
{/* Backdrop: dims and blurs the page, closes on click. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */}
<div
aria-hidden="true"
className={tcls(
'fixed inset-0 z-40 bg-tint-base/3 backdrop-blur-md dark:bg-tint-base/6',
isFullscreen ? 'animate-fade-in' : 'animate-fade-out'
)}
onClick={closeFullscreen}
/>
{/* Centered panel. The padding area lets clicks fall through to the backdrop. */}
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-3 sm:p-5 lg:p-8">
<div
ref={setPanel}
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram"
className={tcls(
'pointer-events-auto relative flex h-full w-full max-w-[110rem] flex-col overflow-hidden rounded-2xl border border-tint-subtle bg-tint-base shadow-2xl',
isFullscreen ? 'animate-blur-in' : 'animate-blur-out'
)}
/>
</div>
</FocusScope>,
document.body
)
: null}
</>
);
}
async function renderMermaidDiagram(args: {
@@ -3,55 +3,50 @@
import type { PanzoomObject } from '@panzoom/panzoom';
import { Button } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
const PAN_STEP = 50;
/**
* Zoom and fullscreen controls for mermaid diagrams, grouped into a single vertical
* toolbar positioned in the bottom-right corner. Panning is done by dragging the diagram.
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
*/
export function MermaidPanZoomControls(props: {
panZoom: PanzoomObject;
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const { panZoom, isFullscreen, onToggleFullscreen } = props;
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
className: tcls('p-1 [&_svg]:size-3.5 opacity-90'),
// Non-interactive tooltips: the Tooltip primitive makes their popper wrapper
// pointer-transparent so it can't steal the hover that reveals these controls.
tooltipProps: {
rootProps: { disableHoverableContent: true },
},
};
return (
<div
className={tcls(
'absolute right-2 bottom-2 z-10 flex flex-col items-center gap-0.5 rounded-lg transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none',
// Keep the controls always visible in fullscreen, otherwise only reveal on hover/focus.
isFullscreen ? 'opacity-100' : 'opacity-0'
)}
>
<Button {...btnProps} icon="plus" label="Zoom in" onClick={() => panZoom.zoomIn()} />
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
{...btnProps}
icon="arrows-to-dot"
label="Reset view"
onClick={() => panZoom.reset()}
icon="chevron-up"
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
{/* Row 2: pan left, reset, pan right */}
<Button
{...btnProps}
icon={isFullscreen ? 'compress' : 'expand'}
label={isFullscreen ? 'Exit full page' : 'View in full page'}
onClick={onToggleFullscreen}
icon="chevron-left"
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
<Button
{...btnProps}
icon="chevron-right"
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
{...btnProps}
icon="chevron-down"
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
</div>
);
}
@@ -5,7 +5,6 @@ import type {
} from '@gitbook/api';
import { useId } from 'react';
import type { DocumentContext } from '../DocumentView';
import { CodeBlock } from './CodeBlock';
import { convertCodeStringToBlock } from './utils';
@@ -16,11 +15,10 @@ import { convertCodeStringToBlock } from './utils';
export function PlainCodeBlock(props: {
code: string;
syntax: string;
mode?: DocumentContext['mode'];
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, mode = 'default', themeKey, themes } = props;
const { code, syntax, themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -35,7 +33,7 @@ export function PlainCodeBlock(props: {
<CodeBlock
document={document}
context={{
mode,
mode: 'default',
}}
themeKey={themeKey}
themes={themes}
@@ -1,13 +1,7 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockCode } from '@gitbook/api';
import {
type HighlightLine,
type HighlightToken,
type RenderedInline,
getInlines,
highlight,
} from './highlight';
import { type RenderedInline, getInlines, highlight } from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
@@ -696,138 +690,6 @@ it('should support multiple code tokens in an annotation', async () => {
]);
});
function joinLineContent(line: HighlightLine): string {
const visit = (tokens: HighlightToken[]): string =>
tokens
.map((t) => {
if (t.type === 'plain') return t.content;
if (t.type === 'shiki') return t.token.content;
return visit(t.children);
})
.join('');
return visit(line.tokens);
}
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
return {
object: 'block',
type: 'code',
data: syntax ? { syntax } : {},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text }],
},
],
},
],
};
}
it('classifies and strips trailing // [!code ++] in JS', async () => {
const lines = await highlightWithInlines(
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
);
expect(lines).toHaveLength(1);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
});
it('classifies and strips trailing # [!code --] in Python', async () => {
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
expect(lines[0]!.diff).toBe('deleted');
expect(joinLineContent(lines[0]!)).toBe('x = 1');
});
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
const lines = await highlightWithInlines(
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
});
it('classifies and strips /* [!code --] */ in CSS', async () => {
const lines = await highlightWithInlines(
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
);
expect(lines[0]!.diff).toBe('deleted');
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
});
it('does not classify when marker is not at end of line', async () => {
const lines = await highlightWithInlines(
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
);
expect(lines[0]!.diff).toBeNull();
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
});
it('returns diff: null for lines without a marker', async () => {
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
expect(lines[0]!.diff).toBeNull();
});
it('classifies and strips marker via plainHighlighting fallback', async () => {
const lines = await highlightWithInlines(
singleLineBlock(undefined, 'plain text // [!code ++]')
);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('plain text');
});
it('preserves inline annotation when marker is stripped', async () => {
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
},
{
object: 'inline',
type: 'annotation',
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
},
],
isVoid: false,
fragments: [],
},
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
},
],
},
],
});
expect(tokens[0]!.diff).toBe('added');
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
// inline annotation around "log" must be preserved
const hasAnnotation = tokens[0]!.tokens.some(
(t) =>
t.type === 'annotation' &&
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
);
expect(hasAnnotation).toBe(true);
});
it('should handle \\r', async () => {
const tokens = await highlightWithInlines({
object: 'block',
@@ -34,114 +34,11 @@ export type HighlightTheme = {
lines: HighlightLine[];
};
export type LineDiffNotation = 'added' | 'deleted';
export type HighlightLine = {
highlighted: boolean;
diff: LineDiffNotation | null;
tokens: HighlightToken[];
};
/**
* Detects an in-source diff notation marker at the end of a line, e.g.
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] *​/`.
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
*/
const NOTATION_PATTERN =
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
export function parseDiffNotation(
line: string
): { diff: LineDiffNotation; markerStart: number } | null {
const match = NOTATION_PATTERN.exec(line);
if (!match) {
return null;
}
const variant = match[1] ?? match[2] ?? match[3];
return {
diff: variant === '++' ? 'added' : 'deleted',
markerStart: match.index,
};
}
/**
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
* (counted across all tokens, recursing into annotations) remain. Used to strip
* trailing diff-notation markers from rendered output.
*/
export function truncateHighlightTokens(
tokens: HighlightToken[],
maxLen: number
): HighlightToken[] {
const out: HighlightToken[] = [];
let remaining = maxLen;
for (const token of tokens) {
if (remaining <= 0) {
break;
}
const len = highlightTokenLength(token);
if (len <= remaining) {
out.push(token);
remaining -= len;
continue;
}
out.push(sliceHighlightToken(token, remaining));
remaining = 0;
}
return out;
}
function highlightTokenLength(token: HighlightToken): number {
switch (token.type) {
case 'plain':
return token.content.length;
case 'shiki':
return token.token.content.length;
case 'annotation':
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
}
}
/**
* Concatenate the text content of a sequence of HighlightTokens, recursing
* into annotation children. Mirror of {@link highlightTokenLength}.
*/
export function getHighlightTokensText(tokens: HighlightToken[]): string {
return tokens
.map((token) => {
switch (token.type) {
case 'plain':
return token.content;
case 'shiki':
return token.token.content;
case 'annotation':
return getHighlightTokensText(token.children);
}
})
.join('');
}
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
switch (token.type) {
case 'plain':
return { type: 'plain', content: token.content.slice(0, maxLen) };
case 'shiki': {
const inner = token.token as ThemedToken & { start?: number; end?: number };
const newContent = inner.content.slice(0, maxLen);
const newToken: ThemedToken & { start?: number; end?: number } = {
...inner,
content: newContent,
};
if (typeof inner.start === 'number') {
newToken.end = inner.start + newContent.length;
}
return { type: 'shiki', token: newToken };
}
case 'annotation':
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
}
}
export type HighlightToken =
| { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken }
@@ -239,9 +136,6 @@ export async function highlight(
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
const lineText = tokens.map((token) => token.content).join('');
const notation = parseDiffNotation(lineText);
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
@@ -258,14 +152,9 @@ export async function highlight(
currentIndex += 1; // for the \n
const finalTokens = notation
? truncateHighlightTokens(result, notation.markerStart)
: result;
return {
highlighted: Boolean(lineBlock?.data.highlighted),
diff: notation?.diff ?? null,
tokens: finalTokens,
tokens: result,
};
}),
};
@@ -3,14 +3,7 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
import { getNodeText } from '@/lib/document';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import {
type HighlightTheme,
type HighlightToken,
type RenderedInline,
getHighlightTokensText,
parseDiffNotation,
truncateHighlightTokens,
} from './highlight';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
@@ -69,14 +62,9 @@ export function plainHighlight(
};
});
// Detect diff notation against the built tokens (not the raw nodes)
// so any evaluated inline expressions are included in the offset math.
const notation = parseDiffNotation(getHighlightTokensText(tokens));
return {
highlighted: Boolean(lineBlock.data.highlighted),
diff: notation?.diff ?? null,
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
tokens,
};
}),
};
@@ -65,40 +65,3 @@ export function convertCodeStringToBlock(args: {
nodes: lines,
};
}
/**
* Resolve the code text of a code block from its wrapper id.
* Returns null when the block isn't in the DOM (e.g. not yet hydrated).
*/
export function getCodeTextFromId(codeId: string): string | null {
const element = document.getElementById(codeId)?.querySelector('code');
return element ? getCodeText(element) : null;
}
/**
* Compute the code text from the DOM,
* ignoring the empty white space we use for empty lines (represented with a class "ew").
*/
export function getCodeText(code: HTMLElement): string {
let text = '';
const iterate = (node: Node) => {
if (node instanceof HTMLBRElement) {
text += '\n';
} else if (node instanceof HTMLSpanElement) {
if (node.classList.contains('ew')) {
text += '\n';
} else {
text += node.innerText;
}
} else if (node instanceof HTMLElement) {
node.childNodes.forEach(iterate);
} else {
text += node.textContent;
}
};
iterate(code);
return text;
}
@@ -36,11 +36,6 @@ export interface DocumentContext {
* @default false
*/
withLinkPreviews?: boolean;
/**
* Optional table row search query.
*/
tableSearchQuery?: string;
}
export interface DocumentContextProps {
@@ -18,7 +18,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
return null;
}
ReactDOM.preload('https://iframely.net/embed.js', { as: 'script' });
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script' });
const embed = await getDataOrNull(
context.contentContext.dataFetcher.getEmbedByUrl({
@@ -41,7 +41,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
}}
data-visual-test="blackout"
/>
<Script strategy="lazyOnload" src="https://iframely.net/embed.js" />
<Script strategy="lazyOnload" src="https://cdn.iframe.ly/embed.js" />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
@@ -1,8 +1,6 @@
import type { DocumentBlockExpandable } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
import { getNodeFragmentByType } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
@@ -12,7 +10,7 @@ import { Blocks } from '../Blocks';
import { Inlines } from '../Inlines';
import { Details } from './Details';
export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
const { block, style, ancestorBlocks, document, context } = props;
const title = getNodeFragmentByType(block, 'expandable-title');
@@ -28,10 +26,6 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
let id = block.meta?.id ?? '';
id = context.getId ? context.getId(id) : id;
const language = context.contentContext
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
return (
<Details
id={id}
@@ -74,7 +68,7 @@ export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
/>
<a
href={`#${id}`}
aria-label={tString(language, 'direct_link_to_heading')}
aria-label="Direct link to heading"
className={tcls(
'absolute',
'top-2',
@@ -29,7 +29,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null;
}
const language = await getSpaceLanguage(context.contentContext);
const language = getSpaceLanguage(context.contentContext);
const contentType = getSimplifiedContentType(file.contentType);
const insights = {
type: 'link_click' as const,
@@ -13,11 +13,11 @@ export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
export function HashLinkButton(props: {
id: string;
block: DocumentBlockTabs | DocumentBlockHeading;
label: string;
label?: string;
className?: ClassValue;
iconClassName?: ClassValue;
}) {
const { id, block, className, iconClassName, label } = props;
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
@@ -1,7 +1,5 @@
import type { DocumentBlockHeading } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -10,7 +8,7 @@ import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
import { getTextAlignment } from './utils';
export async function Heading(props: BlockProps<DocumentBlockHeading>) {
export function Heading(props: BlockProps<DocumentBlockHeading>) {
const { block, style, context, ...rest } = props;
const textStyle = getBlockTextStyle(block);
@@ -20,10 +18,6 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
let id = block.meta?.id ?? '';
id = context.getId ? context.getId(id) : id;
const language = context.contentContext
? await getSpaceLanguage(context.contentContext)
: defaultLanguage;
return (
<Tag
id={id}
@@ -45,11 +39,11 @@ export async function Heading(props: BlockProps<DocumentBlockHeading>) {
id={id}
block={block}
className={tcls(
'-ml-6 self-center pr-2',
'-ml-6 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')}
label="Direct link to heading"
/>
<div
@@ -5,13 +5,13 @@ import { validateIconName } from '@gitbook/icons/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
import { languages } from '@/intl/translations';
import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
export async function Hint({
export function Hint({
block,
style,
ancestorBlocks,
@@ -27,8 +27,8 @@ export async function Hint({
const hasHeading = isHeadingBlock(firstNode);
const language = contextProps.context.contentContext
? await getSpaceLanguage(contextProps.context.contentContext)
: defaultLanguage;
? getSpaceLanguage(contextProps.context.contentContext)
: languages.en;
const label = tString(language, `hint_${block.data.style}`);
@@ -5,7 +5,7 @@ import { resolveContentRefInDocument } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { Caption, type CaptionAlign } from './Caption';
import { Caption } from './Caption';
import type { DocumentContext } from './DocumentView';
export function Images(props: BlockProps<DocumentBlockImages>) {
@@ -52,7 +52,6 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
context={context}
isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
align={align}
/>
))}
</div>
@@ -81,9 +80,8 @@ async function ImageBlock(props: {
siblings: number;
isEstimatedOffscreen: boolean;
withFrame?: boolean;
align?: CaptionAlign;
}) {
const { document, block, context, isEstimatedOffscreen, withFrame, align } = props;
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext
@@ -100,7 +98,7 @@ async function ImageBlock(props: {
return (
<div className={tcls('relative', 'overflow-hidden')}>
<Caption {...props} fit align={align}>
<Caption {...props} fit>
<Image
alt={block.data.alt ?? ''}
sizes={imageBlockSizes}
@@ -124,11 +122,7 @@ async function ImageBlock(props: {
width: getImageDimension(block.data.width, undefined),
height: getImageDimension(block.data.height, 'auto'),
}}
style={
withFrame
? 'rounded-xl'
: 'circular-corners:rounded-2xl rounded-corners:rounded-sm'
}
style={withFrame ? 'rounded-xl' : undefined}
/>
</Caption>
</div>
@@ -51,8 +51,8 @@ export function InlineActionButton(
className: 'text-[1em]',
}}
maxLength={action === 'ask' ? 2048 : 512}
disabled={action === 'ask' && chatState.responding}
aria-busy={action === 'ask' && chatState.responding}
disabled={action === 'ask' && chatState.loading}
aria-busy={action === 'ask' && chatState.loading}
leading={icon}
keyboardShortcut={false}
onSubmit={(value) => handleSubmit(value as string)}
@@ -1,7 +1,7 @@
import { type ContentRef, type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, defaultLanguage } from '@/intl/translations';
import { type TranslationLanguage, languages } from '@/intl/translations';
import {
type ResolvedContentRef,
resolveContentRefFallback,
@@ -60,7 +60,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
if (context.withLinkPreviews) {
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
@@ -1,28 +0,0 @@
'use client';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { ContentKit, type ContentKitClientContextData } from '@gitbook/react-contentkit/client';
import React from 'react';
type ContentKitProps<RenderContext> = React.ComponentProps<typeof ContentKit<RenderContext>>;
/**
* ContentKit wrapper for integration blocks that need client-only adaptive context.
*/
export function ContentKitWithAdaptiveVisitorContext<RenderContext>(
props: ContentKitProps<RenderContext>
) {
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const clientContext = React.useMemo<ContentKitClientContextData>(
() => ({
getVisitorContext: () => ({
visitor: visitorClaims?.visitor ?? null,
}),
}),
[visitorClaims]
);
return <ContentKit {...props} clientContext={clientContext} />;
}
@@ -5,8 +5,6 @@ import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import type { BlockProps } from '../Block';
import './contentkit.css';
import { ContentKitWithAdaptiveVisitorContext } from './ContentKitWithAdaptiveVisitorContext';
import { shouldRenderIntegrationBlockWithAdaptiveVisitorContext } from './adaptive';
import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
@@ -14,12 +12,8 @@ import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
const { block, context, style } = props;
if (!context.contentContext) {
throw new Error('Expected a content context to render an block');
}
if (!context.contentContext.space && !block.meta?.spaceId) {
throw new Error('integration block requires a spaceId from the context or API');
if (!context.contentContext?.space) {
throw new Error('integration block requires a content.spaceId');
}
const initialInput: RenderIntegrationUI = {
@@ -28,28 +22,16 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
action: block.data.action,
context: {
type: 'document',
// When the block originates from a cross-space reusable content, the server adds a spaceId so the integration is
// looked up in the correct source space.
spaceId: block.meta?.spaceId ?? context.contentContext.space.id,
spaceId: context.contentContext?.space.id,
editable: false,
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
},
};
const dataFetcher = block.meta?.token
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
: context.contentContext.dataFetcher;
const initialResponse = await fetchSafeIntegrationUI(
{
...context.contentContext,
dataFetcher,
},
{
integrationName: block.data.integration,
request: initialInput,
}
);
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
integrationName: block.data.integration,
request: initialInput,
});
if (initialResponse.error) {
if (initialResponse.error.code === 404) {
@@ -70,15 +52,9 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
initialOutput
)
? ContentKitWithAdaptiveVisitorContext
: ContentKit;
return (
<div className={tcls(style)}>
<ContentKitComponent
<ContentKit
renderContext={{
integrationName: block.data.integration,
}}
@@ -88,7 +64,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
render={renderIntegrationUi}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKitComponent>
</ContentKit>
</div>
);
}
@@ -1,67 +0,0 @@
import type {
ContentKitDescendantElement,
ContentKitRenderOutput,
ContentKitRootElement,
ContentKitStepper,
} from '@gitbook/api';
type ContentKitElement = ContentKitRootElement | ContentKitDescendantElement | ContentKitStepper;
/**
* Decide whether an integration block should expose Adaptive visitor context to webframes.
*/
export function shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
output: ContentKitRenderOutput
) {
if (output.type === 'complete') {
return false;
}
return (
output.canAccessVisitorClaims === true &&
doesContentKitElementContainWebframe(output.element)
);
}
/**
* Check whether a ContentKit element tree contains a webframe element.
*/
function doesContentKitElementContainWebframe(element: ContentKitElement): boolean {
switch (element.type) {
case 'webframe':
return true;
case 'block':
case 'box':
case 'hstack':
case 'vstack':
case 'step':
case 'modal':
case 'configuration':
case 'stepper':
case 'card':
return doesContentKitElementArrayContainWebframe(element.children);
case 'codeblock':
return (
doesContentKitElementArrayContainWebframe(element.header) ||
doesContentKitElementArrayContainWebframe(element.footer)
);
default:
return false;
}
}
function doesContentKitElementArrayContainWebframe(elements: unknown): boolean {
if (!Array.isArray(elements)) {
return doesContentKitElementContainWebframeValue(elements);
}
return elements.some(doesContentKitElementContainWebframeValue);
}
function doesContentKitElementContainWebframeValue(value: unknown): boolean {
if (typeof value !== 'object' || value === null || !('type' in value)) {
return false;
}
return doesContentKitElementContainWebframe(value as ContentKitElement);
}
@@ -1,5 +1,5 @@
import { getSpaceLanguage, tString } from '@/intl/server';
import { defaultLanguage } from '@/intl/translations';
import { languages } from '@/intl/translations';
import { Icon } from '@gitbook/icons';
import type { DocumentContextProps } from '../DocumentView';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
/**
* Hover card displayed for a link not found.
*/
export async function NotFoundRefHoverCard(
export function NotFoundRefHoverCard(
props: DocumentContextProps & {
children: React.ReactNode;
}
@@ -16,7 +16,7 @@ export async function NotFoundRefHoverCard(
context: { contentContext },
children,
} = props;
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
@@ -54,7 +54,6 @@ export function getOpenAPIContext(args: {
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
mode={props.context.mode}
themeKey="openapi"
themes={
context && 'customization' in context
@@ -1,4 +1,4 @@
import type { DocumentBlockTable } from '@gitbook/api';
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
@@ -9,9 +9,8 @@ import { StickyViewGrid } from './StickyViewGrid';
import { ViewCards } from './ViewCards';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
import { type TableRecordKV, filterTableRecordsBySearchTerm } from './search';
export type { TableRecordKV };
export type TableRecordKV = [string, DocumentTableRecord];
export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
view: View;
@@ -23,15 +22,9 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) =>
a[1].orderIndex.localeCompare(b[1].orderIndex)
);
const filteredRecords = filterTableRecordsBySearchTerm(
block,
records,
context.tableSearchQuery
);
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
return a[1].orderIndex.localeCompare(b[1].orderIndex);
});
switch (block.data.view.type) {
case 'cards':
@@ -39,7 +32,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
<ViewCards
view={block.data.view}
isOffscreen={isOffscreen}
records={filteredRecords}
records={records}
{...props}
/>
);
@@ -48,7 +41,7 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
...props,
view: block.data.view,
isOffscreen,
records: filteredRecords,
records,
};
const { tableWidth } = getViewGridLayout({
block,
@@ -1,80 +0,0 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import { getNodeFragmentByName, getNodeText } from '@/lib/document';
export type TableRecordKV = [string, DocumentTableRecord];
export function filterTableRecordsBySearchTerm(
block: DocumentBlockTable,
records: TableRecordKV[],
searchTerm: string | undefined
): TableRecordKV[] {
const trimmedSearchTerm = searchTerm?.trim();
if (!trimmedSearchTerm) {
return records;
}
return records.filter(([, record]) =>
block.data.view.columns.some((column) => {
const text = getTableCellSearchText(block, record, column);
return new RegExp(trimmedSearchTerm, 'iu').test(text);
})
);
}
function getTableCellSearchText(
block: DocumentBlockTable,
record: DocumentTableRecord,
column: string
): string {
const definition = block.data.definition[column];
const value = record.values[column];
if (!definition || value === null || value === undefined) {
return '';
}
switch (definition.type) {
case 'text': {
if (typeof value !== 'string') {
return '';
}
const fragment = getNodeFragmentByName(block, value);
return normalizeSearchText(fragment ? getNodeText(fragment) : '');
}
case 'select': {
if (!Array.isArray(value)) {
return '';
}
return normalizeSearchText(
value
.map((selectId) => {
return (
definition.options.find((option) => option.value === selectId)?.label ??
selectId
);
})
.join(' ')
);
}
case 'number':
case 'rating': {
return typeof value === 'number' ? `${value}` : '';
}
case 'checkbox': {
return typeof value === 'boolean' ? `${value}` : '';
}
case 'files':
case 'users': {
return Array.isArray(value) ? normalizeSearchText(value.join(' ')) : '';
}
default:
return '';
}
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
@@ -15,7 +15,7 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { Icon } from '@gitbook/icons';
import { useRouter } from 'next/navigation';
interface TabsState {
@@ -56,7 +56,6 @@ const TITLES_MAX = 5;
export interface TabsItem {
id: string;
title: string;
icon?: IconName;
body: React.ReactNode;
}
@@ -320,7 +319,6 @@ function TabsDropdownMenu(props: {
key={tab.id}
onClick={() => onSelect(tab.id)}
active={tab.id === activeTabId}
leadingIcon={tab.icon}
>
{tab.title}
</DropdownMenuItem>
@@ -349,8 +347,7 @@ const TabItem = memo(function TabItem(props: {
id={getTabButtonId(tab.id)}
onClick={() => onSelect(tab.id)}
>
{tab.icon && <Icon icon={tab.icon} className="size-4 shrink-0" />}
<span className="min-w-0 truncate">{tab.title}</span>
{tab.title}
</TabButton>
);
});
@@ -414,7 +411,7 @@ function TabButton(
{...rest}
type="button"
className={tcls(
'relative inline-flex max-w-full items-center gap-1.5 px-3.5 py-2 font-medium text-sm transition-[color]',
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -1,6 +1,4 @@
import type { DocumentBlockTabs } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { validateIconName } from '@gitbook/icons/icons';
import { tcls } from '@/lib/tailwind';
@@ -22,13 +20,9 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
throw new Error('Tab block is missing a key');
}
const icon: IconName | undefined =
tab.data.icon && validateIconName(tab.data.icon) ? tab.data.icon : undefined;
return {
id: tab.meta?.id ?? tab.key,
title: tab.data.title ?? '',
icon,
body: (
<Blocks
key={tab.key}
@@ -11,7 +11,6 @@ import { assert } from 'ts-essentials';
import { Tag } from '../Tag';
import type { BlockProps } from './Block';
import { Blocks } from './Blocks';
import { FilteredUpdate } from './UpdatesFilter';
export function Update(props: BlockProps<DocumentBlockUpdate>) {
const { block, style, ancestorBlocks, ...contextProps } = props;
@@ -44,10 +43,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
const resolvedTags = resolveBlockTags(block.data.tags, revisionTags);
return (
<FilteredUpdate
tagSlugs={resolvedTags.map((tag) => tag.slug)}
className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}
>
<div className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}>
<div
className={tcls(
// Date is only sticky on larger screens when we use flex-row layout, with 0px fallback to prevent flicker and flaky tests before JS sets the variable
@@ -75,7 +71,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
ancestorBlocks={[...ancestorBlocks, block]}
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
/>
</FilteredUpdate>
</div>
);
}
@@ -1,231 +0,0 @@
'use client';
import { Button } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import type { RevisionTag } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { TagIcon } from '../Tag';
const UPDATES_FILTER_SEARCH_PARAM = 'tag';
type UpdatesFilterContextValue = {
selectedTags: string[];
selectedTagSet: Set<string>;
toggleTag: (tag: string) => void;
clearTags: () => void;
};
const emptyUpdatesFilterContext: UpdatesFilterContextValue = {
selectedTags: [],
selectedTagSet: new Set(),
toggleTag: () => {},
clearTags: () => {},
};
const UpdatesFilterContext = React.createContext<UpdatesFilterContextValue | null>(null);
export function UpdatesFilterProvider(props: {
tagSlugs: string[];
children: React.ReactNode;
}) {
const { tagSlugs, children } = props;
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const availableTags = React.useMemo(() => new Set(tagSlugs), [tagSlugs]);
const sanitizeTags = React.useCallback(
(tags: string[]) => {
const next: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
if (!availableTags.has(tag) || seen.has(tag)) {
continue;
}
next.push(tag);
seen.add(tag);
}
return next;
},
[availableTags]
);
const rawSelectedTags = React.useMemo(
() => searchParams?.getAll(UPDATES_FILTER_SEARCH_PARAM) ?? [],
[searchParams]
);
const urlSelectedTags = React.useMemo(
() => sanitizeTags(rawSelectedTags),
[sanitizeTags, rawSelectedTags]
);
const [selectedTags, setSelectedTags] = React.useState(urlSelectedTags);
const selectedTagsRef = React.useRef(selectedTags);
const replaceTags = React.useCallback(
(nextTags: string[]) => {
const params = new URLSearchParams(searchParams?.toString());
params.delete(UPDATES_FILTER_SEARCH_PARAM);
for (const tag of sanitizeTags(nextTags)) {
params.append(UPDATES_FILTER_SEARCH_PARAM, tag);
}
const query = params.toString();
const hash = typeof window === 'undefined' ? '' : window.location.hash;
router.replace(`${pathname}${query ? `?${query}` : ''}${hash}`, { scroll: false });
},
[pathname, router, sanitizeTags, searchParams]
);
React.useEffect(() => {
if (!areTagsEqual(rawSelectedTags, urlSelectedTags)) {
replaceTags(urlSelectedTags);
}
}, [rawSelectedTags, replaceTags, urlSelectedTags]);
React.useEffect(() => {
if (areTagsEqual(selectedTagsRef.current, urlSelectedTags)) {
return;
}
selectedTagsRef.current = urlSelectedTags;
setSelectedTags(urlSelectedTags);
}, [urlSelectedTags]);
// Keep tag clicks responsive while the URL update from router.replace is still pending.
const updateSelectedTags = React.useCallback(
(getNextTags: (currentTags: string[]) => string[]) => {
const nextTags = sanitizeTags(getNextTags(selectedTagsRef.current));
selectedTagsRef.current = nextTags;
setSelectedTags(nextTags);
replaceTags(nextTags);
},
[replaceTags, sanitizeTags]
);
const toggleTag = React.useCallback(
(tag: string) => {
if (!availableTags.has(tag)) {
return;
}
updateSelectedTags((currentTags) =>
currentTags.includes(tag)
? currentTags.filter((currentTag) => currentTag !== tag)
: [...currentTags, tag]
);
},
[availableTags, updateSelectedTags]
);
const clearTags = React.useCallback(() => {
updateSelectedTags(() => []);
}, [updateSelectedTags]);
const selectedTagSet = React.useMemo(() => new Set(selectedTags), [selectedTags]);
const value = React.useMemo(
() => ({
selectedTags,
selectedTagSet,
toggleTag,
clearTags,
}),
[selectedTags, selectedTagSet, toggleTag, clearTags]
);
return <UpdatesFilterContext.Provider value={value}>{children}</UpdatesFilterContext.Provider>;
}
export function useUpdatesFilter(): UpdatesFilterContextValue {
return React.useContext(UpdatesFilterContext) ?? emptyUpdatesFilterContext;
}
export function UpdatesTagFilters(props: {
tags: RevisionTag[];
tagsLabel: string;
clearLabel: string;
}) {
const { tags, tagsLabel, clearLabel } = props;
const { selectedTagSet, selectedTags, toggleTag, clearTags } = useUpdatesFilter();
const isFiltering = selectedTags.length > 0;
if (tags.length === 0) {
return null;
}
return (
<div className="shrink-0 border-tint-subtle border-b pt-px pb-4">
<div className="mb-3 ml-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-1 font-semibold text-tint text-xs uppercase leading-wider">
<Icon icon="tags" className="size-3" />
{tagsLabel}
</div>
<Button
variant="blank"
size="xsmall"
icon="xmark"
label={clearLabel}
onClick={isFiltering ? clearTags : undefined}
aria-hidden={!isFiltering}
tabIndex={isFiltering ? undefined : -1}
className={tcls('text-xs', !isFiltering && 'pointer-events-none invisible')}
/>
</div>
<div className="flex flex-wrap gap-1.5 px-3">
{tags.map((tag) => {
const selected = selectedTagSet.has(tag.slug);
return (
<button
key={tag.slug}
type="button"
aria-pressed={selected}
onClick={() => toggleTag(tag.slug)}
className={tcls(
'inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 font-medium text-xs leading-normal transition-colors',
'circular-corners:rounded-2xl straight-corners:rounded-xs',
'not-focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-primary',
selected
? 'bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover'
: 'bg-tint-5 text-tint-strong hover:bg-tint-hover',
isFiltering && !selected && 'opacity-8 hover:opacity-11'
)}
>
<TagIcon tag={tag} />
<span className="truncate">{tag.label}</span>
</button>
);
})}
</div>
</div>
);
}
function areTagsEqual(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((tag, index) => tag === right[index]);
}
export function FilteredUpdate(props: {
tagSlugs: string[];
className?: string;
children: React.ReactNode;
}) {
const { tagSlugs, className, children } = props;
const { selectedTagSet } = useUpdatesFilter();
const isVisible =
selectedTagSet.size === 0 || tagSlugs.some((tagSlug) => selectedTagSet.has(tagSlug));
return (
<div className={className} hidden={!isVisible}>
{children}
</div>
);
}
@@ -79,7 +79,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader>
{!tabsRef.current ? (
<AIChatDynamicIcon className="animate-blur-in-slow" trademark={trademark} />
@@ -17,7 +17,7 @@ import { TranslationsDropdown } from './SpacesDropdown';
/**
* Render the header for the space.
*/
export async function Header(props: {
export function Header(props: {
context: GitBookSiteContext;
withTopHeader?: boolean;
variants: {
@@ -28,7 +28,6 @@ export async function Header(props: {
const { context, withTopHeader, variants } = props;
const { siteSpace, visibleSections, customization } = context;
const searchProps = getSearchBaseProps(context);
const language = await getSpaceLanguage(context);
const withSections = Boolean(
visibleSections &&
@@ -181,7 +180,7 @@ export async function Header(props: {
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<HeaderLinkMore
label={t(language, 'more')}
label={t(getSpaceLanguage(context), 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
context={context}
@@ -11,6 +11,14 @@ interface HeaderLogoProps {
context: GitBookSiteContext;
}
/**
* We previously gave the logo too much height, making it too big to look good.
* As a response, many orgs added padding inside their logos to make them look better.
* The new logo max-height looks better but might make existing logos look too small.
* To avoid disruption, we only use the new (correct) logo height for logos updated after this date.
*/
const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
/**
* Render the logo for a space using the customization settings.
*/
@@ -18,6 +26,10 @@ interface HeaderLogoProps {
export async function HeaderLogo(props: HeaderLogoProps) {
const { context } = props;
const { customization, linker } = context;
const canUseCompactHeaderLogo =
customization.updatedAt &&
!Number.isNaN(Date.parse(customization.updatedAt)) &&
Date.parse(customization.updatedAt) >= Date.parse(HEADER_COMPACT_LOGO_DATE);
const primaryLink = customization.header.primaryLink
? await resolveContentRef(customization.header.primaryLink, context)
@@ -59,7 +71,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
'max-h-8',
canUseCompactHeaderLogo ? 'max-h-8' : 'max-h-10',
'h-full',
'w-full',
'object-contain',
@@ -17,7 +17,7 @@ import { getSession } from './sessions';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
const VISITOR_STORAGE_KEY = '__session_updated';
const VISITOR_UPDATED_AT_STORAGE_KEY = '__session_updated';
/**
* Visitor state when the visitor is not a signed-in GitBook user.
@@ -40,38 +40,31 @@ type VisitorUserResponse = Pick<AnyVisitorResponse, 'deviceId'> & {
};
/**
* Time while a stored visitor is considered valid before revalidating it.
* Time while a cookie is considered valid before revalidating it.
*/
const STALE_TIME_MS = 60 * 60 * 1000;
interface StoredVisitor {
visitor: VisitorResponse;
updatedAt: number;
}
interface StoredVisitorRaw {
visitor?: unknown;
deviceId?: unknown;
updatedAt?: unknown;
}
function getStoredVisitor(): StoredVisitor | null {
const value = getLocalStorageItem<StoredVisitorRaw | null>(VISITOR_STORAGE_KEY, null);
if (!value || typeof value.updatedAt !== 'number') {
function getVisitorUpdatedAt(deviceId: string): number | null {
const value = getLocalStorageItem<{ deviceId: string; updatedAt: number } | null>(
VISITOR_UPDATED_AT_STORAGE_KEY,
null
);
if (!value) {
return null;
}
if (isVisitor(value.visitor)) {
return { visitor: value.visitor, updatedAt: value.updatedAt };
if (value.deviceId !== deviceId) {
clearVisitorUpdatedAt();
return null;
}
// Migrate the legacy { deviceId, updatedAt } shape written by older clients.
if (typeof value.deviceId === 'string' && value.deviceId) {
return { visitor: { deviceId: value.deviceId }, updatedAt: value.updatedAt };
}
return null;
return value.updatedAt;
}
function setStoredVisitor(visitor: VisitorResponse, updatedAt: number) {
setLocalStorageItem(VISITOR_STORAGE_KEY, { visitor, updatedAt });
function clearVisitorUpdatedAt() {
removeLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY);
}
function setVisitorUpdatedAt(deviceId: string, updatedAt: number) {
setLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY, { deviceId, updatedAt });
}
export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse;
@@ -86,6 +79,10 @@ function isVisitor(value: unknown): value is VisitorResponse {
);
}
function isSignedInVisitor(value: unknown): value is VisitorUserResponse {
return Boolean(isVisitor(value) && value?.userId && value.organizationId);
}
const visitorStore = createStore<{
visitor: VisitorResponse | null;
pendingVisitor: Promise<VisitorResponse> | null;
@@ -174,21 +171,13 @@ function getGlobalVisitor({
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
// Consent withdrawn or tracking disabled: stop using AND retaining the stored id.
removeLocalStorageItem(VISITOR_STORAGE_KEY);
return { visitor: { deviceId: getProposedVisitorId() }, pendingVisitor: null };
}
const { existing, proposedId } = getVisitorFromCookies();
const stored = getStoredVisitor();
// Cookie wins, localStorage survives third-party cookie blocking, proposed id is the fallback
const stableId = existing?.deviceId ?? stored?.visitor.deviceId ?? proposedId;
const fetchGlobalVisitor = async () => {
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', stableId);
url.searchParams.set('proposed', proposedId);
try {
const resp = await fetch(url, {
@@ -209,26 +198,33 @@ function getGlobalVisitor({
throw new Error(`Unexpected __session format: ${JSON.stringify(visitor)}`);
}
setStoredVisitor(visitor, Date.now());
// When cookie tracking is disabled we still allow a signed-in session to be detected,
// but otherwise we preserve the no-cookie behavior by returning an anonymous visitor.
if (!isSignedInVisitor(visitor)) {
clearVisitorUpdatedAt();
return { deviceId: proposedId };
}
setVisitorUpdatedAt(visitor.deviceId, Date.now());
return visitor;
} catch (error) {
clearVisitorUpdatedAt();
console.error('Failed to fetch visitor session ID', error);
return existing ?? stored?.visitor ?? { deviceId: stableId };
if (existing) {
return existing;
}
return { deviceId: proposedId };
}
};
// Return immediately if we already have a visitor, revalidate in the background only when stale
const immediate = existing ?? stored?.visitor ?? null;
if (immediate) {
const isStale =
!stored ||
stored.visitor.deviceId !== immediate.deviceId ||
stored.updatedAt < Date.now() - STALE_TIME_MS;
return {
visitor: immediate,
pendingVisitor: isStale ? fetchGlobalVisitor() : null,
};
const { existing, proposedId } = getVisitorFromCookies();
if (existing) {
// Revalidate in background if stale.
const updatedAt = getVisitorUpdatedAt(existing.deviceId);
const isStale = !updatedAt || updatedAt < Date.now() - STALE_TIME_MS;
return { visitor: existing, pendingVisitor: isStale ? fetchGlobalVisitor() : null };
}
return { visitor: null, pendingVisitor: fetchGlobalVisitor() };
@@ -57,7 +57,7 @@ export async function PDFPage(props: {
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = await getSpaceLanguage(baseContext);
const language = getSpaceLanguage(baseContext);
// Compute the pages to render
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
@@ -1,6 +1,6 @@
'use client';
import { useAIChatController, useAIChatState } from '@/components/AI';
import { useAIChatState } from '@/components/AI';
import type { Assistant } from '@/components/AI';
import { Button } from '@/components/primitives/Button';
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
@@ -15,28 +15,12 @@ import { createStore, useStore } from 'zustand';
type PageActionType = 'button' | 'dropdown-menu-item';
/**
* Context about the current page, attached to the assistant as a reference when opened.
*/
export type PageActionAssistantContext = {
id: string;
title: string;
path?: string;
/** Site-relative href of the page, used to navigate back to it from the chip. */
href?: string;
};
/**
* Action to open the GitBook Assistant.
*/
export function ActionOpenAssistant(props: {
assistant: Assistant;
type: PageActionType;
page?: PageActionAssistantContext;
}) {
const { assistant, type, page } = props;
export function ActionOpenAssistant(props: { assistant: Assistant; type: PageActionType }) {
const { assistant, type } = props;
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
return (
@@ -46,20 +30,8 @@ export function ActionOpenAssistant(props: {
label={assistant.label}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
disabled={chat.responding}
disabled={chat.loading}
onClick={() => {
// Stage a reference to the current page so the assistant is informed about
// the context the user is asking from. Only the sidebar GitBook Assistant
// uses the chat reference system.
if (page && assistant.mode === 'sidebar') {
chatController.addReference({
type: 'page',
id: page.id,
label: page.title,
path: page.path,
href: page.href,
});
}
assistant.open();
}}
/>
@@ -114,7 +86,7 @@ function useCopiedStore(stateKey: string) {
}
/**
* Cache for the markdown version of the page.
* Cache for the markdown versbion of the page.
*/
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
@@ -137,8 +109,7 @@ export function ActionCopyMarkdown(props: {
const fetchMarkdown = async () => {
setLoading(true);
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
const result = await fetch(humanURL).then((res) => res.text());
const result = await fetch(markdownPageURL).then((res) => res.text());
markdownCache.set(markdownPageURL, result);
setLoading(false);
@@ -189,7 +160,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
icon="markdown"
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
href={`${markdownPageURL}?displayAgentInstructions=false`}
href={markdownPageURL}
/>
);
}
@@ -464,7 +435,6 @@ function PageActionWrapper(props: {
size="xsmall"
variant="secondary"
label={label ?? shortLabel}
aria-label={shortLabel}
className="bg-tint-base"
onClick={onClick}
href={href}
@@ -3,13 +3,9 @@
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type {
CustomizationPageActionType,
GitSyncState,
SiteCustomizationSettings,
} from '@gitbook/api';
import { type ReactNode, useRef } from 'react';
import { type Assistant, useAI } from '../AI';
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
import React, { useRef } from 'react';
import { useAI } from '../AI';
import { ToggleChevron } from '../primitives';
import {
ActionCopyMCPCommand,
@@ -22,35 +18,8 @@ import {
ActionViewAsMarkdown,
ActionViewAsPDF,
ActionViewAsRSS,
type PageActionAssistantContext,
} from './PageActions';
/**
* Type of a built-in page action that can be displayed in the page actions menu.
*/
type PageActionType = `${CustomizationPageActionType}`;
/**
* Order used to derive the list of actions from the deprecated boolean flags when the API does not
* provide `items` yet. It matches the order the page actions menu used before the `items` model, so
* existing sites keep the same dropdown ordering until they are migrated.
*/
const LEGACY_PAGE_ACTION_ORDER: PageActionType[] = [
'assistant',
'markdown',
'external-ai',
'mcp',
'git',
'pdf',
];
/**
* Default-button priority used in legacy mode (no `items`). It reproduces the previous behavior,
* which only ever surfaced the assistant, the Git edit link or the markdown copy as the default
* action — never ChatGPT, MCP or PDF.
*/
const LEGACY_DEFAULT_ACTION_PRIORITY: PageActionType[] = ['assistant', 'git', 'markdown'];
export type PageActionsDropdownURLs = {
html: string;
markdown: string;
@@ -68,95 +37,22 @@ interface PageActionsDropdownProps {
urls: PageActionsDropdownURLs;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
/** The current page, referenced by the assistant when opened. */
page: PageActionAssistantContext;
}
/**
* Dropdown menu for the page actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM…).
*
* The order and enabled state of the built-in actions are driven by `actions.items`, the ordered
* list of enabled page actions, with its first available action shown as a quick-access button.
* When the API does not provide `items` yet, the menu falls back to the previous ordering and
* default-action priority so existing sites keep their current behavior.
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
*/
export function PageActionsDropdown(props: PageActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
const { siteTitle, urls } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
// `items` is the source of truth when the API provides it. Until then (legacy mode), we derive
// the list from the deprecated boolean flags using the previous ordering.
const configuredItems = getConfiguredPageActionItems(props.actions);
const isLegacy = configuredItems === null;
const items = configuredItems ?? deriveLegacyPageActionItems(props.actions);
const defaultAction = usePageDefaultAction(props);
const dropdownActions = getPageDropdownActions(props);
let defaultAction: ReactNode = null;
let markdownIsDefault = false;
if (urls.rss) {
// The RSS feed is not part of the configurable `items` list: it is only available on the
// relevant pages (e.g. blog/changelog index). It is promoted as the default action whenever
// present, as a contextual override of the configured list.
defaultAction = <ActionViewAsRSS url={urls.rss} type="button" />;
} else {
// The default button is the first available action. With `items`, that is simply the first
// entry of the configured list. In legacy mode we keep the previous default-action priority
// (assistant → Git → markdown), so existing sites don't suddenly surface ChatGPT/MCP/PDF.
const defaultPriority = isLegacy ? LEGACY_DEFAULT_ACTION_PRIORITY : items;
const defaultActionType = defaultPriority.find(
(type) => items.includes(type) && isActionTypeAvailable(type, urls, assistants)
);
if (defaultActionType) {
defaultAction = renderDefaultActionForType(defaultActionType, {
siteTitle,
urls,
assistants,
page: props.page,
});
markdownIsDefault = defaultActionType === 'markdown';
}
}
// Build the dropdown menu items, grouped by action type. RSS is appended as its own group
// since it is not part of the configurable `items` list.
const groups: { key: string; items: ReactNode[] }[] = items
.map((type) => ({
key: type,
items: renderDropdownActionsForType(type, {
siteTitle,
urls,
markdownIsDefault,
assistants,
page: props.page,
}),
}))
.filter((group) => group.items.length > 0);
if (urls.rss) {
groups.push({
key: 'rss',
items: [<ActionViewAsRSS key="rss" url={urls.rss} type="dropdown-menu-item" />],
});
}
// Count the actual menu items (not the groups): the dropdown toggle must stay visible when a
// single action type still exposes more than one item beyond the default button (e.g. markdown
// exposes both "Copy page" and "View as Markdown").
const menuItemCount = groups.reduce((total, group) => total + group.items.length, 0);
// Insert a separator before each group; the leading one is hidden via `first:hidden`.
const dropdownActions = groups.flatMap((group) => [
<DropdownMenuSeparator key={`separator-${group.key}`} className="first:hidden" />,
...group.items,
]);
return defaultAction || menuItemCount > 0 ? (
return defaultAction || dropdownActions.length > 0 ? (
<ButtonGroup ref={ref} className={props.className}>
{defaultAction}
{!defaultAction || menuItemCount > 1 ? (
{!defaultAction || dropdownActions.length > 1 ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
@@ -179,212 +75,122 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
}
/**
* Return the configured ordered list of enabled page actions, or `null` when the API does not
* provide it yet (legacy mode).
* Return the list of actions to show in the dropdown menu.
*/
function getConfiguredPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] | null {
return actions.items ?? null;
}
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
const { siteTitle, urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
/**
* Derive the ordered list of enabled page actions from the deprecated boolean flags, following the
* ordering used before the `items` model. Used only when the API does not provide `items`.
*/
function deriveLegacyPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] {
return LEGACY_PAGE_ACTION_ORDER.filter((type) => {
switch (type) {
case 'external-ai':
return actions.externalAI;
case 'markdown':
return actions.markdown;
case 'mcp':
return actions.mcp;
// `assistant` is governed by the AI mode setting, and `git`/`pdf` are not represented
// by the legacy `pageActions` flags; all three are gated by availability at render time.
case 'assistant':
case 'git':
case 'pdf':
return true;
default:
return false;
}
});
}
return [
...assistants.map((assistant) => (
<ActionOpenAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
)),
/**
* Whether an action type can be rendered given the available URLs and assistants.
*/
function isActionTypeAvailable(
type: PageActionType,
urls: PageActionsDropdownURLs,
assistants: Assistant[]
): boolean {
switch (type) {
case 'assistant':
return assistants.length > 0;
case 'external-ai':
case 'markdown':
return true;
case 'mcp':
return !!urls.mcp;
case 'git':
return !!urls.editOnGit;
case 'pdf':
return !!urls.pdf;
default:
return false;
}
}
/**
* Render the list of menu items shown in the dropdown for a given action type.
*
* Returns a flat array of items (without separators); the caller groups them and inserts the
* separators between groups.
*/
function renderDropdownActionsForType(
type: PageActionType,
params: {
siteTitle: string;
urls: PageActionsDropdownURLs;
markdownIsDefault: boolean;
assistants: Assistant[];
page: PageActionAssistantContext;
}
): ReactNode[] {
const { siteTitle, urls, markdownIsDefault, assistants, page } = params;
switch (type) {
case 'assistant':
return assistants.map((assistant) => (
<ActionOpenAssistant
key={`assistant-${assistant.id}`}
assistant={assistant}
type="dropdown-menu-item"
page={page}
/>
));
case 'external-ai':
return [
<ActionOpenInLLM
key="chatgpt"
provider="chatgpt"
url={urls.html}
type="dropdown-menu-item"
/>,
<ActionOpenInLLM
key="claude"
provider="claude"
url={urls.html}
type="dropdown-menu-item"
/>,
];
case 'markdown':
return [
actions.markdown ? (
<React.Fragment key="markdown">
<DropdownMenuSeparator className="first:hidden" />
<ActionCopyMarkdown
key="copy-markdown"
isDefaultAction={markdownIsDefault}
isDefaultAction={!assistants.length}
markdownPageURL={urls.markdown}
type="dropdown-menu-item"
/>,
<ActionViewAsMarkdown
key="view-markdown"
markdownPageURL={urls.markdown}
type="dropdown-menu-item"
/>,
];
case 'mcp':
if (!urls.mcp) {
return [];
}
return [
<ActionCopyMCPURL key="copy-mcp" mcpURL={urls.mcp} type="dropdown-menu-item" />,
/>
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" />
</React.Fragment>
) : null,
actions.externalAI ? (
<React.Fragment key="externalAI">
<DropdownMenuSeparator className="first:hidden" />
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" />
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" />
</React.Fragment>
) : null,
actions.mcp && urls.mcp ? (
<React.Fragment key="mcp">
<DropdownMenuSeparator className="first:hidden" />
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" />
<ActionOpenMCP
key="mcp-vscode"
provider="vscode"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>,
/>
<ActionCopyMCPCommand
key="mcp-claude-code"
provider="claude-code"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>,
/>
<ActionCopyMCPCommand
key="mcp-codex"
provider="codex"
mcpURL={urls.mcp}
siteTitle={siteTitle}
type="dropdown-menu-item"
/>,
];
case 'git':
if (!urls.editOnGit) {
return [];
}
return [
<ActionOpenEditOnGit
key="edit-git"
type="dropdown-menu-item"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>,
];
case 'pdf':
if (!urls.pdf) {
return [];
}
return [<ActionViewAsPDF key="pdf" url={urls.pdf} type="dropdown-menu-item" />];
default:
return [];
}
/>
</React.Fragment>
) : null,
urls.editOnGit || urls.pdf || urls.rss ? (
<React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" />
{urls.editOnGit ? (
<ActionOpenEditOnGit
type="dropdown-menu-item"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
) : null}
{urls.rss ? <ActionViewAsRSS url={urls.rss} type="dropdown-menu-item" /> : null}
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
</React.Fragment>
) : null,
].filter(Boolean);
}
/**
* Render the action shown as the quick-access default button for a given action type.
* A default action shown as a quick-access button beside the dropdown menu
*/
function renderDefaultActionForType(
type: PageActionType,
params: {
siteTitle: string;
urls: PageActionsDropdownURLs;
assistants: Assistant[];
page: PageActionAssistantContext;
}
): ReactNode {
const { urls, assistants, page } = params;
function usePageDefaultAction(props: PageActionsDropdownProps) {
const { urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
switch (type) {
case 'assistant':
return assistants[0] ? (
<ActionOpenAssistant assistant={assistants[0]} type="button" page={page} />
) : null;
case 'external-ai':
return <ActionOpenInLLM provider="chatgpt" url={urls.html} type="button" />;
case 'markdown':
return (
<ActionCopyMarkdown isDefaultAction markdownPageURL={urls.markdown} type="button" />
);
case 'mcp':
return urls.mcp ? <ActionCopyMCPURL mcpURL={urls.mcp} type="button" /> : null;
case 'git':
return urls.editOnGit ? (
<ActionOpenEditOnGit
type="button"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
) : null;
case 'pdf':
return urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="button" /> : null;
default:
return null;
if (urls.rss) {
return <ActionViewAsRSS url={urls.rss} type="button" />;
}
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
}
if (urls.editOnGit) {
return (
<ActionOpenEditOnGit
type="button"
provider={urls.editOnGit.provider}
url={urls.editOnGit.url}
/>
);
}
if (actions.markdown) {
return (
<ActionCopyMarkdown
isDefaultAction={!assistant}
markdownPageURL={urls.markdown}
type="button"
/>
);
}
return null;
}
@@ -1,11 +1,10 @@
import { getSpaceLanguage, t, tString } from '@/intl/server';
import { getSpaceLanguage, t } from '@/intl/server';
import type { GitBookSiteContext } from '@/lib/context';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import {
type JSONDocument,
type RevisionPageDocument,
type RevisionTag,
SiteAdsStatus,
SiteInsightsAdPlacement,
} from '@gitbook/api';
@@ -13,7 +12,6 @@ import React from 'react';
import { Icon } from '@gitbook/icons';
import { Ad } from '../Ads';
import { UpdatesTagFilters } from '../DocumentView/UpdatesFilter';
import { PageFeedbackForm } from '../PageFeedback';
import { ThemeToggler } from '../ThemeToggler';
import { SideSheet } from '../primitives/SideSheet';
@@ -24,18 +22,17 @@ import { ScrollToTopButton } from './ScrollToTopButton';
/**
* Aside listing the headings in the document.
*/
export async function PageAside(props: {
export function PageAside(props: {
page: RevisionPageDocument;
document: JSONDocument | null;
filterableTags: RevisionTag[];
context: GitBookSiteContext;
withHeaderOffset: { sectionsHeader: boolean; topHeader: boolean };
withFullPageCover: boolean;
withPageFeedback: boolean;
}) {
const { page, document, filterableTags, withPageFeedback, context } = props;
const { page, document, withPageFeedback, context } = props;
const { customization, site } = context;
const language = await getSpaceLanguage(context);
const language = getSpaceLanguage(context);
return (
<SideSheet
@@ -106,19 +103,7 @@ export async function PageAside(props: {
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
{page.layout.outline ? (
<>
{filterableTags.length > 0 ? (
<UpdatesTagFilters
tags={filterableTags}
tagsLabel={tString(language, 'tags')}
clearLabel={tString(language, 'clear')}
/>
) : null}
<div
className={tcls(
'mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden',
filterableTags.length > 0 && 'mt-4'
)}
>
<div className="mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden">
<ScrollToTopButton className="flex cursor-pointer items-center gap-1 font-semibold text-tint text-xs uppercase leading-wider">
<Icon icon="block-quote" className="size-3" />{' '}
{t(language, 'on_this_page')}

Some files were not shown because too many files have changed in this diff Show More