Compare commits

...

134 Commits

Author SHA1 Message Date
Nicolas Dorseuil bb46b5b38b Make href prop optional in SubHeaderLinkItem and update StructurePreview to remove default href values 2026-06-24 20:23:30 +02:00
Nicolas Dorseuil 8a9f163d31 Add SiteSectionList to StructurePreviewVariantSelector for improved section display 2026-06-24 20:14:18 +02:00
Nicolas Dorseuil 7c365f9162 Add social account links to StructurePreviewMoreMenu component 2026-06-24 20:11:48 +02:00
Nicolas Dorseuil 10a7095182 Add metadata export to Page component for SEO configuration 2026-06-24 19:56:36 +02:00
conico974 614a50373a Merge branch 'main' into conico/structure-preview 2026-06-24 10:47:51 +02:00
Nolann B. d172684b88 Some small OpenAPI design tweaks (#4337) 2026-06-23 21:20:28 +02:00
Nicolas Dorseuil 7646cb301c Add GITBOOK_APP_URL prop to StructurePreview component and update Page to pass it 2026-06-23 20:44:16 +02:00
Nicolas Dorseuil d2ce6adeb6 Merge remote-tracking branch 'origin/main' into conico/structure-preview 2026-06-23 18:48:26 +02:00
conico974 937c8b8eb2 Remove 'remote' from cache usage in specific functions (#4312) 2026-06-23 17:59:43 +02:00
Zeno Kapitein 36dbdb48cc Support agent goal query param for markdownAsk (#4324)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:47:51 +02:00
Zeno Kapitein 2dc76a2c3c Fix empty grouped section dropdown in embedded sites (#4334)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:47:15 +02:00
Samy Pessé 0edc378c57 Version Packages (#4306)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-23 00:10:23 +02:00
Zeno Kapitein 2eb313194a Remove old PageAction checks (#4333) 2026-06-22 17:12:35 +02:00
Brett Jephson 882dfcda83 Tidy relating to prompt (#4332) 2026-06-22 15:48:58 +02:00
Brett Jephson 3ff88ba22f Adds the prompt block (#4329) 2026-06-22 11:53:25 +01:00
Nolann B. 51bd768042 Make the AI Assistant panel resizable (#4330) 2026-06-20 21:41:59 +02:00
spastorelli daadd91eba Fix NextLink prefetch side effect on unsigned claims persistance (#4325) 2026-06-19 11:39:12 +02:00
Nolann B. f4469b2df3 Support OpenAPI one-operation-per-page mode (#4314) 2026-06-19 10:35:09 +02:00
Nolann B. 91b325ca08 Add AI chat reference button on text selection (#4326) 2026-06-19 10:18:07 +02:00
Nolann B. 1616028426 Align code block action buttons vertically with the block (#4327) 2026-06-19 10:03:37 +02:00
Nolann B. 6c15d8314a Update OpenAPI border radius (#4323) 2026-06-18 23:25:19 +02:00
Nicolas Dorseuil 63e4968987 Enhance StructurePreview message handling by validating event origin against GITBOOK_APP_URL 2026-06-18 16:33:48 +02:00
Zeno Kapitein 6c967c9bcc Add search and filters to table blocks (#4322)
Co-authored-by: utkuufuk <utkuufuk@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:41:40 +02:00
Nicolas Dorseuil 6eb3d3b4e5 Refactor StructurePreview by removing obsolete test files and updating middleware for demo routing 2026-06-17 18:40:08 +02:00
Nicolas Dorseuil 2ba90833a3 fix typescript 2026-06-17 18:15:23 +02:00
Johan Preynat c44d822fe1 Trust cookieless integration content host for WebFrames (#4321)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:08:25 +02:00
Zeno Kapitein b0553d5fc8 Skeletons and layout 2026-06-17 17:18:12 +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
Nicolas Dorseuil a06da70f5f Refactor StructurePreview to handle partial updates and navigation messages 2026-06-17 11:41:06 +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
Nicolas Dorseuil 53ec8b9f29 Enhance StructurePreview navigation by adding sectionId handling and updating state management 2026-06-17 10:30:14 +02:00
Nicolas Dorseuil 6f63c00300 Refactor StructurePreview components and state management to reduce client side data 2026-06-17 10:13:40 +02:00
Nicolas Dorseuil c334ea2748 linting 2026-06-16 17:38:28 +02:00
Nicolas Dorseuil d75cdf1334 small comment 2026-06-16 17:38:12 +02:00
Nicolas Dorseuil cb649c55cb Commit to revert, just to demonstrate what we can do directly in GBO 2026-06-16 17:38:02 +02:00
Nicolas Dorseuil 205c512cb6 Refactor variant categorization in StructurePreview to utilize categorizeVariants function for improved clarity and maintainability 2026-06-16 16:53:32 +02:00
Nicolas Dorseuil 334a31d7fc Refactor TableOfContents styles and components for improved class management and responsiveness 2026-06-16 16:37:56 +02:00
Nicolas Dorseuil f157cfc9a7 Add disableAnimations prop to SiteSectionTabs for improved rendering control 2026-06-16 16:24:34 +02:00
Nolann B. 509389c7e5 Update @gitbook/api to latest version (#4315) 2026-06-16 14:30:10 +02:00
Nicolas Dorseuil 3485c0192b Refactor StructurePreviewLogoImage to utilize Image component for improved logo rendering and responsiveness 2026-06-16 13:40:54 +02:00
Nolann B. 88b94b5d36 Handle circular oneOf with discriminator and allOf in OpenAPI schemas (#4070) 2026-06-16 13:34:17 +02:00
Nicolas Dorseuil 27d9ad6fe4 Refactor Header and StructurePreview components to utilize HeaderLayout for improved structure and consistency in header rendering 2026-06-16 13:31:47 +02:00
Nicolas Dorseuil 96602eb552 Refactor AIChatButton and StructurePreview components to enhance AI assistant integration, improving button rendering and functionality 2026-06-16 13:08:59 +02:00
Nicolas Dorseuil 17d092acdc Refactor Search components to enhance structure and functionality, introducing SearchHeaderInput for improved search input handling 2026-06-16 13:01:25 +02:00
Nicolas Dorseuil 107018f570 Refactor SpacesDropdown and related components for improved structure and functionality 2026-06-16 12:49:15 +02:00
Nicolas Dorseuil 3e152b0662 Refactor layout and page components to streamline structure and enhance theme handling 2026-06-16 12:15:26 +02:00
Nicolas Dorseuil 6a8720894d Refactor header link components for improved structure and styling consistency 2026-06-16 12:05:36 +02:00
Nicolas Dorseuil 8a5ed60c26 Refactor StructurePreview and HeaderLogo components to utilize HeaderLogoContent for improved logo rendering and fallback handling 2026-06-16 11:45:02 +02:00
Nicolas Dorseuil 52fc156b06 Refactor StructurePreview component and related state management by removing unused viewport mode logic and simplifying imports 2026-06-16 11:35:54 +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
Nicolas Dorseuil 46091b65d1 Add Structure Preview component and related functionality 2026-06-15 13:40:27 +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
296 changed files with 15834 additions and 2167 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support an optional `goal` query parameter on the markdown ask interface (`?ask=…&goal=…`), letting agents describe the broader end goal they are working towards so the answer can be steered towards it.
-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": patch
---
Fix embeddable search links
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix grouped top-nav section dropdowns rendering empty when the site is embedded in an iframe (visitor-auth embeds, editor preview) or shown in the embeddable view. The dropdown viewport is composited and animated, and a clipped composited layer fails to rasterize its text in Chromium when painted inside a sub compositing root; the rounded-corner clipping is now done on an inner wrapper so the viewport itself is no longer clipped.
-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
}
]
}
@@ -55,6 +55,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
@@ -57,6 +57,7 @@ runs:
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_INTEGRATIONS_CONTENT_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_CONTENT_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
+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.1",
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:",
@@ -127,8 +127,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "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.14",
"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.185.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.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="],
"@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.185.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.
+85
View File
@@ -1,5 +1,90 @@
# gitbook
## 0.27.1
### Patch Changes
- acf2719: 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.
- 72a1596: Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
- 77c717c: 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.
- c44d822: Trust an optional cookieless integration content host (`GITBOOK_INTEGRATIONS_CONTENT_HOST`) for WebFrame postMessage, in preparation for isolating integration-rendered content onto a separate origin. Defaults to `GITBOOK_INTEGRATIONS_HOST`, so behavior is unchanged until the content host is configured.
- 7655041: 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).
- af3cda1: Fix wide content (such as code blocks and tables) overflowing the edges of hint blocks instead of scrolling within them.
- 0ca4952: 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.
- 3ff88ba: Add a Prompt block
- 251630c: Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
- ddba803: 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.
- f4469b2: Support OpenAPI one-operation-per-page mode
- 6c967c9: Add a client-side search field to table blocks, with per-column filters for select and checkbox columns
- 882dfcd: Tidy prompt and add e2e test
- Updated dependencies [f4469b2]
- Updated dependencies [88b94b5]
- @gitbook/react-openapi@1.5.14
## 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
+111 -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]');
},
},
{
@@ -1075,6 +1113,11 @@ const testCases: TestsCase[] = [
url: 'blocks/mermaid',
run: waitForCookiesDialog,
},
{
name: 'Prompt',
url: 'blocks/prompt',
run: waitForCookiesDialog,
},
],
},
{
@@ -1323,6 +1366,7 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
@@ -2221,9 +2265,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 +2305,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 +2339,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 +2427,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 +2479,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');
+1
View File
@@ -48,6 +48,7 @@ const nextConfig = {
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST: process.env.GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
@@ -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.1",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -19,16 +19,16 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "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',
],
},
},
},
],
@@ -1,4 +1,4 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { CustomizationPageActionType, SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { getExposableError, throwIfDataError } from '@/lib/data';
@@ -20,7 +20,14 @@ export async function handleMcpRequest(
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
if (!context.customization.pageActions.mcp) {
// Use the configured `items` list when the API provides it, and fall back to the deprecated
// `mcp` flag otherwise (legacy mode), since this endpoint is called directly and cannot rely on
// any page-rendering fallback.
const { pageActions } = context.customization;
const isMcpEnabled = pageActions.items
? pageActions.items.includes(CustomizationPageActionType.Mcp)
: pageActions.mcp;
if (!isMcpEnabled) {
return new Response('Not Found', { status: 404 });
}
@@ -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,23 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { StructurePreview } from '@/components/StructurePreview';
import { GITBOOK_APP_URL } from '@/lib/env';
import type { Metadata } from 'next';
import { getStructurePreviewSnapshot } from '../snapshot';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function Page(props: PageProps) {
const { context } = await getDynamicSiteContext(await props.params);
return (
<StructurePreview
initialSnapshot={getStructurePreviewSnapshot(context)}
GITBOOK_APP_URL={GITBOOK_APP_URL}
/>
);
}
@@ -0,0 +1,42 @@
import type React from 'react';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayoutClientContexts } from '@/components/SiteLayout/SiteLayoutClientContexts';
import { getThemeFromMiddleware } from '@/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function RootLayout({
children,
...props
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const { context } = await getDynamicSiteContext(await props.params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout
htmlClassName="sheet-open:gutter-stable overflow-hidden site-background"
bodyClassName="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayoutClientContexts
contextId={context.contextId}
forcedTheme={
forcedTheme ??
(context.customization.themes.toggeable
? undefined
: context.customization.themes.default)
}
defaultTheme={context.customization.themes.default}
themeStorageKey={`gitbook-theme-structure:${context.site.id}`}
externalLinksTarget={context.customization.externalLinks.target}
proxyOrigin={context.site.proxy?.origin}
>
{children}
</SiteLayoutClientContexts>
</CustomizationRootLayout>
);
}
@@ -0,0 +1,184 @@
import type {
CustomizationContentLink,
CustomizationHeaderItem,
SiteSection,
SiteSectionGroup,
SiteSpace,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type {
ClientSiteSection,
ClientSiteSectionGroup,
ClientSiteSections,
} from '@/components/SiteSections';
import { categorizeVariants } from '@/components/SpaceLayout/categorizeVariants';
import type { StructurePreviewSnapshot } from '@/components/StructurePreview';
import type { PreviewContentLink, PreviewHeaderLink } from '@/components/StructurePreview/types';
import type { GitBookSiteContext, SiteSections } from '@/lib/context';
import { getLocalizedDescription, getLocalizedTitle } from '@/lib/sites';
export function getStructurePreviewSnapshot(context: GitBookSiteContext): StructurePreviewSnapshot {
const variants = categorizeVariants(context);
const sections = context.visibleSections ?? context.sections;
return {
site: {
title: context.site.title,
},
locale: context.locale,
customization: encodePreviewCustomization(context),
siteSpace: encodePreviewSiteSpace(context.siteSpace, context),
variants: {
generic: variants.generic.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
translations: variants.translations.map((siteSpace) =>
encodePreviewDropdownSpace(siteSpace, context)
),
},
sections: sections ? encodePreviewSiteSections(context, sections) : null,
icons: {
large: {
light: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
dark: context.linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
},
},
};
}
function encodePreviewCustomization(
context: GitBookSiteContext
): StructurePreviewSnapshot['customization'] {
const { customization, locale } = context;
return {
styling: {
search: customization.styling.search,
},
favicon:
'emoji' in customization.favicon && customization.favicon.emoji
? { emoji: customization.favicon.emoji }
: {},
header: {
preset: customization.header.preset,
logo: customization.header.logo
? {
light: customization.header.logo.light,
dark: customization.header.logo.dark,
}
: undefined,
links: customization.header.links.map((link) => encodePreviewHeaderLink(link, locale)),
},
ai: {
mode: customization.ai.mode,
},
trademark: {
enabled: customization.trademark.enabled,
},
socialAccounts: customization.socialAccounts
.filter((account) => account.display.header === true)
.map((account) => ({
platform: account.platform,
handle: account.handle,
})),
};
}
function encodePreviewHeaderLink(
link: CustomizationHeaderItem,
locale: GitBookSiteContext['locale']
): PreviewHeaderLink {
return {
title: getLocalizedTitle(link, locale),
style: link.style,
hasTarget: Boolean(link.to),
links: link.links.map((subLink) => encodePreviewContentLink(subLink, locale)),
};
}
function encodePreviewContentLink(
link: CustomizationContentLink,
locale: GitBookSiteContext['locale']
): PreviewContentLink {
return {
title: getLocalizedTitle(link, locale),
hasTarget: Boolean(link.to),
};
}
function encodePreviewSiteSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['siteSpace'] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
path: siteSpace.path,
};
}
function encodePreviewDropdownSpace(
siteSpace: SiteSpace,
context: GitBookSiteContext
): StructurePreviewSnapshot['variants']['generic'][number] {
return {
id: siteSpace.id,
title: getLocalizedTitle(siteSpace, context.locale),
isActive: siteSpace.id === context.siteSpace.id,
};
}
export function encodePreviewSiteSections(
context: Pick<GitBookSiteContext, 'locale'>,
sections: SiteSections
): ClientSiteSections {
return {
list: sections.list.flatMap((item) => encodePreviewSectionItem(context, item)),
current: encodePreviewSection(context, sections.current),
};
}
function encodePreviewSectionItem(
context: Pick<GitBookSiteContext, 'locale'>,
item: SiteSection | SiteSectionGroup
): (ClientSiteSection | ClientSiteSectionGroup)[] {
switch (item.object) {
case 'site-section':
return [encodePreviewSection(context, item)];
case 'site-section-group': {
const children = item.children.flatMap((child) =>
encodePreviewSectionItem(context, child)
);
if (children.length === 0) {
return [];
}
return [
{
id: item.id,
title: getLocalizedTitle(item, context.locale),
icon: item.icon,
object: item.object,
children,
},
];
}
default:
assertNever(item);
}
}
function encodePreviewSection(
context: Pick<GitBookSiteContext, 'locale'>,
section: SiteSection
): ClientSiteSection {
return {
id: section.id,
title: getLocalizedTitle(section, context.locale),
description: getLocalizedDescription(section, context.locale),
icon: section.icon,
object: section.object,
url: '#',
};
}
@@ -0,0 +1,24 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
/**
* Serve an AI answer as markdown for a page, steered by the end goal ("goal")
* the calling agent provided via the `?goal=` search parameter.
*
* The goal is encoded as a path segment (rather than read from the query) because
* this route is statically rendered, mirroring how the question itself is handled.
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { question: string; goal: string }> }
) {
const { question: encodedQuestion, goal: encodedGoal } = await params;
const { context } = await getStaticSiteContext(await params);
const question = decodeURIComponent(encodedQuestion);
const goal = decodeURIComponent(encodedGoal);
return serveAskMarkdown(context, question, { goal });
}
+2
View File
@@ -10,6 +10,7 @@ import {
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
@@ -32,6 +33,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_ICONS_URL,
GITBOOK_USER_AGENT,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
GITBOOK_DISABLE_TRACKING,
// Secret envs
@@ -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,102 @@
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 TextReference = BaseAIChatReference & {
type: 'text';
/** The selected text content. */
content: string;
};
export type AIChatReference = CodeBlockReference | PageReference | TextReference;
/**
* 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));
}
const textRefs = refs.filter((ref): ref is TextReference => ref.type === 'text');
if (textRefs.length > 0) {
sections.push(serializeTextReferences(textRefs));
}
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 serializeTextReferences(refs: TextReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map((ref) => quoteText(ref.content)).join('\n\n');
return `The user is referring to the following excerpt${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function quoteText(content: string): string {
return content
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
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'),
},
};
},
}),
[]
);
}
@@ -31,9 +31,11 @@ import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatExpandButton } from './AIChatExpandButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages';
import { AIChatResizeHandle } from './AIChatResizeHandle';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
export function AIChat() {
@@ -83,12 +85,14 @@ 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'
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:w-(--ai-chat-width)'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<AIChatResizeHandle />
<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>
@@ -99,6 +103,7 @@ export function AIChat() {
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<AIChatExpandButton />
<Button
onClick={() => chatController.close()}
iconOnly
@@ -225,7 +230,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 +285,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,9 @@
'use client';
import type { ReactNode } from 'react';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
@@ -8,6 +11,49 @@ import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button visual for an AI assistant in the header.
*/
export function AIChatButtonView(props: {
icon: ReactNode;
label: string;
onClick?: () => void;
showLabel?: boolean;
withShortcut?: boolean;
inert?: boolean;
}) {
const { icon, label, onClick, showLabel = true, withShortcut = true, inert = false } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
icon={icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', label)}
onClick={inert ? undefined : onClick}
tabIndex={inert ? -1 : undefined}
className={tcls(inert ? 'pointer-events-none select-none' : null)}
>
{showLabel ? t(language, 'ask') : null}
</Button>
);
}
/**
* Button to open/close the AI chat.
*/
@@ -17,30 +63,14 @@ export function AIChatButton(props: {
withShortcut?: boolean;
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
<AIChatButtonView
icon={assistant.icon}
data-testid="ai-chat-button"
iconOnly={!showLabel || isMobile}
size="medium"
variant="header"
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
{withShortcut ? (
<KeyboardShortcut
keys={['mod', 'i']}
className="border-tint-11 text-tint-1"
/>
) : null}
</div>
}
label={assistant.label}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
</Button>
showLabel={showLabel}
withShortcut={withShortcut}
/>
);
}
@@ -0,0 +1,35 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { Icon } from '@gitbook/icons';
import { Button } from '../primitives';
import { useAIChatWidthStore, useIsAIChatMaxWidth } from './useAIChatWidthStore';
export function AIChatExpandButton() {
const language = useLanguage();
const toggleWidth = useAIChatWidthStore((state) => state.toggleWidth);
const isMaxWidth = useIsAIChatMaxWidth();
return (
<Button
onClick={toggleWidth}
iconOnly
icon={
<Icon
icon={
isMaxWidth
? 'arrow-down-left-and-arrow-up-right-to-center'
: 'arrow-up-right-and-arrow-down-left-from-center'
}
className="scale-90"
/>
}
label={tString(
language,
isMaxWidth ? 'ai_chat_collapse_panel' : 'ai_chat_expand_panel'
)}
variant="blank"
className="max-lg:hidden"
/>
);
}
@@ -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,126 @@
'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.type === 'text' ? ref.content : 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>
) : ref.type === 'text' ? (
// A text selection has no persistent DOM anchor to navigate to, so the
// excerpt is shown as plain (non-interactive) content.
<span className={triggerClassName}>{content}</span>
) : (
<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';
case 'text':
return 'quote-left';
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 });
}
@@ -0,0 +1,83 @@
'use client';
import { tcls } from '@/lib/tailwind';
import React from 'react';
import { useAIChatWidthStore } from './useAIChatWidthStore';
function setResizing(active: boolean) {
document.documentElement.dataset.aiChatResizing = String(active);
}
export function AIChatResizeHandle() {
const setWidth = useAIChatWidthStore((state) => state.setWidth);
const frameRef = React.useRef<number | null>(null);
const widthRef = React.useRef(0);
React.useEffect(() => {
const onResize = () => useAIChatWidthStore.getState().syncWidth();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
setResizing(false);
};
}, []);
const stopResizing = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setResizing(false);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
widthRef.current = useAIChatWidthStore.getState().width;
setResizing(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
// Panel is right-anchored, so its width is the distance from the cursor to the right edge.
widthRef.current = window.innerWidth - event.clientX;
if (frameRef.current === null) {
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
widthRef.current = setWidth(widthRef.current);
});
}
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
return;
}
setWidth(widthRef.current);
stopResizing(event);
};
return (
<div
aria-hidden="true"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={stopResizing}
className={tcls(
'group -translate-x-1/2 absolute inset-y-0 left-0 z-10 hidden w-3 cursor-col-resize touch-none lg:flex',
'items-stretch justify-center'
)}
>
<span className="h-full w-px rounded-full bg-transparent transition-all duration-150 ease-out group-hover:w-0.5 group-hover:bg-primary-solid/40 group-active:w-0.5 group-active:bg-primary-solid" />
</div>
);
}
@@ -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
@@ -0,0 +1,129 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import fnv1a from '@sindresorhus/fnv1a';
import { AnimatePresence, motion } from 'motion/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { useIsMobile } from '@/components/hooks/useIsMobile';
import { useIsMounted } from '@/components/hooks/useIsMounted';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { AIChatIcon } from '../AIChatIcon';
import { useStableTextSelection } from './useStableTextSelection';
/** Gap between the selection and the button. */
const GAP = 8;
/** Minimum distance to the viewport edges. */
const MARGIN = 8;
/**
* Floating "Ask" button anchored above a text selection. Clicking it stages the selection as a
* reference and opens the AI chat. Only rendered in Assistant mode, on non-touch devices.
*/
export function AskAITextSelection() {
const config = useAIConfig();
const language = useLanguage();
const chatController = useAIChatController();
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const enabled = config.aiMode === CustomizationAIMode.Assistant && !isMobile;
const toolbarRef = React.useRef<HTMLDivElement>(null);
const { selection, clear } = useStableTextSelection({
rootSelector: '[data-content-ref-root]',
enabled,
ignoreRef: toolbarRef,
});
const [coords, setCoords] = React.useState<{ top: number; left: number } | null>(null);
// Position once the button has been measured, so it can be centered and clamped to the viewport.
React.useLayoutEffect(() => {
if (!selection) {
return;
}
const el = toolbarRef.current;
if (!el) {
return;
}
const width = el.offsetWidth;
const height = el.offsetHeight;
const { anchor } = selection;
let top = anchor.top - GAP - height;
if (top < MARGIN) {
// Not enough room above the selection: drop below it.
top = anchor.bottom + GAP;
}
top = Math.min(top, window.innerHeight - height - MARGIN);
const left = Math.min(
Math.max(anchor.centerX - width / 2, MARGIN),
window.innerWidth - width - MARGIN
);
setCoords({ top, left });
}, [selection]);
const onClick = () => {
if (!selection) {
return;
}
const content = selection.text;
if (!content.trim()) {
return;
}
chatController.addReference({
type: 'text',
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.focus();
clear();
};
if (!enabled || !isMounted) {
return null;
}
return createPortal(
<AnimatePresence>
{selection ? (
<motion.div
ref={toolbarRef}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: coords ? 1 : 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 40,
}}
// Keep the selection alive: prevent the button from stealing focus on click.
onMouseDown={(event) => event.preventDefault()}
>
<Button
size="small"
variant="primary"
icon={<AIChatIcon state="default" trademark={config.trademark} />}
onClick={onClick}
className="shadow-sm"
>
{t(language, 'ask')}
</Button>
</motion.div>
) : null}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1 @@
export * from './AskAITextSelection';
@@ -0,0 +1,202 @@
'use client';
import * as React from 'react';
type SelectionAnchor = {
top: number;
bottom: number;
centerX: number;
};
export type StableTextSelection = {
anchor: SelectionAnchor;
text: string;
};
type Options = {
/** Selector of the content root; both selection endpoints must be inside it. */
rootSelector: string;
enabled: boolean;
/** Pointer events inside this element are ignored, so clicking the UI keeps the selection. */
ignoreRef: React.RefObject<HTMLElement | null>;
};
const KEYBOARD_DEBOUNCE_MS = 250;
const SCROLL_SETTLE_MS = 200;
// Coalesces the rapid down/up bursts of a double/triple-click into a single, non-flashing show.
const POINTER_SETTLE_MS = 120;
/**
* Track a stable text selection within `rootSelector`, debounced so the floating UI it powers
* doesn't flicker mid-gesture. The selection is dropped on collapse, window blur, scroll, and
* when a new gesture starts.
*/
export function useStableTextSelection(options: Options): {
selection: StableTextSelection | null;
clear: () => void;
} {
const { rootSelector, enabled, ignoreRef } = options;
const [selection, setSelection] = React.useState<StableTextSelection | null>(null);
const clear = React.useCallback(() => {
window.getSelection()?.removeAllRanges();
setSelection(null);
}, []);
React.useEffect(() => {
if (!enabled) {
setSelection(null);
return;
}
let pointerDown = false;
let keyboardTimer: ReturnType<typeof setTimeout> | null = null;
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
const clearKeyboardTimer = () => {
if (keyboardTimer) {
clearTimeout(keyboardTimer);
keyboardTimer = null;
}
};
const clearFinalizeTimer = () => {
if (finalizeTimer) {
clearTimeout(finalizeTimer);
finalizeTimer = null;
}
};
const finalize = () => {
// A new gesture started; it will reschedule its own finalize.
if (pointerDown) {
return;
}
setSelection(readStableSelection(rootSelector));
};
const schedulePointerFinalize = () => {
clearKeyboardTimer();
clearFinalizeTimer();
finalizeTimer = setTimeout(() => {
finalizeTimer = null;
finalize();
}, POINTER_SETTLE_MS);
};
const isIgnored = (target: EventTarget | null) =>
target instanceof Node && !!ignoreRef.current?.contains(target);
const onSelectionChange = () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !sel.toString().trim()) {
clearKeyboardTimer();
setSelection(null);
return;
}
// Mid-drag, or a pointer finalize is already queued: let the gesture settle first.
if (pointerDown || finalizeTimer) {
return;
}
clearKeyboardTimer();
keyboardTimer = setTimeout(finalize, KEYBOARD_DEBOUNCE_MS);
};
const onPointerDown = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
// New gesture: hide and cancel a pending show so it can't fire mid double-click.
pointerDown = true;
clearFinalizeTimer();
setSelection(null);
};
const onPointerUp = (event: PointerEvent) => {
if (isIgnored(event.target)) {
return;
}
pointerDown = false;
schedulePointerFinalize();
};
// pointercancel replaces pointerup when a gesture is interrupted (OS gesture, scroll handoff).
const onPointerCancel = () => {
pointerDown = false;
schedulePointerFinalize();
};
const onScroll = () => {
setSelection(null);
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
if (!pointerDown) {
finalize();
}
}, SCROLL_SETTLE_MS);
};
const onWindowBlur = () => {
clearKeyboardTimer();
setSelection(null);
};
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', onPointerCancel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('blur', onWindowBlur);
return () => {
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', onPointerCancel, true);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('blur', onWindowBlur);
clearKeyboardTimer();
clearFinalizeTimer();
if (scrollTimer) {
clearTimeout(scrollTimer);
}
};
}, [enabled, rootSelector, ignoreRef]);
return { selection, clear };
}
function readStableSelection(rootSelector: string): StableTextSelection | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
return null;
}
const text = sel.toString().trim();
if (!text) {
return null;
}
const root = document.querySelector(rootSelector);
if (!root || !root.contains(sel.anchorNode) || !root.contains(sel.focusNode)) {
return null;
}
const anchor = getSelectionAnchor(sel);
return anchor ? { anchor, text } : null;
}
/** Box centered above the whole selection, or null if it has no size or is scrolled out of view. */
function getSelectionAnchor(sel: Selection): SelectionAnchor | null {
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect.width && !rect.height) {
return null;
}
if (rect.bottom < 0 || rect.top > window.innerHeight) {
return null;
}
return { top: rect.top, bottom: rect.bottom, centerX: rect.left + rect.width / 2 };
}
@@ -3,3 +3,4 @@ export * from './AIChatButton';
export * from './AIChatIcon';
export * from './AIResponseFeedback';
export * from './AIChatControlButton';
export * from './AskAITextSelection';
@@ -0,0 +1,78 @@
'use client';
import {
getLocalStorageItem,
removeLocalStorageItem,
setLocalStorageItem,
} from '@/lib/browser/local-storage';
import { create } from 'zustand';
import { type StorageValue, persist } from 'zustand/middleware';
const AI_CHAT_MIN_WIDTH = 384;
const AI_CHAT_MAX_WIDTH = 640;
const MIN_CONTENT_WIDTH = 720;
type AIChatWidthStore = {
width: number;
toggleWidth: () => void;
setWidth: (width: number) => number;
syncWidth: () => void;
};
type PersistedState = Pick<AIChatWidthStore, 'width'>;
export const useAIChatWidthStore = create<AIChatWidthStore>()(
persist(
(set, get) => ({
width: AI_CHAT_MIN_WIDTH,
toggleWidth: () =>
get().setWidth(
get().width >= AI_CHAT_MAX_WIDTH ? AI_CHAT_MIN_WIDTH : AI_CHAT_MAX_WIDTH
),
setWidth: (width) => {
const clamped = clampWidth(width);
if (get().width !== clamped) {
set({ width: clamped });
}
setWidthOnViewport(clamped);
return clamped;
},
syncWidth: () => setWidthOnViewport(get().width),
}),
{
name: '@gitbook/ai-chat-width',
storage: {
getItem: (name) =>
getLocalStorageItem<StorageValue<PersistedState> | null>(name, null),
setItem: (name, value) => setLocalStorageItem(name, value),
removeItem: (name) => removeLocalStorageItem(name),
},
partialize: (state) => ({ width: state.width }),
onRehydrateStorage: () => (state) => state?.syncWidth(),
}
)
);
/**
* Whether the panel is at its maximum width.
*/
export const useIsAIChatMaxWidth = () =>
useAIChatWidthStore((state) => state.width >= AI_CHAT_MAX_WIDTH);
// Hoisted so the synchronous persist rehydrate (during create() above) can call them before this point.
function setWidthOnViewport(width: number) {
if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--ai-chat-width', `${capToViewport(width)}px`);
}
}
function clampWidth(width: number) {
return Math.min(AI_CHAT_MAX_WIDTH, Math.max(AI_CHAT_MIN_WIDTH, Math.round(width)));
}
// Cap a width so the remaining content keeps a usable minimum at the current viewport.
function capToViewport(width: number) {
return typeof window === 'undefined'
? width
: Math.min(width, Math.max(AI_CHAT_MIN_WIDTH, window.innerWidth - MIN_CONTENT_WIDTH));
}
+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()}
@@ -42,7 +42,7 @@ export function AnnouncementBanner(props: {
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-(--ai-chat-width)">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
@@ -80,8 +80,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'max-w-md',
'text-balance',
'sm:left-auto',
'lg:chat-open:mr-80',
'xl:chat-open:mr-100',
'lg:chat-open:mr-(--ai-chat-width)',
'transition-all',
'motion-reduce:transition-none',
'duration-300',
@@ -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}
@@ -28,6 +28,7 @@ import { ListItem } from './ListItem';
import { BlockMath } from './Math';
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
import { Paragraph } from './Paragraph';
import { Prompt } from './Prompt';
import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent';
import { Stepper } from './Stepper';
@@ -111,6 +112,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <Updates {...props} block={block} />;
case 'update':
return <Update {...props} block={block} />;
case 'prompt':
return <Prompt {...props} block={block} />;
case 'if':
// If block should be processed by the API.
return null;
@@ -151,6 +154,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'hint':
case 'tabs':
case 'stepper-step':
case 'prompt':
case 'if':
return <SkeletonParagraph id={id} className={style} />;
case 'expandable':
@@ -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,11 +28,12 @@ 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';
const title = block.data.title;
const isSingleLine = theme.lines.length <= 1 && !withWrap;
const id = useId();
const codeId = props.id || id;
@@ -39,15 +46,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,14 +79,26 @@ 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={tcls(
'absolute right-2 z-2 flex items-center gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11',
isSingleLine ? 'inset-y-0' : 'top-2'
)}
>
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
title={title}
syntax={block.data.syntax}
style="backdrop-blur-md"
/>
) : null}
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
</div>
<pre
className={tcls(
'relative overflow-auto 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',
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2.5 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
@@ -82,7 +110,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 +141,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} />;
}
@@ -1,10 +1,12 @@
import { GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
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,19 +70,34 @@ 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,
}}
security={{ firstPartyDomains: [GITBOOK_INTEGRATIONS_HOST] }}
security={{
// Trust both the integrations host and the (cookieless) content host that
// serves rendered WebFrames. `ElementWebframe` gates inbound and outbound
// postMessage on this list, so a WebFrame served from the content host would
// break (no resize/ready/actions) if the content host weren't trusted.
// The hosts are identical until a distinct content origin is configured.
firstPartyDomains: [
...new Set([GITBOOK_INTEGRATIONS_HOST, GITBOOK_INTEGRATIONS_CONTENT_HOST]),
],
}}
initialInput={initialInput}
initialOutput={initialOutput}
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 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.type === 'openapi-operation' ? block.data.headless : undefined,
})}
className="openapi-block"
/>
@@ -56,6 +56,7 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
headless: block.data.headless,
})}
className="openapi-block"
/>
@@ -26,8 +26,9 @@ export function getOpenAPIContext(args: {
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
headless?: boolean;
}): OpenAPIContextInput {
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
const { props, specUrl, context, expandAllResponses, expandAllModelSections, headless } = args;
const { block } = props;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
@@ -54,6 +55,7 @@ export function getOpenAPIContext(args: {
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
mode={props.context.mode}
themeKey="openapi"
themes={
context && 'customization' in context
@@ -95,6 +97,7 @@ export function getOpenAPIContext(args: {
),
expandAllResponses: expandAllResponses || props.context.mode === 'print',
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
headless,
id: block.meta?.id,
blockKey: block.key,
locale,
@@ -272,7 +272,7 @@ body {
}
.scalar-activate-button {
@apply flex gap-2 items-center;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-lg straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
@@ -40,7 +40,7 @@
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-stability-alpha {
@@ -78,7 +78,7 @@
}
.openapi-markdown code {
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
.openapi-markdown pre code {
@@ -101,7 +101,17 @@
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
@@ -130,7 +140,8 @@
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace {
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -149,11 +160,12 @@
}
.openapi-column-preview {
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
@apply flex flex-col flex-1 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] scroll-mt-4 top-[calc(var(--toc-top-offset)+1.5rem)];
}
.openapi-column-preview-body {
@apply flex flex-col shrink overflow-hidden gap-4 print-mode:static;
/* overflow-clip + clip-margin so panel box-shadows aren't cut while overflow is still clipped */
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem] gap-4 print-mode:static;
}
.openapi-column-preview pre {
@@ -276,11 +288,11 @@
}
.openapi-schema-enum-value:first-child {
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
@apply rounded-l-md straight-corners:rounded-none circular-corners:rounded-l-lg ml-0;
}
.openapi-schema-enum-value:last-child {
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
@apply rounded-r-md straight-corners:rounded-none circular-corners:rounded-r-lg;
}
/* Schema Description */
@@ -314,7 +326,7 @@
.openapi-schema-pattern code,
.openapi-schema-enum-value code,
.openapi-schema-default code {
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
}
/* Authentication */
@@ -344,7 +356,7 @@
}
.openapi-securities-url {
@apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
@apply ml-0.5 px-0.5 rounded-md straight-corners:rounded-none circular-corners:rounded-lg hover:bg-tint dark:hover:bg-tint-hover transition-colors;
}
.openapi-securities-body {
@@ -396,7 +408,7 @@
}
.openapi-response-tab-content {
@apply flex items-baseline truncate grow shrink max-w-max basis-full mr-auto;
@apply flex items-baseline gap-2 truncate grow shrink max-w-max basis-full mr-auto;
@apply text-left text-pretty relative leading-tight text-tint select-text;
}
@@ -450,16 +462,12 @@
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
}
.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
@apply h-full;
}
.openapi-codesample-header-content {
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
@apply flex items-center font-mono text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path-title {
@@ -486,17 +494,13 @@
}
.openapi-path-variable {
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded-md straight-corners:rounded-none circular-corners:rounded-lg text-sm leading-none before:content-none! after:!content-none;
}
.openapi-path-server {
@apply text-tint inline;
}
.openapi-summary .openapi-path .openapi-method {
@apply m-0 items-center flex px-2 py-1 h-6;
}
.openapi-path-title {
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
@apply whitespace-nowrap md:whitespace-normal;
@@ -532,7 +536,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
@apply border shrink min-h-32 overflow-hidden rounded-corners:rounded-xl circular-corners:rounded-2xl straight-corners:rounded-xs bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
}
.openapi-response-examples-panel {
@@ -578,7 +582,7 @@
}
.openapi-response-examples-statuscode-title {
@apply flex items-center;
@apply flex items-center gap-2;
}
.openapi-response-examples-header .openapi-select > button,
@@ -611,7 +615,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg leading-none;
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
}
@@ -644,7 +648,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-popover {
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
@@ -657,7 +661,7 @@ body:has(.openapi-select-popover) {
}
.openapi-select-item {
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded-md straight-corners:rounded-none circular-corners:rounded-lg !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
@@ -691,11 +695,12 @@ body:has(.openapi-select-popover) {
/* Section Components */
.openapi-section {
@apply flex flex-col overflow-hidden;
/* overflow-clip + clip-margin so the centered AND/OR separator badge isn't cut at the outermost alternatives level */
@apply flex flex-col overflow-clip [overflow-clip-margin:0.75rem];
}
.openapi-section-body {
@apply flex flex-col shrink overflow-hidden;
@apply flex flex-col shrink overflow-clip [overflow-clip-margin:0.75rem];
}
.openapi-section-header {
@@ -753,7 +758,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tabs-tab {
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded-md straight-corners:rounded-none circular-corners:rounded-lg;
}
.openapi-tabs-tab[aria-selected="true"] {
@@ -767,7 +772,7 @@ body:has(.openapi-select-popover) {
/* Disclosure group */
.openapi-disclosure-group {
@apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none circular-corners:last:rounded-b-xl first:rounded-t-md straight-corners:first:rounded-none circular-corners:first:rounded-t-xl first:border-t relative;
@apply border-tint-subtle transition-all border-b border-x overflow-auto rounded-corners:last:rounded-b-xl circular-corners:last:rounded-b-2xl straight-corners:last:rounded-b-xs rounded-corners:first:rounded-t-xl circular-corners:first:rounded-t-2xl straight-corners:first:rounded-t-xs first:border-t relative;
}
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) {
@@ -820,7 +825,7 @@ body:has(.openapi-select-popover) {
/* Disclosure */
.openapi-schemas-disclosure {
@apply border-t border-x last:border-b border-tint-subtle !ring-0 first:!rounded-t-xl last:!rounded-b-xl !rounded-none;
@apply border-t border-x last:border-b border-tint-subtle !ring-0 rounded-corners:first:!rounded-t-xl rounded-corners:last:!rounded-b-xl circular-corners:first:!rounded-t-2xl circular-corners:last:!rounded-b-2xl straight-corners:first:!rounded-t-xs straight-corners:last:!rounded-b-xs !rounded-none;
}
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
@@ -865,7 +870,7 @@ body:has(.openapi-select-popover) {
.openapi-schema-alternatives .openapi-disclosure,
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
) {
@apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
@apply rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
}
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
@@ -888,7 +893,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger-label {
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded-md straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -951,7 +956,7 @@ body:has(.openapi-select-popover) {
}
.openapi-tooltip {
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none circular-corners:rounded-lg font-medium px-1.5 py-0.5 shadow-sm text-[13px];
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
}
.openapi-tooltip svg {
@@ -1015,7 +1020,7 @@ body:has(.openapi-select-popover) {
}
.openapi-path-copy-button {
@apply p-1 flex rounded-md straight-corners:rounded-none;
@apply p-1 flex rounded-lg straight-corners:rounded-none;
@apply hover:bg-tint dark:hover:bg-tint-hover;
}
@@ -1032,7 +1037,7 @@ body:has(.openapi-select-popover) {
}
.openapi-required-scopes {
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
@apply border text-base rounded-corners:rounded-md circular-corners:rounded-xl straight-corners:rounded-none font-medium mx-0;
}
.openapi-required-scopes .openapi-required-scopes-header {

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