Compare commits

..

89 Commits

Author SHA1 Message Date
Zeno Kapitein a716d11ab3 Clarify "Open in Agent" page action
Removing the short label will fall us back to the longer "Open in [ChatGPT/Claude]" label
2026-06-17 17:23:27 +02:00
Zeno Kapitein af3cda1c53 Fix wide content overflowing hint blocks (#4320)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:53 +02:00
Tomek a0ad403a91 Stabilize Playwright Chrome text rendering across platforms (#4318) 2026-06-17 09:21:16 +00:00
Zeno Kapitein 0ca49523a2 Remove external link button from link tooltip hover card (#4316)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:52:02 +02:00
Zeno Kapitein 76550415ac Fix ⌘-C opening the Assistant on Dvorak keyboard layouts (#4311)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:51:20 +02:00
Nolann B. 509389c7e5 Update @gitbook/api to latest version (#4315) 2026-06-16 14:30:10 +02:00
Nolann B. 88b94b5d36 Handle circular oneOf with discriminator and allOf in OpenAPI schemas (#4070) 2026-06-16 13:34:17 +02:00
Greg Bergé 251630c49f Use page actions items list for ordering and default action (#4302)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:19:20 +02:00
Zeno Kapitein acf2719d06 Stabilize AI Assistant visual regression tests (#4305)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:19:56 +02:00
Zeno Kapitein ddba8031ec Reference the current page when opening the Assistant (#4308)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:35:15 +02:00
Zeno Kapitein 72a1596ae8 Fix AI assistant forms overflowing the chat panel (#4309)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:22:57 +02:00
Nolann B. 5b36c59061 Fix PDF export failing on API Reference pages (#4307) 2026-06-12 13:43:38 +00:00
Zeno Kapitein 77c717c251 Group an assistant turn's tool calls into a single expandable (#4303)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:26:27 +02:00
Samy Pessé 60310b4c83 Version Packages (#4278)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-12 14:55:55 +02:00
conico974 6eabaecbad Implement getVariables method for expression evaluation (#4304) 2026-06-12 14:26:17 +02:00
Zeno Kapitein 423bbd06d9 Create Claude launch.json (#4301) 2026-06-12 12:52:24 +02:00
conico974 36f19c8018 Add LLMs directive for documentation links (#4300) 2026-06-12 12:09:25 +02:00
Zeno Kapitein 2ecbdd8f62 add navigateToPage assistant tool (#4299)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:57:55 +02:00
Nolann B. c727cc8b9d Fix inflated visitor counts from unstable anonymous visitorId (#4293) 2026-06-12 00:52:59 +02:00
Zeno Kapitein 2c729d461e Rework Assistant “based on your context” popover wording (#4298) 2026-06-11 22:19:32 +02:00
Tomek d83ea4ce14 chore(a11y): increase width of the collapse/expand arrow in menu items (#4297) 2026-06-11 14:02:38 +00:00
spastorelli bac8608e78 Add support for passing visitor claims to integration webframes (#4291) 2026-06-11 09:52:26 +02:00
Greg Bergé 7a1236a3a5 Fix search results linking to page instead of section anchor (#4295)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:23:59 +00:00
Zeno Kapitein ed80bd9e7a Add recent search (and chat) queries (#4225) 2026-06-10 18:45:07 +02:00
Zeno Kapitein 7036b67307 Fix window.GitBook.registerAssistant is being called twice (#4296) 2026-06-10 15:54:38 +00:00
Zeno Kapitein 6bd522b322 Update site section tabs with better column calc and animations (#4252) 2026-06-10 17:35:38 +02:00
Zeno Kapitein 74fc3f74d7 Fix: Submit feedback confirmation does not auto close (#4240) 2026-06-10 17:35:29 +02:00
conico974 3e384c679e Add suggested follow-up questions to AI answer markdown response (#4284) 2026-06-10 17:29:08 +02:00
Greg Bergé f1fa1061db Upgrade bun.lock (#4294) 2026-06-10 17:09:10 +02:00
Addison 4f754a73a3 Add information about GitBook to page markdown (#4292) 2026-06-09 19:53:07 +02:00
conico974 62d6d28888 Fix context handling in resolveContentRef for page resolution issues (#4290) 2026-06-09 15:10:03 +02:00
conico974 fd73a9912b Fix caching strategy for icon fetching (#4289) 2026-06-08 13:46:32 +02:00
Johan Preynat 595435ac4a Ensure local search matches remote filter when there are no sections (#4287) 2026-06-04 19:14:54 +02:00
Addison aa9d41a1b6 Update footer for llms (#4286) 2026-06-04 16:19:02 +02:00
conico974 30089b404c Adjust caching strategy for icon fetching based on runtime environment (#4283)
Co-authored-by: Steven H <steven@gitbook.io>
2026-06-02 12:00:38 +02:00
Nolann B. 92b68560e1 Add Ask AI in CodeBlock (#4220) 2026-06-02 00:40:54 +02:00
Nolann B. f158064817 Match OpenAPI operation paths regardless of trailing slash (#4282) 2026-05-29 12:16:24 +02:00
Greg Bergé 2885a137f8 Fix heading anchor alignment with heading text and translate its accessibility label (#4281) 2026-05-29 09:38:47 +02:00
spastorelli 0ffb74a6f8 Fix issue where MCP POST requests with expired tokens returns 307 instead of 401 (#4280) 2026-05-28 16:35:12 +02:00
Nolann B. b116a0a2a4 Fix OpenAPI code sample box not scrolling on initial load (#4279) 2026-05-28 12:04:32 +02:00
Greg Bergé b9bb535d52 Full screen view for Mermaid diagrams (#4275)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:17:21 +02:00
Samy Pessé 257ca09a5e Version Packages (#4263)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tomek Gargula <tomasz@gitbook.com>
2026-05-28 07:12:45 +00:00
Tomek f405fffa06 feat: add support for icons in tabs (#4277) 2026-05-27 15:22:25 +00:00
Tomek 16a54b6bb9 Improve accessibility in published docs (#4276) 2026-05-27 12:40:25 +00:00
Greg Bergé 526a928420 Fix annotation hover interaction when card has a target link (#4274) 2026-05-27 12:02:20 +02:00
conico974 c201abab5f Don't display agents instructions for page action (#4272) 2026-05-27 10:41:53 +02:00
Greg Bergé 3697dfefed Add page actions layout visibility (#4270) 2026-05-22 12:37:37 +02:00
spastorelli 287203d197 Show MCP page actions for VA & adaptive sites (#4268) 2026-05-22 08:47:13 +02:00
conico974 0b38f6c93b Update regional cache settings (#4267) 2026-05-20 14:25:59 +02:00
conico974 8ce64f4862 Remove remote from use cache (#4259) 2026-05-20 14:12:51 +02:00
Greg Bergé 127ef54b0f Add updates tag filtering (#4265) 2026-05-20 12:42:51 +02:00
Greg Bergé 67f31aaa7a Always allow LLMs to read the content (#4264) 2026-05-20 09:39:35 +02:00
Greg Bergé 51fef84d39 Fix logo size variations (#4266) 2026-05-19 20:55:45 +02:00
Greg Bergé 0c577a00de Allow CORS for the same root domain (#4262)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:41:07 +02:00
Samy Pessé 8b6dc6dbbd Version Packages (#4255)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-05-19 13:24:41 +02:00
Antoine Coutellier 2e7ba40a25 feat: support integrations inside reusable content blocks (#4250) 2026-05-19 11:29:02 +02:00
Greg Bergé b70b8fd2ca Fix llms.txt not including all content (#4260) 2026-05-18 21:33:07 +02:00
Greg Bergé a9ba996953 Fix left/right aligment of images with caption (#4261) 2026-05-18 21:32:22 +02:00
Brett Jephson 47c8ccba6f Fix: clipping of FA icons (#4257) 2026-05-18 15:09:41 +01:00
conico974 e9842feeef Bump Next.js to version 16.2.6 and fix oom on dev (#4258) 2026-05-18 11:12:47 +02:00
Greg Bergé f4b4fe15c1 Add missing translations (#4254) 2026-05-16 07:16:49 +02:00
Greg Bergé 06b9fd542a Do not add target blank on mailto (#4256) 2026-05-15 20:43:39 +02:00
Taran Vohra 76077f9f6d Fix publish workflow (#4245) 2026-05-15 10:51:43 +02:00
Greg Bergé 7b65f7c0d5 Update iframely embed URL (#4253) 2026-05-14 19:04:34 +02:00
Greg Bergé 37a9453917 Allow to copy in embed (#4251) 2026-05-13 04:49:14 +00:00
Viktor Renkema d994875bea Support code block // [!code -- or ++] notation (#4248) 2026-05-12 15:39:21 +02:00
Claire Chabas a9b5521f3a Fix anchor URL resolution (#4249) 2026-05-11 16:02:20 +02:00
Greg Bergé 91fadb08ed Fix turbo burning Mac CPU (#4247) 2026-05-11 11:36:23 +00:00
Samy Pessé b55605f25e Version Packages (#4199)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-05-08 13:00:03 +00:00
Addison 6ba5e54d19 add isComposing to search (#4244) 2026-05-08 18:47:20 +09:00
Samy Pessé 56aeb19e49 Fix events tracked from the middleware missing some properties (#4242) 2026-05-07 23:15:34 +02:00
conico974 ebab8188f6 Bump @opennextjs/aws and @opennextjs/cloudflare versions (#4243) 2026-05-07 17:01:05 +02:00
Zeno Kapitein 8064c95267 Fix publish workflow npm upgrade in Bun repo (#4239) 2026-05-07 09:36:14 +02:00
conico974 2cef2e02c8 Bump Next.js version to 16.2.5 (#4241) 2026-05-06 22:02:10 +02:00
conico974 8bfced2e0d Add negotiator for markdown acceptance in middleware (#4238)
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 14:59:38 +02:00
conico974 61ee4e3637 Add tests for markdown serving based on user agents (#4227) 2026-05-06 13:52:09 +02:00
Greg Bergé 927aa89213 Add "Go to home" button on 404 page (#4236) 2026-05-05 13:12:12 +00:00
Greg Bergé f29d1e2f16 Round images when site customization border style is rounded (#4237) 2026-05-05 14:50:24 +02:00
conico974 3a9efb8aed bust revision page markdown (#4235) 2026-05-05 14:39:34 +02:00
conico974 f71ab8426c Bust page document data cache (#4234) 2026-05-04 16:15:27 +02:00
conico974 a3d1558f99 Disable regional cache for debugging purposes (#4233) 2026-05-04 14:03:29 +02:00
conico974 ca53869e2d Reduce cache TTL for regional cache and log cache debug (#4232) 2026-05-04 13:32:36 +02:00
conico974 33598e665b Add logging for cache retrieval in GitbookIncrementalCache (#4231) 2026-05-04 11:47:47 +02:00
Samy Pessé 9045ba5248 Remove progressive rollout for markdown stable links (#4230) 2026-05-01 21:56:28 +02:00
Rodrigo Castro 7061fc2223 Fix hidden pages in sitemap exports (#4216) 2026-04-30 14:04:37 +01:00
Samy Pessé 0c9e3952b7 Increase rollout of markdown stable links to 50% (#4228) 2026-04-30 11:37:37 +02:00
Samy Pessé 4b78672135 Fix links to other site-spaces / sections in llms.txt (#4222) 2026-04-30 08:43:32 +02:00
conico974 10289e4881 Revert search worker changes and add error handling (#4226)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:59:05 +02:00
conico974 8b6a6df1d6 Move search into a web worker (#4223)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:42:44 +02:00
235 changed files with 11315 additions and 2958 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Match keyboard shortcuts by the logical character typed instead of the physical key position, so that ⌘-C no longer opens the Assistant on the Dvorak layout (and other non-QWERTY layouts).
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Remove the external link button from the link tooltip hover card, as its arrow read as clickable even though the preview card isn't interactive.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Support variant selector in hidden section
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/icons": patch
---
Icon clipping fix
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
+5
View File
@@ -0,0 +1,5 @@
---
"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
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix embeddable search links
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "🚀 Dev server",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "bun install && bun dev"],
"port": 3000,
"autoPort": false
}
]
}
+4
View File
@@ -15,6 +15,10 @@ 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
+62 -44
View File
@@ -6,8 +6,8 @@
"name": "gitbook",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.6",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.18",
"vercel": "50.37.3",
},
},
@@ -48,7 +48,7 @@
},
"packages/embed": {
"name": "@gitbook/embed",
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -74,7 +74,7 @@
},
"packages/expr": {
"name": "@gitbook/expr",
"version": "1.2.5",
"version": "1.3.0",
"dependencies": {
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
@@ -109,7 +109,7 @@
},
"packages/gitbook": {
"name": "gitbook",
"version": "0.25.1",
"version": "0.27.0",
"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": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -164,15 +164,18 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "^16.2.3",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"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",
@@ -186,6 +189,7 @@
"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",
@@ -228,7 +232,7 @@
},
"packages/icons": {
"name": "@gitbook/icons",
"version": "0.4.6",
"version": "0.5.0",
"bin": {
"gitbook-icons": "./bin/gitbook-icons.js",
},
@@ -266,7 +270,7 @@
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.15",
"version": "0.7.16",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:*",
@@ -308,7 +312,7 @@
},
"packages/react-openapi": {
"name": "@gitbook/react-openapi",
"version": "1.5.12",
"version": "1.5.13",
"dependencies": {
"@gitbook/expr": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
@@ -343,7 +347,7 @@
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch",
"next@16.2.6": "patches/next@16.2.6.patch",
},
"overrides": {
"@codemirror/state": "6.4.1",
@@ -356,7 +360,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.179.0",
"@gitbook/api": "0.184.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -552,21 +556,21 @@
"@bytecodealliance/preview2-shim": ["@bytecodealliance/preview2-shim@0.17.6", "", {}, "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw=="],
"@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/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/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/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/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
"@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/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/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/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/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.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-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-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-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-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
@@ -752,7 +756,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.179.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DrD/Pdfkdv6WWQb1nE0o91O4+gzIhRh+v8l9fhL5Wq57yt0Tk0kTCUycVpI1+H9qUt39bopN4O+KTns4mR2eLQ=="],
"@gitbook/api": ["@gitbook/api@0.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -916,23 +920,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.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="],
@@ -968,9 +972,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@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/aws": ["@opennextjs/aws@4.0.1", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.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=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.8", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.0.1", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.16 <16 || >=16.2.5", "wrangler": "^4.86.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -1586,17 +1590,17 @@
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.6", "", {}, "sha512-tPOhmDhIUcDjvpDDYyiUdssP84Eqm7n5KxJe5J3/g+s6xoDIPAf+SIn06dhw7VkhxIvLOnhDDrX7tsqMHNEhDg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
"@tusbar/cache-control": ["@tusbar/cache-control@1.0.2", "", {}, "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A=="],
@@ -1970,6 +1974,8 @@
"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=="],
@@ -2532,6 +2538,8 @@
"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=="],
@@ -2854,7 +2862,7 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"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": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2922,6 +2930,8 @@
"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=="],
@@ -2994,7 +3004,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.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"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-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=="],
@@ -3308,6 +3318,10 @@
"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=="],
@@ -3342,7 +3356,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.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=="],
"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=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
@@ -3554,10 +3568,6 @@
"@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=="],
@@ -4022,6 +4032,8 @@
"@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=="],
@@ -4594,6 +4606,8 @@
"@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=="],
@@ -4602,6 +4616,8 @@
"@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=="],
@@ -4774,6 +4790,8 @@
"@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.30.0",
"turbo": "^2.9.6",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.18",
"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.179.0",
"@gitbook/api": "0.184.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.3": "patches/next@16.2.3.patch"
"next@16.2.6": "patches/next@16.2.6.patch"
}
}
+8
View File
@@ -1,5 +1,13 @@
# @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.0",
"version": "0.5.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -44,6 +44,16 @@ 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,6 +76,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
src={frameURL}
width="100%"
height="100%"
allow="clipboard-write"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
+1
View File
@@ -101,6 +101,7 @@ 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,5 +1,11 @@
# @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.2.5",
"version": "1.3.0",
"type": "module",
"exports": {
".": {
@@ -195,6 +195,49 @@ 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([
{
+23 -1
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 } = evalESTreeExpr;
const { evaluate, variables } = evalESTreeExpr;
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
@@ -162,6 +162,28 @@ 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,6 +41,16 @@ 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.
+64
View File
@@ -1,5 +1,69 @@
# 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
+106 -36
View File
@@ -31,12 +31,24 @@ import {
headerLinks,
runTestCases,
setTimeToMorning,
waitForAIChatResponse,
waitForCookiesDialog,
waitForCoverImages,
waitForNotFound,
} from './util';
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.`;
// 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 overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
@@ -44,21 +56,45 @@ 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 = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
// The user's prompt varies in length; pin it to a fixed string.
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((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';
});
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
// 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';
});
// 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[] = [
@@ -167,12 +203,13 @@ 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();
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);
// 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();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
{
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
@@ -186,9 +223,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',
@@ -202,9 +239,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)',
@@ -219,9 +256,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)',
@@ -237,12 +274,13 @@ 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();
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);
// 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();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
];
@@ -1056,8 +1094,8 @@ const testCases: TestsCase[] = [
url: 'blocks/annotations',
run: async (page) => {
await waitForCookiesDialog(page);
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
await page.waitForSelector('[data-annotation]');
await page.click('[data-annotation]');
},
},
{
@@ -1323,6 +1361,7 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
@@ -2221,9 +2260,13 @@ 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',
@@ -2257,8 +2300,15 @@ const testCases: TestsCase[] = [
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
AI_PROMPT
);
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the full response to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// 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',
@@ -2284,8 +2334,13 @@ 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',
@@ -2367,8 +2422,15 @@ const testCases: TestsCase[] = [
await actions.nth(3).click();
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
await page.locator('#gitbook-widget-button').click();
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the response posted above to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// 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',
@@ -2412,8 +2474,16 @@ const testCases: TestsCase[] = [
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
});
await iframe.owner().evaluate(overrideAIResponse);
// The turn settles (aria-busy clears) once the stream pauses on the
// confirmation control; wait for that before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
],
},
+63 -5
View File
@@ -13,6 +13,7 @@ import {
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationPageActionType,
CustomizationSearchStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -21,7 +22,14 @@ import {
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -39,6 +47,16 @@ 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.
*/
@@ -159,6 +177,28 @@ 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)
@@ -253,6 +293,9 @@ 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);
},
});
}
@@ -372,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
@@ -396,11 +444,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
type IconURLStates = Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
>;
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
iconStatesWindow.__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -445,7 +495,15 @@ export async function waitForIcons(page: Page) {
const maskImage = icon.querySelector('[data-testid="mask-image"]');
if (!maskImage) {
throw new Error('No mask-image element');
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');
}
const url = maskImage.getAttribute('href');
@@ -5,7 +5,8 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
//TODO: bump it again once I figured out the race condition
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,12 +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,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
//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,
});
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.25.1",
"version": "0.27.0",
"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": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@opennextjs/aws": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@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,15 +56,18 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "^16.2.3",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"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",
@@ -78,6 +81,7 @@
"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",
+10
View File
@@ -18,6 +18,16 @@ export default defineConfig({
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
launchOptions: {
args: [
// Disable subpixel (LCD) text so glyphs always render with
// grayscale antialiasing — removes the red/blue edge fringing
// that varies between macOS (local) and Linux (CI) runs.
'--disable-lcd-text',
// Disable font hinting so glyph rasterization is platform-independent.
'--font-render-hinting=none',
],
},
},
},
],
@@ -158,7 +158,7 @@ function transformSitePageResult(args: {
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(pageItem.path);
let sectionHref = linker.toPathInSpace(section.path);
if (spaceURL) {
if (asEmbeddable) {
@@ -0,0 +1,89 @@
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é'));
});
});
@@ -0,0 +1,76 @@
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;
}
@@ -0,0 +1,78 @@
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);
});
});
@@ -0,0 +1,78 @@
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
}
}
function DescriptionForPageContentToolCall(props: {
async function DescriptionForPageContentToolCall(props: {
toolCall: AIToolCallGetPageContent;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -101,13 +101,13 @@ function DescriptionForPageContentToolCall(props: {
);
}
function DescriptionForMCPToolCall(props: {
async function DescriptionForMCPToolCall(props: {
toolCall: AIToolCallMCP;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
);
}
function DescriptionForGetPagesToolCall(props: {
async function DescriptionForGetPagesToolCall(props: {
toolCall: AIToolCallGetPages;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -1,2 +1,3 @@
export * from './types';
export * from './chat';
export * from './navigate';
@@ -0,0 +1,48 @@
'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 };
});
}
+4 -2
View File
@@ -2,7 +2,9 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
export function getTools(
builtInTools: GitBookIntegrationTool[] = []
): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
return [...getControlTools(), ...builtInTools, ...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>;
}
function useAIConfig(): AIConfig {
export 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.loading ? 'thinking' : 'default'}
state={chat.responding ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
/>
+162 -21
View File
@@ -2,6 +2,7 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
@@ -15,16 +16,20 @@ 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;
};
@@ -81,7 +86,23 @@ export type AIChatState = {
control: AnyAIControl | null;
/**
* If true, the session is in progress.
* 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.
*/
loading: boolean;
@@ -91,13 +112,19 @@ 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: 'close' }
| { type: 'focus' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
@@ -115,6 +142,14 @@ 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,
@@ -133,9 +168,11 @@ const globalState = zustand.create<AIChatState>(() => {
query: null,
followUpSuggestions: [],
control: null,
responding: false,
loading: false,
error: false,
initialQuery: null,
references: [],
};
});
@@ -170,8 +207,13 @@ 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());
@@ -211,6 +253,8 @@ 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;
}) => {
@@ -219,6 +263,7 @@ export function AIChatProvider(props: {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
@@ -232,9 +277,19 @@ 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();
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
@@ -270,7 +325,7 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools();
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
@@ -294,8 +349,8 @@ export function AIChatProvider(props: {
for await (const data of stream) {
if (!data) continue;
if (input.message && globalState.getState().query !== input.message) {
// Chat was cleared, stop processing the stream
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
@@ -306,9 +361,9 @@ export function AIChatProvider(props: {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not loading when the response is finished
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
loading: false,
responding: false,
error: false,
}));
break;
@@ -426,23 +481,40 @@ export function AIChatProvider(props: {
}));
}
// Execute the tool call if it doesn't require confirmation
// 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).
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);
globalState.setState((state) => ({
...state,
loading: false,
error: true,
}));
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
@@ -451,20 +523,32 @@ 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 } = globalState.getState();
const { query, messages, control, references, responding } = 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,
@@ -475,8 +559,9 @@ export function AIChatProvider(props: {
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message) {
if (query === input.message && references.length === 0) {
// 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,
@@ -496,25 +581,28 @@ export function AIChatProvider(props: {
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
loading: true,
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: input.message });
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, trackEvent, streamResponse, language]
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
@@ -523,6 +611,7 @@ export function AIChatProvider(props: {
responseId: null,
error: false,
initialQuery: null,
references: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
@@ -534,6 +623,44 @@ 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,
@@ -559,9 +686,23 @@ export function AIChatProvider(props: {
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
on: onEvent,
};
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
@@ -591,7 +732,7 @@ export function getAIChatStatus(chat: AIChatState): AIChatStatus {
return 'confirm';
}
if (chat.loading) {
if (chat.responding) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
@@ -0,0 +1,117 @@
'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,12 +83,13 @@ 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 shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameHeader className="not-embed:px-4">
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
@@ -225,7 +226,7 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
className="min-h-[20%] max-w-full 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 }}
@@ -280,8 +281,8 @@ export function AIChatBody(props: {
{chat.control ? <AIChatControl control={chat.control} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
responding={chat.responding}
disabled={chat.responding || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
@@ -1,6 +1,6 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { t, tString } from '@/intl/translate';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
@@ -38,6 +38,7 @@ export function AIChatButton(props: {
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
@@ -3,27 +3,29 @@ import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatState } from '../AI/useAIChat';
import { useAIChatController, 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
*/
loading: boolean;
responding: boolean;
onSubmit: (value: string) => void;
}) {
const { onSubmit, disabled, loading } = props;
const { onSubmit, disabled, responding } = props;
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (chat.opened && !disabled && !loading) {
if (chat.opened && !disabled && !responding) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -32,7 +34,14 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, loading, chat.opened]);
}, [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]);
useHotkeys(
'mod+i',
@@ -42,6 +51,9 @@ export function AIChatInput(props: {
},
{
enableOnFormTags: true,
// Match the logical character so Dvorak ⌘-C (physical "I" key) copies
// instead of focusing the Assistant input. RND-11340.
ignoreEventWhen: (e) => e.key.toLowerCase() !== 'i',
}
);
@@ -66,16 +78,23 @@ export function AIChatInput(props: {
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
!disabled && !responding
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading || chat.control !== null}
aria-busy={loading}
disabled={disabled || responding || chat.control !== null}
aria-busy={responding}
ref={inputRef}
header={
<AIChatReferenceChips
references={chat.references}
onRemove={chatController.removeReference}
disabled={responding || disabled}
/>
}
trailing={
<HoverCardRoot openDelay={500}>
<HoverCard
@@ -2,6 +2,7 @@ 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,
@@ -11,6 +12,7 @@ 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';
@@ -20,7 +22,7 @@ export function AIChatMessages(props: {
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
const showLoadingShim = chat.responding && 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 };
@@ -49,6 +51,29 @@ 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}
@@ -61,52 +86,60 @@ export function AIChatMessages(props: {
)}
style={{ animationDelay: '.2s' }}
>
{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'
}
{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"
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
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'
: ''
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{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">
{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">
{toolCount > 0
? t(
language,
@@ -120,50 +153,58 @@ export function AIChatMessages(props: {
)
)
: t(language, 'ai_chat_explored')}
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
</span>
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
{message.content}
{assistantItems.map(({ message, originalIndex }) => (
<Fragment key={originalIndex}>{message.content}</Fragment>
))}
{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 ? (
<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 ? (
<>
{!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}
{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"
/>
</>
) : null}
</Collapsible>
);
})}
) : null}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</Collapsible>
) : null}
</div>
);
});
@@ -0,0 +1,120 @@
'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,5 +1,7 @@
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: {
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[];
}) {
const language = useLanguage();
const { chatController, suggestions: _suggestions } = props;
const { siteSpaceId } = useCurrentContent();
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
const { chatController, suggestions: configuredSuggestions } = props;
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'),
];
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;
}, []);
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-subtle')}>
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint')}>
<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-testid="annotation-button"
data-annotation=""
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,6 +3,7 @@ import type {
DocumentBlockEmbed,
DocumentBlockFile,
DocumentBlockImage,
DocumentBlockImages,
JSONDocument,
} from '@gitbook/api';
@@ -12,6 +13,14 @@ 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.
*/
@@ -22,6 +31,7 @@ export function Caption(
style?: ClassValue;
fit?: boolean;
wrapperStyle?: ClassValue;
align?: CaptionAlign;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
withFrame?: boolean;
@@ -49,6 +59,7 @@ export function Caption(
withFrame && 'p-2',
],
style,
align = 'center',
} = props;
const caption = getNodeFragmentByName(block, 'caption');
@@ -64,7 +75,7 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
<figcaption
className={tcls(
'text-xs',
@@ -0,0 +1,57 @@
'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,6 +141,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
style={style}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
isPrint={mode === 'print'}
/>
);
@@ -81,7 +81,14 @@ export async function CodeBlock(
const theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
return (
<CodeBlockRenderer
block={block}
style={style}
theme={theme}
isPrint={context.mode === 'print'}
/>
);
}
const variables = context.contentContext
@@ -6,6 +6,7 @@ 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';
@@ -13,6 +14,11 @@ 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;
};
/**
@@ -22,7 +28,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -39,15 +45,24 @@ 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
@@ -63,11 +78,18 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
) : null}
</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"
/>
<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>
<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',
@@ -82,7 +104,6 @@ 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',
@@ -114,27 +135,30 @@ 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.highlighted && 'highlighted')}
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
>
{withLineNumbers && (
<span
className="highlight-line-number"
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
/>
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}
>
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -6,6 +6,8 @@ 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.
@@ -31,12 +33,12 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
}, [copied]);
const onClick = () => {
const element = document.getElementById(codeId);
if (!element) {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
return;
}
navigator.clipboard.writeText(getCodeText(element));
navigator.clipboard.writeText(codeText);
setCopied(true);
};
@@ -52,31 +54,3 @@ 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,17 +1,22 @@
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
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.
*/
@@ -19,11 +24,28 @@ 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();
@@ -96,27 +118,114 @@ 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} />;
}
return (
// 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 = (
<div
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
className={tcls(
'group/mermaid relative',
isPresent ? 'flex h-full w-full flex-col' : null
)}
>
<div
ref={wrapperRef}
className={
className={tcls(
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
: 'cursor-grab overflow-hidden active:cursor-grabbing',
isPresent && !isLoading ? 'flex-1' : null
)}
>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
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
)}
/>
</div>
{isLoading ? (
@@ -124,9 +233,53 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
{!isLoading && panZoom ? (
<MermaidPanZoomControls
panZoom={panZoom}
isFullscreen={isPresent}
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
/>
) : 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,50 +3,55 @@
import type { PanzoomObject } from '@panzoom/panzoom';
import { Button } from '@/components/primitives';
const PAN_STEP = 50;
import { tcls } from '@/lib/tailwind';
/**
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
* 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.
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
export function MermaidPanZoomControls(props: {
panZoom: PanzoomObject;
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const { panZoom, isFullscreen, onToggleFullscreen } = 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="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 />
<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()} />
<Button
{...btnProps}
icon="chevron-up"
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
icon="arrows-to-dot"
label="Reset view"
onClick={() => panZoom.reset()}
/>
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
{/* Row 2: pan left, reset, pan right */}
<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="chevron-left"
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
icon={isFullscreen ? 'compress' : 'expand'}
label={isFullscreen ? 'Exit full page' : 'View in full page'}
onClick={onToggleFullscreen}
/>
<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,6 +5,7 @@ import type {
} from '@gitbook/api';
import { useId } from 'react';
import type { DocumentContext } from '../DocumentView';
import { CodeBlock } from './CodeBlock';
import { convertCodeStringToBlock } from './utils';
@@ -15,10 +16,11 @@ 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, themeKey, themes } = props;
const { code, syntax, mode = 'default', themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -33,7 +35,7 @@ export function PlainCodeBlock(props: {
<CodeBlock
document={document}
context={{
mode: 'default',
mode,
}}
themeKey={themeKey}
themes={themes}
@@ -1,7 +1,13 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockCode } from '@gitbook/api';
import { type RenderedInline, getInlines, highlight } from './highlight';
import {
type HighlightLine,
type HighlightToken,
type RenderedInline,
getInlines,
highlight,
} from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
@@ -690,6 +696,138 @@ 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,11 +34,114 @@ 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 }
@@ -136,6 +239,9 @@ 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) {
@@ -152,9 +258,14 @@ export async function highlight(
currentIndex += 1; // for the \n
const finalTokens = notation
? truncateHighlightTokens(result, notation.markerStart)
: result;
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
diff: notation?.diff ?? null,
tokens: finalTokens,
};
}),
};
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
import { getNodeText } from '@/lib/document';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
import {
type HighlightTheme,
type HighlightToken,
type RenderedInline,
getHighlightTokensText,
parseDiffNotation,
truncateHighlightTokens,
} from './highlight';
/**
* Parse a code block without highlighting it.
@@ -62,9 +69,14 @@ 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),
tokens,
diff: notation?.diff ?? null,
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
};
}),
};
@@ -65,3 +65,40 @@ 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;
}
@@ -18,7 +18,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
return null;
}
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script' });
ReactDOM.preload('https://iframely.net/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://cdn.iframe.ly/embed.js" />
<Script strategy="lazyOnload" src="https://iframely.net/embed.js" />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
@@ -1,6 +1,8 @@
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';
@@ -10,7 +12,7 @@ import { Blocks } from '../Blocks';
import { Inlines } from '../Inlines';
import { Details } from './Details';
export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
export async function Expandable(props: BlockProps<DocumentBlockExpandable>) {
const { block, style, ancestorBlocks, document, context } = props;
const title = getNodeFragmentByType(block, 'expandable-title');
@@ -26,6 +28,10 @@ export 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}
@@ -68,7 +74,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
/>
<a
href={`#${id}`}
aria-label="Direct link to heading"
aria-label={tString(language, 'direct_link_to_heading')}
className={tcls(
'absolute',
'top-2',
@@ -29,7 +29,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return null;
}
const language = getSpaceLanguage(context.contentContext);
const language = await 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 = 'Direct link to block' } = props;
const { id, block, className, iconClassName, label } = props;
const textStyle = getBlockTextStyle(block);
return (
<div
@@ -1,5 +1,7 @@
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';
@@ -8,7 +10,7 @@ import { Inlines } from './Inlines';
import { getBlockTextStyle } from './spacing';
import { getTextAlignment } from './utils';
export function Heading(props: BlockProps<DocumentBlockHeading>) {
export async function Heading(props: BlockProps<DocumentBlockHeading>) {
const { block, style, context, ...rest } = props;
const textStyle = getBlockTextStyle(block);
@@ -18,6 +20,10 @@ export 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}
@@ -39,11 +45,11 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
id={id}
block={block}
className={tcls(
'-ml-6 pr-2',
'-ml-6 self-center pr-2',
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')}
label="Direct link to heading"
label={tString(language, '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 { languages } from '@/intl/translations';
import { defaultLanguage } from '@/intl/translations';
import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing';
export function Hint({
export async function Hint({
block,
style,
ancestorBlocks,
@@ -27,8 +27,8 @@ export function Hint({
const hasHeading = isHeadingBlock(firstNode);
const language = contextProps.context.contentContext
? getSpaceLanguage(contextProps.context.contentContext)
: languages.en;
? await getSpaceLanguage(contextProps.context.contentContext)
: defaultLanguage;
const label = tString(language, `hint_${block.data.style}`);
@@ -92,6 +92,11 @@ export function Hint({
'empty:p-0',
'-row-end-1',
'-col-end-1',
// Allow the grid track to shrink below its content's min-content size so wide
// nested blocks (e.g. code blocks, tables) scroll internally instead of
// overflowing the hint. Without this, the `1fr` column keeps its default
// `min-width: auto` and grows past the hint's width.
'min-w-0',
'space-y-3',
'[&_.hint]:border',
'[&_pre]:border',
@@ -5,7 +5,7 @@ import { resolveContentRefInDocument } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
import { Caption } from './Caption';
import { Caption, type CaptionAlign } from './Caption';
import type { DocumentContext } from './DocumentView';
export function Images(props: BlockProps<DocumentBlockImages>) {
@@ -52,6 +52,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
context={context}
isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
align={align}
/>
))}
</div>
@@ -80,8 +81,9 @@ async function ImageBlock(props: {
siblings: number;
isEstimatedOffscreen: boolean;
withFrame?: boolean;
align?: CaptionAlign;
}) {
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const { document, block, context, isEstimatedOffscreen, withFrame, align } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext
@@ -98,7 +100,7 @@ async function ImageBlock(props: {
return (
<div className={tcls('relative', 'overflow-hidden')}>
<Caption {...props} fit>
<Caption {...props} fit align={align}>
<Image
alt={block.data.alt ?? ''}
sizes={imageBlockSizes}
@@ -122,7 +124,11 @@ async function ImageBlock(props: {
width: getImageDimension(block.data.width, undefined),
height: getImageDimension(block.data.height, 'auto'),
}}
style={withFrame ? 'rounded-xl' : undefined}
style={
withFrame
? 'rounded-xl'
: 'circular-corners:rounded-2xl rounded-corners:rounded-sm'
}
/>
</Caption>
</div>
@@ -51,8 +51,8 @@ export function InlineActionButton(
className: 'text-[1em]',
}}
maxLength={action === 'ask' ? 2048 : 512}
disabled={action === 'ask' && chatState.loading}
aria-busy={action === 'ask' && chatState.loading}
disabled={action === 'ask' && chatState.responding}
aria-busy={action === 'ask' && chatState.responding}
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, languages } from '@/intl/translations';
import { type TranslationLanguage, defaultLanguage } from '@/intl/translations';
import {
type ResolvedContentRef,
resolveContentRefFallback,
@@ -60,7 +60,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
);
if (context.withLinkPreviews) {
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
@@ -151,10 +151,7 @@ function InlineLinkTooltipWrapper(props: {
<InlineLinkTooltip
breadcrumbs={breadcrumbs}
isExternal={isExternal}
isSamePage={isSamePage}
openInNewTabLabel={tString(language, 'open_in_new_tab')}
target={{
href: resolved.href,
text: resolved.text,
subText: resolved.subText,
icon: resolved.icon,
@@ -2,91 +2,69 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
export function InlineLinkTooltip(props: {
isSamePage: boolean;
isExternal: boolean;
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
const { isExternal, target, breadcrumbs, children } = props;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">{target.icon}</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
@@ -0,0 +1,28 @@
'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,6 +5,8 @@ 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';
@@ -12,8 +14,12 @@ import { renderIntegrationUi } from './server-actions';
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
const { block, context, style } = props;
if (!context.contentContext?.space) {
throw new Error('integration block requires a content.spaceId');
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');
}
const initialInput: RenderIntegrationUI = {
@@ -22,16 +28,28 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
action: block.data.action,
context: {
type: 'document',
spaceId: context.contentContext?.space.id,
// 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,
editable: false,
theme: 'light', // TODO: how to handle this without moving rendering to the client side?
},
};
const initialResponse = await fetchSafeIntegrationUI(context.contentContext, {
integrationName: block.data.integration,
request: initialInput,
});
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,
}
);
if (initialResponse.error) {
if (initialResponse.error.code === 404) {
@@ -52,9 +70,15 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return null;
}
const ContentKitComponent = shouldRenderIntegrationBlockWithAdaptiveVisitorContext(
initialOutput
)
? ContentKitWithAdaptiveVisitorContext
: ContentKit;
return (
<div className={tcls(style)}>
<ContentKit
<ContentKitComponent
renderContext={{
integrationName: block.data.integration,
}}
@@ -64,7 +88,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
render={renderIntegrationUi}
>
<ContentKitOutput output={initialOutput} context={contentKitServerContext} />
</ContentKit>
</ContentKitComponent>
</div>
);
}
@@ -0,0 +1,67 @@
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 { languages } from '@/intl/translations';
import { defaultLanguage } 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 function NotFoundRefHoverCard(
export async function NotFoundRefHoverCard(
props: DocumentContextProps & {
children: React.ReactNode;
}
@@ -16,7 +16,7 @@ export function NotFoundRefHoverCard(
context: { contentContext },
children,
} = props;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
const language = contentContext ? await getSpaceLanguage(contentContext) : defaultLanguage;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
@@ -54,6 +54,7 @@ export function getOpenAPIContext(args: {
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
mode={props.context.mode}
themeKey="openapi"
themes={
context && 'customization' in context
@@ -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 } from '@gitbook/icons';
import { Icon, type IconName } from '@gitbook/icons';
import { useRouter } from 'next/navigation';
interface TabsState {
@@ -56,6 +56,7 @@ const TITLES_MAX = 5;
export interface TabsItem {
id: string;
title: string;
icon?: IconName;
body: React.ReactNode;
}
@@ -319,6 +320,7 @@ function TabsDropdownMenu(props: {
key={tab.id}
onClick={() => onSelect(tab.id)}
active={tab.id === activeTabId}
leadingIcon={tab.icon}
>
{tab.title}
</DropdownMenuItem>
@@ -347,7 +349,8 @@ const TabItem = memo(function TabItem(props: {
id={getTabButtonId(tab.id)}
onClick={() => onSelect(tab.id)}
>
{tab.title}
{tab.icon && <Icon icon={tab.icon} className="size-4 shrink-0" />}
<span className="min-w-0 truncate">{tab.title}</span>
</TabButton>
);
});
@@ -411,7 +414,7 @@ function TabButton(
{...rest}
type="button"
className={tcls(
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
'relative inline-flex max-w-full items-center gap-1.5 px-3.5 py-2 font-medium text-sm transition-[color]',
props.className
)}
/>
@@ -1,4 +1,6 @@
import type { DocumentBlockTabs } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { validateIconName } from '@gitbook/icons/icons';
import { tcls } from '@/lib/tailwind';
@@ -20,9 +22,13 @@ 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,6 +11,7 @@ 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;
@@ -43,7 +44,10 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
const resolvedTags = resolveBlockTags(block.data.tags, revisionTags);
return (
<div className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}>
<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(
// 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
@@ -71,7 +75,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
ancestorBlocks={[...ancestorBlocks, block]}
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
/>
</div>
</FilteredUpdate>
);
}
@@ -0,0 +1,231 @@
'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">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<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 function Header(props: {
export async function Header(props: {
context: GitBookSiteContext;
withTopHeader?: boolean;
variants: {
@@ -28,6 +28,7 @@ export 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 &&
@@ -180,7 +181,7 @@ export function Header(props: {
{customization.header.links.length > 0 ||
headerSocialAccounts.length > 0 ? (
<HeaderLinkMore
label={t(getSpaceLanguage(context), 'more')}
label={t(language, 'more')}
links={customization.header.links}
socialAccounts={headerSocialAccounts}
context={context}
@@ -11,14 +11,6 @@ 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.
*/
@@ -26,10 +18,6 @@ const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
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)
@@ -71,7 +59,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
canUseCompactHeaderLogo ? 'max-h-8' : 'max-h-10',
'max-h-8',
'h-full',
'w-full',
'object-contain',
@@ -17,7 +17,7 @@ import { getSession } from './sessions';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
const VISITOR_UPDATED_AT_STORAGE_KEY = '__session_updated';
const VISITOR_STORAGE_KEY = '__session_updated';
/**
* Visitor state when the visitor is not a signed-in GitBook user.
@@ -40,31 +40,38 @@ type VisitorUserResponse = Pick<AnyVisitorResponse, 'deviceId'> & {
};
/**
* Time while a cookie is considered valid before revalidating it.
* Time while a stored visitor is considered valid before revalidating it.
*/
const STALE_TIME_MS = 60 * 60 * 1000;
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 (value.deviceId !== deviceId) {
clearVisitorUpdatedAt();
return null;
}
return value.updatedAt;
interface StoredVisitor {
visitor: VisitorResponse;
updatedAt: number;
}
function clearVisitorUpdatedAt() {
removeLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY);
interface StoredVisitorRaw {
visitor?: unknown;
deviceId?: unknown;
updatedAt?: unknown;
}
function setVisitorUpdatedAt(deviceId: string, updatedAt: number) {
setLocalStorageItem(VISITOR_UPDATED_AT_STORAGE_KEY, { deviceId, updatedAt });
function getStoredVisitor(): StoredVisitor | null {
const value = getLocalStorageItem<StoredVisitorRaw | null>(VISITOR_STORAGE_KEY, null);
if (!value || typeof value.updatedAt !== 'number') {
return null;
}
if (isVisitor(value.visitor)) {
return { visitor: value.visitor, updatedAt: value.updatedAt };
}
// 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;
}
function setStoredVisitor(visitor: VisitorResponse, updatedAt: number) {
setLocalStorageItem(VISITOR_STORAGE_KEY, { visitor, updatedAt });
}
export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse;
@@ -79,10 +86,6 @@ 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;
@@ -171,13 +174,21 @@ 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', proposedId);
url.searchParams.set('proposed', stableId);
try {
const resp = await fetch(url, {
@@ -198,33 +209,26 @@ function getGlobalVisitor({
throw new Error(`Unexpected __session format: ${JSON.stringify(visitor)}`);
}
// 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());
setStoredVisitor(visitor, Date.now());
return visitor;
} catch (error) {
clearVisitorUpdatedAt();
console.error('Failed to fetch visitor session ID', error);
if (existing) {
return existing;
}
return { deviceId: proposedId };
return existing ?? stored?.visitor ?? { deviceId: stableId };
}
};
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 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,
};
}
return { visitor: null, pendingVisitor: fetchGlobalVisitor() };
@@ -57,7 +57,7 @@ export async function PDFPage(props: {
const customization =
'customization' in baseContext ? baseContext.customization : defaultCustomization();
const language = getSpaceLanguage(baseContext);
const language = await getSpaceLanguage(baseContext);
// Compute the pages to render
const { pages, total } = selectPages(baseContext.revision.pages, pdfParams);
@@ -1,6 +1,6 @@
'use client';
import { useAIChatState } from '@/components/AI';
import { useAIChatController, useAIChatState } from '@/components/AI';
import type { Assistant } from '@/components/AI';
import { Button } from '@/components/primitives/Button';
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
@@ -15,12 +15,28 @@ 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 }) {
const { assistant, type } = props;
export function ActionOpenAssistant(props: {
assistant: Assistant;
type: PageActionType;
page?: PageActionAssistantContext;
}) {
const { assistant, type, page } = props;
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
return (
@@ -30,8 +46,20 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
label={assistant.label}
shortLabel={tString(language, 'ask')}
description={tString(language, 'ai_chat_ask_about_page', assistant.label)}
disabled={chat.loading}
disabled={chat.responding}
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();
}}
/>
@@ -86,7 +114,7 @@ function useCopiedStore(stateKey: string) {
}
/**
* Cache for the markdown versbion of the page.
* Cache for the markdown version of the page.
*/
const markdownCache = new QuickLRU<string, string>({ maxSize: 10 });
@@ -109,7 +137,8 @@ export function ActionCopyMarkdown(props: {
const fetchMarkdown = async () => {
setLoading(true);
const result = await fetch(markdownPageURL).then((res) => res.text());
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
const result = await fetch(humanURL).then((res) => res.text());
markdownCache.set(markdownPageURL, result);
setLoading(false);
@@ -160,7 +189,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}
href={`${markdownPageURL}?displayAgentInstructions=false`}
/>
);
}
@@ -183,7 +212,6 @@ export function ActionOpenInLLM(props: {
type={type}
icon={provider}
label={tString(language, 'open_in', providerLabel)}
shortLabel={providerLabel}
description={tString(language, 'ai_chat_ask_about_page', providerLabel)}
href={getLLMURL(provider, url, language)}
/>
@@ -435,6 +463,7 @@ function PageActionWrapper(props: {
size="xsmall"
variant="secondary"
label={label ?? shortLabel}
aria-label={shortLabel}
className="bg-tint-base"
onClick={onClick}
href={href}
@@ -3,9 +3,13 @@
import { Button, ButtonGroup } from '@/components/primitives/Button';
import { DropdownMenu, DropdownMenuSeparator } from '@/components/primitives/DropdownMenu';
import { tString, useLanguage } from '@/intl/client';
import type { GitSyncState, SiteCustomizationSettings } from '@gitbook/api';
import React, { useRef } from 'react';
import { useAI } from '../AI';
import type {
CustomizationPageActionType,
GitSyncState,
SiteCustomizationSettings,
} from '@gitbook/api';
import { type ReactNode, useRef } from 'react';
import { type Assistant, useAI } from '../AI';
import { ToggleChevron } from '../primitives';
import {
ActionCopyMCPCommand,
@@ -18,8 +22,35 @@ 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;
@@ -37,22 +68,95 @@ interface PageActionsDropdownProps {
urls: PageActionsDropdownURLs;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
/** The current page, referenced by the assistant when opened. */
page: PageActionAssistantContext;
}
/**
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM).
* 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.
*/
export function PageActionsDropdown(props: PageActionsDropdownProps) {
const ref = useRef<HTMLDivElement>(null);
const language = useLanguage();
const { siteTitle, urls } = props;
const defaultAction = usePageDefaultAction(props);
const dropdownActions = getPageDropdownActions(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);
return defaultAction || dropdownActions.length > 0 ? (
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 ? (
<ButtonGroup ref={ref} className={props.className}>
{defaultAction}
{!defaultAction || dropdownActions.length > 1 ? (
{!defaultAction || menuItemCount > 1 ? (
<DropdownMenu
align="end"
className="!min-w-60 max-w-max"
@@ -75,122 +179,212 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
}
/**
* Return the list of actions to show in the dropdown menu.
* Return the configured ordered list of enabled page actions, or `null` when the API does not
* provide it yet (legacy mode).
*/
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
const { siteTitle, urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
function getConfiguredPageActionItems(
actions: SiteCustomizationSettings['pageActions']
): PageActionType[] | null {
return actions.items ?? null;
}
return [
...assistants.map((assistant) => (
<ActionOpenAssistant
key={assistant.label}
assistant={assistant}
type="dropdown-menu-item"
/>
)),
/**
* 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;
}
});
}
actions.markdown ? (
<React.Fragment key="markdown">
<DropdownMenuSeparator className="first:hidden" />
/**
* 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 [
<ActionCopyMarkdown
isDefaultAction={!assistants.length}
key="copy-markdown"
isDefaultAction={markdownIsDefault}
markdownPageURL={urls.markdown}
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" />
/>,
<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" />,
<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"
/>
</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);
/>,
];
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 [];
}
}
/**
* A default action shown as a quick-access button beside the dropdown menu
* Render the action shown as the quick-access default button for a given action type.
*/
function usePageDefaultAction(props: PageActionsDropdownProps) {
const { urls, actions } = props;
const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction
);
if (urls.rss) {
return <ActionViewAsRSS url={urls.rss} type="button" />;
function renderDefaultActionForType(
type: PageActionType,
params: {
siteTitle: string;
urls: PageActionsDropdownURLs;
assistants: Assistant[];
page: PageActionAssistantContext;
}
): ReactNode {
const { urls, assistants, page } = params;
const assistant = assistants[0];
if (assistant) {
return <ActionOpenAssistant assistant={assistant} type="button" />;
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.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,10 +1,11 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSpaceLanguage, t, tString } 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';
@@ -12,6 +13,7 @@ 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';
@@ -22,17 +24,18 @@ import { ScrollToTopButton } from './ScrollToTopButton';
/**
* Aside listing the headings in the document.
*/
export function PageAside(props: {
export async function PageAside(props: {
page: RevisionPageDocument;
document: JSONDocument | null;
filterableTags: RevisionTag[];
context: GitBookSiteContext;
withHeaderOffset: { sectionsHeader: boolean; topHeader: boolean };
withFullPageCover: boolean;
withPageFeedback: boolean;
}) {
const { page, document, withPageFeedback, context } = props;
const { page, document, filterableTags, withPageFeedback, context } = props;
const { customization, site } = context;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<SideSheet
@@ -103,7 +106,19 @@ export function PageAside(props: {
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
{page.layout.outline ? (
<>
<div className="mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden">
{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'
)}
>
<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')}
@@ -1,6 +1,7 @@
'use client';
import React from 'react';
import { useUpdatesFilter } from '@/components/DocumentView/UpdatesFilter';
import { useScrollActiveId } from '@/components/hooks';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
@@ -19,7 +20,21 @@ const SECTION_INTERSECTING_THRESHOLD = 0.9;
const ACTIVE_ITEM_OFFSET = 100;
export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }) {
const ids = React.useMemo(() => sections.map(({ id }) => id), [sections]);
const { selectedTagSet } = useUpdatesFilter();
const visibleSections = React.useMemo(() => {
if (selectedTagSet.size === 0) {
return sections;
}
return sections.filter((section) => {
if (section.tags === undefined) {
return true;
}
return section.tags.some((tagSlug) => selectedTagSet.has(tagSlug));
});
}, [sections, selectedTagSet]);
const ids = React.useMemo(() => visibleSections.map(({ id }) => id), [visibleSections]);
const enabled = useBodyLoaded();
@@ -46,7 +61,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5"
ref={scrollContainerRef}
>
{sections.map((section) => (
{visibleSections.map((section) => (
<li
key={section.id}
className={tcls(
@@ -4,6 +4,7 @@ import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } f
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
import { getLLMsTxtURL, getPageMarkdownURL } from '@/lib/llms-directive';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -21,7 +22,7 @@ import { PreservePageLayout } from './PreservePageLayout';
const LINK_PREVIEW_MAX_COUNT = 500;
export function PageBody(props: {
export async function PageBody(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
@@ -59,7 +60,7 @@ export function PageBody(props: {
// This happens when: (1) document has full-width blocks, OR (2) page layout is explicitly set to 'wide'
const wideContent = document ? hasFullWidthBlock(document) : false;
const wideLayout = wideContent || page.layout.width === 'wide';
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
const hasVisibleTOCItems =
@@ -88,6 +89,7 @@ export function PageBody(props: {
)}
>
<PreservePageLayout wideLayout={wideLayout} pageHasToc={pageHasToc} />
<LLMsTxtPageDirective context={context} page={page} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -160,3 +162,17 @@ export function PageBody(props: {
</CurrentPageProvider>
);
}
function LLMsTxtPageDirective(props: {
context: GitBookSiteContext;
page: RevisionPageDocument;
}) {
const { context, page } = props;
return (
<div className="sr-only">
For the complete documentation index, see <a href={getLLMsTxtURL(context)}>llms.txt</a>.
This page is also available as <a href={getPageMarkdownURL(context, page)}>Markdown</a>.
</div>
);
}
@@ -20,7 +20,7 @@ export async function PageFooterNavigation(props: {
const { context, page } = props;
const { revision, linker } = context;
const { previous, next } = resolvePrevNextPages(revision.pages, page);
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
const previousHref = previous
? linker.toPathForPage({ pages: revision.pages, page: previous })
: '';

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