Compare commits

..

148 Commits

Author SHA1 Message Date
Peter White 4cb77424e7 Merge branch 'main' into peter/fix-site-redirect-encoded-paths 2026-03-11 14:07:40 +01:00
Peter White ff26b63013 Add changeset 2026-03-11 14:07:16 +01:00
Zeno Kapitein 3ce7e529f7 Fix Variant dropdown not showing in GitBook embed (#4100) 2026-03-11 13:57:13 +01:00
Samy Pessé 0f162a45a7 Version Packages (#4082)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-03-11 12:56:28 +00:00
Peter White 04ca610ef4 Encode site redirect pathname for lookup 2026-03-11 13:31:30 +01:00
Steven H 02ba0ef601 Disable Workers Observability (#4099) 2026-03-11 11:02:44 +00:00
Nolann B. bdc6cd9aba Use OpenAPI publicURL as Scalar specUrl (#4093) 2026-03-11 11:21:14 +01:00
Zeno Kapitein b3875a19d7 Add assistantName property to Docs Embed (#4095) 2026-03-11 10:46:10 +01:00
conico974 884d900e38 Remove check for invalid characters in the url (#4098) 2026-03-11 10:43:32 +01:00
Taran Vohra f68755695e Update workflows with preview URL envs (#4097)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 12:13:49 +05:30
Greg Bergé 9ffde72ced Reset scroll between section navigation (#4089) 2026-03-10 18:18:40 +01:00
Viktor Renkema 9e3629b12b Apply color if tags have this set (#4094) 2026-03-10 17:02:25 +01:00
Greg Bergé 48f81c80f0 Use break-words for code (#4092) 2026-03-10 13:36:08 +00:00
Greg Bergé 0baa8e5022 Fix documentation links opening with /~gitbook/embed/ (#4090) 2026-03-10 14:10:50 +01:00
conico974 59b9663ce3 Add decodeURLPath function for URL decoding and validation (#3915) 2026-03-09 16:29:22 +01:00
Greg Bergé 9be66e3e8a Add visual test for Mermaid blocks (#4087) 2026-03-09 14:22:34 +01:00
Aiko 9a0f35c26d Fix tab content cropping inside stepper on mobile (#4088) 2026-03-09 19:11:21 +06:00
Nolann B. f1826807a7 Add backward compatibility for OpenAPI spec content URL (#4086) 2026-03-09 09:47:26 +01:00
Nolann B. 887c3f8eac Add token verification on OpenAPI proxy (#4085) 2026-03-08 16:50:43 +01:00
Nolann B. d666212674 Add support for localized site section titles (#3980) 2026-03-07 20:52:13 +01:00
conico974 8cb9131d83 Revert middleware matcher changes (#4084) 2026-03-06 17:13:53 +01:00
Greg Bergé 98b36616cb Add interactive controls to docs assistant (#4073) 2026-03-06 16:58:48 +01:00
Nolann B. eceec6f641 Move ~scalar/proxy route to site route group (#4083) 2026-03-05 19:06:06 +01:00
conico974 9f4d93502e Add real IP extraction to analytics event proxy (#4056) 2026-03-05 16:49:44 +01:00
Nolann B. 8ab419a47e Support x-enable-proxy at operation level (#4080) 2026-03-05 10:05:17 +01:00
Samy Pessé 56ce5dfd74 Version Packages (#4048) 2026-03-04 20:32:40 +01:00
Zeno Kapitein 370b731972 Docs Embed: Make trademark optional (#4079)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2026-03-04 14:53:42 +01:00
conico974 b7fb9e57ca Don't use server actions for search (#4067) 2026-03-03 18:27:44 +01:00
Viktor Renkema 5b9f5fa991 Improve wrapping of tags within TOC item (#4075) 2026-03-03 15:26:44 +01:00
Greg Bergé bf1f11794e Fix scroll reset when changing page (#4072) 2026-03-02 16:13:47 +00:00
Nolann B. 8621c7f571 Support alternative security scope groups (#3976) 2026-03-02 17:04:31 +01:00
Zeno Kapitein 4a6f91a19d Show social accounts in header (#4068) 2026-03-02 11:06:49 +01:00
Greg Bergé 38a1c85fee Fix PDF export page URL for /~space/ URLs (#4065) 2026-03-02 09:49:22 +01:00
Nolann B. cb71511cda Wrap MCP tracking call with waitUntil (#4071) 2026-02-27 17:46:01 +01:00
Nolann B. afa476c51f Add server-side insight event tracking for MCP route (#4063) 2026-02-27 17:16:21 +01:00
Zeno Kapitein 1e9ed753a1 Fix typecheck in API 0.165.0 (#4069) 2026-02-27 16:00:05 +01:00
John Cashman 22da7961d1 Include headings inside columns in published page outline (#4045)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2026-02-27 14:27:54 +00:00
Nolann B. e074283bbf Add server-side proxy for Scalar API client to bypass CORS (#4050) 2026-02-27 12:18:07 +01:00
Zeno Kapitein afa868e0e1 Show "Ask Assistant" search result immediately (#4066) 2026-02-27 11:55:21 +01:00
Greg Bergé c5bdde4401 Harden visitor session detection to improve GBO toolbar visibility (#4061) 2026-02-27 11:47:05 +01:00
Greg Bergé 5efe13e622 Fix Firefox mobile sidepanel TOC scroll by constraining sheet flex height (#4062) 2026-02-27 11:21:17 +01:00
Greg Bergé 41ef3435bc Clarify the toolbar message (#4053) 2026-02-27 11:19:57 +01:00
Claire Chabas 157d5fb9e7 Fix cards styles (#4058) 2026-02-26 16:29:39 +01:00
Greg Bergé ec28a7b686 Support scroll to text fragment (#4060) 2026-02-26 16:00:12 +01:00
Viktor Renkema b3e9ff9839 Support icons/emojis in tags (#4054) 2026-02-26 14:45:12 +01:00
Claire Chabas aa2292036c Fix inline code word break (#4057) 2026-02-26 13:28:33 +01:00
Claire Chabas f7c4538b16 Prevent hash link scroll to top in Safari (#4055) 2026-02-26 13:27:04 +01:00
Claire Chabas b9b444c24e Fix inline code overflowing in table cells (#4052) 2026-02-25 18:23:00 +01:00
conico974 6f1b795d80 Improve sharded tag cache performance (#4051) 2026-02-25 17:48:09 +01:00
Samy Pessé 0803db07ef Track opening of search results for records and in the assistant (#4047) 2026-02-25 11:40:39 +01:00
Samy Pessé a9adbc6370 Version Packages (#3991)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 11:37:48 +01:00
Greg Bergé f49b0af3fe Fix E2E tests (#4044) 2026-02-25 09:57:47 +01:00
Samy Pessé 3d8e89cbe9 Show favicon for record search results in the assistant (#4042) 2026-02-24 18:57:22 +01:00
Greg Bergé f0a54bcb27 Fix PDF export links to preserve published/custom domains (#4043) 2026-02-24 17:02:01 +00:00
Claire Chabas c348583a18 Prevent inline code from breaking on multiple lines (#4041) 2026-02-24 13:48:33 +01:00
Greg Bergé a4b238dc0c Support close button in embed (#4036) 2026-02-24 11:35:03 +01:00
Zeno Kapitein 529afa3fac Open Assistant from page action without default question (#4038) 2026-02-24 10:04:18 +01:00
Peter White 577bf26d1e Fix anchor ID collisions by removing non-content UX IDs (#4035) 2026-02-24 09:53:36 +01:00
Samy Pessé 4808f36718 Update @gitbook/api to 0.163.0 (#4039) 2026-02-23 19:00:02 +01:00
Samy Pessé a100c5a6c8 Prepare search for upcoming records (#4037) 2026-02-23 18:34:01 +01:00
Zeno Kapitein 4962662fb1 Improve search breadcrumbs (#4034) 2026-02-23 13:40:57 +00:00
Zeno Kapitein f7c251e2d2 Improve Reddit social account URL handling (#4033) 2026-02-23 11:49:26 +01:00
Greg Bergé 40524d4d4a Upgrade Argos & Playwright (#4031) 2026-02-23 10:04:59 +01:00
Nolann B. 1a7ef78fe2 Handle non-array required field in OpenAPI schema (#4030) 2026-02-21 13:34:12 +01:00
Greg Bergé 5f3f4da2d2 Stabilize E2E (#4028) 2026-02-20 15:35:50 +01:00
Nolann B. 93eea0b6d9 Strip trailing slash from OpenAPI server URLs (#4029) 2026-02-20 14:40:36 +01:00
Viktor Renkema 9dfa9c2db0 Add support for rendering Tags (#4006) 2026-02-20 10:03:49 +01:00
spastorelli d9ff25d28e Integrate sites OAuth server flow in GBO for protected resources like MCP (#4015) 2026-02-19 15:48:49 +01:00
Samy Pessé ae30a576e8 Integrate Ask AI with upcoming insights by passing visitor session IDs (#4027) 2026-02-19 14:36:23 +01:00
Nolann B. 2e495cb28a Add Global Privacy Control (GPC) support (#4023) 2026-02-18 21:32:56 +01:00
Nolann B. f517035e38 Remove available scopes list from OAuth2 endpoints (#4022) 2026-02-18 21:20:58 +01:00
spastorelli 83bd0d74cd Fix issue where customizations stored in cookie is shared between different site previews (#4025) 2026-02-18 19:10:54 +01:00
Greg Bergé 3cfefa40fd Support rendering Mermaid blocks (#4018) 2026-02-18 17:00:34 +01:00
Claire Chabas 59ffb95417 Support hint block custom icon (#4007) 2026-02-18 15:02:33 +01:00
Nolann B. 2ebb18dc5a Make OpenAPI Response structure clearer (#4021) 2026-02-18 14:32:07 +01:00
Claire Chabas 1deb897de1 Bump GitBook API (#4019) 2026-02-18 12:53:29 +01:00
Nolann B. 11d9b80e77 Use precedence for OpenAPI servers (#4020) 2026-02-18 01:20:39 +01:00
John Cashman e73d9afd86 fix(pdf): keep headings together across page breaks (#4016) 2026-02-17 15:59:42 +01:00
Greg Bergé 729501c977 Fix language dropdown contrast (#4017) 2026-02-17 14:27:09 +01:00
Greg Bergé 414866cbec Fix external links logic for proxy (#4012) 2026-02-17 11:41:30 +01:00
Claire Chabas 9d5e397574 Remove code syntax highlighting patch for Safari (#4014) 2026-02-16 19:09:19 +01:00
Nolann B. 92c9b8b056 Cache isAIUserAgent result (#4011) 2026-02-16 13:11:43 +00:00
Greg Bergé a73caf69ff Allow to pass a token to the embed script (#4005) 2026-02-16 12:48:02 +01:00
conico974 f72f250b16 Add deploymentId to nextConfig for custom deployment method (#4010) 2026-02-16 12:34:35 +01:00
Viktor Renkema bd330ac133 Remove tracking-wider in search breadcrumbs (#4009) 2026-02-16 11:34:14 +00:00
Nolann B. e31a8f4275 Hide cookie banner for AI user agents (#3999) 2026-02-16 11:49:03 +01:00
Greg Bergé cc38ec2098 Fix regression showing page actions on page without title + description (#4004) 2026-02-13 10:53:25 +01:00
Nolann B. eab11e2d6f Increase timeout for recursive parse test (#4003) 2026-02-13 09:10:25 +00:00
Zeno Kapitein 152490cd67 Normalize undefined site-space language to English in variant categorization (fix language selector) (#4002) 2026-02-13 09:43:45 +01:00
conico974 833b07b284 Bump Vercel version to 50.15.1 (#4001) 2026-02-13 09:43:27 +01:00
Nolann B. 5f668605c2 Use isCookiesTrackingDisabled for cookie consent integrations (#3997)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 20:40:18 +01:00
conico974 9c762065f2 Bump Vercel CLI version (#4000) 2026-02-12 18:52:40 +01:00
Viktor Renkema 4af7df8902 Updates to style and interactions of toolbar (#3998) 2026-02-12 16:08:05 +01:00
Zeno Kapitein a3f854e1a8 Better localise French ask label (#3996) 2026-02-12 15:02:43 +01:00
Zeno Kapitein 4c54820055 Improve Docs Embed layout (#3994) 2026-02-12 14:06:37 +01:00
Zeno Kapitein 22f574da9c Fix followup & suggested questions overflow (#3995) 2026-02-12 14:06:28 +01:00
Zeno Kapitein 426aabe39a Fix sidebar on bold themes (#3993) 2026-02-12 11:48:43 +01:00
Taran Vohra 64de8d2cf6 Add e2e test for language variant navigation (#3992) 2026-02-12 15:07:27 +05:30
Greg Bergé b4b471fab6 Fix unexpected error thrown when using the embed in standalone mode (#3985) 2026-02-11 15:15:00 +01:00
Samy Pessé 17b32994f6 Version Packages (#3990)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-11 14:37:35 +01:00
Greg Bergé bcc0ef8666 Fix links handling in embedded Assistant (#3987) 2026-02-11 13:57:48 +01:00
Greg Bergé 6ede6ee7c6 Add Korean translations (#3986) 2026-02-10 15:41:28 +01:00
Nolann B. 7a11861d36 Add support for custom cookie banner registration (#3925) 2026-02-10 11:28:24 +01:00
Greg Bergé 10754fbaf4 Fix GitBook icon not displayed in trademark (#3984) 2026-02-10 10:58:30 +01:00
Zeno Kapitein 8f32f4fb1e Better handle huge section groups (#3978) 2026-02-09 15:13:01 +01:00
Nolann B. 717ad0b300 Fix OpenAPI try it button justify (#3975) 2026-02-05 11:16:58 +01:00
Nolann B. a3eed52f24 Refine OpenAPI schema alternatives/allOf handling (#3973) 2026-02-05 11:00:59 +01:00
Greg Bergé 27a34b7ee2 Support defaultExpanded in Expandable block (#3974) 2026-02-04 11:49:14 +01:00
Zeno Kapitein bf3ad28c7f Add margin to TOC items after page group (#3972) 2026-02-03 16:10:32 +01:00
Zeno Kapitein 70c2e90e4b Handle empty page section search results (#3971) 2026-02-03 16:10:13 +01:00
Greg Bergé d0a378cdb6 Disallow Markdown pages crawling (#3970) 2026-02-03 09:40:17 +01:00
Viktor Renkema 81f8ddcf27 Fix flaky Updates blocks in visual tests (#3969) 2026-02-02 17:32:46 +01:00
Zeno Kapitein 9d6457f814 OpenAPI styling tweaks (#3968) 2026-01-30 19:02:30 +01:00
Zeno Kapitein 07c02ebcd2 Header & TOC styling tweaks (#3967) 2026-01-30 15:23:16 +01:00
Samy Pessé 4d3172d13f Rollout to 100% the dereferencing of reusable content at the API level (#3966) 2026-01-30 09:01:12 +01:00
Zeno Kapitein 0aa34cc9a8 Fix Embed options in React (#3964) 2026-01-29 17:32:56 +01:00
Greg Bergé 551d30cfa4 Fix loading of images in PDF export (#3958)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-01-29 15:55:42 +01:00
Samy Pessé 844a0a8ea7 Fix CI for visual testing 2026-01-29 15:26:41 +01:00
Samy Pessé eb8ba66a66 Ensure deployments to preview start when approval job is skipped 2026-01-29 14:06:48 +01:00
Hirotaka Miyagi d738e118a0 Fix: Prevent form submission during IME composition (#3942)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Addison <42930383+addisonschultz@users.noreply.github.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-01-29 12:46:52 +00:00
Samy Pessé c16d08f5d5 Add approval step for external contributors' PRs (#3963) 2026-01-29 11:33:13 +01:00
Samy Pessé 61ec9b25b9 Enable approved PR deployments for preview workflows (#3954) 2026-01-29 11:16:00 +01:00
Samy Pessé b57561d395 Fix rendering of images in reusable content from an external space (#3960) 2026-01-29 10:42:25 +01:00
Zeno Kapitein e00d1c5bbd Support code themes customization option, refactor built-in code themes (#3949) 2026-01-28 13:48:56 +01:00
conico974 9063b56c89 Fix for the assistant (#3948) 2026-01-28 11:16:50 +01:00
Greg Bergé 020d3668d7 Make the button icon validation safer (#3955) 2026-01-28 10:44:52 +01:00
Greg Bergé 4d30cbf058 Fix social links not appearing on desktop (#3950) 2026-01-28 10:44:40 +01:00
Samy Pessé d3f6c38f05 Update scalar dependencies (#3956) 2026-01-28 10:29:56 +01:00
Samy Pessé 6f1db32682 Simplify local development and contributions (#3952) 2026-01-28 09:32:56 +01:00
Samy Pessé 4ad6b2c067 Increase rollout of reusable content deref in the API to 80% (#3951) 2026-01-28 09:22:19 +01:00
Viktor Renkema 07449443a0 Fix rendering of Expandables within Update blocks (#3947) 2026-01-27 20:26:33 +01:00
Greg Bergé b09cc64fec Fix GIF rendering (#3946) 2026-01-27 17:34:02 +01:00
Greg Bergé 1eed2004f6 Avoid keyboard-shortcut flicker by adding OS class and using CSS (#3944) 2026-01-27 17:02:49 +01:00
Greg Bergé 91539b8187 Fix navigation loader (#3945) 2026-01-27 17:01:06 +01:00
Samy Pessé 16cfde87ee Increase rollout of reusable content deref in the API to 50% (#3943) 2026-01-27 12:22:09 +01:00
conico974 58b4a978ff Update Next.js dependency to version 15.4.11 (#3940) 2026-01-27 09:26:11 +01:00
Viktor Renkema e34be0e89b Fix logo sizing bug during navigation (#3928) 2026-01-27 08:46:37 +01:00
Samy Pessé e60c754343 Increase rollout of reusable content deref in the API to 30% (#3939) 2026-01-26 16:22:13 +01:00
Nolann B. 48ca21e471 Add OpenAPI 3.1 nullable array support (#3938) 2026-01-26 14:05:14 +01:00
Nolann B. 3ba9e46f2b Improve OpenAPI server URL validation (#3937) 2026-01-26 13:45:03 +01:00
Greg Bergé 58f0cc8287 Fix search field clear button (#3936) 2026-01-24 16:26:03 +01:00
Nolann B. 29e2b22b63 Fix ordered list item index calculation (#3935) 2026-01-23 16:01:33 +01:00
Greg Bergé 3c7cb09c1b Respect DPR to ensure a good image quality (#3931) 2026-01-23 11:45:00 +01:00
Greg Bergé 0f1cd6745d Refactor search (#3932) 2026-01-23 11:13:20 +01:00
Samy Pessé e90f57b138 Deference reusable content at the API level (#3847) 2026-01-23 10:34:44 +01:00
conico974 7bd5939247 Bust data cache (#3927) 2026-01-22 13:59:35 +01:00
Zeno Kapitein 89de966e7a Support social accounts customization option (#3916) 2026-01-22 13:57:40 +01:00
Greg Bergé 4e2d86318e Fix OpenAPI example not showing (#3930)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 12:11:55 +01:00
300 changed files with 10525 additions and 2175 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Set <html lang> based on translated language
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/embed": patch
---
Fix signed JWT token usage
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix Variant dropdown not showing in GitBook embed
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Align styling of PageLinkItem and PageDocumentItem
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Better conditional display of page actions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix site redirects for malformed encoded path segments
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Standardise toggle chevrons across the app
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Grow InlineActionButton to take available width on line
@@ -26,7 +26,7 @@ inputs:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }}
runs:
@@ -52,6 +52,8 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
@@ -107,7 +109,7 @@ runs:
wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
+3 -1
View File
@@ -20,7 +20,7 @@ inputs:
description: 'Environment to deploy to'
required: true
outputs:
deployment-url:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
@@ -51,6 +51,8 @@ runs:
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
+2 -7
View File
@@ -5,8 +5,6 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -36,13 +34,10 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
build:
runs-on: ubuntu-latest
name: Build (Open Source)
name: Build
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+47 -11
View File
@@ -1,15 +1,25 @@
name: Preview
on:
pull_request:
pull_request_target:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
branches:
- main
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
name: Approve Preview Deployment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -18,6 +28,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Vercel
id: deploy
uses: ./.github/composite/deploy-vercel
@@ -31,6 +43,8 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -39,6 +53,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Cloudflare
id: deploy
uses: ./.github/composite/deploy-cloudflare
@@ -65,7 +81,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && !startsWith(github.ref, 'refs/heads/main')
if: always() && github.event_name != 'push'
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -102,10 +118,13 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -117,16 +136,20 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-vercel'
ARGOS_BUILD_NAME: v2-vercel
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -138,16 +161,20 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
ARGOS_BUILD_NAME: v2-cloudflare
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -159,16 +186,20 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
ARGOS_BUILD_NAME: customers-v2-vercel
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -180,15 +211,19 @@ jobs:
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
ARGOS_BUILD_NAME: customers-v2-cloudflare
browserless-testing-v2-vercel:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -200,6 +235,7 @@ jobs:
env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
# browserless-testing-v2-cloudflare:
# runs-on: ubuntu-latest
# name: Browserless Testing v2 (Cloudflare)
@@ -217,4 +253,4 @@ jobs:
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
-3
View File
@@ -4,9 +4,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
+3
View File
@@ -0,0 +1,3 @@
# AGENTS Instructions
- After making code changes, run `bun format`.
+1 -17
View File
@@ -77,23 +77,7 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
```
and then reinstall dependencies.
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
## Contributing
+864 -218
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+5 -5
View File
@@ -6,11 +6,11 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.4",
"vercel": "^39.4.2"
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.16",
"vercel": "^50.31.1"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.7",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.158.0",
"@gitbook/api": "0.167.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+15
View File
@@ -1,5 +1,20 @@
# @gitbook/browser-types
## 0.1.5
### Patch Changes
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 2e495cb: Add Global Privacy Control (GPC) support
## 0.1.4
### Patch Changes
- 7a11861: Add support for custom cookie banner registration
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
}
},
"sideEffects": false,
"version": "0.1.3",
"version": "0.1.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
+24
View File
@@ -46,6 +46,11 @@ export type GitBookAssistant = {
ui?: boolean;
};
export type GitBookCookieBannerHandler = (options: {
onApprove: () => void;
onReject: () => void;
}) => void;
export type GitBookGlobal = {
/**
* Register an event listener.
@@ -72,6 +77,25 @@ export type GitBookGlobal = {
* Register a custom assistant to be available on the site.
*/
registerAssistant: (assistant: GitBookAssistant) => () => void;
/**
* Register a custom cookie banner handler.
* When registered, the built-in cookie banner will not be displayed.
*/
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
/**
* Indicates whether cookies tracking is disabled for the current user.
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
* and `undefined` when the tracking preference is unknown or not yet determined.
*/
isCookiesTrackingDisabled: () => boolean | undefined;
/**
* Indicates whether global privacy control is enabled for the current user.
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
*/
isGlobalPrivacyControlEnabled: () => boolean;
};
declare global {
+27
View File
@@ -1,5 +1,32 @@
# @gitbook/embed
## 0.4.0
### Minor Changes
- b3875a1: Support `assistantName` property to override Assistant name
## 0.3.0
### Minor Changes
- 370b731: Docs Embed: Make trademark optional
## 0.2.2
### Patch Changes
- b4b471f: Fix unexpected error thrown when using the embed in standalone mode
## 0.2.1
### Patch Changes
- 96e24a1: Fix signed JWT token usage
- 0aa34cc: Fix Embed options in React
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.2.0
### Minor Changes
+71 -6
View File
@@ -49,8 +49,10 @@ GitBook('configure', {
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
tools: [/* ... */],
closeButton: true,
});
```
@@ -99,8 +101,10 @@ frame.configure({
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
assistantName: 'Support Assistant',
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
tools: [/* ... */],
closeButton: true
});
// Listen to events
@@ -124,6 +128,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
@@ -133,6 +138,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}
]}
tools={[/* ... */]}
closeButton
/>
</GitBookProvider>
```
@@ -223,15 +229,62 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `closeButton`
Available in: Standalone script, NPM package, React components
Display a close (`x`) button in the embed sidebar.
- **Type**: `boolean`
- **Default**: `false`
```javascript
closeButton: true
```
Behavior:
- When clicked, the embed sends a `close` event to the parent page.
- In the standalone script, this event is handled automatically and the widget closes.
- In custom iframe integrations (NPM package), you must listen for the `close` event and decide how to hide/collapse your UI.
- In React, this works automatically when using the standard widget flow. If you build custom frame wiring, handle `close` the same way as the NPM package.
NPM package example:
```typescript
const frame = gitbook.createFrame(iframe);
frame.configure({
closeButton: true
});
const unsubscribe = frame.on('close', () => {
// Hide your modal/drawer/container
container.classList.add('hidden');
});
```
Notes:
- The close button is rendered in the same sidebar area as tabs/actions.
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
### `trademark`
Available in: Standalone script, NPM package, React components
Display GitBook branding in the embed. Defaults to true.
- **Type**: `boolean`
- **Default**: `true`
```javascript
trademark: true
```
### `actions`
Available in: Standalone script, NPM package, React components
@@ -278,6 +331,18 @@ greeting: {
}
```
### `assistantName`
Available in: Standalone script, NPM package, React components
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
- **Type**: `string`
```javascript
assistantName: 'Support Assistant'
```
### `suggestions`
Available in: Standalone script, NPM package, React components
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.2.0",
"version": "0.4.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -69,6 +69,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
trademark: true,
};
return {
+16
View File
@@ -62,11 +62,27 @@ export type GitBookEmbeddableConfiguration = {
subtitle: string;
};
/**
* Override the assistant name displayed in the UI.
* Limited to 32 characters.
*/
assistantName?: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
/**
* Display GitBook branding in the embed.
*/
trademark?: boolean;
/**
* Display a close button inside the assistant.
*/
closeButton?: boolean;
};
/**
+28 -4
View File
@@ -11,13 +11,24 @@ import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
GitBookEmbeddableConfiguration;
Partial<GitBookEmbeddableConfiguration>;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, actions, greeting, suggestions, tools } = props;
const {
className,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
trademark = true,
closeButton = false,
assistantName,
} = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
@@ -33,13 +44,26 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs: ['assistant', 'docs'],
tabs,
actions,
greeting,
suggestions,
tools,
closeButton,
trademark,
assistantName,
});
}, [gitbookFrame, actions, greeting, suggestions, tools]);
}, [
gitbookFrame,
actions,
greeting,
suggestions,
tools,
tabs,
closeButton,
trademark,
assistantName,
]);
return (
<iframe
+18 -2
View File
@@ -65,6 +65,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
trademark: true,
};
const widgetButton = document.createElement('button');
@@ -106,6 +107,10 @@ function getIframe() {
widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe);
_frame.on('close', () => {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -152,6 +157,19 @@ const GitBook = (...args: StandaloneCalls) => {
break;
case 'configure': {
const settings = args[1];
// If trademark is disabled, change the (branded) icon to the sparkle icon
if (
settings.trademark === false &&
!settings.button?.icon &&
frameConfiguration.button.icon === 'assistant'
) {
settings.button = {
label: frameConfiguration.button.label,
icon: 'sparkle',
};
}
frameConfiguration = {
...frameConfiguration,
...settings,
@@ -193,5 +211,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
+2 -2
View File
@@ -11,8 +11,8 @@
},
"sideEffects": false,
"dependencies": {
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
+102
View File
@@ -1,5 +1,107 @@
# gitbook
## 0.22.3
### Patch Changes
- f182680: Add backward compatibility for OpenAPI spec content URL
- bdc6cd9: Use OpenAPI publicURL as Scalar specUrl
- b3875a1: Refactor embeddable context to merge local & site properties in one unified way
- eceec6f: Move ~scalar/proxy route to site route group
- 887c3f8: Add token verification on OpenAPI proxy
- d666212: Add support for localized site section titles
- Updated dependencies [b3875a1]
- Updated dependencies [bdc6cd9]
- Updated dependencies [8ab419a]
- Updated dependencies [eceec6f]
- Updated dependencies [887c3f8]
- @gitbook/embed@0.4.0
- @gitbook/react-openapi@1.5.10
- @gitbook/openapi-parser@3.0.10
## 0.22.2
### Patch Changes
- e074283: Add server-side proxy for Scalar API client to bypass CORS
- afa868e: Show "Ask Assistant" search result immediately
- 0803db0: Track opening of search results for records and in the assistant.
- cb71511: Wrap tracking call with waitUntil
- 4a6f91a: Show social accounts in header
- 370b731: Docs Embed: Make trademark optional
- afa476c: Add server-side insight event tracking for MCP route
- Updated dependencies [e074283]
- Updated dependencies [370b731]
- @gitbook/openapi-parser@3.0.9
- @gitbook/react-openapi@1.5.9
- @gitbook/embed@0.3.0
## 0.22.1
### Patch Changes
- 9d5e397: Remove code syntax highlighting patch for Safari
- 3d8e89c: Show favicon for record search results in the assistant.
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 426aabe: Fix TOC header logo on bold themes
- ae30a57: Integrate Ask AI with upcoming insights by passing visitor session IDs.
- f7c251e: Update Reddit social account rendering
- e31a8f4: Hide cookie banner for AI user agents
- 2e495cb: Add Global Privacy Control (GPC) support
- Updated dependencies [1a7ef78]
- Updated dependencies [93eea0b]
- Updated dependencies [b4b471f]
- Updated dependencies [5f66860]
- Updated dependencies [2ebb18d]
- Updated dependencies [11d9b80]
- Updated dependencies [2e495cb]
- Updated dependencies [f517035]
- @gitbook/react-openapi@1.5.8
- @gitbook/embed@0.2.2
- @gitbook/browser-types@0.1.5
## 0.22.0
### Minor Changes
- e00d1c5: Support code themes customization
- f294818: Serve a markdown version of the page when the "Accept" header contains "text/markdown".
### Patch Changes
- 9d6457f: OpenAPI styling tweaks
- d738e11: Prevent form submission during IME composition
- 1e53376: Add sidesheet component, use it for TOC and AIChat
- f7f6f9d: Set <html lang> based on translated language
- 89de966: Support social links
- b53cb1d: Align styling of PageLinkItem and PageDocumentItem
- 27a34b7: Support `defaultExpanded` in Expandable block
- a5ef44a: Better conditional display of page actions
- 07c02eb: Header & TOC tweaks
- 29e2b22: Fix ordered list item index calculation
- f6cf62a: Standardise toggle chevrons across the app
- b57561d: Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
- e172b25: Grow InlineActionButton to take available width on line
- 7a11861: Add support for custom cookie banner registration
- 8f32f4f: Better handle huge section groups
- 0aa34cc: Fix Embed options in React
- Updated dependencies [e00d1c5]
- Updated dependencies [96e24a1]
- Updated dependencies [4e2d863]
- Updated dependencies [d3f6c38]
- Updated dependencies [3ba9e46]
- Updated dependencies [717ad0b]
- Updated dependencies [6f1db32]
- Updated dependencies [7a11861]
- Updated dependencies [0aa34cc]
- Updated dependencies [48ca21e]
- @gitbook/react-openapi@1.5.7
- @gitbook/embed@0.2.1
- @gitbook/openapi-parser@3.0.8
- @gitbook/icons@0.4.3
- @gitbook/browser-types@0.1.4
- @gitbook/react-contentkit@0.7.12
## 0.21.3
### Patch Changes
@@ -0,0 +1,76 @@
import { expect } from '@playwright/test';
import { type TestsCase, getCustomizationURL, runTestCases } from './util';
const testCases: TestsCase[] = [
{
name: 'Cookie Banner',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'should show built-in banner when no custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Check that built-in banner is visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when UserAgent is AI',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Override navigator.userAgent to simulate AI crawler (affects isAIUserAgent())
await page.addInitScript(() => {
Object.defineProperty(navigator, 'userAgent', {
get: () =>
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot',
configurable: true,
});
});
await page.reload();
// Check that built-in banner is not visible for AI UserAgent
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Register a custom cookie banner handler
await page.waitForFunction(() => {
return typeof window !== 'undefined' && window.GitBook !== undefined;
});
await page.evaluate(() => {
window.GitBook?.registerCookieBanner(() => {
// Custom cookie banner handler - no-op for testing to avoid reload
});
});
// Check that built-in banner is not visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
],
},
];
runTestCases(testCases);
-1
View File
@@ -232,7 +232,6 @@ const testCases: TestsCase[] = [
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
+182 -36
View File
@@ -8,16 +8,13 @@ import {
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
import jwt from 'jsonwebtoken';
import {
VISITOR_TOKEN_COOKIE,
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
} from '@/lib/visitors';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
import { getSiteAPIToken } from '../tests/utils';
import {
@@ -33,6 +30,7 @@ import {
getCustomizationURL,
headerLinks,
runTestCases,
setTimeToMorning,
waitForCookiesDialog,
waitForCoverImages,
waitForNotFound,
@@ -73,6 +71,7 @@ const searchTestCases: Test[] = [
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
await searchInput.focus();
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
@@ -98,6 +97,7 @@ const searchTestCases: Test[] = [
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.keyboard.press('ControlOrMeta+K');
await expect(page.getByTestId('search-input')).toBeFocused();
},
@@ -110,6 +110,7 @@ const searchTestCases: Test[] = [
},
})}&q=`,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
},
},
@@ -121,6 +122,7 @@ const searchTestCases: Test[] = [
},
})}&q=gitbook`,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
@@ -134,6 +136,7 @@ const searchTestCases: Test[] = [
},
})}&q=gitbook`,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible();
@@ -146,7 +149,9 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions
@@ -157,7 +162,7 @@ const searchTestCases: Test[] = [
const recommendedQuestions = await page
.getByTestId('search-recommended-question')
.all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
// Fill search input, expecting AI search option
await searchInput.fill(AI_PROMPT);
@@ -181,7 +186,9 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
@@ -198,6 +205,7 @@ const searchTestCases: Test[] = [
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
@@ -212,7 +220,9 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
})}&ask=`,
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible();
@@ -228,7 +238,9 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible();
@@ -257,6 +269,7 @@ const testCases: TestsCase[] = [
name: 'No variants dropdown',
url: '',
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
0
);
@@ -506,6 +519,72 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Language Site (Navigation when switching language variant)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
tests: [
{
name: 'Should resolve to the same page in the new language variant when switching language variant (Source English -> Target Dutch)',
url: 'ecosystem/connection-provider',
screenshot: false,
run: async (page) => {
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the dutch language variant in the dropdown
await variantSelectionDropdown
.getByRole('menuitem', {
name: 'Yjs (NL)',
})
.click();
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the NL variant
await page.waitForURL((url) =>
url.pathname.includes('nl/ecosysteem/connection-provider')
);
// Verify we are on the correct page by checking the h1
await expect(
page.getByRole('heading', { level: 1, name: 'Connectieprovider' })
).toBeVisible();
},
},
{
name: 'Should resolve to the same page in the new language variant when switching language variant (Source Dutch -> Target Finnish)',
url: 'nl/ecosysteem/connection-provider',
screenshot: false,
run: async (page) => {
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the finnish language variant in the dropdown
await variantSelectionDropdown
.getByRole('menuitem', {
name: 'Yjs (FI)',
})
.click();
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the FI variant
await page.waitForURL((url) =>
url.pathname.includes('fi/ekosysteemi/connection-provider')
);
// Verify we are on the correct page by checking the h1
await expect(
page.getByRole('heading', { level: 1, name: 'Yhteysvälittäjä' })
).toBeVisible();
},
},
],
},
{
name: 'GitBook Site (Sections and Section Groups)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
@@ -838,26 +917,31 @@ const testCases: TestsCase[] = [
name: 'Lists',
url: 'blocks/lists',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Code',
url: 'blocks/code',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Cards',
url: 'blocks/cards',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Math',
url: 'blocks/math',
run: async (page) => {
await waitForCookiesDialog(page);
await page.waitForFunction(() => {
const fonts = Array.from(document.fonts.values());
const mjxFonts = fonts.filter(
@@ -874,21 +958,25 @@ const testCases: TestsCase[] = [
name: 'Files',
url: 'blocks/files',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Embeds',
url: 'blocks/embeds',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Page links',
url: 'blocks/page-links',
fullPage: true,
run: waitForCookiesDialog,
},
{
name: 'Annotations',
url: 'blocks/annotations',
run: async (page) => {
await waitForCookiesDialog(page);
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
},
@@ -896,8 +984,18 @@ const testCases: TestsCase[] = [
{
name: 'Stepper',
url: 'blocks/stepper',
run: waitForCookiesDialog,
},
{
name: 'Columns',
url: 'blocks/columns',
run: waitForCookiesDialog,
},
{
name: 'Mermaid',
url: 'blocks/mermaid',
run: waitForCookiesDialog,
},
{ name: 'Columns', url: 'blocks/columns' },
],
},
{
@@ -925,7 +1023,10 @@ const testCases: TestsCase[] = [
toggeable: false,
},
})}`,
run: waitForCookiesDialog,
run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page, { darkMode: true });
},
},
{
name: 'With hero cover',
@@ -1123,6 +1224,16 @@ const testCases: TestsCase[] = [
},
]),
},
{
name: 'Reusable contents',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/reusable-contents/',
tests: [
{
name: 'All cases',
url: '',
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1139,6 +1250,36 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Social links',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'With social links',
url: getCustomizationURL({
socialAccounts: [
{
platform: SiteSocialAccountPlatform.Github,
display: { footer: true },
handle: 'GitbookIO',
},
{
platform: SiteSocialAccountPlatform.Linkedin,
display: { footer: true },
handle: 'gitbook',
},
{
platform: SiteSocialAccountPlatform.Twitter,
display: { footer: false },
handle: 'GitBookIO',
},
],
}),
fullPage: true,
run: waitForCookiesDialog,
},
],
},
{
name: 'Ads',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1504,33 +1645,36 @@ const testCases: TestsCase[] = [
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
tests: [
{
name: 'Redirect to fallback on invalid token pulled from cookie',
url: '',
screenshot: false,
cookies: (() => {
const basePath = '/va-site-redirects-fallback/';
const invalidToken = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
}
);
return [
{
name: getVisitorAuthCookieName(basePath),
value: getVisitorAuthCookieValue(basePath, invalidToken),
httpOnly: true,
},
];
})(),
run: async (page) => {
await expect(page).toHaveURL(/https:\/\/www.google.com/);
},
},
// This test does not work on Playwright
// Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
// @see https://github.com/microsoft/playwright/issues/34889
// {
// name: 'Redirect to fallback on invalid token pulled from cookie',
// url: '',
// screenshot: false,
// cookies: (() => {
// const basePath = '/va-site-redirects-fallback/';
// const invalidToken = jwt.sign(
// {
// name: 'gitbook-open-tests',
// },
// 'invalidKey',
// {
// expiresIn: '24h',
// }
// );
// return [
// {
// name: getVisitorAuthCookieName(basePath),
// value: getVisitorAuthCookieValue(basePath, invalidToken),
// httpOnly: true,
// },
// ];
// })(),
// run: async (page) => {
// await expect(page).toHaveURL(/https:\/\/www.google.com/);
// },
// },
{
name: 'Show error message when invalid token is passed to url',
screenshot: false,
@@ -1979,6 +2123,8 @@ const testCases: TestsCase[] = [
name: 'Switch between tabs',
url: '',
run: async (page) => {
await setTimeToMorning(page);
await page.reload();
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
const iframe = page.frameLocator('#gitbook-widget-iframe');
await iframe.getByTestId('embed-tab-docs').click(); // Switch to docs tab
@@ -2174,7 +2320,7 @@ const testCases: TestsCase[] = [
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
);
const toolConfirmation = iframe
.getByTestId('ai-chat-tool-confirmation')
.getByTestId('ai-chat-tool-confirm-accept')
.first();
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
+4 -4
View File
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - e2e - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - e2e - single-page', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
+23 -2
View File
@@ -2,6 +2,7 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
@@ -154,9 +155,19 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
export async function waitForCoverImages(page: Page) {
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
await page.clock.install({ time: now });
}
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
const selector = options?.darkMode
? 'img[alt="Page cover"].dark\\:inline'
: 'img[alt="Page cover"]:not(.dark\\:inline)';
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
await expect(page.locator(selector)).toBeVisible({
timeout: 10_000,
});
}
@@ -298,6 +309,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
codeTheme: {
default: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
+2
View File
@@ -4,6 +4,7 @@
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -25,6 +26,7 @@ const nextConfig = {
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
@@ -8,6 +8,9 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
@@ -8,6 +8,9 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"env": {
"preview": {
"vars": {
@@ -8,6 +8,9 @@
"allow_importable_env",
"global_fetch_strictly_public"
],
"observability": {
"enabled": false
},
"assets": {
"directory": "../../.open-next/assets",
"binding": "ASSETS"
@@ -6,12 +6,13 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
const originalTagCache = doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
// We can set a long TTL for the regional cache, as we invalidate it on update
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
// Because we invalidate the Cache API on update, we can safely set this to true
regionalCacheDangerouslyPersistMissingTags: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
numberOfHardReplicas: 2,
regionalReplication: {
defaultRegion: 'enam',
},
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.21.3",
"version": "0.22.3",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -17,6 +17,7 @@
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
@@ -46,10 +47,12 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.12.3",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "15.4.10",
"negotiator": "^1.0.0",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -67,7 +70,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -77,12 +80,12 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zustand": "^5.0.3",
"negotiator": "^1.0.0"
"zod-to-json-schema": "^3.25.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2",
"@argos-ci/playwright": "^6.4.2",
"@playwright/test": "^1.58.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6",
@@ -105,7 +108,7 @@
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^39.3.0",
"vercel": "^50.15.1",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
@@ -120,7 +123,7 @@
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
@@ -0,0 +1,162 @@
import type {
ComputedPageResult,
ComputedSectionResult,
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { GitBookBaseContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import type {
SearchPageResult,
SearchSpaceResult,
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const body = (await request.json()) as SearchSiteContentRequest;
const { query, scope } = body;
if (query.length <= 1) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
(async () => {
const result = await throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
);
return result;
})(),
(async () => {
const result = await throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
);
return result;
})(),
]);
const results = searchResults
.map((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
id: resultItem.id,
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
};
return result;
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return resultItem.pages.map((pageItem) =>
transformSitePageResult(context, {
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
);
})
.flat(2);
return NextResponse.json(results);
}
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: siteSectionGroup.title,
},
siteSection && {
icon: siteSection?.icon as IconName,
label: siteSection.title,
},
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection?.siteSpaces?.filter(
(s) => s.space.language === space.space.language
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: siteSpace.title,
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? [];
return [page, ...pageSections];
}
@@ -0,0 +1,32 @@
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function POST(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PUT(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function HEAD(request: NextRequest) {
return handleOpenAPIProxyRequest(request);
}
export async function OPTIONS() {
return handleOpenAPIProxyOptions();
}
@@ -18,6 +18,14 @@ export async function GET(
<html>
<head>
<meta name="color-scheme" content="light dark">
<style type="text/css">
.control {
display: none;
}
.custom-trigger .control {
display: inline-flex;
}
</style>
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -105,17 +113,29 @@ export async function GET(
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'docs'],
closeButton: useCustomTrigger
});
if (useCustomTrigger) {
document.body.classList.add('custom-trigger');
window.GitBook('hide');
} else {
window.GitBook('open');
}
</script>
</html>
`,
@@ -24,10 +24,22 @@ export async function GET(
(function () {
const w = window;
const gb = w.GitBook;
function getScriptSearchParams() {
const script = document.currentScript;
if (!script) return new URLSearchParams();
const url = new URL(script.src);
return url.searchParams;
}
const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token');
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
const initFrameOptions = token ? { visitor: { token } } : undefined;
if (typeof gb === "function") {
gb('init', initOptions);
gb('init', initOptions, initFrameOptions);
} else {
var d = document;
@@ -40,7 +52,7 @@ export async function GET(
};
w.GitBook = g;
g('init', initOptions);
g('init', initOptions, initFrameOptions);
const load = function () {
const style = document.createElement('link');
@@ -1,7 +1,11 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy } from '@/lib/sites';
import { trackServerInsightsEvents } from '@/lib/tracking';
import { waitUntil } from '@/lib/waitUntil';
import { createMcpHandler } from 'mcp-handler';
import type { NextRequest } from 'next/server';
import { z } from 'zod';
@@ -31,18 +35,49 @@ async function handler(
})
);
// Track the search event server-side
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request: nextRequest,
})
);
return {
content: results.flatMap((spaceResult) => {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
+2
View File
@@ -11,6 +11,7 @@ import {
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
GITBOOK_URL,
GITBOOK_USER_AGENT,
@@ -25,6 +26,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_APP_URL,
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -21,16 +21,15 @@ export async function getSpacePDFContext(
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
});
const baseContext: GitBookBaseContext = {
linker,
linker: createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
}),
dataFetcher,
};
@@ -0,0 +1,55 @@
'use client';
import { Button } from '@/components/primitives';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
export const ConfirmControlOutputSchema = z.object({
result: z.enum(['confirmed', 'cancelled']),
});
export const ConfirmControlDef = createAIControl({
name: 'confirm',
exposeAsTool: false,
description:
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.',
inputSchema: z.object({
icon: z
.string()
.optional()
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
label: z.string().describe('Label to be shown in the confirm button.'),
}),
outputSchema: ConfirmControlOutputSchema,
render: (props) => {
return <ConfirmControl {...props} />;
},
});
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
data-testid="ai-chat-tool-confirm-cancel"
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
variant="secondary"
icon="xmark"
label="Cancel"
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
className="flex-1"
variant="primary"
icon={icon}
label={label}
/>
</AIToolContainer>
);
}
@@ -0,0 +1,203 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
const OTHER_OPTION_ID = '$other';
export const SingleChoiceControlOutputSchema = z.object({
id: z.string().describe('The identifier of the option selected by the user.'),
input: z
.string()
.optional()
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
});
export const SingleChoiceControlDef = createAIControl({
name: 'single-choice',
exposeAsTool: true,
description:
'Use this control when you need the user to choose exactly one option from a predefined list.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what single choice they need to make.'
),
options: z
.array(
z
.object({
id: z
.string()
.describe(
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
),
label: z
.string()
.describe('Provide the short label the user sees for this option.'),
description: z
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option.'
),
})
.describe('Define one selectable option the user can pick.')
)
.describe(
'Provide the list of options the user can choose from. The user must select exactly one.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: SingleChoiceControlOutputSchema,
render: (props) => {
return <SingleChoiceControl {...props} />;
},
});
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [otherInput, setOtherInput] = React.useState('');
const canSubmit =
selectedId !== null &&
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<div className="no-scrollbar flex flex-1 flex-col gap-1 overflow-auto">
<p className="mb-1 font-semibold text-sm">{prompt}</p>
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
onClick={() => {
setSelectedId(option.id);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
isSelected
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
isSelected
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
isSelected ? 'bg-primary-original' : 'bg-transparent'
)}
/>
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-sm text-tint-subtle">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-single-choice-option-other"
onClick={() => {
setSelectedId(OTHER_OPTION_ID);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
)}
>
<div className="flex items-center gap-3">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
selectedId === OTHER_OPTION_ID
? 'border-primary-original'
: 'border-tint-subtle'
)}
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
)}
/>
</span>
<div className="flex flex-1 flex-col gap-1">
<p className="font-medium text-sm">Other</p>
{allowOther && selectedId === OTHER_OPTION_ID ? (
<Input
label="Enter your answer"
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-single-choice-other-input"
autoFocus
className="w-full"
sizing="small"
/>
) : null}
</div>
</div>
</button>
) : null}
</div>
<Button
data-testid="ai-chat-tool-single-choice-submit"
variant="primary"
label="Submit answer"
disabled={!canSubmit}
onClick={() => {
if (!canSubmit || !selectedId) {
return;
}
if (selectedId === OTHER_OPTION_ID) {
onSubmit({
id: OTHER_OPTION_ID,
input: otherInput.trim(),
});
return;
}
onSubmit({ id: selectedId });
}}
/>
</AIToolContainer>
);
}
@@ -0,0 +1,15 @@
import { tcls } from '@/lib/tailwind';
import type { ComponentPropsWithRef } from 'react';
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
return (
<div
{...props}
className={tcls(
'min-h-0 animate-present-slow circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-subtle p-2',
props.className
)}
style={{ animationDelay: '0.5s', ...props.style }}
/>
);
}
@@ -0,0 +1,80 @@
import type {
AIStreamResponseToolCallPending,
AIToolCallResult,
AIToolDefinition,
} from '@gitbook/api';
import type { ZodType, z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
type AIControlDefinition<
Name extends string = any,
Input extends Record<string, unknown> = any,
Output extends Record<string, unknown> = any,
> = AIToolDefinition & {
createControl: (args: {
context: AIUIToolContext;
input: Input;
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
}) => AIControl<Name, Input, Output>;
exposeAsTool: boolean;
};
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
onSubmit: (output: Output) => Promise<void>;
};
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
props: AIControlProps<Input, Output>;
name: Name;
context: AIUIToolContext;
render: () => React.ReactNode;
};
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
export function createAIControl<
Name extends string,
InputSchema extends ZodType<Record<string, unknown>>,
OutputSchema extends ZodType<Record<string, unknown>>,
>(def: {
name: Name;
description: string;
inputSchema: InputSchema;
outputSchema: OutputSchema;
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
/**
* Indicates if the control should be exposed as a tool or not.
*/
exposeAsTool: boolean;
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
return {
name: `ui--${def.name}`,
description: def.description,
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
createControl: ({ context, input, send }) => {
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
...input,
onSubmit: async (output) => {
await send({
output,
summary: {
icon: 'check',
text: 'Submitted',
},
});
},
};
return {
props,
name: def.name,
context,
render: () => def.render(props),
};
},
exposeAsTool: def.exposeAsTool,
};
}
@@ -0,0 +1,11 @@
import { ConfirmControlDef } from './ConfirmControl';
import { SingleChoiceControlDef } from './SingleChoiceControl';
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
export type AnyAIControlTool = (typeof CONTROLS)[number];
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
export function getControlTools(): AnyAIControlTool[] {
return CONTROLS.filter((control) => control.exposeAsTool);
}
@@ -1,5 +1,6 @@
import { HighlightQuery } from '@/components/Search/HighlightQuery';
import { Link, StyledLink } from '@/components/primitives';
import { Favicon } from '@/components/utils';
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import type { GitBookSiteContext } from '@/lib/context';
@@ -15,6 +16,7 @@ import type {
ContentRef,
} from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import type * as React from 'react';
/**
@@ -138,25 +140,37 @@ async function DescriptionForSearchToolCall(props: {
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
toolCall.results.map(async (result) => {
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
switch (result.type) {
case 'page': {
const resolved = await resolveContentRef(
result.anchor
? {
kind: 'anchor',
page: result.pageId,
space: result.spaceId,
anchor: result.anchor,
}
: {
kind: 'page',
page: result.pageId,
space: result.spaceId,
},
context
);
return {
...result,
href: resolved?.href || '#',
};
}
case 'record':
return {
...result,
href: result.url ?? '#',
};
default:
assertNever(result);
}
})
);
@@ -192,45 +206,80 @@ async function DescriptionForSearchToolCall(props: {
{hasResults ? (
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
key={`${result.pageId}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
{searchResultsWithHrefs.map((result, index) => {
const resultKey = (() => {
switch (result.type) {
case 'page':
return `${result.spaceId}/${result.pageId}`;
case 'record':
return result.recordId;
default:
assertNever(result);
}
})();
const iconClassName = 'mt-1 size-3 shrink-0 text-tint-subtle';
const icon = <Icon icon="memo" className={iconClassName} />;
return (
<li
key={`${resultKey}-${index}`}
className="animate-fade-in-slow"
style={{
animationDelay: `${index * 25}ms`,
}}
>
<Icon
icon="memo"
className="mt-1 size-3 shrink-0 text-tint-subtle"
/>
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.title}
/>
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<Link
href={result.href}
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
insights={
result.type === 'record'
? {
type: 'search_open_result',
query: toolCall.query,
result: {
recordId: result.recordId,
},
}
: {
type: 'search_open_result',
query: toolCall.query,
result: {
spaceId: result.spaceId,
pageId: result.pageId,
},
}
}
>
{result.type === 'record' ? (
<Favicon url={result.href} className={iconClassName} />
) : (
icon
)}
<div className="flex flex-col gap-1 text-tint">
<h3 className="line-clamp-2 font-medium text-sm text-tint">
<HighlightQuery
query={toolCall.query}
text={result.description}
text={result.title}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
))}
</h3>
{result.description && (
<p className="line-clamp-2 text-tint-subtle text-xs">
<HighlightQuery
query={toolCall.query}
text={result.description}
/>
</p>
)}
</div>
<Icon
icon="chevron-right"
className="ml-auto size-3 shrink-0 self-center"
/>
</Link>
</li>
);
})}
</ol>
</div>
) : null}
@@ -9,6 +9,7 @@ import {
AIModel,
type AIToolCallResult,
type AIToolDefinition,
type SiteInsightsSession,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
@@ -22,6 +23,7 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
options,
}: {
message?: string;
@@ -29,6 +31,7 @@ export async function* streamAIChatResponse({
previousResponseId?: string;
toolCall?: AIToolCallResult;
tools?: AIToolDefinition[];
session: SiteInsightsSession;
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
@@ -57,6 +60,7 @@ export async function* streamAIChatResponse({
previousResponseId,
toolCall,
tools,
session,
}
);
@@ -0,0 +1,8 @@
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
}
+7 -6
View File
@@ -10,13 +10,14 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch';
import { useSetSearchState } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean;
assistantName?: string;
greeting?: {
title: string;
subtitle: string;
@@ -54,10 +55,10 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, suggestions, greeting, children } = props;
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
const value = React.useMemo(
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
[aiMode, trademark, suggestions, greeting, assistantName]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
@@ -84,14 +85,14 @@ export function useAI(): AIContext {
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const assistants: Assistant[] = [];
if (config.aiMode === CustomizationAIMode.Assistant) {
assistants.push({
id: 'gitbook-assistant',
label: getAIChatName(language, config.trademark),
label: config.assistantName ?? getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
@@ -9,12 +9,15 @@ import {
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import * as React from 'react';
import { useTrackEvent } from '../Insights';
import { integrationsAssistantTools } from '../Integrations';
import { useSearch } from '../Search';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
export type AIChatMessage = {
@@ -23,21 +26,6 @@ export type AIChatMessage = {
query?: string;
};
export type AIChatPendingTool = {
icon?: IconName;
label: string;
/**
* Confirm the tool call by calling this function.
*/
confirm: () => Promise<void>;
/**
* Tool call result to cancel it.
*/
cancelToolCall: AIToolCallResult;
};
export type AIChatState = {
/**
* If true, the chat is open.
@@ -70,9 +58,9 @@ export type AIChatState = {
followUpSuggestions: string[];
/**
* Tools that are pending confirmation to be executed.
* Control to be displayed to the user.
*/
pendingTools: AIChatPendingTool[];
control: AnyAIControl | null;
/**
* If true, the session is in progress.
@@ -126,7 +114,7 @@ const globalState = zustand.create<AIChatState>(() => {
messages: [],
query: null,
followUpSuggestions: [],
pendingTools: [],
control: null,
loading: false,
error: false,
initialQuery: null,
@@ -163,7 +151,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
// Event listeners storage
@@ -212,7 +200,7 @@ export function AIChatProvider(props: {
return {
...state,
followUpSuggestions: [],
pendingTools: [],
control: null,
loading: true,
error: false,
messages: [
@@ -227,10 +215,10 @@ export function AIChatProvider(props: {
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const integrationTools = integrationsAssistantTools.getState().tools;
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
const tools = getTools();
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
@@ -263,15 +251,20 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const integrationTools = integrationsAssistantTools.getState().tools;
const tools = getTools();
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
tools: integrationTools.map((tool) => ({
session: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
@@ -315,46 +308,84 @@ export function AIChatProvider(props: {
break;
}
case 'response_tool_call_pending': {
const toolDef = integrationTools.find(
(tool) => tool.name === event.toolCall.tool
);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
const confirmation = toolDef.confirmation;
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
pendingTools: [
...state.pendingTools,
{
icon: confirmation.icon,
label: confirmation.label,
cancelToolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
cancelled: 'User did not confirm the tool call',
},
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
confirm: async () => {
await executeToolCall(event);
},
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
],
input: {
label: confirmation.label,
icon: confirmation.icon,
},
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
} else {
toolToExecute = event;
break;
}
toolToExecute = event;
break;
}
}
@@ -403,7 +434,11 @@ export function AIChatProvider(props: {
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, pendingTools } = globalState.getState();
const { query, messages, control } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// For first message, update the ask parameter in URL
if (messages.length === 0) {
@@ -448,15 +483,9 @@ export function AIChatProvider(props: {
};
});
const pendingTool = pendingTools[0];
streamResponse({
message: input.message,
// If we had a pending tool call, we need to send it as being cancelled
// otherwise the AI will fail to process the message
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
});
streamResponse({ message: input.message });
},
[setSearchState, trackEvent, streamResponse]
[setSearchState, trackEvent, streamResponse, language]
);
// Clear the conversation and reset ask parameter
@@ -467,7 +496,7 @@ export function AIChatProvider(props: {
messages: [],
query: null,
followUpSuggestions: [],
pendingTools: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
@@ -28,6 +28,7 @@ import { useNow } from '../hooks';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { AIChatControl } from './AIChatControl';
import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput';
@@ -87,11 +88,11 @@ export function AIChat() {
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader>
<EmbeddableFrameHeader className="not-embed:px-4">
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
{config.assistantName ?? getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -106,11 +107,12 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<EmbeddableFrameBody className="not-embed:px-4">
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
trademark={config.trademark}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
@@ -141,7 +143,7 @@ export function AIChatDynamicIcon(props: {
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.pendingTools.length > 0
? chat.control
? 'confirm'
: 'done'
: 'default'
@@ -199,13 +201,13 @@ export function AIChatBody(props: {
chat: AIChatState;
welcomeMessage?: string;
suggestions?: string[];
trademark?: boolean;
greeting?: {
title: string;
subtitle: string;
};
}) {
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const { chatController, chat, suggestions, greeting, trademark } = props;
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -223,8 +225,8 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -272,17 +274,21 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex flex-col gap-2 px-4 pb-4">
<div className="flex min-h-0 flex-col gap-2 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
{chat.control ? (
<AIChatControl control={chat.control} />
) : (
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
)}
</div>
</>
);
@@ -18,7 +18,7 @@ export function AIChatButton(props: {
}) {
const { assistant, showLabel = true, withShortcut = true } = props;
const language = useLanguage();
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '#header-content');
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
return (
<Button
@@ -0,0 +1,8 @@
'use client';
import type { AnyAIControl } from '../AI/controls';
export function AIChatControl(props: { control: AnyAIControl }) {
const { control } = props;
return control.render();
}
@@ -3,7 +3,6 @@ import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -67,7 +66,6 @@ export function AIChatMessages(props: {
'break-words',
'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User
? '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'
@@ -96,14 +94,13 @@ export function AIChatMessages(props: {
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
@@ -20,11 +20,12 @@ export default function AIChatSuggestedQuestions(props: {
return (
<div
className="flex flex-col items-start gap-2 self-start"
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
data-testid="ai-chat-suggested-questions"
>
{suggestions.map((question, index) => (
<Button
truncate={false}
data-testid="ai-chat-suggested-question"
key={question}
variant="blank"
@@ -1,70 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { useHotkeys } from 'react-hotkeys-hook';
import type { AIChatState } from '../AI';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
/**
* Display buttons to confirm tool calls.
*/
export function AIChatToolConfirmations(props: {
chat: AIChatState;
}) {
const { chat } = props;
const language = useLanguage();
useHotkeys(
'mod+enter',
(e) => {
e.preventDefault();
chat.pendingTools[0]?.confirm();
},
{
enableOnFormTags: true,
},
[chat.pendingTools]
);
return (
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
{chat.pendingTools.map((tool, index) => (
<div
className="flex w-full animate-present-slow flex-col gap-1"
key={index}
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
>
<Button
data-testid="ai-chat-tool-confirmation"
onClick={() => {
tool.confirm();
}}
tabIndex={index}
label={tool.label}
className="w-full justify-center"
size={index === 0 ? 'medium' : 'small'}
variant={index === 0 ? 'primary' : 'secondary'}
icon={tool.icon}
/>
{index === 0 && (
<div
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
style={{ animationDelay: '1000ms' }}
>
{t(
language,
'press_to_confirm',
<KeyboardShortcut
keys={['mod', 'enter']}
className="mx-0 text-tint"
/>
)}
</div>
)}
</div>
))}
</div>
);
}
@@ -17,18 +17,19 @@ export function AIChatFollowupSuggestions(props: {
return (
<div className="flex grow flex-col">
<div
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
>
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
truncate={false}
data-testid="ai-chat-followup-suggestion"
key={index}
onClick={() => {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
className="starting:h-0 max-w-full origin-left animate-blur-in-slow border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 hover:bg-primary-hover"
size="small"
variant="blank"
style={{
@@ -1,10 +1,9 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig } from 'motion/react';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -14,7 +13,6 @@ import {
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSeparator,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -22,6 +20,7 @@ import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
@@ -41,7 +40,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
onToggleMinify,
});
const visitorSession = useVisitorSession();
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
@@ -81,7 +80,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) {
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
@@ -132,7 +131,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -141,14 +140,13 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -207,18 +205,11 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Open commit in Git client */}
<ToolbarButton
@@ -280,22 +271,11 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -305,18 +285,18 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gears"
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize in GitBook"
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
@@ -327,7 +307,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights in GitBook"
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
@@ -361,13 +341,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit in GitBook"
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
icon="pen-to-square"
motionValues={motionValues}
/>
);
@@ -8,10 +8,21 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
// Params for the expanding arc defined separately for easier tweaking.
const ARC_PARAMS = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 34,
startDistance: -240,
spreadDistance: 45,
fromDistance: -286,
fromSpread: 0,
durationSeconds: 0.6,
staggerMs: 80,
baseRotationDeg: 95,
rotationStepDeg: 18,
offsetAnchorY: 40,
} as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -23,30 +34,56 @@ interface HideToolbarButtonProps {
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
const closingTimeoutRef = useRef(0);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const closeArcMenu = React.useCallback(() => {
if (!open || closing) return;
setClosing(true);
// Clear any existing timeout
window.clearTimeout(closingTimeoutRef.current);
// Wait for the exit animation to complete before unmounting
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
closingTimeoutRef.current = window.setTimeout(() => {
setOpen(false);
setClosing(false);
}, totalDuration);
}, [open, closing]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => window.clearTimeout(closingTimeoutRef.current);
}, []);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
setOpen(false);
closeArcMenu();
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
// Close arc menu on scroll or resize
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
const handleClose = () => closeArcMenu();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, closeArcMenu]);
const items = [
controls?.minimize
@@ -84,18 +121,36 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
onClick={() => {
setOpen((v) => !v);
if (open || closing) {
closeArcMenu();
} else {
setOpen(true);
}
}}
motionValues={motionValues}
icon="eye-slash"
icon="gear"
>
{/* Expanding arc menu */}
{open && (
{(open || closing) && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
style={
{
...sharedMotionStyle,
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
'--start-distance': `${ARC_PARAMS.startDistance}px`,
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
} as React.CSSProperties
}
>
<div
className={tcls(
@@ -107,11 +162,12 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
key={item.icon}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
closing={closing}
{...item}
onClick={() => {
setOpen(false);
closeArcMenu();
item.onClick?.();
}}
/>
@@ -127,13 +183,13 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -143,40 +199,36 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
closing = false,
label,
disabled,
className,
onClick = () => {},
onClick,
icon,
iconClassName,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
onClick={onClick ? () => onClick() : undefined}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
} as React.CSSProperties
}
className={tcls(
@@ -185,48 +237,43 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
<div className="flex items-center gap-2">
<div
className={tcls(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls(
'size-3.5 shrink-0 group-hover:scale-110',
iconClassName
)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
)}
>
{label}
</span>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
@@ -17,7 +17,7 @@
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: forwards;
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,13 +28,53 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
offset-rotate: auto var(--rotation-offset);
}
}
@keyframes hide-toolbar-arc-exit {
from {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
to {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
}
.arcMenuItemExit {
animation-name: hide-toolbar-arc-exit;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.3s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
}
/* ── Arc label fade ── */
@keyframes arc-label-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes arc-label-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.arcLabelFadeIn {
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
}
.arcLabelFadeOut {
animation: arc-label-fade-out 0.2s ease-out forwards;
}
@@ -9,6 +9,7 @@ import {
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -19,15 +20,20 @@ import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
const PILL_STYLE = {
'--toolbar-bg': '#1f1d1b',
borderRadius: '100px', // Set on `style` so Framer Motion can correct for distortions
zIndex: 1, // Ensure pill stacks above the peek label sibling
} as React.CSSProperties;
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, label, minified, onMinifiedChange } = props;
const { children, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
@@ -35,6 +41,10 @@ export function Toolbar(props: ToolbarProps) {
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Track when the pill's layout animation finishes so the hint label
// only appears once the toolbar is fully expanded.
const [showHint, setShowHint] = React.useState(!minified);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
@@ -82,6 +92,10 @@ export function Toolbar(props: ToolbarProps) {
// Any manual expansion should stop the logo animation so the icon stays in its
// “settled” state once the toolbar is open.
setShouldAnimateLogo(false);
} else {
// Hide the hint immediately when minimizing — it will reappear
// once the next expand animation completes.
setShowHint(false);
}
}, [minified]);
@@ -91,47 +105,57 @@ export function Toolbar(props: ToolbarProps) {
}
return (
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<ToolbarVisibilityHint show={showHint} />
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
@@ -198,62 +222,51 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
} = props;
const reduceMotion = useReducedMotion();
const anchor = (
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
@@ -300,10 +313,6 @@ function ToolbarButtonWrapper(props: {
});
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
@@ -328,7 +337,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -339,7 +348,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { useLanguage } from '@/intl/client';
import { AnimatePresence, motion } from 'motion/react';
import { formatDateTime, formatDateWeekday, formatRelative } from '../utils/dates';
type DateFormat = 'relative' | 'weekday' | 'full';
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
/**
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
*/
export function ToolbarDate(props: { value: string }) {
const { value } = props;
const language = useLanguage();
const [formatIndex, setFormatIndex] = React.useState(0);
const [hovered, setHovered] = React.useState(false);
const format = DATE_FORMATS[formatIndex] as DateFormat;
const formatted = React.useMemo(() => {
const date = new Date(value);
switch (format) {
case 'relative':
return formatRelative(language.locale, Date.now() - date.getTime());
case 'weekday':
return formatDateWeekday(date, language.locale);
case 'full':
return formatDateTime(date, language.locale);
}
}, [format, language.locale, value]);
return (
<motion.div
className="inline-flex items-center gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Vertical dots indicating cycleable formats */}
<AnimatePresence>
{hovered && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="overflow-hidden"
>
<div className="flex flex-col items-center gap-[1px]">
{DATE_FORMATS.map((fmt, i) => (
<motion.span
key={fmt}
animate={{
opacity: i === formatIndex ? 1 : 0.3,
scale: i === formatIndex ? 1 : 0.75,
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="block size-[3px] rounded-full bg-current"
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<motion.time
layout="position"
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
onClick={(e) => {
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={formatIndex}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
{formatted}
</motion.span>
</AnimatePresence>
</motion.time>
</motion.div>
);
}
@@ -0,0 +1,51 @@
'use client';
import { motion } from 'motion/react';
import React from 'react';
import { getVisibilityHintDismissed, setVisibilityHintDismissed } from './utils';
interface ToolbarVisibilityHintProps {
show: boolean;
}
export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
const { show } = props;
const [dismissed, setDismissed] = React.useState(() =>
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
);
if (dismissed) {
return null;
}
return (
<motion.div
initial={false}
animate={{
y: show ? 0 : 10,
opacity: show ? 1 : 0,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
This toolbar appears only when signed in to GitBook.
</span>
<button
type="button"
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
onClick={(e) => {
e.stopPropagation();
setVisibilityHintDismissed();
setDismissed(true);
}}
>
Dismiss
</button>
</motion.div>
);
}
@@ -11,6 +11,7 @@ import {
const STORAGE_KEY = 'gitbook_toolbar_closed';
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
type SessionHideReason = 'session' | 'persistent';
@@ -57,6 +58,20 @@ export const setStoredMinified = (value: boolean) => {
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
};
/**
* Check whether the user has dismissed the "only you can see this" hint.
*/
export const getVisibilityHintDismissed = (): boolean => {
return getSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
};
/**
* Persist that the user dismissed the visibility hint for the current session.
*/
export const setVisibilityHintDismissed = () => {
setSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
};
interface UseToolbarVisibilityOptions {
onPersistentClose?: () => void;
onSessionClose?: () => void;
@@ -77,7 +77,8 @@ export async function AdCoverRendering({
'shadow-md',
'max-h-32',
'group-hover/ad:max-h-16',
'transition-all'
'transition-all',
'motion-reduce:transition-none'
)}
/>
</div>
@@ -93,7 +94,8 @@ export async function AdCoverRendering({
'opacity-0',
'group-hover/ad:h-16',
'group-hover/ad:opacity-10',
'transition-all'
'transition-all',
'motion-reduce:transition-none'
)}
>
{ad.description}
@@ -30,11 +30,11 @@ export function AnnouncementBanner(props: {
return (
<div
id="announcement-banner"
data-gb-announcement-banner
className="theme-bold:bg-header-background pt-4 pb-2"
data-nosnippet=""
>
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
href={contentRef?.href ?? ''}
@@ -6,7 +6,13 @@ import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
import { useCustomCookieBanner, useIntegrationsLoaded } from '@/components/Integrations';
import { isAIUserAgent } from '@/lib/browser';
import {
isCookiesTrackingDisabled,
isGlobalPrivacyControlEnabled,
setCookiesTracking,
} from '../Insights';
/**
* Toast to accept or reject the use of cookies.
@@ -15,10 +21,26 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
const { privacyPolicy = 'https://policies.gitbook.com/privacy/cookies' } = props;
const [show, setShow] = React.useState(false);
const language = useLanguage();
const integrationsLoaded = useIntegrationsLoaded();
const { hasCustomCookieBanner } = useCustomCookieBanner();
const isAI = isAIUserAgent();
const hasGlobalPrivacyControl = isGlobalPrivacyControlEnabled();
React.useEffect(() => {
// If global privacy control is enabled, reject cookies
if (hasGlobalPrivacyControl && !isCookiesTrackingDisabled()) {
setCookiesTracking(false);
return;
}
// Always wait for integrations to load, and if a custom banner is registered, hide the built-in banner
if (!integrationsLoaded || hasCustomCookieBanner || isAI) {
setShow(false);
return;
}
setShow(isCookiesTrackingDisabled() === undefined);
}, []);
}, [hasCustomCookieBanner, integrationsLoaded, isAI, hasGlobalPrivacyControl]);
if (!show) {
return null;
@@ -61,6 +83,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'lg:chat-open:mr-80',
'xl:chat-open:mr-100',
'transition-all',
'motion-reduce:transition-none',
'duration-300',
'text-sm'
)}
@@ -7,18 +7,18 @@ import {
import { Card, type CardProps } from '@/components/primitives';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props;
const { document, block, context, style } = props;
const resolved = context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'],
})
@@ -1,6 +1,6 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
@@ -8,18 +8,19 @@ import { useInViewportListener } from '@/components/hooks/useInViewportListener'
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button, ToggleChevron } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight';
import type { HighlightTheme, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
};
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
@@ -29,7 +30,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables } = props;
const { block, mode, style, inlines, inlineExprVariables, themes } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
@@ -40,17 +41,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [theme, setTheme] = useState<null | HighlightTheme>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block, themes));
}, [block, themes]);
// When user scrolls, we need to wait for the scroll to finish before running the highlight
const isScrollingRef = useRef(false);
@@ -99,14 +100,16 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) {
return;
}
highlight(block, inlines, { evaluateInlineExpression, themes }).then(
(theme) => {
if (cancelled) {
return;
}
setLines(lines);
setHighlighting(false);
});
setTheme(theme);
setHighlighting(false);
}
);
});
}
@@ -116,12 +119,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
// Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]);
setTheme(null);
}, [isInViewport, block, inlines, evaluateInlineExpression, themes]);
const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const numberOfLinesOfCode = theme?.lines.length ?? plainTheme.lines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
@@ -136,16 +139,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
/>
);
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
style={style}
>
{renderer}
</CodeBlockExpandable>
@@ -156,21 +160,25 @@ export function ClientCodeBlock(props: ClientBlockProps) {
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
theme: HighlightTheme;
collapsedLineCount: number;
controls?: string;
style?: ClassValue;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const { children, controls, theme, collapsedLineCount, style } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls('group/codeblock-expandable relative', style)}
data-follow-color-scheme="true"
aria-expanded={isExpanded}
>
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
!isExpanded
? '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden'
: ''
)}
style={
{
@@ -181,7 +189,7 @@ function CodeBlockExpandable(props: {
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<div className="pointer-events-none absolute bottom-2 flex w-full justify-center">
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
@@ -194,7 +202,7 @@ function CodeBlockExpandable(props: {
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
: t(language, 'code_block_collapsed', theme.lines.length)}
</Button>
</div>
</div>
@@ -1,6 +1,10 @@
import * as React from 'react';
import type { DocumentBlockCode } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
DocumentBlockCode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document';
@@ -8,14 +12,29 @@ import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { ClientCodeBlock } from './ClientCodeBlock';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import { MermaidCodeBlock } from './MermaidCodeBlock';
import { type RenderedInline, getInlines, highlight } from './highlight';
/**
* Render a code block, can be client-side or server-side.
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props;
export async function CodeBlock(
props: BlockProps<DocumentBlockCode> & {
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}
) {
const {
block,
document,
style,
isEstimatedOffscreen,
context,
themeKey = 'default',
themes: providedThemes,
} = props;
const inlines = getInlines(block);
const isMermaid = block.data.syntax?.toLowerCase() === 'mermaid';
let hasInlineExpression = false;
@@ -50,10 +69,19 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body };
});
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// Get code themes from customization
const themes =
providedThemes ??
(context.contentContext && 'customization' in context.contentContext
? context.contentContext.customization.styling.codeTheme[themeKey]
: undefined);
if (!isMermaid && !isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// In v2, we render the code block server-side
const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
const theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
}
const variables = context.contentContext
@@ -66,15 +94,22 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
}
: {};
const clientProps = {
block,
style,
inlines: richInlines,
inlineExprVariables: variables,
mode: context.mode,
themes,
};
return (
<React.Suspense fallback={null}>
<ClientCodeBlock
block={block}
style={style}
inlines={richInlines}
inlineExprVariables={variables}
mode={context.mode}
/>
{isMermaid ? (
<MermaidCodeBlock {...clientProps} />
) : (
<ClientCodeBlock {...clientProps} />
)}
</React.Suspense>
);
}
@@ -7,10 +7,10 @@ import { tcls } from '@/lib/tailwind';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
theme: HighlightTheme;
'aria-busy'?: boolean;
id?: string;
};
@@ -22,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -30,15 +30,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const id = useId();
const codeId = props.id || id;
/* Shiki returns a color + CSS variables in a single string, which isn't supported in React.
So we parse the foreground & background into objects that can be passed into React's `style` prop. */
const bg = parseShikiColorString(theme.bg);
const fg = parseShikiColorString(theme.fg);
return (
<div
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
className={tcls(
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
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}`}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
{title}
</div>
) : null}
@@ -49,23 +69,32 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base p-2 [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'rounded-md straight-corners:rounded-xs shadow-xs',
title && 'rounded-ss-none'
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
<code
id={codeId}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
>
{lines.map((line, index) => (
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === lines.length - 1}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
@@ -77,13 +106,33 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
function CodeHighlightLine(props: {
line: HighlightLine;
bg?: ShikiColorDefinition;
fg?: ShikiColorDefinition;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
const { line, isLast, withLineNumbers, bg, fg } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<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,
}}
/>
)}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -112,13 +161,53 @@ function CodeHighlightToken(props: { token: HighlightToken }) {
return token.content;
}
case 'shiki': {
if (!token.token.color) {
if (!token.token.htmlStyle) {
return token.token.content;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
return <span style={token.token.htmlStyle}>{token.token.content}</span>;
}
default:
assertNever(token);
}
}
type ShikiColorDefinition = {
color: string | undefined;
vars: Record<string, string> | undefined;
};
/**
* Parse Shiki color string format into separate color and CSS variables.
*
* Shiki returns colors in a format that combines a default color with CSS variables:
* "defaultColor;--shiki-light:value1;--shiki-dark:value2"
*
* React does not parse these strings as valid CSS because they contain values & properties in one.
* We split these so React can apply them separately via the style prop.
*/
function parseShikiColorString(
originalColor: string | undefined
): ShikiColorDefinition | undefined {
if (!originalColor) {
return undefined;
}
const parts = originalColor.split(';');
const color = parts[0];
const vars = parts.slice(1).reduce(
(acc, item) => {
const [key, value] = item.split(':');
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
return {
color,
vars: Object.keys(vars).length > 0 ? vars : undefined,
};
}
@@ -0,0 +1,106 @@
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { getPlainCodeBlock } from './highlight';
/**
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, style } = props;
const source = getPlainCodeBlock(block);
const diagramRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
useEffect(() => {
const container = diagramRef.current;
if (!container) {
return;
}
let cancelled = false;
setError(false);
setIsLoading(true);
renderMermaidDiagram({
container,
source,
id,
darkMode,
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [source, id, darkMode]);
if (error) {
return <ClientCodeBlock {...props} />;
}
return (
<div className={tcls('relative', style)} contentEditable={false}>
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
/>
</div>
{isLoading ? (
<div className="flex h-24 items-center justify-center text-tint">
<Loading className="h-8 w-8" />
</div>
) : null}
</div>
);
}
async function renderMermaidDiagram(args: {
container: HTMLElement;
source: string;
id: string;
darkMode: boolean;
}) {
const { container, source, id, darkMode } = args;
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
import('mermaid'),
import('@mermaid-js/mermaid-zenuml'),
]);
await mermaid.registerExternalDiagrams([zenuml]);
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
});
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
container.innerHTML = svg;
bindFunctions?.(container);
}
function useSafeId() {
const rawId = useId();
return useMemo(() => rawId.replace(/[^a-zA-Z0-9]/g, ''), [rawId]);
}
@@ -1,4 +1,8 @@
import type { JSONDocument } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
JSONDocument,
SiteCustomizationSettings,
} from '@gitbook/api';
import { useId } from 'react';
import { CodeBlock } from './CodeBlock';
@@ -8,8 +12,13 @@ import { convertCodeStringToBlock } from './utils';
* Plain code block with syntax highlighting.
* For simplicity, this is just a wrapper around the CodeBlock component, emulating a document.
*/
export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props;
export function PlainCodeBlock(props: {
code: string;
syntax: string;
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -26,6 +35,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
context={{
mode: 'default',
}}
themeKey={themeKey}
themes={themes}
block={block}
ancestorBlocks={[]}
// We optimize perf by default
@@ -0,0 +1,832 @@
import type { ThemeRegistration } from 'shiki/core';
export const customThemes: Record<string, ThemeRegistration> = {
'default-light': createTheme({
name: 'default-light',
type: 'light',
bg: 'inherit',
fg: 'inherit',
colors: {
basic: {
comment: 'rgb(var(--neutral-9))',
string: 'rgb(var(--success-10))',
constant: 'rgb(var(--warning-10))',
escape: 'rgb(var(--success-10))',
},
keyword: {
default: 'rgb(var(--danger-10))',
operator: 'rgb(var(--tint-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--warning-10))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--danger-10))',
parameter: 'rgb(var(--warning-10))',
},
function: {
default: 'rgb(var(--primary-10))',
support: 'rgb(var(--primary-10))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--warning-10))',
other: 'rgb(var(--tint-12))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--warning-10))',
attributeId: 'rgb(var(--primary-10))',
label: 'rgb(var(--danger-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--success-10))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--warning-10))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-10))',
deleted: 'rgb(var(--danger-10))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-11))',
preprocessor: 'rgb(var(--danger-10))',
default: 'rgb(var(--tint-11))',
invalid: 'rgb(var(--danger-10))',
},
},
}),
'default-dark': createTheme({
name: 'default-dark',
type: 'dark',
bg: 'inherit',
fg: 'inherit',
colors: {
basic: {
comment: 'rgb(var(--neutral-9))',
string: 'rgb(var(--success-11))',
constant: 'rgb(var(--warning-11))',
escape: 'rgb(var(--success-11))',
},
keyword: {
default: 'rgb(var(--danger-11))',
operator: 'rgb(var(--tint-11))',
regexpOperator: 'rgb(var(--primary-11))',
regexpQuantifier: 'rgb(var(--warning-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--danger-11))',
parameter: 'rgb(var(--warning-11))',
},
function: {
default: 'rgb(var(--primary-11))',
support: 'rgb(var(--primary-11))',
},
type: {
custom: 'rgb(var(--primary-11))',
primitive: 'rgb(var(--warning-11))',
other: 'rgb(var(--tint-12))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--warning-11))',
attributeId: 'rgb(var(--primary-11))',
label: 'rgb(var(--danger-11))',
},
markup: {
default: 'rgb(var(--primary-11))',
code: 'rgb(var(--success-11))',
link: 'rgb(var(--primary-11))',
linkUrl: 'rgb(var(--warning-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-12))',
preprocessor: 'rgb(var(--danger-11))',
default: 'rgb(var(--tint-12))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
'monochrome-light': createTheme({
name: 'monochrome-light',
type: 'light',
bg: 'inherit',
fg: 'inherit',
colors: {
basic: {
comment: 'rgb(var(--tint-9))',
string: 'rgb(var(--primary-12))',
constant: 'rgb(var(--primary-11))',
escape: 'rgb(var(--tint-12))',
},
keyword: {
default: 'rgb(var(--primary-10))',
operator: 'rgb(var(--primary-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--primary-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--primary-10))',
parameter: 'rgb(var(--tint-11))',
},
function: {
default: 'rgb(var(--primary-9))',
support: 'rgb(var(--primary-9))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--primary-11))',
other: 'rgb(var(--primary-10))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--primary-11))',
attributeId: 'rgb(var(--primary-9))',
label: 'rgb(var(--primary-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--tint-12))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--primary-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-10))',
preprocessor: 'rgb(var(--primary-10))',
default: 'rgb(var(--primary-11))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
'monochrome-dark': createTheme({
name: 'monochrome-dark',
type: 'dark',
bg: 'inherit',
fg: 'inherit',
colors: {
basic: {
comment: 'rgb(var(--tint-9))',
string: 'rgb(var(--primary-12))',
constant: 'rgb(var(--primary-11))',
escape: 'rgb(var(--tint-12))',
},
keyword: {
default: 'rgb(var(--primary-11))',
operator: 'rgb(var(--primary-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--primary-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--primary-10))',
parameter: 'rgb(var(--tint-9))',
},
function: {
default: 'rgb(var(--primary-9))',
support: 'rgb(var(--primary-9))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--primary-11))',
other: 'rgb(var(--primary-10))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--primary-11))',
attributeId: 'rgb(var(--primary-9))',
label: 'rgb(var(--primary-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--tint-12))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--primary-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-10))',
preprocessor: 'rgb(var(--primary-10))',
default: 'rgb(var(--primary-11))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
};
/**
* Color category mappings for syntax highlighting.
* Organized into logical groups for better readability.
*/
export type ColorCategories = {
/** Basic syntax elements */
basic: {
/** Comments (//, /*, #, etc.) */
comment: string;
/** String literals ("...", '...', `...`) */
string: string;
/** Constants: numeric, language constants (true, false, null), character constants */
constant: string;
/** Escape sequences in strings (\n, \t, etc.) */
escape: string;
};
/** Keywords and operators */
keyword: {
/** Control flow (if, else, for, while, return), storage (const, let, var, function, class) */
default: string;
/** Operators (+, -, *, /, =, ==, etc.) */
operator: string;
/** Regexp operators (|, ^, $) */
regexpOperator: string;
/** Regexp quantifiers (*, +, ?, {n}) */
regexpQuantifier: string;
};
/** Variables and parameters */
variable: {
/** Regular variables */
default: string;
/** Language variables (this, self, super) */
language: string;
/** Function/method parameters */
parameter: string;
};
/** Functions */
function: {
/** User-defined function/method invocations */
default: string;
/** Built-in/library support functions */
support: string;
};
/** Types and classes */
type: {
/** Custom type names (DocumentBlockCode, HighlightLine, etc.) */
custom: string;
/** Primitive types (number, boolean, string, etc.) */
primitive: string;
/** Other types (support.type, support.class, namespaces) */
other: string;
/** Class meta contexts (meta.class) */
classMeta: string;
};
/** Entities (tags, attributes, labels) */
entity: {
/** HTML/XML tags and attributes */
tag: string;
/** Attribute IDs (entity.other.attribute-name.id) */
attributeId: string;
/** Labels (entity.name.label) */
label: string;
};
/** Markup elements */
markup: {
/** Markup elements (bold, italic, headings, lists, quotes, etc.) */
default: string;
/** Inline code in markup */
code: string;
/** Links in markup */
link: string;
/** Link URLs in markup */
linkUrl: string;
/** Separators (meta.separator) */
separator: string;
};
/** Diff highlighting */
diff: {
/** Inserted lines */
inserted: string;
/** Deleted lines */
deleted: string;
/** Changed lines */
changed: string;
};
/** Other syntax elements */
other: {
/** Punctuation (., ,, ;, :, {}, [], (), etc.) */
punctuation: string;
/** Preprocessor directives (#include, #define, etc.) */
preprocessor: string;
/** Other/miscellaneous elements */
default: string;
/** Invalid syntax/errors */
invalid: string;
};
};
/**
* Creates a Shiki theme registration from semantic color categories.
*
* Maps our semantic color categories (comment, keyword, function, etc.) to Shiki's TextMate scopes.
* TextMate scopes are hierarchical selectors (e.g., "keyword.control", "variable.parameter") that
* Shiki uses to identify different syntax elements. This abstraction allows us to define themes
* using semantic categories rather than low-level scope patterns.
*/
function createTheme(options: {
name: string;
type: 'light' | 'dark';
bg: string;
fg: string;
colors: ColorCategories;
}): ThemeRegistration {
const { name, type, bg, fg, colors } = options;
return {
name,
type,
bg,
fg,
settings: [
// Comments
{
scope: ['comment'],
settings: {
foreground: colors.basic.comment,
},
},
// Constants: language constants (true, false, null, etc.)
{
scope: ['constant.language'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: numeric
{
scope: [
'constant.numeric',
'variable.other.enummember',
'keyword.operator.plus.exponent',
'keyword.operator.minus.exponent',
'keyword.other.unit',
'constant.sha.git-rebase',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: regexp
{
scope: ['constant.regexp'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: character
{
scope: ['constant.character', 'constant.other.option'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: escape sequences
{
scope: ['constant.character.escape'],
settings: {
foreground: colors.basic.escape,
},
},
// Constants: other
{
scope: [
'constant.other',
'constant.other.color',
'constant.other.symbol',
'constant.other.placeholder',
'constant.other.character-class.regexp',
'constant.character.set.regexp',
'constant.character.character-class.regexp',
'constant.other.character-class.set.regexp',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: variable constants
{
scope: ['variable.other.constant'],
settings: {
foreground: colors.basic.constant,
},
},
// Strings
{
scope: [
'string',
'meta.embedded.assembly',
'string.tag',
'string.value',
'string.regexp',
'meta.preprocessor.string',
'support.constant.property-value',
'support.constant.font-name',
'support.constant.media-type',
'support.constant.media',
'constant.other.color.rgb-value',
'constant.other.rgb-value',
'support.constant.color',
'punctuation.definition.group.regexp',
'punctuation.definition.group.assertion.regexp',
'punctuation.definition.character-class.regexp',
'punctuation.character.set.begin.regexp',
'punctuation.character.set.end.regexp',
'keyword.operator.negation.regexp',
'support.other.parenthesis.regexp',
],
settings: {
foreground: colors.basic.string,
},
},
// String expressions (template literals, interpolated)
// Only color the punctuation, not the expression content
{
scope: [
'punctuation.definition.template-expression.begin',
'punctuation.definition.template-expression.end',
'punctuation.section.embedded',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Keywords: control flow
{
scope: [
'keyword.control',
'source.cpp keyword.operator.new',
'keyword.operator.delete',
'keyword.other.using',
'keyword.other.directive.using',
'keyword.other.operator',
'entity.name.operator',
'keyword.operator.new',
'keyword.operator.expression',
'keyword.operator.cast',
'keyword.operator.sizeof',
'keyword.operator.alignof',
'keyword.operator.typeid',
'keyword.operator.alignas',
'keyword.operator.instanceof',
'keyword.operator.logical.python',
'keyword.operator.wordlike',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: storage (const, let, var, function, class, etc.)
{
scope: [
'storage',
'storage.type',
'storage.modifier',
'keyword.operator.noexcept',
'storage.type.numeric.go',
'storage.type.byte.go',
'storage.type.boolean.go',
'storage.type.string.go',
'storage.type.uintptr.go',
'storage.type.error.go',
'storage.type.rune.go',
'storage.type.cs',
'storage.type.generic.cs',
'storage.type.modifier.cs',
'storage.type.variable.cs',
'storage.type.annotation.java',
'storage.type.generic.java',
'storage.type.java',
'storage.type.object.array.java',
'storage.type.primitive.array.java',
'storage.type.primitive.java',
'storage.type.token.java',
'storage.type.groovy',
'storage.type.annotation.groovy',
'storage.type.parameters.groovy',
'storage.type.generic.groovy',
'storage.type.object.array.groovy',
'storage.type.primitive.array.groovy',
'storage.type.primitive.groovy',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: general
{
scope: ['keyword'],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: operators (default)
{
scope: ['keyword.operator'],
settings: {
foreground: colors.keyword.operator,
},
},
// Keywords: regexp operators
{
scope: ['keyword.operator.or.regexp', 'keyword.control.anchor.regexp'],
settings: {
foreground: colors.keyword.regexpOperator,
},
},
// Keywords: regexp quantifiers
{
scope: ['keyword.operator.quantifier.regexp'],
settings: {
foreground: colors.keyword.regexpQuantifier,
},
},
// Variable language (this, self, super, etc.)
{
scope: ['variable.language'],
settings: {
foreground: colors.variable.language,
},
},
// Parameters
{
scope: ['variable.parameter'],
settings: {
foreground: colors.variable.parameter,
},
},
// Variables (regular variables, not parameters)
{
scope: [
'variable',
'meta.definition.variable.name',
'support.variable',
'entity.name.variable',
'meta.object-literal.key',
],
settings: {
foreground: colors.variable.default,
},
},
// Functions: invocations (calls) - user-defined functions
{
scope: [
'meta.function-call',
'meta.method-call',
'entity.name.function',
'entity.name.method',
'support.constant.handlebars',
'source.powershell variable.other.member',
'entity.name.operator.custom-literal',
'keyword.other.special-method',
],
settings: {
foreground: colors.function.default,
},
},
// Support functions: built-in/library functions
{
scope: ['support.function', 'support.function.git-rebase'],
settings: {
foreground: colors.function.support,
},
},
// Types: custom type names (DocumentBlockCode, HighlightLine, etc.) - must come first
{
scope: ['entity.name.type', 'entity.name.class', 'entity.other.inherited-class'],
settings: {
foreground: colors.type.custom,
},
},
// Types: primitive (number, boolean, string, etc.) - must come before support.type
{
scope: [
'support.type.primitive',
'storage.type.primitive',
'support.type.builtin',
'entity.name.type.primitive',
'support.type',
],
settings: {
foreground: colors.type.primitive,
},
},
// Types: other (support.class, etc.)
{
scope: [
'support.class',
'entity.name.namespace',
'entity.other.attribute',
'entity.name.scope-resolution',
'meta.type.cast.expr',
'meta.type.new.expr',
'support.constant.math',
'support.constant.json',
'punctuation.separator.namespace.ruby',
],
settings: {
foreground: colors.type.other,
},
},
// Class meta contexts
{
scope: ['meta.class'],
settings: {
foreground: colors.type.classMeta,
},
},
// DOM constants and namespace references (ReactDOM, window.document, etc.)
{
scope: [
'support.constant.dom',
'variable.other.object',
'variable.other.object.property',
'entity.name.class.jsx',
'support.class.builtin',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Entity names: tags, attributes
{
scope: [
'entity.name.tag',
'entity.other.attribute-name',
'support.type.vendored.property-name',
'support.type.property-name',
'source.css variable',
'source.coffee.embedded',
'meta.structure.dictionary.key.python',
],
settings: {
foreground: colors.entity.tag,
},
},
// Attribute IDs (typically styled like functions)
{
scope: ['entity.other.attribute-name.id', 'punctuation.definition.entity'],
settings: {
foreground: colors.entity.attributeId,
},
},
// Punctuation
{
scope: [
'punctuation',
'punctuation.definition.tag',
'punctuation.definition.quote.begin.markdown',
'punctuation.definition.list.begin.markdown',
'punctuation.section.embedded.begin.php',
'punctuation.section.embedded.end.php',
],
settings: {
foreground: colors.other.punctuation,
},
},
// Links: link text
{
scope: [
'markup.underline.link',
'string.other.link',
'punctuation.definition.string.end.markdown',
'punctuation.definition.string.begin.markdown',
],
settings: {
foreground: colors.markup.link,
},
},
// Links: link URLs
{
scope: ['meta.link'],
settings: {
foreground: colors.markup.linkUrl,
},
},
// Preprocessor
{
scope: [
'meta.preprocessor',
'entity.name.function.preprocessor',
'meta.preprocessor.numeric',
],
settings: {
foreground: colors.other.preprocessor,
},
},
// Diff: inserted
{
scope: ['markup.inserted', 'markup.inserted.diff'],
settings: {
foreground: colors.diff.inserted,
},
},
// Diff: deleted
{
scope: ['markup.deleted', 'markup.deleted.diff'],
settings: {
foreground: colors.diff.deleted,
},
},
// Diff: changed
{
scope: ['markup.changed', 'markup.changed.diff', 'meta.diff.header'],
settings: {
foreground: colors.diff.changed,
},
},
// Markup: bold (with bold font style)
{
scope: ['markup.bold', 'punctuation.definition.bold'],
settings: {
foreground: colors.markup.default,
fontStyle: 'bold',
},
},
// Markup: italic (with italic font style)
{
scope: ['markup.italic', 'punctuation.definition.italic'],
settings: {
foreground: colors.markup.default,
fontStyle: 'italic',
},
},
// Markup: inline code
{
scope: ['markup.raw.inline'],
settings: {
foreground: colors.markup.code,
},
},
// Markup: headings, lists, quotes, underline, strikethrough
{
scope: [
'markup.heading',
'markup.heading punctuation.definition.heading',
'entity.name.section',
'header',
'markup.list',
'markup.quote',
'markup.underline',
'markup.strikethrough',
],
settings: {
foreground: colors.markup.default,
},
},
// Separator
{
scope: ['meta.separator'],
settings: {
background: colors.markup.separator,
foreground: fg,
},
},
// Labels
{
scope: ['entity.name.label'],
settings: {
foreground: colors.entity.label,
},
},
// Other
{
scope: [
'meta.embedded',
'source.groovy.embedded',
'string meta.image.inline.markdown',
'variable.legacy.builtin.python',
'storage.modifier.import.java',
'variable.language.wildcard.java',
'storage.modifier.package.java',
],
settings: {
foreground: colors.other.default,
},
},
// Invalid
{
scope: ['invalid'],
settings: {
foreground: colors.other.invalid,
},
},
],
};
}
@@ -8,7 +8,8 @@ async function highlightWithInlines(block: DocumentBlockCode) {
inline,
body: null,
}));
return highlight(block, inlines);
const result = await highlight(block, inlines);
return result.lines;
}
it('should parse plain code', async () => {
@@ -224,7 +225,19 @@ it('should parse code with an inline on a single line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -375,7 +388,19 @@ it('should parse code with an inline on a multiple line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -631,7 +656,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: '.world',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'world',
},
},
{
@@ -645,7 +676,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: ');',
content: ')',
},
},
{
type: 'shiki',
token: {
content: ';',
},
},
],
@@ -699,7 +736,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -711,7 +754,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"Hello"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -734,7 +789,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -746,7 +807,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -1,20 +1,39 @@
import type {
DocumentBlockCode,
DocumentBlockCodeLine,
DocumentInlineAnnotation,
import {
CustomizationCodeTheme,
type CustomizationThemedCodeTheme,
type DocumentBlockCode,
type DocumentBlockCodeLine,
type DocumentInlineAnnotation,
} from '@gitbook/api';
import {
type ThemeRegistrationAny,
type ThemedToken,
createCssVariablesTheme,
createSingletonShorthands,
createdBundledHighlighter,
} from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
import { bundledThemes } from 'shiki/themes';
import { nullIfNever } from '@/lib/typescript';
import { customThemes } from './customThemes';
import { plainHighlight } from './plain-highlight';
export const DEFAULT_THEMES: CustomizationThemedCodeTheme = {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
};
export type HighlightTheme = {
bg?: string;
fg?: string;
themes: {
light: ThemeRegistrationAny;
dark: ThemeRegistrationAny;
};
lines: HighlightLine[];
};
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -34,14 +53,11 @@ export type RenderedInline = {
body: React.ReactNode;
};
const isSafari =
typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const theme = createCssVariablesTheme();
// Merge bundled Shiki themes with our custom themes so both are available to the highlighter
const { getSingletonHighlighter } = createSingletonShorthands(
createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
themes: { ...bundledThemes, ...customThemes },
engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
})
);
@@ -49,12 +65,15 @@ const { getSingletonHighlighter } = createSingletonShorthands(
/**
* Preload the highlighter for a code block.
*/
export async function preloadHighlight(block: DocumentBlockCode) {
export async function preloadHighlight(
block: DocumentBlockCode,
themes: CustomizationThemedCodeTheme = DEFAULT_THEMES
) {
const langName = getBlockLang(block);
if (langName) {
await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
}
}
@@ -67,26 +86,30 @@ export async function highlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): Promise<HighlightLine[]> {
): Promise<HighlightTheme> {
const langName = getBlockLang(block);
if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
// Fallback to plain highlighting if
// - language is not found
// - TEMP : language is PowerShell or C++ and browser is Safari:
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isn’t writable
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
if (!langName) {
// Fallback to plain highlighting if language is not found
return plainHighlight(block, inlines, options);
}
const themes = options?.themes ?? DEFAULT_THEMES;
const code = getPlainCodeBlock(block, undefined, options);
const highlighter = await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
const resolvedThemes = {
light: highlighter.getTheme(themes.light),
dark: highlighter.getTheme(themes.dark),
};
let tokenizeMaxLineLength = 400;
// In some cases, people will use unindented code blocks with a single line.
// In this case, we can safely increase the max line length to avoid not highlighting the code.
@@ -94,38 +117,47 @@ export async function highlight(
tokenizeMaxLineLength = 5000;
}
const lines = highlighter.codeToTokensBase(code, {
const result = highlighter.codeToTokens(code, {
lang: langName,
theme,
themes: resolvedThemes,
// Shiki's light-dark() CSS function provides different colors for light/dark modes based on the resolved themes
defaultColor: 'light-dark()',
tokenizeMaxLineLength,
});
const lines = result.tokens;
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
return {
bg: result.bg,
fg: result.fg,
themes: resolvedThemes,
lines: lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
currentIndex += 1; // for the \n
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
});
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
}),
};
}
/**
@@ -258,7 +290,7 @@ function matchTokenAndInlines(
return result;
}
function getPlainCodeBlock(
export function getPlainCodeBlock(
code: DocumentBlockCode,
inlines?: InlineIndexed[],
options?: {
@@ -1,8 +1,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
@@ -12,42 +13,59 @@ export function plainHighlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): HighlightLine[] {
): HighlightTheme {
const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
// Resolve theme objects: first check bundled Shiki themes, then custom themes, finally fallback to type-only
// The type-only fallback ensures HighlightTheme always has valid theme objects even when theme names don't match
const themes = {
light: bundledThemesInfo.find((theme) => theme.id === options?.themes?.light) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.light) ?? {
type: 'light',
},
dark: bundledThemesInfo.find((theme) => theme.id === options?.themes?.dark) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.dark) ?? {
type: 'dark',
},
};
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
return {
themes: themes,
lines: block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
},
],
};
});
};
}
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
}),
};
}
@@ -1,6 +1,6 @@
import type { DocumentBlockDrawing } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { Image } from '../utils';
import type { BlockProps } from './Block';
@@ -8,11 +8,11 @@ import { Caption } from './Caption';
import { imageBlockSizes } from './Images';
export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
const { block, context } = props;
const { document, block, context } = props;
const resolved =
block.data.ref && context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null;
if (!resolved) {
return null;
@@ -31,7 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing"
sizes={imageBlockSizes}
zoom
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
</Caption>
);
@@ -60,7 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
) : null
}
@@ -15,6 +15,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
const title = getNodeFragmentByType(block, 'expandable-title');
const body = getNodeFragmentByType(block, 'expandable-body');
const isDefaultExpanded = block.data.defaultExpanded ?? false;
const titleParagraph = title?.nodes[0];
@@ -28,7 +29,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
return (
<Details
id={id}
open={context.mode === 'print'}
open={context.mode === 'print' || isDefaultExpanded}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary
@@ -2,7 +2,7 @@ import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
@@ -12,13 +12,17 @@ import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props;
const { document, block, context } = props;
if (!context.contentContext) {
return null;
}
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const contentRef = await resolveContentRefInDocument(
document,
block.data.ref,
context.contentContext
);
const file = contentRef?.file;
if (!file) {
@@ -24,6 +24,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
className={tcls(
textStyle.textSize,
'heading',
'pdf-heading',
'flex',
'items-baseline',
'scroll-mt-(--content-scroll-margin)',
@@ -32,6 +33,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
style,
textStyle.marginTop
)}
data-pdf-heading
>
<HashLinkButton
id={id}
@@ -1,5 +1,6 @@
import type { DocumentBlockHint } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { validateIconName } from '@gitbook/icons/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -17,6 +18,10 @@ export function Hint({
...contextProps
}: BlockProps<DocumentBlockHint>) {
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const customIcon = block.data.icon;
const icon = customIcon && validateIconName(customIcon) ? customIcon : hintStyle.icon;
const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode);
const hasHeading = isHeadingBlock(firstNode);
@@ -57,10 +62,7 @@ export function Hint({
hintStyle.iconColor
)}
>
<Icon
icon={hintStyle.icon}
className={tcls('size-[1.2em]', 'mt-px', firstLine.lineHeight)}
/>
<Icon icon={icon} className={tcls('size-[1.2em]', 'mt-px', firstLine.lineHeight)} />
</div>
{hasHeading ? (
<Block
@@ -1,7 +1,7 @@
import type { DocumentBlockImage, DocumentBlockImages, JSONDocument, Length } from '@gitbook/api';
import { Image, type ImageResponsiveSize } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -81,12 +81,14 @@ async function ImageBlock(props: {
isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) {
const { block, context, isEstimatedOffscreen, withFrame } = props;
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null,
block.data.refDark && context.contentContext
? resolveContentRef(block.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, block.data.refDark, context.contentContext)
: null,
]);
@@ -113,7 +115,7 @@ async function ImageBlock(props: {
}
: null,
}}
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
loading={isEstimatedOffscreen && context.mode !== 'print' ? 'lazy' : 'eager'}
zoom
inlineStyle={{
maxWidth: '100%',
@@ -1,7 +1,7 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useSearch } from '../Search';
import { useSetSearchState } from '../Search';
import { Button, type ButtonProps, Input } from '../primitives';
export function InlineActionButton(
@@ -12,7 +12,7 @@ export function InlineActionButton(
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
const handleSubmit = (value: string) => {
@@ -1,4 +1,4 @@
import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button, type ButtonProps } from '../primitives';
@@ -45,13 +45,13 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
export async function InlineLinkButton(
props: InlineProps<api.DocumentInlineButton> & { buttonProps: ButtonProps }
) {
const { inline, context, buttonProps } = props;
const { document, inline, context, buttonProps } = props;
if (!('ref' in inline.data)) return;
const resolved =
context.contentContext && inline.data.ref
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null;
const href =
@@ -1,9 +1,9 @@
import type { DocumentInlineIcon } from '@gitbook/api';
import { textColorToStyle } from '@/lib/colors';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline';
import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props;
@@ -2,20 +2,22 @@ import type { GitBookBaseContext } from '@/lib/context';
import type { DocumentInlineImage } from '@gitbook/api';
import assertNever from 'assert-never';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Image } from '../utils';
import type { InlineProps } from './Inline';
export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
const { inline, context, ancestorInlines } = props;
const { document, inline, context, ancestorInlines } = props;
const { size = 'original' } = inline.data;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(inline.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null,
inline.data.refDark && context.contentContext
? resolveContentRef(inline.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, inline.data.refDark, context.contentContext)
: null,
]);
@@ -49,7 +51,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
}
: null,
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline
zoom={!isInLink}

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